数位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\) 为所有状态数

posted @ 2025-05-24 17:13  才瓯  阅读(36)  评论(0)    收藏  举报