「算法笔记」康托展开
一、引入
以前从来没听过“康托展开”的 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\))。
#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; }