堆排序与之前提到的希尔排序,归并排序,快速排序是完全不同的排序算法,堆排序是唯一同时在空间和时间两个方面都有较优性能的算法,在空间上,不使用额外的空间,在时间上,又尽可能的快.
学习堆排序,需要了解二叉树 的概念.两点:
堆结构
堆序性
首先说堆结构,堆是一个完全二叉树,首先它必须是一颗二叉树,所谓二叉树,就是任意节点的子节点不超过两个,然后完全二叉树是在二叉树的结构上附加了更加严格的条件,在每一层,节点都是从左到右填充和摆放,结果就像下面这样 :
上面的这个堆,总共有4层,从上到下,每一层的元素个数分别为:1,2,4,2,如果最后一层的元素铺满,应该是1,2,4,8,也就是每一层的元素应该是个元素(根节点为第 0 层),这样就从结构上满足了堆结构上的要求.
这就是一颗树的结构,接下来就是如何用数据结构来表示它了,如果用树的结构来描述,对与每个节点而言,他即可能与父节点比较,也可能会与子节点比较,所以对任意节点而言,必须为每个节点准备三个引用,一个指向父节点,两个指向子节点.我们当然不会用这种方式来存储,因为还有另外一种更好的方式来表示堆结构,并且不需要这额外的三个引用,那就是数组,根据堆结构的堆结构性,我们容易的发现,如果根节点的索引从1开始,那么他的子节点的索引分别为2,3,类推,也就是:
索引1的子节点的索引为2,3
索引2的子节点的索引为4,5
索引3的子节点的索引为6,7
......
索引k的子节点的索引为2k, 2k+1.
同理索引k的父节点的为k/2
完全省略了三个引用的存储空间,而我们只需要将索引的使用从1开始,就可以做到!
再来说堆序性,从根节点出发到任意一个叶子节点的路径上,元素是从大到小的,也就是说对于任意的节点(如果有子节点),该节点的值都不小于它的子节点的值,并且不大于它的父节点.反之亦然。根节点最大,元素是从大到小。
下面我们所讲以及算法实现,都是根节点最小,按照从上到下递增的顺序排列。
只要满足上述两点,则我们可以称之为堆。而且在操作堆的过程中,比如创建堆,或者插入元素,或者删除最小元素,也必须时刻保持堆的堆结构性和堆序性保持不变,但事实上,每次操作堆的元素时,一定会打破这两个平衡条件!那么必须做出其他某种操作,直到堆满足上述两种特性才行。
这两种操作我们叫做上浮(上滤)和下沉(下滤),下面统一就叫上浮和下沉.
插入元素
删除最小元素
先说插入元素,为了保持完全二叉树的状态,我们在堆的最底层最右边插入元素,此时在结构上满足了堆的性质,但是有可能破坏了堆序性,如果插入的节点比父节点小,则它需要和父节点交换,然后不停的递归朝着根节点上浮,直到大于父节点时,到达合适的位置。
上浮操作的实现为(注意,这里是按照从上到下,递减的顺序):
private void swim(int[] array, int k) {
while (k > 1 && less(array,k/2, k)) {
exchange(array, k, k/2);
k = k/2;
}
}
每次与父节点比较,如果大于父节点,则交换
less实现为:
private static boolean less(int[] array, int j, int k) {
return array[j - 1] < array[k - 1];
}
exchange()实现为:
private static void exchange(int[] array, int j, int k) {
int temp = array[j - 1];
array[j - 1] = array[k - 1];
array[k - 1] = temp;
}
注意索引都是从1开始的.
再说删除最小元素,也就是删除了根节点,则堆结构被破坏了,我们需要将根节点的两个子节点中较小的一个上移,这样空出来的这个位置,继续从两个子节点中选择较小的上移,直到在最后一层,我们使用最后一个元素,填充空出来的元素的位置来,保证堆的堆结构性.
删除最小元素的前提是堆已经有序,删除最小元素后,堆结构被破坏,需要将该节点下面的子元素上浮,才能再次让堆恢复堆结构,同时保持堆序性.
实现如下(假设堆从上到下为递增序列):
//assume the value of root node is the minimum
private static void deleteMin(int[] array) {
int length = array.length;
int lastElement = array[length - 1];
int child , k;
for (k = 0; 2 * k < length; k = child) {
child = 2 *k +1;
if(child + 1 > length) break;
if ( array[child] > array[child + 1]) child++;
if (array[child] < lastElement) {
array[k] = array[child];
}
}
array[k] = lastElement;
array[length - 1] = -1;
}
对于任意节点,下沉操作的实现为:
private static void sink(int[] array, int p, int length) {
while (2 * p <= length) {
int child = 2 * p;
if (child < length && less(array, child, child + 1)) child++;
if (!less(array, p, child)) break;
exchange(array, p, child);
p = child;//move to next layer
}
}
下面继续讨论从上到下的递减堆的上浮的过程图为(地址):
位置T破坏了堆序性,则上浮
下沉过程图为:
位置H破快了堆序性,则下沉
文章开始介绍插入元素和删除最小元素
下面给出插入元素和删除最大元素的操作过程(堆序为从上到下,递减序列)
左边在最底层插入元素的上浮过程,右边为删除根节点后的再次恢复堆的过程
注意右边的删除根节点后,再次恢复堆的过程,这是利用堆排序的核心!
上面讲了这么多,如果有不懂的,请自行查阅相关的资料,务必了解相关的概念,如果不了树结构,以及上面提到的上浮和下沉的概念以及具体过程,下面讲排序过程,可能理解会比较困难.
下面开始根据上面铺垫的基础概念和原理来展开堆排序的实现:
- 构造堆
- 删除最大元素,并再次恢复堆
- 重复上一步
构造堆和删除最大元素并恢复堆的示意图如下:
按照对排序的过程,首先要构造堆,从二位数组转化成一个有序堆,我们只需要完成两个事情:
堆结构性
堆序性
数组天然就满足堆结构性,这一点从索引的换算关系就可以看出,可以很容易的通过k/2, k, 2*k,2*k+1,来定位索引为k的节点的父节点k/2, 两个子节点2*k,2*k+1。
堆序性就更容易处理了,前面我们讲过了下沉和上浮的算法:
上浮:
private void swim(int[] array, int k) {
while (k > 1 && less(array, k / 2, k)) {
exchange(array, k, k / 2);
k = k / 2;
}
}
上浮操作的方向是索引朝根节点的方向前进,由k=k/2可知
下沉:
private static void sink(int[] array, int p, int length) {
while (2 * p <= length) {
int child = 2 * p;
if (child < length && less(array, child, child + 1)) child++;
if (!less(array, p, child)) break;
exchange(array, p, child);
p = child;//move to next layer
}
}
下沉操作的方向是从根节点向底部前进,由 child=2*p, p=child 可知
那么要构造堆,我们只需要从左往右(根节点除外),对数组中的每个元素进行上浮操作即可正确构造堆。并且可以在的时间内完成。
但我们知道堆的上浮或者下沉操作涉及到上下两层之间的元素比较,所以我们不必扫描所有的元素,我们利用下沉操作sink(),从右往左开始扫描,并且因为下沉操作是k与2*k 的元素比较,并且方向是从上往下,所以我们不必扫描最底层的元素也就是第层的元素,那么我们从 N/2 的位置开始从右向左扫描,这样下沉操作就会自然覆盖到所有元素。
所以我们的堆的构造为:
//heap construction
int length = array.length;
for (int k = length / 2; k >= 1; k--)
sink(array, k, length);
记住堆构造的优化是从 length/2开始的。
接下来就是排序了,先看图:
首先我们构造完堆后,是一颗从上到下递减的完全二叉树,根节点的元素最大。于是:
将根节点元素与最后一个元素交换,然后对根节点使用下沉操作以恢复堆序性,
将根节点元素与倒数第二个元素交换,然后对根节点使用下沉操作以恢复堆序性,
。。。。。。
最后将根节点与第三个元素交换,然后对根节点使用下沉操作以恢复堆序性
最后将根节点与第二个元素交换,然后对根节点使用下沉操作以恢复堆序性
其中很重要的一个点时,每次实现下沉操作时,下沉的数组的长度都要减一(length--),因为那个元素已经被堆中当前最大元素占用(空间利用的秘诀也就在这里)。
排序实现为:
//sort array
while (length > 1) {
exchange(array, 1, length--);
sink(array, 1, length);
这样当遍历到数组的最前端时,堆已经不复存在,而数组已经按照从小到大的顺序排好了序。
整个过程与开头我们将的思路一样,将根节点(最大元素)删除,然后恢复堆,循环往复,我们就从一个不断变小直到消失的堆从获得了一个排序的数组。
核心代码为:
public static void sort(int[] array) {
//heap construction
int length = array.length;
for (int k = length / 2; k >= 1; k--)
sink(array, k, length);
//sort array
while (length > 1) {
exchange(array, 1, length--);
sink(array, 1, length);
}
}
完整实现的代码地址为: 堆排序