目录

题目描述

1012. 至少有 1 位重复的数字

题意分析

统计 [1, n] 里有多少个整数,它的十进制表示中至少有两位数字相同

「至少有一位重复」是个非常松的条件,正面统计要枚举「哪一位和哪一位重复」,各种情形互相重叠,容斥会写到崩溃。而它的补集——每一位都互不相同——却是个极其规整的条件:从高位往低位填数字时,只需保证新填的数字没被用过。所以第一步必然是正难则反,先求出无重复数字的个数 good,答案就是 n - good

补集统计仍然不能一个个枚举:约束给的是 1 ≤ n ≤ 10^9,十亿次判断在题目时限内跑不完,而且这类题的标准形态就是「按位构造 + 数位上界限制」。十进制下最多 10 位,每位 10 种选择,真正的状态量极小。

按位构造时,只有两件事会影响后面还能填什么:已经用掉了哪些数字,以及前面几位是否与 n 完全相同(相同则当前位不能超过 n 的对应位,否则可以自由填 0 到 9)。前者因为只关心「用没用过」而不关心顺序,可以压成一个 10 位的二进制集合;后者是一个布尔标志。

边界有两处必须想清楚。一是前导零5 这个数在 3 位框架下写成 005,那两个 0 不是「使用了数字 0」,不能占用集合,否则 1020 这类含 0 的数会被误判成重复。二是数字 0 本身:按位构造统计的是 [0, n],而题目要的是 [1, n],所以要减掉 0 这一个。

解法:数位 DP 计数

核心思路

直接统计“至少有一位重复”会遇到大量重叠情况。它的补集更规整:先统计 [1,n] 中各位数字互不相同的数,再用 n 减去这个数量。

n 从高位到低位构造数字,定义:

dfs(pos, mask, tight):前 pos 位已经确定、已使用数字集合为 mask、当前前缀是否仍与 n 的前缀相同,在此条件下补完剩余数位的合法方案数。

  • mask 的第 d 位表示数字 d 是否已经出现在有效数字中;
  • tight = true 时,当前位最多取 n 的对应位;否则可取 0 到 9;
  • mask == 0 && d == 0 表示仍在跳过前导零,这个 0 不应写入 mask
  • 一旦数字已经开始,只有 d 尚未出现在 mask 中时才能继续。

状态不变量:进入 dfs(pos, mask, tight) 时,mask 精确记录已构造的有效前缀中出现过的数字,tight 精确表示该前缀是否贴着上界。 按上述规则枚举下一位,会且只会生成不超过 n、有效数位互不重复的十进制表示。

到达末尾时返回 1,表示得到一种完整构造。全程选择前导零的路径对应数字 0,因此 dfs(0,0,true) 统计的是 [0,n];减 1 才是 [1,n] 中无重复数字的数量。

只有 tight = false 的状态可以按 (pos, mask) 记忆化,因为此时后续上界固定为 9,结果与前缀具体取值无关。受限状态依赖 n 的当前数位,不能与同位置、同掩码的自由状态混用。

解题步骤

  1. n 转成从高位到低位排列的数位序列。
  2. 建立 digits.length * 2^10 的记忆化表,初值设为 -1
  3. dfs(0, 0, true) 开始枚举当前位:
    • 尚未开始且选择 0:保持 mask = 0
    • 否则若该数字未使用:把对应位加入 mask
    • 下一层的 tighttight && d == limit
  4. 仅在 tight = false 时读取和写入记忆化表。
  5. uniquePositive = dfs(0,0,true) - 1,返回 n - uniquePositive

n = 20 为例:

  • 首位取前导零,可得到 0..9,共 10 个无重复数(包含 0);
  • 首位取 1,末位可取除 1 外的 9 个数字,得到 9 个;
  • 首位取 2 时受上界限制,末位只能取 0,得到 20

因此 [0,20] 中共有 20 个无重复数,排除 0 后有 19 个,答案为 20-19=1,唯一的重复数是 11。边界 n=9 时无重复正数恰有 9 个,答案自然为 0。

代码实现

import java.util.Arrays;

class Solution {
    private char[] digits;
    private int[][] memo;

    public int numDupDigitsAtMostN(int n) {
        digits = String.valueOf(n).toCharArray();
        memo = new int[digits.length][1 << 10];
        for (int[] row : memo) {
            Arrays.fill(row, -1);
        }

        int uniquePositive = dfs(0, 0, true) - 1;
        return n - uniquePositive;
    }

    private int dfs(int pos, int mask, boolean tight) {
        if (pos == digits.length) {
            return 1;
        }
        if (!tight && memo[pos][mask] != -1) {
            return memo[pos][mask];
        }

        int limit = tight ? digits[pos] - '0' : 9;
        int ways = 0;
        for (int d = 0; d <= limit; d++) {
            boolean nextTight = tight && d == limit;
            if (mask == 0 && d == 0) {
                ways += dfs(pos + 1, 0, nextTight);
                continue;
            }

            int bit = 1 << d;
            if ((mask & bit) == 0) {
                ways += dfs(pos + 1, mask | bit, nextTight);
            }
        }

        if (!tight) {
            memo[pos][mask] = ways;
        }
        return ways;
    }
}
import "strconv"

func numDupDigitsAtMostN(n int) int {
	digits := strconv.Itoa(n)
	memo := make([][]int, len(digits))
	for i := range memo {
		memo[i] = make([]int, 1<<10)
		for mask := range memo[i] {
			memo[i][mask] = -1
		}
	}

	var dfs func(pos, mask int, tight bool) int
	dfs = func(pos, mask int, tight bool) int {
		if pos == len(digits) {
			return 1
		}
		if !tight && memo[pos][mask] != -1 {
			return memo[pos][mask]
		}

		limit := 9
		if tight {
			limit = int(digits[pos] - '0')
		}

		ways := 0
		for d := 0; d <= limit; d++ {
			nextTight := tight && d == limit
			if mask == 0 && d == 0 {
				ways += dfs(pos+1, 0, nextTight)
				continue
			}

			bit := 1 << d
			if mask&bit == 0 {
				ways += dfs(pos+1, mask|bit, nextTight)
			}
		}

		if !tight {
			memo[pos][mask] = ways
		}
		return ways
	}

	uniquePositive := dfs(0, 0, true) - 1
	return n - uniquePositive
}

复杂度分析

Ln 的十进制位数。

  • 时间复杂度: $O(L \cdot 2^{10} \cdot 10)$。最多有 $L\cdot2^{10}$ 个可复用状态,每个状态枚举 10 个数字;每层至多一个受限分支,不改变数量级。
  • 空间复杂度: $O(L \cdot 2^{10})$,用于记忆化表;递归栈深度为 $O(L)$。

关键点总结

  • 用补集把“至少一位重复”转化为“所有有效数位互不相同”。
  • 位掩码只记录数字是否使用过,不需要记录出现顺序;首个有效数位不可能是 0,所以数字一旦开始,mask 必然非零,mask == 0 可以安全地兼作 started == false,无需再加一维状态。
  • tight 是上界约束:只有此前贴着上界且当前位也取到上界,下一位才继续受限。
  • 记忆化仅缓存自由状态;状态定义、前导零语义和缓存范围必须保持一致。
  • 递归结果包含数字 0,做补集前必须先将它排除。

易错点总结

  • 把前导零记入 mask 在三位框架里,数字 5 写作 005;两个占位 0 不是真实数位,不能据此判重,也不能阻止 10 使用真实的数字 0。
  • 缓存受限状态却不把 tight 纳入键: 例如 n=210 时,自由前缀 12 与受限前缀 21 可到达相同的 (pos, mask),但末位上界分别是 9 和 0,二者结果不能复用。
  • 错误传递 tight 若当前位已经小于上界,后续位应永久自由;若当前位等于上界,后续仍须受限。少任一条件都会漏算或统计大于 n 的数。
  • 忘记排除全前导零路径: dfs 包含数字 0,直接用 n-dfs(...) 会让答案少 1;n=20 会错误得到 0。
  • 掩码只开 1<<9 十进制有 0 到 9 共 10 个数字,使用数字 9 时会越界;维度必须是 1<<10

相似题目

题目 难度 考察点
357. 统计各位数字都不同的数字个数 中等 上界固定为 $10^k$,没有 tight 维度,可以直接用排列数公式,是本题的简化版
902. 最大为 N 的数字组合 困难 可用数字被限定在给定集合内,业务状态从「用过哪些」变成「能否使用」,前导零处理方式不同
233. 数字 1 的个数 困难 统计的不是数的个数而是数字 1 出现的次数,DP 返回值要携带计数而非布尔式的方案数
剑指 Offer 43. 1~n 整数中 1 出现的次数 困难 与 233 同题,常用逐位贡献法求解,可与数位 DP 模板互相印证
面试题 17.06. 2出现的次数 困难 把 233 的目标数字换成 2,用来检验模板是否真的与具体数字解耦