二叉堆
- 二叉堆基础知识
- 和二叉搜索树的区别
- 代码
- 删除堆顶:向下调整
- 其他的操作
- [Top K 问题](#Top K 问题)
-
- 数组中的第K个最大元素
- [前 K 个高频元素](#前 K 个高频元素)
- 多路归并(合并有序序列)
- 数据流的中位数
- 滑动窗口最大值
- 贪心+堆
二叉堆基础知识
二叉堆分为两种类型:
最大堆 (Max Heap):任何一个父节点的值都大于或等于其子节点的值。根节点是最大值。
最小堆 (Min Heap):任何一个父节点的值都小于或等于其子节点的值。根节点是最小值。
存储方式虽然逻辑上是树形结构,但二叉堆通常使用数组来存储,因为完全二叉树的特性使得索引计算非常简单
若父节点索引为 i i i(从 0 开始):
左子节点索引: 2 i + 1 2i + 1 2i+1
右子节点索引: 2 i + 2 2i + 2 2i+2父节点索引: ( i − 1 ) / 2 (i - 1) / 2 (i−1)/2
核心操作
- 插入 (Push):将新元素放在数组末尾,然后向上调整(Sift Up)以维护堆序。
- 弹出 (Pop):移除根节点,将数组最后一个元素放到根部,然后向下调整(Sift Down)
和二叉搜索树的区别
特性,二叉堆 (Binary Heap),二叉搜索树 (BST)
主要用途,快速查找最大/最小值(优先队列),快速查找、插入、删除任意元素
顺序性,纵向有序(父比子大),横向无序,全局有序(左 < 根 < 右)
形状限制,必须是完全二叉树,结构紧凑,形状取决于插入顺序(可能退化为链表)
查找效率,查找最大/小值为 O(1),其他为 O(n),查找平均为 O(logn)
| 特性 | 二叉堆 | 二叉搜索树 |
|---|---|---|
| 主要用途 | 快速查找最大/最小值(优先队列) | 快速查找、插入、删除任意元素 |
| 顺序性 | ,纵向有序(父比子大) | 横向无序,全局有序(左 < 根 < 右) |
| 形状限制 | 必须是完全二叉树 | 结构紧凑,形状取决于插入顺序(可能退化为链表) |
| 查找效率 | 查找效率,查找最大/小值为 O(1)其他为 O(n) | 查找平均为 O(logn) |
代码
cpp
class MinHeap {
private:
std::vector<int> heap;
// 辅助函数:获取父节点和子节点的索引
int getParent(int i) { return (i - 1) / 2; }
int getLeft(int i) { return 2 * i + 1; }
int getRight(int i) { return 2 * i + 2; }
};
向上调整
当你向堆末尾添加一个新元素时,它可能会破坏"父节点比子节点小"的规则。这时需要让它不断与父节点交换,直到找到合适的位置。
cpp
void siftUp(int index) {
// 如果当前节点不是根节点,且值小于父节点,则交换
while (index > 0 && heap[index] < heap[getParent(index)]) {
std::swap(heap[index], heap[getParent(index)]);
index = getParent(index); // 继续向上检查
}
}
public:
void push(int val) {
heap.push_back(val); // 1. 先插到最后
siftUp(heap.size() - 1); // 2. 向上调整
}
删除堆顶:向下调整
删除堆顶(最小值)时,我们不能直接删掉 heap[0]。 标准做法: 把数组最后一个元素覆盖到堆顶,然后让它不断与较小的子节点交换,直到重新平衡。
cpp
void siftDown(int index) {
int smallest = index;
int left = getLeft(index);
int right = getRight(index);
int size = heap.size();
// 找出当前节点、左孩子、右孩子中最小的那个
if (left < size && heap[left] < heap[smallest]) smallest = left;
if (right < size && heap[right] < heap[smallest]) smallest = right;
// 如果最小的不是当前节点,说明需要下沉
if (smallest != index) {
std::swap(heap[index], heap[smallest]);
siftDown(smallest); // 递归或循环继续向下
}
}
public:
void pop() {
if (heap.empty()) return;
heap[0] = heap.back(); // 1. 末尾元素覆盖堆顶
heap.pop_back(); // 2. 删掉末尾
if (!heap.empty()) {
siftDown(0); // 3. 向下调整
}
}
其他的操作
cpp
int top() {
return heap.empty() ? -1 : heap[0]; // 简单处理空堆情况
}
bool empty() { return heap.empty(); }
};
Top K 问题
数组中的第K个最大元素
思路就是维护一个小堆,堆顶最小,然后往里面加数字,加的数字要是大,就把原来的小堆顶pop出来
把新加入的这个大的加到头部,重新维护小堆,加到最后就会剩下来k个大的数字
堆顶就是第k个最大的元素(因为是小堆)
cpp
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int,vector<int>,greater<int>> minHeap;
for(int num:nums){
if(minHeap.size()<k){
minHeap.push(num);
}else if(minHeap.top()<num){
minHeap.pop();
minHeap.push(num);
}
}
return minHeap.top();
}
};
前 K 个高频元素
新知识点pair
std容器,pair
因为对只能存储一个数字,而现在需要用哈希表将数字和频率绑定,另外,绑定之后的存入到小堆中,小堆不知道应该如何比较,先比较第一个数字还是先比较第二个数组??
所以这时候用pair函数,他规定了先比较第一个,如果第一个元素一样,在比较第二个
最后在push进入堆中,先push频率在push元素,就可以按频率高低排列。
最后可以成功得到结果
cpp
class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> counts;
for(int num:nums){
counts[num]++;
}
using PI = pair<int,int>;
priority_queue<PI,vector<PI>,greater<PI>> minHeap;
for(auto& it: counts){
minHeap.push({it.second,it.first});
if(minHeap.size() > k){
minHeap.pop();
}
}
vector<int> result;
while(!minHeap.empty()){
result.push_back(minHeap.top().second);
minHeap.pop();
}
return result;
}
};
多路归并(合并有序序列)
这里自己实现了一下比较规则(a>b)a的优先级更低所以a下沉实现最小堆
不用greater是因为,传入的是指针地址,他回去默认按照指针地址的大小实现最小堆,不会去访问val。
cpp
class Solution {
public:
struct cmp
{
bool operator()(ListNode* a, ListNode* b){
return a->val > b->val;
}
};
ListNode* mergeKLists(vector<ListNode*>& lists)
{
priority_queue<ListNode*,vector<ListNode*>,cmp> pq;
for(auto node:lists){
if(node != nullptr){
pq.push(node);
}
}
ListNode dummy(-1);
ListNode* p = &dummy;
while(!pq.empty())
{
ListNode* minNode = pq.top();
pq.pop();
p->next = minNode;
p = p->next;
if(minNode->next != nullptr)
{
pq.push(minNode->next);
}
}
return dummy.next;
}
};
数据流的中位数
利用堆顶堆,左边的数字用大顶堆,右边的数字用小顶堆
cpp
class MedianFinder {
priority_queue<int,vector<int>,less<int>> queMax;
priority_queue<int,vector<int>,greater<int>> queMin;
public:
MedianFinder() {
}
void addNum(int num) {
if(queMax.empty() || num<=queMax.top()){
queMax.push(num);
if(queMax.size()>queMin.size()+1){
queMin.push(queMax.top());
queMax.pop();
}
}else{
queMin.push(num);
if(queMin.size()>queMax.size()){
queMax.push(queMin.top());
queMin.pop();
}
}
}
double findMedian() {
if(queMax.size()>queMin.size()){
return queMax.top();
}
return (queMax.top()+queMin.top()) / 2.0;
}
};
滑动窗口最大值
思路1:利用二叉堆的top最大最小的性质
利用pair存储索引和数值
然后依次push进二叉堆中
滑动一次之后,先不急于去pop()
等到堆顶元素的索引小于窗口最左边时候(窗口划过达最大值)在把堆顶元素pop()出即可
cpp
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
priority_queue<pair<int,int>> pq;
vector<int> res;
for(int i = 0;i<nums.size();i++){
pq.push({nums[i],i});
if(i+1>=k){
while(pq.top().second > i+1-k){
pq.pop();
}
res.push_back(pq.top().first);
}
}
return res;
}
};
思路2:
利用单调队列,通过维护一个单调队列让队列的头始终是最大值
当push进一个数值,和队列的尾部相比,如果比队列的尾部大,则证明原来的队列的尾部永远不可能是最大值,将未来的尾部pop掉,再重新比较新的尾部和新来的数值,直到尾部比新进来的数值大,此时将新进来的数值push进队列。
当移动窗口的时候,判断左边出队列这个是不是当前的最大值(q.front)如果是则pop出去,如果不是就证明这个数之前已经被淘汰掉了,因为举个例子,342,在4进入的时候,3就已经被剔除掉了。
cpp
// 单调队列类封装
class MonotonicQueue {
private:
// 使用双端队列 deque,因为它支持头部和尾部的高效插入删除
deque<int> q;
public:
// 入队操作:维护队列的单调递减性质
void push(int n) {
// 如果队列尾部的元素比新来的 n 小,说明尾部元素"废了"
// 只要 n 在,尾部那个小的永远不可能是最大值,直接淘汰
while (!q.empty() && q.back() < n) {
q.pop_back();
}
q.push_back(n);
}
// 获取最大值:由于是单调递减,队头永远是最大的
int max() {
return q.front();
}
// 出队操作:试图移除某个数值 n
void pop(int n) {
// 只有当要移除的元素 n 恰好等于队头元素时,才真正弹出
// 如果 n 不等于队头,说明 n 早就在 push 阶段被淘汰了,不需要操作
if (!q.empty() && q.front() == n) {
q.pop_front();
}
}
};
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
MonotonicQueue window;
vector<int> res;
int left = 0, right = 0;
while (right < nums.size()) {
// c 是即将移入窗口的数字
int c = nums[right];
right++;
// 1. 新元素入队(同时淘汰弱者)
window.push(c);
// 窗口还没填满 k 个时,不记录结果
// 当 right - left == k 时,说明窗口大小刚好为 k
if (right - left == k) {
// 2. 记录当前窗口的最大值
res.push_back(window.max());
// d 是即将移出窗口的数字
int d = nums[left];
left++;
// 3. 旧元素出队(检查是否需要移除队头)
window.pop(d);
}
}
return res;
}
};
贪心+堆
贪心策略:我们在每一步都想选择当前"最优"的选项(比如利润最高、结束最早、成本最小)。
堆的作用:由于数据的状态在动态变化(比如做完一个任务,解锁了新任务;或者时间推进,有了新会议),我们需要一个数据结构能 O ( 1 ) O(1) O(1) 拿到当前的最优解,并 O ( log N ) O(\log N) O(logN) 维护剩余数据的有序性。
-
连接棒材的最低费用
-
会议室 II
-
最多可以参加的会议数目
-
IPO (Hard)
-
任务调度器
-
重构字符串