注:本文为《算法导论》中排序相关内容的笔记。对此感兴趣的读者还望支持原作者。
基本概念
堆排序,顾名思义,也是排序算法的一种。与归并排序算法一样,堆排序的时间复杂度是 O ( n lg n ) O(n\lg n) O(nlgn),整体优于插入排序。然而与归并排序不同的是,堆排序和插入排序一样,具有空间原址性:任何时候都只需要常数个额外的元素空间存储临时数据。因此,堆排序是集成了插入排序和归并排序的优点于一身的一种排序算法。
堆是一种常用的数据结构,不仅用在堆排序中,还可以用来构造一种有效的优先队列。(二叉)堆是一个数组,它可以被看作为一个近似的完全二叉树。树中每一个结点对应于数组中的一个元素。除了树的最底层外,树是完全充满的,而且自左向右填充。而完全二叉树则是完全充满的。
举一个例子,假如我们有一个数组 A A A,如图所示。
我们从数组中按序依次读取数组中的元素,构建堆如图所示。
上图中,我们以二叉树的形式构建成一个大顶堆。大顶堆,又被称作最大堆,是指堆中的结点除了根结点外,所有结点
i
i
i的值都要满足:
A
[
P
A
R
E
N
T
(
i
)
]
≥
A
[
i
]
A[PARENT(i)]\ge A[i]
A[PARENT(i)]≥A[i]
即堆中结点的值至多与其父结点一样大。因此,根结点是堆中的最大元素,并且在任意子堆中,该子堆所包含的所有结点的值都不大于该子树结点的根节点。值得注意的是,大顶堆并非完全有序,是部分有序,因为大顶堆只保证父子结点间的大小关系,而不保证兄弟结点间的大小关系。这一点从堆的示意图中也不难看出。当然,有大顶堆,自然而然就有小顶堆。小顶堆的性质与大顶堆恰好相反,再次不在赘述。此外,值得一提的是,因为堆近似于完全二叉树,堆中的结点下标有如下性质。
PARENT(i)
return i / 2;
LEFT(i)
return 2 * i;
RIGHT(i)
return 2 * i + 1;
其中,假设堆的根节点下标为1,PARENT(i),LEFT(i),RIGHT(i)分别代表下标为 i i i的结点的父结点、左孩子和右孩子的结点的下标。此外, i / 2 i/2 i/2自动向下取整,即 ⌈ i / 2 ⌉ \biggl \lceil i/2 \biggr \rceil ⌈i/2⌉。
前面说到,堆可以近似看作一个完全二叉树。如果把堆就当作一棵树,我们定义一个堆中的结点的高度就为该结点到叶结点最长简单路径上的边的数目。进而,我们可以得知一个包含 n n n个元素的堆的高度为 ⌊ lg n ⌋ \biggl \lfloor \lg n \biggr \rfloor ⌊lgn⌋。此外,更有趣的是,堆的一些基本操作运行时间至多与树的高度成正比,即时间复杂度为 O ( lg n ) O(\lg n) O(lgn),例如:
- MAX-HRAPIFY过程:其时间复杂度为 O ( lg n ) O(\lg n) O(lgn),它是维护大顶堆性质的关键。
- BUILD-MAX-HEAP过程:具有线性时间复杂度,功能是从无序的输入数据中构造一个大顶堆。
- HEAP-SORT过程:其时间复杂度为 O ( n lg n ) O(n\lg n) O(nlgn),功能是对一个数组进行原址排序。
堆的维护
上文已经说到,MAX-HEAPIFY是维护大顶堆性质的关键。在构造堆的过程中,可能存在一种现象:给定结点 i i i,且根结点为LEFT(i)和RIGHT(i)的二叉树都是大顶堆,但 A [ i ] A[i] A[i]小于其孩子。毫无疑问,此现象违背了大顶堆的性质。而MAX-HEAPIFY通过让 A [ i ] A[i] A[i]在大顶堆中“逐级下降”的方式,使得以下标 i i i为根结点的子树重新遵循大顶堆的性质。例如,一个堆处于下图状态:
我们可以看出,堆中的结点 A [ 2 ] A[2] A[2]违背了大顶堆的性质,它的值小于它的孩子。因此,我们需要将它“逐级下降”以维护大顶堆的性质,如下图。
从上图中,我们通过将 A [ 2 ] A[2] A[2]与它的孩子结点中较大的进行交换的形式完成了“逐级下降”。因此,结点 A [ 2 ] A[2] A[2]保持了大顶堆的性质,但又导致结点 A [ 4 ] A[4] A[4]违背了大顶堆的性质。因此,我们需要再将结点 A [ 4 ] A[4] A[4]“逐级下降”,如下图。
至此,堆中所有结点都保持了大顶堆的性质。
了解了MAX-HEAPIFY的过程,我们不妨分析对于一下一颗以
i
i
i为根结点、大小为
n
n
n的树,MAX-HEAPIFY的时间复杂度:调整
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)]的时间复杂度为
θ
(
1
)
\theta(1)
θ(1),加上在一颗以
i
i
i的一个孩子为根结点的子树上递归调用MAX-HEAPIFY的时间复杂度。因为每个孩子的子树大小至多为
2
n
/
3
2n/3
2n/3(最坏情况发生在树的最底层恰好半满的时候),我们可以得到MAX-HEAPIFY的时间复杂度为:
T
(
n
)
≤
T
(
2
n
/
3
)
+
θ
(
1
)
T(n)\le T(2n/3)+\theta(1)
T(n)≤T(2n/3)+θ(1)
我们可以求得上述递归式的解为
T
(
n
)
=
O
(
lg
n
)
T(n)=O(\lg n)
T(n)=O(lgn)。也就是说,对于一个高度为
h
h
h的结点来说,MAX-HEAPIFY的时间复杂度为
O
(
h
)
O(h)
O(h)。
可能有的读者对上述推导过程中,每个孩子的子树大小至多为
2
n
/
3
2n/3
2n/3感到疑惑,这里我简单的证明一下。假设一个大小为
n
n
n的树,其最底层是半满的,结点数是
m
m
m。我们将最底层的另一半补满,使该树使全满的。因为在完全二叉树中,树中的结点总数是最底层结点数的2倍减1,则此时可有
n
+
m
=
2
∗
(
m
+
m
)
−
1
n+m=2*(m+m)-1
n+m=2∗(m+m)−1
则求解上式,可有
n
=
3
m
−
1
n=3m-1
n=3m−1。又因为堆是从左向右填充,所以堆中任一结点的左子树的大小都不小于右子树的大小。而左子树此时是半满的,因此其大小为
n
−
1
−
m
2
+
m
\frac{n-1-m}{2}+m
2n−1−m+m,求得左子树大小为
2
n
3
−
1
3
\frac{2n}{3}-\frac{1}{3}
32n−31。因此,子树大小至多为
2
n
/
3
2n/3
2n/3。
堆的构建
至此,我们就可以着手进行堆的构建。我们用自底向上的方法调用MAX-HEAPIFY把一个数组 A [ 1 … n ] A[1\ldots n] A[1…n]转换为大顶堆。此外,因为子数组 A [ ⌊ n / 2 ⌋ + 1 … n ] A[\lfloor n/2 \rfloor+1\dots n] A[⌊n/2⌋+1…n]中的元素都是树的叶子结点,每个叶结点都可以看作只包含一个元素的堆。因此,BUILD-MAX-HEAP对树中的其他结点都调用一次MAX-HEAPIFY即可构建大顶堆。
例如,我们有如下图的一个无序数组。
则我们可以构建二叉树如下。
根据前述,我们将从树中的结点 A [ 1 … ⌊ n / 2 ⌋ ] A[1\ldots \lfloor n/2 \rfloor] A[1…⌊n/2⌋]开始从高到低调用MAX-HEAPIFY。在这里,就是从 A [ 5 ] = 16 A[5]=16 A[5]=16开始建立大顶堆,则可以得到下图。
因为以 A [ 5 ] A[5] A[5]为根结点的子树维持了大顶堆的性质,所以无需改动。然后,我们在结点 A [ 4 ] A[4] A[4]上调用MAX-HEAPIFY,则可以得到下图。
不难看出,因为 A [ 4 ] A[4] A[4]结点的值小于其孩子结点,经过MAX-HEAPIFY过程后, A [ 4 ] A[4] A[4]与 A [ 8 ] A[8] A[8]进行了交换,并且此后将在结点 A [ 3 ] A[3] A[3]上调用MAX-HEAPIFY,结果如下图所示。
同理,因为 A [ 3 ] A[3] A[3]小于其孩子结点,为维持大顶堆的性质, A [ 3 ] A[3] A[3]与 A [ 7 ] A[7] A[7]进行了交换,并将在结点 A [ 2 ] A[2] A[2]上调用MAX-HEAPIFY过程,如下图。
首先, A [ 2 ] A[2] A[2]小于其孩子结点,调用MAX-HEAPIFY后, A [ 2 ] A[2] A[2]与 A [ 5 ] A[5] A[5]进行交换,但1仍小于7,所以仍需要调用MAX-HEAPIFY过程,得到上图结果。最后,我们将对根结点调用MAX-HEAPIFY过程构造大顶堆,如下图。
至此,我们完成大顶堆的构建,不妨就此讨论一下BUILD-MAX-HEAP过程的时间复杂度。
我们已经知道含
n
n
n个元素的堆的高度为
⌊
lg
n
⌋
\lfloor \lg n \rfloor
⌊lgn⌋,高度为
h
h
h的结点至多为
n
/
2
h
+
1
n/2^{h+1}
n/2h+1个(注意结点的高度为该结点到叶子结点最长简单路径上的边的数目),而在一个高度为
h
h
h的结点上运行MAX-HEAPIFY的时间复杂度为
O
(
h
)
O(h)
O(h)。因此,我们可以获知BUILD-MAXHEAP的时间复杂度为
∑
h
=
0
⌊
lg
n
⌋
⌈
n
2
h
+
1
⌉
O
(
h
)
=
O
(
n
∑
h
=
0
⌊
lg
n
⌋
h
2
h
)
\sum_{h=0}^{\lfloor \lg n \rfloor}\lceil \frac{n}{2^{h+1}}\rceil O(h)=O(n\sum_{h=0}^{\lfloor \lg n \rfloor}\frac{h}{2^h})
h=0∑⌊lgn⌋⌈2h+1n⌉O(h)=O(nh=0∑⌊lgn⌋2hh)
因为
∑
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
所以,我们可以得到BUILD-MAX-HEAP的时间复杂度为
O
(
n
∑
h
=
0
⌊
lg
n
⌋
h
2
h
)
=
O
(
n
∑
h
=
0
∞
h
2
h
)
=
O
(
n
)
O(n\sum_{h=0}^{\lfloor \lg n \rfloor}\frac{h}{2^h})=O(n\sum_{h=0}^{\infty}\frac{h}{2^h})=O(n)
O(nh=0∑⌊lgn⌋2hh)=O(nh=0∑∞2hh)=O(n)
因此,我们可以在线性时间内将一个无序数组构造为一个大顶堆。
堆的排序
说了这么多,终于到了最后一步——排序。从大顶堆的性质我们不难看出,此时堆中的结点已经部分有序,稍加操作即可达成完全有序,那么又该如何操作呢?答案是还要从大顶堆的性质出发。根据大顶堆的性质,数组中的最大元素总是在根结点 A [ 1 ] A[1] A[1]中。因此,通过将它与 A [ n ] A[n] A[n]互换,可以让该元素放到正确的位置。然而我们需要注意到,在互换之后,堆的性质被破坏。为维护堆的性质,我们需要对根结点调用MAX-HEAPIFY过程。值得一提的是,此时原先的根结点 A [ 1 ] A[1] A[1]已经从堆中去除。因此,堆排序算法不断重复此过程排序完成。堆的排序示意图如下图所示。
因为每次调用BUILD-MAX-HEAP的时间复杂度为 O ( n ) O(n) O(n),而 n − 1 n-1 n−1次调用MAX-HEAPIFY,每一次的时间复杂度为 O ( lg n ) O(\lg n) O(lgn)。因此,HEAPSORT的时间复杂度为 O ( n lg n ) O(n\lg n) O(nlgn)。
堆排序的实现
好了,至此堆排序算法的相关内容介绍完毕,是时候给出堆排序的代码示例了。
import java.util.Random;
/**
* 堆排序(大顶堆)
* @author 爱学习的程序员
* @version V1.0
*/
public class HeapSort{
/**
* 返回结点i的父结点
* @param arr 构建堆的数组
* @param i 待寻求父结点的结点
* @return 结点的父结点的下标
*/
public static int getParent(int[] arr, int i){
if(i == 0)
return -1;
else
return (i - 1) / 2;
}
/**
* 返回结点i的左孩子
* @param arr 构建堆的数组
* @param i 寻求左孩子的结点
* @return 结点的左孩子的下标
*/
public static int getLeftChild(int[] arr, int i){
if(i > arr.length / 2 - 1)
return -1;
else
return i * 2 + 1;
}
/**
* 返回结点i的右孩子
* @param arr 构建堆的数组
* @param i 寻求右孩子的结点
* @return 结点的右孩子
*/
public static int getRightChild(int[] arr, int i){
if(i > (arr.length - 1)/ 2 - 1)
return -1;
else
return i * 2 + 2;
}
/**
* 构建数组arr中以i为根结点的大顶堆
* @param arr 构建堆的数组
* @param size 堆的大小
* @param i 根结点
* @return 无
*/
public static void maxHeap(int[] arr, int size, int i){
// 获取结点i的左右孩子的下标
int leftChild = getLeftChild(arr, i);
int rightChild = getRightChild(arr, i);
int max = i;
// 确定结点与其左右孩子中的最大值的下标(如果左右孩子不存在,或者左右孩子已不在堆中,则不考虑)
if(leftChild != -1 && leftChild < size && arr[leftChild] > arr[max])
max = leftChild;
if(rightChild != -1 && rightChild < size && arr[rightChild] > arr[max])
max = rightChild;
// 如果结点本身就是最大值,直接返回
if(max == i)
return;
// 否则交换结点与最大值,并且递归调用函数maxHeap以保持大顶堆性质
else{
int temp = arr[max];
arr[max] = arr[i];
arr[i] = temp;
maxHeap(arr, size, max);
}
}
/**
* 构建大顶堆
* @param arr 构建堆的数组
* @param size 堆的大小
* @return 无
*/
public static void maxHeap(int[] arr, int size){
// 从最后一个有孩子的结点出发建立大顶堆
for(int i = (arr.length - 1) / 2; i >= 0; i--)
maxHeap(arr, size, i);
}
/**
* 堆排序
* @param arr 构建堆的数组
* @return 无
*/
public static void heapSort(int[] arr){
// 堆的大小
int size = arr.length;
// 构建大顶堆
maxHeap(arr, size);
// 排序
int temp = 0;
for(int i = size - 1; i > 0; i--){
// 交换堆的第一个元素与堆的最后一个元素
temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
// 构建大顶堆
size--;
maxHeap(arr, size, 0);
}
}
public static void main(String[] args){
// 随机生成测试数组
Random rand = new Random();
int[] arr = new int[10];
//System.out.print("测试数组:");
for(int i = 0; i < arr.length ;i++){
arr[i] = rand.nextInt(100) + 1;
System.out.print(arr[i]+"\t");
}
System.out.println();
heapSort(arr);
// 堆排序
//System.out.print("排序结果:");
for(int i = 0; i < arr.length; i++)
System.out.print(arr[i]+"\t");
}
}
算法总结
- 优点
- 堆排序时间复杂度为 O ( n lg n ) O(n\lg n) O(nlgn),优于插入排序
- 堆排序具有空间原址性,在空间复杂度上优于归并排序。
- 缺点
- 堆排序算法在小规模数据表现一般。