「算法笔记」康托展开

一、引入

以前从来没听过“康托展开”的 myt 在做「NOIP2018 提高组」初赛卷的时候,碰到了一道需要用到康托展开的题,于是就有了以下内容。

那么康托展开是什么呢?康托展开是一个全排列到一个自然数的双射,常用于构建哈希表时的空间压缩。 康托展开的实质是计算当前排列在所有由小到大全排列中的顺序,因此是可逆的。(摘自百度百科

通俗地说,康托展开可以用来求一个 \(1\sim n\) 的任意排列的排名(把 \(1\sim n\) 的所有排列按字典序排序,这个排列的位次就是它的排名)。

康托展开可以在 \(O(n^2)\) 的复杂度内求出一个排列的排名,在用到树状数组优化时可以做到 \(O(n\log n)\)

二、康托展开

1. 公式

先给出康托展开的公式:

\(X=a_1(n−1)!+a_2(n−2)!+\cdots+a_n\cdot 0!\)

其中,\(a_i\) 为整数,并且 \(0\leq a_i<i,1\leq i\leq n\)

\(X\) 表示康托展开的结果。\(a_i\) 表示在当前未出现的元素中比第 \(i\) 个数小的数的个数,后面乘的是还剩下的数的个数的阶乘。

2. 例子

举个栗子:求长为 \(5\) 的排列 \([5,2,4,1,3]\) 的排名。

1. 首先,若第一个数已经确定,那么还有 \(4\) 个数没有确定,则共有 \(4!\) 种排列。而小于 \(5\) 的数开头的排列都会比 \(5\) 开头的排列的字典序要小。令 \(1,2,3,4\) 分别作为第一个数,也就是说第一个数有 \(4\) 种选择,所以共有 \(4\times 4!\) 种排列。这 \(4\times 4!\) 种排列都比 \([5,2,4,1,3]\) 的字典序小。

2. 再来看第二位。由于第一位 \(5\) 已经出现过了,所以还剩下 \(\{1,2,3,4\}\)。第一位 \(5\) 已经确定,接下来小于 \(2\) 的数作为第二位的排列都会比 \(2\) 作为第二位的排列的字典序要小(第一位都是 \(5\))。\(\{1,2,3,4\}\) 中只有 \(1\) 个数比 \(2\) 小。与上一步同理,这时候又有 \(1\times 3!\) 种排列比 \([5,2,4,1,3]\) 的字典序小。

3. 然后看第三位。还剩下 \(\{1,3,4\}\),其中比 \(4\) 小的有 \(2\) 个。那么有 \(2\times 2!\) 种排列比 \([5,2,4,1,3]\) 的字典序小。

4. 接下来看第四位。还剩下 \(\{1,3\}\),其中比 \(1\) 小的有 \(0\) 个。则有 \(0\times 1!\) 种排列比 \([5,2,4,1,3]\) 的字典序小。

5. 最后再看第五位。此时有 \(0\times 0!\) 种排列比 \([5,2,4,1,3]\) 的字典序小。

\(4!\)\(3!\)\(2!\)\(1!\)\(0!\)
\(24\) \(6\) \(2\) \(1\) \(0\)

综上所述,共有 \(4\times 4!+1\times 3!+2\times 2!+0\times 1!+0\times 0!=106\) 种排列比 \([5,2,4,1,3]\) 的字典序小。则 \([5,2,4,1,3]\) 的排名为 \(107\)

int solve(){
    int ans=0;
    for(int i=1;i<=n;i++){
        int cnt=0;    //在当前未出现的元素中比第 i 个数小的数的个数
        for(int j=i+1;j<=n;j++)    //当前未出现的元素:a[(i+1)~n] 
            if(a[j]<a[i]) cnt++;
        ans+=cnt*f[n-i];    //f[i]=i! 
    }
    return ans+1;    //当前算出的是,比给出排列字典序小的排列数,所以还要 +1 
}

三、逆康托展开

逆康托展开可以计算这样的问题:给出一个 \(1\sim n\) 的排列的排名,求这个排列。

举个栗子:已知一个 \(1\sim 5\) 的排列 \(A\) 的排名为 \(107\),求 \(A\)

首先,\(A\) 的排名为 \(107\),那么共有 \(106\) 种排列比 \(A\) 的字典序小。

1. \(\lfloor \frac{106}{4!}\rfloor=4\),则有 \(4\) 个数比 \(A\) 的第一位小,则第一位为 \(5\)

2. 还剩下 \(106-4\times 4!=10\) 种排列比这个排列的字典序小(\(\frac{106}{4!}=4 \cdots\cdots 10\))。由于第一位 \(5\) 已经出现过了,所以还剩下 \(\{1,2,3,4\}\)\(\lfloor \frac{10}{3!}\rfloor=1\),则在 \(\{1,2,3,4\}\) 中有 \(1\) 个数比 \(A\) 的第二位小。则第二位为 \(2\)

3. \(10-1\times 3!=4\)\(\lfloor\frac{4}{2!}\rfloor=2\),则在 \(\{1,3,4\}\) 中有 \(2\) 个数比 \(A\) 的第三位小。则第三位为 \(4\)

4. \(4-2\times 2!=0\)\(\lfloor\frac{0}{1!}\rfloor=0\),则在 \(\{1,3\}\) 中有 \(0\) 个数比 \(A\) 的第四位小。则第三位为 \(1\)

5. \(0-0\times 1!=0\)\(\lfloor\frac{0}{0!}\rfloor=0\),可得第三位为 \(3\)

所以 \(A=[5,2,4,1,3]\)

void solve(int n,int k){    //已知一个 1~n 的排列 A 的排名为 k,求 A
    memset(vis,0,sizeof(vis)),k--;    //有 k-1 种排列比 A 的字典序小 
    for(int i=1;i<=n;i++){
        int cnt=k/f[n-i];    //有 cnt 个数比 A 的第 i 位小 
        for(int j=1;j<=n;j++)
            if(!vis[j]){
                if(!cnt){a[i]=j,vis[j]=1;break;}
                cnt--; 
            }
        k%=f[n-i];    //还剩下几种排列比 A 的字典序小 
    }
}

四、树状数组优化

1. 树状数组优化康托展开

思考康托展开的过程。先不管乘上阶乘的部分,考虑优化求当前未出现的元素中比第 \(i\) 个数小的数的个数(当前有多少个小于它的数还没有出现)的做法。

考虑用树状数组维护。具体来说,初始时我们把树状数组中的每个数设为 \(1\),表示这个数还没有出现。如果某个数出现过了,就把它变成 \(0\)。最后查询 \(1\sim a_{i-1}\) 有多少个 \(1\) 就行了(因为比它小的数都在它的前面,并且出现过的数的值都为 \(0\))。

Luogu P5367 【模板】康托展开 代码:

#include<bits/stdc++.h>
#define int long long
#define lowbit(x) x&(-x)
using namespace std;
const int N=1e6+5,mod=998244353;
int n,a[N],f[N],c[N],ans;
void modify(int x,int y){
    for(int i=x;i<=n;i+=lowbit(i))
        c[i]=(c[i]+y)%mod;
}
int query(int x){
    int ans=0;
    for(int i=x;i;i-=lowbit(i))
        ans=(ans+c[i])%mod;
    return ans;
}
signed main(){
    scanf("%lld",&n),f[0]=1;
    for(int i=1;i<=n;i++)
        f[i]=(f[i-1]*i)%mod;
    for(int i=1;i<=n;i++)
        scanf("%lld",&a[i]),modify(a[i],1);    //初始时每个数都设为 1,表示这个数还没有出现 
    for(int i=1;i<=n;i++)
        modify(a[i],-1),ans=(ans+query(a[i]-1)*f[n-i]%mod)%mod;    //如果某个数出现过了,就把它变成 0。查询 1~a[i-1] 中 1 的个数。 
    printf("%lld\n",ans+1);
    return 0;
}

2. 树状数组+二分优化逆康托展开

把在未选数中找到第 cnt+1 大的数的过程改成二分+树状数组即可。

#include<bits/stdc++.h>
#define int long long
#define lowbit(x) x&(-x)
using namespace std; 
const int N=1e6+5;
int n,k,a[N],f[N],c[N];
void modify(int x,int y){
    for(int i=x;i<=n;i+=lowbit(i))
        c[i]+=y;
} 
int query(int x){
    int ans=0;
    for(int i=x;i;i-=lowbit(i))
        ans+=c[i];
    return ans;
}
void solve(int n,int k){
    k--;
    for(int i=1;i<=n;i++) modify(i,1);    //初始时每个数都设为 1,表示这个数还没有出现 
    for(int i=1;i<=n;i++){
        int cnt=k/f[n-i],l=1,r=n,ans;
        while(l<=r){    //二分求,未出现过的数中,有 cnt 个数比它小的数
            int mid=(l+r)/2;
            if(query(mid)-1>=cnt) ans=mid,r=mid-1;    //query(mid)-1 即查询 mid 前面有几个数还没有出现。这里若用 query(mid-1) 会出锅,因为可能会取到之前出现过的数。 
            else l=mid+1;
        }
        a[i]=ans,k%=f[n-i],modify(a[i],-1);    //如果某个数出现过了,就把它变成 0
    }
}
signed main(){
    scanf("%lld%lld",&n,&k),f[0]=1;
    for(int i=1;i<=n;i++) f[i]=f[i-1]*i;
    solve(n,k);
    for(int i=1;i<=n;i++)
        printf("%lld%c",a[i],i==n?'\n':' ');
    return 0;
}
posted @ 2020-09-16 18:14  maoyiting  阅读(726)  评论(0编辑  收藏  举报