Search Tech Journey

Find topics, journeys and posts

back to blog
algorithmsintermediate 75m read

L42 · DP X — Interval DP

dp[i][j] over ranges with an inner split point k: why Burst Balloons only works when you think about the LAST balloon, and how sentinel padding removes the boundary cases.

🧩DSAPhase 2 · Dynamic programming· Session 042 of 130 75 min

🎯 Write the three-loop interval DP skeleton from memory, and internalise the reversal that makes Burst Balloons tractable: choose the LAST operation in the range, never the first.

Series: LeetCode — From Basics to Interview-Ready · Session 42 / 65 · Phase 2 · Dynamic programming

Why this session exists

Interval DP is genuinely hard, and I want to say that plainly rather than pretend the pattern makes it easy. The skeleton is three nested loops and about eight lines. Recognising that a problem needs it, and finding the right thing to put at the split point, is the difficult part and no template rescues you from it.

The trick in Burst Balloons is thinking about the last balloon in a range rather than the first. That inversion is the whole session. If you ask "which balloon do I burst first", the two remaining pieces are not independent — bursting a balloon changes who its neighbours are, so the left piece's answer depends on what happened in the right piece. The subproblems overlap in a way that destroys the recurrence.

If instead you ask "which balloon is the last one standing in this range", everything works. The last balloon's neighbours at the moment it bursts are, by definition, the two balloons just outside the range — because everything inside the range is already gone. That fact is fixed and known. So the left sub-range and the right sub-range become genuinely independent, and you can add their answers.

That reversal — from first to last — is not a trick specific to balloons. It is the general move for interval problems where operations interact. Matrix chain multiplication asks which multiplication you do last. Optimal binary search tree asks which key is the root. Same shape, same reason.

Blank-file warm-up

Five minutes, empty file, no notes. Write the interval DP skeleton:

  1. The outer loop over interval length, not over i.
  2. The derivation of j from i and the length.
  3. The inner loop for k in range(i, j) combining dp[i][k] and dp[k][j].

You are not solving a problem here. You are proving you can produce the loop structure cold, because in an interview the structure is what you write while you are still thinking about the recurrence.

Pattern anatomy

The shape that summons this pattern: a contiguous range that gets consumed by operations, where the cost of an operation depends on what is adjacent to it at the time. That last clause is the discriminator. If costs were independent of order, you would sort or greedily pick and never need a table.

The invariant: dp[i][j] is the best achievable value for the range strictly between boundaries i and j — or inclusive of both, depending on the problem, and being sloppy about which is the fastest way to lose an hour. Pick a convention, write it in a comment, and hold it.

Dependencies flow from shorter ranges to longer ones. dp[i][j] reads dp[i][k] and dp[k][j], both of which are strictly shorter than [i, j]. That is why the outer loop must be over length: any other order reads unfilled cells.

def interval_dp(n: int) -> int:
    # convention: dp[i][j] = best value for the OPEN interval (i, j)
    dp = [[0] * (n + 1) for _ in range(n + 1)]
 
    for length in range(2, n + 1):          # length of the span i..j
        for i in range(0, n + 1 - length):
            j = i + length
            best = float("-inf")
            for k in range(i + 1, j):       # k is the LAST element handled
                value = dp[i][k] + dp[k][j] + cost(i, k, j)
                best = max(best, value)
            dp[i][j] = best
    return dp[0][n]

Three loops: length, left endpoint, split point. O(n³) time, O(n²) space. The cost(i, k, j) term is the entire problem-specific content, and it is where you spend your thinking.

For Burst Balloons the cost is nums[i] * nums[k] * nums[j] — the value of bursting balloon k when its surviving neighbours are exactly the boundaries i and j. That expression is only correct because of the last-not-first framing.

The cue

You are looking at interval DP when the statement contains these tells:

  1. n around 100 to 500. This is the loudest tell in the whole session. O(n³) at n = 500 is 125 million operations — borderline but plausible in a fast language, comfortable at n = 100. When you see a suspiciously small n on an array problem, cubic is being invited.
  2. Operations remove or merge adjacent elements, and removal changes adjacency. Bursting, cutting, merging stones, multiplying matrices. If removing an element makes two other elements become neighbours, the order matters and greedy will not save you.
  3. "Minimum cost to reduce to one" or "maximum score to remove everything". Both phrasings describe reducing a range by repeated local operations.
  4. The cost of one operation references its current neighbours rather than fixed positions. nums[i-1] * nums[i] * nums[i+1] where the indices shift as things are removed.
  5. A greedy that feels right but you can break with a small counter-example. Burst Balloons has an obvious greedy — burst the smallest first, or the largest first — and both are wrong on three-element inputs. If your first instinct is greedy and you can break it in under a minute, the answer is usually interval DP.

Tell 1 plus tell 2 together is close to conclusive. Small n and order-dependent adjacency means build the triangle.

Guided solve

Burst Balloons — you have n balloons with values nums[i]. Bursting balloon i earns nums[i-1] * nums[i] * nums[i+1], where out-of-range neighbours count as 1. After bursting, the neighbours become adjacent. Maximise total coins.

Start with the naive framing so you can watch it fail, because articulating the failure is what earns the points.

Naive: let dp[i][j] be the best score for range [i, j], and choose which balloon to burst first. Then you would want dp[i][k-1] + dp[k+1][j] + something. But what is the something? When you burst k first, its neighbours are nums[k-1] and nums[k+1], both still present. Fine. But now the left sub-range [i, k-1] will eventually burst its rightmost balloon, and at that moment its right neighbour is whatever survives from the right sub-range. The two halves are coupled. The recurrence is not well-defined. Stop here and say so — this is the moment the interviewer is actually evaluating.

Now the reversal. Define dp[i][j] as the best score for bursting every balloon strictly between i and j, where i and j themselves are never burst — they are the surviving boundary. Choose k to be the last balloon burst in that open range.

Because k is last, everything strictly between i and k is already gone, and everything strictly between k and j is already gone. So at the moment k bursts, its neighbours are exactly i and j. Its value is nums[i] * nums[k] * nums[j] — fully determined, no coupling.

And the two sub-ranges (i, k) and (k, j) are now independent: each is bounded by balloons that survive throughout that sub-problem, so nothing inside one affects anything inside the other.

def maxCoins(nums):
    vals = [1] + nums + [1]                    # sentinels
    n = len(vals)
    dp = [[0] * n for _ in range(n)]
 
    for length in range(2, n):                 # span from i to j inclusive
        for i in range(0, n - length):
            j = i + length
            for k in range(i + 1, j):          # k burst LAST in the open range
                dp[i][j] = max(
                    dp[i][j],
                    dp[i][k] + dp[k][j] + vals[i] * vals[k] * vals[j],
                )
    return dp[0][n - 1]

Trace nums = [3, 1, 5] briefly. With sentinels, vals = [1, 3, 1, 5, 1]. Length-2 spans are empty inside, so they stay 0. For the span i=0, j=2 the only k is 1, giving 1*3*1 = 3. For i=1, j=3, k=2 gives 3*1*5 = 15. Building up, the full range i=0, j=4 tries k = 1, 2, 3 and the maximum comes out at 35, from bursting 1 first, then 3, then 5 — equivalently, 5 last. The bottom-right corner is the answer.

Solo timed

Fifteen minutes each, timer running, no editorial until it fires.

  • Minimum Cost to Cut a Stick — you are given cut positions on a stick. Cutting a piece costs its current length. Same skeleton, but sort the cuts and pad with the two stick endpoints as sentinels first. The cost term is positions[j] - positions[i], independent of k — think about why that is.
  • Matrix Chain style / Minimum Score Triangulation — pick any matrix-chain-shaped problem. The cost term is a product of three dimensions and the split point is the last multiplication performed.

If both land early, try Stone Game VII or Remove Boxes — the latter needs a third state dimension and is a real step up, which makes it a good honest test of whether you understood the framing or just memorised the loops.

Common failure modes

Iterating i in the outer loop. The number-one structural bug. dp[i][j] needs shorter spans, and looping i outward-in or row-major reads zeros from unfilled cells. The answer comes out too small and nothing crashes. Always iterate length first.

Mixing open and closed interval conventions. Defining dp[i][j] as "burst everything strictly between" and then writing for k in range(i, j+1) mixes the two. Decide once, write it in a comment above the table, and make every loop bound obey it.

Forgetting the sentinels, then patching with conditionals. You end up with left = nums[i-1] if i > 0 else 1 inside the innermost loop, which works but is four extra branch points in the hottest code path. Pad the array instead.

Choosing the first operation instead of the last. This is the conceptual failure, not a coding one. The symptom is that you cannot write a correct cost term — you keep needing to know what happened in the other half. If you find yourself stuck there, that is the signal to invert.

Cubic blowup on large n. If n is 10^4, interval DP is not the intended solution no matter how well it fits the story. Re-read the constraints before committing to three nested loops.

Common misconception
✗ What most people think
Burst Balloons should be solvable greedily — burst the smallest-value balloon first, since it contributes least and removing it lets its bigger neighbours multiply together.
Why the myth is so sticky
Try [3, 1, 5] by hand. Greedy-smallest bursts the 1 first for 3*1*5 = 15, then 3 for 1*3*5 = 15, then 5 for 1*5*1 = 5, totalling 35. That happens to be optimal here, which is exactly the danger — the heuristic looks validated. Now try [1, 5, 8] where greedy-smallest gives a different total from the true optimum. The deeper reason greedy cannot work is that a balloon's contribution depends on which neighbours survive until it bursts, so a local decision changes the value of every future decision. There is no exchange argument available, which is precisely the condition under which greedy fails and DP is required.
From first principles
  1. 1
    Because bursting a balloon makes its two neighbours adjacent, the score of a later burst depends on which earlier bursts happened — the operations are not independent.
  2. 2
    Because choosing the FIRST burst in a range leaves two sub-ranges whose boundaries depend on each other's outcomes, that framing does not decompose into independent subproblems.
  3. 3
    Because the LAST balloon burst in a range has, by definition, nothing left beside it inside the range, its neighbours are exactly the two fixed boundaries of that range.
  4. 4
    Because those boundaries are fixed and known before solving the range, the cost of the last burst is a constant expression, and the two remaining sub-ranges never interact.
  5. 5
    Because each of the O(n^2) ranges tries O(n) split points doing constant work, the total is O(n^3).
  6. 6
    Therefore the testable prediction is that runtime grows as the cube of n: going from 100 to 200 balloons should take roughly eight times as long, not four.
Mental model
A row of dominoes standing between two walls that never fall. You are not asking which domino topples first. You are asking which one is the last one standing — because at that instant you know exactly what is beside it: the two walls. Then the same question recurses on the gap to its left and the gap to its right, each with its own pair of walls.
🔔 Fires when you see
Fires when operations on a range interact through adjacency and n is small enough that cubic is affordable — roughly n under 500.
The tradeoff
Bottom-up table, length-ordered loops
+ you gain No recursion depth risk; the fill order is explicit and auditable; typically 2-3x faster than memoised recursion in Python
− you pay You must get the length-first loop order right, and an incorrect order fails silently rather than crashing
Top-down memoised recursion
+ you gain The recurrence reads exactly like the mathematical definition, so it is much easier to derive under pressure; fill order is handled for you automatically
− you pay Recursion depth is O(n) which is fine, but the constant factor of Python function calls plus dict lookups is real; harder to reason about which cells were actually computed
What a senior engineer actually does
Use top-down memoisation while you are still deriving the recurrence, because getting the definition right matters more than constant factors. Convert to the bottom-up table only if you hit a time-limit failure or the interviewer explicitly asks for the iterative form. If n is above 300 in a Python submission, go bottom-up from the start.

Complexity

Time: O(n³). There are O(n²) intervals, each trying O(n) split points with constant work per split. At n = 500 that is 125 million inner iterations, which is too slow for Python but fine for C++ — check the constraints and the expected language before committing.

Space: O(n²) for the table. There is no rolling-array reduction here, because dp[i][j] reads cells scattered across the whole triangle rather than a fixed offset back. That is a genuine structural difference from the L40 grid, and worth noticing: you cannot always optimise space away.

Only the upper triangle i < j is ever used, so a triangular representation halves the memory if you need it. Rarely worth the index arithmetic.

Quick recall · click to reveal
★ = stretch question

Spaced queue

Re-solve whatever is due before starting anything new today. Status ladder:

  • cold — solved unaided, first attempt clean → next review in 60 days
  • warm — solved but slowly or with a stumble → 21 days
  • hint — needed a nudge to get started → 7 days
  • failed — could not produce a working solution → 2 days, then 7 days

Burst Balloons will most likely enter at failed or hint the first time, and that is normal. This is one of the few problems where the honest expectation is that you will not derive it cold on first exposure. Log it truthfully and let the ladder do its job.

Key points