902. 最大为 N 的数字组合 
LeetCode 原题链接
题目描述
给定按升序排列、互不相同的数字字符数组 digits 和整数 n,统计可以用 digits 中数字重复组合出的、范围在 [1, n] 内的正整数个数。
输入:digits = ["1","3","5","7"], n = 100
输出:20
长度为 1 的合法数有 4 个,长度为 2 的有 4² = 16 个;无法组成不超过 100 的三位数,所以共 20 个。
题型判断
不能真的生成所有组合。应先按位数计数,再处理与 n 位数相同的数字:
- 位数比
n 少的数字一定小于 n,可直接用乘法原理统计。
- 与
n 等长时,从最高位到最低位逐位比较。
这是数位计数,也可以视作数位 DP 的简化形式。
核心思路
设 n 有 length 位,可选数字有 digitCount 个。
1. 统计位数更少的数字
依次枚举数字长度:
len = 1, 2, ..., length - 1
题目保证 digits 不包含 "0",所以每一位都可以从 digitCount 个数字中任意选择。长度为 len 的数字共有:
这些数字的位数少于 n,因此一定小于 n,可以全部计入答案。
2. 统计与 n 等长的数字
从 n 的最高位开始逐位处理。进入第 position 位时,始终满足:
前 position 位已经与 n 的前缀完全相同
比较当前位时有三种选择:
- 选择小于
n 当前位的数字:整个数字已经确定小于 n,剩余位可以任意选择。
- 选择等于
n 当前位的数字:前缀继续保持相等,进入下一位比较。
- 选择大于
n 当前位的数字:整个数字一定大于 n,不能计入答案。
如果 digits 中不存在等于当前位的数字,那么所有更小的选择都已经统计,更大的选择又不合法,当前相等前缀无法继续,可以直接返回。
如果 n 的每一位都能在 digits 中找到相同数字,说明 n 本身也能组成,最后需要额外加 1。
示例步骤
digits = ["1","3","5","7"],n = 365:
365 是三位数。整个统计过程分成两部分:先统计一位数和两位数,再单独统计不超过 365 的三位数。
1. 统计位数更少的数字
一位数有 4 个:
两位数的十位和个位都可以从 digits 中任意选择,所以有:
这些数字都不足三位,一定小于 365,因此先得到:
2. 统计与 365 等长的数字
三位数必须从百位开始与 365 比较。这里要抓住一个关键点:
- 当前位比
n 的对应位小,整个数字就已经确定小于 n,后面的位可以任意选择。
- 当前位与
n 的对应位相等,还不能确定大小,需要继续比较下一位。
- 当前位比
n 的对应位大,整个数字一定大于 n,不能计入。
比较百位
365 的百位是 3,可选数字是 1、3、5、7:
- 选择
1:百位满足 1 < 3,所以所有 1xx 都小于 365。剩余十位、个位各有 4 种选择,共有 1 × 4² = 16 个。
- 选择
3:百位与 365 相等,暂时不能计数,需要继续比较十位。
- 选择
5 或 7:百位已经大于 3,组成的数一定大于 365,不能计入。
百位选择 1 时得到的 16 个数字是:
111、113、115、117
131、133、135、137
151、153、155、157
171、173、175、177
到这里累计有:
比较十位
只有百位选择 3 的分支还需要继续,也就是说当前已经匹配了前缀 3。365 的十位是 6:
- 选择
1、3、5:它们都小于 6,所以 31x、33x、35x 一定小于 365。个位可以任意选择,共有 3 × 4¹ = 12 个。
- 选择
7:十位大于 6,组成的数一定大于 365,不能计入。
digits 中没有 6:无法让十位继续与 365 相等,因此相等分支到这里结束,不需要再比较个位。
这 12 个数字是:
311、313、315、317
331、333、335、337
351、353、355、357
最终统计结果为:
一位数: 4
两位数: 16
百位选择 1 的三位数: 16
百位选择 3、十位小于 6: 12
--------------------------------
总数: 48
代码中的:
const remainingLength = target.length - position - 1;
result += digitCount ** remainingLength;
就是在计算“当前位选择得更小后,剩余位置可以任意选择”的组合数。比较百位时还剩两位,所以增加 4²;比较十位时还剩一位,所以每个更小的十位选择增加 4¹。
代码实现
/**
* 统计由 digits 中的数字重复组成,并且不大于 n 的正整数个数。
*
* 计数分为两部分:
* 1. 位数少于 n 的数字,它们一定小于 n,可以直接用乘法原理计算。
* 2. 位数等于 n 的数字,需要从高位到低位逐位和 n 比较。
*
* @param {string[]} digits 按升序排列且互不相同的数字字符,题目保证不包含 "0"
* @param {number} n 上界
* @return {number} 满足条件的正整数个数
*/
var atMostNGivenDigitSet = function (digits, n) {
// 转为字符串,方便按下标从最高位到最低位访问 n 的每一位。
const target = String(n);
// 每一个数位可选择的数字数量。
const digitCount = digits.length;
// 累加所有合法数字的数量。
let result = 0;
/*
* 第一部分:统计位数少于 n 的数字。
*
* 例如 digits 有 4 个数字、n 是三位数:
* - 一位数有 4¹ 个;
* - 两位数有 4² 个。
*
* digits 不包含 "0",因此首位不需要排除前导零,每一位都有
* digitCount 种选择。
*/
for (let length = 1; length < target.length; length++) {
result += digitCount ** length;
}
/*
* 第二部分:统计与 n 位数相同的合法数字。
*
* 循环到 position 时,隐含条件是 position 之前的所有位都与 n 相等。
* 因此只需要比较当前可选数字 digit 和 n 的当前位 target[position]。
*/
for (let position = 0; position < target.length; position++) {
// 记录当前位能否选择与 n 相同的数字,从而继续比较下一位。
let hasEqualDigit = false;
// digits 已按升序排列,可以从小到大检查当前位的所有选择。
for (const digit of digits) {
if (digit < target[position]) {
/*
* 当前位一旦比 n 的当前位小,整个数字就已经确定小于 n。
* 后续每一位都可以从 digits 中任意选择,无须继续逐位比较。
*
* remainingLength 是当前位之后还剩多少位;
* 对于当前这个较小的 digit,可以产生 digitCount^remainingLength
* 个合法数字。
*/
const remainingLength = target.length - position - 1;
result += digitCount ** remainingLength;
} else if (digit === target[position]) {
/*
* 当前位相等时,前缀仍然与 n 相同。
* 这里只做标记,外层循环随后会继续比较下一位。
*/
hasEqualDigit = true;
} else {
/*
* 当前 digit 已经大于 n 的当前位。
* 因为 digits 升序排列,后面的数字只会更大,都不可能合法。
*/
break;
}
}
/*
* 如果当前位没有可选数字等于 n 的当前位,相等前缀无法延续。
* 小于当前位的情况已在内层循环中全部计数,大于当前位的情况不合法,
* 所以此时答案已经完整,可以提前返回。
*/
if (!hasEqualDigit) {
return result;
}
}
/*
* 能走完整个外层循环,说明 n 的每一位都存在于 digits 中,
* 即 n 本身也能由 digits 组成。前面的 result 只统计了小于 n 的数字,
* 因此最后加 1,把 n 自身计入。
*/
return result + 1;
};
变式:只找到不大于 n 的最大数字
如果题目不要求统计个数,而是要求从 digits 中重复选择数字,组成一个不大于 n 的最大正整数,可以直接基于原题代码改造。这里沿用原题约束:digits 非空、按升序排列、互不相同且不包含 "0",n 为正整数。
保留原题的两层循环、hasEqualDigit 标记和提前返回结构,只改变 result 的含义:从“合法数字的数量”改为“目前找到的最大合法数字”。
- 位数更少时:原题累加各个位数的组合数;变式直接保存少一位、全部填最大数字的候选。若
n 只有一位,则先保存 null。
- 当前位选择较小数字时:原题累加剩余位置的组合数;变式保留相等前缀,当前位选较小数字,后面全部填最大数字,更新候选。
- 当前位可以相等时:与原题一样,继续比较下一位。
- 当前位不能相等时:直接返回已保存的候选;如果完整匹配,则返回
n 本身。
提前保存候选,相当于提前记录了回退后的结果,因此不需要另外写向前回退的循环。
/**
* 使用 digits 中的数字重复构造一个不大于 n 的最大正整数。
*
* @param {string[]} digits 非空、升序且互不相同的数字字符,不包含 "0"
* @param {number} n 正整数上界
* @return {number|null} 能构造出的最大数字;不存在合法正整数时返回 null
*/
function largestAtMostN(digits, n) {
const target = String(n);
const maxDigit = digits[digits.length - 1];
/*
* 第一部分:先保存“位数少于 n”的最大合法数字,作为初始候选。
*
* digits 不包含 "0",不会出现前导零,因此位数少于 n 的正整数
* 一定小于 n。在这些数字中,位数越多,数字越大,所以只需要考虑
* target.length - 1 位,不必像原题计数那样枚举所有更短的长度。
*
* 数字可以重复使用,要让这个长度的数字最大,每一位都选 maxDigit。
* maxDigit.repeat(target.length - 1) 生成对应字符串,再用 Number
* 转成数字。例如 n = 365、maxDigit = "7",初始候选就是 77。
* 如果后面无法构造与 n 等长的合法数字,就返回这个较短的候选。
*
* 如果 n 只有一位,就不存在位数更少的正整数,因此先保存 null,
* 表示尚未找到合法答案,而不是返回 0(题目要求正整数)。
* 后续若找到合法的一位数,仍会更新候选或在完整匹配时返回 n;
* 只有始终没有合法答案时才返回 null,例如 digits = ["5"]、n = 3。
*/
let result = target.length > 1
? Number(maxDigit.repeat(target.length - 1))
: null;
// 第二部分:沿用原题结构,逐位匹配 n。
// 进入 position 时,此前的前缀始终与 n 相等。
for (let position = 0; position < target.length; position++) {
let hasEqualDigit = false;
for (const digit of digits) {
if (digit < target[position]) {
/*
* 此前的前缀与 n 完全相同,当前 digit 又小于 n 的对应位,
* 因此无论后面的位怎么选,组成的等长数字都一定小于 n。
*
* 原题在这里累加后续位置的组合数;变式只需要这个分支中
* 最大的数字,所以后面的每一位都选择 maxDigit。
*/
// 保留当前位之前的相等前缀,不包含当前位。
// 能走到这里,说明前缀中的每个数字都已经在 digits 中匹配成功。
const prefix = target.slice(0, position);
// 当前位之后还剩多少位:总位数减去前缀位数,再减去当前这一位。
const remainingLength = target.length - position - 1;
// 后缀全部填最大数字;如果当前已是最后一位,后缀就是空字符串。
const suffix = maxDigit.repeat(remainingLength);
/*
* 拼接“相等前缀 + 当前较小数字 + 最大后缀”,保存为候选答案。
* 例如 n = 365、position = 1、digit = "5"、maxDigit = "7":
* prefix = "3",suffix = "7",得到候选 357。
*
* 可以直接覆盖 result:同一位置的 digit 按升序遍历,候选
* 越来越大;更靠后的位置才变小,则比之前更早变小的候选大;
* 等长候选也一定大于初始化时保存的较短数字。
*
* 这里只保存候选,不能立即返回:后面可能还有更大的 digit,
* 或者可以选择相等数字并继续匹配,从而找到更大的合法答案。
* 如果后续匹配失败,就能直接返回保存的候选,无须向前回退。
*/
result = Number(prefix + digit + suffix);
} else if (digit === target[position]) {
// 前缀仍然相等,继续比较下一位。
hasEqualDigit = true;
} else {
// digits 升序排列,后面的数字也不合法。
break;
}
}
// 相等前缀无法继续,返回之前保存的最大候选。
if (!hasEqualDigit) {
return result;
}
}
// 每一位都在 digits 中,n 本身就是答案。
return n;
}
为什么可以直接覆盖 result?
新候选一定大于之前保存的候选:
- 同一位置中,
digits 升序遍历,后出现的较小数字能构造出更大的候选。
- 不同位置中,越靠后才变小的候选,与
n 相同的前缀越长,一定大于更早就变小的候选。
- 等长的合法数字一定大于位数更少的数字。
所以直接赋值即可,不需要再用 Math.max 比较。
示例步骤
digits = ["1","3","5","7"]、n = 365:
先保存位数更少的最大值:77
百位 3:
选择 1,小于 3 → 保存 177
选择 3,等于 3 → 继续比较十位
十位 6:
选择 1,小于 6 → 保存 317
选择 3,小于 6 → 保存 337
选择 5,小于 6 → 保存 357
没有等于 6 的数字 → 返回 357
再看原本需要回退的例子,digits = ["1","3","5","7"]、n = 300:
先保存位数更少的最大值:77
百位 3:
选择 1,小于 3 → 保存 177
选择 3,等于 3 → 继续比较十位
十位 0:
没有数字小于或等于 0
相等前缀无法继续 → 返回已保存的 177
可以把整个过程记成一句话:遇到更小的选择就保存最大候选,能相等就继续,不能相等就返回候选。
这段代码返回的是数字。如果不存在任何合法正整数,例如 digits = ["5"]、n = 3,返回 null。当前实现沿用原题数值范围;若要支持超过 JavaScript 安全整数范围的上界,应同时使用字符串输入和字符串返回值,避免 Number(...) 转换造成精度损失。
复杂度与易错点
- 设
L 为 n 的位数、D 为 digits.length,原题计数实现的时间复杂度为 O(L × D),也就是 O(log n × digits.length)。
- 变式实现每次构造候选需要
O(L) 时间,最多构造 O(L × D) 次,因此时间复杂度为 O(L² × D)。
- 两种实现的空间复杂度均为
O(L),用于保存目标字符串;变式还需要候选的前缀、后缀等临时字符串。
digitCount ** len 成立的前提是 digits 不包含 "0";否则最高位不能为 0,需要单独处理前导零。
digit 和 target[position] 都是单个数字字符,"1" 到 "9" 的字典序与数值顺序一致,因此可以直接比较。
- 处理某一位时,必须保证此前的前缀与
n 相等;一旦当前位选择了更小的数字,剩余位就可以任意组合并一次性计数。
- 当前位没有相同数字时,应立即返回,因为相等前缀已经无法继续。
- 只有完整匹配
n 的每一位时,才能把 n 本身计入。