Problem6859--学习系列 —— 动态规划 —— 数位 DP

6859: 学习系列 —— 动态规划 —— 数位 DP

[Creator : ]
Time Limit : 1.000 sec  Memory Limit : 256 MiB

Description

引入

数位 DP 往往都是这样的题型,给定一个闭区间 [l,r],让你求这个区间中满足某种条件的数的总数。而这个区间可能很大,简单的暴力代码如下:
int ans=0;
for(int i=l;i<=r;i++){
    if(check(i)) {
        ans++;
    }
}
若区间长度超过 $10^8$,我们暴力枚举就会超时了,而数位 DP 则可以解决这样的题型。数位 DP 实际上就是在数位上进行 DP。

方法一:动态规划

数位

数位是指把一个数字按照个、十、百、千等等一位一位地拆开,关注它每一位上的数字。如果拆的是十进制数,那么每一位数字都是 0~9,其他进制可类比十进制。
数位 DP:用来解决一类特定问题,这种问题比较好辨认,一般具有这几个特征:
  1. 要求统计满足一定条件的数的数量(即,最终目的为计数);
  2. 这些条件经过转化后可以使用「数位」的思想去理解和判断;
  3. 输入会提供一个数字区间(有时也只提供上界)来作为统计的限制;
  4. 上界很大(比如 $10^{18}$),暴力枚举验证会超时。

基本原理

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

技巧

我们从最高位到最低位逐一处理。

分类填数

设整数 $X$ 一共有 $n$ 位,那么 $X$ 可以表示为 $a_{n}a_{n-1}\dots a_{1}$,从高位到低位枚举填写。
因为不含有前导零,所为最高位只能填写 $1 \sim a_{n}-1$,其他位可以填写 $0 \sim a_i$。如下图所示。

每个位上填数的时候,分为两类:$0 \sim a_i-1$ 和 $a_i$,这样填数可以保证不超过 $X$。

技巧 1

类似前缀和思想,转化问题为 [0,R] - [0, L-1] 求解。

技巧 2

从高位到低位填数,要分类讨论。
吧整数 R 的每一位抠出来,存入数组 a,则 $R=a_{n}a_{n-1}\dots a_{1}$。
如果 $i$ 位填写 $0 \sim a_i-1$,则后面每一位可以填 $0 \sim 9$。
如果 $i$ 位填写 $a_i$,则再讨论下一位。
这样,保证填的数不超过 $R$。

答案

答案在上图的最左边分支。如下图所示。

状态定义

定义 $f[i][j]$ 表示一共有 $i$ 位,且最高位数字为 $j$。

预处理

由于我们是从高位向地位递推,所以需要预处理。
void init() {
    for (LL i=0; i<=9; i++) {//一位数
        f[1][i]=1;
    }
    for (LL i=2; i<N; i++) {//阶段:枚举位数
        for (LL j=0; j<=9; j++) {//状态:枚举最高位
            for (LL k=j; k<=9; k++) {//决策:枚举次高位
                f[i][j]+=f[i-1][k];//累加方案数
            }
        }
    }
}
注意:每题的预处理都不一样,需要根据题目来确定。
预处理的时间复杂度为 $O(N*10*10)$,其中 $N$ 表示最大数据的位数。比如 $10^{18}$,那么就是 $N=18$。
所以预处理的时间是很短的。

Input

方法二:记忆化搜索

利用前缀和来求解答案 $\sum_{i=1}^{R}ans_i - \sum_{i=1}^{L−1} ans_i$。

模板

int dfs(int pos, int pre, int lead, int limit) {
    if (!pos) {
        边界条件
    }
    if (!limit && !lead && dp[pos][pre] != -1) return dp[pos][pre];
    int res = 0, up = limit ? a[pos] : 无限制位;
    for (int i = 0; i <= up; i ++) {
        if (不合法条件) continue;
        res += dfs(pos - 1, 未定参数, lead && !i, limit && i == up);
    }
    return limit ? res : (lead ? res : dp[pos][sum] = res);
}
int cal(int x) {
    memset(dp, -1, sizeof dp);
    len = 0;
    while (x) a[++ len] = x % 进制, x /= 进制;
    return dfs(len, 未定参数, 1, 1);
}
signed main() {
    cin >> l >> r;
    cout << cal(r) - cal(l - 1) << endl;
}

cal 函数(一般情况):

注意每次初始化 DP 数组为 −1,长度 len=0

基本参数

  1. len: 数位长度,一般根据这个来确定数组范围
  2. $a_i$: 每个数位具体数字

返回值 

  1.  根据题目的初始条件来确定前导 0 以及 pre

DFS 函数(一般情况):

  1. 变量 res 来记录答案,初始化一般为 0
  2. 变量 up 表示能枚举的最高位数
采用记忆化搜索的方式:
if (!limit && !lead && dp[pos][pre] != -1) return dp[pos][pre];
只有无限制、无前导零才算,不然都是未搜索完的情况。

return limit ? res : dp[pos][pre] = res;
如果最后还有限制,那么返回 res,否则返回 dp[pos][pre]

基本参数:

假设数字 $x$ 位数为 $a_1⋯a_n$

必填参数:

  • pos: 表示数字的位数
从末位或第一位开始,要根据题目的数字构造性质来选择顺序,一般选择从 $a_1$ 到 $a_n$ 的顺序。初始从 len 开始的话,边界条件应该是 pos=0,限制位数应该是 $a_{pos}$,DFS 时 pos−1;初始从1 开始的话,边界条件应该是 pos>len,限制位数应该是 $a_{len−pos+1}$,DFS 时 pos+1。两种都可以,看个人习惯。
  • limit: 可以填数的限制(无限制的话 (limit=0) 0∼9 随便填,否则只能填到 $a_i$
如果搜索到 $a_1⋯a_{pos}⋯a_n$,原数位为 $a_1⋯a_k⋯a_n$,那么我们必须对接下来搜索的数加以限制,也就是不能超过区间右端点 R,所以要引入 limit 这个参数,如果 limit=1,那么最高位数 $up≤a_{pos+1}$,如果没有限制,那么 up=9(十进制下)这也就是确定搜索位数上界的语句 limit ? a[pos] : 9;
如果 limit=1 且已经取到了能取到的最高位时 $(a_{pos}=a_k)$,那么下一个 limit=1
如果 limit=1 且没有取到能取到的最高位时 $(a_{pos}<a_k)$,那么下一个 limit=0
如果 limit=0,那么下一个 limit=0,因为前一位没有限制后一位必定没有限制。
所以我们可以把这 3 种情况合成一个语句进行下一次搜索:limit && i == up ($i$ 为当前枚举的数字)

可选参数:

pre: 表示上一个数是多少
有些题目会用到前面的数

lead: 前导零是否存在,lead=1 存在前导零,否则不存在。
一般来说有些题目不加限制前导零会影响数字结构,所以 lead 是一个很重要的参数。
如果 lead=1 且当前位为 0,那么说明当前位是前导 0,继续搜索 pos+1,其他条件不变。
如果 lead=1 且当前位不为 0,那么说明当前位是最高位,继续搜索 pos+1,条件变动。
如果 lead=0,则不需要操作。

sum: 搜索到当前所有数字之和
有些题目会出现数字之和的条件

cnt: 某个数字出现的次数
有些题目会出现某个数字出现次数的条件

参数基本的差不多这些,有些较难题目会用到更多方法或改变 DP。

Constraints


Source/Category

数位DP