数位DP模板的食用方法

没错!数位 \(\text{DP}\) 虽然是一种动态规划,但因为其在不同题目下的状态设计的高度统一,甚至是完全一致,使得它存在一种“模板”。

模板本体

“数位 \(\text{DP}\) 模板”实际上是一种记忆化搜索(深搜)。具体看如下代码:

ll f(int pos, bool lim, bool zro){
	if(pos == 0) return !zro;
	if(!lim && !zro && dp[pos] != -1) return dp[pos];
	ll res = 0;
	if(zro) res += f(pos - 1, 0, 1);
	int up = lim ? a[pos] : 9;
	for(int d = zro; d <= up; d++) 
        res += f(pos - 1, lim && (d == up), 0);
	if(!lim && !zro) dp[pos] = res;
	return res;
}

让我们来逐行分析代码。

首先,函数传入的基本参数包含一个整型变量 \(pos\) 和两个布尔变量 \(lim\)\(zro\)。分别代表当前填到了从高到低的第 \(pos\) 位,填的时候是否受到限制,以及是否含有前导 \(0\)。这里的“限制”是什么意思呢?

绝大部分数位 \(\text{DP}\) 都有一个上界 \(R\),我们要在不大于 \(R\) 的数中求答案,而这个 \(lim\) 就是用来确保我们填数不会超过 \(R\)。例如 \(R=325\),我们已经填好了百位数,现在要填十位数。假设我们百位数填的是 \(2\),那么十位数显然可以填 \(0\)\(9\) 的任意数,因为目前无论如何填都不会超过 \(R=325\),最多就是 \(\overline{29?}\),不会受到 \(R\) 限制。而如果我们百位数填的是 \(3\),那么十位数最大就只能填到 \(2\),再大就超过 \(R\) 了,这个时候就是受到了 \(R\) 的限制。

if(pos == 0) return !zro;

函数内第一行是一个常规的递归出口,代表我们现在已经枚举出一个完整的数,要判断它是否合法并统计。如果它满足某个条件,那么就返回 \(1\),否则返回 \(0\),因此可以直接返回一个逻辑运算 \(A\) 的结果。代码中展示的 \(zro\) 取反实际上是保证不会将空数(或理解为 \(0\))计入答案,因为如果到递归出口了 \(zro\) 还是 \(\text{True}\) 的话,就代表我们填到这里仍然有前导 \(0\),相当于啥也没填。所以在实际题目中需要返回的是 \(A \land(\neg zro)\) 的结果。

if(!lim && !zro && dp[pos] != -1) return dp[pos];

第二行是记忆化搜索的体现。在不受上界限制和不含前导 \(0\),即 \((\neg lim) \land (\neg zro)=\text{True}\) 的一般条件下,如果我们前面已经记录过当前状态的结果,那么我们就直接调用这个结果而不进行重复的求解,这是优化时间复杂度的核心点。

如果在前面第二行没有已经计算过的结果来调用,那么第三行开始就来首次计算对应的结果,记录在变量 \(res\) 中。

if(zro) res += f(pos - 1, 0, 1);

如果当前状态有前导 \(0\),那我们就可以继续填前导 \(0\),这能够让我们从位数少的数开始枚举。既然当前位我们填了前导 \(0\),那么就代表这一轮枚举得到的位数必定比 \(R\) 少,之后的填数都不会受到 \(R\) 的限制,所以 \(lim\) 传入 \(\text{False}\)\(zro\) 保持 \(\text{True}\) 不变的原因跟刚才相同,如果我们给下一位填前导 \(0\),只要没到递归出口,那么下下一位仍然可以填前导 \(0\)

int up = lim ? a[pos] : 9;

如果我们这一位不填前导 \(0\),而是填实实在在的数字,那么我们需要确定这一位可填数字的上界,这就是 \(lim\) 起作用的地方。上界的确定方法在上文介绍 \(lim\) 概念时已经提到。

for(int d = zro; d <= up; d++) 
    res += f(pos - 1, lim && (d == up), 0);

依次枚举填入的数字。为什么枚举的下界直接设为 \(zro\)\(0/1\) 情况呢?我们需要区分前导 \(0\) 和实实在在的数字 \(0\) 的区别。直观理解为我们平时写下一个含有数字 \(0\) 的数,例如 \(2077\),百位的 \(0\) 是需要“写出来”的,它是这个数的一部分,是实实在在的数字 \(0\)。而前导 \(0\) 就是“不需要写出来”的,比如 \(0002077\),正常情况下前面的三个 \(0\) 不用写,因为它们是前导 \(0\) 而非这个数的一部分。

如果当前状态有前导 \(0(zro=\text{True/1})\),而我们现在要填实实在在的数字,那么实际上就是在填最高位,很显然一个数的最高位不可能是 \(0\),所以当前可填数字的下界就是 \(1\) 。反之,如果当前状态已经没有前导 \(0\)\((zro=\text{False/0})\),那么自然最高位已经填好,现在填的就不是最高位,因此可以填数字 \(0\),可填数字的下界就是 \(0\)

传入下一位的 \(lim\)\(zro\) 应该如何设置?如果下一位填的数字会受到上界的影响,那么必然是到此位为止所有的数字全都在顶着上界填,所以下一位的 \(lim\) 传入 \([lim \land (d=up)]\)。因为现在填的是实实在在的数字,所以 \(zro\) 当然应该传入 \(\text{False}\)

if(!lim && !zro) dp[pos] = res;
return res;

统计好 \(res\) 之后,就进行一般情况的记忆化,然后返回。

经典应用

上文所介绍的是最原始的,使用记忆化搜索进行数位 \(\text{DP}\) 的模板。根据实际题目对于合法数字的限制条件,我们可能需要增加函数传入的参数个数。

例如统计数字和为给定值的数的个数,我们就需要增加统计数字和的 \(sum\) 参数,并进行记忆化。

ll f(int pos, int sum, ll num, bool lim, bool zro){
	if(pos == 0) return !zro && sum == S;
	if(!lim && !zro && dp[pos][sum] != -1) return dp[pos][sum];
	ll res = 0;
	if(zro) res += f(pos - 1, sum, num, 0, 1);
	int up = lim ? a[pos] : 9;
	for(int d = zro; d <= up; d++) 
        res += f(pos - 1, sum + d, num * 10 + d, lim && (d == up), 0);
	if(!lim && !zro) dp[pos][sum] = res;
	return res;
}

统计各位数字互不相同的数的个数(对于数字的出现情况进行限制),我们就需要增加类似于状态压缩的,记录数字出现情况的 \(mask\) 参数,并进行记忆化。

ll f(int pos, int mask, bool lim, bool zro) {
	if(pos == 0) return !zro;
	if(!lim && !zro && dp[pos][mask] != -1) return dp[pos][mask];
	ll res = 0;
	if(zro) res += f(pos - 1, mask, 0, 1);
	int up = lim ? a[pos] : 9;
	for(int d = zro; d <= up; d++){
		if(mask & (1 << d)) continue;
		res += f(pos - 1, mask | (1 << d), lim && (d == up), 0);
	}
	if(!lim && !zro) dp[pos][mask] = res;
	return res;
}

统计相邻数字满足一定条件的数的个数,我们就需要增加记录上一位数字的 \(lst\) 参数。例如:要求相邻数字的差值不小于 \(2\)

ll f(int lst, int x, bool lim, bool zro){
	if (x == 0) return !zro;
	if (!lim && !zro && dp[x][lst] != -1) return dp[x][lst]; 
	ll res = 0;
	if (zro) res = f(lst, x - 1, 0, 1); 
	int up = lim ? a[x] : 9; 
	for(int d = zro; d <= up; d++){
		if(!zro) if(abs(d - lst) >= 2) res += f(d, x - 1, d == up && lim, 0);
		else res += f(d, x - 1, d == up && lim, 0);
	}
	if (!lim && !zro) dp[x][lst] = res; 
	return res;
}

以及许多其他的限制类型。如果需要添加新的参数,那么一定要记得进行记忆化!

posted @ 2025-08-20 21:06  4BboIkm7h  阅读(12)  评论(0)    收藏  举报