Skip to the chapter
←DSAGGroundworkΣDSA
DSA/Intermediate/I10
22 / 48 written chapters
/

Levels and plan
  1. B1Complexity analysis
  2. B2JavaScript toolkit
  3. B3Arrays & strings
  4. B4Prefix sums
  5. B5Hashing
  6. B6Two pointers
  7. B7Sliding window
  8. B8Binary search
  9. B9Basic recursion
  10. B10Sorting algorithms
  11. B11Stacks & queues
  12. B12Linked lists
  1. I1Trees
  2. I2BST operations
  3. I3Tree problems in depth
  4. I4Heaps & priority queues
  5. I5Graphs: representation
  6. I6BFS on grids
  7. I7Graph problems
  8. I8Topological patterns
  9. I9Backtracking
  10. I10DP: 1D
  11. I11DP: 2D
  12. I12DP state machines
  13. I13Greedy algorithms
  14. I14Intervals
  15. I15Bit manipulation
  16. I16Matrix problems
  17. I17Tries
  18. I18Monotonic stack & queue
  19. I19Math for interviews
  20. I20Design & simulation
  21. I21Divide and conquer
  22. I22Randomization
  1. A1Advanced DP
  2. A2Union-Find
  3. A3Advanced graph algorithms
  4. A4Minimum Spanning Tree
  5. A5Advanced graphs
  6. A6Segment & Fenwick trees
  7. A7Sparse tables
  8. A8String algorithms
  9. A9Design problems
  10. A10Advanced backtracking
  11. A11Games & probability DP
  12. A12Ordered structures
  13. A13Sweep line & geometry
  14. A14Interview strategy

ΣI10 · Part 2 · Intermediate

Dynamic programming: 1D

Recursion, minus the part where you solve the same subproblem twice.

29 min read2 diagrams20 sections17 exercises
22 of 48

In this chapter

  • The problem DP exists to fix, drawn out
  • Top-down (memoization) — recursion, plus a cache
  • Bottom-up (tabulation) — build the table forward, no recursion at all
  • The general recipe — the four questions every 1D DP answers
  • Climbing Stairs and Min Cost Climbing Stairs

and 15 more

about 29 min
20On this page
  1. The problem DP exists to fix, drawn out
  2. Top-down (memoization) — recursion, plus a cache
  3. Bottom-up (tabulation) — build the table forward, no recursion at all
  4. The general recipe — the four questions every 1D DP answers
  5. Climbing Stairs and Min Cost Climbing Stairs
  6. Worked example: House Robber
  7. House Robber II — a circle is two lines
  8. Decode Ways — counting with two look-backs
  9. Word Break — does a prefix split into words?
  10. Worked example: Longest Increasing Subsequence
  11. LIS in O(n log n) — the tails array
  12. Two meanings of dp[i], and Kadane's two states
  13. Worked example: Coin Change — minimum coins to make an amount
  14. Perfect Squares — coin change where the coins are squares
  15. Coin Change II — the loop order decides what you count
  16. Partition Equal Subset Sum — the backwards loop
  17. Palindromic substrings — expand around every centre
  18. Triangle — let the rows be the table
  19. A top-down template you can write on a whiteboard
  20. Recognizing it in an unseen problem

The problem DP exists to fix, drawn out

Plain recursion on overlapping subproblems redoes the same work exponentially many times. Watch fib(5) expand:

fib(5) fib(4) fib(3) fib(3) fib(2) fib(2) fib(1) fib(2) fib(1) fib(1) fib(0) fib(2) is computed 3 separate times, fib(3) twice — pure waste, same inputs every time
Every red node is a repeat of work already done elsewhere in the tree.

DP is exactly one idea: cache the result of each distinct subproblem the first time you compute it, and look it up instead of recomputing it every other time. That's it — the rest is just two different ways of organizing that cache.

Top-down (memoization) — recursion, plus a cache

function fib(n, memo = new Map()) {
  if (n <= 1) return n;
  if (memo.has(n)) return memo.get(n); // seen this exact input before — reuse it

  const result = fib(n - 1, memo) + fib(n - 2, memo);
  memo.set(n, result);
  return result;
}

This turns the tree above from O(2ⁿ) into O(n) — there are only n distinct subproblems (fib(0) through fib(n)), and each one is now computed exactly once.

Bottom-up (tabulation) — build the table forward, no recursion at all

0 1 1 2 3 dp[4] = dp[3] + dp[2] needs only the last two
No recursion, no call stack — just an array filled in order, left to right.
function fibBottomUp(n) {
  if (n <= 1) return n;
  const dp = new Array(n + 1);
  dp[0] = 0;
  dp[1] = 1;
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}

// space-optimized: fib only ever needs the last 2 values — O(1) space
function fibOptimized(n) {
  if (n <= 1) return n;
  let prev2 = 0, prev1 = 1;
  for (let i = 2; i <= n; i++) {
    [prev2, prev1] = [prev1, prev1 + prev2];
  }
  return prev1;
}
TimeSpaceNotes
Naive recursionO(2ⁿ)O(n) — call stackrecomputes everything
Top-down (memo)O(n)O(n) memo + O(n) stackeasiest to write from the recursive version
Bottom-up (table)O(n)O(n)no recursion overhead
Bottom-up, optimizedO(n)O(1)only when each state needs a fixed, small window of previous states

The general recipe — the four questions every 1D DP answers

  1. What does dp[i] mean? — state it in one sentence before writing any code. ("dp[i] = the max sum of a subarray ending exactly at i.")
  2. What's the recurrence? — how does dp[i] relate to earlier states?
  3. What's the base case? — the smallest i you can answer directly.
  4. What order do you fill it in? — usually left to right, since dp[i] needs earlier values.

Climbing Stairs and Min Cost Climbing Stairs

You climb a staircase of n steps, taking 1 or 2 steps at a time. In how many different sequences of moves do you reach the top? Start from the last move. To stand on step i you came from step i - 1 (with a 1-step) or from step i - 2 (with a 2-step), and those are the only two ways. So ways[i] = ways[i - 1] + ways[i - 2], with ways[1] = 1 and ways[2] = 2. This is the Fibonacci recurrence from the start of the chapter wearing a costume, and it has the same O(n) time with O(1) space once you keep only the last two values.

The version with a price is where the four questions earn their keep. Each step i has a cost cost[i] that you pay when you leave it, you may start on step 0 or step 1, and the top is one past the last index. State it in one sentence first: best[i] is the cheapest total to be standing on step i, where step n is the top. Then best[0] = best[1] = 0 (you start there for free) and each later step is reached from one of the two steps below by paying for the step you leave.

function climbStairs(n) {
  if (n <= 2) return n;
  let twoBack = 1, oneBack = 2; // ways to reach step 1 and step 2
  for (let step = 3; step <= n; step++) {
    [twoBack, oneBack] = [oneBack, oneBack + twoBack]; // last move was a 1-step or a 2-step
  }
  return oneBack;
}

function minCostClimbingStairs(cost) {
  const n = cost.length;
  const best = new Array(n + 1).fill(0); // best[i] = cheapest way to STAND on step i; the top is step n
  for (let i = 2; i <= n; i++) {
    best[i] = Math.min(best[i - 1] + cost[i - 1], best[i - 2] + cost[i - 2]); // pay for the step you leave
  }
  return best[n];
}

Dry run: minCostClimbingStairs([10, 15, 20])

ibest[i]Why
0, 10, 0you may start on either step without paying
210min(best[1] + 15, best[0] + 10) = min(15, 10): leave step 0 with a 2-step
3 (top)15min(best[2] + 20, best[1] + 15) = min(30, 15): leave step 1 with a 2-step

The answer is 15: start on step 1, pay 15, jump over step 2 to the top. Both functions are O(n) time, and the second one can be reduced to O(1) space the same way as Fibonacci.

⚠ Off-by-one at the top, and numbers that stop being exact The top is index n, not n - 1: you must get past the last step, so the answer is best[n], not the cost of standing on the last step. Pay the cost of the step you leave, not the step you land on, or every answer is shifted. And for counting, the answers are Fibonacci numbers, so they grow fast: as ordinary JavaScript numbers they stop being exact at n = 78, where the count passes 2⁵³ (about 9 × 10¹⁵). When a problem asks for a count modulo something, take the remainder inside the loop; if it asks for the exact value of a large n, use BigInt.

Worked example: House Robber

Can't rob two adjacent houses. At each house, you either skip it (carry forward the best so far) or rob it (best from two houses back, plus this house's value).

function rob(nums) {
  let prevSkip = 0, prevTake = 0; // best up to i-2, best up to i-1
  for (const val of nums) {
    const curr = Math.max(prevTake, prevSkip + val); // skip this OR rob this
    prevSkip = prevTake;
    prevTake = curr;
  }
  return prevTake;
}

Notice the state definition again: "best total up to and including house i." Once that's pinned down precisely, the recurrence (dp[i] = max(dp[i-1], dp[i-2] + nums[i])) falls out directly from re-reading the problem statement.

House Robber II — a circle is two lines

Now the houses stand in a circle, so the first and the last are neighbours and cannot both be robbed. Do not invent a new recurrence. Ask which of the two houses is not robbed. Either the last house is left alone, and the houses 0..n - 2 form an ordinary line, or the first house is left alone, and 1..n - 1 form one. Any valid plan belongs to at least one of the two cases, and each case is the problem you already solved, so the answer is the larger of the two.

function robLine(nums, lo, hi) { // best total from nums[lo..hi], no two adjacent
  let skip = 0, take = 0;
  for (let i = lo; i <= hi; i++) {
    [skip, take] = [take, Math.max(take, skip + nums[i])];
  }
  return take;
}

function robCircle(nums) {
  const n = nums.length;
  if (n === 1) return nums[0]; // the two ranges below would both be empty
  return Math.max(robLine(nums, 0, n - 2), robLine(nums, 1, n - 1)); // skip the last, or skip the first
}

For [2, 3, 2] the first line [2, 3] gives 3, the second line [3, 2] gives 3, and the answer is 3. Robbing both 2s would give 4 on a line, which a circle forbids. For [1, 2, 3, 1] the lines [1, 2, 3] and [2, 3, 1] give 4 and 3, so the answer is 4. Time is O(n) with O(1) space: two passes over the array.

⚠ One house breaks the split With n = 1 the ranges 0..-1 and 1..0 are both empty and the formula returns 0, but the answer is nums[0]. Handle it before the split. Also pass index bounds to the helper rather than calling slice, which copies the array twice and does nothing the bounds do not already do.

This move, "break the circle by deciding one element's fate", is a reusable idea: circular arrays turn into two linear problems in many places, not only here.

Decode Ways — counting with two look-backs

The letters A to Z are encoded as 1 to 26. A string of digits can be read in several ways: "226" can be 2-2-6 (BBF), 22-6 (VF) or 2-26 (BZ). Count the readings. Define ways[i] as the number of ways to decode the first i characters. The last letter of any reading uses either the last one character or the last two, so there are exactly two cases to add:

  • The last character alone is a letter if it is 1 to 9, and then it adds ways[i - 1]. A 0 cannot stand alone and adds nothing.
  • The last two characters together are a letter if they read 10 to 26, and then they add ways[i - 2].

The empty prefix has one way (read nothing), so ways[0] = 1, and the first character gives 1 if it is not "0" and 0 otherwise.

function numDecodings(s) {
  const n = s.length;
  if (n === 0) return 0;
  let twoBack = 1; // ways to decode the first i - 2 characters (the empty prefix has 1 way)
  let oneBack = s[0] === "0" ? 0 : 1; // ways to decode the first i - 1 characters
  for (let i = 2; i <= n; i++) {
    let ways = 0;
    if (s[i - 1] !== "0") ways += oneBack; // the last character alone: 1..9
    const pair = Number(s.slice(i - 2, i));
    if (pair >= 10 && pair <= 26) ways += twoBack; // the last two together: 10..26
    twoBack = oneBack;
    oneBack = ways;
  }
  return oneBack;
}

Dry run: "226" and "206"

StringiLast char aloneLast two charsways[i]
"226"1"2" is fine—1
2"2": adds ways[1] = 122 is in 10..26: adds ways[0] = 12
3"6": adds ways[2] = 226 is in range: adds ways[1] = 13
"206"1"2" is fine—1
2"0" cannot stand alone: adds 020 is in range: adds ways[0] = 11
3"6": adds ways[2] = 106 is not in 10..26: adds 01

"206" has exactly one reading, 20-6. The two-value window keeps the space at O(1), and the time is O(n).

⚠ The zero cases are the whole problem "06" has no valid reading (a 0 cannot start a letter), "100" has none either (the last 0 needs a 1 or 2 in front of it, and that one is already used by the previous 0), and "10" has exactly one. The trap is testing the two-character case with a string comparison or a range like pair <= 26 without the lower bound: "06" is 6 as a number but is not a valid pair. The lower bound of 10 is what rejects it. The empty string returns 0.

Word Break — does a prefix split into words?

Given a string s and a dictionary, can s be cut into a sequence of dictionary words? Define ok[i] as whether the first i characters can be split. The last word in any split is some s[j..i - 1], and what is in front of it must itself be a splittable prefix. So ok[i] is true when some j has ok[j] true and s[j..i - 1] in the dictionary. The empty prefix is splittable: ok[0] = true.

function wordBreak(s, wordDict) {
  const words = new Set(wordDict);
  const maxLen = Math.max(0, ...wordDict.map((w) => w.length));
  const ok = new Array(s.length + 1).fill(false); // ok[i] = the first i characters can be split into words
  ok[0] = true; // the empty prefix needs no words

  for (let i = 1; i <= s.length; i++) {
    for (let j = Math.max(0, i - maxLen); j < i; j++) { // the last word is s[j..i-1], at most maxLen long
      if (ok[j] && words.has(s.slice(j, i))) {
        ok[i] = true;
        break;
      }
    }
  }
  return ok[s.length];
}

Dry run: s = "leetcode", dictionary ["leet", "code"]

iPrefixFoundok[i]
0""base casetrue
1 to 3"l", "le", "lee"no dictionary word ends here with a splittable startfalse
4"leet""leet" starts at j = 0 and ok[0] is truetrue
5 to 7"leetc" ... "leetcod"no word, and "c", "co", "cod" are not wordsfalse
8"leetcode""code" starts at j = 4 and ok[4] is truetrue

The inner loop only looks back maxLen characters, because no word is longer than that. With L the length of the longest word, the loops make O(n · L) substring checks, and each check builds a string of up to L characters and hashes it, so the time is O(n · L²). Space is O(n) plus the dictionary. Without the bound the same code is O(n³) in the worst case.

⚠ Greedy longest-match fails Taking the longest word at each position is tempting and wrong. With s = "abcd" and the dictionary ["ab", "abc", "cd"] greedy takes "abc" and is stuck on "d", while "ab" + "cd" works. The table is correct because it keeps every splittable prefix, not just the longest. Two smaller traps: put the words in a Set (an array's includes makes every check linear in the dictionary), and break out of the loop at the first success, since one split is enough. If the question asks you to list the splits, that is Word Break II and it becomes memoized backtracking; see the advanced backtracking chapter.

Worked example: Longest Increasing Subsequence

// O(n²) — dp[i] = length of the longest increasing subsequence ENDING at i
function lengthOfLIS(nums) {
  if (nums.length === 0) return 0;
  const dp = new Array(nums.length).fill(1); // every element alone is a subsequence of length 1
  let best = 1;
  for (let i = 1; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j] + 1);
    }
    best = Math.max(best, dp[i]);
  }
  return best;
}

There's an O(n log n) version worth knowing exists, even if the O(n²) is your first answer: maintain an array tails where tails[k] is the smallest possible "tail value" of an increasing subsequence of length k+1, and binary search (from the earlier chapter) for where each new number belongs. The length of tails at the end is the answer — a nice example of two earlier patterns (DP + binary search) combining.

LIS in O(n log n) — the tails array

The O(n²) table asks, for every i, a question about every j < i. The faster version flips the bookkeeping. Keep tails, where tails[k] is the smallest possible last value of an increasing subsequence of length k + 1 among the elements seen so far. Why the smallest? Because for extending, a smaller tail is never worse: anything that can follow a tail of 7 can follow a tail of 3.

Two facts make it work. First, tails is always strictly increasing: if a subsequence of length 3 ends at 5, then one of length 2 ends at something smaller than 5, namely its own second-to-last value. Because it is sorted, a binary search can place each new number. Second, a new number x does one of two things. If it is larger than every tail, it extends the longest run and is appended. Otherwise it replaces the first tail that is greater than or equal to it, which lowers that length's best tail without changing any length.

function lengthOfLISFast(nums) {
  const tails = []; // tails[k] = smallest tail of any increasing subsequence of length k + 1
  for (const x of nums) {
    let lo = 0, hi = tails.length;
    while (lo < hi) { // first index whose tail is >= x
      const mid = (lo + hi) >> 1;
      if (tails[mid] < x) lo = mid + 1;
      else hi = mid;
    }
    tails[lo] = x; // extend the longest run, or lower an existing tail
  }
  return tails.length;
}

Dry run: [10, 9, 2, 5, 3, 7, 101, 18]

xSlot foundtails after
100 (empty)[10]
90: replaces 10[9]
20: replaces 9[2]
51: appended[2, 5]
31: replaces 5[2, 3]
72: appended[2, 3, 7]
1013: appended[2, 3, 7, 101]
183: replaces 101[2, 3, 7, 18]

The answer is the final length, 4. Time is O(n log n), one binary search per element, and space is O(n).

⚠ tails is not the subsequence, and the comparison decides "strictly" For [4, 5, 1] the final tails is [1, 5], yet 1 comes after 5 in the input, so that array is not a subsequence of it (the real longest ones are [4, 5]): tails is a record of best tails, not of one real subsequence. If you need the subsequence itself, store the index at each slot plus a parent link per element. For strictly increasing sequences, find the first tail greater than or equal to x (the tails[mid] < x test above). For non-decreasing sequences, find the first tail strictly greater than x instead; with the wrong one, equal values are counted as increases or are dropped.

Two meanings of dp[i], and Kadane's two states

Look at the state definitions used so far, because they come in two flavours, and mixing them up is the commonest reason a recurrence will not close.

Meaning of dp[i]ProblemsThe answer is
the answer for the first i itemsHouse Robber, Coin Change, Word Break, Decode Waysthe last cell, dp[n] (or dp[amount])
the best subsequence or subarray ending at iLIS, Maximum Subarray, Maximum Product Subarraythe best value over all cells

A "first i items" cell is a complete answer about that whole prefix, so the last cell already answers the whole question. An "ending at i" cell is forced to include item i, so the best solution can end anywhere and you must scan every cell.

Maximum Subarray (Kadane's algorithm) is the cleanest example of the second flavour. Let endingHere be the best sum of a non-empty subarray that ends at i. That subarray either is just nums[i] or extends the best subarray that ended at i - 1, so endingHere = max(nums[i], endingHere + nums[i]), and the answer is the largest endingHere seen. For [1, -3, 4, -1, 2, 1, -5, 4] the running values of endingHere are 1, -2, 4, 3, 5, 6, 1, 5, and the best is 6 from [4, -1, 2, 1].

Maximum Product Subarray looks the same and is not, because a negative number turns the smallest product into the largest. One state is therefore not enough. Keep both the largest and the smallest product of a subarray ending at i, and at each step take the best and worst of three candidates: start fresh at x, extend the maximum, or extend the minimum.

function maxSubArray(nums) {
  let endingHere = nums[0], best = nums[0]; // endingHere = best sum of a subarray ending at i
  for (let i = 1; i < nums.length; i++) {
    endingHere = Math.max(nums[i], endingHere + nums[i]); // extend the previous run, or start fresh
    best = Math.max(best, endingHere);
  }
  return best;
}

function maxProduct(nums) {
  let hi = nums[0], lo = nums[0], best = nums[0]; // largest and smallest product ending at i
  for (let i = 1; i < nums.length; i++) {
    const x = nums[i];
    const candidates = [x, hi * x, lo * x]; // start fresh, extend the max, extend the min
    hi = Math.max(...candidates);
    lo = Math.min(...candidates);
    best = Math.max(best, hi);
  }
  return best;
}

Dry run: maxProduct([-2, 3, -4])

xCandidates (x, hi · x, lo · x)hilobest
start—-2-2-2
33, -6, -63-63
-4-4, -12, 2424-1224

The 24 comes from lo · x, the earlier minimum times a negative: without the second state the answer would be 3. Both functions are O(n) time and O(1) space. A zero is handled by the "start fresh" candidate, which resets both values.

⚠ Initialise from the first element, not from 0 Starting best at 0 returns 0 for [-3, -1], where the right answer is -1: the subarray must be non-empty. Start from nums[0] and loop from 1. For the product, compute all three candidates from the old hi and lo before overwriting either, as the code does by building the candidate array first; updating hi and then using it to update lo corrupts the minimum.

Worked example: Coin Change — minimum coins to make an amount

Given coin denominations and a target amount, find the fewest coins that sum to it (or report it's impossible). This is the DP counterpart to the greedy "make change" instinct — and greedy provably fails here for arbitrary denominations (try amount 6 with coins [1, 3, 4]: greedy picks 4+1+1 = 3 coins, but 3+3 = 2 coins is better).

function coinChange(coins, amount) {
  // dp[a] = fewest coins to make amount a. Infinity = "not yet known to be possible"
  const dp = new Array(amount + 1).fill(Infinity);
  dp[0] = 0; // base case: 0 coins needed to make amount 0

  for (let a = 1; a <= amount; a++) {
    for (const coin of coins) {
      if (coin <= a && dp[a - coin] !== Infinity) {
        dp[a] = Math.min(dp[a], dp[a - coin] + 1); // try using one of THIS coin
      }
    }
  }
  return dp[amount] === Infinity ? -1 : dp[amount];
}

Dry run: coinChange([1, 3, 4], 6) — the example above

adp[a]Why
00base case
11dp[0] + one 1-coin
22dp[1] + one 1-coin
31dp[0] + one 3-coin — beats dp[2] + one 1-coin (3)
41dp[0] + one 4-coin — beats dp[3] + one 1-coin (2)
52dp[4] + one 1-coin, ties dp[1] + one 4-coin
62dp[3] + one 3-coin — beats dp[2] + one 4-coin (3)

dp[6] = 2, matching 3 + 3. The loop tries every coin at every amount and keeps the minimum — dp[3] getting to 1 (via the 3-coin, not two 1-coins) is what later lets dp[6] reuse that cheap subproblem instead of building up from dp[2].

⚠ Why greedy fails here (and DP doesn't) Greedy commits to the biggest coin first and never reconsiders — but the best solution can require a smaller coin earlier to leave a better-divisible remainder. DP doesn't guess; it tries every coin at every amount and keeps whichever choice actually produces the minimum, which is exactly the guarantee greedy can't make without a proof.

Notice the loop order: for each amount, try every coin — this is "unbounded" DP (each coin can be reused any number of times), the exact same reuse idea as Combination Sum in the backtracking chapter, just solved by table-filling instead of exploring a tree.

Perfect Squares — coin change where the coins are squares

What is the fewest perfect squares (1, 4, 9, 16, ...) that add up to n? It is Coin Change with the coin set {1, 4, 9, ...} and unlimited use of each, and the same table applies: fewest[i] = 1 + min(fewest[i - r²]) over every square r² ≤ i, with fewest[0] = 0. Recognising that a problem is a known one in disguise is most of the skill here.

function numSquares(n) {
  const fewest = new Array(n + 1).fill(Infinity); // fewest[i] = fewest squares that sum to i
  fewest[0] = 0;
  for (let i = 1; i <= n; i++) {
    for (let root = 1; root * root <= i; root++) {
      fewest[i] = Math.min(fewest[i], fewest[i - root * root] + 1);
    }
  }
  return fewest[n];
}

Dry run: numSquares(12)

ifewest[i]Best last square
1, 2, 31, 2, 31 each time: 1+1, 1+1+1
414 itself, from fewest[0]
5, 6, 72, 3, 41 on top of fewest[4], fewest[5], fewest[6]
824 + 4, from fewest[4]
919 itself
10, 112, 31 on top of fewest[9], fewest[10]
1234 + 4 + 4, from fewest[8]

The answer 3 is 4 + 4 + 4; a greedy pick of the biggest square first would take 9 and then need three 1s, four squares. Time is O(n · √n), since each i tries at most √i squares, and space is O(n). A classical theorem says every positive integer is a sum of four squares, so the answer is never more than 4, which is a useful sanity check but not a reason to skip the table.

Coin Change II — the loop order decides what you count

Coin Change asked for the fewest coins. Coin Change II asks how many different combinations of coins sum to amount, where [1, 2] and [2, 1] are the same combination. The recurrence is again a sum, but now the order of the two loops changes the meaning of the answer.

function change(amount, coins) {
  const ways = new Array(amount + 1).fill(0);
  ways[0] = 1; // one way to make 0: take nothing
  for (const coin of coins) { // coins OUTSIDE: each combination is built in one coin order only
    for (let a = coin; a <= amount; a++) {
      ways[a] += ways[a - coin];
    }
  }
  return ways[amount];
}

function countOrderedWays(amount, coins) {
  const ways = new Array(amount + 1).fill(0);
  ways[0] = 1;
  for (let a = 1; a <= amount; a++) { // amounts OUTSIDE: every order of the same coins counts
    for (const coin of coins) {
      if (coin <= a) ways[a] += ways[a - coin];
    }
  }
  return ways[amount];
}

With coins on the outside and amounts inside, you finish deciding about one coin before the next one is introduced, so each combination is built in one fixed coin order: counted once. With amounts on the outside and coins inside, every amount asks "what was my last coin?" for every coin, so 1 + 2 and 2 + 1 both count: you are counting sequences. For amount = 5 and coins = [1, 2, 5] the coin-outside version gives 4 combinations (5, 2+2+1, 2+1+1+1, 1+1+1+1+1), and the swapped loops give 9 ordered sequences.

Dry run: ways[0..5] after each coin, coins [1, 2, 5]

After coinways[0..5]What changed
none1, 0, 0, 0, 0, 0only the empty selection makes 0
11, 1, 1, 1, 1, 1every amount has exactly one way with only 1s
21, 1, 2, 2, 3, 3ways[a] += ways[a - 2], reading values already updated for this coin
51, 1, 2, 2, 3, 4only ways[5] changes: 3 + ways[0]

Notice that the inner loop runs upward here, so ways[a - coin] already includes uses of the same coin: that is what lets a coin be reused. The full treatment of this family, including how the loop direction flips for 0/1 choices, and the other counting variants such as Target Sum, is in the DP state machines and the knapsack family chapter. Time is O(amount · coins) and space O(amount).

Partition Equal Subset Sum — the backwards loop

Can an array be split into two subsets with equal sums? If the total is odd, no. Otherwise it is asking whether some subset sums to total / 2, which is a yes-or-no cousin of the table above with one big difference: each element may be used at most once. Let reachable[s] mean "some subset of the items seen so far sums to s". Adding an item x makes s reachable if s - x was reachable before this item.

function canPartition(nums) {
  const total = nums.reduce((sum, x) => sum + x, 0);
  if (total % 2 !== 0) return false;
  const target = total / 2;
  const reachable = new Array(target + 1).fill(false); // reachable[s] = some subset sums to s
  reachable[0] = true;
  for (const x of nums) {
    for (let s = target; s >= x; s--) { // DOWNWARD: reachable[s - x] is still from BEFORE this item
      if (reachable[s - x]) reachable[s] = true;
    }
  }
  return reachable[target];
}

Dry run: [1, 5, 11, 5], target 11

After itemReachable sums (0 to 11)
10, 1
50, 1, 5, 6
110, 1, 5, 6, 11 (the target is already hit)
50, 1, 5, 6, 10, 11

11 is reachable (1 + 5 + 5, leaving 11 on the other side), so the answer is true. Time is O(n · total / 2) and space is O(total / 2). Note that this is pseudo-polynomial: it depends on the value of the sum, not only on the number of items.

⚠ The direction of the inner loop is the difference between 0/1 and unbounded Running s upward lets reachable[s - x] already contain the effect of the current item, so the item can feed itself, and you have silently switched to "unlimited copies". The array [2, 6] sums to 8 and the target is 4. The correct answer is false. An upward loop says true: it marks 2, then 4 from 2 + 2, using the single 2 twice. Downward, every read of s - x comes from a position not yet touched by this item. Coin Change II above runs upward on purpose; here it must run downward. The full knapsack treatment is in the DP state machines chapter.

Palindromic substrings — expand around every centre

Count the palindromic substrings of a string, or find the longest one. A palindrome is determined by its centre: grow outwards while the two ends match, and every step that succeeds is one more palindrome with the same centre. The centre is one of 2n - 1 places, n characters (odd lengths) and n - 1 gaps between characters (even lengths), and trying both kinds from every index covers them all.

function countSubstrings(s) {
  let count = 0;

  function expand(left, right) { // grow outwards while the ends match; each success is one palindrome
    while (left >= 0 && right < s.length && s[left] === s[right]) {
      count++;
      left--;
      right++;
    }
  }

  for (let center = 0; center < s.length; center++) {
    expand(center, center); // odd length: the centre is a character
    expand(center, center + 1); // even length: the centre is a gap
  }
  return count;
}

function longestPalindrome(s) {
  let bestStart = 0, bestLen = 0;

  function expand(left, right) {
    while (left >= 0 && right < s.length && s[left] === s[right]) {
      left--;
      right++;
    }
    const len = right - left - 1; // the loop stepped one past the palindrome on each side
    if (len > bestLen) {
      bestLen = len;
      bestStart = left + 1;
    }
  }

  for (let center = 0; center < s.length; center++) {
    expand(center, center);
    expand(center, center + 1);
  }
  return s.slice(bestStart, bestStart + bestLen);
}

Dry run: countSubstrings("aaa")

CentreExpansions that succeedPalindromes found
char 0"a"1
gap 0-1"aa"1
char 1"a", "aaa"2
gap 1-2"aa"1
char 2"a"1

Total 6 (three single letters, two "aa", one "aaa"). Time is O(n²): there are about 2n centres and each can expand O(n) steps, the worst case being a string of one repeated letter. Space is O(1) beyond the output. A different technique (Manacher's algorithm) does the same in O(n), but it is rarely asked for.

⚠ Forgetting the even centres, and an off-by-one in the length Expanding only from single characters misses "aa" and "abba". In longestPalindrome the loop stops one step past the palindrome on each side, so its length is right - left - 1 and it starts at left + 1. Writing right - left + 1 is the usual slip. Compare this with the table-based approach, which is also O(n²) but uses O(n²) memory.

Triangle — let the rows be the table

A triangle of numbers has one entry in row 0, two in row 1, and so on. You start at the top and move to one of the two entries just below, left or right. Find the smallest path sum from top to bottom. Going top-down, the choice at each entry sends you into overlapping paths, so work bottom-up: the cheapest path from an entry down to the bottom is the entry itself plus the smaller of the two cheapest paths from the entries below it. Start with the last row, where the cheapest path is the entry, and fold the rows upward into one array.

function minimumTotal(triangle) {
  const best = [...triangle[triangle.length - 1]]; // best[j] = cheapest path from the row below down to the bottom, via j
  for (let row = triangle.length - 2; row >= 0; row--) {
    for (let j = 0; j <= row; j++) {
      best[j] = triangle[row][j] + Math.min(best[j], best[j + 1]); // step down-left or down-right
    }
  }
  return best[0];
}

Dry run: [[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]]

Row processedbest array afterwardsHow
3 (copied)4, 1, 8, 3the cheapest path from a bottom entry is itself
27, 6, 106 + min(4, 1), 5 + min(1, 8), 7 + min(8, 3)
19, 103 + min(7, 6), 4 + min(6, 10)
0112 + min(9, 10)

The answer is 11 (2 + 3 + 5 + 1). Time is O(n²) for n rows, which is the number of entries, and the extra space is O(n), one row. Because the array is reused row after row, the loop over j must go left to right: best[j] is overwritten using best[j] and best[j + 1], and best[j + 1] has not been overwritten yet at that point. Going right to left would read an already updated value.

⚠ Copy the last row before overwriting it best = triangle[last] without the copy writes into the caller's data, so a second call, or a test that reuses the input, sees a changed triangle. Also check the reading of "adjacent": from index j you may go to j or j + 1 in the next row, never j - 1.

A top-down template you can write on a whiteboard

The bottom-up tables above need you to see the fill order. When you cannot see it, or only some subproblems are ever reached, write the recursive definition first and add a cache. The recipe is four lines: a function of the whole state, a base case, a cache lookup, and a store before returning. Here is Coin Change from earlier written that way, with the cache keyed by the remaining amount:

function coinChangeTopDown(coins, amount) {
  const memo = new Map(); // key: remaining amount; value: fewest coins, or Infinity if impossible

  function solve(remaining) {
    if (remaining === 0) return 0;
    if (remaining < 0) return Infinity;
    if (memo.has(remaining)) return memo.get(remaining); // has(), not a truthiness test: 0 is a real answer

    let best = Infinity;
    for (const coin of coins) best = Math.min(best, solve(remaining - coin) + 1);
    memo.set(remaining, best);
    return best;
  }

  const result = solve(amount);
  return result === Infinity ? -1 : result;
}

Both versions solve the same distinct subproblems, at most amount of them, each doing coins.length work, so the time is O(amount · coins) in both. The rule for any memoized recursion is states × work per state.

Top-down (memoize)Bottom-up (table)
Easiest to deriveyes, it is the recurrence as writtenneeds the fill order worked out
Visits only needed statesyesno, fills the whole table
Stack depthup to the number of levels, so thousands of frames can overflownone
Can drop old rows to save spaceharderyes, when only a window is needed
⚠ Four memo bugs that only some inputs expose An incomplete key. Everything the answer depends on must be in the key; a cache keyed by i alone is wrong if the answer also depends on a remaining budget. A falsy test. if (memo[x]) return memo[x] re-solves every state whose answer is 0 (or false, or an empty string); use has or compare with undefined. Forgetting to store. The function computes the right answer but never caches it, and it is silently exponential again. Depth. A recursion that goes one level per element overflows the stack for inputs in the tens of thousands of levels; convert to the table when the inputs are that large.
Say it like this → "I'll define dp[i] as the answer restricted to just the first i elements, figure out how dp[i] relates to smaller states, then either memoize the recursive version or build the table bottom-up — the state definition is the hard part, the loop that fills it in is almost mechanical once that's right."

Recognizing it in an unseen problem

  • "Maximum/minimum/number of ways to…" over a sequence
  • A brute-force recursive solution exists, but it's exponential because of repeated subproblems
  • The answer at position i can be expressed using answers at earlier positions
  • "Fewest/minimum number of coins/steps/jumps to reach X" with reusable choices → unbounded DP, same shape as Coin Change (Perfect Squares is Coin Change with square coins)
  • "Number of ways to reach / decode / split" where the last move is one of a few kinds → add the counts of the states each kind comes from (Climbing Stairs, Decode Ways)
  • "Can this prefix be split into pieces from a set?" → one boolean per prefix, looking back over the possible last piece (Word Break)
  • Circular input → decide which element is left out and solve two linear problems (House Robber II)
  • "Best subarray ending at i" where a negative or a zero changes everything → carry both the best and the worst value ending at i (Maximum Product Subarray)
  • Counting combinations versus counting sequences → the order of the two loops decides which one you get (Coin Change II); 0/1 choices want the inner loop to run backwards (Partition Equal Subset Sum)
  • If choices interact in only two dimensions (not "a sequence" but "a sequence + a budget," or two sequences compared against each other), that's the cue for 2D DP, next
Before you move on
  • State the four questions (meaning of dp[i], recurrence, base case, fill order) for a new 1D problem before writing any code.
  • Convert a memoized recursive solution into a bottom-up table, and then space-optimize it when only a fixed window of earlier states is needed.
  • Explain, with a concrete counterexample, why greedy fails on Coin Change but DP doesn't.
  • Recognize "unbounded" DP (a choice can repeat) versus "0/1" DP (each choice used at most once), and say what changes in the loop.
  • Given a new problem, decide in one sentence whether it's 1D DP, 2D DP, or not DP at all.
  • Say whether a state means "the answer for the first i items" or "the best one ending at i", and where the final answer is read from in each case.
  • Write House Robber II as two linear passes, Decode Ways with its zero cases, and Word Break with a bound on the word length.
  • Explain why Maximum Product Subarray needs two values per position, and why Coin Change II's loop order counts combinations while the swapped order counts sequences.
  • Explain why the 0/1 knapsack loop runs backwards, with the one-element counterexample for the forward loop.
  • Write the O(n log n) LIS with a binary search, and say why tails is not itself a subsequence.
  • Write a memoized recursion from the four lines of the template, and name the four ways a memo goes wrong.

17Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsClimbing Stairsbeginner
  • 8 testsHouse Robberintermediate
  • 8 testsHouse Robber IIintermediate
  • 8 testsCoin Changeintermediate
  • 8 testsCoin Change IIintermediate
  • 8 testsLongest Increasing Subsequenceintermediate
  • 8 testsWord Breakintermediate
  • 8 testsDecode Waysintermediate
  • 8 testsMaximum Product Subarrayintermediate
  • 8 testsPartition Equal Subset Sumintermediate
  • 8 testsMin Cost Climbing Stairsbeginner
  • 8 testsMaximum Subarrayintermediate
  • 8 testsPerfect Squaresintermediate
  • 8 testsTriangleintermediate
  • 8 testsPalindromic Substringsintermediate
  • 8 testsLongest Palindromic Substringintermediate
  • 9 testsLongest Increasing Subsequence in O(n log n)intermediate
All 318 for this level →

✓Check yourself

5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.

I10

Finished reading?

0 of 48 chapters read in this series

Mark as read

→Next stop

  1. I9Previous · I9Backtracking
  2. I10you are here
  3. Next · I11Dynamic programming: 2DSame idea as 1D, one more dimension — a grid table instead of a row.41 min read

01On this page

20 sections · 0%

  1. 01The problem DP exists to fix, drawn out
  2. 02Top-down (memoization) — recursion, plus a cache
  3. 03Bottom-up (tabulation) — build the table forward, no recursion at all
  4. 04The general recipe — the four questions every 1D DP answers
  5. 05Climbing Stairs and Min Cost Climbing Stairs
  6. 06Worked example: House Robber
  7. 07House Robber II — a circle is two lines
  8. 08Decode Ways — counting with two look-backs
  9. 09Word Break — does a prefix split into words?
  10. 10Worked example: Longest Increasing Subsequence
  11. 11LIS in O(n log n) — the tails array
  12. 12Two meanings of dp[i], and Kadane's two states
  13. 13Worked example: Coin Change — minimum coins to make an amount
  14. 14Perfect Squares — coin change where the coins are squares
  15. 15Coin Change II — the loop order decides what you count
  16. 16Partition Equal Subset Sum — the backwards loop
  17. 17Palindromic substrings — expand around every centre
  18. 18Triangle — let the rows be the table
  19. 19A top-down template you can write on a whiteboard
  20. 20Recognizing it in an unseen problem
Mark as read

[] chapters · t top