优先队列

对于排序,比如快排等将一个序列排成有序,其中包含最大、最小值。但是有时候我们并不需要知道整个序列的排序情况,只想知道最值,这个最值可能是最大、最小值。这时我们就可以使用优先队列。

优先队列应用

  • 调度: 选择优先级最高的进行调度
  • 极值: 只想知道某些样本的极值
  • 很多贪心算法中
  • 堆排序

优先队列性质

本文介绍基于完全二叉树实现的优先队列

基于完全二叉树实现的优先队列性质

任何一颗子树的根节点都比它的两个子节点大/小

什么叫完全二叉树 ?

叶子节点只能出现在最底部的两层,且最底层叶节点均处于次底层叶节点的左侧。

图片说明

完全二叉树可以用向量表示:

  • 2*k+1为左孩子
  • 2*k+2为右孩子
  • k元素的父元素为k/2
  • 根节点从0开始

上述性质可以使得我们使用vector数组来保存完全二叉树数据。

  • v[0] 为根节点
  • v[1] 为左孩子
  • v[2] 为右孩子

完全二叉树高度: logN,没下一层,元素数量增加一倍,高度相对减半

依次类推

大顶堆

任务一颗子树的根节点元素都比它的两个子树节点元素值大,特别的,根元素(堆顶)为最大。

小顶堆

任务一颗子树的根节点元素都比它的两个子树节点元素值小,特别的,根元素(堆顶)为最小。

大小顶堆只是比较的时候是使用‘<’符号还是‘>’符号的问题,所以搞定一个,另一个换个符号即可。

优先队列API

    // 优先队列基类
    template <typename Key>
    class PQ {
    public:
        PQ();
        virtual ~PQ() = default;
        void insert(Key v); // 插入一个元素
        Key top();          // 返回顶部元素
        Key delTop();       // 删除并返回顶部最大/最小元素
        bool empty() { return _a.empty(); }        
        int size() { return _a.size()-1; }
    protected:
        virtual bool compare(int i, int j) = 0;
        virtual void exch(int i, int j) = 0;    // 交互i、j两个位置所在的元素       
        vector<Key> _a;     // 从1开始,0位置不用
        void sink(int k);   // 顶部删除,尾部元素放在顶部下沉
        void swim(int k);   // 从尾部添加元素上浮
    };

关键操作插入和删除

维护优先队列的两个基本操作分别是插入insert、删除delTop。 底层涉及的操作分别对应为swim和sink。

插入上浮swim

优先队列插入一个数据在尾部k插入,然后通过比较父元素k/2进行上浮,不断重复与父元素比较,直到到达顶部元素。

插入时间复杂度

尾部插入后需要逐层与其父元素比较,直到顶部根元素,而完全二叉树的高度为logN,所以最多比较logN次,时间复杂度也就是logN。

    // k位置元素与他的父元素比较,比父元素大/小上浮,直到父元素更大/更小或到达顶部
    template <typename Key>
    void PQ<Key>::swim(int k) {
        while(k/2 >= 1 && compare(k, k/2)) {
            exch(k/2, k);
            k = k/2;
        }
    }

顶部删除下沉sink

优先队列从删除顶部元素,然后拿尾部的元素来填补顶部的空位。为了保持优先队列性质,需要从顶部元素依次与子节点元素比较,逐步下沉比较,直到叶子节点。

删除操作时间复杂度

从顶部删除,然后置换尾元素进行下沉比较,每次先比较两个子元素大小,然后再比较子元素较大的那个与下沉元素比较,所以确定一次下沉操作涉及两次比较,而最多下沉树高次,完全二叉树树高为logN,所以删除操作时间复杂度为2*logN。

    // 下沉k索引所在的元素
    // 在2k和2k+1两个子元素中比较那个大/小,与k进行交换
    // 一直到没有子元素比k大/小,或者到达底部
    template <typename Key>
    void IndexPQ<Key>::sink(int k) {
        int N = size(); // !!!注意第一个元素没用
        while(2*k <= N) {
            int switch_child;
            if(2*k < N ) {
                // 满二叉树
                switch_child = compare(2*k , 2*k+1) ? 2*k : 2*k+1; //2k和2k+1中较大/较小的那个索引
            }
            else {
                // 差一个满树
                switch_child = 2*k;
            }
            if(compare(switch_child, k)) {
                exch(k, switch_child);
                //交互之后原来k位置的元素到了switch_index索引位置,需要继续看下面子树是否需要继续下沉
                k = switch_child;   
            }
            else {
                break;
            }
        }
    }

优先队列完整代码实现

索引优先队列

有时需要将插入元素与一个索引关联起来,比如合并三个有序超大数据流s1, s2, s3。它们每个流中数据都是有序的,流可能来自网络、文件、或者其他程序处理,现在需要合并这3个有序流。因为它们大到内存放不下,所以不能直接合并,这时可以使用索引优先队列进行有序合并,类似选择排序。

测试用例

    SECTION("索引优先队列 : 合并三个有序流的数据") {

        deque<char> v1({'A','B', 'C', 'F', 'G', 'I', 'I', 'Z'});
        deque<char> v2({'B', 'D', 'H', 'P', 'Q', 'Q'});
        deque<char> v3({'A', 'B', 'E', 'F', 'J', 'N'});
        deque<deque<char>> vs = {v1, v2, v3};

        deque<char> res;
        res.insert(res.end(), v1.begin(), v1.end());
        res.insert(res.end(), v2.begin(), v2.end());
        res.insert(res.end(), v3.begin(), v3.end());

        sort(res.begin(), res.end());
        IndexPQ<char> *idxPQ = new MinIndexPQ<char>(3);
        for(size_t i = 0; i < vs.size(); i++) {
            if(!vs[i].empty()) {
                idxPQ->insert(i, vs[i][0]);
                vs[i].pop_front();
            }
        }
        int cnt = 0;
        while(!idxPQ->empty()) {
            REQUIRE(res[cnt++] == idxPQ->top());
            int i = idxPQ->delTop();
            if(!vs[i].empty()) {
                idxPQ->insert(i, vs[i][0]);
                vs[i].pop_front();
            }
        }
    }

索引优先队列实现API

    // 索引优先队列
    // 将一个索引与元素关联起来
    template <typename Key>
    class IndexPQ{
    public:
        IndexPQ(int max);
        virtual ~IndexPQ() = default;
        void insert(int index, Key v);
        size_t size() { return N;}
        bool empty() { return N==0;}
        Key top();          // 隐藏基类方法
        int delTop();       // 隐藏基类方法
    protected:
        size_t N;           // 索引优先队列中有多少元素
        vector<int> pq;     // 二叉堆 从1开始
        vector<int> qp;
        vector<Key> keys;   // 实际元素
        virtual bool compare(int i, int j) = 0;
        void sink(int k);   // 顶部删除,尾部元素放在顶部下沉
        void swim(int k);   // 从尾部添加元素上浮
        void exch(int i, int j){
            swap(pq[i], pq[j]);
        }
    };

同样,索引优先队列也可以分为索引小顶堆和索引大顶堆

索引小顶堆

    // 索引小顶堆
    template <typename Key>
    class MinIndexPQ : public IndexPQ<Key> {
    public:
        MinIndexPQ(int max) : IndexPQ<Key>(max) {};
    private:
        bool compare(int i, int j) override {
            return this->keys[this->pq[i]] < this->keys[this->pq[j]];
        }
    };

索引大顶堆

    // 索引大顶推
    template <typename Key>
    class MaxIndexPQ : public IndexPQ<Key> {
    public:
        MaxIndexPQ(int max) : IndexPQ<Key>(max) {};
    private:
        bool compare(int i, int j) override {
            return this->keys[this->pq[i]] > this->keys[this->pq[j]];
        }
    };

索引优先队列完整代码实现

堆排序

我们还可以利用堆的性质构建一个排序算法,称作堆排序

堆排序的两个关键步骤

  • 建堆
  • 从堆顶选择最值交换到尾部,重新维护堆

关键思路: 从最后一个非叶子节点子树开始建堆 !!!

图片说明

对于上图就是j这个最后非叶子节点作为子树根所在的子树进行建堆。然后依次往回、往上建更大的堆,最终完成整个堆的构建。

这样,一下就省略了一半的元素,大大提高了建堆的效率。

为什么不是从叶子节点开始?

因为叶子节点只有一个元素,本身就是一个合法堆。

    template <typename T>
    void heap_sort(vector<T> &a, Compare<T> *compare) {
        // 建堆
        int N = a.size();
        int k = N/2 - 1;        // 第一个非叶子节点
        while(k>=0) {           // 还未超出根节点
            sink(a, k, N, compare);      // 从k位置作为子树根节点构建大顶堆/小顶堆
            k--;                // 上一个节点继续构建堆
        }
        // 每次去顶部最大值放到最后排序
        while(N > 1) {  // N == 1时就剩一个,不用排了
            swap(a[0], a[--N]);
            sink(a, 0, N, compare);
        }
    }

堆排序完整代码实现

完整测试代码