前段时间刷题时遇到一个很有意思的问题:给定数字集合,把所有排列按字典序排好,第 17086 个排列是什么?很多人第一反应是把全排列全部生成出来,再排序取第 17086 个。但真的有必要吗?9 个数字的全排列有 362880 种,如果 n 变成 12,这个数字是 479001600,直接爆内存。真正懂行的人会用数学方法在 O(n^2) 甚至 O(n log n) 的时间里直接定位答案。
这篇博客不打算讲玄乎的理论,就围绕“17086 字典序的全排列”展开,把全排列、字典序、康托展开和逆康托展开这几个核心概念一次性讲透。你不需要很强的数学基础,只需要会一点代码,跟着我的计算过程走一遍,以后遇到“第 k 个排列”“某个排列排第几”这类问题,基本就能秒杀。
1. 题目拆解:17086 到底在问什么
1.1 全排列:从排队照相说起
先回到基础概念。所谓全排列,就是把一组元素的所有排列方式全部列出来。比如 3 个不同的人去排队拍照,一共有 3! = 6 种排法:123、132、213、231、312、321。有 n 个互不相同的元素,全排列数量就是 n 的阶乘。
这里有个很容易被低估的点:阶乘增长速度极其恐怖。9! = 362880,10! = 3628800,11! = 39916800,12! = 479001600。这意味着当 n 稍微变大一点,暴力枚举全排列就立刻不现实了。9 个元素生成 36 万个排列还能接受,12 个元素生成 4.79 亿个排列,光存储和遍历就是灾难。所以涉及全排列的题目,核心从来不是“能不能暴力”,而是“怎么聪明地避免暴力”。
如果给你 1 到 9 九个数字,全排列总量是 362880。“17086 字典序的全排列”这个说法,本质是在问:在 1 到 9 这 362880 个按字典序排列好的排列中,第 17086 个是哪一个。你也可以理解成,有一本很厚的字典,里面按字母顺序写满了所有 9 位互不重复的数字串,你翻到第 17086 页,看到的是什么。
1.2 字典序:排列世界的“英语字典”
字典序这个概念,可以类比英文字典。abc 排在 abd 前面,因为前两位相同,到第三位 c < d。对数字串来说规则一样:从左往右逐位比较,碰到第一处不同的数字,小的那个串排在前面。比如 123456789 < 123456798,因为前 7 位相同,第 8 位 8 < 9。
全排列问题的排序规则如果不指定,生成顺序千奇百怪。比如交换法生成的排列顺序就不一定是字典序。而 LeetCode、力扣上大量排列类题目默认使用字典序,因为它有两个天然好处:一是结果唯一,所有排列有一个确定的总顺序;二是不依赖生成算法,不管你怎么生成的排列,最后只要按字典序排,结果一致。所以研究全排列时,字典序几乎是最常用的“排序基准”。
1.3 一个数字引出三类经典问题
“17086 字典序的全排列”这个题目可以拆成三个层层递进的问题,覆盖了算法面试的高频考点:
- 给定 n 和 k,直接求字典序第 k 个排列,不需要生成全部排列。这是力扣第 60 题的原型。
- 给定一个排列,反过来求它在字典序中的排名。这就是康托展开。
- 生成全部排列,然后按字典序排序或直接按字典序逐个产出。这个用回溯法或库函数解决。
很多人刷题时只背了第 60 题的模板,压根不明白里面的除法、取余、阶乘到底在干嘛。一旦题目从“求第 k 个”变成“求排名”,就懵了。我希望通过 17086 这个具体数字,把这三个问题串成一条线,让你既看得懂代码,也算得出过程。
2. 全排列生成:从回溯暴力到一行库函数
2.1 回溯法:最基础也最容易写错的全排列生成
生成全排列最通用的方法是回溯。思路很朴素:第一个位置放哪个数、第二个位置放哪个数、依次放完所有位置。用一个 visited 数组记录哪些数字已经被用过,每次递归往前一步,回溯时把状态撤销。
def permute(nums): res = [] path = [] used = [False] * len(nums) def dfs(): if len(path) == len(nums): res.append(path[:]) return for i in range(len(nums)): if used[i]: continue used[i] = True path.append(nums[i]) dfs() path.pop() used[i] = False dfs() return res这段代码有三个细节值得注意。
第一,path[:] 必须写切片拷贝。如果直接 append(path),后续 path.pop() 会把已经存进结果的对象也改掉,最后 res 里全是同一个列表。这个坑我见过无数人踩。
第二,used 数组标记的是“位置”而不是“数值”。当输入包含重复数字时,同一数值可能出现两次,必须配合排序加剪枝来去重,否则生成一堆重复排列。
第三,回溯顺序和字典序的关系:如果 nums 本身有序,且循环按索引递增顺序尝试,那么生成的排列确实是字典序。如果 nums 无序,生成结果就不是字典序。很多人忽略这一点,后面我会详细分析。
回溯法的时间复杂度是 O(n * n!),空间复杂度是 O(n) 递归深度加上 O(n * n!) 的结果存储。注意这个结果存储才是最大的开销。n = 9 时还好,n = 10 时存储 3628800 个元组,内存可能直接飙升到数百 MB。
2.2 Python 的 itertools.permutations:能用但别滥用
如果你只是快速验证思路,Python 的 itertools.permutations 一行就能解决问题:
from itertools import permutations nums = [1, 2, 3, 4, 5, 6, 7, 8, 9] perms = list(permutations(nums)) print(perms[17085]) # (1, 5, 4, 8, 3, 9, 6, 7, 2)注意索引是 17085,因为列表下标从 0 开始,第 17086 个元素的下标是 17085。permutations 在输入有序的前提下,会按字典序逐个产出排列,这是官方实现保证的行为。这也是最直观拿到第 17086 个排列的方式。
但面试或竞赛中直接用库函数要谨慎。一是标准库未必在所有环境都可用,二是面试官问你“下一个排列怎么实现”时,一句 imports permutations 大概率不能过关。更重要的是,permutations 仍然要生成所有排列或者至少迭代到目标位置,时间复杂度 O(n!)。n = 12 时,光迭代到第 4.79 亿个排列,这个耗时就已经无法接受了。
所以我的建议是:日常验证、写脚本可以用库函数;学原理、刷题、应对性能要求时必须理解手写实现。
2.3 交换法和 next_permutation:两个容易混淆的方向
全排列还有一个经典写法:交换法。每次把当前元素和后面的元素交换,然后递归处理剩余部分。
def permute_swap(nums, start=0, res=None): if res is None: res = [] if start == len(nums): res.append(nums[:]) return for i in range(start, len(nums)): nums[start], nums[i] = nums[i], nums[start] permute_swap(nums, start + 1, res) nums[start], nums[i] = nums[i], nums[start] return res交换法空间复杂度低,不需要额外 visited 数组,但它生成的排列顺序存在一个致命问题:默认不是字典序。拿 1、2、3 试一下,输出顺序是 123、132、213、231、321、312,最后两组 321 和 312 明显不满足字典序。如果你把交换法的结果直接当成字典序全排列,做“求第 k 个排列”的题目时就会出错。
真正常用于字典序的工具是 next_permutation 算法。它的使命不是生成顺序任意的排列,而是“从当前排列出发,得到字典序中的下一个排列”。手写版本很经典:
def next_permutation(nums): i = len(nums) - 2 while i >= 0 and nums[i] >= nums[i + 1]: i -= 1 if i < 0: return False j = len(nums) - 1 while nums[j] <= nums[i]: j -= 1 nums[i], nums[j] = nums[j], nums[i] nums[i + 1:] = reversed(nums[i + 1:]) return True这个算法的思想用一句话概括:从右往左找到第一个“上升点”,然后把它替换成右侧比它大的最小元素,最后把后面那段反转成升序。比如 154839672 的下一个排列是什么?用这个算法跑一遍,你能看到它精准地找到下一个字典序排列,而不是像交换法那样乱序。全排列和 next_permutation 是“一对”,前者生成所有排列,后者按字典序一步步推进。如果只需要二进制递增效果,两个都可以;如果需要严格字典序且不想一次性生成全部,next_permutation 是首选。
3. 求第 17086 个排列:逆康托展开实战
3.1 核心思路:像查字典一样定位
现在回到核心问题:求字典序第 17086 个排列。最简单但最笨的思路是生成全部 362880 个排列再取第 17086 个。n = 9 时勉强能忍,n = 10 时就会卡顿,n = 12 时彻底歇菜。
高效做法叫逆康托展开,它的核心思想是“分块定位”。打个比方,一本厚字典里单词按字母顺序排列,你想找第 17086 个词,不需要从第 1 个翻到第 17086 个,而是可以按字母桶去跳:第一页到第 40320 页全是字母 a 开头,17086 没超过 40320,所以目标一定在 a 桶里;拿起 a 桶再看第二位字母,每个第二位字母对应 5040 页……这样不断缩小范围,最终直接锁定目标单词。
具体到数字排列:1 到 9 的全排列,第一位固定为 1 的排列有多少个?固定第一位后,剩下 8 位可以任意排列,数量就是 8! = 40320。所以排列按字典序分组后,每 40320 个一组,共 9 组。第 17086 个排列肯定落在第一组,也就是以 1 开头。因为 17086 ≤ 40320,所以第一位直接确定为 1。
然后处理第二位。位置已经消耗掉一位,剩余 8 个数字,其中每一项再按第二位分组,每组数量是 7! = 5040。用当前剩余排名对 5040 做除法,得到组号,就能确定第二位。这个过程一直重复到所有位置填满。你发现没,这个操作本质上就是一个“不断除阶乘、取商、取余”的过程,和进制转换有异曲同工之妙。
3.2 逆康托展开原理:为什么除法能定位
逆康托展开的数学基础很朴素。设当前剩余可选数字集合大小为 m,当前要决定排列第 i 个位置的数字。剩余 m - 1 个数字的全排列数量是 (m-1)!,也就是说,当前位置每跳过一个可选数字,就跳过了 (m-1)! 个排列。于是把“当前排名”除以 (m-1)!,商就是应该选择剩余数字中从小到大第几个,余数则用于决定后续位置。
写成公式:对于 n 个互异元素的全排列,在从 0 开始计数的排名 r 下,第 i 位应选取剩余集合中下标为r // (n-1-i)!的元素,同时更新r = r % (n-1-i)!。不断重复,直到所有位填满。
注意这里有个极其容易混淆的细节:排名从 0 开始还是从 1 开始。如果题目说“第 k 个排列”,通常 k 从 1 开始,那么编程时第一步要做r = k - 1。力扣第 60 题就是这样。我见过太多人在这里出错:有的忘了减 1,有的减了又加回去,最后答案差了一位还不自知。下面整个推演过程我都用从 1 开始的“第 17086 个”,转成 0 开始的排名就是 17085。
3.3 手把手推算:从 17085 到 154839672
下面我们把整个计算过程完整走一遍。为方便阅读,我整理了一张分步表,每一步的“当前 k”都是 0 开始计数的排名,阶乘 f 是当前位置的权值。
| 步骤 | 剩余可选数字 | 阶乘 f | 当前 k | 商 idx | 选中数字 | 更新 k |
|---|---|---|---|---|---|---|
| 1 | [1,2,3,4,5,6,7,8,9] | 8! = 40320 | 17085 | 0 | 1 | 17085 |
| 2 | [2,3,4,5,6,7,8,9] | 7! = 5040 | 17085 | 3 | 5 | 1965 |
| 3 | [2,3,4,6,7,8,9] | 6! = 720 | 1965 | 2 | 4 | 525 |
| 4 | [2,3,6,7,8,9] | 5! = 120 | 525 | 4 | 8 | 45 |
| 5 | [2,3,6,7,9] | 4! = 24 | 45 | 1 | 3 | 21 |
| 6 | [2,6,7,9] | 3! = 6 | 21 | 3 | 9 | 3 |
| 7 | [2,6,7] | 2! = 2 | 3 | 1 | 6 | 1 |
| 8 | [2,7] | 1! = 1 | 1 | 1 | 7 | 0 |
| 9 | [2] | 0! = 1 | 0 | 0 | 2 | 0 |
我来把关键步骤的口算拆开过程讲一遍,确保你能完全对上。
第一步,17085 除以 40320,商 0 余 17085,所以选剩余数字中下标 0 的数字 1。这一步说明了目标排列在以 1 开头的 40320 个排列里。
第二步,剩余数字 [2,3,4,5,6,7,8,9],k 仍是 17085。17085 除以 5040,商 3 余 1965。商 3 的意思是,以 12 开头的有 5040 个,以 13 开头的有 5040 个,以 14 开头的有 5040 个,这三个块共 15120 个排列全部小于目标排列。所以目标排列跳到下标 3,也就是数字 5。当前剩余 k 是 17085 - 15120 = 1965。
第三步,剩余 [2,3,4,6,7,8,9]。1965 除以 720,商 2 余 525。前面以 152 开头和 153 开头的两块共 1440 个小于目标,所以选择下标 2 的数字 4。当前 k = 525。到这里,前三位是 154。
第四步,剩余 [2,3,6,7,8,9]。525 除以 120,商 4 余 45。跳过四个块,对应下标 4 的数字 8。前四位 1548。后面每一轮都如法炮制,最终依次得到 3、9、6、7、2,完整结果 154839672。
你可能会问:为什么第二步商 3 对应数字 5,而第三步商 2 对应数字 4,下标和数字之间怎么对不上?因为这里的下标是“剩余数字列表”的下标,不是全局数字本身。剩余列表 [2,3,4,6,7,8,9] 中下标 2 才是数字 4。每次选完一个数字,它就从列表里删除,列表越来越短,所以下标含义也随之变化。这一点只要自己做一次计算就会印象非常深刻。
3.4 代码实现:逆康托展开的标准写法
这里给出一个干净、可直接用的逆康托展开实现:
def kth_permutation(nums, k): # k 从 1 开始计数,返回第 k 个字典序排列 nums = sorted(nums) n = len(nums) fact = [1] * n for i in range(1, n): fact[i] = fact[i - 1] * i if k < 1 or k > fact[-1] * n: raise ValueError("k 超出全排列总数范围") r = k - 1 res = [] for i in range(n): f = fact[n - 1 - i] idx = r // f res.append(nums.pop(idx)) r %= f return res print(kth_permutation([1, 2, 3, 4, 5, 6, 7, 8, 9], 17086)) # [1, 5, 4, 8, 3, 9, 6, 7, 2]这段代码里有几个细节值得关注。
nums = sorted(nums)是必须的,因为字典序默认是在元素本身有序的前提下谈论的。如果原数组是 [9, 3, 1, 7, 5, 2, 8, 4, 6],你不排序直接算,结果完全错误。
fact 数组预计算到 n-1 的阶乘,避免循环里反复算阶乘浪费时间。严格来说我们只需要到 (n-1)!,但为了方便统一数组长度,我开成 n 个元素,最后一位是 (n-1)!。还有一种写法是只算到 n-1,然后下标统一用fact[n - 1 - i]。
参数检查容易被忽略。k 合法范围必须是 1 到 n!。比如 n = 3 时 k = 7 就是非法输入,因为只有 6 个排列。如果 k 从 0 开始,那合法范围是 0 到 n! - 1。两种习惯都对,但一个程序里必须统一,否则第二步的除法会得到越界下标。
选数字用的是nums.pop(idx),每个元素只会被选一次。pop 操作是 O(m),整个算法时间复杂度 O(n^2)。n 一般不到 20,所以完全够用。如果 n 很大,可以用树状数组或平衡树把“选剩余第 idx 小元素”优化到 O(n log n),但实际场景里很少遇到,先掌握 O(n^2) 版本就够了。
4. 给定排列求排名:康托展开与验证
4.1 原理:把分块思想倒过来
逆康托展开解决的是“排名到排列”,反过来就有康托展开:给定一个排列,求它在字典序中的排名。这是 17086 这道题验证答案、也验证你理解的关键一步。
康托展开的思想同样很直观:逐个看排列每一位数字,统计“当前剩余数字中比这一位小的数字个数”,把这个数量乘上右侧剩余位置的阶乘,累加到一个变量里,最后加 1 就是排名。
为什么加 1?因为累加出来的数表示“比当前排列小的排列有多少个”。比如最小的排列 123456789,每一位都比它小的数字个数都是 0,累加和是 0,排名是 0 + 1 = 1。最大排列 987654321,累加和是 362879,加 1 得到 362880,恰好是最后一个。
再次强调,康托展开中“比当前位小且还没使用”这一条件很重要。不能简单统计“原始数字中比它小的总数”,否则重复计算了前面已经选走的数字。必须动态维护一个剩余数字集合。
4.2 验证 154839672 的排名等于 17086
下面用完整表格验证上节的答案。排列是 1, 5, 4, 8, 3, 9, 6, 7, 2,剩余集合从 [1..9] 开始,每步移除当前数字。
| 位置 | 当前数字 | 当前剩余集合 | 剩余中比当前数字小的数量 | 阶乘权值 | 贡献 |
|---|---|---|---|---|---|
| 1 | 1 | [1,2,3,4,5,6,7,8,9] | 0 | 8! = 40320 | 0 |
| 2 | 5 | [2,3,4,5,6,7,8,9] | 3 (2,3,4) | 7! = 5040 | 15120 |
| 3 | 4 | [2,3,4,6,7,8,9] | 2 (2,3) | 6! = 720 | 1440 |
| 4 | 8 | [2,3,6,7,8,9] | 4 (2,3,6,7) | 5! = 120 | 480 |
| 5 | 3 | [2,3,6,7,9] | 1 (2) | 4! = 24 | 24 |
| 6 | 9 | [2,6,7,9] | 3 (2,6,7) | 3! = 6 | 18 |
| 7 | 6 | [2,6,7] | 1 (2) | 2! = 2 | 2 |
| 8 | 7 | [2,7] | 1 (2) | 1! = 1 | 1 |
| 9 | 2 | [2] | 0 | 0! = 1 | 0 |
把贡献列全部加起来:
0 + 15120 + 1440 + 480 + 24 + 18 + 2 + 1 + 0 = 17085
排名 = 17085 + 1 = 17086。
看到没有?逆康托展开得到 154839672,再对 154839672 做康托展开又回到 17086。这两个操作互为逆运算,一个从排名到排列,一个从排列到排名。当你自己手工推完这两个过程后,再去看网上任何一份康托展开模板,都会觉得非常亲切。
4.3 编码实现与数值细节
康托展开的实现比逆展开更简单。重点仍是维护“剩余数字集合中比当前数字小的数量”,可以直接用列表加循环统计:
def perm_rank(nums, perm): nums = sorted(nums) n = len(nums) fact = [1] * n for i in range(1, n): fact[i] = fact[i - 1] * i rank = 0 for i in range(n): cnt = 0 for x in nums: if x < perm[i]: cnt += 1 rank += cnt * fact[n - 1 - i] nums.remove(perm[i]) return rank + 1 print(perm_rank([1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 4, 8, 3, 9, 6, 7, 2])) # 17086这个版本下,统计 cnt 的小循环每次遍历剩余数字列表,remove 也是 O(n),整体 O(n^2)。如果 n 较大,可以维护一个树状数组,每个数字用 1 标记“还在剩余集合中”,查询“小于当前数字的剩余数量”就是一次前缀和,复杂度降到 O(n log n)。但这是优化题,基础版本能理解并写对就够应付绝大多数面试。
还有两个细节必须提醒。一是阶乘表依然建议预计算,不要每次位置都重新算阶乘。二是当 n 较大、接近 20 时,排名数值会超过 64 位整数的范围。20! = 2432902008176640000,约 2.4e18,还在 64 位有符号整数范围内;但 21! 已经超了。如果题目不做特殊说明,通常 n 会被限定在 9 或 10 这种小范围,用 Python 的整数无所谓,用 C++ 时就要小心 long long 溢出,必要时用大数库或模运算处理。
5. 实操中的常见问题与避坑指南
5.1 下标混乱:第 1 个还是第 0 个
这是全排列相关题目里最经典、最容易错的点。康托展开返回的排名,多数情况下约定从 1 开始计数,因为业务和题目描述通常说“第 k 个”。但逆康托展开内部又必须用从 0 开始的计数来处理除法定位。很多初学者在这两者间来回切换时晕头转向。
我的经验是死记一条铁律:外部接口用“第 k 个”,内部计算用“0 开始排名 r”,所有逻辑从r = k - 1开始。写代码时不要在多个地方来回加 1 减 1,只在入口做一次转换。如果你发现自己的代码里有好几处+ 1或者- 1,大概率是算法没想清楚,在打补丁。停下来重新理一遍,通常能删掉一半的别扭代码。
5.2 有重复元素时怎么办
前面讲的全是 n 个互不重复元素。如果输入是 [1, 1, 2, 3],事情就变得复杂了。此时全排列总数不再是 4! = 24,而是 4! / 2! = 12,因为两个 1 互相交换后排列不变。
针对重复元素,纯数字的康托展开公式需要改成多重集排列的方式。以排列 [1, 2, 1, 3] 为例,处理第一位 1,要统计比 1 小的数字中还没有被使用的元素个数。由于存在重复值,比当前值小的所有候选必须以多重集排列方式计算贡献:假如候选数字 d 出现过 c_d 次并可全部使用,把 d 放到当前位置后,剩余位置的全排列数是“剩余数字总量 - 1”的阶乘,除以各数字剩余次数的阶乘。
实际操作中,更稳妥的做法是先把重复元素归并成“数值 + 剩余次数”的列表,然后在每个位置枚举“可以放在当前位的小数值”,用多重集排列公式计算跳过的排列数。这个方法比直接对原始数组做康托展开安全得多,因为原始数组里两个相同值会各自带一个位置标记,导致同一个排列被当作用不同怪位置排列算出多个不同排名。
简单说:遇到重复元素,不要照抄普通康托展开模板。先想清楚是用去重后的多重集公式,还是干脆换一种方式处理排名。我见过大量算法题解在这块含糊其辞,实际写代码时很容易踩坑。
5.3 生成顺序不对:交换法不等于字典序
前面提过交换法生成的全排列顺序不是字典序。如果你手头有现成的交换法全排列代码,想看第 17086 个排列,拿到第 17086 个生成的排列直接当答案,必错。正确做法要么改用 next_permutation 反复调用 17085 次,要么先把全部排列收集起来按字典序排序,要么直接上逆康托展开。
这里补充一个既简单又不容易错的折中方案:用回溯法生成,但保证循环按“数值从小到大”的顺序尝试未使用数字。这本质上就是按字典序生成。它比交换法多一个 visited 数组,却省去了排序的麻烦。
另外,C++ 的 std::next_permutation 会原地修改数组并在无法生成下一个时返回 false。刷题时很多同学用它做全排列枚举,写法是:
sort(nums.begin(), nums.end()); do { // 处理当前排列 } while (next_permutation(nums.begin(), nums.end()));这里必须先 sort,否则枚举的不是完整字典序全排列。很多同学漏了 sort,导致输出从某个中间状态开始,数量也少了,查错查得非常痛苦。
5.4 性能对比:暴力生成和数学方法差多少
我实际测试过一个直观场景:n = 9,生成全部 362880 个排列并取出第 17086 个。用 Python 回溯法大约需要数十毫秒到上百毫秒,取决于机器,内存需求同样明显。用 itertools.permutations 推流到第 17086 个,耗时更短,但也需要线性扫过 17086 个排列。用逆康托展开,几乎瞬间完成,因为总共只有 9 轮循环和列表操作。
当 n = 12 时差距就更夸张了。暴力生成全部排列需要 4.79 亿个,每个排列至少要存 12 个数字,内存需要几个 GB,几乎不可行。逆康托展开依然是 12 轮循环,耗时可以忽略不计。
我整理了一个粗略对比表,帮你建立直观感受:
| n | 全排列总数 | 暴力生成全部排列 | 直接遍历到第 k 个 | 逆康托展开 |
|---|---|---|---|---|
| 6 | 720 | 可忽略 | 快 | 快 |
| 9 | 362880 | 约 0.1 秒级 | 快 | 微秒级 |
| 10 | 3628800 | 秒级且内存压力大 | 可以但没必要 | 微秒级 |
| 12 | 479001600 | 不可行 | 不可行 | 微秒级 |
所以结论很明确:如果题目明确要求“第 k 个排列”或“某个排列的排名”,第一反应应该是康托展开家族,而不是全排列生成。生成全排列这个操作,只适用于 n 很小、需要穷举所有方案的场景。
6. 这类问题在实际场景中的应用
6.1 算法竞赛中的高频考法
康托展开和逆康托展开在算法竞赛里算是经典入门偏进阶的组合题。举几个常见的出题方向:
一是构建“排列 ↔ 排名”的互相映射,用于状态压缩或搜索判重。比如八数码问题、数独求解中,有时需要把一个排列状态映射成一个整数,康托展开就提供了完美的空间压缩方式。一个 9 位排列用 362880 以内的整数表示,比直接存数组省太多内存。
二是配合树状数组做动态排名查询。前面提到的“剩余集合 + 前缀和”优化,本质是动态维护一个 01 数组并频繁查前缀和,这几乎是树状数组最经典的入门应用。
三是作为下一排列问题的延伸。给定两个排列,求它们之间有多少个排列,或者在字典序中相差多少位,这类题可以直接用康托展开把两个排列都转成排名,再做减法。理解了 17086 这个例子后,这种题目基本套公式即可。
力扣上和这个知识点直接相关的题目包括:第 46 题全排列、第 47 题全排列 II、第 31 题下一个排列、第 60 题第 k 个排列。刷完这几道,再把康托展开和逆康托展开的模板练熟,该方向就没什么死角了。
6.2 实际业务中的排列组合思路
别觉得这只是竞赛题。业务代码里也有不少地方能借鉴这个思路。
比如生成不重复的短码。假设你要为一批订单生成 6 位不重复的随机邀请码,可以从所有可用字符的全排列中随机选一个排名,再利用逆康托展开得到一个唯一排列。这种方式比每次随机生成再查重更可控,因为排名和编码是一一对应的。当然,字符集扩大后全排列数量暴涨,实际场景会更倾向于直接用自增 ID 映射混淆编码,但思路是相通的。
再比如权限角色的排列组合测试。给系统配置一组权限策略,需要按字典序枚举所有可能性去跑自动化测试,用 next_permutation 或者康托展开都可以做到“可续传”:记录当前处理到哪个排名,下次继续从该排名恢复,而不用从头生成。这个特性在长任务分布式处理中很实用。
还有抽签、排序算法的随机化。Knuth shuffle 是洗牌的标准做法,但从字典序角度,也可以先随机一个排名,再用逆康托展开得到随机排列。由于全排列总数可能极大,直接用排名到排列的映射在某些硬件受限场景反而有优势,因为它避免了反复 swap 操作。
6.3 变种与延伸:从排列到组合
一旦吃透了康托展开的“分块定位”思想,你会发现它还能推广到组合问题。组合同样有字典序,比如从 5 个数里选 3 个,按字典序排列组合,也存在着“给定组合求排名”和“给定排名求组合”的互逆操作。原理相似:当前位置枚举一个数后,跳过的是 C(剩余数字个数, 剩余位置数) 个组合,而不是阶乘。
这种推广思路很值得自己推一遍。当你遇到“字典序第 k 个子集”“字典序第 k 个组合”之类的题目,就不再需要把全部组合生成出来再排序了。可以说,康托展开是“字典序世界”里一把万能钥匙,排列通、组合通、子集也能通。
7. 个人体会与一点建议
我第一次认认真真手算 17086 这个排列的时候,算到一半其实有点怀疑人生:又是 5040 又是 720,这不是折腾人吗?但当 154839672 这个结果完整出来,再用康托展开反查回去,精确地得到 17086 的那一刻,确实有“原来如此”的通透感。那种感觉不是背模板能带来的。
后来我把这个思路用在树状数组优化、多重集康托展开、组合字典序上,发现所有代码都像是从同一个“分块定位”思想里长出来的。所以我会建议你把这篇推演过程自己完整写一遍:不要复制代码,拿张纸,把 1 到 9、17086 这两个数字放进表格里,一步步算。算错了也没关系,恰恰是哪里算错,哪里就是你理解还没到位的地方。
题目本身只是一个数字,但“17086 字典序的全排列”背后的分块、阶乘、互逆映射,才是真正值得反复咀嚼的东西。刷题有时候就是这样,一道题吃透了,一类题都会了。希望你也能从这次推演里拿到属于自己的那个“原来如此”。