Dynamic Programming contents
Knuth's Optimization
Speed up range DPs of the form dp[i][j] = min over k of dp[i][k] + dp[k][j] + C(i, j) from O(n³) to O(n²) by restricting the split point.
Read first: Divide and Conquer DP
Knuth's optimization (the Knuth-Yao speedup) applies to interval DPs, where the answer for a range is built from a split point:
The straightforward evaluation costs : ranges and split points each. With Knuth's optimization it drops to .
Conditions
Let be the (largest) split point achieving the minimum. If
then, to compute , we only need to try between and . The inequality holds when the cost satisfies, for all :
- Monotonicity on ranges: (a sub-range is not more expensive than the range containing it);
- Quadrangle inequality: .
Typical costs that satisfy both: the sum of the elements in the range (prefix[j] - prefix[i]), the length of the range, and (with non-negative weights) the weight of the range in the optimal binary search tree problem.
Why it is
Processing ranges by increasing length, the work for all ranges of length is
which telescopes to . Summing over the lengths gives .
Implementation
The structure is exactly that of an ordinary range DP; only the loop over changes. Here the recurrence is written with half-open ranges: dp[i][j] for the block of items , splitting at .
INF = float("inf")
def knuth_dp(n, cost, base=lambda i: 0):
"""
dp[i][j] (0 <= i < j <= n) = min over i < k < j of dp[i][k] + dp[k][j] + cost(i, j),
with dp[i][i+1] = base(i). Returns dp[0][n].
"""
dp = [[0] * (n + 1) for _ in range(n + 1)]
opt = [[0] * (n + 1) for _ in range(n + 1)]
for i in range(n):
dp[i][i + 1] = base(i)
opt[i][i + 1] = i
for length in range(2, n + 1):
for i in range(0, n - length + 1):
j = i + length
best, best_k = INF, -1
for k in range(opt[i][j - 1], opt[i + 1][j] + 1):
if i < k < j:
value = dp[i][k] + dp[k][j] + cost(i, j)
if value < best:
best, best_k = value, k
dp[i][j] = best
opt[i][j] = best_k
return dp[0][n]
def cubic_dp(n, cost, base=lambda i: 0):
dp = [[0] * (n + 1) for _ in range(n + 1)]
for i in range(n):
dp[i][i + 1] = base(i)
for length in range(2, n + 1):
for i in range(0, n - length + 1):
j = i + length
dp[i][j] = min(dp[i][k] + dp[k][j] + cost(i, j) for k in range(i + 1, j))
return dp[0][n]Example: merging adjacent piles
There are piles of stones in a row. In one move, merge two adjacent piles; the cost is the total size of the new pile. What is the minimum total cost of merging everything into one pile? This is exactly the recurrence above with the sum of the piles (which is both range-monotone and quadrangle-inequality compliant for non-negative sizes).
import random
def merge_cost(piles):
prefix = [0]
for x in piles:
prefix.append(prefix[-1] + x)
return lambda i, j: prefix[j] - prefix[i]
piles = [4, 1, 1, 4]
cost = merge_cost(piles)
# optimal order: merge the two 1s (cost 2), then 4 + 2 (cost 6), then 6 + 4 (cost 10): 2 + 6 + 10 = 18
assert cubic_dp(4, cost) == 18
assert knuth_dp(4, cost) == 18
random.seed(1)
for _ in range(500):
n = random.randint(1, 18)
piles = [random.randint(0, 30) for _ in range(n)]
cost = merge_cost(piles)
assert knuth_dp(n, cost) == cubic_dp(n, cost)The optimized DP agrees with the cubic definition on random inputs.
Example: optimal binary search tree
Given search frequencies for keys in sorted order, build the BST minimizing the expected search cost. If the root of the subtree over keys is key , the two sides are optimal trees over and and every key moves one level deeper, adding the total frequency of the range:
def optimal_bst(freq):
n = len(freq)
prefix = [0]
for f in freq:
prefix.append(prefix[-1] + f)
# e[i][j]: minimal cost of a BST over keys i..j-1 (0 for the empty range)
e = [[0] * (n + 1) for _ in range(n + 1)]
root = [[0] * (n + 1) for _ in range(n + 1)]
for i in range(n):
e[i][i + 1] = freq[i]
root[i][i + 1] = i
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length
best, best_r = INF, -1
lo, hi = root[i][j - 1], root[i + 1][j] # Knuth: r between the neighbours' roots
for r in range(lo, hi + 1):
left = e[i][r]
right = e[r + 1][j]
value = left + right + prefix[j] - prefix[i]
if value < best:
best, best_r = value, r
e[i][j] = best
root[i][j] = best_r
return e[0][n]
def optimal_bst_cubic(freq):
n = len(freq)
prefix = [0]
for f in freq:
prefix.append(prefix[-1] + f)
e = [[0] * (n + 1) for _ in range(n + 1)]
for length in range(1, n + 1):
for i in range(n - length + 1):
j = i + length
e[i][j] = min(e[i][r] + e[r + 1][j] for r in range(i, j)) + prefix[j] - prefix[i]
return e[0][n]
assert optimal_bst([34, 8, 50]) == optimal_bst_cubic([34, 8, 50]) == 142
for _ in range(300):
freq = [random.randint(1, 50) for _ in range(random.randint(1, 20))]
assert optimal_bst(freq) == optimal_bst_cubic(freq)Using it safely
- The proof of the bounds requires the two conditions on ; if you are not sure, test it against the cubic DP on random data as above.
- Iterate ranges so that and are computed before (increasing length, or downwards and upwards).
- Take care of ties: use a consistent tie-breaking rule (always the first or always the last minimum), or the monotonicity of can fail.
- The same idea also speeds up problems where the DP is over prefixes and pieces with an additional layer, the Knuth-Yao form of divide and conquer optimization.