数位 DP
补一下鸽子。
数位 DP 常用的也就有 4 种写法,这里做整理:
- 数位计数。
- 纯数位计数。
- 拆分区间范围。
- 数位 DP。
- 纯 DP
- 枚举 LCP + DP。
一般的数位 DP
数位 DP 的题目一般是如下情形:在一个区间范围内统计一些东西。且区间范围很大。
详解
以例题分析一般步骤:P13085 [SCOI2009] windy 数(加强版)
统计 [a, b] 范围内的数,首先用差分,转化成统计 [1, n] 范围内的答案即可,这样答案就是 [1,b] - [1,a-1]。
现在考虑计算 [1, n] 范围内的答案:
可以对于 n 进行拆位,拆成若干个个数位,再依次考虑每个数位下填什么数字,这样一定能不重不漏考虑到所有数字。
填的数是有限制的,即填出来的数要小于等于 n。除此填出来的数还需要满足题目的限制(相邻数之差需要大于等于 2)。
接下来思考这个填数过程中需要知道些什么,才能满足限制。
回想数字大小比较的过程,是从高位向低位比,所以只要前面比较出来了,后面的就都不需要管了。
所以这里逐步从高到低去填,就可以数位 DP 了。
套路化的,我们可以记录 $ok$,表示前面填的数是否严格小于其所对应的 $n$。如下:
此时对于后面的 $?$ 可以随便填,没有大小的限制,因为在前面填的数已经严格小于其所对于的 $n$ 了。
| Digits | 3 | 6 | 2 | 5 | 7 | 9 |
| Number | 3 | 6 | 1 | ? | ? | ? |
反之,如果是:
此时对于后面的 $?$ 还受到大小的限制(填的数位大小不能超过对应的 $n$ 那一位的大小),因为前面填的数并没有严格小于其所对应的 $n$。
| Digits | 3 | 6 | 2 | 5 | 7 | 9 |
| Number | 3 | 6 | 2 | ? | ? | ? |
再考虑填的数如何满足题目的限制。显然记录一下上一位填的数是什么就行,只要当前数位填的数与上一位数位填的数相差大于等于 2 即可。
那么就可以转移了。考虑枚举当前数位填的数是多少即可。
这里数位 DP 的方式一般使用记忆化搜索,不然太需要注意力了。
对于数位 DP,还需要考虑前导 0 的问题。对于数位 DP ,是接受前导 0 的,也就是如果有前导 0 代表当前填的数的位数比 n 的位数小。
但是这题会受前导零的影响。需要消除影响。不过可以解决。
套路化的记录 $zero$,表示前面填的数是否全为 $0$。
如果前面填的数位全为 $0$,显然这一位不需要受到题目的限制。反之,就正常受到限制即可。
这里需要注意,统计的区间是 [1, n],所以如果填的全部是 $0$,是不能对答案产生贡献的。
本题代码:
注意点:记忆化数组初始化成 -1 才对,因为可能有的数的贡献就是 0,所以初始化成 0 可能 TLE。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=50;
ll L, R, f[N][10][2][2];
int n=0, a[N];
ll dfs(int dep, int lst, bool ok, bool zero)
{
if (dep<1) return (zero^1);
if (f[dep][lst][ok][zero]!=-1) return f[dep][lst][ok][zero];
ll ret=0;
for (int i=0; i<=9; i++)
{
if (!ok && i>a[dep]) continue;
if (zero || (!zero && abs(i-lst)>=2)) ret+=dfs(dep-1, i, ok|(i<a[dep]), zero&&(!i));
}
f[dep][lst][ok][zero]=ret;
return ret;
}
ll solve(ll r)
{
n=0;
while (r>0) a[++n]=r%10, r/=10;
memset(f, -1, sizeof f);
return dfs(n, 0, 0, 1);
}
int main()
{
scanf("%lld%lld", &L, &R);
printf("%lld", solve(R)-solve(L-1));
return 0;
}
套路总结
1.对询问区间差分,考虑 [1, n] 的答案。
2.对 n 进行拆位。
3.记忆化搜索,考虑如何填数。
记忆化搜索时要注意前导 0 的影响!
例题
P8764 [蓝桥杯 2021 国 BC] 二进制问题(Code)
数位 DP 时,对数字进行拆位时,进制数不一定是 10 进制,可以是 n 进制,原理是一样的。
考虑能否整除,也就是记录取模的余数。但是模数不固定不好处理,发现模数值域很小,不妨枚举模数,使模数固定下来,即固定数位和,然后就能做了。
这也是正常 DP 常见套路,枚举一开始不知道的小量级东西。
数位计数的数位 DP
就是纯数位计数。例如统计数位出现次数。但是感觉会比直接数位 DP 难理解一点。。
详解[记忆化搜索]
以例题讲解:P2602 [ZJOI2010] 数字计数
求 0~9 出现次数,不妨只考虑如何求 $x$ 的出现次数,其余是同理的。
先差分。再考虑如何填数。
正常的 DP 思想去思考的话,直接硬做就做完了。但是这里用记忆化搜索,需要多记录一维状态,$x$ 的出现次数为 $sum$ 次。
因为记忆化搜索是自顶而下,所以最终到了边界返回结果时是需要知道 $x$ 出现的次数的,所以需要记录这一维。
然后注意前导 0 的影响。在统计 $0$ 出现次数时,如果前面填的数全为 0,且该位填 0,就不能把 0 这次出现的次数当作一次贡献。
本题代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=15;
int m=0, a[N];
ll L, R, f[N][N][2][2][10];
ll dfs(int dep, int sum, bool ok, bool zero, int d)
{
if (!dep) return sum;
if (f[dep][sum][ok][zero][d]!=-1) return f[dep][sum][ok][zero][d];
ll ret=0;
for (int i=0; i<=9; i++)
{
if (i>a[dep] && !ok) continue;
ret+=dfs(dep-1, sum+(i==d && (i || !zero)), ok|(i<a[dep]), zero&&(!i), d);
}
f[dep][sum][ok][zero][d]=ret;
return ret;
}
ll solve(ll r, int d)
{
m=0;
while (r>0) a[++m]=r%10, r/=10;
memset(f, -1, sizeof f);
return dfs(m, 0, 0, 1, d);
}
int main()
{
scanf("%lld%lld", &L, &R);
for (int i=0; i<=9; i++) printf("%lld ", solve(R, i)-solve(L-1, i));
return 0;
}
/*
1
10 11 12 13 14 15 16 17 18 19
21 31 41 51 61 71 81 91
*/
详解[数位拆分]
由此引出一种新的数位 DP 的方法。
还是用 P2602 来举例,假设 $b=315794$,统计 $[0,315794]$ 之间的数字个数,可以拆分为 $[0,9]$ 之间的答案 + $[10,99]$ 的答案 + $[100,999]$ 之间的答案 ……,一直到这个区间包含 $315794$ 为止。
然后就可以统计 $[100000, 315794]$ 之间的答案了,怎么统计呢,我们一位一位对齐:
- 第 $1$ 位:$[100000,299999]$。
- 第 $2$ 位:$[300000,309999]$。
- 第 $3$ 位:$[310000,314999]$。
- 第 $4$ 位:$[315000,315699]$。
- 第 $5$ 位:$[315700,315789]$。
- 第 $6$ 位:$[315790,315793]$。
最后单独统计本身即可。
其实这种情况就是枚举 LCP 方法的拓展,一般只会在与数位长度及其相关的时候且很复杂的时候才会运用。
大部分时候都可以用其他方法代替,本人一般不常用。
例题
B3883 [信息与未来 2015] 求回文数(加强版)(Code)
求 [1, n] 的回文数,发现长度小于 |n| 的回文数是容易算的,于是直接数位 DP 算长度为 |n| 的回文数即可。
统计 [1, n] 的符合某些条件的数的个数时,把长度拆成小于 |n| 和 等于 |n| 两部分也许会容易考虑一点。
1.可以把多维状态压成一个进制数方便处理。
2.如果记忆化搜索的记忆化数组太大,可以用哈希表代替。
mp.find(a):找 map 中下标为 a 时容器内存的值,如果先前没存过返回 mp.end(),用这个函数可以代替初始化 -1 的问题。
枚举 LCP 的数位 DP
当数位 DP 与多测结合时需要用到的优化技巧。
(LCP 是指 n 的最长公共前缀)
详解
由例题引入:P10959 月之谜
就是 AT_abc336_e 加强版,加了多测。稍稍计算一下复杂度可知,直接把弱化版套到强化版并加入多测,复杂度就是 $O(tlogn^4)$(其中最多有 $1458$ 的常数),显然无法通过。
考虑如何优化?当 $ok=1$ 时,也就是后面的数可以随便填时,会发现这个值是个定值,多测时没必要每次都重复计算。可以在一开始就给预处理出来。
也就是只有当 $ok=0$ 时,才需要继续往下 dfs。注意到 $ok=0$ 时前面填的数一定是 $n$ 的 LCP,于是枚举这个 LCP 即可。
所以枚举 LCP+预处理 可以应对 多测+数位 DP 复杂度太大的情况。
这种方法也是 DP 常用的优化方法,主要思想在于对于十分频繁且几乎固定的状态,可以采用事先预处理的方法,并且枚举复杂度低的东西(这里就是 LCP),以应对多测的复杂情形。
当然,实际实现的时候没必要真的枚举 LCP+预处理的方法去做,虽然能做但太复杂了,可以使用更为巧妙的方法。
仅当 $ok=1$ 时再记忆化,也就是删掉记忆化数组 $ok$ 这一维就好。当 $ok=0$ 时就暴力往下找就行。
这种方法复杂度一样正确,因为只有第一次询问会全部计算一遍记忆化数组,后续询问相当于直接可以调用记忆化数组,时间开销就很小了。
本题代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=12, M=85;
int n=0, a[N], Mod=0;
ll R, L, f[N][M][M][M], p[M][N];
ll dfs(int dep, int dig, int sum, bool ok)
{
if (dep<1) return (dig==Mod && sum%Mod==0);
if (ok && f[dep][dig][sum][Mod]!=-1) return f[dep][dig][sum][Mod];
ll ret=0;
for (int i=0; i<=9; i++)
{
if (!ok && i>a[dep]) continue;
ret+=dfs(dep-1, dig+i, (sum*10%Mod+i)%Mod, ok|(i<a[dep]));
}
if (ok) f[dep][dig][sum][Mod]=ret;
return ret;
}
ll solve(ll r)
{
ll ret=0;
n=0;
while (r>0) a[++n]=r%10, r/=10;
for (int mod=1; mod<=9*9; mod++) Mod=mod, ret+=dfs(n, 0, 0, 0);
return ret;
}
int main()
{
memset(f, -1, sizeof f);
while (~scanf("%lld%lld", &L, &R)) printf("%lld\n", solve(R)-solve(L-1));
return 0;
}
枚举 LCP+预处理的代码:
注意:在推预处理数组和枚举 LCP 时对数的处理要保持一致,不然某些中间量是对不上的,就是代码里标 WA!!! 是不行的。
例如,mod=7,n=12。
那么按照对权逐位相加(也就是预处理数组 f)的处理过程是:$(0+1 \times 10^1) mod 7 = 3$,$(3+2 \times 10^0) mod 7=5$
按照对上一位乘 $10$ 再加当前位的处理过程是:$(0 \times 10+1) mod 7 = 1$,发现这里就已经对不上出错了。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=12, M=85;
int n=0, a[N], Mod=0;
ll R, L, f[N][M][M][M], p[M][N];
//f[i][mod][dig][sum]:当前数字 i个位,当前数字 % mod 数位和是 dig,数字是 sum,的数量
void init()
{
for (int mod=1; mod<=9*9; mod++)
{
f[0][mod][0][0]=1, p[mod][0]=1%mod;
for (int i=1; i<=9; i++) p[mod][i]=p[mod][i-1]*10%mod;
for (int i=1; i<=9; i++)
for (int dig=0; dig<=9*i; dig++)
for (int sum=0; sum<mod; sum++)
for (int d=0; d<=9; d++)
if (dig>=d) f[i][mod][dig][sum]+=f[i-1][mod][dig-d][((sum-d*p[mod][i-1])%mod+mod)%mod];
}
}
ll dfs(int dep, int dig, int sum, bool ok)
{
if (dep<1) return (dig==Mod && !sum);
ll ret=0;
if (ok)
{
if (Mod>=dig) return f[dep][Mod][Mod-dig][((Mod-sum)%Mod+Mod)%Mod];
}
else
{
for (int i=0; i<=a[dep]; i++)
{
ret+=dfs(dep-1, dig+i, (sum+i*p[Mod][dep-1])%Mod, i<a[dep]); //AC!!
// ret+=dfs(dep-1, dig+i, (sum*10+i)%Mod, i<a[dep]); WA!!
}
}
return ret;
}
ll solve(ll r)
{
ll ret=0;
n=0;
while (r>0) a[++n]=r%10, r/=10;
for (int mod=1; mod<=9*9; mod++) Mod=mod, ret+=dfs(n, 0, 0, 0);
return ret;
}
int main()
{
init();
while (~scanf("%lld%lld", &L, &R)) printf("%lld\n", solve(R)-solve(L-1));
return 0;
}
从低位到高位处理的数位 DP
适用于加减法。因为会有进退位,从高位处理就不太合适了,只能从低位处理。
咕咕咕。
练习
P9821 [ICPC 2020 Shanghai R] Sum of Log(代码见题解)
涉及位运算的数位 DP。
1.填 2 个数的数位 DP,本质一样,不过让这 2 个数位数一样更好处理。
2.记录合法方案的方案和,可以先记录合法方案的方案数,再通过方案数更新方案和。
与数学知识相结合的数位 DP,对模数的运用巧妙到了极致的好题。
1.一个数如果可以被几个数整除,那么这个数也可以被那几个数的最小公倍数整除。
根据唯一分解定理,可以直接在质因数上考虑。
a 整除 b 就是 a 的所有质因数出现次数都小于等于 b 的;几个数的 lcm 就是质因数出现次数的 max 再相乘
感性理解起来就很正确,也肯定可以严谨证明,这里懒得证了。
2.数位 DP 在很多情况下状态数量都很多,但是实际合法的状态确很少,这时候可以使用类似离散化的方法优化空间。
3.x%y=(x%ky)%y
拆 max,然后转化成求 i^j>k 的 i^j 的和与方案数。相当于同时填三个数。
1.式子中有 max 可以考虑拆 max
2.求方案和,可以由方案数推来,这里具体求法是考虑每一位数的贡献来算和。

浙公网安备 33010602011771号