Skip to content

Partition DP

Every DP so far has had a state built from prefixesdp[i] answers a question about the first i elements. Partition DP breaks that: the state is a range [i, j], and the recurrence loops over every possible way to split that range in two.

It is the hardest DP shape that regularly appears in interviews, and it is hard for a specific reason: the obvious recurrence is wrong. Thinking about which element to process first gives a subproblem you cannot solve independently. Thinking about which element is processed last gives one you can. That inversion is the whole pattern, and everything else follows from it.

  • The range-state recurrence, and the loop over split points that defines it.
  • Why “think about the last operation” is the reframe that makes it work.
  • Why the fill order must be by increasing range length, not by row.
  • Burst Balloons, and the sentinel trick that makes its subproblems independent.

Partition DP fills a table indexed by (i, j) — but unlike LCS it fills by diagonal, because dp[i][j] depends on shorter ranges strictly inside it. The dependency arrows here are the same idea one dimension over:

dpTwo-index DP: every cell reads only cells already computedthe dependency discipline partition DP shares
rows: a = "ABCB"cols: b = "BDCA"
εBDCAεABCB000000000
base caseRow 0 and column 0 are all zeros: the longest common subsequence with an empty string is empty. Those sentinels are why the loops can start at 1 and never test for out-of-range indices.
1/34

LCS reads the cell above, left and diagonal, so a row-major fill is safe. Partition DP reads every cell strictly INSIDE its range, which a row-major fill would not have computed yet — hence looping over range length first. The discipline is the same: never read a cell before you write it.

partition_dp.py
def matrix_chain(dims):
    """dims has n+1 entries for n matrices; matrix i is dims[i] x dims[i+1]."""
    n = len(dims) - 1
    dp = [[0] * n for _ in range(n)]
 
    # LENGTH first. dp[i][j] needs shorter ranges inside it.
    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            dp[i][j] = float("inf")
            for k in range(i, j):                   # the split point
                cost = (dp[i][k] + dp[k + 1][j]
                        + dims[i] * dims[k + 1] * dims[j + 1])
                dp[i][j] = min(dp[i][j], cost)
    return dp[0][n - 1]
 
 
def burst_balloons(nums):                            # LC 312
    # Sentinel 1s so every subproblem has real neighbours.
    vals = [1] + nums + [1]
    n = len(vals)
    dp = [[0] * n for _ in range(n)]
 
    for length in range(2, n):
        for left in range(n - length):
            right = left + length
            for last in range(left + 1, right):      # LAST balloon burst here
                dp[left][right] = max(
                    dp[left][right],
                    dp[left][last] + dp[last][right]
                    + vals[left] * vals[last] * vals[right],
                )
    return dp[0][n - 1]
 
 
print(matrix_chain([10, 30, 5, 60]))        # expect 4500
print(burst_balloons([3, 1, 5, 8]))         # expect 167

Burst Balloons is the cleanest illustration. Bursting balloon i earns left * i * right where left and right are its current neighbours, and neighbours change as balloons pop.

Try fixing the first balloon to burst. You earn its points, then face a shorter array — but the two sides are now adjacent to each other, so what happens on the left changes the scores available on the right. The subproblems are entangled and there is no valid recurrence.

Now fix the last balloon k in the range (left, right). When k is burst, every other balloon in that range is already gone, so its neighbours are exactly vals[left] and vals[right] — the range boundaries, which never move. And the balloons strictly inside (left, k) and (k, right) were burst before k without ever being adjacent to each other, because k sat between them the whole time.

burst_balloons([3, 1, 5, 8]), so vals = [1, 3, 1, 5, 8, 1].

Ranges of length 2 hold no interior balloons, so they are 0. Length 3 has exactly one choice:

rangeinteriorscore
(0,2)balloon 31·3·1 = 3
(1,3)balloon 13·1·5 = 15
(2,4)balloon 51·5·8 = 40
(3,5)balloon 85·8·1 = 40

Length 4 must compare split points. Take (1,4) — interior balloons 1 and 5:

last burstrecurrencevalue
k=2 (value 1)dp[1][2] + dp[2][4] + 3·1·80 + 40 + 24 = 64
k=3 (value 5)dp[1][3] + dp[3][4] + 3·5·815 + 0 + 120 = **135**

So dp[1][4] = 135 — burst the 1 first, then the 5 while it still has 3 and 8 as neighbours. Continuing to the full range (0,5) gives 167.

Two things worth noticing:

  • The winning choice is not the largest immediate score. Bursting 8 first looks attractive and is wrong. Greedy fails here, which is why it is DP.
  • dp[1][4] needed dp[1][3] and dp[2][4] — both length 3, both strictly shorter. A row-major fill would not have computed dp[2][4] before reaching dp[1][4], which is why the outer loop is over length.
ProblemStateTransitionTotal
Matrix chain (LC 1039 shape)O(n2)O(n^2) rangesO(n)O(n) split pointsO(n3)O(n^3)
Burst Balloons (LC 312)O(n2)O(n^2)O(n)O(n)O(n3)O(n^3)
Merge Stones (LC 1000)O(n2k)O(n^2 k)O(n)O(n)O(n3k)O(n^3 k)
Palindrome Partitioning II (LC 132)O(n)O(n)O(n)O(n)O(n2)O(n^2)

O(n3)O(n^3) is the signature of this family, and it is the reason constraints sit around n ≤ 500. Seeing n ≤ 100 or n ≤ 500 alongside a “split the range” problem is a strong hint that a cubic partition DP is intended rather than something to optimise away.

VariantStateWhat the split meansCanonical problem
Matrix chaindp[i][j]where to place the outermost parenthesisclassic MCM
Burst balloonsdp[l][r] exclusivewhich balloon is burst last312 Burst Balloons
Merge stonesdp[i][j][m]split into m piles1000 Minimum Cost to Merge Stones
Min cuts to palindromesdp[i] (prefix!)last cut position132 Palindrome Partitioning II
Triangulationdp[i][j]which vertex completes the triangle1039 Minimum Score Triangulation
Guess number costdp[i][j]which number to guess first375 Guess Number Higher or Lower II
  • Filling row-major instead of by range length. dp[i][j] reads cells strictly inside [i, j], which a row-major order has not written yet. The result is a table full of zeros silently treated as valid answers.
  • Fixing the first item rather than the last. The subproblems become entangled and there is no correct recurrence. This is the defining conceptual error.
  • Inclusive versus exclusive range boundaries. Burst Balloons wants dp[l][r] = “burst everything strictly between”. Mixing conventions mid-solution produces off-by-one scores that look almost right.
  • Forgetting the sentinel 1s in LC 312. Without them, balloons at the array edges have no neighbour and the multiplication needs special cases at every boundary.
  • Trying greedy. Bursting the largest balloon first is intuitive and wrong. If you cannot prove an exchange argument, it is DP.
  • Assuming O(n3)O(n^3) is too slow. With n ≤ 500 it is roughly 10810^8 — tight but intended. Check the constraint before optimising.
They askWhat they’re checkingThe answer
“Why think about the last operation, not the first?”The core insightFixing the last one leaves two subranges separated by it, so they never affect each other’s neighbours. Fixing the first leaves two halves that become adjacent, and the subproblems entangle
“Why loop over length in the outer loop?”Dependency disciplinedp[i][j] depends on strictly shorter ranges inside it. Row-major order reads cells that have not been written
“What is the complexity, and why?”PrecisionO(n2)O(n^2) states × O(n)O(n) split points = O(n3)O(n^3). That cubic is the family’s signature and explains the small constraints
“Why the sentinel 1s in Burst Balloons?”Practical detailSo every subrange has real boundary neighbours and the score formula needs no edge cases. Multiplying by 1 is the identity, so they never change a result
“Could this be greedy?”Whether you test your instinctsNo — bursting the largest first is locally optimal and globally wrong. There is no valid exchange argument
“Write it top-down instead”FlexibilityMemoised recursion on (left, right) is often easier here, because the recursion naturally visits shorter ranges first and the length-ordering problem disappears
4 problems
0 easy1 medium3 hard

Work down the ladder. Tick each problem off as you go — progress is saved in this browser, and the Export button in the filter bar writes it to a file you can keep.

LC 1039 — Minimum Score Triangulation · Medium

Section titled “LC 1039 — Minimum Score Triangulation · Medium”

LC 132 — Palindrome Partitioning II · Hard (the 1-D case)

Section titled “LC 132 — Palindrome Partitioning II · Hard (the 1-D case)”
pch.quizTag Partition DP — self-check
  1. In Burst Balloons, why fix the LAST balloon burst in a range rather than the first?

    pch.quizShowAnswer

    B — Because when k is burst last, every other balloon in the range is gone, so its neighbours are the fixed range boundaries — and the two subranges never affect each other — Fixing the first leaves two halves that become adjacent, so what happens on one side changes the scores available on the other. The subproblems entangle and there is no valid recurrence.

  2. Why must the outer loop iterate over range LENGTH?

    pch.quizShowAnswer

    B — Because dp[i][j] depends on strictly shorter ranges inside it, which a row-major fill has not written yet — Row-major order reads uncomputed zeros and treats them as valid answers, so the output is wrong with no error. Length-first guarantees every dependency is already written.

  3. What is the complexity of this family, and what does it tell you about the constraints?

    pch.quizShowAnswer

    B — O(n^3): O(n^2) ranges times O(n) split points — which is why constraints sit near n <= 500 — The cubic is the signature. Seeing n <= 100 or n <= 500 next to a split-the-range problem is the statement hinting that a cubic partition DP is intended, not something to optimise away.

  4. Why pad Burst Balloons with sentinel 1s?

    pch.quizShowAnswer

    B — So every subrange has real boundary neighbours and the score formula needs no edge cases — and multiplying by 1 never changes a result — Without them, balloons at the array edges have a missing neighbour and every multiplication needs a boundary check. The 1s are the multiplicative identity, so they are free.

  5. LC 132 asks for minimum palindrome cuts and is only O(n^2). Why is it not cubic?

    pch.quizShowAnswer

    B — Because its state is a PREFIX, not a range — palindrome-ness depends only on the piece itself, not on how it interacts with its surroundings — That distinction is the useful test: if a piece's cost depends on both endpoints interacting with what surrounds it you need a 2-D state; if it depends only on the piece, a 1-D state with a loop suffices.

  • Cue — split a range and search over the split point; or combine adjacent items repeatedly. State is dp[i][j], two indices.
  • The reframe — fix which item is handled last, never first. That is what makes the two subranges independent.
  • Recurrencedp[i][j] = best over k in (i, j) of dp[i][k] + dp[k][j] + cost(i, k, j).
  • Fill orderrange length outermost, then i. Row-major reads uncomputed cells.
  • ComplexityO(n3)O(n^3): O(n2)O(n^2) states × O(n)O(n) splits. Expect n ≤ 500.
  • Sentinels — pad with identity values so boundaries need no special case.
  • 1-D exception — if a piece’s cost depends only on the piece (palindromes), a prefix state gives O(n2)O(n^2).
  • Partition DP is the shape where the state is a range and the transition searches every split — the hardest DP shape that appears regularly in interviews.
  • The single insight is “think about the last operation”. Everything else, including the loop order and the sentinels, follows from wanting independent subproblems.
  • Fill by increasing range length, because each cell depends on shorter ranges strictly inside it.
  • O(n3)O(n^3) is expected, not a failure — the constraints are sized for it.
  • Top-down memoisation is often easier to write here, since the recursion visits shorter ranges first automatically.

Next: Bitmask and Tree DP — when the state is a set rather than an index.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading