javascript:堆(Heap)与优先队列

382 阅读8分钟

一.堆的基本知识

1. 完全二叉树内容的回顾

  • 父子节点的编号存在可计算的关系 因此不需要存储边的信息。编号为i(从0开始)的子节点:左孩子编号:2*i+1,右孩子编号:2*i+2
  • 可以用连续空间存储

2. 堆的概念

  • 一种基于完全二叉树的结构
  • 大顶堆和小顶堆
    • 大顶堆 —— 任意的三元组,父节点都大于两个子节点
    • 小顶堆 —— 任意的三元组,父节点都小于两个子节点
    • 根节点为最大值或最小值
  • 堆适合维护集合的最值

3. 堆的基本操作(以大顶堆为例)

  • 尾部插入
    • 比父节点大就和父节点交换 递归向上调整
    • 这个过程称为SIFT-UP
  • 头部弹出
    • 用最后一个元素(叶子结点)补位 递归向下调整
    • 这个过程称为SIFT-DOWN

4. 堆排序

实现从小到大排序,先对数组中的元素建堆,将堆顶元素与堆尾元素交换,将此操作看做是堆顶元素弹出操作,然后再做尾部元素的向下调整,刚刚弹出去的头部元素所在的最后一位,已经不属于堆的合法位置了,但是还属于数组的有效空间,经过这么一轮操作,相当与将最大值放到了数组的最后一位,进行n轮弹堆操作,整个数组就从小到大了。

5. 堆--优先队列

堆是优先队列的一种实现方式

普通队列优先队列
尾部入队尾部可以插入
头部入队头部可以弹出
头部入队每次出队权值(最大/最小的元素)
数组实现数组实现,逻辑上看成一个堆

6. 堆的理解

堆适合维护:集合最值

二.堆的实现

// 大顶堆
class MaxHeap {
  // 堆最大的存储空间
  MAX_N = 1000;
  constructor() {
    this.data = new Array(this.MAX_N + 5); // 一片连续的存储空间
    this.cnt = 0; // 堆中元素数量
  }
  // 交换元素位置的方法
  swap(i, j) {
    if (i === j) {
      return;
    }
    [this.data[i], this.data[j]] = [this.data[j], this.data[i]];
  }
  // 向上调整,传入当前需要调整的元素下标
  shift_up(ind) {
    // 通过子节点坐标确定父节点坐标:子节点坐标减1再除以2,再向下取整
    // 最⾼就要调整到0号位置并且父节点值小于子节点值时循环向上
    while (ind && this.data[(ind - 1) >> 1] < this.data[ind]) {
      // 交换⽗节点和⼦节点
      this.swap((ind - 1) >> 1, ind);
      // ind变成原父节点下标,进⾏下⼀次交换
      ind = (ind - 1) >> 1;
    }
    return;
  }
  // 向下调整,传入当前需要调整的元素下标
  shift_down(ind) {
    // n:最大子节点下标
    let n = this.cnt - 1;
    // 当ind有子节点时循环
    // 判断有子节点:当前元素左子节点下标(ind * 2 + 1)小于等于最大子节点下标,证明一定有左子节点,但是不一定有右子节点
    while (ind * 2 + 1 <= n) {
      // temp:三元组中最大值的下标
      let temp = ind;
      // 和左子节点比较
      if (this.data[temp] < this.data[ind * 2 + 1]) temp = ind * 2 + 1;
      // 判断有没有右子节点,再比较
      if (ind * 2 + 2 <= n && this.data[temp] < this.data[ind * 2 + 2])
        temp = ind * 2 + 2;
      // 说明当前三元组中最大的就是原先ind指向的元素,无须再向下调整
      if (temp === ind) break;
      // 不是原元素,交换位置
      this.swap(temp, ind);
      // ind变成temp,进⾏下⼀次交换
      ind = temp;
    }
    return;
  }
  // 压入堆中
  push(x) {
    // 新加入的元素放到堆末尾,堆末尾就是cnt,cnt元素数量再+1
    this.data[this.cnt++] = x;
    // 向上调整,传入最后一位的下标
    this.shift_up(this.cnt - 1);
    return;
  }
  // 堆中弹出
  pop() {
    // 堆为空,不能进行弹出
    if (this.size() === 0) return;
    // 先把尾部元素放到头部去,为了实现堆排序,交换头部和尾部元素
    this.swap(0, this.cnt - 1);
    // 堆整体元素数量减少一位
    this.cnt -= 1;
    // 从头部向下调整
    this.shift_down(0);
    return;
  }
  // 查看堆顶元素
  top() {
    return this.data[0];
  }
  // 堆中元素数量
  size() {
    return this.cnt;
  }
  output() {
    let res = "";
    for (let i = 0; i < this.cnt; i++) {
      res += this.data[i];
    }
    console.log(res);
  }
}

function main() {
  let arr = new MaxHeap();
  arr.push(1);
  arr.push(2);
  arr.push(4);
  arr.output();
  arr.pop();
  arr.output();
  arr.pop();
  arr.output();
  console.log(arr.size());
}

main();

3.经典面试题-堆的基础应用

剑指 Offer 40. 最小的k个数

首先有个k个元素的备选集合,每次进来一个新元素的时候,如果新元素比备选集合中的最大值还小,说明新元素才应该留在备选集合中,原先最大值被剔除,需要维护备选集合中的最大值,使用大顶堆,容量是k

var getLeastNumbers = function(arr, k) {
    // 大顶堆
    let h = new Heap((a, b) => b - a);
    for(let x of arr){
        h.push(x);
        // 大顶堆中元素超过k个,最大的出去
        if(h.size() > k) h.pop();
    }
    return h.data;
};
// 后续代码中不包含堆的构造方法,可以回来查阅
// 需要传比较函数,没有保留堆排序的结果,
// 小顶堆:(a, b) => a - b
// 大顶堆:(a, b) => b - a
class Heap {
  constructor(compartor) {
    this.data = [];
    this.compartor = compartor;
  }

  swap(i, j) {
    [this.data[i], this.data[j]] = [this.data[j], this.data[i]];
  }

  shift_up(ind) {
    while (ind && this.compartor(this.data[ind], this.data[(ind - 1) >> 1]) < 0) {
      this.swap((ind - 1) >> 1, ind);
      ind = (ind - 1) >> 1;
    }
    return;
  }

  shift_down(ind) {
    let n = this.size() - 1;
    while (ind * 2 + 1 <= n) {
      let temp = ind;
      if (this.compartor(this.data[ind * 2 + 1], this.data[temp]) < 0) temp = ind * 2 + 1;
      if (ind * 2 + 2 <= n && this.compartor(this.data[ind * 2 + 2], this.data[temp]) < 0) temp = ind * 2 + 2;
      if (temp === ind) break;
      this.swap(temp, ind);
      ind = temp;
    }
    return;
  }

  push(x) {
    this.data.push(x);
    this.shift_up(this.size() - 1);
    return;
  }

  pop() {
    if (this.size() === 0) return;
    const last = this.data.pop();
    if(this.size() !== 0){
        this.data[0] = last;
        this.shift_down(0);
    } 
    return;
  }

  top() {
    return this.data[0];
  }

  size() {
    return this.data.length;
  }
}

1046. 最后一块石头的重量

维护⼀个⼤顶堆,然后每次取出堆顶的两个元素,两两相减,将结果再加⼊到堆中,直到堆中的元素⼩于两个。

var lastStoneWeight = function(stones) {
    // 大顶堆
    let h = new Heap((a, b) => b -a);
    // 把每块石头压入堆中
    for(let s of stones){
        h.push(s);
    }
    // 集合中石头数量超过1个
    while(h.size() > 1){
        // 每次拿出两块最重的石头
        let y = h.top(); h.pop();
        let x = h.top(); h.pop();
        if(x === y) continue;
        h.push(y - x);
    }
    if(h.size() === 0) return 0;
    return h.top();
};

703. 数据流中的第 K 大元素

首先有个前k大元素的备选集合,每次进来一个新元素的时候,如果新元素比备选集合中的最小值还大,说明新元素才是前k大,原先最小值被剔除,需要维护备选集合中的最小值,使用小顶堆,容量是k

var KthLargest = function(k, nums) {
    // 小顶堆
    this.h = new Heap((a, b) => a - b);
    this.k = k;
    for(let n of nums){
        this.add(n);
    }
};

KthLargest.prototype.add = function(val) {
    this.h.push(val);
    if(this.h.size() > this.k) this.h.pop();
    return this.h.top()
};

215. 数组中的第K个最大元素

和上题一致

var findKthLargest = function(nums, k) {
    // 小顶堆
    let h = new Heap((a, b) => a - b);
    for(let x of nums){
        h.push(x);
        if(h.size() > k) h.pop();
    }
    return h.top(); 
};

4.经典面试题-堆的进阶应用

692. 前K个高频单词

可以利⽤Map来计算每个单词出现的次数,然后维护⼀个⼤⼩为K的⼩顶堆,将单词按次数加⼊到⼩顶堆中进⾏调整,如果次数相同,就⽐较单词。最后堆中剩余的单词就是我们需要的单词。⽐较单词时的比较规则需要改成大顶堆,因为需要留下字典序小的元素

var topKFrequent = function(words, k) {
    // key是相应的单词,val是出现频次
    let freq = new Map();
    // 统计单词出现次数
    for(let x of words){
        if(freq.has(x)){
            freq.set(x, freq.get(x) + 1)
        }else{
            freq.set(x, 1)
        }
    }
    let cmp = (a, b) =>{
        // 先按照出现频率比较,频率小的往顶上走,让最小值先出,小顶堆
        if(freq.get(a) !== freq.get(b)){
            return freq.get(a) - freq.get(b)
        }
        // 再按照字典序比较,字典序大的往顶上走,让字典序大的先出,需要留下的是字典序小的,大顶堆
        return a < b ? 1 : -1;
    }
    let h = new Heap(cmp);
    // 遍历单词数组时是有重复单词的,所以遍历字典
    for(let [key, val] of freq){
        h.push(key);
        if(h.size() > k) h.pop();
    }
    // 按照上面的排序股则,发现是相反的,所以需要反转一下
    h.data.sort(cmp).reverse();
    return h.data;
};

295. 数据流的中位数

中位数的性质:会把原序列分成两个集合,中间位置左边是前面集合的最大值,右边是后面集合的最小值,所以可以维护这两个集合,使用两个堆,前面集合为大顶堆,后面集合为小顶堆,不管哪边集合元素多时,需要转移元素到另一个集合中,通过维护两个集合的元素数量,可以维护中位性质,这种结构叫做对顶堆。当新插入元素时,和前面集合的堆顶元素比较,如果比最大值小,就插入前面集合,否则插入后面集合,插入后再调整两个集合的元素数量即可。中位性质的维护思路很接近之前发过的“前中后队列”一题。

var MedianFinder = function() {
    // 大顶堆,前半段
    this.h1 = new Heap((a, b) => b - a);
    // 小顶堆,后半段
    this.h2 = new Heap((a, b) => a - b);
};

/** 
 * @param {number} num
 * @return {void}
 */
MedianFinder.prototype.addNum = function(num) {
    // 设定:前半段元素数量最多比后半段多一个
    // 先判断能否往前半段插入,当前半段为空或者新插入的值小于等于堆顶元素,插入前半段
    if(this.h1.size() === 0 || num <= this.h1.top()){
        this.h1.push(num);
    }else{
        // 否则插入后半段
        this.h2.push(num);
    }
    // 调整两个集合元素数量
    if(this.h2.size() > this.h1.size()){
        this.h1.push(this.h2.top());
        this.h2.pop();
    }
    if(this.h1.size() === this.h2.size() + 2){
        this.h2.push(this.h1.top());
        this.h1.pop();
    }
    return ;
};

/**
 * @return {number}
 */
MedianFinder.prototype.findMedian = function() {
    // 元素总数量
    let n = this.h1.size() + this.h2.size();
    // 当有奇数个元素的话,中位数就是前半段的堆顶元素,偶数个时是两个集合的堆顶元素取平均值
    if(n % 2 === 1){
        return this.h1.top();
    }
    return (this.h1.top() + this.h2.top()) / 2
};

面试题 17.20. 连续中值

和上题一模一样

264. 丑数 II

用堆来实现,先往中压入1,由1生成丑数,把生成的数字全部放入集合中,每次从集合中选出一个最小值,再去生成其他丑数并弹出集合,为了避免生成重复数字,生成条件是当前数字只能乘上和当前数字最大素因子相等或者更大的素因子,比如15,包含的最大素因子是5,那么生成下一个丑数,只能乘5。弹出第n次的结果,就是第n个丑数。维护集合最小值采用小顶堆

var nthUglyNumber = function(n) {
    let h = new Heap((a ,b) => a- b);
    let ans = 0;
    h.push(1);
    // 从堆中弹出n次值
    while(n--){
        ans = h.top();
        h.pop();
        // 用弹出的值生成其他丑数
        // 判断最大的素因子是多少,从大到小判断
        if(ans % 5 === 0){
            h.push(ans * 5);
        }else if(ans % 3 === 0){
            h.push(ans * 5);
            h.push(ans * 3);
        }else{
            h.push(ans * 5);
            h.push(ans * 3);
            h.push(ans * 2);
        }    
    }
    return ans;
};

1801. 积压订单中的订单总数

主要考虑买货或和卖货:买进的话,先看能卖的货看有没有比要买的价格一样或低的,可以抵消数量,抵消完了还有的话,那就只能买进了;卖出的话,先看之前买进的货 看有没有比要卖的价格一样或高的,可以抵消, 抵消完了还有的话,还是得卖;最后要求库存有多少件货。可以用最大优先队列存储要买进的货,用最小优先队列存储要卖出的货,方便拿出来抵消。

var getNumberOfBacklogOrders = function(orders) {
    let buy = new Heap((a, b) => b[0] - a[0]);// 采购,大顶堆
    let sell = new Heap((a, b) => a[0] - b[0]);//销售,小顶堆
    // 处理所有订单
    for(let x of orders){
        // 当前订单是购买
        if(x[2] === 0){
            // 当订单数量不为0,销售订单不为0,并且销售订单的最低价格要小于等于购买订单
            while(x[1] !== 0 && sell.size() !== 0 && sell.top()[0] <= x[0]){
                // 获得能卖出去的单数,购买订单单数和销售订单的剩余订单取个最小值
                let cnt = Math.min(x[1], sell.top()[1]);
                x[1] -= cnt;
                sell.top()[1] -= cnt;
                // 当前销售订单卖完了,就删除这笔订单
                if(sell.top()[1] === 0) sell.pop();
            }
            // 当前这笔购买订单没有完全被满足,就积压下来
            if(x[1] !== 0) buy.push(x);
        }else{
            // 销售订单同理
            while(x[1] !== 0 && buy.size() !== 0 && buy.top()[0] >= x[0]){
                let cnt = Math.min(x[1], buy.top()[1]);
                x[1] -= cnt;
                buy.top()[1] -= cnt;
                if(buy.top()[1] === 0) buy.pop()
            }
            if(x[1] !== 0) sell.push(x);
        }
    }
    let sum = 0;
    let mod = 1000000007;
    // 扫描购买和销售订单,统计积压订单数
    for(let x of buy.data){
        sum = (sum + x[1]) % mod;
    }
    for(let x of sell.data){
        sum = (sum + x[1]) % mod;
    }
    return sum;
};