~/dynamic-programming/interval-dp
Interval DP
Solve a problem for every substring or subarray, shortest first, so each range is built from the smaller ranges inside it.
dp[i][j] is the answer for the range i..j. Fill the table by increasing length, so every shorter range inside is already done.
The answer for a range depends on its two ends or on where you split it: palindromes, merging, cutting, bursting.
O(n²) when a cell looks at its ends, O(n³) when it tries every split point
O(n²)
You’ll recognise it when
- The input is a sequence (a string, a row of balloons, a stick with marks) and the question is about a contiguous range of it, usually the whole thing.
- Solving a range means deciding something about its two ends (do they pair up?) or about where to split it (which multiplication happens last, which cut goes first).
- After that decision, what’s left is again one or two contiguous ranges, just shorter.
nis in the hundreds or low thousands, because the table has about n² cells.
It’s easy to confuse with an ordinary prefix DP like knapsack, where dp[i] covers the first i items. A prefix only ever grows at one end. Reach for interval DP when both ends move, or when two pieces from the middle get combined.
The idea
To check that “level” is a palindrome, you compare the outer letters, l and l, then look at what’s between them, “eve”, and repeat. Each step works on a shorter piece of the same word, marked by where it starts and where it ends.
Interval DP turns that into a table. dp[i][j] holds the answer for the piece from position i to position j, and each piece’s answer uses only shorter pieces inside it. So fill the table in order of length: all pieces of length 1, then length 2, and so on. By the time you reach a piece, everything it needs is already there.
This page works through one problem: the longest palindromic subsequence. A subsequence keeps some letters in their order and skips the rest, so “debugged” contains “degged”, a palindrome of length 6. Look at the end letters of a piece. If they’re equal, they can be the outer pair around the best palindrome inside. If they differ, they can’t both be used, so drop one of them and keep whichever side does better.
How it works
dp[i][j] is the length of the longest palindromic subsequence of s[i..j], so only cells with i ≤ j matter: the upper triangle. The graphic fills it one diagonal at a time as you scroll, on “debugged”.
- One letter is a palindrome. Every
dp[i][i]on the diagonal is 1. - Length 2 is the next diagonal up. For each cell, find its piece in the word above the table and compare the two end letters.
- Ends differ: drop one. “d” and “e” can’t both be in. The cell below,
dp[i+1][j], is the piece without its first letter; the cell to the left,dp[i][j-1], is the piece without its last. Take the larger. - Ends match: wrap the inside. “gg” pairs up around nothing. The empty piece between them, just below the diagonal, counts 0, so the cell is 0 + 2.
- The larger side wins. In “ugg”, dropping the “u” leaves “gg”, worth 2; dropping the last “g” leaves “ug”, worth 1. Keep 2.
- A match further out. “ebugge” starts and ends with “e”, so it’s the best inside “bugg”,
dp[2][5] = 2, plus the pair: 4. - The last cell. “debugged” has “d” at both ends:
dp[1][6] + 2 = 6. - Read the answer in the top-right corner,
dp[0][n-1]. - Walk back to build one. At each cell, if the ends match, both letters go into the palindrome and you step diagonally inside.
- Ends differ: follow a neighbour with the same value. If
dp[i+1][j]equals the current cell, the first letter wasn’t needed. Drop it and move down. - Done: “degged”, length 6. The “b” and “u” in the middle were skipped.
Why it’s correct: take any palindromic subsequence of s[i..j]. If it doesn’t use s[i], it fits inside s[i+1..j], so it’s no longer than dp[i+1][j]. If it doesn’t use s[j], likewise for dp[i][j-1]. If it uses both, they’re its outer letters, so they’re equal and the rest is a palindrome inside s[i+1..j-1]. The recurrence covers all three cases, and each case reads a shorter piece that an earlier diagonal already filled.
def longest_palindrome_subseq(s):
"""Length of the longest subsequence of s that reads the same both ways."""
n = len(s)
if n == 0:
return 0
# dp[i][j] = answer for the substring s[i..j]; cells with i > j mean
# "nothing in between" and stay 0.
dp = [[0] * n for _ in range(n)]
for i in range(n):
dp[i][i] = 1
for length in range(2, n + 1): # short intervals first
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j]:
dp[i][j] = dp[i + 1][j - 1] + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
return dp[0][n - 1]#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// Length of the longest subsequence of s that reads the same both ways.
int longestPalindromeSubseq(const string& s) {
int n = s.size();
if (n == 0) return 0;
// dp[i][j] = answer for the substring s[i..j]; cells with i > j mean
// "nothing in between" and stay 0.
vector<vector<int>> dp(n, vector<int>(n, 0));
for (int i = 0; i < n; i++) {
dp[i][i] = 1;
}
for (int length = 2; length <= n; length++) { // short intervals first
for (int i = 0; i + length - 1 < n; i++) {
int j = i + length - 1;
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][n - 1];
}class Palindrome {
// Length of the longest subsequence of s that reads the same both ways.
static int longestPalindromeSubseq(String s) {
int n = s.length();
if (n == 0) return 0;
// dp[i][j] = answer for the substring s[i..j]; cells with i > j mean
// "nothing in between" and stay 0.
int[][] dp = new int[n][n];
for (int i = 0; i < n; i++) {
dp[i][i] = 1;
}
for (int length = 2; length <= n; length++) { // short intervals first
for (int i = 0; i + length - 1 < n; i++) {
int j = i + length - 1;
if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][n - 1];
}
}Why loop by length? A cell reads the cell below it, so filling row 0 first would read row 1 before it exists. Any order that finishes shorter pieces first works. Looping by length is the one that’s obviously right for every interval problem. For this recurrence, running i from n - 1 down to 0 with j going up also works, and it’s what you’ll often see:
for i in range(n - 1, -1, -1):
dp[i][i] = 1
for j in range(i + 1, n):
... # same two cases
The other flavour: choosing a split point
In the palindrome problem, a piece only looks at its two ends. The other big family decides where to split the piece: every k between i and j is a candidate, and the answer is the best one.
dp[i][j] = min over k of dp[i][k] + dp[k+1][j] + cost(i, k, j)
The classic example is matrix chain multiplication. Multiplying a 10×30 matrix by a 30×5 one costs 10·30·5 multiplications, and in a chain, the order you group them in changes the total a lot. Whatever the grouping, some multiplication happens last, joining the product of matrices i..k to the product of k+1..j. Try every k:
# matrix m has dims[m] rows and dims[m + 1] columns
dp = [[0] * n for _ in range(n)]
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
dp[i][j] = min(dp[i][k] + dp[k + 1][j] + dims[i] * dims[k + 1] * dims[j + 1]
for k in range(i, j))
Same table, same length order, one more loop. Burst balloons and cutting a stick have this shape too; see Variations.
Why it’s O(n²)
There are n(n + 1) / 2 cells with i ≤ j, and each is filled with one comparison and a max, so the time is O(n²) and the table is O(n²) memory. With n = 1500 that’s about 1.1 million cells.
The split-point flavour does up to j - i work per cell, which adds up to about n³ / 6 steps: O(n³) time, still O(n²) memory, since k is looped over and never stored. That’s why those problems usually have n in the hundreds.
| n | cells, ~n²/2 | split-point work, ~n³/6 |
|---|---|---|
| 100 | 5,000 | 170,000 |
| 500 | 125,000 | 21 million |
| 2,000 | 2 million | 1.3 billion: too slow |
For the palindrome problem, row i only reads row i + 1, so with i going down you can keep two rows and use O(n) memory.
Common mistakes
Filling rows from the top
Row by row from i = 0, the cell below hasn’t been filled yet and still holds 0. For “abcba” this returns 2 instead of 5, with no error.
for i in range(n): # ✗ reads row i + 1 before it's filled
for i in range(n - 1, -1, -1): # ✓ row i + 1 is already done
Dropping both ends when they differ
When s[i] != s[j], only one of them has to go; the other might pair with a letter inside. For “aab” the ends differ, but the first two letters make “aa”.
dp[i][j] = dp[i + 1][j - 1] # ✗ gives 1 for "aab"
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) # ✓ gives 2
Mixing up subsequence and substring
A substring is contiguous. The longest palindromic substring of “debugged” is “gg”, length 2, not 6. Matching ends only help there if everything inside is also a palindrome, so its recurrence is a yes/no table: pal[i][j] = s[i] == s[j] and pal[i+1][j-1].
Counting the split element twice
Be clear about what i and j index. Over matrices (or elements), the halves are i..k and k+1..j. Over boundaries between them, as in cutting a stick, the two halves share the cut k. Mixing the two counts an element twice or skips one.
dp[i][k] + dp[k][j] # ✗ for matrices i..j: matrix k is in both halves
dp[i][k] + dp[k + 1][j] # ✓ halves i..k and k+1..j
Variations
- Burst balloons. Bursting a balloon earns the product of it and its current neighbours. Pad the row with a 1 at each end, and let
dp[l][r]be the best total for the balloons strictly betweenlandr. The trick is to choosekas the balloon burst last in that gap: its neighbours are then exactlylandr, and the two sides never interact.dp[l][r] = max(dp[l][k] + a[l]·a[k]·a[r] + dp[k][r]). - Minimum cost to cut a stick. Each cut costs the length of the piece being cut. Sort the cut positions and add
0and the stick length as sentinels,c. Thendp[i][j] = c[j] - c[i] + min(dp[i][k] + dp[k][j])over cutskstrictly betweeniandj, wherekis the first cut made in that piece. Indexing by cuts, not by coordinates, keeps the table small when the stick is long. - Matrix chain multiplication and polygon triangulation. The split-point recurrence above. Triangulating a convex polygon is the same shape: the edge from vertex
ito vertexjbelongs to exactly one trianglei, k, j, which splits the rest into two smaller polygons. - Palindrome partitioning. To cut a string into the fewest palindromes, first fill
pal[i][j]with the substring recurrence from the mistakes above, by length. Then run a 1-D DP over prefixes: the best split ofs[0..j]is 1 + the best split ofs[0..i-1], minimised over everyiwithpal[i][j]true. The number of cuts is the number of pieces minus 1. O(n²) in total. - Top-down instead of by length. A memoised recursion
solve(i, j)computes pieces in whatever order it needs them, so you don’t have to think about the fill order at all. The cost is recursion depth up tonand slower constant factors; the bottom-up loop by length avoids both.
Check yourself
5 quick questions. Pick an answer to see why it's right or wrong.
-
1
You must multiply a chain of matrices A1·A2·…·An and want the grouping with the fewest scalar multiplications. Which approach fits?
Whatever the grouping, one multiplication happens last and joins two independently grouped halves,
i..kandk+1..j. That’s the split-point shape: O(n²) ranges, O(n) splits each. A prefix DP can’t express groupings like (A1·A2)·(A3·A4), the cheapest-pair greedy can be far from optimal, and reordering isn’t allowed because matrix products don’t commute. -
2
Cutting a stick costs the length of the piece you cut. What does this print?
def min_cost(length, cuts):c = [0] + sorted(cuts) + [length]m = len(c)dp = [[0] * m for _ in range(m)]for gap in range(2, m):for i in range(m - gap):j = i + gapdp[i][j] = c[j] - c[i] + min(dp[i][k] + dp[k][j]for k in range(i + 1, j))return dp[0][m - 1]print(min_cost(7, [1, 3, 4, 5]))dp[i][j]is the cheapest way to make every cut strictly betweenc[i]andc[j], withkas the first of them. One best order is 3, then 5, then 1, then 4: 7 + 4 + 3 + 2 = 16. Cutting in the given order 1, 3, 4, 5 costs 7 + 6 + 4 + 3 = 20. Starting at 4, near the middle, gives 17: a balanced first cut is a good guess but not always the best one. -
3
This should print 5 (“abcba” is itself a palindrome). What does it print, and why?
def lps(s):n = len(s)dp = [[0] * n for _ in range(n)]for i in range(n):dp[i][i] = 1for j in range(i + 1, n):if s[i] == s[j]:dp[i][j] = dp[i + 1][j - 1] + 2else:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])return dp[0][n - 1]print(lps("abcba"))For
dp[0][4]the ends match, so it adds 2 todp[1][3], which is still 0 because row 1 comes later. Fill shorter ranges first: loop by length, or runifromn - 1down to 0. It doesn’t crash, becausedp[i + 1]is only read whenj > i, soi + 1 ≤ n - 1. -
4
Burst Balloons: bursting balloon k earns
left · a[k] · rightwith its current neighbours. In the interval DP over the gap between balloonslandr, what should the split pointkmean?If
kgoes last, everything on its left and right was burst whilekstill stood between them, so the two sides never become neighbours: they’re independent subproblems, andkearnsa[l] · a[k] · a[r]. Ifkwent first, the two sides would become neighbours afterwards and their costs would depend on each other. -
5
A split-point interval DP has a cell
dp[i][j]for every pairi ≤ jand tries everykbetween them. Withn = 500, roughly how much work and memory is that?There are about n²/2 cells and each loops over up to n split points: about n³/6, roughly 2·10⁷ for n = 500.
kis only looped over, never stored, so the table stays O(n²). Each cell is filled once, but filling it isn’t O(1), and the exponential number of groupings is exactly what the table avoids enumerating.
Practice problems
- medium Longest Palindromic Subsequence leetcode.com
- medium Removal Game cses.fi
- medium Minimum Score Triangulation of Polygon leetcode.com
- hard Minimum Insertion Steps to Make a String Palindrome leetcode.com
- hard Minimum Cost to Cut a Stick leetcode.com
- hard Palindrome Partitioning II leetcode.com
- hard Burst Balloons leetcode.com