1. 从一道“数数”题说起:为什么我们需要数位DP?
如果你刷过一些算法题,尤其是像POJ、蓝桥杯这类竞赛题,大概率遇到过这样一类问题:给你一个区间[L, R],让你统计在这个区间内,所有数字的十进制(或二进制)表示中,某个特定数字(比如‘6’)出现了多少次,或者满足某种特殊性质的数字有多少个。比如,POJ2282 “The Counting Problem” 就是让你统计0-9每个数字在给定区间内出现的总次数。第一次遇到这种题,你可能会想:“这还不简单?从L到R遍历每个数,拆开每一位数一下不就行了?”
然后你兴冲冲地写了个循环,一提交——Time Limit Exceeded(超时)。为什么?因为L和R的范围可能非常大,比如1 <= L <= R <= 2,000,000,000。20亿次的循环和数位拆分,对于计算机来说也是沉重的负担。这时你就需要一个更“聪明”的算法,它不需要遍历每一个数,而是通过分析数字的结构,直接“计算”出结果。这就是数位动态规划(Digit DP)的核心价值所在。
数位DP解决的是一类与数字的“位数”和“位值”相关的计数问题。它把一个大范围的、看似需要暴力枚举的问题,转化成了一个基于数位位置和状态记忆的、高效的计算过程。理解并掌握数位DP,不仅能让你轻松解决POJ2282、POJ3208(寻找第N个包含“666”的数字)这类经典题目,更是应对蓝桥杯等竞赛中计数问题的利器。今天,我们就以这几个经典问题为脉络,彻底拆解数位DP的思维框架和实现细节。
2. 数位DP的核心思想与通用“记忆化搜索”模板
数位DP之所以高效,是因为它利用了数字的一个关键特性:前缀无关性。举个例子,当我们统计1到54321之间有多少个包含连续“666”的数时,对于前两位是“54”的所有五位数(即54000到54999),它们后续三位(百位、十位、个位)中“666”的出现情况,只取决于当前是否已经出现了“666”以及最后几位是什么,而和前面的“54”具体是多少没有直接关系(只要不超过上界)。这就产生了大量重复的子问题。
数位DP最经典、也最易于理解的实现方式是记忆化搜索(DFS with Memoization)。我们用一个DFS函数来“构造”数字,从最高位向最低位递归,在递归过程中记录关键状态,并利用记忆化数组避免重复计算。
下面给出一个解决“统计区间[0, x]内满足条件P的数字个数”的通用模板。我们通常先实现一个函数solve(int x),它返回[0, x]内满足条件的数的个数,那么区间[L, R]的答案就是solve(R) - solve(L-1)。
#include <bits/stdc++.h> using namespace std; using ll = long long; // 将数字x的每一位分解到数组a中,低位在前(方便递归),长度len int a[20]; ll dp[20][state]; // 状态数组,维度根据具体问题定义 ll dfs(int pos, int state, bool lead, bool limit) { // pos: 当前处理到第几位(从0开始,即最低位) // state: 当前的状态,记录之前位的信息(如前缀中特定数字的个数、是否已出现特定模式等) // lead: 前导零标志。true表示前面的位都是0(即我们构造的数字目前有效部分还没开始) // limit: 上限标志。true表示当前位能填的数字受原始数字x对应位的限制 // 递归边界:所有位都处理完毕 if (pos == -1) { return check(state) ? 1 : 0; // 根据最终状态判断是否计入答案 } // 记忆化:只有在无前导零且无上限限制时,才能直接使用之前计算的结果 // 因为前导零和上限限制会影响后续选择,使得子问题不通用 if (!lead && !limit && dp[pos][state] != -1) { return dp[pos][state]; } int up = limit ? a[pos] : 9; // 当前位能填的最大数字 ll ans = 0; for (int i = 0; i <= up; ++i) { // 计算下一位的状态 int next_state = get_next_state(state, i, lead); // 核心递归:下一位,更新状态,更新前导零和上限标志 ans += dfs(pos - 1, next_state, lead && i == 0, limit && i == up); } // 记录状态,同样只在无限制时记录 if (!lead && !limit) { dp[pos][state] = ans; } return ans; } ll solve(ll x) { if (x < 0) return 0; // 根据题意,有时0需要特殊处理 int len = 0; while (x) { a[len++] = x % 10; x /= 10; } memset(dp, -1, sizeof(dp)); // 初始化DP数组为-1(未计算) // 从最高位开始递归,初始状态通常为0,有前导零,受上限限制 return dfs(len - 1, 0, true, true); }这个模板中有四个关键参数,理解它们至关重要:
pos(位置):表示当前正在处理数字的第几位。递归深度。state(状态):这是数位DP的灵魂,用于记录从最高位到当前pos位(不含)为止,我们所关心的所有信息。不同的题目,state的定义完全不同。例如:- 统计数字‘d’出现次数:
state可以是一个整数,记录到目前为止‘d’出现了几次。 - 寻找包含“666”的数:
state可以记录当前末尾连续‘6’的个数(0, 1, 2),或者用一个状态码表示是否已经出现了“666”。 - 二进制问题(统计1的个数):
state可以记录当前1的个数。
- 统计数字‘d’出现次数:
lead(前导零):这是一个非常容易出错的点。前导零是指我们构造的数字中,高位连续的0。例如,数字0054,前两个0就是前导零。为什么需要它?- 影响状态计算:对于统计数字‘0’出现次数的问题,前导零的‘0’不应该被计入。
lead为true时,当前位的‘0’是前导零,不计入状态。 - 影响记忆化:状态
(pos, state)只有在lead为false(即已经开始了有效数字)时才是通用的,可以记忆化。否则,state可能是在前导零背景下计算出来的,不适用于非前导零的情况。
- 影响状态计算:对于统计数字‘0’出现次数的问题,前导零的‘0’不应该被计入。
limit(上限限制):表示当前位填的数字是否受到原始数字x对应位的限制。例如,x=543,当前处理百位(pos=2):- 如果前面所有位都填的和
x一样(即目前构造的前缀等于x的前缀),那么当前位最多只能填5(limit=true)。 - 如果前面有任何一位填的比
x对应位小(比如十位我们填了3,而x的十位是4),那么从这一位开始,后面所有位都可以填0-9(limit=false)。 - 它同样影响记忆化。只有
limit=false时,后续位的选择才是完全自由的(0-9),子问题才是通用的,才能被记忆化。
- 如果前面所有位都填的和
一个核心技巧:我们通常把数字分解成低位在数组前。这样pos从len-1递归到0,更符合我们从高位向低位思考的习惯。dfs函数中的pos表示“还剩多少位待处理”,当pos == -1时表示所有位都处理完了。
3. 实战拆解一:POJ2282 The Counting Problem(统计数字出现次数)
问题描述:给定两个整数a和b(0 < a, b < 1e8),对于每个数字d (0-9),统计在[a, b](包含)区间内,所有整数的十进制表示中,数字d出现的总次数。
思路分析:这是最经典的数位DP入门题。我们可以对每个数字d (0-9)分别计算。定义state为:从最高位到当前位,数字d已经出现的次数cnt。
状态设计:
dp[pos][cnt]:表示处理到第pos位时,数字d已经出现了cnt次,在无前导零、无上限限制的条件下,从这一位往后能构造出多少个数(这些数最终都会被计入答案,但我们需要在递归边界根据最终的cnt来加权计算总次数)。- 但是注意,我们最终要的是出现次数的总和,而不是数字的个数。如果只在边界返回1或0,我们只能算出有多少个数字包含d,而不是d出现的总次数。
解决方案:修改DFS的返回值。让DFS返回一个pair<ll, ll>,第一个值(cnt)表示满足条件的数字个数,第二个值(sum)表示这些数字中,数字d出现的总次数。
递归过程:
- 从下一位DFS获取结果
(next_cnt, next_sum)。 - 当前位如果填的是数字
d,则对总次数的贡献是:next_cnt(因为当前位的这个d,会在next_cnt个数字的每一位都出现一次)加上next_sum(来自后续位的贡献)。 - 当前位如果填的不是数字
d,则贡献只有next_sum。
细节处理:对于数字0,需要特别小心前导零。只有当lead为false时,当前位的0才被视为有效数字0并可能被统计。
核心代码片段:
pair<ll, ll> dfs(int pos, int cnt, bool lead, bool limit, int digit) { if (pos == -1) { return {1, cnt}; // 一个数字,贡献了cnt次 } if (!lead && !limit && dp[pos][cnt].first != -1) { return dp[pos][cnt]; } int up = limit ? a[pos] : 9; pair<ll, ll> ans = {0, 0}; for (int i = 0; i <= up; ++i) { bool is_lead = lead && (i == 0); int next_cnt = cnt; if (!is_lead && i == digit) { // 非前导零且是目标数字 next_cnt++; } auto res = dfs(pos - 1, next_cnt, is_lead, limit && i == up, digit); ans.first += res.first; // 数字个数累加 ans.second += res.second + ( (!is_lead && i == digit) ? res.first : 0 ); // 次数累加:后续位的总次数 + 当前位的贡献(如果当前位是d) } if (!lead && !limit) { dp[pos][cnt] = ans; } return ans; }避坑点:
- 状态定义与返回值:这是本题的关键。如果只返回数字个数,无法直接求出总次数。必须让DFS携带“贡献值”信息。
- 前导零与数字0:这是最容易WA(错误答案)的地方。必须明确:只有非前导零状态的
0,才是数字0,才需要被统计。在判断i == digit时,一定要结合lead标志。 - 记忆化维度:
dp数组的第二维cnt大小是多少?最坏情况下,一个8位数(1e8)每个位都是d,cnt最大为8。但安全起见,可以设为当前剩余位数pos+1,或者一个稍大的常数(如20)。
通过分别对0-9每个数字调用一次solve函数(内部调用DFS),我们就能得到每个数字在区间内的总出现次数。时间复杂度为O(10 * 位数 * 状态数 * 10),对于题目范围绰绰有余。
4. 实战拆解二:POJ3208 启示录(寻找第N个包含“666”的数)
问题描述:寻找第N个(N可达5e7)包含连续三个“6”(即“666”)的正整数。例如,第一个是666,第二个是1666,第三个是2666,第四个是3666,第五个是4666,第六个是5666,第七个是6660(注意,这里6660中的666是连续的)。
思路分析:这不是一个区间计数问题,而是一个“按序查找”问题。一种方法是二分答案+数位DP检验。即二分一个数字mid,用数位DP计算[1, mid]之间有多少个包含“666”的数,如果个数<N,则答案在右边;否则在左边。直到找到最小的mid,使得[1, mid]内的个数>=N。
因此,核心还是实现一个数位DP函数count(long long x),返回[1, x]内包含“666”的数的个数。
状态设计:我们需要记录一个状态,来表示当前末尾连续‘6’的个数,以及是否已经出现了“666”。 一个经典的设计是:
state = 0: 当前末尾没有连续的6。state = 1: 当前末尾有1个连续的6。state = 2: 当前末尾有2个连续的6。state = 3: 已经出现了“666”(无论末尾是什么)。
状态转移:
- 如果当前
state = 0或1或2:- 当前位填
6:state增加1(如果state已经是2,则变为3)。 - 当前位填其他数字:
state重置为0。
- 当前位填
- 如果当前
state = 3:无论当前位填什么,state都保持为3(已经满足条件)。
递归边界:当pos == -1时,如果state == 3,返回1,否则返回0。
核心代码片段:
ll dp[20][4]; // dp[pos][state] ll dfs(int pos, int state, bool lead, bool limit) { if (pos == -1) { return state == 3 ? 1 : 0; } if (!lead && !limit && dp[pos][state] != -1) { return dp[pos][state]; } int up = limit ? a[pos] : 9; ll ans = 0; for (int i = 0; i <= up; ++i) { int next_state = state; if (state < 3) { if (i == 6) { next_state = state + 1; if (next_state > 3) next_state = 3; } else { next_state = 0; } } // 如果state已经是3,next_state保持为3 ans += dfs(pos - 1, next_state, lead && i == 0, limit && i == up); } if (!lead && !limit) { dp[pos][state] = ans; } return ans; }二分查找实现:
long long findNth(int N) { long long left = 1, right = 1e18; // 一个足够大的上界 long long ans = right; while (left <= right) { long long mid = left + (right - left) / 2; if (count(mid) >= N) { // count(mid)返回[1,mid]中满足条件的数的个数 ans = mid; right = mid - 1; } else { left = mid + 1; } } return ans; }避坑点:
- 状态3的设计:一旦进入状态3(已出现“666”),就必须永远保持为3,无论后面填什么数字。这确保了只要前缀满足了条件,整个数就被计入。
- 前导零的处理:在这个问题中,前导零不影响“666”模式的识别。因为前导零不是‘6’。所以
lead标志主要用来控制记忆化的条件,在状态转移中,当lead为true且i==0时,next_state应该保持为0(因为前导零不是有效数字,不参与连续‘6’的计数)。 - 二分边界:N可以很大(5e7),第N个包含“666”的数会非常大,二分的右边界
right必须设得足够大。1e18是一个比较安全的选择。二分时注意是寻找下界(第一个使count(mid) >= N的mid)。
这个方法将“查找第N个”的问题转化为了O(log(MAX))次“计数问题”,每次计数是数位DP的O(位数*状态数*10),效率非常高。
5. 实战拆解三:第十二届蓝桥杯国赛C++B组H题——二进制问题(统计1的个数为K的数)
问题描述:给定一个区间[L, R](L, R可达1e18)和一个整数K(0 <= K <= 60),统计该区间内,有多少个整数的二进制表示中,恰好有K个‘1’。
思路分析:这是数位DP从十进制向二进制的一个直接迁移。数位DP的本质是与进制无关的,它处理的是“按位计数”。我们只需要把模板中的十进制位(0-9)换成二进制位(0-1),把up从9换成1即可。
状态设计:state可以直接定义为当前已经放置的‘1’的个数cnt。
DP数组:dp[pos][cnt]表示处理到第pos位时,已经使用了cnt个‘1’,在无前导零、无上限限制的情况下,能构造出多少个数。这里前导零在二进制中同样重要,因为高位的‘0’不影响‘1’的计数,但影响数字的有效性(不过对于统计‘1’的个数,前导零的‘0’显然不计为‘1’)。
递归边界:当pos == -1时,所有位处理完毕,判断cnt == K,相等则返回1,否则返回0。
核心代码片段:
ll dp[70][70]; // pos最大约为60(因为1e18 < 2^60),cnt最大为K ll dfs(int pos, int cnt, bool lead, bool limit) { if (pos == -1) { return cnt == K ? 1 : 0; } if (!lead && !limit && dp[pos][cnt] != -1) { return dp[pos][cnt]; } int up = limit ? a[pos] : 1; // 二进制位,最大为1 ll ans = 0; for (int i = 0; i <= up; ++i) { bool is_lead = lead && (i == 0); int next_cnt = cnt; if (i == 1) { next_cnt++; } // 剪枝:如果next_cnt已经超过K,后续无论怎么填都不可能满足条件,可以跳过 // 但在这个简单循环中,剪枝效果不明显,可写可不写 ans += dfs(pos - 1, next_cnt, is_lead, limit && i == up); } if (!lead && !limit) { dp[pos][cnt] = ans; } return ans; }二进制数位分解:
ll solve(ll x) { if (x < 0) return 0; int len = 0; while (x) { a[len++] = x & 1; // 取二进制最低位 x >>= 1; } // 注意:如果x=0,len为0,需要特殊处理,因为0的二进制表示中‘1’的个数为0 if (len == 0) { // 即x==0 a[0] = 0; len = 1; } memset(dp, -1, sizeof(dp)); return dfs(len - 1, 0, true, true); }避坑点与优化:
- 0的处理:
solve(0)需要单独处理。因为我们的数位分解循环while(x)在x=0时不会执行,导致len=0。而dfs(len-1, ...)会访问非法索引。可以在solve开始判断,如果x==0,直接返回K==0 ? 1 : 0。或者在分解后,如果len==0,手动设置len=1, a[0]=0。 - 前导零:在二进制中,前导零同样不贡献‘1’。所以
lead标志的逻辑和十进制完全一致。 - 状态压缩与剪枝:
dp[pos][cnt]的第二维大小是K+1。由于K<=60,pos<=60,内存是足够的。可以进行一个有效的剪枝:在递归中,如果cnt > K,可以直接返回0,因为后面无论怎么填,‘1’的个数只会增加,不可能再等于K了。 - 与组合数学的联系:这个问题实际上可以用组合数直接求解(在无上限限制时)。数位DP相当于自动化、通用化地处理了“上限限制”这个麻烦。理解这一点有助于加深对DP状态的理解。
6. 数位DP的难点精析与调试技巧
经过上面三个例题,你应该对模板和常见状态设计有了了解。但在实战中,还有几个难点和易错点需要特别注意。
难点一:状态设计的抽象与简化状态state是数位DP最难的部分。它需要精确描述“前缀信息对后续决策的影响”。好的状态应该:
- 包含足够信息:能唯一确定后续的计数情况。
- 尽可能小:状态空间太大(比如把整个前缀作为状态)会导致记忆化失效或超内存。 对于“包含特定子串”类问题(如“666”),常用的技巧是使用自动机状态(如0,1,2,3)或KMP的next数组思想来记录匹配进度。对于更复杂的数字性质(如“能被某个数整除”),状态可能需要记录前缀模某个数的余数。
难点二:前导零(lead)的正确处理前导零的处理是数位DP错误的主要来源。必须想清楚:
- 前导零是否影响状态?例如,统计数字‘0’时,前导零的‘0’不算;统计数字‘1’时,前导零无影响。
- 前导零是否影响数字的合法性?例如,有些题目要求数字不能有前导零(即数字是正数且没有多余的0开头),这时在递归边界,如果
lead仍然为true,说明构造的数字是0,可能需要根据题意判断是否合法。 - 记忆化与lead的关系:记忆化数组
dp[pos][state]默认是在lead=false(即已经开始了有效数字)的前提下定义的。lead=true意味着前面的位都是0,此时即使state相同,后续的计数也可能和lead=false时完全不同(比如对数字0的统计)。所以,只有当!lead && !limit时,才能使用记忆化的结果。
难点三:上限限制(limit)的理解limit标志决定了当前位的选择范围。它是保证我们计算的是[0, x]区间,而不是所有位数的全排列的关键。
- 在
limit=true时,当前位的选择受限于原数x的对应位,并且递归下去的子问题也可能受限于更低位。 - 只有
limit=false时,当前位及其所有低位都可以自由选择(0-9或0-1),此时子问题是“通用的”,可以被记忆化。 - 常见错误:在记忆化判断或存储时,忽略了
limit条件,导致计算结果错误(通常偏大)。
调试技巧:
- 小数据暴力对拍:写一个朴素的暴力算法(
for循环遍历区间,逐个判断),用于验证数位DP程序在小数据范围(如1到10000)内的正确性。这是最有效的调试手段。 - 打印递归树:在DFS函数开头打印
pos, state, lead, limit等参数,观察递归过程。特别关注limit从true变为false的时刻,以及lead的变化。 - 关注边界条件:重点测试
L=0,L=1,R=0,R=9,R=10,R=99等边界情况,以及L=R的情况。 - 状态值验证:在记忆化存储和读取时,可以打印
dp数组的值,看是否和预期一致。对于state设计复杂的问题,可以手动计算几个小例子,验证状态转移的正确性。
7. 举一反三:数位DP的常见变体与扩展思路
掌握了基础模型,我们可以看看数位DP还能解决哪些变体问题,这有助于你应对竞赛中的新题。
变体一:统计“数位和”或“数位积”满足条件的数
- 问题示例:统计区间内各位数字之和为S,或之积为P的数字个数。
- 状态设计:
state直接记录到当前位的和或积。注意“积”可能很大,需要观察范围。有时积可以转化为“质因子分解”的状态,或者如果P很小,可以直接记录。 - 技巧:数位和的范围是有限的(最大为9*位数),适合直接作为状态。数位积则可能需要离散化或特殊处理。
变体二:统计“回文数”、“单调数”等具有整体性质的数
- 问题示例:统计区间内的回文数个数。
- 状态设计:这通常需要同时从高位和低位向中间构造。状态可能需要记录已经匹配的前缀(或后缀),或者记录当前构造到中间的位置。这类问题状态设计更灵活,有时需要结合其他算法思想。
变体三:与数论结合,如“能被M整除的数”
- 问题示例:统计区间内能被M整除的数字个数。
- 状态设计:
state记录当前前缀模M的余数r。那么从当前位继续构造,新的余数就是(r * 10 + i) % M。递归边界时,判断余数是否为0。 - 技巧:这是数位DP与模运算结合的经典应用。状态大小是O(M)。
变体四:求满足条件的第K小数(扩展POJ3208)
- 解法:二分答案+数位DP检验。这是非常通用的方法。先二分一个答案mid,用数位DP计算
[1, mid]内满足条件的数的个数cnt。如果cnt < K,说明答案比mid大;否则答案小于等于mid。不断二分直到找到最小的mid使得cnt >= K。
变体五:多维状态与复杂约束
- 问题示例:统计区间内,数字‘4’和‘7’出现次数之差不超过T的数字个数。
- 状态设计:
state需要两个维度,分别记录‘4’和‘7’的出现次数,或者记录它们的差值。由于差值可能为负,需要加一个偏移量(如+T)使其变为非负数组下标。
扩展思路:从记忆化搜索到递推我们讲解的一直是记忆化搜索(DFS+Memo)的写法,因为它直观,易于理解和调试。实际上,数位DP也可以写成纯递推(迭代)的形式,通常称为“数位DP的递推写法”或“Digital DP的DP表填充”。递推写法的代码有时更简洁,但思维难度稍高,不如记忆化搜索那样能清晰地体现“按位构造”的过程。对于初学者,强烈建议先精通记忆化搜索的写法。
数位DP的精髓在于“按位确定”和“状态压缩”。它将一个庞大的区间计数问题,分解为对每个数位独立的、带状态的决策过程。理解并熟练运用lead和limit这两个标志,是写好数位DP的关键。从简单的统计出现次数,到复杂的模式匹配和数论问题,其内核都是一致的。多练习,多思考状态的设计,你就能将这种强大的计数工具运用自如。