写在前面
排序就是将给定数据按照一定的顺序(升序或降序)重新排列。 排序是许多算法的基础,可以让数据变得容易处理。
排序时,需要先确定排序的基准,即“排序键”;在选择排序算法时,时间复杂度、空间复杂度 和 稳定排序 都是重要的考量标准。 所谓稳定排序,即指在数据中含有键值相等的元素,其在排序完成后顺序不变。
时至今日,人们已经开发出了许多种排序方法,其机制个不相同。 我们要根据实际情况,选择适宜的排序方法:
- 复杂度与稳定性
- 除了原有数据外是否还需要额外的内存
- 输入数据的特征可能会影响算法的效率
虽然各种编程语言的库中都有标准排序算法供我们使用,但是,算法乃是程序员的自我修养,而排序是算法的基础,深入理解其原理可以为我们将来实现其他功能提供思路与方法。
初等排序
插入排序
插入排序将待排序数据分为 已排序 和 未排序 部分,取出未排序部分的开头元素,将它的值储存在临时变量v里,然后将所有比v大的元素都后移一位,再将v插入到空位里。这就是插入排序了,很简单吧,我们直接上代码:
1 | // 插入排序算法 |
分析插入排序的特征,我们可以发现,因为插入排序不会直接交换不相邻的元素,而是将所有比当前元素键值大的元素后移,因此,插入排序是稳定的。
再看看复杂度,在最极端的条件下,我们需要将 每个i循环 进行i次移动,总共需要1+2+……+N-1=(N^2 -N)次移动,当N很大时,我们可以忽略N的项而保留 N平方 的项,即插入排序的时间复杂度O(N*N)。但是如果是已经按照升序排列好的数组,我们只需要N次比较就可以完成排序。 由此可见,输入数据的特征可以明显影响排序的效率。亦可以看出,插入排序能够快速处理相对有序的数据,这一点很重要,在高等排序的 希尔排序 中会对这一特性进行扩展。
冒泡排序
冒泡排序实现简单,使用广泛。它的机制是依次比较相邻元素,将所有大小关系相反的数据交换:
1 | void swap(int* p, int* q) { |
类似的,我们对冒泡排序算法进行分析,其在最坏的情况下需要(N^2 -N)次比较,即算法时间复杂度为O(N*N)。顺带一提,函数返回的交换次数也称为逆序数,反应了数据的混乱程度。
选择排序
同样的,选择排序也将数组分为 已排序 和 未排序 的部分。 找出未排序部分的最小(大)值的位置下标 min_j (max_j),将min_j 位置的元素与未排序部分的开头元素交换,重复N-1次(N个元素,最多换N-1次):
1 | // 选择排序算法 |
要注意的是,选择排序会交换不相邻的元素,因此可能出现排序键值相等而元素顺序改变的情况,例如:数组{3H,5L,3D,1S},在对 数字键值 执行选择排序后,会变成{1S,3D,3H,5L} (大家可以自行验证)。可见,选择排序是不稳定的。
再来看看复杂度,无论在什么情况下,选择排序都要进行(N^2-N)/2 次比较运算,复杂度与N*N成正比。
初等排序小结
综合来观察三种初等排序方法。冒泡排序从局部出发,逐项比较,减少逆序数;选择排序面向整体,逐个选择最小(大)值。虽然这二者思路大相径庭,但是我们可以看出,冒泡排序 和 选择排序 不依赖输入数据。而与之相对的,插入算法却很依赖数据,处理相对有序的数据有很高的效率。
高等排序
虽然初等排序在应对一般数据时已经有很好的效果,但是在非常庞大的数据面前便会失去效果。因此,我们就需要运用高等排序。 高等排序是通过运用 分治法与递归法,结合初等排序思路 进行优化而实现的高速算法。
希尔排序
上文提到过,插入排序 在针对相对有序的数据时具有非常高的效率。而 希尔排序就是针对这一特性而实现的高速算法。
希尔排序的思路是,重复进行以间隔为g的插入排序,g的值逐轮减小,最后再执行 间隔g=1 的一般插入排序。 为什么要进行这样的操作呢?
其实,每一轮以g为间隔时,其实都是把选出来的数据组成一个新的数组,这些数组的元素数目N’相对N来讲很小。第一轮时,g1约为数据量N的1/3~ 1/2 ,这些分割出来的数组只有2~3个元素,进行排序非常快捷。第二轮时,经过第一轮的处理,此时的数组相对原数组变得相对有序了一些(好吧,虽然只有一点点,(⊙o⊙)),此时,间隔g2减小,继续执行排序,这些小数组又变得有序了。 继续一轮轮的执行这些操作,间隔g逐渐减小;每一轮操作时,因为之前的插入排序,对数据整体来说,此时的数组已经变得相对有序了很多。所以此时在执行当前g的插入排序,效率比直接执行此gap的插入排序提高了非常多。
根据这个原理,最后在执行 g=1 的插入排序时,因为数据已经变得非常有序了,此时的排序效率非常高。
那么,由此可见,希尔排序的效率就跟Gap间隔数组的选择有很大的关系,根据数学原理,当递推关系式为:g(n+1) = 3*g(n)+1 时,希尔排序的复杂度基本稳定在O(N^1.25)。具体数学原理本文就不做讨论了(才不是因为论文太难看不懂呢😂)。但要注意的是,如果Gap间隔数组为2的幂指数,则算法的效率会大打折扣。代码如下:
1 |
|
归并排序
类似于希尔排序,归并排序也是从局部入手,将庞大复杂的数据向下逐层拆分成小的局部数组,再进行逐层向上的整合,从而实现高速的排序。我们直接上代码:
1 | int L[MAX / 2 + 2]; |
归并排序的核心是merge函数,逐层分割后的局部数组在merge函数的整合下,每一层的复杂度是O(n_left+n_right),于是整个算法的复杂度为O(N*logN)。
我们容易看出,只要元素出现在其键值相等的其他元素之前,其在分割与整合后就一定在其他元素之前,所以归并排序是稳定的。
但要注意的是,虽然归并排序稳定高效,但是它却需要额外的内存来储存分割出来的临时数组,占据额外的空间。
快速排序
快速排序可以说是运用最为广泛的排序算法了,它稳定高效,C++的STL库函数sort( ) 就是基于快速排序的函数。 快速排序基于递归法,在分割的同时对数据进行排序与调换:
1 | // 快速排序算法 |
主函数调用时需要传递初始参数,即 quicksort(0,n-1)。 观察上述代码,i、j 即为所谓的标兵节点。第一轮调用时,i从0,j从n-1开始,选择 arr[0] 为比较基准,储存在temp中。i和j while循环中,i寻找比temp大的节点,j寻找比temp小的节点;i、j 不可错位(设置适宜的循环条件)。i和j 循环结束时,如果是因为i==j,则结束外层循环,进入下一步;而如果是因为找到了目标节点,则两个值互换,继续执行代码; 无论如何,只要外层while循环结束,i和j 相遇在同一节点,再将arr[left]的值换到i位置,此时 i(j) 之前的元素都小于等于temp,之后的元素都大于等于temp。 进行递归调用quicksort(left, i - 1); //左递归 quicksort(i + 1, right); //右递归
,即将左右分别再次执行一次上述操作,直到left=right(局部数组为单个元素)返回,此时,排序就完成了。
上述的只是快速排序基准选择的一个方式,还有很多根据实际情况的方式,但思路大体一致,本文不再赘述。
计数排序
计数排序相对简单,它善于处理元素为正整数的情况,具有极高的效率,但是其元素的最大值具有限制,否则会导致栈溢出:
1 | void CountingSort(int n) { |
为了表达方便,A下标从0开始。
第一个for循环结束,C[k] 的值即为A中值为k的元素出现的次数。再把C逐项累加,那么此时C[k]的值就表示A值为k的元素放置于排序完成数组B中的位置,也表示A中包含几个小于等于K的元素。计数排序能在O(N+Max)的线性时间完成排序,效率可以说非常高了。
打个总结
以上就是常见的排序算法了,其实不难看出,没有最好的排序算法,只有最适宜的排序方法,我们要根据实际情况来选择使用算法。对其他算法也是一样,我们要因地制宜,在不同情形下选择最优解;有些时候可能没有现成的方法让我们直接使用,这时候就需要分析问题,解决问题的能力,站在前人们的肩膀上,用这些思路创新开发出合适的方法。
笔者虽然仔细核对过文中附如的代码,但是难免疏漏,有问题或建议欢迎留言,谢谢。