数位DP详解
数位DP(Digit Dynamic Programming)是一种专门解决“数字计数问题”的动态规划方法,尤其适用于处理“小于等于N的数中满足某条件的数有多少个”这类问题。由于数字的位数通常不超过20位,数位DP能通过状态压缩将看似复杂的计数问题转化为高效的DP求解。
一、数位DP的核心场景与优势
1.1 典型问题场景
数位DP专注于解决与数字的“数位特征”相关的计数问题,例如:
- 计算小于等于N的数中,不含数字7的数有多少个;
- 计算[L, R]范围内,数字中含有连续两个相同数字的数的个数;
- 计算小于等于N的数中,各位数字之和为K的数有多少个。
这类问题若用暴力枚举(遍历每个数并检查条件),当N达到 10 18 10^{18} 1018时会直接超时,而数位DP利用数字的“数位分解”特性,将时间复杂度降至 O ( 位数 × 状态数 ) O(位数 \times 状态数) O(位数×状态数),通常可控制在 O ( 20 × 1000 ) O(20 \times 1000) O(20×1000)以内,高效且稳定。
1.2 核心优势
- 针对性强:专为数字的数位特征设计,能自然融入数位相关的约束条件;
- 效率高:不受N的大小影响,仅与数字的位数相关;
- 扩展性好:同一框架可适配不同的数位约束条件(只需修改状态和转移逻辑)。
二、数位DP的核心思想与框架
2.1 数位分解与状态设计
数位DP的核心是将数字按位分解(如将123分解为[1,2,3]),然后从最高位到最低位逐位处理,同时记录必要的状态信息。关键状态通常包括:
- 当前处理的位位置(pos):标记正在处理第几位(从高位到低位);
- 约束标记(limit):表示当前位的取值是否受原数字对应位的限制(1表示受限制,只能取0到原数字该位的值;0表示不受限制,可任意取0-9);
- 其他自定义状态:根据问题条件设计,如“是否出现过数字7”“当前各位数字之和”等。
2.2 记忆化搜索(DFS + 缓存)
数位DP通常采用“记忆化搜索”实现,原因是:
- 数位处理的递归逻辑清晰,便于维护“约束标记”等状态;
- 记忆化可避免重复计算相同状态(如不同数字的某几位处理到相同pos且状态相同时)。
2.3 通用框架
int count(int n) {
// 将n分解为数位数组(如n=123 → digits = [1,2,3])
List<Integer> digits = new ArrayList<>();
while (n > 0) {
digits.add(n % 10);
n /= 10;
}
Collections.reverse(digits);
int len = digits.size();
// 记忆化缓存:dp[pos][limit][...],...为其他自定义状态
memo = new int[len][2][...];
for (int[][] arr : memo) for (int[] sub : arr) Arrays.fill(sub, -1);
// 从第0位开始处理,初始约束为true(受限制),其他状态初始化为0
return dfs(0, true, ...);
}
// 递归函数:处理第pos位,limit表示是否受约束,其他参数为自定义状态
int dfs(int pos, boolean limit, ...) {
// 递归终止:处理完所有位,返回1(表示找到一个合法数)
if (pos == digits.size()) return 1;
// 检查缓存,若已计算则直接返回
if (memo[pos][limit ? 1 : 0][...] != -1) {
return memo[pos][limit ? 1 : 0][...];
}
// 确定当前位的最大可取值(受limit约束)
int max = limit ? digits.get(pos) : 9;
int res = 0;
// 枚举当前位的所有可能取值
for (int d = 0; d <= max; d++) {
// 新的约束标记:若原limit为true且d等于max,则新limit为true
boolean newLimit = limit && (d == max);
// 根据问题条件,判断当前取值d是否合法,更新自定义状态
if (isValid(d, ...)) {
res += dfs(pos + 1, newLimit, newState(...));
}
}
// 缓存结果并返回
memo[pos][limit ? 1 : 0][...] = res;
return res;
}
三、实战案例:不含数字7的数的计数
3.1 问题描述
计算小于等于N的正整数中,所有数位都不包含数字7的数有多少个。
示例:N=18 → 合法数为1-6,8-16,18(共16个,排除7,17)。
3.2 状态设计
- pos:当前处理的位(0-based,从最高位开始);
- limit:是否受原数字约束(布尔值);
- 无其他自定义状态(只需判断每位是否为7)。
3.3 代码实现
import java.util.*;
public class CountWithoutSeven {
int[] digits;
int[][][] memo; // memo[pos][limit][...],此处无其他状态,简化为二维
public int count(int n) {
if (n == 0) return 0;
// 分解数位(如123 → [1,2,3])
List<Integer> list = new ArrayList<>();
int temp = n;
while (temp > 0) {
list.add(temp % 10);
temp /= 10;
}
Collections.reverse(list);
digits = new int[list.size()];
for (int i = 0; i < digits.length; i++) {
digits[i] = list.get(i);
}
// 初始化记忆化数组:pos范围0~len-1,limit为0或1
memo = new int[digits.length][2][1]; // 第三维占位,实际用二维
for (int[][] arr : memo) {
for (int[] sub : arr) {
Arrays.fill(sub, -1);
}
}
// 从第0位开始,初始limit为true(受约束)
return dfs(0, true);
}
private int dfs(int pos, boolean limit) {
// 处理完所有位,返回1(找到一个合法数)
if (pos == digits.length) {
return 1;
}
// 检查缓存
int limitIdx = limit ? 1 : 0;
if (memo[pos][limitIdx][0] != -1) {
return memo[pos][limitIdx][0];
}
// 当前位的最大可取值
int max = limit ? digits[pos] : 9;
int res = 0;
// 枚举当前位的可能取值(0~max)
for (int d = 0; d <= max; d++) {
// 若当前位是7,则跳过(不合法)
if (d == 7) {
continue;
}
// 计算新的limit
boolean newLimit = limit && (d == max);
// 递归处理下一位
res += dfs(pos + 1, newLimit);
}
// 缓存结果
memo[pos][limitIdx][0] = res;
return res;
}
public static void main(String[] args) {
CountWithoutSeven solver = new CountWithoutSeven();
System.out.println(solver.count(18)); // 输出16
System.out.println(solver.count(77)); // 输出77 - 16(含7的数有16个)=61
}
}
四、进阶案例:数字中含有连续相同数字的计数
4.1 问题描述
计算[L, R]范围内,至少有一对连续相同数字的数有多少个(如1223含有“22”,符合条件)。
提示:可转化为计算[0, R]的数量减去[0, L-1]的数量。
4.2 状态设计
需新增状态记录“上一位的数字”,以判断是否出现连续相同:
- pos:当前位;
- limit:是否受约束;
- prev:上一位的数字(-1表示首位,无前置数字);
- hasSame:是否已出现过连续相同数字(0表示未出现,1表示已出现)。
4.3 代码实现(核心部分)
public class CountWithConsecutive {
int[] digits;
int[][][][] memo; // pos, limit, prev+1(0~10), hasSame(0~1)
public int count(int n) {
if (n < 10) return 0; // 小于10的数不可能有连续相同
// 分解数位...(同上)
// 初始化记忆化数组:pos(0~len-1), limit(0~1), prev(0~10, -1用0表示), hasSame(0~1)
memo = new int[digits.length][2][11][2];
for (int[][][] arr : memo) {
for (int[][] sub : arr) {
for (int[] sub2 : sub) {
Arrays.fill(sub2, -1);
}
}
}
return dfs(0, true, -1, 0);
}
private int dfs(int pos, boolean limit, int prev, int hasSame) {
if (pos == digits.length) {
return hasSame; // 只有已出现连续相同才计数
}
int limitIdx = limit ? 1 : 0;
int prevIdx = prev + 1; // 将-1映射为0,0~9映射为1~10
if (memo[pos][limitIdx][prevIdx][hasSame] != -1) {
return memo[pos][limitIdx][prevIdx][hasSame];
}
int max = limit ? digits[pos] : 9;
int res = 0;
for (int d = 0; d <= max; d++) {
boolean newLimit = limit && (d == max);
int newHasSame = hasSame;
// 判断是否与上一位相同(prev != -1时)
if (prev != -1 && d == prev) {
newHasSame = 1;
}
// 递归处理下一位,更新prev为当前d
res += dfs(pos + 1, newLimit, d, newHasSame);
}
memo[pos][limitIdx][prevIdx][hasSame] = res;
return res;
}
// 计算[L, R]的数量 = count(R) - count(L-1)
public int rangeCount(int L, int R) {
return count(R) - count(L - 1);
}
}
五、数位DP的关键技巧与注意事项
5.1 状态设计技巧
- 最小化状态:只记录必要的信息(如“是否出现过某数字”而非“出现的次数”);
- 离散化状态:将连续状态(如数字和)限制在合理范围内(如和不超过200可直接存储);
- 利用对称性:若某些状态等价(如奇数和偶数在某条件下无区别),可合并状态。
5.2 边界处理
- 前导零问题:数字前的零不视为有效数字(如0012视为12,需在状态中标记是否有前导零);
- 空数字处理:递归终止时需正确判断“是否构成有效数字”(如0是有效数字,但前导零的空串不是)。
5.3 性能优化
- 记忆化数组初始化:每次处理新数字时需重置记忆化数组;
- 状态压缩:将布尔状态(如limit)转为0/1整数,减少维度;
- 剪枝:在枚举数位时,提前跳过不可能满足条件的分支(如已确定无法达成目标,直接返回0)。
总结
数位DP通过将数字按位分解,结合记忆化搜索高效处理与数位特征相关的计数问题:
- 数位分解:将数字转化为便于逐位处理的数组;
- 状态设计:包含位置、约束标记及问题相关的自定义状态;
- 记忆化搜索:通过缓存避免重复计算,降低时间复杂度。
That’s all, thanks for reading~~
觉得有用就点个赞
、收进收藏
夹吧!关注
我,获取更多干货~