题目:前 K 个高频元素
https://leetcode.cn/problems/top-k-frequent-elements/description/
给定一个整数数组 nums 和一个整数 k,返回出现频率前 k 高的元素。可以按任意顺序返回答案。
示例 1:
输入:nums = [1,1,1,2,2,3], k = 2
输出:[1,2]
示例 2:
输入:nums = [1], k = 1
输出:[1]
示例 3:
输入:nums = [1,2,1,2,1,2,3,1,3,2], k = 2
输出:[1,2]
提示:
1 <= nums.length <= 10^5-10^4 <= nums[i] <= 10^4k的取值范围是[1, 数组中不相同的元素的个数]- 题目数据保证答案唯一,即前
k个高频元素的集合是唯一的
进阶: 设计一个时间复杂度 优于 O(n log n) 的算法。
解法一:哈希表 + 最小堆(O(n log k))
思路 :先用哈希表统计每个元素出现的次数,然后用一个大小为 k 的最小堆(优先队列)维护当前出现次数最多的 k 个元素。遍历哈希表,将每个元素加入堆中,若堆大小超过 k,则弹出堆顶(频率最小的元素)。最终堆中留下的就是前 k 个高频元素。
复杂度 :时间复杂度 O(n log k),空间复杂度 O(n)。由于 k ≤ n,且 log k ≤ log n,当 k 较小时优于 O(n log n)。
cpp
class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> freq;
for (int num : nums) freq[num]++;
// 最小堆,按频率排序,存储 pair<频率, 元素>
using pii = pair<int, int>;
priority_queue<pii, vector<pii>, greater<pii>> minHeap;
for (auto& [num, cnt] : freq) {
minHeap.push({cnt, num});
if (minHeap.size() > k) minHeap.pop();
}
vector<int> result;
while (!minHeap.empty()) {
result.push_back(minHeap.top().second);
minHeap.pop();
}
return result;
}
};
解法二:哈希表 + 桶排序(O(n))
思路:利用频率的范围(最大为 n),创建一个大小为 n+1 的桶数组,桶的下标表示频率,桶内存储对应频率的元素列表。先统计频率,然后将每个元素放入对应频率的桶中,最后从高到低遍历桶,收集前 k 个元素。
复杂度:时间复杂度 O(n),空间复杂度 O(n)。
cpp
class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> freq;
for (int num : nums) freq[num]++;
int n = nums.size();
vector<vector<int>> bucket(n + 1); // 桶下标为频率
for (auto& [num, cnt] : freq) {
bucket[cnt].push_back(num);
}
vector<int> result;
for (int i = n; i >= 0 && result.size() < k; --i) {
for (int num : bucket[i]) {
result.push_back(num);
if (result.size() == k) break;
}
}
return result;
}
};
总结
- 解法一 适用于 k 较小或中等的情况,代码简洁。
- 解法二 达到了 O(n) 的最优时间复杂度,但需要额外 O(n) 空间。两种解法均可通过题目测试。
最大堆和最小堆详解
堆(Heap)是一种特殊的完全二叉树,它满足堆性质:对于最大堆 ,任意节点的值大于等于其子节点的值;对于最小堆,任意节点的值小于等于其子节点的值。堆通常用数组实现,因其完全二叉树特性,可以高效地完成插入、删除、取极值等操作。
一、基本概念
1. 完全二叉树
堆是一种完全二叉树,即除了最后一层外,所有层都填满,且最后一层的节点尽量靠左排列。这种结构可以用数组紧凑表示,根节点下标为 0(或 1),节点 i 的左孩子下标为 2i+1,右孩子为 2i+2,父节点为 (i-1)/2。
2. 最大堆
- 定义:对于任意节点
i,heap[i] ≥ heap[2i+1]且heap[i] ≥ heap[2i+2](若子节点存在)。 - 性质:根节点是整个堆的最大值。
3. 最小堆
- 定义:对于任意节点
i,heap[i] ≤ heap[2i+1]且heap[i] ≤ heap[2i+2](若子节点存在)。 - 性质:根节点是整个堆的最小值。
二、堆的基本操作
1. 向上调整(上浮,Heapify Up)
当插入新元素时,先将新元素放在数组末尾,然后不断与其父节点比较,若不满足堆性质则交换,直到满足或到达根节点。
cpp
void heapifyUp(vector<int>& heap, int idx) {
while (idx > 0) {
int parent = (idx - 1) / 2;
if (heap[parent] < heap[idx]) { // 最大堆:父小于子则交换
swap(heap[parent], heap[idx]);
idx = parent;
} else break;
}
}
2. 向下调整(下沉,Heapify Down)
当删除堆顶元素时,将堆顶与最后一个元素交换,删除最后一个元素,然后从堆顶开始不断与较大的子节点交换(最大堆),直到满足堆性质。
cpp
void heapifyDown(vector<int>& heap, int idx, int size) {
while (2*idx+1 < size) {
int child = 2*idx+1;
// 选择较大的子节点(最大堆)
if (child+1 < size && heap[child+1] > heap[child]) child++;
if (heap[idx] < heap[child]) {
swap(heap[idx], heap[child]);
idx = child;
} else break;
}
}
3. 建堆(Build Heap)
给定一个无序数组,可以通过从最后一个非叶子节点开始依次向下调整,在 O(n) 时间内建成堆。
cpp
void buildHeap(vector<int>& heap) {
int n = heap.size();
for (int i = n/2 - 1; i >= 0; --i) {
heapifyDown(heap, i, n);
}
}
4. 插入
在数组末尾添加新元素,然后向上调整。
cpp
void push(vector<int>& heap, int val) {
heap.push_back(val);
heapifyUp(heap, heap.size()-1);
}
5. 删除堆顶
将堆顶与最后一个元素交换,删除最后一个元素,然后向下调整新的堆顶。
cpp
void pop(vector<int>& heap) {
if (heap.empty()) return;
heap[0] = heap.back();
heap.pop_back();
heapifyDown(heap, 0, heap.size());
}
6. 获取堆顶
直接返回 heap[0]。
三、时间复杂度
| 操作 | 时间复杂度 |
|---|---|
| 建堆 | O(n) |
| 插入 | O(log n) |
| 删除堆顶 | O(log n) |
| 获取堆顶 | O(1) |
四、C++ 中的堆实现:priority_queue
C++ 标准库提供了优先队列 priority_queue,默认是最大堆 (通过 std::less<T> 比较)。要得到最小堆,可以使用 std::greater<T>。
1. 最大堆
cpp
#include <queue>
priority_queue<int> maxHeap; // 默认最大堆
maxHeap.push(3);
maxHeap.push(1);
maxHeap.push(4);
cout << maxHeap.top(); // 4
2. 最小堆
cpp
priority_queue<int, vector<int>, greater<int>> minHeap;
minHeap.push(3);
minHeap.push(1);
minHeap.push(4);
cout << minHeap.top(); // 1
3. 自定义类型
需要提供比较函数,例如存储 pair<int,int> 按第一个元素排序:
cpp
struct Compare {
bool operator()(const pair<int,int>& a, const pair<int,int>& b) {
return a.first > b.first; // 最小堆,按 first 升序
}
};
priority_queue<pair<int,int>, vector<pair<int,int>>, Compare> pq;
五、典型应用场景
- 优先队列:任务调度、事件模拟。
- 求 Top K 问题:用最小堆维护前 K 大元素,用最大堆维护前 K 小元素。
- 堆排序:堆排序算法,时间复杂度 O(n log n),空间 O(1)。
- 合并 K 个有序链表:用最小堆每次取出最小值。
- 中位数维护:用最大堆存较小的一半,最小堆存较大的一半,可动态求中位数。
六、堆与 STL 其他容器的对比
| 特性 | 堆(priority_queue) | 有序容器(set/map) | 无序容器(unordered_set/map) |
|---|---|---|---|
| 顺序 | 只保证堆顶最值 | 全局有序 | 无序 |
| 插入 | O(log n) | O(log n) | O(1) 平均 |
| 查找 | 不支持快速查找 | O(log n) | O(1) 平均 |
| 删除 | 只能删除堆顶 | 可删除任意元素 | 可删除任意元素 |
| 适用场景 | 需要频繁取极值 | 需要有序遍历或查找 | 需要快速查找 |
七、总结
- 堆是一种高效维护极值的数据结构,核心是上浮和下沉操作。
- C++ 的
priority_queue封装了堆,使用方便。 - 理解堆的性质和操作,是解决许多算法问题的基础,如 Top K、合并 K 个有序序列、中位数等。