数据结构 优先队列

269 阅读4分钟

上篇讲解了队列实现:队列实现栈以及栈实现队列,你可能还听过优先队列(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

这多完美呀,确实,是这么个意思,但是你的时间复杂度高呀,insertO(1) 很好,但是 deleteMax 方法呢?来看下:sort 是 O(n log(n)),shift 是 O(n),合在一起是 O(n log(n)),优先队列是 O(log(n)),所以你的方法多出了一个 n 呢 😂

下面就来看下优先队列如何实现吧,由于我们要使用二叉堆实现优先队列,所以需要先了解下二叉堆:

二叉堆在逻辑上是一种特殊的二叉树(完全二叉树),只不过存储在数组里。

画个图你立即就能理解了,比如  arr  是一个数组,注意数组的第一个索引 0 为空

image.png

所以它的获取相关节点代码如下所示:

// 获取父节点的索引
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  的操作呢?为了维护堆结构。

我们要讲的是最大堆(根节点的值为数组中最大值),每个父节点都比它的两个子节点大,但是在插入元素和删除元素时,难免破坏此性质,这就需要通过这上浮和下沉来恢复此性质了。

对于最大堆,会破坏堆性质的有两种情况:

  1. 如果某个节点 A 比它的子节点(中的一个)小,那么 A 就不配做父节点,应该下去,下面那个更大的节点上来做父节点,这就是对 A 进行下沉
  2. 如果某个节点 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  的行为,下面就可以实现优先队列的 deleteMaxinsert 了。

这两个方法就是建立在  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.  滑动窗口最大值

image.png

首先看下我们实现的优先队列:

和上面描述略有不同:

  1. 增加 compare 函数用于自定义比较函数
  2. 使用 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;
}

参考:二叉堆详解实现优先级队列