数位 DP 是一类统计满足某条件的数的个数的动态规划方法。核心思路是将数字拆成各位,从高位到低位逐位决策,用"是否紧贴上限"作为状态。
一、适用场景
Mermaid · 渲染中(下方为源码)
graph TD A[数位DP] --> B[拆数字各位] B --> C[高位到低位决策] C --> D[状态: 紧贴上限?]
- 求 [L, R] 中满足某性质的整数个数
- 性质与"各位数字"相关(如:不含 4、各位之和为 k、相邻位不等)
- 数据范围通常 10⁹ ~ 10¹⁸,无法枚举
二、通用模板(记忆化搜索)
// 模板:统计 [0, n] 中满足条件的数的个数
public int countNumbers(int n) {
char[] digits = String.valueOf(n).toCharArray();
int len = digits.length;
int[][] memo = new int[len][/* 状态维度 */];
for (int[] row : memo) Arrays.fill(row, -1);
return dfs(digits, 0, /* 初始状态 */, true, memo);
}
// pos: 当前处理到第几位
// state: 题目相关状态(如前一位数字、数字和等)
// tight: 是否受上限约束
int dfs(char[] digits, int pos, int state, boolean tight, int[][] memo) {
if (pos == digits.length) return 1; // 成功构造一个数
if (!tight && memo[pos][state] != -1) return memo[pos][state];
int limit = tight ? digits[pos] - '0' : 9;
int count = 0;
for (int d = 0; d <= limit; d++) {
// 剪枝:跳过不合法的数字
if (!isValid(d, state)) continue;
int nextState = transition(state, d);
count += dfs(digits, pos + 1, nextState, tight && (d == limit), memo);
}
if (!tight) memo[pos][state] = count;
return count;
}三、经典例题
1. 不含数字 4 的数(LeetCode 233 变体)
// 统计 [1, n] 中不含数字 4 的正整数个数
public int countWithout4(int n) {
char[] digits = String.valueOf(n).toCharArray();
int[][] memo = new int[digits.length][1];
for (int[] row : memo) Arrays.fill(row, -1);
return dfs(digits, 0, true, memo) - 1; // 减去 0
}
int dfs(char[] digits, int pos, boolean tight, int[][] memo) {
if (pos == digits.length) return 1;
if (!tight && memo[pos][0] != -1) return memo[pos][0];
int limit = tight ? digits[pos] - '0' : 9;
int count = 0;
for (int d = 0; d <= limit; d++) {
if (d == 4) continue; // 跳过 4
count += dfs(digits, pos + 1, tight && (d == limit), memo);
}
if (!tight) memo[pos][0] = count;
return count;
}2. 各位数字之和等于 K
// 状态:pos + 剩余需要的数字和
int dfs(char[] digits, int pos, int remain, boolean tight, int[][] memo) {
if (pos == digits.length) return remain == 0 ? 1 : 0;
if (remain < 0) return 0;
if (!tight && memo[pos][remain] != -1) return memo[pos][remain];
int limit = tight ? digits[pos] - '0' : 9;
int count = 0;
for (int d = 0; d <= limit; d++) {
count += dfs(digits, pos + 1, remain - d, tight && (d == limit), memo);
}
if (!tight) memo[pos][remain] = count;
return count;
}3. 相邻位不重复
// 状态:pos + 前一位数字(0-9, 10表示首位)
int dfs(char[] digits, int pos, int prev, boolean tight, int[][] memo) {
if (pos == digits.length) return 1;
if (!tight && memo[pos][prev] != -1) return memo[pos][prev];
int limit = tight ? digits[pos] - '0' : 9;
int count = 0;
for (int d = 0; d <= limit; d++) {
if (d == prev) continue; // 相邻不重复
count += dfs(digits, pos + 1, d, tight && (d == limit), memo);
}
if (!tight) memo[pos][prev] = count;
return count;
}四、求区间 [L, R] 的技巧
int answer = count(R) - count(L - 1);利用前缀和思想,将区间问题转化为两次 [0, x] 的计数。
五、复杂度分析
- 状态数:O(位数 × 状态维度) ≈ O(20 × S)
- 转移:每个状态枚举 0~9,O(10)
- 总复杂度:O(20 × S × 10),通常 < 10⁴
六、面试要点
- tight 的含义:当前前缀是否等于 n 的对应前缀,决定本位上界
- memo 只在 !tight 时缓存:tight 状态下每个位置的上界不同,不可复用
- 前导零处理:若题目不允许前导零,需额外状态标记"是否还在前导零阶段"
- LeetCode 高频:233(数字 1 的个数)、600(不含连续 1)、902(最大为 N 的数字组合)、1012(至少有 1 位重复)