0.引入的引入
数位是指把一个数字按照个、十、百、千等等一位一位地拆开,关注它每一位上的数字。如果拆的是十进制数,那么每一位数字都是 0~9,其他进制可类比十进制。
而数位DP,就是解决在区间[L,R]这个范围内,求满足某种约束的数字的数量或总和或乘积或平方这一类问题。
两种写法,一种是记忆化搜索,一种是迭代写法。这里是记忆化搜索,优点是好写好维护。
1.由例题引入数位DP
数位DP有一个通用的技巧,就是利用前缀和的思想,先求出[1,R]区间的满足约束的数字答案,再处理[1,L−1]的满足约束的数字答案,这样Ans[R]−Ans[L−1]所求得的区间就是[L,R]区间的答案。
所以我们要求解的内容就变为了求[0/1,x]这一区间满足限定的答案,其实0/1表示可以区间取0或1作为开始
在代码中表现为
- a[1...len]表示将数分解成R进制(一般为10进制或者2进制),用数组存储,表示数字分解为R进制下的第ai位,或者也可以说是系数
- 最高位为a[len],最低位为a[1]。
例如我们将1145转化为10进制下的数位
代码写起来是这样的
1 2 3 4 5 6 7 8 9
| ll solve(ll x){ int len=0; while (x>0) { a[++len]=x%10; x/=10; } return dfs(...) }
|
填数的话我们从高位往低位去填
例题:[洛谷P4999]
求解区间[L,R]中所有数的数位和之和
数位和就是把一个数所有数位上的数字加起来,例如1145->1+1+4+5=11
既然是记忆化搜索,我们一层一层搜索,我们先来定义一些状态能够跑起来再说。
- pos:整形变量,表示当前枚举的位置,一般从高位到低位
我们假设x=2048,用?表示仍未填写的位数,目前一位都没有填,所以是???
我们从高位往低位取填,第一步填写最高位,很显然我们只能填[0,2]的数
- 若填写[0,1]的数,则我们可以从[0,1999]都可以取到
- 若填写2,则我们只能从[2000,2048],我们发现对于一般情况,可以做到[1,9]随便填写,但是这里因为有着上界的限制,2???后面的三位只能最高填到048,所以我们需要记录一个变量limit来表示当前数位是否可以任意填写

- limit:布尔变量,表示当前的第pos位是否收到取值限制
- 当为True表示取的数不能大于当前的数位,为true时表示[pos+1,R]取的都是ai的限制,
当我们搜索到pos=0的时候表示所有数位填写完毕,这是一个递归边界,我们需要返回枚举的结果,在传递结果的时候我们需要一个变量进行存储
- sum: 整形变量,表示[pos+1,len]填写完毕所获得的结果答案,这个题表示的是数位和
以上是最普通的暴搜,但是鉴于我们有着1018的上界,所以我们需要暴力枚举。
我们观察枚举的数位,我们发现如下案例
我们发现其实他们的结果都是一样的!因为对于后面未填写的结果,只要没有上界的限制,都会取到[0,99],因为我们关注的是数位和,所以只需要前面数字数位和一样,并且没有限制(limit=false)就可以记录答案!
设状态f[pos][sum]
- 位置[pos+1,len]已经填写完毕,在没有最高数位限制的情况下,我对于[1,pos]进行任意填写,满足约束的所有数位和。

我们可以大致写出记忆化搜索的代码啦,这里我们先初始化f数组为−1,即全为非法状态。
- 小技巧,~x是按位取反操作,该操作返回0时当且仅当x=−1,用来判断值是否为−1
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| ll dfs(int pos,bool limit,int sum){ if(!pos){ return sum; } if(!limit&&~f[pos][sum]){ return f[pos][sum]; } int up; if(limit){ up=a[pos]; }else up=9; ll ret=0; for(int i=0;i<=up;i++){ ret=(ret+dfs(pos-1,limit&&i==up,sum+i))%mod; } if(!limit){ f[pos][sum]=ret; } return ret; }
|
等会,初始状态怎么办,limit怎么处理!
我们想一想,我们发现对于上界最大取多少完全取决于limit,如果limit=false则上界可以取到9,如果limit=true,则上界只能取到数位的值。
也就是说,如果我们初始状态我们设limit=false的话,那最高位的枚举就会取到[1,9],这样答案就会错误!
所以我们在初始化时limit=true,pos=len,sum=0。
代码solve函数即如下
1 2 3 4 5 6 7 8 9 10
| ll solve(ll x){ int len=0; while (x>0) { a[++len]=x%10; x/=10; } return dfs(len,true,0); }
|
那么如何证明我这个并没有退化成暴搜呢
考虑f状态数量,数字上界为1018,18个9时是sum最大,结果即为9×18+1即163
总状态数即为19×163=3097,很少
计算复杂度时,我们考虑一个状态计算值时,需访问多少个状态,由数位一般性取值下为[0,9],容易得到为10个状态。
可得计算出f的时间复杂度为O(len×sum×R),也就是共有3097×10=30970个状态
是否会存在limit为true的节点过多导致状态记录不符合一般性退化成暴力搜索呢,我们显然可以发现

limit=true在全部数位都是true的情况下只能是一条链!链的长度就是len,也就是说扣除最右边那条链,剩下的状态数量也是不会超过len×sum×R的,黑色部位的连边我们默认认为O(1)复杂度,就是假设已经算完。
所以我们为什么要设置limit=false才能使用状态?
- 多组数据下这个状态是可以复用的
- 我们发现当limit=true属于是特殊情况,这种情况出现只是因为数位的限制,这些状态不符合一般性能取到[0,9]的情况,这些情况我们是不能复用的,记录上还浪费我们的空间。
所以你不会在任何数位dp题目中见到将limit作为状态的
完整代码如下,一般来说我们是需要开long long的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| #include<iostream> #include<cstring> #define ll long long using namespace std; int T; const ll ML=20,mod=1e9+7,MZ=9*18+5; int a[ML],f[ML][MZ]; ll dfs(int pos,bool limit,int sum){ if(!pos){ return sum; } if(!limit&&~f[pos][sum]){ return f[pos][sum]; } int up; if(limit){ up=a[pos]; }else up=9; ll ret=0; for(int i=0;i<=up;i++){ ret=(ret+dfs(pos-1,limit&&i==up,sum+i))%mod; } if(!limit){ f[pos][sum]=ret; } return ret; } ll solve(ll x){ int len=0; while (x>0) { a[++len]=x%10; x/=10; } return dfs(len,true,0); } int main(){ memset(f,-1,sizeof(f)); cin>>T; while (T--) { ll l,r; cin>>l>>r; ll ans=(solve(r)-solve(l-1)+mod)%mod; cout<<ans<<endl; } return 0; }
|
例题实践
- 不含前导零且相邻两个数字之差至少为 2 的正整数被称为 windy 数。windy 想知道,在 a 和 b 之间,包括 a 和 b ,总共有多少个 windy 数?(1≤a≤b≤2×109)
我们发现有了相邻数位的约束,
所以我们需要加上前面是否有前导0——lead0和上一位填写的数字——last
注意last初始赋值不能赋值−1!不然最高位就取不到−1,0,1。
我们可以知道约束条件体现在[pos+1,len]上,[1,pos]任意填写,这里的约束条件就是last
故设状态f[pos][last],表示[pos+1,len]已经填写完毕,[1,pos]任意填写,上一位数为last的情况下windy数共有多少
不难写出代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| #include<iostream> #include<cstring> #define ll long long using namespace std; const int MN=15,INF=1e9+7; int f[MN][MN],a[MN];
ll dfs(int pos,bool limit,bool lead0,int last){ if(!pos) return 1; if(!limit&&last!=INF&&~f[pos][last]){ return f[pos][last]; } int up,ret=0; if(limit){ up=a[pos]; }else up=9; for(int i=0;i<=up;i++){ if(lead0){ ret=ret+dfs(pos-1,limit&&i==up,lead0&&i==0,i==0 ? last : i); }else if(abs(last-i)>=2){ ret=ret+dfs(pos-1,limit&&i==up,0,i); } } if(!limit&&last!=INF){ f[pos][last]=ret; } return ret; } int solve(ll x){ int len=0; while (x>0) { a[++len]=x%10; x/=10; } return dfs(len,1,1,INF); } int main(){ memset(f,-1,sizeof(f)); ll l,r; cin>>l>>r; cout<<solve(r)-solve(l-1); return 0; }
|
设sum(i) 表示i 的二进制表示中1 的个数。给出一个正整数N ,花神要问你∏i=1Nsum(i) ,也就是sum(1)∼sum(N) 的乘积。
注意这题的不同点是求乘积
我们仔细想一下其实差不多,我们在统计递归树子树答案结果用的是和,这里我们只需要改为乘就可以了
但是,如果我们仍设ret=0的话那0×x=0啊,没关系只需要设ret=1就可以了
类似例子0,但这里是二进制,只有1,而1出现次数就是数码和
故设状态f[pos][cnt],表示[pos+1,len]体经填写了cnt个1,[1,pos]填写,所有合法方案的乘积
故不难写出代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| #include<iostream> #include<cstring> #define ll long long using namespace std; const int mod=1e7+7,MN=110; ll f[MN][MN],a[MN]; ll dfs(int pos,bool limit,int cnt){ if(!pos){ return max(cnt,1); } if(!limit&&~f[pos][cnt]){ return f[pos][cnt]; } ll up,ret=1; if(limit){ up=a[pos]; }else up=1; for(int i=0;i<=up;i++){ ret=(ret*dfs(pos-1,limit&&i==up,cnt+(i==1)))%mod; } if(!limit){ f[pos][cnt]=ret; } return ret; } ll solve(ll x){ int len=0; while (x>0) { a[++len]=x%2; x/=2; } return dfs(len,true,0); } int main(){ memset(f,-1,sizeof(f)); ll x; cin>>x; cout<<solve(x); return 0; }
|
古代人认为666 是属于魔鬼的数。
不但如此,只要某数字的十进制表示中有三个连续的6,古代人也认为这是个魔鬼的数,比如666,1666,6663,16666,6660666 等等。
古代典籍中经常用“第X 小的魔鬼的数”来指代这些数,这给研究人员带来了极大的不便。
现在请编写一个程序,可以实现输入X,输出对应的魔鬼数。
不难设状态为f[pos][lst1][lst2],表示[pos+1,len]填写完毕,对于pos的上一位lst1是否是6(lst1=0/1,bool变量),对于pos的上上一位lst2是否为6(lst2=0/1),的蘑菇数的个数。
不难有dfs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| ll f[MN][2][2];
ll dfs(int pos,bool lt1,bool lt2,bool lim){ if(!pos) return 1; if(!lim&&~f[pos][lt1][lt2]) return f[pos][lt1][lt2]; int up; if(lim) up=a[pos]; else up=9; ll ret=0; for(int i=0;i<=up;i++){ if(lt1&<2&&i==6) continue; ret+=dfs(pos-1,i==6,lt1,lim&&i==up); } if(!lim) f[pos][lt1][lt2]=ret; return ret; }
ll solve(ll x){ int len=0; while(x>0){ a[++len]=x%10; x/=10; } return dfs(len,0,0,1); }
|
考虑第x小如何求出,不难发现值域上对于次数,右边界扩大那么一定有单调性。我们实际上可以考虑在值域上二分,每一次二分DFS求出个数,mid+1−dfs即为次数,我们要求二分恰好到x,那么当mid+1−dfs≥x时才能更新r,否则更新l
那么代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| #include<bits/stdc++.h> #define ll long long using namespace std; constexpr int MN=20; int a[MN],T; ll f[MN][2][2];
ll dfs(int pos,bool lt1,bool lt2,bool lim){ if(!pos) return 1; if(!lim&&~f[pos][lt1][lt2]) return f[pos][lt1][lt2]; int up; if(lim) up=a[pos]; else up=9; ll ret=0; for(int i=0;i<=up;i++){ if(lt1&<2&&i==6) continue; ret+=dfs(pos-1,i==6,lt1,lim&&i==up); } if(!lim) f[pos][lt1][lt2]=ret; return ret; }
ll solve(ll x){ int len=0; while(x>0){ a[++len]=x%10; x/=10; } return dfs(len,0,0,1); }
int main(){ memset(f,-1,sizeof(f)); cin>>T; while(T--){ int x; cin>>x; ll l=665,r=1e18; while(l+1<r){ ll mid=(l+r)>>1; if(mid+1-solve(mid)<x){ l=mid; }else r=mid; } cout<<l+1<<'\n'; } return 0; }
|
形参设定
以下为记忆化搜索dfs常设定的形参
- pos:整形,表示当前枚举的位置,一般从高到低
- limit:布尔,表示当前pos位是否收到限制
- 为true时表示当前取得位数不能大于a[pos],只有在[pos+1,len]的值填写的数都是ai的时候该值才能为true,在递归树上表示为一条链
- last:整形,表示上一位填写的值(即pos+1填写的值)
- lead0:布尔,表示是否有前导零,即在len→(pos+1)的位置是否都是前导0
- 基于尝试,我们往往认为一个数没有前导0,最高位不能为0.在只有没有前导0的时候,才能够计算0的贡献,那么前导0什么时候会和答案有关,有以下情况
- 统计0的出现次数
- 相邻数值的差值
- 以最高位为起点确定的奇偶位
- sum:整形,表示len→(pos+1)的数位和
- r:整形变量,表示整个数前缀取模m所得的余数
- st:整形变量,用于状态压缩
- 对一个集合的数在数位上的出现次数的奇偶性有要求时,其二进制形式就可以表示每个数出现的奇偶性
- 3
参考
GhostXL算法学习笔记——数位DP