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

      排序是计算机最基本的算法之一。在计算机中,排序算法可以分为内部排序(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.堆排序(Heap Sort)

     堆排序也是一种选择排序,是直接选择排序的改进版。它是利用堆这种数据结构所设计的排序算法,利用数组的特点快速定位指定索引的元素。

      堆可以看成是一颗完全二叉树的数组对象,完全二叉树只允许最后一层有空缺节点并且最后一层的节点要集中在左边。当每个父节点都大于它的两个子节点时,我们就称该堆为大顶堆;当每个父节点都小于它的两个子节点时,我们就称该堆为小顶堆。

      完全二叉树有个特性,左边子节点的位置 = 当前父节点的位置 × 2 + 1,右边子节点的位置 = 当前父节点的位置 × 2 + 2 。对于一个数组 [ A,B,C,D,E,F,G ] 来说,它的满二叉树排列方式如下:

      升序排序采用大顶堆,堆排序的第一步,就是要构建一棵大顶堆树。构建大顶堆树需要对每个父节点进行判断,看它是否比它的两个子节点都大,如果不是,就与数字最大的子节点交换位置。拿数组 [ 5,4,7,3,6,2,1,8 ] 来举例,算法的第一步,就是从最后一个父节点往前遍历父节点,将该数组组成的完全二叉树变成大顶堆:

      对于这颗二叉树来说,父节点为索引0、1、2、3四个位置的节点,我们只需遍历这四个,并让它们与它们的子节点进行大小比较,保证当前父节点的数字最大,获得大顶堆(对于一颗包含n个节点的完全二叉树数组来说,父节点为索引从 0到(n / 2 )-1之间的节点)。

      在上图遍历到最后一步时,虽然最大的元素已经在顶端了,但是我们发现它的左子树又不满足大顶堆的标准,于是我们得继续将数字5和6的位置进行交换:

      这样,我们就完成了第一个大顶堆的构建。

      算法的第二步,就是将当前得到的大顶堆顶部根节点最大的数字与整棵二叉树最末尾的数字交换位置,此时放在末尾的最大的数字就作为已排好序的数字。

      交换完位置后,除去已排好序的数字,新的二叉树顶部的根节点已经不满足大顶堆的条件了,所以需要重新构建大顶堆。但注意观察,除了顶部根节点,其它父节点都满足大顶堆,所以我们只需要从顶部根节点开始向后进行堆调整:

      这里调整完的右子树也满足大顶堆,所以已经完成第二个大顶堆的构建。构建完大顶堆后再将最大的数与当前新的二叉树最后一个元素交换位置(即已排好序的数字的前一位)。

     

      接下来的工作便是继续重复大顶堆的构建和首尾元素位置交换的过程,直到最后整棵树都完成排序。

      堆排序算法的实现方式如下:

      我们来分析算法的时间复杂度,假设该完全二叉树的高度为k,每一层的节点数为2^( i – 1)个(i表示当前是第几层)。而除去最底下的叶节点,对每一个子节点来说,它们在初始化第一个大顶堆时,最多需要往下调整 k – i 次。例如,对于一个高度为5的完全二叉树,当最上层根节点与第二层子节点交换位置后,第二层子节点可能还需要与第三层子节点交换,交换到第三层可能还需要和第四层进行交换,交换到第四层可能还需要跟第五层进行交换,于是第二层子节点最多需要下沉 5 – 2 = 3次。

      对于所有父节点来说,它们总的下沉次数为 S = 2^(k-2) × 1 + 2^(k-3) × 2 + 2^(k-4) × 3 + …. +  2^0 × (k-1)。因此S = 2^k – k – 1,k为二叉树的深度,所以k = log2(n),代入得S = n – log2(n) – 1 ,所以对每个父节点下沉的时间复杂度为O(n)。

      而重建堆的时间复杂度就好计算多了,在每次顶部根节点和最后的子节点交换位置后,重新得到的完全二叉树只需考虑一个节点下沉的最大次数,即顶部根节点。它的最大下沉次数为log2(n)。每次新大顶堆构建时节点个数都减少1,所以每次下沉次数相加为log2(n!) = n log2(n)。因此新大顶堆构建总的时间复杂度为O( nlog2(n) )。

      我们把初始化建堆和排序重建堆的时间复杂度相加,得到总的时间复杂度为O( n+nlog2(n) ) = O( nlog2(n) )。

      因为堆排序过程中有顶部根节点与最后一个元素交换位置的操作,这让原来相同的数字前后顺序可能被打乱,所以堆排序是不稳定的排序算法。

5.直接插入排序(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) 快一些。

 

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

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

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

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

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

7.归并排序(Merge Sort)

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

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

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

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


8.计数排序(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)(只考虑额外开辟的空间)。上述计数排序只是简单的按照统计数组的下标输出了元素值,并没有真正给原始序列进行排序,因此该计数排序是不稳定的(虽然网上大多数资料都说计数排序是稳定的,但个人感觉计数排序是不稳定的,具体稳定性有待考究)

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

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值