本文带来的是七大经典排序算法,每个算法给出C++的实现。
排序的稳定性
假设,且在排序前的序列中领先于(即 )。
- 如果排序后仍领先于,则称所用的排序方法是稳定的;
- 若可能使得排序后的序列中领先,则称所用的排序方法是不稳定的。
内排序与外排序
- 内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。
- 外排序是由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。
对于内排序来说,排序算法的性能主要受以下三个方面的影响:
- 时间性能:排序算法的时间开销是衡量其好坏的最重要的标志。
在内排序中,主要进行两种操作:比较和移动。- 比较指关键字之间的比较,这是要做排序最起码的操作。
- 移动指记录从一个位置移动到另一个位置。
总之,高效率的内排序算法应该是具有尽可能少的关键字比较次数和尽可能少的记录移动次数。
- 辅助空间:执行算法需要的额外空间。
辅助存储空间是除了存放待排序所占用的存储空间之外,执行算法所需要的其他存储空间。 - 算法的复杂性:是算法本身的复杂度,而不是指算法的时间复杂度。显然算法过复杂也会影响排序的性能。
根据排序过程中借助的主要操作,内排序可以分为:插入排序、交换排序、选择排序和归并排序。
排序算法总结
先来个排序算法的整体总结:
比较排序和非比较排序
常见的快速排序、归并排序、堆排序、冒泡排序等属于比较排序 。在排序的最终结果里,元素之间的次序依赖于它们之间的比较。每个数都必须和其他数进行比较,才能确定自己的位置。比较排序的优势是,适用于各种规模的数据,也不在乎数据的分布,都能进行排序。可以说,比较排序适用于一切需要排序的情况。
冒泡排序一种交换排序,它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。
其基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
排序动图:
代码实现:
改进冒泡排序:在for循环中,增加对flag的判断,可以避免在已经基本有序的情况下的无意义循环判断。
冒泡排序复杂度分析:
所以,冒泡排序的时间复杂度为。
选择排序是一种简单直观的排序算法。选择排序是表现最稳定的排序算法之一,因为无论什么数据进去都是的时间复杂度 ,所以用到它的时候,数据规模越小越好。唯一的优点就是不占用额外的内存空间。
其基本思想是:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
也就是通过次关键字间的比较,从个记录中选出关键字最小的记录,并和第个记录交换之。
代码实现:
选择排序复杂度分析:
从选择排序的过程来看,它最大的特点就是交换移动数据次数相当少,这样也就节约了相应的时间。分析它的时间复杂度发现,无论最好最差的情况,其比较次数都是一样的多,第趟排序需要进行次关键字的比较,此时需要比较
次。而对于交换次数而言,当最好的时候交换为0次,最差的时候,也就初始降序时,交换次数为次,基于最终的排序时间是比较与交换的次数总和,因此,总的时间复杂度依然为。应该说,尽管与冒泡排序同为,但选择排序的性能上还是要略优于冒泡排序。
插入排序是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用In-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序的元素逐步向后挪位,为最新元素提供插入空间。
对于少量元素的排序,插入排序是一个有效的算法。在其实现过程使用双层循环,外层循环对除了第一个元素之外的所有元素,内层循环对当前元素前面有序表进行待插入位置查找,并进行移动。
代码实现:
直接插入排序复杂度分析:
插入排序的平均时间复杂度也是,空间复杂度为常数阶,具体时间复杂度和数组的有序性也是有关联的。插入排序中,当待排序数组是有序时,是最优的情况,只需当前数跟前一个数比较一下就可以了,这时一共需要比较 次,时间复杂度为。最坏的情况是待排序数组是逆序的,此时需要比较次数最多,最坏的情况是。
希尔排序是第一批时间复杂度突破的算法之一。它也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为缩小增量排序。它与插入排序的不同之处在于,它会优先比较距离较远的元素。
希尔排序将原本有大量记录数的记录进行分组。分割成若干个子序列,此时每个子序列待排序的记录个数就比较少了,然后在这些子序列内分别进行直接插入排序,当整个序列都基本有序时,注意只是基本有序时,再对全体记录进行一次直接插入排序。
基本有序:小的关键字基本在前面,大的基本在后面,不大不小的基本在中间。
为了得到基本有序的结果,可以采取这样的跳跃分割策略:将相距某个“增量”的记录组成一个序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。
增量的选取:增量序列的最后一个值一定取1,增量序列中的值应尽量们没有除1之外的公因子。
代码实现:
希尔排序时间复杂度分析:
希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。这里“增量”的选取就非常关键,可究竟应该选取什么样的增量才是最好,目前还是一个数学难题,迄今为止还没有人找到一种最好的增量序列。其时间复杂度为,要好于直接排序的。需要注意的是,增量序列的最后一个增量值必须等于1才行。另外由于记录是跳跃式的移动,希尔排序并不是种稳定的排序算法。
由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以希尔排序是不稳定的。
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值(大顶堆),或者每个结点的值都小于或等于其左右孩子结点的值(小顶堆)。从堆的定义可知,根结点一定是堆中所有结点最大(小)者。
如果按照层序遍历的方式给结点从1开始编号,则结点之间满足如下关系:
一棵完全二叉树,如果 ,则结点是二叉树的根,无双亲;如果 ,则其双亲是结点 。
堆排序就是利用堆(假设利用大顶堆)进行排序的方法。它的基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的 个序列重新构造成一个堆,这样就会得到 个元素中的次小值。如此反复执行,便能得到一个有序序列了。
代码实现:
整个排序过程分为两个 for循环:第一个循环要完成的就是将现在的待排序序列构建成一个大顶堆。第二个循环要完成的就是逐步将每个最大值的根结点与末尾元素交换,并且再调整其成为大顶堆。
堆排序时间复杂度分析:
堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆两部分组成。
在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为。在正式排序时,第次取堆顶记录重建堆需要用 的时间(完全二叉树的某个结点到根结点的距离为),并且需要取次堆顶记录,因此,重建堆的时间复杂度为 。
所以总体来说,堆排序的时间复杂度为。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为。这在性能上要远远好过于冒泡、简单选择、直接插入的 的时间复杂度。
空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。
归并在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法的一个非常典型的应用。归并排序是一种稳定的排序方法。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是的时间复杂度。代价是需要额外的内存空间。
排序动图:
代码实现:
分析归并排序的时间复杂度:
一趟归并需要将SR[1]~SR[n]中相邻的长度为h 的有序序列进行两两归并,并将结果放到中,这需要将待排序序列中的所有记录扫描一遍,因此耗费时间,而由完全二叉树的深度可知,整个归并排序需要进行次,因此,总的时间复杂度为,而且这是归并排序算法中最好、最坏、平均的时间性能。
由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为的栈空间,因此空间复杂度为。另外,对代码进行仔细研究,可以发现 Merge函数中有语句,这就说明它需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。
也就是说,归并排序是一种比较占用内存,但却效率高且稳定的算法。
- 希尔排序相当于直接插入排序的升级,它们同属于插入排序类;
- 堆排序相当于简单选择排序的升级,它们同属于选择排序类;
- 快速排序其实就是前面认为最慢的冒泡排序的升级,它们都属于交换排序类。
原始快速排序
快排通过不断比较和移动交换来实现排序的,只不过它的实现增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的记录从后面直接移动到前面,从而减少了总的比较次数和移动交换次数。
快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
代码实现:
快速排序时间复杂度分析:
在最优情况下,Partition每次都划分得很均匀,如果排序个关键字,其递归树的深度就为 ,即仅需递归 次,需要时间为的话,第一次 Partiation应该是需要对整个数组扫描一遍,做次比较。然后,获得的枢轴将数组一分为二,那么各自还需要的时间,于是不断划分下去,可以得到快排的时间复杂度为。
在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。如果递归树画出来,它就是一棵斜树。此时需要执行次递归调用,且第i次划分需要经过次关键字的比较才能找到第个记录,也就是枢轴的位置,因此比较次数为
最终其时间复杂度为。
平均的情况,设枢轴的关键字应该在第的位置(),那么:
由数学归纳法可证明,其数量级为。
就空间复杂度来说,主要是递归造成的栈空间的使用,最好情况,递归树的深度为,其空间复杂度也就为,最坏情况,需要进行递归调用,其空间复杂度为,平均情况,空间复杂度也为。
由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。
快速排序优化
上述叙述的快排其实存在很多问题,下面列举两个优化。
优化选取枢轴
从前面的讲解可以很容易看出,枢轴的选取决定了快排的效率。也就是说,枢轴的选取变成了快排潜在的性能瓶颈。前面的讲解中,我们固定的选取第一个关键字作为枢轴,这其实是不合理的做法。
首先有人提出随机获取一个和之间的数,让和交换,这被称为随机选取枢轴法。这在某种程度上,解决了对于基本有序的序列快速排序时的性能瓶颈。但是由于随机性的存在,仍然不是很有效的选取到合理的枢轴。
再改进,有了三数取中法,即取三个关键字先进行排序,将中间数作为枢轴。一般是取左端、右端和中间三个数。这样至少这个中间数一定不会是最小或者最大的数,从概率来说,取三个数均为最小或最大数的可能性是微乎其微的,因此中间数位于较为中间的值的可能性就大大提高了。
三数取中对小数组来说有很大的概率选择到一个比较好的 pivotkey,但是对于非常大的待排序的序列来说还是不足以保证能够选择出一个好的pivotkey,因此还有个办法是所谓九数取中(median-of-nine),它先从数组中分三次取样,每次取三个数,三个样品各取出中数,然后从这三个中数当中再取出一个中数作为枢轴。显然这就更加保证了取到的pivotkey是比较接近中间值的关键字。这里不再过多叙述了。
优化不必要的交换
仔细看排序过程就可以看出很多交换是不必要的。这里可以做一定的改进来减少不必要的交换。
这几个算法的代码均已整理到gitee上了:
SortAlgorithm