of {$slidecount} ½ {$title} ATZJG.NET {$author}

首页






排序
  • 插入排序
  • 谢尔排序
  • 堆排序
  • 归并排序
  • 快速排序
  • 间接排序
  • 桶排序
  • 外部排序


Haifeng Xu


(hfxu@yzu.edu.cn)

This slide is based on the book of Mark Allen Weiss
Data Structures and Algorithm Analysis in C++
张怀勇等译.

目录

预备知识

预备知识

为简单起见, 我们的例子中排序的对象是整数. 而且还假设整个排序工作能够在主存中完成.

不能在主存中完成而必须在磁盘或磁带上完成的排序叫作外部排序(external sorting).

种类 时间复杂度
插入排序 $O(N^2)$
谢尔排序(shellsort) $o(N^2)$
堆排序 $O(N\log N)$
快速排序 $O(N\log N)$

任何通用的排序算法均需要 $\Omega(N\log N)$ 次比较.

我们假设存在 “<” 和 “>” 操作符. 除赋值运算符外, 这种运算是仅有的允许对输入数据进行的操作. 在这些条件下的排序称为基于比较的排序(comparison-based sorting).

STL 中的排序算法

STL 中, 排序是通过使用函数模板 sort 来完成的.

void sort(Iterator begin, Iterator end);
void sort(Iterator begin, Iterator end, Comparator cmp);

sort 算法不能保证相等的项保持它们原始的次序. 如果这个很重要的话, 可以使用 stable_sort 来替代 sort.

sort(v.begin(), v.end());// 按递增排序
sort(v.begin(), v.end(), greater());// 按递减排序
sort(v.begin(), v.begin()+(v.end()-v.begin())/2);// 将容器的前半部分按递增排序

插入排序

插入排序

最简单的排序算法之一是插入排序(insertion sort).

插入排序由 $N-1$ 趟(pass)排序组成.

插入排序利用了这样一个事实: 位置 $0$ 到位置 $p$ 上的元素是已经排过序的.

每趟后的插入排序状态
p=0 34 8 64 51 32 21 移动的个数
p=1 8 34 64 51 32 21 1
p=2 8 34 64 51 32 21 0
p=3 8 34 51 64 32 21 1
p=4 8 32 34 51 64 21 3
p=5 8 21 32 34 51 64 4

我们的策略是: 在第 $p$ 趟, 我们将位置 $p$ 上的元素向左移动至它在前 $p+1$ 个元素中的正确位置上.(实际上是将那些比位置 $p$ 上的元素大往右挪动.)

插入排序的例程:

/** Fig.7-2
 * Simple insertion sort.
 */
template <typename Comparable>
void insertionSort( vector<Comparable> & a )
{
    int j;

    for( int p = 1; p < a.size( ); p++ )
    {
        Comparable tmp = a[p];// 保留第 p 个位置的数
        //int j;//放到外层比较好
        // 下面的 for 循环就是对每个阶段, 将从 0 到 p-1 个位置的元素
        // 如果满足条件 tmp < a[j-1], 则右移一位.
        // 如果前面的数小于等于 tmp, 则不动.
        for( j = p; j > 0 && tmp < a[j-1]; j-- )
            a[j] = a[j-1];
        a[j] = tmp;// 最后将 tmp 保留
    }
}

插入排序的 STL 实现

插入排序的 STL 实现

在 STL 中, 排序例程不采用由具有可比性的项所组成的数组作为单一的参数.

而是接收一对迭代器来代表在某范围内的起始和终止标志.

双参数: 一对迭代器, 假设所有的项都可以排序.

三参数: 有一个函数对象作为第三个参数.

分析

第一个观点最容易实现, 因为模板类型参数(例如, 泛型)对双参数排序来说都是 iterator:. 然而, Object 不是一个泛型参数. 我们可以通过编写一个辅助例程来解决这个问题. 如下面所示.

template <typename Iterator>
void insertionSort( const Iterator & begin, const Iterator & end )
{
    if( begin != end )
        insertionSortHelp( begin, end, *begin );// *begin 是 begin 所指的对象.
}

// 辅助例程, 它将 Object 作为另一个模板类型参数.
template <typename Iterator, typename Object>
void insertionSortHelp( const Iterator & begin, const Iterator & end,
                        const Object & obj )
{
    insertionSort( begin, end, less<Object>( ) );
}

这里的小窍门是在双参数排序中, *begin 具有类型 Object, 并且辅助例程具有所需要的第二个泛型的参数.

下面写三参数的排序, 需要声明 tmpObject 类型. 三参数排序只具有 IteratorComparator 是泛型类型. 因此我们不得不再次使用相同的技巧来得到一个四参数例程, 将一个 Object 类型的项作为第四个参数, 仅仅是为添加一个辅助的泛型类型.

下面的代码使用迭代器来取代使用数组索引, 使用 lessThan 函数对象类取代了对 operator< 的调用.

template <typename Iterator, typename Comparator>
void insertionSort( const Iterator & begin, const Iterator & end,
                    Comparator lessThan )
{
    if( begin != end )
        insertionSort( begin, end, lessThan, *begin );
}

template <typename Iterator, typename Comparator, typename Object>
void insertionSort( const Iterator & begin, const Iterator & end,
                    Comparator lessThan, const Object & obj )
{
    Iterator j;

    for( Iterator p = begin+1; p != end; ++p )
    {
        Object tmp = *p;
        for( j = p; j != begin && lessThan( tmp, *( j-1 ) ); --j )
            *j = *(j-1);
        *j = tmp;
    }
}

插入排序的分析

插入排序的分析

由于每一个嵌套循环都花费 $N$ 此迭代, 因此插入排序为 $O(N^2)$.

而且这个界是精确的, 因为以反序的输入可以达到该界. 对于 p 的每个值, Fig.7-2 的测试最多执行 p+1 次. 对所有的 p 求和, 得到

\[ \sum_{i=2}^{N}i=2+3+4+\cdots+N=\Theta(N^2). \]

另一方面, 如果输入数据已预先排序, 那么运行时间为 $O(N)$, 因为内层 for 循环的检测总是立即判定不成立而终止.

事实上, 如果输入几乎被排序, 那么插入排序将运行得很快.

插入排序的平均情形也是 $\Theta(N^2)$.

一些简单排序算法的下界

一些简单排序算法的下界

{\bf 定理 7.1.} 由 $N$ 个互异元素构成的数组, 其平均逆序数是 $N(N-1)/4$.

{\bf 证明.} 对于任意的元素的表 $L$, 考虑其反序表 $L_r$. 表 $L$ 和表 $L_r$ 的逆序总个数为 $C_N^2=N(N-1)/2$. 因此, 平均逆序数是 $N(N-1)/4$.

{\bf 定理 7.2.} 通过交换相邻元素进行排序的任何算法平均需要 $\Omega(N^2)$ 时间.

{\bf 证明.} 初始的平均逆序数是 $N(N-1)/4=\Omega(N^2)$, 而每次交换只减少一个逆序, 因此需要 $\Omega(N^2)$ 时间.

这个下界告诉我们, 为了使一个排序算法以亚二次(subquadratic)$o(N^2)$ 时间运行, 必须执行一些比较, 特别是要对相距较远的元素进行交换.

排序算法通过删除逆序得以继续进行, 而为了有效地进行, 还必须每次交换删除多个逆序.

谢尔排序

谢尔排序(Shellsort)

谢尔排序的发明者是 Donald Shell, 该算法是冲破二次时间屏障的第一批算法之一, 不过, 直到它最初被发现的若干年后才证明了它的亚二次时间界.

谢尔排序是通过比较相距一定间隔的元素来工作的, 各趟比较所用的距离随着算法的进行而减小, 直到只比较相邻元素的最后一趟排序为止. 因此, 谢尔排序有时也叫缩减增量排序(diminishing increment sort).

谢尔排序使用一个递减序列 $h_t, h_{t-1},\ldots,h_2,h_1$. 这里必须要求 $h_1=1$.

谢尔排序每趟之后的情况
Position 0 1 2 3 4 5 6 7 8 9 10 11 12
初始状态 81 94 11 96 $12$ 35 17 95 28 $58$ 41 75 15
5排序之后 35 17 11 28 12 41 75 15 96 58 81 94 95
3排序之后 28 12 11 35 15 41 58 17 94 75 81 96 95
1排序之后 11 12 15 17 28 35 41 58 75 81 94 95 96

在使用增量 $h_k$ 的一趟排序之后, 对于每一个 $i$, 我们有 $a[i]\leq a[i+h_k]$. 所有相隔 $h_k$ 的元素都被排序. 此时称文件是 $h_k$ 排序的($h_k$-sorted).

谢尔排序的一个重要性质

一个 $h_k$ 排序的文件, 在此后执行其他排序($h_{k-1}$排序)后仍然保持它的 $h_k$ 排序性.

注意: 这在某些情况下不一定成立, 比如上面的 $h2=3$ 排序之后三个数 17,94,75 并没有保持 $h_3=5$ 排序后的次序 17,75,94. 原因在于这两个间隔 5 和 3 使得下一次的区间有重叠. 证明(有问题): $h_k$ 排序后, 子列 $\{a[i+jh_k]\}$ 已经排好序. 接下来的 $h_{k-1}$ 排序的间距较 $h_k$ 短, 即使新的排序子列含有上一次的子列中的元素, 也不会改变上一次子列元素的相对位置. 因此得证.

注: 从上面的分析, 为了能够减少不必要的比较, 因此增量序列中的数尽可能不要有公因子. 后面将要介绍的 Hibbard 增量序列即是这样设计的.

一般来说, 如果前面的各趟排序的成果被后面的各趟排序给打乱, 则算法就没有意义了.

谢尔排序的本质

一趟 $h_k$ 排序的作用就是对 $h_k$ 个独立的子数组执行一次插入排序. 这个观察对于分析谢尔排序的运行时间很重要.

代码实现

这里使用 Shell 建议的序列: $h_t=[N/2]$, $h_k=[h_{k+1}/2]$. (我们称之为谢尔增量序列, 但是这个序列并不好.)

/**
 * Shellsort, using Shell's (poor) increments.
 */
template <typename Comparable>
void shellsort( vector<Comparable> & a )
{
    for( int gap = a.size( ) / 2; gap > 0; gap /= 2 )
        for( int i = gap; i < a.size( ); i++ )
        {
            Comparable tmp = a[ i ];
            int j = i;

            for( ; j >= gap && tmp < a[ j - gap ]; j -= gap )
                a[ j ] = a[ j - gap ];
            a[ j ] = tmp;
        }
}

我们看到最里层的 for 循环所做的正式插入排序.

谢尔排序的分析

谢尔排序的分析

定理7.3 使用谢尔增量时, 谢尔排序的最坏情形运行时间是 $\Theta(N^2)$.

证明:


Hibbard 增量序列

Hibbard 提出一个稍微不同的增量序列, 它在实践中和理论上都给出更好的结果. 它的增量序列是: \[ 1,3,7,\ldots,2^k-1. \]

注意相邻增量之间没有公因子. 我们来分析使用这个增量序列的谢尔排序最坏情形的运行时间.

定理7.4 使用 Hibbard 增量的谢尔排序的最坏情形运行时间为 $\Theta(N^{3/2})$.

证明: 我们证明上界, 需要用到堆垒数论(additive number theory)中某些众所周知的结果.

堆排序

堆排序

第六章提到, 优先队列可以用于以 $O(N\log N)$ 时间进行排序. 基于该思想的算法叫作 堆排序(heapsort).

该算法的主要问题在于, 它使用了一个附加的数组. 因此, 存储需求增加一倍. 在某些实例中这可能是一个问题. 注意, 将第二个数组复制回第一个数组的附加时间消耗只是 $O(N)$. 这不可能显著影响运行时间. 这个问题是空间的问题.

避免使用第二个数组的聪明的方法是利用这样的事实: 在每次 deleteMin 之后, 堆缩小了 1. 因此, 堆中最后的单元可以用来存放刚刚删去的元素.

使用这种策略, 在最后一次 deleteMin 后, 该数组将以递减顺序包含这些元素. 如果想将这些元素排成更典型的递增顺序, 那么可以改变堆序性质使得父亲的元素的值大于儿子的值. 这样就得到 max 堆. (STL 中默认的就是 max 堆.)

在我们的实现方法中将使用一个 max 堆.

例如, 考虑输入序列 31,41,59,26,53,58,97. 我们将得到下面的 max 堆.

如果使用的是二叉堆, 则具体过程如下:


这里的数据结构与二叉堆稍有不同.


/**
 * Standard heapsort.
 *  
 */
template <typename Comparable>
void heapsort( vector<Comparable> & a )
{
    for( int i = a.size( ) / 2; i >= 0; i-- )  /* buildHeap */
        percDown( a, i, a.size( ) );
    for( int j = a.size( ) - 1; j > 0; j-- )
    {
		//将 a[j]
        swap( a[ 0 ], a[ j ] );                /* deleteMax */
        percDown( a, 0, j );
    }
}

/**
 * Internal method for heapsort.
 * i is the index of an item in the heap.
 * Returns the index of the left child.
 */
inline int leftChild( int i )
{
    return 2 * i + 1;
}

/**
 * Internal method for heapsort that is used in deleteMax and buildHeap.
 * i is the position from which to percolate down.
 * n is the logical size of the binary heap.
 */
template <typename Comparable>
void percDown( vector<Comparable> & a, int i, int n )
{
    int child;
    Comparable tmp;

    for( tmp = a[ i ]; leftChild( i ) < n; i = child )
    {
        child = leftChild( i );
		
		//如果有右儿子且右儿子的值比左儿子的值更大, 则 child 指向右儿子.
		//总之 child 指向(a[i]的)两个儿子中的较大的那个.
        if( child != n - 1 && a[ child ] < a[ child + 1 ] )
            child++;
		
		// 如果父亲的值比 child 所指儿子的值来得小, 则较大的值上浮.
        if( tmp < a[ child ] )
            a[ i ] = a[ child ];
        else
            break; // 跳出 for 循环即可.
    }
    a[ i ] = tmp;// 将 tmp 值放到合适的位置.
}

归并排序

归并排序(mergeSort)

归并算法以 $O(N\log N)$ 最坏情形时间运行, 而所使用的比较次数几乎是最优的. 它是递归算法的一个很好的实例.

这个算法的基本操作是合并两个已排序的表. 该算法是经典的分治(divide-and-conquer)策略.

/**
 * Mergesort algorithm (driver).
 */
template <typename Comparable>
void mergeSort( vector<Comparable> & a )
{
    vector<Comparable> tmpArray( a.size( ) );

    mergeSort( a, tmpArray, 0, a.size( ) - 1 );
}

/**
 * Internal method that makes recursive calls.
 * a is an array of Comparable items.
 * tmpArray is an array to place the merged result.
 * left is the left-most index of the subarray.
 * right is the right-most index of the subarray.
 */
template <typename Comparable>
void mergeSort( vector<Comparable> & a,
            vector<Comparable> & tmpArray, int left, int right )
{
    if( left < right )
    {
        int center = ( left + right ) / 2;
        mergeSort( a, tmpArray, left, center );
        mergeSort( a, tmpArray, center + 1, right );
        merge( a, tmpArray, left, center + 1, right );
    }
}

上面调用中的 merge 例程在下面给出. 它的作用就是合并两个已排序的数组. 当然这里是将 a 数组的前部分与后部分进行合并排序. 这里 merge 例程使用了 mergeSort 例程中定义的临时数组 tmpArray.

这样做的原因是, 如果对 merge 的每个递归调用均局部声明一个临时数组, 那么在任一时刻就可能有 $\log N$ 个临时数组处在活动期.

严密的考察指出, 由于 mergemergeSort 的最后一行, 因此在任一时刻只需要一个临时数组活动, 而且这个临时数组可以在 mergeSort 驱动程序中建立.

/**
 * Internal method that merges two sorted halves of a subarray.
 * a is an array of Comparable items.
 * tmpArray is an array to place the merged result.
 * leftPos is the left-most index of the subarray.
 * rightPos is the index of the start of the second half.
 * rightEnd is the right-most index of the subarray.
 */
template <typename Comparable>
void merge( vector<Comparable> & a, vector<Comparable> & tmpArray,
            int leftPos, int rightPos, int rightEnd )
{
    int leftEnd = rightPos - 1;
    int tmpPos = leftPos;
    int numElements = rightEnd - leftPos + 1;

    // Main loop
    while( leftPos <= leftEnd && rightPos <= rightEnd )
        if( a[ leftPos ] <= a[ rightPos ] )
            tmpArray[ tmpPos++ ] = a[ leftPos++ ];
        else
            tmpArray[ tmpPos++ ] = a[ rightPos++ ];

    while( leftPos <= leftEnd )    // Copy rest of first half
        tmpArray[ tmpPos++ ] = a[ leftPos++ ];

    while( rightPos <= rightEnd )  // Copy rest of right half
        tmpArray[ tmpPos++ ] = a[ rightPos++ ];

    // Copy tmpArray back
    for( int i = 0; i < numElements; i++, rightEnd-- )
        a[ rightEnd ] = tmpArray[ rightEnd ];
}

注意: 这里最后的由数组 tmpArray 拷贝到数组 a, 采用的是倒序拷贝, 也就是使用了指标 rightEnd. 原因是这个指标在前面并未发生改变, 而 leftPosrightPos 都发生了变化.

归并排序的分析

归并排序的分析

假设要排序的对象的个数是 $N$. 这里不妨假设 $N=2^m$.

\[ \begin{cases} T(1)=1\\ T(N)=2T(N/2)+N \end{cases} \]


方法一

$N$ 去除递推关系的两边.

\[ \frac{T(N)}{N}=\frac{T(N/2)}{N/2}+1 \]

由于该方程对 $N=2^k$ 都成立, 因此有

\[ \begin{cases} \frac{T(N/2)}{N/2}&=&\frac{T(N/4)}{N/4}+1\\ \frac{T(N/4)}{N/4}&=&\frac{T(N/8)}{N/8}+1\\ &\vdots&\\ \frac{T(2)}{2}&=&\frac{T(1)}{1}+1 \end{cases} \]

将所有这些方程相加, 注意有 $m=\log N$ 个式子. 得到 \[ \frac{T(N)}{N}=\frac{T(1)}{1}+\log N, \]

从而, \[ T(N)=N(1+\log N)=N\log N+N=O(N\log N). \]


方法二

直接代入递推关系.

首先, \[ T(N)=2T(N/2)+N. \]

$T(N/2)=2T(N/4)+N/2$ 代入上式, 得 \[ T(N)=2(2T(N/4)+N/2)+N=4T(N/4)+2N. \]

再将 $T(N/4)=2T(N/8)+N/4$ 代入上式, 得 \[ T(N)=4(2T(N/8)+N/4)+2N=8T(N/8)+3N. \]

依次类推(可用归纳法证明), 可得 \[ T(N)=2^k T(N/2^k)+kN. \]

$k=m=\log N$, 即有 \[ T(N)=NT(1)+N\log N=N+N\log N=O(N\log N). \]

上面虽然仅对 $N=2^m$ 进行了计算证明, 但显然可以对 $2^m < N < 2^{m+1}$ 的情形进行分析. 得出同样的结论.


归并排序的优缺点

虽然归并排序的运行时间是 $O(N\log N)$, 但是它有一个严重的问题, 就是合并两个排好序的表, 需要额外的线性内存.

在整个算法中还要花费一些额外的操作, 比如将数据复制到临时数组再复制回来, 其结果是严重降低了排序的速度.

这种复制可以通过在递归的交替层面上审慎地交换 $a$$tmpArray$ 的角色加以避免.

归并排序的一种变形也可以非递归地实现(见练习 7.16).

下一节描述的快速排序算法较好地平衡了这两者, 而且也是 C++ 库中普遍使用的排序例程.

快速排序

快速排序(quicksort)

顾名思义, 快速排序是在实践中最快的已知排序算法, 它的平均运行时间是 $O(N\log N)$.

该算法之所以特别快, 主要在于非常精炼和高度优化的内部循环.

它的最坏情形的性能是 $O(N^2)$, 但稍加努力就可避免这种情形.

通过将堆排序与快速排序结合起来, 就可以在堆排序的 $O(N\log N)$ 最坏运行时间下, 得到对几乎所有输入的最快运行时间. (练习7.27描述了这一方法.)

快速排序的步骤

快速排序的步骤

虽然多年来快速排序算法曾被认为是理论上高度优化而在实践中不可能正确编程的一种算法, 但是该算法简单易懂而且不难证明.

像归并排序一样, 快速排序也是一种分治的递归算法.

将数组 $S$ 排序的基本算法由下列简单的四步组成:

  1. 如果 $S$ 中元素个数是 $0$ 或 $1$, 则返回.
  2. 取 $S$ 中任一元素 $v$, 称之为枢纽元(pivot).
  3. 将 $S-\{v\}$ 划分成两个不相交的集合: $S_1=\{x\in S-\{v\}\mid x\leq v\}$ 和 $S_2=\{x\in S-\{v\}\mid x\geq v\}$.
  4. 返回 $\{$ quicksort($S_1$), 后跟 $v$, 继而 quicksort($S_2$) $\}$.

由于在那些等于枢纽元的元素的处理上, 第三步划分的描述不是惟一的, 因此这就成了设计决策. 一部分好的实现方法是将这种情形尽可能有效地处理.

直观地看, 我们希望把等于枢纽元的大约一半的键分到 $S_1$ 中, 而另外的一半分到 $S_2$ 中, 很像我们希望二叉查找树保持平衡的情形.

快速排序的各步演示示例

快速排序更快的原因

快速排序更快的原因

如同归并排序那样, 快速排序递归地解决两个子问题并需要线性的附加工作(第3步). 不过与归并排序不同, 这两个子问题并不保证具有相等的大小, 这是个潜在的隐患.

快速排序更快的原因在于, 第3步划分成两组实际上是在适当的位置进行并且非常有效, 它的高效不仅弥补了大小不等的递归调用的不足而且还超过了它.

选取枢纽元

选取枢纽元

虽然上面描述的算法无论选择哪个元素作为枢纽元都能完成排序工作, 但是有些选择显然更优.

一种错误的方法

将第一个元素用作枢纽元.

选取前两个互异的键中的较大者作为枢纽元, 也是不好的策略.

一种安全的做法

随机选取枢纽元.

三数中值分割法

由 $N$ 个数组成的数组, 其中值是第 $\lceil N/2\rceil$ 个最大的数. 枢纽元的最好的选择是取数组的中值.

可是这很难算出, 并且会明显减慢快速排序的速度.

这里的策略是通过随机选取三个元素并用它们的中值作为枢纽元. 事实上, 随机性并没有多大的帮助, 而且如上面所述, 代价昂贵.

因此一般的做法是使用 左端右端中心位置 上的三个元素的中值作为枢纽元.

例如, 输入是 $8,1,4,9,6,3,5,2,7,0$.

于是枢纽元 $v=6$.

使用三数中值分割法消除了预排序输入的不好情形, 并且减少了快速排序大约 $14\%$ 的比较次数.

分割策略

分割策略

第一步将枢纽元与最后的元素交换, 使得枢纽元离开要被分割的数据段.

暂时先假设所有的元素互异.

在分割阶段要做的就是把所有比枢纽元小的元素移到数组的左边, 而把所有比枢纽元大的元素移动到数组的右边.



如何处理那些等于枢纽元的元素

我们必须考虑的一个重要的细节是如何处理那些等于枢纽元的元素.

问题在于, 当 $i$ 遇到一个等于枢纽元的元素时是否应该停止以及当 $j$ 遇到一个等于枢纽元的元素时是否应该停止.

直观地看, $i$ 和 $j$ 应该做相同的工作, 因为否则分割将出现偏向一方的倾向. 例如, 如果 $i$ 停止而 $j$ 不停止, 那么所有等于枢纽元的元素都将分到 $S_2$ 中.


如果 $i,j$ 都停止

考虑特殊情形, 比如数组中所有的元素都相等, 此时 pivot 当然有很多重复的.

因此, 看上去没多少意义的交换, 其正面效果确是当 $i,j$ 在中间交错时, 这种分割建立了两个几乎相等的子数组. 归并排序的分析告诉我们, 此时总的运行时间为 $O(N\log N)$.


如果 $i,j$ 都不停止

也就是说, 当 $i,j$ 所指的元素等于 pivot 时, $i,j$ 继续移动, 不进行交换的操作. 那么就应该有相应的程序防止 $i$ 和 $j$ 越出数组的端点.

如果所有的元素几乎相同, 比如 $a,a,a,\ldots,a,0,a$. 最后一个是 pivot, $i$ 一开始指向第一个 $a$, $j$ 指向 $0$, 所以 $j$ 停止. 但 $i$ 不停止继续右移(注意此时不会交换 $a$ 和 $0$). 然后都是 $a$, $j$ 不动, $i$ 一直右移, 因此要防止它们越出数组的端点. 直到 $i$ 指向最后一个 $a$ 才停止. 这样 $S_1=S$, 而 $S_2=\emptyset$.

总结

进行不必要的交换建立两个均衡的子数组比蛮干冒险得到两个不均衡的子数组要好.

均衡的要求是因为我们这里使用递归算法. 因此, 如果 $i$ 和 $j$ 遇到等于枢纽元的元素时, 那么 $i,j$ 就要停止, 然后交换它们所指的元素.

小数组

小数组

对于很小的数组($N\leq 20$), 快速排序不如插入排序好.

而且, 因为快速排序是递归的, 所以这样的情形经常发生.

解决办法

对于小的数组不使用快速排序, 而是使用插入排序这样的对小数组更有效的排序算法.

使用这种策略实际上可以节省大约 $15%$ 的运行时间, 相对于自始至终使用快速排序而言.

一种好的截止范围是 $N=10$, 当然也可以令 $N\in[5,20]$.

这种做法也避免了一些有害的退化情形, 比如当数组元素只有 2 个时, 取三数的中值就不可行了.

实际的快速排序例程

实际的快速排序例程

/** Fig7-14
 * Quicksort algorithm (driver).
 */
template <typename Comparable>
void quicksort( vector<Comparable> & a )
{
    quicksort( a, 0, a.size( ) - 1 );
}
/** Fig7-15
 * Return median of left, center, and right.
 * Order these and hide the pivot.
 */
template <typename Comparable>
const Comparable & median3( vector<Comparable> & a, int left, int right )
{
    int center = ( left + right ) / 2;
    if( a[ center ] < a[ left ] )
        swap( a[ left ], a[ center ] );
    if( a[ right ] < a[ left ] )
        swap( a[ left ], a[ right ] );
    if( a[ right ] < a[ center ] )
        swap( a[ center ], a[ right ] );

        // Place pivot at position right - 1
    swap( a[ center ], a[ right - 1 ] );
    return a[ right - 1 ];
}

这里倒数第二行, 是将枢纽元(a[center])放在 right-1 的位置. 原因是

快速排序的主例程

它包括分割和递归调用.

/**
 * Internal quicksort method that makes recursive calls.
 * Uses median-of-three partitioning and a cutoff of 10.
 * a is an array of Comparable items.
 * left is the left-most index of the subarray.
 * right is the right-most index of the subarray.
 */
template <typename Comparable>
void quicksort( vector<Comparable> & a, int left, int right )
{
    if( left + 10 <= right )
    {
        Comparable pivot = median3( a, left, right );

            // Begin partitioning
        int i = left, j = right - 1;
        for( ; ; )
        {
		//以下是算法的两个内部 while 循环, 速度非常快
            while( a[ ++i ] < pivot ) { } //Note: 初始 i=left+1
            while( pivot < a[ --j ] ) { } //Note: 初始 j=right-2
			//当 i与j 停止后, 只要 i < j, 就交换元素
            if( i < j )
                swap( a[ i ], a[ j ] );
            else
                break;
        }

        swap( a[ i ], a[ right - 1 ] );  // Restore pivot

        quicksort( a, left, i - 1 );     // Sort small elements
        quicksort( a, i + 1, right );    // Sort large elements
    }
    else  // Do an insertion sort on the subarray
        insertionSort( a, left, right );
}

程序中 i,j 分别初始化为 i=left+1, j=right-2 的原因是利用了 a[left], a[center]a[right-1] 已经排好序这一事实.

如果将程序中相应部分替换为如下的代码, 则不一定能正确运行. 比如当 a[i]=a[j]=pivot 时, 会产生无穷循环.

    int i = left + 1, j = right - 2;
    for( ; ; )
    {
        while( a[ i ] < pivot ) i++;//问题出在当 a[i]>=pivot 时, i 不自增加, 从而导致无穷循环.
        while( pivot < a[ j ] ) j--;
        if( i < j )
            swap( a[ i ], a[ j ] );
        else
            break;
    }

快速排序的分析

快速排序的分析

正如归并排序那样, 快速排序也是递归的, 因此, 对于它的分析也需要求解也给递推公式.

假设有一个随机的枢纽元(不用三数中值分割法), 并对一些小的文件不设截止范围. 和归并排序一样, 取 $T(0)=T(1)=1$. 快速排序的运行时间等于两个递归调用的运行时间加上花费在分割上的线性时间(枢纽元的选取仅花费常数时间). 从而我们得到基本的快速排序关系:

\[ T(N)=T(i)+T(N-i-1)+cN. \]

其中 $i=|S_1|$ 是指集合 $S_1$ 中元素的个数. 我们考虑三种情况.

最坏情形的分析

枢纽元始终是最小元素. 此时 $i=0$, 如果我们忽略无关紧要的 $T(0)=1$, 那么递推关系为 \[ T(N)=T(N-1)+cN,\quad N > 1. \]

于是我们有 \[ \begin{cases} T(N-1)=T(N-2)+c(N-1),\\ T(N-2)=T(N-3)+c(N-2),\\ \vdots\\ T(2)=T(1)+c(2),\\ \end{cases} \] 将这些方程相加, 得 \[ T(N)=T(1)+c\sum_{i=2}^{N}i=O(N^2). \]

这正是前面声称的情形.

最佳情形的分析

在最佳情形下, 枢纽元正好位于中间. 为了简化数学推导, 我们假设两个子数组恰好各位原数组的一半大小. 虽然这会该处稍微过高的估计, 但是由于我们只关心 $O()$, 因此结果还是可以接受的.

\[ T(N)=2T(\frac{N}{2})=cN, \]

上式两边分别除以 $N$, 得 \[ \frac{T(N)}{N}=\frac{T(\frac{N}{2})}{\frac{N}{2}}+c . \]

于是, 反复使用这个方程, 有

\[ \begin{aligned} \frac{T(\frac{N}{2})}{\frac{N}{2}}=\frac{T(\frac{N}{4})}{\frac{N}{4}}+c,\\ \frac{T(\frac{N}{4})}{\frac{N}{4}}=\frac{T(\frac{N}{8})}{\frac{N}{8}}+c,\\ \frac{T(\frac{N}{8})}{\frac{N}{8}}=\frac{T(\frac{N}{16})}{\frac{N}{16}}+c,\\ \vdots\\ \frac{T(2)}{2}=\frac{T(1)}{1}+c \end{aligned} \]

将所有这些方程相加, 得到 \[ \frac{T(N)}{N}=\frac{T(1)}{1}+c\log N, \]

由此得到 \[ T(N)=cN\log N+NT(1)=cN\log N+N=O(N\log N). \]

平均情形的分析

一般来说, 这是最困难的分析部分. 对于平均情形, 我们假设对于 $S_1$, 每一个文件的大小都是等可能的, 因此每个大小均有概率 $\frac{1}{N}$. 这个假设对于这里的枢纽元选取和分割方法实际上是合理的, 不过, 对于某些其他情况可能并不合理. 那些不保持子数组随机性的分割方法不能使用这种分析方法. 有趣的是, 这些方法看来导致程序在实际运行中花费更长的时间.

由该假设可知, $T(i)$(从而 $T(N-i-1)$) 的平均值为 \[ \frac{1}{N}\sum_{j=0}^{N-1}T(j). \]

于是快速排序的平均运行时间为: \[ T(N)=\frac{2}{N}\biggl[\sum_{j=0}^{N-1}T(j)\biggr]+cN \]

快速排序算法的平均运行时间

快速排序算法的平均运行时间

记 $T(i)$ 为对 $i$ 个元素的集合的运行时间. $T(1)$=1. 于是其平均运行时间为

\[ T(i)=\frac{1}{N}\sum_{j=0}^{N-1}T(j). \]

根据上面的分析, 快速选择的运行时间满足下面的递推关系:

\[ T(N)=\frac{2}{N}\biggl[\sum_{j=0}^{N-1}T(j)\biggr]+cN. \]

两边乘以 $N$, 得 \[ NT(N)=2\biggl[\sum_{j=0}^{N-1}T(j)\biggr]+cN^2. \]

从而也有 \[ (N-1)T(N-1)=2\biggl[\sum_{j=0}^{N-2}T(j)\biggr]+c(N-1)^2. \]

两式相减, 得 \[ NT(N)-(N-1)T(N-1)=2T(N-1)+2c(2N-1). \]

化简得 \[ NT(N)=(N+1)T(N-1)+2cN-c \]

舍去其中的常数项 c, 得 \[ \frac{T(N)}{N+1}=\frac{T(N-1)}{N}+\frac{2c}{N+1}. \]

从而有 \[ \begin{cases} \frac{T(N-1)}{N}=\frac{T(N-2)}{N-1}+\frac{2c}{N}.\\ \frac{T(N-2)}{N-1}=\frac{T(N-3)}{N-2}+\frac{2c}{N-1}.\\ \vdots\\ \frac{T(2)}{3}=\frac{T(1)}{2}+\frac{2c}{3}.\\ \end{cases} \]

将这 $N-1$ 个式子相加, 得 \[ \frac{T(N)}{N+1}=\frac{T(1)}{2}+2c\sum_{i=3}^{N+1}\frac{1}{i}. \]

这个和大约等于 $\log(N+1)+\gamma-\frac{3}{2}$, 其中 $\gamma$ 称为欧拉常数(Euler's constant).

于是 \[ \frac{T(N)}{N+1}=O(\log N), \] 从而 \[ T(N)=O(N\log N). \]

选择问题的线性期望时间算法

选择问题的线性期望时间算法

选择问题(selection problem)是指: 在集合 $S$ 中查找第 $k$ 个最大(或最小)的元素.

基于快速排序算法的快速选择(quickselect)的基本步骤如下:

  1. 如果 $|S|=1$, 那么 $k=1$, 并将 $S$ 中的元素作为结果返回. 如果正在使用小数组的截止方法, 并且 $|S|\leq CUTOFF$, 则将 $S$ 排序并返回第 $k$ 个最小元.
  2. 选取一个枢纽元 $v\in S$.
  3. 将集合 $S-\{v\}$ 分割成 $S_1$ 和 $S_2$, 就像快速排序中所做的那样.
    • 如果 $k\leq |S_1|$, 那么第 $k$ 个最小元必然在 $S_1$ 中. 在这种情况下, 返回 quickselect($S_1,k$).
    • 如果 $k=1+|S_1|$, 那么枢纽元就是第 $k$ 个最小元;
    • 否则, 第 $k$ 个最小元就在 $S_2$ 中, 它是 $S_2$ 中的第 $(k-|S_1|-1)$ 个最小元. 我们进行一次递归调用并返回 quickselect($S_2,k-|S_1|-1$).

与快速排序对比, 快速选择只进行了一次递归调用而不是两次. 快速选择的最坏情形和快速排序相同, 也是 $O(N^2)$. 直观看来, 这是因为快速排序的最坏情形发生在 $S_1$ 和 $S_2$ 有一个是空的时候; 于是, 快速选择也就不是真的节省一次递归调用.

但可以证明, 快速选择的平均运行时间是 $O(N)$. 具体分析类似于快速排序的分析.

记 $T(i)$ 为对 $i$ 个元素的集合的运行时间. $T(1)$=1. 于是其平均运行时间为

\[ T(i)=\frac{1}{N}\sum_{j=0}^{N-1}T(j). \]

根据上面的分析, 快速选择的运行时间满足下面的递推关系:

\[ T(N)=\frac{1}{N}\sum_{j=0}^{N-1}T(j)+cN. \]

两边乘以 $N$, 得 \[ NT(N)=\sum_{j=0}^{N-1}T(j)+cN^2. \]

从而也有 \[ (N-1)T(N-1)=\sum_{j=0}^{N-2}T(j)+c(N-1)^2. \]

两式相减, 得 \[ NT(N)-(N-1)T(N-1)=T(N-1)+c(2N-1). \]

舍去其中的常数项 c, 整理得 \[ T(N)=T(N-1)+2c. \]

从而有 \[ \begin{cases} T(N)=T(N-1)+2c\\ T(N-1)=T(N-2)+2c\\ \vdots\\ T(2)=T(1)+2c\\ \end{cases} \]

将这 $N-1$ 个式子相加, 得 \[ T(N)=T(1)+2c(N-1)=O(N). \]

间接排序

间接排序

函数模板直接应用快速排序和谢尔排序的算法时,

一般来说, 这个问题的解决方案很简单: 生成一个指向 Comparable 的指针数组, 然后重新排列这些指针.

一旦确定了元素应该在的位置, 就可以直接将该元素放在相应的位置上, 而不必进行过多的中间复制操作. 这需要使用称为 中间置换(in-situ permutation) 的算法. 用 C++ 实现的时候需要一些新的语法.

具体步骤

例子:

分析

该算法有一个潜在的严重问题. 使用 copy 导致了双倍的空间需求. 假设 $N$ 很大(否则就直接使用插入排序)并且Comparable对象也很大(否则就不必使用指针来实现排序了), 这样, 我们有理由相信这个操作几乎是运行在接近计算机内存的极限范围内. 虽然我们可以期望使用指针的一个额外的vector向量, 但是不会寄希望于使用Comparable类型的额外vector向量. 因此, 我们需要在不使用额外数组的情况下将数组 a进行重新排列.

随之而来的第二个问题是使用copy时, 总计使用了 $2N$ 次的 Comparable 对象复制. 虽然这相对于原始的算法而言已经是很大的改进了, 我们还是可以对该算法进行进一步的改进. 特别的, 我们将不会用到超过 $3N/2$ 次的 Comparable 对象复制; 并且, 对于所有的输入, 只需要使用比 $N$ 稍多一点的复制. 这不仅会节省空间, 也会节省时间.

阐述改进的做法

为说明我们的意图, 首先从 i=2 开始. 由于p[2]指向a[4], 可知, 需要将 a[4] 移动到 a[2]. 为此, 需要先将a[2]存储到一个临时对象中, 比如tmp.

然后将a[4]复制到a[2].

a[4]已经复制到a[2]后, 该位置就可以放置a[3]了, 因为p[4]-->a[3].

类似的, 对于上面数组aindex=3, 寻找数组p中对应位置的元素(指针)所指向的对象. 即p[3]-->a[2], 于是将a[2]放置a[3]中. 但是a[2]在一开始已经被覆盖, 它的值存储在tmp中, 因此

总结

我们这里重新排列了三个元素, 却仅仅使用了四次Comparable 对象复制和一个额外的Comparable 对象tmp的存储空间. 事实上, 之前在插入排序中我们已经使用了这个方法(先将 a[i]保存到临时对象tmp中, 然后依次进行“移动”a[j]=a[j-1]). 和插入排序不同的是, 这里不是一个一个依次访问元素, 而是使用数组p来引导重新排列. 如果将这种操作称为滑动算法, 那么在二叉堆的insert操作中也使用了类似的滑动算法.

复杂度分析

重新排列长为 $L$($ >1$) 的数组需要用到 $L+1$ 次Comparable类型的对象复制. (当 $L=1$ 时不需要复制.)

对给定 $N$ 个元素的数组, 如上面的例子所示, 可以分成若干个组. 例如长度为 2 的{a[0],a[1]}, 长度为3的{a[2],a[3],a[4]}. 每个组需要通过一个for循环进行重新排列. 若记 $C_L$ 为长度为 $L$ 的循环所需Comparable对象复制的次数, $m_L$ 是长度为 $L$ 的循环的个数. 则总的Comparable对象复制的次数 $M$ 为 \[ M=m_2 C_2+m_3 C_3+\cdots+m_N C_N \] 书上写的是 \[ M=N-C_1+(C_2+C_3+\cdots+C_N). \]

大型对象数组的间接排序

大型对象数组的间接排序代码

#ifndef POINTER_H
#define POINTER_H

template<typename Comparable>
class Pointer
{
    public:
        /** Default constructor */
        Pointer(Comparable *rhs=NULL): pointee(rhs){}

        /** Default destructor */
        ~Pointer(){}

		//重载 operator<
        bool operator<(const Pointer & rhs) const
        {//这里比较的是两个Comparable类型
            return *pointee < *rhs.pointee;
        }
		
		//此方法定义了从 Pointer<Comparable> 到 Comparable * 类型的转换
        operator Comparable * () const
        {
            return pointee;
        }

    protected:

    private:
        Comparable *pointee;
};

#endif // POINTER_H

template<typename Comparable>
void largeObjectSort(vector<Comparable> & a)
{
    vector<Pointer<Comparable> > p(a.size());
    int i,j, nextj;

    for(i=0; i < a.size(); i++)
        p[i]=&a[i];

    quicksort(p);

    //Shuffle items in place
    for(i=0; i<a.size(); i++)
    {
        if(p[i]!=&a[i])
        {
            Comparable tmp=a[i];
            for(j=i; p[j]!=&a[i]; j=nextj)
            {
                nextj=p[j]-&a[0];//p[j]所指向的对象在a数组中的索引.
                a[j]=*p[j];
                p[j]=&a[j];
            }
            a[j]=tmp;
            p[j]=&a[j];
        }
    }
}

指针之间的减法是合法的. 如果 p1p2 指向同一数组中的两个元素, 那么 p1-p2 是它们之间的距离, 这是一个int 类型数. 因此 p[j]-&a[0]p[j]所指向的对象在a数组中的索引.

智能指针

智能指针

前面提到我们需要对指针数组p进行排序, 这里的排序并不是指针所存储的地址的排序, 而是指针所指向对象的排序. 如果使用 vector<Comparable *> 才存储这个指针数组p, 然后调用 quicksort(p), 那么quicksort 实际上是在对指针中存储的地址构成的向量在排序. 这个排序对于实际所指向的对象数组a的排序起不到任何作用. 为此需要建立智能指针类(smart pointer class) Pointer.

End






Thanks very much!

This slide is based on Jeffrey D. Ullman's work, which can be download from his website.