康托展开是什么

康托展开用于求一个排列在所有 1~n 的排列间的字典序排名,它是一个从排列到自然数的双射,常用于构建哈希表时的空间压缩。通俗点讲,康托展开可以求解一个排列的序号,比如:12345 在 1~5 的所有的排列中字典序为 1 ,12354 的序号为 2 ,以此类推。因为康托展开是双射,所以同样存在逆康托展开,即从排名倒推出排列。

康托展开

康托展开原理

1.何为字典序

在数学中,字典序是基于字母顺序排列的单词按字母顺序排列的方法。在全排列中,它们的字典序则是基于数字大小来进行比较的,对于数字 1、2、3......n 的排列,不同排列的先后关系是从左到右逐个比较对应的数字的大小来决定。比如 4 的排列 [2,3,1,4] 和 [2,3,4,1] ,因为第三位出现不同,且 1<4 ,所以 [2,3,1,4] 排在 [2,3,4,1] 前面。

2.如何计算

我们拿长度为 5 的排列 [2,5,3,4,1] 来举例子:

  • 第一位是 2 ,比 2 小且还没用过的数字有 1 ,所以可以令 1 为开头,后面四位任意排列,共有 1×4! 种排列比当前小
  • 固定第一位,看第二位 5 ,比 5 小且还没用过的数字有 1、3、4 , 所以可以分别令第二位为1、3、4,后面三位任意排列,共有 3×3! 种排列比当前小
  • 固定一二位,看第三位 3 ,比 3 小且还没用过的数字有 1 ,所以可以令第三位为 1 ,后面二位任意排列,共有 1×2! 种排列比当前小
  • 固定一到三位,看第四位 4 ,比 4 小且还没用过的数字是 1 ,所以可以令第四位为 1 ,后面一位任意排,共有 1×1! 种排列比当前小

按照上面步骤的统计,我们就可以得出比排列 [2,5,3,4,1] 的字典序小的排列一共有 1×4!+3×3!+1×2!+1×1!=45 个,所以我们就能知道排列 [2,5,3,4,1] 是第 46 个排列,即字典序为 46 。可以注意到,我们是从左往右依次按位去计算,每位用到的数字一定是比当前位小且前面没出现过的数字,否则就会统计重复。

3.原理总结

根据上面的步骤,我们可以发现康托展开的原理很简单。设有排列 $p=a_1a_2a_3...a_n$ ,那么对任意字典序比 p 小的排列,一定存在 i ,使其前 i-1(1≤i<n) 位与 p 对应,第 i 位比 $p_i$ 小,后续的位随意组合。于是对于任意的 i ,满足条件的排列数就是在后 n-i+1 位中选出一个比 $p_i$ 小的数 $a_i$,并将剩下 n-i 个数任意排列的方案数,即为 $A_i · (n-i)!$ ($A_i$ 表示 $p_i$ 后面比 $p_i$ 小的数的个数)。所以我们遍历所有的位,即可得到总方案数 $\sum_{i=1}^{n-1} A_i · (n-i)!$ ,再加上 1 即为排名。

代码实现

根据上面的原理总结,问题就转化成如何求 $A_i$ 了,显然可以用 $O(n^2)$ 的方式来求:

const int maxn = 11;
// 存储1-10阶乘
ll fact[maxn] = {1,1,2,6,24,120,720,5040,40320,362880,3628800};  
ll P[maxn], A[maxn];
// 函数传入的参数是1-n的排列 
ll cantor(ll P[], int n)
{
    ll ans = 1;
    for(int i = 1; i <= n; ++i)
        for(int j = i+1; j <= n; ++j)
            if(P[j] < P[i])
                A[i]++; // 统计第i位后面有多少比它小的 
    for(int i = 1; i < n; ++i)
        ans += A[i] * fact[n-i];
    return ans;
}

这个复杂度其实很够了,毕竟 21! 就已经超过 long long 范围了,很多时候康托展开也就是在这个规模下进行。如果再大,就需要上高精度或者需要取模了。当然,优化也不难,注意到我们每次要用到当前有多少个小于它的数还没有出现,这里用树状数组统计比它小的数出现过的次数就可以了,可以优化到 O(NlogN) :

const int maxn = 11;
// 存储1-10阶乘
ll fact[maxn] = {1,1,2,6,24,120,720,5040,40320,362880,3628800};  
ll P[maxn], A[maxn], tree[maxn];
// lowbit操作
ll lowbit(ll x){ return x & -x; }
// 前n项和 
ll query(ll x)
{
    ll ans = 0;
    for(int i = x; i >= 1; i -= lowbit(i))
        ans += tree[i];
    return ans;
} 
// 单点更新 
void update(ll idx, ll x)
{
    for(int i = idx; i < maxn; i += lowbit(i))
        tree[i] += x;
}
// 函数传入的参数是1-n的排列 
ll cantor(ll P[], int n)
{
    ll ans = 1;
        // 倒着来统计,保证统计的是当前位之后出现的比它小的数字
    for(int i = n; i >= 1; --i) {
        A[i] = query(P[i]);
        update(P[i],1);
    }
    for(int i = 1; i < n; ++i)
        ans += A[i] * fact[n-i];
    return ans;
}

应用例题

康托展开的一个典型的应用是八数码问题

逆康托展开原理

与康托展开相对应的是逆康托展开,即求指定排名的排列。因为康托展开是双射关系,所以我们可以通过把上面的步骤倒过来推出排列。

1.举个栗子

还是以上面的栗子,假设现在有一个 1-5 的排列,我们可以知道,最大的排列就是 [5,4,3,2,1] ,根据康托展开,我们可以列出式子 4×4!+3×3!+2×2!+1×1! 。根据数学我们可知, 4! 是严格大于 3×3!+2×2!+1×1! ,所以可以认为对于长度为 5 的排列,假设有 x 个比他小的排列,$\lfloor \frac{x}{4!} \rfloor$ 就是有多少个数比第一位数字小

假设有个长度为 5 的排列序号是 46 ,我们可以通过上面的数学运算倒推回他是哪一个排列:

  • 首先 46-1 = 45 ,代表有45个排列比它小,$\lfloor \frac{45}{4!} \rfloor$ = 1,有一个数比第一位数小,所以第一位是 2

2.数学推算

代码实现

康托展开应用

个人感悟

由于康托展开可以使得一长串数字的排列化为排名,可以用于hash也就是散列表的储存,此时储存就不需要存一大串数字,而是直接储存它的排名,需要输出时再逆康托展开即可。

参考资料

最后修改:2021 年 05 月 18 日 04 : 58 PM
如果觉得我的文章对你有用,请随意赞赏