优先队列(堆)解决 Top-K问题

Top-K

Top-K问题:在一组数据中寻找特征最明显的K个元素。例如,在[1,3,2,4,3,1]数字中,寻找最大的前K个元素,寻找电影中播放次数最多的前K部电影... ... 这类问题都可以统称为Top-K问题。

存在一个十分适合解决该问题的数据结构:优先队列 ,而优先队列实际上是

我们使用来解决Top-K问题时,实际上就是:将数据堆化,这样堆顶总是当前数据中最符合条件的元素,然后取K次即可。

那如何实现一个堆?

堆的实现

堆实际上是一个满足某些条件的完全二叉树(只有最底层的节点未被填满,且最底层节点尽量靠左填充)。 此外,堆分为两种:

  • 大根堆:即根元素最大,对于完全二叉树的每一棵子树,根元素都是最大的元素。任意节点的值都大于等于其子节点的值。
  • 小根堆:即根元素最小,对于完全二叉树的每一棵子树,根元素都是最小的元素。任意节点的值都小于等于其子节点的值。

知道堆是一个完全二叉树后,我们知道二叉树是可以使用数组来实现的:第i个节点的左孩子是第i*2+1个节点,右孩子是第i*2+2个节点,父节点是第(i-1)/2向下取整个节点;

接着,我们只需要在构建堆(添加节点)的时候让堆满足之前所说的大根/小根性质即可。

后文将主要实现大根堆。

元素入堆

初始时,堆是一个空数组,然后需要每次添加元素时都进行堆化操作。

元素入堆时,我们为了保证完全二叉树的性质,因此总是将元素添加到堆的最后,然后从最后开始逐步向上堆化,即不断与父节点进行比较决定是否交换,直到不满足大/小根堆的性质为止。

在堆化过程中,我们会不断比较堆元素与父节点的元素从而决定是否需要交换,为此我们也需要一个方法来统一比较代码,因为每个节点的数据结构可能不同

代码实现如下:

js 复制代码
class MaxHeap {
  //构造函数
  constructor(comparator) {
    this.heap = [];
    this.comparator = comparator;
  }

  /**
   * 元素入堆
   * @param {any} x 待入堆元素
   */
  push(x) {
    this.heap.push(x);
    this.heapify2Top();
  }

  /**
   * 从下向上堆化
   * @param {number} i 堆化的起始节点索引
   */
  heapify2Top(i = this.heap.length - 1) {
    if (i < 0) return;
    while (i !== 0) {
      const parentIndex = Math.floor((i - 1) / 2);
      if (parentIndex >= 0 && this.comparator(this.heap[i], this.heap[parentIndex])) {
        //交换
        const temp = this.heap[i];
        this.heap[i] = this.heap[parentIndex];
        this.heap[parentIndex] = temp;
        //更新i
        i = parentIndex;
      } else {
        break;
      }
    }
  }
}

元素出堆

对于堆这样的数据结构,元素出堆是指的堆头元素出堆,这也正好满足我们解决Top-K问题,但是每次出堆后我们仍要保证堆的性质没有变化。

出堆时,总是将当前堆顶元素与堆底元素进行交换,然后将堆底元素出堆,这样不会影响完全二叉树的结构,最后需要从堆顶开始堆化:循环与当前节点的左右子节点进行比较,寻找出当前节点与左右孩子节点中的最大值,如果当前节点最大则堆化结束,否则进行交换后继续向下堆化。

代码实现如下:

js 复制代码
  /**
   * 堆顶元素出堆
   * @returns 堆顶元素
   */
  pop() {
    if (!this.heap.length) return;
    const temp = this.heap[0];
    this.heap[0] = this.heap[this.heap.length - 1];
    this.heap[this.heap.length - 1] = temp;

    const max = this.heap.pop();
    this.heapify2Bottom();
    return max;
  }

  /**
   * 从上向下堆化
   * @param {number} i 堆化起始索引 
   */
  heapify2Bottom(i = 0) {
    while (true) {
      const leftChild = i * 2 + 1;
      const rightChild = i * 2 + 2;
      //比较 当前节点i,左子节点,右子节点中最大值节点
      let changeIndex = i;
      if (leftChild < this.heap.length && this.comparator(this.heap[leftChild], this.heap[changeIndex])) changeIndex = leftChild;
      if (rightChild < this.heap.length && this.comparator(this.heap[rightChild], this.heap[changeIndex])) changeIndex = rightChild;
      //当前节点为最大节点,则堆化结束
      if (changeIndex === i) break;
      //交换
      const temp = this.heap[changeIndex];
      this.heap[changeIndex] = this.heap[i];
      this.heap[i] = temp;
      //更新i
      i = changeIndex;
    }
  }

到了这里我们就可以使用堆来解决TopK问题了🤩!

实战:LeetCode-347.前 K 个高频元素

给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。

text 复制代码
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]

这个题是求出数组中数字出现次数最高的前k个元素,因此我们需要记录每个元素的出现次数,然后将元素和次数作为节点堆化即可。

js 复制代码
var topKFrequent = function (nums, k) {
  //统计元素出现次数
  const map = new Map();
  for (const num of nums) {
    map.set(num, (map.get(num) || 0) + 1);
  }
  //初始化堆,我们后续添加的节点结构为:[元素,出现次数],从而决定比较函数compartor如下
  const maxHeap = new MaxHeap((a, b) => {
    return a[1] - b[1] <= 0 ? false : true;
  })
  //逐个入堆
  for (let [key, value] of map) {
    maxHeap.push([key, value]);
  }

  const res = [];
  //出堆k次,从而获取Top-k个元素
  for (let i = 0; i < k; i++) {
    const node = maxHeap.pop();
    res.push(node[0]);
  }
  return res;
};

完整堆代码:

js 复制代码
class MaxHeap {
  //构造函数
  constructor(comparator) {
    this.heap = [];
    this.comparator = comparator;
  }

  /**
   * 元素入堆
   * @param {any} x 待入堆元素
   */
  push(x) {
    this.heap.push(x);
    this.heapify2Top();
  }

  /**
   * 从下向上堆化
   * @param {number} i 堆化的起始节点索引
   */
  heapify2Top(i = this.heap.length - 1) {
    if (i < 0) return;
    while (i !== 0) {
      const parentIndex = Math.floor((i - 1) / 2);
      if (parentIndex >= 0 && this.comparator(this.heap[i], this.heap[parentIndex])) {
        //交换
        const temp = this.heap[i];
        this.heap[i] = this.heap[parentIndex];
        this.heap[parentIndex] = temp;
        //更新i
        i = parentIndex;
      } else {
        break;
      }
    }
  }

  /**
   * 堆顶元素出堆
   * @returns 堆顶元素
   */
  pop() {
    if (!this.heap.length) return;
    const temp = this.heap[0];
    this.heap[0] = this.heap[this.heap.length - 1];
    this.heap[this.heap.length - 1] = temp;

    const max = this.heap.pop();
    this.heapify2Bottom();
    return max;
  }

  /**
   * 从上向下堆化
   * @param {number} i 堆化起始索引 
   */
  heapify2Bottom(i = 0) {
    while (true) {
      const leftChild = i * 2 + 1;
      const rightChild = i * 2 + 2;
      //比较 当前节点i,左子节点,右子节点中最大值节点
      let changeIndex = i;
      if (leftChild < this.heap.length && this.comparator(this.heap[leftChild], this.heap[changeIndex])) changeIndex = leftChild;
      if (rightChild < this.heap.length && this.comparator(this.heap[rightChild], this.heap[changeIndex])) changeIndex = rightChild;
      //当前节点为最大节点,则堆化结束
      if (changeIndex === i) break;
      //交换
      const temp = this.heap[changeIndex];
      this.heap[changeIndex] = this.heap[i];
      this.heap[i] = temp;
      //更新i
      i = changeIndex;
    }
  }
}

总结

  • 堆可以解决Top-K问题,一般我们也将其称为优先队列。也可以用于排序,堆排序即构造堆后逐一出堆得到排序后数据。
  • 堆是一棵完全二叉树。
  • 元素入堆:
    • 添加到堆底
    • 堆化:从下向上,与父节点进行比较,大于父节点则交换,直到不大于父节点或到达根为止。
  • 元素出堆:
    • 将堆顶元素与堆底元素交换,弹出堆底元素(不影响二叉树结构)
    • 堆化:从上向下,与左右子节点比较,与最大的节点交换,如果最大节点为当前节点或当前节点无子节点则堆化结束。
相关推荐
hackeroink1 小时前
【2024版】最新推荐好用的XSS漏洞扫描利用工具_xss扫描工具
前端·xss
迷雾漫步者2 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-3 小时前
验证码机制
前端·后端
燃先生._.4 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
XH华4 小时前
初识C语言之二维数组(下)
c语言·算法
南宫生4 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
不想当程序猿_5 小时前
【蓝桥杯每日一题】求和——前缀和
算法·前缀和·蓝桥杯
高山我梦口香糖5 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235245 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式