算法修炼之路(三)—— 排序

时间:2024-03-30 09:49:57

      排序是计算机最基本的算法之一。在计算机中,排序算法可以分为内部排序(In-place)和外部排序 (Out-place) 两大类。内部排序是指不需要开辟额外空间进行排序的算法,排序过程在原序列内存上进行。而与之对应的外部排序则需要开辟额外的空间进行辅助。计算机有十大经典排序算法,接下来我们一起来探究这些经典的排序算法。

算法修炼之路(三)—— 排序

 

1.冒泡排序(Bubble Sort)

     冒泡排序是交换排序的一种,它的算法原理在于比较一组数据中相邻的两个元素,根据大小关系按需要对它们进行位置交换。从数据中开始的一对到结尾的最后一对,排序过程都会对每一对相邻元素做相同的比较工作,并且针对所有的元素重复以上步骤。排序的思想就是每遍历一次把最大的元素放到最后,每循环一次,需要进行重复操作的元素就越来越少,因为最大的元素每次都会排在最后面,下一次排序过程便无需参与比较。该过程会持续进行,直到没有数字需要比较为止。

      对于冒泡排序来说,在最坏的情况下,数组每次遍历到的第一个元素都要全程进行位置交换排到后面(例如把正序数组变成倒序数组),这种情况下,数组进行的交换操作会从第一次的n次,第二次的n-1次,第三次的n-2次,一直到最后的1次,因此总的交换次数为(n+1)n / 2,所以该算法的时间复杂度为O( n^2 )。由于算法一直是在原数组上进行操作,所以该算法空间复杂度为O(1)。

算法修炼之路(三)—— 排序

     冒泡排序最好的情况下是整个数组已经排好序了,不需要进行排序。对于这种情况,当我们第一次遍历,对每两个相邻的元素进行比较后,发现不需要执行任何交换操作时,就可以停止第二次遍历,直接结束循环,此时算法的时间复杂度为O(n)。对于数组中某个已经排好序的元素,我们可以在每次遍历完对它进行判断,如果从它在第一位开始,遍历的过程中都没有与其它元素进行任何交换操作,则直接结束循环遍历,返回排好序的数组:

算法修炼之路(三)—— 排序

      如果存在重复元素,冒泡排序不会改变两个元素的位置,因为不会进行交换操作。对于这种情况,我们称该算法是稳定的。
 

2.快速排序(Quick sort)

     快速排序是对冒泡排序的一种改进,它同样采用了分治思想。 使用快速排序,我们首先需要设定一个基准分界值pivot,通过该分界值将数组分成左右两部分(称为分区partition)。然后将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中在数组左边。然后左边和右边的数据又可以各自取一个分界值,再通过递归重复上述操作。当每次左右部分都完成排序后,整个数组的排序也就完成了。

      我们拿数组[ 5,4,7,3,6,2,8 ] 来举例。首先我们取一个基准值,我们默认取数组第一位,即 5。 然后我们定义两个相等的指针left 和 right,初始化为索引1。根据数组长度循环遍历,从第二位开始与基准值进行比较。当发现遍历到的元素比基准值小时,我们便让两个指针所对应的元素交换位置。

算法修炼之路(三)—— 排序

      例如,第一次遍历到元素4,比基准值5小,此时left和right所处位置元素交换位置,因为还是同一个位置,所以序列并没有改变,此时序列为 [ 5,4,7,3,6,2,8 ]。交换完元素后,我们要让left指针指向下一位,而right根据循环也递增。

算法修炼之路(三)—— 排序

      接着根据right的位置遍历到元素7,发现比基准值5大,无需交换位置,此时left无需递增,还是停留在原来的位置,而right递增进入下一个循环。所以此时left=2,right=3。此时序列仍为 [ 5,4,7,3,6,2,8 ]。

算法修炼之路(三)—— 排序

      接着继续根据right的位置遍历到元素3,发现3比5小,此时left和right所处位置元素交换位置,序列变为 [ 5,4,3,7,6,2,8 ]。

算法修炼之路(三)—— 排序

      交换完元素,让left递增指向下一位,left=3,right继续根据循环递增,right=4。

算法修炼之路(三)—— 排序

      接着遍历到元素6,6比基准值5大,无需交换位置,left停留在位置3,right递增为5。

算法修炼之路(三)—— 排序

      然后遍历到元素2,比基准值5小,left和right交换位置元素,此时序列变为[ 5,4,3,2,6,7,8 ]。

算法修炼之路(三)—— 排序

      然后left指向下一个位置4,right递增为6。然后发现元素8比5大,无需交换位置,此时right继续递增为7后超出数组范围,结束循环。

算法修炼之路(三)—— 排序

      最后的一步,循环结束后,将left指针前一个位置的元素与基准值交换位置,序列由[ 5,4,3,2,6,7,8 ] 变为 [2,4,3,5,6,7,8 ]。

算法修炼之路(三)—— 排序

      此时我们便发现,基准值5左边的元素都比5小,右边的元素都大于5。接着我们对基准值左右两部分的元素各自继续进行上述操作,直到最后完成所有元素的排序:

算法修炼之路(三)—— 排序

      上述算法基准值必须从第一位开始取,但对于快速排序算法来说,基准值最好从中间开始取,这样可以防止陷入最糟糕的时间复杂度。对于从中间取基准值的写法,我们可以额外开辟新的空间,用来存放每次排好序的左右两部分元素:

算法修炼之路(三)—— 排序

      这段代码虽然额外消耗了空间,但是却更灵活(空间复杂度为O(n))。如果不额外创建数组,快速排序的空间复杂度可以理解为递归的深度,平均需要递归log2(n)次,所以平均空间复杂度为O(log2(n))。

      对于快速排序算法来说,在基准值选取合理的情况下,时间复杂度一般为O(nlog2(n)) (以2为底,n的对数)。但最糟糕的情况下,当我们每次选择的基准值都为最大或最小数字时,所有数都划分到一个分区,此时的时间复杂度则为O(n^2),空间复杂度为O(n)。此外,快速排序也是一个稳定的排序算法。

 

3.直接选择排序(Straight Select Sort)

     直接选择排序是一种简单直观的排序算法。它的工作原理是:第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的第一位。第二次从剩余的未排序元素中寻找到最小(或最大)元素,存放在序列的第二位。以此类推,直到全部待排序的数据元素的个数为零。

      直接选择排序也是在原数组上进行操作的,所以它的空间复杂度为O(1)。由于不管数组是否已经排好序,直接选择排序算法每次都会遍历整个未排序数组元素去查找最小元素值的索引,所以直接选择排序是时间复杂度任何情况下都是O(n^2)。

算法修炼之路(三)—— 排序

      如果序列中出现相同元素,例如序列6,8,6,4,1,第一次遍历会把数字1与第一个6交换位置,此时第一个6排在了第二个6的后面,改变了原来相同元素的排列顺序。因此,直接选择排序是一个不稳定的排序算法。

 

4.直接插入排序(Straight Insertion Sort)

      直接插入排序是插入排序的一种,可以用我们平常玩扑克牌的时候给扑克牌排序来比喻。在发牌过程中,我们会一张一张的给扑克牌排好序,如果碰到比手中后面的牌小的牌,就将它插入到第一张比它大的牌之前的位置。与计算机不同的是,人眼可以一次性看出这张牌要插在哪里,而计算机得从当前抽到的牌与序列后面的牌一张一张进行比较,直到找到合适的位置插入该牌。

算法修炼之路(三)—— 排序

      插入排序的原理,就是不断更新有序序列的过程。一开始将数组的第一个元素认为是有序序列,然后从后往前扫描该有序序列,把数组中其余的元素,根据大小比较规则,插入到有序数组中,直至数组中的所有元素有序排列为止。对于n个元素的序列需要进行n-1趟排序,所以排序趟数的时间复杂度为O(n)。

      而每一趟排序,在最坏的情况下,要插入的元素得与有序序列元素每一个都进行大小比较。在每一趟排序过程,有序序列的个数为i个( i表示第几趟遍历)。第一趟有一个,第二趟有两个,第三趟有三个…..第n-1趟有n-1个,所以比较次数的时间复杂度也为O(n)。因此插入排序算法的时间复杂度为O(n) × O(n) = O(n^2)。

算法修炼之路(三)—— 排序

      假如序列已经排好序了,对序列中的元素进行的n-1趟扫描,每一趟只需对元素进行1次比较,因此在最好的情况下该算法的时间复杂度为O(n)。

    如果只是想知道插入的位置,我们可以不用每次对正确的位置进行线性搜索,每次都与前一个元素进行大小比较,一个个来确定插入的位置。我们可以用二分搜索的方式,直接在有序序列中找出最先出现的比插入元素大的元素,将插入元素放在该元素前面:

算法修炼之路(三)—— 排序

     二分查找的基本思想是将n个元素分成大致相等的两部分,取a[n/2]与x做比较,如果x=a[n/2],则找到x,算法中止;如果x<a[n/2],则只要在数组a的左半部分继续搜索x,如果x>a[n/2],则只要在数组a的右半部搜索x。

     假设有n个元素,每一次操作的元素个数都会不断递减,从n个,到n/2个,到n/4个,一直到n/2^k 个(接下来操作元素的剩余个数)。其中k就是循环的次数,因为剩余操作个数一定得大于1,所以n/2^k >=1 。令n/2^k = 1 (表示搜索到只剩一个元素时),k = log2 n (以2为底,n的对数),所以二分搜索的最差的时间复杂度为O(log2 n)。

      二分搜索改进了插入位置查找方法,每趟插入最多只需要进行log2 i 次位置搜索,元素最多进行i次移动。虽然前后两种算法最差情况下的时间复杂度都是O(n^2),并且对于排序来讲不管确不确定插入位置都需要将插入位置后面的元素往后移动,我们似乎可以在移动的过程中再来确定插入的位置,提前确定插入的位置显得有些多余。但是当你只是想知道所插入元素应该插入到有序序列的哪一个位置时,显然进行位置搜索的时间复杂度O( log2 n) 要比一个一个比较的O(n) 快一些。

 

5.希尔排序(Shell's Sort)

     希尔排序也是插入排序的一种,是直接插入排序的更高效的改进版本,也叫“缩小增量排序”。直接插入排序每次只能将数据移动一位,而希尔排序与之不同的地方在于,它可以一次跨越远距离对元素进行排序。

      对于包含n个元素的序列,希尔排序一开始会选择一个小于n的增量gap1,把序列根据该增量进行分组,所有距离为gap1倍数的元素被放入同一个分组中,并对每个分组进行直接插入排序。接着取第二个gap2<gap1,对第一次各分组已排好序的序列再整体重复上述分组和排序…. 经过一系列分组排序,整个序列的有序性得到了很大的改善,越往后排序效率越高,直至所取的增量gap = 1,即所有元素作为同一个分组进行最后的直接插入排序。

算法修炼之路(三)—— 排序

      希尔排序的增量的选择与证明一直是个数学难题,一般一开始的选择增量gap = n/2,缩小增量为 gap = gap /2 。

算法修炼之路(三)—— 排序

      我们可以看到,除了最外层多了一层对不同增量gap的分组进行排序的循环,里面的两层循环和直接插入排序基本一致,只是间隔由原来的1变为gap。 虽然希尔排序和直接插入排序最糟糕情况下的时间复杂度都为O(n^2),但是相比直接插入排序,当数据量十分庞大时,希尔排序可以明显减少元素比较和移动的次数,因此速度会快很多。由于分组进行直接插入排序,在不同的直接插入排序过程中,相同元素的先后顺序可能会被打乱,因此希尔排序不是稳定的排序算法。

6.归并排序(Merge Sort)

     归并排序也是采用分治思想的典型算法,先将整个序列不断细分,然后将最小子序列进行排序,并将排好序的最小子序列合并。得到的合并子序列再进行排序,与其它同级子序列再进行合并后再排序,直到最后得到完全有序的完整序列。

算法修炼之路(三)—— 排序

    归并排序需要额外开辟一个空间用来存放合并后的序列,并且设定两个指针,最初位置分别为左右两个已经排序的序列的起始位置。然后比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并将该指针移动到下一位置。接着继续重复比较操作,直到某一指针超出所处序列范围,直接把另一个序列剩下的元素添加到合并序列末尾。归并排序最简单的写法就是用递归来实现:

算法修炼之路(三)—— 排序

      分析归并排序的时间复杂度,分割过程采用二分法,时间复杂度为O(log2 n) (以2为底,n的对数),合并过程指针一共移动n次,时间复杂度为O(n)。所以归并排序的时间复杂度为O(nlog2 n)。

      因为递归会产生栈溢出问题,我们可以用迭代来实现归并排序。因为合并要用到数组方法,所以我们先创建一个新数组,把原数组中每一位都包装成数组,添加到新数组里的每一位。然后以相邻两个数组为一对进行合并,每次合并后覆盖左边位置的数组,然后下一次循环在左边合并好的所有新数组范围里继续合并,直到最后合并到数组第一位:

算法修炼之路(三)—— 排序


7.计数排序(Counting Sort)

      计数排序不是一种基于比较的排序算法,但却比任何基于比较的排序算法都快。它需要额外开辟一个数组C,将原始数组的数据值转化为数组键值记录在额外开辟的数组C中。因此,使用计数排序的数组元素必须都是整数。

算法修炼之路(三)—— 排序

      计数排序需要开辟额外的空间,根据序列中最大数和最小数之间的数字范围创建新数组,所以不适合数字范围太大的序列。计数排序很适合对数字范围小而确定,并且重复元素多的序列进行排序,例如对于序列 [10,2,,3,3,1, 3,0],我们便可以使用计数排序。该序列中最大数为10,最小数为0,其差值为10-0=10,因此需要创建一个长度为11的数组(因为数组索引是从0开始计算)。
 

算法修炼之路(三)—— 排序

      当原数组是n个0~k之间的整数时,计数排序的时间复杂度为O(n+k),空间复杂度为O(k)(只考虑额外开辟的空间)。上述计数排序只是简单的按照统计数组的下标输出了元素值,并没有真正给原始序列进行排序,因此该计数排序是不稳定的(虽然网上大多数资料都说计数排序是稳定的,但个人感觉计数排序是不稳定的,具体稳定性有待考究)

算法修炼之路(三)—— 排序

      十大经典排序算法,今天就暂时讲到这里,剩下的堆排序、桶排序和基数排序,留待以后再做补充。下次算法专题将讲解二分搜索,欢迎继续关注。