【数位 DP / 记忆化搜索】蓝桥云课 - 光轨区的 AI 数 题解
1. 题目概述
- 题目大意 :求区间 l,rl, rl,r 内满足"任意相邻数位上的数字之差的绝对值均不超过 kkk"的整数个数。
- 数据规模 :1≤l≤r≤10181 \le l \le r \le 10^{18}1≤l≤r≤1018,0≤k≤80 \le k \le 80≤k≤8
2. 解题思路
由于 r≤1018r \le 10^{18}r≤1018,直接暴力遍历会超时。本题是标准的数位 DP(数位动态规划)问题,采用记忆化搜索的方式最为简便。
-
区间差分 :
求 l,rl, rl,r 区间内满足条件的个数,转化为求前缀和:
ans = solve(r) - solve(l - 1)。 -
状态定义 :
设计 DFS 函数
dfs(pos, pre, is_limit, is_num):pos:当前处理到从高到低的第几位。pre:上一个数位选择的数字(0∼90 \sim 90∼9)。is_limit:当前位填数是否受上限限制(若为true,则最高只能填到当前位的数字限制)。is_num:前面是否已经填入了有效的数字(用于处理前导 0)。
-
转移条件:
- 填写当前位数字 ddd 时,若
is_num为true,必须满足 ∣d−pre∣≤k|d - pre| \le k∣d−pre∣≤k; - 若
is_num为false且 d=0d = 0d=0,表示依然是前导 0,转移时is_num仍为false; - 若
is_num为false且 d>0d > 0d>0,则 ddd 作为最高有效位,转移时pre设为 ddd,is_num设为true。
- 填写当前位数字 ddd 时,若
-
记忆化 :
只有当
!is_limit && is_num时(即不受上限约束且已经填了有效数字),状态才具有通用性,可以保存在dp[pos][pre]数组中进行复用。
3. C++ 源码
cpp
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
LL l, r;
int k;
int digits[20];
LL dp[20][10];
/**
* @brief 数位 DP 记忆化搜索
* @param pos 当前搜索的数位索引(从高位到低位)
* @param pre 上一位填写的数字
* @param is_limit 是否受到高位限制
* @param is_num 前面是否已经填入了有效数字(处理前导 0)
*/
LL dfs(int pos, int pre, bool is_limit, bool is_num) {
// 递归基:搜索完所有数位
if (pos < 0) return is_num ? 1 : 0;
// 记忆化检索
if (!is_limit && is_num && dp[pos][pre] != -1) {
return dp[pos][pre];
}
LL res = 0;
int up = is_limit ? digits[pos] : 9; // 当前位填数的上限
for (int d = 0; d <= up; d++) {
if (!is_num) {
if (d == 0) {
// 仍为前导 0
res += dfs(pos - 1, pre, is_limit && (d == up), false);
} else {
// 开始填入最高有效位
res += dfs(pos - 1, d, is_limit && (d == up), true);
}
} else {
// 已有有效前缀,校验相邻数位差绝对值是否 <= k
if (abs(d - pre) <= k) {
res += dfs(pos - 1, d, is_limit && (d == up), true);
}
}
}
// 记忆化保存
if (!is_limit && is_num) {
dp[pos][pre] = res;
}
return res;
}
// 计算 [1, n] 内满足条件的 AI 数个数
LL solve(LL n) {
if (n <= 0) return 0;
int len = 0;
while (n > 0) {
digits[len++] = n % 10;
n /= 10;
}
memset(dp, -1, sizeof(dp));
return dfs(len - 1, 0, true, false);
}
int main() {
// 优化标准输入输出
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (cin >> l >> r >> k) {
cout << solve(r) - solve(l - 1) << "\n";
}
return 0;
}
4. 复杂度分析
- 时间复杂度 :O(log10(r)×10×10)O(\log_{10}(r) \times 10 \times 10)O(log10(r)×10×10)。数字 rrr 最多有 191919 位,状态数为 19×10=19019 \times 10 = 19019×10=190 个,每个状态遍历 0∼90 \sim 90∼9 转移,计算量在 10310^3103 级别,运行时间在 1ms 以内。
- 空间复杂度 :O(log10(r)×10)O(\log_{10}(r) \times 10)O(log10(r)×10)。仅需要占用极小的
dp[20][10]数组与递归栈空间,内存消耗小于 1MB。
5.疑难解惑
1. 第一维 20(代表 pos:当前处理到的数位索引)
- 原因 :题目中的数据范围通常为 1≤l≤r≤10181 \le l \le r \le 10^{18}1≤l≤r≤1018。101810^{18}1018 在十进制下是一个 19 位的数字。
- 作用 :数组的第一维需要存储从第 000 位(个位)到最高位(第 181818 位)的状态。开到
20可以保证完全容纳最多 19 位的数字索引(0∼180 \sim 180∼18),防止数组越界,属于竞赛和算法题中留有余量的安全写法(即"最大值 + 余裕")。
2. 第二维 10(代表 pre:上一位填入的十进制数字)
- 原因 :在十进制下,任意数位上的数字只可能是 0,1,2,3,4,5,6,7,8,90, 1, 2, 3, 4, 5, 6, 7, 8, 90,1,2,3,4,5,6,7,8,9 这 10 种可能。
- 作用 :数组的第二维需要记录上一位具体填的是哪个数字,以便检查相邻数位的差值绝对值(
abs(d - pre) <= k)。因为数字取值范围是 0∼90 \sim 90∼9,所以大小开到10正好能够覆盖所有可能的数字情况。
3. 代码中 if (pos < 0) return is_num ? 1 : 0; 的含义
这是递归搜索的递归基(终止条件) 。当 pos < 0 时,说明从高位到低位(从 len - 1 一路减到 -1)的所有数位都已填完。此时通过 is_num 判断填入的数字是否合法。
以下以最大为 3 位数(pos 初始为 2)为例,演示不同数字的具体执行逻辑:
示例 1:构成数字 352(常规合法数字)
pos = 2(百位) :填数字3→\rightarrow→ 开始填入有效数字,is_num变为truepos = 1(十位) :填数字5→\rightarrow→is_num仍为truepos = 0(个位) :填数字2→\rightarrow→is_num仍为truepos = -1:递归终止,此时is_num为true,返回 1 (代表数字352是 1 个合法方案)。
示例 2:构成数字 7(带前导 0 的一位数)
pos = 2(百位) :填数字0→\rightarrow→ 仍为前导 0,is_num为falsepos = 1(十位) :填数字0→\rightarrow→ 仍为前导 0,is_num为falsepos = 0(个位) :填数字7→\rightarrow→ 开始填入有效数字,is_num变为truepos = -1:递归终止,此时is_num为true,返回 1 (代表数字7是 1 个合法方案)。
示例 3:全填前导 0 的情况(非法/空数字 0)
pos = 2(百位) :填数字0→\rightarrow→is_num为falsepos = 1(十位) :填数字0→\rightarrow→is_num为falsepos = 0(个位) :填数字0→\rightarrow→is_num为falsepos = -1:递归终止,此时is_num为false,返回 0(说明这是无效的纯前导 0 路径,不计入最终结果)。
4. 代码中 if (!is_limit && is_num && dp[pos][pre] != -1) return dp[pos][pre]; 的含义
这段代码是数位 DP 中最关键的**"记忆化剪枝"**逻辑:如果在之前的搜索中已经计算并保存了"当前状态",且当前状态不受限制,就直接返回历史计算好的结果,不再重复递归。
一、三个条件的拆解
-
!is_limit(不受最高位限制)- 含义 :当前能够填入的数字范围是完整的 0∼90 \sim 90∼9,而不是受到原始数字上限(不能超过
digits[pos])的约束。 - 作用 :如果受到限制(
is_limit == true),后面能填的数字组合会变少,计算出的方案数是特定/残缺的,不能作为通用的状态存起来给后续分支复用。
- 含义 :当前能够填入的数字范围是完整的 0∼90 \sim 90∼9,而不是受到原始数字上限(不能超过
-
is_num(已经填入了有效数字)- 含义:前面已经填过非 0 数字,当前处理的不再是前导 0。
- 作用 :如果当前还是前导 0(
is_num == false),pre(上一位数字)其实没有实际意义(例如000中的前导 0 并不是真正的数值 0)。只有确定进入有效数字阶段,dp[pos][pre]的记录才是准确的。
-
dp[pos][pre] != -1(已经记忆化存过值)- 含义 :初始时
dp数组都被赋值为-1。如果不等于-1,说明之前有其他搜索分支已经经过(pos, pre)这种状态,并把计算好的合法方案数存入了dp[pos][pre]中。
- 含义 :初始时
二、具体数字举例(以 n=538,k=2n = 538, k = 2n=538,k=2 为例)
假设我们要计算 1∼5381 \sim 5381∼538 中满足相邻数字差值绝对值 ≤2\le 2≤2 的数字个数。
-
场景 1:第一次计算
pos = 0(个位),pre = 3的情况- 当搜索树走到
13x(百位填1,十位填3)时:- 递归进入
pos = 0(个位),此时pre = 3(十位是3)。 - 因为百位填的是
1(小于上限5),此时is_limit = false。 - 已经填入了有效数字
1和3,所以is_num = true。 - 检查
dp[0][3],发现是初始值-1,未命中记忆化。 - 程序继续向下执行循环,依次尝试个位填
1, 2, 3, 4, 5(满足 ∣d−3∣≤2|d - 3| \le 2∣d−3∣≤2),算出后续共有 5 种合法方案。 - 返回前将结果写入记忆化数组:
dp[0][3] = 5。
- 递归进入
- 当搜索树走到
-
场景 2:第二次遇到
pos = 0(个位),pre = 3的情况(触发记忆化)- 当搜索树走到
23x(百位填2,十位填3)时:- 递归再次进入
pos = 0(个位),此时pre同样是3。 - 百位填的是
2(仍小于上限5),is_limit = false。 - 已经填入了有效数字
2和3,is_num = true。 - 执行判断:
if (!is_limit && is_num && dp[0][3] != -1),此时dp[0][3]在上一步已经被赋值为 5。 - 触发条件!直接返回
5。程序无需再次跑for循环校验条件,瞬间完成该分支计算。
- 递归再次进入
- 当搜索树走到