目录
理论回顾
大顶堆和小顶堆
堆是一棵完全二叉数,大顶堆,即根节点的元素都要大于等于孩子节点;小顶堆,即根节点的元素都要小于等于孩子节点。
大顶堆或小顶堆很适合用来查询前 k 个高频元素或低频元素。
优先级队列 priority_queue
什么是优先级队列
之前说过,STL提供的容器适配器有栈 stack、队列 queue,还有第三种优先级队列 priority_queue。
优先级队列和队列的区别在于,优先级队列会让优先级更高的先出队,而不是先入队的先出队。每当有新元素进入,优先级队列都会根据排序规则找到优先级最高的元素并将其移动到队头,以保证每次从队头弹出的都是优先级最高的元素。例如,排序规则为降序排列时,优先级队列都会让最大的元素移动到队头,以保证队列是降序排列的。
优先级队列的底层实现就是堆,可以自定义是大顶堆或小顶堆。优先级队列默认使用降序排列,即默认用大顶堆完成元素排序。优先级队列默认的底层容器是 vector,相当于用数组模拟完全二叉树。
同样,优先级队列和其他容器适配器一样也不可以使用迭代器遍历。
学习参考:C++ STL priority_queue容器适配器详解
优先级队列的定义和初始化
#include <queue> // 该头文件包含了优先级队列
// 优先级队列的定义
// typename T:指定队列中存储元素的具体类型
// typename Container:指定优先级队列使用的基础容器,默认使用 vector 容器
// typename Compare:指定评定元素优先级的比较函数,默认使用 std::less<T>,即降序排列;std::greater<T>是升序排列
// 但大部分情况使用的都是自定义排序规则
priority_queue<typename T, typename Container=std::vector<T>, typename Compare=std::less<T> > name;
// 优先级队列的初始化
// 1)底层容器默认vector,排序方式默认 less<T>,只需填写元素类型
priority_queue<int> que;
// 2)手动指定底层容器和排序规则
priority_queue<int,vector<int>,less<int>> que; // 大顶堆
priority_queue<int,vector<int>,greater<int>> que; // 小顶堆
优先级队列的基本操作
优先级队列的成员函数 | 功能 | 时间复杂度 |
---|---|---|
empty() | 如果 priority_queue 为空的话,返回 true;反之,返回 false。 | O(1) |
size() | 返回 priority_queue 中元素的个数。 | O(1) |
top() | 返回 priority_queue 中优先级最高的元素,但不删除该元素。 | O(1) |
push(item) | 根据既定的排序规则,将元素 item 移动存储到 priority_queue 中适当的位置。 | O(logn) |
pop() | 删除 priority_queue 中优先级最高的元素,但并不返回该元素 | O(logn) |
优先级队列自定义排序方式
优先级队列自定义排序 和 sort 自定义排序的方法类似,但要注意的是,在写比较函数时,sort 和 优先级队列正好相反。当 left < right 时,sort 会按从小到大排序,而优先级队列会按照从大到小,即大顶堆排序。具体原因还不太能搞懂。。。
看了些其他大佬写的理解,说是因为优先级队列底层实现是堆,而每次元素出堆时是把堆顶元素和最后一个元素互换,也就是把堆顶元素沉到底层容器 vector 的末尾,出堆的过程导致了元素反序,相当于逆序输出。
反向思考一下,如果我们需要在优先级队列中让元素是从大到小输出的,那么在队列里排序的时候就应该是反过来从小到大,就需要让优先级队列里 left < right,这样出堆逆序输出时才会是从大到小。
参考链接:一文讲明白优先级队列为啥按less排序却是从大到小_牛客网
// 自定义比较器类
class MyComparator {
public:
// 重载 operator 函数
bool operator()(const int lhs, const int rhs) {
// 升序排列,小顶堆,是 lhs > rhs
// 发现和sort中的自定义排序规则相反
return lhs > rhs;
}
};
前 k 个高频元素
题干
题目:给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。
进阶:你所设计算法的时间复杂度 必须 优于 O(n log n) ,其中 n 是数组大小。
示例 1:
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
思路
这道题主要有三个操作:对元素的出现频次进行统计、根据统计频次进行排序、罗列前 k 个高频元素。对数组中的元素出现频次进行统计,可以使用 map 存储元素、元素的出现次数;而如何根据频次进行排序?可以使用 sort 快排,也可以使用优先级队列。
方法一快排:当使用 map 统计完每个元素和元素次数后,我们需要先将 map 转化为 vector,才能使用 sort 快排。sort 中的比较函数 cmp 的条件应为 map 的 value值(即元素次数)大时才返回 true,也就是说我们需要根据元素次数对元素进行降序排列。最后再从排序后的 vector 中输出前 k 个元素。时间复杂度 O(n*logn),空间复杂度O(n)。
方法二优先级队列:我们需要获取前 k 个高频元素,那我们其实只需要维护这前 k 个元素即可,不需要维护所有的元素。当用 map 统计完每个元素的次数后,我们遍历 map,用含 k 个节点的小顶堆更新每次遍历完的前 k 个高频元素,每当新元素的频次比小顶堆的要大,就把堆顶踢出,插入新元素,这样遍历完 map 后还剩余在小顶堆中的元素就是前 k 个。时间复杂度 O(n*logk),空间复杂度O(n)。
Q:为什么用小顶堆而不是大顶堆?
我们知道大顶堆是按照降序排列的,每次从堆顶剔除元素时都是删去的最大值。而我们需要保留前 k 个元素,删去最大值和我们的需求冲突,我们需要的是删除小的元素。因此应该要用小顶堆,每次都对 k 个元素进行升序排列,当有频次更高的元素则插入小顶堆,然后剔除最小频次的堆顶元素,最后剩余的就是 k 个频次最高的元素。
代码
方法一:快排
bool cmp(pair<int,int> a, pair<int,int> b){
return a.second > b.second;
}
class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int,int> count;
// 用 map 统计元素个数
for (int i : nums) {
count[i]++;
}
// 将 map 转化为 vector
vector<pair<int,int>> sortedMap(count.begin(),count.end());
// 根据元素个数对 键值对 进行降序排列
sort(sortedMap.begin(),sortedMap.end(),cmp);
vector<int> result; // 存储前 k 个元素
for (int i = 0; i < k; ++i) {
result.push_back(sortedMap[i].first);
}
return result;
}
};
方法二:优先级队列/小顶堆
class Solution {
public:
// 定义优先级队列的排序方式
class cmp {
public:
bool operator()(pair<int, int>& lhs, pair<int, int>& rhs) {
return lhs.second > rhs.second;
}
};
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int,int> count;
// 使用 map 统计元素和元素出现的次数
for (int i : nums) {
count[i]++;
}
// 定义一个小顶堆,即优先级队列,大小为 k
priority_queue<pair<int,int>,vector<pair<int,int>>,cmp> topK;
// 用优先级队列对 map 中的元素排序
auto it = count.begin();
// 先插入 k 个元素
while (k--){
topK.push(*it);
it++;
}
while (it != count.end()){
if (it->second > topK.top().second){
topK.pop(); // 弹出最小值
topK.push(*it); // 插入更大的
}
it++;
}
// 将小顶堆最后的 k 个元素存入数组中,由于题目没有要求一定要按顺序输出元素,因此直接存入数组即可
// 如果要让数组中的元素是根据频次从高到低排列,那么应该让小顶堆中的元素逆序存入数组,因为小顶堆是升序排列
vector<int> result;
while (!topK.empty()){
result.push_back(topK.top().first);
topK.pop();
}
return result;
}
};
栈与队列总结
题目汇总
熟悉栈和队列的基础操作:用栈实现队列、用队列实现栈
栈的经典应用:括号匹配问题、删除相邻重复项、逆波兰表达式
队列的经典应用:求滑动窗口最大值(单调队列)、求前 k 个高频元素(优先级队列)
三种容器适配器
容器适配器 | 基础容器筛选条件 | 默认使用的基础容器 |
---|---|---|
stack | 基础容器需包含以下成员函数: empty() size() back() push_back() pop_back() 满足条件的基础容器有 vector、deque、list。 | deque |
queue | 基础容器需包含以下成员函数: empty() size() front() back() push_back() pop_front() 满足条件的基础容器有 deque、list。 | deque |
priority_queue | 基础容器需包含以下成员函数: empty() size() front() push_back() pop_back() 满足条件的基础容器有vector、deque。 | vector |
常见问题
Q1:Stack、Queue是不是容器?
Stack、Queue都是容器适配器,不是容器。容器适配器就是通过封装某个序列式容器,并重新组合该容器中包含的成员函数,使其满足某些特定场景的需要。
Q2:Stack、Queue可不可以用迭代器遍历?
容器适配器都不可以用迭代器遍历所有空间。
Q3:栈里面的元素在内存中是连续分配的吗 ?
-
陷阱1:栈是容器适配器,底层容器使用不同的容器,导致栈内数据在内存中不一定是连续分布的。
-
陷阱2:栈默认底层容器是deque,那么deque在内存中的数据分布是什么样的呢? 答案是:不连续的。