数位 DP

217 阅读8分钟

一、引入

数位 DP 问题一般具有如下特征:

  1. 要统计满足一定条件的数的数量(即计数问题)
  2. 这些条件与「数位」有关(即能/不能填哪些数)
  3. 输入会提供一个数字区间(有时也只提供上界)来作为统计的限制
  4. 上界很大,暴力枚举会超时

数位 DP 的基本原理:

考虑人类计数的方式,最朴素的计数就是从小到大开始依次加一。但我们发现对于位数比较多的数,这样的过程中有许多重复的部分。例如,从 7000 数到 7999、从 8000 数到 8999、和从 9000 数到 9999 的过程非常相似,它们都是后三位从 000 到 999,不一样的地方只有千位。所以我们可以把这些过程归并起来,将这些过程中产生的计数答案也都存放在一个通用的数组里。此数组根据题目具体要求设置状态,用递推或 DP 的方式进行状态转移。

二、分析与设计

数位 DP 一般有两类写法,一种是 记忆化搜索 写法,一种是 迭代 写法。笔者这里力推 记忆化搜索 写法,因其泛用、系统、易编码。

2.1 记搜过程

从顶点向下搜索,到最底层得到方案数,一层一层向上返回结果并累加,最后从搜索的起点得到最终答案。

在数位 DP 中,这个过程就是从高位向低位搜索,在数位长度得到方案数,再一层层向高位返回结果并累加。

2.2 状态设计

理解了上面记忆化搜索的过程,现在需要考虑的是如何设计状态。显然,我们需要知道当前在哪一个数位上。其次,我们还需要根据题目要求设定一些状态——这就需要我们传入一些变量。于是,我们便搭好了记忆化搜索函数 dfs 的初步框架:

int dfs(int i, ...);

dfs(i, ...) 的含义为从第 ii(高)位开始填入数字,且……,能得到的方案数。接下来,我们思考几个填入数字时的痛点问题,即在第 ii 位上能/不能填哪些数?

2.2.1 上界——不超过 nn

上界指我们在第 ii 位上能填入的最大的数。题目一般会给定一个上界 nn,最后构造出的数不能超过它。所以如果前面 i1i - 1 位填入的数都和 nn 的前 i1i - 1 位相同的话,那么第 ii 位上填入的数就不成超过 nn 的第 ii 位。反之,则可以填入任意一个数,即最大可以填入 99

如何知道前面填入的数都和 nn 相同呢?我们可以设计一个布尔型变量 limit,表示第 ii 位上能填的数受到 nn 的约束。

  • 递归边界:对计算结果没有影响。
  • 状态转移:当且仅当第 iilimittrue 且填入了与 nnii 位相同的数字,第 i+1i + 1 位才受到约束。
  • 递归入口:显然受到 nn 的约束,传入 true

2.2.2 下界——前导零

下界指在第 ii 位上能填入的最小的数。显然,00 是最小的数,看起来我们总是应该尝试从 00 开始填,但是真的一定能填入 00 吗?有一个很棘手的问题——前导零

在数学上,前导零是没有意义的,比如 123、0123 和 00123 都是同一个数。也就是说,在数学上,在一个数前面 补上零不补零 这两种操作是等价的。然而,在记忆化搜索的过程中,我们是从高位向低位填入数字,并记录相应的状态。所以,填入前导零(跳过)不填前导零 两种填数方式可能会对状态产生不同的影响。结合下面的案例会更好理解。

例一:求 1000 以内没有重复数位的数的个数。

显然,如果我们填入前导零,会误把 00 这个数给用掉,导致最后计算出的结果偏小。比如,040 并没有重复数位。

例二:求 1000 以内数位和等于 4 的数的个数。

这里前导零对我们计算数位和是没有影响的,所以不论是否填入前导零都不会影响结果。

综上所述,在一些情况下,我们必须严格区分 填入前导零不填前导零 这两种填数方式,且必须采用 不填前导零 这种填数方式,否则会无法得到正确答案。

效仿上一节中对上界的处理,我们可以设计一个布尔型变量 lead0,表示第 ii 位上是否存在前导零。

  • 递归边界:只有至少填入过一个数才能构成合法数字,即 lead0false 才是有效方案。
  • 状态转移:当且仅当第 iilead0true不填前导零 时,第 i+1i + 1 位才存在前导零。
  • 递归入口:还没有填入任何数,显然存在前导零,传入 true

2.2.3 其他状态

此类状态需要根据题目要求灵活定义,比如上一节的两个案例,分别要记录用了哪些数和已经填入的数位和。

2.3 实现记忆化搜索

完成了状态的设计后,我们来进一步完善记忆化搜索的代码。目前搭好的框架如下:

int dfs(int i, int state, ..., bool limit, bool lead0) {
    ...
}

我们首先解决递归边界的问题。显然当 i 等于 nn 的数位长度 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 数组没有对 limitlead0 两个状态进行记忆化,也仅在 limitlead0 均为 false 的情况下才更新 memo。这里可能比较难理解,不妨先思考一下要记忆化?因为子问题存在大量重复计算,暴力递归会超时。比如 2.2.2 中的例一,前两位填入 5588 和填入 8855 这两种情况,递归到子问题时状态是一样的。然而,在 limitlead0true 的情况下,它们只能由确定的父问题转移而来,所以不存在重复计算,也就不需要记忆化。这样既节省了空间,也简化了代码。实际上,这两个变量的目的是为了方便我们理解和区分状态。

三、案例分析

我们以 LeetCode 1012. 至少有 1 位重复的数字 为例,介绍如何解决具体的数位 DP 问题,以及模板在使用上的细节。

3.1 解题思路

题目要求至少有 1 位重复的数字,即有 2 位重复的数字、有 3 位重复的数字、……、等等,看起来情况复杂。正难则反,我们可以转换为求没有重复数位的数字的数量,再用总数减去它即可。

3.2 状态设计

  • i:表示第 ii 位,套模板即可
  • mask:求没有重复数位的数字,我们要知道已经填过哪些数字,可以用一个整数的二进制位来表示 0--9 是否被填过,即状态压缩的思想。
  • limit:是否受到上界 nn 的约束。
  • 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);
    }
};

References