堆
翻了翻博客,大概在前年的时候,总结过一些数据结构的知识以及使用,但是始终没有发现堆的影子。也是从去年总结到今年的常见算法合集中,遇到需要用堆来解决的算法,一筹莫展。所以将对堆的常规操作做一个总结,以便记忆。
堆的基础概念
堆是一种特殊的树。满足于下列两点要求,他就是一个堆。
- 堆是一个完全二叉树。
- 堆中每个节点的值都必须大于等于(小于等于)其子树中每个节点的值。
第一点,堆是完全二叉树。完全二叉树要求,除了最后一层,其他层的节点个数都是满的,且最后一层的节点都靠左排列。 第二点,堆中每个节点的值都必须大于等于(小于等于)其子树中每个节点的值,换种说法,堆中每个节点的值都大于等于(小于等于)其左右子节点的值。
堆可以分为两种。
- 对于每个节点的值都大于等于子树中每个节点的值的堆,叫做大顶堆
- 对于每个节点的值都小于等于子树中每个节点的值得堆,叫做小顶堆。
如何实现一个堆
实现一个堆,首先需要知道堆都支持什么操作以及如何存储一个堆。
堆的存储
堆是一个完全二叉树,用数组来存储完全二叉树是非常节省空间的,可以单纯通过数组的下标就找到一个节点的左右节点以及父节点。

从图中可以看出,数组中下标为 i 的节点,其左节点的下标为 2*i,其右节点的下标为 2*i+1,其父节点的下标为 i/2。
堆操作
插入一个元素
往堆中插入一个元素之后,依旧需要满足堆的两个特性。 需要进行调整以满足对的特性,这个过程叫做堆化。 堆化有两种方法,从下往上和从上往下。
堆化非常简单,就是顺着节点所在路径,向上或者向下对比,然后交换。 分析从下往上的堆化过程。
- 先把数据插入堆中,然后与其父节点比大小。
- 如果新节点大于父节点,就交换两个节点。
- 一直重复这个过程,直到父节点大于等于新节点。
当前过程翻译成为代码,如下:
class MaxHeap {
constructor() {
this.data = [0];
}
//插入元素
insertItem(item) {
this.data.push(item);
this.__shiftUp(this.getSize());
}
//获取堆的大小
getSize() {
return this.data.length - 1;
}
//向上堆化
__shiftUp(k) {
while (k > 1 && this.data[k] > this.data[parseInt(k / 2)]) {
this.__swap(k, parseInt(k / 2));
k = parseInt(k / 2);
}
}
//交换元素
__swap(i, j) {
[this.data[i], this.data[j]] = [this.data[j], this.data[i]];
}
}
删除堆顶元素
在堆的定义中,任何节点都大于等于(小于等于)其左右节点的值,由此可得堆顶元素就是堆中数据的最大值(最小值)。
上述代码中,我们构建的是大顶堆,堆顶就是最大元素。
删除思路:
删除堆顶元素之后,把第二大的元素放到堆顶,也就是其左右节点中较大的。迭代删除第二大节点,以此类推,直到叶子节点被删除。
这种方法会有问题,可能最后堆化出来的堆不满足完全二叉树的特性。
- 为了满足完全二叉树的特性,可以把最后一个节点放到堆顶
- 使用同样父子节点比较的方式,对于不满足关系的父子节点,交换两个节点
- 重复这个过程,直到父子关系均满足大小关系
上述步骤就是从上往下堆化法。
当前过程翻译成为代码,如下:
class MaxHeap {
//删除堆顶
deleteMax() {
if (this.getSize() === 0) return -1;
this.data[1] = this.data.pop();
this.__shiftDown(this.getSize(), 1);
}
//向下堆化
__shiftDown(len, k) {
while (true) {
let maxPos = k;
if (2 * k <= len && this.data[k] < this.data[2 * k]) maxPos = 2 * k;
if (2 * k + 1 <= len && this.data[maxPos] < this.data[2 * k + 1])
maxPos = 2 * k + 1;
if (k === maxPos) break;
this.__swap(maxPos, k);
k = maxPos;
}
}
}
如何基于堆实现排序
堆排序的过程大致分为两个步骤:建堆和排序。
建堆
将数组原地组建成一个堆。所谓’原地’就是,不借助另一个数组,在原数组上操作。 建堆有两种思路:
- 在堆中插入一个元素的思路。起初堆中只包含一个数据,就是下标为 1 的数据,调用插入操作,将下标从 2 到 n 的数据依次插入到堆中,采用从下往上堆化的的方式。
- 从后往前处理数组,并且每个数据都是从上往下堆化。
采用第二种思路,叶子节点向下堆化只能和自己本身比较,所以从第一个非叶子节点开始,依次堆化。 在完全二叉树中,小标 n/2+1 到 n 的节点是叶子节点,我们不需要堆化,下标从 1 到 n/2 的数据进行堆化。
function __buildHeap(arr, num) {
for (let i = parseInt(num / 2); i > 0; i--) {
__shiftDown(arr, num, i);
}
}
function __shiftDown(arr, len, k) {
while (true) {
let maxPos = k;
if (2 * k <= len && arr[k] < arr[2 * k]) maxPos = 2 * k;
if (2 * k + 1 <= len && arr[maxPos] < arr[2 * k + 1]) maxPos = 2 * k + 1;
if (k === maxPos) break;
__swap(arr, maxPos, k);
k = maxPos;
}
}
function __swap(arr, i, j) {
[arr[i], arr[j]] = [arr[j], arr[i]];
}
排序
建堆结束之后,数组中的数据都已经按着大顶堆的特性排列。数组中的第一个元素是堆顶,也就是最大的元素。 思路:
- 我们把它跟最后一个元素交换一下,那么最大的元素就放到下标为 n 的位置。(类似删除堆顶元素)
- 用堆化的方法,将剩下的 n-1 元素重新构建成堆。
- 再取堆顶元素,放到下标 n-1 的位置,一直重复这个过程,直到堆中只剩下标为 1 的元素,排序完成。
function sort(arr, num) {
__buildHeap(arr, num);
let len = num;
while (len > 1) {
__swap(arr, len, 1);
len--;
__shiftDown(arr, len, 1);
}
}
堆排序包括建堆和排序两个操作,建堆的复杂度是 O(n),堆排序的复杂度是 O(nlogn),所以整体的复杂度为 O(nlogn)。 堆排序不是稳定的排序算法,因为在排序的过程中,堆嘴后一个元素和堆顶节点交换,可能改变值相同数据原始的相对顺序。
总结
堆是一种完全二叉树。最大的特性是:每个节点的值大于等于(小于等于)其子树节点的值。因此分为大顶堆和小顶堆。
堆中最重要的操作是插入数据和删除堆顶数据。插入数据时,把新数据放到最后,从下往上堆化。删除堆顶元素时,把最后一个元素放到堆顶,从上往下堆化。两者的复杂程度都是 O(logn)。
堆排序包含两个过程:建堆和堆化。建堆时,将下标从 n/2 到 1 的节点依次从上到下的堆化操作,然后可以把数组中的数字变成堆这种数据结构。排序时,堆顶的元素放到堆的末尾,并将堆的大小减一,然后再堆化,重复这个过程,直到堆中只剩下一个元素。