60. 排列序列
题目描述
给出集合 [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 \le n \le 9 1≤n≤9
- 1 ≤ k ≤ n ! 1 \le k \le n! 1≤k≤n!
题解:
法一:
参考 下一个排列 ,直接求出第 1~k
个排列,返回结果。
时间复杂度: O ( n ∗ k ) O(n*k) O(n∗k)
额外空间复杂度: O ( n ) O(n) O(n)
法一代码:

class Solution {
public:
string getPermutation(int n, int k) {
string ret = "";
for ( int i = 1; i <= n; ++i )
ret.push_back( i + '0' );
int t, j;
while ( --k ) {
t = n - 1;
while ( t > 0 && ret[t - 1] > ret[t] ) --t;
if ( --t < 0 ) break;
j = n - 1;
while ( j > t && ret[j] < ret[t] ) --j;
swap( ret[t], ret[j] );
reverse( ret.begin() + t + 1, ret.end() );
}
return ret;
}
};
/*
时间:140ms,击败:18.61%
内存:5.8MB,击败:96.84%
*/
速度不敢恭维。。。
法二:
模拟。
其实没有必要把第 1~k
的每个排列都求出来,只要能从左往右依次确认第 k
个排列的每一位元素即可。
举个例子,假设 n = 4, k = 8
,列出以 1,2,3,4
开头的排列:
- 1 + [2, 3, 4的排列],(4 - 1)! = 6种
- 2 + [1, 3, 4的排列],(4 - 1)! = 6种
- 3 + [1, 2, 4的排列],(4 - 1)! = 6种
- 4 + [1, 2, 3的排列],(4 - 1)! = 6种
可以确定 k = 8
在第二组中,第一位可以确定是 2
。
在排除掉第一组的 6
个排列后,k
变为 k' = k - 6 = 2
,对剩下的元素继续分组:
- 21 + [3, 4的排列],(3 - 1)! = 2种
- 23 + [1, 4的排列],(3 - 1)! = 2种
- 24 + [1, 3的排列],(3 - 1)! = 2种
由于 k' = 2
在第一组,可以确定第二位为 1
。
由于在第一组,前面排除掉 0
个元素,k'
变为 k'' = k' - 0 = 2
,对剩下的元素继续分组:
- 213 + [4],(2 - 1)! = 1种
- 214 + [3],(2 - 1)! = 1种
可以确定 k'' = 2
在第二组中,所以第三位可以确定为 4
。
剩下的 k''' = k'' - 1 = 1
,只剩下元素 3
了,没必要继续划分。所以,第 k = 8
个排列就是 2 1 4 3
。
总结一下规律,假设当前推测的为第 i
个元素:
所以第 k = 8
个排列就是:[2, 1, 4, 3]
所以可以这样去做:
设第 k
个排列为 a − 1 , a 2 , . . . , a n a-1, a_2, ..., a_n a−1,a2,...,an,从左往右依次确定每个元素 a i a_i ai,同时需要记录哪些元素已经被使用,从小到大枚举 i
:
- 前面已经使用了
i - 1
个元素,还剩下n - i + 1
个元素未使用,每个元素作为当前的 a i a_i ai 都有(n - i)!
个排列 - 在第
k
个排列中, a i a_i ai 为剩余未使用元素中的第 k − 1 ( n − i ) ! + 1 \frac{k-1}{(n - i)!} + 1 (n−i)!k−1+1 小元素 - 确定了 a i a_i ai 后,当前
n - i + 1
个元素的第k
个排列变成剩下的n - i
个元素的第 $ (k - 1) % (n - i)! + 1$ 个排列
时间复杂度: O ( n 2 ) O(n^2) O(n2)
额外空间复杂度: O ( n ) O(n) O(n)
class Solution {
public:
string getPermutation(int n, int k) {
vector<int> fac( n + 1 );
fac[0] = fac[1] = 1;
for ( int i = 2; i <= n; ++i )
fac[i] = fac[i - 1] * i;
vector<bool> vis( n + 1, true );
string ret = "";
for ( int i = 1; i <= n; ++i ) {
int t = (k - 1) / fac[n - i] + 1;
for ( int j = 1; j <= n; ++j ) {
t -= vis[j];
if ( !t ) {
vis[j] = false;
ret.push_back( j + '0' );
break;
}
}
k = (k - 1) % fac[n - i] + 1;
}
return ret;
}
};
/*
时间:0ms,击败:100.00%
内存:5.7MB,击败:99.09%
*/
法三:
逆康托展开。
首先介绍一下 康托展开:
康托展开是一个全排列到一个自然数的双射,常用于构建hash表时的空间压缩。设有 n
个数1,2,3,4,…,n
,可以组成不同 n!
种排列组合,康托展开表示的就是在 n
个不同元素的全排列中, 比当前排列组合小的个数,那么也可以表示当前排列组合在 n
个不同元素的全排列中的名次(当前的名次 = 比当前排列组合小的个数 + 1)。
其原理就是 x = a[n]*(n - 1)! + a[n - 1]*(n - 2)! + ... + a[i]*(i - 1)! + ... + a[1] * 0!
其中, a[i]
表示 i+1~n
中,比 nums[i]
小的元素个数,所以 0 <= a[i] < n
,康托展开公式就是这玩意。
例如有3个数(1,2,3),则其排列组合及其相应的康托展开值如下:
排列组合 | 名次 | 康托展开 | 值 |
---|---|---|---|
123 | 1 | 0 * 2! + 0 * 1! + 0 * 0! | 0 |
132 | 2 | 0 * 2! + 1 * 1! + 0 * 0! | 1 |
213 | 3 | 1 * 2! + 0 * 1! + 0 * 0! | 2 |
231 | 4 | 1 * 2! + 1 * 1! + 0 * 0! | 3 |
312 | 5 | 2 * 2! + 0 * 1! + 0 * 0! | 4 |
321 | 6 | 2 * 2! + 1 * 1! + 0 * 0! | 5 |
比如其中的 231
:
- 想要计算排在它前面的排列组合数目
123,132,213
,则可以转化为计算比首位小即小于2的所有排列「1 * 2!」,首位相等为2并且第二位小于3的所有排列「1 * 1!」,前两位相等为23
并且第三位小于1的所有排列(0 * 0!)的和即可,康托展开为:1 * 2!+ 1 * 1! + 0 * 0! = 3
。 - 所以小于
231
的组合有 3 个,所以231
的名次是4。
再举个例子说明,在1,2,3,4,5
5个数的排列组合中,计算 34152
的康托展开值:
- 首位是3,则小于3的数有两个,为1和2,a[5]=2,则首位小于3的所有排列组合为
a[5]*(5-1)!
- 第二位是4,则小于4的数有两个,为1和2,注意这里3并不能算,因为3已经在第一位,所以其实计算的是在第二位之后小于4的个数。因此a[4]=2
- 第三位是1,则在其之后小于1的数有0个,所以a[3]=0
- 第四位是5,则在其之后小于5的数有1个,为2,所以a[2]=1
- 最后一位就不用计算啦,因为在它之后已经没有数了,所以a[1]固定为0
- 根据公式:
X = 2 * 4! + 2 * 3! + 0 * 2! + 1 * 1! + 0 * 0! = 2 * 24 + 2 * 6 + 1 = 61
所以比 34152
小的组合有61个,即 34152
是排第62。
具体代码如下:
static const int FAC[] = {
1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880}; // 阶乘
int cantor(int *a, int n)
{
int x = 0;
for (int i = 0; i < n; ++i) {
int smaller = 0; // 在当前位之后小于其的个数
for (int j = i + 1; j < n; ++j) {
if (a[j] < a[i])
smaller++;
}
x += FAC[n - i - 1] * smaller; // 康托展开累加
}
return x; // 康托展开值
}
注意:这里实现的算法复杂度为 O ( n 2 ) O(n^2) O(n2),实际当n很大时,内层循环计算在当前位之后小于当前位的个数可以用 线段树来处理计算,这样复杂度降为 O ( n l o g n ) O(nlogn) O(nlogn)。
下面来介绍 逆康拓展开:
因为 康托展开 是全排列到自然数的 双射 ,因此两者是可逆的。还以上述的例子为例,给出 61
,求出排列,逆推回去就行:
- 61 / 4! = 2…13,所以
a[5] = 2
,说明比首位小的元素有两个,所以首位为3
- 13 / 3! = 2…1 ,所以
a[4] = 2
,说明在第二位之后有两个元素小于第二位的元素,所以第二位为4
- 1 / 2! = 0…1,所以
a[3] = 0
,说明在第三位之后没有元素比第三位小,所以第三位为1
- 1 / 1! = 1…0 ,所以
a[4] = 1
,说明在第四位之后有一个元素比它小,所以第四位为5
- 最后剩下的第五位就是
2
故所求的排列为 34152
。
逆康拓展开代码如下:
static const int FAC[] = {
1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880}; // 阶乘
//康托展开逆运算
void decantor(int x, int n)
{
vector<int> rest; // 存放当前可选数,保证有序
for(int i=1;i<=n;i++)
v.push_back(i);
vector<int> ans; // 所求排列组合
for(int i=n;i>=1;i--)
{
int r = x % FAC[i-1];
int t = x / FAC[i-1];
x = r;
a.push_back(v[t]); // 剩余数里第t+1个数为当前位
v.erase(v.begin()+t); // 移除选做当前位的数
}
}
那么 逆康托展开 用到本题上就是:
class Solution {
public:
string getPermutation(int n, int k) {
vector<int> fac( n + 1 );
fac[0] = fac[1] = 1;
for ( int i = 2; i <= n; ++i )
fac[i] = fac[i - 1] * i;
vector<bool> vis( n + 1, true );
int d;
string ret = "";
--k;
for ( int i = n; i >= 1; --i ) {
d = k / fac[i - 1];
for ( int j = 1; j <= n; ++j ) {
d -= vis[j];
if ( d == -1 ) {
ret += j + '0';
vis[j] = false;
break;
}
}
k %= fac[i - 1];
}
return ret;
}
};
/*
时间:0ms,击败:100.00%
内存:5.8MB,击败:97.69%
*/
好像跟 方法二 差不多,都是从前往后依次确定每一位的值。