[笔记]数位dp例题及详解-上
数位dp的定义引自洛谷日报#84:
求出在给定区间\([L,R]\)内,符合条件\(f(i)\)的数\(i\)的个数。条件\(f(i)\)一般与数的大小无关,而与数的组成有关。
由于是按位dp,数的大小对复杂度的影响很小。
由于数位dp状态的上下文信息比较多,所以一般用记忆化搜索实现,而非递推。
附上数位dp题单:https://www.luogu.com.cn/training/494976#problems
P4999 烦人的数学作业
题意简述:\(T\)次询问,每次给定\(L,R\),输出\([L,R]\)中所有数的数位和之和。
\(1\leq L\leq R\leq 10^{18},1\leq T\leq 20\)
我们发现范围很大,如果模拟会超时。所以引入数位dp的做法,数位dp一般会利用前缀和的思想,把\([L,R]\)转化为\([1,R]-[1,L-1]\),那么怎么计算\([1,x]\)呢?
用\(f[i][j]\)表示从最高位开始填了\(i\)位,数位和为\(j\)的答案。
思考如何转移:因为我们从最高位开始填,那么显然每一位都有限制。拿\(520\)举例:
- 第\(1\)位如果填\(0\sim 4\),那么后面可以随便填没有限制。
- 第\(1\)位如果填\(5\),那么第\(2\)位就要受限,如果填\(0\sim 1\)就和第一条一样,填\(2\)就是第二条,这样循环下去直到填完……
所以\(dfs\)的参数应有三个。
- \(pos\),表示当前正在填哪一位,从最高位\(len\)(原数的位数)开始往前填,根节点\(pos=len\),即正在填最高位。\(pos=0\)为结束条件。
- \(limit\),
bool
类型,表示当前这一位有没有限制。 - \(sum\),表示从最高位填到\(pos+1\)数位和是多少,用作递归结束的返回值。
但是我们发现这样就是一个普通的模拟,把所有数都试了一遍。所以需要记忆化,如果\(f[pos][sum]\)已经计算过了,直接返回即可(需要注意\(limit=false\)时才能用)。
为什么\(limit=false\)才需要记忆化,是因为\(limit=true\)的情况只有\(i=a[pos]\)(见代码),所以在递归树上只是一条链,没有记忆的必要。
至于时间复杂度,那就是状态数量(在\(f\)没有冗余空间的情况下,就是\(f\)的大小),再乘上转移的复杂度(此处是\(O(10)\))。
(DP的时间复杂度分析常用方法)
因为调用是\(f[pos][sum]\),所以状态数量\(len*10len\)即\(10*log_{10}^2r\),而单次循环执行次数是\(10\),所以时间复杂度是\(O(log_{10}^2r\times 10^2)\)。
upd 2024/11/25:之所以不把\(10^2\)消掉,是想表达“这个常数虽然在此题中是固定的,但是会随着对该题的推广而变化(比如变化进制的话,这个常数就成变量了)”。
实际上是可以进一步改进此算法,以消除掉这个常数以及一个\(\log_{10}r\)的。具体见[笔记]数位dp再刷。
思考:传递的参数中如果有数组等,为了节省空间,把它定义成全局数组,然后利用回溯传递状态更好(这里的\(pos,sum\)就可以这样子优化掉,但是本身递归层数就不多,所以影响几乎没有,也有一些变量不能回溯,比如\(lim\))。
点击查看代码
#include<bits/stdc++.h> #define int long long #define mod 1000000007 using namespace std; int f[25][250],a[25],t,l,r; int dfs(int pos,bool limit,int sum){ if(pos==0) return sum; if(!limit&&f[pos][sum]) return f[pos][sum]; int rig=limit?a[pos]:9; int ans=0; for(int i=0;i<=rig;i++){ ans=(ans+dfs(pos-1,limit&&i==rig,sum+i))%mod; //依次枚举这一位填什么 //如果这一位没有限制,那么填前一位也一定没有限制。 //如果这一位有限制,那么只有这一位填的数为a[pos]时才有限制(具体上面有说明) } if(!limit) f[pos][sum]=ans; return ans; } int solve(int x){//把x的值存入a数组 int len=0; while(x){ a[++len]=x%10; x/=10; } return dfs(len,1,0); } signed main(){ cin>>t; while(t--){ cin>>l>>r; cout<<(solve(r)-solve(l-1)+mod)%mod<<endl; } return 0; }
P2602 [ZJOI2010] 数字计数
与刚才那道很像哦,只不过询问的是\(0\sim 9\)每个数字出现多少次。一个求和,一个计数。
似乎数位dp一般思路就是先写一个暴搜,然后再思考怎么记忆化。一开始想到两种暴搜思路:
- 将状态表示成\(sta[10]\),为了省空间不再通过参数传递,而是开一个全局,通过回溯实现。传递的参数有\(pos\)、\(limit\)和\(zero\),前两个含义和上面的一样,\(zero\)是
bool
类型,表示填到现在有没有前导\(0\)。具体过程也差不多,就是把所有状态枚举一遍。\(pos=0\)时结束,把当前的\(sta\)加入到\(ans\)数组中。最后输出\(ans\)。但是如果这一位是前导\(0\)则不能被算入\(sta\)中,所以需要判断什么时候需要加,也就是i!=0||!zero
(解释:如果当前填的数本来就不为\(0\),或者是当前填的数为\(0\),但是这个\(0\)不是前导\(0\),那么是可以累加到\(sta\)中的)。 - 不用数组表示状态,开\(4\)个参数\(pos,limit,cnt,zero\),其中\(cnt\)表示数字\(x\)的出现次数(与上面的\(sum\)类似,不过一个是求和,一个是计数),\(zero\)含义与上面类似。\(pos=0\)结束,返回\(cnt\)。主函数中调用\(10\)次\(dfs\),\(x\)分别取\(0\sim 9\)。调用一次输出一个。
其实用思路B,求出所有位数的和,依次减去\(1\sim9\)的和就是\(0\)的和了,这样不用处理前导\(0\)的情况,但此处就不实现了。
先把暴搜代码写出来(30pts):
思路A:全局数组表示状态,$1$遍搜索出结果
#include<bits/stdc++.h> #define int long long using namespace std; int l,r; int a[20],sta[10],ans[10],t[10]; void dfs(int pos,bool limit,bool zero){ if(pos==0){ for(int i=0;i<10;i++) ans[i]+=sta[i]; return; } int rig=limit?a[pos]:9; for(int i=0;i<=rig;i++){ int temp=sta[i]; sta[i]+=(i!=0||!zero); dfs(pos-1,limit&&i==rig,zero&&i==0); sta[i]=temp; } } void solve(int x){ int len=0; do{ a[++len]=x%10; x/=10; }while(x); dfs(len,1,1); } signed main(){ cin>>l>>r; solve(r); for(int i=0;i<10;i++) t[i]=ans[i]; memset(ans,0,sizeof ans); solve(l-1); for(int i=0;i<10;i++){ cout<<t[i]-ans[i]<<" "; } return 0; }
思路B:搜索$0\sim 9$共$10$次
#include<bits/stdc++.h> #define int long long using namespace std; int l,r; int a[20],x; int dfs(int pos,bool limit,int cnt,bool zero){ if(pos==0) return cnt; int rig=limit?a[pos]:9; int ans=0; for(int i=0;i<=rig;i++){ ans+=dfs(pos-1,limit&&i==rig,cnt+((i!=0||!zero)&&i==x),zero&&i==0); } return ans; } int solve(int x){ int len=0; do{ a[++len]=x%10; x/=10; }while(x); return dfs(len,1,0,1); } signed main(){ cin>>l>>r; for(x=0;x<10;x++){ cout<<solve(r)-solve(l-1)<<" "; } return 0; }
我们发现,思路B更好优化一些。因为思路A把所有数位看做一个整体,如果记忆化不知道应该从何处入手。所以我们选择思路B,在!limit&&!zero
的情况下使用\(f[pos][cnt]\)记忆,与上道题类似。
思路B+记忆化
#include<bits/stdc++.h> #define int long long using namespace std; int l,r,f[20][20]; int a[20],x; int dfs(int pos,bool limit,int cnt,bool zero){ if(pos==0) return cnt; if(!limit&&!zero&&f[pos][cnt]!=-1) return f[pos][cnt]; int rig=limit?a[pos]:9; int ans=0; for(int i=0;i<=rig;i++){ ans+=dfs(pos-1,limit&&i==rig,cnt+((i!=0||!zero)&&i==x),zero&&i==0); } if(!limit&&!zero) f[pos][cnt]=ans; return ans; } int solve(int x){ int len=0; do{ a[++len]=x%10; x/=10; }while(x); return dfs(len,1,0,1); } signed main(){ cin>>l>>r; for(x=0;x<10;x++){ memset(f,-1,sizeof f); cout<<solve(r)-solve(l-1)<<" "; } return 0; }
这样就能\(AC\)了。
时间复杂度即状态数\(\times\)转移复杂度:\(O(10*(log_{10} r)^2\ *\ 10)=O(10^2\times log_{10}^2r)\)
\(f\)数组为什么要memset成全\(-1\)呢?因为有可能记忆化的值为\(0\),而上一题需要记忆化的只有\(>0\)的值。不过为了保险,还是应该赋值为一个更确定用不到的值,或者额外开一个\(vis\)数组。
P6218 [USACO06NOV] Round Numbers S
参数中的\(cnt1\)表示\(1\)的个数,\(qian\)表示前导\(0\)的个数。暴搜比较好实现,就不单独放了。记忆化数组\(f[pos][dif]\)表示当前正在填\(pos\),\(0\)和\(1\)出现个数的差值\(+55\)的值(\(+55\)是为了防止越界)。
点击查看代码
#include<bits/stdc++.h> using namespace std; int l,r,len,f[50][110]; bool a[50]; int dfs(int pos,bool limit,int cnt1,int qian,bool zero){ if(cnt1>(len-qian)/2) return 0; if(pos==0) return 1; if(!limit&&!zero&&f[pos][cnt1-(len-qian-cnt1)+55]!=-1) return f[pos][cnt1-(len-qian-cnt1)+55]; int rig=limit?a[pos]:1,ans=0; for(int i=0;i<=rig;i++){ bool now=(zero&&i==0); ans+=dfs(pos-1,limit&&i==rig,cnt1+i,qian+now,now); } if(!limit&&!zero) f[pos][cnt1-(len-qian-cnt1)+55]=ans; return ans; } int solve(int x){ len=0; while(x){ a[++len]=x%2; x/=2; } return dfs(len,1,0,0,1); } int main(){ cin>>l>>r; memset(f,-1,sizeof f); cout<<solve(r)-solve(l-1); return 0; }
P2657 [SCOI2009] windy 数
模拟填数即可,先写出暴搜。然后我们根据题意,可以知道某个状态只和上一位有关。所以如果两个状态\(pos\)相同,而且上一位也相同,答案就是一样的。所以定义记忆化数组\(f[pos][last]\)表示正在填\(pos\),\(pos+1\)位填的是\(last\)这个数的答案。需要注意的是第一个填的数没有\(last\)(代码中用\(-1\)表示),所以不能用记忆化。
点击查看代码
#include<bits/stdc++.h> using namespace std; int l,r,a[20],f[20][10]; int dfs(int pos,bool limit,int last,bool zero){ if(pos==0) return 1; if(!zero&&!limit&&last!=-1&&f[pos][last]!=-1) return f[pos][last]; int rig=limit?a[pos]:9,ans=0; for(int i=0;i<=rig;i++){ if(!zero&&abs(last-i)<2) continue; ans+=dfs(pos-1,limit&&i==rig,i,zero&&i==0); } if(!zero&&!limit&&last!=-1) f[pos][last]=ans; return ans; } int solve(int x){ int len=0; while(x){ a[++len]=x%10; x/=10; } return dfs(len,1,-1,1); } int main(){ cin>>l>>r; memset(f,-1,sizeof f); cout<<solve(r)-solve(l-1); return 0; }
\([The\ End]\)
呼 先更到这
时间好紧,可能必须留到周末了(
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效