上篇讲解了队列实现:队列实现栈以及栈实现队列,你可能还听过优先队列(Priority Queue),本文就来看下。
优先队列解决的问题:插入元素,获取最值元素(最大或最小)和删除最值元素,并且这些操作能保持在 O(log(n)) 复杂度。
这时候有大聪明要说了:插入元素,获取、删除最值,这不很好实现吗?我随便一敲,代码就出来了:
class PriorityQueueBigSmart {
list: number[] = [];
insert(x: number) {
this.list.push(x);
}
deleteMax(): number | undefined {
this.list.sort((a, b) => b - a);
return this.list.shift();
}
max(): number | undefined {
this.list.sort((a, b) => b - a);
return this.list[0];
}
}
const queue = new PriorityQueueBigSmart();
queue.insert(2);
queue.insert(1);
queue.insert(4);
queue.insert(3);
console.log(queue.deleteMax());
console.log(queue.deleteMax());
console.log(queue.max());
// 4
// 3
// 2
这多完美呀,确实,是这么个意思,但是你的时间复杂度高呀,insert 是 O(1) 很好,但是 deleteMax 方法呢?来看下:sort 是 O(n log(n)),shift 是 O(n),合在一起是 O(n log(n)),优先队列是 O(log(n)),所以你的方法多出了一个 n 呢 😂
下面就来看下优先队列如何实现吧,由于我们要使用二叉堆实现优先队列,所以需要先了解下二叉堆:
二叉堆在逻辑上是一种特殊的二叉树(完全二叉树),只不过存储在数组里。
画个图你立即就能理解了,比如 arr 是一个数组,注意数组的第一个索引 0 为空:
所以它的获取相关节点代码如下所示:
// 获取父节点的索引
parent(root: number) {
return Math.floor(root / 2);
}
// 获取左子节点的索引
left(root: number) {
return root * 2;
}
// 获取右子节点的索引
right(root: number) {
return root * 2 + 1;
}
因为这棵二叉树是「完全二叉树」,所以把 arr[1] 作为整棵树的根的话,每个节点的父节点和左右孩子的索引都可以通过简单的运算得到,这就是二叉堆设计的一个巧妙之处。
二叉堆还分为最大堆和最小堆。最大堆的性质是:每个节点都大于等于它的两个子节点。类似的,最小堆的性质是:每个节点都小于等于它的子节点。
两种堆核心思路都是一样的,本文以最大堆为例讲解。
对于一个最大堆,根据其性质,显然堆顶,也就是 arr[1] 一定是所有元素中最大的元素。
我们先简单实现一下优先队列的框架
class PriorityQueue<T> {
queue: T[] = [];
size = 0;
/* 返回当前队列中最大元素 */
max(): T {
return this.queue[1];
}
/* 插入元素 e */
insert(e: T) {
// ...
}
/* 删除并返回当前队列中最大元素 */
deleteMax(): T {
// ...
}
/* 上浮第 x 个元素,以维护最大堆性质 */
swim(x: number) {
// ...
}
/* 下沉第 x 个元素,以维护最大堆性质 */
sink(x: number) {
// ...
}
/* 交换数组的两个元素 */
swap(i: number, j: number) {
let temp: T = this.queue[i];
this.queue[i] = this.queue[j];
this.queue[j] = temp;
}
/* queue[i] 是否比 queue[j] 小? */
less(i: number, j: number) {
return this.queue[i] < this.queue[j];
}
/* 还有 left, right, parent 三个方法 */
}
空出来的四个方法是二叉堆和优先级队列的奥妙所在。
实现 swim 和 sink
为什么要有上浮 swim 和下沉 sink 的操作呢?为了维护堆结构。
我们要讲的是最大堆(根节点的值为数组中最大值),每个父节点都比它的两个子节点大,但是在插入元素和删除元素时,难免破坏此性质,这就需要通过这上浮和下沉来恢复此性质了。
对于最大堆,会破坏堆性质的有两种情况:
- 如果某个节点 A 比它的子节点(中的一个)小,那么 A 就不配做父节点,应该下去,下面那个更大的节点上来做父节点,这就是对 A 进行下沉。
- 如果某个节点 A 比它的父节点大,那么 A 不应该做子节点,应该把父节点换下来,自己去做父节点,这就是对 A 的上浮。
当然,错位的节点 A 可能要上浮(或下沉)很多次,才能到达正确的位置,恢复堆的性质。所以代码中肯定有一个 while 循环。
细心的同学也许会问,这两个操作不是互逆的吗,所以上浮的操作一定能用下沉来完成,为什么我还要费劲写两个方法?
是的,操作是互逆等价的,但是最终我们的操作只会在堆底和堆顶进行(等会讲原因),显然堆底的「错位」元素需要上浮,堆顶的「错位」元素需要下沉。
上浮的代码实现:
swim(x: number) {
// 只要父节点元素小于当前节点元素,那么就要把当前节点上浮
// 即当前节点移上去,原父节点移下来,也就是切换位置
// 当节点移到堆顶时,就停止
while (x > 1 && this.less(this.parent(x), x)) {
this.swap(this.parent(x), x);
// x 设置为父节点,继续向上延伸
x = this.parent(x);
}
}
下沉的代码实现:
下沉比上浮略微复杂一点,因为上浮某个节点 A,只需要 A 和其父节点比较大小即可;但是下沉某个节点 A,需要 A 和其两个子节点比较大小,如果 A 不是最大的就需要调整位置,要把较大的那个子节点和 A 交换。
sink(x: number) {
// 以堆底为边界
while (this.left(x) <= this.size) {
// 先假设左边子节点较大
let max = this.left(x);
// 比较右边子节点,如果更大,更新max
if (this.right(x) <= this.size && this.less(max, this.right(x))) {
max = this.right(x);
}
// 如果左右子节点最大值都比 x 小,那么就符合要求了
if (this.less(max, x)) {
break;
}
// 交换 x 和 max 的内容与位置
this.swap(x, max);
x = max;
}
}
至此,二叉堆的主要操作就讲完了。明白了 sink 和 swim 的行为,下面就可以实现优先队列的 deleteMax 和 insert 了。
这两个方法就是建立在 swim 和 sink 上的。insert 方法先把要插入的元素添加到堆底的最后,然后让其上浮到正确位置。
insert(e: T) {
this.size++
this.queue[this.size]=e
this.swim(this.size)
}
deleteMax 方法先把堆顶元素 A 和堆底最后的元素 B 对调,然后删除 A,最后让 B 下沉到正确位置。
deleteMax() {
// 获取最大元素
let max = this.queue[1];
// 移到最后面
this.swap(1, this.size);
// 删除最后面的元素
this.queue[this.size] = undefined;
this.size--;
// 把移到前面的元素下沉到正确位置
this.sink(1);
// 返回删除的元素
return max;
}
至此,一个优先队列就实现了,插入和删除元素的时间复杂度为 O(log n),n 为当前二叉堆(优先队列)中的元素总数。因为我们时间复杂度主要花费在 sink 或者 swim 上,而不管上浮还是下沉,最多也就树(堆)的高度,也就是 log 级别。
下面来实践下题目:239. 滑动窗口最大值
首先看下我们实现的优先队列:
和上面描述略有不同:
- 增加 compare 函数用于自定义比较函数
- 使用
MyPriorityQueue名称,因为PriorityQueue在 LeetCode 中命名冲突了
class MyPriorityQueue<T> {
queue: Array<T | undefined> = [];
size = 0;
compare: (q: Array<T | undefined>, i: number, j: number) => boolean;
constructor(
compare: (q: Array<T | undefined>, i: number, j: number) => boolean
) {
this.compare = compare;
}
/* 返回当前队列中最大元素 */
max() {
return this.queue[1];
}
/* 插入元素 e */
insert(e: T) {
// 由于 size 是从 0 开始,而且二叉堆不需要 0
// 所以先加 size 之后再赋值数组
this.size++;
// 先把新元素加到最后
this.queue[this.size] = e;
// 然后让它上浮到正确的位置
this.swim(this.size);
}
/* 删除并返回当前队列中最大元素 */
deleteMax() {
// 获取最大元素
let max = this.queue[1];
// 移到最后面
this.swap(1, this.size);
// 删除最后面的元素
this.queue[this.size] = undefined;
this.size--;
// 把移到前面的元素下沉到正确位置
this.sink(1);
// 返回删除的元素
return max;
}
/* 上浮第 x 个元素,以维护最大堆性质 */
swim(x: number) {
// 只要父节点元素小于当前节点元素,那么就要把当前节点上浮
// 即当前节点移上去,原父节点移下来,也就是切换位置
// 当节点移到堆顶时,就停止
while (x > 1 && this.less(this.parent(x), x)) {
this.swap(this.parent(x), x);
// x 设置为父节点,继续向上延伸
x = this.parent(x);
}
}
/* 下沉第 x 个元素,以维护最大堆性质 */
sink(x: number) {
// 以堆底为边界
while (this.left(x) <= this.size) {
// 先假设左边子节点较大
let max = this.left(x);
// 比较右边子节点,如果更大,更新max
if (this.right(x) <= this.size && this.less(max, this.right(x))) {
max = this.right(x);
}
// 如果左右子节点最大值都比 x 小,那么就符合要求了
if (this.less(max, x)) {
break;
}
// 交换 x 和 max 的内容与位置
this.swap(x, max);
x = max;
}
}
/* 交换数组的两个元素 */
swap(i: number, j: number) {
let temp = this.queue[i];
this.queue[i] = this.queue[j];
this.queue[j] = temp;
}
/* queue[i] 是否比 queue[j] 小? */
less(i: number, j: number) {
return this.compare(this.queue, i, j);
}
// 获取父节点的索引
parent(root: number) {
return Math.floor(root / 2);
}
// 获取左子节点的索引
left(root: number) {
return root * 2;
}
// 获取右子节点的索引
right(root: number) {
return root * 2 + 1;
}
}
有了优先队列,我们就来思考下如何解决此问题。首先把区间内 k 个数都放到优先队列中,这样队列会自动排序,并且可以获取最大值或删除最大值,之后再把第 k+1 个数插入到队列,并且判断最大值的下标是否在 k 个数的区间内,如果最大值在区间内的话,那么[1, k+1]区间的最大值就是这个最大值,如果不在区间内的话,那么要把不在区间的最大值都删除掉,直到找到在区间的最大值。
function compare(q: Array<[number, number] | undefined>, i: number, j: number) {
let qi = q[i][0] || 0;
let qj = q[j][0] || 0;
return qi < qj;
}
function maxSlidingWindow(nums: number[], k: number): number[] {
let q = new MyPriorityQueue<[number, number]>(compare);
let t = k;
let res: number[] = [];
while (t--) {
q.insert([nums[k - t - 1], k - t - 1]);
}
if (q.max()) {
res.push(q.max()?.[0] as unknown as number);
}
for (let i = k; i < nums.length; i++) {
q.insert([nums[i], i]);
if (q.max()) {
let t = q.max();
while (t && t[1] <= i - k) {
q.deleteMax();
t = q.max();
}
res.push(t?.[0] as unknown as number);
}
}
return res;
}
参考:二叉堆详解实现优先级队列