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问题,一般我们也将其称为优先队列。也可以用于排序,堆排序即构造堆后逐一出堆得到排序后数据。
- 堆是一棵完全二叉树。
- 元素入堆:
- 添加到堆底
- 堆化:从下向上,与父节点进行比较,大于父节点则交换,直到不大于父节点或到达根为止。
- 元素出堆:
- 将堆顶元素与堆底元素交换,弹出堆底元素(不影响二叉树结构)
- 堆化:从上向下,与左右子节点比较,与最大的节点交换,如果最大节点为当前节点或当前节点无子节点则堆化结束。