数位 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 为假。

总结:

  • 枚举当前第 \(i\) 位为 \(j\) 时满足 small||j<=a[i]
  • 转移子状态(递归调用时)填入新 smallsmall||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 的下标中)呢?

思考后得出了结论:

  1. 与枚举有关的。
  2. 与边界、答案有关的。

与枚举有关的比如:ismall,它们控制了枚举的数——剩下需要枚举的深度或是当前枚举的数位范围。

与边界、答案有关的比如 sum、将要提到的 zero,它们控制了返回的答案——之前的 sum 不同,子状态返回的总和也不会相同。

还是要多多做题、体会、理解。

Luogu P2602 [ZJOI2010] 数字计数

题意

给定两个正整数 \(a\)\(b\),求在 \([a,b]\) 中的所有整数中,每个数码(digit)各出现了多少次。

对于 \(100\%\) 的数据,保证 \(1\le a\le b\le 10^{12}\)

posted @ 2024-01-21 15:45  Po7ed  阅读(6)  评论(0编辑  收藏  举报