本文记述了针对归并排序的 3 项改进和一份参考实现代码,并在说明了算法的性能后用随机数据进行了验证。
◆ 思想
本文实现了《算法(第4版)》书中提到的 2 项改进和练习题 2.2.10。
- 对小规模子数组使用插入排序。因为递归会使小规模问题中方法的调用过于频繁,所以改进对它们的处理方法就能改进整个算法。
- 测试数组是否已经有序。任意有序的子数组算法的运行时间就变成线性的了。
- 快速归并。在 merge() 函数中,按降序将待排序数组的后半部分复制到辅助数组中,然后将其归并到待排序数组中。这样就可以去掉内循环中检测某半边是否用尽的代码。
◆ 实现
排序代码采用《算法(第4版)》的“排序算法类模板”实现。(代码中涉及的基础类,如 Array,请参考算法文章中涉及的若干基础类的主要API)
// merge4.hxx...class Merge4
{...template<class _T,class = typename std::enable_if<std::is_base_of<Comparable<_T>, _T>::value>::type>staticvoidsort(Array<_T> & a){Array<_T> aux = Array<_T>(a.size());__sort__(a, 0, a.size()-1, aux);}...template<class _T,class = typename std::enable_if<std::is_base_of<Comparable<_T>, _T>::value>::type>staticvoid__sort__(Array<_T> & a, int lo, int hi, Array<_T> & aux){if (hi - lo <= 15) { // #1for (int i = lo+1; i <= hi; ++i)for (int j = i; j > lo && __less__(a[j], a[j-1]); --j)__exch__(a, j, j-1);} else {int mi = lo + (hi - lo) / 2;__sort__(a, lo, mi, aux);__sort__(a, mi+1, hi, aux);if (__less__(a[mi], a[mi+1])) // #2for (int i = lo; i <= hi; ++i)aux[i] = a[i];else__merge__(a, lo, mi, hi, aux);}}...template<class _T,class = typename std::enable_if<std::is_base_of<Comparable<_T>, _T>::value>::type>staticvoid__merge__(Array<_T> & a, int lo, int mi, int hi, Array<_T> & aux){int i = lo, j = hi;for (int k = lo; k <= mi; ++k)aux[k] = a[k];for (int k = mi+1; k <= hi; ++k) // #3aux[k] = a[hi - (k - (mi+1))];for (int k = lo; i <= j; ++k) // #4if (__less__(aux[i], aux[j])) a[k] = aux[i++];else a[k] = aux[j--];}...template<class _T,class = typename std::enable_if<std::is_base_of<Comparable<_T>, _T>::value>::type>staticbool__less__(_T const& v, _T const& w){return v.compare_to(w) < 0; // #5}...
当待排序的子数组规模较小时,采用插入排序(#1)。测试数组是否已经有序,可确保有序的子数组算法的运行时间变成线性的(#2)。按降序将待排序数组的后半部分复制到辅助数组中(#3),然后将其归并到待排序数组中(#4)。将 '<' 改为 '>',即得到逆序的结果(#5)。
◆ 性能
对 __merge()__ 函数的改进,导致了不稳定的归并。
时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|
N*log(N) | N | 否 |
◆ 验证
测试代码采用《算法(第4版)》的倍率实验方案,用随机数据验证其正确性并获取时间复杂度数据。
// test.cpp...time_trial(int N)
{Array<Double> a(N);for (int i = 0; i < N; ++i) a[i] = Std_Random::random(); // #1Stopwatch timer;Merge4::sort(a); // #2double time = timer.elapsed_time();assert(Merge4::is_sorted(a)); // #3return time;
}...test(char * argv[])
{int T = std::stoi(argv[1]); // #4double prev = time_trial(512);Std_Out::printf("%10s%10s%7s\n", "N", "Time", "Ratio");for (int i = 0, N = 1024; i < T; ++i, N += N) { // #5double time = time_trial(N);Std_Out::printf("%10d%10.3f%7.2f\n", N, time, time/prev); // #6prev = time;}
}...
用 [0,1) 之间的实数初始化待排序数组(#1),打开计时器后执行排序(#2),确保得到正确的排序结果(#3)。整个测试过程要执行 T 次排序(#4)。每次执行排序的数据规模都会翻倍(#5),并以上一次排序的时间为基础计算倍率(#6),
此测试在实验环境一中完成,
$ g++ -std=c++11 test.cpp std_out.cpp std_random.cpp stopwatch.cpp type_wrappers.cpp$ ./a.out 15N Time Ratio1024 0.008 2.672048 0.018 2.254096 0.040 2.228192 0.087 2.1716384 0.187 2.1532768 0.401 2.1465536 0.855 2.13131072 1.815 2.12262144 3.837 2.11524288 8.097 2.111048576 17.023 2.102097152 35.708 2.104194304 73.782 2.078388608 153.411 2.0816777216 319.890 2.09
可以看出,随着数据规模的成倍增长,排序所花费的时间将是上一次规模的 2.1? 倍,且在不断变小。将数据反映到以 2 为底数的对数坐标系中,可以得到如下图像,
O(N*log(N)) 代表了线性对数级别复杂度下的理论排序时间,该行中的数据是以 Time 行的第一个数据为基数逐一乘 2 + 2/log(N) 后得到的结果(因为做的是倍率实验,所以乘 (2*N*log(2*N)) / (N*log(N)),化简得到 2 + 2/log(N),即乘 2+2/log(1024),2+2/log(2048),2+2/log(4096),... 2+2/log(16777216);因为是二分归并,所以 log 的底数为 2)。
◆ 最后
完整的代码请参考 [gitee] cnblogs/18201378 。
写作过程中,笔者参考了《算法(第4版)》的归并排序、练习题 2.2.10、“排序算法类模板”和倍率实验。致作者 Sedgwick,Wayne 及译者谢路云。