902. 最大为 N 的数字组合

LeetCode 原题链接

题目描述

给定按升序排列、互不相同的数字字符数组 digits 和整数 n,统计可以用 digits 中数字重复组合出的、范围在 [1, n] 内的正整数个数。

输入:digits = ["1","3","5","7"], n = 100
输出:20

长度为 1 的合法数有 4 个,长度为 2 的有 4² = 16 个;无法组成不超过 100 的三位数,所以共 20 个。

题型判断

不能真的生成所有组合。应先按位数计数,再处理与 n 位数相同的数字:

  1. 位数比 n 少的数字一定小于 n,可直接用乘法原理统计。
  2. n 等长时,从最高位到最低位逐位比较。

这是数位计数,也可以视作数位 DP 的简化形式。

核心思路

nlength 位,可选数字有 digitCount 个。

1. 统计位数更少的数字

依次枚举数字长度:

len = 1, 2, ..., length - 1

题目保证 digits 不包含 "0",所以每一位都可以从 digitCount 个数字中任意选择。长度为 len 的数字共有:

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 个:

1、3、5、7

两位数的十位和个位都可以从 digits 中任意选择,所以有:

4 × 4 = 4² = 16

这些数字都不足三位,一定小于 365,因此先得到:

4 + 16 = 20

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 相等,暂时不能计数,需要继续比较十位。
  • 选择 57:百位已经大于 3,组成的数一定大于 365,不能计入。

百位选择 1 时得到的 16 个数字是:

111、113、115、117
131、133、135、137
151、153、155、157
171、173、175、177

到这里累计有:

20 + 16 = 36

比较十位

只有百位选择 3 的分支还需要继续,也就是说当前已经匹配了前缀 3365 的十位是 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;

就是在计算“当前位选择得更小后,剩余位置可以任意选择”的组合数。比较百位时还剩两位,所以增加 ;比较十位时还剩一位,所以每个更小的十位选择增加

代码实现

/**
 * 统计由 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 的含义:从“合法数字的数量”改为“目前找到的最大合法数字”。

  1. 位数更少时:原题累加各个位数的组合数;变式直接保存少一位、全部填最大数字的候选。若 n 只有一位,则先保存 null
  2. 当前位选择较小数字时:原题累加剩余位置的组合数;变式保留相等前缀,当前位选较小数字,后面全部填最大数字,更新候选。
  3. 当前位可以相等时:与原题一样,继续比较下一位。
  4. 当前位不能相等时:直接返回已保存的候选;如果完整匹配,则返回 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(...) 转换造成精度损失。

复杂度与易错点

  • Ln 的位数、Ddigits.length,原题计数实现的时间复杂度为 O(L × D),也就是 O(log n × digits.length)
  • 变式实现每次构造候选需要 O(L) 时间,最多构造 O(L × D) 次,因此时间复杂度为 O(L² × D)
  • 两种实现的空间复杂度均为 O(L),用于保存目标字符串;变式还需要候选的前缀、后缀等临时字符串。
  • digitCount ** len 成立的前提是 digits 不包含 "0";否则最高位不能为 0,需要单独处理前导零。
  • digittarget[position] 都是单个数字字符,"1""9" 的字典序与数值顺序一致,因此可以直接比较。
  • 处理某一位时,必须保证此前的前缀与 n 相等;一旦当前位选择了更小的数字,剩余位就可以任意组合并一次性计数。
  • 当前位没有相同数字时,应立即返回,因为相等前缀已经无法继续。
  • 只有完整匹配 n 的每一位时,才能把 n 本身计入。