Week 18: Advanced Dynamic Programming — Bitmask & Digit DP

Weeks 13 and 14 defined DP state with a single index, a pair of indices, or a range — state that was always some small handful of integers. This week introduces state that's an entire set: bitmask DP represents "which subset of items has been used so far" as a single integer, unlocking a class of problems — the Traveling Salesman Problem chief among them — that look exponential until you notice the number of distinct subsets is itself something a DP table can index by. Digit DP applies the same "compress a large space into a manageable state" idea to counting problems over huge numeric ranges, using the digits of a number as the recursion's positions instead of an array's indices.

Module 15 of 17 Week 18 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Solve the Traveling Salesman Problem with bitmask DP in O(2ⁿ · n²) instead of O(n!)
  • Recognize when a "which subset has been used" state should be represented as a bitmask
  • Write a digit DP to count numbers with a property across a range without enumerating every number

1. Bitmask DP: The Traveling Salesman Problem

The Traveling Salesman Problem asks for the minimum-cost route that visits every city exactly once and returns to the start. Trying every permutation of cities is O(n!) — for n = 15 that's already over a trillion routes. The key realization: the cost of the rest of a route only depends on the current city and which set of cities has already been visited, not on the specific order they were visited in. That set has 2ⁿ possible values, small enough to index directly — represent it as an n-bit integer, where bit i means "city i has been visited."

tsp_bitmask.cpp
#include <vector>
#include <climits>
using namespace std;

// dist[i][j] = cost to travel directly from city i to city j
// dp[mask][i] = min cost to have visited exactly the cities in `mask`, ending at city i
// O(2^n * n^2) time, O(2^n * n) space
int tsp(vector<vector<int>>& dist) {
    int n = dist.size();
    int fullMask = (1 << n) - 1;
    vector<vector<int>> dp(1 << n, vector<int>(n, INT_MAX));

    dp[1][0] = 0;   // start at city 0, having visited only city 0 (bit 0 set)

    for (int mask = 1; mask <= fullMask; mask++) {
        for (int last = 0; last < n; last++) {
            if (!(mask & (1 << last))) continue;         // last must be in this mask
            if (dp[mask][last] == INT_MAX) continue;      // unreachable state

            for (int next = 0; next < n; next++) {
                if (mask & (1 << next)) continue;         // next already visited
                int newMask = mask | (1 << next);
                int newCost = dp[mask][last] + dist[last][next];
                if (newCost < dp[newMask][next]) {
                    dp[newMask][next] = newCost;
                }
            }
        }
    }

    // Close the tour: return from every possible last city back to city 0
    int best = INT_MAX;
    for (int last = 1; last < n; last++) {
        if (dp[fullMask][last] != INT_MAX) {
            best = min(best, dp[fullMask][last] + dist[last][0]);
        }
    }
    return best;
}

dp[mask][last] means "the minimum cost of a partial route that has visited exactly the cities marked in mask, currently sitting at city last." There are 2ⁿ masks and n possible last cities, so the table has O(2ⁿ · n) entries, and filling each one considers up to n transitions — giving O(2ⁿ · n²) overall. For n = 15, that's roughly 7 million operations instead of a trillion-plus permutations — the difference between infeasible and instant.

Bitmask operations you'll use constantly

mask | (1 << i) sets bit i (marks city i visited). mask & (1 << i) tests bit i (is city i visited?). mask & ~(1 << i) clears bit i. These three operations, combined with iterating mask from 0 to 2ⁿ - 1, are the entire vocabulary bitmask DP is built from.

2. Bitmask DP: The Assignment Problem

The assignment problem: given n workers and n tasks, with cost[i][j] for assigning worker i to task j, find the minimum-cost way to assign every worker to exactly one task. This is the same bitmask shape as TSP, but the mask tracks "which tasks have been assigned" while workers are processed one at a time in a fixed order — since every worker gets assigned exactly once, you don't need the mask to also track which workers are done.

assignment_bitmask.cpp
#include <vector>
#include <climits>
using namespace std;

// dp[mask] = min cost to assign the first popcount(mask) workers to the tasks in `mask`
// O(2^n * n) time
int minAssignmentCost(vector<vector<int>>& cost) {
    int n = cost.size();
    int fullMask = (1 << n) - 1;
    vector<int> dp(1 << n, INT_MAX);
    dp[0] = 0;   // no workers assigned yet, no tasks used

    for (int mask = 0; mask < fullMask; mask++) {
        if (dp[mask] == INT_MAX) continue;
        int worker = __builtin_popcount(mask);   // the next worker to assign
        for (int task = 0; task < n; task++) {
            if (mask & (1 << task)) continue;     // task already taken
            int newMask = mask | (1 << task);
            int newCost = dp[mask] + cost[worker][task];
            if (newCost < dp[newMask]) dp[newMask] = newCost;
        }
    }
    return dp[fullMask];
}

__builtin_popcount(mask) counting the set bits is what recovers "which worker are we assigning next" without needing a second dimension in the DP table — since exactly popcount(mask) workers must have been assigned to reach a state with popcount(mask) tasks used. That collapses this problem's state from TSP's O(2ⁿ · n) down to O(2ⁿ), and the transition cost from O(n) to O(n) per mask but without the extra last dimension — an O(2ⁿ · n) algorithm overall, one factor of n cheaper than TSP.

Ask "does the state need to track order?"

TSP's state needed last because the cost of the next move depends on which city you're currently at. The assignment problem doesn't need an equivalent, because popcount(mask) already implies which worker is next — always check whether the "current position" your DP wants to track is actually recoverable from the mask alone before adding it as a separate dimension.

3. Digit DP: Counting Numbers with a Property in a Range

Digit DP answers questions like "how many numbers in [1, N] have digit sum divisible by 7?" — where N can be up to 10¹⁸, far too large to check one number at a time. The state is built from the digits of N instead of the number's value: process one digit position at a time, tracking whether the number built so far is still "tight" against N's corresponding prefix (constraining which digits are legal next) or already strictly smaller (in which case any digit is legal).

digit_dp.cpp
#include <string>
#include <vector>
using namespace std;

class DigitCounter {
public:
    // Count integers in [0, N] whose digit sum is divisible by `divisor`
    long long countWithDigitSumDivisible(long long N, int divisor) {
        digits = to_string(N);
        this->divisor = divisor;
        memo.assign(digits.size(), vector<vector<long long>>(
            divisor, vector<long long>(2, -1)));
        return solve(0, 0, true);
    }

private:
    string digits;
    int divisor;
    // memo[position][digitSumSoFar % divisor][isTight] -- -1 means "not computed yet"
    vector<vector<vector<long long>>> memo;

    long long solve(int pos, int sumMod, bool tight) {
        if (pos == (int)digits.size()) {
            return sumMod == 0 ? 1 : 0;   // reached the end: does the digit sum qualify?
        }
        if (!tight && memo[pos][sumMod][0] != -1) {
            return memo[pos][sumMod][0];
        }

        int limit = tight ? (digits[pos] - '0') : 9;
        long long count = 0;

        for (int d = 0; d <= limit; d++) {
            bool nextTight = tight && (d == limit);
            count += solve(pos + 1, (sumMod + d) % divisor, nextTight);
        }

        if (!tight) memo[pos][sumMod][0] = count;   // only cache the "free" (non-tight) states
        return count;
    }
};

Memoization only applies to tight == false states: once a number's prefix is strictly smaller than N's, the remaining choices don't depend on N at all, so that subproblem's answer is reusable across every branch that reaches the same (position, sumMod) with slack already used up. tight == true states are never reusable — they're only ever reached along the single path that exactly matches N's prefix, so there's at most one such state per digit position and caching them buys nothing. This is what keeps the whole search to O(digits · divisor) distinct memoized states, each doing O(10) work, despite N itself being astronomically large.

Range queries: subtract, don't re-derive

For "count numbers in [L, R]" rather than [0, N], call countWithDigitSumDivisible(R, divisor) - countWithDigitSumDivisible(L - 1, divisor) — the same prefix-sum-style trick from Week 2, applied to a digit DP instead of an array, rather than trying to build a digit DP that handles an arbitrary lower bound directly.

4. Recognizing State-Compression Problems

Bitmask DP and digit DP are both instances of the same underlying move: when a problem's naive state space is enormous but the actual number of distinct states that matter is small, find a compact encoding for that smaller space and index a DP table by it directly. Two signals point to bitmask DP specifically: the number of "items" involved is small (typically n ≤ 20, since 2ⁿ needs to stay computationally reasonable), and the problem cares about which subset has been used, not the order within it. Digit DP's signal is different: the input is a huge numeric range and the property being counted depends on the number's digits (digit sum, count of a specific digit, no two adjacent digits equal) rather than its magnitude directly.

Check the constraints first

A constraint like 1 ≤ n ≤ 20 alongside an otherwise combinatorial-sounding problem is close to a direct hint that bitmask DP is intended — 2²⁰ is about a million, comfortably within a DP table's reach, while 20! is not. Reading the constraints before the problem statement is often the fastest way to recognize this pattern under time pressure.

5. Hands-on Exercise

Hands-on

Build and benchmark a state-compression toolkit

Implement all three algorithms and confirm each against a brute-force baseline on small inputs.

Requirements:

  1. Implement tsp for a 4-city graph you can also solve by hand-checking all 3! = 6 possible routes (fixing city 0 as the start); confirm the answers match.
  2. Implement minAssignmentCost for a 4x4 cost matrix and confirm it matches a brute-force check of all 4! = 24 possible assignments.
  3. Implement countWithDigitSumDivisible and, for a small N like 100, confirm it matches a brute-force loop that checks every number from 0 to N directly.
  4. Time tsp for n = 12, n = 15, and n = 18, and record how the runtime scales — confirm it tracks 2ⁿ far more closely than n!.
  5. Extend countWithDigitSumDivisible into a range version using the subtraction trick from the callout above, and test it on [50, 150].
Hint

For requirement 4, avoid running n = 18 against a true brute-force baseline — 17! is far too large to compute in reasonable time. Compare bitmask-DP runtimes to each other across increasing n, not to a brute-force baseline that stops being feasible past roughly n = 10.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

In the TSP bitmask DP, what does the state dp[mask][last] represent, and why does it need both a mask and a last dimension?

dp[mask][last] is the minimum cost of a partial route that has visited exactly the cities in mask, currently ending at city last. The mask alone isn't enough because the cost of the next move depends on which specific city the route is currently at — two different partial routes that have visited the same set of cities but end at different cities can have very different costs for what comes next, so last has to be tracked separately.

Q2

Why does the assignment problem's bitmask DP not need a last-style dimension the way TSP does?

Workers are assigned in a fixed order, one per step, so the number of tasks already assigned (popcount(mask)) always tells you exactly which worker is being assigned next — that information doesn't need to be stored separately because it's fully recoverable from the mask itself, unlike TSP where the identity of the current city isn't implied by the visited set.

Q3

In digit DP, why are only the tight == false states memoized, and not the tight == true states?

A tight state is only ever reached along the single path whose digits exactly match N's prefix so far, so there is at most one tight state per digit position — it's never revisited, so caching it saves nothing. A non-tight state, once a digit has gone strictly below N's corresponding digit, no longer depends on N at all, and many different branches of the recursion can arrive at the same (position, digit-sum-so-far) combination, which is exactly what memoization is for.

Q4

What constraint on n in a problem statement is the strongest signal that bitmask DP is the intended approach, and why that specific range?

A constraint like n ≤ 20 on a problem that otherwise sounds combinatorial (visiting every item, assigning every item, or covering every item) is the signal — 2²⁰ is about a million, small enough for a DP table indexed by subset to run comfortably within typical time limits, while the true combinatorial count (like 20! permutations) would be far too large. Larger bounds like n ≤ 10⁵ rule bitmask DP out entirely, since even 2¹⁰⁰⁰ is astronomically infeasible.