👑作者主页:@进击的安度因
🏠学习社区:进击的安度因(个人社区)
📖专栏链接:数据结构
文章目录
- 一、前言
- 二、排序的概念和运用
- 三、八大排序讲解及实现
- 1、直接插入排序
- 1.1 排序思路
- 1.2 代码实现
- 1.3 时空复杂度
- 2、希尔排序
- 2.1 排序思路
- 2.2 代码实现
- 2.3 时空复杂度:star:
- 3、直接选择排序
- 3.1 排序思路
- 3.2 代码实现
- 3.3 时空复杂度
- 4、堆排序
- 5、冒泡排序
- 5.1 排序思路
- 5.2 代码实现
- 5.3 时空复杂度:star:
- 6、快速排序:star:
- 6.1 算法思想
- 6.2 快排递归版本
- 6.3 hoare 版本
- 6.4 挖坑法
- 6.5 前后指针版本
- 6.6 缺陷分析及优化
- 6.7 快排递归版本完整代码
- 6.8 快排非递归版本
- 6.9 时空复杂度
- 7、归并排序:star:
- 7.1 算法思想
- 7.2 归并递归版本
- 7.3 归并排序非递归版本
- 7.4 时空复杂度
- 8、计数排序
- 8.1 算法思想
- 8.2 代码实现
- 8.3 时空复杂度
- 四、排序算法复杂度及稳定性分析
- 五、排序性能测试框架
- 六、结语
一、前言
Hello,小伙伴们,我是 a n d u i n anduin anduin 。距离上次更新已经整整一个礼拜了。那么有小伙伴可能会疑惑,是不是 a n d u i n anduin anduin 偷懒不写文章呢?让我狡辩一下 … 其实是 a n d u i n anduin anduin 最近在刷题,但是感觉那些题解写起来质量也不高,于是就没发出来。但是一天前, a n d u i n anduin anduin 突然想起了好久没更新了,于是我决定要写一篇 “硬核” 的文章,用这篇文章来弥补这近一个星期的摆烂。于是经过两天的沉浸式写博客,本篇文章就诞生了。总体文章有 30000 30000 30000 字左右,篇幅较长,这是我数据结构专栏中最满意的一篇博客,因为这一次写文章的时候掉的头发是最多的(bushi),就用这篇 八大排序 来完美地收尾初阶数据结构吧!
咳咳,好了,现在我们进入正题,首先介绍一下文章内容:
我们的文章内容主要围绕下图来进行讲解,在本篇博客中,我会阐述排序的概念,八大排序的思想、代码思路、代码实现和时空复杂度分析,并且在最后做出总结,并且附上源码链接。
今天的内容还是含金量挺高的(尤其是带⭐️的),所以小伙伴们,打起精神,如果认认真真看完这边博客并下去练习,我相信你就可以手撕八大排序!
二、排序的概念和运用
所谓排序,就是将一串数据,按照某种规律,或者以某种特性或关键字,将数据按照递增或者递减,将数据从无序转变为有序的操作。
而排序其实在我们生活中运用十分广泛,例如在购物网站上选购一个电脑,可以通过综合、销量、评论数、新品价格来排序,如果对价格有需求,也可以按照价格升序,或者降序来排序,通过这种方式来买到心仪的商品。
而究其根本,这些都需要排序算法来实现。那么在编程中有什么排序算法,它们的性能如何?如何模拟实现这些算法?如果还不太了解的话,没关系,因为今天的内容就是讲解经典八大排序,下面我们就会一一讲解并实现。
三、八大排序讲解及实现
1、直接插入排序
1.1 排序思路
直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个 已经排好序的有序序列 中,直到所有的记录插入完为止,得到一个新的有序序列 。
我们的扑克牌就使用了插入排序的思想:
比如拿到牌之后,我们都会理牌,那么通常就会按照大小顺序,将“对子”,“顺子”,按照特定顺序,将牌理成整体有序,或者局部有序。
进行插入排序的步骤:
当插入第 i ( i ≥ 1 ) i(i \ge 1) i(i≥1) 个元素时,前面的 a r r a y [ 0 ] , a r r a y [ 1 ] , … , a r r a y [ i − 1 ] array[0],array[1],…,array[i-1] array[0],array[1],…,array[i−1] 已经排好序,此时用 a r r a y [ i ] array[i] array[i] 的排序码与 a r r a y [ i − 1 ] , a r r a y [ i − 2 ] , … array[i-1],array[i-2],… array[i−1],array[i−2],… 的排序码顺序进行比较,找到插入位置即将 a r r a y [ i ] array[i] array[i] 插入,原来位置上的元素顺序后移。
这么说可能还不是很直观,其实插入排序的过程就可以想象成之前我们学习 顺序表的尾插 ,我们假定序列 a r r a y [ ] array[] array[] 只有 1 1 1 个数。假定每次都是插入一个元素,且插入的元素需要将这个 ”顺序表“ 构成有序,如果插入元素 a r r a y [ i ] < a r r [ i − 1 ] array[i] <arr[i - 1] array[i]<arr[i−1] ,那么就至少需要向前调整 1 1 1 次;如果 a r r a y [ i ] ≥ a r r a y [ i − 1 ] array[i] \ge array[i - 1] array[i]≥array[i−1] 那么就 直接插入在 i i i 下标处 即可。
动图演示插入排序过程:
1.2 代码实现
对于 单趟插入排序 ,假设 e n d end end 是上一次排序的最后一个位置,那么本次需要排序的元素为 t m p = a [ e n d + 1 ] tmp = a[end + 1] tmp=a[end+1] 。
之后通过一个循环,如果 a [ e n d ] > t m p a[end] > tmp a[end]>tmp ,说明 t m p tmp tmp 插入的位置在前面,所以把 a [ e n d + 1 ] = a [ e n d ] a[end + 1] = a[end] a[end+1]=a[end] ,将元素往后移 1 1 1 格,并将 e n d − − end-- end−− ,将索引向前调整;如果 a [ e n d ] < = t m p a[end] <= tmp a[end]<=tmp ,说明 t m p tmp tmp 在当前位置: e n d + 1 end + 1 end+1 就可以直接插入,停止循环。
最后 e n d end end 停下来的位置永远是插入位置的前一个位置 ,比如:
- 有序序列为 1 3 5 1 \ 3 \ 5 1 3 5 , e n d = 2 end = 2 end=2 , a [ e n d ] = 5 a[end] = 5 a[end]=5 , t m p = a [ e n d + 1 ] = 0 tmp = a[end + 1] = 0 tmp=a[end+1]=0
- e n d = 2 , a [ e n d ] = 5 , t m p = 0 → a [ e n d ] > t m p → a [ e n d + 1 ] = a [ e n d ] → a [ 3 ] = 5 e n d − − → e n d = 1 end = 2, a[end] = 5, tmp = 0 → a[end] > tmp →a[end + 1] = a[end] → a[3] = 5 end-- → end = 1 end=2,a[end]=5,tmp=0→a[end]>tmp→a[end+1]=a[end]→a[3]=5end−−→end=1
- e n d = 1 , a [ e n d ] = 3 , t m p = 0 → a [ e n d ] > t m p → a [ e n d + 1 ] = a [ e n d ] → a [ 2 ] = 3 e n d − − → e n d = 0 end = 1, a[end] = 3, tmp = 0 → a[end] > tmp → a[end + 1] = a[end] → a[2] = 3 end-- → end = 0 end=1,a[end]=3,tmp=0→a[end]>tmp→a[end+1]=a[end]→a[2]=3end−−→end=0
- e n d = 0 , a [ e n d ] = 1 , t m p = 0 → a [ e n d ] > t m p → a [ e n d + 1 ] = a [ e n d ] → a [ 1 ] = 0 e n d − − → e n d = − 1 end = 0, a[end] = 1, tmp = 0 → a[end] > tmp → a[end + 1] = a[end] → a[1] = 0 end-- → end = -1 end=0,a[end]=1,tmp=0→a[end]>tmp→a[end+1]=a[end]→a[1]=0end−−→end=−1
- e n d = − 1 → e n d < 0 \color{red}end = -1 \ → end < 0 end=−1 →end<0,终止循环
- a [ e n d + 1 ] = t m p a[end + 1] = tmp a[end+1]=tmp ,最终序列 0 1 3 5 0 \ 1 \ 3 \ 5 0 1 3 5
这是单趟,那么完整的也很简单嘛,其实就是 n − 1 n - 1 n−1 趟,因为第一个元素是不用排的,第一趟其实就是排的序列的第二个元素了。每趟最终插入的位置为 e n d + 1 end + 1 end+1 ,需要防止越界。
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
// 单趟
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
// 填到 end + 1 位置
a[end + 1] = tmp;
}
}
1.3 时空复杂度
插入排序的最坏的情况为,数组与我们需要排序的目标相悖。比如我们需要排升序,但是原始序列为降序 。为最坏情况,为 O ( N 2 ) O(N^{2}) O(N2) 。如果目的是升序,序列也正好是升序的话,为最好情况,这时时间复杂度为 O ( N ) O(N) O(N) 。
取其最坏,最终时间复杂度: O ( N 2 ) O(N^{2}) O(N2) 。
插入排序并没有开辟额外空间,所以 空间复杂度: O ( 1 ) O(1) O(1)
2、希尔排序
2.1 排序思路
希尔排序也属于插入排序,但是和直接插入排序又略微不同。
先看一下概念:
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
这段话是什么意思呢?这句话是说,取一个整数,作为 间距 g a p gap gap ,对于每个元素,与其间隔为 g a p gap gap 的元素分成一个组,对每组排序 。不断调整 g a p gap gap ,对每组进行不断排序,在 g a p gap gap 调整到 1 1 1 后进行插入排序,就可以将数据排成有序。而按照间距 g a p gap gap 分组并排序被称为 预排序 。
所以可以归纳希尔排序的步骤就是 两点 :
- 预排序
- 插入排序
比如,一次完整希尔排序 就像下图:
2.2 代码实现
希尔排序属于插入排序,而它的思想其实是和直接插入排序差不多的,因为最后一次希尔排序为插入排序。希尔排序无非就是多了几组预排序的过程。
所以它的代码和直接插入排序是十分相似的。
对于插入排序,我们其实就可以看做是 g a p = 1 gap = 1 gap=1 的希尔排序,那么把插入排序一些数据做一下替换,就得出了单组希尔排序 :
// 对于一组
for (int i = 0; i < n - gap; i += gap)
{
// 对于单个元素
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
这里的 g a p gap gap 不仅是每组中元素的间距,也是组数。上面代码只完成了单组,对于完整的一组需要在外面套上一层循环,需要循环 g a p gap gap 次:
for (int j = 0; j < gap ;j++)
{
for (int i = j; i < n - gap; i += gap)
{
// ...
}
}
我们发现,当我们学习了直接插入排序之后,写一趟希尔排序还是很简单的。原理嘛,就和之前插入排序得到思路是相似的,只需要把之前的挪动 1 1 1 位看成挪动 g a p gap gap 位。不懂的画一下图,就完全ok了。
还有注意一下对单组进行排序时的结束条件 i < n − g a p i < n - gap i<n−gap ,这里结束条件是这个的原因为最后一组的最后一个元素下标为 n − g a p − 1 n - gap - 1 n−gap−1 ,当元素进行插入时,不能越界。
而这样写,就套了 三层循环 了,看起来比较繁杂,我们可以做出一些调整,上方代码为排 g a p gap gap 组,每次排一组。我们可以改进为 g a p gap gap 组并排 :
for (int i = 0; i < n - gap; i++) // 注意这里从 i+= gap 变为 i++
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
这里就只有 两层循环 了,代码更加简洁,唯一做出改变的就是 i + = g a p i+= gap i+=gap 变为 i + + i++ i++ 。
这里就相当于,每一次都是排其他组的数据,举个例子:当前 i = 0 i = 0 i=0 ,那么此刻排的就是第一组,之后 i + + i++ i++ , i = 1 i = 1 i=1 ,那么此刻就是排的第二组 … 当循环结束,这 g a p gap gap 组数据也就都被排好了。这就是 g a p gap gap 组并排。
而 希尔排序的预排序的 g a p gap gap 越大时,那么对于单组中的数据就可以更快地到后面(挪动间距为 g a p gap gap),这就造成了数据就越不接近有序;而 g a p gap gap 越小时,数据跳动越慢,但是越来越接近有序(比如插入排序) 。综合这两点,所以我们一般进行希尔排序时, g a p gap gap 是动态变化的, g a p gap gap 的初值一般为数组长度 。之后每次除以一半或者除以三分之一。
比如我们每次除以三分之一:
while (gap > 1)
{
gap = gap / 3 + 1; // 或 gap /= 2;
// gap 组并排
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
分析:
这里调整时,是用的
g
a
p
=
g
a
p
/
3
+
1
gap = gap / 3 + 1
gap=gap/3+1,为什么在这边
+
1
+1
+1 呢?原因是希尔排序最后
1
1
1 次为插入排序,所以最后
1
1
1 次
g
a
p
=
1
gap = 1
gap=1 ,如果
g
a
p
gap
gap 初值为
8
8
8 ,如果每次仅仅让
g
a
p
/
=
3
gap /= 3
gap/=3 ,由于c语言中 /
是下取整,那么每次
g
a
p
gap
gap 就为
8
2
0
8 \ 2 \ 0
8 2 0 ,最后
1
1
1 次为
0
0
0 ,无法完成希尔排序,所以要加上一个
1
1
1 ,这样就可以保证最后
1
1
1 次
g
a
p
=
1
gap = 1
gap=1 。
但是如果每次除以2的情况就不需要,因为每次 g a p / = 2 gap /= 2 gap/=2 最终结果必定等于 1 1 1 ,不信的话可以测试几组数据试试。
2.3 时空复杂度⭐️
数据被分为 g a p gap gap 组,每组有 N / g a p N / gap N/gap 个数据。那么对于那么对于单组,最坏的挪动次数就是 1 + 2 + 3 + . . . + N / g a p − 1 \color{blue}1 + 2 + 3 + ... + N/gap - 1 1+2+3+...+N/gap−1 ,是公差为 1 1 1 的等差数列。
一共有 g a p gap gap 组,那么对于一次预排序的总次数就为 ( 1 + 2 + 3 + . . . + N / g a p − 1 ) × g a p \color{blue}(1 + 2 + 3 + ... + N/gap - 1) \times gap (1+2+3+...+N/gap−1)×gap ,但是这里 g a p gap gap 是不确定的,是不好算的。
既然这样,我们就换个角度来看,对于每次预排序的时间复杂度,该怎么计算:
假设 g a p gap gap 每次除以 3 3 3 ,一开始 N N N 很大, g a p = N / 3 + 1 gap = N / 3 + 1 gap=N/3+1 ,将当前 g a p gap gap 的取值带入上方对于一次预排序的次数的公式中, ( 1 + 2 + 3 + . . . + N N / 3 + 1 − 1 ) × ( N / 3 + 1 ) ≈ N \color{blue}(1 + 2 + 3 + ... + \frac{N}{N / 3 + 1} - 1) \times (N/3 + 1) \approx N (1+2+3+...+N/3+1N−1)×(N/3+1)≈N ,那么起始处, 预排总次数约为 N N N 。
… … …
快结束时, g a p gap gap 很小,这时次数也约为 N N N ,因为此刻由于预排序的作用 ,数组几乎有序,几乎不需要排序,遍历一遍大约就可以求出!!!
而中间的结果我们先忽略,就看起始两次,我们将其认为对于每次预排序,时间复杂度为 O ( N ) O(N) O(N) 。
如果 g a p gap gap 每次除以 3 3 3 ,那么就大约进行 N / 3 / 3 / 3... / 3 = log 3 N N / 3 / 3 / 3 ... / 3 = \log_{3}{N} N/3/3/3.../3=log3N ;
如果 g a p gap gap 每次除以 2 2 2 ,那么就大约进行 N / 2 / 2 / 2... / 2 = log 2 N N / 2 / 2 / 2 ... / 2 = \log_{2}{N} N/2/2/2.../2=log2N ;
那么综合起来,实际上希尔排序的时间复杂度大约在 [ log 3 N ∗ N , log 2 N ∗ N ] [\ \log_{3}{N} * N \ , \ \log_{2}{N} * N \ ] [ log3N∗N , log2N∗N ] 这个量级之间 ,和我们之前学习过的堆排序的时间复杂度相近。
但是这只是我们的一些计算推导,实际上希尔排序真正的时间复杂度很难计算,上面我们计算只是简单推导而已,里面其实涉及到了很多数学只是,所以我们再参考一下著名的数据结构书籍中对于希尔排序时间复杂度的分析:
《数据结构(C语言版)》— 严蔚敏:
《数据结构-用面相对象方法与C++描述》— 殷人昆
而 g a p gap gap 是按照 K n u t h Knuth Knuth 大佬提出的方式取值的, K n u t h Knuth Knuth大佬进行了大量的试验统计,我们在这里先认为 希尔排序的最终时间复杂度为 O ( N 1.3 ) O(N^{1.3}) O(N1.3) 。
而空间复杂度就很简单了,并没有开辟额外的空间,所以空间复杂度为 O ( 1 ) O(1) O(1) 。
3、直接选择排序
3.1 排序思路
选择排序的思想非常简单:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
选择排序的步骤:
- 在元素集合 a r r a y [ i ] − − a r r a y [ n − 1 ] array[i]--array[n-1] array[i]−−array[n−1] 中选择关键码最大(小)的数据元素
- 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
- 在剩余的 a r r a y [ i ] − − a r r a y [ n − 2 ] array[i]--array[n-2] array[i]−−array[n−2]( a r r a y [ i + 1 ] − − a r r a y [ n − 1 ] array[i+1]--array[n-1] array[i+1]−−array[n−1])集合中,重复上述步骤,直到集合剩余 1 1 1 个元素
下面使用动图展示一下选择排序的过程:
3.2 代码实现
假设我们排升序,那么每次就要选最小数的下标 m i n i mini mini ,遍历数组,找出 m i n i mini mini ,然后交换,让起始位置 b e g i n + + begin++ begin++ ,直到 b e g i n = n begin = n begin=n 停止,此刻数组就有序了。
void SelectSort(int* a, int n)
{
// 选 1 个数
int begin = 0;
while (begin < n)
{
int mini = begin;
for (int i = begin + 1; i < n; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
}
Swap(&a[begin], &a[mini]);
begin++;
}
}
这是我们单次选 1 1 1 个数的情况,实际上我们还可以一次选两个数 m i n i mini mini 和 m a x i maxi maxi 对应最小和最大值:
这里代码需要做出一些改变,增加一个终止位置 e n d end end 。循环找 m i n i mini mini 和 m a x i maxi maxi ,每次找到之后交换 b e g i n begin begin 和 m i n i mini mini 对应的值和 e n d end end 和 m a x i maxi maxi 对应的值,将 b e g i n + + , e n d − − begin++, end-- begin++,end−− ,直到 b e g i n ≥ e n d begin \ge end begin≥end 。
但是这里有一个 注意点 :
假设序列已经找到了 m i n i mini mini 和 m a x i maxi maxi :
b e g i n begin begin 和 m a x i maxi maxi 的位置重合了,那么 b e g i n begin begin 和 m i n i mini mini 在交换的时候,就把最小值换到了 b e g i n ( m a x i ) begin(maxi) begin(maxi) 处,最大值被换走了 。那么接下来交换 e n d end end 和 m a x i maxi maxi 的时候,就把 最小值 换到了 e n d end end 处。
这就导致了排序错误,我们原本期望的序列为图中含有绿色方格的一块。
所以我们需要处理一下,但 b e g i n = = m a x i begin == maxi begin==maxi 时,在交换过 b e g i n begin begin 和 m i n i mini mini 的值后,原 m i n i mini mini 的值为当前的最大值,那么就把 m a x i = m i n i maxi = mini maxi=mini ,让最大值下一次能交换到正确的位置。
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin;
for (int i = begin + 1; i <= end; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[begin], &a[mini]);
// 特殊处理
if (begin == maxi)
{
maxi = mini;
}
Swap(&a[end], &a[maxi]);
begin++;
end--;
}
}
3.3 时空复杂度
选择排序无论是选单边,还是两边一起选,时间复杂度都是铁铁的 O ( N 2 ) O(N^{2}) O(N2) 。因为总是要每次遍历选出 最小/大下标,然后进行数据交换。
所以选择排序的效率是不太行的,一般不常用,但是它的思路很简单。
空间复杂度是 O ( 1 ) O(1) O(1) ,因为没有开辟额外空间 。
4、堆排序
堆排序我们之前的文章已经详细讲解过,详情见这篇博客:【数据结构】堆的拓展延伸 —— 堆排序 和 TopK问题
其中时空复杂度我们也分析过:
时间复杂度: O ( N × l o g N ) O(N \times log N) O(N×logN),空间复杂度 O ( 1 ) O(1) O(1) 。
5、冒泡排序
5.1 排序思路
冒泡排序属于交换排序,所谓交换排序就是就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
冒泡排序可能是这篇文章中最”亲切“的排序了。因为这个排序十分形象,并且容易理解。这个排序像水面冒气泡一样,从底部(数组开头)冒到水面上(数组结尾),一次冒一个数据,所以被形象的称为“冒泡排序”。
我们再看一下动图:
再从代码层面分析一下:
n n n 个数,那么就需要冒泡 n − 1 n - 1 n−1 趟,将数据冒到结尾,在每趟冒泡排序中,比较相邻两个元素,如果满足条件,则交换。
5.2 代码实现
void BubbleSort(int* a, int n)
{
// n - 1 趟
for (int j = 0; j < n - 1; j++)
{
int flag = 1;
// 交换次数随着趟数减少
for (int i = 0; i < n - j - 1; i++)
{
if (a[i] > a[i + 1])
{
Swap(&a[i], &a[i + 1]);
flag = 0;
}
}
if (flag)
{
break;
}
}
}
仔细看,其实这里我们的代码是优化过的:如果一趟冒泡排序中,没有发生交换,说明有序,那么 break
退出循环。
比如序列:
1 1 2 5 6 7 1 \ 1 \ 2 \ 5 \ 6 \ 7 1 1 2 5 6 7 ,在一趟冒泡排序中始终满足 a [ i ] ≤ a [ i + 1 ] a[i] \le a[i + 1] a[i]≤a[i+1] ,没有发生交换,说明它本身是有序的,所以无需排序了,直接退出。
5.3 时空复杂度⭐️
冒泡排序的时间复杂度为 O ( N 2 ) O(N^{2}) O(N2) ,大家可能会疑惑,冒泡排序不是优化过了吗?为什么时间复杂度为 O ( N 2 ) O(N^{2}) O(N2) ?
接下来为大家解答,冒泡排序每次的比较次数,是随着趟数而减少的,找一下规律,其实可以发现,它的总执行次数是一个公差为 − 1 -1 −1 的等差数列: ( n − 1 ) + ( n − 2 ) + . . . + 1 (n - 1) + (n - 2) + ... + 1 (n−1)+(n−2)+...+1 ,根据等差数列求和公式得: ( n − 1 + 1 ) × ( n − 1 ) 2 \frac{(n - 1 + 1) \times (n - 1)}{2} 2(n−1+1)×(n−1) ,化简得 n 2 2 − n 2 \frac{n^{2}}{2} - \frac{n}{2} 2n2−2n ,根据时间复杂度规律,最终为 O ( N 2 ) O(N^{2}) O(N2) 。
而我们的优化是只对 数组前半部分有序 或 整体几乎有序 才有优化作用!如果有序的部分在后半段,每趟排序还是要从头开始一一比较,相当于还是重新排序。
而且数组前半部分有序的优化程度也不大,最好情况是 优化后 在 整体有序 的情况下,遍历一遍数组,通过 break
退出,这时 最好时间复杂度为
O
(
N
)
O(N)
O(N) 。
综上,取最坏情况,时间复杂度为 O ( N 2 ) O(N^{2}) O(N2)。
空间复杂度为 O ( 1 ) O(1) O(1) 。
6、快速排序⭐️
6.1 算法思想
快速排序是 H o a r e Hoare Hoare 于1962年提出的一种二叉树结构的交换排序方法,其 基本思想 为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
6.2 快排递归版本
上方我们已经给出了思想,我们这边再具体解释一下。
快排为交换排序的一种。快排在开始时,会选择一个 key
做基准值,并设定 给定,然后进行单趟排序,单趟排序后,当排序停止,会把 key
的索引或 key 值本身与边界某一值交换,形成区间划分。
这个区间划分通常为 key
左边的值都小于 key
,key
右边的值都大于 key
,这样就使得区间被划分了。中间的 key
值不用管,当前 key
值已经到了正确的位置。那么现在排序就变为:对左区间和右区间的排序。
那么每次排序后都会确定一个元素的位置,确定位置后继续划分区间…这样的过程实际上就是递归,通过递归,对设定的基准值分割开的左右区间完成排序。
递归的返回条件为 左区间索引 ≥ 右区间索引 左区间索引 \ge 右区间索引 左区间索引≥右区间索引 ,此刻说明区间只有 1 1 1 个元素或无元素。
根据上述解释,我们可以大约写出框架:
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
// 对左右区间进行划分
int key = partion(a, begin, end);
// 递归左右区间
QuickSort(a, begin, key - 1);
QuickSort(a, key + 1, end);
}
上述为快速排序递归实现的主框架,我们可以发现与这个框架与二叉树前序遍历非常像,对于认真看完二叉树博客的小伙伴们在写递归框架时可想想二叉树前序遍历规则即可快速写出来,最后我们只要分析如何按照 基准值 来对区间中数据进行划分即可。
而按照基准值划分区间的方法有三种,分别为 h o a r e hoare hoare 版本、挖坑法、双指针版本 ,接下来我们一一讲解。
6.3 hoare 版本
h o a r e hoare hoare 大佬的这个版本十分惊艳,但是也是遗留 “坑” 最多的一个版本(自认为)。为什么这么说呢,等过会我们就知道了。
先看动图:
hoare 版本的思想是这样的:
首先取左边界为基准索引 k e y key key ,定义两个指针 l e f t 、 r i g h t left、right left、right 分别指向最左边的元素和最右边的元素。
r i g h t right right 先走,如果 r i g h t right right 指向的元素大于 k e y key key 指向的元素,则 r i g h t − − right-- right−− ,如果指向元素小于 k e y key key 指向的元素,则停止;
停止之后, l e f t left left 开始走,如果 l e f t left left 指向的元素小于 k e y key key 指向的元素,则 l e f t + + left++ left++ ,如果指向元素大于 k e y key key 指向的元素,则停止;
当 e f t eft eft 和 r i g h t right right 都停下后,交换 l e f t left left 和 r i g h t right right 位置的值。
直到 l e f t ≥ r i g h t left \ge right left≥right ,循环停止。
上面过程就保证了 l e f t left left 和 r i g h t right right 相遇的位置的值是小于 k e y key key 的!!! 此刻交换 a [ l e f t ] / a [ r i g h t ] 和 a [ k e y ] a[left] / a[right] 和 a[key] a[left]/a[right]和a[key] ,令 k e y = l e f t 或 r i g h t key = left 或 right key=left或right ,此刻左右区间就已经被划分好了, k e y key key 就是分割点。
我们这边规定,如果取左边作为 k e y key key 就要保证左边比 k e y key key 小,右边比 k e y key key 大;如果取右边作为 k e y key key 则反一下。
下面讲一下细节 :
细节1:我们有没有想过,为什么 l e f t left left 和 r i g h t right right 相遇的位置是小于 k e y key key 的?是巧合还是必然?
这是必然的,我们分析一下情况,一共有两种情况:
- 第一种是 r i g h t right right 停住, l e f t left left 遇到 r i g h t right right ,相遇位置就是 r i g h t right right 停住的位置。
当前情况就是 r i g h t right right 在走时,遇到比 a [ k e y ] a[key] a[key] 小的元素停住了,然后 l e f t left left 走时,和 r i g h t right right 位置是小于 k e y key key 。
- 第二种是 l e f t left left 停止, r i g h t right right 遇到 l e f t left left ,相遇位置是 l e f t left left 停住的位置。
l e f t left left 和 r i g h t right right 已经交换过一次元素,所以 l e f t left left 的位置必定小于 k e y key key , l e f t left left 停住了,之后 r i g h t right right 走,直到和 l e f t left left 相遇,相遇位置是小于 k e y key key 的。
还有一种特殊情况,就是 r i g h t right right 先走,右边的数字都比 a [ k e y ] a[key] a[key] 大, r i g h t − − right-- right−− , r i g h t right right 一直走到与 l e f t left left 重合, a [ k e y ] a[key] a[key] 和 a [ l e f t ] a[left] a[left] 交换后不变。
细节2:开头说过 h o a r e hoare hoare 大佬的版本有很多坑,坑在哪里?如何解决?
其实这里容易引起的错误还是很多的,比如:
-
k e y key key 右边的值都大于 k e y key key ,如果循环内部不加限制, r i g h t right right 就会一直 − − -- −− ,当前还不会越界,但是是一个隐患。
-
左右两边都有和 k e y key key 相等的值,导致左右两边卡死。
举个例子:
例如这种情况, a [ k e y ] = 6 a[key] = 6 a[key]=6 ,右边先走时,由于 a [ r i g h t ] = a [ k e y ] a[right] = a[key] a[right]=a[key] ,所以不走;左边走时,由于 a [ l e f t ] = a [ k e y ] a[left] = a[key] a[left]=a[key] ,所以不走。这样循环就卡死了!
所以,我们一开始的思路是需要 改进 的,当 a [ r i g h t ] ≥ a [ k e y ] a[right] \ge a[key] a[right]≥a[key] 时, r i g h t − − right-- right−−;在 a [ l e f t ] < = a [ k e y ] a[left] <= a[key] a[left]<=a[key] 时, l e f t + + left++ left++。这样就解决了 死循环的问题 。
但是修好了这个 bug 另一个 bug 就冒出来了,之前说过情况 1 1 1 是有隐患的,现在就显现出来了,一旦加上上面的条件,如果 k e y key key 都大于 k e y key key ,那么这边由于没有限制了, r i g h t right right 就 ”飙“ 出去了。
所以在 r i g h t right right 和 l e f t left left 每次走的时候需要加上限制 l e f t < r i g h t left < right left<right 。
由此,我们就可以写出代码:
// hoare 版本
int partion1(int* a, int begin, int end)
{
int left = begin, right = end;
int key = left;
while (left < right)
{
// 右边先走
// 两个条件一个防止跑出去,找大于 a[key] 的值
while (left < right && a[right] >= a[key])
{
right--;
}
while (left < right && a[left] <= a[key])
{
left++;
}
Swap(&a[left], &a[right]);
}
// 交换值, key 作为分割点
Swap(&a[left], &a[key]);
key = left;
return key;
}
怎么样,是不是挺 “坑” 的(doge)。
6.4 挖坑法
由于 h o a r e hoare hoare 大佬版本遗留的 “坑” 比较多,所以后面就有一些好心人在 h o a r e hoare hoare 版本的基础上,对快排做出了一些改良,比如我们的 挖坑法 。
虽然名字里带“坑”,但是这个方法其实非常通俗易懂。
先看动图:
挖坑法的思想:
挖坑法多了一个 h o l e hole hole ,也就是坑位。我们将
key = a[left]
,将 k e y key key 保存到一个临时变量中,假设 k e y key key 这个位置的值被挖掉了,形成一个坑位。此时 h o l e hole hole 就是坑位的下标。
依然是右边先走,循环条件为 l e f t < r i g h t left < right left<right 并且 r i g h t right right 指向的元素大于等于 k e y key key ,一旦 r i g h t right right 停下来,那么就把 a [ h o l e ] = a [ r i g h t ] a[hole] = a[right] a[hole]=a[right] ,把 r i g h t right right 指向的值填到坑中,此刻 r i g h t right right 作为新的坑。而 r i g h t right right 之前的值也被转移到了别的地方,这个位置就被看做为空,变成坑。
左边则是相同的思路,同理左边停下后,让 a [ h o l e ] = a [ l e f t ] a[hole] = a[left] a[hole]=a[left] ,让 l e f t left left 作为新的坑。
直到 l e f t ≥ r i g h t left \ge right left≥right 停止。
最后的 h o l e hole hole 就是 k e y key key 值该去的地方,把这个地方填上 k e y key key ,此刻 h o l e hole hole 为分割点。
代码实现:
int partion2(int* a, int begin, int end)
{
int left = begin, right = end;
int key = a[left];
int hole = left;
while (left < right)
{
// 右边找小,填到左边坑里面
while (left < right && a[right] >= key)
{
right--;
}
a[hole] = a[right];
hole = right;
// 左边找大,填到右边坑里面
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];
hole = left;
}
a[hole] = key;
// 最后 left 和 right 都在坑上,和 hole 是重合的
return hole;
}
6.5 前后指针版本
前后指针版本是最难的,也是“最难”的。
说它最难,是因为它本身思路比较抽象,可能看动图都不太好理解;说它“最难”实际上是反话,因为它在某种程度上,又很简单。
先看动图:
前后指针法的思路:
前后指针法的算法思想如下:
定义三个变量, p r e v = b e g i n , c u r = b e g i n + 1 , k e y = b e g i n prev = begin,cur = begin + 1, key = begin prev=begin,cur=begin+1,key=begin,主要使用 c u r cur cur 来遍历数组。
如果 c u r cur cur 指向的元素比 k e y key key 小,此刻停下来, + + p r e v ++prev ++prev ,交换调整后的 p r e v prev prev 位置和 c u r cur cur 位置的值,完事之后 c u r + + cur++ cur++ 。
如果 c u r cur cur 指向的元素大于等于 k e y key key ,此刻 c u r + + cur++ cur++ ,其他啥也不干。
就这样循环往复,直到 c u r > e n d cur > end cur>end ,此刻交换 p r e v prev prev 和 k e y key key 位置的值。
当前的 p r e v prev prev 作为新的 k e y key key ,为分割点。
这里的 p r e v prev prev 和 c u r cur cur 有两种状况:
- 紧跟 c u r cur cur ,这时 a [ c u r ] < a [ k e y ] a[cur] < a[key] a[cur]<a[key] ,它俩同步往后走
- 指向比 k e y key key 大的位置的前一个, p r e v prev prev 和 c u r cur cur 之间就是 ≥ a [ k e y ] \ge a[key] ≥a[key] 的值。
每当 a [ c u r ] < a [ k e y ] a[cur] < a[key] a[cur]<a[key] , c u r cur cur 位置小于 a [ k e y ] a[key] a[key] 的值总是会被推到前面。
循环的过程就相当于是将小于 a [ k e y ] a[key] a[key] 的值往前翻,大于 a [ k e y ] a[key] a[key] 的值往后像“翻跟头”一样推着走。
最后 p r e v prev prev 的位置指向比 a [ k e y ] a[key] a[key] 大的位置的前一个,那么交换 a [ p r e v ] a[prev] a[prev] 和 a [ k e y ] a[key] a[key] ,让 k e y = p r e v key = prev key=prev ,此刻 k e y key key 为分割点。
优化思路:
实际上我们发现当 p r e v prev prev 紧跟 c u r cur cur 时,它俩指向的是一个位置的元素,所以这种情况是没必要交换的,所以可以提前判断一下 + + p r e v ! = c u r ++prev != cur ++prev!=cur ,如果不满足这个条件,那么干脆就不要交换了,反正是同一个位置的值。
接着来写一下代码:
int partion3(int* a, int begin, int end)
{
int prev = begin;
int cur = begin + 1;
int key = begin;
while (cur <= end)
{
// 找到比 key 小的值时,跟 ++prev 位置交换,
// 小的往前翻,大的往后翻
// 重复数据不会交换 - 优化后
if (a[cur] < a[key] && ++prev != cur)
Swap(&a[cur], &a[prev]);
// 重复数据会交换 - 优化前
/*if (a[cur] < a[key])
Swap(&a[++prev], &a[cur]);*/
// cur 必定会走一步
cur++;
}
Swap(&a[prev], &a[key]);
//return prev; // 也可以 key,prev 和 key 是相等的
key = prev;
return key;
}
6.6 缺陷分析及优化
缺陷1:有序或接近有序序列时间复杂度过高
其实对于快排来说,它的时间复杂度是不稳定的,比如上方三个版本,在乱序的序列中,效率可能还可以,因为选取的 k e y key key 值是随机的。
但是对于有序序列,比如要排正序,但是序列是逆序。如果每次选 k e y key key 还是按照之前的选法,那么每次可能就会选中最边上的一个,选中最大或最小的数,假设序列长度为 N N N ,每次选取一个极端值,就会选取 N N N 次,就会递归 N N N 层,每一层中的时间复杂度为 O ( N ) O(N) O(N) ,那么最终时间复杂度为 O ( N 2 ) O(N^{2}) O(N2) 。
但是这速度对于快排来说,是不是说不过去,我们能否每次选 k e y key key 都 命中一段区间的中位数 ,让每段区间被二分,那么最终就只会递归 l o g N log N logN 层,每层时间复杂度为 O ( N ) O(N) O(N) ,总时间复杂度为 O ( N × l o g N ) O(N \times log N) O(N×logN) 。就像下图,像一棵二叉树一样。
所以这边就引申出了第一个优化:三数取中
所谓三数取中,就是不取最大,不取最小,在 b e g i n , e n d , ( b e g i n + e n d ) / 2 begin, end, (begin + end) / 2 begin,end,(begin+end)/2 中选取一个中间值,尽量让 k e y key key 可以命中每段区间中点。
而这段逻辑的话,其实也并不复杂,直接上代码:
int GetIndexMid(int* a, int begin, int end)
{
int mid = (begin + end) >> 1;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] > a[end])
{
return begin;
}
else
{
return end;
}
}
else // a[begin] >= a[mid]
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[end] > a[begin])
{
return begin;
}
else
{
return end;
}
}
}
缺陷2:不必要的递归层数太多,空间浪费
假设我们只有 10 10 10 个数,那么这种情况采用递归是不是浪费空间,是不是多此一举?
所以当 e n d − b e g i n + 1 < 10 end - begin + 1 < 10 end−begin+1<10 时,可以采用插入排序优化,这样子就没必要开那么多层函数栈帧。
对于一棵满二叉树,最后一层的节点数占总结点数的 1 2 \frac{1}{2} 21 ,倒数第二、三层分别为 1 4 、 1 8 \frac{1}{4}、\frac{1}{8} 41、81 ,我们就假定快排递归展开后,最后形态为完全二叉树。
假设当前有10个节点,那么对于下图中红色箭头标出的点来说就无须递归,因为再细分也就是一个数:
那么大约就可以节省下三层的递归,下三层的节点数占总结点数的 87.5 % 87.5\% 87.5% ,省去了大部分的递归。
所以这边就引申出了 第二个优化:小区间优化
这边我们范围给大一点,当 e n d − b e g i n + 1 < 15 end - begin + 1 < 15 end−begin+1<15 时,就采用直接插入排序优化。
递归框架中发生的变化:
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
// 小于一定数目使用 直接插入排序
if ((end - begin + 1) < 15)
{
// 数组位置:a + begin
// 元素个数:end - beghin + 1
InsertSort(a + begin, end - begin + 1);
}
else
{
int key = partion(a, begin, end);
// 递归左右区间
QuickSort(a, begin, key - 1);
QuickSort(a, key + 1, end);
}
}
缺陷3(最致命的一点):对于相同数据来说,三数取中无效,时间复杂度过高
比如 2 2 2 2 2 2 2 2 2 \ 2 \ 2 \ 2 \ 2 \ 2 \ 2 \ 2 2 2 2 2 2 2 2 2 这个序列来说三数取中是完全无效的,特别数据量一大,不仅容易超时,还容易爆栈。
所以就需要优化,这就引申出了第三个优化:三路划分。
之前的我们是主要将区间划分为两段, [ b e g i n , k e y − 1 ] [begin, key - 1] [begin,key−1] 和 [ k e y + 1 , e n d ] [key + 1, end] [key+1,end] 。 左区间值小于 k e y key key ,右区间值大于 k e y key key ,可以称为 两路划分 。
现在我们需要进行三路划分,就是将区间分割为左区间小于 k e y key key ,中间区间等于 k e y key key ,右区间大于 k e y key key 。
其实这个思路更为简单,简单讲一下思路:
设定一个 c u r = b e g i n + 1 cur = begin + 1 cur=begin+1, l e f t = b e g i n , r i g h t = e n d , k e y = a [ l e f t ] left = begin, right = end, key = a[left] left=begin,right=end,key=a[left]。
就是将区间分割为左区间小于 k e y key key ,中间区间等于 k e y key key ,右区间大于 k e y key key 。
给定一个循环,循环中如果 a [ c u r ] < k e y a[cur] < key a[cur]<key ,此刻交换 c u r cur cur 和 l e f t left left 指向的元素,使 l e f t + + left++ left++ , c u r + + cur++ cur++ 。(如果一开始这个条件就满足,则会把 k e y key key 逐渐往中间推。)
如果 a [ c u r ] > k e y a[cur] > key a[cur]>key ,此刻 r i g h t right right 这个位置的值比 k e y key key 大,也有可能比 k e y key key 小。交换 c u r cur cur 和 r i g h t right right 指向元素后,如果 c u r + + cur++ cur++ 可能该位置元素就不满足最终区间划分条件,所以这里只能 r i g h t − − right-- right−−.
如果 a [ c u r ] = = k e y a[cur] == key a[cur]==key ,那么只需要 c u r + + cur++ cur++。
当 c u r > r i g h t cur > right cur>right 时, r i g h t right right 后的元素都是大于 k e y key key 的,区间也都调整好了,这时候循环也就停止了。
实际上这一过程就像把和 k e y key key 相等的值往中间推,把比 k e y key key小的值往左边甩,把比 k e y key key 大的值往右边甩,最后等于 k e y key key 的就在中间。
最后分割成的区间就是 [ b e g i n , l e f t − 1 ] , [ l e f t , r i g h t ] , [ r i g h t + 1 , e n d ] [begin, left - 1], [left, right], [right + 1, end] [begin,left−1],[left,right],[right+1,end],这时等于 k e y key key 的区间不用递归,只需要递归排序左右区间即可。
如果不太理解可以画一下草图,这边博主就不带着画了。
这一过程也是挺简单的,我们直接看代码:
// 三路划分 处理重复数据量大的情况,处理完中间区间就是 22222222222
void QuickSortT(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
// 三数取中一下
int mid = GetIndexMid(a, begin, end);
Swap(&a[mid], &a[begin]);
int left = begin, right = end;
int cur = begin + 1;
int key = a[left];
// 跟 key 相等的值,往后推
// 比 key 小的甩到左边
// 比 key 大的甩到右边
// 和 key 相等的就在中间
while (cur <= right)
{
if (a[cur] < key)
{
Swap(&a[cur], &a[left]);
left++;
cur++;
}
else if (a[cur] > key) //
{
// r 这个位置有可能比 Key 大,也有可能比 key 小
// 所以 cur 不 ++
// 如果 cur 比 key 大,之后还是得换回去处理
Swap(&a[cur], &a[right]);
right--;
}
else
{
cur++;
}
}
// 区间被划分为 [begin, left - 1] [left, right] [right + 1, end]
QuickSortT(a, begin, left - 1);
QuickSortT(a, right + 1, end);
}
6.7 快排递归版本完整代码
这边调用的 partion
我们用 前后指针 的(代码少些doge):
int GetIndexMid(int* a, int begin, int end)
{
int mid = (begin + end) >> 1;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] > a[end])
{
return begin;
}
else
{
return end;
}
}
else // a[begin] >= a[mid]
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[end] > a[begin])
{
return begin;
}
else
{
return end;
}
}
}
int partion3(int* a, int begin, int end)
{
// 三数取中
int mid = GetIndexMid(a, begin, end);
Swap(&a[begin], &a[mid]);
int prev = begin;
int cur = begin + 1;
int key = begin;
while (cur <= end)
{
// 找到比 key 小的值时,跟 ++prev 位置交换,
// 小的往前翻,大的往后翻
// 重复数据不会交换
if (a[cur] < a[key] && ++prev != cur)
Swap(&a[cur], &a[prev]);
// 重复数据会交换
/*if (a[cur] < a[key])
Swap(&a[++prev], &a[cur]);*/
// cur 必定会走一步
cur++;
}
Swap(&a[prev], &a[key]);
//return prev;
key = prev;
return key;
}
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
// 小于一定数目使用 直接插入排序
if ((end - begin + 1) < 15)
{
InsertSort(a + begin, end - begin + 1);
}
else
{
int key = partion3(a, begin, end);
// 递归左右区间
QuickSort(a, begin, key - 1);
QuickSort(a, key + 1, end);
}
}
6.8 快排非递归版本
其实快排不仅能用递归,还是可以使用非递归的,非递归的好处就是不需要多次递归开辟多层函数栈帧,在空间消耗上略有优势。
快排的非递归需要借助数据结构 - 栈 来完成。
快排递归的过程就相当于对每一段区间进行处理,那么非递归我们可以用两个变量来模拟各个区间。
接下来我们开始展开思路:
一开始,我们将 b e g i n begin begin 和 e n d end end 分别入栈。给定一个循环,如果栈不为空就继续循环。
由于栈是后进先出,所以先用 r i g h t right right 接收 e n d end end 右区间,再用 l e f t left left 接收左区间,在接收完之后,将这两个元素分别出栈。
得到了区间之后,就对区间进行单趟排序(可以调用上面的 h o a r e hoare hoare 等),用 k e y key key 接收分隔点。
我们再想想处理完一次完整区间后,下一次要如何处理?
先处理左区间 [ l e f t , k e y − 1 ] [left, key - 1] [left,key−1] ,再处理 [ k e y + 1 , r i g h t ] [key + 1, right] [key+1,right] 。由于栈先进后出,所以要先入右区间,在入左区间。
每次循环只会取出两个值,那么就是一小段区间,在取出左区间后,会先处理左区间,然后不断分割小区间,每次取出两个值一直对栈顶上的两个元素的区间进行处理,这样就模拟除了快排的过程。
优化思路:
如果区间内只有 1 1 1 个元素,就无需处理了,所以可以加个条件判断一下,举个例子,对于右区间来说, k e y key key 是分割点, k e y + 1 key + 1 key+1 则是右区间的起始位置,如果 k e y + 1 < r i g h t key + 1 < right key+1<right ,那么说明区间中不止一个元素,这种情况就入栈处理。类比左边也是一样的道理。
// 快排非递归
void QuickSortNorR(int* a, int begin, int end)
{
ST st;
StackInit(&st);
// 压栈
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{
// 后进先出,先出 right
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
// 先取左区间,后取右区间
// 所以先入右区间再入左区间
int key = partion3(a, left , right);
// 如果区间内只有1个元素,则无需入栈
if (key + 1 < right)
{
StackPush(&st, key + 1);
StackPush(&st, right);
}
if (left < key - 1)
{
StackPush(&st, left);
StackPush(&st, key - 1);
}
}
StackDestroy(&st);
}
6.9 时空复杂度
对于快排的时间复杂度,本来是不太稳定的,因为处理有序序列或者序列中元素相同的情况下,可能会造成 O ( N 2 ) O(N^{2}) O(N2) 的时间复杂度。
但是当我们 三数取中 或者 三路划分 后,时间复杂度就相对稳定了。
加上这两个功能之后,如果画出每层的元素情况,就像下图一样,像一棵完全二叉树。
由于每次每一块都是被二分,一共 N N N 个节点,所以这边大约就是 l o g N log N logN 层。
样图:
那么对于递归的版本,就需要开 l o g N log N logN 层栈帧;对于非递归的版本,原理和递归类似,也认为处理 l o g N log N logN 次。
每次递归/处理中,时间复杂度为 O ( N ) O(N) O(N) 。所以快排的时间复杂度为 O ( N × l o g N ) O(N \times log N) O(N×logN) 。
而对于时间复杂度也因为优化的原因,几乎不会出现极端情况,我们认为最佳情况就是像二叉树一样,最多开辟 l o g N log N logN 层栈帧,根据递归版本根据空间复杂度的计算公式: 递归深度 × 每次递归中额外空间消耗 递归深度 \times 每次递归中额外空间消耗 递归深度×每次递归中额外空间消耗,每次递归消耗空间为 O ( 1 ) O(1) O(1) ,一共 l o g N log N logN 层,所以空间复杂度为 O ( l o g N ) O(log N) O(logN) 。对于非递归的也是一个道理,推一下就明白了~
7、归并排序⭐️
7.1 算法思想
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
如果说快排是前序,那么归并恰巧就是它的对立面,归并排序相当于是二叉树的后序遍历,我们看一张图:
归并排序是逐个分解为一个个小区间,直到不能分割为止,然后一步步 归并起来 ,逐层返回。
而这一过程需要借助一个辅助数组 tmp
来完成归并过程。
对于归并的详细过程可以参考下图:
7.2 归并递归版本
学习过之前我们算法笔记的同学们相信已经对归并有一些了解了,兼顾没怎么了解过的同学,我在这边简单梳理一下思路:
对于归并排序来说,首先开辟一个辅助数组 t m p tmp tmp 。我们每一次取一个中间点 m i d mid mid 。
然后按照后序遍历的方式,分别递归左右区间: [ b e g i n , m i d ] [begin, mid] [begin,mid] , [ m i d + 1 , e n d ] [mid + 1, end] [mid+1,end] 一直递归到底部,递归的返回条件为 b e g i n > = e n d begin >= end begin>=end 。
然后开始归并,设定相关变量,然后将两区间内对应元素由小到大放置到 t m p tmp tmp 数组对应位置处。
如果放置过程结束,一个数组没有放置完,则需要在循环结束后,将数组的数据全部倒入
tmp
数组中。在上面的过程完毕之后,再把 t m p tmp tmp 数组中的数据拷贝回原数组。
最终,递归逐层返回后,就完成了归并过程。
过程不难,注意点我也都在注释部分标注了:
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end)
{
return;
}
int mid = (begin + end) >> 1;
// 递归到底部
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
/*
* 第一种拷贝回原数组的方式 - memset
* 此种做法 cnt 从 begin 开始
* memset 从 begin 位置开始,一共拷贝 end - begin + 1 个元素
* 和下面做法道理相同
*/
// int cnt = begin;
/*
* 第二种拷贝回原数组的方式 - 循环拷贝
* 此种做法 cnt 从 0 开始
* 开始拷贝的位置从 begin 开始
* cnt 最终的长度就是 [begin, end] 之间的长度
* 没问题
*/
int cnt = 0;
while (begin1 <= end1 && begin2 <= end2)
{
// 保持稳定性
if (a[begin1] <= a[begin2])
{
tmp[cnt++] = a[begin1++];
}
else
{
tmp[cnt++] = a[begin2++];
}
}
while (begin1 <= end1) tmp[cnt++] = a[begin1++];
while (begin2 <= end2) tmp[cnt++] = a[begin2++];
// 方法1
// memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
// 方法2
for (int i = begin, j = 0; i <= end; i++, j++)
{
a[i] = tmp[j];
}
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("mallol fail");
return;
}
_MergeSort(a, 0, n - 1, tmp);
}
7.3 归并排序非递归版本
归并排序的非递归版本在这一块是一个难点,因为这个本身就不容易想到。
我们先想一下,对于归并排序来说,能不能借助辅助数据结构实现?
如果用栈,那是不太行的。因为归并是一种类似二叉树后序遍历的排序,当将区间入栈后,把区间拿出来处理,之后要继续分割时,一段区间可能就不见了,所以借助辅助数据结构时不太行的。
所以我们可以不借助数据结构,用一种相对简单的方法完成。
我们可以设定一个 r a n g e N rangeN rangeN ,控制我们的区间大小, r a n g e N rangeN rangeN 就是归并时每组的数据个数。由于我们是类似二叉树后序遍历的方式,所以我们一开始的归并实际上就是 r a n g e N rangeN rangeN 为 1 1 1 情况。
如下图:
通过每次改变 r a n g e N rangeN rangeN 实际上也就是改变了区间大小,就模拟除了归并递归到底,从小区间合并逐渐到大区间合并的过程。所以我们就让 r a n g e N rangeN rangeN 每次 × 2 \times 2 ×2 ,这样子就是归并每次扩大区间的过程。
但是上面的方法只能解决数组长度恰巧被整除的情况,对于无法被整除的情况可能就会造成越界。
比如 n = 13 n = 13 n=13 。在 r a n g e N = 4 rangeN = 4 rangeN=4 时,最后一段区间 [ 13 , 16 ] [13, 16] [13,16] 越界,所以这里是需要做出一下调整的。
我们设置四个点 b e g i n 1 = i , e n d 1 = i + r a n g e N − 1 , b e g i n 2 = i + r a n g e N , e n d 2 = i + 2 ∗ r a n g e N − 1 begin1 = i, end1 = i + rangeN - 1, begin2 = i + rangeN, end2 = i + 2 * rangeN - 1 begin1=i,end1=i+rangeN−1,begin2=i+rangeN,end2=i+2∗rangeN−1 四个点来规定两段区间。
列举一下,这四个点的越界情况,我们可以分为三种情况:
- e n d 1 , b e g i n 2 , e n d 2 end1, begin2, end2 end1,begin2,end2 越界
- b e g i n 2 , e n d 2 begin2, end2 begin2,end2 越界
- e n d 2 end2 end2 越界
以上就是三种越界情况,我们需要分别处理:
处理方式分为 修正区间 和 不修正区间 :
修正区间 :
第一种越界情况,实际上就是 e n d 1 ≥ n end1 \ge n end1≥n ,那么这种情况修正区间的话,这时将 e n d 1 = n − 1 end1 = n - 1 end1=n−1 ,之后将没有越界的部分拷贝到 t m p tmp tmp 数组中,然后将 [ b e g i n 2 , e n d 2 ] [begin2, end2] [begin2,end2] 修正为一个不存在的区间。
第二种越界情况,就是 b e g i n 2 ≥ n begin2 \ge n begin2≥n ,这种情况下直接将 [ b e g i n 2 , e n d 2 ] [begin2, end2] [begin2,end2] 修正为不存在的区间即可。
第三种越界情况,就是 e n d 2 ≥ n end2 \ge n end2≥n,这种情况将 e n d 2 = n − 1 end2 = n - 1 end2=n−1 ,让两端区间正常归并。
这种情况可以边归并边拷贝,也可以一组归并完了拷贝。
不修正区间 :
第一种越界情况,修正区间之后实际上就是拷贝的原数组的数据,所以没必要修正, break
掉。
第二种越界情况,实际上也是拷贝原数据,也可以 break
。
但是第三种越界情况,就需要修正一下,否则这次归并无法完成,之后的归并也都错误了,让 e n d 2 = n − 1 end2 = n - 1 end2=n−1 。
这种情况只能边归并边拷贝,因为有些区间是未处理的,如果贸然进行拷贝会把随机值,或者错误数据拷贝进来。
好了,到这边,归并非递归的思路我们就理完了,接下来我把两个版本都写下来 :
修正区间:
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
int rangeN = 1;
while (rangeN < n)
{
// 一组归并的跨距为 2 * rangeN
for (int i = 0; i < n; i += 2 * rangeN)
{
int begin1 = i, end1 = i + rangeN - 1;
int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
int j = i;
// 修正区间
if (end1 >= n)
{
end1 = n - 1;
// begin2 和 end2 修正为不存在的区间
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)
{
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
// 可以局部拷贝
//memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
memcpy(a, tmp, sizeof(int) * n);
rangeN *= 2;
}
free(tmp);
tmp = NULL;
}
不修正区间:
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
int rangeN = 1;
while (rangeN < n)
{
for (int i = 0; i < n; i += 2 * rangeN)
{
int begin1 = i, end1 = i + rangeN - 1;
int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
int j = i;
if (end1 >= n)
{
break;
}
else if (begin2 >= n)
{
break;
}
else if (end2 >= n)
{
end2 = n - 1;
//break;
}
while (begin1 <= end1 && begin2 <= end2)
{
// 保持稳定性
if (a[begin1] <= a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
while (begin1 <= end1) tmp[j++] = a[begin1++];
while (begin2 <= end2) tmp[j++] = a[begin2++];
memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
// 这里不能外部拷贝,因为有些情况是直接 break 出来的,tmp 中不是正确数据
// memcpy(a, tmp, sizeof(int) * n); // 会把错误数据拷入
rangeN *= 2;
}
free(tmp);
tmp = NULL;
}
7.4 时空复杂度
对于归并递归版本,每次都是区间二分,然后开始递归的。所以递归层数是严格的 l o g N log N logN ,每次递归中时间复杂度为 O ( N ) O(N) O(N) ,所以总体时间复杂度为 O ( N × l o g N ) O(N \times log N) O(N×logN) ;对于非递归, r a n g e N rangeN rangeN 每次乘 2 2 2 ,每次 r a n g e N rangeN rangeN 处理的时间复杂度为 O ( N ) O(N) O(N) ,时间复杂度也是 O ( N × l o g N ) O(N \times log N) O(N×logN)。
对于归并排序的空间复杂度,递归和非递归有一些计算上的区别,但是结果不影响。
归并排序首先需要一个 t m p tmp tmp 数组,空间复杂度为 O ( N ) O(N) O(N) 。如果对于递归,还会开 l o g N log N logN 层栈帧,所以递归版本消耗的总空间大月为 N + l o g N N + log N N+logN ,当 N N N 足够大时, l o g N log N logN 省略,所以为 O ( N ) O(N) O(N);对于非递归,那么就仅仅只有 t m p tmp tmp 的消耗。
所以综上所述,归并的空间复杂度为 O ( N ) O(N) O(N)。
8、计数排序
8.1 算法思想
思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。
我们先看一下计数排序的动图:
计数排序实际上就是将数组中对应数据出现的次数,将数据出现次数映射到一个新数组中。在与数据相等值的下标处,将这个下标位置的元素自增。每出现一个数字就自增一次。
而平常的映射就是直接在其相等下标位置处理,叫做 绝对映射 ;还有一种映射方式叫 相对映射 。我们先看绝对映射。
绝对映射:
所谓绝对映射,就是开辟一个辅助数组 c c c ,数组大小为待排序数组的最大元素的大小 m a x max max 。
然后遍历数组,将数据映射到辅助数组 c c c 中。
然后根据 c o u n t count count 数组中的元素,根据元素对应的下标,将下标的值填入 a a a 数组中,如果 c o u n t count count 数组中该位置为 0 0 0, 则不需要填。
最后 a a a 数组中的元素就已经被排序好了。
其实讲到这里,大家也大约可以看出来 绝对映射 的缺点:当最大元素很大,或者是出现负数时,就无法映射了。因为空间开大了浪费空间,并且无法在负数下标自增。所以这就引出了 相对映射 。
相对映射:
相对映射,就是根据数据之间的相对情况来开辟数组大小,并在转换后的相对位置执行映射。
比如有这样一组数据: { 5000 , 5001 , 5500 , 5501 } \{5000, 5001, 5500, 5501\} {5000,5001,5500,5501} ,对于这组数据我们开 5501 5501 5501 个空间肯定是浪费的。
我们相对映射的思路就是遍历序列,找到序列最大值 m a x max max 和最小值 m i n min min ,然后开辟 m a x − m i n + 1 max - min + 1 max−min+1 个空间,让空间尽可能充分利用。
之后映射自增时,也使用相对位置,这个相对位置就是数组元素减去数组元素的最小值: a [ i ] − m i n a[i] - min a[i]−min 。
在最后将元素放到原数组中时,也需要将数组下标加上最小值: i + m i n i + min i+min 放回去就可以。
通过相对映射,对于元素有负数,和空间浪费的情况都可以解决。(ps:元素有负数的情况,无需特殊处理,因为相对映射的原因,这些步骤都可以正确进行,不信可以试验一下)。
8.2 代码实现
接下来,就使用相对映射的思路写出代码:
// 计数排序 正负数都可以排
void CountSort(int* a, int n)
{
// 1. 找最小值和最大值
int max = a[0], min = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
{
min = a[i];
}
}
// 2. 根据差值构建 count 数组
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
perror("malloc fail");
exit(-1);
}
// 初始化
memset(count, 0, sizeof(int) * range);
// 3. 将值映射到count数组中
for (int i = 0; i < n; i++)
{
count[a[i] - min]++; // 映射到相对位置
}
int cnt = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
a[cnt++] = i + min;
}
}
}
8.3 时空复杂度
计数排序的时间复杂度其实是由 r a n g e range range 和 N N N 的关系来衡量的,当我们不确定 r a n g e range range 和 N N N 的大小时,我们可以认为 计数排序的时间复杂度为 O ( m a x ( N , r a n g e ) ) O(max(N, range)) O(max(N,range)) 。取较大的一个。
而空间复杂度则是 O ( r a n g e ) O(range) O(range)。
实际上通过时空复杂度上看,我们发现计数排序在数据集中的情况下是非常厉害的,能达到几乎 O ( N ) O(N) O(N) 的时间复杂度,并且空间复杂度也不会太大。但是对于范围分散,跨度大的序列就不适合,不仅时间没啥优势,空间占比也是个大问题。所以计数排序的适用范围是有限的。
四、排序算法复杂度及稳定性分析
这里我们就用两张图概括:
这里提一下排序的稳定性:
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中, r [ i ] = r [ j ] r[i]=r[j] r[i]=r[j],且 r [ i ] r[i] r[i] 在 r [ j ] r[j] r[j] 之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
稳定性是排序算法一种额外的优点。如果一种排序可以通过某种措施,达到数据相对次序不变的效果,则称该排序是稳定的。
五、排序性能测试框架
void TestOP()
{
srand(time(0));
const int N = 10000000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
}
// clock 获取程序运行到这块的时间
// end1 - begin1 = 排序时间
// 获取的是毫秒
// 时间过小时,计算不出来
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSortT(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
BubbleSort(a6, N);
int end6 = clock();
int begin7 = clock();
MergeSort(a7, N);
MergeSortNonR(a7, N);
int end7 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
printf("BubbleSort:%d\n", end6 - begin6);
printf("MergeSort:%d\n", end7 - begin7);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
}
六、结语
到这里本篇博客就到此结束了,对于数据结构的学习,我们也就暂时告一段落了。
接下来 a n d u i n anduin anduin 更新的内容大多就是 C + + C++ C++ 和算法笔记了。
对于这篇文章,博主个人觉得还是不错的,希望你们阅读完之后可以有所收获!
如果小伙伴们需要源码的话,可以到我的 g i t e e gitee gitee 打包下载源码:八大排序
我们下期见~
