📖 第 6.5 章:数位 DP(Digit DP)
⏱ 预计阅读时间:50 分钟 | 难度:🟡 中等(USACO Gold 高频考点)
前置条件
- DP 入门(第 6.1 章)
- 经典 DP 问题(第 6.2 章)
🎯 学习目标
学完本章后,你将能够:
- 理解数位 DP 的核心框架:按位从高到低填数
- 掌握「tight(是否贴上界)」标志的作用
- 处理「前导零」问题
- 解决「区间 [L, R] 内满足某数字性质的数的个数」类问题
6.5.1 问题引入
一类常见问题
统计 1 到 N 中,满足某种「数字属性」的整数个数。
示例:
- [1, N] 中各位数字之和等于 S 的数的个数
- [1, N] 中不含数字 4 的数的个数
- [1, N] 中各位数字单调不降的数的个数
为什么不能暴力? N 可能高达 10^18,逐一枚举完全不可能。
核心思路: 像「填数字」一样,从最高位到最低位逐位枚举,用 DP 记录状态,避免重复计算。
6.5.2 数位 DP 的框架
核心状态
| 状态量 | 含义 |
|---|---|
pos | 当前填到第几位(从高位到低位) |
tight | 当前选的数字是否恰好贴着 N 的对应位(是否受上界约束) |
...其他属性... | 问题特定的属性(如各位之和、上一位的值等) |
关键逻辑
如果 tight == true:
当前位只能填 0 ~ digit[pos](digit[pos] 是 N 的第 pos 位)
填 digit[pos] 时,下一位仍然 tight
填 < digit[pos] 时,下一位不再 tight(自由了!)
如果 tight == false:
当前位可以填 0~9(自由枚举)
下一位也不 tight
6.5.3 完整例题一:各位数字之和
问题: 统计 1 到 N 中,各位数字之和恰好等于 S 的正整数个数。
#include <bits/stdc++.h>
using namespace std;
string num_str; // N 的字符串表示
int target_sum; // 目标数字和 S
// dp[pos][sum_so_far][tight][started]
// 记忆化,避免重复计算相同状态
map<tuple<int,int,bool,bool>, long long> memo;
// pos: 当前位(从 0 开始,0 是最高位)
// sum: 已填数字的和
// tight: 是否贴上界
// started: 是否已经开始(用于处理前导零)
long long solve(int pos, int sum, bool tight, bool started) {
if (sum > target_sum) return 0; // 剪枝:和已超出
if (pos == (int)num_str.size()) {
// 填完所有位
return (started && sum == target_sum) ? 1 : 0;
}
auto key = make_tuple(pos, sum, tight, started);
if (memo.count(key)) return memo[key];
int limit = tight ? (num_str[pos] - '0') : 9;
long long result = 0;
for (int d = 0; d <= limit; d++) {
bool new_tight = tight && (d == limit);
bool new_started = started || (d != 0);
int new_sum = (new_started ? sum + d : 0); // 前导零不计入和
result += solve(pos + 1, new_sum, new_tight, new_started);
}
return memo[key] = result;
}
long long count_up_to(long long N, int S) {
num_str = to_string(N);
target_sum = S;
memo.clear();
return solve(0, 0, true, false);
}
int main() {
long long L, R; int S;
cin >> L >> R >> S;
// 区间 [L, R] = f(R) - f(L-1)
cout << count_up_to(R, S) - count_up_to(L - 1, S) << "\n";
return 0;
}
追踪(N=20, S=2):
填第 0 位(最高位,limit=2):
d=0(未 started):进入 (1, 0, false, false)
填第 1 位(1-9):
d=2:(ended, sum=2, tight=false) → 1(即数字 02 = 2)
其余不满足 sum=2
d=1(started=true):进入 (1, 1, false, true)
填第 1 位(0-9):
d=1:(ended, sum=2) → 1(即 11)
d=2(tight):进入 (1, 2, false, true)
填第 1 位(0-9):
d=0:(ended, sum=2) → 1(即 20)
其他 sum > 2 → 0
合计:2(数字 2 和 11 和 20)
6.5.4 数位 DP 通用模板(更简洁版)
竞赛中通常用数组代替 map 做记忆化:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int digits[20]; // N 的各位数字(从高位到低位)
int n_digits; // 总位数
ll dp[20][200][2][2]; // dp[pos][sum][tight][started]
// 维度根据问题调整
bool computed[20][200][2][2];
int S; // 目标数字和
ll solve(int pos, int sum, bool tight, bool started) {
if (sum > S) return 0;
if (pos == n_digits) return (started && sum == S) ? 1 : 0;
ll& ret = dp[pos][sum][tight][started];
if (computed[pos][sum][tight][started]) return ret;
computed[pos][sum][tight][started] = true;
int lim = tight ? digits[pos] : 9;
ret = 0;
for (int d = 0; d <= lim; d++) {
ret += solve(pos + 1,
(started || d > 0) ? sum + d : 0,
tight && (d == lim),
started || (d > 0));
}
return ret;
}
ll count_up_to(ll N) {
n_digits = 0;
while (N > 0) { digits[n_digits++] = N % 10; N /= 10; }
reverse(digits, digits + n_digits);
memset(computed, 0, sizeof(computed));
return solve(0, 0, true, false);
}
int main() {
ll L, R; cin >> L >> R >> S;
cout << count_up_to(R) - count_up_to(L - 1) << "\n";
}
6.5.5 例题二:不含连续相同数字
问题: 统计 [L, R] 中,没有两个相邻数字相同的整数个数。
(例:123、145 满足,122、344 不满足)
额外状态: last_digit(上一位填的数字)
ll dp2[20][11][2][2]; // [pos][last_digit][tight][started]
// last_digit: 0~9 表示上一位数字,10 表示"还没开始"
ll solve2(int pos, int last, bool tight, bool started) {
if (pos == n_digits) return started ? 1 : 0;
ll& ret = dp2[pos][last][tight][started];
if (computed[pos][last][tight][started]) return ret;
computed[pos][last][tight][started] = true;
int lim = tight ? digits[pos] : 9;
ret = 0;
for (int d = 0; d <= lim; d++) {
// 已 started 时,禁止 d == last(相邻相同)
if (started && d == last) continue;
ret += solve2(pos + 1,
(started || d > 0) ? d : 10, // 前导零时 last 不更新
tight && (d == lim),
started || (d > 0));
}
return ret;
}
6.5.6 例题三:各位数字单调不降
问题: 统计 [1, N] 中,各位数字从左到右单调不降的整数个数。
(例:1359、2233 满足,132、231 不满足)
额外状态: min_digit(当前允许填的最小数字)
ll dp3[20][10][2][2]; // [pos][min_allowed][tight][started]
ll solve3(int pos, int min_d, bool tight, bool started) {
if (pos == n_digits) return started ? 1 : 0;
ll& ret = dp3[pos][min_d][tight][started];
// ... 记忆化判断 ...
int lim = tight ? digits[pos] : 9;
ret = 0;
for (int d = (started ? min_d : 0); d <= lim; d++) {
ret += solve3(pos + 1,
d, // 下一位不能小于 d
tight && (d == lim),
true);
}
return ret;
}
6.5.7 区间查询:f(R) - f(L-1)
几乎所有数位 DP 都满足区间可减性:
$$\text{count}[L, R] = \text{count}[1, R] - \text{count}[1, L-1]$$
所以 count_up_to(N) 函数是核心,用它两次就能回答区间查询。
注意: 当 L = 0 时,L - 1 = -1 需要特殊处理(count_up_to(-1) = 0)。
6.5.8 常见数位 DP 问题类型
| 问题类型 | 额外状态 | 示例 |
|---|---|---|
| 各位和 = S | sum | 本章 6.5.3 |
| 不含特定数字 | 无(限制在 limit 里) | 无 4 的数 |
| 相邻不同 | last_digit | 本章 6.5.5 |
| 单调不降 | min_digit | 本章 6.5.6 |
| 恰好 k 个某数字 | count_of_digit | 恰好含 3 个 7 |
| 整除性 | 余数 mod m | 能被 7 整除的数 |
⚠️ 常见错误
| 错误 | 原因 | 修复方案 |
|---|---|---|
| 忘记前导零处理 | 把 0007 当 7 位数处理 | 加 started 标志区分 |
| 记忆化键不完整 | 缺少 tight 或 started | 4 个维度都要包含 |
| 区间端点错误 | 查 [L, R] 时用 f(L) 而非 f(L-1) | count(R) - count(L-1) |
| dp 数组大小不够 | sum 最大可达 9×18=162 | dp[20][163][2][2] |
💪 练习题
🟢 题目 1:不含数字 4
统计 [1, N] 中不含数字 4 的整数个数(N ≤ 10^15)。
✅ 完整解答
思路: 数位 DP,遇到 d=4 时跳过。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int digits[20], n_digits;
ll dp[20][2][2];
bool vis[20][2][2];
ll solve(int pos, bool tight, bool started) {
if (pos == n_digits) return started ? 1 : 0;
ll& ret = dp[pos][tight][started];
if (vis[pos][tight][started]) return ret;
vis[pos][tight][started] = true;
int lim = tight ? digits[pos] : 9;
ret = 0;
for (int d = 0; d <= lim; d++) {
if (d == 4) continue; // 不含4
ret += solve(pos + 1, tight && (d == lim), started || (d > 0));
}
return ret;
}
ll count_up_to(ll N) {
if (N <= 0) return 0;
n_digits = 0;
ll tmp = N;
while (tmp) { digits[n_digits++] = tmp % 10; tmp /= 10; }
reverse(digits, digits + n_digits);
memset(vis, 0, sizeof(vis));
return solve(0, true, false);
}
int main() {
ll L, R; cin >> L >> R;
cout << count_up_to(R) - count_up_to(L - 1) << "\n";
}
🟡 题目 2:各位数字和为 S
统计 [L, R] 中各位数字之和恰好为 S 的整数个数(L, R ≤ 10^18,S ≤ 162)。
✅ 完整解答
直接使用 6.5.4 节的通用模板,设置目标和 S。
🔴 题目 3:各位数字单调不降 + 数字和 ≤ K
统计 [1, N] 中,各位数字单调不降且数字之和不超过 K 的整数个数(N ≤ 10^15,K ≤ 100)。
✅ 完整解答
状态: (pos, last_digit, sum, tight, started)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int n_digits, K;
int digits[20];
ll dp[20][10][101][2][2];
bool vis[20][10][101][2][2];
ll solve(int pos, int last, int sum, bool tight, bool started) {
if (sum > K) return 0;
if (pos == n_digits) return started ? 1 : 0;
ll& ret = dp[pos][last][sum][tight][started];
if (vis[pos][last][sum][tight][started]) return ret;
vis[pos][last][sum][tight][started] = true;
int lim = tight ? digits[pos] : 9;
ret = 0;
for (int d = (started ? last : 0); d <= lim; d++) {
ret += solve(pos + 1, d, (started || d > 0) ? sum + d : 0,
tight && (d == lim), started || (d > 0));
}
return ret;
}
ll count_up_to(ll N) {
if (N <= 0) return 0;
n_digits = 0; ll tmp = N;
while (tmp) { digits[n_digits++] = tmp % 10; tmp /= 10; }
reverse(digits, digits + n_digits);
memset(vis, 0, sizeof(vis));
return solve(0, 0, 0, true, false);
}
int main() {
ll L, R; cin >> L >> R >> K;
cout << count_up_to(R) - count_up_to(L - 1) << "\n";
}
💡 章节联系: 数位 DP 是 USACO Gold 每年必出的题型之一。它将「计数问题」转化为「逐位填数的决策 DP」,是 DP 思想的精华体现。掌握后可进一步学习「数位 + 组合数学」的双重计数技巧。