常见排序算法
- 前言
- 排序分类
- 一、插入排序
- 直接插入排序
- 希尔排序
- 二、选择排序
- 直接选择排序
- 堆排序
- 三、交换排序
- 冒泡排序
- 快速排序
- 1、hoare版本
- 2、挖坑法
- 3、前后指针版本
- 快排时间复杂度分析
- 快排的优化
- 4、快排非递归实现(利用栈实现)
- 5、快排非递归实现(利用队列实现)
- 6、三路并排
- 四、归并排序
- 1、归并排序
- 2、归并排序的非递归
- 3、归并排序的非递归(利用栈和队列)
- 总结
前言
说起排序大家想必一定不陌生了,在生活中排序可是无处不在:
就比如你想在想买手机:你想以手机的价格为主,我们想看一看最贵的手机:
那么我们就可以以价格来作为排序,先看最贵的,再看最便宜的,比如:
我们可以看到有很多方式来供我们选择;
如果我们先要看看大学排名也是可以的:
当然,这些数据量都是很大的,为了能够让程序更快的做出反应,提供给用户更好的体验,那么高效、快速、稳定的排序算法是必不可少的!
下面我们来介绍一下几种比较常见的排序!
排序分类
当然有的学校可能还会学:桶排序、基数排序等排序算法(比如我的学校😊😊😊)这些排序算法个人感觉实用性不强,这里就不讲它们了!
一、插入排序
插入排序基本思想:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列
直接插入排序
直接插入排序在我们生活中也是比较常见了,就比如我们在都地主的时候,摸牌阶段,我们是一张一张摸的,然后我们每摸一张就将其插入到正确位置,一般情况下咱们手里的牌都是有序的,然后将摸到的排插入进去!
下面我们就来模拟一下这个摸牌过程:
现在我们想要将其插入到手牌中的正确位置:
我们该怎么办?
我们从右往左依次将摸到的牌和手牌比较呗:如果我们摸到的牌比手牌大,那么将手牌往后挪一下呗,然后继续比较与下一张手牌比较呗!
当然一般会出现3种情况:
1、摸到的牌刚好大于等于最后一张手牌:
那么我们摸到的牌直接插入到后面就是了;
2、摸到的牌比手牌中任意一张牌都小:
那么我们的手牌肯定都要向后移动一步,把最开始的位置留给摸到的牌:
3、我摸到的牌刚好插在手牌的中间:
经过调整过后:
然后我们将上述情况转换为编程的情况:
就是一个有序将一个数插入到有序数组里面去:
我们先将待插入数据保存一下:
然后呢去和有序数组里面的值比较:
当然我们需要注意空白处并不是真的没有数据,只是我们为了更加形象的描述故意画成的空白!!
实际上是这样的:
我们不用担心将数据往后挪过后将,待插入数据覆盖掉!
我们在插入比较之前就已将待插入数据放在key变量里面保存起来了!
然后我们接着进行比较,发现还是和上面一样的情况,我们继续将数据往后挪:
然后我们发现还是和上面一样的情况,那么还是重复上面的情况:
然后我们在进行下一轮的比较:
我们发现这一次,刚好将key插入到合理的位置!!至此一趟插入排序完成!这个有序区间又扩大了一格!!
下次如果我们还想插入数据的话,我们直接重复上面的步骤即可!!;
当然有种极端情况:key比有序区间中的任何一个数都小,那么数据就会不断往后移,end不断向前走,最后end会减到-1,这时候我们也就可以停止往前走了,直接插入在end后面,也就还是nums[end+1]=key;
当然上面的以上操作都是有前提的!!
必须是对有序区间进行插入!!
必须必须!!!
为此我们将这种思想运用到对数组进行排序:
既然你说了必须是有序区间,那么一个元素的区间是有序的吧!!那么既然这样,我就将其下一个元素当作key来插入,当我插入完成过后,我的有序区间就扩大了一格!后序我在重复上诉步骤,不就将整个数组变得有序了?
经过插入排序过后,有序区间变为了[0,end+1] (这个end是刚开始表示区间时的end,不是变化后的end!下图说的end也是如此!)
好了大致思路我们已经清楚了,现在我们来大致写一写代码:
void InsertSort(int* nums, int len)//直接插入排序
{
for (int i = 0; i < len - 1; i++)//注意控制着整个有序区间的范围,
{/这里的循环停止条件是len-1,不是len,如果小于len的话key就有越界的风险,就比如刚好end=len-1,那么key=nums[len],这不妥妥越界嘛!
//下面是[0,end]区间的一趟插入排序
int end=i;//为了让有序区间不断扩大,我们需要每次都更新end
int key = nums[end + 1];
while (end >= 0)
{
if (key >= nums[end])
break;//
else
{
nums[end + 1] = nums[end];
end--;
}
}
nums[end + 1] = key;//这里是处理的中间插入和头部插入的情况!这两个插入元素的位置一样!
//至此才完成一趟插入排序!!
}
}
然后我们来简单分析一下时间复杂度、空间复杂度:
首先空间复杂度O(1),毫无疑问;
那么时间复杂度?
O (N ^ 2)
插入排序最坏的情况就是对逆序进行排序,那么每一趟都会挪动数据:
第一次:挪动1次
第二次:挪动2次
第三次:挪动3次
……
第n-1次:挪动n-1次
很明显是个等差数列!
然后根据大O渐近表示法,时间复杂度就是O(N ^ 2);
那么既然最坏的情况我们都讨论了,最好的情况嘞!
最好的情况就是数据已经有序了,那么我们每一次都不需要挪动数据,只需要比较一次,就break掉了,所以最好的情况的时间复杂度就是O(N)这可是个非常不错的速度;
其实我们在认真想一下,如果数组越接近有序的话是不是插入排序的时间复杂度就越接近O(N)!!!
相反数组越接近逆序,时间复杂度就越接近O(N^2);
为此呢我们从这一点看出,插入排序是一个适应性比较强的排序算法!!
直接插入排序的特性总结:
1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1),它是一种稳定的排序算法
4. 稳定性:稳定
希尔排序
上面我们说到插入排序:如果数组越接近有序的话插入排序的时间复杂度就越接近O(N),
那么希尔这个大佬呢就这样想:那么我先对数组进行预排序(预排序不是直接插入排序,可能排完过后数组仍然无序!),最后再进行一次插入排序,这样的话时间复杂度是不是就降低了点呢?
事实就是这样的!
为此希尔排序的主要思想就是:
1、先对数组进行预排序;
2、在对数组进行直接插入排序;
什么是预排序呢?
就是将数组分成gap组,同时所有距离为gap的为一组;然后对同一组的数据进行直接插入排序:
就比如下面:
就比如现在我红色线条的为一组,绿色线条的为一组,蓝色线条的为一组!
然后我们分别对3组进行直接插入排序,为了方便描述我们现在将这3组单独抽出来看:
这么看就明白了吧!其他两组也是这样的!!
现在我们对红色这一组进行直接插入排序,但是我们需要注意一点,这里的key不在是nums[end+1]
而是nums[end+gap];同时我们的end往前走也不是一步一步的走而是一口气走gap步,这一点尤其需要注意!
第一组预排序:
第二组预排序:
第三组预排序:
整个组排完过后(也就是一个gap预排序完):
我们可以发现比之前跟接近有序了(或许现在看不出来,随着gap不断减小数组会越来越接近有序)
为此我们可以先把这一组插入排序的代码写出来:
for (int i = j; i < len - gap; i += gap)//这里的停止条件是i<len-gap
{/(举个特例)这里我们可以这样理解,红色一组结束条件:i<len-3;
/绿色一组结束条件:i<len-2;
/蓝色一组结束条件:i<len-1;
//为了能够得到一个通用的结束条件,也就是要同时满足这三个不等式,那么我们只能让i<min{len-3,len-2,len-1};那么写出一个通用表达式就是:i<len-gap;这是第一组的结束条件,也是所有组的循环结束条件;
int end = i;
int key = nums[end + gap];
while (end >= 0)
{
if (key >= nums[end])
break;
else
{
nums[end + gap] = nums[end];
end -= gap;
}
}
nums[end + gap] = key;
}
那么我们为什么要进行预排序呢?主要是想快一点将大数排到后面去,小数排到前面去;
gap越大,数组预排序过后越接近无序;
gap越小,数组预排序过后越接近有序;
当然这只是一组的插入排序,我们需要对gap组都进行一次,但是再次之前我们先来计算一下,一组的时间复杂度;
总共gap组,每一组元素数目:n/gap(这里是大概算的,多一个少一个无所谓,本来时间复杂度就是估算)
那么
第一次:挪动1次;
第二次:挪动2次;
……
第n/gap-1:挪动n/gap-1次;
很明显这又是一个等差数列:
时间复杂度:O(n)=[(1+n/gap-1)x (n/gap-1) ] x1/2=1/2 x ((n/gap)^2-(n/gap));
现在我们来实现一下gap组完成预排序:
for (int j = 0; j < gap; j++)//控制有多少组数据进行插入排序
{
for (int i = j; i < len - gap; i += gap)//控制每一组的插入排序
{
int end = i;
int key = nums[end + gap];
while (end >= 0)//控制一躺插入排序
{
if (key >= nums[end])
break;
else
{
nums[end + gap] = nums[end];
end -= gap;
}
}
nums[end + gap] = key;
}
}
现在又gap组,那么这gap组的时间复杂度就是:O(n)=1/2 x (n^2/gap-n);
现在我们以gap为一组的进行的预排序已经全部完成!我们需要跟新一下gap,当然我们不可能让gap变大,那样只会与我们的预期背道而驰!更接近无序!为此我们的gap只有不断的缩小才能保证预排序越来越接近有序,这样我们才会减轻直接插入排序的负担! 当然也无需再单独掉一次直接插入排序,当gap=1的时候,就是直接插入排序了,gap等于1,表示将整个数组分成1组,增量为1的为一组:
这就直接是直接插入排序了,所以我们只需保证最后一次gap等于1就可以了;
为此完整的希尔排序:
void ShellSort(int* nums, int len)//希尔排序
{
int gap = len;
while (gap > 1)
{
gap /= 2;//这里也可以给gap=gap/3+1;(据说这种方式比前一种快一点点,也就快一点点而已)
for (int j = 0; j < gap; j++)
{
for (int i = j; i < len - gap; i += gap)
{
int end = i;
int key = nums[end + gap];
while (end >= 0)
{
if (key >= nums[end])
break;
else
{
nums[end + gap] = nums[end];
end -= gap;
}
}
nums[end + gap] = key;
}
}
}
}
刚才我们上面计算了对gap组进行预排序的时间复杂度是:O(n)=1/2 x (n^2/gap-n)
第一个gap(注意是第一个!!):gap=n/2将其带入上式:
O(n)≈ n
整个希尔排序最坏时间复杂度:
第一次 :gap1=n/2 O1(n)=1/2 x n x (2^1-1);
第二次 : gap2=gap1/2 O2(n=1/2 x n x (2^2-1);
第三次 : gap3=gap2/2 O3(n)=1/2 x n x (2^3-1);
……………………
第log2(n)次 gap=n/2^(log2(n)) O(n)=1/2 x n x (2^(log2(n))-1);
为什么会只有log2(n)次?
我们的n/2/2/2/2/2/……=1;
最终推出:gap最多变换:log2(n)次!
然后全部加起来:
O(N)=n^2-n-1/2 x n x log2(n);
使用渐近表示法:
O(N)≈N^2;
)
所以希尔排序最坏的情况下时间复杂度还是O(N ^ 2),最坏的情况是啥?逆序啊,但是实际上哪有这多的逆序啊,基本上大多数数据都是无序的,有序和逆序的数据少之又少!!为此希尔排序我们不能以最坏的情况和最好的情况(最好O(N))来定义希尔排序的时间复杂度,我们需要用平局时间复杂度来定义希尔排序的效率:也就是O(N( ^ 1.3)) 我们常说的希尔排序的时间复杂度是指的其平均的时间复杂度,不是最坏的情况,这一点不同于其他排序算法!
当然关于这个时间希尔排序的时间复杂度的证明,实乃玩不转,我直接上张图片吧:
1、
2、
这下能看到希尔排序的时间复杂度有多难求了吧!
希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就
会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的
希尔排序的时间复杂度都不固定:
二、选择排序
基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的
数据元素排完 。
直接选择排序
听名字就很简单粗暴啊,就是再一堆数中选出一个最小的数或最大的数;与数组中对应位置的值进行交换,然后再去剩余数中选出次小或次大的数,再与数组中的值进行交换!这样不断重复,最后待选数据只剩一个的时候我们就停止选数,这是排序结束:
但是这里呢我们做一下小小的优化,我们一次性选择两个数出来,具体方案就是:
我们在[left,right]区间选出最小值的下标和最大值的下标将其分别和left位置值交换,right值交换,
然后缩小区间,left++,right–;当区间只剩下一个元素的时候我们就可以停止了!
代码实现:
void SelectSort(int* nums, int len)//选择排序
{
int left = 0;
int right = len - 1;
int maxi = 0;
int mini = 0;
while (left < right)
{
maxi = mini = left;
for (int i = left; i <= right; i++)
{
if (nums[i] > nums[maxi])
maxi = i;
if (nums[i] < nums[mini])
mini = i;
}
if (maxi == left)//这里我们需要注意一下
maxi = mini;
Swap(nums + mini, nums + left++);
Swap(nums+maxi,nums+right--);
}
}
我们来解释一下为什么倒数第6行会有个if语句,主要是怕maxi和left位置重了,就比如:
像这样的话,由于我们是先将mini位置的值换到了left位置,也就导致了我们的maxi所指的最大值就不在left位置了,被调到了mini的位置,如果我们不做任何调整的话,我们将maxi所指的值调到right就不是将最大值跳到right所指的位置!为此对于这种情况,我们需要修正一下maxi;
时间复杂度不用说了铁铁的O(N^2);
并且对于这个排序来说没有什么最坏和最优情况,不管是什么情况都会去遍历一遍!
比较差劲!!但是简单粗暴!
直接选择排序的特性总结:
1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
堆排序
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是
通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
关于堆排序的详细描述,可以移步至我的另一篇博客:二叉树和堆
堆排序的特性总结:
1. 堆排序使用堆来选数,效率就高了很多。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
三、交换排序
交换排序基本思想: 所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
冒泡排序
说起冒泡排序大家相比一定不陌生,这个算法在大学校园可谓是备受欢迎!游走于各大高校之间!
主要是由于冒泡排序太形象了!
主要思想就是,每进行一次冒泡就将最大值或最小值冒到了数组最后一个位置,然后数组元素减减,因为我已经将最大值或最小值冒到最后面了,不需要再对最后一个元素进行冒泡了,因此我们的数组元素个数要-1,减的就是已经冒好的元素!
代码实现:
void BubbleSort(int* nums, int len)//冒泡排序
{
for (int j = 0; j < len - 1; j++)
{
int exchange = 0;
for (int i = 0; i < len -j- 1; i++)
{
if (nums[i] > nums[i + 1])
{
Swap(nums + i, nums + i + 1);
exchange = 1;
}
}
if (!exchange)
break;
}
}
这里我们稍微做了一点点优化,我们如果在某一次冒泡中,发现数组已经有序了,那么就不用再进行剩下的冒泡了,直接break掉就好了,为了实现这一操作,我们定义了标记参数,exchange对每次冒泡都赋值为0,表示假设这一次冒泡是有序的,如果它不进入if语句,exchang就不会被改变,最后冒完过后判断一下exchange,如果exchange还等于0,那么说明数组已经有序了,无序在进行下一次冒泡;如果exchange等于1,就表示假设错误,我们无法判断这一次冒泡完毕过后数组是否有序,因此我们需要再次进行新的冒泡!
这样一来的话,冒泡排序最好的情况就是数组已经有序了,这时时间复杂度就是O(N);
最坏的情况还是逆序:
第一次冒泡: n-1次
第二次冒泡:n-2次
第三次冒泡:n-3次
……
第n-1次冒泡:1次
很明显是个等差数列,时间复杂度还是O(N^2);
冒泡排序的特性总结:
1. 冒泡排序是一种非常容易理解的排序
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:稳定
快速排序
正如它的名字一样这个算法很快!不快的话怎么敢叫这个名字!
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
1、hoare版本
该版本主要是思想是先将数组分成一个区间:
然后呢将left或者right作为keyi的基准值随便一个都行,如果是选择left作为keyi的基准值的话,那么我们就先从右边开始找比下标keyi所指的值 严格小(注意是严格小!!不包括等于!!) 的值,找到了就停下来:(当然如果是right作为keyi的基准值,那么就先从keft开始!)
现在right已经找到了,现在我们让left去找比keyi所指的值严格大的值:
我们也找到了,然后交换left和right所指向的值:
然后我们再继续重复上面的步骤:
再次交换:
再次进行重复上述步骤:
最后我们发现right与left重叠,这时候我们就可以停下来了,我们将keyi所指向的值和left或right此时指向的位置的值进行交换:
清晰一点:
我们可以发现原来keyi所指向的值现在到left位置去了,我们再仔细看一下,我们会发现再left的右边全部都是比6小的值,再left的右边全是比6大的值,你看我们是不是将6一下拍到了正确的位置,你按照升序来看这个数组的话,6是不是数组中第6 大 的元素,而我们通交换过后是不是就将6交换到正确位置去了!那么是不是说明我们接下来的排序就不需要对其进行排序了,因为6这个元素已经排好了,已经归为到正确位置了!,那么我们是不是可以将区间分为
这样两个区间,然后对这两个区间分别使用同样的办法进行交换,最有又会有一个元素被交换到合适的位置,然后我们又可以以这个元素的下标为分界将区间又分为两个子区间,就这样不断交换,不断分区间,最后区间到了不可再分或者区间只剩下一个元素的时候我们就可以不用继续往下分了,为此这是一个妥妥的递归啊!
动态演示:
代码实现:
void Quick1(int* nums, int left, int right)
{
if (left >= right)//区间不可再分或者区间只剩下最后一个元素就不用再分了
return;
int begin = left;
int end = right;
int keyi = right;//这里如果是选择right为基准值那么就先从左边开始找
//如果是选left的话,下面的循环就先从右边开始找!这个顺序一定正确!!
while (begin < end)
{
while (begin < end && nums[begin] <= nums[keyi])//注意这里我们需要加上等号,因为我们要找到是严格大或者严格小!等于的情况也会被过滤掉!同时也需要将上范围,不能让begin一直--,因为如果数组已经有序了的话,begin就一直找不到比nums[keyi]严格小的值,就会越界:比如1 2 3 4 5,begin就会越界,因此我们需要限制住begin;下面的left也是同理!
begin++;
while (begin < end && nums[end] >= nums[keyi])
end--;
Swap(begin+nums,end+nums);
}
Swap(nums+keyi,nums+end);
Quick1(nums,left,end-1);
Quick1(nums, begin+1, right);
}
void QuickSort(int* nums, int len)//快速排序
{
Quick1(nums,0,len-1);
}
下面我们来回答一下,为什么keyi=left,就必须从右边开始找,keyi=right,就必须从左边开始找:
首先我们先来看一张图:(假设keyi以left为基准)
这样看来没有问题啊吧,毕竟我们是让right找小,left找大,两者都找到了,就互相往对面的区间交换,这就保证了左区间的值都是小于等于nums[keyi]的,右区间的值都是大于等于nums[keyi]的
那么最后left一定会和right重叠在一起对吧!
好,现在left和right重叠在一起主要有两种情况:
1、left停下,right–与left相遇;
2、right停下,left++与right相遇;
我们现在来讨论一下情况1:
right为什么–,因为它要找比nums[keyi]严格小的值,那些大于等于等于它的值就会自动跳过去,直到找到为止,left才开始动起来,你们在仔细想一想此时left所处的位置是不是上一次right与left完成交换过后的位置,那么left所处的位置的值一定是比nums[keyi]的值小的,因为这时right找小找到的,然后和left找大找到的互换的因此我们回到这一次,left所处的位置一定是比nums[keyi]的值要小的!那么我left不动right一直–最后不就刚好找到比nums[keyi]严格小的值嘛,最后左区间和右区间重叠在一起,为了:
为了将这两个区间更好的分割开,我们就以nums[keyi]来作为两个区间的分割线,所以我们只需将这个分割线部分替换成nums[keyi]就可以了,这样真正就保证了nums[keyi]左边全是小于等于它的值,右边全是大于等于它的值!这样我们就将nums[keyi]放到了正确的位置;
下面我们来讨论情况2:
right停下是因为他已经找到了比nums[keyi]严格小的值,而left不断寻找严格大于nums[keyi]的值直到遇到right也没找到,但是此时right和left指向的值还是比nums[keyi]值严格小的值,至此为了更好的区分两个区间,我们把nums[keyi]值来作为两个区间的分割线,但是原本left、right位置的值我们又不能把他扔了,我们干脆将其和keyi值互换一下值不就行了嘛,换了过后还是满足left左边的值小于等于nums[keyi],r=left右边的值还是大于等于nums[keyi]的;
至此我们讨论为什么上面这种交换方法行;
紧接着我们来论证一下为什么从左边开始,再从右边开始不行:
最终left与right相遇还是分为两种情况:
1、left停下,right–到相遇点:
由于我们是先从左边开始的,那么left停下那么说明它找到了比nums[keyi]严格大的值,再等着right找到比nums[keyi]严格小的值来交换,right不争气啊没找到,最后直接与left相遇,然后我们再将nums[keyi]和nums[left]交换,发现还是满足left左边全是小于等于nums[keyi]的,left右边(right右边)全是大于等于nums[keyi]的,没问题啊,似乎也是可以的;
2、right停下,left++到相遇点:
right停下的位置的值是不是上一次的left位置的值交换过去的,交换给right的是严格比nums[keyi]大的值对吧!,那么回到本次,right所指向的位置的值一定比nums[keyi]大,那么最后left、right相遇点的值一定比nums[keyi]大,我们如果再次交换left位置的值和keyi位置的值的话,那么就会将比nums[keyi]大的值交换到left的左边,这视乎与我们的预期相违背了,我们预期是想将left左边全是小于等于nums[keyi]的值,现在多了个比nums[keyi]大的值出来,显然不太合理!!从这里我解释了为什么按上面方法去走是不行的!
为此我们如果选定
left为keyi,就必须先从右边开始找值;
left为right,就必须先从左边开始找值;
先后顺序千万不能变!!!
2、挖坑法
其实理解了hoare版本,这个版本也能很快上手:
还是将数组看成一个数组现在我们定义个指针hole表示“坑”,然后把“坑”里面的元素先保存在key里面;然后剩余步骤与上面版本的做法差不多,right去找比key严格大的值,只不过,right找到过后并不是再等left找到严格比key小的值过后二者交换,而是马上将right位置所指的值丢进坑里面,然后right成为新的坑!:
然后接下来就该left去找严格比key大的值了操作同上,最后left和right一定会相遇,那么就可以停止循环了,同时将key放入left位置,最终key会被插入到正确的位置,也能做到所有小于等于key的值都被扔到了key的左边,所有大于等于key的值都被扔到了key的右边!
动态演示:
代码实现:
void Quick2(int* nums, int left, int right)
{
if (left >= right)
return;
int begin = left;
int end = right;
int hole = left;
int key = nums[hole];
while (begin < end)
{
while (begin < end && nums[end] >= key)
end--;
nums[hole] = nums[end];
hole = end;
while (begin < end && nums[begin] <= key)
begin++;
nums[hole] = nums[begin];
hole = begin;
}
nums[end] = key;
Quick2(nums,left,end-1);
Quick2(nums,end+1,right);
}
void QuickSort(int* nums, int len)//快速排序
{
Quick2(nums,0,len-1);
}
3、前后指针版本
前后指针版本理解起来比前两种方法要难一点,但是代码方面比较简洁:
前后指针的的大概思想就是:
还是选最左边或最右边的值作为基准keyi;
假设我们选择以左边作为keyi;
现在我让我的prev从left开始;
cur从prev+1开始:
现在我们还是让cur找小,那么当nums[cur]>=nums[keyi]时cur++,当不满足上述条件时,就说明找到了小于nums[keyi]的值,那么这时候我的prev先++,然后将此时prev所指的值与cur此时所指的值进行交换;然后cur往后走,直到cur超出了数组范围就可以停下来了,此时再将prev所指向的值与keyi所指的值进行交换,这时也能达到prev左边的值全是小于等于nums[keyi]的,右边的值全是大于等于nums[keyi]的:
我们来详细走一下流程:
动图:
我们可以来想一下为什么这样的方法可行?
首先我们可以将数组分为两部分看:
在没有找到nums[keyi]的合适位置之前我们先把它放在数组首元素来看,也就是说我们现在只看蓝色部分!现在cur的任务时找到比nums[keyi]小的值,那么反过来想就是再蓝色部分中所有大于等于nums[keyi]的值都将被cur无情略过,那么现在你想象一下,假设这个蓝色数组中全都是大于等于nums[keyi]的值,那么蓝色部分的数组元素是不是全部都被cur略过,而此时prev是一直没动的!那么prev和cur此时是不是有差距!,prev和cur之间的元素是不是一定都是比nums[keyi]大的或者与之相等的,我们只看蓝色部分哈!好,现在明白了prev与cur之间的元素全是比nums[keyi]大于等于的元素,那么我prev+1位置的元素也就是一定比nums[keyi]大的或者与之相等的元素,那么现在假设我找到了一个nums[cur]<nums[keyi]的值,我为什么会是prev先加加在交换与cur所指的值呢L?
这主要是由于prev的初始位置所决定的,prev初始位置在蓝色数组的外边,现在我们是对蓝色数组进行操作,我们自然的先prev++进入蓝色数组才能进行交换啊!也是就是说,prev所指的位置是上一次交换后的结果,也可以理解为prev所指的位置的指一定是小于等于nums[keyi]的,要想得到大于nums[keyi]的值,prev必须先加加!因为我们刚才不是说了嘛prev与cur之间的元素全是大于等于nums[keyi]的!
下面我们们利用代码来实现:
void Quick3(int* nums, int left, int right)
{
if (left >= right)
return;
int prev = left;
int cur = prev + 1;
int keyi = left;
while (cur <= right)
{
if (nums[cur] < nums[keyi])//这里条件还可以改为(nums[cur]<nums[keyi]&&++prev!=cur)//如果改为这样的话,可以处理当prev与cur指向同一个元素时,我们不需要进行交换!(交换的代价还是比较大(这是对于老的计算机来说,现在的计算机不存在这个问题!))
{
prev++;//改成第二种条件的话,就不用这条语句!
Swap(nums + prev, nums + cur);
}
cur++;
}
Swap(nums + prev, nums + keyi);
Quick3(nums, left, prev - 1);
Quick3(nums, prev + 1, right);
}
void QuickSort(int* nums, int len)//快速排序
{
Quick3(nums, 0, len - 1);
}
快排时间复杂度分析
首先理想情况下假设这个数组能够一直被二分的话:
你看着是不是就像是一颗满二叉树!,然后你看一个区间[left,right]的的时间复杂度是不是就是O(right-left+1);
第一层:[left,right] 时间复杂度就是 O(N)
第二层:第一个区间n/2个元素,第二个区间n/2个元素,每个区间的时间复杂度O(n/2)两个区间加起来:O(N)
后面一次类推……
也就是说每一次的时间复杂度都是O(N)
那么总共有log2(N)层,
所以整个快速排序总的时间复杂度就是O(N*log2(N));
当然我们都是了是理想情况,实际情况是一个区间不可能每次都能被二分,有的区间可能分完过后就只有左区间或者只有右区间,也就是一颗普通二叉树,但是那些缺失的部分来说对于N来说完全是微不足道(N很大,比如100W);
换个角度理解就是如果数组能被二分的次数越多,时间复杂度就越接近NlogN,相反如果数组每次都不能被二分的话,那么时间复杂度就越接近O(N^2),就比如对一个已经有序的数组在进行排序,那么每一次都排完一个数过后,都只能二分出一个区间,然后再下去遍历……:
比如:
我们有没有什么办法解决这个问题呢?
快排的优化
快排优化
1、三数取中
造成这个的主要原因是由于我们固定的选着了最左边或者最右边作为基准值,可是如果我们所选的基准值,刚好就是这个区间的最值呢?是不是就不能保证这个区间能够二分,那么它的形状就与满二叉树的形式不太接近,时间复杂度自然啊而然的就更趋向与O(N^2);那么如何才能避免我们所选的基准值不是该区间的最值呢?
那么我们就不能让我们的基准值不变,要有让它“动”起来:
我们可以取该区间的左端点值,右端点值,中间点的值,三个点的元素进行比较,选择处于中间的值去做基准值,当然我们还是让左端点最为keyi,那么我么就需要将处于中间的值与left所指向的值进行交换,然后在开始什么、hoare、挖坑、双指针,这样就尽可能提高了区间能够被二分的概率!那么对于有序这种极端情况我们就能做出一点优化!:
int GetMid(int* nums, int left, int right)//三个数中选出处于中间大的数
{
int mid = (right - left) / 2 + left;//这里也不一定要固定取中间值,也可以取随机值,但是范围要在[left,right]之间
if (nums[mid] > nums[left])
{
if (nums[mid] < nums[right])
return mid;
else
{
if (nums[left] > nums[right])
return left;
else
return right;
}
}
else
{
if (nums[mid] > nums[right])
return mid;
else
{
if (nums[right] < nums[left])
return right;
else
return left;
}
}
}
int PartSort1(int* nums, int left, int right)
{
int mid = GetMid(nums, left, right);//优化--三数取中
int keyi = left;
Swap(nums + keyi, nums + mid);//将选出来中间值交换到keyi位置去
while (left < right)
{
while (left < right && nums[right] >= nums[keyi])
--right;
while (left < right && nums[left] <= nums[keyi])
++left;
Swap(left+nums,right+nums);
}
Swap(nums+keyi,nums+left);
return left;
}
void QuickSort(int* nums,int left ,int right)
{
if (left >= right)
return;
int hole = PartSort1(nums,left,right);
QuickSort(nums, left, hole - 1);
QuickSort(nums,hole+1,right);
}
其他两个版本的快排也可以这样优化;
2、小区间优化
快排还可以优化。当分到区间只剩下10个元素左右的时候,我们就可以考虑不用往下递归了,毕竟递归是有风险的,递归的太深了,容易爆栈!所以当区间分到只剩下不到10个元素的时候,我们就可以考虑使用一下直接插入排序!
解释一下小区间优化的好处:
如果我们当我们的区间只剩下10个元素的时候我们还去递归的话,大概需要递归3次,你在想一下,这还只是对一个只剩10元素的区间的递归次数,实际情况肯定是不止者一个只有10的元素的区间,是指数多个!:
红线一下的部分都是需要我们去递归的!就光最后一层我们就需要去递归调用至少一半以上!递归是有代价的!!现在我们加上小区间优化,那么最是不是就相当于每次只要区间还剩下10个以内的元素的时候,我们就去直接插入排序!是不是就是说后面3层我们就不用再去递归处理了,那么这样一来你看啊(当成满二叉树看)就光最后一层就占了整颗树一半的数目,现在我们加上小区间优化,后3层都不用去递归,直接减少了整个程序75%左右的递归次数!!!不得不说这个优化是真的6!
ps:官方库里面也是利用了上面两种方式优化,比如C语言的qsort就是这样优化的!
代码方面:
void QuickSort(int* nums,int left ,int right)
{
if (left >= right)
return;
if (right - left + 1 <10)//小区间优化
{
InsertSort(nums+left,right-left+1);
return;
}
int hole = PartSort1(nums,left,right);//三数取中有,这里就不写了
QuickSort(nums, left, hole - 1);
QuickSort(nums,hole+1,right);
}
但是不管怎么优化总归是递归,是递归就有一个致命的缺点,就是递归次数太深了容易爆栈,因此每一种递归写法,我们都应该考虑一下能不能写成非递归的形式,快排亦是如此!
4、快排非递归实现(利用栈实现)
栈的特点就是先进行后出:
而我们的单躺排序是对一个区间进行操作,因此我们需要往栈里面存入区间,取的时候也应该按照区间去取:
初始栈
现在栈里面有了元素,现在我们将这个区间弹栈出来,我们就能拿到[L,R]区间,然后在对这个区间进单躺排序,排完序过后,区间一分为二,我们要把这两个区间都入栈,如果想要先处理左边区间,先入右边区间(栈是先入后出):
然后呢再次重复上述步骤,再一次弹栈出一个区间,对该区间进行单躺排!,然后区间又可以一分为二然后又是重复上面的步骤入栈,只不过我们入栈的时候应该注意一点就是区间只有一个元素或者无效区间(left>=right)的时候不进行入栈操作,说白了就是模拟递归的做法:
代码实现:
加了优化的
void QuickSortStack(int* nums, int left,int right)
{
Stack st;
StackInit(&st);
StackPush(&st, right);
StackPush(&st,left);
while (StackEmpty(&st) == false)
{
int left = StackTop(&st);
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
if (right - left + 1 < 10)//小区间优化
{
InsertSort(nums+left,right-left+1);
continue;
}
int mid = PartSort1(nums, left,right);
if (mid -1> left)
{
StackPush(&st, mid - 1);
StackPush(&st,left);
}
if (mid + 1 < right)
{
StackPush(&st, right);
StackPush(&st,mid+1);
}
}
StackDestroy(&st);
}
5、快排非递归实现(利用队列实现)
快排的另一种非递归方式是利用队列来实现,队列的特性是啥?
先进先出嘛:
我们刚才不是说了嘛,单躺排是对区间进行排序,我们只需要给区间他就能帮我们将一个值排到正确位置:
然后我们出队,对这个区间进行单躺排序,然后区间又会一分为二,我们然后又将者两个区间进行入队:
然后后我们又出队,对该区间进行单躺排序:该区间又一分为二,又将两个区间入队:
你这时再仔细看一看是不是有点层序的意思(广度优先),我们现在时一层一层的进行单躺排序:
当队列里面没有元素时,就代表者需要进行单躺排序的区间没有了,那么整个数组也就是有序了:
代码实现:
void QuickSortQueue(int* nums, int left, int right)
{
Queue q;
QueueInit(&q);
QueuePush(&q, left);
QueuePush(&q,right);
while (!QueueEmpty(&q))
{
int left = QueueFront(&q);
QueuePop(&q);
int right = QueueFront(&q);
QueuePop(&q);
if (right - left + 1 < 15)//小区间优化
{
InsertSort(nums + left, right - left + 1);
continue;
}
int mid = PartSort2(nums,left,right);
if (left < mid - 1)
{
QueuePush(&q, left);
QueuePush(&q,mid-1);
}
if (mid + 1 < right)
{
QueuePush(&q, mid + 1);
QueuePush(&q,right);
}
}
QueueDestroy(&q);
}
6、三路并排
其实上面的优化已经很好了,但是对于数组中的元素全是同一个元素这也的特例快排还是不能很好的解决,就算有上面的优化,此时快排的时间复杂度也会退化到O(N^2),为了解决这个问题大佬们提出了一个nb的算法,这个算法叫做三路并排,就是将数组分为上个区间:严格小于key的区间、等于key的区间、严格大于可以的区间:
算法的主要思路呢,是利用三个指针:left、right、cur;cur从left开始,然后key先保存一下left位置的值(也可以以右边作为基准值,那么key也就保存right所指的值),然后cur不断往后走;
1、如果cur位置的值小于key,那么就将cur此时所指的值往左边“甩”,即:交换left位置和cur位置的值,然后left++,cur++;
2、如果cur位置的值等于key,那么cur直接往后走;
3、如果cur位置的值大于key,交换cur位置的值和right位置的值;
画图解释:
这里的话可能会有读者疑惑,为什么将cur的值扔向左边过后cur可以++,而将cur的·值扔向右边时,我们不能将cur++?
首先我们先这样想,假设现在数组里面全是相同的元素,那么left、right是不是一直停留在原地!cur一直往后走,cur与left之间逐步拉开差距,那么left与cur之间是不是全是等于key的元素!具体区间就是[left,cur)之间全是等于key的值;那么如果这时候cur在遇到个小于key的值的话,那么将cur位置的值扔向左边,将此时left的值扔在cur位置,此时nums[cur]==key,那么cur就可以++;而右边的话,我们就我们无法保证交换过来的值与key的大小关系,因此cur不能++;
画个图来表示left、cur、right之间的关系:
这样应该更能理解上面的问题;
明白了上面的关系;
接下来我们就快一点将后面的步骤走完:
我们可以看到当cur>right时就可以停止循环了,然后[left,right]区间我们就不用去递归排序了,我们只需要递归处理[begin,left-1]和[right+1,end]区间就行了,而且我们可以发现重复元素越多的话,左右连边区间就越小,我们时间效率就越高!
而处理普通情况的话,那么就与上面的双指针版快排没有什么区别!
代码实现:
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void QuickSort(int* nums, int left, int right)//三路并排(没有加三数取中、小区间优化)
{
if (left >= right)
return;
int begin = left;
int end = right;
int cur = left;
int key = nums[cur];
while (cur <= right)
{
if (nums[cur] < key)
{
Swap(cur + nums, left + nums);
left++;
cur++;
}
else if (nums[cur] == key)
cur++;
else
{
Swap(nums+cur,nums+right);
right--;
}
}
QuickSort(nums, begin, left - 1);
QuickSort(nums,right+1,end);
}
快速排序的特性总结:
1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(logN)
4. 稳定性:不稳定
四、归并排序
1、归并排序
基本思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并;
动态过程:
简单来说就是在两个有序区间之内选数,每次都选小的出来,放入临时数组中,当两个有序数组都选完过后,我们就直接将临时数组里面的元素在拷回原数组!
但是现在我们如何得到一个有序区间?
我们就将区间不断二分呗!当区间只剩下一个元素不就可以看成一个有序区间了?
代码实现:
void Merge(int* nums, int left, int right, int* tmp)
{
if (left >= right)
return;
int mid = (right - left) / 2 + left;
Merge(nums, left, mid, tmp);
Merge(nums,mid+1,right,tmp);
int begin1 = left;
int end1 = mid;
int begin2 = mid + 1;
int end2 = right;
int k = left;
while (begin1<=end1&&begin2<=end2)
{
if (nums[begin1] < nums[begin2])
{
tmp[k++] = nums[begin1++];
}
else
{
tmp[k++] = nums[begin2++];
}
}
if (begin2 <= end2)//当左区间归并完过后,右区间还有剩余,那么没必要再去归并右区间了,直接将临时数组拷贝回去就行了;
{
memcpy(nums+left,tmp+left,(k-left) * sizeof(int));//注意此时的tmp元素数目
return;
}
while (begin1 <= end1)//左区间有剩余,继续归并左区间
tmp[k++] = nums[begin1++];
memcpy(nums+left,tmp+left,sizeof(int)* (k-left));
}
void MergeSort(int* nums, int len)
{
int* tmp = (int*)malloc(sizeof(int) * len);//我们将从有序数组里面选出来的值先放入临时数组,待有序数组被选完了,我们就将临时数组的值拷贝回原数组!
if (!tmp)
exit(-1);
Merge(nums,0,len-1,tmp);
free(tmp);
}
时间复杂度分析:
理想情况下:
总共logN层,每一层大概遍历N次,除了第一层不用归并之外,每一层的每个小区间都要归并,每一层的所有小区间合计起来就是O(N),因此logN层的时间复杂度就是O(N*logN)
所有归并排序的时间复杂度就是O(N*logN);
空间复杂度:这颗“树”的深度就是logN,也就是递归深度,我们要建立logN个栈帧,然后加上额外的临时空间O(N),所以准确的空间复杂度就是O(logN+N),然后大O渐进表示就是O(N);
2、归并排序的非递归
归并排序的非递归的话,用如果还是像快排的非递归方式那样的话,是不行的也就是说单独用栈或队列的话完不成,主要是该递归属于“后序遍历”,我们归并完了这一层,但是我们找不到上一层,我们也就无法回到上一层,继续归并!如果我们能知道上一层的区间话,我们就能很好的对整个数组进行归并;为此我们需要从底层开始归并,我们可以设置一个gap,表示每个小区间的元素个数,然后我们两两一个大区间,(gap初始化为1)对这个大区间进行归并
:begin1=i;end1=i+gap-1;begin2=i+gap;edn2=i+2*gap-1;
这样的话,[begin1,end2]这个大区间有序了,i+=2*gap,来到下一个大区间进行归并;
就这样不断先后归并,最后变为:
现在我是不是每2个元素的一个小区间有序了,那么现在我们还是重复上面的步骤,将这两个小区间进行归并;
最终变为:
gap*=2;现在我是不是每4个元素的一个小区间有序了,那么现在我们还是重复上面的步骤,将这两个小区间进行归并;
gap*=2;现在我是不是每8个元素的一个小区间有序了,那么现在我们还是重复上面的步骤,将这两个小区间进行归并;
我们可以发现这时候右区间,没有了,我们就不需要进行归并了,gap*=2;
此时达到循坏结束条件,此时数组也有序了!!
那么现在完了吗?
还没有!!
我们上面列举的情况刚好是左右两个区间刚好都完整的存在,但是实际上并不是这样的,实际上的话,end1可能越界、begin2可能越界、end2可能越界;面对这三种情况,我们需要对区间进行修正,让其能进行正常归并!!
1、end1越界
也就是这种情况,[begin1,end1]区间不完全存在,[begin2,end2]区间根本不存在,对于这种情况我们要么修正区间在向下进行正常归并,要么直接就break掉,不用往下归并了,因为右跟不不存在区间;
如果是修正区间的话,end1=len-1;begin2=len;end2=len-1;让右区间不参与归并就好了!
2、end2越界
左区间完全存在,有区间不完全存在,为了能正常归并我们需要修正一下右区间的范围,
即end2=len-1;
3、begin2越界
像这种情况,左区间完全存在,右区间完全不存在,我们可以采用第一种情况的处理方式,但是我们不需要对左区间进行修正,我们将右区间修正成非法区间就好了,begin2=len;end2=len-1;
也可以直 接break掉,不用参与下面的归并;
代码实现:
void MergeSortNoR(int* nums, int len)
{
int* tmp = (int*)malloc(sizeof(int) * len);
if (!tmp)
exit(-1);
int gap = 1;
while (gap < len)
{
for (int i = 0; i < len; i+=2*gap)//i跳过一个大区间,来到下一个待归并的大区间
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
if (end1 >= len || begin1 >= len)
break;
if (end2 >= len)
end2 = len - 1;
int k = begin1;
int left = begin1;
int right = end2;
while (begin1 <= end1 && begin2 <= end2)
{
if (nums[begin1] < nums[begin2])
{
tmp[k++] = nums[begin1++];
}
else
{
tmp[k++] = nums[begin2++];
}
}
if (begin2 <= end2)//当左区间归并完过后,右区间还有剩余,那么没必要再去归并右区间了,直接将临时数组拷贝回去就行了;
{
memcpy(nums +left, tmp + left, (k - left) * sizeof(int));//注意此时的tmp元素数目
continue;
}
while (begin1 <= end1)//左区间有剩余,继续归并左区间
tmp[k++] = nums[begin1++];
memcpy(nums + left, tmp + left, sizeof(int) * (k - left));
}
gap *= 2;
}
free(tmp);
}
时间复杂还是O(N*logN)
空间复杂度:O(N)
3、归并排序的非递归(利用栈和队列)
首先我们(0,5)的区间可以细分成这样吧,我们把区间看成是一个元素,我们从底层开始层序进行归并([3,3],[4,4])开始进行归并,归并完过后[3,4]区间有序了,我们在去对([0,0],[1,1])进行归并,归并完过后[0,1]区间就有序了,我们在去归并([3,4],[5,5])区间,注意这时[3,4]区间已经有序了,归并完过后,[3,5]区间就有序了……依次类推……就这样倒着进行层序取数据进行归并!当只剩下最后一个区间[0,5]时就不用归并了,可以直接结束了;
那么我们如何才能实现上述的操作呢?
这不就需要借助我们的队列和栈了,
我们先将这些区间按照层序遍历(正向)的思想不断入队列(这些区间很好获取吧!同时我们需要注意像[left,right],left==right这种没有孩子区间的,我们就没必要计算其孩子区间并入队列了);
当入队列过后,我们再按照层序遍历的思想将这些元素全部取出来存入栈中,最后栈中存的元素不就是:
我们最后每次弹出两个元素(两个区间)进行归并,当只剩下最后一个元素(一个区间,2个数值)时,那么排序结束,此时数组已经有序了;
代码实现:
void MergeSortNoR1(int* nums, int len)
{
int* tmp = (int*)malloc(sizeof(int) * len);
if (!tmp)
exit(-1);
Queue q;
QueueInit(&q);
Stack st;
StackInit(&st);
QueuePush(&q, 0);
QueuePush(&q,len-1);
//1、将区间入队列和进栈(两个操作一起进行),时间复杂度度O(N)
while (false == QueueEmpty(&q))
{
int left = QueueFront(&q);
QueuePop(&q);
int right = QueueFront(&q);
QueuePop(&q);
StackPush(&st, right);
StackPush(&st,left);
int mid = (right - left) / 2 + left;
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
if (left != right)
{
QueuePush(&q, begin1);
QueuePush(&q, end1);
QueuePush(&q, begin2);
QueuePush(&q, end2);
}
}
//2、开始进行归并排序O(N*logN)
while (StackSize(&st) > 2)//栈中只剩下最后一个区间
{
int begin2 = StackTop(&st);
StackPop(&st);
int end2 = StackTop(&st);
StackPop(&st);
int begin1 = StackTop(&st);
StackPop(&st);
int end1 = StackTop(&st);
StackPop(&st);
int left = begin1;
int right = end2;
int k = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (nums[begin1] < nums[begin2])
{
tmp[k++] = nums[begin1++];
}
else
{
tmp[k++] = nums[begin2++];
}
}
if (begin2 <= end2)//当左区间归并完过后,右区间还有剩余,那么没必要再去归并右区间了,直接将临时数组拷贝回去就行了;
{
memcpy(nums + left, tmp + left, (k - left) * sizeof(int));//注意此时的tmp元素数目
continue;
}
while (begin1 <= end1)//左区间有剩余,继续归并左区间
tmp[k++] = nums[begin1++];
memcpy(nums + left, tmp + left, sizeof(int) * (k - left));
}
StackDestroy(&st);
QueueDestroy(&q);
free(tmp);
}
时间复杂度O(N+N*logN),渐进表示O(N*logN);
空间复杂度O(N)
归并排序的特性总结:
1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定
总结
各个排序比较:
以上便是比较常用的排序算法的总结!欢迎大家留言交流!😊😊😊😊