一、前言:priority_queue 到底是什么
std::priority_queue 本质上是一个二叉堆(完全二叉树),用数组存储,默认是大根堆(堆顶最大)。
priority_queue 三件事:
- 底层为什么用数组存二叉树;
- 仿函数(functor)为什么比函数指针强;
- 存储指针类型时,直接比较会比较地址而不是值,如何用模板特化解决。
本文从零手写一个简化版,完整复刻 STL 的模板架构。
二、二叉堆原理:为什么用数组存二叉树
二叉堆是一棵完全二叉树:除了最后一层,其他层都满,最后一层从左到右连续排。正因 "没有空洞",它可以用连续数组存储,不需要指针。
下标规律(最关键,必须背熟):
表格
| 关系 | 公式 |
|---|---|
父节点 parent 的左孩子 |
parent * 2 + 1 |
父节点 parent 的右孩子 |
parent * 2 + 2 |
孩子 child 的父节点 |
(child - 1) / 2 |
用数组存完全二叉树的好处:连续内存、Cache 友好、下标直接算父子,不需要额外指针。这就是堆选择数组的根本原因。
堆的核心性质(以大根堆为例):每个父节点的优先级 ≥ 它的两个孩子 。所以堆顶一定是全堆优先级最高的元素,取 top 只需 O(1) 访问 _con[0]。
三、整体骨架:3 个模板参数
cpp
template<class T, class Container = std::vector<T>, class compare = Less<T>>
class priority_queue
{
compare com; // 比较仿函数对象
Container _con; // 底层容器,堆的数组
};
表格
| 模板参数 | 作用 | 默认值 |
|---|---|---|
T |
堆里存储的元素类型 | 用户指定 |
Container |
底层容器(须支持 [] 随机访问) |
std::vector<T> |
compare |
优先级比较规则(仿函数) | Less<T> |
核心认知:priority_queue 是 "容器适配器"(container adapter),不是独立容器。 它不自己存数据,而是把 vector 包起来,只暴露 push/pop/top 这几个受限接口,从外部保证堆性质不被破坏。这也是为什么类里有两个成员:仿函数 com + 容器 _con。
因为堆需要 "数组下标直接算出孩子 / 父亲",底层容器必须支持
[]随机访问。所以vector、deque可以,list不行(它只支持顺序访问,不支持下标)------ 这是面试高频送分点。
四、头文件与命名空间:正确写法
cpp
#ifndef PRIORITY_QUEUE_H // 如果没定义过
#define PRIORITY_QUEUE_H // 就定义它
... 全部代码 ...
#endif
- 用
<cassert>而不是<assert.h>(C++ 风格头文件,名字进std::命名空间);- 头文件里不建议写
using namespace std;,否则所有#include你的文件都会被强塞进std的名字,容易冲突。要用就写全限定std::vector、std::swap。
五、仿函数(Functor):是什么、为什么用它
仿函数 = 一个重载了 operator() 的类,实例化后可像函数一样调用:
cpp
template<class T>
class Less
{
public:
bool operator()(const T& x, const T& y) const
{
return x < y;
}
};
调用 com(3, 5) 等价于 com.operator()(3, 5)。
为什么用仿函数而不是函数指针? 三条硬理由(面试爱问):
- 类型在编译期确定 ,
operator()可内联展开,没有函数指针的间接调用开销;- 仿函数可以携带成员状态(STL 很多复杂比较器依赖此特性);
- 符合 STL"一切可调用对象统一接口" 的设计哲学。
大坑提醒:仿函数方向必须和名字、和 STL 一致
Less的语义是 "x 是否小于 y",所以operator()必须return x < y;Greater的语义是 "x 是否大于 y",所以operator()必须return x > y;
为什么这关系到堆的方向? 看 Adjust_up 的判断行:
cpp
if (com(_con[parent], _con[child])) // com(parent, child) 为真 → 孩子上浮
判断逻辑是 "com(parent, child) 为真 → 父的优先级低于子 → 孩子该往上爬 "。
- 若
Less是x < y:com(parent, child) = parent < child为真 = 父比子小 → 把大的孩子浮上去 → 大根堆 ✅- 若你写成
x > y:com(parent, child) = parent > child为真时交换 → 父比子大还交换 → 把小的浮上去 → 变成小根堆 ❌
结论:Less 必须返回 x < y,Greater 必须返回 x > y。 写反了,你以为是堆顶最大,实际堆顶最小。
手动验证(修正后的
Less,com(a,b)=a<b):push 3, 1, 9, 4
- push 3 →
[3]- push 1 →
[3,1],com(3,1)=3<1?假 → 不动- push 9 →
[3,1,9],child=2,com(3,9)=3<9?真 → 交换 →[9,1,3]- push 4 →
[9,1,3,4],child=3,parent=1,com(1,4)=1<4?真 → 交换 →[9,4,3,1],再向上com(9,4)=9<4?假 → 停 最终[9,4,3,1],堆顶 9 ✅,pop 依次出9 4 3 1。
传参方式:用 const T&
cpp
bool operator()(const T& x, const T& y) const; // ✅ 引用传递,零拷贝
堆的每次 push/pop 要做 O(logN) 次比较,如果 T 是 string、自定义类,值传递的拷贝开销是实打实的。统一写 const T& 最稳。
六、模板特化:解决 int* 指针比较的坑
如果直接存 int* 而不特化,比较的是指针地址值 ,而不是指针指向的数据:
cpp
int a = 10, b = 2;
int* p1 = &a;
int* p2 = &b;
// 不特化的话,com(p1, p2) 比较的是 &a 和 &b 的地址,不是 10 和 2
地址大小和值大小毫无关系,堆的顺序会全乱。所以做全特化:
cpp
template<>
class Less<int*>
{
public:
bool operator()(int* const& x, int* const& y) const
{
return *x < *y; // 解引用后按值比较
}
};
逐点说明:
template<>表示全特化 :专门为int*提供一份Less。模板实例化Less<int*>时优先命中特化版本。int* const& x:x是 "指向 int 的 const 指针的引用"------ 传指针本身但不拷贝,同时不允许在函数内改 x 的指向。用引用省一次指针拷贝。return *x < *y;:解引用后比较值,这正是特化的意义。
重要:特化版本的方向(
*x < *y)必须和通用Less(x < y)、和堆调整逻辑配套,都要是 "小在前、大根堆"。如果通用版改了方向,特化版也要同步改,别漏。
七、逐函数拆解
7.1 push ------ 尾插 + 向上调整
cpp
void push(T x)
{
_con.push_back(x);
Adjust_up((int)_con.size() - 1);
}
干什么:
- 新元素先直接塞到 vector 末尾(作为完全二叉树的最后一个叶子);
- 调用
Adjust_up,让新叶子不断向上爬,直到满足堆性质。
为什么先放叶子再上浮?因为不确定新元素该落在哪,先放叶子再上浮最简单;且上浮只影响 "叶子到根" 这一条路径,代价 O(logN)。
7.2 Adjust_up ------ 向上调整(核心,逐行)
cpp
void Adjust_up(int child)
{
int parent = (child - 1) / 2; // ① 由孩子下标算父节点下标
while (child > 0) // ② 没到根就继续
{
if (com(_con[parent], _con[child])) // ③ 父优先级低于子
{
std::swap(_con[child], _con[parent]); // ④ 孩子上浮
child = parent; // ⑤ 上移到新位置
parent = (child - 1) / 2; // ⑥ 重算父
}
else
{
break; // ⑦ 已满足堆性质,停
}
}
}
- ① 完全二叉树性质:孩子
child的父下标 =(child-1)/2。- ②
child > 0表示还没到根(根下标 0),到根就不需要再浮。- ③
com(parent, child)为真 = 父优先级低于子(修正后Less下 = 父 < 子),堆性质被破坏,需交换。- ④⑤⑥ 交换后,
child更新到新位置,重算其父,继续向上检查。- ⑦ 父不比子低(堆性质满足),立即
break。
复杂度:O(logN)(从叶子爬到根,最多树高次)。
7.3 pop ------ 交换堆顶与末尾 + 向下调整
cpp
void pop()
{
assert(!empty());
std::swap(_con[0], _con[_con.size() - 1]); // ① 堆顶与末尾交换
_con.pop_back(); // ② 删末尾(原堆顶)
Adjust_down(0); // ③ 新根向下调整
}
为什么 pop 不直接删堆顶? 因为数组头部删除是 O(N)(全部前移)。利用完全二叉树特性:把堆顶和最后一个叶子交换,再 pop_back 删末尾,代价 O(1);然后用 Adjust_down 把换上去的新根 "沉" 到正确位置,O(logN)。这是 priority_queue 高效的核心技巧。
空堆 pop 是未定义行为(
_con[0]、_con.size()-1越界),必须assert(!empty())。
7.4 Adjust_down ------ 向下调整(核心,逐行)
cpp
void Adjust_down(int parent)
{
int child = parent * 2 + 1; // ① 左孩子下标
while (child < (int)_con.size()) // ② 左孩子存在才继续
{
if (child + 1 < (int)_con.size() && com(_con[child], _con[child + 1]))
{
child++; // ③ 选左右孩子中优先级更高的那个
}
if (com(_con[parent], _con[child])) // ④ 父比更高优先级的孩子低
{
std::swap(_con[child], _con[parent]); // ⑤ 父下沉
parent = child; // ⑥
child = parent * 2 + 1; // ⑦ 继续向下
}
else
{
break; // ⑧ 满足堆性质,停
}
}
}
- ① 左孩子下标 =
parent*2+1(右孩子 =parent*2+2)。- ② 只要左孩子还在数组范围内(说明还有孩子)。
- ③ 最关键的一步 ------ 先比较左右两个孩子,挑出优先级更高 的那个作为候选
child。com(左,右)为真 = 左优先级低于右 → 选右。必须选更高的那个,否则只拿左孩子比,可能漏掉真正该上位者,堆会被破坏。- ④⑤⑥⑦ 父比更高优先级的孩子低 → 交换、父下移、重算孩子下标,继续向更下层检查。
- ⑧ 父不比孩子低,堆性质已满足,
break。
复杂度:O (logN)(从根到叶子,下沉树高次)。
手动验证:在
[9,4,3,1]上 pop
- 交换堆顶与末尾:
swap(_con[0],_con[3])→[1,4,3,9];pop_back()→[1,4,3]Adjust_down(0):child=1,com(4,3)=4<3?假 → child 保持 1;com(1,4)=1<4?真 → swap →[4,1,3],parent=1,child=3,while(3<3)结束 得到[4,1,3],堆顶 4 ✅
7.5 top / empty / size ------ 简单接口
cpp
T& top()
{
assert(!empty()); // 必须判空,空堆 _con[0] 越界
return _con[0];
}
bool empty() const { return _con.empty(); }
size_t size() const { return _con.size(); }
top()返回堆顶的引用,允许调用方修改;但也意味着 "可能破坏堆性质"(STL 的 priority_queue 的 top 返回 const 引用,更安全)。- 必须加
assert(!empty()),否则空堆越界访问是未定义行为。 empty/size直接转发给底层_con,是 "适配器" 模式的典型 ------没有自己的数据,全靠转发。
八、完整可编译代码
cpp
#ifndef PRIORITY_QUEUE_H
#define PRIORITY_QUEUE_H
#include <iostream>
#include <vector>
#include <algorithm>
#include <cassert>
namespace PQueue
{
// 仿函数 Less:x < y,配合大根堆
template<class T>
class Less
{
public:
bool operator()(const T& x, const T& y) const { return x < y; }
};
// 仿函数 Greater:x > y,配合小根堆
template<class T>
class Greater
{
public:
bool operator()(const T& x, const T& y) const { return x > y; }
};
// 全特化:int* 按指向的值比较
template<>
class Less<int*>
{
public:
bool operator()(int* const& x, int* const& y) const { return *x < *y; }
};
template<class T, class Container = std::vector<T>, class compare = Less<T>>
class priority_queue
{
private:
compare com;
Container _con;
public:
void push(T x)
{
_con.push_back(x);
Adjust_up((int)_con.size() - 1);
}
void pop()
{
assert(!empty());
std::swap(_con[0], _con[_con.size() - 1]);
_con.pop_back();
Adjust_down(0);
}
bool empty() const { return _con.empty(); }
size_t size() const { return _con.size(); }
T& top()
{
assert(!empty());
return _con[0];
}
private:
void Adjust_up(int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (com(_con[parent], _con[child]))
{
std::swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else break;
}
}
void Adjust_down(int parent)
{
int child = parent * 2 + 1;
while (child < (int)_con.size())
{
if (child + 1 < (int)_con.size() && com(_con[child], _con[child + 1]))
child++;
if (com(_con[parent], _con[child]))
{
std::swap(_con[child], _con[parent]);
parent = child;
child = parent * 2 + 1;
}
else break;
}
}
};
}
#endif
测试 Demo 与运行结果
cpp
#include "priority_queue.h"
using namespace std;
int main()
{
// 大根堆(默认 Less)
PQueue::priority_queue<int> pq;
pq.push(3); pq.push(1); pq.push(9); pq.push(4);
while (!pq.empty()) { cout << pq.top() << " "; pq.pop(); }
cout << endl; // 9 4 3 1
// 小根堆(传入 Greater)
PQueue::priority_queue<int, vector<int>, PQueue::Greater<int>> min_pq;
min_pq.push(3); min_pq.push(1); min_pq.push(9); min_pq.push(4);
while (!min_pq.empty()) { cout << min_pq.top() << " "; min_pq.pop(); }
cout << endl; // 1 3 4 9
// int* 特化版本:按指向的值比较
int a1 = 10, a2 = 2, a3 = 30;
PQueue::priority_queue<int*> ptr_pq;
ptr_pq.push(&a1); ptr_pq.push(&a2); ptr_pq.push(&a3);
while (!ptr_pq.empty()) { cout << *ptr_pq.top() << " "; ptr_pq.pop(); }
cout << endl; // 30 10 2
return 0;
}
输出:
9 4 3 1
1 3 4 9
30 10 2
九、下标类型专题:int 还是 size_t
你可能会看到下标用
int还是size_t的争论。关键点:
(child - 1) / 2当child = 0:
- 用
int:0-1 = -1,-1/2 = 0(C++ 整数除法向 0 取整),安全;- 用
size_t:0 - 1会无符号下溢 成极大值,再/2也是巨大数,可能导致死循环或越界。所以用int恰好避开了下溢 bug。child < _con.size()的 int/size_t 混合比较 :int会被提升为size_t,若 child 为负会出问题。这里 child≥0 安全,但编译器会 warning。修正版统一强转(int)_con.size(),消除警告。
结论:堆下标用 int 是常见且能避开下溢的写法,但要统一,别和 size_t 混着比。
十、坑点总结(务必背下来)
- 头文件守卫用
#ifndef + #define,不是#ifdef(否则代码不会被编译)。Less的operator()是x < y,Greater是x > y;名字、语义、堆方向三者必须一致,否则大小根堆会反。- 仿函数用
const T&传参,避免大对象反复拷贝。pop不能直接删堆顶,必须 "堆顶与末尾交换 + pop_back + 向下调整",否则 O (N)。Adjust_down必须先选出左右孩子中优先级更高的那个,再和父比较,否则堆性质被破坏。top()和pop()空堆必须assert,否则越界。- 下标用
int并统一类型,避开size_t下溢。