This slide is based on the book of Mark Allen Weiss
张怀勇等译.
为简单起见, 我们的例子中排序的对象是整数. 而且还假设整个排序工作能够在主存中完成.
不能在主存中完成而必须在磁盘或磁带上完成的排序叫作
种类 | 时间复杂度 |
---|---|
插入排序 | |
谢尔排序(shellsort) | |
堆排序 | |
快速排序 |
任何通用的排序算法均需要
我们假设存在 “<” 和 “>” 操作符. 除赋值运算符外, 这种运算是仅有的允许对输入数据进行的操作. 在这些条件下的排序称为
在
void sort(Iterator begin, Iterator end); void sort(Iterator begin, Iterator end, Comparator cmp);
sort(v.begin(), v.end());// 按递增排序 sort(v.begin(), v.end(), greater());// 按递减排序 sort(v.begin(), v.begin()+(v.end()-v.begin())/2);// 将容器的前半部分按递增排序
最简单的排序算法之一是
插入排序由
插入排序利用了这样一个事实: 位置
p=0 | 34 | 8 | 64 | 51 | 32 | 21 | 移动的个数 |
---|---|---|---|---|---|---|---|
p=1 | 64 | 51 | 32 | 21 | 1 | ||
p=2 | 51 | 32 | 21 | 0 | |||
p=3 | 32 | 21 | 1 | ||||
p=4 | 21 | 3 | |||||
p=5 | 4 |
我们的策略是: 在第
插入排序的例程:
/** 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 中, 排序例程不采用由具有可比性的项所组成的数组作为单一的参数.
而是接收一对迭代器来代表在某范围内的起始和终止标志.
双参数: 一对迭代器, 假设所有的项都可以排序.
三参数: 有一个函数对象作为第三个参数.
第一个观点最容易实现, 因为模板类型参数(例如, 泛型)对双参数排序来说都是
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>( ) ); }
这里的小窍门是在双参数排序中,
下面写三参数的排序, 需要声明
下面的代码使用迭代器来取代使用数组索引, 使用
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; } }
由于每一个嵌套循环都花费
而且这个界是精确的, 因为以反序的输入可以达到该界. 对于
\[ \sum_{i=2}^{N}i=2+3+4+\cdots+N=\Theta(N^2). \]
另一方面, 如果输入数据已预先排序, 那么运行时间为
事实上, 如果输入几乎被排序, 那么插入排序将运行得很快.
插入排序的平均情形也是
{\bf 定理 7.1.} 由 $N$ 个互异元素构成的数组, 其平均逆序数是
{\bf 证明.} 对于任意的元素的表
{\bf 定理 7.2.} 通过交换相邻元素进行排序的任何算法平均需要
{\bf 证明.} 初始的平均逆序数是
这个下界告诉我们, 为了使一个排序算法以
排序算法通过删除逆序得以继续进行, 而为了有效地进行, 还必须每次交换删除多个逆序.
谢尔排序的发明者是 Donald Shell, 该算法是冲破二次时间屏障的第一批算法之一, 不过, 直到它最初被发现的若干年后才证明了它的亚二次时间界.
谢尔排序是通过比较相距一定间隔的元素来工作的, 各趟比较所用的距离随着算法的进行而减小, 直到只比较相邻元素的最后一趟排序为止. 因此, 谢尔排序有时也叫
谢尔排序使用一个递减序列
Position | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
初始状态 | 11 | 96 | $12$ | 95 | 28 | $58$ | 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 |
在使用增量
一个
一般来说, 如果前面的各趟排序的成果被后面的各趟排序给打乱, 则算法就没有意义了.
一趟
这里使用 Shell 建议的序列:
/** * 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; } }
我们看到最里层的
Hibbard 提出一个稍微不同的增量序列, 它在实践中和理论上都给出更好的结果. 它的增量序列是: \[ 1,3,7,\ldots,2^k-1. \]
注意相邻增量之间没有公因子. 我们来分析使用这个增量序列的谢尔排序最坏情形的运行时间.
第六章提到, 优先队列可以用于以
该算法的主要问题在于, 它使用了一个附加的数组. 因此, 存储需求增加一倍. 在某些实例中这可能是一个问题. 注意, 将第二个数组复制回第一个数组的附加时间消耗只是
避免使用第二个数组的聪明的方法是利用这样的事实: 在每次
使用这种策略, 在最后一次
在我们的实现方法中将使用一个 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 值放到合适的位置. }
归并算法以
这个算法的基本操作是合并两个已排序的表. 该算法是经典的分治(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 ); } }
上面调用中的
这样做的原因是, 如果对
严密的考察指出, 由于
/** * 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 ]; }
注意: 这里最后的由数组
假设要排序的对象的个数是
\[ \begin{cases} T(1)=1\\ T(N)=2T(N/2)+N \end{cases} \]
用
\[ \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} \]
将所有这些方程相加, 注意有
从而, \[ T(N)=N(1+\log N)=N\log N+N=O(N\log N). \]
直接代入递推关系.
首先, \[ T(N)=2T(N/2)+N. \]
将
再将
依次类推(可用归纳法证明), 可得 \[ T(N)=2^k T(N/2^k)+kN. \]
令
上面虽然仅对
虽然归并排序的运行时间是
在整个算法中还要花费一些额外的操作, 比如将数据复制到临时数组再复制回来, 其结果是严重降低了排序的速度.
这种复制可以通过在递归的交替层面上审慎地交换
归并排序的一种变形也可以非递归地实现(见练习 7.16).
下一节描述的快速排序算法较好地平衡了这两者, 而且也是 C++ 库中普遍使用的排序例程.
顾名思义, 快速排序是在实践中最快的已知排序算法, 它的平均运行时间是
该算法之所以特别快, 主要在于非常精炼和高度优化的内部循环.
它的最坏情形的性能是
通过将堆排序与快速排序结合起来, 就可以在堆排序的
虽然多年来快速排序算法曾被认为是理论上高度优化而在实践中不可能正确编程的一种算法, 但是该算法简单易懂而且不难证明.
像归并排序一样, 快速排序也是一种分治的递归算法.
将数组 $S$ 排序的基本算法由下列简单的四步组成:
由于在那些等于枢纽元的元素的处理上, 第三步划分的描述不是惟一的, 因此这就成了设计决策. 一部分好的实现方法是将这种情形尽可能有效地处理.
直观地看, 我们希望把等于枢纽元的大约一半的键分到 $S_1$ 中, 而另外的一半分到 $S_2$ 中, 很像我们希望二叉查找树保持平衡的情形.
如同归并排序那样, 快速排序递归地解决两个子问题并需要线性的附加工作(第3步). 不过与归并排序不同, 这两个子问题并不保证具有相等的大小, 这是个潜在的隐患.
快速排序更快的原因在于, 第3步划分成两组实际上是在适当的位置进行并且非常有效, 它的高效不仅弥补了大小不等的递归调用的不足而且还超过了它.
虽然上面描述的算法无论选择哪个元素作为枢纽元都能完成排序工作, 但是有些选择显然更优.
将第一个元素用作枢纽元.
选取前两个互异的键中的较大者作为枢纽元, 也是不好的策略.
随机选取枢纽元.
由 $N$ 个数组成的数组, 其中值是第
可是这很难算出, 并且会明显减慢快速排序的速度.
这里的策略是通过随机选取三个元素并用它们的中值作为枢纽元. 事实上, 随机性并没有多大的帮助, 而且如上面所述, 代价昂贵.
因此一般的做法是使用
例如, 输入是
于是枢纽元 $v=6$.
使用三数中值分割法消除了预排序输入的不好情形, 并且减少了快速排序大约 $14\%$ 的比较次数.
第一步将枢纽元与最后的元素交换, 使得枢纽元离开要被分割的数据段.
暂时先假设所有的元素互异.
在分割阶段要做的就是把所有比枢纽元小的元素移到数组的左边, 而把所有比枢纽元大的元素移动到数组的右边.
我们必须考虑的一个重要的细节是如何处理那些等于枢纽元的元素.
问题在于, 当 $i$ 遇到一个等于枢纽元的元素时是否应该停止以及当 $j$ 遇到一个等于枢纽元的元素时是否应该停止.
直观地看, $i$ 和 $j$ 应该做相同的工作, 因为否则分割将出现偏向一方的倾向. 例如, 如果 $i$ 停止而 $j$ 不停止, 那么所有等于枢纽元的元素都将分到 $S_2$ 中.
考虑特殊情形, 比如数组中所有的元素都相等, 此时 pivot 当然有很多重复的.
因此, 看上去没多少意义的交换, 其正面效果确是当 $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 ]; }
这里倒数第二行, 是将枢纽元(
它包括分割和递归调用.
/** * 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 ); }
程序中
如果将程序中相应部分替换为如下的代码, 则不一定能正确运行. 比如当
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. 于是其平均运行时间为
\[ \overline{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)+c(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}. \]
这里 \[ \sum_{i=3}^{N+1}\frac{1}{i}\approx \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)的基本步骤如下:
与快速排序对比, 快速选择只进行了一次递归调用而不是两次. 快速选择的最坏情形和快速排序相同, 也是 $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). \]
函数模板直接应用快速排序和谢尔排序的算法时,
一般来说, 这个问题的解决方案很简单: 生成一个指向
一旦确定了元素应该在的位置, 就可以直接将该元素放在相应的位置上, 而不必进行过多的中间复制操作. 这需要使用称为
例子:
该算法有一个潜在的严重问题. 使用
随之而来的第二个问题是使用
为说明我们的意图, 首先从
然后将
当
类似的, 对于上面数组
总结
我们这里重新排列了三个元素, 却仅仅使用了四次
重新排列长为 $L$($ >1$) 的数组需要用到 $L+1$ 次
对给定 $N$ 个元素的数组, 如上面的例子所示, 可以分成若干个组. 例如长度为 2 的
#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; //将a数组中元素的地址保存到指针数组p中. 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]; } } }
前面提到我们需要对指针数组
指针之间的减法是合法的. 如果
根据上面的代码,
程序中
程序中