深入理解 C++ STL:stack、queue 与 deque 从使用到底层实现全解析

在 C++ 标准模板库(STL)中,stack(栈)和queue(队列)是开发者最常用的数据结构之一,但很多人仅停留在调用push/pop的表层,对它们的设计思想、底层依赖的deque双端队列,以及背后的数据结构原理知之甚少。

本文将结合手写模拟源码、底层结构原理图与经典算法场景,从使用方法、设计模式、底层数据结构、性能对比、面试考点 五个维度,带你彻底吃透stackqueuedeque的所有技术细节。


一、栈(stack):后进先出的线性表

1.1 核心特性与本质

栈是一种 LIFO(Last In First Out,后进先出) 的线性数据结构:元素只能从**栈顶(top)**一端插入,也只能从栈顶一端删除,就像弹夹压子弹 ------ 最后压入的子弹最先被射出。

需要特别注意:std::stack 并不是独立的容器,而是一个「容器适配器」。它不自己管理内存、存储数据,而是封装另一个底层容器,对外只暴露符合 "栈" 语义的统一接口。

1.2 std::stack 常用接口与使用

STL 中 stack 的核心接口如下表:

成员函数 功能说明
stack() 构造一个空栈
empty() 检测栈是否为空,返回 bool
size() 返回栈中元素个数,类型为 size_t
top() 返回栈顶元素的引用(提供 const 和非 const 两个版本)
push(const T& val) 将元素压入栈顶,底层调用容器的 push_back
pop() 弹出栈顶元素,无返回值,底层调用容器的 pop_back

补充知识 : C++11 之后新增了 emplace() 成员函数,可以直接在栈顶原地构造 元素,避免了临时对象的拷贝构造开销。当栈中存储自定义类对象时,emplace(args...) 直接调用构造函数,性能优于 push(T(args))

此外 stack 不提供迭代器,也不支持遍历 ------ 这是设计上的刻意封装:栈的语义就是只允许操作栈顶,不允许访问中间元素。

基础使用示例:

cpp 复制代码
#include <stack>
#include <iostream>
int main() {
    std::stack<int> st;
    st.push(1);
    st.push(2);
    st.push(3);
    std::cout << st.top(); // 输出 3
    st.pop();
    std::cout << st.top(); // 输出 2
    return 0;
}

1.3 栈的经典算法应用

结合原理图,我们讲解三个栈的高频面试题,这也是图片资料中的核心考点。

(1)最小栈(MinStack)

题目 :设计一个支持 pushpoptop 操作,并能在O (1) 时间内检索到最小元素的栈。

实现思路:使用双栈

  • _elem 栈:存储所有元素,负责正常的入栈出栈
  • _min 栈:存储每一步的当前最小值,栈顶始终是当前栈的最小值

操作规则

  • 压入元素时:如果元素小于等于 _min 栈顶,就同时压入 _min 栈;否则只压入 _elem
  • 弹出元素时:如果弹出的元素等于 _min 栈顶,_min 也同步弹出

画图可以直观看到:依次压入 6、5、3 时,_min 栈会同步压入 6、5、3;弹出 3 时,_min 也弹出 3,此时栈顶最小值变为 5。

拓展优化:还可以用「差值法」只用一个栈 + 一个变量存最小值,进一步节省空间。

cpp 复制代码
class MinStack {
public:
    MinStack() {
        
    }
    
    void push(int value) {
        _elem.push(value);
        if(_min.empty() || value <= _min.top())
        {
            _min.push(value);
        }
    }
    
    void pop() {
        if(_elem.top() == _min.top())
        {
        _min.pop();
        }
        _elem.pop();
        
    }
    
    int top() {
        return _elem.top();
    }
    
    int getMin() {
        return  _min.top();
    }

    private:
    stack<int> _elem;
    stack<int> _min;
};
(2)栈的压入、弹出序列

题目:输入两个整数序列,第一个表示压入顺序,判断第二个是否为该栈的合法弹出顺序。

模拟法思路

  1. 用辅助栈模拟入栈过程,按入栈序列逐个压入
  2. 每次压入后,循环检查栈顶是否等于出栈序列的当前元素
  3. 如果相等就弹出栈顶,出栈索引后移;直到栈空或不相等
  4. 最终如果入栈序列走完但栈不为空,则不是合法序列

画图可以清晰展示:入栈 [1,2,3,4,5]、出栈 [4,5,3,2,1] 的完整匹配过程。

cpp 复制代码
class Solution {
public:    
    bool IsPopOrder(vector<int>& pushV, vector<int>& popV) {
        stack<int> st;
        size_t pushi = 0 , popi = 0;
        while(pushi < pushV.size())
        {
          st.push(pushV[pushi++]);
          while((!st.empty())&&st.top()==popV[popi])
          {
             popi++;
             st.pop();
          }
        }
        return st.empty();
    }
};
(3)逆波兰表达式求值(RPN)

逆波兰表达式又称后缀表达式 ,运算符位于操作数之后,比如 2 1 + 3 * 对应中缀表达式 (2+1)*3

解法思路

  • 遇到数字:直接入栈
  • 遇到运算符:连续弹出两个数(先弹出的是右操作数,后弹出的是左操作数),计算后将结果压回栈中
  • 遍历结束后,栈顶元素就是最终结果

易错点:因为栈是后进先出,后缀表达式中左操作数先出现、先入栈,所以计算时要注意左右操作数的顺序,减法和除法顺序不能反。

cpp 复制代码
class Solution {
public:
    int evalRPN(vector<string>& tokens) {
        stack<int> st;
        for(auto & str : tokens)
        {
            if(str == "+" || str == "-" || str == "*" || str == "/" )
            {
              int right = st.top();
              st.pop();
              int left = st.top();
              st.pop();

              switch(str[0])
              {
               case '+':
               st.push(left + right);
               break;
               case '-':
               st.push(left - right);
               break;
               case '*':
               st.push(left * right);
               break;
               case '/':
              st.push(left / right);
               break;
               default:
               break;
              }
            }
            else
            {
                st.push(stoi(str));
            }
        }
        return st.top();
    }
};

1.4 stack 模拟实现源码解析

我们结合 stack.h 中的手写实现,逐行拆解适配器的本质:

cpp 复制代码
namespace a {
    template<class T, class Container = std::deque<T>>
    class stack {
    public:
        void push(const T& x) { _con.push_back(x); }
        void pop() { _con.pop_back(); }
        size_t size() const { return _con.size(); }
        bool empty() const { return _con.empty(); }
        
        const T& top() const { return _con.back(); }
        T& top() { return _con.back(); }
    private:
        Container _con; // 底层容器
    };
}

代码细节全解析

  1. 双模板参数 :第一个 T 是元素类型;第二个 Container 是底层容器类型,默认使用 std::deque<T>
  2. 纯封装逻辑 :所有成员函数没有任何数据结构逻辑,全部是调用底层容器的对应接口 ------pushpush_backpoppop_backtopback。这就是「适配器」的核心:换接口,不换实现。
  3. const 重载 :提供了 const 和非 const 两个版本的 top(),保证 const 栈对象也能调用,符合 C++ const 成员函数的设计规范。

标准库的 stack 还实现了 swapemplace、移动构造、关系运算符重载等功能,我们的模拟实现是核心简化版。


二、队列(queue):先进先出的线性表

2.1 核心特性与本质

队列是 FIFO(First In First Out,先进先出) 的线性数据结构:元素从队尾(back) 入队,从**队头(front)**出队,和现实中排队的逻辑完全一致。

stack 一样,std::queue 同样是容器适配器 ,默认底层容器也是 deque

2.2 std::queue 常用接口与使用

核心接口如下表:

成员函数 功能说明
queue() 构造空队列
empty() 检测队列是否为空
size() 返回队列中元素个数
front() 返回队头元素的引用
back() 返回队尾元素的引用
push(const T& val) 队尾入队
pop() 队头出队

和栈一样,队列也不提供迭代器,不支持遍历;同样支持 emplace 原地构造优化。

2.3 队列的经典应用:二叉树层序遍历(BFS)

队列最典型的应用就是广度优先搜索(BFS),最基础的就是二叉树的层序遍历 ------ 按层次从上到下、从左到右访问所有节点。

算法步骤

  1. 根节点先入队
  2. 每次记录当前层的节点数 size,循环 size 次:依次出队节点、访问节点、将节点的左右孩子入队
  3. 队列为空时遍历结束

画图清晰展示了一棵三层二叉树的层序遍历过程,每一层的节点都会批量入队、批量出队,保证按层访问。

cpp 复制代码
class Solution {
public:
    vector<vector<int>> levelOrder(TreeNode* root) {
        vector<vector<int>> res;
        if(root == nullptr) return res; // 空树直接返回
        
        queue<TreeNode*> q;
        q.push(root);

        while(!q.empty())
        {
            int size = q.size(); // 当前这一层一共有多少个节点
            vector<int> curLevel;     // 保存本层结果

            // 遍历当前层所有节点
            for(int i = 0; i < size; ++i)
            {
                TreeNode* node = q.front();
                q.pop();
                curLevel.push_back(node->val);

                // 把左右孩子入队(下一层)
                if(node->left != nullptr)
                    q.push(node->left);
                if(node->right != nullptr)
                    q.push(node->right);
            }
            res.push_back(curLevel); // 将本层加入总结果
        }
        return res;
    }
};

2.4 queue 模拟实现源码解析

结合 queue.h 的代码:

cpp 复制代码
namespace b {
    template<class T, class Container = std::deque<T>>
    class queue {
    public:
        void push(const T& x) { _con.push_back(x); }
        void pop() { _con.pop_front(); }
        size_t size() const { return _con.size(); }
        bool empty() const { return _con.empty(); }
        
        const T& front() const { return _con.front(); }
        T& front() { return _con.front(); }
        const T& back() const { return _con.back(); }
        T& back() { return _con.back(); }
    private:
        Container _con;
    };
}

与 stack 的核心区别

  • pop() 调用的是底层的 pop_front()(头删),而 stackpop_back()(尾删)------ 这正是 FIFO 和 LIFO 的本质差异。
  • 多了 front()back() 两个接口,分别访问队头和队尾;而栈只有 top() 一个访问接口。

思考题:为什么 vector 不能做 queue 的底层容器?

答:因为 vector 没有高效的头删操作,头部删除需要搬移所有元素,时间复杂度 O (n);而 dequelist 的头删都是 O (1)。

三、拓展:优先级队列(priority_queue)

它是 STL 重要的容器适配器。

3.1 本质与特性

priority_queue 本质是堆(heap) ,默认是大顶堆 (堆顶始终是最大元素)。它也是容器适配器,默认底层容器是 vector------ 因为堆结构需要随机访问来快速计算父子节点位置。

3.2 大小堆控制

  • 默认使用 less<T> 比较器,构造大顶堆
  • 如需小顶堆,需要手动指定第三个模板参数为 greater<T>
cpp 复制代码
std::priority_queue<int> big_heap;                  // 默认大顶堆
std::priority_queue<int, std::vector<int>, std::greater<int>> small_heap; // 小顶堆

自定义类型放入优先级队列,需要重载 <> 运算符,或者自定义仿函数作为比较器。

3.3 经典应用:TopK 问题

求数组中第 K 大的元素是堆的经典场景:

  • 方案 1:全部元素放入大顶堆,弹出 K-1 次,堆顶即为第 K 大,时间 O (n log n)
  • 方案 2:维护 K 个元素的小顶堆,遍历数组,比堆顶大就替换,最终堆顶就是第 K 大,时间 O (n log k),适合海量数据
cpp 复制代码
class Solution {
public:
    int findKthLargest(vector<int>& nums, int k) {
        priority_queue<int> pq(nums.begin(),nums.end());
        while(--k)
        {
            pq.pop();
        }
        return pq.top();
    }
};

四、容器适配器与适配器设计模式

4.1 什么是适配器模式

适配器模式是一种结构型设计模式 ,核心作用是:将一个类的接口转换成客户希望的另一个接口,让原本接口不兼容的类可以协同工作

生活中最典型的例子就是电源适配器:欧洲插座和美国插头规格不兼容,通过适配器转换接口就能正常使用。STL 的容器适配器也是同理 ------ 把 deque/vector/list 的通用接口,转换成栈、队列、优先级队列的特定语义接口。

4.2 三个适配器的底层容器要求

不是所有容器都能作为适配器的底层,必须满足对应的接口要求:

适配器 要求的底层接口 可用底层容器 默认底层容器
stack push_backpop_backback vector、list、deque deque
queue push_backpop_frontfrontback list、deque deque
priority_queue 随机访问、push_backpop_back vector、deque vector

4.3 为什么 stack 和 queue 默认选择 deque

这是高频面试题,核心原因有四点:

  1. 不需要遍历:栈和队列本身不提供迭代器,完美避开了 deque 遍历效率低的缺点。
  2. 扩容开销小:deque 扩容只需要新增缓冲区、修改中控指针,不需要像 vector 那样搬移全部元素。
  3. 两端操作高效:队列需要头删尾插,deque 两端操作都是 O (1),远优于 vector 的头删 O (n)。
  4. 内存利用率高:对比 list,deque 是分段连续存储,不需要额外存储前后指针,空间浪费更少。

一句话总结:deque 刚好结合了 vector 和 list 的优点,又完美避开了它们的缺点,是栈和队列底层的最优解。


五、deque 深层原理:分段连续的双端队列

deque(double-ended queue,双端队列)是 STL 中最复杂的容器之一,也是理解栈和队列的关键。

5.1 整体结构:分段连续 + 中控器

deque 对外表现得像 "连续数组",但底层实际上是分段连续的,由两部分组成:

  1. 缓冲区(buffer):真正存储数据的连续内存块,每个缓冲区大小固定。
  2. 中控器(map) :一个指针数组(二级指针 T**),每个元素指向一个缓冲区的首地址,负责管理所有缓冲区。

当缓冲区数量增多、map 空间不足时,中控器会扩容 ------ 重新分配更大的指针数组,拷贝原有指针,不需要搬移任何数据,这是 deque 扩容效率高于 vector 的核心原因。

5.2 迭代器:模拟连续空间的核心

deque 能支持随机访问、让用户感觉 "空间连续",全部秘密都在它的迭代器设计上。deque 的迭代器是一个类,包含 4 个核心成员:

cpp 复制代码
struct __deque_iterator {
    T* cur;          // 当前指向的元素
    T* first;        // 当前缓冲区的起始地址
    T* last;         // 当前缓冲区的末尾地址(超尾)
    map_pointer node;// 指向中控器中当前缓冲区的指针
};

迭代器工作原理

  • operator++ 时:先 cur++,如果 cur == last(走到当前缓冲区末尾),就 node++ 切换到下一个缓冲区,再把 cur 置为下一个缓冲区的 first
  • 随机访问 operator+= 时:先计算偏移量,如果在当前缓冲区内就直接移动;如果跨缓冲区,就计算跨越的缓冲区数量,移动 node 指针,再定位到对应位置。

正是因为迭代器封装了跨缓冲区的复杂逻辑,用户才能像操作连续数组一样使用 deque。但也因为每次移动都要做边界检查,deque 迭代器的遍历效率远低于 vector 的原生指针。

5.3 核心操作的底层逻辑

  • 尾插 push_back:末尾缓冲区有空间就直接放;没空间就申请新缓冲区,加到 map 末尾。
  • 头插 push_front:开头缓冲区有前置空间就直接放;没空间就申请新缓冲区,加到 map 开头。

这就是 deque 头尾插入都是 O (1) 的原因 ------ 不需要搬移现有元素,只需要新增缓冲区。

注意:deque 中间位置的插入删除是 O (n) 的,因为需要搬移元素,且分段结构让搬移更复杂,所以 deque 不适合频繁中间操作。

5.4 性能测试深度分析

测试 1:vector 与 deque 的 sort 效率对比

test_op1 对 100 万个元素的 vector 和 deque 分别排序,结果是 vector 排序远快于 deque

根本原因

  1. CPU 缓存与空间局部性:vector 是完全连续内存,遍历时 CPU 缓存可以预取后续元素,缓存命中率极高;deque 分段存储,跨缓冲区时缓存失效,需要从内存重新加载,速度大幅下降。
  2. 迭代器开销:vector 迭代器就是原生指针,零开销;deque 迭代器每次移动都要做边界判断,额外增加了很多指令。
测试 2:deque 直接排序 vs 拷贝到 vector 再排序

test_op2 对比了两种方案:直接对 deque 排序;把 deque 拷贝到 vector → 排序 → 拷回 deque。 结果反而是第二种方案更快

这验证了上面的结论:虽然多了两次拷贝,但 vector 排序的效率提升远远超过了拷贝的开销。这也是实际开发中的优化技巧:如果需要对 deque 做大量排序 / 遍历,可以先转成 vector 处理再转回。

cpp 复制代码
void test_op1()
{
	srand(time(0));
	const int N = 1000000;

	deque<int> dq;
	vector<int> v;

	for (int i = 0; i < N; ++i)
	{
		auto e = rand() + i;
		v.push_back(e);
		dq.push_back(e);
	}

	int begin1 = clock();
	sort(v.begin(), v.end());
	int end1 = clock();

	int begin2 = clock();
	sort(dq.begin(), dq.end());
	int end2 = clock();

	printf("vector:%d\n", end1 - begin1);
	printf("deque:%d\n", end2 - begin2);
}

void test_op2()
{
	srand(time(0));
	const int N = 1000000;

	deque<int> dq1;
	deque<int> dq2;

	for (int i = 0; i < N; ++i)
	{
		auto e = rand() + i;
		dq1.push_back(e);
		dq2.push_back(e);
	}

	int begin1 = clock();
	sort(dq1.begin(), dq1.end(), less<int>());
	int end1 = clock();

	int begin2 = clock();
	// vector
	vector<int> v(dq2.begin(), dq2.end());
	sort(v.begin(), v.end());
	dq2.assign(v.begin(), v.end());
	int end2 = clock();

	printf("deque sort:%d\n", end1 - begin1);
	printf("deque copy vector sort, copy back deque:%d\n", end2 - begin2);
}

int main()
{
    test_op1();
	test_op2();

	return 0;
}

5.5 deque 优缺点总结

维度 优点 缺点
头尾操作 两端插入删除都是 O (1) 中间插入删除 O (n),效率低
随机访问 支持 [] 随机访问 效率低于 vector,需要计算缓冲区跳转
扩容开销 扩容仅需新增缓冲区、拷贝中控指针,开销小 中控器和缓冲区存在一定空间浪费
遍历效率 --- 遍历效率低,缓存命中率差,迭代器边界检查开销大
内存布局 分段连续,内存碎片少 非真正连续,不适合传给要求连续数组的 C 接口

六、高频面试题总结

  1. stack 和 queue 是容器吗?默认底层是什么? 答:不是容器,是容器适配器。stack 和 queue 默认底层是 deque,priority_queue 默认是 vector。
  2. 为什么 stack/queue 默认用 deque 不用 vector? 答:queue 需要头删,vector 头删效率极低;deque 扩容不需要搬移全部元素,开销更小;两端 O (1) 操作完美适配栈和队列的语义。
  3. deque 的底层原理是什么? 答:由多段固定大小的缓冲区和一个中控器(指针数组)组成。迭代器封装了跨缓冲区逻辑,对外模拟连续空间。支持两端 O (1) 插入删除,随机访问和遍历效率低于 vector。
  4. 用两个栈实现队列 / 两个队列实现栈
    • 栈实现队列:一个入栈负责入队,一个出栈负责出队;出栈空时把入栈所有元素倒过来。
    • 队列实现栈:入栈时倒腾队列,让新元素在队头。
cpp 复制代码
//栈实现队列
class MyQueue {
public:
    MyQueue() {
        
    }
    
    void push(int x) {
        instack.push(x);
    }

    void transfer()
    {
        if(outstack.empty())
      {
        while(!instack.empty())
        {
            outstack.push(instack.top());
            instack.pop();
        }
      }
    }
    
    int pop() {
      transfer();
      int i = outstack.top();
        outstack.pop();
        return i;
    }
    
    int peek() {
        transfer();
        return outstack.top();
    }
    
    bool empty() {
        return instack.empty() && outstack.empty();
    }
    private:
    stack<int> instack;
    stack<int> outstack;
};
cpp 复制代码
//队列实现栈
class MyStack {
public:
    MyStack() {
        
    }
    
    void push(int x) {
        q.push(x);
        for(int i = 0; i < q.size() - 1; ++i )
        {
            q.push(q.front());
            q.pop();
        }
    }
    
    int pop() {
        int top = q.front();
        q.pop();
        return top;
    }
    
    int top() {
        return q.front();
    }
    
    bool empty() {
        return q.empty();
    }

    private:
    queue<int> q;

};

最后

stackqueue 看似简单,背后却藏着适配器设计模式的思想,以及 deque 复杂的分段存储结构。理解这些底层原理,不仅能让我们更精准地选择和使用 STL 容器,也能在面试中建立更深的技术优势。

相关推荐
Highcharts.js42 分钟前
甘特图纵向任务、横向时间显示图表可视化开发|Highcharts甘特图示例
开发语言·前端·javascript·可视化·甘特图·数据可视化·highcharts
wuminyu1 小时前
JVM锁膨胀与Futex源码解析
java·linux·c语言·jvm·c++
郝学胜-神的一滴1 小时前
C++11 工程级应用 08:Lambda表达式与Tuple元组
开发语言·jvm·c++·python·程序人生·开源
sylviiiiiia1 小时前
Leetcode hot100 多数元素/相交链表/反转链表
算法·leetcode·链表
CoderYanger1 小时前
A.每日一题:3622. 判断整除性
java·程序人生·算法·leetcode·面试·职场和发展·蓝桥杯
吴声子夜歌1 小时前
ApacheCommons——commons-lang3(Java 基础语言增强)(二)
java·开发语言·apache
传奇开心果编程1 小时前
【Rust入门知识点学与练】第1课变量与打印
开发语言·学习·rust
rannn_1111 小时前
【力扣hot100】动态规划专题|70、118、198、279、322、139、300、152、416、32
算法·leetcode·动态规划
渡之1 小时前
ArduPilot(APM)滤波器之 HarmonicNotchFilter 深度解析
算法·无人机