使用比较运算符” < ”和” > ”,将相容的序放到输入中,且除了赋值运算符外,这两种运算是仅有的允许对输入数据进行的操作,在这些条件下的排序叫做“基于比较的排序
”。本文介绍的除了桶式排序
都是基于比较的排序。
最简单的排序算法之一是插入排序。插入排序由 N-1 趟(pass)排序组成。对于 P=1 趟到 P=N-1 趟,插入排序保证从位置 0 到位置 P 上的元素为已排序状态。
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;
}
}
运行时间上界为 \sum_{i=1}^{N-1}i = 1, 2, \ldots, N-1 = \frac{1+N-1}{2} \times (N-1) = \frac{N^2}{2} - \frac{N}{2} ,去掉常系数项和低阶项,时间复杂度为 O(N^2) 。
逆序(inversion)
成员存数的数组的一个逆序是指数组中具有性质 i < j 但 A[i] > A[j] 的序偶 (A[i], A[j]) 。例如数组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)
。这正好是由插入排序执行的交换次数。情况总是这样,因为交换两个不按原序排列的相邻元素恰好消除一个逆序,而一个排过序的数组没有逆序。
假设不存在重复元素,输入数据是前 N 个整数的某个排列,并设所有的排列都是等可能的。有如下定理:
定理1
N 个互异数的数组的平均序数是 N(N-1)/4 。
定理2
通过交换相邻元素进行排序的任何算法平均需要 \Omega(N^2) 时间。
希尔排序的名称来源于它的发明者Donald Shell
。该算法是冲破二次时间屏障的第一批算法之一,不过,自从它最初被发现,又过了若干年后才证明了它的亚二次时间界。它通过比较相距一定间隔的元素来工作;各趟比较所用的距离随着算法的进行而减小,直到只比较相邻元素的最后一趟排序为止。由于这个原因,希尔排序有时也叫缩小增量排序
(diminishing increment sort)。
希尔排序使用一个序列 h_1, h_2, \ldots, h_t ,叫做增量序列
(increment sequence)。只要 h_1=1 ,任何增量序列都是可行的,不过有些增量序列比另外一些增量序列更好。
对于 h_k- 排序的一般做法是,对于 h_k, h_{k+1}, …, N-1 中的每一个位置 i ,把其上的元素放到 i, i-h_k, i-2h_k \ldots 中间的正确位置上。
增量序列的一种流行(但是不好)的选择是使用Shell
建议的序列: h_t = \lfloor N / 2 \rfloor 和 h_k = \lfloor h_{k_1}/2\rfloor 。如下为使用这种增量序列的实现代码实现。
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;
}
}
我们知道,优先队列(堆)可以用于花费 O(NlogN) 时间的排序。基于该想法的算法叫做堆排序(heapsort)
并给出我们至今所见到的最佳的大 O 运行时间。然而,在实践中它却慢于使用 Sedgewick 增量序列的希尔排序。
建立 N 个元素的二叉堆花费 O(N) 时间,然后执行 N 次DeleteMin
操作。按照顺序,最小的元素先离开堆。通过将这些元素记录到第二个数组然后再将数组拷贝回来,我们得到 N 个元素的排序。由于每个 DeleteMin 花费时间 O(log N) ,因此总的运行时间是 O(NlogN) 。使用这样的策略,在最后一次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);
}
}
可以证明,堆排序总是使用至少 NlogN - O(N) 次比较,而且存在输入数据能够达到这个界。
归并排序以 O(NlogN) 最坏情形运行时间运行,而所使用的比较次数几乎是最优的。它是递归算法一个很好的实例。
这个算法中基本的操作是合并两个已排序的表。因为这两个表是已经排序的。所以若将输出放到第三个表中时则该算法可以通过对输入数据一趟排序来完成。基本的合并算法是取两个输入数组A
和B
,一个输出数组C
,以及三个计数器Aptr
, Bptr
, Cptr
,它们初始置于对应数组的开始端。A[Aptr]
和B[Bptr]
中的较小者被拷贝到C
中的下一个位置,相关的计数器向前推进一步。当输入表有一个用完的时候,则将另一个表中剩余部分拷贝到C
中。
合并两个已排序的表的时间显然是线性的,因为最多进行了 N-1 次比较,其中 N 是元素的总数。为了看清这一点,注意每次比较都是把一个元素加到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!!!");
}
正如它的名字所标示的,快速排序
是在实践中最快的已知排序算法,它的平均运行时间是 O(NlogN) 。该算法之所以特别快,主要是由于非常精炼和高度优化的内部循环。它的最坏情形的性能为 O(N^2) ,但稍加努力就可以避免这种情形。虽然多年来快速排序算法被认为是理论上高度优化而在实践中却不可能正确编程的一种算法,但是如今该算法简单易懂而且不难证明。像归并排序一样,快速排序也是一种分治的递归算法。
将数组 S 排序的基本算法由下列简单的四步组成:
由于对那些等于枢纽元
的处理,第(3)步分割的描述不是唯一的,因此这就成了一个设计上的决策。一部分好的实现是将这种情形尽可能有效地处理。直观地看,我们希望把等于枢纽元的大约一半的关键字分到 S_1 中,而另外的一半分到 S_2 中,很像我们希望二叉查找树保持平衡一样。
三数中值分割法(Median-of-Three Partitioning)。一组 N 个数的中值是第 \lceil N / 2 \rceil 个最大的数。枢纽元最好的选择是数组的中值
。不幸的是,这很难算出,且明显减慢快速排序的速度。这样的中值的估计量可以通过随机选取三个元素并用它们的中值作为枢纽元
得到。事实上,随机性并没有多大的帮助,因此一般的做法是使用左端,右端和中心位置上的三个元素的中值
作为枢纽元。例如,输入为 8, 1, 4, 9, 6, 3, 5, 2, 7, 0 ,它的左边元素是 8 ,右边元素是 0 ,中心位置 \lfloor (Left + Right)/2 \rfloor = \lfloor (0 + 9)/2 \rfloor = 4 上的元素是 6 。于是枢纽元则是 v=6 。
有几种分割策略用于实践,这里介绍一种比较好的(但它依然很容易做错或产生低效)。该方法:
枢纽元
而言的。枢纽元
的元素,并将 j 左移,移过那些大于枢纽元
的元素。枢纽元
与 i 所指向的元素交换。等于枢纽元的关键字处理:如果 i 和 j 遇到等于枢纽元的关键字,那么我们就让 i 和 j 都停止。对于这种输入,这实际上是不花费二次时间的四种可能性中惟一的一种可能。
对于很小的数组( N\le20 ),快速排序不如插入排序好。不仅如此,因为快速排序是递归的,所以这样的情形还经常发生。通常的解决方法是对于小的数组不递归地使用快速排序,而代之以诸如插入排序这样的对小数组有效的排序算法。使用这样的策略实际上可以节省大约 %15 (相对于自始至终使用快速排序)的运行时间。
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))
;可以使用这种方法处理我们已经描述过的大部分数据结构。
虽然我们得到一些 O(NlogN) 的排序算法,但是,尚不清楚我们是否还能做得更好。可以证明,任何只用到比较的算法在最坏的情况下需要 \Omega(NlogN) 次比较(从而 \Omega(NlogN) 的时间),因此归并排序和堆排序在一个常数因子范围内是最优的。
决策树
决策树(decision tree)
是用于证明下界的抽象概念。它是一棵二叉树
,每个节点表示在元素之间一组可能的排序,它与已经进行的比较一致。比较的结果是树的边。通过只使用比较进行排序的每一种算法都可以用决策树表示。当然,只有输入数据非常少的情况下画决策树才是可行的。由排序算法所使用的比较次数
等于最深的树叶的深度
。
虽然任何只使用比较的一般排序算法在最坏的情况下需要运行时间 \Omega(NlogN) ,但是我们要记住,在某些特殊情况下以线性时间进行排序仍然是可能的。
一个简单的例子就是桶式排序(bucket sort)
。为使桶式排序能够正常工作,必须要满足一些额外的条件。输入数据 A_1, A_2, \ldots, A_N 必须只由小于 M 的正整数组成。这种情况下,排序算法就很简单:使用一个大小为 M 称为Count
的数组,它被初始化为全0
。于是,Count
有 M 个单元(或称桶),这些桶初始化为空。当读 A_i 时, Count[A_i] 增1。在所有的输入数据读入后,扫描数组Count
,打印出排序后的表。该算法用时 O(M + N) 。如果 M 为 O(N) ,那么总量就是 O(N) 。
尽管桶式排序看似太平凡用处不大,但是实际上却存在许多输入只是一些小的整数的情况,使用像快速排序
这样的排序方法真的是小题大作了。
目前为止,我们讲到过的所有算法都需要将输入数据装入内存。然后,存在一些应用程序,它们的输入数据量太大装不进内存。外部排序(external sorting)
就是设计用来处理这样很大的输入数据的。
大部分内存排序算法都用到内存可直接寻址的事实。希尔排序
用一个时间单位比较元素 A[i] 和 A[i - h_k] ;堆排序
用一个时间单位比较元素 A[i] 和 A[i*2 + 1] 。使用三数中值分割法
的快速排序
以常数个时间单位比较 A[Left] , A[Center] 和 A[Right] 。如果输入数据在磁盘上,那么所有这些操作就失去了它们的效率,因为磁盘
上的元素只能被顺序访问。即使数据在一张磁盘上,由于转动磁盘和移动磁盘所需的延迟,仍然存在实际上的效率损失。
假设至少有三个磁盘驱动器进行排序工作。我们需要两个驱动器执行有效的排序,而第三个驱动器进行简化的工作。如果只有一个磁盘驱动器可用,那么我们则不得不说:任何算法都将需要 \Omega(N^2) 次磁盘访问。
基本的外部排序
算法使用归并排序
中的Merge
子程序。假设我们有四个磁盘, T_{a1}, T_{a2}, T_{b1}, T_{b_2} ,它们是两个输入磁盘和两个输出磁盘。设数据最初在 T_{a1} 上,并设内存可以一次容纳(和排序) M 个记录。一种自然的做法是第一步从输入磁盘一次读入 M 个记录,在内部(内存)将这些记录排序,然后再把这些排过序的记录交替地写到 T_{b1} 或 T_{b2} 上。我们将把每组排过序的记录叫做一个顺串(run)
。做完这些之后,我们倒回所有的磁盘。假设我们的输入数据如下:
磁盘 | 数据 |
---|---|
T_{a1} | 81 , 94 , 11 , 96 , 12 , 35 , 17 , 99 , 28 , 58 , 41 , 75 , 15 |
T_{a2} | |
T_{b1} | |
T_{b2} |
如果 M = 3 ,那么在顺串构造以后,磁盘将包含如下所示的数据。
磁盘 | 顺串 | 顺串 | 顺串 |
---|---|---|---|
T_{a1} | |||
T_{a2} | |||
T_{b1} | 11, 81, 94 | 17, 28, 99 | 15 |
T_{b2} | 12, 35, 96 | 41, 58, 75 |
现在 T_{b1} 和 T_{b2} 都包含一组顺串。我们将每个磁盘的第一个顺串取出并将二者合并,把结果写到 T_{a1} 上,该结果是一个二倍长的顺串
。然后,我们再从每个磁盘取出下一个顺串合并,并将结果写到 T_{a2} 上。继续这个过程,交替使用 T_{a1} 和 T_{a2} ,直到 T_{b1} 或 T_{b2} 为空。此时,或者 T_{b1} 和 T_{b2} 均为空,或者剩下一个顺串。对于后者,我们把剩下的顺串拷贝到适当的顺串上。将四个磁盘倒回,并重复相同步骤,这一次用两个 a 磁盘作输入,两个 b 磁盘作输出,结果得到一些 4M 的顺串。我们继续这个过程直到得到长为 N 的一个顺串
。
该算法将需要 \lceil log(N/M) \rceil 趟工作,外加一趟构造初始化的顺串。例如,若我们有 1000 万个记录,每个记录 128 个字节,并有 4 兆字节的内存,则第一趟将建立 320 个顺串。此时我们再需要 \lceil log320 \rceil = 9 趟以完成排序。上面的例子则是 \lceil log13/3 \rceil = 3 趟。
第一趟:
磁盘 | 顺串 | 顺串 |
---|---|---|
T_{a1} | 11, 12, 35, 81, 94, 96 | 15 |
T_{a2} | 17, 28, 41, 58, 75, 99 | |
T_{b1} | ||
T_{b2} |
第二趟:
磁盘 | 顺串 |
---|---|
T_{a1} | |
T_{a2} | |
T_{b1} | 11, 12, 17, 28, 35, 51, 58, 75, 81, 94, 96, 99 |
T_{b2} | 15 |
第三趟:
磁盘 | 顺串 |
---|---|
T_{a1} | 11, 12, 15, 17, 28, 35, 51, 58, 75, 81, 94, 96, 99 |
T_{a2} | |
T_{b1} | |
T_{b2} |
对于最一般的内部(内存)排序
应用程序,选用的方法不是插入排序
,希尔排序
,就是快速排序
,它们的选用主要是根据输入的大小来决定。下表显示在一台相对较慢的计算机上处理各种不同大小的文件时的运行时间(单位:秒)。
N | 插入排序 O(N^2) | 希尔排序 O(N^{7/6})(?) | 堆排序 O(NlogN) | 快速排序 O(NlogN) | 优化快速排序 O(NlogN) |
---|---|---|---|---|---|
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 |
这里没有包括归并排序
,因为它的性能对于在主存(内存)排序
不如快速排序
那么好,而且它的编程一点也不省事。然而归并(合并)
却是外部排序
的中心思想。