Part 14 · Lesson 57 · read
DP on Strings
Longest common subsequence, edit distance and the longest palindrome inside a string.
On this page
Two strings, one table
Lesson 56 filled two-dimensional tables (tables with rows and columns) for grids and knapsack. Strings work the same way. When a question compares two strings, let one side of the table follow each string. The cell dp[i][j] then answers the question about the first i characters of the first string and the first j characters of the second string. Each cell is worked out from its neighbours: the cell above, the cell to the left, and the cell diagonally up-left. This is just like the grid paths you already know.
Two words matter here. A subsequence keeps the characters in their original order, but it may skip some. "ace" is a subsequence of "abcde". A substring must be one unbroken piece, with no gaps. "bcd" is a substring, but "ace" is not. Read the question twice to see which one it asks about, because the methods are different.
Longest common subsequence
The longest common subsequence (LCS) of two strings is the longest subsequence that appears in both strings. Its characters are in the same order in both, but they do not need to be next to each other. For "abcde" and "ace" it is "ace", with length 3.
Let dp[i][j] be the LCS length of a[0..i) and b[0..j) (the first i characters of a and the first j characters of b). A prefix is the start of a string. Look only at the last character of each prefix:
- If
a[i-1] === b[j-1], the two characters match. It is always safe to use them, sodp[i][j] = dp[i-1][j-1] + 1. - If they do not match, at least one of the two last characters is not used. So the answer is the bigger of two choices. You can drop a's last character (
dp[i-1][j]), or drop b's last character (dp[i][j-1]).
We add a row and a column of zeros for the empty prefixes. This keeps the index numbers simple. That is why the table has m + 1 rows and n + 1 columns.
// Length of the longest common subsequence of a and b.// dp[i][j] = LCS length of the first i characters of a and the first j characters of b.function lcs(a, b) { const m = a.length, n = b.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); // row 0 and column 0 stay 0: an empty prefix has no common part for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (a[i - 1] === b[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; // the two last characters match: extend the diagonal } else { dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); // drop a's last character, or drop b's } } } return dp[m][n];}console.log(lcs("abcde", "ace")); // 3console.log(lcs("abc", "def")); // 0console.log(lcs("AGGTAB", "GXTXAYB")); // 4Time and memory are both O(m × n). Row i only reads row i-1. So you can keep just two rows and use only O(n) memory. The cost is that you can no longer rebuild the string itself.
Traced: filling the LCS table
Each row is the start of "abcde" (0 to 5 characters). Each column is the start of "ace" (0 to 3). Row 0 and column 0 stay 0.
1const m = a.length, n = b.length;▶const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));3for (let i = 1; i <= m; i++) {4 for (let j = 1; j <= n; j++) {5 if (a[i - 1] === b[j - 1]) {6 dp[i][j] = dp[i - 1][j - 1] + 1;7 } else {8 dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);9 }10 }11}12return dp[m][n];| a | "abcde" |
| b | "ace" |
| dp | [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]] |
Recovering the actual string
The table holds lengths, not letters. To get a real LCS, start at the bottom-right corner and follow the choices backwards. If the two characters match, that character is in the answer, and you move diagonally up-left. If they do not match, move to the neighbour with the bigger value. You collect the letters from the end of the string to the start, so reverse them at the end.
// Same table, then walk back from the bottom-right corner to read off one actual LCS.function lcsString(a, b) { const m = a.length, n = b.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); } } const letters = []; let i = m, j = n; while (i > 0 && j > 0) { if (a[i - 1] === b[j - 1]) { letters.push(a[i - 1]); // this character is part of the answer i--; j--; // move diagonally } else if (dp[i - 1][j] >= dp[i][j - 1]) { i--; // the value came from above: skip a's character } else { j--; // the value came from the left: skip b's character } } return letters.reverse().join(""); // we collected the letters from the end, so reverse them}console.log(lcsString("abcde", "ace")); // aceconsole.log(lcsString("AGGTAB", "GXTXAYB")); // GTABEdit distance
The edit distance between two words is the smallest number of one-character changes (insert, delete or replace one character) needed to turn the first word into the second. Let dp[i][j] be the cost of turning the first i characters of word1 into the first j characters of word2. Each change matches one move on the table:
- Replace: swap the last character of word1 for the last character of word2. Then solve the smaller problem:
dp[i-1][j-1] + 1(the cell diagonally up-left). - Delete word1's last character:
dp[i-1][j] + 1(the cell above). - Insert word2's last character onto word1:
dp[i][j-1] + 1(the cell to the left). - If the two last characters are already equal, you need no change. Copy
dp[i-1][j-1].
This time the border (the first row and first column) is not zero. Turning i characters into an empty string takes i deletions. Building j characters from nothing takes j insertions.
// Minimum insert / delete / replace operations to turn word1 into word2.// dp[i][j] = edits to turn the first i characters of word1 into the first j characters of word2.function editDistance(word1, word2) { const m = word1.length, n = word2.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; // turn i characters into nothing: i deletions for (let j = 0; j <= n; j++) dp[0][j] = j; // build j characters from nothing: j insertions for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (word1[i - 1] === word2[j - 1]) { dp[i][j] = dp[i - 1][j - 1]; // last characters already agree: no operation needed } else { dp[i][j] = 1 + Math.min( dp[i - 1][j - 1], // replace word1's last character with word2's dp[i - 1][j], // delete word1's last character dp[i][j - 1], // insert word2's last character at the end ); } } } return dp[m][n];}console.log(editDistance("horse", "ros")); // 3console.log(editDistance("intention", "execution")); // 5console.log(editDistance("", "abc")); // 3| (empty) | r | o | s | |
|---|---|---|---|---|
| 0 | 1 | 2 | 3 | |
| h | 1 | 1 | 2 | 3 |
| o | 2 | 2 | 1 | 2 |
| r | 3 | 2 | 2 | 2 |
| s | 4 | 3 | 3 | 2 |
| e | 5 | 4 | 4 | 3 |
The last row is the answer row. Its last cell, 3, is the edit distance (horse → rorse → rose → ros: replace h by r, delete r, delete e). The first row (0 1 2 3) and the first column (0 to 5) are the border. They show the cost of building a prefix from nothing, or deleting it completely.
Longest palindromic subsequence
A palindrome reads the same forwards and backwards, like “level”. There are two clear ways to find the longest palindromic subsequence of s.
Way 1: LCS with the reversed string. A subsequence that appears in s and also in reverse(s) reads the same both ways. So the LCS of the two strings is the answer. You have nothing new to learn.
// A palindrome reads the same forwards and backwards, so the longest palindromic// subsequence of s is the longest common subsequence of s and its reverse.function longestPalindromeSubseq(s) { const t = s.split("").reverse().join(""); const n = s.length; const dp = Array.from({ length: n + 1 }, () => new Array(n + 1).fill(0)); for (let i = 1; i <= n; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = s[i - 1] === t[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); } } return dp[n][n];}console.log(longestPalindromeSubseq("bbbab")); // 4 ("bbbb")console.log(longestPalindromeSubseq("cbbd")); // 2 ("bb")Way 2: interval DP. Interval DP is DP where the state is a slice (interval) of one string or array. An interval is a slice of the string from position i to position j. Let dp[i][j] be the answer for the slice s[i..j]. If the two ends match, they wrap around the best palindrome of the inside, and we add 2. If they do not match, drop one end. We must fill the table from short slices to long slices. The code does this by looping bottom-up: dp[i] needs dp[i+1], so i runs downwards.
// Interval DP: dp[i][j] = longest palindromic subsequence inside s[i..j] (both ends included).function longestPalindromeSubseq(s) { const n = s.length; const dp = Array.from({ length: n }, () => new Array(n).fill(0)); for (let i = n - 1; i >= 0; i--) { // go from the bottom row up: dp[i] needs dp[i + 1] dp[i][i] = 1; // a single character is a palindrome of length 1 for (let j = i + 1; j < n; j++) { if (s[i] === s[j]) { dp[i][j] = dp[i + 1][j - 1] + 2; // matching ends wrap whatever is best in between } else { dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]); // drop the left end, or drop the right end } } } return dp[0][n - 1];}console.log(longestPalindromeSubseq("bbbab")); // 4console.log(longestPalindromeSubseq("cbbd")); // 2console.log(longestPalindromeSubseq("a")); // 1Both take O(n²) time. Interval DP is worth knowing. Many problems that ask for "the best answer for the slice from i to j" look like this.
LIS in O(n log n)
Lesson 55 solved the longest increasing subsequence (LIS) with an O(n²) DP. “Strictly increasing” means every number is bigger than the one before it. Here is that slower version again, so we can compare:
// O(n^2): best[i] = length of the longest increasing subsequence that ENDS at index i.function lisQuadratic(nums) { const best = new Array(nums.length).fill(1); let answer = 0; for (let i = 0; i < nums.length; i++) { for (let j = 0; j < i; j++) { if (nums[j] < nums[i]) best[i] = Math.max(best[i], best[j] + 1); } answer = Math.max(answer, best[i]); } return answer;}console.log(lisQuadratic([10, 9, 2, 5, 3, 7, 101, 18])); // 4The faster method keeps an array called tails. The value tails[k] is the smallest possible last value of any increasing subsequence with length k + 1 that we have seen so far. A smaller last value is better, because it is easier to add to. Two facts follow. First, tails is always sorted from small to big, so we can use binary search (a way to find a spot in a sorted list by checking the middle and discarding the half that cannot contain it). Second, each new number x has only two cases:
xis bigger than every tail. It makes the longest subsequence one longer, so add it at the end.- Otherwise, find the first tail that is
>= x(use binary search) and replace it withx. A subsequence of that length can now end on a smaller value.
// O(n log n). tails[k] = the smallest possible LAST value of an increasing subsequence of length k + 1.// tails is always sorted, so we can binary search it.function lengthOfLIS(nums) { const tails = []; for (const x of nums) { let lo = 0, hi = tails.length; // find the first index whose value is >= x while (lo < hi) { const mid = (lo + hi) >> 1; if (tails[mid] < x) lo = mid + 1; else hi = mid; } tails[lo] = x; // lo === tails.length means "extend by one"; otherwise "lower an existing tail" } return tails.length;}console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 4console.log(lengthOfLIS([7, 7, 7, 7])); // 1| x | tails after it | What happened |
|---|---|---|
| 10 | [10] | tails was empty, so start a subsequence of length 1 |
| 9 | [9] | 9 replaces 10. A smaller last value for length 1 is always better |
| 2 | [2] | 2 replaces 9, for the same reason |
| 5 | [2, 5] | 5 is bigger than every tail, so add it. Length is now 2 |
| 3 | [2, 3] | 3 replaces 5. Now length 2 can end at 3 |
| 7 | [2, 3, 7] | add it. Length is now 3 |
| 101 | [2, 3, 7, 101] | add it. Length is now 4 |
| 18 | [2, 3, 7, 18] | 18 replaces 101. The answer is the length of tails, which is 4 |
The length of tails is the answer. Binary search costs O(log n) for each number, so the total is O(n log n). We use >= (find the first tail that is not smaller than x). The subsequence must be strictly increasing, so an equal value must replace a tail and must not extend it.
tails may come from different positions. They do not have to form a subsequence of the input. This small example shows it:// Watch tails change. Note that it is NOT the subsequence itself.function showTails(nums) { const tails = []; for (const x of nums) { let lo = 0, hi = tails.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (tails[mid] < x) lo = mid + 1; else hi = mid; } tails[lo] = x; console.log(x, "->", [...tails]); }}showTails([4, 5, 1]);// 4 -> [ 4 ]// 5 -> [ 4, 5 ]// 1 -> [ 1, 5 ] but 1, 5 is not a subsequence of [4, 5, 1]: the 5 comes BEFORE the 1To rebuild the real subsequence, you would also remember, for each element, which earlier index it extended. Then you walk back from the last tail. Questions almost always ask only for the length.
Practice questions
For each question, say out loud what dp[i][j] (or dp[i]) means before you write any code.
Given two strings text1 and text2, return the length of their longest common subsequence, or 0 if there is none. A subsequence keeps the original order, but it may skip characters.
Hint 1
dp[i][j] be the answer for the first i characters of one string and the first j characters of the other.Hint 2
Hint 3
Show all 3 solutions with explanations
Define solve(i, j) for the first i and first j characters. Memoisation (memoization) means saving each answer after you work it out, so you never work it out twice. Save the answer for each pair. Without the saved answers, the recursion splits in two at every mismatch. That is exponential, which means the work doubles again and again.
function longestCommonSubsequence(text1, text2) { const memo = new Map(); function solve(i, j) { if (i === 0 || j === 0) return 0; const key = i * 1001 + j; if (memo.has(key)) return memo.get(key); const result = text1[i - 1] === text2[j - 1] ? solve(i - 1, j - 1) + 1 : Math.max(solve(i - 1, j), solve(i, j - 1)); memo.set(key, result); return result; } return solve(text1.length, text2.length);}console.log(longestCommonSubsequence("abcde", "ace")); // 3console.log(longestCommonSubsequence("abc", "abc")); // 3console.log(longestCommonSubsequence("abc", "def")); // 0There are m × n different pairs and each is worked out once, so O(m × n) time and memory. The recursion can go m + n calls deep. That is fine for the usual limit of 1000 characters each.
Fill an (m+1) × (n+1) table row by row. Use zeros for the empty prefixes.
function longestCommonSubsequence(text1, text2) { const m = text1.length, n = text2.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = text1[i - 1] === text2[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); } } return dp[m][n];}console.log(longestCommonSubsequence("abcde", "ace")); // 3console.log(longestCommonSubsequence("abc", "abc")); // 3console.log(longestCommonSubsequence("abc", "def")); // 0The time is the same O(m × n), with no recursion. If you are asked for the string itself, you walk back through this table.
Row i reads only row i-1 and the cell to its own left. So keep two rows, prev (the old row) and curr (the new row), and swap them.
function longestCommonSubsequence(text1, text2) { const n = text2.length; let prev = new Array(n + 1).fill(0); for (let i = 1; i <= text1.length; i++) { const curr = new Array(n + 1).fill(0); for (let j = 1; j <= n; j++) { curr[j] = text1[i - 1] === text2[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], curr[j - 1]); } prev = curr; } return prev[n];}console.log(longestCommonSubsequence("abcde", "ace")); // 3console.log(longestCommonSubsequence("abc", "abc")); // 3console.log(longestCommonSubsequence("abc", "def")); // 0Time is still O(m × n), but memory drops to O(n). To save the most, make the second string the shorter one.
Start with the table. It is the version everybody expects, and the other two are small changes to it. Mention the two-row trick if memory comes up. (LeetCode 1143.)
Given two strings word1 and word2, return the smallest number of operations to turn word1 into word2. The allowed operations are: insert a character, delete a character, and replace a character.
Hint 1
dp[i][j] = the fewest edits to turn the first i characters of word1 into the first j characters of word2.Hint 2
Hint 3
Show all 3 solutions with explanations
Compare the last characters of the two prefixes. If they are equal, drop both for free. If not, try replace, delete and insert, and take 1 + the cheapest. Save every answer (i, j) so you never repeat work.
function minDistance(word1, word2) { const memo = new Map(); function solve(i, j) { if (i === 0) return j; // build j characters from nothing: j insertions if (j === 0) return i; // remove i characters: i deletions const key = i * 1001 + j; if (memo.has(key)) return memo.get(key); let result; if (word1[i - 1] === word2[j - 1]) { result = solve(i - 1, j - 1); } else { result = 1 + Math.min(solve(i - 1, j - 1), solve(i - 1, j), solve(i, j - 1)); } memo.set(key, result); return result; } return solve(word1.length, word2.length);}console.log(minDistance("horse", "ros")); // 3console.log(minDistance("intention", "execution")); // 5console.log(minDistance("", "a")); // 1Without the saved answers, each mismatch splits three ways. That is exponential. With them, there are m × n states and each takes O(1) work.
Fill the border first: 0..m down the first column and 0..n along the first row. Then fill each cell from its left, upper and diagonal neighbours.
function minDistance(word1, word2) { const m = word1.length, n = word2.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (word1[i - 1] === word2[j - 1]) dp[i][j] = dp[i - 1][j - 1]; else dp[i][j] = 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]); } } return dp[m][n];}console.log(minDistance("horse", "ros")); // 3console.log(minDistance("intention", "execution")); // 5console.log(minDistance("", "a")); // 1O(m × n) time and memory. The diagonal move is replace. The move from above is delete. The move from the left is insert.
Each row needs only the row above, so keep two arrays. The first cell of row i is i (all deletions).
function minDistance(word1, word2) { const n = word2.length; let prev = Array.from({ length: n + 1 }, (_, j) => j); for (let i = 1; i <= word1.length; i++) { const curr = new Array(n + 1); curr[0] = i; for (let j = 1; j <= n; j++) { curr[j] = word1[i - 1] === word2[j - 1] ? prev[j - 1] : 1 + Math.min(prev[j - 1], prev[j], curr[j - 1]); } prev = curr; } return prev[n];}console.log(minDistance("horse", "ros")); // 3console.log(minDistance("intention", "execution")); // 5console.log(minDistance("", "a")); // 1The time is the same O(m × n), and the memory is O(n).
The full table is the easiest to explain. You can point at the three neighbours and name the change each one stands for. Offer the two-row version as the way to save memory. (LeetCode 72.)
Given a string s, return the length of its longest palindromic subsequence (the characters stay in order, and gaps are allowed).
Hint 1
Hint 2
s[i..j]. What do the two end characters tell you if they are equal? What if they are different?Show all 2 solutions with explanations
A subsequence of s that is also a subsequence of reverse(s) reads the same in both directions. So run the LCS table on the string and its reverse.
function longestPalindromeSubseq(s) { const t = s.split("").reverse().join(""); const n = s.length; const dp = Array.from({ length: n + 1 }, () => new Array(n + 1).fill(0)); for (let i = 1; i <= n; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = s[i - 1] === t[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); } } return dp[n][n];}console.log(longestPalindromeSubseq("bbbab")); // 4console.log(longestPalindromeSubseq("cbbd")); // 2console.log(longestPalindromeSubseq("a")); // 1O(n²) time and memory. It reuses the last problem and is hard to get wrong. That makes it a good first answer when you feel nervous.
dp[i][j] is the answer for the slice s[i..j]. If the ends are equal, add 2 to the answer for the inside. If they are different, drop one end. Loop i downwards, so dp[i+1] is ready when you need it.
function longestPalindromeSubseq(s) { const n = s.length; const dp = Array.from({ length: n }, () => new Array(n).fill(0)); for (let i = n - 1; i >= 0; i--) { dp[i][i] = 1; for (let j = i + 1; j < n; j++) { if (s[i] === s[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];}console.log(longestPalindromeSubseq("bbbab")); // 4console.log(longestPalindromeSubseq("cbbd")); // 2console.log(longestPalindromeSubseq("a")); // 1When j = i + 1 and the characters match, dp[i+1][j-1] is dp[i+1][i]. That is an empty slice, and it is still 0 because the table started full of zeros. O(n²) time and memory.
Both take O(n²). The reverse trick is quicker to write. The interval version shows that you know a pattern that appears in many other problems. (LeetCode 516.)
Given an integer array nums, return the length of the longest strictly increasing subsequence (each number is bigger than the one before). Aim for O(n log n).
Hint 1
Hint 2
tails[k]: the smallest last value of any increasing subsequence with length k + 1. Is that array sorted?Hint 3
>= it, and replace that slot. If there is none, add the number at the end.Show all 2 solutions with explanations
best[i] is the length of the longest increasing subsequence that ends at index i. It is 1 plus the best value among all earlier smaller numbers.
function lengthOfLIS(nums) { const best = new Array(nums.length).fill(1); let answer = 0; for (let i = 0; i < nums.length; i++) { for (let j = 0; j < i; j++) { if (nums[j] < nums[i]) best[i] = Math.max(best[i], best[j] + 1); } answer = Math.max(answer, best[i]); } return answer;}console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 4console.log(lengthOfLIS([7, 7, 7, 7])); // 1Two loops inside each other over n elements give O(n²) time and O(n) memory. This is fine for n up to a couple of thousand, but too slow for more.
Keep a sorted tails array. For each number, use binary search (check the middle again and again) to find the first tail that is at least as large, and replace it. If there is none, add the number at the end. The final length is the answer.
function lengthOfLIS(nums) { const tails = []; for (const x of nums) { let lo = 0, hi = tails.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (tails[mid] < x) lo = mid + 1; else hi = mid; } tails[lo] = x; } return tails.length;}console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 4console.log(lengthOfLIS([7, 7, 7, 7])); // 1Replacing a tail never changes how long the subsequences are. It only makes them easier to extend later. Each number costs one O(log n) binary search, so the total is O(n log n) time and O(n) memory. Remember that tails is not itself a valid subsequence. Only its length is the answer.
If you are unsure, give the O(n²) DP first. Then offer the tails method as the faster version. If the interviewer asks for the subsequence itself, the O(n²) DP is simpler to explain. It remembers which earlier element each one came from. (LeetCode 300.)
Given two strings word1 and word2, return the smallest number of one-character deletions (from either string) needed to make the two strings equal.
Hint 1
Hint 2
Show all 2 solutions with explanations
Whatever is left must be a common subsequence. Keep the longest one and delete everything else from both strings: m + n - 2 × LCS.
function minDistance(word1, word2) { const m = word1.length, n = word2.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = word1[i - 1] === word2[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); } } return m + n - 2 * dp[m][n];}console.log(minDistance("sea", "eat")); // 2console.log(minDistance("leetcode", "etco")); // 4console.log(minDistance("abc", "abc")); // 0O(m × n) time and memory. Each deleted character is in one of the strings but not in the kept subsequence. So the total number of deletions is (m - L) + (n - L), where L is the LCS length.
The table has the same shape as edit distance, but only deletions are allowed. A match is free. A mismatch costs one deletion from either side. The borders are i and j.
function minDistance(word1, word2) { const m = word1.length, n = word2.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (word1[i - 1] === word2[j - 1]) dp[i][j] = dp[i - 1][j - 1]; else dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1]); } } return dp[m][n];}console.log(minDistance("sea", "eat")); // 2console.log(minDistance("leetcode", "etco")); // 4console.log(minDistance("abc", "abc")); // 0This is edit distance without the replace move. It gives the same answers as the LCS formula, in the same O(m × n) time.
The LCS formula is one line once you see it. Seeing the link is the point of the question. The direct DP is a good back-up if you do not see the link. (LeetCode 583.)
Given a string s, return its longest palindromic substring (one unbroken piece). If several have the same length, any of them is accepted.
Hint 1
Hint 2
Hint 3
dp[i][j] = "is s[i..j] a palindrome?" The answer depends on the two ends and on dp[i+1][j-1].Show all 3 solutions with explanations
Try every pair of start and end positions. Check if each piece is a palindrome. Keep the longest one.
function longestPalindrome(s) { let best = ""; for (let i = 0; i < s.length; i++) { for (let j = i; j < s.length; j++) { if (j - i + 1 <= best.length) continue; let l = i, r = j, ok = true; while (l < r) { if (s[l++] !== s[r--]) { ok = false; break; } } if (ok) best = s.slice(i, j + 1); } } return best;}console.log(longestPalindrome("babad")); // babconsole.log(longestPalindrome("cbbd")); // bbThere are O(n²) substrings and each check costs O(n), so the total is O(n³). Mention it to show you have a starting point, then improve it.
A palindrome is a mirror around its centre. The centre is one character for odd length, or the gap between two characters for even length. That gives 2n - 1 centres. Grow outwards from each centre while both sides match.
function longestPalindrome(s) { let start = 0, bestLen = 0; function expand(l, r) { while (l >= 0 && r < s.length && s[l] === s[r]) { l--; r++; } const len = r - l - 1; // the loop overshot by one on each side if (len > bestLen) { bestLen = len; start = l + 1; } } for (let i = 0; i < s.length; i++) { expand(i, i); // odd length, centre at i expand(i, i + 1); // even length, centre between i and i + 1 } return s.slice(start, start + bestLen);}console.log(longestPalindrome("babad")); // babconsole.log(longestPalindrome("cbbd")); // bbconsole.log(longestPalindrome("a")); // aEach expansion takes O(n) in the worst case (a string like "aaaa…"), and there are 2n - 1 of them. So the time is O(n²) and the extra memory is O(1).
isPal[i][j] is true when s[i] === s[j] and the inside piece s[i+1..j-1] is a palindrome (or has at most one character). Fill the table from the bottom row upwards.
function longestPalindrome(s) { const n = s.length; const isPal = Array.from({ length: n }, () => new Array(n).fill(false)); let start = 0, bestLen = 1; for (let i = n - 1; i >= 0; i--) { for (let j = i; j < n; j++) { if (s[i] === s[j] && (j - i < 2 || isPal[i + 1][j - 1])) { isPal[i][j] = true; if (j - i + 1 > bestLen) { bestLen = j - i + 1; start = i; } } } } return s.slice(start, start + bestLen);}console.log(longestPalindrome("babad")); // aba ("bab" is equally valid)console.log(longestPalindrome("cbbd")); // bbconsole.log(longestPalindrome("a")); // aO(n²) time and O(n²) memory. An interviewer may expect this DP. But it uses more memory than expanding around centres, for the same time.
Prefer expanding around centres. It takes O(n²) time, O(1) memory, and the code is short. Know the DP version too, because the idea works for other problems. (Manacher's algorithm reaches O(n), but interviewers rarely expect it.) (LeetCode 5.)
Given a string s, return the number of palindromic substrings in it. Substrings with different start or end positions count separately, even if the text is the same.
Hint 1
Hint 2
Show all 2 solutions with explanations
For every odd-length and even-length centre, grow outwards. Add one to the count each time the two sides match.
function countSubstrings(s) { let count = 0; function expand(l, r) { while (l >= 0 && r < s.length && s[l] === s[r]) { count++; // s[l..r] is a palindrome l--; r++; } } for (let i = 0; i < s.length; i++) { expand(i, i); expand(i, i + 1); } return count;}console.log(countSubstrings("abc")); // 3console.log(countSubstrings("aaa")); // 6Every palindromic substring has exactly one centre, so each one is counted exactly once. O(n²) time, O(1) memory.
Use the same isPal[i][j] table as before. Count the cells that are true.
function countSubstrings(s) { const n = s.length; const isPal = Array.from({ length: n }, () => new Array(n).fill(false)); let count = 0; for (let i = n - 1; i >= 0; i--) { for (let j = i; j < n; j++) { if (s[i] === s[j] && (j - i < 2 || isPal[i + 1][j - 1])) { isPal[i][j] = true; count++; } } } return count;}console.log(countSubstrings("abc")); // 3console.log(countSubstrings("aaa")); // 6O(n²) time and O(n²) memory. Each cell decides one substring.
Expanding around centres uses less memory and is shorter. This question is the counting twin of LeetCode 5, so if you solve one you can solve the other. (LeetCode 647.)
Make it stick
- Write the LCS rule from memory. Include what the border row and column hold.
- Walk back through an LCS table to get the string. Say why you reverse it at the end.
- Match insert, delete and replace to the three neighbouring cells in edit distance.
- Explain two ways to find the longest palindromic subsequence.
- Explain what
tails[k]means in the O(n log n) LIS. Say why tails is not the subsequence. - Say how a subsequence is different from a substring.
Revisit schedule: solve two of the questions again tomorrow, again in 3 days, and once more in a week — without looking at the answers. Each time it will feel easier. That is the memory forming.
What's next
That finishes dynamic programming. Lesson 58 starts the last part with two small toolkits that interviews often use. One is the trie, a tree made for working with the starts of words (prefixes). The other is bit manipulation, which means small tricks that work on the binary form of numbers (the 0s and 1s a computer uses). After that, Lesson 59 shows how to run the interview itself.
Practise on LeetCode
Real interview problems that use exactly what this lesson teaches. Solve them in JavaScript after the practice questions above.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series