数据结构:堆
堆
堆的定义
什么是堆呢?
堆(Heap)是一种特殊的完全二叉树,满足:
在满足完全二叉树的性质下,多了一个特殊的规则:
堆序性:
大顶堆(Max-Heap):每个节点的值 ≥ 其左右孩子的值。
小顶堆(Min-Heap):每个节点的值 ≤ 其左右孩子的值。
堆的存储方式
通常用数组来存储堆.
为什么呢?因为完全二叉树的性质:
除了最后一层,其它层都是满的。
最后一层从左到右连续填充。
因此用数组存储非常方便。
数组存储方式(假设下标从 0 开始):
父节点:i
左孩子:2i + 1
右孩子:2i + 2
父节点:(i - 1) / 2
堆的基本操作
我们这里都以最小堆为例,最大堆是同样的道理的。
建堆(Build Heap)
从最后一个非叶子节点开始,依次往上调整。
为啥要这样做呢,因为每一个每个节点都必须满足“堆序性”(父节点的值 ≥ 子节点的值,对于大顶堆)。
那我们建堆时,必须保证所有的子树都满足堆的性质。
而叶子节点本身就没有孩子,它天然就是一个合法的堆,不需要对其进行调整。
非叶子节点有孩子,需要检查并调整。如果它的孩子本身已经是堆,只要调整当前节点,就能保证以它为根的子树是堆。
那为什么要从下往上?
如果你从上往下开始调整,根节点调整好了,但它的孩子可能还没调整过,就会破坏堆的性质。
从下往上调整时,先保证子树是堆,再调整它的父节点,就能一步步保证整个大树成为堆。
代码如下:
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>
#include <vector>
#include <unordered_map>
#include <limits.h>
#include <queue>
#include <string.h>
#include <stack>
using namespace std;
vector<int> heap = {6, 5, 3, 1, 2, 4};
void heapify(vector<int>& heap,int n,int i)
{int minn=i;int l=2*i+1;int r=2*i+2;if(l<n&&heap[l]<heap[minn]){minn=l;}if(r<n&&heap[r]<heap[minn]){minn=r;}if(minn!=i){swap(heap[i],heap[minn]);//这里发生了调整可能会影响到子树,因此递归调整子树。 heapify(heap,n,minn);}
}
void build_heap(vector<int>& heap)
{int n=heap.size(); //如何建立一个堆呢 //必须是从最后一个非叶子节点开始依次往上调整 for(int i=n/2-1;i>=0;i--){//为啥i是从n/2-1开始//因为当下标从0开始,//左孩子的节点就是 2*i+1//当i=n/2-1时,代入其左孩子就是 n-1恰好是最后一个节点 heapify(heap,n,i);}
}
int main()
{//ios::sync_with_stdio(0),cin.tie(0),cout.tie(build_heap(heap);for(int i=0;i<heap.size();i++){cout<<heap[i]<<" ";} return 0;}
循环次数 ≈ n/2(因为只对非叶子节点调用 heapify)。
每次 heapify 最坏可能下沉一条路径,代价 O(log n)。
看起来就像:O(n/2 * log n) = O(n log n)。
但并不是遍历到的所有非叶子节点都要下沉到最底部,也就是logn
越靠近叶子的节点,能下沉的层数越少。
只有根节点才可能下沉 log n 层。
因此实际的复杂度更低为O(n)。
插入(Insert)
把新元素放在堆的末尾。
然后通过不断的上浮操作往上调整堆。
为啥非要放在末尾呢?因为必须保证完全二叉树的性质,从上往下从左往右。
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>
#include <vector>
#include <unordered_map>
#include <limits.h>
#include <queue>
#include <string.h>
#include <stack>
using namespace std;
vector<int> heap;
void upadjust(vector<int>& heap,int index)
{//上浮操作就是不断把新插入的节点与它的父亲节点比较,看是否有必要上浮while(index>0){int p=(index-1)/2;if(heap[index]<heap[p]){swap(heap[index],heap[p]);index=p;}//如果发生了上浮,那么我们就需要继续往上浮看看是否需要继续调整else{break;}}
}
void insert(vector<int>& heap,int x)
{//需要先插入到堆的末尾,原因是保证完全二叉树的结果//从上往下从左往右heap.push_back(x); upadjust(heap,heap.size()-1);
}
int main()
{//ios::sync_with_stdio(0),cin.tie(0),cout.tie(insert(heap,6);insert(heap,5);insert(heap,3);insert(heap,1);insert(heap,2);insert(heap,4);for(int i=0;i<heap.size();i++){cout<<heap[i]<<" ";}return 0;}
插入一个节点,最多上浮到根节点,因此时间复杂度为树的高度O(logn)
时间复杂度:O(logn)。
删除堆顶(Extract)
把堆顶元素取出,用最后一个元素填到堆顶。
为啥非要取最后一个元素呢,还是为了保证完全二叉树的性质,从上到下,从左往右,
但这样会可能会破化堆序性,因此我们要通过“下沉”调整堆。
代码如下:
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>
#include <vector>
#include <unordered_map>
#include <limits.h>
#include <queue>
#include <string.h>
#include <stack>
using namespace std;
vector<int> heap;
void upadjust(vector<int>& heap,int index)
{//上浮操作就是不断把新插入的节点与它的父亲节点比较,看是否有必要上浮while(index>0){int p=(index-1)/2;if(heap[index]<heap[p]){swap(heap[index],heap[p]);index=p;}//如果发生了上浮,那么我们就需要继续往上浮看看是否需要继续调整else{break;}}
}
void insert(vector<int>& heap,int x)
{//需要先插入到堆的末尾,原因是保证完全二叉树的结果//从上往下从左往右heap.push_back(x); upadjust(heap,heap.size()-1);
}
void heapify(vector<int>& heap,int n,int i)
{int l=2*i+1;int r=2*i+2;int minn=i;//假设是iif(l<n&&heap[l]<heap[minn]){minn=l; } if(r<n&&heap[r]<heap[minn]){minn=r; }if (minn != i) {swap(heap[i], heap[minn]);// 继续下沉heapify(heap,n,minn);} }
void delete_heap(vector<int>& heap)
{if(heap.size()==0){return;}else{heap[0]=heap[heap.size()-1];heap.pop_back();if(heap.size()!=0)heapify(heap,heap.size(),0);}
}
int main()
{//ios::sync_with_stdio(0),cin.tie(0),cout.tie(insert(heap,6);insert(heap,5);insert(heap,3);insert(heap,1);insert(heap,2);insert(heap,4);for(int i=0;i<heap.size();i++){cout<<heap[i]<<" ";}cout<<endl;delete_heap(heap);for(int i=0;i<heap.size();i++) {cout<<heap[i]<<" ";}return 0;}
删除需要下沉操作从根节点到叶子节点为树的高度,因此为logn
时间复杂度:O(logn)。
取堆顶(Peek)
返回堆顶元素即可。
没啥好说的,直接返回即可。
时间复杂度:O(1)。
堆的应用
堆排序(Heap Sort)
建一个大顶堆。
每次取堆顶(最大值),放到数组末尾,然后调整堆。
时间复杂度:O(n log n),原地排序,不稳定。
Top-K 问题
海量数据中快速找到前 K 大或前 K 小的数。
一亿个数,找前K小的数
前 K 小 → 大顶堆
一亿个数,找前K大的数
前 K 大 → 小顶堆
思路
先把前 K 个数放入堆中。
遍历剩余元素:
对于 前 K 小:如果当前元素 < 堆顶 → 替换堆顶,并下沉。
对于 前 K 大:如果当前元素 > 堆顶 → 替换堆顶,并下沉。
遍历完毕,堆中就是前 K 个数。
优先队列(Priority Queue)
C++ STL:priority_queue
Java:PriorityQueue
用堆来快速取“优先级最高”的元素。
dijkstra算法的堆优化
借助优先队列
实时中位数
数据不断流入,如何快速得到 中位数?
用大顶堆存左半边,小顶堆存右半边,快速得到中位数。
左半边(小于中位数) → 大顶堆(堆顶是左半边最大值)
右半边(大于中位数) → 小顶堆(堆顶是右半边最小值)