1.排序的概念及其应用
1.1排序的概念
排序 :所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性 :假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j] ,且 r[i] 在 r[j] 之前,而在排序后的序列中, r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序 :数据元素全部放在内存中的排序。
外部排序 :数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2排序的应用
比如我们要购买手机可以按价格排序,按销量排序等。
1.3常见的排序算法
还有一个计数排序我们下面会具体介绍。
2.排序算法的实现
2.1插入排序
直接插入排序是一种简单的插入排序法,其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为 止,得到一个新的有序序列 。
实际中我们玩扑克牌时,就用了插入排序的思想。
那么具体是怎么插入排序呢?我们来通过一张动图看看
首先我们来实现单趟
我们每次要拿有序后的那个值插入到有序的部分里,让他们变成有序,所以我们定义一个end,让[0,end]有序,让end+1位置的值插入到[0,end]中,这里在循环结束后我们再让tmp插入,而不是在else语句中,因为我们要考虑到一种情况,就是end+1位置的值最小,要将它插入到最左边,我们始终都没有进入else语句中,直到循环结束,所以在循环结束后插入才是方便的,不然还要再判断一下。
然后我们再来看一下完整的,数组的最后一个数的下标是n-1,最后要让end+1位置的值,也即是下标为n-1的数,往[0,end]中插入,所以end最后为n-2,也就是循环结束的条件是i<n-1。
我们来测试一下:
直接插入排序的特性总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度: O(N^2)
- 空间复杂度: O(1) ,它是一种稳定的排序算法
- 稳定性:稳定
2.2希尔排序
我们发现,当被排序的对象越接近有序时,插入排序效率越高(最好情况就是顺序,时间复杂度为O(N),最坏情况是逆序,时间复杂度为O(N^2)),那我们是否有办法将数组变成接近有序后再用插入排序,这个时候就得看一个叫希尔的大佬发现了这个排序方法,并命名为希尔排序。
希尔排序是对插入排序的优化,希尔排序法又称缩小增量法。希尔排序法的基本思想是: 先选定一个整数作为增量,把待排序文件中所有数据分 组,以所有距离为等差数列的分在同一组内,并对每一组进行排序,将增量缩小,然后重复上述分组和排序的工 作。当到达增量gap =1 时,排序完正好有序。
希尔排序的特性总结:
- 希尔排序是对直接插入排序的优化。
- 当 gap > 1 时都是预排序,目的是让数组更接近于有序。当 gap == 1 时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
- 希尔排序的时间复杂度不好计算,因为 gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定:
《数据结构 (C 语言版 ) 》 --- 严蔚敏
《数据结构 - 用面相对象方法与 C++ 描述》 --- 殷人昆
因为咋们的 gap 是按照 Knuth 提出的方式取值的,而且 Knuth 进行了大量的试验统计,我们暂时就按照:O(n^1.25)到O(1.6*n^1.25)来算。 - 稳定性:不稳定
接下来我们就来代码实现:
首先我们来实现一次排序,假设增量gap为3,可以发现和插入排序非常类似,只不过这里我们每次插入排序时,隔了gap个间隔。
然后就是一组数据排序,就比如在上图中gap=2时,红色字体4,2,5,8,5就是一组,就相当于是这一组数据在插入排序。(i < n-gap是为了拿tmp数据时不会造成数组越界)
注意:下面的代码中end不是等于0,而是end=i,忘记改了
上面只是一组数据,我们把总的数据分成了gap组,现在要对每组数据分别插入排序
但是我们可以优化一下,不用三层循环,两层循环即可,只需要把i+=gap改为i++就行,这样的话就不是一组一组的排,而是每组轮着排,但两者效果一样,没有区别,只不过这样写简便点。
这只是一趟,是预排序,我们要让总的数据整体变成有序,所以每趟排序之后gao都要减小直到gap为1,就是插入排序,那么gap要如何取值呢?由上面希尔排序的特性我们可以取gap=gap/3+1这是一个较好的取值,注意这里+1是为了能够正好整除3(比如当gap最后等于2时,就会陷入死循环,所以为了能够整除,在后面+1)
测试一下:
把预排序也打印出来看一下
2.3选择排序
基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的 数据元素排完 。
直接选择排序 :
1.在元素集合 array[i]--array[n-1] 中选择关键码最大 ( 小 ) 的数据元素
2.若它不是这组元素中的最后一个 ( 第一个 ) 元素,则将它与这组元素中的最后一个(第一个)元素交换
3.在剩余的 array[i]--array[n-2] ( array[i+1]--array[n-1] )集合中,重复上述步骤,直到集合剩余 1 个元素
动图如下:
直接选择排序的特性总结:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度: O(N^2)
- 空间复杂度: O(1)
- 稳定性:不稳定
代码实现:
我们先来实现单趟排序,我们定义一个变量begin和一个变量mini(其中mini是最小数字的下标,begin是依次排序时的下标)先遍历一遍找到最小的,将其下标赋值给mini,循环结束后,将下标为begin和mini的两个数字交换,然后begin++接下来找第二小的,重复以上步骤。
要想重复以上步骤,肯定还要一层循环,注意begin小于n-1就行,因为当我们将前面下标[0,n-2]的数字都排序好之后,下标为n-1的数字就不用排了,最后剩下的那个肯定是最大的。
运行测试:
其实选择排序可以再优化一下,我们将小的换到左边,是不是也可以同时找大的,把大的换到右边。按照这个思路,我们接下来将代码优化改进一下。
运行测试:
这么写其实是有一点问题的,我们造一组能出问题的数据就可以发现
这是为什么呢?我们可以调试看一下:
在上面两张图中,从第一张图上我们可以发现最小的1与最大的9交换完之后,我们的maxi还是0,然后在第二张图上可以看到,由于maxi还是0,就变成了0下标的1和end下标的5交换。所以从这里我们就能看出问题,maxi在这种情况时需要刷新成交换后的下标。
我们可以这样修改:
由于这种情况只有在最大值出现在0下标时才会发生,原因是在for循环中,第二个if语句是不会进入的,这样的话maxi就会一直是初始化时的begin,所以在交换完最小值后,我们先判断一下begin和maxi是否相等,相等就说明是最大值出现在下标0的情况,那我们此时就把mini的值赋给maxi,不相等就说明是正常情况,直接交换最大值。
注意:为什么要把mini的值赋给maxi?那是因为我们把最小值交换完之后,最大值就从下标0换到了下标为mini的位置,此时mini的位置处就是最大值,所以直接把mini的值赋给maxi。
再测试运行一下:
2.4堆排序
堆排序之前讲过,在堆排序和TOPK问题时有介绍过。
2.5冒泡排序
基本思想:
在待排序的一组数中,将相邻的两个数进行比较,若前面的数比后面的数大就交换,否则不交换;如此下去,直至最终完成排序。在这个过程中,较大的数如同气泡一样逐渐"上浮"到数列的末尾,较小的数则会"下沉"到数列的开头。
动图如下:
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度: O(N^2)
- 空间复杂度: O(1)
- 稳定性:稳定
代码实现:
冒泡排序还是非常简单的,对于新手来说具有教学意义,但实践中不会用到,在这里我们对其优化了一下,如果是顺序的话,我们定义了一个变量flag=0,在顺序时不会进入for循环的if语句中,flag就还是等于0,进入第二个if语句中,直接跳出循环,所以在顺序时只需要遍历一遍数组,时间复杂度这时为O(N),提高了效率。
2.6快速排序
快速排序是 Hoare 于 1962 年提出的一种二叉树结构的交换排序方法,其基本思想为: 任取待排序元素序列中 的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右 子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止 。
将区间按照基准值划分为左右两半部分的常见方式有:
- hoare 版本
- 挖坑法
- 前后指针版本
代码实现:
2.6.1递归实现
先来实现单趟排序,还是比较简单的,但是要注意一点,就是右边找小和左边找大时也要保证begin<end。另外keyi下标位置的值key交换后,左边全是比key小的,右边全是比key大的。
注意:为什么begin和end相等时,此时a[begin]一定比a[keyi]小呢?因为右边在找小,比左边先走,右边停下来时,一定是比key小的值。
单趟排序完之后,如下图,但是并没有排序好,还需要继续排左边和右边,所以我们可以通过递归左右子序列,继续排序,这样的话递归完就能排序好整个数组
递归如下:
递归到最后,还剩一个序列或者为空时就返回,即left>=right,如下图举例:
运行测试:
仔细思考一下,是不是只要每次都是二分的话效率就比较高,所以选key就很关键,只要选的key是一个适中的值就要好点,但我们每次选key都是最左边left位置的值,如果是有序的话,递归的深度就是N,二分的情况下递归的深度时logN,这个效率就会比较慢了,所以我们可以优化一下。这里有一个方法叫三数选中
三数选中:
选取三个数,最左边,最右边和中间三个数,从这三个中选取中间值,换到最左边,成为key,这样的话,我们就能拿到一个适中的值,效率也会提升。
挖坑法和前后指针法实现单趟排序如下(难度不大这里就不细讲了):
2.6.2非递归实现
递归的话还是会存在一些问题,例如递归深度太深出现栈溢出的问题。所以非递归对于实现快排来说也是非常重要的。
如下图:
假设每次key都是一个大小适中的值
根据这个思路我们来实现代码:
在这之前要先实现栈,之前有讲过,这里就不再赘述。
这里我们的栈实现的是整型数字的操作,在上图中,我们提到的是一个区间的序列入栈出栈,这里也可以将整型修改为结构体类型来实现区间的入栈出栈,但是没有必要,我们可以一次入两个数字,表示他的区间,出栈的时候也一次出两个数字,这样就能得到区间,但是入栈的时候要先入区间的右边数字,再入左边数字。另外keyi+1<end和begin<keyi-1都表示区间为空或者只有一个数字,这个时候就不用入栈了。
测试运行:
快速排序的特性总结:
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫 快速 排序
- 时间复杂度: O(N*logN)
- 空间复杂度: O(logN)
- 稳定性:不稳定
2.7归并排序
基本思想:
归并排序( MERGE-SORT )是建立在归并操作上的一种有效的排序算法 , 该算法是采用分治法( Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
动图效果:
代码实现:
2.7.1递归实现
先开辟一个数组的空间,在新数组中进行归并,然后再复制到原数组。
后序递归,先递归左子序列,在递归右子序列,然后再归并。最后再把归并好后的数据复制给原数组,注意a和tmp要+begin,因为归并右子序列时,要从mid+1开始复制。
注意:不能在[begin,mid-1][mid,end]有序后开始归并,为什么呢?因为会出现死循环,导致栈溢出。
如下图所示:
[2,3]会一直循环,导致栈溢出
运行测试:
2.7.2非递归实现
快排非递归使用栈实现,那归并排序呢?答案是可以,但是比较麻烦,因为快排是相当于前序,二归并排序则相当于后序,首先要分解,然后再归并,分解的时候出栈,等要归并的时候,栈已经空了,这个时候就需要用两个栈,另一个栈用来存出栈的数据用来归并,所以就比较麻烦。
这里我们直接循环进行归并,gap=1时,就一个数和一个数进行归并,gap=2,就两个数和两个数进行归并,如下图所示:
我们先来实现11归并,如下:
再来看22归并,44归并等,直到循环归并完所有数据。
但是这里有一点问题,因为gap*=2,所以在11归并之后gap是2的次方倍,所以[begin1,end1][begin2.end2]两个区间中,边界end2一定是2的次方倍数减1,如果我们的数据不足2的次方倍数个,就会发生越界的情况,如下图:
我们将区间打印出来,我们只有9个数,很明显这已经越界了
我们可以分为三种情况,而下面两种情况又可以分为一种。
只有end2越界时,是所有数据进行归并,我们只需要修正一下end2就行:第二种情况,则是后面的数据中,不足一个区间的数,那我们就不需要就行归并,因为它就是有序的,等到下一次区间范围更大时,也就是包含那组越界的区间数据时再归并
这里针对越界的情况,调整一下。
运行测试:
归并排序的特性总结:
- 归并的缺点在于需要 O(N) 的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度: O(N*logN)
- 空间复杂度: O(N)
- 稳定性:稳定
3.测试各个排序性能
测试代码如下:
这里我们先用10000个随机数来测试一下:
注意:单位是毫秒
然后再来将数据个数提升至100000(由于冒泡比较慢,就直接不测试了)
N=10000000时(比较慢的就不测试了)
再来看看非递归的快排和非递归的归并
算法复杂度及稳定性分析如图: