带你学懂数据结构中的八大排序(下)

简介: 排序(Sort)是初阶数据结构中的最后一块内容,所谓排序,就是通过某种手段,使目标数据变为递增或递减,排序有很多种方式:插入、选择、交换、归并、映射 等等,本文会介绍这些方式下的详细实现方法,因篇幅较长,故分为上下文的形式介绍,本文是下半部分。

✨个人主页: Yohifo

🎉所属专栏: 数据结构 | C语言

🎊每篇一句: 图片来源


You can avoid reality, but you cannot avoid the consequences of avoiding reality.

你可以逃避现实,但你无法逃避其带来的后果。

be3245696e7e8509130437f7c6f0f7c.png



📘前言


排序(Sort)是初阶数据结构中的最后一块内容,所谓排序,就是通过某种手段,使目标数据变为递增或递减,排序有很多种方式:插入、选择、交换、归并、映射 等等,本文会介绍这些方式下的详细实现方法,因篇幅较长,故分为上下文的形式介绍,本文是下半部分。


下面是通过排序生成的排行榜

fe7a6f21080d5a8ccbf2790a60b45ac.png



📘正文


📖交换排序


交换排序的核心在于交换,当两数符合交换条件时,就执行交换,通过不断的数据交换,实现数据间的有序性,交换排序中的代表之一就是有名的冒泡排序,另一个就是大名鼎鼎的快速排序,鉴于快速排序的重要性,它的相关介绍会非常多


📃冒泡排序


思想:将数据遍历 n-1 次,当前者大于后者时,就交换两个数,如此重复,直到数据有序


//冒泡排序voidBubbleSort(int*pa, intn)
{
assert(pa);
//思路:升序,当前值比后值大,就交换for (inti=0; i<n-1; i++)
    {
boolflag=true;  //一个小优化,虽然没什么用//冒泡的次数,与 i 挂钩for (intj=0; j<n-1-i; j++)
         {
if (pa[j] >pa[j+1])
            {
swap(&pa[j], &pa[j+1]);
flag=false;
            }
         }
if (flag)
break;  //如果一次交换都没有出现,说明数组有序,直接结束    }
}

动图展示:

冒泡排序.gif

时间复杂度:


冒泡排序比较费时间,在大多数情况下需要将数据遍历 N^2 次,即 O(N^2)

空间复杂度


仅仅只需要一个 tmp 变量辅助交换 O(1)

稳定性:


稳定,当两个相同数相遇时,两者相同,不执行交换程序,相对位置保持不变

📃快速排序


快排是本文的重头戏,光是实现方式就有三种,还有迭代版以及最后的三种优化方式,快排只有优化到位了,才能变成真正的快排(完全体)


76f5e1fcda9c46516ef2bb417a81189.png

🖋️快排(递归版)


递归版快排比较好写,但递归思想比较难想到,需要画出递归展开图辅助理解


注意: 众所周知,递归虽好,但是存在局限性,因为递归开辟的栈帧位于栈区,栈区空间是有限的,一旦排序数据量过大,会建立非常多的栈帧,从而引发栈溢出问题,因此当递归层次太深时,不推荐使用递归的方式实现

7de8f2c86442482b91ed901cec1ec0e8 (1).png


💡霍尔版


无论是什么版本的快排,都是遵循一个原则:选 key划分,选取一个 key ,使 key 左边的值小于等于 key ,右边的值大于 key ,这是快排的核心思想,霍尔(Hore)版的快排实现思想如下:


选取最左边的值为 key,比较时右边先走

因选的是左边,所以右边会先走(向左走),当右边在走的过程中遇到小于等于 key 的值时停下

右边走完后,换左边走(向右走),当遇到大于 key 的值时停止

此时交换左右两处的值

当左遇到右时(必定相遇,因为一次走一步),终止循环

执行最后一步,交换此时左(右)与 key 值,此时就完成了需求:右边值 <= key < 左边值

以上是快排的单次实现,将排序这个大问题转成小问题,指定 begin 与 end ,当最后一步执行完后,可以从此时的 key 处分割出两块区域:[begin,key - 1]、key、[key + 1,end],将其中的两块区域传入函数,继续执行递归,当所有数据都排序完成后,快排就结束了


重难点:如果 key 选在最左边,那么右边先走;如果 key 选在最右边,左边先走。这样做的目的是保证最后一次交换时(左右重叠时与 key 值的交换)key 左边的数小于等于 key

//霍尔版intPartSort1(int*pa, intbegin, intend)
{
assert(pa);
GetMid(pa, begin, end); //这是三数取中,后面会提//选 key 在左边,右边先走intkeyi=begin;
intlefti=begin;
intrighti=end;
while (lefti<righti)
    {
while (lefti<righti&&pa[righti] >pa[keyi])
righti--;
while (lefti<righti&&pa[lefti] <=pa[keyi])
lefti++;
swap(&pa[lefti], &pa[righti]);
    }
swap(&pa[keyi], &pa[lefti]);
keyi=lefti;
returnkeyi;
}
//快速排序voidQuickSort(int*pa, intbegin, intend)
{
assert(pa);
//思路:选出key,key 的右边小于key,key 的左边大于keyif (begin>=end)
return;
if ((end-begin+1) <20)
InsertSort(pa+begin, (end-begin+1));  //这是小区间优化,后面会提else    {
intkeyi=PartSort1(pa, begin, end);   //霍尔法//int keyi = PartSort2(pa, begin, end); //挖坑法//int keyi = PartSort3(pa, begin, end); //双指针法//[begin, keyi - 1] keyi [keyi + 1, end]QuickSort(pa, begin, keyi-1);
QuickSort(pa, keyi+1, end);
    }
}

动图展示:

注意:为确保容易理解,这里直接选取优秀动图展示,动图来源

c9e8c052bd2847cc8b86b34fcb795091.gif

递归展开图:

1d328b010b254eafb9ee37078b145c8a.png


💡挖坑法


思路:挖坑法核心思想与霍尔版一致,不过挖坑法为了便于理解,引入了坑位这个概念,简单来说就是先在 key 处挖坑,然后右边先走(假设 key 在最左边),找到小于等于 key 的值,就将此值放入到坑中,并在这里形成新坑;然后是左边走,同样的,找到值 -> 填入坑 -> 挖新坑,如此重复,直到左右相遇,此时的相遇点必然是一个未填充的坑,当然这个坑就是给 key 值准备的


//挖坑法intPartSort2(int*pa, intbegin, intend)
{
assert(pa);
GetMid(pa, begin, end); //这是三数取中,后面会提//挖坑,先在key处挖坑,右边先走,找到小于等于key的,填入坑中,此处形成新坑intkey=pa[begin];
intlefti=begin;
intrighti=end;
intholei=lefti;  //坑位while (lefti<righti)
    {
while (lefti<righti&&pa[righti] >key)
righti--;
pa[holei] =pa[righti]; //将当前值填入坑中holei=righti; //挖新坑while (lefti<righti&&pa[lefti] <=key)
lefti++;
pa[holei] =pa[lefti];
holei=lefti;
    }
pa[holei] =key;
returnholei;
}
//快速排序voidQuickSort(int*pa, intbegin, intend)
{
assert(pa);
//思路:选出key,key 的右边小于key,key 的左边大于keyif (begin>=end)
return;
if ((end-begin+1) <20)
InsertSort(pa+begin, (end-begin+1));  //这里是小区间优化,后面会提else    {
//int keyi = PartSort1(pa, begin, end); //霍尔法intkeyi=PartSort2(pa, begin, end);   //挖坑法//int keyi = PartSort3(pa, begin, end); //双指针法//[begin, keyi - 1] keyi [keyi + 1, end]QuickSort(pa, begin, keyi-1);
QuickSort(pa, keyi+1, end);
    }
}



动图展示:

6da3766e34fd4dc9b7e88ef58d58df9c.gif

注意:为确保容易理解,这里直接选取优秀动图展示,动图来源


递归展开图与上面一致


💡双指针


思想:双指针的实现方式与上面两种截然不同,但最核心的思想仍是依赖 key,一样的先找 key,然后定义两个指针 prev 与 cur ,prev 的起始位置为 key ,而 cur 则是位于 prev 的下一位,判断 cur 处的值是否小于等于 key 值,如果是,则先 ++prev 后再交换 prev 与 cur 处的值,如此循环,直到 cur 移动至数据尾,最后一次交换为 key 与 prev 间的交换,交换完成后,就达到了快排的要求


//双指针法intPartSort3(int*pa, intbegin, intend)
{
assert(pa);
GetMid(pa, begin, end); //三数取中,后面会提//思想:cur找比key小的,++prev后,交换int*pKey=pa+begin;
int*prev=pKey;
int*cur=prev+1;
int*pend=pa+end;
while (cur<=pend)
    {
if (*cur<=*pKey)
swap(++prev, cur);
cur++;
    }
swap(prev, pKey);
returnprev-pa;
}
//快速排序voidQuickSort(int*pa, intbegin, intend)
{
assert(pa);
//思路:选出key,key 的右边小于key,key 的左边大于keyif (begin>=end)
return;
if ((end-begin+1) <20)
InsertSort(pa+begin, (end-begin+1));  //小区间优化,后面会提else    {
//int keyi = PartSort1(pa, begin, end); //霍尔法//int keyi = PartSort2(pa, begin, end); //挖坑法intkeyi=PartSort3(pa, begin, end);   //双指针法//[begin, keyi - 1] keyi [keyi + 1, end]QuickSort(pa, begin, keyi-1);
QuickSort(pa, keyi+1, end);
    }
}

动图展示:

注意:为确保容易理解,这里直接选取优秀动图展示,动图来源

3b5f47e4e32b4fcf86a3a034d6ec8e79.gif

递归展开图与上面一致


🖋️快排(迭代版)

前面说过,递归版快排 可能存在栈溢出问题。这时就需要使用迭代版快排,迭代版是借助栈来实现的,它不需要递归那样重复创建与销毁栈帧


分析:[begin ,end] 为一个大区间,借助递归是为了先使此区间的左边都比 key 小(等于),左边都比 key 大,当做完后,执行递归:[begin,key - 1] 为左半区间,[key + 1,end] 为右半区间;无论哪个区间,进入递归后都会形成新区间 [begin,end]


一顿分析下来不难发现,递归的目的是将区间不断细分,不断进行选 key 划分的操作,直到细分至1个元素或非法区间,递归就结束了,此时整组数据也都排好序了,下面来看看迭代版快排是如何实现的


思路:栈的特性是先进后出,我们可以先将最外层的区间值入栈,即将 begin 与 end 入栈,之后进行选 key 划分的操作,判断左右区间是否合法,合法才能入栈,继续循环,如果所有区间都非法,栈就空了,循环也就结束了


先将 begin 与 end 入栈

取出栈中的值,得到一个区间 [left,right] 注意:先取的是右边,因为栈的特性

根据此区间进行选 key 划分,当操作结束后,记录下当前 key 的位置

判断 key - 1 是否大于 left,如果大于,说明左半区间合法,将 left 与 key - 1 入栈,后续将会形成新的区间

同理,判断 key + 1 是否小于 right ,小于则说明右半区间合法,将区间值入栈

区间会生成区间,直到区间非法,直到所有的区间都非法,栈也就空了,此时也就不需要进行排序操作了,整个迭代版快排也就结束了

注意: 需要借助栈,因此会用到栈的头文件与源文件,缺失的同学需自行添加


//快排,迭代版voidQuickSortNonR(int*pa, intbegin, intend)
{
assert(pa);
//思路:利用栈的特性,先排序大范围,再排序小范围Stacks;
StackInit(&s);
StackPush(&s, begin);   //先将最开始的区间入栈StackPush(&s, end);
while (!StackEmpty(&s))
    {
intrighti=StackTop(&s);  //先取的是右,再取左StackPop(&s);
intlefti=StackTop(&s);
StackPop(&s);
intkeyi= (lefti+righti) /2;
//小区间优化if ((righti-lefti+1) <20)
InsertSort(pa+lefti, righti-lefti+1);
elsekeyi=PartSort3(pa, lefti, righti);    //排序,调用双指针法//判断是否符合条件入栈if ((keyi+1) <righti)
        {
StackPush(&s, (keyi+1));
StackPush(&s, righti);  //这里入的是右,与前面呼应        }
if ((keyi-1) >lefti)
        {
StackPush(&s, lefti);
StackPush(&s, (keyi-1));  这里入的是右,与前面呼应        }
    }
StackDestroy(&s);
}


动图展示:

无,上面这个迭代版核心部分调用的是双指针法进行选 key 划分,只不过将递归这个事情变成了入栈与出栈


未优化前的快排都一样

时间复杂度:


如果数据接近顺序或接近逆序,所耗时间为 O(N^2),理想情况下为 O(N*logN)

空间复杂度:


递归是会耗费空间的,因此空间复杂度为 O(logN)

稳定性:


不稳定,极有可能相同数中的后者与 key 交换,相对顺序被破坏

下面介绍针对排序的各种优化



🖋️优化一、三数取中


前面说过,接近有序或逆序的数据,对于快排是不太友好的,因为未优化前的快排选 key 始终是最右或最左,即有可能是最大或最小数,就像二分取中一样,快排只有尽可能取到中间数,才能发挥它的最大实力


因此我们可以借助一个函数:三数取中,分别取数据头、尾、中间进行比较,选取其中位于中间的数,再将其交换至数据首位(待会 key 取右边),经过这一优化后,快排的提升是非常明显的


//快排优化方案//优化一、三数取中voidGetMid(int*pa, intbegin, intend)
{
assert(pa);
intmid= (begin+end) /2;
intmidVali=begin;    //假设最左值为中值if (pa[midVali] >pa[mid])
    {
//1.begin > mid > endif (pa[mid] >pa[end])
midVali=mid;
//2.end > begin > midelseif (pa[end] >pa[midVali])
midVali=begin;
//3.end = begin > midelsemidVali=end;
    }
else    {
//1.mid > begin > endif (pa[end] <pa[midVali])
midVali=begin;
//2.end > mid > beginelseif (pa[mid] <pa[end])
midVali=mid;
elsemidVali=end;
    }
swap(&pa[begin], &pa[midVali]); //交换中间数至数据首}


性能对比

优化效果不言而喻,这个测试比较极端,有序组是绝对有序的,因此未加优化版快排是非常慢的

快排 排序50w数据(乱序) 排序50w数据(有序)
未加优化前的快排 耗时 154 ms 耗时 111697 ms
加三数取中后的快排 耗时 160 ms 耗时 80 ms

🖋️优化二、小区间优化


对于递归来说,越是接近小区间,所耗费时间就越长,越不利于排序,此时坚持使用快排是个不明智的选择,为此我们可以借助其他排序,弥补快排在小区间排序中的不足


这里借助的是直接插入排序,直接插入排序是个很不错的排序,稳定、速度也是中规中矩,小区间的定义取决于我们,我这里是将小于20的区间定义为小区间


//快速排序voidQuickSort(int*pa, intbegin, intend)
{
assert(pa);
//思路:选出key,key 的右边小于key,key 的左边大于keyif (begin>=end)
return;
if ((end-begin+1) <20)
InsertSort(pa+begin, (end-begin+1));  //这就是小区间优化else    {
//int keyi = PartSort1(pa, begin, end); //霍尔法//int keyi = PartSort2(pa, begin, end); //挖坑法intkeyi=PartSort3(pa, begin, end);   //双指针法//[begin, keyi - 1] keyi [keyi + 1, end]QuickSort(pa, begin, keyi-1);
QuickSort(pa, keyi+1, end);
    }
}


同样放个性能对比:

这里默认加了三数取中,小区间优化不像三数取中那样明显,但加了总比没加好

快排 排序50w数据(乱序) 排序50w数据(有序)
未加小区间优化前的快排 耗时 162 ms 耗时 86 ms
加小区间优化后的快排 耗时 107 ms 耗时 66 ms


🖋️优化三、三路划分


接下来介绍快排的完全版本:三路划分


分析:部分数据中存在多个与 key 相等的值,如果按照以前的快排方式,会有很多重复操作,因此我们需要将与 key 相等的值集中在中间,形成中路,比 key 小的放在其左边,大的放在其右边。这样会形成 左、中、右 三路数据,大大提高了快排速度


思路:三路划分的核心在于控制中路的左右边界,这里需要借助三个变量:lefti、righti、curi,显然 lefti 位于 begin 处,righti 位于 end 处,curi 位于 begin + 1 处。实现起来也很简单:


判断当前 curi 处值是否大于 key ,大于就将其与 righti 处的值交换,然后 righti-- 扩大右路

之后再判断 curi 是否小于 key ,小于就与 lefti 交换,此时 lefti++ 扩大左路,curi 也需要+1,因为 curi 一开始是在 lefti 的下一个,lefti 动,curi 也要跟着动,不然它就被覆盖了

如果 curi 既不大于 key,也不小于 key,说明它等于 key,此时将 curi 处的值划入中路,不需要交换,直接 curi++ 就行了

如此重复,直到 curi 大于 righti,显然此时有三条路,[begin,lefti]、[lefti+1,righti-1]、[righti,end] 这就是三路划分

//优化三、三路划分//将与key相同的值,分到中间,避免过多key而导致的性能下降//FV的意思是完全版本voidQuicSortFV(int*pa, intbegin, intend)
{
assert(pa);
if (begin>=end)
return;
if ((end-begin+1) <20)
InsertSort(pa+begin, end-begin+1);
else    {
GetMid(pa, begin, end);
intkey=pa[begin];
intlefti=begin;
intrighti=end;
intcuri=begin+1;
while (curi<=righti)
        {
if (pa[curi] >key)
swap(&pa[curi], &pa[righti--]); //扩大右路elseif (pa[curi] <key)
swap(&pa[curi++], &pa[lefti++]);    //扩大左路elsecuri++; //此时等于key,扩大中路的范围就行了        }
QuicSortFV(pa, begin, lefti-1);
QuicSortFV(pa, righti+1, end);
    }
}



注意: 此时的三数取中需要进行优化,不再取最右、中间、最左 这三个位置的数,而是取 最右、随机位置、最右,改进的原因是排序OJ题有些测试用例会搞事情,引入随机位置这个概念后,快排适应性会更强,当然,优化后的三数取中任然可以用于优化其他版本的快排


//int mid = (begin + end) / 2;  //之前的三数取中,mid 取的是中间位intmid=begin+rand() % (end-begin);   //mid 取随机位置


性能展示:

这里借助力扣中的一道中等题:排序数组,来展示展示完全版快排的实力

199201886a705ececc0739758a020f7.png

注:只有优化拉满的快排才能通过这道题,关键点之一就是三数取中取随机位置,也就是它的测试用例搞事情,迭代版快排也可以升级为完全版,返回一个数组,只将左右两路入栈就行了


📖其他排序


快排已经结束了,现在来说说其他排序:归并与计数,归并也是个很优秀的排序,稳定、快速,而计数是整数排序里的王者,要说他们有什么缺点,可能就是比较耗时间了


📃归并排序


归并排序的核心思想:合并两个有序数组,合并后数组就有序了


归并:回归与合并


归并排序多用于外排序,即对磁盘中的大数据进行排序


708dcd95d9a14842de85c67387f1753.png


🖋️归并(递归版)


思路:首先要得到左右皆有序的数组,然后合并,显然这个需要借助递归实现,将大问题化小问题:将原数据分为左右两个区间,交给递归让他们变得有序,最后再执行有序数组合并。依靠递出,区间会慢慢变小,直到区间内只有两个数,执行合并,然后逐渐向上回归,回归的过程就是不断合并的过程,数据最开始的左右区间会逐渐变得有序,当回归到第一层时,执行最后一次有序数组合并,数据整体就变得有序了


注意: 归并排序需要借助额外空间,将合并的数据先放在额外空间中,再通过内存拷贝的方式拷贝回原数组


//归并排序void_MergeSort(int*pa, intbegin, intend, int*tmp)
{
assert(pa&&tmp);
//思路:令数组左右两边有序,然后合并两个有序数组if (begin>=end)
return;
intmid= (begin+end) /2;    //分成左右两个区间进行递出_MergeSort(pa, begin, mid, tmp);    //递归左半区间_MergeSort(pa, mid+1, end, tmp);  //递归右半区间//下面是合并有序数组部分intleft1=begin;  //左半区间左边界intright1=mid;   //左半区间右边界intleft2=mid+1;    //右半区间左边界intright2=end;   //右半区间右边界intpos=begin;    //这是额外空间的下标,会随着递归层度而变化while (left1<=right1&&left2<=right2)
    {
//升序,取小的放前面if (pa[left1] <=pa[left2])
tmp[pos++] =pa[left1++];
elsetmp[pos++] =pa[left2++];
    }
//确保合并完成while (left1<=right1)
tmp[pos++] =pa[left1++];
while (left2<=right2)
tmp[pos++] =pa[left2++];
//将额外空间中的数据拷贝回原数组中memcpy(pa+begin, tmp+begin, sizeof(int) * (end-begin+1));
}
voidMergeSort(int*pa, intn)
{
assert(pa);
int*tmp= (int*)malloc(sizeof(int) *n);   //额外辅助空间assert(tmp);
_MergeSort(pa, 0, n-1, tmp);  //归并主程序free(tmp);  //记得释放tmp=NULL;
}


动图展示:

注意:为确保容易理解,这里直接选取优秀动图展示,动图来源

6115e49098734b78b4a683a0c7171d8e.gif


时间复杂度:


归并也是二分的思想 O(N*logN)

空间复杂度:


归并还需要额外空间,因此空间复杂度为 O(N + logN)

稳定性:


稳定,合并数组的过程中,两个相同数的相对位置不会被改变,因为前者总是比后者先并入数组

🖋️归并(迭代版)


归并也有迭代版,它不像快排那样借助栈,只需要定义一个范围 rangeN ,默认为1,将这个 rangeN 套入循环中,对 rangN 范围内的数据进行合并,rangeN 会逐渐扩大,直到其 >= n,此时范围非法,整个迭代版归并排序就完成了



//归并,迭代版voidMergeSortNonR(int*pa, intn)
{
assert(pa);
//思路:通过一个变量来控制归并范围,范围从1开始,到n-1结束int*tmp= (int*)malloc(sizeof(int) *n);
assert(tmp);
intrangeN=1; //范围从1开始while (rangeN<n)
    {
for (inti=0; i<n; i+=rangeN*2)
        {
//第一组intbegin1=i;
intend1=i+rangeN-1;
//第二组intbegin2=i+rangeN;
intend2=i+rangeN*2+1;
//迭代版需要考虑边界问题,不能越界if (end1>=n)
break;  //左半区间的右边界越界,直接跳出(只有一个数组,也没有合并的必要)elseif (begin2>=n)
break;  //右半区间的左边界越界,也是直接跳出(右半区间非法)elseif (end2>=n)
end2=n-1;   //右半区间的右边界越界,将其矫正至 n - 1 处,不能跳出,否则会有数据遗漏//因为是迭代,需要面面俱到intpos=i;
while (begin1<=end1&&begin2<=end2)
            {
if (pa[begin1] <=pa[begin2])
tmp[pos++] =pa[begin1++];
elsetmp[pos++] =pa[begin2++];
            }
//确保数据合并完成while (begin1<=end1)
tmp[pos++] =pa[begin1++];
while (begin2<=end2)
tmp[pos++] =pa[begin2++];
memcpy(pa+i, tmp+i, sizeof(int) * (end2-i+1));  //一块一块的拷贝        }
rangeN*=2;
    }
free(tmp);
tmp=NULL;
}


注意: 迭代版归并存在很严重的边界问题,如果不加以判断,那么肯定会发生越界问题,可以通过判断解决问题


方案一、直接跳出

注意: 采取直接跳出的话,只能将额外空间中的数据逐块拷贝回原数组,即在 for 循环中进行拷贝;如果整体拷贝,即在 for 循环外进行拷贝,是会出现问题的


//迭代版需要考虑边界问题,不能越界if (end1>=n)
break;   //左半区间的右边界越界,直接跳出(只有一个数组,也没有合并的必要)elseif (begin2>=n)
break;   //右半区间的左边界越界,也是直接跳出(右半区间非法)elseif (end2>=n)
end2=n-1;    //右半区间的右边界越界,将其矫正至 n - 1 处,不能跳出,否则会有数据遗漏


方案二、修正范围

修正范围不像直接跳出那样讲究,逐块拷贝或整体拷贝都是可行的


//修正范围不会跳出循环if (end1>=n)
{
end1=n-1;
begin2=n;
end2=n-1;
}
elseif (begin2>=n)
{
begin2=n;
end2=n-1;
}
elseif (end2>=n)
end2=n-1;

2e9cd6046f129a9dabc02026730e009.png

时间复杂度:


归并也是二分的思想 O(N*logN)

空间复杂度:


迭代版不用递归,归并还需要额外空间,因此空间复杂度为 O(N)

稳定性:


稳定,合并数组的过程中,两个相同数的相对位置不会被改变,因为前者总是比后者先并入数组

📃计数排序


计数排序又称非比较排序,计数排序的核心思想是映射,将待排序数据映射到辅助空间中的对应位置,这个位置的值,就是当前下标(即被映射值)的出现次数,当所有数据都被映射到辅助空间中后,把辅助空间遍历一遍,如果当前位置有值,就将下标值赋给原数组,直到当前位置为空,当辅助空间遍历结束后,计数排序就结束了


优化:采取相对映射,尽可能减小空间浪费


//计数排序voidCountSort(int*pa, intn)
{
assert(pa);
//思路:映射,将所有数映射到一片空间中,依次拷贝即可intmax=pa[0];
intmin=pa[0];
//找最大值与最小值for (inti=1; i<n; i++)
    {
if (pa[i] >max)
max=pa[i];
if (pa[i] <min)
min=pa[i];
    }
//相对映射,空间绝对够用,因为是 最大值-最小值+1int*mapSpace= (int*)malloc(sizeof(int) * (max-min+1));    //辅助空间assert(mapSpace);
memset(mapSpace, 0, sizeof(int) * (max-min+1)); //初始化为0for (inti=0; i<n; i++)
mapSpace[pa[i] -min]++;    //将数据映射到辅助空间中intj=0;  //控制原数组的下标,需要单独定义for(inti=0 ; i< (max-min+1);i++)
    {
//需要把当前位置清空while (mapSpace[i]--)
pa[j++] =i+min;  //拷贝至原数组    }
free(mapSpace); //释放原空间mapSpace=NULL;
}

e086317974b98f30a050bf64f91bd60.png

时间复杂度:


对于整数来说,计数是绝对的王者,无非就是将数据遍历了三遍,因此为 O(N)

空间复杂度:


需要开辟辅助空间 O(Max - Min + 1)

稳定性:


不稳定,计数排序数据都是直接覆盖的

注意:


计数排序适用于数据较为集中,且为整数的数据

绝对映射是直接根据最大值 + 1来开辟空间,很是浪费

📖排序总结


说明:快排与归并采用的都是递归版

排序名称 时间复杂度 空间复杂度 稳定性
直接插入排序 O(N^2) O(1) 稳定
希尔排序 O(N^1.3) O(1) 不稳定
简单选择排序 O(N^2) O(1) 不稳定
堆排序 O(N*logN) O(1) 不稳定
冒泡排序 O(N^2) O(1) 稳定
快速排序 O(N*logN) O(logN) 不稳定
归并排序 O(N*logN) O(N+logN) 稳定
计数排序 O(N) O(Max-Min+1) 不稳定


来看看各种排序的性能表现(10w无序数据):

说明:快排(完全版+递归),归并(递归)

bf72d5116333f41ef1f9b1137479927.png


📘总结


排序有很多种,有好的、有坏的,我们要重点掌握优秀的排序,比如希尔和堆排,当前其他排序的思想也得清楚,知道怎么实现就行了。排序界有三位大哥:希尔、快排、归并,关于快排C语言有专门的库函数qsort实现,这个函数优化极佳,是最快的快排。


如果你觉得本文写的还不错的话,期待留下一个小小的赞👍,你的支持是我分享的最大动力!


如果本文有不足或错误的地方,随时欢迎指出,我会在第一时间改正


目录
相关文章
|
2月前
|
算法 搜索推荐 Java
数据结构与算法学习十三:基数排序,以空间换时间的稳定式排序,速度很快。
基数排序是一种稳定的排序算法,通过将数字按位数切割并分配到不同的桶中,以空间换时间的方式实现快速排序,但占用内存较大,不适合含有负数的数组。
41 0
数据结构与算法学习十三:基数排序,以空间换时间的稳定式排序,速度很快。
|
2月前
|
存储 搜索推荐 算法
【用Java学习数据结构系列】七大排序要悄咪咪的学(直接插入,希尔,归并,选择,堆排,冒泡,快排)以及计数排序(非比较排序)
【用Java学习数据结构系列】七大排序要悄咪咪的学(直接插入,希尔,归并,选择,堆排,冒泡,快排)以及计数排序(非比较排序)
33 1
|
2月前
|
搜索推荐 索引
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(二)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
2月前
|
搜索推荐 C++
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(一)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
2月前
|
算法
蓝桥杯宝藏排序 | 数据结构 | 快速排序 归并排序
蓝桥杯宝藏排序 | 数据结构 | 快速排序 归并排序
05_用一个栈实现另一个栈的排序
05_用一个栈实现另一个栈的排序
|
2月前
|
人工智能 搜索推荐 算法
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(三)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
4月前
|
C语言
数据结构——排序【上】
数据结构——排序【上】
|
4月前
|
搜索推荐 算法 测试技术
【数据结构】排序
【数据结构】排序
|
5月前
|
搜索推荐
【数据结构常见七大排序(三)上】—交换排序篇【冒泡排序】And【快速排序】
【数据结构常见七大排序(三)上】—交换排序篇【冒泡排序】And【快速排序】
【数据结构常见七大排序(三)上】—交换排序篇【冒泡排序】And【快速排序】