排序
这一章将围绕着数组元素的排序展开。为了减轻负担,所有的元素都为整数(其实前面几章也都是这样做的)。
-
内部排序和外部排序
-
内部排序:整个排序工作可以在主存中完成,元素个数一般小于 1 0 6 10^{6} 106。
-
外部排序:整个排序工作不能再主存中完成而必须在磁带或磁盘中完成。
另外,为了后面的叙述方面,我们假定这个数列的长度为 N N N。
插入排序InsertionSort
插入排序的原理很简单。首先我们给出一个位置 P P P,假设在位置 P P P之前的数列都已经处于已排序状态,这样位置 P P P处的元素只需要在前面的数列找到自己合适的的位置即可。总的来说,我们要跑 N − 1 N-1 N−1次,位置 0 0 0上的元素不需要排序,因为对于一个元素的数列来说,有序无序没有意义。
/**
书上给的样例,因为P前的元素都已经排序,那么我们只需要保证有足够的位置让P处的元素插入即可。
这样一来,我们仅仅目标位置右边到P位置的元素向右移动一个位置便能够得到足够的位置供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;
}
}
一些简单排序算法的下界
-
逆序(inversion)
数组中具有 i < j i<j i<j但是 A [ i ] > A [ j ] A[i]>A[j] A[i]>A[j]的序偶1 ( A [ i ] , A [ j ] A[i],A[j] A[i],A[j])。一个排过序(升序)的数组没有逆序。
我们假设不存在重复元素,并且所有的排列都是有可能的。可以得到下列两个定理:
-
N N N个互异数的数组的平均逆序数是 N ( N − 1 ) / 4 N(N-1)/4 N(N−1)/4。
-
通过交换相邻元素进行排序的任何算法平均都需要 Ω ( N 2 ) \Omega (N^2) Ω(N2)时间。
这个定理建立在前一个定理的基础上,我们交换的次数实际上就是逆序的个数。
-
希尔排序ShellSort
希尔排序名字源于它的发明者Donald Shell。它是冲破二次时间屏障的第一批算法之一,拥有亚二次时间界。由于它的工作原理,又被称为缩小增量排序(diminishing increment sort)。
在希尔排序中,我们取一系列的数值作为增量(increment),这一系列数值被称作增量序列(increment sequence)。增量序列由 h 1 h_1 h1开始逐步增大(这不一定是一个递增数列),也就是这样的序列: h 1 , h 2 . . . h t h_1,h_2...h_t h1,h2...ht。其中 h 1 h_1 h1等于1,在实际排序过程中,增量是由大到小最后到1的。
希尔排序是插入排序的更好版本,在这个排序过程中,我们将数组元素分为 h t h_t ht个小组,然后各个小组内进行插入排序;接着增量 h t h_t ht减少为 h t − 1 h_{t-1} ht−1,重复前面的操作。在这个过程中,数组的有序性不断提高,最后我们就能够得到一个排序好的数组。
如图,希尔排序的一次排序中,进行了多次插入排序。
关于增量序列的选择
Shell建议的序列为: h t = N / 2 , h k = h k + 1 / 2 h_t = N/2, h_k=h_{k+1}/2 ht=N/2,hk=hk+1/2。这个序列很流行,但是并不好。在实际上,我们应该尽量减少增量间的公因子,最好的情况是增量互素。
堆排序heapSort
堆排序是建立在我们前几章提到的优先队列的基础上的,它利用了二叉堆的结构性和堆序性。
#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--)//构建堆
percDown(A, i , N);
for (i = N-1; i > 0; i--)//删除最大值,但实际上并没有移除
{
swap(&A[0], &A[i]);
percDown(A, 0, i);
}
}
归并排序mergeSort
归并排序将两个已排序的合并到第三个表中。这涉及到三个数组和三个计数器(指针),前两个数组为长度相同的输入数组,后一个数组为输出数组。指向两个输入数组的指针指向的元素下标相同,选中相同下标的两个元素中较小的那个填入输出数组。
实际应用中,我们将一个数组分为前后两个输入数组进行归并排序(分治思想),见以下例程:
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;
while(lPos <= leftEnd && 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++];
}
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 available for tmpArray");
}
快速排序quickSort
快速排序是实践中已知的最快的排序算法,它是一种采用分治思想的递归算法。但值得一提的是,对于小数组的处理,快速排序并不是最好的选择,插入排序会更好。
快速排序可以简单地分为四个步骤:
- 如果S中元素个数是0或1,返回。
- 取S中任意一个元素 v v v,作为
枢纽元(pivot)
。- 将S中剩下的元素分为两个不相交的集合 S 1 = { x ∈ S ∣ x ≤ v } 和 S 2 = { x ∈ S ∣ x ≥ v } S_1=\{x\in S | x\leq v \}和S_2=\{x\in S | x\geq v \} S1={x∈S∣x≤v}和S2={x∈S∣x≥v}。
- 返回quickSort( S 1 S_1 S1),继而 v v v,然后quickSort( S 2 S_2 S2)。
枢纽元的选取
我们的输入很有可能是预排序的,为了保证 S 1 , S 2 S_1,S_2 S1,S2大小尽量相等,我们不要选择第一或者第二个元素作为枢纽元,这可能会让两边严重不平衡。
随机选取枢纽元是一个不错的想法,这个办法有一定的安全性,毕竟随机的枢纽元不会接连不断地产生劣质的分割。
还有一个更好的选择,三数中值分割法(Median-of-Three Partitioning)
我们首先选取第最左边的元素,中间位置的元素,和最右边的元素,对它们进行排序,选择中值作为枢纽元。这种方案不仅能一定消除坏情况,还能减少快排的运行时间。
如何分割
分割的策略选择是一个问题。原书使用了两个下标 i , j i,j i,j, i i i从数组的第一个开始往后移动, j j j从数组的最后一个向前移动。在移动的过程中,当 i i i移动到大于枢纽元的元素就停下,当 j j j移动到小于枢纽元的元素就停下,这样一来,当 i , j i,j i,j都停下时, i i i指向一个大元素, j j j指向一个小元素,这个时候如果 i i i还在 j j j的左边,那么交换两者指向的元素。
我们不断重复这个过程,直到 i , j i,j i,j指向同一个元素。
然后我们进行分割的最后一步,将枢纽元与 i i i指向的元素交换位置。
这样一来我们便能保证枢纽元左边的元素都比它小,右边的元素比它大。
现在我们该开始考虑如何处理那些等于枢纽元的关键字。书上的建议是当遇到这种情况时,我们应该停下 i , j i,j i,j,直接进入下一个步骤。
不要用快速排序对小数组排序
当数组的长度 N ≤ 20 N\leq 20 N≤20时,选择插入排序会比快速排序更好。
例程
#define elementType int
#define Cuttoff ( 3 )
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]);
swap(&A[center], &A[right-1]);
return A[right-1];
}
void qSort(elementType A[], int left, int right)
{
int i,j;
elementType pivot;
if(left + Cuttoff <= 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]);
qSort(A, left, i - 1);
qSort(A, i + 1, right);
}
else
insertionSort(A + left, right - left + 1);
}
void quickSort(elementType A[], int N)
{
qSort(A, 0, N - 1);
}
快速选择quickSelect
我们可以把快排的思想应用到解决选择问题上,即查找第 k k k个最小/大元。这只需要简单的将两次递归减少为一次递归(因为前文提到的分割策略)。如果你希望这个过程不破坏原来的排序,只需要在拷贝上进行这个操作。
针对大型结构
非基本类型,如结构体等等,我们可以通过比较它们某个特定的域来排序。但不需要交换他们的位置,只需要交换指向这些结构的指针即可。
决策树decision tree
序偶: ordered pair,用
()
包裹的一对数字,如(3,6)
,出自离散数学。 ↩︎