数位 dp 学习笔记(灵神模板)

  我谔谔,数位 dp 几年了还不会,学的都是些奇奇怪怪的写法,导致每次比赛遇到数位 dp 的题要么不会,要么写半天。灵神的数位 dp 模板其实很早就有看过,不过当时不是很理解递归的含义于是放弃了,最近重新来回来看发现还是递归好写,不仅简短而且基本都是一个框架,这就可以大大减少思考量,基本只要遇到数位 dp 的题直接套板然后部分修改就好了。

  先给出最基本的模板,求的是区间 $[0,n]$ 中满足条件的数的个数:

int dfs(int u, int high) {
    if (u == s.size()) return 1;
    if (!high && f[u] != -1) return f[u];
    int l = 0, r = high ? s[u] - '0' : 9;
    int ret = 0;
    for (int i = l; i <= r; i++) {
        ret += dfs(u + 1, high && i == r);
    }
    if (!high) f[u] = ret;
    return ret;
}

  这个版本是允许数字有前导零的,可以这么做的前提是有前导零不会影响答案。

  代码中的 $s$ 是对 $n$ 转换成字符串后的结果。

  参数中的 $u$ 是指当前在第 $u$ 位填数字,数字是从最高位开始依次往低位填的。$\text{high}$ 是一个 $\text{bool}$ 变量,如果是 $1$ 表示前面填的数字都是 $n$ 对应位上的数字,那么第 $u$ 位能填的数字只能是 $[0, s[u]]$;否则如果是 $0$ 表示前面至少存在一个位填的数字小于 $n$ 对应位上的数字,那么第 $u$ 位能填的数字可以是 $[0, 9]$。

  dfs(u, high) 返回的是从第 $u$ 位开始填,第 $u$ 位前面填的数字是否都贴着上界,所能构造出满足条件的数的数量。边界条件是 $u$ 等于 $n$ 的数位长度,此时返回 $1$(有其他条件的话还要判断是否满足)。

  $l$ 和 $r$ 对应第 $u$ 位上能填的数字的范围,其中在这个模板中 $l$ 都是 $0$,$r$ 受 $\text{high}$ 的影响。

  $f(u)$ 是为了实现记忆化搜索,其实可以把 $f$ 扩展成 $f(u, 0/1)$,实现时之所以不用记录 $\text{high}$ 那维,是因为当 $\text{high} = 1$ 时必然只会搜一次(标记,这里其实我现在也不是很理解)。

  最后调用的方法是 dfs(0, 1),一开始置 $\text{high} = 1$ 是因为第 $0$ 位能填的数字范围只能是 $[0, s[0]]$。

  再给出另外一个不含前导 $0$ 的模板,求的是区间 $[1,n]$ 中满足条件的数的个数:

int dfs(int u, int high, int lead) {
    if (u == s.size()) return !lead;
    if (!high && !lead && f[u] != -1) return f[u];
    int ret = 0;
    if (lead) ret = dfs(u + 1, 0, 1);
    int l = 0, r = high ? s[u] - '0' : 9;
    for (int i = max(l, lead); i <= r; i++) {
        ret += dfs(u + 1, high && i == r, 0);
    }
    if (!high && !lead) f[u] = ret;
    return ret;
}

  参数中的 $\text{lead}$ 是一个 $\text{bool}$ 变量,如果是 $1$ 表示第 $u$ 位之前都没有填过任何数字,表示有前导零;否则如果是 $0$ 表示第 $u$ 位之前填过数字,没有前导零。当递归到边界时,如果 $\text{lead} = 1$ 表示都没填过数字,返回 $0$,否则返回 $1$。

  当 $\text{lead} = 1$ 时,当前第 $u$ 位可以继续不填数字,即执行 dfs(u + 1, 0, 1),$\text{high}$ 会变成 $0$,是因为前 $u$ 位都没填过数字,自然就不会帖着 $n$ 对应位的上界。

  另外第 $u$ 为最小能填的数取决于 $\text{lead}$,当 $\text{lead} = 1$ 时,因为此时第 $u$ 位上要填数字,因此必须从 $1$ 开始填,否则可以从 $0$ 开始填。

  其他的部分与上一个模板几乎一样,最后调用的方法是 dfs(0, 1, 1)

  上面两个模板都是解决求某个前缀中满足条件的数的数量,如果询问变成了求 $[L, R]$ 范围内满足条件的数的数量时,我们就可以用前缀和的思想求 dp(R) - dp(L-1) 即可。其中 dp 函数中的内容是:

int dp(int n) {
    s = to_string(n);
    memset(f, -1, sizeof(f));
    return dfs(0, 1, 1);
}

  不过这里再给出另外一个模板,直接解决求 $[L, R]$ 范围内满足条件的数的数量:

  不考虑前导零:

int dfs(int u, int low, int high) {
    if (u == s1.size()) return 1;
    if (!low && !high && f[u] != -1) return f[u];
    int l = low ? s1[u] - '0' : 0, r = high ? s2[u] - '0' : 9;
    int ret = 0;
    for (int i = l; i <= r; i++) {
        ret += dfs(u + 1, low && i == l, high && i == r);
    }
    if (!low && !high) f[u] = ret;
    return ret;
}

  考虑前导零:

int dfs(int u, int low, int high, int lead) {
    if (u == s1.size()) return !lead;
    if (!low && !high && !lead && f[u] != -1) return f[u];
    int ret = 0;
    if (lead && s1[u] == '0') ret = dfs(u + 1, 1, 0, 1);
    int l = low ? s1[u] - '0' : 0, r = high ? s2[u] - '0' : 9;
    for (int i = max(l, lead); i <= r; i++) {
        ret += dfs(u + 1, low && i == l, high && i == r, 0);
    }
    if (!low && !high && !lead) f[u] = ret;
    return ret;
}

  当询问的 $L$ 和 $R$ 超过 long long 的范围时,我们需要转成字符串的形式去求 dp(R) - dp(L-1),这里 $L-1$ 就要涉及到高精度减法了,使用这个模板有一个好处就是可以避免这个减 $1$ 的处理。

  代码中的 $s_1$ 和 $s_2$ 分别对应 $L$ 和 $R$ 转换成字符串的结果。如果 $s_1$ 的长度小于 $s_2$ 的长度,那么在 $s_1$ 前补充 $0$ 使其与 $s_2$ 的长度相等。

  参数中的 $\text{low}$ 与 $\text{high}$ 类似,用来表示第 $u$ 位前填的数字是否都贴着 $L$ 的下界。

  对应的 $l$ 的取值就会受到 $\text{low}$ 的影响,当 $\text{low} = 1$ 说明第 $u$ 位只能从 $s_1[u]$ 开始填(否则填的数字就会小于 $L$),否则可以从 $0$ 开始填。当考虑前导 $0$ 时,那么可以填的最小数字理应是 $\min\{ l, \text{lead} \}$。需要注意的是不可以令 $l = \max\{ l, \text{lead} \}$,这里的 $l$ 和 $r$ 是第 $u$ 位可以填的数字的下界和上界,只能受到 $\text{low}$ 和 $\text{high}$ 的影响,即 $l$ 和 $r$ 不能被修改

  调用的方法是 dfs(0, 1, 1) 或 dfs(0, 1, 1, 1)

  下面选一些题来应用这几个模板。


  统计强大整数的数目

  首先考虑数字能否有前导零,虽然题目说的是统计不含前导零的数的数量,但可以发现是否有前导零并不影响结果,这是因为对于满足最低的 s.size() 位与 s 一样,且每一位的数字不超过 limit 的数,很明显补上前导零后这个数还是满足这两个条件。

  当 $u$ 不在最低的 s.size() 位时,那么第 $u$ 位可以填的数字是 $[l, \min\{ r, \text{limit} \}]$,注意不能把 $r$ 对 $\text{limit}$ 取最小值。否则 $u$ 在最低的 s.size() 位时,那么第 $u$ 位只能填 s 对应位上的数字 t = s[s.size() - (s1.size() - u)] - '0',并且只有 $t$ 满足 $l \leq t \leq r$ 时才能填。

  AC 代码如下:

class Solution {
public:
    long long numberOfPowerfulInt(long long start, long long finish, int limit, string s) {
        string s1 = to_string(start), s2 = to_string(finish);
        s1 = string(s2.size() - s1.size(), '0') + s1;
        vector<long long> f(s1.size(), -1);
        function<long long(int, int, int)> dfs = [&](int u, int low, int high) {
            if (u == s1.size()) return 1ll;
            if (!low && !high && f[u] != -1) return f[u];
            int l = low ? s1[u] - '0' : 0, r = high ? s2[u] - '0' : 9;
            long long ret = 0;
            if (s1.size() - u <= s.size()) {
                int t = s[s.size() - (s1.size() - u)] - '0';
                if (t >= l && t <= r) ret = dfs(u + 1, low && t == l, high && t == r);
            }
            else {
                for (int i = l; i <= r && i <= limit; i++) {
                    ret += dfs(u + 1, low && i == l, high && i == r);
                }
            }
            if (!low && !high) f[u] = ret;
            return ret;
        };
        return dfs(0, 1, 1);
    }
};

  [SCOI2009] windy 数

  这题很明显不能有前导零,因为前导零会影响到相邻两个数字之差至少为 $2$ 这个条件。同时我们还要用一个参数记录上一个数字选了什么,记作 $\text{last}$。最后在枚举当前可以填的数字 $i$ 时,只能选满足 $\left| i - last \right| \geq 2$ 的数字 $i$。

  AC 代码如下:

#include <bits/stdc++.h>
using namespace std;

const int N = 15;

string s1, s2;
int f[N][N];

int dfs(int u, int last, int low, int high, int lead) {
    if (u == s1.size()) return !lead;
    if (!low && !high && !lead && f[u][last] != -1) return f[u][last];
    int ret = 0;
    if (lead && s1[u] == '0') ret = dfs(u + 1, -2, 1, 0, 1);
    int l = low ? s1[u] - '0' : 0, r = high ? s2[u] - '0' : 9;
    for (int i = max(l, lead); i <= r; i++) {
        if (abs(i - last) >= 2) ret += dfs(u + 1, i, low && i == l, high && i == r, 0);
    }
    if (!low && !high && !lead) f[u][last] = ret;
    return ret;
}

int main() {
    int a, b;
    scanf("%d %d", &a, &b);
    s1 = to_string(a), s2 = to_string(b);
    s1 = string(s2.size() - s1.size(), '0') + s1;
    memset(f, -1, sizeof(f));
    printf("%d", dfs(0, -2, 1, 1, 1));
    
    return 0;
}

  E - Digit Sum Divisible

  直接 dp 的话发现做不了。考虑把 $1 \sim N$ 中的数按照每位数字之和进行分类,那么最多可以分成 $9 \times 14$ 类。然后枚举各位数字之和 $p$,分别求 $1 \sim N$ 中有多少个数的各位数字之和为 $p$ 且这个数模 $p$ 等于 $0$。另外可以发现前导零不会影响答案,dp 的参数列表为 dfs(u, s, r, p, high),其中 $s$ 表示前 $u$ 位的数字之和,$r$ 表示前 $u$ 位构成的十进制数模 $p$ 的值。

  答案就是 $\sum\limits_{p=1}^{9 \times 14}{\text{dfs}(0, 0, 0, p, 1)}$。

  AC 代码如下:

#include <bits/stdc++.h>
using namespace std;

typedef long long LL;

const int N = 15, M = 140;

string str;
LL f[N][M][M];

LL dfs(int u, int s, int r, int p, int high) {
    if (u == str.size()) return s == p && !r;
    if (!high && f[u][s][r] != -1) return f[u][s][r];
    int up = high ? str[u] - '0' : 9;
    LL ret = 0;
    for (int i = 0; i <= up; i++) {
        ret += dfs(u + 1, s + i, (r * 10 + i) % p, p, high && i == up);
    }
    if (!high) f[u][s][r] = ret;
    return ret;
}

int main() {
    LL n;
    scanf("%lld", &n);
    str = to_string(n);
    LL ret = 0;
    for (int i = 1; i < 140; i++) {
        memset(f, -1, sizeof(f));
        ret += dfs(0, 0, 0, i, 1);
    }
    printf("%lld", ret);
    
    return 0;
}

 

参考资料

  数位 DP 通用模板:https://www.bilibili.com/video/BV1rS4y1s721/

  数位 DP 通用模板 v2.0【力扣双周赛 121】:https://www.bilibili.com/video/BV1Fg4y1Q7wv/

posted @ 2024-01-26 22:51  onlyblues  阅读(141)  评论(0编辑  收藏  举报
Web Analytics