堆(Heap)数据结构在被广泛应用于操作系统调度、网络路由、图算法等领域。 但在前端中似乎很少提及到堆结构,但是它的一些特点如:快速找到最大或最小值、支持动态数据、动态调整能力(优先级队列)等,我们也可以利用这些特点来优化前端程序;本文主要讲解了什么是堆、堆的实现思路(蛮有趣的过程)以及堆在前端开发中能处理什么问题。
一、完全二叉树概念
了解堆之前需要了解什么是完全二叉树;
1. 什么是全完二叉树
- 除二叉树最后一层外,其他各层的节点数都达到最大个数。
- 且最后一层从左向右的叶节点连续存在,只能缺右侧若干节点。
- 完美二叉树是特殊的完全二叉树。
2. 图示
此时就一个完全二叉树,除了最后一层其他层的叶子节点都是连续存在的
完美二叉树
非完全二叉树: 中间子节点不连续
二、堆的特点
1. 是一种完全二叉树结构
堆的本质是一种特殊的树形数据结构,使用完全二叉树来实现;
2. 分为最大堆和最小堆
最小堆:堆中每一个节点都小于等于(<=)它的子节点:
最大堆:堆中每一个节点都大于等于(>=)它的子节点;
三、堆结构意义
处理一个集合中的最大值和最小值, 例如从一个数组中依次取出最大值。会有以下几种方案:
1. 数组或链表
- 每次获取最大值或最小值都是算法都是O(n)级别,如果依次获取则会是O(n^2);
- 如果进行排序后获取排序的过程也会消耗性能;
2. 哈希表
哈希表数据的位置是根据hash算法算出来的,无法知道一个数据存放桶的位置,所以根本不适合;
3. 二叉搜索树
获取最大值和最小值也是O(logn)级别, 但是树形结构较为复杂,并且还需要维持树的平衡;
4. 堆
以最大堆为例,每次获取最大值只需要把头节点拿出,后续会自动上滤操作(后面详解)确保顶点永远是最大的,算法也是O(logn);即使是100万次的数据最多只需要操作20次就能找出最大或最小值;
四、堆结构和数组的关系
1. TopK问题
TopK问题是指在一组数据中,找出最前面的K个最大/最小的元素;常用的解决方案有使用排序算法、快速选择算法、堆结构等:
2. 堆底层还是使用数组
二叉堆用树形结构表示出来是一颗完全二叉树:通常在实现的时候我们底层会使用数组来实现:
3. 图示结构
堆节点索引对应关系就是二叉树的层序遍历
4. 堆节点和数组索引对应关系(重点)
后面方法很多地方都要基于这些规律来计算元素对应的索引
假设i为堆节点的索引,根据上图可以推断出
- i = 0,它就是根节点,对应数组也就是第0项;
- 获取父节点在数组索引: Math.floor( (i - 1) / 2 )
-
- 假设获取上图中的节点5的父节点;
- 它的节点索引为4,也就是i = 4;
- 通过公式计算出父节点的索引为1,也就是值为10的节点
- 获取左子节点索引: 2 * i + 1
- 获取右子节点索引: 2 * i + 2
五、堆的基本结构
先构建一个堆的基本结构里面包括一些特定的属性和方法
1. 属性
- data为一个数组,堆底层通过数组结构来实现的;
- size 手动维护堆的长度,也可以通过data数组长度来判断;
2. 方法
这里先实现一些简单和常用的方法
- swap() 将数组中索引i和j的值进行位置交换方法
- peek() 返回堆中最大或最小元素
- isEmpty() 判断当前堆是否是一个空堆
3. 结构代码
class Heap {
constructor() {
// 底层储存数据还是使用数组
this.data = [];
// 维护当前堆的数据长度,也可以通过data长度维护
this.size = 0;
}
// 交换位置方法,将数组中索引i和j的值进行位置交换方法
// 此方法是为了上滤或下滤操作
swap(i, j) {
let temp = this.data[i];
this.data[i] = this.data[j];
this.data[j] = temp;
}
// insert 在堆中插入一个新元素
insert(value) {}
// delete在堆中删除最大或最小元素或者叫extract
delete() {}
// peek 返回堆中最大或最小元素
peek() {
return this.data[0];
}
// isEmpty 判断堆是否为空
isEmpty() {
return this.size === 0;
}
// buildHeap 通过一个数组原地创建堆结构
buildHeap(list) {}
}
六、实现堆插入insert
这里以最大堆进行示例展示, 每次插入后需要进行上滤操作来维护最大堆特性
1. 图示插入流程
- 假设一个堆的数组结构是[30, 50, 60, 40, 25, 90];此时展示未一个堆的话就是
- 现在需要对堆进行insert插入100
head.insert(100) 第一步将它添加到数组最后一位
- 然后根据当前插入值的索引找到父节点索引进行比较
当前100的索引值为6,根据前面公式得出父节点索引为2;此时发现父节点小于子节点100,不符合最大堆特性需要进行上滤操作,父子节点交换位置
- 上滤后还需要和父节点进行比较看是否符合最大堆特性
发现还是不符合,100继续和90交换位置得到最后结果
2. 代码思路
- 将新插入的元素放入数组data尾部;
- 获取新元素的位置:index = this.data.length - 1;
- 根据新元素位置获取父元素位置: Math.floor((index - 1) / 2);
- 比较父元素和子元素值 (循环对比)
-
- 如果父元素大于或等于子元素则跳转循环break;
- 如果父元素小于子元素则需要上滤操作
-
-
- 交换父子元素的位置;
- 将子元素index赋值为父元素的索引
- 然后和新位置的父级比较
-
3. 代码
此时就可以将上滤操作抽离出来为一个单独的方法
insert(value) {
// 1. 将新元素插入到尾部
this.data.push(value);
this.size++;
// 2. 循环对比是否进行上滤操作
this.upperFilter();
}
upperFilter() {
// 3. 新插入的元素在data最尾部,先获取索引
let index = this.data.length - 1;
// 循环跳出的结果是Index<=0,也就是新添加的元素已经放入到顶部
// 所以只要没break终止,index > 0的情况下要不断的进行比较
while (index > 0) {
// 根据当前索引获取父级索引(必须在循环内,每次上滤父级索引会变化)
let parentIndex = Math.floor((index - 1) / 2);
// 如果父元素大于或等于插入的值则无需上滤直接结束循环
if (this.data[parentIndex] >= this.data[index]) {
break;
}
// 如果父元素小于插入值则两者需要交换数组的位置,并且更新index值继续和新位置的父级比较
this.swap(parentIndex, index);
index = parentIndex;
}
}
七、实现抽取最大值
1. 作用
为了解决TopK问题,需要对堆的最大值进行抽取,也就是删除一个堆的顶部元素后进行的操作
- 每次删除元素后需要对堆进行重构以维护最大堆的特性;
- 需要使用向下替换操作这种操作叫做下滤(percolate down);
2. 图示步骤
- 假设之前的最大堆为 [ 90, 40, 60, 30, 25, 50 ]
- 将数组中最后一个元素放在顶部
数组长度减去1,此时就不符合最大堆特性了需要对顶部元素进行下滤
- 下滤操作
先对比左右两个子节点,找到其中较大的节点然后调换位置
交换完成后如果后面还有子节点则继续进行下滤操作
3. 步骤说明
- 声明一个变量maxNum保存堆的首位最大值;
- 删除堆的最后一个节点,也就是数组的最后一个节点。把它放在堆的首位,也就是数组的首位。注意:数组的长度也要减去1;此时就不符合最大堆特性了,需要进行下滤操作
- 对堆的首位节点下滤操作( 需要下滤节点和它的左右子节点索引 ):
-
- 首位节点默认index = 0,左子节点的索引为index * 2 + 1, 右子节点索引为 index * 2 + 2;
- 比较两个子节点,获取较大那个索引值并且保存为largeIndex
- 对比下滤节点和较大的子节点
-
-
- 如果最大的子节点还是比下滤节点小则直接停止循环
- 如果最大的子节比下滤节点大则交换位置和对应的索引index = largeIndex,然后根据新索引继续和后面子节点比较
-
-
- 循环退出的条件就是当前下滤节点没有左子节点 2 * index + 1 < this.data.length;
4. 代码
extract() {
// 首先: 边界判断没有元素或只有一个元素情况下
if (this.size === 0) return undefined;
if (this.size === 1) {
this.size--;
return this.data.shift();
}
// 1. 获取第一个元素
let maxValue = this.data[0];
// 2. 将最后一个元素放入到头部,并且删除data最后的长度
this.data[0] = this.data.pop();
this.size--;
// 3. 提到头部的第一个元素索引进行下滤 (可以抽离为一个下滤方法)
let index = 0;
// 如果还有左子节点则还需要循环下滤对比,没有左子节点则退出
while ( 2 * index + 1 < this.data.length) {
// 获取左右两个子节点的索引值
let leftChildIndex = index * 2 + 1;
// let rightChildIndex = index * 2 + 2; 或者在左子节点索引加1
let rightChildIndex = leftChildIndex + 1;
// 声明较大索引默认为左子节点,因为堆是不会在有右子节点的情况下没有左子节点,所以只判断是否存在右节点
let largeIndex = leftChildIndex;
// 如果有右子节点,并且右子节点比左子节点大
if (rightChildIndex < this.size && this.data[rightChildIndex] > this.data[leftChildIndex]) {
largeIndex = rightChildIndex;
}
// 如果下滤的节点大于子节点较大值则停止下滤操作
if (this.data[index] >= this.data[largeIndex]) {
break;
}
// 交换位置和下滤索引继续往后面的子节点对比
this.swap(index, largeIndex);
index = largeIndex;
}
return maxValue
}
八、原地建堆
1. 概念
之前创建堆结构是需要通过一次次insert元素来实现的,而原地建堆是指创建堆的过程中不使用额外的内存空间,直接在原有数组上进行操作;
比如: 直接传入一个数组[30, 50, 60, 40, 25, 90]将它变为最大堆[ 90, 50, 60, 40, 25, 30 ],使用的是内部的buildHeap(arr)方法
2. 图示过程
- 方法1:自下而上的下滤方式这种更有效率,因为叶子节点无需触发下滤操作
- 方法2:自上而下的上滤方式,但是每个节点都必须进行上滤
这里采用下滤的方式进行原地建堆
- 先根据数组画出完全二叉树结构
此时并非一个最大堆结构
- 获取最后一个非叶子节点进行下滤操作
最后一个非叶子节点为60,将它进行下滤
- 下滤处理完后继续处理上上个非叶子节点
此时为50符合最大堆特性所以还在原来位置
- 下滤处理完后继续处理50上个非叶子节点
此时为30也是根节点,此时就是上一个提取最大值方法,从根节点进行下滤
- 最后结果
3. 代码
- 先设置内部data值为传入的arr数组
- 从第一个非叶子节点开始下滤,完成后继续下滤操作上一个非叶子节点直到处理完根节点(自下而上)
// 由于需要对每个非叶子节点进行下滤处理,所以可以把下滤抽离出一个方法
// nodeIndex:就是需要下滤操作的节点索引
heapifyDown(nodeIndex) {
// 3. 提到头部的第一个元素索引进行下滤 (可以抽离为一个方法)
let index = nodeIndex;
// 如果还有左子节点则还需要循环下滤对比,没有左子节点则退出
while (2 * index + 1 < this.data.length) {
// 获取左右两个子节点的索引值
let leftChildIndex = index * 2 + 1;
// let rightChildIndex = index * 2 + 2; 或者在左子节点索引加1
let rightChildIndex = leftChildIndex + 1;
// 声明较大索引默认为左子节点,因为堆是不会在有右子节点的情况下没有左子节点,所以只判断是否存在右节点
let largeIndex = leftChildIndex;
// 如果有右子节点,并且右子节点比左子节点大
if (rightChildIndex < this.size && this.data[rightChildIndex] > this.data[leftChildIndex]) {
largeIndex = rightChildIndex;
}
// 如果下滤的节点大于子节点较大值则停止下滤操作
if (this.data[index] >= this.data[largeIndex]) {
break;
}
// 交换位置和下滤索引继续往后面的子节点对比
this.swap(index, largeIndex);
index = largeIndex;
}
}
// 原地建堆
buildHeap(arr) {
// 将内部数据保存为传入的数组
this.data = arr;
this.size = arr.length - 1;
// 获取到最后一个非叶子节点,也就是最后一个节点的父节点
let start = Math.floor((this.size - 1) / 2);
// 循环处理每一个非叶子节点,0的情况就是根节点
for (let i = start; i >= 0; i--) {
this.heapifyDown(i);
}
}
九、总结上滤和下滤
堆中最核心的操作就是上滤和下滤,两者里面都是根据当前元素的索引进行操作,一个找父亲一个找儿子
1. 上滤:
- 和父元素进行对比,也是根据规律获取父元素索引,然后根据索引比较两者值大小,看看是否需要交换位置;
- 循环结束条件为需要上滤的元素索引值已经为0了,没有父节点了;
2. 下滤:
- 和两个或一个子元素进行比较,如果只有一个子元素的情况下那必然是左子节点(完全二叉树特性);
- 当前节点和子节点中较大的那个比较,然后决定是否交换位置;
- 循环结束条件为当前下滤的节点没有左字节了
十、使用场景
堆在前端开发中也是经常会使用到的数据结构,最主要运用到的还是优先级队列:
1. 优先级队列概念
它每次出队的元素都是具有最高优先级的,可以理解为元素按照关键字进行排序。优先级队列可以用数组、链表等数据结构来实现,但是堆是最常用的实现方式。
2. 使用场景
优先级队列在生活和编程中有大量的使用场景,如:
- 医院叫号可能会优先排病情重的病人;
- 登机头等舱的客人会优先登机;
- 计算机中也会根据每个线程任务的重要性进行排序执行;
3. 案例
假设现在需要对学生成绩从高到低颁奖,需要从一个集合中优先把高分的学生找到
// 学生类
class Student {
constructor(name, score) {
this.name = name;
this.score = score;
}
// 这里需要通过valueOf方法返回分数,这样实例对象就能进行比较
valueOf() {
return this.score;
}
}
// 创建优先级队列
class PriorityQueue {
constructor() {
this.heap = new Heap();
}
// 入队
enqueue(student) {
this.heap.insert(student);
}
// 出队
dequeue() {
return this.heap.extract();
}
isEmpty() {
return this.heap.isEmpty();
}
}
// 分数从高到低获取
let p = new PriorityQueue();
p.enqueue(new Student("张三", 60))
p.enqueue(new Student("李四", 40))
p.enqueue(new Student("王五", 80))
p.enqueue(new Student("赵六", 70))
while (!p.isEmpty()) {
console.log(p.dequeue());
}