
目录
- 数位DP:从原理到实战的深度解析(对话版)
-
- 引言
- 一、初识数位DP:它解决什么问题?
- 二、数位DP核心框架:四步走
-
- 步骤1:将数字拆分为各位数组
- 步骤2:定义递归函数(核心)
- 步骤3:记忆化搜索(Memoization)
- [步骤4:处理区间[L, R]](#步骤4:处理区间[L, R])
- 三、入门例题:统计0~n中不含数字6的数的个数
- [四、进阶例题:统计[L, R]中各位数字之和等于sum的数的个数](#四、进阶例题:统计[L, R]中各位数字之和等于sum的数的个数)
- 五、数位DP的常见坑点与优化
- 六、数位DP的经典应用场景
- 七、总结
数位DP:从原理到实战的深度解析(对话版)
引言
数位DP(数位动态规划)是动态规划中一类专门解决数字统计问题的经典算法,比如"统计1~n中不含数字6的数的个数""计算区间[L,R]中各位数字之和等于10的数的数量"等。这类问题如果用暴力枚举,在数值范围很大(比如1e18)时完全不可行,而数位DP能通过"按位处理+状态记忆"将时间复杂度降到O(位数×状态数),是处理大数数字统计的"杀手锏"。本文以"新手提问+导师解答"的对话形式,拆解数位DP的核心逻辑、实现框架和经典例题,让你彻底掌握这一算法。
一、初识数位DP:它解决什么问题?
新手 :导师您好!我看到"数位DP"这个名字就懵了------"数位"是指数字的每一位(个位、十位、百位)吗?它和普通DP有什么区别?
导师 :没错!"数位"就是指数字的每一位,数位DP的核心就是按位处理数字,结合DP的状态记忆,统计满足特定条件的数字数量。
先明确数位DP的适用场景:
- 问题要求统计区间[L, R] 内满足某种"数位规则"的数字个数(比如不含4、各位和为9、是回文数等);
- 数值范围极大(比如L=0,R=1e18),暴力枚举(遍历每个数检查条件)必然超时;
- 条件仅与数字的各位数字有关,与数字的大小无直接关系(比如"不含6"只看每一位是不是6,和数字是6还是16无关)。
和普通DP的区别:
- 普通DP的状态通常对应"前i个物品/前i个字符",数位DP的状态对应"处理到数字的第i位";
- 数位DP需要处理前导零、数位上界限制(比如处理到第3位时,前两位是12,第3位最大只能是3,而不是9)这两个特殊边界。
二、数位DP核心框架:四步走
新手 :那数位DP的通用思路是什么?有没有固定框架可以套?
导师:数位DP有非常固定的实现框架,核心是"把数字拆成各位→递归处理每一位→记忆化存储状态→处理边界条件",具体分四步:
步骤1:将数字拆分为各位数组
比如数字n=123,拆成数组digits = [1,2,3](高位到低位),方便按位处理。
步骤2:定义递归函数(核心)
递归函数通常设计为:dfs(pos, state, limit, lead),参数含义:
pos:当前处理到第几位(从高位往低位处理,比如pos=0对应百位,pos=1对应十位);state:当前的状态(根据问题定,比如"各位数字之和""是否出现过数字6"等);limit:布尔值,表示当前位是否受上界限制(比如处理123的十位时,若百位是1,十位最大只能是2,limit=true;若百位小于1,十位可以取0-9,limit=false);lead:布尔值,表示当前是否是前导零(比如数字0012其实是12,前导零需要特殊处理)。
函数返回值:从当前位开始,满足条件的数字个数。
步骤3:记忆化搜索(Memoization)
用数组/哈希表存储(pos, state)的计算结果(仅当limit=false且lead=false时,状态才可以复用,因为受限制的状态无法通用),避免重复计算。
步骤4:处理区间[L, R]
数位DP通常先实现f(x)函数(统计0~x中满足条件的数的个数),然后答案就是f(R) - f(L-1)(注意处理L=0的情况)。
三、入门例题:统计0~n中不含数字6的数的个数
新手 :先从最简单的例子入手吧!比如"统计0~n中不含数字6的数的个数",用数位DP怎么实现?
导师:这个是数位DP的"Hello World",咱们一步步拆解实现。
例题分析
- 条件:数字的每一位都不能是6;
- 状态定义:因为只需要判断"是否出现6",所以state可以简化为"是否已经出现6"(0=未出现,1=出现);
- 目标:统计state始终为0的数字个数。
完整代码实现(C++)
cpp
#include <iostream>
#include <vector>
#include <cstring> // 用于memset
using namespace std;
// 存储各位数字
vector<int> digits;
// 记忆化数组:dp[pos][state],pos是当前位,state是是否出现6(0/1)
// -1表示未计算
int dp[20][2];
// 递归函数:pos-当前处理位,state-是否出现6,limit-是否受上界限制,lead-前导零
int dfs(int pos, int state, bool limit, bool lead) {
// 递归终止:处理完所有位
if (pos == digits.size()) {
// 前导零(数字0)也算合法,只要state=0(未出现6)
return state == 0 ? 1 : 0;
}
// 记忆化:如果不受限制且不是前导零,直接返回已计算的结果
if (!limit && !lead && dp[pos][state] != -1) {
return dp[pos][state];
}
// 当前位能取的最大值:受限制则取digits[pos],否则取9
int up = limit ? digits[pos] : 9;
int res = 0; // 记录当前位的合法数个数
// 遍历当前位的所有可能取值
for (int i = 0; i <= up; ++i) {
// 前导零且当前位是0:state不变,lead仍为true
if (lead && i == 0) {
res += dfs(pos + 1, state, limit && (i == up), true);
} else {
// 计算新的state:如果当前位是6,或之前已经出现6,state=1
int new_state = state | (i == 6 ? 1 : 0);
// 新的limit:当前位受限制且取到了最大值
bool new_limit = limit && (i == up);
// 前导零结束,lead=false
res += dfs(pos + 1, new_state, new_limit, false);
}
}
// 记忆化存储:仅当不受限制且不是前导零时存储
if (!limit && !lead) {
dp[pos][state] = res;
}
return res;
}
// 计算0~n中不含数字6的数的个数
int f(int n) {
digits.clear();
// 把n拆分为各位数字(高位到低位)
if (n == 0) {
digits.push_back(0);
} else {
while (n) {
digits.insert(digits.begin(), n % 10);
n /= 10;
}
}
// 初始化记忆化数组为-1
memset(dp, -1, sizeof(dp));
// 从第0位开始递归,初始state=0(未出现6),limit=true(第一位受限制),lead=true(前导零)
return dfs(0, 0, true, true);
}
// 统计[L, R]中不含数字6的数的个数
int countNo6(int L, int R) {
// 处理L=0的情况,避免f(-1)
return f(R) - (L == 0 ? 0 : f(L - 1));
}
int main() {
int L = 1, R = 100;
cout << "区间[" << L << "," << R << "]中不含数字6的数的个数:" << countNo6(L, R) << endl;
// 输出:81(100个数中,含6的有19个:6,16,...,96共10个,60-69共10个,减去重复的66,总计19个,100-19=81)
return 0;
}
代码核心解析
- 拆分数位 :
f(n)函数将n拆分为高位到低位的数组(比如123→[1,2,3]),方便按位处理; - 递归终止条件 :
pos == digits.size()表示所有位处理完毕,此时只要state=0(未出现6),就计数1; - 记忆化判断 :只有
limit=false(不受上界限制)且lead=false(无前导零)时,状态才可以复用------因为受限制的位(比如十位只能取0-2)的结果不能用到其他情况(比如十位可以取0-9); - 前导零处理 :前导零(比如0012)本身不算有效数字,所以当
lead=true且i=0时,state不更新,lead仍为true; - 区间处理 :
countNo6(L, R) = f(R) - f(L-1),这是数位DP处理区间的通用技巧。
新手 :这段代码我能看懂!但为什么记忆化只存储limit=false的状态?
导师:举个例子:处理数字123的十位时,若百位是1(limit=true),十位最大只能取2,此时计算的结果只能用于"百位是1"的情况;若百位小于1(limit=false),十位可以取0-9,此时的结果可以复用给所有"百位小于1"的情况。因此只有不受限制的状态才有复用价值,这是数位DP记忆化的关键。
四、进阶例题:统计[L, R]中各位数字之和等于sum的数的个数
新手 :如果条件变成"各位数字之和等于sum",该怎么改?
导师 :核心是修改state的定义------把state从"是否出现6"改为"当前各位数字之和",其余框架不变。
完整代码实现(C++)
cpp
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
vector<int> digits;
// dp[pos][sum]:处理到第pos位,当前和为sum的合法数个数
int dp[20][180]; // 最多20位,各位和最大20*9=180
// pos-当前位,cur_sum-当前和,target_sum-目标和,limit-是否受限,lead-前导零
int dfs(int pos, int cur_sum, int target_sum, bool limit, bool lead) {
if (pos == digits.size()) {
// 前导零(数字0)的和为0,只有target_sum=0时计数1
return (lead ? (target_sum == 0) : (cur_sum == target_sum)) ? 1 : 0;
}
if (!limit && !lead && dp[pos][cur_sum] != -1) {
return dp[pos][cur_sum];
}
int up = limit ? digits[pos] : 9;
int res = 0;
for (int i = 0; i <= up; ++i) {
if (lead && i == 0) {
// 前导零,和不变
res += dfs(pos + 1, cur_sum, target_sum, limit && (i == up), true);
} else {
int new_sum = cur_sum + i;
// 剪枝:和超过目标,直接跳过
if (new_sum > target_sum) break;
res += dfs(pos + 1, new_sum, target_sum, limit && (i == up), false);
}
}
if (!limit && !lead) {
dp[pos][cur_sum] = res;
}
return res;
}
// 计算0~n中各位和等于target_sum的数的个数
int f(int n, int target_sum) {
digits.clear();
if (n == 0) {
digits.push_back(0);
} else {
while (n) {
digits.insert(digits.begin(), n % 10);
n /= 10;
}
}
memset(dp, -1, sizeof(dp));
return dfs(0, 0, target_sum, true, true);
}
// 统计[L, R]中各位和等于sum的数的个数
int countSum(int L, int R, int sum) {
return f(R, sum) - (L == 0 ? 0 : f(L - 1, sum));
}
int main() {
int L = 1, R = 100, sum = 10;
cout << "区间[" << L << "," << R << "]中各位和等于" << sum << "的数的个数:" << countSum(L, R, sum) << endl;
// 输出:9(19,28,37,46,55,64,73,82,91)
return 0;
}
核心修改点
- 状态定义 :
dp[pos][cur_sum]表示处理到第pos位,当前各位和为cur_sum的合法数个数; - 递归终止条件 :判断
cur_sum == target_sum(非前导零)或target_sum == 0(前导零); - 剪枝优化 :如果
new_sum > target_sum,直接break(因为后续位都是非负数,和只会更大),减少递归次数; - 参数传递 :递归函数增加
target_sum参数,指定目标和。
五、数位DP的常见坑点与优化
新手 :学习数位DP容易踩哪些坑?有没有通用优化技巧?
导师:新手最容易踩的5个坑:
- 前导零处理:比如统计"回文数"时,前导零会导致0012被误判为回文数,必须单独处理;
- 记忆化条件 :只有
limit=false且lead=false时才存储状态,否则会导致结果错误; - 状态范围:定义dp数组时要预估状态的最大值(比如各位和最大是20*9=180,dp数组要开够);
- 区间处理 :计算
f(L-1)时要注意L=0的情况,避免出现负数; - 剪枝时机:比如"各位和超过目标"时及时break,能大幅提升效率。
通用优化技巧:
- 剪枝:根据条件提前终止无效递归(比如和超过目标、已经出现非法数字);
- 状态压缩:如果状态是多个维度(比如"是否出现6"+"各位和"),可以将状态合并为一个整数(比如state = (has6 << 8) | cur_sum);
- 预处理:对于多次查询的场景,可以预处理数位数组和dp数组,避免重复初始化。
六、数位DP的经典应用场景
新手 :除了这两个例子,数位DP还能解决哪些问题?
导师:数位DP的应用场景非常广泛,核心是"数字的数位满足特定规则",常见场景:
- 数字合法性统计:不含某数字(如不含4、6)、包含某数字(如包含8)、各位数字满足大小关系(如非递减数123、非递增数321);
- 数字特征统计:各位和等于/大于/小于某值、数位乘积满足条件、是回文数、是质数(需结合质数判断);
- 组合计数:区间内满足条件的数的个数、第k个满足条件的数(需结合二分)。
七、总结
核心要点回顾
- 数位DP核心:按位处理数字+记忆化存储状态+处理limit(上界限制)和lead(前导零);
- 通用框架 :
- 拆分数位为高位到低位的数组;
- 定义递归函数
dfs(pos, state, limit, lead); - 记忆化存储
limit=false且lead=false的状态; - 用
f(R) - f(L-1)处理区间[L, R];
- 状态设计:根据问题条件定义state(如"是否出现6""当前各位和""是否是回文"等);
- 边界处理:重点关注前导零和上界限制,这是数位DP的核心难点。
学习建议
数位DP的核心是"框架固定,状态灵活"------只要掌握了通用框架,不同问题只需要修改状态定义和终止条件。建议你:
- 先吃透"不含数字6"的入门例题,手动推导递归过程,理解记忆化的作用;
- 练习"各位和等于sum""回文数统计"等进阶例题,掌握状态设计的技巧;
- 结合真题(比如NOIP、蓝桥杯的数位DP题)巩固,重点关注前导零和limit的处理。
记住:数位DP的关键不是背模板,而是理解"为什么要处理limit和lead""为什么只有不受限制的状态才能记忆化"------想清楚这两个问题,任何数位DP问题都能迎刃而解。