C++入门篇(十七):priority_queue 完整模拟实现——仿函数、区间建堆与指针特化(附完整代码仓库)

0.1 概述&序言

这里是白杨。上一篇 stack/queue 篇里,priority_queue 只讲到"用法 + 堆的两个调整"就收尾了。我自己复盘的时候越看越不对劲:构造函数没讲、仿函数没讲、自定义类型只有一个句号、指针陷阱完全没提------对复习党来说,这一块是整篇最"骨架"的。

所以这篇专门补完。而且越写越觉得值:仿函数不只是 priority_queue 的配件,它是整个 STL 的通用基础设施------sort 的升降序、find_if 的筛选条件、关联容器的比较规则,都靠它。

这篇要过五件事:

  • 仿函数是什么:f(1, 2) 和 f.operator()(1, 2) 的关系
  • 完整模拟实现:构造函数 + 区间建堆(上篇缺的那块)
  • 自定义类型与指针陷阱 :priority_queue<Date*> 为什么会排错
  • Top-K 实战:前 K 大 / 第 K 大 / 前 K 小
  • 附:顺手做了个 deque 排序效率实验

老规矩,跑得通的结果才写进来(环境见下);完整代码文末自取,也可以直接去仓库:gitee.com/BaiYang_SQ/priority-queue-sim。

实测环境:w64devkit g++ 15.2.0(-std=c++11 -O2 -Wall -Wextra)+ VS2022;教学简化版,生产请直接使用 std::priority_queue。

文中代码均为本人手写并实测;官方文档:cppreference·priority_queue。

让我们开始吧。

一、priority_queue 快速回顾

priority_queue 的本质:底层用堆算法维护优先级,堆顶永远是"优先级最高"的元素 。默认大堆(最大元素在堆顶),想变小堆就把比较器换成 greater。

接口 说明 复杂度
push(x) 插入,自动上浮 O(log N)
pop() 删除堆顶,自动下沉 O(log N)
top() 只看堆顶 O(1)
empty() / size() 判空 / 个数 O(1)
cpp 复制代码
priority_queue<int> q1;                                    // 默认大堆
priority_queue<int, vector<int>, greater<int>> q2;         // 小堆

用法上篇讲过,这里不展开。真正值得挖的是第三个模板参数------greater<int> 这个括号背后的东西:仿函数。

二、仿函数:对象为什么能当函数用

priority_queue 的模板签名里,第三个参数是这样解的:

cpp 复制代码
template <class T, class Container = std::vector<T>, class Compare = std::less<T> >
class priority_queue;

Compare 这个位置最终传的是一个类,不是函数。为什么类能拿来"比较"?看仿函数长什么样:

cpp 复制代码
template <class T>
struct Less
{
    bool operator()(const T& x, const T& y) { return x < y; }
};

template <class T>
struct Greater
{
    bool operator()(const T& x, const T& y) { return x > y; }
};

仿函数(函数对象)的本质:重载了 operator() 的类,它的对象可以像函数一样被"调用"。

cpp 复制代码
Less<int> lessFunc;
Greater<int> greaterFunc;
cout << lessFunc(1, 2) << endl;              // 1
cout << lessFunc.operator()(1, 2) << endl;   // 1(等价写法)
cout << greaterFunc(1, 2) << endl;           // 0(1 < 2 不成立)

【一句话记】lessFunc(1, 2) 就是 lessFunc.operator()(1, 2) 的简写------对象像函数一样用,所以叫"仿函数"。

实测输出:

text 复制代码
lessFunc(1, 2) = 1,lessFunc.operator()(1, 2) = 1
greaterFunc(1, 2) = 0
sort 降序: 9 8 7 6 5 4 3 3 2 2 2 1 0
find_if 找到第一个奇数且 11 的倍数: 55

后两行是仿函数的另两个用法:给 sort 传 Greater<int>() 排降序、给 find_if 传一个自定义"条件对象"(既是奇数、又是 11 的倍数):

cpp 复制代码
class Conditional
{
public:
    bool operator()(int x) { return x % 2 && x % 11 == 0; }
};

vector<int> v{3, 2, 7, 6, 0, 4, 1, 9, 8, 5, 2, 2, 3};
sort(v.begin(), v.end(), Greater<int>());          // 降序
auto it = find_if(v.begin(), v.end(), Conditional());

【面试】STL 为什么大量用仿函数,而不是用函数指针?

  • 没有间接调用开销 :仿函数的 operator() 是普通成员函数,编译器可以直接内联;函数指针调用往往要经过一次间接跳转;
  • 可以携带状态:类可以有成员变量(比如"带容差的比较器"),函数指针做不到;
  • 给算法传"策略" :同一个 sort,换个比较器就换一套排序规则------策略模式。

三、完整模拟实现:构造函数与建堆

3.1 先理解"堆"长在数组里

堆是一棵完全二叉树,而完全二叉树可以按下标连续存在数组里,这就是它能 O(log N) 调整的秘密:

  • parent = (i - 1) / 2;left = 2i + 1;right = 2i + 2
  • 大堆 :每个双亲 ≥ 孩子(Compare = Less 时);小堆 反之(换 Greater)

3.2 完整版实现

上篇只贴了 adjust_up / adjust_down 两个函数(还省略了构造函数),这次直接给完整不省略的版本:

cpp 复制代码
namespace bit
{
template <class T, class Container = vector<T>, class Compare = Less<T>>
class priority_queue
{
public:
    priority_queue() {}

    // 迭代器区间构造:先装进底层容器,再自底向上建堆
    template <class InputIterator>
    priority_queue(InputIterator first, InputIterator last)
        : _con(first, last)
    {
        // 建堆:从最后一个非叶子结点倒着向下调整
        for (int i = ((int)_con.size() - 2) / 2; i >= 0; --i)
            adjust_down((size_t)i);
    }

    void push(const T& x)
    {
        _con.push_back(x);
        adjust_up(_con.size() - 1);
    }

    void pop()
    {
        swap(_con[0], _con[_con.size() - 1]);   // 堆顶和末尾交换
        _con.pop_back();                        // 删掉原来的堆顶
        adjust_down(0);                         // 新堆顶向下调整
    }

    const T& top() const { return _con[0]; }
    bool empty() const   { return _con.empty(); }
    size_t size() const  { return _con.size(); }

private:
    void adjust_up(size_t child)
    {
        size_t parent = (child - 1) / 2;
        while (child > 0)
        {
            Compare compare;   // 仿函数对象:像函数一样调用
            if (compare(_con[parent], _con[child]))
            {
                swap(_con[child], _con[parent]);
                child = parent;
                parent = (child - 1) / 2;
            }
            else
                break;
        }
    }

    void adjust_down(size_t parent)
    {
        size_t child = parent * 2 + 1;
        Compare compare;
        while (child < _con.size())
        {
            if (child + 1 < _con.size() && compare(_con[child], _con[child + 1]))
                ++child;   // 挑"更该上去"的那个孩子

            if (compare(_con[parent], _con[child]))
            {
                swap(_con[child], _con[parent]);
                parent = child;
                child = parent * 2 + 1;
            }
            else
                break;
        }
    }

    Container _con;
};
}

三个设计点,挨个说:

① Compare 单独抽出来是"解耦" :容器只负责存数据(Container),比较逻辑(Compare)单独一个模板参数。想换大堆小堆,只动 Compare,容器和堆调整代码一行不用改。

② 区间构造 = 装进容器 + 建堆 :_con(first, last) 先把数据全塞进 vector,然后......

③ 建堆为什么从 (n-2)/2 开始? 这是全篇最值得押的一道题:

  • 最后一个结点下标是 n-1,它的双亲是 ((n-1)-1)/2 = (n-2)/2;
  • 下标比 (n-2)/2 更大的结点全是叶子------叶子连孩子都没有,"向下调整"无事可做(天然合法);
  • 所以从最后一个非叶子结点开始,倒着向前,每个结点做一次向下调整,整棵树就是堆了。

复杂度上的直觉:越靠近底部的子树越矮、数量越多;越靠上子树越高、数量指数级减少,两边一抵消,建堆整体是 O(N),比"逐个 push"(O(N log N))更快。

3.3 实测

text 复制代码
[大堆·区间建堆] top=9 | 弹 5 次: 9 8 7 6 5
[大堆·逐个push] top=9 | 弹 5 次: 9 8 7 6 5
[小堆·区间建堆] top=0 | 弹 5 次: 0 1 2 2 2

前两行结果完全一致:区间建堆和逐个 push 得到的是同一个堆 (只是效率不同)。第三行把 Compare 换成 Greater,同样一份代码立刻变成小堆。

【必背】push = 尾插 + 向上调整;pop = 堆顶与末尾交换、删尾、新堆顶向下调整;建堆从 (n-2)/2 倒着 adjust_down。

四、自定义类型与指针陷阱

4.1 自定义类型:要么给运算符,要么给仿函数

把 Date 放进 priority_queue,有两条路:

cpp 复制代码
class Date
{
public:
    Date(int year = 1900, int month = 1, int day = 1)
        : _year(year), _month(month), _day(day)
    {}

    bool operator<(const Date& d) const   // 大堆需要:Date 支持 <
    {
        return (_year < d._year) ||
               (_year == d._year && _month < d._month) ||
               (_year == d._year && _month == d._month && _day < d._day);
    }

    bool operator>(const Date& d) const   // 小堆需要:Date 支持 >
    {
        return (_year > d._year) ||
               (_year == d._year && _month > d._month) ||
               (_year == d._year && _month == d._month && _day > d._day);
    }

    friend ostream& operator<<(ostream& _cout, const Date& d);
    // 教学方便起见成员公开;工程里建议 private + 提供接口
    int _year; int _month; int _day;
};

// 路线二:自定义仿函数,量身定做比较逻辑
class LessDate
{
public:
    bool operator()(const Date& x, const Date& y)
    {
        return (x._year < y._year) ||
               (x._year == y._year && x._month < y._month) ||
               (x._year == y._year && x._month == y._month && x._day < y._day);
    }
};

实测(LessDate 大堆 + Greater<Date> 小堆):

text 复制代码
[LessDate 大堆] top=2026-10-30 | 依次弹出: 2026-10-30 2026-10-29 2026-10-28 2021-3-30 2020-1-30
[Greater 小堆] top=2026-10-28 | 弹 3 次: 2026-10-28 2026-10-29 2026-10-30

【面试】自定义类型进 priority_queue 的条件:默认大堆要求类型支持 operator<;换 greater 变小堆要求 operator>。不想改类型?手写一个仿函数传进去即可------比较逻辑和数据类型解耦。

4.2 指针陷阱:priority_queue<Date*> 排的是什么?

重点来了。如果存的是指针:

cpp 复制代码
bit::priority_queue<Date*> q;   // Compare 默认是 Less<Date*>

Less<Date*> 里的比较是 x < y------两个指针比大小,比的是地址!地址大小和日期大小毫无关系。用一个可控的实验暴露它:

cpp 复制代码
Date arr[2] = {Date(2026, 10, 30), Date(2026, 10, 29)};
Date* p30 = &arr[0];   // 日期更大,地址更低(数组内地址随下标递增)
Date* p29 = &arr[1];

cout << std::less<Date*>()(p30, p29);   // 1:按地址比较,竟然认为"10-30 < 10-29"
cout << bit::Less<Date*>()(p30, p29);   // 0:按日期比较,正确

实测输出:

text 复制代码
按地址比:std::less<Date*>()(p30, p29) = 1   ← 竟认为 10-30 < 10-29(错)
按对象比:bit::Less<Date*>()(p30, p29)  = 0   ← 偏特化后比的是日期(对)
[std::less 版本] top=2026-10-29   ← 比地址,拿到错的堆顶
[bit::Less 特化版] top=2026-10-30   ← 按日期,正确

后两行是真实的堆行为对照:用未特化的比较器,堆顶是错的 10-29;用偏特化后的 bit::Less,堆顶是 10-30。

修复方案:给 Less 加一个指针偏特化------所有指针类型一次性处理,比给每个类写仿函数优雅得多:

cpp 复制代码
// 偏特化:所有"指针类型"都按指向的对象比较
template <class T>
struct Less<T*>
{
    bool operator()(T* const& x, T* const& y) { return *x < *y; }
};

【易错】指针 ≠ 对象。priority_queue<Date*> 不处理比较器,排的就是地址;这也是"模板偏特化"最实用的一类战场------下一篇《模板进阶》会专门展开全特化 / 偏特化的完整玩法,这里先混个脸熟。

五、Top-K 实战

priority_queue 最经典的 OJ 用法就是 Top-K。OJ 读数模式长这样(K 从输入读):

cpp 复制代码
int k;
cin >> k;
while (k-- && !q.empty())
{
    cout << q.top() << " ";
    q.pop();
}

我们固定 K 实测(v = {3,2,7,6,0,4,1,9,8,5,2,2,3}):

text 复制代码
前 5 大: 9 8 7 6 5
第 3 大: 7
前 5 小: 0 1 2 2 2

三种问法各对应一个套路:

  • 前 K 大:大堆弹 K 次;
  • 第 K 大:大堆弹 K-1 次,剩下的堆顶就是答案------建堆 O(N) + K 次调整 O(K log N);
  • 前 K 小 :换 Greater 小堆,弹 K 次。

【面试加一问】数据量 N 巨大、K 很小(比如 100 亿里找前 10)怎么办?------反过来维护一个大小为 K 的小堆:遍历时比堆顶大就替换,空间 O(K)、时间 O(N log K),不用把所有数据都装进来。这个思路常和"海量数据处理"一起考。

六、附:deque 排序效率实测

上一篇讲 deque 时说过"头尾 O(1)、遍历慢",那是理论。补一个实验兜底:100 万个数,一份直接在 deque 上 sort,一份拷到 vector 排完再拷回 deque(-O2):

text 复制代码
N = 1000000(-O2 实测)
直接 sort(deque)              : 43 ms
拷贝到 vector 排序再拷回 deque : 30 ms

拷贝折腾了两趟,反而快 13ms。原因和之前 list 那篇是同一个:deque 的"分段连续"让迭代器频繁跨段检测、缓存命中率也低------排序和遍历都用不上它的长处,反而吃满它的短板。

【一句话记】deque 的用武之地是"两端进出",不是"排序和遍历"------这也解释了标准库为什么拿它给 stack/queue 当底层:那俩恰好只需要两端操作。

七、速记卡

考前扫一眼,面试不卡壳:

八、自测三连

先自己默答,再看答案对照:

  1. 仿函数是什么?f(1, 2) 的等价写法是什么?priority_queue 里它管什么?

答案

仿函数是重载了 operator() 的类;f(1, 2) 等价于 f.operator()(1, 2)。在 priority_queue 里它作为 Compare 模板参数,决定"谁排在堆顶"------Less 大堆、Greater 小堆。

  1. 区间构造的建堆为什么从 (n-2)/2 开始?为什么它和"逐个 push"结果一样?

答案

(n-2)/2 是最后一个结点的双亲,也就是最后一个非叶子结点;它的后面全是叶子,叶子天然是合法的堆,不用调整。从它开始倒着做向下调整,每个子树都被修正成堆,自底向上完成建堆。和逐个 push 只是"建堆路径不同",最终得到的是同一个堆,但建堆整体是 O(N),更快。

  1. priority_queue<Date*> 直接塞指针会怎样?为什么?怎么修?

答案

默认 Less<Date*> 比较的是指针地址 ,和日期大小无关,堆顶会拿错(实测拿到了 10-29 而不是 10-30)。修复:给比较器加指针偏特化 Less<T*>,用 *x < *y 按指向的对象比较;或者传入一个针对该类型的自定义仿函数。


附录 A:完整代码仓库

完整工程(单文件版,全部实验一次跑完)在 Gitee:

gitee.com/BaiYang_SQ/priority-queue-sim

仓库文件 说明
priority_queue_sim.cpp 单文件:全部实验 + 完整模拟实现;章节 ↔ 函数名对照见仓库 README 导览表
output.txt 全部实验的真实运行输出(不想装编译器,直接看这个)
build.bat Windows 一键编译 + 运行 + 刷新 output.txt
README.md 一分钟上手 + 代码导览

面试前手写练手,照这个"默写版"骨架抄(比完整版少了 top/empty/size 等边角):

cpp 复制代码
// priority_queue 面试默写版(完整版见正文第三节 / 仓库)
#include <iostream>
#include <vector>
using namespace std;

template <class T, class Container = vector<T>, class Compare = less<T>>
class priority_queue
{
public:
    priority_queue() {}

    template <class InputIterator>
    priority_queue(InputIterator first, InputIterator last)
        : _con(first, last)
    {
        for (int i = ((int)_con.size() - 2) / 2; i >= 0; --i)
            adjust_down((size_t)i);
    }

    void push(const T& x) { _con.push_back(x); adjust_up(_con.size() - 1); }

    void pop()
    {
        swap(_con[0], _con[_con.size() - 1]);
        _con.pop_back();
        adjust_down(0);
    }

    const T& top() const { return _con[0]; }

private:
    void adjust_up(size_t child)
    {
        size_t parent = (child - 1) / 2;
        while (child > 0)
        {
            Compare compare;
            if (compare(_con[parent], _con[child]))
            {
                swap(_con[child], _con[parent]);
                child = parent;
                parent = (child - 1) / 2;
            }
            else break;
        }
    }

    void adjust_down(size_t parent)
    {
        size_t child = parent * 2 + 1;
        Compare compare;
        while (child < _con.size())
        {
            if (child + 1 < _con.size() && compare(_con[child], _con[child + 1]))
                ++child;
            if (compare(_con[parent], _con[child]))
            {
                swap(_con[child], _con[parent]);
                parent = child;
                child = parent * 2 + 1;
            }
            else break;
        }
    }

    Container _con;
};

int main()
{
    priority_queue<int> q;
    for (int e : {5, 1, 9, 3, 7}) q.push(e);
    cout << q.top() << endl;   // 9
    q.pop();
    cout << q.top() << endl;   // 7
    return 0;
}

好了,priority_queue 这一篇到此完结!下一篇回到主线------入门篇的收官之战:模板进阶 (预告:非类型模板参数、模板全特化与偏特化、分离编译------对,就是本篇指针陷阱埋下的那个伏笔继续往下挖)。如果对你有帮助,不要忘记点赞三连一波哦,我是白杨,我们下期见。

相关推荐
逐光者9332 小时前
滤波器的 R 与 C 如何选择:从截止频率到工程实践
c语言·开发语言·r语言
Eider19982 小时前
大模型输出格式幻觉解决思路
开发语言·前端·javascript
啦啦啦啦啦zzzz2 小时前
分布式存储----缓存组
linux·服务器·c++·分布式·缓存
艾iYYY3 小时前
思考: C++ 万能引用与完美转发
c++·windows·microsoft
余额瞒着我当琳3 小时前
C++算法学习篇5--二分查找法
c++·学习·算法
Want5953 小时前
Python太极八卦图
开发语言·python
饕餮争锋3 小时前
python开发工具uv简介
开发语言·python·uv
蒸蒸yyyyzwd3 小时前
秋招学习笔记 Day 64 之准备系统软件开发工程师岗位笔记
c++·八股
奇树谦3 小时前
LRU 缓存原理与 C++ 实践示例
开发语言·c++·缓存