Dynamic Programming
Introduction
In the previous section, we explored greedy algorithms, strategies that build solutions by making locally optimal choices at each step. Greedy algorithms are often fast and simple, but they sometimes fail; some problems require us to consider multiple possible choices before we know which is best.
Dynamic programming (DP) breaks a problem into overlapping subproblems and solve each one only once, storing results for reuse. This caching/storing often reduces exponential time to polynomial. Unlike greedy algorithms, DP explores the full space of subproblem solutions rather than committing to a single choice at each step, making it more broadly applicable at the cost of additional time and space.
Types of Dynamic Programming
Top-down (memoization)
This form of DP starts at the “top”, recursively decomposing the problem until hitting the base cases. Results are cached/stored to avoid redundant computation. For example, calculating the th Fibonacci number begins with , which recurses into and , bottoming out at our base cases and .
Bottom-up (tabulation)
This form of DP starts at the "bottom", iteratively building up solutions to larger subproblems from smaller ones. We fill a table or array in order, so that each entry is already by the time we need it. For example, when calculating the th Fibonacci number, we start from the bases cases and , then iteratively compute for increasing until we reach .
General Framework
Dynamic programming encompasses a wide variety of problem types, but most can be solved by working through the same core questions. The following tips and techniques address each one.
1. Identify Subproblems and State
Often, one of the hardest parts of DP is figuring out what a "subproblem" looks like. You should ask yourself: what information do I need to fully describe the state of the problem at any point?
Some common subproblem structures include:
- Including/excluding an element: should I take element or not?
- Prefix/suffix: optimal solution on the first elements
- Interval: optimal solution on subarray
- Remaining capacity: items and budget/weight left (e.g. knapsack)
Once you've identified the subproblem structure, you should make it precise! Write a one/two sentence definition of ; this forces you to commit to exactly what the subproblem is optimizing, which makes the recurrence much easier to write correctly.
2. Write the recurrence relation and base case
After defining , you should ask yourself: how does relate to strictly smaller subproblems? This gives your recurrence. Don't forget to consider all cases (e.g. edge cases, zero capacity)!
Base cases are where the recurrence "bottoms out". You should ask yourself: what is the smallest valid input to your table, and what is the answer there by definition (not by recurrence)?
3. Computation Order
You should compute each subproblem only after all subproblem it depends on. You can determine the dependency direction from your reccurence. Think about which subproblems need to be solved first, and which ones depend on others.
4. Correctness
Prove correctness by strong induction on the subproblem "size" (however you have defined it in your solution). With the inductive structure, first verify that the smallest subproblems are corrctly computed with the base cases. After, assume is correct for all (inductive hypothesis). Show that the recurrence for produces the correct answer.
5. Runtime
You should analyze the runtime by considering the number of subproblems the work per subproblem. In addition, you should consider any precomputations done before filling in the table entries (e.g. computing prefix sums), as well as any steps required for solution reconstruction.
6. Solution Reconstruction
Often, the answer for your DP problem can be found by simply looking into an table value. Other times, you may need to iterate over your table and take the optimum across select entries. Another common pattern for solution reconstruction is backtracking; sometimes, we don't just care about the numerical value of our solution, but rather the steps (for example, after running Bellman-Ford, we might want to return the total cost AND the nodes along the least-cost path). To do this, you should:
- During the forward pass, store a point at each table entry that records which case the recurrence takes.
- Trace back from the final state following these pointers (backtracking from your final solution to recover the path).
Remember to include these steps in your runtime analysis!
Bellman-Ford
Introduction
Consider the single-source shortest path problem: given a weighted directed graph and a source node , find the shortest path from to every other vertex. Dijkstra's algorithm solves this greedily, but fails on graphs with negative edge weights. Consider the following graph, where we let our source node be .
If we use Dijkstra's algorithm to find the shortest path from to , we would greedily select the edge from to with weight and be done. However, we observe that the least cost path is (total cost ).
This failure of Dijkstra's algorithm motivates the Bellman-Ford algorithm, which handles negative edge weights by reformulating shortest paths as a DP problem. We add the constraint that the input graph has no negative weight cycles (otherwise there is no shortest path, as we could repeatedly take the cycle path to lower our overall cost).
The Algorithm
We first observe that any shortest path in the graph with nodes uses at most edges. A simple path cannot repeat vertices; a non-sample path would contain a cycle, which either has positive cost (and can be removed) or negative cost (contradicting our negative-cost cycle assumption).
We define the subproblem to be cost of the shortest path from source node to using at most edges. The previous observation gives us a finite bound on the number of edges any shortest path can use.
Our base cases are and for all (impossible to reach node using edges). For our recurrence, the shortest path can use at most edges (no change), or it arrives at via some edge using at most edges to reach :
To fill out the table, we can fill it out for each of the rounds, iterating through each node to update its minimum-path cost. We output for all as our final answer.
The table has dimensions , so there are cells to fill. The cost per cell isn't ; to compute , we take the minimum over all incoming edges to , which varies by vertex. If we sum the work across all vertices in a single round :
since each edge contributes one or two computations (depending on an undirected or directed graph) for nodes and . Since we drop constants, each round requires work, giving a total time complexity of .
Bellman-Ford DP Visualizer
Initialize base case
The base case sets the source distance to 0 before any edges are used.
dp[i][v] = min(dp[i - 1][v], min(dp[i - 1][u] + w(u, v)))Full DP table
Row 0 initializes s to 0 and every other entry to ∞. Later rows fill in as the simulation progresses.
| i | s | a | b | c |
|---|---|---|---|---|
| 0 | 0 | - | - | - |
| 1 | - | - | - | - |
| 2 | - | - | - | - |
| 3 | - | - | - | - |
Segmented Least Squares
Introduction
Given points in the plane, we often want to describe their trend by fitting a line. The classic least squares line has a clean closed form of minimizing the squared distance between points and the line, but a single line isn't always a good model: data that rises, levels off, then climbs again is captured far better by a sequence of line segments.
This creates a tradeoff. More segments always lower the total error (with point and segments connecting each consecutive pair of points, our squared error is zero), but such a fit just memorizes the data. We want few segments and low error at the same time. The segmented least squares problem formalizes and solves this balance.
For points and a line , the least squares error is the sum of squared vertical distances:
Setting the partials with respect to and to zero gives the minimizing line in closed form:
The main takeaway from this is that for any group of points, the minimum single-line error can be computed quickly.
We are given points with . A solution partitions them into contiguous segments, fitting one line per segment. Writing for the minimum single-line error on segment , and charging a fixed penalty per segment, the cost of a partition is
Design a polynomial time algorithm to find a partition minimizing this cost. Observe that having large favors a coarse fit, while small tolerates more segments. In addition, the brute force algorithm requires trying all partitions, which is far too slow.
The Algorithm
Let refer to point . Consider the last point . It sits in some segment, which (by contiguity) must be for some . Once we fix where that segment starts, the points form an independent instance of the same problem. That substructure gives the DP relation.
Let be the minimum single-line error on , and let be the optimal cost for the first points. We try every possible starting point for the final segment:
We fill in the table from , and output as our answer. To recover the segments, store the argmin at each and trace back from .
Precomputing all : there are pairs, and with prefix sums of , , , , each error is an lookup, so we take time for the precomputations. Filling the table costs per entry, which again across all OPT entries. Thus, the overall runtime of our algorithm is .
Knapsack Problem
Introduction
The knapsack problem is a classic dynamic programming example. We have items with weight and value with an overall weight constraint . We want to select some set of items such that the total weight and the total value is maximized.
We can't solve this greedily; taking the highest-value items or best value-to-weight ratio first can both lead to suboptimal solutions (often times a combination of smaller, lower-ratio items fits better and gives more total value). This forces us to consider some subsets; however, there are of them. Dynamic programming gives us a way to avoid this exponential blowup by identifying the right subproblem structure.
The Algorithm
We observe that deciding whether to include item is a binary choice: we either take it or leave it. If we take it, we use of our capacity and gain in value, leaving us with a smaller knapsack on the remaining items. If we choose not to take the item, the problem reduces to the same capacity with one fewer item to consider.
Thus, we let our be the max value possible with items and weight limit . For our base cases, we let for all and for all . Intuitively, a weight limit of zero leaves no room for any item, and considering zero items leaves nothing to select; in both cases, the achievable value is 0.
Using our observation, we have two cases to consider in our recurrence .
- Leave item : item is not included, so the optimal value is just , which is the best we can do with the first items and the same capacity.
- Take item : item is included, contributing to our value and consuming of our capacity. The remaining capacity must be allocated optimally among the first items, giving . This case is valid when .
Taking the better of the two choices, we have the recurrence:
To fill out the OPT table, we let our outer loop iterate from and inner loop iterate from (this way, each subproblem is computed only after and are already filled). After filling out the entire table, we output as our final answer.
The table has entries, each of which are filled in time using our recurrence (only 1 or 2 choices per table entry). Our total runtime is .
This is a pseudo-polynomial algorithm: it is polynomial in the numeric value of W, but W can be exponential in the number of bits needed to represent it. The algorithm is efficient when is small, but can be exponentially slow when given a large .
Practice
Problem 1. [Longest Common Subsequence] Given two lists of integers, find the length of their longest common subsequence (LCS). A subsequence is a new sequence derived from an existing sequence by deleting some or all of its elements without changing the relative order of the remaining elements. A common subsequence appears in both subsequences. You don't have to output the LCS, just its length.
Example:
- List A:
3 1 3 2 7 4 8 2 - List B:
6 5 1 2 3 4
The longest common subsequence of lists A and B is 1 2 4 (or 1 3 4), so the answer is 3.
Solution
We use dynamic programming to solve this question. Let the length of list be and the length of list be . We create an OPT table, where OPT[i][j] represents the longest common subsequence that can be formed using the sequences and .
For any or , let . We construct our OPT table using the following relation for , .
For our final answer, we output .
Here is a pseudocode implementation:
initialize (n + 1) x (m + 1) OPT table
Let OPT[n + 1][j] and OPT[i][m + 1] be 0 for all i, j.
for i = n down to 1:
for j = m down to 1:
OPT[i][j] = recurrence relation
output OPT[1][1]
Correctness:
Claim: equals the length of the longest common subsequence of and .
Proof: By induction on
Base case: When or , at least one of the sequences is empty, so the LCS has length .
Inductive Step: Let and , and assume the claim holds for all with . In particular, it holds for , , and .
Let denote the true length of the LCS of and .
Case 1: . We claim . By the inductive hypothesis, equals the LCS length of and , so is achievable by prepending the match . Conversely, any common subsequence of and has length at most , since after optionally matching with , the remainder is a common subsequence of and . Thus .
Case 2: . Since and cannot be matched, any common subsequence either excludes or excludes (or both). Therefore , where is the LCS length of and , and is the LCS length of and . By the inductive hypothesis, and , so .
In both cases, , completing the induction.
Runtime:
To find our solution, we fill in our sized OPT table. We fill out each entry using our recurrence relation, which takes time to evaluate. Thus, our overall runtime is .
Problem 2. [Lunch Selection] David loves sushi! Fortuantely, right outside of his house, there is a restaurant that sells both salmon and tuna rolls. Every day for lunch across days, he must choose to eat exactly one of the tuna or salmon rolls. He will gain a satisfaction reward for eating a salmon roll and for eating a tuna roll on day .
Unfortunately, the restaurant's salmon supply is limited, so David refuses to eat salmon on two consecutive days: if he eats a salmon roll on day , he MUST eat a tuna roll on day (there is no such restriction on tuna). Subject to this constraint, he wants to maximize his total satisfaction , where is his choice on day , , and .
Given an algorithm that takes as input and and outputs the maximum total satisfiaction David can achieve subject to the constraint above. Your algorithm should run in time.
Solution
Subproblems. Let be the max total satisfaction over days given that David eats salmon on day , and the max total satisfaction over days given that he eats tuna on day .
For our recurrence, note that eating salmon on day forces tuna on day :
Tuna on day has no restriction on day , so David could have consumed either salmon or tuna:
We initialize our DP table with base cases and . We fill the table by iterating from up to , and output as our final answer.
Correctness. Every valid choice sequence corresponds to exactly one path through these states, and each transition enumerates exactly the valid predecessors (salmon only follows tuna; tuna follows either). Since the reward from day onward depends only on the choice at day , not on the earlier history, optimal substructure holds: an optimal solution restricted to the first days is itself optimal among sequences ending in that day's choice.
Runtime. Each of the states is computed in from the previous day's two values, so the algorithm runs in time and space (since we only need day 's values to compute day 's values, we can simply keep two variables that are overwritten at each iteration). If instead we wanted to reconstruct the sequence of optimal choices David made, we would need to store a full array (which is space).
Problem 3. [Increasing Subsequence] Given an array of integers , determine the longest increasing subsequency in the array, i.e. the longest subsequence where every element is strictly greater than the previous one.
Recall that a subsequence is a sequence that can be derived from an array by deleting some elements without changing the order of the remaining elements.
Output a polynomial time algorithm that finds longest increasing subsequence of an array. If multiple answers exist, output any one of them.
Example: 7 3 5 3 6 2 9 8
The longest increasing subsequence of this array is 3 5 6 9 (other subsequences of length 4 exist).
Solution
For our subproblem, we let be the length of longest increasing subsequence ending with element .
For our recurrence, we note that any increasing subsequence ending at is formed by appending to some increasing subsequence ending at an earlier element with and (the dummy element covers the case where starts a fresh subsequence). We pick the predecessor maximizing :
We let our base case be with dummy element (this ensures that we can create a new increasing subsequence of length with each element ).
is the length of the best increasing subsequence ending at , so the length of the overall longest increasing subsequence is
To output an actual subsequence and not just its length (reconstruct the sequence), store the maximizing predecessor for each :
Let . Follow the chain until reaching the dummy index , collect the real elements, and reverse them.
Running time. We fill in increasing order of , and each scans all , giving total, which is polynomial. Reconstruction adds .
The DP already satisfies the polynomial-time requirement, but there is a standard improvement. Maintain an array where is the smallest possible tail value among all increasing subsequences of length found so far. For each , binary search for the first entry (strict increase) and overwrite it, or append if no such entry exists. The array stays sorted throughout, and its final length is the LIS length; predecessor pointers recover the subsequence as before.
Problem 4. [Coin Game] Caleb and David play a game with a row of coins with values . Players alternate turns, with Caleb going first. On a turn, the current player removes either the leftmost or the rightmost remaining coin and adds its value to their score. Play continues until no coins remain.
Assuming both players play optimally (each maximizes their own final score), give an algorithm that outputs Caleb's final score.
Example: 4 9 1 3
Caleb takes the 3, then David is forced to choose from 4 9 1. Regardless of what David chooses, Caleb gets the 9 on his next turn. Thus, Caleb's optimal score is 12.
Hint
The set of remaining coins is always a contiguous block, so can we index our subproblem by intervals?
Solution
Coming soon!