堆排序巩固
序言:
作为时间复杂度为
O
(
n
lg
n
)
O(n\lg n)
O(nlgn)的成员之一,堆排序亦是一种重要的排序算法。而且,相较于归排和快排,堆排的实现更为复杂一些(包含3个过程),若对其没有一个全面的理解,则很难在面试中现场写出来。本文将详细记录其算法思想(参考《算法导论》第3版)。
1. 堆
(二叉)堆是一个数组,它可以被看成一个近似的完全二叉树(除了底层外,该树完全充满,底层从左往右填充)。所谓堆排序,是我们把待排序的数组,由层次遍历而假想成一个堆,并假想成我们是在堆上进行操作,而实际上是对数组在进行操作。
这里,我们假定数组名为
A
A
A,
A
.
l
e
n
g
t
h
A.length
A.length表示数组的长度,
A
.
h
e
a
p
−
s
i
z
e
A.heap-size
A.heap−size表示堆的大小(我们不一定把整个
A
A
A都看成堆,我们可能只对前
A
.
l
e
n
g
t
h
−
i
A.length - i
A.length−i 个元素看成是堆),且
0
≤
A
.
h
e
a
p
−
s
i
z
e
≤
A
.
l
e
n
g
t
h
0 \le A.heap-size \le A.length
0≤A.heap−size≤A.length。
此外,给定一个结点的下标,我们可以容易地计算出
p
a
r
e
n
t
(
i
)
=
⌊
(
i
−
1
)
/
2
⌋
l
e
f
t
(
i
)
=
2
i
+
1
r
i
g
h
t
(
i
)
=
2
i
+
2
\begin{aligned} parent(i) &= \left \lfloor {(i - 1)/ 2} \right \rfloor \\ left(i) &= 2i + 1\\ right(i) &= 2i + 2 \end{aligned}
parent(i)left(i)right(i)=⌊(i−1)/2⌋=2i+1=2i+2
注意了,此处下标
i
i
i 是从 0 开始的(导论里是从1开始的)。
- C++实现
int parent(int i) {
return (i - 1) >> 1;
}
int left(int i) {
return (i << 1) + 1;
}
int right(int i) {
return left(i) + 1;
}
对于堆,其结点需要满足一定的性质。对于最大堆,父结点的值一定大于等于子结点的值;对于最小堆,则反之。其中最大堆一般用于排序,而最小堆一般用于构造优先队列。
2. 维护堆的性质
上文已经提及,堆排序使用的是最大堆,而最大堆的父结点的值一定大于等于子结点的值。倘若不满足这个条件,即父结点的值小于它结点的值,则我们需要让这个父节点与其儿子作换位(逐级下沉),直到使得该结点为根的子树满足最大堆的性质。
- C++实现
void maxHeapify(vector<int>& a, int i, int heapSize) {
int lchd = left(i), rchd = right(i), largest = i;
if (lchd < heapSize && a[lchd] > a[i]) largest = lchd;
if (rchd < heapSize && a[rchd] > a[largest]) largest = rchd;
if (largest != i) {
//说明有变化
swap(a[i], a[largest]);
maxHeapify(a, largest, heapSize);//递归调用
}
}
过程参阅下图,即所谓逐级下沉。
- 父结点下降一级,可能其与之孙结点仍不满足最大堆的性质,因此需要递归调用;
- 最坏情况下,我们需要递归调用次数为“该二叉堆的深度”次,因此时间复杂度为 O ( lg n ) O(\lg n) O(lgn), n n n 表示二叉堆的元素个数。或者说是 O ( h ) O(h) O(h), h h h 表示二叉堆的高度。
3. 建堆
我们可以根据自底向上的方法,将一个数组转换为最大堆(一个数组可以看做一个二叉堆的层次遍历,但该堆并不一定是最大堆,我们需要交换数组某些元素的位置,使之为某个最大堆的层次遍历)。我们根据下标自大向小的顺序对每个元素执行维护堆函数 maxHeapify 即可。
- C++实现
int buildMaxHeap(vector<int>& a) {
int heapSize = a.size();
//自底向上
for (int i = (heapSize >> 1) - 1; i >= 0; --i)
maxHeapify(a, i, heapSize);
//这里 i 不是从 heapSize 开始取的,
//而是从第一个非叶子结点 (heapSize >> 1) - 1 开始的
//当然,我们也可以从 heapSize 开始
//但是叶子结点只会进入 维护堆函数 maxHeapify 一次便跳出
return heapSize;
}
这个函数的的时间复杂度非常有意思。我们刚才已经知道,对于高度为 h h h 的结点,其执行 maxHeapify 的复杂度为 O ( lg n ) O(\lg n) O(lgn),而外部循环执行了 A . h e a p − s i z e A.heap-size A.heap−size 次,最坏情况下理应是 O ( n lg n ) O(n\lg n) O(nlgn)。但是,对于maxHeapify ,只有是根结点的复杂度为 O ( lg n ) = O ( h ) O(\lg n) =O(h) O(lgn)=O(h),每下降一级,其最深递归层次将递减。
对于高度为
h
h
h 的层次,包含的结点数至多为
2
lg
n
−
h
=
n
2
h
2^{\lg n - h} = \frac{n}{2^h}
2lgn−h=2hn (根节点的高度为
h
h
h, 结点数为
2
0
=
1
2^0=1
20=1)。即,对于高度为
h
h
h 的层次,外循环次数为
n
2
h
\frac{n}{2^h}
2hn,内部maxHeapify 的复杂度为
O
(
h
)
O(h)
O(h),buildMaxHeap 总的时间复杂度为:
∑
h
=
0
⌊
lg
n
⌋
O
(
h
)
⋅
n
2
h
=
O
(
n
∑
h
=
0
⌊
lg
n
⌋
h
2
h
)
\begin{aligned} \sum_{h=0}^{\left \lfloor{\lg n} \right \rfloor}{O(h)·\frac{n}{2^h}} = O(n\sum_{h=0}^{\left \lfloor{\lg n} \right \rfloor}\frac{h}{2^h}) \end{aligned}
h=0∑⌊lgn⌋O(h)⋅2hn=O(nh=0∑⌊lgn⌋2hh)
其中
∑
h
=
0
∞
h
2
h
=
1
/
2
(
1
−
1
/
2
)
2
=
2
(
对
几
何
级
数
∑
h
=
0
∞
x
h
求
导
可
以
推
得
)
\begin{aligned} \sum_{h=0}^{\infty}{\frac{h}{2^h}} = \frac{1/2}{(1-1/2)^2} = 2\\ (对几何级数\sum_{h=0}^{\infty}{x^h}求导可以推得) \end{aligned}
h=0∑∞2hh=(1−1/2)21/2=2(对几何级数h=0∑∞xh求导可以推得)
因此,buildMaxHeap 总的时间复杂度为
O
(
n
)
O(n)
O(n)。
4. 堆排序
以上,我们已经完成了维护堆函数,以及建堆函数的编写。堆排序算法已经呼之欲出:
首先,利用建堆函数完成对原数组的重排,构建成一个标准的最大堆。此时,堆的大小
A
.
h
e
a
p
−
s
i
z
e
A.heap-size
A.heap−size 等于数组长度
A
.
l
e
n
g
t
h
A.length
A.length;
此时,堆顶元素(也是数组首元素)是数组中最大的元素,我们与数组的尾元素作交换,那么该最大元素便正确入位了;
一次交换后,最大堆的性质被破坏(堆顶被换成了数组中的尾元素),我们要重新维护堆。注意,此时的堆大小应当减去1(砍掉最后一个元素),然后再去维护堆,因为我们已经不需要再去处理尾元素了(它已经被排好序)。不断重复交换,维护堆的过程, 直至堆大小为1而停止。
- C++实现
void heapSort(vector<int>& a) {
int heapSize = buildMaxHeap(a);
for (int i = a.size() - 1; i > 0; --i) {
swap(a[i], a[0]);
maxHeapify(a, 0, --heapSize);
}
}
heapSort 的时间复杂度是 O ( n lg n ) O(n\lg n) O(nlgn),因为每次调用 buildMaxHeap 的复杂度为 O ( n ) O(n) O(n)(低阶小量),而 n − 1 n - 1 n−1 次调用 maxHeapify ,每次的时间是 O ( lg n ) O(\lg n) O(lgn)(高阶主导量)。