数位动态规划 (Digit DP)¶
数位动态规划(简称数位 DP)专门用于解决形如:“在给定闭区间 \([L, R]\) 中,统计满足某种特定数位性质(如不含连续 1、各个数位之和为特定值、数位各不相同等)的整数个数”。其数值范围往往高达 \(10^{18}\) 乃至 \(10^{100}\),暴力枚举必然 TLE,而数位 DP 能在 \(\mathcal{O}(\log_{10}(R) \times \text{状态数})\) 的极速时间内精确求解。
1. 核心数学思想:前缀差分拆解¶
区间 \([L, R]\) 内满足条件的数字数量,根据容斥与前缀差分原理,可以等价拆解为:
其中 \(f(X)\) 表示在区间 \([0, X]\)(或 \([1, X]\))中满足条件的数字个数。因此,问题彻底转化为:给定一个上限字符串 \(S\)(\(X\) 的十进制表示),统计不超过 \(S\) 的合法整数数量。
2. 工业级记忆化搜索通用框架¶
数位 DP 最清晰、最不易出错的编写范式是带前导零与上界标记的递归记忆化搜索(Memoized DFS)。
2.1 四大核心参数设计¶
pos(当前数位):从最高位(\(pos = 0\))自左向右递归填数,直到到达最低位(\(pos = n\))完成一个完整整数的构造;state(转移特征):题目要求的具体数位状态(例如:当前数位和、上一位填的数字、已使用的数字位掩码集合等);is_limit(上界约束标记):- 若
is_limit == true:当前位能填入的数字上限被严格卡死在 \(S[pos]\)。填入的数字只能在 \([0, S[pos] - '0']\) 中选择; - 若
is_limit == false:前面某些位已经严格小于 \(S\) 的对应位,当前位拥有完全自由,可以不受限制地填入 \([0, 9]\); is_num(前导零有效数字标记):- 若
is_num == false:前面所有高位都尚未填入有效数字(即全部视作前导零)。此时当前位可以选择跳过不填(继续保持前导零状态,迈向下一位),或者选填 \([1, up]\) 开始构造首位有效数字; - 若
is_num == true:前面已经产生有效数字,当前位哪怕填 \(0\) 也代表合法的真实数位。
2.2 记忆化存储铁律 (Critical Rule of Memoization)¶
记忆化数组 memo[pos][state] 唯有在 !is_limit && is_num 时才允许查询和写入!
- 为什么?
- 当
is_limit == true时,搜索空间受制于当前特定上限的前缀,其解是“残缺”的,绝不能当成通用的全集解存入记忆化; - 当
is_num == false时,当前状态受前导零影响,亦非标准状态; - 只有当既不受上限束缚(可自由填 \(0 \sim 9\)),又已经是真实有效数字时,计算结果才具备真正的子问题通用性,可以被后续搜索直接复用!
3. 通用代码模板 (以 LeetCode 2719 统计数位和为例)¶
#include <string>
#include <vector>
#include <cstring>
class DigitDP {
std::string s;
int min_sum, max_sum;
int memo[25][405]; // memo[pos][sum]
// pos: 当前位; sum: 当前累计数位和; is_limit: 是否贴上界; is_num: 是否已填数字
int dfs(int pos, int sum, bool is_limit, bool is_num) {
if (sum > max_sum) return 0; // 剪枝
if (pos == s.size()) {
return is_num && sum >= min_sum;
}
// 仅在无约束且有效数字时读取记忆化
if (!is_limit && is_num && memo[pos][sum] != -1) {
return memo[pos][sum];
}
int res = 0;
// 1. 若前面未填数字,当前位可继续跳过 (维持前导零)
if (!is_num) {
res = (res + dfs(pos + 1, sum, false, false)) % 1000000007;
}
// 2. 确定当前位填数字的上下界
int low = is_num ? 0 : 1;
int up = is_limit ? (s[pos] - '0') : 9;
for (int d = low; d <= up; ++d) {
res = (res + dfs(pos + 1, sum + d, is_limit && (d == up), true)) % 1000000007;
}
// 仅在无约束且有效数字时缓存记忆化
if (!is_limit && is_num) {
memo[pos][sum] = res;
}
return res;
}
public:
int count(std::string num, int min_s, int max_s) {
s = num;
min_sum = min_s;
max_sum = max_s;
std::memset(memo, -1, sizeof(memo));
return dfs(0, 0, true, false);
}
};
- 时间复杂度:状态总数至多为 \(\text{位数} \times \text{数位和上限} \approx 25 \times 400 \approx 10^4\),每个状态内循环至多 10 次,单次查询通常在数毫秒内极速完成。
- 空间复杂度:\(\mathcal{O}(\text{位数} \times \text{状态数})\)。
4. LeetCode 经典真题精选与题解提示¶
-
LeetCode 2719 - 统计整数数目 (Count of Integers)
困难提示
标准数位和约束模板。
- 计算 \(f(num2) - f(num1)\),若 \(num1\) 本身数位和在 \([min\_sum, max\_sum]\) 内则额外 \(+1\)。
- 状态定义为已填数位的和 \(sum\)。
-
LeetCode 233 - 数字 1 的个数 (Number of Digit One)
困难提示
数位出现频次累计。
- 状态记录已经出现的数字 1 的个数 \(cnt\)。
- 遍历到末尾时直接返回累积的 \(cnt\);递归加和各分支结果。
-
LeetCode 600 - 不含连续1的非负整数 (Non-negative Integers without Consecutive Ones)
困难提示
二进制数位 DP。
- 将数字转化为二进制字符串。
- 状态记录前一位填的是否为 1:若上一位为 1,则当前位只能填 0;若上一位为 0,则当前位可选填 0 或 1。
-
LeetCode 2376 - 统计特殊整数 (Count Special Integers)
困难提示
状压 + 数位 DP (每个数位互不相同)。
- 特殊整数要求每个数位各不相同。
- 用二进制掩码 \(mask\) 记录已经使用过的数字集合(0 到 9 对应 10 位)。
- 枚举当前位数字 \(d\) 时,检查 \((mask >> d) \& 1 == 0\);填入后更新掩码 \(mask | (1 << d)\)。
-
LeetCode 902 - 最大为 N 的数字组合 (Numbers At Most N Given Digit Set)
困难提示
候选受限集合数位 DP。
- 当前位可选的数字只能来自于给定的字符数组
digits。 - 同样使用
dfs(pos, is_limit, is_num)框架,但在循环选择数字 \(d\) 时仅遍历digits中的可用字符。
- 当前位可选的数字只能来自于给定的字符数组