目录
前言
总代码
堆的简介
仿函数
堆的基础框架建立size、empty、top、
向上调整法 and push
向上调整
push
向下调整法 and pop
向下调整法
pop
迭代器区间初始化(构造)
逻辑讲解
为何选择向下建堆?
建堆代码实现
结语
前言
本篇博客主要讲解的重点在于仿函数和容器适配器
如果有希望了解堆是什么东西的,可以看一下下方链接的博客,里面对堆的相关知识讲解非常详细,当然下文也会进行相关的讲解
堆-时间复杂度讲解-topk问题
而堆的函数使用并不困难,如下:
也就不到10个函数,我们在下文都有所讲解
总代码
如果有友友只是复习需要,只想看完整代码的话,可以直接点下面的gitee链接
当然对于看完了整篇文章的友友也可以照着这里的代码敲一篇进一步理解喔...(* ̄0 ̄)ノ
堆 - priority_queue - blog - 2024-08-08
堆的简介
堆是一个树状结构,如果是小堆的话,其遵循的规则就是父节点 < 子节点,如下:
如果是大堆的话,那么就是父节点 > 子节点
而我们的堆其实是存储在一个数组上的,我们找一个节点的父节点或子节点都遵循着一定的数学规律
父节点 = (子节点 - 1)/ 2
子节点 = 父节点 * 2 + 1(左节点) / +2(右节点)
如上图,真实的情况应该是一个数组,一段连续的空间存储着一串数字
而我们的堆则是被抽象出来的,并不是说真的就是像一棵树一样存在内存里,只是抽象成一棵树的形状更利于我们看
仿函数
仿函数是什么呢?
func();
如果我们只看这个代码的话,你会觉得这是什么?一个函数调用对吧
但如果我告诉你,这是一个类里面重载了一个operator(),所以我们在外面使用()其实就是调用了那个类里面的operator()
struct func
{
void operator()()
{
cout<<"hello world"<<endl;
}
};
hello world
那么我们为什么要在堆这个章节讲解这个内容呢?
试想一下,我们的堆分为大堆和小堆,我们如果想要实现大堆,是不是只能进入内部自己去改><啊,但是这样子的话太挫了,我们如果能在外面自己控制就好了
所以,就有了仿函数的出现
我们只需要自己写一个仿函数作为模板的参数传给堆,我们就可以实现在外面控制大堆小堆了
template<class T>
struct myless
{
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
template<class T>
struct mygreater
{
bool operator()(const T& x, const T& y)
{
return x > y;
}
};
堆的模板参数,第三个是仿函数,第二个是容器适配器
template<class T, class container = vector<T>, class compare = myless<T>>
我们能看到,在上面的代码中我们写了一个myless和一个mygreater
返回的就是一个bool而已
但是我们可以实现在类里面像函数一样调用他,如果我们直接写一个函数的话封装就被破坏了
而我们在堆的模板参数上也把这个加上去了,并且我们还给了一个缺省值,默认其初始情况为大堆
其实这个仿函数厉害的一点就在于他的灵活,如果我们这个堆里面存储的是一个自定义类型的话,我们标准库里的仿函数可能在某些情况下效率并不高或者说没用,这时我们甚至可以自己写一个仿函数传过去
struct date_less
{
bool operator()(Date* d1, Date* d2)
{
return d1->year < d2->year;
}
};
struct date_less
{
bool operator()(Date* d1, Date* d2)
{
return d1->month< d2->month;
}
};
struct date_less
{
bool operator()(Date* d1, Date* d2)
{
return d1->day< d2->day;
}
};
我们可以自己根据情况自己实现仿函数传给堆,这可是相当厉害的
堆的基础框架建立size、empty、top、
我们的堆使用的和stack(栈)、queue(队列)一样,都是适配器模式
如果有不理解什么是适配器模式的,可以简单看一下下面这一篇文章,里面有相关讲解
【STL】| C++ 栈和队列(详解、deque(双端队列)介绍、容器适配器的初步引入)
所以我们可以在模板参数这个部分可以将容器适配器传过来并给一个缺省值——vector<T>
然后我们后面还需要再加一个仿函数作为第三个参数,这个可以帮助我们灵活地控制大小堆
而我们堆的成员函数就只有一个仿函数
然后我们里面的大部分函数都可以使用仿函数里面的函数实现
代码如下:
//仿函数
template<class T>
struct myless
{
bool operator()(const T& x, const T& y)
{
return x < y;
}
};
//仿函数
template<class T>
struct mygreater
{
bool operator()(const T& x, const T& y)
{
return x > y;
}
};
template<class T, class container = vector<T>, class compare = myless<T>>
class priority_queue
{
public:
//强制生成构造函数
priority_queue() = default;
const T& top()
{
return _con[0];
}
size_t size()
{
return _con.size();
}
bool empty()
{
return _con.empty();
}
private:
container _con;
};
向上调整法 and push
向上调整
如果我们要插入一个新数据的话,可以选择在最尾部插入一个数据,然后将其慢慢向上调整即可
而我们函数的参数是孩子节点的位置,我们需要先在函数内将其parent找出来
父节点 = (子节点 - 1)/ 2
子节点 = 父节点 * 2 + 1(左节点) / +2(右节点)
然后写一个while循环表示一直向上寻找、比较
如果父<子(大堆)或者父>子(小堆),我们就将父子两个节点互换,然后孩子节点跑到parent的位置,parent节点继续向上寻找新的parent
如果child节点<0了,就证明到头了
或者如果不满足这关系的话(父<子(大堆)或者父>子(小堆)),那就直接break
值得注意的是吗,我们的比较逻辑可以直接问放进仿函数里面,由仿函数来控制
代码如下:
template<class T, class container = vector<T>, class compare = myless<T>>
void AdjustUp(int child)
{
compare comf;
int parent = (child - 1) / 2;
while (child > 0)
{
if (comf(_con[child], _con[parent]))
{
swap(_con[child], _con[parent]);
child = parent;
parent = (parent - 1) / 2;
}
else
break;
}
}
push
我们的push逻辑就简单多了,直接利用仿函数自带的push_back尾插数据,然后让这个数据执行一次向上调整的逻辑即可
void push(const T& x)
{
_con.push_back(x);
AdjustUp((int)(_con.size()) - 1);
}
向下调整法 and pop
向下调整法
向下调整法最关键的一个点就是——比较父节点与子节点,满足条件就交换,然后向下,不满足条件就break
只不过问题的关键就在于(假设建大堆),我们怎么知道是左节点大还是右节点大?
我们无从得知,所以我们就用一招假设法,我假设左节点比较大
然后下面再给一个 if 判断一下,如果左确实大,那就不管,如果右比较大,那我就将child变成右即可
然后还是按照条件找子,然后判断大小,判断是否要交换
如果要交换,那就交换完之后再向下找,直到不满足交换条件或者超出数组大小了,再结束
如果不满足条件,就直接break
代码如下:
template<class T, class container = vector<T>, class compare = myless<T>>
void AdjustDown(int parent)
{
compare comf;
int child = parent * 2 + 1;
while (child < _con.size())
{
if (child + 1 < _con.size() && comf(_con[child + 1], _con[child]))child++;
if (comf(_con[child], _con[parent]))
{
swap(_con[child], _con[parent]);
parent = child;
child = child * 2 + 1;
}
else break;
}
}
pop
图示如下:
我们可以看到,pop的主要逻辑就是先将首位交换然后再去掉尾,这样就实现了出堆
然后再将最上面的数据依次向下调整即可
代码如下:
void pop()
{
swap(_con[0], _con[_con.size() - 1]);
_con.pop_back();
AdjustDown(0);
}
迭代器区间初始化(构造)
逻辑讲解
为了适配所有的迭代器(因为迭代器的本质就是模仿指针,我们要的只是迭代器指向的数据而不是迭代器本身),所以我们需要写一个模板
template<class InputIterator>
我们在函数里面只需要写一个迭代器遍历的逻辑,然后依次将数据尾插进堆里面
注意,我们这里并不复用我们上面写到的push,因为push的逻辑是向上调整建堆,实际上向下调整建堆的效率会更高(这点我们在下文会讲)
所以我们先使用容器适配器自带的push_back插入迭代器指向的数据
然后我们再自己实现一个向下建堆的逻辑
为何选择向下建堆?
注意看,如果我们选择向上建堆的话,我们就需要从58开始,一个一个向上遍历,需要遍历7次
而我们如果向下调整的话,我们则是从56开始遍历,只需要遍历3次!!!
或许你会觉得,也就是几次而已,但如果我们的堆有100层呢?
那我们向下调整建堆会比向上调整建堆效率(满节点的情况下)高上一倍
如果向上要用30s,那么我向下只需要15s(理想情况下)
所以我们肯定是自己写以一个向下调整建堆的逻辑比较好
建堆代码实现
我们需要的是从最后一个节点的父节点位置开始遍历,所以我们可以写一个for循环,i 就可以从最后一个位置开始找父节点
最后一个位置是 _con.size()-1
父节点 = (子节点 - 1)/ 2
子节点 = 父节点 * 2 + 1(左节点) / +2(右节点)
综上,i = (_con.size()-1 -1)/ 2
代码如下:
template<class InputIterator>
priority_queue(InputIterator first, InputIterator last)
{
while (first != last)
{
_con.push_back(*first);
first++;
}
//建堆
for (int i = ((int)(_con.size()) - 2) / 2; i >= 0; i--)
{
AdjustDown(i);
}
}
各位会看到我上面写的是 i = ( (int)(_con.size()) -1 -1)/ 2
这是因为size()返回的是一个size_t类型的数据,但是我们要让他-1就是整数了(int),所以最好强转一下(不会报警告)
结语
这篇博客到这里,对堆(优先级队列)的讲解就结束啦(~ ̄▽ ̄)~
如果觉得对你有帮助的话,请务必多多支持博主喔<(_ _)>~( ̄▽ ̄)~*