二路划分
- 假设将n元素的序列仅仅划分为两个子序列,称之为二路划分
- 一种二路划分的方法:
- 把前面n-1个元素放到第一个子序列中(称为A),最后一个元素放到第二个子序列中(称为B)。然后对A递归进行排序,然后再将B仅有的一个元素归并插入到A中
- 这种方法是插入排序的递归形式。该算法的复杂度为O
- 另一种二路划分的方法:
- 将关键字最大的元素放入B,剩余元素放入A,然后对A进行递归排序。然后将A和B归并,此时直接将B添加到A的尾部就可以了
分而治之的方法
- 上述方案是将n个元素划分为两个极不平衡的子序列A和B。A有n-1个元素,而B仅有一个元素
- 现在我们划分的平衡一点,假设A包含n/k个元素,B包含其余的元素。递归地应用分而治之对A和B进行排序,然后采用一个被称之为归并的过程,将有序子序列A和B归并成一个序列
- 假设有8个元素,关键字分别为[10、4、6、3、8、2、5、7]。则有:
- 如果选定k=2:
- 则可以划分出两个子序列[10、4、6、3]与[8、2、5、7]
- 将上面两个子序列进行排序,可以得到两个有序子序列[3、4、6、10]、[2、5、7、8]
- 现在从头元素开始比较,将两个有序子序列归并到一个子序列。元素2与3比较,2被移到归并序列;3与5比较,3被移动到归并序列;4与5比较,4被移到归并序列;5与6比较,以此类推....
- 如果选定k=4:
- 则可以划分出两个子序列[10、4]与[6、3、8、2、5、7]
- 将上面两个子序列进行排序,可以得到两个有序子序列[4、10]、[2、3、5、6、7、8]
- 根据相似的原理进行归并,便可以得到有序序列
- 下面是对分而治之排序算法的伪代码描述。当生成的较小的实例个数为2,且A划分后的子序列具有n/k元素时,元素便可以得到最后结果:
- 下面是相关的证明:
二路归并排序代码实现
- 上面的伪代码是在k=2时的排序算法,称为归并排序,更准确的说,是二路归并排序
- 下图是在k=2时的归并排序的C++函数(非完整版,简略版):
- 用一个数组a存储元素序列E,并用a返回排序后的序列
- 当序列E被划分为两个子序列时,不必把它们分别复制到A和B中,只需简单地记录它们在序列E中的左右边界
- 然后将排序后的子序列归并到一个新数组b中,最后再将它们复制回a中
- 如果仔细考察上面的程序,就会发现,递归只是简单地对序列反复划分,知道序列的长度变为1,这时再进行归并。这个过程用n为2的幂来描述会更好:
- 长度为1的子序列被归并为长度为2的有序子序列
- 长度为2的子序列被归并为长度为4的有序子序列
- 这个过程不断重复,直到归并为一个长度为n的序列
- 下图是n=8时的归并(和复制)过程
二路归并的迭代器算法(直接归并排序)
- 有很多方面可以改进上面的C++函数。例如消除递归
- 二路归并排序的一种迭代器算法是这样的:
- 首先将每两个相邻的大小为1的子序列归并
- 然后将每两个相邻的大小为2的子序列归并
- 如此重复,直到只剩下一个有序序列
- 轮流地将元素从a归并到b,从b归并到a,实际上消除了从b到a的复制过程,算法如下
- 下面的函数用归并排序对数组元素a[0:n-1]排序
template
void mergeSort(T a[], int n) { T *b = new T[n]; int segmentSize = 1; while (segmentSize < n) { mergePass(a, b, n, segmentSize); //从a到b的归并 segmentSize += segmentSize; mergePass(b, a, n, segmentSize); //从b到a的归并 segmentSize += segmentSize; } delete[] b; }
- 为了完成排序代码,需要函数mergePass,不过这个函数仅用来确定需要归并的子序列的左右边界。实际的归并是由函数merge完成的
//从x到y归并相邻的数据段 template
void mergePass(T x[], T y[], int n, int segmentSize) { //下一个数据段的起点 int i = 0; //从x到y归并相邻的数据段 while (i <= n - 2 * segmentSize) { merge(x, y, i, i + segmentSize - 1, i + 2 * segmentSize - 1); i = i + 2 * segmentSize; } //少于两个满数据段 if (i + segmentSize < n) //剩余两个数据段 merge(x, y, i, i + segmentSize - 1, n - 1); else //只剩一个数据段,复制到y for (int j = i; j < n; j++) y[j] = x[j]; } //把两个相邻数据段从c归并到d template void merge(T c[], T d[], int startOfFirst, int endOfFirst, int endOfSecond) { int first = startOfFirst; //第一个数据段的索引 int second = endOfFirst + 1; //第二个数据段的索引 int result = startOfFirst; //归并数据段的索引 //直到有一个数据段归并到归并段d while ((first <= endOfFirst) && (second <= endOfSecond)) { if (c[first] <= c[second]) d[result++] = c[first++]; else d[result++] = c[second++]; } //归并剩余元素 if (first > endOfFirst) for (int q = second; q <= endOfSecond; q++) d[result++] = c[q]; else for (int q = first; q <= endOfFirst; q++) d[result++] = c[q]; }
- 下面验证一下结果:
自然归并排序
- 在自然归并排序中,首先认定在输入序列中已经存在的有序段
- 例如:
- 在输入数列[4、8、3、7、1、5、6、2]中可以认定4个有序段:[4、8],[3、7],[1、5、6],[2]
- 从左至右扫描序列元素,若位置i的元素比位置i+1的元素大,则位置i便是一个分割点。然后归并这些有序段,直到剩下一个有序段
- 归并有序段1和2可得有序段[3、4、7、8],归并有序段3和4可得有序段[1、2、5、6]。最后归并这两个有序段可得到[1、2、3、4、5、6、7、8]。这样,只需要两次归并
- 自然归并的最好情况是:输入序列已经有序。自然归并排序只认定了一个有序段,不需要归并,但上面的mergeSort()函数仍要进行[]次归并,因此自然归并排序所需时间为Θ(n),而上面的mergeSort()函数需要用时Θ(n)
- 自然归并的最坏情况是:输入序列按递减顺序排序。最初认定的有序段有n个。这是的归并排序和自然归并排序需要相同的归并次数,但自然归并排序为记录有序段的边界需要更多的时间。因此,在最坏情况下的性能,自然归并排序不如直接归并排序
- 在一般情况下,n个元素序列有n/2个有序段,因为第i个元素关键字大于第i+1个元素关键字的概率是0.5。如果开始的有序段仅有n/2个,那么自然归并排序所需的归并比直接归并排序的要少。但是自然归并排序在认定初始有序段和记录有序段的边界时需要额外时间。因此,只有输入序列确实有很少的有序段时,才建议使用自然归并排序
演示说明
- 设有一个序列[4、8、3、7、1、5、6、2]:
- 假设以元素6作为支点(middle),则left段包含4、3、1、5、2,right段包含8、7
- 将left排序的结果为1、2、3、4、5,将right排序的结果为7、8
- 最终得到有序序列[1、2、3、4、5、6、7、8]
- 设有一个序列[4、3、1、5、2]:
- 假设以元素3作为支点(middle),则left段包含1、2,right段包含4、5
- 将left排序的结果为1、2,将right排序的结果为4、5
- 最终得到有序序列[1、2、3、4、5]
C++代码实现
- 下面是quick()的定义:
- quickSort把数组的最大元素移动到数组的最右端,然后调用递归函数_qucikSort执行排序
template
int indexOfMax(T a[], int n) { int max = 0; int index = 0; for (int index = 1; index < n; index++){ if (a[index] > a[max]) max = index; } return max; } template void quick(T a[], int n) { if (n <= 1) return; //把最大元素移动到数组的最右端 int max = indexOfMax (a, n); std::swap(a[max], a[n - 1]); //然后调用_quickSort进行递归排序 _quickSort(a, 0, n - 2); }
- quick()函数中为什么要将最大元素放置于最右边的原因:下面的_qucikSort()要求每一个数据段,或者其最大元素位于右端,或者其后继元素大于数据段的所有元素,因此,把最大元素移到最右端;如果这个条件不满足,例如,当支点是最大元素时,第一个do循环语句的结果是左索引值leftCursor将大于n-1
- 下面是_quickSort()的定义:
- _qucikSort把数据段划分为左、中、右。支点(pivot)总是待排序数段的左元素。其实还可以选择性能更好的排序算法。在后面我们将讨论这种算法
- 在do-while语句中,把关系操作符<和>改为<=和>=,程序依然正确(这时,数据段最右边的元素比支点要大)
template
void _quickSort(T a[], int leftEnd, int rightEnd) { if (leftEnd >= rightEnd) return; int leftCursor = leftEnd, rightCursor = rightEnd + 1; T pivot = a[leftEnd]; //将位于左侧不小于支点的元素和位于右侧不大于支点的元素交换 while (true) { do { //寻找左侧不小于支点的元素 leftCursor++; } while (a[leftCursor] < pivot); do { //寻找右侧不大于支点的元素 rightCursor--; } while (a[rightCursor] > pivot); //没有找到交换的元素对,退出 if (leftCursor >= rightCursor) break; std::swap(a[leftCursor], a[rightCursor]); } //放置支点 a[leftEnd] = a[rightCursor]; a[rightCursor] = pivot; _quickSort(a, leftEnd, rightCursor - 1); //对左侧的数段排序 _quickSort(a, rightCursor + 1, rightEnd); //对右侧的数段排序 }
- 下面进行测试
int main() { int a[] = { 4,5,1,4,2,3,8 }; quick
(a, sizeof(a) / sizeof(int)); for (auto val : a) { std::cout << val << " "; } return 0; }
复杂度分析
- 上面的quickSort()所需的递归栈空间为O(n)。若使用栈来模拟递归,则需要的空间可以减少为O(logn)。在模拟过程中,首先对数据段left和right中较小者进行排序,把较大者的边界放入栈中
- 在最坏情况下,例如数据段left总是空,这时的快速排序用时为Θ()。在最好情况下,即数据段left和right的元素数目总是大致相同,这时的快速排序用时为O(nlogn)。而快速排序的平均复杂度也是Θ(nlogn)
- 下图对本专栏的排序算法在平均情况下和最坏情况下的复杂度做了比较:
三值取中快速排序
性能测量
C++排序方法
问题描述
- 选择问题就是:从n个元素的数组中找出第k小的元素
- 选择问题的一个实际应用就是寻找“中值”元素。此时k=[n/2]。例如查找中间工资、中间年龄等等
解决方法①
- 选择问题可在O(nlogn)时间内解决。一种方法是:
- 首先对n个元素的数组a进行排序(堆排序或归并排序等)
- 然后取出a[k-1]的元素
- 使用快速排序可以获得更好的平均性能。尽管该算法在最坏情况下的渐进复杂度比较差,仅为O()
- 代码如下:
template
T select(T a[], int n, int k) { if (n < 1 || k < 1 || k > n) throw std::out_of_range("out_of_range"); //排序,排序之后a为1 2 3 4 4 5 8 std::sort(a, a + n); return a[k - 1]; } int main() { int a[] = { 4,5,1,4,2,3,8 }; int ret_val = select (a, sizeof(a) / sizeof(int), 5); if (ret_val) std::cout << "select success:" << ret_val << std::endl; else std::cout << "select failed:" << ret_val << std::endl; return 0; }
解决方法②
- 修改上面的快速排序函数quickSort(),我们可以得到选择问题的一个较快的求解方法
- 原理为:
- 如果在两个while循环之后,将支点元素a[leftEnd]交换到a[j],那么a[leftEnd]便是a[leftEnd:rightEnd]中第j-leftEnd+1小的元素
- 如果要寻找的第k小的元素在a[leftEnd:rightEnd]中:
- 如果j-leftEnd+1等于k,那么答案就是a[leftEnd]
- 如果j-leftEnd+1
- 因此,只需进行0次或1次递归调用
- 代码如下:在select中的递归调用可用for或while循环来代替
template
int indexOfMax(T a[], int n) { int max = 0; int index = 0; for (int index = 1; index < n; index++) { if (a[index] > a[max]) max = index; } return max; } template T select(T a[], int n, int k) { if (k<1 || k>n) throw std::out_of_range("out_of_range"); //把最大元素移动到数组的最右端 int max = indexOfMax (a, n); std::swap(a[max], a[n - 1]); //然后调用_quickSort进行递归排序 return _select(a, 0, n - 1, k); } template T _select(T a[], int leftEnd, int rightEnd, int k) { if (leftEnd >= rightEnd) return a[leftEnd]; int leftCursor = leftEnd, rightCursor = rightEnd + 1; T pivot = a[leftEnd]; //将位于左侧不小于支点的元素和位于右侧不大于支点的元素交换 while (true) { do { //寻找左侧不小于支点的元素 leftCursor++; } while (a[leftCursor] < pivot); do { //寻找右侧不大于支点的元素 rightCursor--; } while (a[rightCursor] > pivot); //没有找到交换的元素对,退出 if (leftCursor >= rightCursor) break; std::swap(a[leftCursor], a[rightCursor]); } if (rightCursor - leftEnd + 1 == k) return pivot; //放置支点 a[leftEnd] = a[rightCursor]; a[rightCursor] = pivot; //第一个数据段递归调用 if (rightCursor - leftEnd + 1 < k) return _select(a, rightCursor + 1, rightEnd, k - rightCursor + leftEnd - 1); else return _select(a, leftEnd, rightCursor - 1, k); } int main() { int a[] = { 4,5,1,4,2,3,8 }; std::cout << "select success:" << select (a, sizeof(a) / sizeof(int), 5) << std::endl; return 0; }
复杂度分析
- 解决方法②所用的程序在最坏情况下的复杂度是Θ()。所谓最坏情况就是left总是为空,第k小元素总是位于right。如果left和right总是一样大小或大小相差不超过一个元素,那么对上面的程序来说,可得到以下递归表达式:
待续