使用比较运算符""和"
",将相容的序放到输入中,且除了赋值运算符外,这两种运算是仅有的容许对输入数据进行的操做,在这些条件下的排序叫作“
基于比较的排序
”。本文介绍的除了桶式排序
都是基于比较的排序。算法
最简单的排序算法之一是插入排序。插入排序由 趟(pass)排序组成。对于
趟到
趟,插入排序保证从位置 0 到位置 P 上的元素为已排序状态。shell
void InsertionSort(ElementType A[], int N) { int j, P; ElementType Tmp; for (P = 1; P < N; P++) { Tmp = A[P]; for (j = P; j > 0 && A[j - 1] > Tmp; j--) A[j] = A[j - 1]; A[j] = Tmp; } } 复制代码
运行时间上界为
,去掉常系数项和低阶项,时间复杂度为
。编程
逆序(inversion)数组
成员存数的数组的一个逆序是指数组中具备性质但
的序偶
。例如数组
A=[34, 8, 64, 51, 32, 21]
有9个逆序,即(34, 8)
, (34, 32)
, (34, 21)
, (64, 51)
, (64, 32)
, (64, 21)
, (51, 32)
, (51, 21)
以及(32, 21)
。这正好是由插入排序执行的交换次数。状况老是这样,由于交换两个不按原序排列的相邻元素刚好消除一个逆序,而一个排过序的数组没有逆序。markdown
假设不存在重复元素,输入数据是前个整数的某个排列,并设全部的排列都是等可能的。有以下定理:数据结构
定理1ide
个互异数的数组的平均序数是
。oop
定理2性能
经过交换相邻元素进行排序的任何算法平均须要时间。优化
希尔排序的名称来源于它的发明者Donald Shell
。该算法是冲破二次时间屏障的第一批算法之一,不过,自从它最初被发现,又过了若干年后才证实了它的亚二次时间界。它经过比较相距必定间隔的元素来工做;各趟比较所用的距离随着算法的进行而减少,直到只比较相邻元素的最后一趟排序为止。因为这个缘由,希尔排序有时也叫缩小增量排序
(diminishing increment sort)。
希尔排序使用一个序列,叫作
增量序列
(increment sequence)。只要,任何增量序列都是可行的,不过有些增量序列比另一些增量序列更好。
对于排序的通常作法是,对于
中的每个位置
,把其上的元素放到
中间的正确位置上。
增量序列的一种流行(可是很差)的选择是使用Shell
建议的序列:和
。以下为使用这种增量序列的实现代码实现。
void Shellsort(ElementType A[], int N) { int i, j, Increment; ElementType Tmp; for (Increment = N/2; Increment > 0; Increment /=2) for (i = Increment; i < N; i++) { Tmp = A[i]; for(j = i; j > Increment; j -= Increment) if(Tmp < A[j - Increment]) A[j] = A[j - Increment]; else break; A[j] = Tmp; } } 复制代码
咱们知道,优先队列(堆)能够用于花费时间的排序。基于该想法的算法叫作
堆排序(heapsort)
并给出咱们至今所见到的最佳的大运行时间。然而,在实践中它却慢于使用
增量序列的希尔排序。
创建个元素的二叉堆花费
时间,而后执行
次
DeleteMin
操做。按照顺序,最小的元素先离开堆。经过将这些元素记录到第二个数组而后再将数组拷贝回来,咱们获得个元素的排序。因为每一个
花费时间
,所以总的运行时间是
。使用这样的策略,在最后一次
DeleteMin
后,该数组将以递减的顺序包含这些元素。若是想要这些元素排成更典型的递增顺序,那么咱们能够改变序的特性使得父亲的关键字的值大于儿子的关键字的值。这样就获得最大堆(max heap)
。
#define LeftChild(i) (2* (i) + 1) void PercDown(ElementType A[], int i, int N) { int Child; ElementType Tmp; for(Tmp = A[i]; LeftChild(i) < N; i = Child) { Child = LeftChild(i); if(Child != N-1 && A[Child + 1] > A[Child]) Child++; if(Tmp < A[Child]) A[i] = A[Child]; else break; } A[i] = Tmp; } void Heapsort(ElementType A[], int N) { int i; for(i = N/2; i >= 0; i--) /* BuildHeap */ PercDown(A, i, N); for(i = N-1; i > 0; i--) { Swap(&A[0], &A[i]); /* DeleteMax */ PercDown(A, 0, i); } } 复制代码
能够证实,堆排序老是使用至少次比较,并且存在输入数据可以达到这个界。
归并排序以最坏情形运行时间运行,而所使用的比较次数几乎是最优的。它是递归算法一个很好的实例。
这个算法中基本的操做是合并两个已排序的表。由于这两个表是已经排序的。因此若将输出放到第三个表中时则该算法能够经过对输入数据一趟排序来完成。基本的合并算法是取两个输入数组A
和B
,一个输出数组C
,以及三个计数器Aptr
, Bptr
, Cptr
,它们初始置于对应数组的开始端。A[Aptr]
和B[Bptr]
中的较小者被拷贝到C
中的下一个位置,相关的计数器向前推动一步。当输入表有一个用完的时候,则将另外一个表中剩余部分拷贝到C
中。
合并两个已排序的表的时间显然是线性的,由于最多进行了次比较,其中
是元素的总数。为了看清这一点,注意每次比较都是把一个元素加到
C
中,但最后的比较除外,它至少添加两个元素。
/* Lpos = start of left half, Rpos = start of right half */ void Merge(ElementType A[], ElementType TmpArray[], int Lpos, int Rpos, int RightEnd) { int i, LeftEnd, NumElements, TmpPos; LeftEnd = Rpos - 1; TmpPos = Lpos; NumElements = RightEnd - Lpos + 1; /* main loop */ while(Lpos <= LefEnd && Rpos <= RightEnd) if (A[Lpos] <= A[Rpos]) TmpArray[TmpPos++] = A[Lpos++]; else TmpArray[TmpPos++] = A[Rpos++]; while(Lpos <= LeftEnd) TmpArray[TmpPos++] = A[Lpos++]; while(Rpos <= RightEnd) TmpArray[TmpPos++] = A[Rpos++]; /* copy TmpArray back */ for (i = 0; i < NumElements; i++, RightEnd--) A[RightEnd] = TmpArray[RightEnd]; } void MSort(ElementType A[], ElementType TmpArray[], int Left, int Right) { int Center; if (Left < Right) { Center = (Left + Right) / 2; MSort(A, TmpArray, Left, Center); Msort(A, TmpArray, Center + 1, Right); Merge(A, TmpArray, Left, Center + 1, Right); } } void Mergesort(ElementType A[], int N) { ElementType *TmpArray; TmpArray = malloc(N * sizeof(ElementType)); if (TmpArray != NULL) { MSort(A, TmpArray, 0, N-1); free(TmpArray); } else FatalError("No space for tmp array!!!"); } 复制代码
正如它的名字所标示的,快速排序
是在实践中最快的已知排序算法,它的平均运行时间是。该算法之因此特别快,主要是因为很是精炼和高度优化的内部循环。它的最坏情形的性能为
,但稍加努力就能够避免这种情形。虽然多年来快速排序算法被认为是理论上高度优化而在实践中却不可能正确编程的一种算法,可是现在该算法简单易懂并且不难证实。像归并排序同样,快速排序也是一种分治的递归算法。
将数组排序的基本算法由下列简单的四步组成:
因为对那些等于枢纽元
的处理,第(3)步分割的描述不是惟一的,所以这就成了一个设计上的决策。一部分好的实现是将这种情形尽量有效地处理。直观地看,咱们但愿把等于枢纽元的大约一半的关键字分到中,而另外的一半分到
中,很像咱们但愿二叉查找树保持平衡同样。
三数中值分割法(Median-of-Three Partitioning)。一组个数的中值是第
个最大的数。枢纽元最好的选择是数组的
中值
。不幸的是,这很难算出,且明显减慢快速排序的速度。这样的中值的估计量能够经过随机选取三个元素并用它们的中值做为枢纽元
获得。事实上,随机性并无多大的帮助,所以通常的作法是使用左端,右端和中心位置上的三个元素的中值
做为枢纽元。例如,输入为 ,它的左边元素是
,右边元素是
,中心位置
上的元素是
。因而枢纽元则是
。
有几种分割策略用于实践,这里介绍一种比较好的(但它依然很容易作错或产生低效)。该方法:
枢纽元
而言的。枢纽元
的元素,并将枢纽元
的元素。枢纽元
与等于枢纽元的关键字处理:若是和
遇到等于枢纽元的关键字,那么咱们就让
和
都中止。对于这种输入,这其实是不花费二次时间的四种可能性中唯一的一种可能。
对于很小的数组(),快速排序不如插入排序好。不只如此,由于快速排序是递归的,因此这样的情形还常常发生。一般的解决方法是对于小的数组不递归地使用快速排序,而代之以诸如插入排序这样的对小数组有效的排序算法。使用这样的策略实际上能够节省大约
(相对于自始至终使用快速排序)的运行时间。
void Quicksort(ElementType A[], int N) { Qsort(A, O, N - 1); } ElementType Median3(ElementType A[], int Left, int Right) { int Center = (Left + Right) / 2; if(A[Left] > A[Center]) Swap(&A[Left], &A[Center]); if(A[Left] > A[Right]) Swap(&A[Left], &A[Right]); if(A[Center] > A[Right]) Swap(&A[Center], &A[Right]); /* Invariant: A[Left] <= A[Center] <= A[Right] */ Swap(&A[Center], &A[Right - 1]); /* Hide pivot */ return A[Right - 1]; /* Return pivot */ } #define Cutoff (3) void Qsort(ElementType A[], int Left, int Right) { int i, j; ElementType Pivot; if (Left + Cutoff <= Right) { Pivot = Median3(A, Left, Right); i = Left; j = Right - 1; for (;;) { while(A[++i] < Pivot) {} while(A[--j] > Pivot) {} if (i < j) Swap(&A[i]), &A[j]; else break; } Swap(&A[i], &A[Right - 1]); /* Restore pivot */ Qsort(A, Left, i - 1); Qsort(A, i + 1, Right); } else /* Do an insertion sort on the subarray */ InsertionSort(A + Left, Right - Left + 1); } 复制代码
目前为止,关于排序的所有讨论,都假设要被排序的元素是一些简单的整数。实际应用中,经常须要某个关键字对大型结构进行排序。例如,咱们可能有些工资名单的记录,每一个记录由姓名,地址,电话号码,诸如工资这样的财务信息,以及税务信息组成。咱们可能要经过一个特定的域,好比姓名,来对这些信息进行排序。对于全部算法来讲,基本的操做就是交换,不过这里交换两个结构多是很是昂贵的操做,由于结构实际上很大。这种状况下,实际的解法是让输入数组包含指向结构的指针。咱们经过比较指针指向的关键字,并在必要时交换指针来进行排序。这意味着,全部的数据移动基本上就像咱们对整数排序那样进行。咱们称之为间接排序(indirect sorting))
;可使用这种方法处理咱们已经描述过的大部分数据结构。
虽然咱们获得一些的排序算法,可是,尚不清楚咱们是否还能作得更好。能够证实,任何只用到比较的算法在最坏的状况下须要
次比较(从而
的时间),所以归并排序和堆排序在一个常数因子范围内是最优的。
决策树
决策树(decision tree)
是用于证实下界的抽象概念。它是一棵二叉树
,每一个节点表示在元素之间一组可能的排序,它与已经进行的比较一致。比较的结果是树的边。经过只使用比较进行排序的每一种算法均可以用决策树表示。固然,只有输入数据很是少的状况下画决策树才是可行的。由排序算法所使用的比较次数
等于最深的树叶的深度
。
虽然任何只使用比较的通常排序算法在最坏的状况下须要运行时间,可是咱们要记住,在某些特殊状况下以线性时间进行排序仍然是可能的。
一个简单的例子就是桶式排序(bucket sort)
。为使桶式排序可以正常工做,必需要知足一些额外的条件。输入数据必须只由小于
的正整数组成。这种状况下,排序算法就很简单:使用一个大小为
称为
Count
的数组,它被初始化为全0
。因而,Count
有个单元(或称桶),这些桶初始化为空。当读
时,
增1。在全部的输入数据读入后,扫描数组
Count
,打印出排序后的表。该算法用时。若是
为
,那么总量就是
。
尽管桶式排序看似太平凡用处不大,可是实际上却存在许多输入只是一些小的整数的状况,使用像快速排序
这样的排序方法真的是小题大做了。
目前为止,咱们讲到过的全部算法都须要将输入数据装入内存。而后,存在一些应用程序,它们的输入数据量太大装不进内存。外部排序(external sorting)
就是设计用来处理这样很大的输入数据的。
大部份内存排序算法都用到内存可直接寻址的事实。希尔排序
用一个时间单位比较元素和
;
堆排序
用一个时间单位比较元素和
。使用
三数中值分割法
的快速排序
以常数个时间单位比较,
和
。若是输入数据在磁盘上,那么全部这些操做就失去了它们的效率,由于
磁盘
上的元素只能被顺序访问。即便数据在一张磁盘上,因为转动磁盘和移动磁盘所需的延迟,仍然存在实际上的效率损失。
假设至少有三个磁盘驱动器进行排序工做。咱们须要两个驱动器执行有效的排序,而第三个驱动器进行简化的工做。若是只有一个磁盘驱动器可用,那么咱们则不得不说:任何算法都将须要次磁盘访问。
基本的外部排序
算法使用归并排序
中的Merge
子程序。假设咱们有四个磁盘,,它们是两个输入磁盘和两个输出磁盘。设数据最初在
上,并设内存能够一次容纳(和排序)
个记录。一种天然的作法是第一步从输入磁盘一次读入
个记录,在内部(内存)将这些记录排序,而后再把这些排过序的记录交替地写到
或
上。咱们将把每组排过序的记录叫作一个
顺串(run)
。作完这些以后,咱们倒回全部的磁盘。假设咱们的输入数据以下:
磁盘 | 数据 |
---|---|
![]() |
![]() |
![]() |
|
![]() |
|
![]() |
若是,那么在顺串构造之后,磁盘将包含以下所示的数据。
磁盘 | 顺串 | 顺串 | 顺串 |
---|---|---|---|
![]() |
|||
![]() |
|||
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
如今和
都包含一组顺串。咱们将每一个磁盘的第一个顺串取出并将两者合并,把结果写到
上,该结果是一个二倍长的
顺串
。而后,咱们再从每一个磁盘取出下一个顺串合并,并将结果写到上。继续这个过程,交替使用
和
,直到
或
为空。此时,或者
和
均为空,或者剩下一个顺串。对于后者,咱们把剩下的顺串拷贝到适当的顺串上。将四个磁盘倒回,并重复相同步骤,这一次用两个
磁盘做输入,两个
磁盘做输出,结果获得一些
的顺串。咱们继续这个过程直到获得长为
的一个
顺串
。
该算法将须要趟工做,外加一趟构造初始化的顺串。例如,若咱们有
万个记录,每一个记录
个字节,并有
兆字节的内存,则第一趟将创建
个顺串。此时咱们再须要
趟以完成排序。上面的例子则是
趟。
第一趟:
磁盘 | 顺串 | 顺串 |
---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
|
![]() |
||
![]() |
第二趟:
磁盘 | 顺串 |
---|---|
![]() |
|
![]() |
|
![]() |
![]() |
![]() |
![]() |
第三趟:
磁盘 | 顺串 |
---|---|
![]() |
![]() |
![]() |
|
![]() |
|
![]() |
对于最通常的内部(内存)排序
应用程序,选用的方法不是插入排序
,希尔排序
,就是快速排序
,它们的选用主要是根据输入的大小来决定。下表显示在一台相对较慢的计算机上处理各类不一样大小的文件时的运行时间(单位:秒)。
N | 插入排序![]() |
希尔排序![]() |
堆排序![]() |
快速排序![]() |
优化快速排序![]() |
---|---|---|---|---|---|
10 | 0.00044 | 0.00041 | 0.00057 | 0.00052 | 0.00046 |
100 | 0.00675 | 0.00171 | 0.00420 | 0.00284 | 0.00244 |
1000 | 0.59564 | 0.02927 | 0.05565 | 0.03153 | 0.02587 |
10000 | 58.864 | 0.42998 | 0.71650 | 0.36765 | 0.31532 |
100000 | NA | 5.7298 | 8.8591 | 4.2298 | 3.5882 |
1000000 | NA | 71.164 | 104.68 | 47.065 | 41.282 |
这里没有包括归并排序
,由于它的性能对于在主存(内存)排序
不如快速排序
那么好,并且它的编程一点也不省事。然而归并(合并)
倒是外部排序
的中心思想。