# 第八章 排序和选择

> 译者：[Rachel-Hu](https://github.com/Rachel-Hu)

至少曾经有一段时间，大部分的CPU运转时间和I/O带宽是花在排序上的（然而在现在这个时代，我怀疑它们多是被用在了MPEG文件渲染上）。正因如此，排序也就成为了大量研究和写作的主题。我们只会对排序做浅显的介绍。

## 8.1 基本概念

任何排序的目的都是排列一些被我们称之为记录的项目集，使他们依据某些序列关系排列整齐。通常来说，排序关系仅查看每个记录的一部分，即键（key）。可以根据多个键值对记录进行排序，在这种情况下，我们以主键（primary key）和辅键（secondary key）作为区分。这种区分事实上是由排序函数实现的：若记录A和B的主键相等时，当且仅当A的辅键位于B的辅键之前时，A排在B的前面。否则，当A的主键位于B的主键之前时，将A排在B前。如若存在更多的键，该定义显而易见可以以此方式扩展。为方便起见，在本书中我将假设记录均属于Record类型，且我们将要排序的这些记录具有某种顺序关系。同时，无论我们采用的是哪种顺序，我都将以before(A,B)表示A的键位于B的键之前。

尽管从概念上而言，我们移动记录从而使它们按序排列，然而这些记录在实际应用中可能会非常大。因此，我们通常会倾向于交换指向这些记录的指针。如果有必要的话，我们会在最后重新将真正的数据排序。在Java中，此举显而易见非常容易，因为“庞大”的数据项总是
经由指针访问的。

**稳定性（Stability）。** 当记录的主键相等时，如果一种排序算法能够维持其原有次序，那么该算法被称作是稳定的。在实际操作中，任何排序算法都可以变成稳定的，只要将待排序的记录原本的位置添加为最终的辅键，使得诸如(Bob, Mary, Bob,
Robert)的键表变为(Bob.1, Mary.2, Bob.3, Robert.4)的形式。

**逆序（Inversions）。** 在有些分析中，我们需要了解给定的键序列的无序性。其中一种有效的方法是计算该序列的逆序数：在键序列$k_0,...,k_{N-1}$中，逆序数是满足$i < j$且$k_i > k_j$的整数对$(i,j)$的数量。譬如，

> Charlie, Alpha, Bravo

这个单词序列中有两个的逆序，而在

> Charlie, Bravo, Alpha

中则有三个。如果键已经按序排列了，其逆序数为零。如果键以相反的属性排列，使得每一对键都处于错误的顺序，逆序数为$N(N-1)/2$，即两两为一对的键对的数量。当所有的键的初始位置和它们在顺序序列中的正确位置的距离都在$D$以内时，我们可以保守估计其原始序列中的逆序数上限为$DN$。

**外部与内部排序** 全过程均在主存中完成的排序算法被称为内部排序。需要借助辅助存储器（在过去主要是磁带）存储中间值的排序被称为外部排序。输入和输出的来源与此分类无关。内部排序可以在源自外部文件的数据上完成；决定性因素在于中间的文件。

## 8.2 一点注释

本书中的多数算法的处理对象（或者其处理对象可以被认为）是数组。在描述和解释过程中，我们有时需要对数组的内容作一些假定。为此，我将会使用David Gries的符号来详细解说我的数组。该符号表示了一个数组的一部分，该部分的元素具有从$a$到$b$的下标，并且满足属性$P$。它还假设$a &le; b + 1$；如果$a &gt; b$，那么该部分为空。也可以使用下例描述数组片段，其中包含下标为$c + 1$到$d - 1$的项，满足属性P，且$c < d$。把这些段拼接到一起，就可以描述整个数组。比如，以上符号为真，如果数组A有$N$个元素，其中下标为$0$到$i - 1$的项为有序的，且$0 &le; i &le; N$。如下符号表示了一个只有一项的数组片段，其下标为j且各项（此项）满足$P$。最后，我偶尔会需要数组的嵌套片段同时满足条件。比如，下例表示了一个数组片段，其第$0$到$N - 1$项满足$P$，$0$到$i - 1$项满足$Q$，$0 &le; N$，且$0 &le; i &le; N$。

## 8.3 插入排序

有一种非常简单， 且对于较小的应用而言已经足够的排序，那就是直接插入排序。在排序的过程中，我们将一个未处理项插入目前已排好顺序的项的列表中，如图8.2所示，而这也是它名称的由来。算法则如图8.1所示。

一种常见的测量排序时间的方式是计算键值比较的次数（在图8.1中，则为调用before的次数。函数insertionSort所需要的总时间（也即所需要的最长时间）为$\sum_{0 < i < N} C_{IL}(i)$，其中$C_{IL}(m)$为i = m时内层循环（j）所需的时间，$N$为数组A的大小。查看内层循环我们可以得知，所需要的比较数与序列为0到i - 1的记录中键值比x大的记录的数量相等。如果至少有一个较小的键，那么该数值需要加上1。因为A\[0..i\]已被排好序，这段区间内不存在逆序，所以在A已排好序的部分中位于x之后的元素正好等于序列A\[0\],...,A\[i\]中的逆序数（因为x就是A\[i\]）。当x被正确地插入后，得到的序列中将不会有任何逆序。基于上述条件易得，以键值比较数测量的insertionSort的运行时间，其上限为$I + N - 1$，其中$I$为insertionSort的原参数的逆序数。因此，待排序的数组越有序，insertionSort运行得越快。

## 8.4 希尔排序

插入排序所存在的问题可以通过查看最坏的情况（即数组最初为逆序）得知。键与它们最终应处的位置相去甚远，且必须被一项一项地移动到最终位置。如果键能够被迅速地移动到很远的位置，那么排序速度就可能得到提升。

```java
/** Permute the elements of A to be in ascending order. */
static void insertionSort(Record[] A) {
    int N = A.length;
    for (int i = 1; i < N; i += 1) {
        Record x = A[i];
        int j;
        for (j = i; j > 0 && before(x, A[j-1]); j -= 1) {
            A[j] = A[j-1];
        }
        A[j] = x;
    }
}
```

图8.1：对数组进行插入排序的程序。假设before函数可以体现所需的顺序关系。

图8.2：插入排序示例，展示了每次调用insertiontElement前的数组。每行的间隙用于将已排序的部分和未排序的部分分开。

```java
/** Permute the elements of KEYS, which must be distinct,
* into ascending order. */
static void distributionSort1(int[] keys) {
    int N = keys.length;
    int L = min(keys), U = max(keys);
    java.util.BitSet b = new java.util.BitSet();
    for (int i = 0; i < N; i += 1)
        b.set(keys[i] - L);
    for (int i = L, k = 0; i <= U; i += 1)
        if (b.get(i-L)) {
            keys[k] = i; k += 1;
        }
}
```

图8.3 排列足够小且密集的集合中的不同键。在这里我们假设函数min和max返回数组中的最小和最大值。如果数组为空，返回值则是任意值。

这就是希尔排序的原理。我们选择逐渐减小的步长，$s_0 > s_1 > ... > s_{m_1}$，通常设定$s_{m-1} = 1$。随后，对于每一个$j$，我们将这$N$项纪录分为$s_j$个交错序列
$$R_0, R_{s_j}, R_{2s_j},...$$
$$R_1, R_{s_j + 1}, R_{2s_j + 1},...,$$
$$...$$
$$R_{s_j - 1}, R_{2s_j - 1},...$$
然后应用插入排序将以上部分分别排序。图8.4展示了对一个逆序的向量进行希尔排序的过程（总共需要49次比较，而直接插入排序则需要120次）。

一个性能良好的$s_j$序列需要$s_j = \lfloor2_{m-j} - 1\rfloor$，其中$m = \lfloor lg N\rfloor$。这个序列可以证实所需的比较数为$O(N^{1.5})$，比$O(N^2)$要好得多。直观来说，这种每个$s_j$都是相对质数序列，其优势在于每轮操作中该向量的每个坐标都和另一些坐标的集合共同参与了排序。序列变得更加错杂，也更可能减少下一轮的逆序数。

图8.4 希尔排序示例，其初始态为一个逆序向量，步长分别为15，7，3和1。标记为#I的一列给出了数组中剩余的逆序数，标记为#C的一列则给出了由上一行的数组得到下一行所需的键比较数。数组下方的弧线标明了每个阶段所处理的元素。

## 8.5 计数排序（Distribution counting）

当键的范围受限的时候，排序存在着许多可能的优化方式。在Jon Bentley的Programming Pearls一书的第一列中，作者提供了一种简单的算法，用于排列N个不同的键，且每个键都处于有限的整数范围内，使得编程者能够构建一个以这些整数为下标的位向量。在图8.3的程序中，我使用了Java中的BitSet类型。BitSet是一个非负整数的抽象集合，由元素为一位（1-bit）的打包数组实现。

让我们来思考一种更通用的手段，使得我们能够处理多个记录拥有相同键的情况。假设将要排序的记录的键在某个足够小的整数范围内。如此，图8.6中的函数distributionSort2就能稳定地排列N个记录，将他们从输入数组（A）移动到另一个输出数组（B）。对于A中的每一个记录，它都能正确地计算该记录在B中的正确位置。要达到这个目的，该算法应用了以下事实：B中任何记录的位置都应该是比它具有更小的键，或者具有相同的键但是排在它前方的记录的数量。图8.5囊括了一个正在运行的程序示例。

## 8.6 选择排序

在插入排序中，我们需要逐步确定每项的最终位置。另一种方式是通过选择每一步中最小（或最大）的键，将每个记录一步到位地放置在其最终位置上。这一思路最简单的实现方式是直接选择排序，如下所示。

```java
static void selectionSort(Record[] A)
{
    int N = A.length;
    for (int i = 0; i < N-1; i += 1) {
        int m, j;
        for (j = i+1, m = i; j < N; j += 1)
            if (before(A[j], A[m]) m = j;
        /* Now A[m] is the smallest element in A[i..N-1] */
        swap(A, i, m);
    }
}
```

在这里我们假设swap(A,i,m)将交换A的第i和第m项。这种排序因为存在记录的交换，导致它并不稳定。从另一方面而言，我们能对此程序

图8.5 程序distributionSort2的示意图。标记为A的数组展示了将要排序的数值。每项的键为斜线左边的数字。这些数据被排好序，存储在图中多处展示的数组B中。左边的标记对应的是图8.6程序中的点。每个点，$B_k$，表示$i = k$时最后一个循环末尾的情况。数组count的作用在不断变化。最开始（在count<sub>1</sub>处）count\[k-1\]包含了键为(k-1)-1的各项。随后（在count<sub>2</sub>处），它包含了键值小于k-1的各项。在第$B_i$行中，count\[k-1\]表示了下一个键值为k的项在B中的位置。（在这些地方则为k-1而非k，因为1是最小的键。）

```java
/** Assuming that A and B are not the same array and are of
* the same size, sort the elements of A stably into B.
*/
void distributionSort2(Record[] A, Record[] B)
{
    int N = A.length;
    int L = min(A), U = max(A);
    /* count[i-L] will contain the number of items <i */
    // NOTE: count[U-L+1] is not terribly useful, but is
    // included to avoid having to test for for i == U in
    // the first i loop below.
    int[] count = new int[U-L+2];
    // Clear count: Not really needed in Java, but a good habit
    // to get into for other languages (e.g., C, C++).
    for (int j = L; j <= U+1; j += 1)
        count[j-L] = 0;
    for (int i = 0; i < N; i += 1)
        count[key(A[i]) - L + 1] += 1;
    /* Now count[i-L] == # of records whose key is equal to i-1 */
    // See Figure 8.5, point count1.
    for (int j = L+1; j <= U; j += 1)
    count[j-L] += count[j-L-1];
    /* Now count[k-L] == # of records whose key is less than k,
    * for all k, L <= k <= U. */
    // See Figure 8.5, point count2.
    for (i = 0; i < N; i += 1) {
        /* Now count[k-L] == # of records whose key is less than k,
         * or whose key is k and have already been moved to B. */
        B[count[key(A[i])-L]] = A[i];
        count[key(A[i])-L] += 1;
        // See Figure 8.5, points B0–B6.
    }
}
```

图8.6 计数排序。此程序假设key(R)为整数。

进行改进，使它将结果输出到另外一个数组中，那么维持稳定性就变得相对简单了。

显而易见，以上算法对数据并不敏感。和插入排序不同的是，它总是需要相同的比较数：$N(N - 1)/2$。因此，在这种基础的形式中，即便该方法非常简单，但在与插入排序相比仍不具备优势（至少在顺序机器上是如此）。

但从另一角度而言，我们也曾见过另一种选择排序：堆排序（出处为6.4章）是选择排序的一种形式，它在实际应用中记录了之前的比较结果，并因此能够极大地加快选择最小项这一步。

## 8.7 交换排序：快速排序

C. A. R. Hoare开创了最流行的内部排序方法之一。他显然采用了这种技术，并将其命名为快速排序。这个名字是非常妥当的。该方法的基本算法如下。

```java
static final int K = ...;
void quickSort(Record A[])
{
    quickSort(A,0,A.length-1);
    insertionSort(A);
}
/* Permute A[L..U] so that all records are < K away from their */
/* correct positions in sorted order. Assumes K > 0. */
void quickSort(Record[] A, int L, int U)
{
    if (U-L+1 > K) {
        Choose Record T = A[p], where L ≤ p ≤ U;
P: Set i and permute A[L..U] to establish the partitioning
condition:
        quickSort(A, L, i-1); quickSort(A, i+1, U);
    }
}
```

在这里，K是一个常数，可以通过调整它的值调节排序的速度。一旦近似排序获得所有距它们最终位置k-1以内的记录，最终的插入排序将在$O(KN)$的时间内进行。如果我们能够选择合适的T，使它的键接近A中记录的键的中间值，那么我们就能粗略地计算出，对N个记录执行quicksort时所需的键比较的时间近似为$C(N)$，其定义如下。
$$C(K) = 0$$
$$C(N) = N - 1 + 2C(\lfloor N/2\rfloor)$$

这需要假设我们能够通过$N-1$次比较对N个元素的数组进行划分，并且这一假设具有可能性的。考虑$N = 2_mK$的情况，我们可以粗略了解这一问题的解答方案：
$$C(N) = 2^mK + 2C(2^{m-1}K)$$
$$ = 2^mK - 1 + 2^mK - 2 + 4C(2^{m-2}K)$$
$$ = m2^mK - 2^m + 1$$
$$ = \underbrace{2^mK + ... + 2^mK}_\text{m} - 1 - 2 - 4 - ... - 2^{m-1} + C(K)$$
$$\in \Theta(m2^mK) = \Theta(NlgN)$$
(因为 $lg(2^mK) = mlgK$)。

不幸的是，在最坏的情况，即枢轴T具有最大或者最小的键值，快速排序本质上就成了直接选择排序，其运行时间则成为了$\Theta(N^2)$。因此，在选择区分元素时我们必须小心。有一种方法是随机选择一个记录作为T。这样我们就能确定避免最坏的情况。T常见的选择是A\[L\]，A\[(L+U)/2\]和A\[U\]的中位数，这种选择几乎不会失败。

**划分。** 本节的剩余部分则是如何在每个阶段（即上面程序里的步骤P）划分数组。有很多方法可以实现这一目的。这里给出Nico Lomuto的一种方法，它并非是最快的，但非常简单。

```java
P:
swap(A, L, p);
T = A[p]
i = L;
for (int j = L+1; j <= U; j += 1) {
    if (before(A[j],T)) {
        i += 1;
        swap(A, j, i);
    }
}
swap(A, L, i);
```

有些作者在开发非递归版本的快速排序时遇到了麻烦。显然他们认为非递归方法可以极大地提高其性能。许多人都持有这种递归具有较高成本的观点，因此我觉得我不应该对此感到惊讶。然而，使用C版本的快速测试表明，迭代版本提高了3%的性能。这几乎不值得付出重写的代价。

## 8.8 归并排序

快速排序是一种分而治之的算法。我们可以尝试称之为分而治之，是因为它并不能保证成功地将数据划分均匀。一项更早的技术，即归并排序，是另一种分而治之的算法，且能保证均匀划分数据。
它的概述如下。

```java
/** Sort items A[L..U]. */
static void mergeSort(Record[] A, int L, int U)
{
    if (L >= U)
        return;
    mergeSort(A, L, (L+U)/2);
    mergeSort(A, (L+U)/2+1, U);
    merge(A, L, (L+U)/2, A, (L+U)/2+1, U, A, L);
}
```

merge程序有如下格式：

```java
/** Assuming V0[L0..U0] and V1[L1..U1] are each sorted in */
/* ascending order by keys, set V2[L2..U2] to the sorted contents */
/* of V0[L0..U0], V1[L1..U1]. (U2 = L2+U0+U1-L0-L1+1). */
void merge(Record[] V0, int L0, int U0, Record[] V1, int L1, int U1,
            Record[] V2, int L2)
```

因为V0和V1已经按升序排列了，以上任务很容易在$\Theta(N)$时间内完成，其中$N = U2 - L2 + 1$，即为两个数组的总大小。合并按从左到右的顺序在数组上完成。这一特点使得它非常适合内存较小又需要大量排序操作的电脑。数组可以位于仅限于序列访问的序列辅助存储器上，即是说，需要按索引升序（或者降序）的顺序读取或者写入数组的设备。

当然了，真正的工作是由归并过程完成的。这些归并的模式相当有趣。为了描述更简单起见，考虑N为2的幂的情况。如果跟踪mergeSort的执行过程，你将会在调用merge时看到如下调用模式。

在尝试对链表的元素进行归并排序时，我们可以充分利用这种这种模式，因为链表的对半划分并不如数组那么容易。假设所有记录彼此链接为链表Lists。下面的程序展示了如何在这些链表上执行归并排序。图8.7说明了这一过程。该程序维护了已排序的子链表的二项式梳（binomial comb），comb\[0..M-1\]，使得comb\[i\]中的链表长度为$2^i$或为空。

```java
/** Permute the Records in List A so as to be sorted by key. */
static void mergeSort(List<Record> A)
{
    int M = a number such that 2
    M−1 ≥ length of A;
    List<Record>[] comb = new List<Record>[M];
    for (int i = 0; i < M; i += 1)
        comb[i] = new LinkedList<Record> ();
    for (Record R : A)
        addToComb(comb, R);
    A.clear ();
    for (List<Record> L : comb)
        mergeInto(A, L);
}
```

在每个阶段，梳包含了将要归并的已排序的链表。首先我们为每个新项构建对应的梳，然后通过最终遍历将该梳的所有链表合并。我们以如下方式将新元素加入梳。

```java
/** Assuming that each C[i] is a sorted list whose length is either 0
 * or 2^i elements, adds P to the items in C so as to
 * maintain this same condition. */
static void addToComb(List<Record> C[], Record p)
{
    if (C[0].size () == 0) {
        C[0].add (p);
        return;
    } else if (before(C[0].get (0), p))
        C[0].add (p);
    else
        C[0].add (p, 0);
    // Now C[0] contains 2 items
    int i;
    for (i = 1; C[i].size () != 0; i += 1)
        mergeLists(C[i], C[i-1]);
    C[i] = C[i-1]; C[i-1] = new LinkedList ();
}
```

我将mergeList程序留给你们完成。

```java
/** Merge L1 into L0, producing a sorted list containing all the
 * elements originally in L0 and L1. Assumes that L0 and L1 are
 * each sorted initially (according to the before ordering).
 * The result ends up in L0; L1 becomes empty. */
static void mergeLists (List<Record> L0, List<Record> L1) ...
```

### 8.8.1 复杂度

快速排序的预估最短时间适用于归并排序的最长时间，因为归并排序的每一步确实将数据均分为两半（而且将两个链表或者数组归并需要的时间为线性的）。因此，归并排序是复杂度为$\Theta(NlgN)$的算法，其中$N$为记录的数量。与快速排序和插入排序不同的是，归并排序，正如我描述的那样，对数据的顺序并不敏感。当我们考虑外部排序时，这一点会有所变化，但$O(NlgN)$次比较依然是它的上限。

## 8.9 基于比较的排序的速度

我展示了一系列的算法，声明它们中性能最好的在最坏的情况下需要$\Theta(NlgN)$次比较。关于此上限，有几个明显的问题值得一问。

图8.7 链表的归并排序，展示了L的许多元素经过处理后，梳的状态。最后一步则是在原链表中的11个元素全部加入梳后，将梳里剩余的链表归并。小方框中的0和1是用以说明正在进行的归并模式的标记。每个空的方框都以0标记，否则以1标记。如果你将4个方框中的数字作为一个二进制数来理解，且将最上方的一位作为单位，该数等于已处理的元素数。

首先，比较是如何转化为指令的？其次，我们能做得比$NlgN$更好吗？

第一个问题的重点在于，比较是一个常数时间操作这一点，并不是那么准确。比如说，当我们比较字符串时，字符串的长度对于最坏情况的比较时间具有重要影响。当然，平均而言，我们并不能深入到字符串这样的细节来评判性能差异。不过，这意味着要正确地将比较转换为指令，我们必须抛开另一个因素，键的长度。假设我们的拥有N个记录的集合里每个键都是相异的。这意味着这些键本身就必须长达$\Omega(lgN)$。假设键的长度位于此范围内，且比较时间与键的大小成比例（在最坏的情况下），这意味着排序事实上需要$\Theta(N(lgN)^2)$的时间（假设移动其中一个记录所需的时间在最坏的情况下与键长成比例）。

至于我们能否做得比$NlgN$更好，答案是，如果我们能够得到的关于键的唯一信息是它们是如何进行比较的，那么我们并不能得到比$NlgN$更好的性能。也就是说，$NlgN$次比较是所有使用比较的可行排序算法在最坏情况下的下限。

这一论断的证明非常具有启发性。一个排序算法可以被认为是首先执行一系列的比较，然后，仅仅依赖于比较获得的信息，再决定如何置换其输入。当然这两个操作常常是同时进行的，不过我们可以在这里忽略这一点。为了让程序有足够的“了解”，使得它得以以不同的方式置换不同的输入，这些输入必须能够获得不同的比较结果序列。因此，我们可以将这种理想化的排序过程展示为树状，其中叶节点为置换，内部节点为比较，内部节点的每个左子节点包含了当比较结果为真时所进行的比较和置换，每个右子节点包含了当比较结果为假时所进行的比较和置换。图8.8说明了$N = 3$的情况。树的高度与比较数相关。因为可能的置换数（也就是叶节点）为$N!$，且具有M个叶节点的二叉树的最小高度为$\lceil lgM\rceil$，具有N个记录的比较树的最小高度大约为$lg(N!)$。
现在：
$$lgN! = lgN + lg(N-1) + ... + 1$$
$$\le lgN + lgN + ... + lgN$$
$$ = NlgN $$
$$\in O(NlgN)$$
而且（假设N为偶数）
$$lgN! \ge lgN + lg(N-1) + ... + lg(N/2)$$
$$\ge (N/2 + 1)lg(N/2)$$
$$\in \Omega(NlgN)$$

图8.8 $N = 3$的比较树。待排序的三个数值是A，B和C。每个内部节点都代表一次测试。左子节点表明了测试成功（为真）的情况，右子节点表明了不成功的情况。叶节点（方框）表明了三个数值在该路径下比较结果决定的顺序。我们假设A，B和C不同。该树是最佳的，证明了在最坏的情况下将这三个数值排序需要三次比较。

那么
$$lgN!\in \Theta(NlgN)$$
因此任何仅仅使用（真/假）键比较来获取其输入的键的顺序信息的排序算法，在最坏的情况下都需要$\Theta(NlgN)$次比较来排列N个键。

## 8.10 基数排序

为了得到8.9中的结果，我们假设唯一可用的键检查是比较它们从而获得顺序。假设我们并不受限于简单地比较键。我们能够改善我们的$O(NlgN)$界限吗？有趣的是，我们某种程度上是可以的。可以通过基数排序实现这一目的。

大部分键事实上是长度确定的片段序列（主要是字符或者字节）且具有词典排序关系。即是说，键$k_0k_1...k_{n-1}$比$k_0'k_1'...k_{n-1}'$小，如果$k_0 < k_0'$或者$k_0 = k_0'$且$k_1...k_{n-1}$比$k_1'...k_{n-1}'$小（我们可以始终认为键具有同等长度，只要为短一些的键选择合适的填充字符即可）。就像在搜索中一样，我们在键集中使用连续的字符，将字符串分配给子树，我们也可以使用键的连续字符来对它们进行排序。有两种基本算法，一种从最低位依次处理到最高位（LSD-first），另一种从最高位处理到最低位（MSD-first）。在此我使用“位”这个通用术语，它不仅包括十进制位，也包括字母，以及其他任何适用于待排序的数据的概念。

### 8.10.1 LSD第一基数排序

LSD第一基数排序的概念是，先使用最低位排列所有的记录，然后使用第二位，等等。在每个阶段，我们都执行一次稳定排序，使得两个具有相同k位字符的记录能够接着被剩下的字符位排序。因为字符可取的值范围有限，很容易在线性时间内将它们排好序（使用，比如说，distributionSort2，或者，假如记录被存储在链表中，通过保存一个链表标题数组，并分配给每个可能的字符值）。图8.9说明了这一过程。

LSD第一基数排序正是卡片排序器使用的算法。这些机器有一系列的箱子，可以通过插板编程将卡片从进纸器，根据特定列的打孔信息，放入对应的箱子。对每列重复上述过程，可以得到一个有序的卡片组。

记录入箱的不同分布都需要（大致的）常数时间（假设我们使用指针以避免移动大量的数据）。因此，总时间与键数据的总量，也就是所有键中的总字节数，成比例。换言之，基数排序复杂度为$O(B)$，其中B为键数据的总字节数。如果键长为K字节，那么$B = NK$，其中N是记录的数量。因为归并排序，堆排序等等都需要$O(NlgN)$次比较，每次在最坏情况下需要时间K，我们能够得到这些排序的总时间为$O(NKlgN) = O(BlgN)$。即便我们假设比较时间为常数，如果键不过长（为了得到N个不同的键我们必须使$K \ge log_CN$，其中C是可能的字符数），那么基数排序也是$O(NlgN)$。

因此，如果放宽我们能够对键进行的操作限制，那么我们至少能在理论上得到更快的排序过程。像往常一样，魔鬼隐藏在细节中。如果键比$log_CN$要长得多，（而它们通常是这样），那么对最后的字符的操作通常会被极大地浪费掉。一种可能的改进方法是对前两个字符使用基数排序，剩下的部分则使用插入排序来完成（理论上来说，在基数排序之后该序列就几乎已经按序排列了）。Knuth将该方法归功于M. D. Maclaren。为了这个目的，我们必须捏造字符的定义，允许字符的长度随着N略微增长。比如说，当$N = 100000$时，Maclaren的最优过程为对键的第一和第二个10位片段进行排序（在8位的机器上，就是前2.25个字符）。当然，这一技术在理论上并不能保证$O(B)$的性能。

### 8.10.2 MSD第一基数排序

从最高位起执行基数排序对我们来说似乎更为自然。我们对输入依据最高位的字符进行排序，得到C个（或者更少的）子序列，每个子序列属于一个起始字符（即是说，在任何子序列中的所有键的第一个字符都是相同的。）

图8.9 LSD第一基数排序示例。每次操作按一位字符排序，从最低位开始。排序过程包含将记录分配到索引为字符的箱中，然后箱中的所有字符被连接在一起。图中仅展示了不为空的箱。

图8.10 MSD第一基数排序示例，数据与图8.9中一致。第一行展示了A的起始内容，最后一行展示了最终内容。具有一致起始字符的部分排序片段由单斜线分开。*字符说明数组片段即将被排序，posn列则表示即将排序的字符的位置。

随后，我们按第二个字符对每个有至少一个键的子序列进行排序，得到另一组由前两个字符都相等的键组成的子序列。这一过程持续进行，直到所有的子序列长度都为1。在每个阶段，我们将子序列排序，使得一个所有字符串都在另一个子序列之前的子序列排在前面。当这一过程结束时，我们只需按合适的顺序接触所有的子序列。

棘手的部分是追踪所有子序列，使得它们能够在最后以合适的顺序输出，且我们能够迅速找到下一个长度大于1的子序列。以下是排列一个数组的方法示意图，在图8.10中有详细说明。

```java
static final int ALPHA = size of alphabet of digits;
/** Sort A[L..U] stably, ignoring the first k characters in each key. */
static void MSDradixSort(Record[] A, int L, int U, int k) {
    int[] countLess = new int[ALPHA+1];
    Sort A[L..U] stably by the kth character of each key, and for each
        digit, c, set countLess[c] to the number of records in A
        whose kth character comes before c in alphabetical order.
    for (int i = 0; i <= ALPHA; i += 1)
        if (countLess[i+1] - countLess[i] > 1)
            MSDradixSort(A, L + countLess[i],
                            L + countLess[i+1] - 1, k+1);
}
```

## 8.11 使用库

尽管我们在本章中历经艰辛学习了许多排序算法，然而，在多数程序中你甚至不用考虑自己写排序子程序！优秀的库为你提供了它们。Java标准库里存在java.util.Collections这一类型，其中只包含与Collections有关的实用程序的静态定义。对于排序，我们有

```java
/** Sort L stably into ascending order, as defined by C. L must
* be modifiable, but need not be expandable. */
public static <T> void sort (List<T> L, Comparator<? super T> c) { · · · }
/** Sort L into ascending order, as defined by the natural ordering
* of the elements. L must be modifiable, but need not be expandable. */
public static <T extends Comparable<T>> void sort (List<T> L) { · · · }
```

这两种方法实用了一种归并排序，可以保证在最坏情况下的$O(NlgN)$复杂度。基于这些定义，你并不需要编写自己的排序例程，除非需要排序的序列大得出奇（特别是当它需要外部排序的时候）。如果需要排序的是基本类型（如int），或者在你的应用里有必要挤出每一个多余的微秒（这很少见）。

## 8.12 选择

考虑寻找数组中间值的问题。中间值，一个在数组中比它小的元素和比它大的元素一样多的数值。找到该值的一种暴力解就是将数组排序，然后选择中间的元素（或者中间的元素之一，如果该数组有偶数个元素的话）。然而，我们可以做得更好。

该问题的通用版本就是选择：给定一个（通常是未排序的）元素序列和一个数字k，找到排好序的元素序列中的第k个元素。找中间值，最大值和最小值则是该问题的特殊情况。或许最简单有效的方法是如下所示的，对Hoare快速排序算法的简单改编。

```java
/** Assuming 0<=k<N, return a record of A whose key is kth smallest
* (k=0 gives the smallest, k=1, the next smallest, etc.). A may
* be permuted by the algorithm. */
Record select(Record[] A, int L, int U, int k) {
    Record T = some member of A[L..U];
    Permute A[L..U] and find p to establish the partitioning
        condition:
    if (p-L == k)
        return T;
    if (p-L < k)
        return select(A, p+1, U, k - p + L - 1);
    else
        return select(A, L, p-1, k);
}
```

其中的重点是当数组被快速排序划分时，数值T是第（p-L)小的元素，第（p-L)小的键被存储在A\[L..p-1\]中，且最大的键被存储在A\[p+1..U\]中。因此，如果$k \lt p - L$，第（p-L)小的键在A的左半边中，如果$k \gt p$，它一定是右半边中第（k-p+L-1）大的元素。

乐观地说，假设每个划分将数组分为两半，这里的循环控制代价（以比较数计量）为
$$C(1) = 0$$
$$C(N) = N + C(\lceil N/2\rceil)$$
其中$N = U - L + 1$。N来自于划分，而$C(\lceil N/2\rceil)$来自递归调用。它与快速排序和归并排序的循环的不同之处在于$C(...)$的系数为1而不是2。对于$N = 2_m$我们有
$$C(N) = 2^m + C(2^{m-1})$$
$$ = 2^m + 2^{m-1} + C(2^{m-2})$$
$$ = 2^{m+1} - 1 = N - 1$$
$$\in \Theta(N)$$
该算法，和快速排序一样，从概率上而言非常好。也存在可以保证线性界限的选择算法，不过我们将把该内容留给算法课。

## 练习

**8.1** 给定两个键集（即是说，任意键集内部都不包含重复的键），$S_0$和$S_1$，均表示为数组。假设你能够以“大于等于”的方式比较键，你会如何计算$S_0$和$S_1$的交集？需要多长时间？

**8.2** 给定一个较大的单词列表，你如何快速找到所有的字谜？（这里的字谜是列表中的一个单词，它可以通过重排它的各字母组成新的单词，比如dearth和thread）。

**8.3** 假设我们有一个含有N个记录的数组D。在不改动数组的情况下，我需要计算一个N个元素的数组，P，包含整数0到N-1的某种序列，使得序列D\[P\[0\]\], D\[P\[1\]\], ... ,D\[P\[N-1\]\]得以被稳定排序。给出一个通用的方法，可以适用于任何排序算法（不管该算法是否稳定），且不需要任何额外的存储空间（除了排序算法通常需要的空间）。

**8.4** 一个非常简单的拼写检查只需在移除掉单词的标点后在词典中查找。比较实用Java库中不同类型实现这一功能的方法：分别使用ArrayList，TreeSet和HashSet将单词按顺序存储。除了学习实用Java库以外并不需要太多的编程。

**8.5** 给定一系列数字，$\[x_i, x_i'\]$，每个数都满足$0 \le x_i \lt x_i' \le 1$。我想知道0和1之间所有没有被这些数涵盖的范围。即是说，如果输入为$\[0.25, 0.5\]$，输出则为$\[0.0, 0.25\]$和$\[0.5, 1.0\]$（不用在意终点）。请说明如何快速地实现这一点。