目录
一、基础堆排序
首先我们用上一篇实现的二叉堆,来实现一个基础的堆排序算法。
上一篇链接:
堆(Heap)的详解和二叉堆(Binary Heap)的代码实现
二叉堆(最大堆)具体的实现代码如下:
/**
* 在堆的有关操作中,需要比较堆中元素的大小,所以E extends Comparable
* @param <E>
*/
public class MaxHeap<E extends Comparable> {
protected E[] data;
protected int count;
protected int capacity;
/**
* 构造函数,构造一个空堆,可容纳capacity个元素
* @param capacity
*/
public MaxHeap(int capacity){
data = (E[]) new Comparable[capacity + 1];
count = 0;
this.capacity = capacity;
}
/**
* 返回堆中的元素个数
* @return
*/
public int size(){
return count;
}
/**
* 返回一个布尔值,表示堆中是否为空
* @return
*/
public boolean isEmpty(){
return count == 0;
}
/**
* 向最大堆中插入一个新的元素
* @param t
*/
public void insert(E e){
if(count < capacity){
data[count + 1] = e;
count++;
shiftUp(count);
}
}
/**
* 最大堆核心辅助函数,把新插入的元素,移动到合适的位置。
* @param k
*/
private void shiftUp(int k){
while(k > 1 && data[k / 2].compareTo(data[k]) < 0){
swap(k / 2, k);
k = k / 2;
}
}
/**
* 取出最大堆中,最大的元素
* @return
*/
public E extractMax(){
if(count <= 0){
return null;
}
E e = data[1];
swap(1, count);
count --;
shiftDown(1);
return e;
}
/**
* 最大堆核心辅助函数,把根节点的元素,移动到合适的位置。
* @param k
*/
private void shiftDown(int k){
while(k * 2 <= count){ //判断节点k是否有子节点,只要有右节点就一定有子节点
int j = k * 2; //在此轮循环中,data[k]和data[j]交换位置
if(j + 1 <= count && data[j + 1].compareTo(data[j]) > 0){
//有右子节点,并且右子节点元素比左子节点元素更大。
j ++;
}
// data[j] 是 data[2*k]和data[2*k+1]中的最大值
if(data[k].compareTo(data[j]) >= 0){
//节点k已经比它的所有子节点更大,不需要交换位置了。
break;
}
swap(k, j);
k = j;
}
}
private void swap(int i, int j){
E temp = data[i];
data[i] = data[j];
data[j] = temp;
}
}
由于最大堆的特性,插入和取出操作后,都会保证当前堆中最大的元素在根节点上。
所有只需要将所有的元素依次添加到堆中, 再将所有元素从堆中依次取出来,每次取出的元素都是堆中剩余元素的最大值,即完成了排序。
具体是实现代码如下:
public class HeapSort1 {
/**
* 对整个arr数组使用HeapSort1排序
* HeapSort1, 将所有的元素依次添加到堆中, 在将所有元素从堆中依次取出来, 即完成了排序
* 无论是创建堆的过程, 还是从堆中依次取出元素的过程, 时间复杂度均为O(nlogn)
* 整个堆排序的整体时间复杂度为O(nlogn)
* @param arr
*/
public void heapSort(int arr[]){
int length = arr.length;
MaxHeap<Integer> maxHeap = new MaxHeap<Integer>(length);
for(int i = 0; i < length; i++){
maxHeap.insert(arr[i]);
}
//由于每次从最大堆中取出都是最大值,而我们是需要从小到大排序,
//所以从数组的最后一个元素开始遍历赋值。
for(int i = length - 1; i >= 0; i--){
arr[i] = maxHeap.extractMax();
}
}
}
我们简单的分析一下上面实现的堆排序的时间。代码中进行了两层循环,每层循环都要对n个元素进行遍历,每次遍历无论是insert操作还是extractMax操作,它们的时间复杂度都是O(logn),所以整体的时间复杂度O(nlogn)。
堆操作中Shift Up或者Shift Down,都是从下往上或从上往下依次每层移动,直到找到合适的位置,都是和层数相关的,最差的情况是要移动logn层,所以时间复杂度O(logn)。
这里我们用堆排序算法和其他几种排序算法做对比测试,测试结果如下:
对一百万个完全随机的数据进行排序:
MergeSort: 1000000 true 202ms
QuickSort2Ways: 1000000 true 106ms
QuickSort3Ways: 1000000 true 125ms
HeapSort1: 1000000 true 1122ms
对一百万个近乎有序的数据进行排序:
MergeSort: 1000000 true 67ms
QuickSort2Ways: 1000000 true 33ms
QuickSort3Ways: 1000000 true 65ms
HeapSort1: 1000000 true 549ms
对一百万个有大量重复数据的数据进行排序:
MergeSort: 1000000 true 140ms
QuickSort2Ways: 1000000 true 50ms
QuickSort3Ways: 1000000 true 18ms
HeapSort1: 1000000 true 343ms
从上面的测试结果中,我们可以看到堆排序对于每一种测试用例,都会比其他几个排序算法慢一些。但是整体的速度还是可以接受的,可以在比较快的时间里,对一百万个数据完成排序,因为堆排序本身也是一个O(nlogn)级别的算法。
二、Heapify
上面我们实现的堆排序,是将数组中所有的元素,使用最大堆提供的插入方法,依次插入到堆中。接下来我们来优化堆排序算法,事实上将整个数组构建成一个堆的过程,有一个更好的方式。下面我们来看一下这个更好方式的动态演示。
给定一个数组,我们让这个数组的排列形成一个堆的形状,我们称这个过程为Heapify。下面我们来举一个例子,看看这个过程到底是怎么实现的。
现在假设我有这样一个数组,大家可以看到这个数组索引和对应的元素值,我们可以根据二叉堆的定义(从上到下,从左到右给二叉树中所有的节点编号。节点的编号就对应着数组中的索引,索引从1开始),把这个数组构建成一个完全二叉树,如下图。
现在的这颗完全二叉树还不是堆,它并不满足任意节点都不大于父节点。不过大家应该可以观察到这一点,对于当着这样的二叉树来说,所有的叶子节点(没有子节点的节点)本身就是一个最大堆。那么在这个例子中,如下图蓝色的叶子节点,一共有5个最大堆,没有堆中的元素只有一个。
在这里大家要注意一个性质,对于一颗完全二叉树来说,第一个非叶子节点的索引是什么呢?是这颗完全二叉树的元素个数除于2得到的索引值。如下图的当前例子 10/2=5。
我们下面从后往前的考察每一个不是叶子节点的节点,我们第一个要考察的就是元素22。现在以元素22为根节点的所构成的子树,不满足堆的性质,那么我们这么样让它满足堆的性质呢?其实非常简单,我们只需要对元素22做Shift Down操作。在这里元素22比子节点元素62小,交换一下两个元素的位置。此时我们的数组就变成如下样子。这时索引5为根节点的子树也满足了堆的性质。
下面我们来考察元素13,依然是对该元素执行Shift Down操作,比较它的两个子节点元素30和元素41,元素41比元素30大,并且元素41比父节点元素13大,所有元素41和元素13交换位置。这时索引4为根节点的子树也满足了堆的性质。
然后接下来依次对剩余的其他子节点做Shift Down操作,得到如下最大堆。
总结一下
- 根据二叉堆的定义,把这个数组构建成一个完全二叉树。
- 然后从后往前依次遍历所有的非叶子节点的节点,对每次遍历到的节点做Shift Down操作。
- 最后就得到我们的构建好的堆。
具体实现代码如下:
在上面发的MaxHeap类中新加一个构造方法,传入一个数组,实现Heapify过程。
/**
* 构造函数,通过一个给定数组创建一个最大堆。
* Heapify的过程
* @param arr
*/
public MaxHeap(E[] arr){
if(arr == null || arr.length == 0){
return;
}
int length = arr.length;
//创建一个长度为length + 1的数组
data = (E[]) new Comparable[length + 1];
capacity = length;
//把传入的数组赋值给新建的数组,从下标1开始
for(int i = 1; i <= length; i++){
data[i] = arr[i - 1];
}
count = length;
//对所有的非 叶子节点做shiftDown操作,使这些数组满足堆的性质
for(int i = count / 2; i >= 1; i--){
shiftDown(i);
}
}
调用新增的构造方法,实现堆排序。
public void heapSort2(int arr[]){
int length = arr.length;
MaxHeap<Integer> maxHeap = new MaxHeap<Integer>(arr);
//由于每次从最大堆中取出都是最大值,而我们是需要从小到大排序,
//所以从数组的最后一个元素开始遍历赋值。
for(int i = length - 1; i >= 0; i--){
arr[i] = maxHeap.extractMax();
}
}
测试对比测试两种堆排序的实现,heapSort2的效率要比heapSort1高,这就是不同的建堆效果所引起来的性能差异。
但是测试发现堆排序的时间效率依然不如归并排序和快速排序,正是因为如此,在系统级别实现的排序算法中,很少有使用堆排序的,更多的是使用快速排序。堆这种数据结构更多的是用于动态数据的维护。
大家可能就有疑问,为什么第二种建堆过程要比一个一个将元素插入进堆中的速度要快呢?这里给出大家一个结论(严格的数学证明有些难度,不在这里扩展说明)。
将n个元素逐个插入到一个空堆中,创建一个含有n个元素的堆,时间复杂度是O(nlogn)。
heapity的过程,从后向前、从n/2的位置开始,逐步遍历到第一个元素,依次进行Shift Down过程,使得一个数组整体变成一个堆的过程,时间复杂度是O(n)。
这里我们介绍了一个更快的算法heapify过程,直接将整个数组构建成堆。同时尝试写了两个版本的堆排序。大家可以看到在这两个版本的堆排序中,都需要将整个数组的元素依次放入的堆中,实际上新开了一整个堆的额外空间,空间复杂度O(n)。但是事实上对于堆排序来说,我们可以非常容易的改造,使得我们不使用任何额外的空间,原地对这个数组的元素进行排序。下面将继续优化堆排序算法,来达到这一目的。
三、原地堆排序
前面我们所介绍的堆排序算法,都需要先把数组的元素放入堆中,再从堆中取出来。整个操作开辟了n个元素的额外空间,用来创建堆。但是事实上我们依靠堆排序的思想,完全可以让整个数组的排序在原地进行的,而不需要任何的额外空间。下面我们就要介绍这个实现。
通过上面构造堆的过程,一个数组我们就可以把它看成是一个堆,因此我们对于一个数组来说,可以通过之前介绍的heapify过程,将他构建成一个最大堆。在这个最大堆中,第一个位置的元素就是整个数组中最大值的那个元素。对于这个最大的元素,他显然应该放在整个数组末尾的位置。比如说现在整个数组末尾的元素是w。我们要做的事情就是将元素v和元素w交换一下位置,这样一来元素v就已经放在排好序数组的正确位置。也是元素v作为整个数组中最大的元素,已经放在了数组最后位置。
但是此时由于我们把元素w放在第一的位置上,现在整个数组前部分已经不是一个最大堆。因为元素w不是最大值,不满足最大堆的特性。此时大家应该想到了,对元素w位置的索引做Shift Down操作,将橙色部分的数组再次转变成一个最大堆。
这样一来,此时前面部分最大的元素v又在第一位置,堆末尾的元素w在整个数组倒数第二的位置,前面部分最大堆不包括蓝色部分。这时候又是将元素v和元素w交换一下位置。
此时这个元素v放到了排好序后的正确位置,倒数第二个位置。而前面部分由于元素w的存在不是最大堆了。再次对元素w所在的索引做Shift Down操作。让前面部分数组满足最大堆的性质,成为一个最大堆。
整个过程以此类推,所有的元素从后向前逐步依次排好序,直到整个数组都排序完成。
这个过程我们应用最大堆的原理,直接在这个数组上原地进行操作,整个算法的空间复杂度是O(1)。
由于我们整个排序过程直接在数组上进行的,而数组的索引通常是从0开始的,所以我们在编写相应的算法(主要是heapify过程和Shift Down过程),在索引左子节点、有子节点、父节点和最后一个非叶子节点的时候,就要根据从0开始索引进行一定的调整。大家可以看如下图和公式。
最后一个非叶子节点的索引 = (最后一个元素的索引-1)/2开始 = (count - 1 - 1)/2
根据这个规律,我们可以改写之前实现的堆这个类,这个堆中的元素是从0开始的。我们前面实现的堆是从1开始索引, 因为这是堆很经典的实现,而且这个规律更好观察。但是等大家熟练掌握了以后,也完全可以使用从0开始索引的数组,来实现一个堆。
使用堆的基本思想进行原地排序的基本思路已经介绍完了,下面看一下具体的代码实现。
/**
* 不使用一个额外的最大堆,直接在原数组上进行原地堆排序
*/
public class HeapSort {
public void heapSort(int[] arr){
int n = arr.length;
//heapify过程,将整个数组构建成一个最大堆。
//此时堆的是从0开始索引的,最后一个非叶子节点的索引
for(int i = (n - 1 - 1) / 2; i >= 0; i--){
shiftDown(arr, n, i);
}
for(int i = n - 1; i > 0; i--){
swap(arr, 0, i);
shiftDown(arr, i, 0);
}
}
/**
* 堆中Shift Down操作
* @param arr
* @param n
* @param k
*/
private void shiftDown(int[] arr, int n, int k){
while(2 * k + 1 < n){ //当前节点至少存在一个子节点(左子节点)
int j = 2 * k + 1;
if(j + 1 < n && arr[j + 1] > arr[j]){
//如果有右子节点,并且右子节点比左子节点更大
j += 1;
}
if(arr[k] >= arr[j]){
//如果当前节点大于等于最大的子节点,则退出循环。
break;
}
//如果当前节点小于最大的子节点,则交换位置
swap(arr, k, j);
k = j;
}
}
/**
* 交换数组中的两个元素
* @param arr
* @param i
* @param j
*/
private void swap(int[] arr, int i, int j){
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
这里实现的heapsort,比上面两个实现的heapsort效率更高。这是因为新的heapsort不需要额外的空间,也不需要对这些额外的空间进行处理(开辟新空间和往新空间里面赋值)。
四、总结
上面我们分析堆排序算法,并且一步一步的优化堆排序算法的实现,降低算法的时间复杂度和空间复杂度,最终得到一个比较完善的堆排序算法。
我们在解决实际算法问题的时候,在没有更好的思路的时候,也不妨先使用简单的暴力解法,然后再考虑一步一步的去优化暴力解法。