康托(Cantor)展开与逆展开理解与运用

前言

        文章仅作参考、学习

        作者本人的文章是分享自己对于一些算法、数据结构、技巧的理解,写的内容可能比较简单或偏于大众化,也更好理解。文章后面通常会配套题目与题解:)。

        本文章内容依据“CC BY-NC-SA 4.0”许可证进行授权。转载请署名、附上出处链接,不得用于商业用途,详细见本篇文章后记。

        通篇代码使用C++不妨碍非代码部分的理解:))。

        如果本文有任何错误,也请各位帮忙指正,感谢!

源自

        原作者:Ymy251325

        原文链接:康托(Cantor)展开与逆展开理解与运用-CSDN博客

Now

        可以不用,不能不知道——今天我们来看一个关于计算全排列排名的好东西:康托展开与逆展开


目录

前言

源自

Now

是什么

康托展开

原理、举例

公式

完整定义

C++代码

代码实现

代码分析

代码优化

逆康托展开

原理

问题整理、转换

证明

        第一步:确定基础情况

        第二步:归纳、做出假设

        第三步:确定并证明联系

回归原问题

说明

其他证明

举例

C++代码

代码实现

代码分析

代码优化

练习

后记

授权许可

Reference-参考

修改日志


是什么

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

………………引自——百度百科

        简单来说,一个全排列中的任意排列 都可以通过康托展开得到一个自然数,两者是一一对应。康托展开可以求一个全排列的全部排列按字典序排序的排名同样可以通过这个排名逆推原来的排列

康托展开

        这里先讨论展开。

原理、举例

        这里先不说什么公式(需要的直接跳到后面),直接举例

        比如,有一个1~5的排列,现在需要计算2, 5, 4, 3, 1这一排列P按字典序排序的排名。那么可以将其转化为计算按字典序在该排列前面一共有多少个排列。

(比较排名?e.g.:对于排列1, 2, 3, 4, 5与2, 5, 4, 3, 1,显而易见,因为两者第一个元素前者比较小,所以前者字典序比较小,所以排名应在后者前面。我们可以通过比较相同位置的元素的大小来确定排名的先后)

        所以,我们先由排列P(2, 5, 4, 3, 1)的第一个元素开始依次往后看。

  •         对于第一个元素2,我们可以知道 别的 第一个元素<2 的排列 字典序一定是小于排列P的;所以1~5中,1 < 2,既当前位置有1这一种可能使该排列的字典序小于P,后面还有4个元素,这4种元素的排列总数为4!,因为前面有1种可能,所以这里可以得到   1*4!   个排列字典序小于P
  •         继续向下一位看。在1~5中排除掉刚刚已经在第一个位置的2(前面出现过了后面不可能再出现),就是在(1, 3, 4, 5)中,有(1, 3, 4) < 5,这里有3种可能,后面3个元素共有3!个排列,所以这里又可以得到   3*3!   个排列字典序小于P
  •         同理,下一位有   (1, 3) < 4(上面使用过2,故不算), 后面剩余2位,可以得到   2 * 2!   个排列
  •         下一位:有1 < 3 ,后面剩1位,得到   1*1!   
  •         最后一位:(无元素) < 1 ,后面剩0位,得到   0*0!   

总结一下:刚刚得到字典序小于P(2, 5, 4, 3, 1)的排列个数

X=1*4!+3*3!+2*2!+1*1!+0*0!=47

但这里需要注意:现在我们求的是 按字典序排序下 在排列P之前 有几个排列,我们原先要计算的是排名,所以要加上1,得X=48

公式

        上面举例过一遍了,下面的公式应该很好理解吧,就不细说了

        X=a_{1}(n-1)!+a_{2}(n-2)!+...+a_{i}(n-i)!+...+a_{n-1}\cdot1!+a_{n}\cdot0! + 1

        其中:

                {a_i}表示比第i位上的元素字典序小且没使用过的元素个数

                n既排列元素的个数

                X是排列的排名

完整定义

………………引自Zhihu user---我不玩了给我退学 

(这里说排名从0开始,建议计算后手动加1.比较符合“排名”,更形象,加不加也无所谓啦)(看不看无所谓,知道基本意思就行了)

C++代码

代码实现
//计算阶乘
int factorial(int n) {
    if (n == 0 || n == 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

//康托展开函数
int cantorExpansion(const vector<int>& p){
    int n = p.size();
    int x = 0;
    //计算康托展开的每一位
    for(int i = 0; i < n - 1;i++){
    	//smc:smaller_count
        int smc = 0;
        for(int j = i + 1; j < n;j++){
            if (p[j] < p[i]){
                smc++;
            }
        }
        x += smc * factorial(n - i - 1);
    }

    return x;
}

上面这样写比较直观;当然可以省去factorial函数,像下面这样(也就只改了个x的计算)

int cantorExpansion(const vector<int>& p){
	int n = p.size();
    int x = 0;
    for(int i = 0; i < n - 1;i++){
        int smc = 0;
        for(int j = i + 1; j < n;j++){
            if(p[j] < p[i]){
                smc++;
            }
        }
        x = (x + smc) * (n - i - 1);
    }
    return x;
}

(可能有些看官不理解第二段代码计算x的部分,这里解释一下:

 x+smc 会累加上 smc 并乘上下一位对应的位数(也就是这一位阶乘的最高位),下一次一并计算,上次只乘了阶乘的最后一位的 smc 会再乘阶乘的下一位,直到都乘过了。光说可能有点抽象,下面模拟以下全排列1~5在此函数下X的计算过程( {a_i}表示比第i位上的元素字典序小且没使用过的元素个数):   

X=((((a_1*4)+a_2)*3+a_3)*2+a_4)*1  

展开可以得到

X=1*2*3*4\cdot a_1+1*2*3\cdot a_2+1*2\cdot a_3+1 \cdot a_4

自己推一下就能理解啦qwq)

代码分析

        两个不同的cantor函数写法都用了两层循环所以时间复杂度都是\theta (n^2)

        另外factorial函数分析得其时间复杂度是\theta (n)

        所以总体时间复杂度都是\theta (n^2)

代码优化

        (这是8月5日未更新树状数组优化前的原话:这里其实可以用线段树将时间复杂度降到\theta (n \log n),至于为什么不放代码,别问,问就是不会:)。这里先插个锚点🚩,等以后会了再来:)。本来说是要用线段树的,但发现用线段树有点“杀鸡用牛刀”、“大炮打蚊子”的意思,而且树状数组常数比线段树小,在数据不是很大的情况下甚至比线段树还快,树状数组还比线段树简单、整洁(其实还是我没学、不会,不然多少要装一下,这里锚点再留着🚩,等有机会了继续补:)),下面是用树状数组优化的完整代码,各位看官可以参考参考。

#include <bits/stdc++.h>

using namespace std;

vector<int> bit;

int lowbit(int k){
	return k & -k;
}

void add(int x, int z){
	int size = bit.size();
	for(int i = z;i <= size;i += lowbit(i)) bit[i] += x;
}

int summing(int l, int r){
	int sum = 0;
	for(int i = l - 1;i != 0;i -= lowbit(i)) sum -= bit[i];
	for(int i = r;i != 0;i -= lowbit(i)) sum += bit[i];
	return sum;
}

//在这上面的3个函数是BIT的基本三件套,没学过树状数组的建议学了再看

int cantorExpansion(const vector<int>& p){
	int n = p.size();
    int x = 0;
    for(int i = 0; i < n - 1;i++){
        int used = summing(1, p[i]);//这里used可以得到在p[i]之前有多少个元素使用过
        x = (x + p[i] - used - 1) * (n - i - 1);
        add(1, p[i]);
        //这里的p[i] - used - 1实际上是上面未优化代码的smc,p[i] - 1得到的是该位a[i]最大的可能
        //所以p[i] - used - 1其实写成p[i] - 1 - used的话
        //比较好理解,我懒得改了(因为我原代码里有调试的代码片段,改就要一起改)
        //其他的都一样跟上面的大差不差,实际上就是把计算原来smc的for优化掉了。
    }
    return x;
}

int main() {
    vector<int> p = {2, 5, 4, 3, 1};//这里写上原排列以计算,你们想的话可以自己写个输入
    bit.assign(p.size() + 1, 0);
    int result = cantorExpansion(p);
    cout << "NO. Cantorexpansion of \n{2, 5, 4, 3, 1} is: \n" << result + 1 << endl;
    
    return 0;
}

        我觉得这里还是附上一点说明才好。代码里的注释已经讲的很很很很很很很清楚了,这里再解释一下优化思路。原来的时间复杂度是\theta (n^2),cantorExpansion函数最外层的循环是一定要遍历的(应该吧),没什么优化思路。那么我们试图优化里面的for循环。尝试树状数组解决(因为树状数组时间复杂度较小,这题刚好可以转化为求区间和(前缀和),所以想到用树状数组(当然,我是知道后还原的思考过程,不是我自己推的,我也是看的别的文章知道的[手动滑稽])),优化掉里层循环,我们用树状数组表示各位的使用状态,然后使用树状数组的\theta (\log_N)级的超快查找、修改单点,就可以把原来计算smc的\theta (N)降下来。加上外层循环,总体时间复杂度就是\theta (N\log_N),这样就完成了我们的任务!!!

        另外,在下面给的练习中,洛谷P5367这题有20个测试点,普通的康托展开只能拿前10个测试点的分。既一半、50分。所以,题解区就也有很多优化值得一看,也有线段树优化的。但树状数组的大多优化思路也都差不多,就是把树状数组当作一个桶,用0和1来表示各位的状态,也是算前缀和,直接求出smc,与此处的优化稍有不同但不大,计算x的过程也不是完全一样,感兴趣的可以去看看。

        关于其他的:我在更新这篇优化之前也有再写了一篇关于树状数组的文章,归于本人“日常分享”的专栏,质量相比别人的可能不是很优秀,各位看官感兴趣可以参考参考,下面是链接:

树状数组(BIT)简单分析、应用-CSDN博客

(本人坚持原创、探寻极限、追求卓越。你的一个赞与关注,是对我莫大的支持!!!)


逆康托展开

        前面有说,排名与排列是一一对应的,所以我们可以通过排名逆推原排列

原理

        看到社区视乎没有多少篇讲为什么逆康托展开可以像他们所讲的那样那么算,所以这里我讲讲我对于这的理解。下面是简单推论、证明

问题整理、转换

        这里先不急,我们先来分析一下怎么逆推。(因为之前在X计算的最后加上了1,所以这边要减去1才好算,后面的X默认都是减去1的)因为排名X与a_i有关,所以我们尝试求每一位的a_i。根据公式,我们需要将X 除以 与a_i匹配的剩余位阶乘(既(n-i)!),为了直接得到我们想要的a_i,我们假设完美情况(完美情况就是指 X除以(n - i)!的商一定是ai)

X/((n-i)!)=a_i......R(a_i>0),

这里的余数为第i位的下一位往后的所有排列的总数既

R = a_{i+1}(n-(i+1))!+a_{i+2}(n-(i+2))!+...+a_{n-1}\cdot 1!+a_{n}\cdot 0!,

分析一下,要得到这个完美情况,就要使

(n - i)! > R,

(a_i>0是因为如果a_i=0,那么后面的总排列数既R一定小于等于(n-i)!-1,所以不用考虑)

所以,我们现在要来 证明 完美情况总是成立 以便 后面的 由排名 逆推 原排列。首先先来分析最坏情况,因为(n-i)!无法改变,所以我们试图改变R使R尽可能的大,又因a_i>0,所以min(a_i)=1,为了方便证明,我们忽略a_i前面部分的所有使得a_i=a_1=1,因为考虑最坏情况,又min(a_i)=1,所以使得i位上的元素为2,下一位上的元素为最大的n,那么这个排列就是

2, n, n-1, ..., 3, 1,

这里因为忽略了第i位前面的所有,这里n_0为元素个数重新计算(n_0-i)!既为(n_0-1)!,由下一位既 元素为n>2的这一位 可以算得有(n_0-2) !*(n_0-2),再下一位就是(n_0-3) !*(n_0-3),这样逐位推下去,到倒二位 既(3!*3)的下一位少了个(2!*2),直接到了(1!*1),观察可知,上面这一过程很有规律。所以,我们的问题又可以转换成另一个问题,另外,该规律序列少了一个(2!*2),相信你们也知道怎么转换、转换后是什么了,所以我们不妨大胆把这个(2!*2)加上(不影响推论、证明、结果),所以:

n_0-1视做x,加上(2!*2),则问题可以转换成:
对于一任意正整数x(x > 1),求证:

x!>(x-1)(x-1)!+(x-2)(x-2)!+...+1*1!,

总是成立。

        其实这变成了一个数学问题,我们用数学归纳法可以很简单的证明出来。

证明
        第一步:确定基础情况

x!=2*1>(x-1)!=1!=1*1=1(x=2)

        第二步:归纳、做出假设

                假设对于一个正整数k(k>1),有

k!>(k-1)(k-1)!+(k-2)(k-2)!+...+1*1!

        第三步:确定并证明联系

                我们可以看到,不等式右边单项式的元素是递减,每次减1的(直接看,不分解阶乘),所以,我们根据这个联系试证明

(k+1)!>k\cdot k!+(k-1)(k-1)!+...+1*1!

只要证明出这个不等式,再加上基础情况,对于原证明所有的满足条件的k都会成立

我们知道一个阶乘数等于该数乘上一个数的阶乘,在这里既

(k+1)!=(k+1)*k!

由乘法分配律得

(k+1)k!=k\cdot k!+k!

最后我们得到

(k+1)!=k\cdot k!+k!

带入到

(k+1)!>k\cdot k!+(k-1)(k-1)!+...+1*1!

k\cdot k! + k!>k\cdot k!+(k-1)(k-1)!+...+1*1!

因为我们知道(上面假设的)

k!>(k-1)(k-1)!+(k-2)(k-2)!+...+1*1!

在此不等式两边同时加上k\cdot k!得到

k\cdot k! + k!>k\cdot k!+(k-1)(k-1)!+...+1*1!

(k+1)!>k\cdot k!+(k-1)(k-1)!+...+1*1!

到此,证明完成

所以,对于一任意正整数x(x > 1)

x!>(x-1)(x-1)!+(x-2)(x-2)!+...+1*1!,

总是成立。

(其实,一个证明与这个十分相似,既对于任意正整数x(x > 1)

x!=(x-1)(x-1)!+(x-2)(x-2)!+...+1*1!+1,

总是成立。可能可以在某些与阶乘相关的地方用到吧,感兴趣的看官可以用数学归纳法推一下,这里就不推了)

回归原问题

        所以,上面一式成立,我们之前没解决的证明一个个都成立了,既原证明

(n - i)! > R

总是成立。我们就一直有“完美情况”

X/((n-i)!)=a_i......R(a_i>0)

对于每一位,我们都可以用此公式来计算当前位置的a_i,我们只要用排列的排名X来除以 与a_i匹配的剩余位阶乘(既(n-i)!)就可以得到a_i,然后取出余数R当作下一位的X,继续套用上面的公式,直到余数为0时就能求出各位的a_i了,再通过各位的a_i就能推出原排列了。

说明

        证明仅供参考。

        本证明仅是作者个人的理解,可能有些错误,或不严谨、表达不标准,如有错误,也请大家指出,如果是不严谨,只要问题不是很大,大家将就着看吧,或者看看下面的”其他证明“。感谢理解。

其他证明

        这里再提供另一种证明思路、过程,可能比我的证明更严谨更准确,仅供参考。

………………引自Githab user---FrogIf 

举例

        哈哈h,刚刚原理部分可能讲的有点上头了,不过问题不大,看不懂也没关系(也不难,应该看得懂吧,相信你们水平比我强多了),只要记住怎么算的就行了,再说一遍公式:

X/((n-i)!)=a_i......R(a_i>0)

        下面来个简单的举例:

        之前我们算出(2, 5, 4, 3, 1)X为48,因为为了更形象“排名”加上了个1,这并不是原公式里的也会影响我们计算,所以在这里将其减去1得到47.

        根据公式,我们需要将X除以(n-i)!得到a_iR,将R带入下一位的X继续算直到余数为0即可得到各位的a_i(注意不是原排列)。

  •         将X=47除以(n-i)!=4!得到a_1=1R=23,将下一位X设为R得到X=23
  •         23/3!=3......5,a_2=3
  •         5/2!=2......1,a_3=2
  •         1/1!=1......0,a_4=1
  •         最后一位一定是0

        现在,我们由各位的a_i推出原排列

  •         由a_1=1得在第一个元素后面有1个比它小的,所以我们取出(1, 2, 3, 4, 5)中第二小的2,加入得到排列(2, )(也可以看作取下标为1的元素(下标从0开始))
  •          由a_2=3得在第二个元素后面有3个比它小的,所以我们取(1, 3, 4, 5)中第4小的5,加入得到排列(2, 5, )(也可以看作取下标为3的元素(下标从0开始), 后面就不再说了)
  •          取(1, 3, 4)中第3小的4,加入得到排列(2, 5, 4, )
  •          取(1, 3)中第2小的3,加入得到排列(2, 5, 4, 3, )
  •          取(1)中第0小的(可以理解为又是最大又是最小,所以是0;或者理解成或者取下标0)1,加入得到原排列(2, 5, 4, 3, 1)

公式上面提过了,这里就不再提了。

C++代码

代码实现
int factorial(int n){
    if (n == 0 || n == 1)
        return 1;
    return n * factorial(n - 1);
}

//逆康托展开
void inverseCantorExpansion(int n, int k, vector<int>& p){
    //n是排列的长度,k是康托展开得到的数值,p是还原后的排列
    vector<int> ele;
    for (int i = 1; i <= n;i++){
        ele.push_back(i);
    }//ele临时1~n最小字典序排列

    for (int i = 0; i < n;i++){
        int fact = factorial(n - 1 - i);
        int index = k / fact;
        p[i] = ele[index];//对应文章中举例的部分 把除的a_i看做下标
        ele.erase(ele.begin() + index);//去除取出的元素
        k %= fact;
    }
}

        还有一种写法,大致框架一样,各部分用了些稍不同的代码,是来自Githab user---FrogIf的,需要的自行参见,文章后记有链接。

代码分析

        时间复杂度\theta (n^2)

代码优化

        可以用树状数组优化,需要的自行学习。

        (这里的优化今天(8月5号)没时间补了,可能会推个几天(一定会补!!!!),但其他文章会照常发,如果想看这部分的建议过一两天再来看看)


练习

        不多说,直接放链接,都是洛谷的,都有题解。

        1.先给个模板,可以拿来上手练习;还有提示一下,没用树状数组或者线段树优化只能得一半的分数哦,在本篇康托展开优化的那里有提到这个。

        P5367

        2.这题其实可以直接用next_permutation或者用dfs自己写个。这题有一题解十分巧妙,本质上是康托展开,只是优化了一下,详细见:题解 P1088 【火星人】 - 洛谷专栏 (luogu.com.cn)

       P1088

        3.

        P1034


后记

授权许可

        本文章内容依据“CC BY-NC-SA 4.0”许可证进行授权。转载请署名、附上出处链接,并以相同的许可发布,不得用于商业用途。

        This work is licensed under CC BY-NC-SA 4.0

        Full license name:

Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International

Reference-参考

 
【给初心者的】康托展开 - 知乎 (zhihu.com)

康托展开 (frogif.github.io)

修改日志

        2024-08-04 20:54:31

                原文发布

        2024-08-04 23:40:36

                改正了一些错字、修改了一些标点不正确的问题、修改了一些格式不正确的地方、修改了文章结构,修复了一些显示错误、不完整的问题;

                将逆康托展开->原理->证明->第三步:确定并证明练习 部分

        只要证明出这个不等式,再加上基础情况,对于原证明所有的满足条件的k都会成立

我们知道一个阶乘数等于该数乘上一个数的阶乘,在这里既

        (k+1)=(k+1)*k!

        ”中的(k+1)=(k+1)*k!错误,修改为了(k+1)!=(k+1)*k!

        2024-08-05 09:34:02

                修改了一些格式不正确的地方

                将逆康托展开->原理->问题整理、转换 部分“将n_0视做x,加上(2!*2),则问题可以转换成:”中的“将n_0视做x错误,修改为了“将n_0-1视做x;

        2024-08-06 00:xx:xx

                增加了康托展开的树状数组优化完整代码

                增加了一个作者的关于树状数组的链接

                修改全篇文章大部分的字体样式,部分段落结构,为较长的句子加上了空格分解,为部分地方加上了更详细的解释。使文章更加整洁易懂。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值