Skip to main content
  1. Posts/

数位 DP

·2 mins·
Table of Contents

数位 dp #

介绍 #

OI-Wiki 对数位 dp 的基本介绍如下:

数位:把一个数字按照个、十、百、千等等一位一位地拆开, 关注它每一位上的数字. 如果拆的是十进制数, 那么每一位数字都是 $0, 1, …, 9$,其他进制可类比十进制. 数位 DP: 用来解决一类特定问题,这种问题比较好辨认,一般具有这几个特征:

  • 要求统计满足一定条件的数的数量 (即最终目的为计数) ;
  • 这些条件经过转化后可以使用「数位」的思想去理解和判断;
  • 输入会提供一个数字区间(有时也只提供上界)来作为统计的限制;
  • 上界很大 (比如 $10^{18}$ ) , 暴力枚举验证会超时.

算法 #

从位上进行动态规划, 需要考虑:

  • 范围限制以及其它计数约束, 算法中表现为当前为可以使用的数字个数;
  • 递推顺序, 从高位向地位进行动态规划;
  • 算法复杂度, 通常利用递归 + 记忆化搜索减少时间复杂度.

对于LeetCode-2376 统计特殊整数, 灵神提供的模板如下:

class Solution {
public:
    int countSpecialNumbers(int n) {
        auto s = to_string(n); // 把上限 n 转化为字符串
        int m = s.length(), dp[m][1 << 10]; // dp数组, 记忆化搜索需要使用, 代表 不受约束时 填了数 (先导 0 不算) 考虑了 0 - i 位, 所用数字为 mask 的数字个数. 需要注意的是, 只用二维存储, 但含义是四维的, 在递归函数中做进一步解释
        memset(dp, -1, sizeof(dp)); // dp数组初始化
        function<int(int, int, bool, bool)> f = [&](int i, int mask, bool is_limit, bool is_num) -> int { // 递归函数
            if (i == m) return is_num; // i == m, 所有数位已经填完了, 如果是 is_num 为 true, 说明答案填了数, 返回 1 ; 否则说明考虑的是 0 (每一位都没有天数)
            if (!is_limit && is_num && dp[i][mask] >= 0) return dp[i][mask]; // 满足 dp 定义才能调用已有结果, 参考 dp 数组的含义
            int res = 0; // 计算新结果
            if (!is_num) res = f(i + 1, mask, false, false); // 可以跳过当前数位
            for (int d = 1 - is_num, up = is_limit ? s[i] - '0' : 9; d <= up; ++d) // 枚举要填入的数字 d
                if ((mask >> d & 1) == 0) // d 不在 mask 中
                    res += f(i + 1, mask | (1 << d), is_limit && d == up, true); // 填入 d , 后续的位数变化, mask变化, is_limit 由 前面位是否为上限 以及 d 是否为该位上限 共同决定, 因为填了数, is_num 为 true 
            if (!is_limit && is_num) dp[i][mask] = res; // 如果不受 n 限制, 并且填了数, 就记忆化
            return res;
        };
        return f(0, 0, true, false); // 从最高位开始填, 一开始一个没有填, 受到 n 限制, 还没有填 的数的个数
    }
};

最需要注意的一点就是, $dp$ 是二维的, 而 $f$ 是四维的, 所以记忆化以及调用时都要注意条件限制. 做这种处理是因为 is_limit == true 的情况只会走一次, 所以没有必要记忆化.