LeetCode 440. 字典序的第K小数字
题目描述
题意分析
给定两个整数
n和k,在1到n这n个整数里,按字典序从小到大排好之后,返回排在第k位的那个数。第一个必须抠清楚的点是「字典序」不是「数值序」。字典序是把整数当成字符串逐字符比较的:
1 < 10 < 11 < 12 < 13 < 2 < 3 < …,所以2排在13后面,10紧跟在1后面。凡是把题意读成「第 k 小的数」的,答案会全错。第二个信号来自数据范围:
n最大可以到 $10^9$,而k保证不超过n。$10^9$ 个数既装不进内存,也不允许我们真的生成出来再排序,甚至连「从头到尾一个一个数过去」的 $O(n)$ 遍历都过不了。题目给出这样的量级,等价于明说:只接受与n的位数相关的解法。边界上要留意几处:
k从 1 开始计数,不是从 0;n = 1时唯一的答案就是1;答案本身一定落在[1, n]内,可以用int返回,但中间的计数过程未必安全。
解法:前缀树计数跳过子树
核心思路
问题关键:
n可达 $10^9$,不能生成并排序1..n。需要在不枚举所有数字的前提下,直接跳到字典序第k个位置。为什么建模为十叉前缀树:字典序就是前缀树的先序遍历。前缀
p的孩子依次为p0, p1, ..., p9;从1开始的遍历顺序是1, 10, 100, ..., 11, ..., 2, ...。因此,只要知道某个前缀子树有多少个合法数字,就能一次跳过整棵子树。前缀
p与下一个前缀p + 1在各层形成左闭右开区间:
- 第一层:
[p, p + 1);- 下一层:
[10p, 10(p + 1));- 再下一层继续同时乘 10。
每层在
1..n内的节点数为min(n + 1, next) - first,逐层累加就是该前缀的子树大小。循环不变量:
cur是当前访问的数字,k是从cur还需向后走的步数。初始cur = 1已占第一个位置,所以先执行k--。设当前子树大小为steps:
steps <= k:答案不在当前子树,执行cur++、k -= steps,整体跳到下一个兄弟;steps > k:答案在当前子树,执行cur *= 10、k--,进入先序遍历的第一个孩子。正确性:同一前缀的所有数字在字典序中连续出现,
countSteps又精确统计了这段连续区间的长度,所以比较steps与k能正确决定“跳过”还是“深入”。两种操作都按实际跨过的节点数更新k,保持不变量;当k = 0时,cur正是目标数字。
解题步骤
- 初始化
cur = 1,并把 1 基的k减一,改成还需前进的步数。- 用
countSteps逐层统计[first, next)与[1, n + 1)的交集长度;边界变量必须使用 64 位。- 若当前子树大小不超过剩余步数,跳到兄弟并扣除整棵子树;否则进入最左孩子并只扣当前节点。
k归零时返回cur。面试口述示例:
n = 13, k = 6的字典序开头是1,10,11,12,13,2。以 1 为前缀的子树共有 5 个节点;初始减一后k = 5,满足steps <= k,整棵子树被跳过,cur = 2、k = 0,答案就是 2。边界反例:
k = 1时减一后直接返回 1;当n = 10时,计数循环必须包含first == n的那一层,否则会漏掉数字 10。
代码实现
class Solution {
public int findKthNumber(int n, int k) {
long cur = 1;
k--;
while (k > 0) {
long steps = countSteps(n, cur, cur + 1);
if (steps <= k) {
cur++;
k -= steps;
} else {
cur *= 10;
k--;
}
}
return (int) cur;
}
private long countSteps(int n, long first, long next) {
long steps = 0;
while (first <= n) {
steps += Math.min((long) n + 1, next) - first;
first *= 10;
next *= 10;
}
return steps;
}
}
func findKthNumber(n int, k int) int {
cur := int64(1)
k--
for k > 0 {
steps := countPrefixSteps(n, cur, cur+1)
if steps <= int64(k) {
cur++
k -= int(steps)
} else {
cur *= 10
k--
}
}
return int(cur)
}
func countPrefixSteps(n int, first int64, next int64) int64 {
limit := int64(n) + 1
steps := int64(0)
for first <= int64(n) {
if next < limit {
steps += next - first
} else {
steps += limit - first
}
first *= 10
next *= 10
}
return steps
}
复杂度分析
- 时间复杂度:$O(\log^2 n)$。前缀树深度为 $O(\log n)$,主循环在每层至多跨过常数个十进制兄弟;每次
countSteps又向下统计 $O(\log n)$ 层。- 空间复杂度:$O(1)$,只维护当前前缀、区间边界和计数,没有真正建树。
关键点总结
- 把字典序识别为十叉前缀树的先序遍历,是从“排序”转向“计数跳步”的关键。
[first, next)逐层乘 10 可以统一统计前缀子树;用n + 1作为右开上界,避免末端少算一个节点。- 跳兄弟扣
steps,进孩子只扣 1;先写清k表示“剩余步数”就不容易混淆。first、next会乘到 $10^{10}$,Java 必须用long,Go 必须用int64。
易错点总结
- 忘记初始
k--:n = 13, k = 1会多走一步,无法返回 1。- 跳过子树时只做
k--,或深入孩子时做k -= steps:两种分支跨过的节点数正好写反。- 条件写成
steps < k:当steps == k时本应正好跳到兄弟,却会错误深入子树。- 用
min(n, next)而不是min(n + 1, next):右开区间会漏算数字n。- 用
int保存层级边界:接近 $10^9$ 时继续乘 10 会溢出,可能得到负计数或死循环。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 386. 字典序排数 | 中等 | 同一棵十叉树,但要求完整先序输出而非定位 |
| 60. 排列序列 | 困难 | 用阶乘计数在排列树上跳步,逐位确定答案 |
| 233. 数字 1 的个数 | 困难 | 按位统计数位出现次数,计数而不枚举 |
| 378. 有序矩阵中第 K 小的元素 | 中等 | 对答案二分,用「不超过 mid 的个数」定位第 k |
| 668. 乘法表中第k小的数 | 困难 | 值域二分配合逐行计数,处理巨大隐式表格 |