1. 问题背景与核心需求解析
今天要讨论的是LeetCode 902题"最大为N的数字组合"的解题思路。这道题在算法面试中属于中等偏上难度,主要考察对数字组合问题的处理能力。题目要求我们给定一个非空的数字字符串数组digits和一个整数n,返回可以用digits中的数字组合成的小于等于n的正整数的个数。
1.1 题目理解与示例分析
先来看题目给出的示例:
输入:digits = ["1","3","5","7"], n = 100
输出:20
解释:可以组合的数字有:1, 3, 5, 7, 11, 13, 15, 17, 31, 33, 35, 37, 51, 53, 55, 57, 71, 73, 75, 77.
这个问题的难点在于如何高效地计算所有可能的组合数,特别是当n非常大时(比如n是10^9),暴力枚举显然不可行。
1.2 核心算法思路
解决这类数字组合问题,通常可以采用数位动态规划(Digit DP)的方法。这种方法特别适合处理与数字各位相关的计数问题。基本思路是将数字n拆分成各个位,然后逐位考虑可能的选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数位动态规划详解
2.1 状态定义与转移
我们需要定义几个关键状态:
- pos:当前处理到的数字位数(从最高位开始)
- tight:布尔值,表示前面的位是否严格等于n的对应位
- leading_zero:布尔值,表示当前是否处于前导零状态
状态转移时需要考虑:
- 如果tight为true,当前位不能超过n的对应位
- 可以选择digits中的任意数字,但要满足上述条件
- 需要处理前导零的特殊情况
2.2 递归实现框架
java复制private int dfs(String num, int pos, boolean tight, boolean leadingZero, String[] digits, Integer[][] memo) {
if (pos == num.length()) {
return leadingZero ? 0 : 1;
}
if (memo[pos][tight ? 1 : 0] != null && !leadingZero) {
return memo[pos][tight ? 1 : 0];
}
int limit = tight ? num.charAt(pos) - '0' : 9;
int res = 0;
for (String d : digits) {
int digit = Integer.parseInt(d);
if (digit > limit) continue;
boolean newTight = tight && (digit == limit);
boolean newLeadingZero = leadingZero && (digit == 0);
res += dfs(num, pos + 1, newTight, newLeadingZero, digits, memo);
}
if (!leadingZero) {
memo[pos][tight ? 1 : 0] = res;
}
return res;
}
3. 完整解决方案实现
3.1 预处理与边界处理
在实现完整解法前,需要考虑几个边界情况:
- digits数组可能包含重复元素(虽然题目说非空,但没说唯一)
- n可能为0(但题目说是正整数)
- digits中的数字可能不按顺序排列
java复制public int atMostNGivenDigitSet(String[] digits, int n) {
String num = String.valueOf(n);
int len = num.length();
Integer[][] memo = new Integer[len][2];
// 先计算位数小于len的所有可能组合数
int res = 0;
for (int i = 1; i < len; i++) {
res += (int) Math.pow(digits.length, i);
}
// 再加上位数等于len的组合数
res += dfs(num, 0, true, true, digits, memo);
return res;
}
3.2 时间复杂度分析
这个解法的时间复杂度主要取决于状态数。因为有O(L)个位置(L是n的位数),每个位置有2种tight状态,所以总状态数是O(L)。每个状态的处理时间是O(D)(D是digits的数量),所以总时间复杂度是O(L*D)。
4. 优化与变种思考
4.1 迭代法实现
递归实现虽然直观,但可能会有栈溢出风险。我们可以改用迭代法实现:
java复制public int atMostNGivenDigitSetIterative(String[] digits, int n) {
String s = String.valueOf(n);
int len = s.length();
int[] dp = new int[len + 1];
dp[len] = 1;
for (int i = len - 1; i >= 0; i--) {
int si = s.charAt(i) - '0';
for (String d : digits) {
int di = Integer.parseInt(d);
if (di < si) {
dp[i] += Math.pow(digits.length, len - i - 1);
} else if (di == si) {
dp[i] += dp[i + 1];
}
}
}
for (int i = 1; i < len; i++) {
dp[0] += Math.pow(digits.length, i);
}
return dp[0];
}
4.2 处理前导零的替代方法
在某些情况下,我们可能需要考虑包含前导零的数字。这时可以修改状态定义,或者在最后结果中加上1(代表数字0)。
5. 常见错误与调试技巧
5.1 典型错误案例
- 忘记处理位数小于n的数字组合
- tight状态转移错误
- 前导零处理不当导致重复计数
- 没有对digits数组进行排序(某些实现可能需要)
5.2 调试建议
- 先用小例子手动计算预期结果
- 打印中间状态(如每个pos和tight组合的计算结果)
- 特别注意边界情况:n=1, digits=["0"], digits包含"0"等情况
提示:在面试中,可以先讨论暴力解法,然后引出数位DP的优化思路,展示你的问题分析和优化能力。
6. 实际应用场景延伸
虽然这道题看起来是纯算法问题,但类似的数字组合计数在实际中有广泛应用:
- 密码破解中的尝试次数计算
- 产品序列号生成系统
- 数字权限管理系统
- 游戏中的数字谜题设计
理解这类问题的解法可以帮助我们在实际开发中处理类似的限制性组合问题。比如在设计优惠码系统时,可能需要计算特定规则下可以生成多少种有效的优惠码组合。
7. 性能优化进阶
对于特别大的n(比如10^100),我们可以进一步优化:
- 预处理digits数组,快速查询有多少数字小于/等于某个值
- 使用更紧凑的状态表示
- 并行计算不同位数的结果
java复制// 预处理digits的优化版本
public int atMostNGivenDigitSetOptimized(String[] digits, int n) {
String s = String.valueOf(n);
int len = s.length();
int[] dp = new int[len + 1];
dp[len] = 1;
// 预处理digits为有序数组
int[] di = new int[digits.length];
for (int i = 0; i < digits.length; i++) {
di[i] = Integer.parseInt(digits[i]);
}
Arrays.sort(di);
for (int i = len - 1; i >= 0; i--) {
int si = s.charAt(i) - '0';
// 使用二分查找加速
int idx = Arrays.binarySearch(di, si);
if (idx >= 0) {
dp[i] = dp[i + 1];
idx--;
} else {
idx = -idx - 2;
}
dp[i] += (idx + 1) * (int) Math.pow(digits.length, len - i - 1);
}
for (int i = 1; i < len; i++) {
dp[0] += (int) Math.pow(digits.length, i);
}
return dp[0];
}
8. 语言特性与实现细节
在不同编程语言中实现时,需要注意语言特性的差异:
- Java中String转换为char/digit的效率
- Python的大整数处理优势
- C++的位运算优化可能
- JavaScript的数字精度问题
例如在Python中,可以利用其内置的高精度整数支持简化实现:
python复制def atMostNGivenDigitSet(self, digits: List[str], n: int) -> int:
s = str(n)
k = len(s)
dp = [0] * k + [1]
for i in range(k-1, -1, -1):
for d in digits:
if d < s[i]:
dp[i] += len(digits) ** (k-i-1)
elif d == s[i]:
dp[i] += dp[i+1]
return dp[0] + sum(len(digits)**i for i in range(1, k))
9. 测试用例设计
全面的测试用例应该包括:
- 常规情况
- digits = ["1","3","5","7"], n = 100 → 20
- 边界情况
- digits = ["1"], n = 1 → 1
- digits = ["9"], n = 8 → 0
- 大数测试
- digits = ["1","2","3"], n = 10^9
- 包含0的特殊情况
- digits = ["0","1"], n = 10 → 3 (0,1,10)
- 重复数字
- digits = ["1","1","2"], n = 12 → 6 (1,2,11,12,21,22)
10. 算法可视化理解
为了更好理解数位DP的工作方式,可以想象我们在构建一棵决策树:
- 根节点代表空数字
- 每个层级代表数字的一位
- 每条边代表选择一个digit
- 根据tight状态限制可选的分支
通过记忆化存储每个状态的结果,我们避免了重复计算,大大提高了效率。这种方法的核心思想是将一个大问题分解为相互关联的子问题,这正是动态规划的精髓所在。
