一、关于数位 dp
有时候我们会遇到某类问题,它所统计的对象具有某些性质,答案在限制/贡献上与统计对象的数位之间有着密切的关系,有可能是数位之间联系的形式,也有可能是数位之间相互独立的形式。(如求满足条件的第 K 小的数是多少,或者求在区间 [L,R] 内有多少个满足限制条件的数等)
常见的在 dp 状态中需要记的信息:当前位数、与上界之间的关系(从高到低做这个信息为 0/1,即当前与上界相等/小于上界。往往数位 dp 的对象是 0 到某个上界 R,为了统计这个范围的信息,我们需要保证从高位往低位做的过程中,这个数始终是小于等于这个上界的。从低到高做这个信息为 0/1/2),是否处于前导零状态等,更多的是跟题目条件有关的信息(包括题目的限制/贡献)。
写法:
- 每一维的信息用循环遍历到,转移时判每一种合法/不合法的情况。(缺点: 容易漏情况)
- 手动转移。手展合法的情况。(缺点: 难写难查)
二、例题
1. HDU3652 B-Number
题目大意:问 1~N 中所有含有 13 并且能被 13 整除的数的个数。N≤109。
设 dp[i][j][k][t=0/1] 表示当前第 i 位,上一位(即第 i+1 位)是 j,对 13 取模余数为 k,小于/等于上界(t 表示与上界之间的关系)的方案数。(针对前缀的个数,对 13 取模也是对前缀 13 取模)
我们需要考虑 i-1 位的数位,这个数位枚举的范围由 t 决定。若 t=0,即此时它小于上界,则当前这个数位枚举的范围为 0~9;若 t=1,即此时它等于上界,则当前这个数位枚举的范围为 0~N 的当前数位。设枚举到的这个数位为 c,按照定义,则可以从 dp[i-1][c][(k*10+c)%13][t=1&&c=N 的当前位] 转移到 dp[i][j][k][t]。
由于统计的是数的个数,因此转移为: dp[i][j][k][t]+=dp[i-1][c][(k*10+c)%13][t=1&&c=N 的当前位]。
以上的 dp 状态对于这道题还有遗漏的地方,还需再开一维状态记录目前是否出现了 13。此时还可以缩减状态:用 0 表示之前什么都没有出现过,1 表示上一位以 1 结尾,2 表示已出现了 13。
Code:
数位 dp 的第一种写法:
void solve(){ memset(dp,0,sizeof(dp)); dp[0][0][1][0]=1; for(int i=0;i<len;i++) //数位 for(int j=0;j<=12;j++) //mod 13 的值 for(int k=0;k<=1;k++) //与上界之间的关系 for(int m=0;m<=2;m++) //处理是否出现了 13。0 表示之前什么都没有出现过,1 表示上一位以 1 结尾,2 表示已出现了 13。 if(dp[i][j][k][m]!=0){ int end=(k==1)?s[i]-'0':9; //数位上界 for(int x=0;x<=end;x++) //当前数位 dp[i+1][(j*10+x)%13][k==1&x==r][(x==1&&m!=2)?1:((m==2||(x==3&&m==1))?2:0)]+=dp[i][j][k][m]; } } //ans=sigma dp[len][0][0/1][2]
数位 dp 的另一种写法:(记忆化搜索)
#include<bits/stdc++.h> #define int long long using namespace std; const int N=15; int n,f[N][N][N][2][2],a[20]; int dfs(int i,int last,int p,bool have13,bool less){ //i:数位 last:上一个数位 p:mod 13的值 have13:是否含有13 less:与上界之间的关系 if(!i) return have13&&(p==0); if(!less&&f[i][last][p][have13][less]!=-1) return f[i][last][p][have13][less]; int end=less?a[i]:9,ans=0; for(int j=0;j<=end;j++) //当前数位 ans+=dfs(i-1,j,(p*10+j)%13,have13||(last==1&&j==3),less&&j==end); if(!less) f[i][last][p][have13][less]=ans; return ans; } int calc(int x){ int n=0; while(x) a[++n]=x%10,x/=10; return dfs(n,0,0,0,1); } signed main(){ while(~scanf("%lld",&n)){ memset(f,-1,sizeof(f)); printf("%lld ",calc(n)); } return 0; }
2. Luogu P4317 花神的数论题
题目大意:问 1~N 中所有数转化为二进制后数位中 1 的个数之积。N≤1015。
Solution:
令 dp[i][j][k] 表示 dp 到第 i 位,数位中有 j 个 1,跟上界 N 之间的大小关系为 k(0相等,1小于)的方案数。
第 i 位取 0:dp[i-1][j][k]→dp[i][j][k|N(i)]
(若 N(i) 为 0,则与上界的大小关系不变;若 N(i) 为1,而第 i 位取了 0,则已经小于上界)
第 i 位取 1:dp[i-1][j][k]*max(k,N(i))→dp[i][j+1][k]
(在这种情况下,若 k=N(i)=0是不合法的,也就是说之前已经达到上界了,并且在这一位是0,但第 i 位取了1,就超过了上界,所以要乘以 max(k,N(i)))
其中 N(i) 表示上界 N 在二进制下第 i 位的取值。
最终的答案为:对于任意 1≤j≤n,jdp[n][j][0]+dp[n][j][1] 的乘积。
Code:
写法1:
#include<bits/stdc++.h> #define int long long using namespace std; const int N=60,mod=1e7+7; int n,f[N][N][2],ans=1,cnt,a[N]; int mul(int x,int a,int mod){ //快速幂 int ans=mod!=1; for(x%=mod;a;a>>=1,x=x*x%mod) if(a&1) ans=ans*x%mod; return ans; } signed main(){ scanf("%lld",&n); while(n) a[++cnt]=n%2,n/=2; reverse(a+1,a+1+cnt),f[0][0][0]=1; for(int i=1;i<=cnt;i++) for(int j=0;j<i;j++) for(int k=0;k<=1;k++){ f[i][j][k|a[i]]+=f[i-1][j][k]; f[i][j+1][k]+=f[i-1][j][k]*max(k,a[i]); } for(int j=1;j<=cnt;j++) ans=ans*mul(j,f[cnt][j][0]+f[cnt][j][1],mod)%mod; printf("%lld ",ans); return 0; }
写法2:
#include<bits/stdc++.h> #define int long long using namespace std; const int N=60,mod=1e7+7; int n,f[N][N][2],a[N],t; int mul(int x,int n,int mod){ //快速幂 int ans=mod!=1; for(x%=mod;n;n>>=1,x=x*x%mod) if(n&1) ans=ans*x%mod; return ans; } int dfs(int i,int cnt,bool less){ //i:数位 cnt:1的个数 less:与上界之间的关系 if(!i) return t==cnt; if(!less&&f[i][cnt][less]!=-1) return f[i][cnt][less]; int end=less?a[i]:1,ans=0; for(int j=0;j<=end;j++) //当前数位 ans+=dfs(i-1,cnt+(j==1),less&&j==end); if(!less) f[i][cnt][less]=ans; return ans; } int calc(int x){ int n=0; while(x) a[++n]=x%2,x/=2; int ans=1; for(int i=1;i<=n;i++){ //枚举1的个数 memset(f,-1,sizeof(f)); t=i,ans=ans*mul(i,dfs(n,0,1),mod)%mod; } return ans; } signed main(){ scanf("%lld",&n); printf("%lld ",calc(n)); return 0; }
3. Luogu P2602 数字计数
题目大意:给定两个正整数 a 和 b,求在 [a,b] 中的所有整数中,每个数码(digit)各出现了多少次。a≤b≤1012。
Solution:
做法一:预处理出所有 0~10x-1 的答案,将询问中给出的 [a, b] 转化为 [0, b] 的答案减去 [0,a-1] 的答案,对于最高位上界为 R 的任务,可以利用预处理出的结果统计最高位在 0~R-1 范围内的答案;将最高位独自的贡献统计了之后就可只考虑其他数位,问题转化到了一个结构相同但数位减少了一的任务上去。
做法二:
令 f[i][0/1] 表示已经做到了第 i 位,与上界之间的大小关系(0相等,1小于)时的答案总和。由于在后续的统计当中单个数位贡献的权还跟满足该种状态的数字个数有关,因此我们需要额外记录一下数字个数 g[i][0/1]。(在本题中这个值可以直接计算)
4. HDU4734 f(x)
题目大意:定义一个十进制数 N 的权值 F(N) 为其各个数位乘上 2 的(后面位数)次幂之和。给出 A,B。问 0~B 中权值不超过 F(A) 的数个数。A,B≤109。
Solution:
Sum 的范围大约在 5k 的样子,直接存下来即可。
令 dp[i][j][0/1] 表示 dp 到数字的第 i 位,每个数位对 F 值贡献之和为 j 的方案数。
#include<bits/stdc++.h> #define int long long using namespace std; const int N=15,M=2e4+5; int t,A,B,f[N][M][2],a[N]; int F(int x){ //计算 F(x) int cnt=0,y=1; while(x) cnt+=x%10*y,x/=10,y<<=1; return cnt; } int dfs(int i,int sum,bool less){ //i:数位 j:每个数位对 F 值贡献之和 less:与上界之间的关系 if(!i) return sum>=0; if(sum<0) return 0; if(!less&&f[i][sum][less]!=-1) return f[i][sum][less]; int end=less?a[i]:9,ans=0; for(int j=0;j<=end;j++) //当前数位 ans+=dfs(i-1,sum-j*(1<<(i-1)),less&&j==end); if(!less) f[i][sum][less]=ans; return ans; } int calc(int x){ int n=0; while(x) a[++n]=x%10,x/=10; return dfs(n,F(A),1); } signed main(){ memset(f,-1,sizeof(f)); scanf("%lld",&t); for(int k=1;k<=t;k++){ scanf("%lld%lld",&A,&B); printf("Case #%lld: %lld ",k,calc(B)); } return 0; }
5. Codeforces 55D Beautiful Number
题目大意:给定两个正整数 a 和 b,求在 [a,b] 中的所有整数中,有多少个数能够整除它自身的所有非零数位。a≤b≤1018。
Solution:
一种直接的想法是维护当前数模 1,2,...,9 的余数。每次乘十再加上新的数位后取个模就是新的状态。另外再维护一个状态表示 1,2,..,9 这些数位有哪些出现过。状态数18*2*9!*29≈69。
注意到若是我们维护了模 9 的余数,便自然能够推出模 3 的余数。类似这样的冗余状态有不少。
按照上面的思路,一个数能被其所有的非零数位整除,即它能被所有的非零数位的最小公倍数整除,这个最小公倍数的最大值显然是 1 到 9 的最小公倍数 2520 ,然后就可以对 2520 的模进行状态转移。 我们可以维护这个数本身模 lcm(1,2,...,9)=2520 的余数。从这个值可推出模所有 1,2,..,9 数位的余数。这一步优化将状态从 9!→2520。目前的状态数为 18*2*2520*512≈47。
dp[i][p][j][k] 表示当前 dp 到第 i 位,与上界的大小关系为 p,目前数的大小模 2520 的余数为 j,数位出现的状态数为 k 的方案数。每 dp 到新的一位,只需要枚举之前的状态和这一位选用的数,就可做到 O(1) 转移。
类似地,我们观察维护的最后一维。是否有和之前优化过程类似的冗余的地方?
如果数位当中已经出现了 9,那么出现 3 并不会在这个数上增加什么限制。
题目当中的约束可以等价地转换为:出现了的数位的 LCM | 原数%2520。
这些 LCM 只会取到 2520 的因子。2520=23×32×5×7,所以 1 到 9 这九个数的最小公倍数只会出现 4×3×2×2=48 种情况,是可以接受的。状态数降到了18*2*2520*48≈46。
#include<bits/stdc++.h> #define int long long using namespace std; const int N=2520; int t,l,r,cnt,a[20],v[N+5],f[20][N+5][50]; int dfs(int i,int p,int x,bool less){ //i:数位 p:目前数的大小模 2520 的余数 x:i位之前的数的每一位数的最小公倍数 if(!i) return p%x==0; if(!less&&f[i][p][v[x]]!=-1) return f[i][p][v[x]]; int end=less?a[i]:9,ans=0; for(int j=0;j<=end;j++) //当前数位 ans+=dfs(i-1,(p*10+j)%N,j?x/__gcd(x,j)*j:x,less&(j==end)); //x/__gcd(x,j)*j 即 x*j/gcd(x,j)=lcm(x,j)。计算的是包含当前位时所有位上的数的最小公倍数(当前位所选数不为 0,如果为 0 就是原数) if(!less) f[i][p][v[x]]=ans; return ans; } int calc(int x){ int n=0; while(x) a[++n]=x%10,x/=10; return dfs(n,0,1,1); } signed main(){ memset(f,-1,sizeof(f)); for(int i=1;i<=N;i++) if(N%i==0) v[i]=++cnt; //标记 2520 的因子 scanf("%lld",&t); while(t--){ scanf("%lld%lld",&l,&r); printf("%lld ",calc(r)-calc(l-1)); } return 0; }
6. 2012 Multi-University Training Contest 6 XHXJ's LIS(HDU 4352)
题目大意:给定两个正整数 a 和 b,求在 [a,b] 中的所有整数中,有多少个数(不包含前导零)的数位最长上升子序列长度恰好为 k。T≤104,a≤b≤1018。
Solution:
由于 LIS 只能 dp 求,所以我们维护的状态也应是dp状态。
考虑 n2 LIS 的求法:当确定了一个新的数位后,这个位置的 dp 值跟之前所有位置的dp 值都有关系。维护这些信息的复杂度比维护具体这个数是什么还要高,显然不现实。
1.考虑树状数组优化 LIS 的求法:在树状数组 LIS 的求法中,最终的答案和当新加入一个数后 dp 值的更新只跟树状数组有关。
- 下标:0~9
- 取值范围:1~10
- 状态数:1010,难以接受!
如果我们直接维护树状数组所维护的序列,发现一个性质:单调不降。且由于要求的是严格上升序列,所以 0 位置的值必定不超过 1。
我们考虑维护差分序列,下标范围:0~9, 取值范围:0~1。状态数下降到 210。
2.考虑二分求 LIS 的做法:其中 f(i) 表示长度为 i 的上升子序列结尾最小可以是多少。下标范围:1~10,取值范围:0~9,状态数:1010。
冷静思考,发现 f(i) 实际上是严格单增的。
因此只需要记录一下 0~9 哪些数在 f(i) 中出现过就足够还原出整个 dp 数组的信息。状态数 210。
用十位二进制表示 0~9 出现的情况,和二分求 LIS 一样的方法进行更新。
#include<bits/stdc++.h> #define int long long using namespace std; const int N=11; int t,l,r,k,f[25][1<<N][N][2],a[N]; //f[i][j][k][0/1]:当前的数位为 i,状态为 j,要求的 LIS 长度为 k,与上界之间的关系为 0/1 的方案数。 int solve(int x,int S){ //获取新的状态 for(int i=x;i<=9;i++) if(S&(1<<i)) return S^(1<<i)^(1<<x); return S^(1<<x); } int dfs(int i,int S,bool have0,bool less){ //i:数位 S:当前的状态 have0:是否含有前导零 less:与上界之间的关系 if(!i) return __builtin_popcount(S)==k; //状态中 1 的数目就是 LIS 的长度 if(!less&&f[i][S][k][less]!=-1) return f[i][S][k][less]; int end=less?a[i]:9,ans=0; for(int j=0;j<=end;j++) //当前数位 ans+=dfs(i-1,(have0&&j==0)?0:solve(j,S),have0&&j==0,less&&j==end); if(!less) f[i][S][k][less]=ans; return ans; } int calc(int x){ int n=0; while(x) a[++n]=x%10,x/=10; return dfs(n,0,1,1); } signed main(){ memset(f,-1,sizeof(f)); scanf("%lld",&t); for(int i=1;i<=t;i++){ scanf("%lld%lld%lld",&l,&r,&k); printf("Case #%lld: %lld ",i,calc(r)-calc(l-1)); } return 0; }
7.HDU4507 恨7不成妻
题目大意:如果一个整数符合下面 3 个条件之一,那么我们就说这个整数和 7 有关——
- 整数中某一位是 7;
- 整数的每一位加起来的和是 7 的整数倍;
- 这个整数是 7 的整数倍;
求在区间 [L,R] 内和 7 无关的数字的平方和。L,R≤1018。
Solution:
“某一位是 7”只需记录是否出现过 7,“每一位加起来的和是 7 的整数倍”只需记录数位和 mod 7 的值,“是 7 的整数倍”只需记录当前数 mod 7 的值。此题的重点在于如何在 dp 的过程中维护平方和。
我们之前接触的大部分数位 dp 统计的都是满足性质的数的个数。转移时,都是若满足条件,则转移后的 dp 值+=转移前的 dp 值。思考这个过程。
令 f0 表示满足性质的数的个数。
转移前的 f0:Σ合法的 xi 1
假设当前的数位是 c。在每一个数的后面都加上当前的数位 c,并不会改变它们的个数(一个数后面添加一个 c 仍是一个数)。
所以转移后的 f0 依然是:Σ合法的 xi 1
Σ合法的 xi 1⇒Σ合法的 xi 1,转移前和转移后的 f0 都是满足性质的数的个数,这也就是为什么可以直接把转移前的 dp 值加到转移后的 dp 值上。
思考另一个东西。
令 f1 表示满足条件的数之和。
Σ合法的 xi xi⇒Σ合法的 xi (10xi+c)⇒10 Σ合法的 xi xi+c·Σ合法的 xi 1⇒10·转移前的 f1+c·转移前的 f0
如果我们维护了 f0,那么就可以通过这个式子去更新 f1。
类似地,令 f2 表示满足条件的数的平方和。
Σ合法的 xi xi2⇒Σ合法的 xi (10xi+c)2⇒100Σ合法的 xi xi2+20·c·Σ合法的 xi xi+c2·Σ合法的 xi 1⇒100·转移前的 f2+20·c·转移前的 f1+c2·转移前的 f0
于是,如果我们已经维护了 f0 和 f1,就可以完成对 f2 的维护。