Dynamic programming (DP) solves problems by breaking them into overlapping subproblems, solving each Subproblem once, and storing the results. Two properties must hold for DP to apply:
When both hold, DP reduces an exponential-time recursive solution to polynomial time.
Given an array of non-negative integers representing money at each house, maximise the amount you Can rob without robbing two adjacent houses.
Given coins of different denominations and a target amount, find the minimum number of coins needed To make that amount. Return -1 if it is not possible.
- **0/1 knapsack:** iterate $w$ from $W$ down to $weight_i$ (reverse) — prevents using the same item twice in one iteration - **Unbounded knapsack:** iterate $w$ from $weight_i$ up to $W$ (forward) — allows reusing the item within the same iterationGetting this direction wrong is one of the most common DP bugs.
def longest_palindromic_substring ( s ):
Longest palindromic substring using expanding around center.
Time: O(n^2), Space: O(1)
while left >= 0 and right < len (s) and s[left] == s[right]:
if right - left + 1 > max_len:
max_len = right - left + 1
while left >= 0 and right < len (s) and s[left] == s[right]:
if right - left + 1 > max_len:
max_len = right - left + 1
return s[start : start + max_len]
def longest_palindromic_substring_dp ( s ):
Longest palindromic substring using DP.
Time: O(n^2), Space: O(n^2)
dp = [[ False ] * n for _ in range (n)]
# Single characters are palindromes
# Check for palindromes of length 2
# Check for palindromes of length 3+
for length in range ( 3 , n + 1 ):
for i in range (n - length + 1 ):
if s[i] == s[j] and dp[i + 1 ][j - 1 ]:
return s[start : start + max_len]
def longest_palindromic_subsequence ( s ):
Length of longest palindromic subsequence.
Time: O(n^2), Space: O(n^2) or O(n) with rolling array
dp = [[ 0 ] * n for _ in range (n)]
for i in range (n - 1 , - 1 , - 1 ):
for j in range (i + 1 , n):
dp[i][j] = dp[i + 1 ][j - 1 ] + 2
dp[i][j] = max (dp[i + 1 ][j], dp[i][j - 1 ])
Given a string and a dictionary of words, determine if the string can be segmented into Space-separated dictionary words.
def word_break ( s , word_dict ):
Check if s can be segmented into dictionary words.
Time: O(n^2) with hash set lookup, Space: O(n)
word_set = set (word_dict)
dp[ 0 ] = True # empty string is always valid
for i in range ( 1 , n + 1 ):
if dp[j] and s[j : i] in word_set:
Interval DP problems involve optimising over intervals (subarrays, substrings). The state is d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] representing the optimal value for the subproblem from index i i i to j j j .
The fill order is critical: shorter intervals must be computed before longer ones (because longer Intervals depend on shorter ones).
Given a chain of matrices with dimensions d 0 × d 1 d_0 \times d_1 d 0 × d 1 , d 1 × d 2 d_1 \times d_2 d 1 × d 2 …, d n − 1 × D n d_{n-1} \times D_n d n − 1 × D n , find the minimum number of scalar multiplications to compute the product.
d p [ i ] [ j ] = min i ≤ k < j ( d p [ i ] [ k ] + d p [ k + 1 ] [ j ] + d i ⋅ d k + 1 ⋅ d j + 1 ) dp[i][j] = \min_{i \le k \lt j} (dp[i][k] + dp[k+1][j] + d_i \cdot d_{k+1} \cdot d_{j+1}) d p [ i ] [ j ] = min i ≤ k < j ( d p [ i ] [ k ] + d p [ k + 1 ] [ j ] + d i ⋅ d k + 1 ⋅ d j + 1 )
def matrix_chain_order ( dims ):
Minimum scalar multiplications for matrix chain.
Time: O(n^3), Space: O(n^2)
n = len (dims) - 1 # number of matrices
dp = [[ 0 ] * n for _ in range (n)]
# length is the chain length (number of matrices in the subchain)
for length in range ( 2 , n + 1 ):
for i in range (n - length + 1 ):
cost = dp[i][k] + dp[k + 1 ][j] + dims[i] * dims[k + 1 ] * dims[j + 1 ]
dp[i][j] = min (dp[i][j], cost)
Given nums where nums[i] is the value of the i i i -th balloon, burst all balloons to maximise Coins. When you burst balloon i i i You get nums[left] * nums[i] * nums[right] coins, where left And right are the nearest unburst balloons.
def burst_balloons ( nums ):
Maximum coins from bursting balloons.
Time: O(n^3), Space: O(n^2)
# Add virtual balloons with value 1 at boundaries
dp = [[ 0 ] * m for _ in range (m)]
for length in range ( 1 , n + 1 ):
for left in range ( 1 , m - length):
right = left + length - 1
for k in range (left, right + 1 ):
coins = arr[left - 1 ] * arr[k] * arr[right + 1 ]
coins += dp[left][k - 1 ] + dp[k + 1 ][right]
dp[left][right] = max (dp[left][right], coins)
Bitmask DP uses a bitmask to represent a subset of elements as a state. This is applicable when the Number of elements is small ( n ≤ 20 n \le 20 n ≤ 20 ), giving 2 n 2^n 2 n states.
Find the minimum cost to visit all cities exactly once and return to the start.
d p [ m a s k ] [ i ] = min j ∈ m a s k , j ≠ i ( d p [ m a s k ∖ i ] [ j ] + d i s t [ j ] [ i ] ) dp[mask][i] = \min_{j \in mask, j \ne i} (dp[mask \setminus \\{i\\}][j] + dist[j][i]) d p [ ma s k ] [ i ] = min j ∈ ma s k , j = i ( d p [ ma s k ∖ i ] [ j ] + d i s t [ j ] [ i ])
Travelling Salesman Problem using bitmask DP.
Time: O(2^n * n^2), Space: O(2^n * n)
Only practical for n <= 20.
# dp[mask][i] = minimum cost to visit cities in mask, ending at city i
dp = [[ INF ] * n for _ in range ( 1 << n)]
dp[ 1 ][ 0 ] = 0 # start at city 0, mask = 0b...001
for mask in range ( 1 << n):
new_mask = mask | ( 1 << v)
# Return to city 0 from any ending city
result = min (result, dp[full_mask][i] + dist[i][ 0 ])
State: d p [ i ] dp[i] d p [ i ] — optimal value for the prefix of length i i i .
Examples: climbing stairs, house robber, coin change, word break, longest increasing subsequence.
def longest_increasing_subsequence ( nums ):
LIS — longest strictly increasing subsequence.
Time: O(n log n) using patience sorting
tails = [] # tails[i] = smallest tail of increasing subsequence of length i+1
pos = bisect.bisect_left(tails, x)
State: d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] — optimal value for the subproblem ending at position ( i , j ) (i, j) ( i , j ) .
Examples: unique paths, minimum path sum, edit distance, LCS.
Number of unique paths from top-left to bottom-right in an m x n grid.
Can only move right or down.
Time: O(m * n), Space: O(n) with rolling array
State: d p [ w ] dp[w] d p [ w ] — optimal value using capacity w w w .
Examples: 0/1 knapsack, unbounded knapsack, subset sum, partition equal subset sum.
State: d p [ n o d e ] dp[node] d p [ n o d e ] — optimal value for the subtree rooted at node. Combine children’s results.
def max_path_sum_binary_tree ( root ):
Maximum path sum in a binary tree (path can start/end at any node).
Time: O(n), Space: O(h) recursion stack
left = max ( 0 , dfs(node.left))
right = max ( 0 , dfs(node.right))
max_sum = max (max_sum, left + node.val + right)
return node.val + max (left, right)
Greedy algorithms make locally optimal choices at each step, hoping they lead to a globally optimal Solution. DP considers all possibilities and chooses the globally optimal one.
Greedy works when the problem has the greedy-choice property — a locally optimal choice leads to A globally optimal solution. This holds for matroid structures.
Problem Greedy? DP? Greedy Complexity Activity selection Yes Yes O ( n log n ) O(n \log n) O ( n log n ) Fractional knapsack Yes Yes O ( n log n ) O(n \log n) O ( n log n ) Huffman coding Yes Yes O ( n log n ) O(n \log n) O ( n log n ) Dijkstra’s shortest path Yes (non-negative) Yes (Bellman-Ford) O ( ( V + E ) log V ) O((V+E) \log V) O (( V + E ) log V ) 0/1 knapsack No Yes — Longest common subsequence No Yes — Edit distance No Yes —
def activity_selection ( activities ):
Maximum number of non-overlapping activities.
Greedy: sort by end time, always pick the earliest-ending activity.
activities.sort( key =lambda x : x[ 1 ]) # sort by end time
for start, end in activities:
Many 2D DP problems only depend on the previous row (or previous few rows). Instead of storing the Entire n × m n \times m n × m table, store only O ( m ) O(m) O ( m ) or O ( n ) O(n) O ( n ) values.
def edit_distance_optimised ( s1 , s2 ):
Edit distance with O(min(n,m)) space.
prev = list ( range (m + 1 ))
for i in range ( 1 , n + 1 ):
for j in range ( 1 , m + 1 ):
if s1[i - 1 ] == s2[j - 1 ]:
current[j] = 1 + min (prev[j], current[j - 1 ], prev[j - 1 ])
Sometimes the DP state can be compressed or reduced by identifying that not all state variables are Independent.
Example: In the knapsack problem, the state is ( i t e m , w e i g h t ) (item, weight) ( i t e m , w e i g h t ) . But with space optimisation, we Only need w e i g h t weight w e i g h t because items are processed one at a time.
DP gives the optimal value, but often you need the actual solution (which items to take, what the Path is, etc.). Reconstruction requires either storing backpointers or re-running the DP logic.
def knapsack_reconstruct ( weights , values , capacity ):
0/1 Knapsack with solution reconstruction.
Returns (max_value, list_of_item_indices)
dp = [[ 0 ] * (capacity + 1 ) for _ in range (n + 1 )]
for i in range ( 1 , n + 1 ):
for w in range (capacity + 1 ):
dp[i][w] = max (dp[i][w], dp[i - 1 ][w - weights[i - 1 ]] + values[i - 1 ])
for i in range (n, 0 , - 1 ):
if dp[i][w] != dp[i - 1 ][w]:
return dp[n][capacity], items
The hardest part of DP is defining the state. A good state should be:
Sufficient — the state captures all information needed to make future decisionsMinimal — the state does not contain redundant informationCommon mistake: trying to use too many state variables. Start with a recursive solution, identify What parameters change in recursive calls, and those are your state variables.
DP base cases are analogous to loop initialisation. Getting them wrong produces wrong answers for Small inputs that cascade into wrong answers for large inputs. Always test with the smallest Non-trivial input (e.g., n = 1 n = 1 n = 1 Empty string, single element).
Bottom-up DP must fill the table in an order such that when computing d p [ s t a t e ] dp[state] d p [ s t a t e ] All states that d p [ s t a t e ] dp[state] d p [ s t a t e ] depends on have already been computed. For interval DP, shorter intervals before longer. For 0/1 knapsack with space optimisation, iterate w w w in reverse. Getting the fill order wrong Produces undefined behaviour (using uninitialised values).
DP values can grow exponentially (e.g., Fibonacci, counting paths in a grid). For n = 100 n = 100 n = 100 F 100 ≈ 3.5 × 10 20 F_{100} \approx 3.5 \times 10^{20} F 100 ≈ 3.5 × 1 0 20 Which exceeds 64-bit range. Use arbitrary-precision integers (Python’s int is always arbitrary precision) or modular arithmetic when appropriate.
A subsequence does not need to be contiguous (LCS, LIS). A subarray/substring must be contiguous (maximum subarray, longest palindromic substring). These require different DP formulations:
Subsequence: d p [ i ] [ j ] dp[i][j] d p [ i ] [ j ] considers all elements between i i i and j j j Subarray: d p [ i ] dp[i] d p [ i ] is the optimal value for subarrays ending at i i i Not every optimisation problem needs DP. Activity selection, fractional knapsack, and minimum Spanning trees all have greedy solutions. Using DP where greedy works is correct but slower — O ( n 2 ) O(n^2) O ( n 2 ) or O ( n ⋅ W ) O(n \cdot W) O ( n ⋅ W ) instead of O ( n log n ) O(n \log n) O ( n log n ) .
Top-down memoisation eliminates redundant computation but does not reduce recursion depth. If the Recursive solution has O ( n ) O(n) O ( n ) depth, the memoised version still has O ( n ) O(n) O ( n ) recursion depth and can Still stack overflow for large n n n . Use bottom-up DP for problems with deep recursion.
Many DP problems have multiple valid decompositions, but some lead to efficient solutions and others Do not. For the longest increasing subsequence, the O ( n 2 ) O(n^2) O ( n 2 ) DP (d p [ i ] dp[i] d p [ i ] = length of LIS ending at i i i ) works but the O ( n log n ) O(n \log n) O ( n log n ) solution using patience sorting requires a different approach Entirely. Always consider whether a more efficient state representation exists.
Digit DP solves counting problems on ranges by processing numbers digit by digit. It is applicable When the problem involves counting numbers in a range that satisfy a property based on their digits.
def count_numbers_without_digit ( n , forbidden ):
Count numbers from 1 to n that do not contain a forbidden digit.
Time: O(log10(n) * 2 * 10), Space: O(log10(n) * 2)
digits = list ( map ( int , str (n)))
def dp ( pos , tight , started ):
return 1 if started else 0
key = (pos, tight, started)
limit = digits[pos] if tight else 9
for d in range ( 0 , limit + 1 ):
new_tight = tight and (d == limit)
new_started = started or (d != 0 )
count += dp(pos + 1 , new_tight, new_started)
return dp( 0 , True , False )
The state tracks:
pos: current digit positiontight: whether the prefix is equal to the prefix of n (restricts upper bound)started: whether we have placed a non-zero digit yet (handles leading zeros)Tree DP involves computing a value for each subtree and combining results from children. The key Insight is post-order traversal: compute children first, then the parent.
Diameter of a binary tree (longest path between any two nodes).
Time: O(n), Space: O(h) recursion stack
left_height = height(node.left)
right_height = height(node.right)
max_diameter = max (max_diameter, left_height + right_height)
return 1 + max (left_height, right_height)
def house_robber_tree ( root ):
House robber on a binary tree: cannot rob parent and child simultaneously.
return ( 0 , 0 ) # (rob, skip)
left_rob, left_skip = dfs(node.left)
right_rob, right_skip = dfs(node.right)
rob = node.val + left_skip + right_skip
skip = max (left_rob, left_skip) + max (right_rob, right_skip)
For problems where the state involves a subset of elements, bitmask DP provides a compact Representation. The state space is O ( 2 n ) O(2^n) O ( 2 n ) Limiting applicability to n ≤ 20 n \le 20 n ≤ 20 .
Assignment problem: Assign n n n workers to n n n jobs with minimum total cost.
def assignment_problem ( cost ):
Minimum cost assignment using bitmask DP.
Time: O(n^2 * 2^n), Space: O(n * 2^n)
dp = [[ float ( ' inf ' )] * (n + 1 ) for _ in range ( 1 << n)]
dp[ 0 ][ 0 ] = 0 # no workers assigned, cost 0
for mask in range ( 1 << n):
worker_count = bin (mask).count( ' 1 ' )
prev_mask = mask | ( 1 << job)
dp[prev_mask][worker_count + 1 ] = min (
dp[prev_mask][worker_count + 1 ],
dp[mask][worker_count] + cost[worker_count][job]
When DP state values are sparse but large (e.g., coordinates up to 10 9 10^9 1 0 9 but only 10 5 10^5 1 0 5 distinct Values), compress them to a contiguous range before applying DP.
def coordinate_compress ( values ):
"""Map sparse values to 0..n-1."""
sorted_unique = sorted ( set (values))
return {v: i for i, v in enumerate (sorted_unique)}
This technique is essential for problems like “count points in rectangles” where the coordinate Range is large but the number of points is manageable.
DP is applicable when a problem has these characteristics:
Optimal substructure: The optimal solution can be constructed from optimal solutions to subproblemsOverlapping subproblems: The recursive solution solves the same subproblem multiple timesFinite state space: The number of distinct subproblems is manageable (polynomial)Red flags that suggest DP:
“Find the maximum/minimum/longest/shortest…” “Count the number of ways to…” “Is it possible to…” The problem involves making a sequence of choices The input size is moderate (up to ~200 for 2D DP, ~20 for bitmask DP) Red flags that suggest NOT DP:
The input size is very large (DP state space would be too big) The problem requires an exact sequence, not just a value (reconstruction may be needed) Greedy works (the greedy choice property holds) The problem is on a tree/graph with no obvious DP state (may need tree/graph-specific techniques) This topic covers the core concepts of dynamic programming, including underlying theory, practical implementation, and key applications.
Key concepts include:
variables, data types, and control flow functions and procedures object-oriented programming error handling and debugging modular design Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.