在讨论堆的两个基本操作之前,我们首先要弄清楚什么是堆。堆是树的一种,什么样的树可以称之为堆?需要满足以下两个条件:
- 堆是一个完全二叉树
- 堆中任意一个节点的值都必须大于等于(或者小于等于)其子树每个节点的值
1. 往堆中插入一个元素(也是建堆的过程)
从下往上的方法实现(大顶堆):
- 主要的时间消耗在于堆化操作,所以时间复杂度为o(logn)
- 空间复杂度为o(1)
public class Heap{
int []a; //存储着一个堆,从下标1开始存储数据
int n; //堆可以存储的元素最大个数
int count; //现有的堆中元素个数
public Heap(int capacity){
this.a = new int[capacity];
this.n = capacity
this.count = 0;
};
public void insert(int value){
if(count >= n) return; //表示堆满
a[++count] = value;
int i = count;
while(i/2 > 0 && a[i] > a[i/2]){ //不断与跟节点对比,若大于根节点,则向上替换
swap(a, i, i/2);
i = i/2;
}
};
public void swap(int[]a, int i, int j){
int temp = a[i];
a[i] = a[j];
a[j] = temp;
};
}
2. 从堆中删除堆顶元素
从堆需要满足的第二个条件,我们可以得知,堆顶元素要么是最大值,要么是最小值。删除堆顶元素,也就是删除堆中的最小值或者最大值。为了维持堆的特性,我们可以采取以下措施:
将堆中的最后一个元素放在堆顶,然后从上往下进行调节,如果父子节点不满足大顶堆(或小顶堆)的定义,则继续往下,直至满足条件,具体代码实现如下:
- 主要的时间消耗在于堆化操作,所以时间复杂度为o(logn)
- 空间复杂度为o(1)
class Solution{
public void removeMax(int[] a, int count){
a[1] = a[count--]; //将根节点的值替换为大顶堆的最后一个值,并将count--
heapify(a, count, 1);
};
public void heapify(int[]a, int count, int i){
while(true){
int maxChild = i;
if(2*i <= count && a[2*i] > a[i]){ //若左子节点大于根
maxChild = 2*i;
}
if((2*i+1) <= count && a[2*i+1] > a[i]){ //若右子节点大于根
maxChild = 2*i + 1;
}
if(i==maxChild){ //子节点的值都小于根时,已经调整完成
break;
}
swap(a, i, maxChild);
i = maxChild; //继续调整过程
}
};
public void swap(int[]a, int i, int j){
int temp = a[i];
a[i] = a[j];
a[j] = temp;
};
}
3. 堆排序的过程
堆排序需要两个步骤:
- 建堆,采用从下往上的步骤建堆,其时间复杂度为o(n)(其推导过程可以使用递归树进行分析)0,空间复杂度为o(1)。
- 排序,输出根节点,再重新建堆的过程。时间复杂度为o(nlogn),空间复杂度为o(1)
- 堆排序的过程中存在堆的最后一个元素和交换堆顶元素的过程,这有可能会改变相等的元素的相对顺序,所以堆排序并不是稳定的
class Solution{
public void sort(int[] a,int count){
buildHeap(a, count); //建堆过程
//排序过程
int k = count;
while(k > 0){
swap(a, k--, 1); //将最大值置于数组末尾,并将待排序数组长度减1
heapify(a, k, 1); //恢复大顶堆
}
};
public void buildHeap(int[]a, count){
for(int i = count/2; i>0; i--){
heapify(a, count, i); //类似于删根节点后,将最后一个节点拿到第一个节点,并向下调整的过程,由于完全二叉树的性质,只需要从非叶子节点开始调整
}
}
public void heapify(int[]a, int count, int i){
while(true){
int maxChild = i;
if(2*i < count && a[i] < a[2*i]){
maxChild = 2*i;
}
if((2*i+1) < count && a[i] < a[2*i+1]){
maxChild = 2*i +1;
}
if(maxChild == i)
break;
i = maxChild;
}
};
public void swap(int[]a, int i, int j){
int temp = a[i];
a[i] = a[j];
a[j] = temp;
};
}
4. 思考:堆排序和快速排序谁更适合于实际的应用,换句话说堆排序和快排谁的性能更好?
从时间复杂度上来说,堆排序的时间复杂度稳定在o(nlogn),快速排序在最坏情况下时间复杂度会从o(nlogn)退化到o(n^2),仅仅从时间复杂度的角度考虑,似乎堆排序要优于快速排序,事实上是这样吗?
其实实际应用中,快排的性能是要比堆排序更好的,原因如下:
- 堆排序的数据访问方式不如快排友好。快排的数据访问是局部顺序的,而堆化后的数据访问是跳跃式的,这对cpu的缓存是不友好的。
- 同样的数据,堆排序中数据需要交换的次数要多于快速排序。主要原因在于,建堆的过程中会将原本有序的数组变得杂乱无序,数组本身的有序程度在建堆时反倒是下降了。
- 快排性能退化到o(nlogn)是小概率事件,并且我们还有随机法、三取数法等方法减少快排性能退化的概率,所以实际应用中在基于比较的排序算法里面,快排是非常高效的。