「学习笔记」数位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\)
按照我们上面记忆化搜索的模式,我们在搜索时,需要设 \(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\)
由于需要判断当前数字和上一个数字的差是否为 \(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\)
对于此题,我们的记忆化搜索参量就不止 \(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\)
很明显,如果按照之前的模板操作,这道题是轻而易举的。
但是,当我们记录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\)
待更(((