LeetCode 440 - 字典序的第 K 小数字


文章目录

摘要

这道题乍一看像是要把 1 到 n 排序后找第 k 小的数字,但 n 最大能到 10⁹,如果你真把所有数字转字符串排个序,那内存瞬间爆炸。

其实它考的不是排序,而是一个非常巧妙的 按字典序遍历前缀树(Trie-like 结构) 的技巧:不需要真的生成树,只需要能算出每个前缀下面有多少数字,就能一步步往下走,找到第 k 个位置的数字。

描述

题目要求你在 1 到 n 之间,按照"字符串字典序"的排序方式,找到第 k 小的数字。例如:

对于 n = 13:

字典序排列:

txt 复制代码
1, 10, 11, 12, 13, 2, 3, 4, 5, 6, 7, 8, 9

所以 k=2 时答案是 10。

你会发现字典序不是按数字大小排,而是按"字符串从左到右"排序的方式来排。

因为 n 可以到 10⁹,我们不能直接生成数组。必须找到一条"逻辑路径",让我们能够模拟字典序,而不生成完整序列。

题解答案

关键思路:

把 1 ~ n 看成挂在一个"字典序前缀树"上的节点,例如:

txt 复制代码
1
 ├── 10
 │    ├── 100
 │    ├── 101
 │    ...
 ├── 11
 ├── 12
2
3
...

我们要找第 k 小,就是在这棵树上进行类似 DFS 的"前序遍历",但不是真的遍历,而是每次通过计数跳过一个整个子树

核心操作是:

给定前缀 prefix,我们可以算出在区间 [prefix, prefix+1) 之间有多少数字 <= n。

例如前缀 1,它下有:

txt 复制代码
1, 10, 11, 12, 13

五个数字。

如果想找第 k 小的数字:

  1. prefix = 1 开始;
  2. 看 prefix 下面一整个子树有多少数字 count;
  3. 如果 k > count,则说明第 k 个数字不在 prefix 这一支里,我们跳到下一个 prefix(也就是 prefix+1),并将 k -= count;
  4. 如果 k <= count,则说明目标在 prefix 这一支里,我们往下走(prefix *= 10),并将 k -= 1,因为 prefix 本身是该子树第一个数字;
  5. 重复直到 k==0;

这种方式堪称暴力排序的完全替代方案,同时效率极高。

可运行 Swift 实现

下面的代码可直接在 Xcode Playground 或 Swift 文件里运行:

swift 复制代码
import Foundation

class Solution {
    func findKthNumber(_ n: Int, _ k: Int) -> Int {
        var k = k - 1  // 因为我们把 prefix 本身当成第一个节点
        var prefix = 1

        while k > 0 {
            let count = countPrefix(n, prefix, prefix + 1)
            if k >= count {
                // 不在这一支,跳到下一个前缀兄弟节点
                k -= count
                prefix += 1
            } else {
                // 在这一支,往下走
                prefix *= 10
                k -= 1
            }
        }

        return prefix
    }

    // 计算区间 [prefix, nextPrefix) 中的数字数量(<= n)
    private func countPrefix(_ n: Int, _ prefix: Int, _ nextPrefix: Int) -> Int {
        var curr = prefix
        var next = nextPrefix
        var count = 0

        while curr <= n {
            count += min(n + 1, next) - curr
            curr *= 10
            next *= 10
        }
        return count
    }
}

// Demo 测试
let solution = Solution()

let tests = [
    (13, 2),
    (100, 10),
    (1_000_000_000, 100000),
    (1, 1),
    (5000, 150)
]

for (n, k) in tests {
    print("n=\(n), k=\(k) → \(solution.findKthNumber(n, k))")
}

题解代码分析

我们逐步拆解代码中最关键的几个点。

1. 为什么要 k -= 1

因为我们把当前 prefix 当作该前缀子树的第一个节点。例如 prefix=1:

字典序顺序:

txt 复制代码
1, 10, 11, 12, 13, ...

所以当你决定往 prefix 的子树里继续往下走时,需要把 1 的位置算进去,于是 k -= 1

2. countPrefix 是整个题目的灵魂

它计算:

[prefix, nextPrefix) 这个前缀区间内,有多少数字 <= n。

不断扩大区间:

txt 复制代码
[prefix, nextPrefix)
[prefix*10, nextPrefix*10)
[prefix*100, nextPrefix*100)
...

直到超过 n。

例如 n = 13,prefix = 1,nextPrefix = 2:

txt 复制代码
curr=1, next=2: min(14,2)-1 = 1
curr=10, next=20: min(14,20)-10 = 4
curr=100 > 13 stop
总计 = 5

也就是 1, 10, 11, 12, 13 共 5 个数字。

3. 核心循环为什么这么高效?

因为每次不是"一个数字一个数字地走",而是按"整个子树数量"跳:

  • 如果 k 比子树大 → 一步跳过这整个分支;
  • 如果在子树里 → 直接深入下一层分支;

这让时间复杂度变成 O(log n),非常高效。

示例测试及结果

在 Demo 中我们测试了几组:

例如:

swift 复制代码
n = 13, k = 2

输出:

txt 复制代码
10

再例如:

swift 复制代码
n = 100, k = 10

字典序前十个分别是:

txt 复制代码
1, 10, 100, 11, 12, 13, 14, 15, 16, 17 ...

第 10 个是 17。

再大一点测试:

swift 复制代码
n = 1_000_000_000, k = 100000

这种规模如果你用排序法绝对挂掉,但前缀跳跃法是几毫秒级别即可算出。

时间复杂度

每次跳层计算数量的过程是按十倍增长,最多进行 log₁₀(n) 次。

外层循环最多也执行 log₁₀(n) 次。

总的时间复杂度:

O(log² n)

在 n=1e9 这种规模下非常快。

空间复杂度

只使用若干整数变量,栈内不存储大量数据:

O(1)

总结

这道 LeetCode 440 是一道典型的"字典序前缀树"思维题,看似困难,但理解后你会发现:

  1. 我们不是在排序数字,而是在"跳过"子树。
  2. 关键是能计算某个前缀下有多少数字。
  3. 利用这个计数,就能快速找到第 k 个数字。

这类题在某些数据结构、分布式系统的"范围扫描""前缀计数""分页"中其实很有用:

你不需要把所有节点都展开,只要能计算"前缀区间的数量",就能精确跳到需要的位置。

相关推荐
ZPC82108 分钟前
【无标题】
人工智能·pytorch·算法·机器人
2301_7644413310 分钟前
使用python构建的STAR实验ΛΛ̄自旋关联完整仿真
开发语言·python·算法
Rainy Blue88313 分钟前
前缀和与差分(蓝桥杯高频考点)
数据结构·算法·蓝桥杯
Dfreedom.13 分钟前
机器学习经典算法全景解析与演进脉络(无监督学习篇)
人工智能·学习·算法·机器学习·无监督学习
kaoshi100app19 分钟前
本周,河南二建报名公布!
开发语言·人工智能·职场和发展·学习方法
421!19 分钟前
ESP32学习笔记之GPIO
开发语言·笔记·单片机·嵌入式硬件·学习·算法·fpga开发
智算菩萨27 分钟前
【How Far Are We From AGI】4 AGI的“生理系统“——从算法架构到算力基座的工程革命
论文阅读·人工智能·深度学习·算法·ai·架构·agi
福赖30 分钟前
《算法:生产车间》
算法
alphaTao38 分钟前
LeetCode 每日一题 2026/3/16-2026/3/22
linux·windows·leetcode
空空潍38 分钟前
LeetCode力扣 hot100一刷完结
算法·leetcode