Dynamic Programming/Digit & Probability DP

Lesson 8.114,043 words

Digit & Probability DP

Two DP patterns with unusual state. Digit DP counts the integers in a range [L,R][L, R] that satisfy a digit constraint by walking the decimal places of the bound, carrying a tight flag that marks when the prefix still equals the bound's.

╌╌╌╌

Most dynamic programs in this section index their subproblems by something you can point at: a prefix of an array, a remaining capacity, a subset encoded as a mask. Two classic patterns are harder to see because their state lives in the structure of a single object — the decimal digits of a number, or the probabilities on the edges of a process — rather than in an input array. Both still rest on the same two requirements from the principles lesson: overlapping subproblems and a substructure that lets us recombine them. What changes is what the state must remember and what value it accumulates.

Digit DP: counting numbers, not enumerating them

Many problems ask: how many integers in satisfy some property of their digits? — no 4 anywhere, digit sum divisible by , no two equal adjacent digits, at most three distinct digits. The ranges are astronomical ( up to ), so we cannot loop . But the property depends only on the digits, and there are at most of them, so a DP over digit positions runs in time proportional to the number of digits, not their value.

The first move is a standard reduction. Let count the valid integers in . Then the count in is , so it suffices to solve the prefix problem , counting valid numbers from up to a single upper bound .

Why a plain digit-by-digit count is not enough

Fix and write it as a digit string . We build a candidate number one digit at a time, most-significant first, and at each position choose a digit. The complication is the upper bound. As long as every digit we have placed so far equals the corresponding digit of , the number is still pinned to the boundary, so the next digit may range only up to — go higher and we exceed . But the moment we place a digit strictly below, the prefix drops under , and every remaining position is free to use any digit through without risk of overflow.

That single bit of history — is the prefix still equal to 's prefix? — is exactly the extra state digit DP needs. Call it the tight flag.

Building a number under the bound , most-significant digit first. The single bold path stays tight (each placed digit equals 's digit, so the next digit is capped at ): cap , then , then , ending at itself. Any branch placing a digit below the cap turns free ("low" at the units), after which all later digits range over to .

The figure shows the crux: there is exactly one tight path at any depth — the one that has matched digit-for-digit — and it is the only place where the next digit is capped. Everything hanging off a below-cap choice is free, and free subtrees with the same remaining length and the same carried information are identical, which is precisely the overlapping-subproblem structure that makes this a DP and not a enumeration.1

The state and recurrence

Beyond position and the tight flag, the state must carry whatever the property needs to be checkable incrementally. For digit sum that is the running sum modulo ; for no 4 it is nothing extra (we just forbid the digit 4); for no equal adjacent digits it is the previous digit. Write that problem-specific carry as .

At position the choosable digits are , where when tight and when free. Placing advances to the next position, updates the carry to , and keeps the prefix tight only if it was tight and hit the cap:

The base case is reaching past the last position, : return if the accumulated carry marks a valid number (e.g. for the divisibility property) and otherwise. The answer is , starting tight at the most-significant digit.

Why memoization collapses the tree, for the bound . The single blue column is the tight boundary path (positions ): visited exactly once and never cached, since it depends on 's own digits. Every below-cap choice drops into the free region, where states are keyed only by and are shared (memoized) across all the prefixes that reach them — the boxed free-state at each position stands for one cached entry reused by many branches

This is why the carry must be chosen to be exactly the information the validity test consumes — no more (or the state space blows up) and no less (or the substructure breaks). Memoizing on collapses the exponential tree to a table.2

Algorithm 1:DigitCount(N)\textsc{DigitCount}(N) — count valid integers in [0,N][0, N], memoized
  1. 1
    DD \gets decimal digits of NN, most-significant first, length mm
  2. 2
    memo\textit{memo} \gets empty map
    keyed by (i,s)(i, s), free case only
  3. 3
    function Rec(i,s,tight)\textbf{function } \textsc{Rec}(i, s, \textit{tight}):
  4. 4
    if i=1i = -1 then return [s is accepting]\textbf{return } [\,s \text{ is accepting}\,]
    1 or 0
  5. 5
    if not tight\textit{tight} and (i,s)memo(i, s) \in \textit{memo} then return memo[i,s]\textit{memo}[i, s]
  6. 6
    capD[i]\text{cap} \gets D[i] if tight\textit{tight} else 99
  7. 7
    total0\textit{total} \gets 0
  8. 8
    for d0d \gets 0 to cap\text{cap} do
  9. 9
    if dd is forbidden given ss then continue
    property-specific prune
  10. 10
    totaltotal+Rec(i1, Step(s,d), tight and d=D[i])\textit{total} \gets \textit{total} + \textsc{Rec}\parens{i-1,\ \textsc{Step}(s,d),\ \textit{tight} \text{ and } d = D[i]}
  11. 11
    if not tight\textit{tight} then memo[i,s]total\textit{memo}[i, s] \gets \textit{total}
  12. 12
    return total\textbf{return } \textit{total}
  13. 13
    return Rec(m1, s0, true)\textbf{return } \textsc{Rec}(m-1,\ s_0,\ \textbf{true})

To ground the recurrence, count the integers in that contain no digit 4. Here the carry is empty — validity depends only on forbidding the digit 4 — so a free state is keyed by position alone. Let be the number of valid ways to fill remaining free positions: each place picks any of the non-4 digits, so , giving , , . Now walk the tight path of , and at each position sum the free completions of the below-cap digits:

  • Hundreds (cap ): a leading digit in is below the cap and none is 4, so each opens free completions: . Digit 3 keeps the prefix tight and continues down.
  • Tens (cap , prefix 3): below-cap digits are valid, each with completions: . Digit 2 stays tight.
  • Units (cap , prefix 32): below-cap digits are valid (excluding 4, which is below the cap but forbidden), contributing . Digit 5 completes the tight path at itself, which contains no 4, so it counts as .

Summing the tight path: , matching a brute-force count over all integers. The DP touched three positions instead of enumerating them.

Note the memo caches only the free states: tight states lie on the single boundary path, are visited once, and depend on 's specific digits, so caching them would be both useless and unsound across different bounds. With positions, a carry from a small set , and digit choices, the running time is — for no 4 that is a few hundred operations to count over . Count Numbers with Unique Digits and Numbers With Repeated Digits are this template with tracking a -bit mask of used digits; the latter is cleanest as .3

digit_dp.pypython
from typing import Callable, Generic, Hashable, TypeVar

# the problem-specific summary carried alongside (position, tight).
Carry = TypeVar("Carry", bound=Hashable)

class DigitProperty(Generic[Carry]):
  """
    A digit property expressed as the three pieces a digit DP needs:\n
    the initial carry, how placing a digit updates it, and whether a fully\n
    built number with a given carry is accepted. Choose `Carry` to be exactly\n
    the information the validity test consumes — no more (the table blows up)\n
    and no less (the substructure breaks).\n
  """

  def __init__(
    self,
    initial: Carry,
    step: Callable[[Carry, int, int], Carry],
    accept: Callable[[Carry], bool],
  ) -> None:
    """
      `initial` is the carry before any digit is placed. `step(carry, digit,\n
      position)` returns the carry after appending `digit` at `position` (the\n
      number of less-significant places still to fill). `accept(carry)` decides\n
      whether a completed number with that carry counts.\n
    """
    self.initial: Carry = initial
    self.step: Callable[[Carry, int, int], Carry] = step
    self.accept: Callable[[Carry], bool] = accept

def count_up_to(bound: int, property: DigitProperty[Carry]) -> int:
  """
    Count integers in [0, bound] whose digits satisfy `property`.\n
    This is the prefix count f(N) of the lesson's DigitCount(N).\n
  """
  if bound < 0:
    return 0

  # decompose the bound into its decimal digits, most-significant first.
  digits: list[int] = [int(character) for character in str(bound)]
  length: int = len(digits)

  # memo holds only free states, keyed by (position, carry); tight states sit
  # on the single boundary path, are visited once, and depend on the bound's
  # own digits, so caching them would be unsound across different bounds.
  memo: dict[tuple[int, Carry], int] = {}

  def recurse(position: int, carry: Carry, tight: bool) -> int:
    # a completed number counts iff its final carry is accepted.
    if position == length:
      return 1 if property.accept(carry) else 0

    # free states repeat across the tree, so serve them from the memo.
    if not tight and (position, carry) in memo:
      return memo[(position, carry)]

    # tight rows are capped at the bound's digit; free rows span 0..9.
    cap: int = digits[position] if tight else 9
    remaining: int = length - position - 1

    # try every digit, staying tight only while we match the cap.
    total: int = 0
    for digit in range(cap + 1):
      next_carry: Carry = property.step(carry, digit, remaining)
      total += recurse(position + 1, next_carry, tight and digit == cap)

    # cache free states only; tight states live on the unique boundary path.
    if not tight:
      memo[(position, carry)] = total
    return total

  return recurse(0, property.initial, True)

def count_in_range(low: int, high: int, property: DigitProperty[Carry]) -> int:
  """
    Count integers in [low, high] satisfying `property`, via f(high) -\n
    f(low - 1). Both bounds are inclusive.\n
  """
  if low > high:
    return 0
  return count_up_to(high, property) - count_up_to(low - 1, property)

def without_forbidden_digit(forbidden: int) -> DigitProperty[bool]:
  """
    Property: the digit `forbidden` (e.g. 4) appears nowhere. The carry is a\n
    single bool — True while the number is still clean.\n
  """
  return DigitProperty(
    initial=True,
    step=lambda clean, digit, _position: clean and digit != forbidden,
    accept=lambda clean: clean,
  )

def digit_sum_divisible_by(modulus: int) -> DigitProperty[int]:
  """
    Property: the sum of digits is divisible by `modulus`. The carry is the\n
    running digit sum reduced modulo `modulus`.\n
  """
  return DigitProperty(
    initial=0,
    step=lambda residue, digit, _position: (residue + digit) % modulus,
    accept=lambda residue: residue == 0,
  )

Probability & Expectation DP: averaging instead of optimizing

The second pattern keeps the DP skeleton but changes the operator. An optimization DP combines child values with or ; a probability DP combines them with a probability-weighted sum. The value stored in a state is no longer the best you can do from here but the expected value of the random process started from here. The key fact is linearity of expectation: the expected value of a state is the average of its successors' expected values, each weighted by the transition probability — and this holds whether or not the transitions are independent.

The classic instance is the expected number of steps to absorption in a random walk. Consider a process that moves between states by chance and eventually reaches a terminal (absorbing) state. We want the expected number of steps from each starting state.

A small absorbing process. From state , with probability we step to and with probability to the absorbing ; from we go to or to , each with probability (every edge is labelled "half"). is the expected steps to reach End, and solving the system gives , , .

Each step costs , and after that step we are in a successor chosen with probability , from which the remaining expected cost is . Linearity of expectation turns this into one equation per state:

The is the step just taken; the sum is the expected remaining cost, averaged over where that step lands.4 For the figure, and ; solving the two-by-two system gives and .

When is this a DP, and when a linear system?

The recurrence above is a genuine DP — solvable by memoization or a bottom-up sweep — exactly when the dependency graph on states is acyclic, so that each depends only on states already computed. That is the common, easy case: games that strictly advance (a counter that only grows, a round number that only increases, soup that only gets consumed), where you evaluate states in reverse topological order and read off the answer.

The same shape computes expected values other than step counts — replace the constant by the per-step reward, or drop it and let an accepting state contribute its payoff — and hitting probabilities, where with boundary states pinned to or . The acyclic, DP-friendly versions are the staple of competitive problems.

A worked acyclic example: expected die rolls to a target

Roll a fair -sided die repeatedly, summing the pips, and stop the instant the running total is . What is the expected number of rolls? Let be the expected additional rolls when the current total is (and for ). One roll lands on each with probability :

Because every transition strictly increases , the dependency graph is acyclic and we fill from downward — a textbook one-dimensional DP. Soup Servings is the same shape in two dimensions (the two soup volumes only decrease, so the state space is acyclic and finite), and New 21 Game is a probability-DP over a sliding window of point totals, made by maintaining a running sum of the window of -values rather than re-summing each transition.

The acyclic dependency graph for expected die rolls with target . Each total depends only on the larger totals (one die step), so every edge points rightward and the graph has no cycle. Totals are absorbing with (green); evaluating right-to-left (reverse topological order) finalizes each from values already known. Blue arrows show the six transitions out of one state
Algorithm 2:ExpectedRolls(T)\textsc{ExpectedRolls}(T) — expected fair-die rolls to reach total T\ge T
  1. 1
    E[t]0E[t] \gets 0 for all tTt \ge T
  2. 2
    for tT1t \gets T - 1 down to 00 do
  3. 3
    s0s \gets 0
  4. 4
    for k1k \gets 1 to 66 do
  5. 5
    ss+E[t+k]s \gets s + E[t + k]
    each outcome with prob 1/61/6
  6. 6
    E[t]1+s/6E[t] \gets 1 + s / 6
    the 11 counts this roll
  7. 7
    return E[0]\textbf{return } E[0]

The reverse-topological sweep is what makes this and not a linear-system solve: each reads only larger totals, already finalized. The window sum trick (subtract the leaving term, add the entering one) drops the inner loop and yields even when the die has many faces — the same optimization that turns New 21 Game from into .

expected_die_rolls.pypython
def expected_die_rolls(target: int, faces: int = 6) -> float:
  """
    Expected number of fair `faces`-sided rolls until the running total\n
    reaches at least `target`, starting from total 0. A non-positive target\n
    needs no rolls.\n
  """
  if faces < 1:
    raise ValueError("a die needs at least one face")
  if target <= 0:
    return 0.0

  # expected[total] = expected additional rolls from this total; totals >=
  # target are absorbing with value 0 and are read as such past the array end.
  expected: list[float] = [0.0 for _ in range(target + 1)]

  # window_sum tracks E[total+1] + ... + E[total+faces] so the inner loop over
  # outcomes collapses to one subtract-and-add per state.
  window_sum: float = 0.0
  for total in range(target - 1, -1, -1):
    # slide the window to cover total+1..total+faces: add the new low end,
    # drop the outcome that just fell off the high end.
    window_sum += expected[total + 1] if total + 1 <= target else 0.0
    leaving: int = total + 1 + faces
    if leaving <= target:
      window_sum -= expected[leaving]

    # one roll plus the average of where it lands.
    expected[total] = 1.0 + window_sum / faces

  return expected[0]
absorbing_markov_chain.pypython
from typing import Generic, Hashable, TypeVar

# the label identifying a state of the chain.
Label = TypeVar("Label", bound=Hashable)

class State(Generic[Label]):
  """
    One state of the chain: its label and its outgoing transitions as a map\n
    from successor label to probability. An empty transition map marks an\n
    absorbing state — the process stops there.\n
  """

  def __init__(self, label: Label) -> None:
    self.label: Label = label
    self.transitions: dict[Label, float] = {}

  def add_transition(self, target: Label, probability: float) -> None:
    """
      Record a step to `target` taken with `probability`.\n
    """
    self.transitions[target] = self.transitions.get(target, 0.0) + probability

  @property
  def is_absorbing(self) -> bool:
    """
      Whether this state has no outgoing transitions.\n
    """
    return len(self.transitions) == 0

  def __repr__(self) -> str:
    return f"State({self.label!r})"

class MarkovChain(Generic[Label]):
  """
    A discrete-time Markov chain over labelled states.\n
    States with no outgoing transitions are absorbing.\n
  """

  def __init__(self) -> None:
    self._states: dict[Label, State[Label]] = {}

  def state(self, label: Label) -> State[Label]:
    """
      The state for `label`, creating it on first reference.\n
    """
    if label not in self._states:
      self._states[label] = State(label)
    return self._states[label]

  def add_transition(
    self, source: Label, target: Label, probability: float
  ) -> None:
    """
      Add a `source -> target` step taken with `probability`. Both states are\n
      created as needed.\n
    """
    self.state(target)
    self.state(source).add_transition(target, probability)

  @property
  def labels(self) -> list[Label]:
    """
      Every state label, in insertion order.\n
    """
    return list(self._states)

  def expected_steps_to_absorption(self) -> dict[Label, float]:
    """
      For each state, the expected number of steps to reach any absorbing\n
      state, solving E = 1 + P E over the transient states by Gaussian\n
      elimination. Handles cyclic chains where a reverse sweep cannot.\n
    """
    # index the transient states; absorbing states are pinned to 0 separately.
    transient: list[Label] = [
      label for label in self._states if not self._states[label].is_absorbing
    ]
    position: dict[Label, int] = {
      label: index for index, label in enumerate(transient)
    }
    size: int = len(transient)

    # build (I - Q) E = 1 over transient states; absorbing successors carry
    # E = 0 and so drop out of each row.
    matrix: list[list[float]] = [[0.0 for _ in range(size)] for _ in range(size)]
    constants: list[float] = [1.0 for _ in range(size)]
    for label in transient:
      row: int = position[label]
      matrix[row][row] = 1.0
      for target, probability in self._states[label].transitions.items():
        if target in position:
          matrix[row][position[target]] -= probability

    # solve, then scatter the answers back; absorbing states keep their 0.
    solution: list[float] = _solve_linear_system(matrix, constants)
    expected: dict[Label, float] = {label: 0.0 for label in self._states}
    for label in transient:
      expected[label] = solution[position[label]]
    return expected

  def hitting_probability(self, target_set: set[Label]) -> dict[Label, float]:
    """
      For each state, the probability of ever reaching a state in\n
      `target_set`. Targets are pinned to 1; other absorbing states to 0;\n
      transient states solve P[v] = sum_u p(v, u) P[u] by Gaussian\n
      elimination.\n
    """
    # transient states exclude targets (pinned to 1) and other absorbers (0).
    transient: list[Label] = [
      label
      for label in self._states
      if not self._states[label].is_absorbing and label not in target_set
    ]
    position: dict[Label, int] = {
      label: index for index, label in enumerate(transient)
    }
    size: int = len(transient)

    # build P[v] = sum_u p(v, u) P[u]: a step into a target adds its known 1
    # to the constant; a step into another transient stays on the left.
    matrix: list[list[float]] = [[0.0 for _ in range(size)] for _ in range(size)]
    constants: list[float] = [0.0 for _ in range(size)]
    for label in transient:
      row: int = position[label]
      matrix[row][row] = 1.0
      for target, probability in self._states[label].transitions.items():
        if target in target_set:
          constants[row] += probability
        elif target in position:
          matrix[row][position[target]] -= probability

    # pin the boundary: targets hit with probability 1, other absorbers 0.
    solution: list[float] = _solve_linear_system(matrix, constants)
    probability_of_hit: dict[Label, float] = {}
    for label in self._states:
      if label in target_set:
        probability_of_hit[label] = 1.0
      elif self._states[label].is_absorbing:
        probability_of_hit[label] = 0.0

    # fill in the solved transient probabilities.
    for label in transient:
      probability_of_hit[label] = solution[position[label]]
    return probability_of_hit

def _solve_linear_system(
  matrix: list[list[float]], constants: list[float]
) -> list[float]:
  """
    Solve `matrix @ solution = constants` by Gaussian elimination with partial\n
    pivoting. Returns the solution vector; an empty system yields an empty\n
    vector.\n
  """
  size: int = len(constants)
  if size == 0:
    return []

  # work on an augmented copy so the caller's data is untouched.
  augmented: list[list[float]] = [
    matrix[row][:] + [constants[row]] for row in range(size)
  ]

  for column in range(size):
    # partial pivot: bring the largest-magnitude entry into the pivot row.
    pivot_row: int = max(
      range(column, size),
      key=lambda candidate: abs(augmented[candidate][column]),
    )
    if abs(augmented[pivot_row][column]) < 1e-12:
      raise ValueError("singular system: no unique solution")
    augmented[column], augmented[pivot_row] = (
      augmented[pivot_row],
      augmented[column],
    )

    # eliminate this column from every other row.
    pivot: float = augmented[column][column]
    for target_row in range(size):
      if target_row == column:
        continue
      factor: float = augmented[target_row][column] / pivot
      for term in range(column, size + 1):
        augmented[target_row][term] -= factor * augmented[column][term]

  # the matrix is now diagonal: read each unknown straight off its row.
  return [augmented[row][size] / augmented[row][row] for row in range(size)]

What these two patterns really are

Digit DP is a special case of a much older idea: counting the accepting paths of a finite automaton over a bounded input. The digit-by-digit walk traces a run of a deterministic automaton whose states are the carries , and the count valid numbers question is the automaton-intersection count between that property-automaton and the less-than-or-equal-to- automaton — the tight flag is the state of the second automaton. Once framed this way, digit DP generalizes far past base : the same technique counts binary strings avoiding a pattern (a transfer-matrix / regular-language count), lattice points under algebraic constraints, and, in the theory of automatic sequences (Allouche and Shallit, Automatic Sequences, 2003), whole families of number-theoretic counting functions. For a fixed carry-automaton the count is a transfer-matrix product, so can even be computed by matrix exponentiation in when the number of positions is huge.5

Expectation DP is standard Markov chain theory. The system is the standard expected hitting time to an absorbing set, and the matrix that makes it solvable is the fundamental matrix of the transient part , whose row sums give the expected steps to absorption (Kemeny and Snell, Finite Markov Chains, 1960). The acyclic DP case is precisely when is nilpotent (can be permuted to strictly triangular form), so the inverse is a finite sum and no linear solve is needed — the reverse topological sweep is Gaussian elimination that happens to require no back-substitution. This is the same object behind absorbing random walks on graphs, the gambler's ruin problem, and the stationary analysis that underlies Google's original PageRank (Page, Brin, Motwani, Winograd, 1999), where the relevant quantity is the stationary distribution of a chain rather than a hitting time, but the linear-algebra machinery is identical. The dividing line this lesson draws — acyclic means DP, cyclic means solve the system — is the boundary between a nilpotent and a general transient matrix.

Takeaways

  • Digit DP counts integers in with a digit property by solving the prefix problem and using ; it walks the decimal positions of the bound rather than the values.
  • The essential extra state is the tight flag: while the built prefix equals 's prefix the next digit is capped at ; the first below-cap digit makes it free, after which all later digits range over . State is , and only free states are memoized.
  • Choose the carry to be exactly what the validity test reads (running sum mod , previous digit, used-digit mask) — no more, no less — so the substructure holds and the table stays small.
  • Expectation DP stores = expected value from state and combines children with a probability-weighted sum, , by linearity of expectation; absorbing states have .
  • It is a plain DP when the state graph is acyclic (quantities that only advance) — evaluate in reverse topological order — and a linear system when transitions form cycles, solved by Gaussian elimination instead.

Footnotes

  1. CLRS, Ch. 15 — Dynamic Programming: overlapping subproblems and optimal substructure, the two conditions that let digit and expectation DPs reuse subproblem solutions across a position/carry state.
  2. Erickson, Ch. — Dynamic Programming: the discipline of identifying the minimal summary a subproblem must carry — here the tight flag plus a problem-specific digit carry, or the expected-value scalar per state.
  3. Skiena, § — Dynamic Programming / Combinatorics: counting DPs that accumulate a sum over choices rather than a min/max, with bounded-range digit counting as a recurring instance.
  4. CLRS, App. C — Counting & Probability (C.3, indicator random variables): linearity of expectation holds without independence, which is what licenses as a per-state recurrence.
  5. Allouche & Shallit, Automatic Sequences (2003) for the finite-automaton / transfer-matrix view of digit counting; Kemeny & Snell, Finite Markov Chains (1960) for the fundamental matrix giving expected steps to absorption, of which the acyclic expectation DP is the nilpotent- special case.
Practice

╌╌ END ╌╌