文章目录
参考《算法导论(第三版)》第 6 章。
堆排序是利用堆这种数据结构而设计的一种排序算法,它是一种就地(原地,原址)排序算法。堆排序的时间复杂度为
O
(
n
l
o
g
n
)
O(n\ log\ n)
O(n log n),空间复杂度为
O
(
1
)
O(1)
O(1)。
堆不仅用在堆排序中,而且它也可以构造一种有效的优先队列。
首先对堆的概念及其基本操作做些解释。
(二叉)堆简介
(二叉)堆是一个数组,它可以被看成一个的完全二叉树,如下如所示。
如果把堆看成是一棵完全二叉树,那么一个包含
n
n
n 个元素的堆的高度为
⌊
l
o
g
n
⌋
\lfloor log\ n \rfloor
⌊log n⌋。
堆的数组 A A A 包括两个属性:
- A . l e n g t h A.length A.length,表示数组元素的个数;
- A . h e a p S i z e A.heapSize A.heapSize,表示有多少个堆元素存储在该数组中。
我们将堆存储在下标从 1 1 1 开始计数的数组中,也就是说,虽然 A [ 1 , . . , A . l e n g t h ] A[1,..,A.length] A[1,..,A.length] 可能都存有数据,但只有 A [ 1 , . . , A . h e a p S i z e ] A[1,..,A.heapSize] A[1,..,A.heapSize] 中存放的是堆的有效元素。
堆的分类
二叉堆可以分为两种形式:最大堆和最小堆。在这两种堆中,结点的值都要满足堆的性质:
(1)大顶堆:每个结点的值都大于或者等于它的左右子节点(若存在)的值。
(2)小顶堆:每个结点的值都小于或者等于它的左右子节点(若存在)的值。
在堆排序算法中,我们使用的是最大堆。
堆的基本操作
下面都是以最大堆的基本操作来说明,最小堆的相应操作过程类似。
计算堆中每个结点的父节点、左孩子和右孩子的下标
我们将堆存储在下标从
1
1
1 开始计数的数组中。
给定一个结点的下标
i
i
i,我们很容易计算得到它的父结点、左孩子和右孩子的下标:
p
a
r
e
n
t
(
i
)
=
⌊
i
/
2
⌋
l
e
f
t
(
i
)
=
2
i
;
r
i
g
h
t
(
i
)
=
2
i
+
1
;
\begin{array}{l} parent(i) = \lfloor i / 2 \rfloor \\ left(i) = 2i; \\ right(i) = 2i + 1; \end{array}
parent(i)=⌊i/2⌋left(i)=2i;right(i)=2i+1;
在大多数计算机上,通过将
i
i
i 的值左移一位,
l
e
f
t
(
i
)
left(i)
left(i) 过程可以在一条指令内计算出
2
i
2i
2i。
采用类似方法,可以在
r
i
g
h
t
(
i
)
right(i)
right(i) 过程中也可以通过将
i
i
i 的值左移一位并在低位加 1,快速计算得到
2
i
+
1
2i+1
2i+1。
至于
p
a
r
e
n
t
(
i
)
parent(i)
parent(i) 过程,则可以通过把
i
i
i 的值右移一位计算得到
⌊
i
/
2
⌋
\lfloor i / 2 \rfloor
⌊i/2⌋。
以上三类操作的时间复杂度均为 O ( 1 ) O(1) O(1)。
维护堆的性质: M a x _ H e a p i f y Max\_Heapify Max_Heapify
定义过程
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify 来维护最大堆,它的输入为一个数组
A
A
A 和一个下标
i
i
i,其作用是使以
i
i
i 为根的子树满足堆的性质。
在调用
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify 的时候,我们假定根节点为
l
e
f
t
(
i
)
left(i)
left(i) 和
r
i
g
h
t
(
i
)
right(i)
right(i) 的二叉树都已经是最大堆了,但这时
A
[
i
]
A[i]
A[i] 有可能小于其孩子,这样就违背了最大堆的性质。
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify 通过让
A
[
i
]
A[i]
A[i] 的值在最大堆中“逐级下降”,从而使得以下标
i
i
i 为根节点的子树重新遵循最大堆的性质。
这是一个递归调用的过程:
(1)在递归的每一步中,从
A
[
i
]
,
A
[
l
e
f
t
(
i
)
]
,
A
[
r
i
g
h
t
(
i
)
]
A[i],A[left(i)],A[right(i)]
A[i],A[left(i)],A[right(i)] 中选出最大的,将其下标存储在
l
a
r
g
e
s
t
largest
largest 中;
(2)如果
A
[
i
]
A[i]
A[i] 是最大的,那么以
i
i
i 为根结点的子树已经是最大堆,程序结束;
(3)否则,最大元素是以
i
i
i 的某个孩子节点,则交换
A
[
i
]
A[i]
A[i] 和
A
[
l
a
r
g
e
s
t
]
A[largest]
A[largest],从而使
i
i
i 及其 两个孩子节点满足了堆的性质;
(4)在交换后,下标为
l
a
r
g
e
s
t
largest
largest 的结点的值是原来的
A
[
i
]
A[i]
A[i],于是以该节点为根的子树又有可能会违反最大堆的性质,因此需要对该子树递归调用
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify。
Max_Heapify(A, i) {
l = left(i);
r = right(i);
if l <= A.heapSize && A[l] > A[i]
largest = l;
else largest = i;
if r <= A.heapSize && A[r] > A[largest]
largest = r;
// 至此,找到了 A[i], A[left(i)], A[right(i)] 中最大的元素
if largest != i
exchange A[i] with A[largest];
Max_Heapify(A, largest);
}
在上面例子中:
(a)初始状态,在结点
i
=
2
i=2
i=2 处,
A
[
2
]
A[2]
A[2] 违背了最大堆性质,因为它的值不大于它的孩子。
(b)通过交换
A
[
2
]
A[2]
A[2] 和
A
[
4
]
A[4]
A[4] 的值,结点
i
=
2
i=2
i=2 恢复了最大堆的性质,但又导致了结点
4
4
4 违反了最大堆的性质,递归调用
M
a
x
_
H
e
a
p
i
f
y
(
A
,
4
)
Max\_Heapify(A,4)
Max_Heapify(A,4)。
(c)通过交换
A
[
4
]
A[4]
A[4] 和
A
[
9
]
A[9]
A[9] 的值,结点
i
=
4
i=4
i=4 的最大堆性质得到了恢复。再次调用
M
a
x
_
H
e
a
p
i
f
y
(
A
,
9
)
Max\_Heapify(A,9)
Max_Heapify(A,9),此时不再有新的数据交换。
对于一个树高为
h
h
h 的结点来说,
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify 调整其最大堆的性质的时间复杂度为
O
(
h
)
O(h)
O(h)。
由于包含
n
n
n 个元素的堆的高度为
⌊
l
o
g
n
⌋
\lfloor log\ n \rfloor
⌊log n⌋,所以该操作的时间复杂度为
O
(
l
o
g
n
)
O(log\ n)
O(log n)
堆的插入操作
堆的插入操作是自底向上进行的:
(1)将新元素插入到数组的下一个位置,也就是将新结点插入到完全二叉树的第一个空结点的位置。
(2)新插入的元素可能会违反最大堆的性质,所以从新插入的结点到根结点的路径上,为新增的元素值寻找恰当的插入位置。
(3)在此过程中,当前结点会不断地与其父结点进行比较,如果当前结点的元素值较大,则交换当前元素与其父结点的元素值。
(4)这一过程不断重复,直到当前结点元素的值小于其父结点的元素值时终止。
Heap_Insert(A, key) {
A.heapSize = A.heapSize + 1;
A[A.heapSize] = key; // 将新元素插入到末尾
i = A.heapSize;
while i > 1 && A[parent(i)] < A[i] // 自底向上地调整
exchange A[i] with A[parent(i)];
i = parent(i);
}
在包含 n n n 个元素的堆上, H e a p _ I n s e r t Heap\_Insert Heap_Insert 操作的时间复杂度为 O ( l o g n ) O(log\ n) O(log n)。
提取并删除堆顶元素
堆的删除操作是自顶向下进行的:
(1)首先记录堆顶元素,将堆顶元素与堆的末尾元素进行交换。
(2)然后不断向下维护堆的性质,直接调用
M
a
x
_
H
e
a
p
i
f
y
(
A
,
1
)
Max\_Heapify(A, 1)
Max_Heapify(A,1) 即可。
(3)最后返回记录下的堆顶元素。
Heap_ExtrackAndDeleteMax(A) {
max = A[1];
A[1] = A[A.heapSize];
A.heapSize = A.heapSize - 1;
Max_Heapify(A, 1);
return max;
}
在包含 n n n 个结点的堆中, H e a p _ E x t r a c k A n d D e l e t e M a x Heap\_ExtrackAndDeleteMax Heap_ExtrackAndDeleteMax 的时间复杂度为 O ( l o g n ) O(log\ n) O(log n),因为除了 O ( l o g n ) O(log\ n) O(log n) 时间复杂度的 M a x _ H e a p i f y Max\_Heapify Max_Heapify 操作之外,其他操作都是常数阶的。
删除操作常在优先队列中使用,在堆排序中用不到。
修改堆中元素的值
对于最大堆来说,我们允许将元素
x
x
x 的值增加到
k
k
k,这里假设
k
k
k 的值不小于
x
x
x 的原值。
该过程与向堆中插入元素的过程类似:
Heap_Increase_Key(A, i, key) { // 将 i 位置的值增加到 k
if A[i] > key
error "new key is smaller than current key"
A[i] = key;
while i > 1 && A[parent(i)] < A[i] // 自底向上地调整
exchange A[i] with A[parent(i)];
i = parent(i);
}
对于最大堆,我们允许增加堆中某一元素的值;对于最小堆,我们允许减小堆中某一元素的值,该过程的时间复杂度都为
O
(
l
o
g
n
)
O(log\ n)
O(log n)。
受此启发,在使用最小堆去优化 Prim 算法或 Dijkstra 算法时,假设图中顶点个数为
∣
V
∣
|V|
∣V∣,边的个数为
∣
E
∣
|E|
∣E∣。我们可以只维护规模为
∣
V
∣
|V|
∣V∣ 的一个最小堆,堆中每个元素对应一个节点,如果是 Prim 算法,堆中元素代表算法执行到目前为止每个顶点到当前最小生成树的最短距离;如果是 Dijkstra 算法,堆中元素代表算法执行到目前为止起点到每个顶点的最短距离。
在堆优化的算法中,我们需要考察每一条边,当有到达某个节点距离更短的边(Prim算法)或到达某个节点的最短距离可以更短(Dijkstra算法)时,我们可以找到相应的节点,然后减小权值即可。这样处理每条边的时间复杂度最小为
O
(
1
)
O(1)
O(1)(堆中元素值不需要更新),最大为
O
(
l
o
g
n
)
O(log\ n)
O(log n)(堆中元素值需要减小,调整的时间复杂度为
O
(
l
o
g
n
)
O(log\ n)
O(log n))。
因此这样堆优化的 Prim 算法以及 Dijkstra 算法的时间复杂度可以从
O
(
∣
E
∣
l
o
g
∣
E
∣
)
O(|E|log|E|)
O(∣E∣log∣E∣) 真正意义上变为
O
(
∣
E
∣
l
o
g
∣
V
∣
)
O(|E|log|V|)
O(∣E∣log∣V∣)。
自底向上建堆,任何情况下的时间复杂度为 O ( n ) O(n) O(n)(调整法)
给定一个大小为
n
=
A
.
l
e
n
g
t
h
n=A.length
n=A.length 的数组
A
A
A,可以通过 自底向上地调用过程
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify 将其转换为最大堆。
A
[
⌊
n
/
2
⌋
+
1..
n
]
A[\lfloor n / 2 \rfloor +1..n]
A[⌊n/2⌋+1..n] 中的元素都是树的叶结点,每个叶结点都可以看成只包含一个元素的堆,不会违背最大堆的性质,所以无需对其执行
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify 过程。
Build_Max_Heap(A) {
A.heapSize = A.length;
for i = ⌊A.length / 2⌋ downto 1
Max_Heapify(A, i);
}
我们可以简单地估算 B u i l d _ M a x _ H e a p Build\_Max\_Heap Build_Max_Heap 运行时间的上界。每次调用 M a x _ H e a p i f y Max\_Heapify Max_Heapify 的时间复杂度为 O ( l o g n ) O(log\ n) O(log n),需要 O ( n ) O(n) O(n) 次这样的调用,总的时间复杂度是 O ( n l o g n ) O(nlog\ n) O(nlog n)。当然这个上界虽然正确,但不是渐进紧确的。
但是不同结点运行
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify 的时间与该结点的树高相关,而且大部分结点的高度都很小。
包含
n
n
n 个元素的堆的高度为
⌊
l
o
g
n
⌋
\lfloor log\ n \rfloor
⌊log n⌋,高度为
h
h
h 的堆最多包含
⌈
n
/
2
h
+
1
⌉
\lceil n / 2^{h+1} \rceil
⌈n/2h+1⌉ 个结点。
在一个高度为
h
h
h 的结点上运行
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify 的代价为
O
(
h
)
O(h)
O(h),我们可以将
B
u
i
l
d
_
M
a
x
_
H
e
a
p
Build\_Max\_Heap
Build_Max_Heap 的总代价表示为:
∑
h
=
0
⌊
l
o
g
n
⌋
⌈
n
/
2
h
+
1
⌉
O
(
h
)
=
O
(
n
∑
h
=
0
⌊
l
o
g
n
⌋
h
2
h
)
\sum_{h=0}^{\lfloor log\ n \rfloor} \lceil n / 2^{h+1} \rceil O(h)=O(n\sum_{h=0}^{\lfloor log\ n \rfloor} \frac{h}{2^h})
h=0∑⌊log n⌋⌈n/2h+1⌉O(h)=O(nh=0∑⌊log n⌋2hh)
由级数公式:
∑
k
=
0
∞
k
x
k
=
1
(
1
−
x
)
2
\sum_{k=0}^{\infty} kx^k = \frac{1}{(1-x)^2}
k=0∑∞kxk=(1−x)21
将
x
=
1
/
2
x = 1/2
x=1/2 代入可得:
∑
h
=
0
∞
h
2
h
=
1
/
2
(
1
−
1
/
2
)
2
=
2
\sum_{h=0}^{\infty} \frac{h}{2^h} = \frac{1/2}{(1-1/2)^2} = 2
h=0∑∞2hh=(1−1/2)21/2=2
所以,可以得到
B
u
i
l
d
_
M
a
x
_
H
e
a
p
Build\_Max\_Heap
Build_Max_Heap 的时间复杂度为:
O
(
n
∑
h
=
0
⌊
l
o
g
n
⌋
h
2
h
)
=
O
(
n
∑
h
=
0
∞
h
2
h
)
=
O
(
n
)
O(n\sum_{h=0}^{\lfloor log\ n \rfloor} \frac{h}{2^h}) = O(n\sum_{h=0}^{\infty} \frac{h}{2^h}) = O(n)
O(nh=0∑⌊log n⌋2hh)=O(nh=0∑∞2hh)=O(n)
因此可以在线性时间内,把一个无序数组构造成为一个最大堆,即自底向上构建堆的时间复杂度为 O ( n ) O(n) O(n)。
在上面例子中:
(a)一个包含 10 个元素的输入数组
A
A
A 及其对应的完全二叉树。图中显示的是调用
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify 前,循环控制变量
i
i
i 指向结点
5
5
5 的情况。(由于无需对叶节点维护堆的性质)
(b)调用
M
a
x
_
H
e
a
p
i
f
y
(
A
,
5
)
Max\_Heapify(A,5)
Max_Heapify(A,5) 后的结果如该图所示,下一次迭代,循环变量
i
i
i 指向结点
4
4
4。
(c ~ e)执行
B
u
i
l
d
_
M
a
x
_
H
e
a
p
Build\_Max\_Heap
Build_Max_Heap 中 for 循环的后续迭代操作。需要注意的是,任何时候在某个结点调用
M
a
x
_
H
e
a
p
i
f
y
Max\_Heapify
Max_Heapify,该结点的两个子树都已经是最大堆。
(f)执行完
B
u
i
l
d
_
M
a
x
_
H
e
a
p
Build\_Max\_Heap
Build_Max_Heap 时的最大堆。
自顶向下建堆,最坏情况下的时间复杂度为 O ( n l o g n ) O(nlog\ n) O(nlog n)(插入法)
如果我们事先不知道数组中有多少个元素,可以通过不断向堆中插入元素的方式构建二叉堆,该算法的执行过程就是不断调用
H
e
a
p
_
I
n
s
e
r
t
Heap\_Insert
Heap_Insert 方法。
H
e
a
p
_
I
n
s
e
r
t
Heap\_Insert
Heap_Insert 过程是自底向上进行的:
(1)将新元素插入到数组的下一个位置,也就是将新结点插入到完全二叉树的第一个空结点的位置。
(2)新插入的元素可能会违反最大堆的性质,所以从新插入的结点到根结点的路径上,为新增的元素值寻找恰当的插入位置。
(3)在此过程中,当前结点会不断地与其父结点进行比较,如果当前结点的元素值较大,则交换当前元素与其父结点的元素值。
(4)这一过程不断重复,直到当前结点元素的值小于其父结点的元素值时终止。
这里,我们也可以分析一下插入建堆的时间复杂度。我们先看最理想的情况,假设每次插入的元素都是严格递减的,那么每个元素只需要和它的父结点比较一次。那么其最优情况就是
O
(
n
)
O(n)
O(n)。
对于最坏的情况下,每次新增加一个元素都需要调整到它的根结点。而这个长度为
l
o
g
n
log\ n
log n。那么它的时间复杂度为如下公式:
∑
i
=
1
n
O
(
⌊
l
o
g
i
⌋
)
≥
∑
i
=
⌈
n
/
2
⌉
n
O
(
⌊
l
o
g
⌈
n
/
2
⌉
⌋
)
\sum_{i=1}^{n}O(\lfloor log\ i \rfloor) \geq \sum_{i=\lceil n/2\rceil}^{n}O(\lfloor log\ \lceil n/2\rceil \rfloor) \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \
i=1∑nO(⌊log i⌋)≥i=⌈n/2⌉∑nO(⌊log ⌈n/2⌉⌋)
≥
∑
i
=
⌈
n
/
2
⌉
n
O
(
⌊
l
o
g
(
n
/
2
)
⌋
)
\geq \sum_{i=\lceil n/2\rceil}^{n}O(\lfloor log\ (n/2) \rfloor)\ \
≥i=⌈n/2⌉∑nO(⌊log (n/2)⌋)
=
∑
i
=
⌈
n
/
2
⌉
n
O
(
⌊
l
o
g
n
−
1
)
⌋
)
= \sum_{i=\lceil n/2\rceil}^{n}O(\lfloor log\ n-1) \rfloor)
=i=⌈n/2⌉∑nO(⌊log n−1)⌋)
≥
n
2
O
(
l
o
g
n
)
\geq \frac{n}{2}O(log\ n)\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \
≥2nO(log n)
=
O
(
n
l
o
g
n
)
=O(n log \ n)\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \
=O(nlog n)
常用的建堆方法(自底向上调整堆的性质)主要用于堆元素已经确定好的情况,而插入建堆的过程主要用于动态的增加元素来建堆。插入建堆的过程也常用于建立优先队列的应用。这些可以根据具体的情况来选取。
堆排序算法流程
堆排序也是一种就地排序,其时间复杂度为 O ( n l o g n ) O(nlog\ n) O(nlog n),空间复杂度为 O ( 1 ) O(1) O(1)。其排序过程分为两部分:
- 构建初始堆,根据升序降序需求选择最大堆(大顶堆)或最小堆(小顶堆)。
- 进行迭代,逐个从堆中选取堆顶元素,将其与堆的末端的元素交换,堆的大小 h e a p S i z e heapSize heapSize 减一,并重新调整堆的结构使其满足堆的性质。
一般升序采用最大堆(大顶堆),降序采用最小堆(小顶堆)。
下面还是使用最大堆来说明如何对数组
A
A
A 进行堆排序(升序):
(1)构建初始堆。构建堆的过程采用前面两种之一均可,根据具体情况从中做出选择。
(2)将堆顶元素与末尾元素交换,这样就可以使末尾元素最大。
(3)令
A
.
h
e
a
p
S
i
z
e
=
A
.
h
e
a
p
S
i
z
e
−
1
A.heapSize = A.heapSize - 1
A.heapSize=A.heapSize−1,相当于从堆中删去了最大的元素。
(4)剩余的结点中,根的孩子结点仍然是最大堆,而新的根节点可能会违背最大堆的性质,所以需要调用
M
a
x
_
H
e
a
p
i
f
y
(
A
,
1
)
Max\_Heapify(A,1)
Max_Heapify(A,1),从而在
A
[
1..
A
.
h
e
a
p
S
i
z
e
]
A[1..A.heapSize]
A[1..A.heapSize] 维持最大堆。
(5)堆排序算法会不断重复以上过程,直到堆的大小等于
1
1
1,此时堆中只剩一个最小元素,也恰好在数组的开头位置,便完成了升序排序。
堆排序的时间复杂度为 O ( n l o g n ) O(n log\ n) O(nlog n),其中包括构建初始堆的时间( O ( n ) O(n) O(n) 或 O ( n l o g n ) O(nlog\ n) O(nlog n)),和逐个从堆中删除堆顶元素并调整的时间( O ( n l o g n ) O(nlog\ n) O(nlog n))。
需要注意的是,堆本身就是一个数组,只是可以将其看作是完全二叉树而已,堆排序也是就地排序,所以堆排序的空间复杂度为 O ( 1 ) O(1) O(1)。
Heap_Sort(A) {
Build_Max_Heap(A);
for i = A.length downto 2
exchange A[1] with A[i];
A.heapSize = A.heapSize - 1;
Max_Heapify(A, 1);
}
在上面的例子中:
(a)执行堆排序算法第一步,构建初始堆,图中所示就是构建得到的最大堆。
(b ~ j)每次将堆中最后一个元素与堆顶元素交换,通过
A
.
h
e
a
p
S
i
z
e
=
A
.
h
e
a
p
S
i
z
e
−
1
A.heapSize = A.heapSize - 1
A.heapSize=A.heapSize−1 的方式从堆中删除该堆顶元素,并通过
M
a
x
_
H
e
a
p
i
f
y
(
A
,
1
)
Max\_Heapify(A,1)
Max_Heapify(A,1) 维护堆的性质。其中仅浅色阴影的结点被保留在堆中。
(k)最终数组
A
A
A 的排序结果。