简介
堆其实是很基础的数据结构,堆往往是初学者学习的第一个树形数据结构,排在数组、链表、队列、栈等线形数据结构之后。但其实堆并没有那么好理解,而且也常常被忽略(特别是对于OIer来说),因为经常可以直接调包使用,不需要手写堆,C++内置了std::priority_queue
,python也有import heapq
。
堆的思想简洁明了——大的在上面;堆的实现高效优雅——只需要维护一个数组;堆的插入、删除、修改的时间复杂度都是一样的 ,因此堆绝对值得好好地理解并且手写一个出来。
一般来说,只要满足父节点的值比子节点大就可以称作堆,但是在具体实现上,最常用的还是二叉堆,也就是同时满足堆性质和二叉树性质的堆,除了二叉堆以外,还有二项堆、斐波那契堆等。通常说的堆指的都是二叉堆。
堆的储存
注意:以下默认为最大堆(Max Heap),最小堆(Min Heap)也是同理
要储存堆,首先要给堆中的每个结点编号。首先,以根节点为1,从上至下,每层再从左至右地编号,如图:
*其实编号从零开始也可以,但是之后取子节点和父节点编码的公式会有所不同。
然后,我们只需要用一个数组,就可以储存下整个堆了。
一般来说,我们会让堆是一棵完全二叉树(Complete Binary Tree),完全二叉树就是除了最后一层以外每一层均填满,而且最后一层从左至右地填满(请与**完美二叉树(Perfect Binary Tree)**区分),这样既节省了数组的空间,又方便了后续的操作。
假如这个堆不是一棵完全二叉树会怎么样呢?请看下图:
如果这样的话,数组就会变成下图:
可以看到存在空间浪费,不仅如此,后续操作也会带来麻烦。因此通常令堆为完全二叉树,并使用一个大小为的数组储存。
这种储存方式相应的操作
观察图形与编号的关系不难发现有以下规律:
假如某节点编号为x
左子节点的编号就是x*2
,或者写成x<<1
右子节点的编号就是x*2 + 1
,或者写成x<<1|1
父节点的编号就是x/2
,或者写成x>>1
这些对我们后面的操作非常有用
采用面向对象的方式写堆的代码
向上调整和向下调整
在讲解堆的插入、删除、修改、建立操作之前,需要先了解堆的两个最基本的操作:向上调整(Percolate Up)和向下调整(Percolate Down)
向上调整
假如其中一个节点偏大而不满足堆性质,那么根据最大堆的特点我们可以知道,这个节点需要向上调整其位置。按照以下程序框图执行。
当然,也有可能遇到不存在父节点的情况(此节点已经变成了根节点),那么也算调整完毕
例:有一个这样的堆,其中橙色节点需要向上调整
因此交换它们,并继续向上调整61
交换61和36,并继续向上调整61
调整结束,可以看到调整后的二叉树已经满足了堆性质。
向下调整
向下调整就是和向上调整相反的过程,假如一个节点偏小而不满足堆性质,则这个节点需要被向下调整,按照下面的流程图完成向下调整。
同样举个例子
22的两个子节点36、26都比它大,则交换22和36,并继续向下调整22
交换25和22
22的两个子节点7、3都比它小,满足堆性质,向下调整结束。
时间复杂度分析
无论是向上调整还是向下调整,节点移动的距离都不会超过树高,则显然时间复杂度为
实现
这里实现的是整数的最大堆,当然也可以实现泛型的Heap<T>
,或者进一步自定义比较函数的Heap<T, _cop<T, T>>
inline int& Heap::_get(int node){
if(!isValid(node)) throw "无效节点";
return tree[node-1];
}
bool Heap::isValid(int node){
return node >= 1 && node <= size();
}
void Heap::shiftUp(int node){ //传入要调整的节点编号
while(node > 1 && _get(node>>1) < _get(node)){ //如果不是根节点而且父节点小于自己就交换自己和父节点
std::swap(_get(node>>1), _get(node));
node >>= 1;
}
}
void Heap::shiftDown(int node){
while((node<<1|1) <= size()){ //如果有两个孩子
int max_chd = _get(node<<1) > _get(node<<1|1) ? node<<1 : node<<1|1; //取较大的孩子
if(_get(max_chd) > _get(node)){
std::swap(_get(max_chd), _get(node));
node = max_chd;
}
else break;
}
if(node<<1 == size() && _get(node<<1) > _get(node)){ //最后可能出现只有一个子节点的情况,单独判断
std::swap(_get(node<<1), _get(node));
}
}
*完整代码在最后面
插入操作
有了向上调整,插入操作就很简单了,我们只需要把新的节点加到数组末尾,然后向上调整它。
相应的二叉树为:
调整后即为插入完毕后的堆
由于插入操作本质上就是向上调整,复杂度显然是
实现
void Heap::push(int val){
tree.push_back(val);
shiftUp(size());
}
删除操作
删除操作就是插入操作的逆操作,在堆里面,通常说的删除是指pop操作,也就是删除根节点。
逆着插入操作,我们只需要将根节点的值与最后一个节点交换,去掉最后一个节点,然后向下调整根节点。
顺带一提,假如需要删除非根节点,只需要让这个节点一直与它的父节点交换直到它变成根节点,再执行pop
操作就行了。
显然时间复杂度也是
实现
void Heap::pop(){
if(size() == 0) throw "堆不能为空";
if(size() == 1){
tree.pop_back();
return;
}
std::swap(_get(1), _get(size()));
tree.pop_back();
shiftDown(1);
}
void Heap::remove(int node){
if(size() == 0) throw "堆不能为空";
while(node > 1){ //向上交换到根节点
std::swap(_get(node>>1), _get(node));
node >>= 1;
}
pop();
}
修改操作
修改操作非常显然,就不过多讲解了
将一个节点修改成更大的节点,那么就向上调整它
将一个节点修改成更小的节点,那么就向下调整它
时间复杂度:
实现
void Heap::set(int node, int val){
if(val > _get(node)){
_get(node) = val;
shiftUp(node);
}
if(val < _get(node)){
_get(node) = val;
shiftDown(node);
}
}
堆的建立
堆的建立显然有最简单的方式,就是逐个将节点插入一个空堆,但这样的时间复杂度为
存在更好的办法,可以使得建堆只需要 的时间复杂度。
我们从倒数第二行开始,自底向上对每个节点进行向下调整。(因为倒数第一行没有子节点不需要向下调整)
进行倒序遍历:
7号、6号没有子节点,不调整。5号、4号的子节点小于它,无需调整。
向下调整3号后:
向下调整2号后:
向下调整1号后:
可以看到此时堆已经建好了
时间复杂度分析
为了简便,考虑这个堆是一个完美二叉树时的情况(当 足够大时最后一行的影响可以忽略)
设树高为 ,则
最差情况时,倒序调整每个节点时都要经过最长路径到叶子节点。
定义根节点深度为0,则深度为为 的节点有 个,经过的最长路径为 ,对每个节点求和有:
则时间复杂度为
也可以使用主定理(Master Theorem)求解,结果是一样的。
实现
Heap::Heap(std::vector<int>& vec){
tree = vec;
for(int i = size()>>1; i; --i){
shiftDown(i);
}
}
完整代码
class Heap{
inline int& _get(int node); //_get不能暴露给用户,因为可能使值被修改导致堆性质被破环。
public:
std::vector<int> tree;
Heap(){};
Heap(std::vector<int>& vec);
inline int size();
inline int top();
inline int get(int node);
bool isValid(int node);
void shiftUp(int node);
void shiftDown(int node);
void push(int val);
void pop();
void remove(int node);
void set(int node, int val);
};
Heap::Heap(std::vector<int>& vec){
tree = vec;
for(int i = size()>>1; i; --i){
shiftDown(i);
}
}
inline int Heap::size(){
return tree.size();
}
inline int Heap::top(){
if(size() == 0) throw "堆不能为空";
return get(1);
}
inline int Heap::get(int node){
if(!isValid(node)) throw "无效节点";
return tree[node-1];
}
inline int& Heap::_get(int node){
if(!isValid(node)) throw "无效节点";
return tree[node-1];
}
bool Heap::isValid(int node){
return node >= 1 && node <= size();
}
void Heap::shiftUp(int node){ //传入要调整的节点编号
while(node > 1 && _get(node>>1) < _get(node)){ //如果不是根节点而且父节点小于自己就交换自己和父节点
std::swap(_get(node>>1), _get(node));
node >>= 1;
}
}
void Heap::shiftDown(int node){
while((node<<1|1) <= size()){ //如果有两个孩子
int max_chd = _get(node<<1) > _get(node<<1|1) ? node<<1 : node<<1|1; //取较大的孩子
if(_get(max_chd) > _get(node)){
std::swap(_get(max_chd), _get(node));
node = max_chd;
}
else break;
}
if(node<<1 == size() && _get(node<<1) > _get(node)){ //最后可能出现只有一个子节点的情况,单独判断
std::swap(_get(node<<1), _get(node));
}
}
void Heap::push(int val){
tree.push_back(val);
shiftUp(size());
}
void Heap::pop(){
if(size() == 0) throw "堆不能为空";
if(size() == 1){
tree.pop_back();
return;
}
std::swap(_get(1), _get(size()));
tree.pop_back();
shiftDown(1);
}
void Heap::remove(int node){
if(size() == 0) throw "堆不能为空";
while(node > 1){ //向上交换到根节点
std::swap(_get(node>>1), _get(node));
node >>= 1;
}
pop();
}
void Heap::set(int node, int val){
if(val > _get(node)){
_get(node) = val;
shiftUp(node);
}
if(val < _get(node)){
_get(node) = val;
shiftDown(node);
}