天天看点

数位dpの学习笔记

0x10 数位dp简介

数位dp通常用于解决这类题目:

给定一个范围 \(l\) ~ \(r\) ,求出这个范围内,符合某种条件的数字个数、数字的和或数字的积。

给出的数字非常之巨大,采用 \(O(N)\) 的算法无法通过题目,当我们遇到这类题目时,通常是拿不到暴力分的。

于是,我们在遇到这类题目时,通常要使用数位dp来解决。

0x20 数位dp实现方式

我们通常都使用记忆化搜索的方法来进行实现,可以 \(AC\)。

注意,当我们的记忆化不当时,或搜索次数、参量处理不当时,我们很有可能会超时或得到错误的答案。

所以,设计好的参量和状态,认真处理每个参量,是十分重要的。

这里给出记忆化搜索的模板,一个好的模板可以帮助你灵活面对难度较大的题目。

int tot = 0;//tot为数字的位数。 
int dig[20];//dig为数字拆分后的各个位上的数字。 
int dp[255][255];//dp为设计的转移状态。 
int dfs (int now, int cnt, int zero, int limit) {
	//now为当前的处理的位数。 
	//cnt为变量,用来存储例如1的个数。数位累计和等。 
	//zero标记当前是否有前导零。 
	//limit为限制,也就是是否到达数字的边界。 
	
	if (now > tot) {
		return 1;
		/*处理的位数已经超过了数字的个数,也就表示该数字处理完成,退出循环。
		  而通常返回1,表示该数合法,因为之前的操作都是当处理位数合法的时候进行的。
		  当然,根据题目和写法的不同,可能操作上有差异。 
		*/
	} 
	
	if (!limit && !zero && dp[now][cnt] != -1) {
		return dp[now][cnt];
		//该操作就是记忆化的体现。 
		//对于有的题目,前导零可能对于答案没有影响,所以需要灵活调整条件。 
		//而状态初始为-1,当当前状态不为-1时,也就是该状态处理过了。
		//而根据记忆化的操作,我们就可以直接返回答案了。 
	} 
	
	int up = limit ? dig[tot - now + 1] : 9;
	//up表示边界,当当前数字已经是边界了的时候,就是dig[tot-now+1].
	//否则,该进制的最大数位数字,十进制就是9,二进制就是1。
	
	int ans = 0;
	//在大部分情况中,答案不止于int范围,依旧需要根据情况理智处理。
	
	for (int i = 0; i <= up; i ++) {//枚举数字从0到up 
		if ()......//按情况写条件
		ans += dfs (now + 1, 当前状态转移, (zero && i == 0), (i == dig[now]));
		//对于第三个参量,搜索当前标记zero为真且枚举当前数字为0时,zero标记才为真,否则为假。
		//对于第四个参量,当前数字为当前搜索的数字时,limit标记才为真,否则为假 
	} 
	
	if (!limit && !zero) {
		dp[now][cnt] = ans;
		//这里是记忆化的存储,条件依旧需要根据情况书写。 
	} 
	
	return ans;
	//返回答案 
} 
           

0x30 数位dp基础

在难度不是很高的情况下,记忆化搜索就能帮助我们完成很多题目。既然我们了解了数位dp的概念和基本实现方法,那我们就用几道基础例题来快速提升自己吧!

0x31 基础例题 \(1\)

P4317 花神的数论题

按照我们上面记忆化搜索的模式,我们在搜索时,需要设 \(4\) 个参数。但是我们在该题可以很直接的看出,前导零是不会对答案造成影响的,因为它并不会影响二进制下 \(1\) 的个数。所以,我们搜索的时候,只需要设立 \(3\) 个参量就可以了。

而dp数组的状态对我们来说其实是不太重要的,我们只要将其运用固定地用在记忆化的地方就可以了。

该题我们直接采用暴力累乘,并不用担心时间,将模板套用即可。

可能需要吸氧。

代码讲解如下:

#include <cstdio>
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>

using namespace std;

typedef long long ll;

const int mod = 10000007;

const int N = 115;

int dig[15], tot = 0; 

ll dp[N][N];

ll dfs (int now, int cnt, int lim) {
	//只需要设立三个参数。 
	//cnt:二进制下1的个数。 
	if (now > tot) {
		return cnt;
		//当处理的位数超过数字的个数时,表示处理结束,返回答案。 
	} 
	if (!lim && dp[now][cnt] != -1) {
		return dp[now][cnt];
		//满足记忆化的要求。 
	}
	
	ll res = 1;//因累乘,所以初始化为1。 
	
	int up = lim ? dig[tot - now + 1] : 1;//定义边界。 
	
	for (int i = 0; i <= up; i ++) {
		ll tp = dfs (now + 1, cnt + (i == 1), (lim && (i == up)));
		//对于第二个参量,当当前数字为1时才增加答案。 
		//当边界标记为真且当前数字为边界时,边界标记才为真。 
		
		res = max (1ll, tp) * res % mod;
		//tp可能小于1,所以和1取max,直接暴力累乘。 
	}
	
	if (!lim) {
		dp[now][cnt] = res;
		//记忆化的记录答案。 
	}
	
	return res;
}

ll calc (ll x) {
	memset (dp, -1, sizeof (dp));
	//状态的初始值。 
	
	do {
		dig[++tot] = x % 2;
		x /= 2;
	}while (x);
	//二进制拆分。 
	
	return dfs (1, 0, 1);
	//初始处理第1位,1的个数为0,边界标记为真。 
}

int main() {
	ll n;
	scanf ("%lld", &n);
	printf ("%lld", calc (n));
	return 0;
}
           

0x32 基础例题 \(2\)

P2657 [SCOI2009] windy 数

由于需要判断当前数字和上一个数字的差是否为 \(2\) ,所以要记录上一位的数字。于是记忆化搜索的变量设置为上一个数字。

在枚举下一个数字时,如果不满足相差为 \(2\) 的条件,那么可以直接跳过。注意,如果当前有前导零,那么该数位可以任意填。

根据前缀和的思想,题目给出的 \(a\) 和 \(b\) 区间,我们计算出的是 \(1\) 到 \(a\) 和 \(1\) 到 \(b\) 满足条件的数字个数。

于是我们需要输出 \(1\) 到 \(b\) 满足条件的数字个数减去 \(1\) 到 \(a-1\) 满足条件的个数,就是我们最终的答案。

于是这样,大体的思路就出来了,灵活运用模板就可以通过。

#include <cstdio>
#include <algorithm>
#include <cstring>
#include <iostream>
#include <cmath>

using namespace std;

typedef long long ll;

int dig[15], dp[115][115], tot = 0;

int dfs (int now, int last, int zero, int limit) {
	if (now > tot) {
		return 1;
		//处理完了返回该数合法 
	}
	
	if (!limit && dp[now][last] != -1) {
		return dp[now][last];
		//记忆化 
	}
	
	int sum = 0, up = limit ? dig[tot - now + 1] : 9;
	//定义边界 
	
	for (int i = 0; i <= up; i ++) {
		if (i < last + 2 && i > last - 2 && !zero) {
			continue;
			//不符合相差为2的条件 
		}
		
		sum += dfs (now + 1, i, (zero && !i), (limit && (i == up)));
		//对于第三个参量,当枚举的数位为0且当前为0时,前导零标记才为真。
		//当前边界标记为真且当前枚举的数位到达边界,边界标记才为真。 
	}
	
	if (!zero && !limit) {
		dp[now][last] = sum;
		//记忆化的记录答案。 
	}
	
	return sum;
}

ll calc (ll x) {
	memset (dp, -1, sizeof (dp));
	memset (dig, 0, sizeof (dig));
	
	tot = 0;
	//这里的初始化很重要,不要忘记。 
	
	do {
		dig[++tot] = x % 10;
		x /= 10;
	}while (x);
	//十进制拆分。
	
	return dfs (1, -1, 1, 1);
}

int main() {
	ll a, b;
	scanf ("%lld%lld", &a, &b);
	printf ("%lld", calc (b) - calc (a - 1));
	//前缀和思想的运用。 
	
	return 0;
}
           

0x33 基础例题 \(3\)

P4124 [CQOI2016]手机号码

对于此题,我们的记忆化搜索参量就不止 \(4\) 个了。

我们需要多增加 \(3\) 个参量:

\(1.\) 标记是否出现过 \(4\)。

\(2.\) 标记是否出现过 \(8\)。

\(3.\) 标记是否构成三连号。

另外,我们依旧有用到前缀和的思想。但是当我们将左边界减去一后,无法满足电话号码 \(11\) 位的条件,这里我们需要特判。

我们依旧需要记忆化来优化时间,大体的代码就能写出来了。

#include <cstdio>
#include <algorithm>
#include <iostream>
#include <cstring>

using namespace std;

typedef long long ll;

int dig[15], tot = 0;

ll dp[35][15][15][3][3][3][3];

ll dfs (int pos, int now, int last, bool con3, bool f4, bool f8, bool limit) {
	if (f4 == true && f8 == true) {
		return 0;
		//同时出现4和8时,退出搜索。 
	}
	
	if (pos == 0) {
		return con3 == true ? 1 : 0;
		//搜索完时,判断是否三连号,否则不合法。 
	}
	
	if (!limit && dp[pos][now][last][con3][f4][f8][limit] != -1) {
		return dp[pos][now][last][con3][f4][f8][limit];
		//记忆化返回答案。 
	} 
	
	int up = limit ? dig[pos] : 9;//边界。 
	
	ll ans = 0;
	
	for (int i = 0; i <= up; i ++) {
		bool c3 = false;
		if (last == i && i == now) {
			c3 = true;
			//三连号标记。 
		}
		
		ans += dfs (pos - 1, i, now, (c3 || con3), (i == 4 || f4), (i == 8 || f8), limit && i == dig[pos]);
		//对于第四个参量,只要当前构成三连号并且三连号标记为真,接下去搜索的三连号标记就为真。 
		//对于第五个参量,只要当前有4或者标记4为真,接下去搜索的4标记就为真。 
		//对于第六个参量,只要当前有8或者标记8为真,接下去搜索的8标记就为真。 
		//对于第七个参量,当边界标记为真且当前数位到达边界,边界标记为真。 
	}
	
	if (!limit) {
		dp[pos][now][last][con3][f4][f8][limit] = ans;
		//记忆化记录答案。 
	}
	
	return ans;
}

ll calc (ll x) {  
	tot = 0;
	
	do {
		dig[++tot] = x % 10;
		x /= 10;
	}while (x);
	//拆分。 
	
	ll ans = 0;
	
	for (int i = 1; i <= dig[tot]; i ++) {
		ans += dfs (tot - 1, i, -1, false, (i == 4), (i == 8), (i == dig[tot]));
		//累计答案。 
	}
	
	return ans;
}

int main() {
	ll l, r;
	scanf ("%lld%lld", &l, &r);
	
	memset (dp, -1, sizeof (dp)); 
	
	if (l == 10000000000) {
		printf ("%lld", calc (r));
		return 0;
		//特判。 
	} 
	
	printf ("%lld", calc(r) - calc (l - 1));
	
	return 0;
}
           

0x40 数位dp进阶

对于一些难度较高的题目,单凭记忆化搜索的时间优化无法通过。

于是,我们需要对于状态进行合并、预处理或剪枝等。这些进阶的方法可以帮助我们实现时间上的巨大优化。

接下来,就从几道进阶的数位dp例题来学习优化时间的方法吧。

0x41 进阶例题 \(1\)

P4127 [AHOI2009]同类分布

很明显,如果按照之前的模板操作,这道题是轻而易举的。

但是,当我们记录dp状态的时候,就会发现情况不对。

这里的数字会达到 \(10^{18}\) ,我们无法记录 dp状态了。

于是这里,我们就很容易想到取模的做法。

那我们将什么作为模数呢?

我们可以枚举所有的数位之和来当做模数。

于是,判断 \(mod\) 与数位上的和相同且原来的数字模数位上的和为 \(0\) ,这个答案就合法。

#include <iostream>
#include <cstring>
#include <algorithm>
#include <cstdio>
#include <cmath>

using namespace std;

typedef long long ll;

ll l, r; 

ll dp[22][222][222];

int dig[22], tot = 0, mod;

ll dfs (int now, int cnt, ll rem, int limit) {
	//now:当前处理的数位。
	//cnt:枚举了的数位之和。
	//rem:数字的总和模去mod后的得数。
	//limit:标记是否到达边界。 
	
	if (now > tot) {
		if (cnt == 0) {//不符合题意。 
			return 0;
		}
	}
	
	if (now > tot) {
		if (rem == 0 && mod == cnt) {
			//这是符合题意的情况。 
			return 1;
		}
		else {
			return 0;
		}
	}
	
	if (!limit && dp[now][cnt][rem] != -1) {
		return dp[now][cnt][rem];
	}
	
	ll res = 0;
	
	int up = limit ? dig[tot - now + 1] : 9;
	
	for (int i = 0; i <= up; i ++) {
		res += dfs (now + 1, cnt + i, (10 * rem + i) % mod, i == up && limit);
	}
	
	if (!limit) {
		dp[now][cnt][rem] = res;
	} 
	
	return res;
}

ll calc (ll x) {
	tot = 0;
	
	do {
		dig[++tot] = x % 10;
		x /= 10;
	}while (x);
	
	ll ans = 0;
	
	for (mod = 1; mod <= 9 * tot; mod ++) {
		//枚举模数。 
		memset (dp, -1, sizeof (dp));
		//每次都要初始化dp状态。 
		ans += dfs (1, 0, 0, 1);
	}
	
	return ans;
}

int main() {
	scanf ("%lld%lld", &l, &r);
	printf ("%lld", calc (r) - calc (l - 1));
	return 0;
}
           

0x42 进阶例题 \(2\)