原生JavaScript实现heap数据结构

189 阅读6分钟

一、关于TOP K问题

  • TOP K问题 :Top K指的是从n(很大)个数据中,选取最大(小)的k个数据。例如学校要从全校学生中找到成绩最高的500名学生,再例如某搜索引擎要统计每天的100条搜索次数最多的关键词。
  • 对于TOPK问题,解决方法有很多:

    • 方法一:对源数据中所有数据进行排序,取出前K个数据,就是TopK。但是当数据量很大时,只需要k个最小的数,整体排序很耗时,效率不高。
    • 方法二:维护一个K长度的数组a[],先读取源数据中的前K个放入数组,对该数组进行升序排序,再依次读取源数据第K个以后的数据,和数组中最小的元素(a[0])比较,如果小于a[0]直接pass,大于的话,就丢弃最小的元素a[0],利用二分法找到其位置,然后该位置前的数组元素整体向前移位,直到源数据读取结束。这比方法一效率会有很大的提高,但是当K的值较大的时候,长度为K的数据整体移位,也是非常耗时的。
    • 方法三:对于这种问题,效率比较高的解决方法是使用堆排序。

二、 基本概念:

  • 完全二叉树:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1~ 2^(h-1) 个节点。

完全二叉树.png

  • 满二叉树:满二叉树是一种特殊的的完全二叉树,如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。

满二叉数.png

三、堆是什么?

  • 堆是一种特殊的 完全二叉树 ,完全二叉树意味着每个节点都有两个孩子节点

  • 最大堆:所有的节点都 大于等于≥ 它的子节点;

大顶堆.png

  • 最小堆:所有的节点都 小于等于≤ 它的子节点。

小顶堆.png

四、原生js实现(MinHeap)最小堆

MinHeap类方法含义
swap()交换两个节点的位置
getParentIndex()获取父节点的位置
getLeftIndex()获取左侧子节点的位置
getRightIndex()获取右侧子节点的位置
shiftUp()进行上移操作
shiftDown()进行下移操作
insert()插入节点的值
pop()删除堆顶操作
peek()获取堆顶的值
size()获取堆的大小
class MinHeap {
  constructor() {
    this.heap = [];
  }
​
  getParentIndex(i) {
    return (i - 1) >> 1;
  }
​
  getLeftIndex(i) {
    return i * 2 + 1;
  }
​
  getRightIndex(i) {
    return i * 2 + 2;
  }
​
  shiftUp(index) {
    if (index === 0) { return; }
    const parentIndex = this.getParentIndex(index);
    if (this.heap[parentIndex] > this.heap[index]) {
      this.swap(parentIndex, index);
      this.shiftUp(parentIndex);
    }
  }
​
  swap(i1, i2) {
    [this.heap[i1], this.heap[i2]] = [this.heap[i2], this.heap[i1]];
  }
​
  insert(value) {
    this.heap.push(value);
    this.shiftUp(this.heap.length - 1);
  }
​
  pop() {
    this.heap[0] = this.heap.pop();
    this.shiftDown(0);
    return this.heap[0];
  }
​
  shiftDown(index) {
    const leftIndex = this.getLeftIndex(index);
    const rightIndex = this.getRightIndex(index);
    if (this.heap[leftIndex] < this.heap[index]) {
      this.swap(leftIndex, index);
      this.shiftDown(leftIndex);
    }
    if (this.heap[rightIndex] < this.heap[index]) {
      this.swap(rightIndex, index);
      this.shiftDown(rightIndex);
    }
  }
​
  peek() {
    return this.heap[0];
  }
​
  size() {
    return this.heap.length;
  }
}

五、逐步解析

1、初始化堆

class MinHeap{
    //创建一个构造器,存放一个堆
    constructor(){
        this.heap = [];
    }
}

2、交换位置swap()

初始化完一个堆之后,如果想要实现上下移操作,还需要对两个节点进行位置交换。

swap(i1, i2){
  [this.heap[i1], this.heap[i2]] = [this.heap[i2], this.heap[i1]];
}

3、获取父节点、左侧子节点、右侧子节点位置

  • JS 通常用数组来表示堆。
  • 左侧节点的位置是 2*index+1
  • 右侧节点的位置是 2*index+2
  • 父节点位置是 (index - 1) / 2
getParentIndex(i) {
  return (i - 1) >> 1;
}
​
getLeftIndex(i) {
  return i * 2 + 1;
}
​
getRightIndex(i) {
  return i * 2 + 2;
}

4、进行上移操作shiftUp()

对于上移操作来说,实现思路如下:

  • 先判断当前节点的位置是否在堆的顶点处,如果是,则不进行上移操作;如果否,则继续进行比较;
  • 获取父节点的位置索引,获取索引的目的是为了获取该索引的具体值;
  • 将当前节点的值与父节点的值进行对比,如果父节点的值大于当前节点的值,则进行上移操作;
  • 递归进行上移操作,直到到达堆顶为止。
shiftUp(index){
  //如果在堆的顶点处,则不进行上移操作,直接返回结果
  if (index === 0) {
    return;
  }
  //获取父节点(即获取当前节点的父节点的值,且每个节点的父节点只有一个)
  const parentIndex = this.getParentIndex(index);
  //判断如果堆的父节点如果大于子节点,则进行位置交换
  if (this.heap[parentIndex] > this.heap[index]) {
    this.swap(parentIndex, index);
    //交换完成之后,继续递归进行上移操作
    this.shinftUp(parentIndex);
  }
}

5、进行下移操作shiftDown()

对于下移操作来说,实现思路如下:

  • 先获取左右侧节点;
  • 将左侧子节点与当前节点进行比较,如果左侧子节点比当前节点小,则进行位置交换,之后将交换完的节点继续进行比较;
  • 左侧节点比较完之后,接下来比较右侧节点;
  • 将右侧子节点与当前节点进行比较,如果右侧子节点比当前节点小,则进行位置交换,之后将交换完的节点继续进行比较;
  • 如此循环操作,直到最后一个节点为止。
shiftDown(index){
  // 获取左右侧子节点
  const leftIndex = this.getLeftIndex(index);
  const rightIndex = this.getRightIndex(index);
  //  对左侧结点进行交换
  if (this.heap[leftIndex] < this.heap[index]) {
    this.swap(leftIndex, index);
    this.shiftDown(leftIndex);
  }
  //  对右侧结点进行交换
  if (this.heap[rightIndex] < this.heap[index]) {
    this.swap(rightIndex, index);
    this.shiftDown(rightIndex);
  }
}

6、插入节点的值insert()

对于插入节点操作来说,实现思路如下:

  • 将值插入堆的底部,即数组的尾部。
  • 然后上移:将这个值和它的父节点进行交换,直到父节点小于等于这个插入的值。
  • 大小为k的堆中插入元素的时间复杂度为 O(logK)
insert(value){
  //把新的值放到数组的最后一位
  this.heap.push(value);
  //将值进行上移操作
  this.shiftUp(this.heap.length - 1);
}

7、删除堆顶操作pop()

对于删除堆顶操作来说,实现思路如下:

  • 用数组尾部元素替换堆顶(因为直接删除堆顶会破坏堆结构)。
  • 然后下移:将新堆顶和它的子节点进行交换,直到子节点大于等于这个新堆顶。
  • 大小为 k 的堆中删除堆顶的时间复杂度为 O(logK)
pop() {
  //将最后一位移出来替换掉第一个元素
  this.heap[0] = this.heap.pop();
  //将值进行下移操作
  this.shiftDown(0);
}

8、获取堆顶的值peek()

  • 直接返回数组第一位即可
peek(){
  return this.heap[0];
}

9、获取堆的大小size()

  • 直接返回数组大小即可
size() {
  return this.heap.length;
}

六、leetcode实战

剑指 Offer 49. 丑数 - 力扣(LeetCode)

题目描述:

我们把只包含质因子 2、3 和 5 的数称作丑数(Ugly Number)。求按从小到大的顺序的第 n 个丑数。
示例:
输入: n = 10
输出: 12
解释: 1, 2, 3, 4, 5, 6, 8, 9, 10, 12 是前 10 个丑数。
  • 要得到从小到大的第 n 个丑数,可以使用最小堆实现。
  • 初始时堆为空。首先将最小的丑数 1 加入堆。
  • 每次取出堆顶元素 x,则 x 是堆中最小的丑数,由于 2x, 3x, 5x2x,3x,5x 也是丑数,因此将 2x, 3x, 5x2x,3x,5x 加入堆。
  • 上述做法会导致堆中出现重复元素的情况。为了避免重复元素,可以使用哈希集合去重,避免相同元素多次加入堆。
  • 在排除重复元素的情况下,第 n次从最小堆中取出的元素即为第 n 个丑数。
var nthUglyNumber = function (n) {
  const factors = [2, 3, 5];
  const seen = new Set();
  const heap = new MinHeap();
  seen.add(1);
  heap.insert(1);
  let ugly = 0;
  for (let i = 0; i < n; i++) {
    heap.pop()
    ugly = heap.peek();
    for (const factor of factors) {
      const next = ugly * factor;
      if (!seen.has(next)) {
        seen.add(next);
        heap.insert(next);
      }
    }
​
  }
  return ugly;
};