常见的排序算法——堆排序(三)
本文记述了针对堆排序实施 Floyd 方法的基本思想和一份参考实现代码,并在说明了算法的性能后用随机数据进行了验证。
◆ 思想
“大多数在下沉排序期间重新插入堆的元素会被直接加入到堆底。Floyd 在 1964 年观察发现,我们正好可以通过免去检查元素是否到达正确位置来节省时间。”(引《算法(第4版)》)。在下沉过程中,把较大的子结点与当前结点交换,然后沿着交换的路径继续向下交换,直至到达堆底。然后通过逐步将某结点与其父结点交换使元素上浮到正确的位置,直到堆顶或者该结点不再大于它的父结点为止。
◆ 实现
排序代码采用《算法(第4版)》的“排序算法类模板”实现。(代码中涉及的基础类,如 Array,请参考算法文章中涉及的若干基础类的主要API)
// heap3.hxx
...
class Heap3
{
...
template
<
class _T,
class = typename std::enable_if<std::is_base_of<Comparable<_T>, _T>::value>::type
>
static
void
sort(Array<_T> & a)
{
int N = a.size();
for (int k = N/2; k >= 1; --k)
__sink__(a, k, N);
while (N > 1) {
__exch__(a, 1, N);
--N;
__floyd__(a, 1, N); // #1
}
}
...
template
<
class _T,
class = typename std::enable_if<std::is_base_of<Comparable<_T>, _T>::value>::type
>
static
void
__sink__(Array<_T> & a, int k, int n)
{
while (2*k <= n) {
int j = 2*k;
if (j < n && __less__(a[j-1], a[j])) ++j;
if (!__less__(a[k-1], a[j-1])) break;
__exch__(a, k, j);
k = j;
}
}
...
template
<
class _T,
class = typename std::enable_if<std::is_base_of<Comparable<_T>, _T>::value>::type
>
static
void
__floyd__(Array<_T> & a, int k, int n)
{
while (2*k <= n) { // #2
int j = 2*k;
if (j < n && __less__(a[j-1], a[j])) ++j;
__exch__(a, k, j);
k = j;
}
while (k > 1 && __less__(a[k/2-1], a[k-1])) { // #3
__exch__(a, k/2, k);
k = k/2;
}
}
...
template
<
class _T,
class = typename std::enable_if<std::is_base_of<Comparable<_T>, _T>::value>::type
>
static
bool
__less__(_T const& v, _T const& w)
{
return v.compare_to(w) < 0;
}
...
template
<
class _T,
class = typename std::enable_if<std::is_base_of<Comparable<_T>, _T>::value>::type
>
static
void
__exch__(Array<_T> & a, int i, int j)
{
_T t = a[i-1];
a[i-1] = a[j-1];
a[j-1] = t;
}
...
下沉过程使用 Floyd 方法(#1)。把较大的子结点与当前结点交换,然后沿着交换的路径继续向下交换,直至到达堆底(#2)。通过逐步将某结点(位置 k)与其父结点(位置 k/2)交换使元素上浮到正确的位置,直到堆顶或者该结点不再大于它的父结点为止(#3)。
◆ 性能
时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|
N*log(N) | 1 | 否 |
◆ 验证
测试代码采用《算法(第4版)》的倍率实验方案,用随机数据验证其正确性并获取时间复杂度数据。
// test.cpp
...
time_trial(int N)
{
Array<Double> a(N);
for (int i = 0; i < N; ++i) a[i] = Std_Random::random(); // #1
Stopwatch timer;
Heap3::sort(a); // #2
double time = timer.elapsed_time();
assert(Heap3::is_sorted(a)); // #3
return time;
}
...
test(char * argv[])
{
int T = std::stoi(argv[1]); // #4
double 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) { // #5
double time = time_trial(N);
Std_Out::printf("%10d%10.3f%7.2f\n", N, time, time/prev); // #6
prev = 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 15
N Time Ratio
1024 0.009 2.25
2048 0.020 2.22
4096 0.043 2.15
8192 0.095 2.21
16384 0.206 2.17
32768 0.445 2.16
65536 0.964 2.17
131072 2.082 2.16
262144 4.485 2.15
524288 9.628 2.15
1048576 20.567 2.14
2097152 43.770 2.13
4194304 92.940 2.12
8388608 197.189 2.12
16777216 417.866 2.12
可以看出,随着数据规模的成倍增长,排序所花费的时间将是上一次规模的 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/18314180 。
查看性能对比,了解此算法与其它排序算法的相似性和差异性。
写作过程中,笔者参考了《算法(第4版)》的堆排序、练习题 2.4.40、“排序算法类模板”和倍率实验。致作者 Sedgwick,Wayne 及译者谢路云。
受限于作者的水平,读者如发现有任何错误或有疑问之处,请追加评论或发邮件联系 green-pi@qq.com。作者将在收到意见后的第一时间里予以回复。 本文来自博客园,作者:green-cnblogs,转载请注明原文链接:https://www.cnblogs.com/green-cnblogs/p/18314180 谢谢!