当前位置:

首页 > 编程开发 > C++ STL实现队列与栈方法

C++ STL实现队列与栈方法

C++中使用std::stack和std::queue适配器可高效实现栈和队列,二者默认以std::deque为底层容器,提供语义清晰、类型安全的接口,并支持替换底层容器以优化性能;在多线程环境下需通过互斥锁等机制确保线程安全。

C++中使用std::stack和std::queue适配器可高效实现栈和队列,二者默认以std::deque为底层容器,提供语义清晰、类型安全的接口,并支持替换底层容器以优化性能;在多线程环境下需通过互斥锁等机制确保线程安全。

C++如何使用STL容器实现队列和栈

在C++中,要实现队列(Queue)和栈(Stack)这两种基本的数据结构,最简洁、最符合现代C++实践的方式就是利用标准模板库(STL)中提供的适配器容器std::queuestd::stack。它们并不是全新的数据结构,而是将现有的序列容器(如std::dequestd::liststd::vector)封装起来,只暴露队列或栈特有的操作接口,从而保证了操作的语义正确性。

解决方案

使用std::queuestd::stack非常直观,它们默认都以std::deque(双端队列)作为底层容器,因为std::deque在两端插入和删除元素都非常高效。

实现栈 (Stack)

栈是一种“后进先出”(LIFO, Last In, First Out)的数据结构。std::stack提供了push(入栈)、pop(出栈)、top(查看栈顶元素)、empty(判断是否为空)和size(获取元素数量)等操作。

#include 
#include  // 包含stack头文件
#include 

void demonstrateStack() {
    std::stack myStack;

    // 入栈操作
    myStack.push("Apple");
    myStack.push("Banana");
    myStack.push("Cherry");
    std::cout << "当前栈顶元素: " << myStack.top() << std::endl; // Cherry

    // 栈的大小
    std::cout << "栈中元素数量: " << myStack.size() << std::endl; // 3

    // 遍历并出栈
    std::cout << "开始出栈: " << std::endl;
    while (!myStack.empty()) {
        std::cout << "弹出: " << myStack.top() << std::endl;
        myStack.pop();
    }
    std::cout << "栈是否为空? " << (myStack.empty() ? "是" : "否") << std::endl; // 是
}

实现队列 (Queue)

队列是一种“先进先出”(FIFO, First In, First Out)的数据结构。std::queue提供了push(入队)、pop(出队)、front(查看队头元素)、back(查看队尾元素)、empty(判断是否为空)和size(获取元素数量)等操作。

#include 
#include  // 包含queue头文件
#include 

void demonstrateQueue() {
    std::queue myQueue;

    // 入队操作
    myQueue.push(10);
    myQueue.push(20);
    myQueue.push(30);
    std::cout << "当前队头元素: " << myQueue.front() << std::endl; // 10
    std::cout << "当前队尾元素: " << myQueue.back() << std::endl;  // 30

    // 队列的大小
    std::cout << "队列中元素数量: " << myQueue.size() << std::endl; // 3

    // 遍历并出队
    std::cout << "开始出队: " << std::endl;
    while (!myQueue.empty()) {
        std::cout << "出队: " << myQueue.front() << std::endl;
        myQueue.pop();
    }
    std::cout << "队列是否为空? " << (myQueue.empty() ? "是" : "否") << std::endl; // 是
}

将这两个示例代码放在main函数中调用,就能清晰地看到它们的工作方式。

为什么STL选择提供适配器容器,而不是直接使用序列容器?

这是一个很棒的问题,它触及了软件设计中的一个核心原则:接口与实现的分离。STL之所以提供std::stackstd::queue作为容器适配器,而不是直接让开发者去用std::vectorstd::list模拟,主要有几个深层考量:

首先,语义清晰性。当你看到代码中使用了std::stack,你立刻就知道这个数据结构遵循LIFO原则,它的操作(push、pop、top)也严格限定在栈的语义范围内。如果直接用std::vector,虽然可以通过push_backpop_back实现栈,但std::vector还提供了operator[]inserterase等大量不符合栈语义的操作。这会模糊代码意图,也可能导致误用,比如不小心从中间插入或删除元素,从而破坏了栈的结构。适配器强制了接口,确保了数据结构的行为符合其定义。

其次,类型安全和约束。适配器容器通过封装底层容器,只暴露了特定数据结构所需的操作。这是一种“最少知识原则”的应用。它降低了出错的可能性,因为你无法执行不属于栈或队列的操作。这在大型项目或团队协作中尤为重要,它为开发者提供了一个明确且受限的接口,减少了心智负担和潜在的bug。

再者,底层实现的可替换性std::stackstd::queue允许你指定不同的底层序列容器(如std::dequestd::list、甚至std::vector——尽管std::vector作为std::queue的底层容器效率不高)。这意味着,如果未来你发现默认的std::deque在特定场景下性能不佳,你可以不改变上层逻辑(即栈或队列的操作语义),仅仅通过修改模板参数就能切换到底层容器,比如换成std::list。这种灵活性是直接使用单一序列容器无法比拟的。它将“我需要一个栈”和“这个栈应该如何高效地实现”这两个问题解耦了。

所以,与其说STL“没有直接使用”,不如说它提供了一个更高层次的抽象,让开发者能够以更清晰、更安全、更灵活的方式使用这些经典数据结构。这是一种设计上的优雅,它让我们的代码不仅能工作,而且能更好地表达意图。

自定义底层容器:如何选择最适合你的场景?

虽然std::stackstd::queue默认使用std::deque作为底层容器,但在某些特定场景下,我们可能需要根据性能特征来选择std::liststd::vector。理解它们的优缺点对于做出明智的选择至关重要。

1. std::deque (默认选择)

  • 优点: std::deque(双端队列)在两端(头部和尾部)插入和删除元素都具有常数时间复杂度O(1)。这是因为std::deque在内部通常实现为一系列固定大小的块,这些块的指针存储在一个映射表中,使得它能高效地扩展。这对于栈(只在一端操作)和队列(两端操作)都是理想的。
  • 缺点: 相比std::vectorstd::deque的元素不保证存储在连续的内存空间中,这可能导致缓存局部性稍差,在某些对缓存敏感的场景下性能略逊。随机访问(通过operator[])虽然也是O(1),但通常比std::vector慢一点。
  • 适用场景: 绝大多数情况下,std::deque都是std::stackstd::queue的最佳默认选择。如果你不确定,就用它。

2. std::list

  • 优点: std::list(双向链表)在任何位置插入和删除元素都具有常数时间复杂度O(1),因为它只需要修改前后节点的指针。对于栈和队列,这意味着pushpop操作效率极高,且不会导致内存重新分配和元素拷贝(这是std::vector可能面临的问题)。
  • 缺点: std::list不支持随机访问(例如operator[]),访问元素需要O(n)的时间复杂度。每个元素都需要额外的内存来存储前后节点的指针,导致内存开销较大。缓存局部性极差,因为元素在内存中不连续。
  • 适用场景:
    • 栈: 如果你的栈需要存储大量元素,并且你非常关心每次push/pop操作的稳定常数时间性能,且内存重新分配的开销是不可接受的,std::list可能是一个选择。
    • 队列: std::list是实现队列的优秀选择,因为它在头部和尾部插入/删除都非常高效。如果你的队列元素数量变化频繁,且对内存分配和性能的稳定性有较高要求,可以考虑std::list
    • 避免: 如果你需要频繁地查看队列或栈的中间元素,或者对内存使用有严格限制,std::list不是一个好选择。

3. std::vector

  • 优点: std::vector(动态数组)的元素存储在连续的内存空间中,具有极佳的缓存局部性,随机访问效率最高(O(1))。
  • 缺点:
    • 作为栈的底层: std::vector作为栈的底层容器是高效的,因为push_backpop_back(对应栈的pushpop)通常是O(1)摊销时间。但当容量不足时,push_back会导致内存重新分配和所有元素的拷贝,这会带来O(n)的开销。
    • 作为队列的底层: std::vector作为队列的底层容器效率很低。push操作(push_back)是高效的,但pop操作(从头部删除元素)需要移动所有后续元素,其时间复杂度为O(n)。这使得std::vector不适合作为std::queue的底层容器。
  • 适用场景:
    • 栈: 如果你对栈的内存连续性有要求(例如,为了更好的缓存性能),并且可以接受偶尔的O(n)重新分配开销,或者你知道栈的最大容量并能预先reserve,那么std::vector可以作为std::stack的底层容器。
    • 避免: 绝对不要将std::vector作为std::queue的底层容器,除非你的队列几乎从不执行pop操作。

如何指定底层容器:

#include 
#include 
#include  // 用于作为底层容器
#include    // 用于作为底层容器
#include   // 默认,但也可以显式指定

// 使用std::list作为底层容器的栈
std::stack> myStackWithList;

// 使用std::vector作为底层容器的栈
// 注意:std::vector作为栈的底层,其push_back和pop_back操作是高效的
std::stack> myStackWithVector;

// 使用std::list作为底层容器的队列
std::queue> myQueueWithList;

// 使用std::deque作为底层容器的队列(显式指定,与默认行为一致)
std::queue> myQueueWithDeque;

选择合适的底层容器,需要根据你的具体需求(性能、内存、操作模式)进行权衡。大多数时候,std::deque的平衡性使其成为默认的最佳选择,但了解其他选项能帮助你在特定瓶颈出现时进行优化。

在多线程环境中,STL队列和栈的线程安全问题及初步考量

当我们将STL的std::queuestd::stack引入多线程编程时,一个非常关键且必须严肃对待的问题就是线程安全性。简单来说,std::queuestd::stack本身并不是线程安全的。这意味着,如果多个线程同时对同一个队列或栈进行pushpoptop/front/backsize等操作,就可能发生数据竞争(data race),导致未定义行为。这包括但不限于数据损坏、程序崩溃、或者得到错误的结果。

为什么它们不是线程安全的?

STL容器的设计哲学是“提供高效且通用的数据结构,将线程安全留给用户去实现”。容器内部的操作,例如在push时修改内部指针、分配内存,或者在pop时更新状态,这些都不是原子操作。多个线程同时执行这些非原子操作时,它们的执行顺序是不可预测的,可能导致内部状态处于不一致的中间状态。

举个例子,一个线程正在push一个元素,它可能已经分配了内存并准备将数据写入,但还没来得及更新容器的大小或尾部指针。此时,另一个线程尝试pop,它可能读取到一个过时的大小,或者尝试访问一个尚未完全写入的元素,甚至访问到无效内存。

如何实现线程安全?

要让std::queuestd::stack在多线程环境下安全地工作,我们通常需要引入同步机制。最常见的方法是使用互斥锁(std::mutex)。

一个基本的线程安全队列或栈通常会是这样的:

#include 
#include 
#include  // 对于阻塞队列/栈很有用
#include           // 用于异常处理

// 示例:一个简单的线程安全队列
template 
class ThreadSafeQueue {
private:
    std::queue q;
    mutable std::mutex mtx; // mutable allows const methods to lock
    std::condition_variable cv; // 用于等待和通知

public:
    void push(const T& value) {
        std::lock_guard lock(mtx); // 自动加锁解锁
        q.push(value);
        cv.notify_one(); // 通知一个等待的线程有新数据了
    }

    T pop() {
        std::unique_lock lock(mtx); // 允许在等待时解锁
        // 等待直到队列不为空
        cv.wait(lock, [this]{ return !q.empty(); });
        T value = q.front();
        q.pop();
        return value;
    }

    bool try_pop(T& value) { // 非阻塞式pop
        std::lock_guard lock(mtx);
        if (q.empty()) {
            return false;
        }
        value = q.front();
        q.pop();
        return true;
    }

    bool empty() const {
        std::lock_guard lock(mtx);
        return q.empty();
    }

    size_t size() const {
        std::lock_guard lock(mtx);
        return q.size();
    }
};

// 线程安全栈的实现类似,只是操作接口不同

初步考量和注意事项:

  1. 互斥锁的粒度: 上述示例中,每个操作都加锁,这是最直接的实现方式。但如果操作非常频繁且锁竞争激烈,这可能成为性能瓶颈。在某些高级场景中,可能会考虑更细粒度的锁,或者无锁(lock-free)数据结构,但这会显著增加实现的复杂性。
  2. 阻塞与非阻塞: pop操作可能需要等待队列中有元素。上面的pop方法是阻塞的(使用std::condition_variable),即如果没有元素,调用线程会等待直到有元素可用。你也可以提供一个非阻塞的try_pop方法,如果队列为空则立即返回一个指示(例如false)。
  3. 异常安全: 确保在操作过程中抛出异常时,锁能够正确释放。std::lock_guardstd::unique_lock是RAII(Resource Acquisition Is Initialization)的典范,它们能够自动管理锁的生命周期,非常适合处理异常。
  4. top()/front()的陷阱: 如果一个线程调用top()front()获取了栈顶/队头元素,但在它使用这个元素之前,另一个线程执行了pop操作,那么之前获取的元素可能已经失效,或者它所指向的内存已被释放或重用。因此,通常建议将pop和获取元素合并为一个操作,或者确保在获取元素后立即使用。我的pop示例就是返回了值,避免了这个问题。
  5. 死锁风险: 如果你的线程安全容器与其他锁或资源交互,需要警惕死锁的风险。始终保持一致的加锁顺序,或者使用std::lock配合std::adopt_lock等高级技术。

在实际项目中,尤其是在高性能或高并发场景,直接手写一个完美无缺的线程安全容器是很有挑战的。通常,我们会倾向于使用成熟的并发库,例如Intel TBB (Threading Building Blocks) 中的concurrent_queue,或者Boost库中的并发容器,它们提供了经过充分测试和优化的线程安全数据结构。但理解其底层原理和如何使用std::mutexstd::condition_variable来构建,是掌握现代C++并发编程的基础。

本文内容来源于互联网,如有侵权请联系删除。
作者最新文章
编程开发
相关文章 更多
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字符集编码,这导致了一个直接的问题:当文件中包含非拉丁字符(如中文、日文、韩文等)时,

一个 memwatch 实战案例:定位野指针问题
一个 memwatch 实战案例:定位野指针问题

内存监控工具的价值与挑战在软件开发,尤其是使用C/C++这类手动管理内存的语言时,内存错误是程序员最常遭遇的难题之一。其中,野指针问题因其隐蔽性和破坏性,往往成为最难定位的“幽灵”缺陷。它可能潜伏在代码中,在特定条件下才被触发,导致程序崩溃、数据损坏或难以预测的行为。传统的调试手段,如打印日志或使用

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

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

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

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