排序,也称为排序算法,可以说是我们学习算法的过程中遇到的第一个门槛,也是实际应用中使用得较为频繁的算法,我将自己对所学的排序算法进行一个归纳总结与分享,如有错误,欢迎指正!
(一)排序的分类
排序算法主要分为内部排序与外部排序,当数据量大时,数据无法全部加载到内存中,因此需要接抓外部存储(文件、磁盘等)进行排序。而内部排序则是指将要处理的所有数据加载到内部存储器中,并在内存中就完成排序。
本文针对的为内部排序。
(二)内部排序
2 交换排序
之前介绍了选择排序,而选择排序之所以叫选择排序就是因为是选择指定的元素放在指定的位置上。而这里我们介绍的交换排序自然就是通过交换元素来完成排序了(不是说其他排序方法不使用交换,而是核心思想是否是交换)。
本次介绍的两种交换排序算法,一种是学习排序第一步都会学的冒泡排序算法,另一种则是应用得比较多的快速排序算法。
2.1 冒泡排序(Bubble Sort)
冒泡排序(Bubble Sort),这是一个非常有意思的名字。我们看到这个名字不妨去想一下:
又一个大水缸,我们往水缸底部注射泡泡,泡泡就会一直往上冒。而冒泡排序就是越小的元素会经过不断交换,慢慢“冒”到数列的前端。
而冒泡排序是排序办法中的一种“笨”办法,之所以它“笨”,就是因为他在每一轮中都要将每两个相邻元素两两比较,逐步地将小的元素往数组前面“冒”,将较大的元素逐步往后“沉”。
不难想到,第n轮的比较交换结束后,倒数第n个元素就是排列好,有序的了。举第一轮的例子来说,当第一个和第二个元素相比较交换后,后者的元素是前两个元素里的最大值,那么第二个元素和第三个元素相比较交换后,第三个元素就是前三个元素里的最大值,那么一直比较交换到最后一个元素的时候,最后一个元素就是整个数组的最大值了。
那么我们可以借助这个特性,使得第二轮只排序交换第一个到第 n - 1 个元素,第三轮只排序交换第一个到第 n - 2 个元素,依此类推下去,最后一轮只需要排序第一个元素,也就是排序完成。
那么我们一共需要比较多少轮呢?由于我们不知道原数组的情况,最保险的做法是排序的轮数等于数组的元素数量减一,这样才能保证数组的每个元素都在应在的位置上,即是有序的。
但是假如我们实际上并不需要排列那么多次呢?举一个极端的例子,就是数组本身就是有序的,那么我们的比较就显得很多余了,这时我们可以做一个小小的优化,就是设定一个标志,标志本轮排序是否发生了交换,如果没有发生交换则说明数组已经有序,就可以中断排列了。
下面上张图来说明这个过程:橙色是排列好的有序数组段,图源网络
由于冒泡排序算法实在是入门级的排序算法,下面就不多赘述了,直接上代码:
冒泡排序是一个时间复杂度为O(n²)的稳定的算法,不管是否需要交换,都需要经过两两的比较。经过优化过的冒泡排序的最好情况是数组本身就是顺序的,最坏情况是数组逆序的(但是直接写个逆序就好了啊,为啥要用冒泡排序)。冒泡排序实际上应用得较少,但是是排序算法中最为基础的一种,还是需要理解和掌握的。
看起来交换排序很“笨”,但实际上并不是,接下来介绍的另一种交换排序方法却很快,应用得也十分频繁,那就是快速排序算法,也称为“快排”。
2.2 快速排序(Quick Sort)
快速排序,名字简单粗暴,而它之所以叫这个名字的原因就是因为其特性:快。
快速排序是冒泡排序的改进,冒泡排序的比较是比较“笨”的方法,要将一一去将两个相邻的元素比较。但快速排序的比较就十分的聪明了,这里会用到两个重要的思想,分而治之(Divide and conquer)和递归(recursive)的思想。
我们都学过二分法,对一个数组采用二分法一直分下去分到最底层就只剩一个或者两个元素,而对一个或两个元素的操作是很轻松的。那么我们想要排序一个数组,可以将一个数组利用一个数作为基准值(pivot)去分成两段,使得左边一段的数全比这个数小,右边一段的数全比这个数大,然后再分别将左边与右边再一次按照这种规则分段,一直到分最底层并将其排序。
这就是快速排序的思想,我们用一个实际生活中的例子来说明:
有十个高矮不一的男同学,现在需要我们去给他们从低个到高个排队。我们没办法去知道这十个同学身高的中值,于是我们随便挑了一个男同学作为基准(pivot),将比他个子低的男同学排到他的左边,将比他个子高的男同学排在他的右边(这个过程称为分区partition),这样这个作为pivot的男同学位置就排好了。
那么我们再分别在比他个子低的男同学里再随便找一个男同学作为基准(pivot),按照第一轮的方式排队,不难发现,作为基准的每个同学的位置都能排好,一直到作为我们第一个基准的男同学的左边的每一个同学的位置都排好为止。右边也同理。
当最后一个作为基准的同学队排完的时候,整个队列就排好了。而我们不断在分段中找基准的过程,就是递归。
快速排列的思想就是这样子了,并不算很难理解,快速排列借助的就是分而治之和递归的思想去完成的,但是在程序中应该如何实现呢?选择基准由于是随机的,很容易实现,但是快速排序的最大难点在于怎么将基准左右两端的元素正确的放在两端(也就是怎么实现分区)。
分区(partition)的实现思路
实现分区的原理就是不断将比基准值小的元素放在前面,比基准值大的数放在后面。
分区的实现方法有很多,这里我只介绍我常用的两种实现思路。
思路一:用一个数组实例来说明:
10,8,22,34,5,12,28,21,11
先选定第一个元素10作为pivot,相当于将10移除出数组作为基准值,然后在剩下的数组里找到比10小的元素就往前面放,比如先找到8,就将8放到10之后的第一位,在找到5放在10之后的第二位。当整个数组遍历完毕,将所有的比10小的数都尽可能往前放后:
10,8,5,34,22,12,28,21,11
此时我们在将基准值10与最后一个比10小的数交换:
5,8,10,34,22,12,28,21,11
这样以10为基准的分区就完成了,接下来就是向10的左边和右边分别递归的过程了。
思路二:用同样的一个数组实例来说明:
10,8,22,34,5,12,28,21,11
先选定第一个元素 10 作为pivot,那么我们就从第二个元素开始查找比10大的元素,一直查找到第三个元素22的时候,将22标记,然后在从第四个元素开始找比10小的元素,一直查找到第五个元素5,然后将22和5的位置交换。
10,8,5,34,22,12,28,21,11
再从5开始移动标记,移动到34,发现也比10大,标记34,再开始从22向后查找,查找到最后也没有比10大的数了,这时将基准值10放在标记的34的位置,这样第一轮的分区就完成了。接下来的就是向10的左边和10的右边分别递归分区过程。
在这查找比基准大的值的过程中,如果没有发现比基准值大的值,说明这个值就是在这个递归中的最大值,只需要将第一位和最后一位交换位置即可。
该思路坑太多,慎入!!!
再上一张图来说明整个排序过程:黄色为基准值pivot,橙色为已排列,图源网络
下面上根据思路一实现快速排序的代码:
思路二的代码我写了六十多行才实现,有想法的小伙伴可以尝试一下(如果三四十行实现了请务必要私信我),基准值的选取也可以不选择第一个,随机选择其他的值也是可行的(但是一定要找到结束分区的条件),快排的写法非常的多,大家也可以多试试其他的思路。
我们在开始介绍快速排序的时候,就提到快速排序的特点就是速度快。
快速排序的平均时间复杂度是O(nlogn),最坏情况达到O(n²)(顺序数列的快排,每次都会比较,实际上跟冒泡排序基本没有区别),那就有人问了,时间复杂度达到O(n²)的算法怎么能算快呢?但是实际上快速排序很少会出现最坏情况,在处理数据的时候一般都是O(nlogn),而且快排之所以快,是因为它在大多数情况会比其他O(nlogn)时间复杂度的算法表现的要更好。
因为快速排序的O(nlogn)中的隐含的常数因子通常很小,比复杂度稳定为O(nlogn)的归并排序要小很多,所以在遇到顺序性较弱的随机数列来说,它的性能通常要比归并排序要优秀很多。
最后总结一下,快速排序是冒泡排序算法的改进,本质上是在利用了分治思想的冒泡排序算法。冒泡算法是两两比较小的往前“冒”,大的往后“沉”;而快速排序则就是与基准值(pivot)比较小的往前“冒,”大的往后“沉”。
快速排序还有一个重点:分区。基本上掌握了分区(partition),就掌握了快速排序。