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 or 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
NNsatisfy 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 amortized instead of .
- 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).
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)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
work — so this counts numbers up to 10^1810^18 in about 20 states total,
versus enumerating 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:
Computing that inner max naively costs per step — total. A
monotonic deque keeps the window’s maximum available in : 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.
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 37from 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 37Every index enters and leaves the deque at most once, so the whole sliding-window maximum bookkeeping is total — the deque turns an DP into an 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
— 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 or
even amortized if the a_ia_i queries arrive in sorted order —
turning an recurrence into .
Divide-and-conquer DP optimization. Targets 2D recurrences
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
instead of .
Knuth’s optimization. Targets interval DP recurrences
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 to .
Complexity summary
| Technique | Naive cost | Optimized cost | Trigger condition |
|---|---|---|---|
| Digit DP | enumeration | Counting numbers up to NN by a digit-local property | |
| Monotonic-deque DP | Transition needs a sliding-window max/min of previous dpdp values | ||
| Convex hull trick | or | Transition is dp[j] + b_j * a_idp[j] + b_j * a_i, a line evaluated at a query point | |
| D&C DP optimization | per layer | per layer | Optimal split point is monotonic across jj |
| Knuth’s optimization | 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 — states, cut points each. About . Space .
- The loop bound
n - days + 1n - days + 1is the constraint “at least one job per day” expressed as arithmetic. Lettingjjrun ton - 1n - 1lets 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 -1must come first.[9,9,9][9,9,9]withd = 4d = 4is 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 and , and it is what the extrahardesthardestvariable 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
hardesthardestto 0 is therefore correct rather than lucky.
Follow-ups you should expect: ” up to ?” — becomes uncomfortable,
so look at the structure. “Sum per day instead of maximum?” — prefix sums make each
group cost , 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 , roughly with . Space .
k = 2k = 2always 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]withk = 3k = 3is-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 == jbase gives — and note[1][1]withk = 2k = 2passes the divisibility check becausen - 1n - 1is 0. piles == 1piles == 1recurses intodp(i, j, k)dp(i, j, k), the same range with a different pile count. It looks circular but is not:pilespilesstrictly decreases toward the split cases, so the recursion terminates.- The range sum is charged exactly once per merge, at the moment
kkpiles become 1. Charging it anywhere else double-counts, which is the most common wrong recurrence.
Follow-ups you should expect: “Why is acceptable?” — n <= 30n <= 30 says so;
at 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 over is
minimised at the median, not the mean. The argument: moving right by
changes the total by , 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 for the cost table plus for the DP — and
at . The cost table can be built in 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 .
- 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 == 0with houses remaining is infinity, not 0. Without that guard the DP would happily serve houses with no mailbox.k == nk == ngives 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
kkand extra boxes could always be dumped on an existing house at no cost. Requiringboxes == 0boxes == 0ati == ni == nis 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: ”?” — the cost function satisfies the quadrangle inequality, so divide-and-conquer optimisation brings the DP to ; 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 -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
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 902 | Numbers At Most N Given Digit Set | Hard | Digit DP over a restricted digit alphabet, plus a length-based combinatorial count for numbers shorter than NN |
| 1425 | Constrained Subsequence Sum | Hard | The monotonic-deque DP above, applied directly |
| 1696 | Jump Game VI | Medium | The 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 toNNwithout 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 sliding-window-max transition
into by keeping the deque’s indices’
dpdpvalues 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 coffeeWas this page helpful?
Let us know how we did
