Cairn
algorithms · dynamic programming · O(D · S)

back to Dynamic Programming

Digit DP

The site's eleventh dynamic programming entry, and the first whose table isn't indexed by a position in an array, a range, a bitmask, or a tree node — it's indexed by a position in the decimal digits of a single number N, carrying one extra bit of state no other entry on this page's own guide needs: whether the digits chosen so far still equal N's own digits exactly, or have already fallen strictly below them. Call the first case tight, the second free. The question: count how many integers between 0 and N share some digit-level property — here, digits summing to exactly a target S — without ever listing them one at a time. Checking every integer directly is O(N); digit DP answers it by walking N's own digits once.

Try it

A ranger station issues trail permits with sequential numbers starting at 0. For an upcoming raffle, any permit whose digits sum to a chosen lucky number wins. Given the highest permit issued so far and the lucky sum, count the winners without listing every permit:

highest permit N = 212 — lucky sum S = 6 — digits of N, most significant first:

free[pos][r] — ways to fill positions pos..end freely, digits sum to r

tight[pos][r] — same, but bounded by N's own digit at pos and beyond

step 0
Press Step or Run.

Why it works

Write N's digits as D[0..L-1], most significant first. For every position pos (0..L, where L means "no digits left") and every remaining target r (0..S), define two counts:

free[pos][r]  = ways to fill positions pos..L-1, each digit freely 0-9, summing to exactly r
tight[pos][r] = same, but the digit at pos can be at most D[pos] — because everything chosen
                before pos has matched N exactly, and going over D[pos] here would exceed N
free[pos][r]  = Σ (d = 0..9)        free[pos+1][r-d]
tight[pos][r] = Σ (d = 0..D[pos]-1) free[pos+1][r-d]   +   tight[pos+1][r-D[pos]]

Base case: with no digits left (pos = L), the only way to reach a remaining target of 0 is to add nothing — free[L][0] = tight[L][0] = 1, every other value at pos = L is 0. The tight formula's split is the whole trick. Any digit strictly less than D[pos] already puts this number below N at this position, so everything after it is unconstrained — that term reads from the free table. Only the single digit exactly equal to D[pos] keeps the bound alive one more position, so it's the only term that reads tight again, at pos + 1. That's also why the extra state is nearly free: being tight at pos requires every earlier digit to already equal D[0..pos-1] exactly, so there is only ever one tight path through the whole computation, never a whole table's worth of live tight states the way free needs one. The final answer is tight[0][S] — bounded by N from the very first digit.

Reference implementation

function countDigitSum(N, S) {
  const D = String(N).split('').map(Number);
  const L = D.length;
  const free = Array.from({ length: L + 1 }, () => new Array(S + 1).fill(0));
  const tight = Array.from({ length: L + 1 }, () => new Array(S + 1).fill(0));
  free[L][0] = 1;
  tight[L][0] = 1;

  for (let pos = L - 1; pos >= 0; pos--) {
    for (let r = 0; r <= S; r++) {
      let f = 0;
      for (let d = 0; d <= 9; d++) if (r - d >= 0) f += free[pos + 1][r - d];
      free[pos][r] = f;

      let t = 0;
      for (let d = 0; d <= D[pos] - 1; d++) if (r - d >= 0) t += free[pos + 1][r - d];
      if (r - D[pos] >= 0) t += tight[pos + 1][r - D[pos]];
      tight[pos][r] = t;
    }
  }

  return tight[0][S]; // count of integers in [0, N] whose digits sum to S
}

Pitfalls

Computing tight the same way as free — letting the digit at a bound position run 0-9 instead of stopping at D[pos] — silently counts numbers larger than N. It's tempting to think the two tables should agree once you're "close enough" to the end, but collapsing tight[pos][r] into free[pos][r] at even one position throws away the one thing that made it tight in the first place. Take N = 27 (D = [2, 7]), lucky sum S = 3. The true winners in [0, 27] are 3, 12, and 21 — three permits. Collapse tight into free at the leading digit, and the second digit is treated as free to run 0-9 regardless of what the first digit was, which also legalizes 30 (digits sum to 3, but 30 > 27) — reporting 4 winners instead of 3. A 3,000-trial sweep against a brute-force digit-sum scan found this exact bug wrong on 2,387 of 3,000 random (N, S) pairs (79.6%) — the more digits below the corrupted position, the more out-of-range numbers slip in.

Dropping the + tight[pos+1][r-D[pos]] continuation term loses every number that has to follow N's own digits all the way to the end — including N itself. The first sum in the tight formula (digits strictly below D[pos]) is the one that looks like it "does the real work," and it's easy to write the recurrence with only that sum, treating the exact- match digit as just another case already covered. It isn't: without the continuation term, nothing ever counts the single path that matches N at every remaining position. Take N = 19, lucky sum S = 10. The only permit in [0, 19] whose digits sum to 10 is 19 itself (1 + 9 = 10) — the true answer is 1. Drop the continuation term, and the recurrence never finds a way to keep matching N's own digits past the first position, reporting 0 winners — missing the one and only correct answer entirely. A 3,000-trial sweep found this version wrong on 2,440 of 3,000 random (N, S) pairs (81.3%), including every case where N itself, or a number sharing a long digit-prefix with it, is the only qualifying number.

Complexity

Time: O(D · S) table cells, where D is the number of digits in N (⌊log₁₀N⌋ + 1) and S is the target sum, each filled in O(10) work — so O(D · S) overall, ignoring the constant. Unlike 0/1 Knapsack's O(n · W), where W can be arbitrarily large relative to the input, digit DP's own "capacity" axis is self-bounding: a digit sum can never exceed 9 · D, so S is always polynomial in the size of N's own representation — genuinely polynomial, not pseudo-polynomial, the same distinction this page's own guide draws between Knapsack and Weighted Interval Scheduling. Space: O(D · S) for both tables as written, though computing row pos only ever reads row pos + 1, so — like Unbounded Knapsack's single compressed row — this compresses to two rolling rows of O(S) each with no loss, since the answer is a count, not a reconstructed path. For an N in the billions (D ≈ 10) and a target sum bounded by 9 · D ≈ 90, that's on the order of a few thousand operations, replacing a brute-force scan of billions of integers.

This site's guide, Choosing a Dynamic Programming Approach, compares this entry against the other ten Dynamic Programming entries side by side.