康托展开学习笔记

前言:稍微学习了一下康托展开,写个笔记总结一下。

定义

首先搬来百度百科的解释

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

提取一下定义的精髓,就是说康托展开能:

  1. 压缩构建 hash 表时的空间消耗
  2. 计算一个排列在所有全排列中根据字典序从小到大的排名

对于压缩空间消耗,不是本文的讲解重点,蒟蒻参考这篇博客,简单总结了一下原理:

可以认为康托展开是一种特殊的 hash 函数。

它可以对一个全排列通过康托展开压缩成只占很小的空间,方便存储。

同时由于康托展开的双射关系,我们也可以避免 hash 冲突的产生,是一种很实用的工具。

而究竟是如何实现的,以及为什么能够压缩空间,接下来我们就重点学习一下康托展开逆康托展开

原理介绍

康托展开

给定一个排列 \(a\),我们可以通过一个式子,求出它的康托展开值 \(x\)

\[x=a_n(n-1)!+a_{n-1}(n-2)!+\dots+a_2\times 1!+a_1\times 0!\qquad(a_i\in \mathbb{Z}\wedge 0\le a_i<i\wedge i\in[1,n]) \]

\(a_i\) 表示原数第 \(i\) 位在当前未出现的元素中是排在第几位。

这就是康托展开公式

相信刚看到这个式子的时候脑袋就要爆炸了也许只有我这个蒟蒻的脑袋乱成浆糊,关于这个式子为什么是这样,我们举个例子就差不多能理解了。比如一个排列 \((6,2,1,4,3,5)\),我们根据式子模拟一下计算过程:

首先我们根据给定的排列,计算序列 \(a\):

第一位是 \(6\),小于 \(6\) 的数有 \(1\sim 5\)\(5\) 个数,因此 \(a_6=5\),则首位小于 \(6\) 的所有排列就有 \(a_6\times (6-1)!=600\) 种。

第二位是 \(2\),小于 \(2\) 的数只有 \(1\)\(1\) 个数,因此 \(a_5=1\),小于 \(2\) 的所有排列就有 \(a_5\times(5-1)!=24\) 种。

第三位是 \(1\),后面并没有小于 \(1\) 的数了,因此 \(a_4=1\),小于 \(1\) 的所有排列就有 \(a_4\times(4-1)!=0\) 种。

依此类推......

最终算出 \(a\),直接算也好,将 \(a\) 套公式也好,将每个算出的结果加起来,就是 \(600+24+0+2+0+0=626\),因此答案就是 \(626\)

咳咳,没反应过来?仔细想一想,答案到底是不是 \(626\) ?

回想一下,我们前面的所有过程都是在计算小于当前位的所有排列的种数,但是加起来后算出来的应该是比当前排列小的第一个排列的排名,因此最后我们要记得再 \(+1\) 一下,才是最终答案。

相信到此你已经比较深刻地理解了康托展开公式的步骤过程和简单原理,具体的证明感兴趣的同学可以自行查阅,由于作者时间有限没有能力,本文不再深究。

什么?你还不太理解?再手模几组排列试试。

逆康托展开

既然有了康托展开,怎么能少了逆康托展开呢?

我们刚开始定义中就提到康托展开存在双射关系,因此康托展开的过程必然是可逆的。也就是给定长度和排列的排名,求排列种的每一个数。

实际上也很简单,我们直接把康托展开的过程逆过来就好了。

首先我们还是把康托展开的例子搬过来,长度为 \(6\) 的排列,排名为 \(627\),求排列中的每个数。

同样倒过来模拟我们上面的过程:

首先我们先把一个 \(1\) 减掉,得到 \(x=626\),接下来我们根据 \(x\),算出每一位的 \(a_i\)

\(626{\div}(6-1)!=5\cdots\cdot\cdot\cdot26\),说明 \(a_6=5\),比当前小的所有没有用过的数有 \(5\) 个,显然当前位是 \(6\)

\(26{\div}(5-1)!=1\cdots\cdot\cdot\cdot2\),说明 \(a_5=1\),比当前小的还没有用过的数有 \(1\) 个,显然当前位是 \(2\)
\(2{\div}(4-1)!=0\cdots\cdot\cdot\cdot2\),说明 \(a_4=0\),没有比当前小的还没有用过的数,显然当前位是 \(1\)

依此类推......

最终显然能够算出结果是 \(621435\),与原来的排列完全相同。

如果还没有理解的话,同样再推几组样例就差不多啦。

例题选讲

P3014 [USACO11FEB] Cow Line S

思路

这道题显然是个康托展开的模板题。

只要把康托展开和逆康托展开的过程实现一遍就好了。

在代码实现中如果直接手动模拟,时间复杂度为 \(O(n^2)\) 的,具体过程蒟蒻在这里不多介绍(主要因为没有写)。

在康托展开的过程中可以考虑用权值线段树或者树状数组维护一下比当前数小的数的数量时间复杂度为 \(O(n\log n)\)

代码实现

实际上代码的实现还是有很多细节的,代码放在这里,看注释吧。

#include<iostream>
#include<cstring>
using namespace std;
typedef long long ll;//保险开long long,不会见祖宗
const int N=21;
int n;
//------------------------------树状数组三部曲start
ll t[N];
#define lowbit(x) ((x)&-(x))
void add(int a,ll x)
{
    for(int i=a;i<=n;i+=lowbit(i))t[i]+=x;
}
ll query(int a)
{
    ll res=0;
    for(int i=a;i;i-=lowbit(i))res+=t[i];
    return res;
}
//------------------------------树状数组三部曲end
bool st[N];//存储逆康托展开过程中的每个数是否被用过
//------------------------------阶乘预处理start
ll fac[N];
void init()
{
    fac[0]=1;
    for(int i=1;i<N;i++)fac[i]=fac[i-1]*i;
}
//------------------------------阶乘预处理end
int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);
    int T;
    cin>>n>>T;
    init();
    while(T--)
    {
        char op[2];
        cin>>op;
        switch(op[0])
        {
            case 'P'://逆康托展开
                memset(st,0,sizeof st);//多测不清空,爆零两行泪
                ll x;
                cin>>x;
                x--;//不要忘记-1
                for(int i=1;i<=n;i++)
                {
                    int tmp=x/fac[n-i];//倒推一遍
                    for(int j=1;j<=n;j++)
                        if(!st[j])//没有使用过
                            if(!tmp--)//如果就是当前数,直接输出并标记,否则-1
                            {
                                cout<<j<<' ';
                                st[j]=1;
                                break;
                            }
                    x%=fac[n-i];//最后不要忘记取余数
                }
                cout<<"\n";//别忘了最后换行
                break;
            case 'Q'://康托展开
                memset(t,0,sizeof t);//多测不清空,爆零两行泪
                for(int i=1;i<=n;i++)add(i,1);//初始化每个数都没有出现过
                ll res=1;//直接初始化成1,不能忘
                for(int i=1,a;i<=n;i++)
                {
                    cin>>a;
                    add(a,-1);//记得将当前数删掉
                    res+=query(a)*fac[n-i];//比当前数小的数再乘阶乘,存储到答案中
                }
                cout<<res<<"\n";
                break;
        }
    }
    return 0;
}

UVA11525

思路

首先不难看出,这是一道逆康托展开的题目。但是 \(n\) 的计算方式貌似有点奇怪,有没有感觉有一点熟悉?往上翻翻。

这和康托展开公式很相似嘛,考虑从这里入手。

手模样例不难看出,\(S_i\) 的意义就是:对于第 \(i\) 个数字,后面的数字中有多少个比当前位大。因此,我们就可以直接考虑维护每个数字找第 \(k\) 大这个过程。

方法有很多,题解中也有不少。可以考虑时间复杂度为 \(O(n\log n)\) 的但常数比较大的权值线段树或平衡树,也可以考虑时间复杂度为 \(O(n\log^2n)\) 但是常数较小的二分 + 树状数组,个人认为最优秀的是时间复杂度为 \(O(n\log n)\) 并且常数很小的树状数组 + 倍增。

但是由于倍增解法各位大佬已经讲得很清楚了(本蒟蒻懒),因此蒟蒻这里带来码量极小(头文件和红黑树命名除外)的平板电视中的红黑树实现。

代码实现

#include<iostream>
#include<ext/pb_ds/assoc_container.hpp>
using namespace __gnu_pbds;
using namespace std;
tree<int,null_type,less<int>,rb_tree_tag,tree_order_statistics_node_update>rbt;
int k,T;
int main()
{
	cin>>T;
	while(T--)
	{
		cin>>k;
		for(int i=1;i<=k;i++)rbt.insert(i);
		for(int i=1,a;i<=k;i++)
		{
			cin>>a;
			auto it=rbt.find_by_order(a);//返回排名为a的元素
			cout<<*it<<(i!=k?' ':'\n');
			rbt.erase(it);
		}
	}
	return 0;
}

个人认为学会平板电视,对于一些需要用到简单平衡树的题目来说还是很方便的。但是对于正经的数据结构题,还是老老实实的手写平衡树吧,小心被卡常

最后,本题解难免有一些讲解的不好的地方,欢迎各位大佬神犇指导。

CF501D

思路

本题也是一道需要对康托展开的式子比较熟悉才能做出来的题目,不难看出这个 \(n!\) 很恶心,同时 \(n\le 2\times10^5\) 的数据范围也不允许我们直接计算结果。因此,对于本题我们需要做一下转换。

借用上一题题目给出的式子:

\[\sum_{i=1}^KS_i\times(K-i)! \]

可以发现,对于本题来说仍然有效。

我们下标从 \(1\) 开始,假设第一个排列为 \(a\),第二个排列为 \(b\),我们要求的排列为 \(c\),我们就可以得到:

\[\begin{split} \sum_{i=1}^nc_i\times(n-i)!&=\sum_{i=1}^na_i\times(n-i)!+b_i\times(n-i)!\\ &=\sum_{i=1}^n(a_i+b_i)\times(n-i)! \end{split} \]

因此 \(c_i=a_i+b_i\),我们就可以通过简单的加和得到 \(c_i\)

接下来我们再来处理 \(n!\),显然我们会因为庞大的数据范围而无法直接计算。但是我们发现对于 \(n\) 个数的排列,最多也只有 \(n!\)。因此如果一种方案超出 \(n!\),说明它一定是不合法的,某些数超出了我们设定的范围。因此我们可以通过进位的方式来解决,这种方式及其巧妙,使得我们可以不用算出 \(n!\) 的大小就可以解决问题(蒟蒻当时就是因为没想到应该怎么转化才没做出来这题)。

最后就是简单的逆康托展开求出排列了,特别的,本题的排列是从 \(0\) 开始的,因此处理的时候边界问题可能会有些繁琐。

代码实现

还是老套路,只用码量小的平板电视和树状数组,注释应该比较清楚,这里就不再解释啦。

#include<iostream>
using namespace std;
//———————————————————————————————————————平板电视红黑树的命名start
#include<ext/pb_ds/assoc_container.hpp>
using namespace __gnu_pbds;
tree<int,null_type,less<int>,rb_tree_tag,tree_order_statistics_node_update>rbt;
//———————————————————————————————————————平板电视红黑树的命名end
typedef long long ll;
const int N=2e5+10;
int n,a[N],b[N];//a存储输入的两个数组,b存储新数组
//———————————————————————————————————————树状数组start
ll t[N];
#define lowbit(x) ((x)&-(x))
void add(int a,int x)
{
    for(int i=a;i<=n;i+=lowbit(i))t[i]+=x;
}
ll query(int a)
{
    ll res=0;
    for(int i=a;i;i-=lowbit(i))res+=t[i];
    return res;
}
//———————————————————————————————————————树状数组end
int main()
{
    cin>>n;
    for(int i=1;i<=n;i++)cin>>a[i];
    for(int i=n;i;i--)
    {
        add(a[i]+1,1);//注意将下标+1,否则会引发数组越界
        b[i]=query(a[i]);//存储查询结果
    }
    for(int i=1;i<=n;i++)cin>>a[i];
    for(int i=1;i<=n;i++)add(i,-1);//恢复树状数组
    for(int i=n;i;i--)
    {
        add(a[i]+1,1);
        b[i]+=query(a[i]);
    }
    for(int i=n;i;i--)
	{
		b[i-1]+=b[i]/(n-i+1);//借鉴了DengDuck大佬的代码思路
		b[i]%=(n-i+1);//处理进位问题
	}
    for(int i=0;i<n;i++)rbt.insert(i);//先将所有元素加入红黑树中
    for(int i=1;i<=n;i++)
    {
        auto it=rbt.find_by_order(b[i]);//找第b[i]大的元素
        cout<<*it<<' ';
        rbt.erase(it);
    }
    return 0;
}

结语

康托展开是一类巧妙的方式解决全排列字典序第 \(k\) 大问题,它结合了组合数学中的诸多性质孕育而生。而蒟蒻能力有限,无法全面透彻的带领读者领悟康托展开之美,仅仅希望蒟蒻的微薄之力能够让大家有一些收获。

文章不长,但难免有纰漏之处,欢迎各位神仙大佬提出自己的建议和指导,蒟蒻感激不尽。

update:
2023.7.21 完成博客大体内容,包括定义介绍,原理讲解以及模板题的讲解。
2023.7.23 新加入两道例题,使内容更加丰富。

参考资料:oi wiki ,百度百科

posted @ 2023-07-21 17:48  week_end  阅读(32)  评论(0编辑  收藏  举报