当前位置:

首页 > 编程开发 > C++STL进阶:手写priority_ueue与仿函数机制详解

C++STL进阶:手写priority_ueue与仿函数机制详解

1. 引言:什么是priority_queue 在C++标准库的众多容器中,priority_queue(优先级队列)是一个既实用又充满设计巧妙的组件。它不像vector或list那样直接存储数据,而是一个“容器适配器”。这意味着它站在巨人的肩膀上,复用现有容器的能力,并赋予其全新的行为逻辑。 简单

1. 引言:什么是priority_queue

在C++标准库的众多容器中,priority_queue(优先级队列)是一个既实用又充满设计巧妙的组件。它不像vectorlist那样直接存储数据,而是一个“容器适配器”。这意味着它站在巨人的肩膀上,复用现有容器的能力,并赋予其全新的行为逻辑。

简单来说,你可以把它想象成一个智能的“排队系统”:每次有人离开队伍(pop),走掉的总是当前队伍里“优先级最高”的那位,而不是先来后到。这种特性使其在需要频繁获取最大或最小元素的场景中,比如任务调度、数据流的中位数查找,效率极高。

1.1 基本概念

从接口上看,priority_queue的核心操作非常简洁:

  • 插入push):时间复杂度为 O(log N)。
  • 删除堆顶pop):时间复杂度为 O(log N)。
  • 查看堆顶top):时间复杂度为 O(1)。

关键在于,每次执行pop,弹出的永远是当前队列中优先级最高的元素。至于什么是“高优先级”,则由你定义的比较规则来决定。

C++STL进阶:手写priority_ueue与仿函数机制详解

1.2 底层结构

它的高效并非魔法,而是源于其底层数据结构——堆(Heap)。默认情况下,priority_queue使用std::vector作为底层容器,并在其上维护一个堆结构:

  • 大顶堆:父节点的值总是大于或等于子节点的值,因此top()返回的是最大值。
  • 小顶堆:父节点的值总是小于或等于子节点的值,因此top()返回的是最小值。

堆的性质通过两个核心操作来维持:向上调整(Heapify Up)用于插入元素后恢复堆序,向下调整(Heapify Down)用于删除堆顶元素后恢复堆序。

这里有一个初学者容易困惑的点:priority_queue的默认比较行为是“反直觉”的。默认使用std::less仿函数,结果却构建了一个大顶堆(值大的优先级高);而使用std::greater时,反而构建了小顶堆(值小的优先级高)。在实现时,我们需要严格遵循这一设计。

1.3 比较器的默认行为

priority_queue的灵活性很大程度上来自于它的模板参数Compare。这个参数用于定义元素间的优先级比较规则。

  • 如果不显式指定,默认使用std::less
  • std::less本身是一个仿函数(Functor),其内部就是调用operator<
  • 在默认的std::less下,priority_queue表现为大顶堆

因此,如果你需要一个小顶堆,只需显式传入std::greater即可。当然,你也可以传入任何自定义的仿函数,来实现更复杂的优先级逻辑。

注意:比较器并非必须手动编写,它有默认行为。 是否显式提供,完全取决于你是否需要改变默认的排序规则。下文我们会通过显式构造来深入演示仿函数是如何工作的。

2. priority_queue的核心实现

理解了它的外在行为,我们不妨深入内部,看看一个简易版的priority_queue是如何搭建起来的。这能帮助我们更好地理解其设计哲学。

2.1 成员变量与默认构造

2.1.1 结构框架与默认构造

首先,我们定义类的骨架。可以看到,它采用了模板来提供高度的可配置性。

template, class Compare = Less>
// 这里的Container和Compare模板参数,正是为了清晰地展示底层结构
class priority_queue {
public:
    priority_queue() = default;  // 使用编译器生成的默认构造函数
private:
    Container _con;               // 底层容器
};

这里引入了两个关键模板参数:Container(底层容器类型)和Compare(比较仿函数类型)。它们正是“适配器模式”和“仿函数机制”的体现,我们稍后会详细展开。

2.1.2 迭代器区间构造

除了默认构造,一个实用的构造函数是接受一个迭代器区间,并用其中的元素初始化堆。

template
priority_queue(InputIterator first, InputIterator last)
    :_con(first, last) // 先用区间初始化底层容器
{
    // 底层容器有了数据,但逻辑上还不是堆,需要“建堆”
    // 这里采用效率更高的“向下调整建堆法”
    for (int i = (_con.size() - 1 - 1) / 2; i >= 0; i--) {
        adjust_down(i);
    }
}

为什么选择向下调整建堆?因为它的时间复杂度是O(N),优于逐个插入的向上调整建堆法O(N log N)。建堆的核心是adjust_down函数:

void adjust_down(size_t parent) {
    Compare com; // 实例化比较器
    size_t child = parent * 2 + 1; // 先默认左孩子
    while (child < _con.size()) {
        // 如果存在右孩子,且右孩子“优先级更高”,则让child指向右孩子
        if (child + 1 < _con.size() && com(_con[child], _con[child + 1])) {
            child++;
        }
        // 关键比较:如果父节点“优先级低于”选出的孩子节点,则交换
        // 在大堆中:parent < child 时,com(parent, child)为true,交换
        // 在小堆中:parent > child 时,com(parent, child)为true,交换
        if (com(_con[parent], _con[child])) {
            swap(_con[parent], _con[child]);
            parent = child;
            child = parent * 2 + 1;
        }
        else {
            break; // 父节点已满足堆序,调整结束
        }
    }
}

2.2 push插入数据

插入操作的逻辑很直观:先把新元素放到数组末尾,然后执行向上调整,使其“上浮”到正确位置。

void push(const T& x) {
    _con.push_back(x);
    adjust_up(_con.size() - 1); // 从最后一个位置开始向上调整
}

向上调整算法adjust_upadjust_down的逆向过程:

void adjust_up(size_t child) {
    Compare com;
    while (child > 0) {
        size_t parent = (child - 1) / 2;
        // 如果孩子节点“优先级高于”父节点,则交换
        if (com(_con[parent], _con[child])) {
            swap(_con[parent], _con[child]);
            child = parent;
        }
        else {
            break;
        }
    }
}

2.3 pop删除数据

删除堆顶元素是优先级队列的核心。技巧在于,不能直接删除首元素,那样会破坏结构。标准做法是:

void pop() {
    assert(!empty());
    // 1. 将堆顶元素与末尾元素交换
    std::swap(_con[0], _con[_con.size() - 1]);
    // 2. 删除现在的末尾元素(即原堆顶)
    _con.pop_back();
    // 3. 对新的堆顶元素执行向下调整,恢复堆序
    adjust_down(0);
}

2.4 top / empty / size

这些接口的实现最为直接,因为它们不改变堆结构:

const T& top() const { return _con[0]; } // 堆顶即优先级最高者
bool empty() const { return _con.empty(); }
size_t size() const { return _con.size(); }

看到这里,你可能已经注意到,整个实现高度依赖于两个设计:适配器模式(通过Container参数选择底层容器)和仿函数机制(通过Compare参数定义优先级规则)。这正是priority_queue设计精妙之处,下面我们来详细剖析。

3. 适配器模式与priority_queue的设计

3.1 什么是适配器模式

适配器模式,顾名思义,就是“转换接口”的设计模式。生活中最常见的例子就是电源适配器:把墙上的220V交流电,转换成手机需要的5V直流电。

在软件设计中,容器适配器也是如此。它不自己管理内存,而是“包装”一个已有的底层容器(如vectordeque),通过限制或改变其接口,提供一种全新的数据结构语义。stackqueue也是容器适配器的典型代表。

3.2 priority_queue的适配器设计

回顾我们的类模板声明:

template, class Compare = Less>
class priority_queue {
    Container _con;
    // ...
};
  • Container:指定底层容器类型,默认是vector。你也可以换成deque等支持随机访问和尾插尾删的容器。
  • Compare:指定比较仿函数类型,默认是Less(对应大顶堆)。

这种设计的精妙之处在于:模板参数的缺省值让普通用户无需关心细节即可使用(priority_queue pq;),同时又为高级用户保留了完全的定制能力(可以指定底层容器和比较规则)。这就是“适配”思想的完美体现。

4. 仿函数

如果说适配器模式决定了priority_queue的“身体”,那么仿函数就赋予了它灵活的“灵魂”。

4.1 什么是仿函数

仿函数,也叫函数对象,本质是一个类,但它重载了函数调用运算符operator(),使得该类的对象可以像函数一样被调用。

一个最简单的比较仿函数定义如下:

template
class Less {
public:
    bool operator()(const T& x, const T& y) {
        return x < y;
    }
};

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

4.2 仿函数的用法

4.2.1 作为比较器,控制容器或算法的行为

这是仿函数最经典的用途。通过将“比较规则”作为参数传入,我们可以轻松定制容器或算法的行为,而无需修改其内部代码。

示例1:控制优先级队列是大堆还是小堆

int main() {
    // 默认使用 Less → 大堆
    ZL::priority_queue pq1;
    // 显式指定 Greater → 小堆
    ZL::priority_queue, Greater> pq2;

    pq1.push(100); pq1.push(5); pq1.push(200); pq1.push(10);
    while (!pq1.empty()) {
        cout << pq1.top() << " "; // 输出:200 100 10 5
        pq1.pop();
    }
    return 0;
}

示例2:控制排序算法的升降序

template
void BubbleSort(T* a, int n, Compare com) {
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < n - i - 1; ++j) {
            // 比较规则由传入的仿函数对象决定
            if (com(a[j + 1], a[j]))
                swap(a[j], a[j + 1]);
        }
    }
}
int main() {
    int a[] = { 1,3,4,5,6,3,2 };
    BubbleSort(a, 7, Less());    // 升序排序
    BubbleSort(a, 7, Greater()); // 降序排序
    // ...
}

C++STL进阶:手写priority_ueue与仿函数机制详解

4.2.2 作为判断条件或转换规则

仿函数的能力不限于比较。任何需要“可调用对象”的场景,它都能胜任。

示例1:作为判断条件(查找第一个偶数)

struct IsEven {
    bool operator()(int x) {
        return x % 2 == 0;
    }
};
int main() {
    int a[] = { 1,3,2,9,1,3,4,5 };
    // 使用 find_if 算法,配合仿函数作为谓词
    auto it = find_if(a, a + 7, IsEven());
    cout << *it << endl; // 输出:2
    return 0;
}

示例2:作为转换规则(将偶数乘以2)

struct DoubleIfEven {
    int operator()(int x) {
        return (x % 2 == 0) ? x * 2 : x;
    }
};
int main() {
    vector v = {1, 2, 3, 4, 5};
    // 使用 transform 算法,对每个元素应用仿函数进行转换
    transform(v.begin(), v.end(), v.begin(), DoubleIfEven());
    // v 变为 {1, 4, 3, 8, 5}
    return 0;
}

4.2.3 处理指针时自定义比较逻辑

这是一个非常实用的场景。当我们存储的是指针时,默认比较的是指针地址,而非指针所指对象的内容。仿函数可以轻松解决这个问题。

// 假设 Date 类已重载了 operator<
struct PDateLess {
    bool operator()(const Date* p1, const Date* p2) {
        return *p1 < *p2; // 比较的是Date对象本身,而非指针地址
    }
};

int main() {
    ZL::priority_queue, PDateLess> q1;
    q1.push(new Date(2018, 10, 29));
    q1.push(new Date(2018, 10, 28));
    q1.push(new Date(2018, 10, 30));
    while (!q1.empty()) {
        cout << *q1.top() << " "; // 按日期顺序弹出
        delete q1.top();
        q1.pop();
    }
    return 0;
}

这里自定义仿函数的意义在于“纠正”默认行为:让优先级队列比较Date对象的内容,而不是存储它们的指针地址。

4.3 仿函数存在的意义

那么,为什么C++要引入仿函数,而不是一直使用函数指针呢?从根本上说,仿函数是现代C++为了摒弃函数指针的诸多缺陷而提供的更优解决方案。下面的表格清晰地对比了两者的差异:

面临的问题 使用函数指针的痛点 使用仿函数的优势
算法需要多种行为 需要编写多个函数,或传递函数指针,效率低且通常无法内联优化。 将行为封装成类,编译器可以轻松内联operator()调用,几乎没有额外开销。
需要携带状态 普通函数无法在多次调用间保持状态(除非使用全局或静态变量,但这会破坏封装)。 仿函数是类,可以拥有成员变量,自然地在多次调用间维护状态信息。
类型安全 函数指针的类型检查相对较弱,容易传递错误类型的函数。 仿函数是具体的类类型,模板参数推导和类型检查更为严格。
与模板结合 函数指针作为模板参数语法不直观,且能力有限。 仿函数作为类型,可以无缝作为模板参数,使用起来非常自然。
复杂规则封装 复杂的比较或操作逻辑难以复用和组合。 一个仿函数类可以实例化多个对象,在程序各处复用,也易于通过继承或组合来构建更复杂的逻辑。

正是这些优势,使得仿函数(以及后来出现的lambda表达式)成为现代C++泛型编程和STL设计中不可或缺的一环。理解了它,你也就掌握了priority_queue乃至整个STL灵活性的钥匙。

本文内容来源于互联网,如有侵权请联系删除。
作者最新文章
编程开发
上一篇: python dict key详解
相关文章 更多
C++动态数组初始化怎么写?常用语句与代码示例
C++动态数组初始化怎么写?常用语句与代码示例

深入解析C++中动态数组的初始化机制,涵盖new操作符的不同用法、基本类型与类对象的初始化差异,以及为何在现代C++开发中应优先使用std::vector。

using namespace 使用中遇到的问题怎么解决
using namespace 使用中遇到的问题怎么解决

命名空间的基本概念与常见引入问题在C++等编程语言中,命名空间(namespace)是一种将代码标识符(如变量、函数、类名)封装在特定名称下的机制,其主要目的是避免命名冲突,尤其是在大型项目或使用多个第三方库时。使用“using namespace”指令可以将指定命名空间中的所有名称引入当前作用域,

c语言函数递归 实操经验总结:这些技巧很实用
c语言函数递归 实操经验总结:这些技巧很实用

理解递归的基本原理在C语言中,递归是一种函数调用自身的编程技术。要掌握它,首先需要理解其核心思想:将一个复杂的大问题,分解为一个或几个与原问题相似但规模更小的子问题,直到子问题足够简单,可以直接求解。这个过程通常包含两个关键部分:递归出口和递归体。递归出口定义了问题何时不再继续分解,即最简单、可直接

c语言函数递归 怎么选?常见方案对比分析
c语言函数递归 怎么选?常见方案对比分析

递归函数的基本概念与适用场景在C语言编程中,递归是一种函数调用自身的编程技巧。它并非适用于所有问题,但在处理某些具有自相似结构的问题时,能提供极其清晰和优雅的解决方案。递归的核心思想是将一个大规模问题分解为一个或多个同类型但规模更小的子问题,直到子问题简单到可以直接求解。典型的适用场景包括树形结构的

Objective-C 内存管理入门:从 alloc 到 dealloc 的生命周期详解
Objective-C 内存管理入门:从 alloc 到 dealloc 的生命周期详解

理解内存管理的基石在Objective-C的编程世界中,内存管理是开发者必须掌握的核心技能之一。它直接关系到应用的性能、稳定性与资源利用效率。与一些采用自动垃圾回收机制的语言不同,Objective-C在很长一段时间里,依赖一套基于引用计数的、需要开发者部分介入的管理规则。这套规则的核心思想是明确的

如何正确使用 dealloc 以避免 iOS 应用中的内存泄漏
如何正确使用 dealloc 以避免 iOS 应用中的内存泄漏

理解 dealloc 的角色与时机在 iOS 应用开发中,内存管理是保障应用性能与稳定性的基石。dealloc 方法是 Objective-C 中对象生命周期结束时的关键回调,它标志着对象即将被系统回收内存。正确理解其触发时机至关重要:当一个对象的引用计数降为零时,运行时系统会自动调用该对象的 de

深入理解 Objective-C 中的 dealloc 方法:内存管理核心机制
深入理解 Objective-C 中的 dealloc 方法:内存管理核心机制

内存管理的基石在Objective-C的世界里,内存管理是开发者必须掌握的核心技能之一。作为一门在手动引用计数(MRC)时代诞生的语言,Objective-C要求程序员对对象的生命周期有清晰的认识。dealloc方法正是这一生命周期中至关重要的终点站。它是一个实例方法,当对象的引用计数降为零时,系统

理解 native2ascii:Java 国际化开发中的字符编码工具
理解 native2ascii:Java 国际化开发中的字符编码工具

native2ascii 工具的基本定位在Ja va应用程序的国际化与本地化开发过程中,处理非拉丁字符集是一个常见且关键的环节。Ja va内部使用Unicode字符集来统一表示全球各种语言的文字,但其属性文件(.properties)在历史上要求使用ASCII编码,或者更准确地说,要求非ASCII字

如何使用 native2ascii 转换中文字符为 Unicode 转义序列
如何使用 native2ascii 转换中文字符为 Unicode 转义序列

理解 native2ascii 工具的基本用途在软件开发,特别是涉及国际化处理的场景中,开发者常常需要处理不同编码的文本资源。native2ascii 是 Ja va 开发工具包(JDK)中提供的一个命令行实用程序,其主要功能是将包含本地字符编码(非ASCII字符)的文件,转换为包含 Unicode

Java native2ascii 命令详解:解决属性文件乱码问题
Java native2ascii 命令详解:解决属性文件乱码问题

native2ascii 命令的由来与作用在Ja va开发中,处理国际化资源文件是一个常见需求。资源文件通常以.properties格式存储,用于支持多语言界面。然而,Ja va属性文件默认采用ISO-8859-1字符集编码,这导致了一个直接的问题:当文件中包含非拉丁字符(如中文、日文、韩文等)时,

查看更多
精品专题 更多
装机必备
装机必备

正软商城装机必备专区,精选办公、浏览器、安全防护、影音播放、压缩解压、设计创作和系统工具等电脑常用正版软件,帮助用户快速完成新电脑软件配置。

Windows
Windows

正软商城Windows软件专区,汇集适用于Windows电脑的办公、设计、安全防护、影音播放、开发工具和系统优化软件,提供软件介绍、系统要求、正版授权及购买下载服务。

macOS软件
macOS软件

正软商城macOS软件专区,精选适用于Mac电脑的办公、设计、影音、效率、开发和系统工具,提供软件功能介绍、macOS兼容版本、正版授权及购买下载服务。

Mac软件 更多
灵活计算器
灵活计算器
macOS/iOS/Android

灵活计算器是一款笔记式算数应用,支持实时计算、动态关联和云端同步功能。记录、整理和输出之间的过渡会更自然,适合长期写作、做笔记或持续沉淀个人内容。

赤友清理大师
赤友清理大师
macOS

赤友清理大师是一款为 Mac 设计的智能清理优化工具,可精准扫描垃圾、大文件、重复文件等,释放磁盘空间。做扫描整理、文字提取和表格转换时,它能把识别后的处理步骤接得更顺,资料录入这类场景会省下不少时间。

极度公式
极度公式
Windows/macOS/Linux

极度公式是一款跨平台专业LaTeX公式识别编辑软件,支持OCR公式识别和多平台编辑。和使用说明,避免使用,享受完整功能与稳定支持。做扫描整理、文字提取和表格转换时,它能把识别后的处理步骤接得更顺,资料录入这类场景会省下不少时间。

WINDOWS 更多
Windows 10
Windows 10
Windows

Windows 10 是一款微软推出的经典操作系统,拥有硬件兼容性与多任务处理能力。它更偏向把系统状态查看和常用调节动作放在一起,适合需要持续观察和微调设备状态的场景。

极度公式
极度公式
Windows/macOS/Linux

极度公式是一款跨平台专业LaTeX公式识别编辑软件,支持OCR公式识别和多平台编辑。和使用说明,避免使用,享受完整功能与稳定支持。做扫描整理、文字提取和表格转换时,它能把识别后的处理步骤接得更顺,资料录入这类场景会省下不少时间。

密码键盘
密码键盘
Windows/macOS/iOS/Android

密码键盘是一款兼具安全性与便捷性的高效密码管理器。日常使用里的持续防护和信息管理会更突出,适合把安全控制放进长期使用流程中的场景。