1. 什么是堆
堆的概念主要有 2 点:
- 堆是一个完全二叉树;
- 堆中每个节点的值都大于等于(或小于等于)其左右子树节点的值;
如果根节点是最大值则是大顶堆,如果根节点是最小值则是小顶堆。
2. 堆的2种常用操作
由于堆是一个完全二叉树,所以堆可以用一个数组来表示。如果数组 A 的下标从 0 开始存储数据,则 A[0] 表示根节点,A[1] 是其左子节点,A[2] 是其右子节点。进而可以得到:对于节点 A[i],其左子节点为 A[i*2+1],右子节点为 A[i*2+2],其父节点为 A[(i-1)/2]。
堆有 2 种常用操作:增加一个元素、删除堆顶元素。这里涉及到一个堆化
的概念,堆化就是调整二叉树的节点的值,让其满足堆的特性的过程。
具体可见代码及说明(kotlin编写):
class Heap(private val capacity: Int) {
private val array = IntArray(capacity)
//当前堆里的数据个数
private var count: Int = 0
/**
* 构造的是大顶堆
* 自下而上堆化:先将数据插入到叶子节点上,然后将该节点与其父节点比较交换,直到满足堆的特性为止
*/
fun insert(value: Int) {
if (count == capacity) {
return
}
array[count++] = value
var p = count - 1 //插入的节点在数组中的索引
var pp = (p - 1) / 2 //父节点索引
while (p > 0 && array[pp] < value) {
array[p] = array[pp]
p = pp
pp = (p - 1) / 2
}
array[p] = value
}
/**
* 删除堆顶元素
* 自上而下堆化:将节点与其子节点比较交换,直到满足堆的特性为止
*/
fun removeTop(): Int? {
if (count == 0)
return null
//堆顶元素就是数组的第一个元素
var top = array[0]
//将最后一个元素与堆顶元素交换
array[0] = array[--count]
//直接删除最后一个元素
array[count] = 0
if (count == 0)
return top
//自上而下进行堆化
var p = 0
var target = array[0]
while (p < count) {
var maxIndex = p
//左子节点索引
var left = p * 2 + 1
//右子节点索引
var right = left + 1
if (left < count && array[left] > target) {
maxIndex = left
}
if (right < count && array[right] > target) {
maxIndex = right
}
if (p == maxIndex)
break
array[p] = array[maxIndex]
p = maxIndex
}
array[p] = target
return top
}
}
堆的插入、删除操作时间复杂度都为 O(logn):插入、删除节点都是一个堆化的过程,其实就是节点进行比较交换的过程,如果从根节点开始操作,一直到叶子节点才结束,则比较交换的次数与完全二叉树的高度相关,而完全二叉树的高度为 log₂n,所以说其时间复杂度为 O(logn)
3. 堆排序
1.先在原数组内建堆,如果要升序排列则建一个大顶堆。建堆有 2 种方式:
- 假设堆里只有1个元素,我们将数组里剩余 n-1 个元素依次插入到堆里,插入完成后则建堆完毕;
- 对于一个堆来说,任意一个节点以及其子树节点合在一起点肯定也是一个堆,每个叶子节点单独来说肯定是一个堆。从堆的最后一个非叶子节点起到根节点,我们依次对每个节点进行堆化操作,操作完成后堆就形成了。
2.大顶堆的第一个元素肯定是最大值,我们每次删除堆顶元素,然后再重新堆化,一直到堆里只有一个元素为止,这时候数组排序就排好了。
代码如下(kotlin编写):
/**
* 堆排序
*/
fun heapSort(array: IntArray?) {
array ?: return
if (array.isEmpty())
return
//如果是升序排列,则构造大顶堆,如果是降序排列,则构造小顶堆
buildHeap(array)
//每次从大顶堆中取出最大的数,放到最后面,共需要 n - 1 次
var c = 0
//对于数组A[0, 1, 2, ..., n-1],现在是一个大顶堆
//1.交换A[0]与A[n-1],将A[0, 1, 2, ..., n-2]进行堆化操作
//2.交换A[0]与A[n-2],将A[0, 1, 2, ..., n-3]进行堆化操作
//3.依次反复操作,直到只有一个元素时停止,整个数组就排序完成了
while (c < array.size - 1) {
c++
swap(array, 0, array.size - c)
//剩下的数据继续堆化
heapify(array, 0, array.size - c - 1)
}
}
//先原地建堆:叶子节点本身就是一个堆,所以我们从非叶子节点开始往前到根节点,依次进行堆化操作
private fun buildHeap(array: IntArray) {
var n = array.size
//用数组表示的完全二叉树中,非叶子节点索引值范围约为[0, n/2]
//从 n/2 节点开始从后往前,依次对每个节点都执行堆化操作,最终可完成建堆,这种建堆方式效率高一点
for (i in n / 2 downTo 0) {
heapify(array, i, n - 1)
}
}
/**
* 进行堆化
*
* @param array 数组
* @param start 起始索引
* @param end 结束索引
*/
private fun heapify(array: IntArray, start: Int, end: Int) {
var p = start
while (p < end) {
//在堆中左子节点对应数组中的索引,注意这里我们数组索引是从0开始存数据的
var left = p * 2 + 1
//在堆中右子节点对应数组中的索引
var right = left + 1
//将当前节点值与左、右子节点的值进行比价,找出最大的
var maxIndex = p
if (left <= end && array[left] > array[maxIndex]) {
maxIndex = left
}
if (right <= end && array[right] > array[maxIndex]) {
maxIndex = right
}
if (p == maxIndex)
break
//将大的节点值交换上去
swap(array, p, maxIndex)
p = maxIndex
}
}
private fun swap(array: IntArray, i: Int, j: Int) {
var tmp = array[i]
array[i] = array[j]
array[j] = tmp
}
4. 小结
堆排序首先需要了解堆的概念,而堆是一个完全二叉树,完全二叉树又可以直接用数组来表示。所以要了解堆排序算法,必须先掌握二叉树的相关知识,以及树的相关概念操作等,否则理解起来会很费劲。
堆排序空间复杂度 O(1),我们是直接在原数组里建堆,不需要额外空间
堆排序时间复杂度 O(nlogn)
堆排序时会涉及到堆顶节点跟最后一个节点的交换,可能会使得相同值的节点先后顺序改变,所以堆排序是不稳定的。
堆排序与快排时间复杂度、空间复杂度是相同的,但是堆排序涉及到大量的比较交换操作,相对来说堆排序没有快排速度快。