[笔记]数位dp例题及详解-上

【接下回】- 数位 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 \times 10len\)\(10 \times \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;
}

这样就能通过了。

时间复杂度即状态数 \(\times\) 转移复杂度:\(O(10 \times ( \log _{10} r)^2\ \times \ 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]\) 呼 先更到这
时间好紧,可能必须留到周末了(

posted @ 2024-04-06 22:09  Sinktank  阅读(289)  评论(0)    收藏  举报
★CLICK FOR MORE INFO★ TOP-BOTTOM-THEME
Enable/Disable Transition
Copyright © 2023 ~ 2025 Sinktank - 1328312655@qq.com
Illustration from 稲葉曇『リレイアウター/Relayouter/中继输出者』,by ぬくぬくにぎりめし.