康托展开&逆康托展开详解
- 康托展开
- 康托展开公式
- 康托展开代码
- 逆康托展开
- 逆康托展开具体过程
- 尼康托展开代码
- 逆康托的应用
- 使用场景
康托展开
康托展开是一个全排列到一个自然数的双射,常用于构建哈希表时的空间压缩。 康托展开的实质是计算当前排列在所有由小到大全排列中的顺序,因此是可逆的。
也就是对于n个自然数:1,2,3,… ,n。一共有n!种不同的排列,将这n!种排列按照升序排列组成一个列表list,对于其中的一个排列current,康托展开表示的就是当前这个排列current在列表list中的顺序是第几个(从0开始),也可以描述为这n!排列中比当前排列current小的个数。
比如对于1,2,3这三个数,一共有3!=6种排列:(123,132,213,231,312,321)
- 对于排列123,排在第0位,比123小的排列有0个
- 对于排列132,排在第1位,比132小的排列有1个:123
- 对于排列213,排在第2位,比213小的排列有2个:123,132
- …
康托展开公式
X = a n ( n − 1 ) ! + a n − 1 ( n − 2 ) ! + . . . + a 1 0 ! X=a_{n}(n-1)!+a_{n-1}(n-2)!+...+a_{1}0! X=an(n−1)!+an−1(n−2)!+...+a10!
- 其中ai为整数,并且0<=ai<i,1<=i<=n
- ai表示当前未出现的数字中比第i位小的数字的个数,也就是在原排列中,排在下标i后的,比下标i位置处的数还小的数的个数(最右边的下标为0)。
上面ai的定义比较绕,举个例子就明白了,假设有4个数1,2,3,4要求构造一个排列,并求构造出的排列是所有升序全排列中的第几个或者说比构造出来的排列小的排列有几个?
从最高位到最低位开始构造排列:3241
- 3XXX:对于数字(1,2,3,4),3已经出现了,未出现的数字为(1,2,4)。未出现的数字中比3小的数有两个(2,3),所以a4 = 2,不管最高位是几,后面三位数一共有(4-1)! = 3! = 6种排列,而最高位为1和2时肯定比3241小,一共有a4(4-1)! = 2*(4-1)! = 2*3! 种排列比3241小
- 32XX:对于数字(1,2,3,4),2和3已经出现了,未出现的数字为(1,4)。未出现的数字中比2小的数字有一个(1),所以a3=1。这个时候相当于固定最高位为3 (最高位为1和2时的排列都小于排列3241,在第一步中已经计算过了,最高为3时的排列也可能出现小于3241的情况,在这一步开始的后面所有步骤都是统计这种情况),当百位数字为1时的排列肯定小于3241,后面两位一共有2! 种排列,所以最高位固定为3,百位为2时,这种情况一共有a3(3-1)! = 1* (3-1)! = 1*2! 种排列比3241小
- 324X:对于数字(1,2,3,4),已经出现的数字为(2,3,4),为出现的数字为1,未出现的数字中比4小的数字有1个,所以a2=1,当十位数字为4时,后面一位一共有1! 种排列。所以排列的前两位数字为32,十位为4时,这种情况一共有a2(2-1)! = 1*(2-1)! = 1*1! 种排列比3241小
- 3241:最后一位为1时,所有数字都已经出现了,所以a0 固定为 0,所以当前三位为324,个位1时,这种情况一共有a1(1-1)! = 0*(1-1)! = 0*0! 种排列比3241小。
根据康托展开公式可得:
X
=
2
∗
3
!
+
1
∗
2
!
+
1
∗
1
!
+
0
∗
0
!
=
15
X= 2*3! + 1*2! + 1*1! + 0*0! = 15
X=2∗3!+1∗2!+1∗1!+0∗0!=15
所以比3241小的排列一共有15个,当前排列在所有由小到大全排列中的顺序为15(从0开始)
这里顺序从0开始计数是因为最小的排列康托展开公式得出的值就是0
比如:最小的排列1234,根据康托展开可得X = 0,在康托展开的介绍中有这么一句:康托展开的实质是计算当前排列在所有由小到大全排列中的顺序,所以1234在所有由小到大全排列种的顺序为0,也就是康托展开的最小编号是从0开始的
康托展开代码
public class Main {
/**
* 康托展开代码实现
*
* @param a 表示当前的一个排列字符串,最高位在最左边,最低位在最右边
* @param n 当前排列字符串的长度
* @return 康托展开值
*/
public static int cantor(String a, int n) {
//保存从0~n-1的所有阶乘结果
int[] factorial = new int[n];
factorial[0] = 1;
//求阶乘
for (int i = 1; i < n; i++) {
factorial[i] = i * factorial[i - 1];
}
int result = 0;//保存康托展开的值
for (int i = 0; i < n; i++) {
int smaller = 0;//在当前位之后小于当前位的数字个数
for (int j = i + 1; j < n; j++) {
if (a.charAt(j) < a.charAt(i)) {
smaller++;
}
}
result += factorial[n - i - 1] * smaller;//累加康托展开的每一项
}
return result; //最终的康托展开值
}
public static void main(String[] args) {
String s = "3241";
System.out.println(cantor(s, s.length()));
}
}
逆康托展开
引言:根据康托展开的描述:康托展开是一个全排列到一个自然数的双射,双射的意思也就是从康托展开可以得出从一个全排列到一个自然数的一个映射,也可以得出一个自然数到全排列的映射。康托展开是可逆的。
简单的说就是:
- 康托展开:给出一个全排列,通过康托展开可以得到当前全排列在所有由小到大全排列中的顺序
- 逆康托展开:给出一个数字k。通过逆康托展开值可以得出在所有由小到大全排列中排列在第k位的那个排列(注意:从第0位开始)
逆康托展开具体过程
对于(1,2,3,4)的所有全排列,求解在所有由小到大全排列中排列在第15位的排列是多少,由康托展开很容易逆推出这个排列 XXXX,从最高位开始依次求解每一位的数。具体流程如下:
- 求解第4位:15 / (4-1)! = 15 / 6 = 2 余 3,说明比第四位小的数字有2个,则最高位数字从
(1,2,3,4)里选第3个数(注意:选择前需要按照从小到大的顺序),所以最高位为3 - 求解第3位:3/(3-1)! = 3 / 2 = 1 余 1,说明比第三位小的数字有1个,则百位数字从(1,2,4)里选第2个数(注意,因为3已经被最高位选过了,所以这里需要将3从待选择的集合里去除),所以百位数字为2
- 求解第2位:1/(2-1)! = 1 / 1 = 1 余 0 ,说明比第二位小的数字有1个,则十位数字从(1,4)里选第2个数字,所以十位数字为4
- 求解第1位:也就是最后一位:0 / (1-1)! = 0,说明比第一位小的数字有0个,则个位数字从(1)里选第1个数字,所以个数位数为1
所以对于(1,2,3,4)的所有全排列,在所有由小到大全排列中排列在第15位的排列是3241
通过上述流程可以发现尼康托的求解排列流程就是下面两步:
- 求解第i位的数字:X/(i-1)! = a 余 b,从(a1,a2,a3,…,an)里选择第a+1个数,a1<a2<a3<…<an
- 求解第i-1位的数字:b/(i-2)! = c 余d,假定上一步选择的第a+1个数为a2,则这一步从(a1,a3,… ,an)中选择第c+1个数。
每次求解第i位的数字时使用上一步的余数(初始时为给定的数字k) 除以 (i-1)! 所得的商加1,就是需要从待选择的集合中选取的第几个数。每次选择一个数时,将这个数从待选择的集合中删除。
尼康托展开代码
public class Main {
/**
* 尼康托展开求解排列
* @param n 待排列集合的长度
* @param k 第k个排列(注意,从0开始)
* @return 所有由小到大全排列中排列在第k位的那个排列
*/
public static String reverseCantor(int n, int k) {
//保存从0~n-1的所有阶乘结果
int[] factorial = new int[n];
factorial[0] = 1;
//求阶乘
for (int i = 1; i < n; i++) {
factorial[i] = i * factorial[i - 1];
}
//标记哪些数字是可以待选择的,false表示可以选择
boolean[] used = new boolean[n + 1];
//保存结果
StringBuilder result = new StringBuilder();
//从最高位开始依次求解排列的每一个位
for (int i = 1; i <= n; i++) {
int num = k / factorial[n - i];//商
k = k % factorial[n - i];//余数
//从待选择的集合中选择第num+1个数
for (int j = 1; j <= n; j++) {
if (!used[j]) {
if (num == 0) {
result.append(j);
used[j] = true;//标记已选择
break;
}
num--;
}
}
}
return result.toString();
}
public static void main(String[] args) {
System.out.println(reverseCantor(4,15));
}
}
逆康托的应用
力扣第60题:排列序列
注意:这里的排列编号是从1开始的,而康托展开中编号是从0开始的,所以,只需要在求解前,对k进行减1操作就可以了。
AC代码:
class Solution {
public static String getPermutation(int n, int k) {
k--;//康托展开中编号从0开始,这里先减1
//保存从0~n-1的所有阶乘结果
int[] factorial = new int[n];
factorial[0] = 1;
//求阶乘
for (int i = 1; i < n; i++) {
factorial[i] = i * factorial[i - 1];
}
//标记哪些数字是可以待选择的,false表示可以选择
boolean[] used = new boolean[n + 1];
//保存结果
StringBuilder result = new StringBuilder();
//从最高位开始依次求解排列的每一位
for (int i = 1; i <= n; i++) {
int num = k / factorial[n - i];//商
k = k % factorial[n - i];//余数
//从待选择的集合中选择第num+1个数
for (int j = 1; j <= n; j++) {
if (!used[j]) {
if (num == 0) {
result.append(j);
used[j] = true;
break;
}
num--;
}
}
}
return result.toString();
}
}
使用场景
根据开头对康托展开的描述:康托展开是一个全排列到一个自然数的双射,常用于构建哈希表时的空间压缩。
比较常见的使用场景就是:
- 给定一个排列,求解这个排列在全排列中从小到大的顺序
- 给定一个在全排列中的顺序,求解这个排列
- 构建哈希表时的空间压缩:在保存一个比较长的序时候,可以将这个序列映射为一个自然数,这个时候只需要保存序列对应的自然数就可以了,大大减少了空间的使用。当需要使用序列的时候,在根据逆康托求解出这个序列即可