数位 dp
前言 带有私人情感,请理性阅读。直接跳到 理论 去算了。
前言
不同于其他 dp,数位 dp 的初学者很容易懵逼,比如我。
都是递推版数位 dp 害的!
看到 oj 上“简单”的模板题,题解中生动抽象的分析——什么顶着上界枚举、分开讨论、处理前导 \(0\)、满 \(i\) 位时记为 \(dp_i\)、统计答案……
头都炸了!亿点都不好理解!
跟我念三遍:
数位 dp 只有记搜才是通解!!!
为什么这么说?我也不知道,反正看一个大佬的题解这么说的。
初学者面对那十分甚至九分抽象的递推计算,甚至 \(114514\) 分抽象的统计答案,火卓!学不了一点!
好不容易弄明白了,做难一点的题目,不会!看题解,全是记搜!md 又要重新学。
不多逼逼,先开始吧。
理论
先上模板代码,可以看看注释
#include <iostream>
#include <cstring>
using namespace std;
typedef long long ll;
constexpr int N=10;// 数位长度
int a[N];
ll mem[N][2];// 记忆数组(memory)
#define now mem[i][small]// 当前状态
ll dfs(int i,bool small=false)// 记忆化搜索,反向枚举每一位
{
if(~now)return now;// 搜过了,直接返回
if(!i)return now=1;// 枚举结束,返回。1 可以替换成其他边界状态
now=0;
for(int j=0;j<=9;j++)if(small||j<=a[i])
{
now+=dfs(i-1,small||j<a[i]);
}
return now;
}
ll calc(ll x)
{
memset(mem,0xff,sizeof(mem));// 初始化为 -1
int cnt=0;
while(x)a[++cnt]=x%10ll,x/=10ll;// 数位分离
return dfs(cnt);
}
int main()
{
ll l,r;
scanf("%lld %lld",&l,&r);
printf("%lld",calc(r)-calc(l-1));// 前缀相减
return 0;
}
解释一下:
上面这段代码可以返回 \(r-l+1\)。你可能会觉得这很智障:我直接减不就得了?
你先别急。
calc(r)-calc(l-1)
显然大多数题目需要求区间 \([l,r]\) 的答案。
那么我们可以直接用 \([0,r]\) 的答案 \(-[0,l-1]\) 的答案(类似前缀和)。
这么做的原因是同时控制枚举的上下界 \([l,r]\) 较麻烦。只控制上界就好做许多。
当然,具体求 \([0,r],[0,l-1]\) 时,\(0\) 由于它的特殊性,可能求出的答案与实际不符,但没有关系,因为它会被消掉。
但是如果 \(0\le l,r\le N\),就要注意了:\(l-1\) 可能取到 \(-1\),需要特判。
while(x)a[++cnt]=x%10ll,x/=10ll;
数位分离。可以自己模拟一下。
举个例子,\(114514\) 会被分解为
下标 | \(0\) | \(1\) | \(2\) | \(3\) | \(4\) | \(5\) | \(cnt=6\) |
---|---|---|---|---|---|---|---|
数字 | 空 | \(4\) | \(1\) | \(5\) | \(4\) | \(1\) | \(1\) |
memset(mem,0xff,sizeof(mem));
将整个数组赋值为 \(-1\)。
关于为什么不是 \(0\):很多状态都可能是 \(0\),所以需要区分。
Tip:~
\(\iff\)!=-1
。
small
small
为真时,表示当前枚举的数已经小于了上界。此时若枚举下一位,可以随意枚举 \(0\sim 9\),因为这一位怎么枚举都顶不到上界。
依然以 \(114514\) 为例:
下标 | \(0\) | \(1\) | \(i=2\) | \(3\) | \(4\) | \(5\) | \(cnt=6\) |
---|---|---|---|---|---|---|---|
数字 | 空 | \(4\) | \(1\) | \(5\) | \(\color{red}{4}\) | \(1\) | \(1\) |
当前枚举 | 空 | 空 | 空 | \(5\) | \(\color{red}{2}\) | \(1\) | \(1\) |
显然已经枚举了 \(1125\),因为 \(2<4\),所以 small==true
,也就是说 \(1125{\color{red}00}\sim 1125{\color{red}99}\) 都是合法的。
反之:
下标 | \(0\) | \(1\) | \(i=2\) | \(3\) | \(4\) | \(5\) | \(cnt=6\) |
---|---|---|---|---|---|---|---|
数字 | 空 | \(4\) | \(1\) | \(5\) | \(\color{red}{4}\) | \(1\) | \(1\) |
当前枚举 | 空 | 空 | 空 | \(5\) | \(\color{red}{4}\) | \(1\) | \(1\) |
前面的枚举都顶着上界,small==false
,所以之后的枚举只有 \(1145{\color{red}00}\sim 1145{\color{red}14}\) 合法。
如何转移 small
?
- 如果
small
已经为真,说明已经没有顶着上界(小于上界),所以子状态的small
也为真。同时下一位可以为 \(0\sim 9\)。 - 如果
small
为假,即顶着上界,那么:- 若当前位枚举 \(0\sim a_i-1\),因为 \(0\sim a_i-1<a_i\),
small
为真。 - 若当前位枚举 \(a_i\),依然顶着上界,
small
为假。
- 若当前位枚举 \(0\sim a_i-1\),因为 \(0\sim a_i-1<a_i\),
总结:
- 枚举当前第 \(i\) 位为 \(j\) 时满足
small||j<=a[i]
。 - 转移子状态(递归调用时)填入新
small
为small||j<a[i]
。
可以暂停思考一下为什么是这样。
好的,理论结束,实践开始!
例题
Luogu P4999 烦人的数学作业
题意
\(T\) 组数据,每次给定 \(l,r\),求区间 \([l,r]\) 中每个数字的数位和。
数位和:如 \(123\) 的数位和为 \(1+2+3=6\)。
\(1\le l,r\le 10^{18},T\le 20\)。
解法
直接遍历显然不行。考虑数位 dp。
不要忘了:刚刚的代码只是模板,状态需要按需添加。
考虑新增一个状态 \(sum\),表示 \(\sum_{k=1}^{i-1}a_k\),即目前为止的数位和。那么 mem
数组就要增加一维属于 \(sum\)。同时转移时维护新 \(sum\) 为 \(sum+j\)。\(j\) 为新枚举的数。枚举到头时(边界),就可以返回 \(sum\)。
代码就出来了:
#include <iostream>
#include <cstring>
using namespace std;
typedef long long ll;
constexpr ll mod=1e9+7;
constexpr int N=20,S=N*10;
int T;
int a[N];
ll mem[N][S][2];
#define now mem[i][sum][small]
ll dfs(int i,int sum=0,bool small=false)
{
if(~now)return now;
if(!i)return now=ll(sum);// 此处 sum 即为答案
now=0;// 记得清 0
for(int j=0;j<=9;j++)if(small||j<=a[i])
now=(now+dfs(i-1,sum+j,small||j<a[i]))%mod;
return now;
}
ll calc(ll x)
{
memset(mem,0xff,sizeof(mem));
int cnt=0;
while(x)a[++cnt]=x%10ll,x/=10ll;
return dfs(cnt);
}
int main()
{
scanf("%d",&T);
ll l,r;
while(T--)
{
scanf("%lld %lld",&l,&r);// 下面这里+(mod<<1ll)为了防负数(有取模)
printf("%lld\n",(calc(r)-calc(l-1)+(mod<<1ll))%mod);
}
return 0;
}
【重要】关于状态
我在做了一些数位 dp 题之后就自然产生了一个疑问——怎么确定一个变量要不要存在状态中(mem
的下标中)呢?
思考后得出了结论:
- 与枚举有关的。
- 与边界、答案有关的。
与枚举有关的比如:i
、small
,它们控制了枚举的数——剩下需要枚举的深度或是当前枚举的数位范围。
与边界、答案有关的比如 sum
、将要提到的 zero
,它们控制了返回的答案——之前的 sum
不同,子状态返回的总和也不会相同。
还是要多多做题、体会、理解。
Luogu P2602 [ZJOI2010] 数字计数
题意
给定两个正整数 \(a\) 和 \(b\),求在 \([a,b]\) 中的所有整数中,每个数码(digit)各出现了多少次。
对于 \(100\%\) 的数据,保证 \(1\le a\le b\le 10^{12}\)。