前端如何实现和利用堆结构

81 阅读13分钟

堆(Heap)数据结构在被广泛应用于操作系统调度、网络路由、图算法等领域。 但在前端中似乎很少提及到堆结构,但是它的一些特点如:快速找到最大或最小值、支持动态数据、动态调整能力(优先级队列)等,我们也可以利用这些特点来优化前端程序;本文主要讲解了什么是堆、堆的实现思路(蛮有趣的过程)以及堆在前端开发中能处理什么问题。

一、完全二叉树概念

了解堆之前需要了解什么是完全二叉树;

1. 什么是全完二叉树

  1. 除二叉树最后一层外,其他各层的节点数都达到最大个数。
  2. 且最后一层从左向右的叶节点连续存在,只能缺右侧若干节点。
  3. 完美二叉树是特殊的完全二叉树。

2. 图示

此时就一个完全二叉树,除了最后一层其他层的叶子节点都是连续存在的

完美二叉树

非完全二叉树: 中间子节点不连续

二、堆的特点

1. 是一种完全二叉树结构

堆的本质是一种特殊的树形数据结构,使用完全二叉树来实现;

2. 分为最大堆和最小堆

最小堆:堆中每一个节点都小于等于(<=)它的子节点:

最大堆:堆中每一个节点都大于等于(>=)它的子节点;

三、堆结构意义

处理一个集合中的最大值和最小值, 例如从一个数组中依次取出最大值。会有以下几种方案:

1. 数组或链表

  1. 每次获取最大值或最小值都是算法都是O(n)级别,如果依次获取则会是O(n^2);
  2. 如果进行排序后获取排序的过程也会消耗性能;

2. 哈希表

哈希表数据的位置是根据hash算法算出来的,无法知道一个数据存放桶的位置,所以根本不适合;

3. 二叉搜索树

获取最大值和最小值也是O(logn)级别, 但是树形结构较为复杂,并且还需要维持树的平衡;

4. 堆

以最大堆为例,每次获取最大值只需要把头节点拿出,后续会自动上滤操作(后面详解)确保顶点永远是最大的,算法也是O(logn);即使是100万次的数据最多只需要操作20次就能找出最大或最小值;

四、堆结构和数组的关系

1. TopK问题

TopK问题是指在一组数据中,找出最前面的K个最大/最小的元素;常用的解决方案有使用排序算法、快速选择算法、堆结构等:

2. 堆底层还是使用数组

二叉堆用树形结构表示出来是一颗完全二叉树:通常在实现的时候我们底层会使用数组来实现:

3. 图示结构

堆节点索引对应关系就是二叉树的层序遍历

4. 堆节点和数组索引对应关系(重点)

后面方法很多地方都要基于这些规律来计算元素对应的索引

假设i为堆节点的索引,根据上图可以推断出

  1. i = 0,它就是根节点,对应数组也就是第0项;
  2. 获取父节点在数组索引: Math.floor( (i - 1) / 2 )
    1. 假设获取上图中的节点5的父节点;
    2. 它的节点索引为4,也就是i = 4;
    3. 通过公式计算出父节点的索引为1,也就是值为10的节点
  1. 获取左子节点索引: 2 * i + 1
  2. 获取右子节点索引: 2 * i + 2

五、堆的基本结构

先构建一个堆的基本结构里面包括一些特定的属性和方法

1. 属性

  1. data为一个数组,堆底层通过数组结构来实现的;
  2. size 手动维护堆的长度,也可以通过data数组长度来判断;

2. 方法

这里先实现一些简单和常用的方法

  1. swap() 将数组中索引i和j的值进行位置交换方法
  2. peek() 返回堆中最大或最小元素
  3. 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. 图示插入流程

  1. 假设一个堆的数组结构是[30, 50, 60, 40, 25, 90];此时展示未一个堆的话就是
  1. 现在需要对堆进行insert插入100

head.insert(100) 第一步将它添加到数组最后一位

  1. 然后根据当前插入值的索引找到父节点索引进行比较

当前100的索引值为6,根据前面公式得出父节点索引为2;此时发现父节点小于子节点100,不符合最大堆特性需要进行上滤操作,父子节点交换位置

  1. 上滤后还需要和父节点进行比较看是否符合最大堆特性

发现还是不符合,100继续和90交换位置得到最后结果

2. 代码思路

  1. 将新插入的元素放入数组data尾部;
  2. 获取新元素的位置:index = this.data.length - 1;
  3. 根据新元素位置获取父元素位置: Math.floor((index - 1) / 2);
  4. 比较父元素和子元素值 (循环对比)
    1. 如果父元素大于或等于子元素则跳转循环break;
    2. 如果父元素小于子元素则需要上滤操作
      1. 交换父子元素的位置;
      2. 将子元素index赋值为父元素的索引
      3. 然后和新位置的父级比较

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问题,需要对堆的最大值进行抽取,也就是删除一个堆的顶部元素后进行的操作

  1. 每次删除元素后需要对堆进行重构以维护最大堆的特性;
  2. 需要使用向下替换操作这种操作叫做下滤(percolate down);

2. 图示步骤

  1. 假设之前的最大堆为 [ 90, 40, 60, 30, 25, 50 ]

  1. 将数组中最后一个元素放在顶部

数组长度减去1,此时就不符合最大堆特性了需要对顶部元素进行下滤

  1. 下滤操作

先对比左右两个子节点,找到其中较大的节点然后调换位置

交换完成后如果后面还有子节点则继续进行下滤操作

3. 步骤说明

  1. 声明一个变量maxNum保存堆的首位最大值;
  2. 删除堆的最后一个节点,也就是数组的最后一个节点。把它放在堆的首位,也就是数组的首位。注意:数组的长度也要减去1;此时就不符合最大堆特性了,需要进行下滤操作
  3. 对堆的首位节点下滤操作( 需要下滤节点和它的左右子节点索引 ):
    1. 首位节点默认index = 0,左子节点的索引为index * 2 + 1, 右子节点索引为 index * 2 + 2;
    2. 比较两个子节点,获取较大那个索引值并且保存为largeIndex
    3. 对比下滤节点和较大的子节点
      1. 如果最大的子节点还是比下滤节点小则直接停止循环
      2. 如果最大的子节比下滤节点大则交换位置和对应的索引index = largeIndex,然后根据新索引继续和后面子节点比较
    1. 循环退出的条件就是当前下滤节点没有左子节点 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. 方法1:自下而上的下滤方式这种更有效率,因为叶子节点无需触发下滤操作
  2. 方法2:自上而下的上滤方式,但是每个节点都必须进行上滤

这里采用下滤的方式进行原地建堆

  1. 先根据数组画出完全二叉树结构

此时并非一个最大堆结构

  1. 获取最后一个非叶子节点进行下滤操作

最后一个非叶子节点为60,将它进行下滤

  1. 下滤处理完后继续处理上上个非叶子节点

此时为50符合最大堆特性所以还在原来位置

  1. 下滤处理完后继续处理50上个非叶子节点

此时为30也是根节点,此时就是上一个提取最大值方法,从根节点进行下滤

  1. 最后结果

3. 代码

  1. 先设置内部data值为传入的arr数组
  2. 从第一个非叶子节点开始下滤,完成后继续下滤操作上一个非叶子节点直到处理完根节点(自下而上)
// 由于需要对每个非叶子节点进行下滤处理,所以可以把下滤抽离出一个方法
// 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. 上滤:

  1. 和父元素进行对比,也是根据规律获取父元素索引,然后根据索引比较两者值大小,看看是否需要交换位置;
  2. 循环结束条件为需要上滤的元素索引值已经为0了,没有父节点了;

2. 下滤:

  1. 和两个或一个子元素进行比较,如果只有一个子元素的情况下那必然是左子节点(完全二叉树特性);
  2. 当前节点和子节点中较大的那个比较,然后决定是否交换位置;
  3. 循环结束条件为当前下滤的节点没有左字节了

十、使用场景

堆在前端开发中也是经常会使用到的数据结构,最主要运用到的还是优先级队列:

1. 优先级队列概念

它每次出队的元素都是具有最高优先级的,可以理解为元素按照关键字进行排序。优先级队列可以用数组、链表等数据结构来实现,但是堆是最常用的实现方式。

2. 使用场景

优先级队列在生活和编程中有大量的使用场景,如:

  1. 医院叫号可能会优先排病情重的病人;
  2. 登机头等舱的客人会优先登机;
  3. 计算机中也会根据每个线程任务的重要性进行排序执行;

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());
}