Skip to content

Advanced DP Optimizations

Phase 6 covered DP as a modeling discipline: pick the right state, write the recurrence, fill the table. This page is about a different question — once the recurrence is right, can it run faster? A handful of named optimizations turn an O(n2)O(n^2) or O(n3)O(n^3) transition into something close to linear, by exploiting extra structure (monotonicity, convexity) that the naive recurrence doesn’t use. This is not an exhaustive list — competitive programming has a deep well of these — but the ones below cover the techniques that actually show up repeatedly.

What you’ll learn

  • Digit DP: counting how many numbers up to NN satisfy a per-digit property, without ever enumerating the numbers themselves.
  • A pointer back to bitmask DP (Phase 6) for the subset-state family of optimizations.
  • Monotonic-deque-optimized DP: when a transition needs “the best DP value in a sliding window,” a deque keeps that lookup O(1)O(1) amortized instead of O(k)O(k).
  • A conceptual tour of three further speedups — the convex hull trick, divide-and-conquer DP, and Knuth’s optimization — and the shape of recurrence each one targets.

Digit DP: counting without enumerating

Some problems ask “how many integers from 00 to NN satisfy some per-digit property” where NN can be astronomically large (10^1810^18). Enumerating every number is impossible, but the property only ever depends on which digits appear and whether the number built so far is still capped by NN’s own digits — so the DP state is (position, tight)(position, tight), where tighttight means “every digit chosen so far exactly matches NN’s digits” (so the next digit is capped) versus “already strictly smaller” (so the next digit can be anything 0-90-9).

digit_dp.py
from functools import lru_cache
 
 
def count_without_digit(n, forbidden):
    digits = [int(c) for c in str(n)]
 
    @lru_cache(maxsize=None)
    def dp(pos, tight):
        if pos == len(digits):
            return 1                          # built a full, valid number
        limit = digits[pos] if tight else 9    # capped only while still matching N's prefix
        total = 0
        for d in range(0, limit + 1):
            if d == forbidden:
                continue                       # this digit isn't allowed anywhere
            total += dp(pos + 1, tight and d == limit)
        return total
 
    result = dp(0, True)
    dp.cache_clear()                           # dp is only valid for THIS n's digit string
    return result
 
 
n = 50
result = count_without_digit(n, forbidden=4)
brute_force = sum(1 for x in range(n + 1) if "4" not in str(x))
print("digit DP:", result)
print("brute force (sanity check):", brute_force)
digit_dp.py
from functools import lru_cache
 
 
def count_without_digit(n, forbidden):
    digits = [int(c) for c in str(n)]
 
    @lru_cache(maxsize=None)
    def dp(pos, tight):
        if pos == len(digits):
            return 1                          # built a full, valid number
        limit = digits[pos] if tight else 9    # capped only while still matching N's prefix
        total = 0
        for d in range(0, limit + 1):
            if d == forbidden:
                continue                       # this digit isn't allowed anywhere
            total += dp(pos + 1, tight and d == limit)
        return total
 
    result = dp(0, True)
    dp.cache_clear()                           # dp is only valid for THIS n's digit string
    return result
 
 
n = 50
result = count_without_digit(n, forbidden=4)
brute_force = sum(1 for x in range(n + 1) if "4" not in str(x))
print("digit DP:", result)
print("brute force (sanity check):", brute_force)

The state space is tiny — len(digits) * 2len(digits) * 2 states, each doing O(10)O(10) work — so this counts numbers up to 10^1810^18 in about 20 states total, versus enumerating 101810^{18} numbers directly.

Bitmask DP: already covered in Phase 6

The other major “state isn’t an index” family — bitmask DP, where the state is which subset of items has been used (Traveling Salesman being the canonical example) — is covered in depth in Bitmask and Tree DP back in Phase 6. If subset-shaped state is new to you, that’s the page to revisit; this lesson focuses on the optimizations that speed up an already-correct recurrence rather than the modeling itself.

Monotonic-deque-optimized DP

Some recurrences need, at each step, the best DP value within a sliding window of the last kk positions:

dp[i]=nums[i]+max(0, maxikj<idp[j])dp[i] = nums[i] + \max\bigl(0,\ \max_{\,i-k \,\le\, j \,<\, i} dp[j]\bigr)

Computing that inner max naively costs O(k)O(k) per step — O(nk)O(nk) total. A monotonic deque keeps the window’s maximum available in O(1)O(1): push indices onto the back, but first pop off any index whose dpdp value is <=<= the new one (they can never be the answer again, since the new index is both more recent and at least as good), and pop stale indices off the front once they fall outside the window.

monotonic_deque_dp.py
from collections import deque
 
 
def constrained_subset_sum(nums, k):
    n = len(nums)
    dp = [0] * n
    dq = deque()                          # holds indices, dp[dq[0]] .. dp[dq[-1]] decreasing
 
    for i in range(n):
        while dq and dq[0] < i - k:
            dq.popleft()                   # front index fell outside the window -- drop it
 
        best_prev = dp[dq[0]] if dq and dp[dq[0]] > 0 else 0
        dp[i] = nums[i] + best_prev
 
        while dq and dp[dq[-1]] <= dp[i]:
            dq.pop()                       # a worse-or-equal, older candidate is now useless
        dq.append(i)
 
    return max(dp)
 
 
nums = [10, 2, -10, 5, 20, -7, -15]
k = 2
print("constrained subset sum:", constrained_subset_sum(nums, k))   # expect 37
monotonic_deque_dp.py
from collections import deque
 
 
def constrained_subset_sum(nums, k):
    n = len(nums)
    dp = [0] * n
    dq = deque()                          # holds indices, dp[dq[0]] .. dp[dq[-1]] decreasing
 
    for i in range(n):
        while dq and dq[0] < i - k:
            dq.popleft()                   # front index fell outside the window -- drop it
 
        best_prev = dp[dq[0]] if dq and dp[dq[0]] > 0 else 0
        dp[i] = nums[i] + best_prev
 
        while dq and dp[dq[-1]] <= dp[i]:
            dq.pop()                       # a worse-or-equal, older candidate is now useless
        dq.append(i)
 
    return max(dp)
 
 
nums = [10, 2, -10, 5, 20, -7, -15]
k = 2
print("constrained subset sum:", constrained_subset_sum(nums, k))   # expect 37

Every index enters and leaves the deque at most once, so the whole sliding-window maximum bookkeeping is O(n)O(n) total — the deque turns an O(nk)O(nk) DP into an O(n)O(n) one.

Further reading: three more named optimizations

These show up less often than digit DP or the monotonic-deque trick, but recognizing the recurrence shape that triggers each one is worth knowing even without memorizing the implementation.

Convex hull trick (CHT). Targets recurrences of the form

dp[i]=minj<i(dp[j]+bjai)dp[i] = \min_{j < i} \bigl(dp[j] + b_j \cdot a_i\bigr)

— read each jj as defining a line y = b_j \cdot x + dp[j]y = b_j \cdot x + dp[j], and dp[i]dp[i] as the minimum of all those lines evaluated at x = a_ix = a_i. Maintaining the lower envelope of the lines added so far (a monotonic stack of lines, popping ones that are never optimal) answers each query in O(logn)O(\log n) or even amortized O(1)O(1) if the a_ia_i queries arrive in sorted order — turning an O(n2)O(n^2) recurrence into O(nlogn)O(n \log n).

Divide-and-conquer DP optimization. Targets 2D recurrences

dp[i][j]=mink<j(dp[i1][k]+cost(k,j))dp[i][j] = \min_{k < j} \bigl(dp[i-1][k] + \text{cost}(k, j)\bigr)

where the optimal split point opt(i, j)opt(i, j) is monotonic in jj for a fixed ii (opt(i, j) \le opt(i, j+1)opt(i, j) \le opt(i, j+1)). Solving for the middle jj first, then recursing on the left and right halves with kk’s search range narrowed by that monotonicity, computes an entire DP row in O(nlogn)O(n \log n) instead of O(n2)O(n^2).

Knuth’s optimization. Targets interval DP recurrences

dp[i][j]=minik<j(dp[i][k]+dp[k+1][j])+cost(i,j)dp[i][j] = \min_{i \le k < j} \bigl(dp[i][k] + dp[k+1][j]\bigr) + \text{cost}(i, j)

when costcost satisfies the quadrangle inequality. Under that condition, the optimal split opt(i, j)opt(i, j) satisfies opt(i, j-1) \le opt(i, j) \le opt(i+1, j)opt(i, j-1) \le opt(i, j) \le opt(i+1, j) — bounding each split point’s search range enough that the total work across the whole table drops from O(n3)O(n^3) to O(n2)O(n^2).

Complexity summary

TechniqueNaive costOptimized costTrigger condition
Digit DPO(N)O(N) enumerationO(digits×extra state×10)O(\text{digits} \times \text{extra state} \times 10)Counting numbers up to NN by a digit-local property
Monotonic-deque DPO(nk)O(nk)O(n)O(n)Transition needs a sliding-window max/min of previous dpdp values
Convex hull trickO(n2)O(n^2)O(nlogn)O(n \log n) or O(n)O(n)Transition is dp[j] + b_j * a_idp[j] + b_j * a_i, a line evaluated at a query point
D&C DP optimizationO(n2)O(n^2) per layerO(nlogn)O(n \log n) per layerOptimal split point is monotonic across jj
Knuth’s optimizationO(n3)O(n^3)O(n2)O(n^2)Interval DP whose cost satisfies the quadrangle inequality

Practice — real LeetCode problems

Three partition DPs. All three share one skeleton — split the array into kk consecutive groups, minimise the sum of group costs — and each adds a different twist: a running maximum, a divisibility constraint, and a precomputed cost matrix that opens the door to divide-and-conquer optimisation.

LC 1335 — Minimum Difficulty of a Job Schedule · Hard

Problem. Jobs must be done in order over exactly dd days, at least one job per day. A day’s difficulty is the maximum difficulty among its jobs; the schedule’s difficulty is the sum over days. Return the minimum, or -1-1 if impossible.

Constraints. 1 <= len(jobDifficulty) <= 3001 <= len(jobDifficulty) <= 300, 0 <= jobDifficulty[i] <= 10000 <= jobDifficulty[i] <= 1000, 1 <= d <= 101 <= d <= 10.

Examples. jobDifficulty = [6,5,4,3,2,1], d = 2jobDifficulty = [6,5,4,3,2,1], d = 2 gives 77 — day one takes the first five jobs (difficulty 6), day two takes the last (difficulty 1) · [9,9,9], d = 4[9,9,9], d = 4 gives -1-1 · [1,1,1], d = 3[1,1,1], d = 3 gives 33

Editorial · approach, complexity, follow-ups

The canonical partition DP: split a sequence into exactly kk consecutive groups, minimising the total group cost. State is (start index, days remaining); the transition is where does this day end.

Time O(n2d)O(n^2 d)ndnd states, O(n)O(n) cut points each. About 300×300×10=9×105300 \times 300 \times 10 = 9 \times 10^5. Space O(nd)O(nd).

  • The loop bound n - days + 1n - days + 1 is the constraint “at least one job per day” expressed as arithmetic. Letting jj run to n - 1n - 1 lets a later day get zero jobs, which returns a too-small answer rather than crashing — the nastiest kind of bug.
  • if n < d: return -1if n < d: return -1 must come first. [9,9,9][9,9,9] with d = 4d = 4 is that check.
  • The running maximum is carried inside the loop, not recomputed with max(jobDifficulty[i:j+1])max(jobDifficulty[i:j+1]) on each iteration. That is the difference between O(n2d)O(n^2 d) and O(n3d)O(n^3 d), and it is what the extra hardesthardest variable buys.
  • Greedy fails. Balancing the job counts per day is wrong — the first example puts five jobs on day one and one on day two, because the 6 dominates its day regardless of how many cheap jobs sit with it.
  • Difficulty 0 is allowed by the constraints, so a day can cost nothing; initialising hardesthardest to 0 is therefore correct rather than lucky.

Follow-ups you should expect:dd up to nn?” — O(n3)O(n^3) becomes uncomfortable, so look at the structure. “Sum per day instead of maximum?” — prefix sums make each group cost O(1)O(1), and the cost function becomes convex, which unlocks the optimisations. “Minimise the maximum day difficulty (LC 410, LC 1011)?” — binary search the answer and greedily pack; a different and usually easier technique. “Which jobs on which day?” — store the chosen jj per state. “Days need not be non-empty?” — the bound relaxes and the answer becomes monotone in dd.

LC 1000 — Minimum Cost to Merge Stones · Hard

Problem. Merge exactly kk consecutive piles into one at a cost equal to the total number of stones in those kk piles. Return the minimum cost to end with one pile, or -1-1 if impossible.

Constraints. 1 <= len(stones) <= 301 <= len(stones) <= 30, 2 <= k <= 302 <= k <= 30, 1 <= stones[i] <= 1001 <= stones[i] <= 100.

Examples. stones = [3,2,4,1], k = 2stones = [3,2,4,1], k = 2 gives 2020 · stones = [3,2,4,1], k = 3stones = [3,2,4,1], k = 3 gives -1-1 · stones = [3,5,1,2,6], k = 3stones = [3,5,1,2,6], k = 3 gives 2525

Editorial · approach, complexity, follow-ups

Interval DP with a third dimension, and it is worth understanding why the obvious two-dimensional version fails. dp[i][j]dp[i][j] alone cannot express “this range is not finished yet” — a range may need to be left as several piles so its neighbour can merge with them. Tracking how many piles a range collapses into is exactly the missing information.

The feasibility check. Every merge turns kk piles into 1, removing k - 1k - 1. Going from nn piles to 1 removes n - 1n - 1, so (n - 1) % (k - 1) == 0(n - 1) % (k - 1) == 0 is necessary — and, given the freedom to choose consecutive groups, sufficient.

Why range(i, j, k - 1)range(i, j, k - 1). The first pile, spanning i..midi..mid, must itself collapse to a single pile, which requires (mid - i) % (k - 1) == 0(mid - i) % (k - 1) == 0. Stepping by k - 1k - 1 enumerates precisely those cut points and skips the impossible ones. Stepping by 1 still gives the right answer — the infeasible branches return infinity — but does k - 1k - 1 times more work.

Time O(n3k/(k1))O(n^3 k / (k-1)), roughly O(n3)O(n^3) with n30n \le 30. Space O(n2k)O(n^2 k).

  • k = 2k = 2 always works, since (n-1) % 1 == 0(n-1) % 1 == 0. The first example is the classic “merge adjacent pairs” problem and answers 20.
  • [3,2,4,1][3,2,4,1] with k = 3k = 3 is -1-1: 3 mod 2 is 1, so no sequence of merges lands on one pile.
  • A single pile costs 0 and needs no merges, which the i == ji == j base gives — and note [1][1] with k = 2k = 2 passes the divisibility check because n - 1n - 1 is 0.
  • piles == 1piles == 1 recurses into dp(i, j, k)dp(i, j, k), the same range with a different pile count. It looks circular but is not: pilespiles strictly decreases toward the split cases, so the recursion terminates.
  • The range sum is charged exactly once per merge, at the moment kk piles become 1. Charging it anywhere else double-counts, which is the most common wrong recurrence.

Follow-ups you should expect: “Why is O(n3)O(n^3) acceptable?” — n <= 30n <= 30 says so; at n=1000n = 1000 you would need Knuth’s optimisation, which applies because the cost function satisfies the quadrangle inequality. “Merge any kk piles, not consecutive?” — a completely different problem: sort and use a heap, greedily merging the smallest, which is Huffman coding. ”k = 2k = 2 only?” — the two-dimensional interval DP suffices. “Maximise the cost instead?” — the same table with maxmax. “Reconstruct the merge order?” — store the chosen midmid per state.

LC 1478 — Allocate Mailboxes · Hard

Problem. Given house positions on a street and kk mailboxes to place, return the minimum total distance between each house and its nearest mailbox.

Constraints. 1 <= k <= len(houses) <= 1001 <= k <= len(houses) <= 100, 1 <= houses[i] <= 10**41 <= houses[i] <= 10**4, all positions distinct.

Examples. houses = [1,4,8,10,20], k = 3houses = [1,4,8,10,20], k = 3 gives 55 · houses = [2,3,5,12,18], k = 2houses = [2,3,5,12,18], k = 2 gives 99

Editorial · approach, complexity, follow-ups

Two independent facts, composed.

Fact 1: one mailbox goes at the median. Minimising xip\sum |x_i - p| over pp is minimised at the median, not the mean. The argument: moving pp right by δ\delta changes the total by δ(houses left of phouses right of p)\delta \cdot (\text{houses left of } p - \text{houses right of } p), so the total decreases until the counts balance. With an even count any point between the two middle houses is optimal, which is why (i + j) // 2(i + j) // 2 is fine. Being able to give that argument is the main thing being tested — guessing “mean” is the classic error.

Fact 2: blocks are contiguous. Once the houses are sorted, an optimal solution never has a mailbox serving houses on both sides of a house served by another — you could swap and do no worse. So this is a partition into kk consecutive blocks, the same skeleton as the previous two problems.

Time O(n3)O(n^3) for the cost table plus O(n2k)O(n^2 k) for the DP — 10610^6 and 10610^6 at n=100n = 100. The cost table can be built in O(n2)O(n^2) with the recurrence cost[i][j] = cost[i+1][j-1] + houses[j] - houses[i]cost[i][j] = cost[i+1][j-1] + houses[j] - houses[i], which is worth mentioning. Space O(n2+nk)O(n^2 + nk).

  • Sort first. The third test case [7,4,6,1][7,4,6,1] arrives unsorted; without sorting the contiguity argument is false and the answer is wrong.
  • boxes == 0boxes == 0 with houses remaining is infinity, not 0. Without that guard the DP would happily serve houses with no mailbox.
  • k == nk == n gives 0 — one mailbox per house, every distance zero. That is the fourth test case.
  • Leftover mailboxes are never a problem, since the DP consumes exactly kk and extra boxes could always be dumped on an existing house at no cost. Requiring boxes == 0boxes == 0 at i == ni == n is still correct because splitting a block is free.
  • Median, not mean. On [1,4,8,10,20][1,4,8,10,20] served by one mailbox, the median 8 costs 25 while the mean 8.6 rounds to 9 and costs 26.

Follow-ups you should expect:n=105n = 10^5?” — the cost function satisfies the quadrangle inequality, so divide-and-conquer optimisation brings the DP to O(knlogn)O(kn \log n); that is the intended answer and the reason this problem sits on this page. “Why does D&C optimisation apply?” — the optimal split point is monotone in the block index, which follows from the quadrangle inequality. “Squared distances instead?” — the optimum per block becomes the mean, and it is kk-means in one dimension. “Mailboxes only at given candidate positions?” — add a dimension over candidates. “Minimise the maximum distance instead?” — binary search the radius and greedily cover.

LeetCode problem set

#ProblemDifficultyThe twist
902Numbers At Most N Given Digit SetHardDigit DP over a restricted digit alphabet, plus a length-based combinatorial count for numbers shorter than NN
1425Constrained Subsequence SumHardThe monotonic-deque DP above, applied directly
1696Jump Game VIMediumThe same monotonic-deque shape: dp[i] = nums[i] + max(dp[j] for j in the last k indices)dp[i] = nums[i] + max(dp[j] for j in the last k indices), without the “reset to 0” clamp that Constrained Subsequence Sum needs

Recap

  • Digit DP: state (position, tight, ...extra)(position, tight, ...extra) counts numbers up to NN without enumerating them — extend the extra state to match whatever per-digit property the problem asks about.
  • Bitmask DP (subset state) lives in Phase 6’s Bitmask and Tree DP — revisit that page for the modeling side of subset-shaped DP.
  • Monotonic-deque DP turns an O(nk)O(nk) sliding-window-max transition into O(n)O(n) by keeping the deque’s indices’ dpdp values decreasing.
  • Convex hull trick, D&C DP optimization, and Knuth’s optimization each target a specific recurrence shape — lines evaluated at a point, a monotonic split point, and the quadrangle inequality, respectively — worth recognizing by name even without full fluency in the implementation.

That closes out the advanced-CP-topics phase — between string automatons, bit-level tricks, and these DP speedups, you now have the toolkit for the recurring “how do I make this fast enough” questions that separate CP problems from a typical coding interview.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did