算法-数位dp
算法-数位dp
前置知识:
\(\texttt{dp}\)
\(\texttt{Dfs}\)
参考文献
https://www.cnblogs.com/y2823774827y/p/10301145.html
https://www.luogu.com.cn/blog/mak2333/solution-p2602
\(\texttt{Introduction}\)
数位 \(\texttt{dp}\) 是指求在数位限制下有多少满足要求的数的 \(\texttt{dp}\)。例如,求“在 \([L,R]\) 范围内连续出现过 \(3\) 个 \(3\) 的数”,“相邻两位之间差为质数的 \(5\) 位数”或“在 \([L,R]\) 区间内 \(6\) 出现的次数”。读完这篇文章以后,你就都会做了。
数位 \(\texttt{dp}\) 有两种主要方法:循环递推
或记忆化搜索
。
先讲循环递推
,例题是数字计数。
\(\texttt{Description}\)
求在 \([a,b]\) 区间内的数 \(0\sim9\) 数字分别出现次数,前导 \(0\) 不算。
数据范围:\(1\le a\le b\le 10^{12}\)。
\(\texttt{Solution}\)
为了讲得更透彻,蒟蒻会把同一个东西用不同的方法多次描述,文章较长,请见谅。
Step 1 预处理
设 \(sum_{i,j}(1\le i\le 12,1\le j\le 9)\) 表示数字 \(j\) 在满 \(i\) 位整数(\([1,10^i-1]\))中出现的次数。因为除了 \(0\) 以外,\(1\sim 9\) 在这题中其实是一模一样的,所以 \(sum_{i,1}=sum_{i,2}=...=sum_{i,9}\)。
所以蒟蒻们还不如直接用 \(sum_i\) 表示 \(sum_{i,j}\),表示数字 \(1\sim9\) 在满 \(i\) 位整数中出现的次数。所以 \(sum_1=1\),因为 \(sum_2\) 可以由 \(sum_1\) 个数前面加 \(0\sim 9\) 递推得,也可以把数放在首位,所以
\(sum\) 数列打表出来就是 \(1,20,300,4000,...\)。
code
void pro(){ //其实代码很短
ten[0]=1;//10^0=1
for(int i=1;i<=12;i++){
ten[i]=ten[i-1]*10;
sum[i]=sum[i-1]*10+ten[i-1];
}
}
Step 2 DP
预处理完 \(sum_i\) 后,可以抓一只 \(p\) 位数 \(n\) 求 \(0\sim9\) 在 \([1,n]\) 中出现的次数。首先设 \(nl_i(1\le i\le p)\) 表示 \(n\) 的从右往左第 \(i\) 位的数字。即
code
int p; lng bit=n;
for(p=0;n;n/=10) nl[++p]=n%10;//最后p就是n的位数
然后令 \(f_j(0\le j\le 9)\) 表示数字 \(j\) 在 \([1,n]\) 中出现的次数。考虑 \([1,n]\) 中 \(i\) 位数中数字 \(j(1\le j\le 9)\) 的出现次数:
- 如果 \(j\) 为第 \(i\) 位(从右往左,即最高位,\(j\) 满足 \(1\le j<nl_i\)),则 \(j\) 出现了 \(10^{i-1}\) 次。
- 如果 \(j\) 不是第 \(i\) 位(\(j\) 满足 \(1\le j\le 9\)),则 \(j\) 出现了 \(nl_i\times sum_{i-1}\)。
- 如果 \(j\) 为第 \(i\) 位并且 \(j==nl_i\),则 \(j\) 出现了 \(n \mod 10^{i-1}+1\) 次(包括 \(nl_i0...00\))。
最后的问题——这个我们一直避着的 \(0\) 出现次数怎么算?
比如 \(n=1000\),如果考虑前导 \(0\),数就会是 \(0000,0001,0002,0003,0004,...0999,1000\) 这样,有:
- 对于第 \(i\) 位的前导 \(0\),出现了 \(10^{i-1}\)。
又因为 \(p\) 位数就没有前导 \(0\) 了,所以前导 \(0\) 的总数根据 \(p\) 而定,跟 \(nl_i(1\le i\le p)\) 无关。
code
for(int i=p;i>=1;i--){
for(int j=0;j<=9;j++)
f[j]+=sum[i-1]*nl[i];
for(int j=0;j<=nl[i]-1;j++)
f[j]+=ten[i-1];
bit-=nl[i]*ten[i-1];//维护bit=n mod 10^(i−1)
f[nl[i]]+=bit+1;
f[0]-=ten[i-1];
}
最后,把 \(b\) 和 \(a-1\) 各当做 \(n\) 跑一次数位 \(\texttt{dp}\),作差就是答案。
\(\texttt{Code}\)
#include <bits/stdc++.h>
using namespace std;
//&Start
#define lng long long
//&Debug
void debug(int x,lng*arr){
for(int i=1;i<=x;i++)
printf("%lld%c",arr[i],"\n "[i<x]);
}
//&dpight
const int W=15;
lng ten[W],sum[W],fa[10],fb[10];
void pro(){
ten[0]=1;
for(int i=1;i<=12;i++){
ten[i]=ten[i-1]*10;
sum[i]=sum[i-1]*10+ten[i-1];
}
}
int nl[W];
void dp(lng n,lng*f){
int p; lng bit=n;
for(p=0;n;n/=10) nl[++p]=n%10;
for(int i=p;i>=1;i--){
for(int j=0;j<=9;j++)
f[j]+=sum[i-1]*nl[i];
for(int j=0;j<=nl[i]-1;j++)
f[j]+=ten[i-1];
bit-=nl[i]*ten[i-1];
f[nl[i]]+=bit+1;
f[0]-=ten[i-1];
}
}
//&Main
lng a,b;
int main(){
scanf("%lld%lld",&a,&b);
pro();
dp(a-1,fa), dp(b,fb);
for(int i=0;i<=9;i++)
printf("%lld%c",fb[i]-fa[i],"\n "[i<9]);
return 0;
}
然后是记忆化搜索
,例题是\(\texttt{windy}\)数。
\(\texttt{Description}\)
求在 \([A,B]\) 中满足“相邻两个数字之差至少为 \(2\)”的数的数量。
数据范围:\(1\le A\le B\le 2000000000\)。
\(\texttt{Solution}\)
有人说记忆化搜索的数位 \(\texttt{dp}\) 就是套模板,但是如果你不懂原理,模板都套不起来。
同理,把求 \([A,B]\) 范围中 \(\texttt{windy}\) 数的数量变成求 \([1,B]\) 中的减去 \([1,A-1]\) 中的。
直接抓 \(p\) 位数 \(n\),\(nl_i\) 表示 \(n\) 从右往左第 \(i\) 位数,代码就不放了。
Step 1 求不满 \(p\) 位 \(\texttt{windy}\) 数数量
令 \(f_{i,j}\) 表示有 \(i\) 位,最高位是 \(j\) 的 \(\texttt{windy}\) 数数量,所以递推方程明显:
\[f_{1,j}=1(0\le j\le 9) \] \[f_{i,j}=\sum\limits_{J=0,|j-J|\ge2}^9f_{i-1,J}(2\le i\le p,0\le j\le 9) \]
然后有 \(i(1\le i<p)\) 位的 \(\texttt{windy}\) 数量就为
。
code
void Pre(){
for(int j=0;j<=9;j++) f[1][j]=1;
for(int i=2;i<=10;i++)
for(int j=0;j<=9;j++)
for(int J=0;J<=9;J++)
if(abs(J-j)>=2) f[i][j]+=f[i-1][J];
}
//...
lng DP(lng n){
int p; lng res=0;
for(p=0;n;n/=10) nl[++p]=n%10;
//...
for(int i=p-1;i>=1;i--)
for(int j=1;j<=9;j++)
res+=f[i][j];
return res;
}
Step 2 求 \(p\) 位 \(\texttt{windy}\) 数数量
记忆化搜索上场。
表示当前要求从右往左第 \(w\) 位,第 \(w+1\) 位是 \(d\),\(free\) 表示前面从左往右的 \(p-w\) 位是否不和 \(n\) 的前 \(p-w\) 位相同。从 \(w\) 递归到 \(w-1\)。\(\texttt{Dfs}\) 的值表示这样的 \(\texttt{windy}\) 数数量。
首先因为同理在这题中 \(0\sim9\) 也是几乎相同的,除了顶到 \(nl_i\) 的情况。所以把除了 \(free==0\) 以外的状态 \((w,d)\) 的答案用记忆化搜索的数组
记录下来。刚开始时 \(g_{w,d}=-1(1\le w\le p,0\le d\le 9)\),如果某次 \(\texttt{Dfs}\) 中发现已经 \(g_{w,d}\neq-1\),就直接返回 \(g_{w,d}\) 的值。
如果 \(w==0\) 就 \(return~1\),具体递归数位 \(\texttt{dp}\) 的方法看代码。
code
lng Dfs(int w,int d,bool free){
if(!w) return 1;
if(free&&~g[w][d]) return g[w][d];
//输出记忆答案,~x为真表示x!=-1
int up=free?9:nl[w]; lng res=0; //up是递归的下一个d最大值
for(int i=0;i<=up;i++)
if(abs(i-d)>=2)//满足windy数要求
res+=Dfs(w-1,i,free||i<up);//递归
if(free) g[w][d]=res; //储存记忆
return res;
}
lng DP(lng n){
int p; lng res=0;
for(p=0;n;n/=10) nl[++p]=n%10;
memset(g,-1,sizeof g);//初始化
for(int i=1;i<=nl[p];i++) res+=Dfs(p-1,i,i<nl[p]);
//第一位的取值为[1,nl[p]]
for(int i=p-1;i>=1;i--)
for(int j=1;j<=9;j++)
res+=f[i][j];//不足p位的windy数总数
return res;
}
\(\texttt{Code}\)
#include <bits/stdc++.h>
using namespace std;
//%Start
#define lng long long
//%dp
const int W=15,D=10;
int nl[W];
lng a,b,f[W][D],g[W][D];
void Pre(){
for(int j=0;j<=9;j++) f[1][j]=1;
for(int i=2;i<=10;i++)
for(int j=0;j<=9;j++)
for(int J=0;J<=9;J++)
if(abs(J-j)>=2) f[i][j]+=f[i-1][J];
}
lng Dfs(int w,int d,bool free){
if(!w) return 1;
if(free&&~g[w][d]) return g[w][d];
int up=free?9:nl[w]; lng res=0;
for(int i=0;i<=up;i++)
if(abs(i-d)>=2)
res+=Dfs(w-1,i,free||i<up);
if(free) g[w][d]=res;
return res;
}
lng DP(lng n){
int p; lng res=0;
for(p=0;n;n/=10) nl[++p]=n%10;
memset(g,-1,sizeof g);
for(int i=1;i<=nl[p];i++) res+=Dfs(p-1,i,i<nl[p]);
for(int i=p-1;i>=1;i--)
for(int j=1;j<=9;j++)
res+=f[i][j];
return res;
}
//%Main
int main(){
scanf("%lld%lld",&a,&b);
Pre();
printf("%lld\n",DP(b)-DP(a-1));
return 0;
}
然后放道例题,手机号码。
\(\texttt{Description}\)
[CQOI2016]手机号码
求在 \([L,R]\) 中,满足:
- 不能同时有 \(4\) 和 \(8\)。
- 出现过 \(3\) 个连续相同数。
的 \(11\) 位数个数。
数据范围:\(10^{10}\le L\le R<10^{11}\)。
\(\texttt{Solution}\)
用记忆化搜索好,用循环递推代码至少 \(100\) 行。
要找从右往左第 \(w\) 位的数。
个数(从右往左第 \(w+1\) 个数)是 \(d\)。
上上个数(从右往左第 \(w+2\) 个数)是 \(ld\)。
\(free\) 表示前 \(p-w\) 位是否不和 \(n\) 的前 \(p-w\) 位相同。
\(h4\) 表示 \(4\) 是否在前 \(p-w\) 位中出现过。
\(h8\) 表示 \(8\) 是否在前 \(p-w\) 位中出现过。
\(h3\) 表示 \(3\) 个连续相同数是否在前 \(p-w\) 位中出现过。
然后用记忆化搜索数组 \(f_{w,d,ld,h4,h8,h3}\) 储存 \(\texttt{Dfs}\) 值(注意了,不能缺斤少两,不能用 \(f_{w,d,h4,h8,h3}\),必须把所以状态作为下标!),然后类似 \(\texttt{windy}\) 数地 \(\texttt{Dfs}\) 一下。具体见代码。
code
lng Dfs(int w,int d,int ld,bool free,bool h4,bool h8,bool h3){
if(h4&&h8) return 0ll;//剪枝,如果4和8已经同时
if(!w) return 1ll*h3;//如果w==0并且h3==1,return 1
if(free&&~f[w][d][ld][h4][h8][h3]) return f[w][d][ld][h4][h8][h3];
//输出记忆答案
int up=(free?9:nl[w]); lng res=0;//up是下一个d的最大值
for(int i=0;i<=up;i++)
res+=Dfs(w-1,i,d,free||i<up,h4||(i==4),h8||(i==8),h3||(i==d&&i==ld));
//递归,如果i==d&&i==ld,h3=1
if(free) f[w][d][ld][h4][h8][h3]=res;//储存答案
return res;
}
然后这题还有一个坑点,因为最后答案是 \(DP(R)-DP(L-1)\),而 \(L-1\) 可能是 \(10\) 位数,所以 \(\texttt{dp(n)}\) 时特判,如果 \(p\neq 11\),\(\texttt{dp(n)}=0\)。
code
lng DP(lng n){
int p; lng res=0;
for(p=0;n;n/=10) nl[++p]=n%10;
if(p!=11) return 0;
// debug(p,nl);
memset(f,-1,sizeof f);
for(int i=1;i<=nl[p];i++)//只有11位数
res+=Dfs(p-1,i,-1,i<nl[p],(i==4),(i==8),0);
return res;
}
\(\texttt{Code}\)
#include <bits/stdc++.h>
using namespace std;
//^Start
#define lng long long
//^Debug
void debug(int x,int*arr){
for(int i=1;i<=x;i++)
printf("%d%c",arr[i],"\n "[i<x]);
}
//^DP
const int W=15,D=10;
int nl[W];
lng f[W][D][D+1][2][2][2];
void Pre(){/*Nothing*/}
lng Dfs(int w,int d,int ld,bool free,bool h4,bool h8,bool h3){
if(h4&&h8) return 0ll;
if(!w) return 1ll*h3;
if(free&&~f[w][d][ld][h4][h8][h3]) return f[w][d][ld][h4][h8][h3];
int up=(free?9:nl[w]); lng res=0;
for(int i=0;i<=up;i++)
res+=Dfs(w-1,i,d,free||i<up,h4||(i==4),h8||(i==8),h3||(i==d&&i==ld));
if(free) f[w][d][ld][h4][h8][h3]=res;
return res;
}
lng DP(lng n){
int p; lng res=0;
for(p=0;n;n/=10) nl[++p]=n%10;
if(p!=11) return 0;
// debug(p,nl);
memset(f,-1,sizeof f);
for(int i=1;i<=nl[p];i++)
res+=Dfs(p-1,i,10,i<nl[p],(i==4),(i==8),0);
return res;
}
//^Main
lng L,R;
int main(){
scanf("%lld%lld",&L,&R);
Pre();
printf("%lld\n",DP(R)-DP(L-1));
return 0;
}
到此,我们可以总结出记忆化搜索版数位 \(\texttt{dp}\) 的模板了。
\(\texttt{Code}\)
#include <bits/stdc++.h>
using namespace std;
//^Start
#define lng long long
//^DP
const int W=15,D=10;
int nl[W];
lng f[W][]...[][][];
void Pre(){
/*
写些预处理
*/
}
lng Dfs(int w,/*w+1位等相关的数字*/,bool free,/*布尔类型的要求*/){
if(/*已经不符合*/) return 0;
if(!w&&/*符合*/) return 1;
if(free&&~f[w][]...[][][]) return f[w][]...[][][];
int up=(free?9:nl[w]); lng res=0;
for(int i=0;i<=up;i++)
res+=Dfs(w-1,/*相关数组递推*/,free||i<up,/*要求完成递推*/);
if(free) f[w][]...[][][]=res;
return res;
}
lng DP(lng n){
int p; lng res=0;
for(p=0;n;n/=10) nl[++p]=n%10;
if(/*已经不符合*/) return 0;
memset(f,-1,sizeof f);
for(int i=1;i<=nl[p];i++)
res+=Dfs(p-1,/*初始相关数*/,i<nl[p],/*初始要求完成情况*/);
return res;
}
//^Main
lng L,R;
int main(){
scanf("%lld%lld",&L,&R);
Pre();
printf("%lld\n",DP(R)-DP(L-1));
return 0;
}
两种数位 \(\texttt{dp}\) 那种好?
本蒟蒻认为记忆化搜索好,毕竟时间复杂度、空间复杂度两种都没什么区别,但 \(\texttt{Dfs}\) 又好想,代码又短,而且还有模板。要说数位 \(\texttt{dp}\) 的时间复杂度和空间复杂度,是根据题目而定的,并且除非特别毒瘤的题目,绝对不会 \(\texttt{TLE}\) 或 \(\texttt{MLE}\) 什么的。
练习题
数位 \(\texttt{dp}\) 的题到处都是,我想我也没有提供练习题的必要。
然后我就讲完了,祝大家学习愉快!