一、priority_queue的介绍
- 优先级队列是一种容器适配器,根据严格的弱排序标准,它的第一个元素总是它所包含的元素中最大的。
- 此上下文类似于堆,在堆中可以随时插入元素,并且只能检索最大堆元素(优先级队列中位于顶部的元素)。
- 优先级队列被实现为容器适配器,容器适配器即将特定容器类封装作为其底层容器类,queue 提供一组特定的成员函数来访问其元素。元素从特定容器的“尾部”弹出,其称为优先级队列的顶部。
- 底层容器可以是任何标准容器类模板,也可以是其他特定设计的容器类。容器应该可以通过随机访问、迭代器访问,并支持以下操作:
- empty():检测容器是否为空
- size():返回容器中有效元素的个数
- front():返回容器中第一个元素的引用
- push_back():在容器尾部插入元素
- pop_back():删除容器尾部元素
- 标准容器类 vector 和 deque 满足这些需求。默认情况下,如果没有为特定的 priority_queue 类实例化指定容器类,则使用 vector。
- 需要支持随机访问迭代器,以便始终在内部保持堆结构。容器适配器通过在需要时自动调用算法函数 make_heap、push_heap 和 pop_heap 来自动完成次操作。
二、priority_queue的使用
优先级队列默认使用 vector 作为其底层存储数据的容器,在 vector 上又使用了堆算法将 vector 中元素构造成堆的结构,因此 priority_queue 就是堆,所有需要用到堆的位置,都可以考虑使用 priority_queue。注意:默认情况下 priority_queue 是大堆。
函数声明 | 接口说明 |
priority_queue() | 构造一个空的优先级队列 |
empty() | 检测优先级队列是否为空,是返回 true,否则返回 false |
top() | 返回优先级队列中最大(最小)元素,即堆顶元素 |
push(x) | 在优先级队列中插入元素 x |
pop() | 删除优先级队列中最大(最下)元素,即堆顶元素 |
小Tips:默认情况下,priority_queue 是大堆(默认第三个模板参数是 less);如果在 priority_queue 中放自定义类型的数据,用户需要在自定义类型中提供 > 或者 < 的重载。
2.1 数组中的第k个最大元素
class Solution { public: int findKthLargest(vector<int>& nums, int k) { //建一个大堆,向下调整建堆的时间复杂度是O(N) priority_queue<int> pq(nums.begin(), nums.end()); //pop k-1次,时间复杂度是O(K*logN) while(--k) { pq.pop(); } return pq.top(); } };
上面这种做法当 K 的值接近 N 的时候,它的时间复杂度就是 O(N*logN),是不满足题目要求的,下面再用另外一种方法来解决。
lass Solution { public: int findKthLargest(vector<int>& nums, int k) { //用前k个数建一个小堆 priority_queue<int, vector<int>, greater<int>> pq(nums.begin(), nums.begin() + k); //遍历剩下的 N-k 个,比堆顶大就换进去 //时间复杂度是 (N-K)logN for(size_t i = k; i < nums.size(); i++) { if(nums[i] > pq.top()) { pq.pop(); pq.push(nums[i]); } } return pq.top(); } };
上面这种解法是先用数组中的前 K 个元素建一个小堆,然后从数组的第 K+1 个元素开始往后遍历,遇到比堆顶元素大的就将堆顶的元素 pop 出来,将当前元素 push 进去。建堆的时间复杂度是 O(K),更新小堆的时间复杂度是 O((N-K)logK),这种做法当 K 很小的时候时间复杂度可以近似看做 O(NlogK),当 K 很大的时候,时间复杂度可以近似看做 O(logK)。
三、priority_queue模拟实现
3.1 仿函数
仿函数本质上一个类,之所以把它叫仿函数是因为这个类的对象可以像函数一样去使用。举例如下:
//一个仿函数 template<class T> class Less { public: bool operator()(const T& x, const T& y) { return x < y; } }; int main() { Less<int> lessfunc;//定义一个对象 cout << lessfunc(1, 2) << endl;//该类型对象可以像函数一样去使用 //cout << lessfunc.operator()(1, 2) << endl;//和上面等价 return 0; }
小Tips:仿函数一般都是类模板,这样就可以支持不同类型的大小比较,前提是该种类型重载实现了比较运算符。仿函数的诞生是为了代替函数指针,函数指针的可读性比较差。
3.2 成员变量
template<class T, class Container=std::vector<T>, class Compare = Less<T>> class priority_queue { public: //成员 private: Container _con; };
小Tips:需要注意这里有三个模板参数,第一个模板参数表示要在优先级队列中存储的数据类型;优先级队列本质上也是一个容器适配器,所以第二个模板参数表示优先级队列要使用的底层容器;第三个模板参数是一个仿函数,用来进行大小比较的,因为优先级队列中会涉及到建大堆还是建小堆,中间会涉及到比较,如果没有这个仿函数,那么大小比较就只能写死了,这样不太好。
3.3 成员函数
3.3.1 构造函数
priority_queue() {} template<class InputeIterator> priority_queue(InputeIterator first, InputeIterator last) { //先将数据插入 while (first != last) { _con.push_back(*first); ++first; } //建堆,从最后一个非叶子节点开始向下调整 for (int i = (_con.size() - 1) / 2; i >= 0; i--) { AdjustDown(i); } }
小Tips:迭代器区间构造函数是一个函数模板,只要是能支持 ++ 操作的迭代器区间都可以,即单向迭代器、双向迭代器、随机迭代都可以。其次将数据插入容器后还需要建堆,这里采用向下调整建堆,它的时间复杂度是 O(N)。
3.3.2 AdjustDown
void AdjustDown(int parent) { Compare com; while (parent * 2 + 1 < (int)_con.size()) { //找到最大的孩子 int maxchild = parent * 2 + 1; if (maxchild + 1 < (int)_con.size() && com(_con[maxchild], _con[maxchild + 1])) { maxchild++; } //和父节点比较 if (com(_con[parent], _con[maxchild])) { std::swap(_con[maxchild], _con[parent]); parent = maxchild; } else { break; } } }
小Tips:在仿函数的加持下,向下调整代码中的大小比较不再固定,建大堆和小堆这一份代码就够了,最终是大堆还是小堆是由仿函数来控制的。
3.3.3 push
void push(const T& val) { _con.push_back(val); AdjustUp(_con.size() - 1); }
小Tips:在优先级队列的尾部追加数据,会涉及到向上调整,向上调整的代码如下所示。
3.3.4 AdjustUp
void AdjustUp(int child) { Compare com; int parent = (child - 1) / 2; while (child > 0)//父亲不会小于0,因此这里的判断条件要用孩子大于0,孩子等于0说明已经调整到根节点,就无需继续调整了 { if (com(_con[parent], _con[child])) { std::swap(_con[child], _con[parent]); child = parent; parent = (child - 1) / 2;//这里parent不会小于0 } else { break; } } }
3.3.5 pop
void pop() { std::swap(_con[0], _con[_con.size() - 1]); _con.pop_back(); AdjustDown(0); }
小Tips:优先级队列中出数据,出的是堆顶的数据,堆顶的数据也就是容器中的第一个数据,如果底层容器是 vector 那么堆顶的数据就是下标为 0 的数据,出堆顶的数据不能直接使用头删,这样会导致后面数据的父子关系全乱了。正确的做法是,将堆顶的数据和容器中的最后一个数据交换,然后执行 pop_back 去删除,最后还需要从根节点开始执行一次向下调整,让交换到堆顶的数据去到它应该去的地方。
3.3.6 empty
bool empty() { return _con.size() == 0; }
3.3.7 size
size_t size() { return _con.size(); }
四、结语
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,春人的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是春人前进的动力!