数位DP问题
数位DP问题
用来解决一类对数位进行某种计数的问题,通常有以下特点:
-
统计满足某种条件或约束的数量,且需要统计的区间跨度极大
-
某些条件或约束可以利用数位拆分的方式去满足
-
通常给定的区间 \([l,r]\) 可以拆分为 \([1,l],[1,r]\) 然后利用前置和做差计算
因为需要满足的约束以及边界条件较难进行完整判断,所以设计状态转移方程进行迭代很难成功
这里采用记忆化搜索的方式进行计算
例题一:https://www.luogu.com.cn/problem/P4999
给出一个区间 \([l,r]\) ,求 \(l\) 到 \(r\) 区间内每个数的数位和
const int mod=1e9+7;
LL dp[32][400];
LL dfs(int pos,bool lim,LL sum,int a[]){
if (pos==0) return sum;
if (!lim&&dp[pos][sum]!=-1) return dp[pos][sum];
int k=lim?a[pos]:9;
LL res=0;
for (int i=0;i<=k;i++)
res=(res+dfs(pos-1,lim&&i==k,sum+i,a))%mod;
if (!lim) dp[pos][sum]=res;
return res;
}
LL work(LL x){
memset(dp,-1,sizeof dp);
int idx=0,a[32];
while (x){
a[++idx]=x%10;
x/=10;
}
return dfs(idx,1,0,a);
}
void solve(){
LL l,r;
cin>>l>>r;
cout<<(work(r)-work(l-1)+mod)%mod<<endl;
}
记一个数的数位和为 \(sum_x\) ,题意转化为求解 \(\sum_{i=l}^r sum_i\) ,满足前缀和性质,则可以分解为求解 \(\sum_{i=0}^r sum_i-\sum_{i=0}^{l-1} sum_i\)
DFS 需要的参数有 \(pos,lim,sum\) ,其中 \(pos\) 表示当前枚举到了哪一个数位,\(lim\) 表示当前位是否被上一位约束,\(sum\) 表示枚举到第 \(pos\) 位前数位和为 \(sum\)
逐行分析代码:
LL dfs(int pos,bool lim,LL sum,int a[]){
if (pos==0) return sum;//从大到小枚举位,第0位递归终点返回sum
if (!lim&&dp[pos][sum]!=-1) return dp[pos][sum];
//如果当前位没有被约束,且曾经记录过当前的状态,那么直接使用
int k=lim?a[pos]:9;//判断当前位能枚举到的上界
//如果存在约束,这一位最大只能填到a的第pos位上的数,否则可以填到9
LL res=0;
for (int i=0;i<=k;i++)//枚举
res=(res+dfs(pos-1,lim&&i==k,sum+i,a))%mod;
//lim&&i==k表示约束的传递
if (!lim) dp[pos][sum]=res;//如果这一位没有限制,那么将这个状态记忆化
return res;
}
约束的传递:假如有一个数为 \(8234\) ,从第四位开始枚举,因为枚举的所有数不能超过 \(8234\) ,那么所有数的第四位也不应该超过 \(8\)
约束值一开始传入为 \(1\) ,那么第四位就被约束到了 \([0,8]\) 。当枚举第三位时,如果第四位填的是 \(8\) ,显然第三位就不能超过 \(2\) ,如果第四位填的是 \([0,7]\) ,则第三位可以任填。
lim&&i==k
体现了传递性,只有上一位被约束后,下一位才会有被约束的资格
因为这道题中,前导 \(0\) 对答案无贡献,所以无需考虑前导 \(0\) 带来的影响
LL dp[32][400];
记忆化的 \(dp_{i,j}\) 表示在无约束的情况下填到第 \(i\) 位且前 \(i\) 位的数位和为 \(j\) 时 \([1,i]\) 位满足约束的所有数的数位和
为什么只有在没有约束的条件下才进行记忆?
因为如果当前位存在约束,那么之后的状态都不是共用的,即存在某些状态不合法,无法从这些状态转移
例题二:https://www.luogu.com.cn/problem/P2602
给定两个正整数 \(a\) 和 \(b\),求在 \([a,b]\) 中的所有整数中,每个数码各出现了多少次
LL dp[20][2][20][2];
LL dfs(int pos,bool lim,bool zro,int cnt,int dig,int a[]){
if (!pos) return cnt;
if (dp[pos][lim][cnt][zro]!=-1) return dp[pos][lim][cnt][zro];
LL res=0;
int k=lim?a[pos]:9;
for (int i=0;i<=k;i++){
if (zro)
res+=dfs(pos-1,lim&&i==k,zro&&i==0,i==dig&&dig!=0?cnt+1:cnt,dig,a);
else
res+=dfs(pos-1,lim&&i==k,0,i==dig?cnt+1:cnt,dig,a);
}
dp[pos][lim][cnt][zro]=res;
return res;
}
LL work(LL x,int dig){
int idx=0,a[20];
memset(dp,-1,sizeof dp);
while(x){
a[++idx]=x%10;
x/=10;
}
return dfs(idx,1,1,0,dig,a);
}
void solve(){
LL a,b;
cin>>a>>b;
for (int i=0;i<=9;i++)
cout<<work(b,i)-work(a-1,i)<<' ';
}
这道题中,需要对 \(0\) 进行计数,因为前导 \(0\) 不能参与计数,所以需要考虑这一因素
那么 DFS 传入的参数有:\(pos,lim,zro,cnt,dig\) ,\(zro\) 表示当前位前是否有前导 \(0\) ,\(cnt,dig\) 表示 \(pos\) 位前,数位为 \(dig\) 的数量
for (int i=0;i<=k;i++){
if (zro)//如果存在前导0
res+=dfs(pos-1,lim&&i==k,zro&&i==0,i==dig&&dig!=0?cnt+1:cnt,dig,a);
//zro&&i==0表示前导0的传递,与约束的传递类似
//dig为0时,如果存在前导0且当前填0,则需要刨去这一次的计数
else
res+=dfs(pos-1,lim&&i==k,0,i==dig?cnt+1:cnt,dig,a);
}
这里记忆化的 \(dp\) 有四维空间,即把 DFS 的所有参数都记录下来,虽然有部分参数可以根据对答案的影响进行压缩,但在空间允许的条件下,这种方式可以减少思考过程
LL dp[20][20][2];
LL dfs(int pos,bool lim,bool zro,int cnt,int dig,int a[]){
if (!pos) return cnt;
if (!lim&&dp[pos][cnt][zro]!=-1) return dp[pos][cnt][zro];
LL res=0;
int k=lim?a[pos]:9;
for (int i=0;i<=k;i++){
if (zro)
res+=dfs(pos-1,lim&&i==k,zro&&i==0,i==dig&&dig!=0?cnt+1:cnt,dig,a);
else
res+=dfs(pos-1,lim&&i==k,0,i==dig?cnt+1:cnt,dig,a);
}
if (!lim) dp[pos][cnt][zro]=res;
return res;
}
上面为压缩空间后的函数,只有不存在约束时,产生的结果才会被转移,所以每次 DFS 后都根据 \(lim\) 判断是否记录
例题三:https://www.luogu.com.cn/problem/P2657
不含前导零且相邻两个数字之差至少为 \(2\) 的正整数被称为 windy 数。windy 想知道,在 \(a\) 和 \(b\) 之间,包括 \(a\) 和 \(b\) ,总共有多少个 windy 数?
int dp[20][20];
int dfs(int pos,int lim,int zro,int p,int a[]){
if (!pos) return 1;
if (!lim&&!zro&&p!=11&&dp[pos][p]!=-1) return dp[pos][p];
int res=0;
int k=lim?a[pos]:9;
for (int i=0;i<=k;i++){
if (zro)
res+=dfs(pos-1,lim&&i==k,zro&&i==0,i==0?p:i,a);
else if (abs(i-p)>=2)
res+=dfs(pos-1,lim&&i==k,0,i,a);
}
if (!lim&&!zro&&p!=11) dp[pos][p]=res;
return res;
}
int work(int x){
int idx=0,a[20];
memset(dp,-1,sizeof dp);
while (x){
a[++idx]=x%10;
x/=10;
}
return dfs(idx,1,1,11,a);
}
void solve(){
int a,b;
cin>>a>>b;
cout<<work(b)-work(a-1)<<endl;
}
这道题引入了一个新的约束条件:相邻两位数之差为 \(2\),参数也要新设置一个 \(p\) 表示前一位填的数
另外在进入 DFS 时需要对 \(p\) 的初值进行设置, 因为一开始枚举的 \(pos\) 没有前一位,所以要设置一个不约束最高位的值,即 \(\lvert p-a_{top}\rvert \ge 2\)
浅谈在实现数位DP中迭代法与记忆化搜索的优劣性
迭代法主要依赖预处理后的 \(dp_{i,j,\cdots}\) ,在约束很多时,边界条件也会增多,状态转移方程易写错或写漏
记忆化搜索在面对不同题时,需要变化的部分不多,无非是对 DFS 参数的设置以及向下搜索的特判
理论上这两种方式的时间复杂度相同,都会将所有状态仅访问一遍,但记忆化搜索需要调用多次递归,常数可能略大于迭代实现的常数
时间复杂度通常为:\(O(\log n\times k)\) ,\(k\) 表示限制条件,总时间复杂度为 \(O(N)\) ,\(N\) 为所有状态数