碎碎念🤥
太久太久没有写点东西了,两个原因:
- 懒(不是)
- AI 时代下,真的还有人用这种形式去获取知识吗? 但是最近感觉,越是 AI 崛起的时代,打牢基础知识越发的重要,不然你可能会被 AI 生成的代码耍得团团转。
想起很久之前(三年前),挖了一个算法图解的坑,其中介绍了几种常见的数组排序算法:
还差一个堆排序。然后正好最近阅读React 源码的时候也会用到,天时地利人和,所以就把这个坑给填一下。
在这之前,我们先来做一点前置知识的补充。
前置知识:二叉树
堆排序的本质是把数组转成一个完全二叉树 ,然后使用二叉树的形式对数组进行排序,找到数组中的最小值(最大值)。和冒泡排序等数组排序方法不同,堆排序只关注如何快速的拿到最小值(最大值) ,不在乎剩余的其他值的顺序。

使用 javascript
代码,构建一棵二叉树:
javaScipt
class TreeNode {
constructor(val) {
this.val = val;
this.left = null;
this.right = null;
}
}
function buildBinaryTree(arr) {
if (!arr.length) return null;
const root = new TreeNode(arr[0]);
const queue = [root];
let i = 1;
while (i < arr.length) {
const current = queue.shift();
if (i < arr.length) {
current.left = new TreeNode(arr[i++]);
queue.push(current.left);
}
if (i < arr.length) {
current.right = new TreeNode(arr[i++]);
queue.push(current.right);
}
}
return root;
}
// 示例使用
const tree = buildBinaryTree([1, 2, 3, 4, 5]);
构造完成后,可以得到一个二叉树对象如下:
json
{
"val": 1,
"left": {
"val": 2,
"left": {
"val": 4,
"left": null,
"right": null
},
"right": {
"val": 5,
"left": null,
"right": null
}
},
"right": {
"val": 3,
"left": null,
"right": null
}
}
前置知识:什么是堆?

堆就是排好序的完全二叉树。 小顶堆每个父节点都要小于两个子节点,根节点的值最小的一种二叉树。
上面的 demo 中的二叉树[1,2,3,4,5],使用二叉树直接构造完成后,就是一个小顶堆。但是如果是一串没有排好序的混乱数字呢?如何做堆的初始化?
堆排序
我们使用 [9, 5, 6, 2, 3, 8, 1, 7, 4] 作为初始的二叉树。如何让这个二叉树变成一个最小堆?
堆构建
在闷头直接钻进代码之前,我们先用示例图来理解整个过程。
初始状态:

我们需要让每个父节点小于其子节点,怎么下手?
如果我们从顶部开始操作,去处理第一层和第二层节点,也就是[9,5,6] 三个节点,那么我们会把 5 放在顶部,但是明显 5 并不是整棵树的最小值,且后续遍历也无法处理到这个 5。(个人思考过程,聪明的人都不会考虑这个逻辑)
所以我们需要从底部向顶部进行遍历,当底部有序之后,局部的父节点一定是最小的。以 demo 中的数据为例,需要优先处理的是这个部分。

处理结束之后,我们从右到左,逐层处理每一层的父节点,拿到局部的最小值。



这里我们把 2 和 5 交换之后可以发现,二叉树是这样子的:

在这个子区域内,由于 2 和 5 交换了位置,5 变成 7 和 4 的父节点,并不满足最小堆的规则。所以需要额外在做一次交换,即 4 和 5 交换位置。不难发现,如果我们发生了节点的值交换,需要额外进行一个判断,判断交换之后的节点是否是"非叶子节点"
。如果是非叶子节点,还需要额外做一次判断是否需要再次交换位置。
这个过程像是一个元素从顶部逐渐"沉底",我们称之为"下沉"。




于是我们就得到了一个最小堆:[1, 2, 6, 4, 3, 8, 9, 7, 5]
现在考虑两个情况,我们从小顶堆里面把 1 给拿走了,或者我新加入了一个新的数字 0,这两种情况要如何来处理,让这个失去平衡的小顶堆恢复到有秩序的样子呢?
拿走 1
我们做堆排序就是为了我们要拿最值的时候方便,此时我们拿走顶部的 1,如何让二叉树重新变成最小堆?

群龙无首,我们先随便抓一个人来当头头,其他的事情等会再说,我看这个 5 就不错,先上来!

然后呢?5 很明显不是整棵树中的最小值,需要给它调整一下位置。如何调整呢?和初始化的时候一样从底部开始一个个区域网上找吗?
其实除了顶部的 5 之外,其他节点已经是一个符合最小堆规则的位置了,所以其实需要做的是把 5 的位置找对即可。我们先把 5 和 2 进行位置交换。

继续交换,5 和 3 换位置

可以发现,移除一个元素后,让末尾的元素顶上来之后,我们执行的也是一个"下沉"的操作。和初始化不同的是,这个过程是自顶向下的而不是自底向上。
加入 0

如果从数组尾部插入一个值之后,处理逻辑就是需要从底层"上浮"。



实现一个堆排序
按照以上思路,如果我们需要实现一个堆排序算法,需要实现的功能点为以下几点: 1.初始化二叉树,让二叉树排序变成小顶堆,从底层开始,做"下沉"操作。 2.删除后,替换的元素需要"下沉" 3.新加入元素后,需要"上浮"
javascript
/**
* 比较两个索引的值(用于小顶堆)
* @param {Array<number>} heap - 堆数组
* @param {number} i
* @param {number} j
* @returns {boolean}
*/
function compare(heap, i, j) {
return heap[i] < heap[j];
}
/**
* 交换堆中两个位置的值
* @param {Array<number>} heap
* @param {number} i
* @param {number} j
*/
function swap(heap, i, j) {
[heap[i], heap[j]] = [heap[j], heap[i]];
}
/**
* 向下沉底(用于初始化堆或删除堆顶后修复)
* @param {Array<number>} heap
* @param {number} i - 当前节点索引
* @param {number} heapSize - 有效堆大小(可用于排序阶段)
*/
function sinkDown(heap, i, heapSize = heap.length) {
const left = 2 * i + 1;
const right = 2 * i + 2;
let smallest = i;
if (left < heapSize && compare(heap, left, smallest)) {
smallest = left;
}
if (right < heapSize && compare(heap, right, smallest)) {
smallest = right;
}
if (smallest !== i) {
swap(heap, i, smallest);
sinkDown(heap, smallest, heapSize);
}
}
/**
* 向上冒泡(用于插入新元素)
* @param {Array<number>} heap
* @param {number} i - 当前节点索引
*/
function bubbleUp(heap, i) {
while (i > 0) {
const parent = Math.floor((i - 1) / 2);
if (compare(heap, i, parent)) {
swap(heap, i, parent);
i = parent;
} else {
break;
}
}
}
/**
* 构建小顶堆(heapify)
* @param {Array<number>} heap
*/
function buildMinHeap(heap) {
const start = Math.floor(heap.length / 2) - 1;
for (let i = start; i >= 0; i--) {
sinkDown(heap, i);
}
}
/**
* 插入新元素到堆中
* @param {Array<number>} heap
* @param {number} value
*/
function insert(heap, value) {
heap.push(value);
bubbleUp(heap, heap.length - 1);
}
/**
* 删除堆顶最小值
* @param {Array<number>} heap
* @returns {number|null}
*/
function removeMin(heap) {
if (heap.length === 0) return null;
const min = heap[0];
heap[0] = heap[heap.length - 1];
heap.pop();
sinkDown(heap, 0);
return min;
}
我们如果要做数组排序,可以直接
堆排序实际应用
使用堆排序之后,我们可以很方便使用 removeMin 方法直接拿到整个数组中最小的数。在实际项目开发中,可以用这个算法实现一个优先任务队列。
比如项目中存在多个 ajax 请求,这些请求根据业务逻辑有多个不同的优先级。当多个事件排队等候请求的时候,我们就可以使用堆排序去管理这个优先级。
又比如 React 的调度算法(挖个新坑,以后再说,因为没理解清楚具体详细步骤,只知道一个大概),也是使用小顶堆做任务的调度,实现了 fiber 的调度和回调可中断等特性。
总结
理解堆排序算法的难点在于"下沉"操作,需要循环去处理每一层节点。另外算法代码需要自己去手敲一遍,最好结合 demo 一起理解,不然容易陷入一听就懂,一写就错的尴尬处境。
另外,周末就要有周末的样子!写完收工!出门骑车!
