浅谈算法和数据结构: 二 基本排序算法
http://www.cnblogs.com/yangecnu/p/Introduction-Insertion-and-Selection-and-Shell-Sort.html
本篇开始学习排序算法。排序与我们日常生活中息息相关,比如,我们要从电话簿中找到某个联系人首先会按照姓氏排序、买火车票会按照出发时间或者时长排序、买东西会按照销量或者好评度排序、查找文件会按照修改时间排序等等。在计算机程序设计中,排序和查找也是最基本的算法,很多其他的算法都是以排序算法为基础,在一般的数据处理或分析中,通常第一步就是进行排序,比如说二分查找,首先要对数据进行排序。在Donald Knuth 的计算机程序设计的艺术这四卷书中,有一卷是专门介绍排序和查找的。
排序的算法有很多,在维基百科上有这么一个分类,另外大家有兴趣也可以直接上维基百科上看相关算法,本文也参考了上面的内容。
首先来看比较简单的选择排序(Selection sort),插入排序(Insertion sort),然后在分析插入排序的特征和缺点的基础上,介绍在插入排序基础上改进的希尔排序(Shell sort)。
一 选择排序
原理:
选择排序很简单,他的步骤如下:
- 从左至右遍历,找到最小(大)的元素,然后与第一个元素交换。
- 从剩余未排序元素中继续寻找最小(大)元素,然后与第二个元素进行交换。
- 以此类推,直到所有元素均排序完毕。
之所以称之为选择排序,是因为每一次遍历未排序的序列我们总是从中选择出最小的元素。下面是选择排序的动画演示:
实现:
算法实现起来也很简单,我们新建一个Sort泛型类,让该类型必须实现IComparable接口,然后我们定义SelectionSort方法,方法传入T数组,代码如下:
/// <summary> /// 排序算法泛型类,要求类型实现IComparable接口 /// </summary> /// <typeparam name="T"></typeparam> public class Sort<T> where T : IComparable<T> { /// <summary> /// 选择排序 /// </summary> /// <param name="array"></param> public static void SelectionSort(T[] array) { int n = array.Length; for (int i = 0; i < n; i++) { int min = i; //从第i+1个元素开始,找最小值 for (int j = i + 1; j < n; j++) { if (array[min].CompareTo(array[j]) > 0) min = j; } //找到之后和第i个元素交换 Swap(array, i, min); } } /// <summary> /// 元素交换 /// </summary> /// <param name="array"></param> /// <param name="i"></param> /// <param name="min"></param> private static void Swap(T[] array, int i, int min) { T temp = array[i]; array[i] = array[min]; array[min] = temp; } }
下图分析了选择排序中每一次排序的过程,您可以对照图中右边的柱状图来看。
测试如下:
static void Main(string[] args) { Int32[] array = new Int32[] { 1, 3, 1, 4, 2, 4, 2, 3, 2, 4, 7, 6, 6, 7, 5, 5, 7, 7 }; Console.WriteLine("Before SelectionSort:"); PrintArray(array); Sort<Int32>.SelectionSort(array); Console.WriteLine("After SelectionSort:"); PrintArray(array); Console.ReadKey(); }
输出结果:
分析:
选择排序的在各种初始条件下的排序效果如下:
- 选择排序需要花费 (N – 1) + (N – 2) + ... + 1 + 0 = N(N- 1) / 2 ~ N2/2次比较 和 N-1次交换操作。
- 对初始数据不敏感,不管初始的数据有没有排好序,都需要经历N2/2次比较,这对于一些原本排好序,或者近似排好序的序列来说并不具有优势。在最好的情况下,即所有的排好序,需要0次交换,最差的情况,倒序,需要N-1次交换。
- 数据交换的次数较少,如果某个元素位于正确的最终位置上,则它不会被移动。在最差情况下也只需要进行N-1次数据交换,在所有的完全依靠交换去移动元素的排序方法中,选择排序属于比较好的一种。
二 插入排序
原理:
插入排序也是一种比较直观的排序方式。可以以我们平常打扑克牌为例来说明,假设我们那在手上的牌都是排好序的,那么插入排序可以理解为我们每一次将摸到的牌,和手中的牌从左到右依次进行对比,如果找到合适的位置则直接插入。具体的步骤为:
- 从第一个元素开始,该元素可以认为已经被排序
- 取出下一个元素,在已经排序的元素序列中从后向前扫描
- 如果该元素小于前面的元素(已排序),则依次与前面元素进行比较如果小于则交换,直到找到大于该元素的就则停止;
- 如果该元素大于前面的元素(已排序),则重复步骤2
- 重复步骤2~4 直到所有元素都排好序 。
下面是插入排序的动画演示:
实现:
在Sort泛型方法中,我们添加如下方法,下面的方法和上面的定义一样
/// <summary> /// 插入排序 /// </summary> /// <param name="array"></param> public static void InsertionSort(T[] array) { int n = array.Length; //从第二个元素开始 for (int i = 1; i < n; i++) { //从第i个元素开始,一次和前面已经排好序的i-1个元素比较,如果小于,则交换 for (int j = i; j > 0; j--) { if (array[j].CompareTo(array[j - 1]) < 0) { Swap(array, j, j - 1); } else//如果大于,则不用继续往前比较了,因为前面的元素已经排好序,比较大的大就是教大的了。 break; } } }
测试如下:
Int32[] array1 = new Int32[] { 1, 3, 1, 4, 2, 4, 2, 3, 2, 4, 7, 6, 6, 7, 5, 5, 7, 7 }; Console.WriteLine("Before InsertionSort:"); PrintArray(array1); Sort<Int32>.InsertionSort(array1); Console.WriteLine("After InsertionSort:"); PrintArray(array1); Console.ReadKey();
分析:
插入排序的在各种初始条件下的排序效果如下:
1. 插入排序平均需要N2/4次比较和N2/4 次交换。在最坏的情况下需要N2/2 次比较和交换;在最好的情况下只需要N-1次比较和0次交换。
先考虑最坏情况,那就是所有的元素逆序排列,那么第i个元素需要与前面的i-1个元素进行i-1次比较和交换,所有的加起来大概等于N(N- 1) / 2 ~ N2 / 2,在数组随机排列的情况下,只需要和前面一半的元素进行比较和交换,所以平均需要N2/4次比较和N2/4 次交换。
在最好的情况下,所有元素都排好序,只需要从第二个元素开始都和前面的元素比较一次即可,不需要交换,所以为N-1次比较和0次交换。
2. 插入排序中,元素交换的次数等于序列中逆序元素的对数。元素比较的次数最少为元素逆序元素的对数,最多为元素逆序的对数 加上数组的个数减1。
3.总体来说,插入排序对于部分有序序列以及元素个数比较小的序列是一种比较有效的方式。
如上图中,序列AEELMOTRXPS,中逆序的对数为T-R,T-P,T-S,R-P,X-S 6对。典型的部分有序队列的特征有:
- 数组中每个元素离最终排好序后的位置不太远
- 小的未排序的数组添加到大的已排好序的数组后面
- 数组中只有个别元素未排好序
对于部分有序数组,插入排序是比较有效的。当数组中逆元素的对数越低,插入排序要比其他排序方法要高效的多。
选择排序和插入排序的比较:
上图展示了插入排序和选择排序的动画效果。图中灰色的柱子是不用动的,黑色的是需要参与到比较中的,红色的是参与交换的。图中可以看出:
插入排序不会动右边的元素,选择排序不会动左边的元素;由于插入排序涉及到的未触及的元素要比插入的元素要少,涉及到的比较操作平均要比选择排序少一半。
三 希尔排序(Shell Sort)
原理:
希尔排序也称之为递减增量排序,他是对插入排序的改进。在第二部插入排序中,我们知道,插入排序对于近似已排好序的序列来说,效率很高,可以达到线性排序的效率。但是插入排序效率也是比较低的,他一次只能将数据向前移一位。比如如果一个长度为N的序列,最小的元素如果恰巧在末尾,那么使用插入排序仍需一步一步的向前移动和比较,要N-1次比较和交换。
希尔排序通过将待比较的元素划分为几个区域来提升插入排序的效率。这样可以让元素可以一次性的朝最终位置迈进一大步,然后算法再取越来越小的步长进行排序,最后一步就是步长为1的普通的插入排序的,但是这个时候,整个序列已经是近似排好序的,所以效率高。
如下图,我们对下面数组进行排序的时候,首先以4为步长,这是元素分为了LMPT,EHSS,ELOX,AELR几个序列,我们对这几个独立的序列进行插入排序,排序完成之后,我们减小步长继续排序,最后直到步长为1,步长为1即为一般的插入排序,他保证了元素一定会被排序。
希尔排序的增量递减算法可以随意指定,可以以N/2递减,只要保证最后的步长为1即可。
实现:
/// <summary> /// 希尔排序 /// </summary> /// <param name="array"></param> public static void ShellSort(T[] array) { int n = array.Length; int h = 1; //初始最大步长 while (h < n / 3) h = h * 3 + 1; while (h >= 1) { //从第二个元素开始 for (int i = 1; i < n; i++) { //从第i个元素开始,依次次和前面已经排好序的i-h个元素比较,如果小于,则交换 for (int j = i; j >= h; j = j - h) { if (array[j].CompareTo(array[j - h]) < 0) { Swap(array, j, j - h); } else//如果大于,则不用继续往前比较了,因为前面的元素已经排好序,比较大的大就是教大的了。 break; } } //步长除3递减 h = h / 3; } }
可以看到,希尔排序的实现是在插入排序的基础上改进的,插入排序的步长为1,每一次递减1,希尔排序的步长为我们定义的h,然后每一次和前面的-h位置上的元素进行比较。算法中,我们首先获取小于N/3 的最大的步长,然后逐步长递减至步长为1的一般的插入排序。
下面是希尔排序在各种情况下的排序动画:
分析:
1. 希尔排序的关键在于步长递减序列的确定,任何递减至1步长的序列都可以,目前已知的比较好的序列有:
- Shell's 序列: N/2 , N/4 , ..., 1 (重复除以2);
- Hibbard's 序列: 1, 3, 7, ..., 2k - 1 ;
- Knuth's 序列: 1, 4, 13, ..., (3k - 1) / 2 ;该序列是本文代码中使用的序列。
- 已知最好的序列是 Sedgewick's (Knuth的学生,Algorithems的作者)的序列: 1, 5, 19, 41, 109, ....
该序列由下面两个表达式交互获得:
- 1, 19, 109, 505, 2161,….., 9(4k – 2k) + 1, k = 0, 1, 2, 3,…
- 5, 41, 209, 929, 3905,…..2k+2 (2k+2 – 3 ) + 1, k = 0, 1, 2, 3, …
“比较在希尔排序中是最主要的操作,而不是交换。”用这样步长的希尔排序比插入排序和堆排序都要快,甚至在小数组中比快速排序还快,但是在涉及大量数据时希尔排序还是比快速排序慢。
2. 希尔排序的分析比较复杂,使用Hibbard’s 递减步长序列的时间复杂度为O(N3/2),平均时间复杂度大约为O(N5/4) ,具体的复杂度目前仍存在争议。
3. 实验表明,对于中型的序列( 万),希尔排序的时间复杂度接近最快的排序算法的时间复杂度nlogn。
四 总结
最后总结一下本文介绍的三种排序算法的最好最坏和平均时间复杂度。
名称 |
最好 |
平均 |
最坏 |
内存占用 |
稳定排序 |
插入排序 |
n |
n2 |
n2 |
1 |
是 |
选择排序 |
n2 |
n2 |
n2 |
1 |
否 |
希尔排序 |
n |
nlog2n |
依赖于增量递减序列目前最好的是 nlog2n |
1 |
否 |
希望本文对您了解以上三个基本的排序算法有所帮助,后面将会介绍合并排序和快速排序。
算法分析
希尔排序的算法性能
排序类别 |
排序方法 |
时间复杂度 |
空间复杂度 |
稳定性 |
复杂性 |
||
平均情况 |
最坏情况 |
最好情况 |
|||||
插入排序 |
希尔排序 |
O(Nlog2N) |
O(N1.5) |
|
O(1) |
不稳定 |
较复杂 |
时间复杂度
步长的选择是希尔排序的重要部分。只要最终步长为1任何步长序列都可以工作。
算法最开始以一定的步长进行排序。然后会继续以一定步长进行排序,最终算法以步长为1进行排序。当步长为1时,算法变为插入排序,这就保证了数据一定会被排序。
Donald Shell 最初建议步长选择为N/2并且对步长取半直到步长达到1。虽然这样取可以比O(N2)类的算法(插入排序)更好,但这样仍然有减少平均时间和最差时间的余地。可能希尔排序最重要的地方在于当用较小步长排序后,以前用的较大步长仍然是有序的。比如,如果一个数列以步长5进行了排序然后再以步长3进行排序,那么该数列不仅是以步长3有序,而且是以步长5有序。如果不是这样,那么算法在迭代过程中会打乱以前的顺序,那就
不会以如此短的时间完成排序了。
步长序列 |
最坏情况下复杂度 |
已知的最好步长序列是由Sedgewick提出的(1, 5, 19, 41, 109,...),该序列的项来自
这两个算式。
这项研究也表明“比较在希尔排序中是最主要的操作,而不是交换。”用这样步长序列的希尔排序比插入排序和堆排序都要快,甚至在小数组中比快速排序还快,但是在涉及大量数据时希尔排序还是比快速排序慢。
算法稳定性
由上文的希尔排序算法演示图即可知,希尔排序中相等数据可能会交换位置,所以希尔排序是不稳定的算法。
直接插入排序和希尔排序的比较
直接插入排序是稳定的;而希尔排序是不稳定的。
直接插入排序更适合于原始记录基本有序的集合。
希尔排序的比较次数和移动次数都要比直接插入排序少,当N越大时,效果越明显。
在希尔排序中,增量序列gap的取法必须满足:最后一个步长必须是 1 。
直接插入排序也适用于链式存储结构;希尔排序不适用于链式结构。
浅谈算法和数据结构: 三 合并排序
合并排序,顾名思义,就是通过将两个有序的序列合并为一个大的有序的序列的方式来实现排序。合并排序是一种典型的分治算法:首先将序列分为两部分,然后对每一部分进行循环递归的排序,然后逐个将结果进行合并。
合并排序最大的优点是它的时间复杂度为O(nlgn),这个是我们之前的选择排序和插入排序所达不到的。他还是一种稳定性排序,也就是相等的元素在序列中的相对位置在排序前后不会发生变化。他的唯一缺点是,需要利用额外的N的空间来进行排序。
一 原理
合并排序依赖于合并操作,即将两个已经排序的序列合并成一个序列,具体的过程如下:
- 申请空间,使其大小为两个已经排序序列之和,然后将待排序数组复制到该数组中。
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置
- 比较复制数组中两个指针所指向的元素,选择相对小的元素放入到原始待排序数组中,并移动指针到下一位置
- 重复步骤3直到某一指针达到序列尾
- 将另一序列剩下的所有元素直接复制到原始数组末尾
该过程实现如下,注释比较清楚:
private static void Merge(T[] array, int lo, int mid, int hi) { int i = lo, j = mid + 1; //把元素拷贝到辅助数组中 for (int k = lo; k <= hi; k++) { aux[k] = array[k]; } //然后按照规则将数据从辅助数组中拷贝回原始的array中 for (int k = lo; k <= hi; k++) { //如果左边元素没了, 直接将右边的剩余元素都合并到到原数组中 if (i > mid) { array[k] = aux[j++]; }//如果右边元素没有了,直接将所有左边剩余元素都合并到原数组中 else if (j > hi) { array[k] = aux[i++]; }//如果左边右边小,则将左边的元素拷贝到原数组中 else if (aux[i].CompareTo(aux[j]) < 0) { array[k] = aux[i++]; } else { array[k] = aux[j++]; } } }
下图是使用以上方法将EEGMR和ACERT这两个有序序列合并为一个大的序列的过程演示:
二 实现
合并排序有两种实现,一种是至上而下(Top-Down)合并,一种是至下而上 (Bottom-Up)合并,两者算法思想差不多,这里仅介绍至上而下的合并排序。
至上而下的合并是一种典型的分治算法(Divide-and-Conquer),如果两个序列已经排好序了,那么采用合并算法,将这两个序列合并为一个大的序列也就是对大的序列进行了排序。
首先我们将待排序的元素均分为左右两个序列,然后分别对其进去排序,然后对这个排好序的序列进行合并,代码如下:
public class MergeSort<T> where T : IComparable<T> { private static T[] aux; // 用于排序的辅助数组 public static void Sort(T[] array) { aux = new T[array.Length]; // 仅分配一次 Sort(array, 0, array.Length - 1); } private static void Sort(T[] array, int lo, int hi) { if (lo >= hi) return; //如果下标大于上标,则返回 int mid = lo + (hi - lo) / 2;//平分数组 Sort(array, lo, mid);//循环对左侧元素排序 Sort(array, mid + 1, hi);//循环对右侧元素排序 Merge(array, lo, mid, hi);//对左右排好的序列进行合并 } ... }
以排序一个具有15个元素的数组为例,其调用堆栈为:
我们单独将Merge步骤拿出来,可以看到合并的过程如下:
三 图示及动画
如果以排序38,27,43,3,9,82,10为例,将合并排序画出来的话,可以看到如下图:
下图是合并排序的可视化效果图:
对6 5 3 1 8 7 24 进行合并排序的动画效果如下:
下图演示了合并排序在不同的情况下的效率:
四 分析
1. 合并排序的平均时间复杂度为O(nlgn)
证明:合并排序是目前我们遇到的第一个时间复杂度不为n2的时间复杂度为nlgn(这里lgn代表log2n)的排序算法,下面给出对合并排序的时间复杂度分析的证明:
假设D(N)为对整个序列进行合并排序所用的时间,那么一个合并排序又可以二分为两个D(N/2)进行排序,再加上与N相关的比较和计算中间数所用的时间。整个合并排序可以用如下递归式表示:
D(N)=2D(N/2)+N,N>1;
D(N)=0,N=1; (当N=1时,数组只有1个元素,已排好序,时间为0)
因为在分治算法中经常会用到递归式,所以在CLRS中有一章专门讲解递归式的求解和证明,使用主定理(master theorem)可以直接求解出该递归式的值,后面我会简单介绍。这里简单的列举两种证明该递归式时间复杂度为O(nlgn)的方法:
Prof1:处于方便性考虑,我们假设数组N为2的整数幂,这样根据递归式我们可以画出一棵树:
可以看到我们对数组N进行MergeSort的时候,是逐级划分的,这样就形成了一个满二叉树,树的每一及子节点都为N,树的深度即为层数lgN+1,满二叉树的深度的计算可以查阅相关资料,上图中最后一层子节点没有画出来。这样,这棵树有lgN+1层,每一层有N个节点,所以
D(N)=(lgN+1)N=NlgN+N=NlgN
Prof2:我们在为递归表达式求解的时候,还有一种常用的方法就是数学归纳法,
首先根据我们的递归表达式的初始值以及观察,我们猜想D(N)=NlgN.
- 当N=1 时,D(1)=0,满足初始条件。
- 为便于推导,假设N是2的整数次幂N=2k, 即D(2k)=2klg2k = k*2k
- 在N+1 的情况下D(N+1)=D(2k+1)=2k+1lg2k+1=(k+1) * 2k+1,所以假设成立,D(N)=NlgN.
2. 合并排序需要额外的长度为N的辅助空间来完成排序
如果对长度为N的序列进行排序需要<=clogN 的额外空间,认为就是就地排序(in place排序)也就是完成该排序操作需要较小的,固定数量的额外辅助内存空间。之前学习过的选择排序,插入排序,希尔排序都是原地排序。
但是在合并排序中,我们要创建一个大小为N的辅助排序数组来存放初始的数组或者存放合并好的数组,所以需要长度为N的额外辅助空间。当然也有前人已经将合并排序改造为了就地合并排序,但是算法的实现变得比较复杂。
需要额外N的空间来辅助排序是合并排序的最大缺点,如果在内存比较关心的环境中可能需要采用其他算法。
五 几点改进
对合并排序进行一些改进可以提高合并排序的效率。
1. 当划分到较小的子序列时,通常可以使用插入排序替代合并排序
对于较小的子序列(通常序列元素个数为7个左右),我们就可以采用插入排序直接进行排序而不用继续递归了),算法改造如下:
private const int CUTOFF = 7;//采用插入排序的阈值 private static void Sort(T[] array, int lo, int hi) { if (lo >= hi) return; //如果下标大于上标,则返回 if (hi <= lo + CUTOFF - 1) Sort<T>.SelectionSort(array, lo, hi); int mid = lo + (hi - lo) / 2;//平分数组 Sort(array, lo, mid);//循环对左侧元素排序 Sort(array, mid + 1, hi);//循环对右侧元素排序 Merge(array, lo, mid, hi);//对左右排好的序列进行合并 }
2. 如果已经排好序了就不用合并了
当已排好序的左侧的序列的最大值<=右侧序列的最小值的时候,表示整个序列已经排好序了。
算法改动如下:
private static void Sort(T[] array, int lo, int hi) { if (lo >= hi) return; //如果下标大于上标,则返回 if (hi <= lo + CUTOFF - 1) Sort<T>.SelectionSort(array, lo, hi); int mid = lo + (hi - lo) / 2;//平分数组 Sort(array, lo, mid);//循环对左侧元素排序 Sort(array, mid + 1, hi);//循环对右侧元素排序 if (array[mid].CompareTo(array[mid + 1]) <= 0) return; Merge(array, lo, mid, hi);//对左右排好的序列进行合并 }
3. 并行化
分治算法通常比较容易进行并行化,在浅谈并发与并行这篇文章中已经展示了如何对快速排序进行并行化(快速排序在下一篇文章中讲解),合并排序一样,因为我们均分的左右两侧的序列是独立的,所以可以进行并行,值得注意的是,并行化也有一个阈值,当序列长度小于某个阈值的时候,停止并行化能够提高效率,这些详细的讨论在浅谈并发与并行这篇文章中有详细的介绍了,这里不再赘述。
六 用途
合并排序和快速排序一样都是时间复杂度为nlgn的算法,但是和快速排序相比,合并排序是一种稳定性排序,也就是说排序关键字相等的两个元素在整个序列排序的前后,相对位置不会发生变化,这一特性使得合并排序是稳定性排序中效率最高的一个。在Java中对引用对象进行排序,Perl、C++、Python的稳定性排序的内部实现中,都是使用的合并排序。
七 结语
本文介绍了分治算法中比较典型的一个合并排序算法,这也是我们遇到的第一个时间复杂度为nlgn的排序算法,并简要对算法的复杂度进行的分析,希望本文对您理解合并排序有所帮助,下文将介绍快速排序算法。
http://www.cnblogs.com/yangecnu/p/Introduce-Quick-Sort.html
http://www.cnblogs.com/yangecnu/p/Introduce-Priority-Queue-And-Heap-Sort.html
上篇文章介绍了时间复杂度为O(nlgn)的合并排序,本篇文章介绍时间复杂度同样为O(nlgn)但是排序速度比合并排序更快的快速排序(Quick Sort)。
快速排序是20世纪科技领域的十大算法之一 ,他由C. A. R. Hoare于1960年提出的一种划分交换排序。
快速排序也是一种采用分治法解决问题的一个典型应用。在很多编程语言中,对数组,列表进行的非稳定排序在内部实现中都使用的是快速排序。而且快速排序在面试中经常会遇到。
本文首先介绍快速排序的思路,算法的实现、分析、优化及改进,最后分析了.NET 中列表排序的内部实现。
首先看一个来自于优酷的快速排序算法演示:
快速排序的时间主要耗费在划分操作上,对长度为 k 的区间进行划分,共需 k-1 次关键字的比较。
最坏时间复杂度:最坏情况是每次划分选取的基准都是当前无序区中关键字最小(或最大)的记录,划分的结果是基准左边的子区间为空(或右边的子区间为空),而划分所得的另一个非空的子区间中记录数目,仅仅比划分前的无序区中记录个数减少一个。因此,快速排序必须做 n-1 次划分,第 i 次划分开始时区间长度为 n-i-1, 所需的比较次数为 n-i(1<=i<=n-1), 故总的比较次数达到最大值 Cmax =n(n-1)/2=O(n^2) 。如果按上面给出的划分算法,每次取当前无序区的第 1 个记录为基准,那么当文件的记录已按递增序(或递减序)排列时,每次划分所取的基准就是当前无序区中关键字最小(或最大)的记录,则快速排序所需的比较次数反而最多。
最好时间复杂度:在最好情况下,每次划分所取的基准都是当前无序区的“中值”记录,划分的结果与基准的左、右两个无序子区间的长度大致相等。总的关键字比较次数为 O(n×lgn)。
用递归树来分析最好情况下的比较次数更简单。因为每次划分后左、右子区间长度大致相等,故递归树的高度为 O(lgn), 而递归树每一层上各结点所对应的划分过程中所需要的关键字比较次数总和不超过 n,故整个排序过程所需要的关键字比较总次数 C(n)=O(n×lgn) 。因为快速排序的记录移动次数不大于比较的次数,所以快速排序的最坏时间复杂度应为 O(n^2 ),最好时间复杂度为 O(n×lgn)。
基准关键字的选取:在当前无序区中选取划分的基准关键字是决定算法性能的关键。 ① “三者取中”的规则,即在当前区间里,将该区间首、尾和中间位置上的关键字比较,以三者之中值所对应的记录作为基准,在划分开始前将该基准记录和该区的第 1 个记录进行交换,此后的划分过程与上面所给的 Partition 算法完全相同。 ② 取位于 low 和 high 之间的随机数 k(low<=k<=high), 用 R[k] 作为基准;选取基准最好的方法是用一个随机函数产生一个位于 low 和 high 之间的随机数k(low<=k<=high), 用 R[k] 作为基准 , 这相当于强迫 R[low..high] 中的记录是随机分布的。用此方法所得到的快速排序一般称为随机的快速排序。随机的快速排序与一般的快速排序算法差别很小。但随机化后,算法的性能大大提高了,尤其是对初始有序的文件,一般不可能导致最坏情况的发生。算法的随机化不仅仅适用于快速排序,也适用于其他需要数据随机分布的算法。
平均时间复杂度:尽管快速排序的最坏时间为 O(n^2 ), 但就平均性能而言,它是基于关键字比较的内部排序算法中速度最快的,快速排序亦因此而得名。它的平均时间复杂度为 O(n×lgn)。
空间复杂度:快速排序在系统内部需要一个栈来实现递归。若每次划分较为均匀,则其递归树的高度为 O(lgn), 故递归后所需栈空间为 O(lgn) 。最坏情况下,递归树的高度为 O(n), 所需的栈空间为 O(n) 。
稳定性:快速排序是非稳定的。
一 原理
快速排序的基本思想如下:
- 对数组进行随机化。
- 从数列中取出一个数作为中轴数(pivot)。
- 将比这个数大的数放到它的右边,小于或等于它的数放到它的左边。
- 再对左右区间重复第三步,直到各区间只有一个数。
如上图所示快速排序的一个重要步骤是对序列进行以中轴数进行划分,左边都小于这个中轴数,右边都大于该中轴数,然后对左右的子序列继续这一步骤直到子序列长度为1。
下面来看某一次划分的步骤,如下图:
上图中的划分操作可以分为以下5个步骤:
- 获取中轴元素
- i从左至右扫描,如果小于基准元素,则i自增,否则记下a[i]
- j从右至左扫描,如果大于基准元素,则i自减,否则记下a[j]
- 交换a[i]和a[j]
- 重复这一步骤直至i和j交错,然后和基准元素比较,然后交换。
划分过程的代码实现如下:
/// <summary> /// 快速排序中的划分过程 /// </summary> /// <param name="array">待划分的数组</param> /// <param name="lo">最左侧位置</param> /// <param name="hi">最右侧位置</param> /// <returns>中间元素位置</returns> private static int Partition(T[] array, int lo, int hi) { int i = lo, j = hi + 1; while (true) { //从左至右扫描,如果碰到比基准元素array[lo]小,则该元素已经位于正确的分区,i自增,继续比较i+1; //否则,退出循环,准备交换 while (array[++i].CompareTo(array[lo]) < 0) { //如果扫描到了最右端,退出循环 if (i == hi) break; } //从右自左扫描,如果碰到比基准元素array[lo]大,则该元素已经位于正确的分区,j自减,继续比较j-1 //否则,退出循环,准备交换 while (array[--j].CompareTo(array[lo]) > 0) { //如果扫描到了最左端,退出循环 if (j == lo) break; } //如果相遇,退出循环 if (i >= j) break; //交换左a[i],a[j]右两个元素,交换完后他们都位于正确的分区 Swap(array, i, j); } //经过相遇后,最后一次a[i]和a[j]的交换 //a[j]比a[lo]小,a[i]比a[lo]大,所以将基准元素与a[j]交换 Swap(array, lo, j); //返回扫描相遇的位置点 return j; }
划分前后,元素在序列中的分布如下图:
二 实现
与合并算法基于合并这一过程一样,快速排序基于分割(Partition)这一过程。只需要递归调用Partition这一操作,每一次以Partition返回的元素位置来划分为左右两个子序列,然后继续这一过程直到子序列长度为1,代码的实现如下:
public class QuickSort<T> where T : IComparable<T> { public static void Sort(T[] array) { Sort(array, 0, array.Length - 1); } private static void Sort(T[] array, int lo, int hi) { //如果子序列为1,则直接返回 if (lo >= hi) return; //划分,划分完成之后,分为左右序列,左边所有元素小于array[index],右边所有元素大于array[index] int index = Partition(array, lo, hi); //对左右子序列进行排序完成之后,整个序列就有序了 //对左边序列进行递归排序 Sort(array, lo, index - 1); //对右边序列进行递归排序 Sort(array, index + 1, hi); } }
下图说明了快速排序中,每一次划分之后的结果:
一般快速排序的动画如下:
三 分析
- 在最好的情况下,快速排序只需要大约nlgn次比较操作,在最坏的情况下需要大约1/2 n2 次比较操作。
在最好的情况下,每次的划分都会恰好从中间将序列划分开来,那么只需要lgn次划分即可划分完成,是一个标准的分治算法Cn=2Cn/2+N,每一次划分都需要比较N次,大家可以回想下我们是如何证明合并排序的时间复杂度的。
在最坏的情况下,即序列已经排好序的情况下,每次划分都恰好把数组划分成了0,n两部分,那么需要n次划分,但是比较的次数则变成了n, n-1, n-2,….1, 所以整个比较次数约为n(n-1)/2~n2/2.
- 快速排序平均需要大约2NlnN次比较,来对长度为n的排序关键字唯一的序列进行排序。 证明也比较简单:假设CN为快速排序平均花在比较上的时间,初始C0=C1=0,对于N>1的情况,有:
其中N+1是分割时的比较次数, 表示将序列分割为0,和N-1左右两部分的概率为1/N, 划分为1,N-2左右两部分的概率也为1/N,都是等概率的。
然后对上式左右两边同时乘以N,整理得到:
然后,对于N为N-1的情况:
两式相减,然后整理得到:
然后左右两边同时除以N(N+1),得到:
然后处理一下得到:
- 平均情况下,快速排序需要大约1.39NlgN次比较,这比合并排序多了39%的比较,但是由于涉及了较少的数据交换和移动操作,他要比合并排序更快。
- 为了避免出现最坏的情况,导致序列划分不均,我们可以首先对序列进行随机化排列然后再进行排序就可以避免这一情况的出现。
- 快速排序是一种就地(in-place)排序算法。在分割操作中只需要常数个额外的空间。在递归中,也只需要对数个额外空间。
- 另外,快速排序是非稳定性排序。
四 改进
对一般快速排序进行一些改进可以提高其效率。
1. 当划分到较小的子序列时,通常可以使用插入排序替代快速排序
对于较小的子序列(通常序列元素个数为10个左右),我们就可以采用插入排序直接进行排序而不用继续递归,算法改造如下:
private const int CUTTOFF = 10; private static void Sort(T[] array, int lo, int hi) { //如果子序列为1,则直接返回 if (lo >= hi) return; //对于小序列,直接采用插入排序替代 if (hi - lo <= CUTTOFF - 1) { Sort<int>.InsertionSort(array, lo, hi); return; } //划分,划分完成之后,分为左右序列,左边所有元素小于array[index],右边所有元素大于array[index] int index = Partition(array, lo, hi); //对左右子序列进行排序完成之后,整个序列就有序了 //对左边序列进行递归排序 Sort(array, lo, index - 1); //对右边序列进行递归排序 Sort(array, index + 1, hi); }
2. 三平均分区法(Median of three partitioning)
在一般的的快速排序中,选择的是第一个元素作为中轴(pivot),这会出现某些分区严重不均的极端情况,比如划分为了1和n-1两个序列,从而导致出现最坏的情况。三平均分区法与一般的快速排序方法不同,它并不是选择待排数组的第一个数作为中轴,而是选用待排数组最左边、最右边和最中间的三个元素的中间值作为中轴。这一改进对于原来的快速排序算法来说,主要有两点优势:
(1) 首先,它使得最坏情况发生的几率减小了。
(2) 其次,未改进的快速排序算法为了防止比较时数组越界,在最后要设置一个哨点。如果在分区排序时,中间的这个元素(也即中轴)是与最右边数过来第二个元素进行交换的话,那么就可以省略与这一哨点值的比较。
对于三平均分区法还可以进一步扩展,在选取中轴值时,可以从由左中右三个中选取扩大到五个元素中或者更多元素中选取,一般的,会有(2t+1)平均分区法(median-of-(2t+1)。常用的一个改进是,当序列元素小于某个阈值N时,采用三平均分区,当大于时采用5平均分区。
采用三平均分区法对快速排序的改进如下:
private static void Sort(T[] array, int lo, int hi) { //对于小序列,直接采用插入排序替代 if (hi - lo <= CUTTOFF - 1) { //Sort<int>.InsertionSort(array, lo, hi); return; } //采用三平均分区法查找中轴 int m = MedianOf3(array, lo, lo + (hi - lo) / 2, hi); Swap(array, lo, m); //划分,划分完成之后,分为左右序列,左边所有元素小于array[index],右边所有元素大于array[index] int index = Partition(array, lo, hi); //对左右子序列进行排序完成之后,整个序列就有序了 //对左边序列进行递归排序 Sort(array, lo, index - 1); //对右边序列进行递归排序 Sort(array, index + 1, hi); } /// <summary> /// 查找三个元素中位于中间的那个元素 /// </summary> /// <param name="array"></param> /// <param name="lo"></param> /// <param name="center"></param> /// <param name="hi"></param> /// <returns></returns> private static int MedianOf3(T[] array, int lo, int center, int hi) { return (Less(array[lo], array[center]) ? (Less(array[center], array[hi]) ? center : Less(array[lo], array[hi]) ? hi : lo) : (Less(array[hi], array[center]) ? center : Less(array[hi], array[lo]) ? hi : lo)); } private static bool Less(T t1, T t2) { return t1.CompareTo(t2) < 0; }
使用插入排序对小序列进行排序以及使用三平均分区法对一般快速排序进行改进后运行结果示意图如下:
3. 三分区(3-way partitioning) 快速排序
通常,我们的待排序的序列关键字中会有很多重复的值,比如我们想对所有的学生按照年龄进行排序,按照性别进行排序等,这样每一类别中会有很多的重复的值。理论上,这些重复的值只需要处理一次就行了。但是一般的快速排序会递归进行划分,因为一般的快速排序只是将序列划分为了两部分,小于或者大于等于这两部分。
既然要利用连续、相等的元素不需要再参与排序这个事实,一个直接的想法就是通过划分让相等的元素连续地摆放:
然后只对左侧小于V的序列和右侧大于V对的序列进行排序。这种三路划分与计算机科学中无处不在,它与Dijkstra提出的“荷兰国旗问题”(The Dutch National Flag Problem)非常相似。
Dijkstra的方法如上图:
从左至右扫描数组,维护一个指针lt使得[lo…lt-1]中的元素都比v小,一个指针gt使得所有[gt+1….hi]的元素都大于v,以及一个指针i,使得所有[lt…i-1]的元素都和v相等。元素[i…gt]之间是还没有处理到的元素,i从lo开始,从左至右开始扫描:
· 如果a[i]<v: 交换a[lt]和a[i],lt和i自增
· 如果a[i]>v:交换a[i]和a[gt], gt自减
· 如果a[i]=v: i自增
下面是使用Dijkstra的三分区快速排序代码:
private static void Sort(T[] array, int lo, int hi) { //对于小序列,直接采用插入排序替代 if (hi - lo <= CUTTOFF - 1) { Sort<int>.InsertionSort(array, lo, hi); return; } //三分区 int lt = lo, i = lo + 1, gt = hi; T v = array[lo]; while (i<=gt) { int cmp = array[i].CompareTo(v); if (cmp < 0) Swap(array, lt++, i++); else if (cmp > 0) Swap(array, i, gt--); else i++; } //对左边序列进行递归排序 Sort(array, lo, lt - 1); //对右边序列进行递归排序 Sort(array, gt + 1, hi); }
三分区快速排序的每一步如下图所示:
三分区快速排序的示意图如下:
Dijkstra的三分区快速排序虽然在快速排序发现不久后就提出来了,但是对于序列中重复值不多的情况下,它比传统的2分区快速排序需要更多的交换次数。
Bentley 和D. McIlroy在普通的三分区快速排序的基础上,对一般的快速排序进行了改进。在划分过程中,i遇到的与v相等的元素交换到最左边,j遇到的与v相等的元素交换到最右边,i与j相遇后再把数组两端与v相等的元素交换到中间
这个方法不能完全满足只扫描一次的要求,但它有两个好处:首先,如果数据中没有重复的值,那么该方法几乎没有额外的开销;其次,如果有重复值,那么这些重复的值不会参与下一趟排序,减少了无用的划分。
下面是采用 Bentley&D. McIlroy 三分区快速排序的算法改进:
private static void Sort(T[] array, int lo, int hi) { //对于小序列,直接采用插入排序替代 if (hi - lo <= CUTTOFF - 1) { Sort<int>.InsertionSort(array, lo, hi); return; } // Bentley-McIlroy 3-way partitioning int i = lo, j = hi + 1; int p = lo, q = hi + 1; T v = array[lo]; while (true) { while (Less(array[++i], v)) if (i == hi) break; while (Less(v, array[--j])) if (j == lo) break; // pointers cross if (i == j && Equal(array[i], v)) Swap(array, ++p, i); if (i >= j) break; Swap(array, i, j); if (Equal(array[i], v)) Swap(array, ++p, i); if (Equal(array[j], v)) Swap(array, --q, j); } //将相等的元素交换到中间 i = j + 1; for (int k = lo; k <= p; k++) Swap(array, k, j--); for (int k = hi; k >= q; k--) Swap(array, k, i++); Sort(array, lo, j); Sort(array, i, hi); }
三分区快速排序的动画如下:
4.并行化
和前面讨论对合并排序的改进一样,对所有使用分治法解决问题的算法其实都可以进行并行化,快速排序的并行化改进我在之前的浅谈并发与并行这篇文章中已经有过介绍,这里不再赘述。
五 .NET 中元素排序的内部实现
快速排序作为一种优秀的排序算法,在很多编程语言的元素内部排序中均有实现,比如Java中对基本数据类型(primitive type)的排序,C++,Matlab,Python,FireFox Javascript等语言中均将快速排序作为其内部元素排序的算法。同样.NET中亦是如此。
.NET这种对List<T>数组元素进行排序是通过调用Sort方法实现的,其内部则又是通过Array.Sort实现,MSDN上说在.NET 4.0及之前的版本,Array.Sort采用的是快速排序,然而在.NET 4.5中,则对这一算法进行了改进,采用了名为Introspective sort 的算法,即保证在一般情况下达到最快排序速度,又能保证能够在出现最差情况是进行优化。他其实是一种混合算法:
- 当待分区的元素个数小于16个时,采用插入排序
- 当分区次数超过2*logN,N是输入数组的区间大小,则使用堆排序(Heapsort)
- 否则,使用快速排序。
有了Reflector这一神器,我们可以查看.NET中的ArraySort的具体实现:
Array.Sort这一方法在mscorlib这一程序集中,具体的实现方法有分别针对泛型和普通类型的SortedGenericArray和SortedObjectArray,里面的实现大同小异,我们以SortedGenericArray这个类来作为例子看:
首先要看的是Sort方法,其实现如下:
该方法中,首先判断运行的.NET对的版本,如果是4.5及以上版本,则用IntrospectiveSort算法,否则采用限定深度的快速排序算法DepthLimitedQuickSort。先看IntrospectiveSort:
该方法第一个元素为数组的最左边元素位置,第二个参数为最右边元素位置,第三个参数为2*log2N,继续看方法内部:
可以看到,当num<=16时,如果元素个数为1,2,3,则直接调用SwapIfGreaterWithItem进行排序了。否则直接调用InsertSort进行插入排序。
这里面也是一个循环,每循环一下depthLimit就减小1个,如果为0表示划分的次数超过了2logN,则直接调用基排序(HeapSort),这里面的划分方法PickPivortAndPartitin的实现如下:
它其实是一个标准的三平均快速排序。可以看到在.NET 4.5中对Quick进行优化的部分主要是在元素个数比较少的时候采用选择插入,并且在递归深度超过2logN的时候,采用基排序。
下面再来看下在.NET 4.0及以下平台下排序DepthLimitedQuickSort方法的实现:
从名称中可以看出这是限定深度的快速排序,在第三个参数传进去的是0x20,也就是32。
可以看到,当划分的次数大于固定的32次的时候,采用了基排序,其他的部分是普通的快速排序。
六 总结
由于快速排序在排序算法中具有排序速度快,而且是就地排序等优点,使得在许多编程语言的内部元素排序实现中采用的就是快速排序,本问首先介绍了一般的快速排序,分析了快速排序的时间复杂度,然后就分析了对快速排序的几点改进,包括对小序列采用插入排序替代,三平均划分,三分区划分等改进方法。最后介绍了.NET不同版本下的对元素内部排序的实现。
快速排序很重要,希望本文对您了解快速排序有所帮助。
浅谈算法和数据结构: 五 优先级队列与堆排序
在很多应用中,我们通常需要按照优先级情况对待处理对象进行处理,比如首先处理优先级最高的对象,然后处理次高的对象。最简单的一个例子就是,在手机上玩游戏的时候,如果有来电,那么系统应该优先处理打进来的电话。
在这种情况下,我们的数据结构应该提供两个最基本的操作,一个是返回最高优先级对象,一个是添加新的对象。这种数据结构就是优先级队列(Priority Queue) 。
本文首先介绍优先级队列的定义,有序和无序数组以及堆数据结构实现优先级队列,最后介绍了基于优先级队列的堆排序(Heap Sort)
一 定义
优先级队列和通常的栈和队列一样,只不过里面的每一个元素都有一个”优先级”,在处理的时候,首先处理优先级最高的。如果两个元素具有相同的优先级,则按照他们插入到队列中的先后顺序处理。
优先级队列可以通过链表,数组,堆或者其他数据结构实现。
二 实现
数组
最简单的优先级队列可以通过有序或者无序数组来实现,当要获取最大值的时候,对数组进行查找返回即可。代码实现起来也比较简单,这里就不列出来了。
如上图:
· 如果使用无序数组,那么每一次插入的时候,直接在数组末尾插入即可,时间复杂度为O(1),但是如果要获取最大值,或者最小值返回的话,则需要进行查找,这时时间复杂度为O(n)。
· 如果使用有序数组,那么每一次插入的时候,通过插入排序将元素放到正确的位置,时间复杂度为O(n),但是如果要获取最大值的话,由于元阿苏已经有序,直接返回数组末尾的 元素即可,所以时间复杂度为O(1).
所以采用普通的数组或者链表实现,无法使得插入和排序都达到比较好的时间复杂度。所以我们需要采用新的数据结构来实现。下面就开始介绍如何采用二叉堆(binary heap)来实现优先级队列
二叉堆
二叉堆是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。 有了这一性质,那么二叉堆上最大值就是根节点了。
二叉堆的表现形式:我们可以使用数组的索引来表示元素在二叉堆中的位置。
从二叉堆中,我们可以得出:
· 元素k的父节点所在的位置为[k/2]
· 元素k的子节点所在的位置为2k和2k+1
跟据以上规则,我们可以使用二维数组的索引来表示二叉堆。通过二叉堆,我们可以实现插入和删除最大值都达到O(nlogn)的时间复杂度。
对于堆来说,最大元素已经位于根节点,那么删除操作就是移除并返回根节点元素,这时候二叉堆就需要重新排列;当插入新的元素的时候,也需要重新排列二叉堆以满足二叉堆的定义。现在就来看这两种操作。
从下至上的重新建堆操作: 如果一个节点的值大于其父节点的值,那么该节点就需要上移,一直到满足该节点大于其两个子节点,而小于其根节点为止,从而达到使整个堆实现二叉堆的要求。
由上图可以看到,我们只需要将该元素k和其父元素k/2进行比较,如果比父元素大,则交换,然后迭代,一直到比父元素小为止。
private static void Swim(int k) { //如果元素比其父元素大,则交换 while (k > 1 && pq[k].CompareTo(pq[k / 2]) > 0) { Swap(pq, k, k / 2); k = k / 2; } }
这样,往堆中插入新元素的操作变成了,将该元素从下往上重新建堆操作:
代码实现如下:
public static void Insert(T s) { //将元素添加到数组末尾 pq[++N] = s; //然后让该元素从下至上重建堆 Swim(N); }
动画如下:
由上至下的重新建堆操作:当某一节点比其子节点要小的时候,就违反了二叉堆的定义,需要和其子节点进行交换以重新建堆,直到该节点都大于其子节点为止:
代码实现如下:
private static void Sink(int k) { while (2 * k < N) { int j = 2 * k; //去左右子节点中,稍大的那个元素做比较 if (pq[j].CompareTo(pq[j + 1]) < 0) j++; //如果父节点比这个较大的元素还大,表示满足要求,退出 if (pq[k].CompareTo(pq[j]) > 0) break; //否则,与子节点进行交换 Swap(pq, k, j); k = j; } }
这样,移除并返回最大元素操作DelMax可以变为:
1. 移除二叉堆根节点元素,并返回
2. 将数组中最后一个元素放到根节点位置
3. 然后对新的根节点元素进行Sink操作,直到满足二叉堆要求。
移除最大值并返回的操作如下图所示:
以上操作的实现如下:
public static T DelMax() { //根元素从1开始,0不存放值 T max = pq[1]; //将最后一个元素和根节点元素进行交换 Swap(pq, 1, N--); //对根节点从上至下重新建堆 Sink(1); //将最后一个元素置为空 pq[N + 1] = default(T); return max; }
动画如下:
三 堆排序
概念
运用二叉堆的性质,可以利用它来进行一种就地排序,该排序的步骤为:
1. 使用序列的所有元素,创建一个最大堆。
2. 然后重复删除最大元素。
如下图,以对S O R T E X A M P L E 排序为例,首先本地构造一个最大堆,即对节点进行Sink操作,使其符合二叉堆的性质。
然后再重复删除根节点,也就是最大的元素,操作方法与之前的二叉堆的删除元素类似。
创建最大二叉堆:
使用至下而上的方法创建二叉堆的方法为,分别对叶子结点的上一级节点以重上之下的方式重建堆。
代码如下:
for (int k = N / 2; k >= 1; k--) { Sink(pq, k, N); }
排序
利用二叉堆排序其实就是循环移除顶部元素到数组末尾,然后利用Sink重建堆的操作。如下图,实现代码如下:
while (N > 1) { Swap(pq, 1, N--); Sink(pq, 1, N); }
堆排序的动画如下:
分析
1. 在构建最大堆的时候,最多需要2N次比较和交换
2. 堆排序最多需要2NlgN次比较和交换操作
优点:堆排序最显著的优点是,他是就地排序,并且其最坏情况下时间复杂度为NlogN。经典的合并排序不是就地排序,它需要线性长度的额外空间,而快速排序其最坏时间复杂度为N2
缺点:堆排序对时间和空间都进行了优化,但是:
1. 其内部循环要比快速排序要长。
2. 并且其操作在N和N/2之间进行比较和交换,当数组长度比较大的时候,对CPU缓存利用效率比较低。
3. 非稳定性排序。
四 排序算法的小结
本文及前面文章介绍了选择排序,插入排序,希尔排序,合并排序,快速排序以及本文介绍的堆排序。各排序的稳定性,平均,最坏,最好的时间复杂度如下表:
可以看到,不同的排序方法有不同的特征,有的速度快,但是不稳定,有的稳定,但是不是就地排序,有的是就地排序,但是最坏情况下时间复杂度不好。那么有没有一种排序能够集合以上所有的需求呢?
五 结语
本文介绍了二叉堆,以及基于二叉堆的堆排序,他是一种就地的非稳定排序,其最好和平均时间复杂度和快速排序相当,但是最坏情况下的时间复杂度要优于快速排序。但是由于他对元素的操作通常在N和N/2之间进行,所以对于大的序列来说,两个操作数之间间隔比较远,对CPU缓存利用不太好,故速度没有快速排序快。
下文将开始介绍查找算法,并介绍二叉查找树。