一、引入
数位 DP 问题一般具有如下特征:
- 要统计满足一定条件的数的数量(即计数问题)
- 这些条件与「数位」有关(即能/不能填哪些数)
- 输入会提供一个数字区间(有时也只提供上界)来作为统计的限制
- 上界很大,暴力枚举会超时
数位 DP 的基本原理:
考虑人类计数的方式,最朴素的计数就是从小到大开始依次加一。但我们发现对于位数比较多的数,这样的过程中有许多重复的部分。例如,从 7000 数到 7999、从 8000 数到 8999、和从 9000 数到 9999 的过程非常相似,它们都是后三位从 000 到 999,不一样的地方只有千位。所以我们可以把这些过程归并起来,将这些过程中产生的计数答案也都存放在一个通用的数组里。此数组根据题目具体要求设置状态,用递推或 DP 的方式进行状态转移。
二、分析与设计
数位 DP 一般有两类写法,一种是 记忆化搜索 写法,一种是 迭代 写法。笔者这里力推 记忆化搜索 写法,因其泛用、系统、易编码。
2.1 记搜过程
从顶点向下搜索,到最底层得到方案数,一层一层向上返回结果并累加,最后从搜索的起点得到最终答案。
在数位 DP 中,这个过程就是从高位向低位搜索,在数位长度得到方案数,再一层层向高位返回结果并累加。
2.2 状态设计
理解了上面记忆化搜索的过程,现在需要考虑的是如何设计状态。显然,我们需要知道当前在哪一个数位上。其次,我们还需要根据题目要求设定一些状态——这就需要我们传入一些变量。于是,我们便搭好了记忆化搜索函数 dfs 的初步框架:
int dfs(int i, ...);
dfs(i, ...) 的含义为从第 (高)位开始填入数字,且……,能得到的方案数。接下来,我们思考几个填入数字时的痛点问题,即在第 位上能/不能填哪些数?
2.2.1 上界——不超过
上界指我们在第 位上能填入的最大的数。题目一般会给定一个上界 ,最后构造出的数不能超过它。所以如果前面 位填入的数都和 的前 位相同的话,那么第 位上填入的数就不成超过 的第 位。反之,则可以填入任意一个数,即最大可以填入 。
如何知道前面填入的数都和 相同呢?我们可以设计一个布尔型变量 limit,表示第 位上能填的数受到 的约束。
- 递归边界:对计算结果没有影响。
- 状态转移:当且仅当第 位
limit为true且填入了与 第 位相同的数字,第 位才受到约束。 - 递归入口:显然受到 的约束,传入
true。
2.2.2 下界——前导零
下界指在第 位上能填入的最小的数。显然, 是最小的数,看起来我们总是应该尝试从 开始填,但是真的一定能填入 吗?有一个很棘手的问题——前导零。
在数学上,前导零是没有意义的,比如 123、0123 和 00123 都是同一个数。也就是说,在数学上,在一个数前面 补上零 和 不补零 这两种操作是等价的。然而,在记忆化搜索的过程中,我们是从高位向低位填入数字,并记录相应的状态。所以,填入前导零 和 (跳过)不填前导零 两种填数方式可能会对状态产生不同的影响。结合下面的案例会更好理解。
例一:求 1000 以内没有重复数位的数的个数。
显然,如果我们填入前导零,会误把 这个数给用掉,导致最后计算出的结果偏小。比如,040 并没有重复数位。
例二:求 1000 以内数位和等于 4 的数的个数。
这里前导零对我们计算数位和是没有影响的,所以不论是否填入前导零都不会影响结果。
综上所述,在一些情况下,我们必须严格区分 填入前导零 和 不填前导零 这两种填数方式,且必须采用 不填前导零 这种填数方式,否则会无法得到正确答案。
效仿上一节中对上界的处理,我们可以设计一个布尔型变量 lead0,表示第 位上是否存在前导零。
- 递归边界:只有至少填入过一个数才能构成合法数字,即
lead0为false才是有效方案。 - 状态转移:当且仅当第 位
lead0为true且 不填前导零 时,第 位才存在前导零。 - 递归入口:还没有填入任何数,显然存在前导零,传入
true。
2.2.3 其他状态
此类状态需要根据题目要求灵活定义,比如上一节的两个案例,分别要记录用了哪些数和已经填入的数位和。
2.3 实现记忆化搜索
完成了状态的设计后,我们来进一步完善记忆化搜索的代码。目前搭好的框架如下:
int dfs(int i, int state, ..., bool limit, bool lead0) {
...
}
我们首先解决递归边界的问题。显然当 i 等于 的数位长度 len 时就没法再填数了。此时,我们根据 lead0 判断是否构成了一个有效数字,并返回方案数。
int dfs(int i, int state, ..., bool limit, bool lead0) {
if (i == len) {
return lead0 ? 0 : res;
}
...
}
接着,我们判断能填入哪些数字,并循环计算所有方案数。
int dfs(int i, int state, ..., bool limit, bool lead0) {
if (i == len) {
return lead0 ? 0 : res;
}
int res = 0;
if (lead0) {
// 不填入前导零,这意味着一定不等于 n 的第 i 位,自然不再受到约束
res += dfs(i + 1, next_state, ..., false, true);
}
int low = lead0 ? 1 : 0:
int up = limit ? s[i] - '0' : 9;
for (int d = low; d <= up; d++) {
// 要么前面已经填过数,要么这里填了非零的数,所以后面都不可能再出现前导零
res += dfs(i + 1, next_state, ..., limit && d == up, false);
}
return res;
}
最后,把递归改造成记忆化搜索。
vector<vector<int>> memo(len, vector<int>(N, -1)); // N 表示可能的状态总数
// 这里的 memo 只作为参考,不一定是二维的
// 注意没有对 limit 和 lead0 记忆化,详见下面的分析
int dfs(int i, int state, ..., bool limit, bool lead0) {
if (i == len) {
return lead0 ? 0 : res;
}
int& res = memo[i][state];
if (!limit && !lead0 && res != -1) {
// 这里一定要判断 !limit && !lead0,详见下面的分析
return res;
}
res = 0;
if (lead0) {
// 不填入前导零,这意味着一定不等于 n 的第 i 位,自然不再受到约束
res += dfs(i + 1, next_state, ..., false, true);
}
int low = lead0 ? 1 : 0:
int up = limit ? s[i] - '0' : 9;
for (int d = low; d <= up; d++) {
// 要么前面已经填过数,要么这里填了非零的数,所以后面都不可能再出现前导零
res += dfs(i + 1, next_state, ..., limit && d == up, false);
}
return res;
}
注意,我们这里的 memo 数组没有对 limit 和 lead0 两个状态进行记忆化,也仅在 limit 和 lead0 均为 false 的情况下才更新 memo。这里可能比较难理解,不妨先思考一下要记忆化?因为子问题存在大量重复计算,暴力递归会超时。比如 2.2.2 中的例一,前两位填入 、 和填入 、 这两种情况,递归到子问题时状态是一样的。然而,在 limit 或 lead0 为 true 的情况下,它们只能由确定的父问题转移而来,所以不存在重复计算,也就不需要记忆化。这样既节省了空间,也简化了代码。实际上,这两个变量的目的是为了方便我们理解和区分状态。
三、案例分析
我们以 LeetCode 1012. 至少有 1 位重复的数字 为例,介绍如何解决具体的数位 DP 问题,以及模板在使用上的细节。
3.1 解题思路
题目要求至少有 1 位重复的数字,即有 2 位重复的数字、有 3 位重复的数字、……、等等,看起来情况复杂。正难则反,我们可以转换为求没有重复数位的数字的数量,再用总数减去它即可。
3.2 状态设计
i:表示第 位,套模板即可mask:求没有重复数位的数字,我们要知道已经填过哪些数字,可以用一个整数的二进制位来表示 0--9 是否被填过,即状态压缩的思想。limit:是否受到上界 的约束。lead0:是否存在前导零。
3.3 状态转移方程
只有在 mask 中尚未使用过数字 d 时才能被选取,且进入下一次搜索时要将 mask 更新。
int dfs(int i, int mask, bool limit, bool lead0) {
...
for (int d = low; d <= up; d++) {
if ((mask >> d & 1) == 0) {
res += dfs(i, mask | 1 << d, limit && d == up, false);
}
}
...
}
3.4 参考代码
class Solution {
public:
int numDupDigitsAtMostN(int n) {
string s = to_string(n);
int len = s.size();
vector<vector<int>> memo(len, vector<int>(1 << 10, -1));
function<int(int, int, bool, bool)> dfs = [&](int i, int mask, bool limit, bool lead0) -> int {
if (i == len) {
return lead0 ? 0 : 1;
}
int& res = memo[i][mask];
if (!limit && !lead0 && memo[i][mask] != -1) {
return res;
}
res = 0;
if (lead0) {
res += dfs(i + 1, mask, false, true);
}
int low = lead0;
int up = limit ? s[i] - '0' : 9;
for (int d = low; d <= up; d++) {
if ((mask >> d & 1) == 0) {
res += dfs(i + 1, mask | (1 << d), limit && d == up, false);
}
}
return res;
};
return n - dfs(0, 0, true, true);
}
};