数位 DP(下)
本篇对应源码见仓库
solutions/xcpc/algorithm/dp/digit/。
我的总结
- 核心是枚举每一位,使用记忆化搜索
- 需要明白什么时候可以记忆化,也就是说什么样的状态是可以复用的
- 记忆化数组 \(dp\) 的状态定义: \(dp[pos][state]\) ,\(pos\) 是当前处理的数位, \(state\) 是状态,可以是多个状态
- \(dfs\) 的设计,参数有哪些。
那么解题需要分析以下问题:
- \(dp\) 数组的定义(需要几维,表示什么状态)
- \(dp\) 数组的状态什么时候可以复用
- \(ans\) 的定义和计算方法
- \(dfs\) 需要哪些参数
- 递归的出口条件以及返回值是什么
- 什么时候可以进行递归调用加到 \(ans\) 中
- 什么时候可以对当前状态进行记忆化
别担心,我会从头开始,用最通俗易懂的方式,一步一步带你学会数位DP。我们开始吧!
什么是数位DP?
想象一下,让你统计从1到10000之间,所有不包含数字“4”的数的个数。
-
暴力做法:你可以写一个循环,从1遍历到10000,对每个数,判断它每一位是不是4。如果不是,计数器加一。这很简单,但如果范围变成1到\(10^{18}\)呢?循环肯定超时了。
-
数位DP的用武之地:数位DP就是为了解决这种在给定区间 \([L, R]\) 内,统计满足特定性质的数的个数的问题。它的核心思想是按位(从高位到低位)构造满足条件的数。
数位DP的核心思想与模板
数位DP的本质是一种记忆化搜索。我们不关心具体的数字是几,只关心构造到当前位置(pos)时,前面的数字满足了什么状态(state),以及当前位可以填什么数字。
通常,我们会将问题 solve(L, R) 转化为 count(R) - count(L-1),其中 count(x) 函数计算的是区间 [1, x] 内满足条件的数的个数。这样问题就统一了。
现在,我们来设计一个函数 dfs(pos, state, limit, leading_zero) 来进行记忆化搜索。这个函数的名字和参数不是固定的,但含义基本一致:
-
pos: 当前正在处理的是数字的第几位(通常从高位向低位,或者反过来,但从高位开始更直观)。 -
state: 一个或多个变量,用来记录之前位的状态对当前位选择的影响。这是数位DP最灵活的部分,需要根据具体题目来设计。比如,前面是否已经出现了数字“6”,或者前面所有数字的和是多少等等。 -
limit(关键参数): 一个布尔值,表示当前位可以填的数字是否受限制。-
limit = true: 表示我们之前填的每一位数字,都和上限x的对应位数字完全一样。因此,当前位pos能填的数字最大不能超过x在这一位的数字。例如,我们要统计[1, 567],当前处理到第二位(十位),且第一位(百位)填了5,那么这一位就只能填0到6。 -
limit = false: 表示我们之前填的某一位数字已经比x的对应位要小了。那么从当前位pos开始,后面所有的位都可以随便填(0到9),因为无论怎么填,最后组成的数都肯定小于x。例如,统计[1, 567],第一位我们填了4,那么后面两位就可以随便填00到99了。
-
-
leading_zero(常用参数): 一个布尔值,表示当前是否处于前导零状态。-
leading_zero = true: 表示前面都是0(或者当前是最高位),当前位可以填0(从而继续是前导零),也可以填1-9的数字(从这一位开始就不是前导零了)。这个参数在处理一些与“0”相关的限制时非常有用,比如不能出现“0”或者“0”有特殊意义。 -
leading_zero = false: 表示前面已经填过非零数字了,当前位必须被视为一个正常的数字。
-
记忆化
我们会开一个DP数组,通常是 dp[pos][state]。当 limit = false 且 leading_zero = false 时,我们就可以把 dfs(pos, state, false, false) 的结果存起来。下次再遇到完全一样的 pos 和 state,直接返回结果,避免重复计算。
为什么 limit = true 或 leading_zero = true 时不能记忆化?
因为当 limit 为真时,能填的数字范围是受限的,这个限制依赖于具体的上限 x。例如,同样是处理第2位,对于 x=567 和 x=589,limit 为真时能填的范围是不同的。所以这种情况下的结果不具有“泛用性”。前导零同理,它是一种临时的、不普遍的状态。
第一个例子:统计 [L, R] 区间内不含“4”和“62”的数字
我们来解一道经典题:统计在给定区间 [L, R] 内,不包含数字“4”,也不包含连续的“62”的数的个数。 (例如HDU 2089, "不要62")
1. 问题转化
我们只用实现一个函数 count(n),它计算 [1, n] 中满足条件的数的个数。最终答案就是 count(R) - count(L-1)。
2. 设计 dfs 函数
我们需要一个函数 long long dfs(int pos, int pre, bool limit, bool leading_zero)。
-
pos: 当前处理第pos位(从高到低)。 -
pre: 前一位填的数字是什么。这是我们的state,因为要判断是否出现“62”,必须知道前一位是不是6。 -
limit: 当前位是否受限。 -
leading_zero: 是否是前导零(这题其实可以不加,但作为模板我们带着)。
3. dfs 函数实现细节
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <cstring> // for memset
using namespace std;
long long dp[20][10]; // dp[pos][pre]: dp数组
int N[20]; // 用来存储数字n的每一位
// dfs函数: 从高位到低位构造数字
// pos: 当前处理的是第几位 (从高到低, 比如n=123, pos=3是百位, pos=2是十位)
// pre: 前一位填的数字 (我们的state)
// limit: 当前位的选择是否受n的限制
// leading_zero: 是否有前导零 (这题可以简化,但作为模板保留)
long long dfs(int pos, int pre, bool limit, bool leading_zero) {
// 1. 递归基(出口)
// 如果所有位都填完了(pos=0),说明我们成功构造了一个满足条件的数,返回1
if (pos == 0) {
return 1;
}
// 2. 记忆化
// 只有在不受限制,且没有前导零的情况下,结果才是通用的,可以记忆化
if (!limit && !leading_zero && dp[pos][pre] != -1) {
return dp[pos][pre];
}
// 3. 核心逻辑:遍历当前位可以填的数字
long long ans = 0;
// up决定了当前位能填数字的上限
int up = limit ? N[pos] : 9;
for (int i = 0; i <= up; ++i) {
// 剪枝:检查当前填的数字i是否合法
// a. 不能是4
if (i == 4) continue;
// b. 不能是"62"组合
if (pre == 6 && i == 2) continue;
// 递归到下一位
// pos-1: 处理下一位
// i: 下一位的pre就是当前位的i
// limit && (i == up): 只有当前位受限(limit=true)且当前位填了上限值(i==up)时,下一位才继续受限
// leading_zero && (i == 0): 只有当前是前导零且当前位也填0时,下一位才继续是前导零
ans += dfs(pos - 1, i, limit && (i == up), leading_zero && (i == 0));
}
// 4. 记录结果到dp数组
if (!limit && !leading_zero) {
dp[pos][pre] = ans;
}
return ans;
}
// 计算[1, n]区间内满足条件的数的个数
long long count(int n) {
if (n == 0) return 0; // 视题目要求,0是否满足。这里我们从1开始计数。
int len = 0;
while (n > 0) {
N[++len] = n % 10;
n /= 10;
}
memset(dp, -1, sizeof(dp));
// 我们从最高位len开始,前一位数字可以认为是任意非6的数(比如10),limit=true, leading_zero=true
return dfs(len, 10, true, true) - 1; // 减去0本身(如果dfs会把0算进去的话)
}
int main() {
int l, r;
while (cin >> l >> r && (l != 0 || r != 0)) {
cout << count(r) - count(l - 1) << endl;
}
return 0;
}
代码讲解:
-
count(n)函数:-
将数字
n拆分到数组N中。比如n=567,N存的会是N[1]=7, N[2]=6, N[3]=5。这样N[pos]就对应了pos位的数字。 -
初始化
dp数组为-1,表示都没算过。 -
调用
dfs(len, 10, true, true)开始搜索。len是总位数,pre的初始值只要不是6就行(比如10),limit和leading_zero初始都为true。 -
dfs会把0也算作一个合法的数(因为它不含4和62),但题目通常要求统计[1, n],所以要减去dfs为0构造出的那一种情况。
-
-
dfs函数:-
出口:
pos == 0,所有位都安全填完,找到一个合法数字,返回1。 -
记忆化:
!limit && !leading_zero时,dp[pos][pre]的结果是通用的,可以存取。 -
循环:
for (int i = 0; i <= up; ++i)是核心。up由limit决定。这是数位DP的精髓——用limit来控制每一位的上限。 -
剪枝:
if (i == 4)和if (pre == 6 && i == 2)是根据题目要求加的限制条件。 -
递归:
ans += dfs(...)。注意limit和leading_zero参数的更新:-
limit && (i == up): 只有当前位本身是受限的,并且你填的数字又达到了上限,下一位才会继续受限。否则,下一位就自由了。 -
leading_zero && (i == 0): 只有当前允许前导零,并且你也确实填了0,下一位才继续是前- 导零状态。
-
-
如何学习与练习
-
理解模板: 把上面这个例子的代码和逻辑彻底搞懂。理解每一个参数的含义,尤其是
limit。可以拿纸笔,手动模拟count(30)的过程,看看dfs是如何被调用的,limit是如何变化的。-
dfs(2, 10, true, true)for30:-
pos=2, limit=true,up=3. -
i=0:dfs(1, 0, false, true) -
i=1:dfs(1, 1, false, false) -
i=2:dfs(1, 2, false, false) -
i=3:dfs(1, 3, true, false)(因为i==up)
-
-
-
修改模板: 尝试解决一些类似的问题,你只需要修改
dfs的state和剪枝条件。-
例题1:Windy数:求
[L, R]区间内,不含前导零且相邻两位数字之差至少为2的数的个数。-
你需要修改
state:pre仍然是前一位的数字。 -
你需要修改剪枝:
if (!leading_zero && abs(i - pre) < 2) continue; -
注意
leading_zero的处理。当leading_zero为true时,这个差值限制不生效。
-
-
例题2:数字游戏:求
[L, R]区间内,各位数字之和能被K整除的数的个数。-
你需要修改
state:int sum_mod,表示到目前为止,各位数字之和模K的结果。 -
剪枝条件没有了。
-
递归
state变化:dfs(pos-1, (sum_mod + i) % K, ...) -
递归出口:
if (pos == 0) return sum_mod == 0 ? 1 : 0;
-
-
-
练习平台:
-
洛谷 (Luogu): 有很多数位DP的模板题和好题,可以按标签查找。
-
Codeforces: 也有很多数位DP题目,难度梯度更大。
-
HDU Online Judge: HDU 2089 是最经典的入门题。
-
总结一下关键步骤
-
转化问题: 将
[L, R]的询问变成count(R) - count(L-1)。 -
设计
dfs函数:dfs(pos, state, limit, leading_zero)。确定state需要记录哪些信息来满足题目限制。 -
实现
dfs:-
递归出口:
pos == 0,根据state判断是否是合法方案。 -
记忆化:
!limit && !leading_zero时存取dp[pos][state]。 -
循环与剪枝:
for (int i = 0; i <= up; ++i),根据题目要求剪枝。 -
递归调用: 正确更新
limit和leading_zero状态。
-
-
编写
count函数: 拆分数字,调用dfs并处理初始条件和边界情况。
数位DP就是这样一个“一旦学会,题型都一样”的算法。多花点时间理解第一个例子的模板,亲手敲一遍,再用它去解决几道不同的题目,你就会发现它的套路其实非常清晰。

浙公网安备 33010602011771号