LeetCode 60. 排列序列【数学,逆康托展开】困难
本文属于「征服LeetCode」系列文章之一,这一系列正式开始于2021/08/12。由于LeetCode上部分题目有锁,本系列将至少持续到刷完所有无锁题之日为止;由于LeetCode还在不断地创建新题,本系列的终止日期可能是永远。在这一系列刷题文章中,我不仅会讲解多种解题思路及其优化,还会用多种编程语言实现题解,涉及到通用解法时更将归纳总结出相应的算法模板。
为了方便在PC上运行调试、分享代码文件,我还建立了相关的仓库:https://github.com/memcpy0/LeetCode-Conquest。在这一仓库中,你不仅可以看到LeetCode原题链接、题解代码、题解文章链接、同类题目归纳、通用解法总结等,还可以看到原题出现频率和相关企业等重要信息。如果有其他优选题解,还可以一同分享给他人。
由于本系列文章的内容随时可能发生更新变动,欢迎关注和收藏征服LeetCode系列文章目录一文以作备忘。
给出集合 [1,2,3,...,n]
,其所有元素共有 n!
种排列。
按大小顺序列出所有排列情况,并一一标记,当 n = 3
时, 所有排列如下:
"123"
"132"
"213"
"231"
"312"
"321"
给定 n
和 k
,返回第 k
个排列。
示例 1:
输入:n = 3, k = 3
输出:"213"
示例 2:
输入:n = 4, k = 9
输出:"2314"
示例 3:
输入:n = 3, k = 1
输出:"123"
提示:
1 <= n <= 9
1 <= k <= n!
解法 逆康托展开
要想解决本题,首先需要了解一个简单的结论:对于 n n n 个不同的元素(例如数 1 , 2 , ⋯ , n 1, 2, \cdots, n 1,2,⋯,n ),它们可以组成的排列总数目为 n ! n! n! 。
对于给定的 n n n 和 k k k ,不妨从左往右确定第 k k k 个排列中的每一个位置上的元素到底是什么。
我们首先确定排列中的首个元素 a 1 a_1 a1 。根据上述的结论,我们可以知道:
- 以 1 1 1 为 a 1 a_1 a1 的排列一共有 ( n − 1 ) ! (n-1)! (n−1)! 个;
- 以 2 2 2 为 a 1 a_1 a1 的排列一共有 ( n − 1 ) ! (n-1)! (n−1)! 个;
- ⋯ \cdots ⋯
- 以 n n n 为 a 1 a_1 a1 的排列一共有 ( n − 1 ) ! (n-1)! (n−1)! 个。
由于我们需要求出从小到大的第 k k k 个排列,因此:
- 如果 k ≤ ( n − 1 ) ! k \leq (n-1)! k≤(n−1)! ,我们就可以确定排列的首个元素为 1 1 1 ;
- 如果 ( n − 1 ) ! < k ≤ 2 ⋅ ( n − 1 ) ! (n-1)! < k \leq 2 \cdot (n-1)! (n−1)!<k≤2⋅(n−1)! ,我们就可以确定排列的首个元素为 2 2 2 ;
- ⋯ \cdots ⋯
- 如果 ( n − 1 ) ⋅ ( n − 1 ) ! < k ≤ n ⋅ ( n − 1 ) ! (n-1) \cdot (n-1)! < k \leq n \cdot (n-1)! (n−1)⋅(n−1)!<k≤n⋅(n−1)! ,我们就可以确定排列的首个元素为 n n n 。
因此,第
k
k
k 个排列的首个元素就是:
a
1
=
⌊
k
−
1
(
n
−
1
)
!
⌋
+
1
a_1 = \lfloor \frac{k-1}{(n-1)!} \rfloor + 1
a1=⌊(n−1)!k−1⌋+1
其中
⌊
x
⌋
\lfloor x \rfloor
⌊x⌋ 表示将
x
x
x 向下取整。
当我们确定了 a 1 a_1 a1 后,如何使用相似的思路,确定下一个元素 a 2 a_2 a2 呢?实际上,我们考虑以 a 1 a_1 a1 为首个元素的所有排列:
- 以 [ 1 , n ] \ a 1 [1,n] \backslash a_1 [1,n]\a1 最小的元素为 a 2 a_2 a2 的排列一共有 ( n − 2 ) ! (n−2)! (n−2)! 个;
- 以 [ 1 , n ] \ a 1 [1,n] \backslash a_1 [1,n]\a1 次小的元素为 a 2 a_2 a2 的排列一共有 ( n − 2 ) ! (n-2)! (n−2)! 个;
- ⋯ \cdots ⋯
- 以 [ 1 , n ] \ a 1 [1,n] \backslash a_1 [1,n]\a1 最大的元素为 a 2 a_2 a2 的排列一共有 ( n − 2 ) ! (n−2)! (n−2)! 个;
其中
[
1
,
n
]
\
a
1
[1,n] \backslash a_1
[1,n]\a1 表示包含
1
,
2
,
⋯
n
1, 2, \cdots n
1,2,⋯n 中除去
a
1
a_1
a1 以外元素的集合。这些排列从编号
(
a
1
−
1
)
⋅
(
n
−
1
)
!
(a_1-1) \cdot (n-1)!
(a1−1)⋅(n−1)! 开始,到
a
1
⋅
(
n
−
1
)
!
a_1 \cdot (n-1)!
a1⋅(n−1)! 结束,总计
(
n
−
1
)
!
(n-1)!
(n−1)! 个,因此第
k
k
k 个排列实际上就对应着这其中的第
k
′
=
(
k
−
1
)
m
o
d
(
n
−
1
)
!
+
1
k' = (k-1) \bmod (n-1)! + 1
k′=(k−1)mod(n−1)!+1
个排列。这样一来,我们就把原问题转化成了一个完全相同但规模减少
1
1
1 的子问题:求
[
1
,
n
]
\
a
1
[1, n] \backslash a_1
[1,n]\a1 这
n
−
1
n-1
n−1 个元素组成的排列中,第
k
′
k'
k′ 小的排列。
算法:
- 设第 k k k 个排列为 a 1 , a 2 , ⋯ , a n a_1, a_2, \cdots,a_n a1,a2,⋯,an ,我们从左往右地确定每一个元素 a i a_i ai 。我们用数组 valid \textit{valid} valid 记录每一个元素是否被使用过。
- 我们从小到大枚举
i
i
i :
- 我们已经使用过了 i − 1 i−1 i−1 个元素,剩余 n − i + 1 n-i+1 n−i+1 个元素未使用过,每一个元素作为 a i a_i ai 都对应着 ( n − i ) ! (n-i)! (n−i)! 个排列,总计 ( n − i + 1 ) ! (n-i+1)! (n−i+1)! 个排列;
- 因此在第 k k k 个排列中, a i a_i ai 即为剩余未使用过的元素中第 ⌊ k − 1 ( n − i ) ! ⌋ + 1 \lfloor \frac{k-1}{(n-i)!} \rfloor + 1 ⌊(n−i)!k−1⌋+1 小的元素;
- 在确定了 a i a_i ai 后,这 n − i + 1 n-i+1 n−i+1 个元素的第 k k k 个排列,就等于 a i a_i ai 之后跟着剩余 n − i n-i n−i 个元素的第 ( k − 1 ) m o d ( n − i ) ! + 1 (k-1) \bmod (n-i)! + 1 (k−1)mod(n−i)!+1 个排列。
在实际的代码中,我们可以首先将 k k k 的值减少 1 1 1,这样可以减少运算,降低代码出错的概率。对应上述的后两步,即为:
- 因此在第 k k k 个排列中, a i a_i ai 即为剩余未使用过的元素中第 ⌊ k ( n − i ) ! ⌋ + 1 \lfloor \frac{k}{(n-i)!} \rfloor + 1 ⌊(n−i)!k⌋+1 小的元素;
- 在确定了 a i a_i ai 后,这 n − i + 1 n-i+1 n−i+1 个元素的第 k k k 个排列,就等于 a i a_i ai 之后跟着剩余 n − i n-i n−i 个元素的第 k m o d ( n − i ) ! k \bmod (n-i)! kmod(n−i)! 个排列。
实际上,这相当于我们将所有的排列从 0 0 0 开始进行编号。
class Solution {
public:
string getPermutation(int n, int k) {
--k;
int factory[10] = {1};
for (int i = 1; i < n; ++i) factory[i] = factory[i - 1] * i;
bool vis[10] = {false};
string ans;
for (int i = n - 1; i >= 0; --i) {
int num = k / factory[i];
k = k % factory[i];
for (int j = 1; j <= n; ++j) {
if (!vis[j]) {
if (num-- == 0) {
ans += '0' + j;
vis[j] = true;
break;
}
}
}
}
return ans;
}
};
复杂度分析:
- 时间复杂度: O ( n 2 ) O(n^2) O(n2)
- 空间复杂度: O ( n ) O(n) O(n)