手撕 C++ priority_queue:二叉堆 + 模板仿函数 + 模板特化(保姆级逐函数拆解)

一、前言:priority_queue 到底是什么

std::priority_queue 本质上是一个二叉堆(完全二叉树),用数组存储,默认是大根堆(堆顶最大)。

priority_queue 三件事:

  1. 底层为什么用数组存二叉树;
  2. 仿函数(functor)为什么比函数指针强;
  3. 存储指针类型时,直接比较会比较地址而不是值,如何用模板特化解决。

本文从零手写一个简化版,完整复刻 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)。

为什么用仿函数而不是函数指针? 三条硬理由(面试爱问):

  1. 类型在编译期确定 ,operator() 可内联展开,没有函数指针的间接调用开销;
  2. 仿函数可以携带成员状态(STL 很多复杂比较器依赖此特性);
  3. 符合 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);
}

干什么:

  1. 新元素先直接塞到 vector 末尾(作为完全二叉树的最后一个叶子);
  2. 调用 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

  1. 交换堆顶与末尾:swap(_con[0],_con[3]) → [1,4,3,9];pop_back() → [1,4,3]
  2. 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 的争论。关键点:

  1. (child - 1) / 2 当 child = 0 :
    • 用 int:0-1 = -1,-1/2 = 0(C++ 整数除法向 0 取整),安全;
    • 用 size_t:0 - 1 会无符号下溢 成极大值,再 /2 也是巨大数,可能导致死循环或越界。所以用 int 恰好避开了下溢 bug。
  2. child < _con.size() 的 int/size_t 混合比较 :int 会被提升为 size_t,若 child 为负会出问题。这里 child≥0 安全,但编译器会 warning。修正版统一强转 (int)_con.size(),消除警告。

结论:堆下标用 int 是常见且能避开下溢的写法,但要统一,别和 size_t 混着比。


十、坑点总结(务必背下来)

  1. 头文件守卫用 #ifndef + #define,不是 #ifdef(否则代码不会被编译)。
  2. Less 的 operator() 是 x < y,Greater 是 x > y;名字、语义、堆方向三者必须一致,否则大小根堆会反。
  3. 仿函数用 const T& 传参,避免大对象反复拷贝。
  4. pop 不能直接删堆顶,必须 "堆顶与末尾交换 + pop_back + 向下调整",否则 O (N)。
  5. Adjust_down 必须先选出左右孩子中优先级更高的那个,再和父比较,否则堆性质被破坏。
  6. top() 和 pop() 空堆必须 assert,否则越界。
  7. 下标用 int 并统一类型,避开 size_t 下溢。
相关推荐
ttwuai1 小时前
Go后台管理系统开源项目有哪些?如何按交付形态筛掉不合适的仓库
开发语言·golang·开源
小小龙学IT1 小时前
C++ Redis 客户端深度解析(hiredis 与 redis-plus-plus)——工业数采实时缓存 C++ 侧实战
c++·redis·缓存
yuniko-n1 小时前
【Java】关于容器选型:Deque、PriorityQueue、LinkedList
java·开发语言
扶风ff1 小时前
练题簿在线免费刷题:创建个人任务,把刷题、听题和模考目标放到每天的学习中
开发语言·javascript·学习·小程序
垆边人似月.1 小时前
华为机试题 :最长递增子序列题目
数据结构·c++·算法·华为
yuniko-n1 小时前
【JUC】wait 和 sleep
java·开发语言
kimnoic1 小时前
Python常见模块及其用法示例详解
开发语言·python
程序员-Benothing2 小时前
Linux文本排序统计命令:sort、uniq、wc、cut、tr实战
java·开发语言·算法
CRMEB2 小时前
前后端技术栈全面换代!CRMEB 多商户(Java)v3.0更新预告
java·开发语言