学习笔记:数位 DP
::::info[致管理大大] 不是,你这让我怎么整改……我是真的不会了。
首先,数学公式中有中文,好吧,这个我改了。
然后,请使用英文定义变量,一看这一大堆
最后,逻辑条件请使用集合符号,啊这个……我是要改
然后我投喂给 AI 的话不仅改了这些东西,改的面目全非,而且改成了一些废话的低质内容,根本没法用。所以求求你给我过吧。真的很绝望。 ::::
往期文章:
学习笔记:状压 DP
理论
数位 DP,通常处理一种统计区间
在这么大的数据中,直接暴力是不行的,分段打表也不太能处理(不是代码长度爆了就是 TLE),所以我们需要我们今天请出的大杀器——数位 DP。
数位 DP 能够处理的问题一般有以下的特征:
- 可以通过记录这个数的部分信息来表示一个状态。
- 在后面数位做出的选择不会影响在前面数位做出的选择(即 DP 的无后效性)。假如我们发现了有后效性,可以考虑通过增加状态,或者枚举某些变量来满足无后效性。
1. 原理
1.1 递推实现
首先,通过一道你谷上没有的题目引出:
统计
[l, r] 中不含1 的数字个数。l, r\leq 10^{18} 。
数位 DP 的实现有两种方法:递推和记忆化搜索。在本题,我会给出两种方法的代码。
数位 DP 的一个常见转化是通过差分,把
接下来想 DP。首先,我们考虑这样一种 DP 的状态:
首先,我们考虑常规的 DP,即递推。在某一位之后,显然接上任何不含有
但是,可能我们的
可以将这个式子分开理解:第一部分表示这一位为
接着,我们考虑边界情况。首先,任意选最后一位的方案数显然是
#include <bits/stdc++.h>
#define int long long
#define endl '\n'
using namespace std;
const int N = 18 + 5;
int l, r, dp[N], dp2[N], a[N];
int calc(int x)
{
if(x == 0) return 1; //这里需要特判,否则会有tot=0
memset(dp, 0, sizeof dp), memset(dp2, 0, sizeof dp2);
int tot = 0;
while(x > 0) //拆位
{
a[ ++ tot] = x % 10;
x /= 10;
}
dp[1] = 9;
if(a[1] == 0) dp2[1] = 1; //初始值
else dp2[1] = a[1];
for(int i = 2; i <= tot; i ++ ) //转移,之前已经提到过
{
dp[i] = 9 * dp[i - 1];
if(a[i] == 0)
dp2[i] = dp2[i - 1];
else if(a[i] == 1)
dp2[i] = dp[i - 1];
else
dp2[i] = dp2[i - 1] + (a[i] - 1) * dp[i - 1];
}
return dp2[tot]; //答案就是最高位的 dp2 值,因为不能超过原数
}
signed main()
{
ios :: sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> l >> r;
cout << calc(r) - calc(l - 1) << endl;
return 0;
}
1.2 记忆化搜索实现
但是,数位 DP 的递推写法还是太吃操作了,尤其是转移的分类讨论很容易写挂。有没有更加简单强势的做法推荐一下?有的,那就是记忆化搜索。
记忆化搜索的逻辑其实和递推有着很多不同。与递推的按照该位是否取满分类讨论不同,记忆化搜索直接枚举这一位,然后根据是否满足条件选择搜不搜索下一位。接着,对于一些相同且冗余的状态选择使用一个数组记忆化。
记忆化搜索可以如下定义:
解释一下:方程中的
接着考虑边界。假如来到了
#include <bits/stdc++.h>
#define int long long
#define endl '\n'
using namespace std;
const int N = 18 + 5;
int l, r, dp[N][2], a[N];
int dfs(int x, bool up)
{
if(x == 0) return 1; //边界
if(dp[x][up] ^ -1) return dp[x][up]; //记忆化
int lim = up ? a[x] : 9, res = 0;
for(int i = 0; i <= lim; i ++ ) //之前已经提到过了,不多解释
{
if(i == 1) continue;
res += dfs(x - 1, up & (i == lim));
}
return dp[x][up] = res; //记忆化
}
int calc(int x)
{
memset(dp, -1, sizeof dp);
int tot = 0;
while(x > 0)
{
a[ ++ tot] = x % 10;
x /= 10;
}
return dfs(tot, true);
}
signed main()
{
ios :: sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> l >> r;
cout << calc(r) - calc(l - 1) << endl;
return 0;
}
1.3 两种方法的对比
代码长度方面,两种方法差不多。
时间复杂度方面,递推实现是
接着是思考难度方面。递推实现需要你想出第一位的边界条件以及如何转移(而且转移方程一般不止一个),而记忆化搜索你只需要考虑边界,其它的直接搜下去就可以了。所以说记忆化搜索要好想很多。
最后是代码是否好背可以复用。递推实现基本上每次换一个题就要改方程,但是记忆化搜索一般只需要改一下参数就可以了,代码大致不变。所以记忆化搜索又胜。
综上所述,记忆化搜索优于递推(虽然你要用递推写也可以),所以以后的代码都会使用记忆化搜索实现。
2. 例题
2.1 有趣数字
求区间
[l, r] 内有多少个数满足这个数的各个数位单调不降。l, r\le 10^{100} 。对10^9+7 取模。
首先看到这么大的数字千万不要先去拿你的高精度板子,因为数位 DP 其实是不需要高精度的。(假如答案不取模当我没说)因为数位 DP 其实是需要先拆位再计算的,你拿一个 string 读进去其实是帮你提前拆好位了。但是我们不能方便的对于字符串做减法,所以可以写一个单点判断
接着,我们考虑设计状态。在这里,数字单调不降是相对于它前面的那个数,所以我们还需要额外记录一下前面的那个数,因此定义
边界也是直接返回
#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
const int N = 100 + 5;
const int MOD = 1e9 + 7;
string L, R;
int a[N], dp[N][N][2];
int dfs(int x, int pre, bool up)
{
if(x == 0) return 1; //边界
if(dp[x][pre][up] ^ -1) return dp[x][pre][up]; //记忆化
int lim = up ? a[x] : 9, res = 0;
for(int i = pre; i <= lim; i ++ )
res = (res + dfs(x - 1, i, up & (i == lim))) % MOD; //转移
return dp[x][pre][up] = res; //记忆化
}
int calc(string s)
{
int len = s.size();
for(int i = 0; i < len; i ++ )
a[i + 1] = s[i] - '0';
reverse(a + 1, a + len + 1); //由于我习惯高位在后面,所以反转一下
memset(dp, -1, sizeof dp);
return dfs(len, 0, true);
}
int pd(string s) //单点判断
{
for(int i = 0; i < s.size() - 1; i ++ )
if(s[i] > s[i + 1])
return 0;
return 1;
}
int main()
{
ios :: sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> L >> R;
cout << (calc(R) - calc(L) + pd(L) + MOD) % MOD << endl;
return 0;
}
2.2 windy 数
求区间
[l, r] 内有多少个整数满足相邻两个数字之差至少为2 。l, r\leq 10^{18} 。
一个状态多一点的数位 DP 题。话说弱化版我是分段打表的。
这道题,我们需要加入一个数位
于是就可以设计状态了。令
枚举下一位,如果合法(差值超过
int dfs(int x, int pre, bool up, bool is0)
{
if(x == 0) return 1;
if(dp[x][pre][up][is0] ^ -1) return dp[x][pre][up][is0];
int lim = up ? a[x] : 9, res = 0;
for(int i = 0; i <= lim; i ++ )
{
if(abs(i - pre) < 2 && !is0) continue;
res += dfs(x - 1, i, up & (i == lim), is0 & (i == 0));
}
return dp[x][pre][up][is0] = res;
}
2.3 手机号码
求区间
[l, r] 内的所有满足如下条件的数:
- 必须是
11 位数。- 不能同时出现
8 和4 。- 需要有三个连续且相同的数。
一道状态巨多的数位 DP 题。首先,我们考虑需要维护哪些信息。首先,
接着,转移还是枚举下一位的数,
边界条件就是假如所有性质就返回
int dfs(int x, int pre, int pre2, bool up, bool is4, bool is8, bool is3, bool is0)
{
if(x == 0)
return (is3 && !(is4 && is8)) ? 1 : 0;
if(dp[x][pre][pre2][up][is4][is8][is3][is0] ^ -1)
return dp[x][pre][pre2][up][is4][is8][is3][is0];
int lim = up ? a[x] : 9, res = 0;
for(int i = 0; i <= lim; i ++ )
res += dfs(x - 1, i, pre, up & (i == lim), is4 | (i == 4),
is8 | (i == 8), is3 || (pre == pre2 && pre == i && !is0),
is0 & (i == 0));
return dp[x][pre][pre2][up][is4][is8][is3][is0] = res;
}
2.4 杠杆数
求
[l, r] 中的杠杆数数量。杠杆数定义为选择一个数作为支点,两边可以平衡的数,例如
1234 是一个杠杆数,我们取3 为支点,有1\times 2+2\times 1=4\times 1 。
话说紫题这么水的吗。
首先,我们想一下可不可以一次搜索就搜出来所有的杠杆数。显然这是不太可能的,因为一次搜索在不确定支点的情况下,难以判定一个数是否是杠杆数。
但是,难道这就没有办法了?不是的,我们可以枚举支点!这样的话,问题就变得好做一些了。首先先证明一下对于非
::::info[证明]
首先,这个数非
然后尝试移动这个支点。由于这个数必定左边和右边都有一位非
故杠杆数只有一个支点。 ::::
我们考虑设计状态。首先,我们需要判定这个数是不是杠杆数,可以设计两个变量
接着就是套路了。由于我们枚举了杠杆支点,所以
于是使用
#include <bits/stdc++.h>
#define int long long
#define endl '\n'
using namespace std;
const int N = 18 + 5;
int x, y, a[N], dp[N][N][2000][2][2];
int dfs(int x, int pos, int w, bool up,bool is0)
{
if(x == 0)
{
if(is0) return 0;
return w == 0 ? 1 : 0;
}
int kw = w + 1000; //力矩可能为负,所以偏移
if(dp[x][pos][kw][up][is0] ^ -1) return dp[x][pos][kw][up][is0];
int lim = up ? a[x] : 9, res = 0;
for(int i = 0; i <= lim; i ++ )
{
int nx = x - 1, npos = pos, nw, nup = up & (i == lim), nis0 = is0 & (i==0);
if(x < pos) nw = w + (pos - x) * i;
else nw = w - (x - pos) * i;
res += dfs(nx, npos, nw, nup, nis0);
}
return dp[x][pos][kw][up][is0] = res;
}
int calc(int x)
{
int tot = 0;
while(x > 0)
{
a[ ++ tot] = x % 10;
x /= 10;
}
memset(dp, -1, sizeof dp);
int ans = 0;
for(int i = 1; i <= tot; i ++ ) //枚举支点
ans += dfs(tot, i, 0, true, true);
return ans;
}
signed main()
{
ios :: sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> x >> y;
cout << calc(y) - calc(x - 1) << endl;
return 0;
}
2.5 数字计数
求
[l, r] 中每个数码(0\sim 9 )出现了多少次。l, r\leq 10^{12} 。
这道题居然是统计每个数码出现的次数,而不是满足条件的数字个数!好吧其实是一样的。
首先,一次搜索求出所有数码很困难,所以我们可以考虑枚举数码然后搜索(反正也要每个数码求一遍)。接着,考虑设计状态。由于这里
于是,设计
于是,具体实现的时候枚举下一位的数字,假设下一位数字为
#include <bits/stdc++.h>
#define int long long
#define endl '\n'
using namespace std;
const int N = 12 + 5;
int l, r, a[N], dp[N][N][N][2][2];
int dfs(int x, int num, int cnt, bool up, bool is0)
{
if(x == 0) return cnt;
if(dp[x][num][cnt][up][is0] ^ -1) return dp[x][num][cnt][up][is0];
int lim = up ? a[x] : 9, res = 0;
for(int i = 0; i <= lim; i ++ )
{
int nx = x - 1, nnum = num, ncnt, nup = up & (i == a[x]), nis0 = is0 & (i == 0);
ncnt = cnt + ((i == num && !nis0) ? 1 : 0);
res += dfs(nx, nnum, ncnt, nup, nis0);
}
return dp[x][num][cnt][up][is0] = res;
}
int calc(int x, int num)
{
int tot = 0;
while(x > 0)
{
a[ ++ tot] = x % 10;
x /= 10;
}
memset(dp, -1, sizeof dp);
return dfs(tot, num, 0, true, true);
}
signed main()
{
ios :: sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> l >> r;
for(int i = 0; i <= 9; i ++ ) //枚举数码
cout << calc(r, i) - calc(l - 1, i) << " ";
return 0;
}
2.6 练习
- 二进制问题
- 烦人的数学作业
- Round Numbers S
- 花神的数论题
- 萌数
- 同类分布
- 如果有别的题可以找我补充。
结语
数位 DP 是一种下限较高,上限不高的 DP 类型,只要熟练了板子,大多数的数位 DP 题都较为简单。个人感觉数位 DP 是很不“DP”的一种 DP,因为 DP 的题目大多都不依赖板子。总之,弄懂了基本原理,数位 DP 就是一个很简单的东西,不要因为题目难度高就觉得它难,其实数位 DP 有很多大水紫。
参考资料:
- 数位 dp