栈和队列,可能是C++数据结构教材里最早出现的两个概念,也是面试里被问得最不会出错的“送分题”。但真实项目里,这两个结构常常被用错:有人拿list硬扛队列,有人以为priority_queue是“有序队列”,还有人把数据结构里的栈和系统调用栈当成两个东西。这篇文章我换个讲法,不堆定义,从它们要解决的问题出发,把原理、手写实现、STL内部结构、工程场景和常见坑一次说清楚。不管你是刚开始学C++数据结构,还是准备面试想快速系统过一遍,都可以照着这篇文章的节奏往下走。
1. 先从两个生活场景说起:什么是栈,什么是队列
1.1 栈的本质:只能从一端进出的线性表
想象你往一个开口的箱子里叠盘子,先放进去的盘子在最底下,最后放进去的盘子在最上面,而你只能从最上面拿盘子。这个过程就是“后进先出”,英文缩写LIFO(Last In First Out)。
栈这个数据结构,抽象出来就是三个核心操作:push把元素压到栈顶,pop把栈顶元素弹出去,top只看一眼栈顶元素但不移除。你永远只能访问栈顶,其他位置的元素对使用者来说是不可见的。
很多人刚学的时候不理解:为什么非要搞得这么“不方便”?什么都能访问不是更灵活吗?
这里恰恰是栈的精髓。限制操作范围不是缺陷,而是安全。栈把“能操作的可能性”压缩到只有栈顶这一个位置,于是所有涉及到顺序回溯、嵌套、撤销的逻辑都可以放心地交给它,因为状态变化完全可预测。工程上最怕的就是代码路径不可预测,而栈这种“受限访问”天然给使用者降低了心智负担。
1.2 队列的本质:一端进、另一端出的公平顺序
队列的逻辑正好相反:先进先出(FIFO,First In First Out)。食堂打饭就是最典型的例子——先到先得,后来的人排在队尾,谁也不许插队。
队列同样有两个端点,但这两个端点的职责明确:数据从队尾进入,从队头离开。入队操作一般叫push或者enqueue,出队操作叫pop或者dequeue。
队列的价值在于维护“顺序的公平性”。打印任务、进程调度、消息处理,这些场景都要求“先请求的先被处理”。如果你一味地追求“快”而允许插队,很多业务场景就会出问题。比如在线售票系统,如果一个后来的人抢到了本该属于前面人的票,那就是严重的逻辑事故。队列的意义,就是通过结构本身把这种事故排除掉。
1.3 教材把它们放在一起,不是偶然
很多教材把栈和队列放在同一章,不只是因为它们都“简单”,而是因为它们在抽象层面有一个共同身份:都是线性表,区别只在于操作位置受了限制。
线性表本身可以是数组、可以是链表,元素排成一条线。一旦你规定“只能在一端操作”,它就变成了栈;一旦你规定“队尾进、队头出”,它就变成了队列。这个“通过限制操作来定义结构”的思路,在计算机科学里到处都是:接口越窄,调用方越不容易犯错。数据库的权限、操作系统的内核态用户态、编程语言里的访问控制,本质上都在做同一件事——把不必要的可能性关进笼子里。
理解了这一层,栈和队列就不再是死的定义,而是一种解决问题的思维模型。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手造轮子:从零实现一套可用的栈和队列
2.1 用C++模板实现动态栈
理论讲得再多,不写代码等于白学。我们先写一个能用的栈,要求它支持任意类型、自动扩容、基本操作齐全。
cpp复制template <typename T>
class MyStack {
private:
T* arr;
int capacity;
int topIdx;
public:
MyStack() : capacity(4), topIdx(-1) {
arr = new T[capacity];
}
~MyStack() { delete[] arr; }
void push(const T& val) {
if (topIdx + 1 == capacity) {
capacity *= 2;
T* newArr = new T[capacity];
for (int i = 0; i <= topIdx; ++i) {
newArr[i] = arr[i];
}
delete[] arr;
arr = newArr;
}
arr[++topIdx] = val;
}
void pop() {
if (!empty()) --topIdx;
}
T& top() { return arr[topIdx]; }
bool empty() const { return topIdx == -1; }
int size() const { return topIdx + 1; }
};
这个实现里有几个细节值得说。
第一,为什么用模板?因为栈本身只关心“存进去再取出来”,不关心元素的具体类型。用模板能让一份代码同时支持int、string、自定义类,避免重复造轮子。
第二,为什么topIdx初始化为-1?这样空栈时topIdx为-1,push时先++再赋值,逻辑上最直观。如果你初始化为0,判断空和满的条件会复杂很多。
第三,为什么扩容要翻倍而不是加1?因为每次push都搬迁整个数组,如果容量只加1,连续push n个元素会触发O(n)次搬迁,整体复杂度退化到O(n^2)。翻倍扩容让搬迁次数变成对数级,均摊下来每次push还是O(1)。这也是std::vector底层扩容策略的核心逻辑。
第四,这个简版代码最大的问题是没处理拷贝构造和赋值操作符。因为类里有裸指针,默认的浅拷贝会导致两个对象指向同一块内存,析构时double free,程序直接崩溃。生产环境要么用std::vector
2.2 为什么多数场景下不建议用链表写队列
教材里经常用链表实现队列,目的是训练指针操作和结构体使用。但如果你在工程里也这么干,大概率会踩到性能坑。
链表队列的每个节点除了存数据,还要存一个next指针。对于int这种4字节的小对象,一个节点里指针就占了8字节,内存开销直接翻倍。更麻烦的是,频繁入队出队意味着频繁new和delete节点,内存分配器在高频调用下会产生开销和碎片,表现就是程序越来越慢,内存占用却不见减少。
从CPU缓存的角度看,数组是连续内存,加载相邻元素时缓存命中率极高;链表节点散落在内存各处,每次访问都可能触发缓存未命中,数据量一大,差距会非常明显。
所以我的建议是:默认情况下,用数组或环形队列实现队列;只有当元素本身很大、对象的生命周期差异极大,或者你需要实现无锁队列这种特殊数据结构的候,才考虑链表。教科书里的链表队列是用来学思想的,不是用来直接抄进项目的。
2.3 环形队列:为什么需要浪费一个位置
用数组实现普通队列会遇到一个尴尬问题:front出队后,前面的空位就浪费了。比如容量是8的数组,你入队8个元素再出队8个元素,front和rear都跑到末尾,数组看起来“满了”,其实前面的空间全空着。
解决办法是环形队列。把数组头尾相接,当tail到达数组末尾时,取模回到开头继续用。
cpp复制template <typename T>
class CircleQueue {
private:
T* data;
int capacity;
int head;
int tail;
public:
CircleQueue(int cap) : capacity(cap), head(0), tail(0) {
data = new T[capacity];
}
~CircleQueue() { delete[] data; }
bool empty() const { return head == tail; }
bool full() const { return (tail + 1) % capacity == head; }
bool push(const T& val) {
if (full()) return false;
data[tail] = val;
tail = (tail + 1) % capacity;
return true;
}
bool pop() {
if (empty()) return false;
head = (head + 1) % capacity;
return true;
}
T& front() { return data[head]; }
int size() const { return (tail - head + capacity) % capacity; }
};
这个实现的重点在于“空”和“满”的判断。空状态是head == tail,满状态是(tail + 1) % capacity == head,也就是说永远空出一个位置不用。为什么要故意浪费一格?因为如果不浪费,满的时候head也等于tail,那时候“空”和“满”就没法区分了,只能额外加一个count变量来判断。浪费一格空间,换来的是更简洁的判断逻辑,而这种逻辑在处理线程池任务队列、网络缓冲区时非常常见——队列满了要么阻塞要么返回失败,比多维护一个计数器更可控。
3. STL里的栈和队列:容器适配器与底层真相
3.1 为什么说stack和queue不是“独立容器”
很多新手翻开STL源码,看到std::stack的定义时会愣一下:
cpp复制template<class T, class Container = std::deque<T>>
class stack;
原来stack的第二个模板参数是一个容器类型,而且有默认值deque。也就是说,std::stack本身并不像vector那样自己管理内存,它只是把另一个容器包了一层,对外只暴露栈的接口。
这就是“容器适配器”的含义。适配器就像一个转接头:USB-C转HDMI的转接头自己不产生信号,它只是让原本不适配的设备连接起来。std::stack把deque的接口“裁剪”成只有push、pop、top、empty、size这几个方法,内部存储完全由底层容器负责。
这也解释了一个经典问题:为什么std::stack不提供迭代器?因为栈不允许你遍历内部元素——一旦能遍历,破坏“只能操作栈顶”这个核心约束,栈就不成其为栈了。适配器通过隐藏内部实现来保证数据结构语义不被破坏,这正是封装的意义。
3.2 deque为什么是默认底层容器
既然stack和queue都是适配器,底层的选择就很重要。默认的deque是“双端队列”的缩写,它在结构上很有意思:由一段段连续空间拼接而成,通过一个中控器记录各段空间的地址。
deque能做到vector做不到的事:在头部插入和删除也是O(1)。这让它成为stack和queue的理想底层。vector没有pop_front,在头部插入是O(n),根本不适合做队列;list虽然头部尾部操作都是O(1),但内存不连续、节点开销大,做栈时性能也不如deque。
| 容器 | 尾部插入 | 头部插入 | 随机访问 | 内存连续性 |
|---|---|---|---|---|
| vector | O(1)均摊 | O(n) | O(1) | 连续 |
| deque | O(1) | O(1) | O(1)迭代器间接 | 分段连续 |
| list | O(1) | O(1) | O(n) | 不连续 |
实际项目中,除非你明确知道数据量极小、list的额外开销可以忽略,否则让stack和queue使用默认的deque就是最稳妥的选择。不要为了展示自己会指定底层容器而强行换成list,往往得不偿失。
3.3 priority_queue:堆就是优先级队列的灵魂
priority_queue是队列家族里的“特权分子”。普通队列遵循先进先出,它却不管进入顺序,每次弹出的永远是当前优先级最高的元素。这在任务调度、定时器管理、Top K问题里非常常用。
它的底层是用堆实现的,默认是用std::vector存数据,内部通过堆算法维护大根堆。模板声明长这样:
cpp复制template<class T, class Container = std::vector<T>, class Compare = std::less<T>>
class priority_queue;
用自定义类型时,需要提供比较规则。比如一个带优先级的任务:
cpp复制#include <iostream>
#include <queue>
#include <vector>
struct Task {
int priority;
int id;
bool operator<(const Task& other) const {
return priority < other.priority;
}
};
int main() {
std::priority_queue<Task> pq;
pq.push({3, 1001});
pq.push({1, 1002});
pq.push({5, 1003});
while (!pq.empty()) {
std::cout << pq.top().id << std::endl;
pq.pop();
}
return 0;
}
输出顺序是1003、1001、1002,因为优先级5最大。这里有一个值得注意的坑:operator< 返回的是“当前对象是否小于对方”。默认的Compare是std::less,所以priority_queue是“最大堆”。如果你想让优先级小的先出队,就得自定义Compare,而不是颠倒operator<里的比较方向,否则很容易绕晕。
回到工程场景,线程池的阻塞队列选型经常会问到:任务优先级固定的场景用priority_queue没问题,但如果有超时需求,优先队列就不够了,得用时间轮或者最小堆去配合处理。数据结构没有银弹,选型前先想清楚你的“优先级”到底是什么维度。
4. 真实项目里,栈和队列在解决哪些问题
4.1 函数调用栈:程序运行时最核心的一块内存
你写的每个C++程序,运行时都在使用栈——函数调用栈。调用一个函数时,系统把参数、返回地址、局部变量压入一个新的栈帧;函数返回时,这个栈帧被弹出,控制权交还给调用方。
为什么系统偏偏用栈?因为函数调用的返回顺序天然是后进先出。假设main调用funcA,funcA调用funcB,执行顺序是main先入栈,然后funcA入栈,最后funcB入栈。funcB执行完最先返回,接着是funcA,最后才是main。这个顺序和栈的LIFO完全吻合。
如果用队列会怎样?按先进先出的规则,你得先返回main,但main还没执行完,它正在等funcA的结果。所以队列在函数调用这个场景下从逻辑上就讲不通。
理解这一点,你就明白为什么递归无限循环会导致“栈溢出”:每递归一层就压入一个新栈帧,函数永远不会返回,栈帧永远不弹出,而调用栈的空间是有限的。默认栈大小在Linux下通常是8MB,Windows下是1MB左右。这不是数据结构栈的概念,就是你程序真正在用的那1MB到8MB内存。
4.2 后缀表达式求值与双栈撤销重做
栈在表达式求值里是绝对的主角。平时我们写的中缀表达式比如1 + 2 * 3,计算机处理起来很别扭,因为运算符优先级关系复杂。把中缀转成后缀表达式(逆波兰表达式)后,就变成1 2 3 * +,计算机用栈就能一路算到底。
后缀表达式求值的规则很简单:遇到数字压栈,遇到运算符弹出两个数计算,结果再压回栈。两个数弹出的顺序要注意,先弹出的是右操作数,后弹出的是左操作数,减法和除法尤其容易在这里栽跟头。
cpp复制#include <iostream>
#include <stack>
#include <string>
#include <cctype>
int evalRPN(const std::vector<std::string>& [token](https://taotoken.net?utm_source=general)s) {
std::stack<int> st;
for (const auto& token : tokens) {
if (token.size() == 1 && !std::isdigit(token[0])) {
int b = st.top(); st.pop();
int a = st.top(); st.pop();
if (token == "+") st.push(a + b);
else if (token == "-") st.push(a - b);
else if (token == "*") st.push(a * b);
else if (token == "/") st.push(a / b);
} else {
st.push(std::stoi(token));
}
}
return st.top();
}
栈还支撑着编辑器的撤销重做功能。很多实现使用“双栈模型”:一个撤销栈、一个重做栈。撤销时,把当前状态从撤销栈弹出,压入重做栈;重做时反向操作。浏览器的前进后退按钮也是同样的道理。这类场景的核心特征都是“回溯最近的操作”,而栈正是为回溯而生的结构。
4.3 消息队列、线程池与BFS:队列的三大实战战场
消息队列的“队列”这个词不是白叫的。生产者把消息发到Broker,消费者按顺序拉取处理,这种先进先出的语义天然匹配普通队列。很多新手会问:为什么消息队列能保证消息顺序?答案就是它底层用的队列模型。至于“重复消费问题”,根因往往不在队列本身,而是消费方在处理完消息之后,确认(ACK)和位移提交的时机没控制好,导致消息被重复投递。这属于消费端逻辑要解决的问题,而不是队列数据结构本身的问题。
线程池里也藏着队列。一个典型线程池包含一个任务队列和若干工作线程,主线程往队列里塞任务,工作线程从队列取任务执行。任务队列承担了“解耦”和“缓冲”两个角色:一方面生产者不需要知道消费者是谁,另一方面当任务短时间暴增时,队列能把这些任务暂时攒住,不至于把系统压垮。选型时,无优先级差别的用普通队列,有优先级的用priority_queue,这已经是老生常谈但依然值得反复强调的决策点。
算法领域,BFS(广度优先搜索)是队列最经典的应用。在树或图里做层序遍历时,先遇到的节点先扩展它的邻居,这正是先进先出。写BFS的骨架通常是:
cpp复制#include <queue>
void bfs(Node* root) {
if (!root) return;
std::queue<Node*> q;
q.push(root);
while (!q.empty()) {
Node* cur = q.front();
q.pop();
// 处理 cur
for (auto* child : cur->children) {
q.push(child);
}
}
}
如果用栈来做BFS,你会得到一个深度优先的行为,因为栈总是先处理最后压入的节点。数据结构的选择直接决定遍历顺序,这也是面试里最喜欢问的“为什么”之一。
5. 容易卡住你的几个C++细节
5.1 调用栈溢出:不只是递归的专利
说到栈溢出,大家第一反应是递归太深。但实际项目里还有一种很常见的情况:在函数里定义了超大的局部数组。比如:
cpp复制void func() {
int a[1000000]; // 4MB
// ...
}
这个数组占4MB,在栈上限8MB的Linux环境下可能还能运行,但如果再递归几层,或者你的编译器把栈上限设得更小,程序当场就给你一个段错误。这类问题用gdb调试时,core文件里会直接定位到那个函数,但很多人第一反应是“内存泄漏”,其实是栈空间不够。
解决思路有两个层面。第一,大对象不要放在栈上,改用堆:std::vector、std::unique_ptr、new都是把数据放到堆区,堆空间远大于栈。第二,递归深度大的算法建议改成显式栈迭代,或者用循环实现。尾递归优化在某些场景下有效,但依赖编译器和优化级别,不能作为默认保证。
我之前排查过一个进程时不时段错误的问题,起初以为是第三方库的bug,后来用gdb看core栈,发现每次都是卡在同一个函数,那个函数里定义了一个巨大的局部数组,又在循环里被调用了十几次。把数组改成vector之后,问题再没出现过。数据结构栈和系统调用栈是同一个东西,别忘了这一点。
5.2 一个不少人问过的问题:std::queue为什么没有clear
很多第一次用std::queue的人都会惊讶:为什么没有clear()方法?stack也没有。STL的容器适配器遵循“最小接口原则”,只提供满足语义所需的方法。queue要支持的是入队出队、访问队头队尾,清空操作不属于必要的队列语义。
想要清空一个queue,最直接的办法是直接赋值一个新的空对象:
cpp复制std::queue<int> q;
// ... 入队了一堆数据
q = std::queue<int>(); // 整体替换,底层deque被析构,内存释放
有的人会写while (!q.empty()) q.pop();,逻辑上没错,但时间复杂度是O(n),而且逐个pop会多次调用底层容器的pop_front,效率远不如直接替换。也有人试过std::queue
顺带提一嘴,priority_queue同样没有clear()。如果要清空,同样是整体替换。STL适配器就是这么“抠门”,但了解了背后的设计思想,你反而会觉得这种抠门是对的。
5.3 底层容器选错,接口和性能一起出问题
stack和queue的底层容器是可以指定的,但指定之前先想清楚约束条件。stack的底层容器必须支持back、push_back、pop_back,vector、deque、list都可以;queue的底层容器必须支持front、back、push_back、pop_front,vector没有pop_front,所以vector不能直接当queue底层。
这些只是编译层面的接口约束,运行性能是另一回事。我之前见过有人把std::list作为std::queue的底层,因为list删除头节点是O(1),理论上没问题。但数据规模到了几百万之后,内存占用和运行时间都上来了,因为每个节点都要多存一个指针,而且节点在内存里不连续,CPU缓存命中率上不去。换回默认的deque后,性能立刻恢复了。
还有一个容易被忽略的点:deque的迭代器会在插入删除时失效。stack和queue封装后不暴露迭代器,反而规避了这类问题。如果你图省事直接用deque去写队列,又同时遍历和pop,很容易踩中未定义行为。很多“莫名其妙”的崩溃,最后查下来都是这种细节。
这个内容让我想到一个判断标准:什么情况下才值得手动指定底层容器?绝大多数项目都不需要。默认的deque是STL作者经过性能权衡后的选择,你不需要为了“炫技”去改它。如果哪天真遇到性能瓶颈,先做profiling,确认瓶颈在容器的某个操作上,再考虑换底层或者换数据结构,别靠猜。
写到这儿,我把栈和队列从原理到实战都过了一遍。说句实在话,数据结构学得好不好,不看背了多少定义,就看你在实际代码里能不能想到“这个东西可以用栈/队列解决”。我自己的习惯是,遇到顺序相关的问题,先问自己一句:这个顺序是后进先出还是先进先出?问完这个问题,一半的答案就出来了。栈和队列真正迷人的地方,不在于实现有多难,而在于它们用最简单的方式,定义了计算机世界里最基础的两种秩序。建议你把文章里那段手写栈和环形队列的代码自己跑一遍,再试着把中缀表达式转后缀的算法实现一下,理解会完全不一样。
