1. 为什么学了链表还要学栈和队列:这两个结构到底解决了什么问题
先从一个很实际的问题说起。很多初学者在学到栈(Stack)和队列(Queue)的时候,第一反应往往是:这不就是两个加了限制的线性表吗?数组和链表都学过了,随便都能存数据,为什么还要专门搞两个新结构出来?
这个疑问我当年也有。直到后来在项目里写表达式求值、做撤销重做功能、处理消息分发,才真正意识到:栈和队列的价值不是“能存数据”,而是“定义了一种规则”。数组和链表回答的是“数据怎么放”,栈和队列回答的是“数据按什么顺序取”。这两个结构把存取顺序写死了,写死之后带来的好处是巨大的——逻辑变简单了,程序变安全了,代码的可读性上来了。
打个比方。数组和链表就像一个大抽屉,东西往里塞就行,没人在乎你拿的时候翻得多乱。栈就像一个超市的试吃签筒,从哪头插进去,就只能从那头拔出来,顺序完全可控。队列则像奶茶店的取餐口,先到先做先取,谁也不能插队。
正是这种“自己给自己定规矩”的特性,让栈和队列成为整个计算机世界里被用得最频繁的两个结构。函数调用、递归、浏览器的前进后退、操作系统的消息循环、CPU的任务调度、搜索算法的状态展开,底层全部是栈和队列。可以说,不懂栈和队列,你只是会写代码而已;懂了栈和队列,你才算开始理解程序运行的方式。
这篇文章我不会只把教科书上的定义抄一遍,而是用C++把两种结构从原理到实现完整走一遍,包括数组版和链表版各自的写法、为什么队列要设计成环形的、标准库里的容器适配器是怎么封装的,以及笔试面试里公认会踩的坑。适合正在学数据结构的同学,也适合想系统梳理一遍的开发者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈的两种实现方式:数组栈和链表栈,分别适合什么场景
栈的定义一句话就完了:只允许在表的一端进行插入和删除操作的线性表。这一端叫栈顶(top),另一端叫栈底(bottom)。插入叫入栈(push),删除叫出栈(pop)。核心特点就是后进先出——Last In First Out,简称LIFO。
这句话每个教材都有,但真正写代码的时候会蹦出一堆问题:栈顶指针指向哪?栈空怎么判断?数组栈满了怎么办?链表栈需要头结点吗?下面直接上代码和解释。
2.1 数组栈的完整实现
数组栈的本质就是一片连续内存加一个栈顶下标。最直接的想法是定义一个数组,再加一个int变量记录栈顶位置。
cpp复制#include <iostream>
#include <stdexcept>
using namespace std;
template<typename T>
class ArrayStack {
private:
T* data; // 动态数组指针
int capacity; // 栈容量
int topIndex; // 栈顶索引,-1表示空栈
public:
ArrayStack(int cap = 128) : capacity(cap), topIndex(-1) {
data = new T[capacity];
}
~ArrayStack() {
delete[] data;
}
void push(const T& val) {
if (topIndex + 1 >= capacity) {
// 扩容,容量翻倍
int newCap = capacity * 2;
T* newData = new T[newCap];
for (int i = 0; i <= topIndex; ++i) {
newData[i] = data[i];
}
delete[] data;
data = newData;
capacity = newCap;
}
data[++topIndex] = val;
}
void pop() {
if (isEmpty()) {
throw runtime_error("栈为空,不能出栈");
}
--topIndex;
}
T& top() {
if (isEmpty()) {
throw runtime_error("栈为空,无法访问栈顶");
}
return data[topIndex];
}
bool isEmpty() const {
return topIndex == -1;
}
int size() const {
return topIndex + 1;
}
};
这里有几个关键点值得展开说。
第一,topIndex初始化为-1,而不是0。这是一个容易搞混的地方。设计成这样,意味着入栈时先++topIndex再赋值,栈顶永远指向最新元素。如果初始化成0然后先赋值再++,指针指向的就是下一个空位,那判断栈空的条件就变了。两种写法都能用,但-1初始化的好处是空栈判断非常自然:topIndex == -1。
第二,扩容逻辑。我上面写的是容量翻倍。为什么要翻倍而不是每次+1?因为如果每次都只加一个元素的空间,需要重新分配内存、拷贝数据、释放旧内存,当连续push大量数据时,时间复杂度会变成O(n²)。翻倍扩容虽然会造成一定的空间浪费(比如栈里只有129个元素,却占了256个空间),但均摊下来每次push的时间复杂度是O(1),这是典型的“空间换时间”思路,也是std::vector在底层采用的做法。
第三,top()和pop()为什么要分开?很多初学者会觉得,出栈就应该既拿到值又删掉元素。实际上分开设计是刻意的。pop()负责删除,top()负责读取。如果合并在一起,当栈为空时你就必须面对两个问题:返回值抛异常还是不抛?抛什么类型?分开之后职责清晰,调用方可以自己控制怎么处理空栈情况,语义也干净。
2.2 链表栈的完整实现
数组栈有明显的优点:局部性好、内存连续、访问快。但它也有天然缺陷:需要预分配内存、扩容涉及拷贝。如果栈的使用场景是频繁地创建销毁、元素数量不确定,链表栈更灵活。
链表栈其实不需要像数组栈那样关注容量,因为每个节点都是动态分配的内存量。核心思路就是头插法:新节点永远插到头部,头部就是栈顶。
cpp复制#include <iostream>
#include <stdexcept>
using namespace std;
template<typename T>
class LinkedStack {
private:
struct Node {
T data;
Node* next;
Node(const T& val, Node* nxt = nullptr) : data(val), next(nxt) {}
};
Node* head; // 栈顶指针
int count; // 栈内元素个数
public:
LinkedStack() : head(nullptr), count(0) {}
~LinkedStack() {
while (head != nullptr) {
Node* old = head;
head = head->next;
delete old;
}
}
void push(const T& val) {
Node* newNode = new Node(val, head);
head = newNode;
++count;
}
void pop() {
if (isEmpty()) {
throw runtime_error("栈为空,不能出栈");
}
Node* old = head;
head = head->next;
delete old;
--count;
}
T& top() {
if (isEmpty()) {
throw runtime_error("栈为空,无法访问栈顶");
}
return head->data;
}
bool isEmpty() const {
return head == nullptr;
}
int size() const {
return count;
}
};
链表栈的push本质就是一个单链表头插法。新节点的next指向原来的栈顶,然后把头指针指向新节点,完成。需要注意的是delete这条路:析构函数里要循环删除每个节点,防内存泄漏;pop之后也必须要delete掉旧节点。很多人初学时会漏了delete,写汇编级程序时还好,但一旦长时间运行,内存泄漏就会越来越严重。
2.3 两种栈怎么选:我的个人判断
我在实际项目里,90%的情况会直接选数组栈,原因有三个:
- 数组栈内存连续,CPU缓存命中率高。当数据量不大时,连续内存的遍历和操作速度比离散的链表节点快一个数量级。
- 数组栈的
pop和top时间复杂度虽然都是O(1),但常数极小——只是移动一个下标并访问内存。链表栈则涉及指针跳转,还可能触发内存分配器。 - 数组栈不会频繁
new/delete,避免了内存碎片。
链表栈真正的用武之地是:元素数量不可预估、且入栈出栈操作极其频繁,导致扩容代价无法接受的时候;或者系统对分配内存的总量有严格要求时,链表结构可以做到“用到多少就new多少”。
不过话说回来,如果是做竞赛或者刷题,我甚至很少手写这两种栈——直接用std::vector配合push_back和pop_back就是完美的数组栈,代价更低。这就是下面要讲的C++标准库的优势,但理解手写实现能让你的代码功底更扎实。
3. 队列的实现难点在哪:环形队列的诞生逻辑与链表队列
队列的定义也很简单:只允许在表的一端插入、另一端删除的线性表。插入的那端叫队尾(rear),删除的那端叫队头(front)。特征就是先进先出——First In First Out,简称FIFO。你排队买饭就是队列,第一个来的人先打完饭走人。
但是队列的实现比栈麻烦得多,因为两个端都在动。如果用普通的数组实现队列,会出现一个致命问题:出队时队头往前走,前面空出来的位置就永远浪费了。每出队一个元素,数组前面的空间就“死”一块,用不了多久,数组后面满了、前面空着,却再也插不进新元素。这就是俗称的“假溢出”。
3.1 环形队列:如何绕开假溢出
解决假溢出的经典方案就是把数组的逻辑头尾接起来,让队尾走到数组末尾后可以回到数组开头。这就是环形队列(Circular Queue)。
环形队列的实现核心是模运算。假设数组容量为capacity,用一个front指向队头位置,用rear指向队尾的下一个空位。初始时front == rear == 0。入队时rear = (rear + 1) % capacity,出队时front = (front + 1) % capacity。
但这里有个麻烦事:当队列为空时front == rear,当队列满时,如果也让rear追上front,就会形成front == rear,这就跟空队列判断冲突了。所以环形队列一般会故意牺牲一个存储单元:当(rear + 1) % capacity == front时判定队满。这样一来,最多只能存储capacity - 1个元素,多出来的那个空位用来区分“空”和“满”。
cpp复制#include <iostream>
#include <stdexcept>
using namespace std;
template<typename T>
class CircularQueue {
private:
T* data;
int capacity;
int front;
int rear;
public:
CircularQueue(int cap = 8) : capacity(cap), front(0), rear(0) {
data = new T[capacity];
}
~CircularQueue() {
delete[] data;
}
bool isEmpty() const {
return front == rear;
}
bool isFull() const {
return (rear + 1) % capacity == front;
}
void push(const T& val) {
if (isFull()) {
throw runtime_error("队列已满,无法入队");
}
data[rear] = val;
rear = (rear + 1) % capacity;
}
void pop() {
if (isEmpty()) {
throw runtime_error("队列为空,无法出队");
}
front = (front + 1) % capacity;
}
T& frontValue() {
if (isEmpty()) {
throw runtime_error("队列为空,无法访问队头");
}
return data[front];
}
int size() const {
return (rear - front + capacity) % capacity;
}
};
这段代码里,最值得反复琢磨的是size()这一行:(rear - front + capacity) % capacity。为什么不能直接写rear - front?因为rear不一定比front大。当rear从数组末尾绕回开头后,rear可能比front小。加上capacity取模后,不管哪个方向绕,都能得到正确的元素个数。这个公式对应了环形队列最核心的思想:在连续内存中模拟环形的访问方式,必须用取模运算把下标“逼回”合法范围。
另一个值得注意的点是,pop()并没有清空data[front]。理论上讲,pop后原本的那个元素已经“不可达”了,留着旧值不影响逻辑。但如果你存的是动态指针或者引用计数对象,建议在pop里把旧位置重置为默认值(比如data[front] = T()),帮助垃圾回收或防止悬挂引用。不过对于简单类型,没必要多此一举。
3.2 链表队列:不需要环形算术
如果觉得环形队列的取模运算绕,链表队列就直白得多。两个指针front指向链头,rear指向链尾。入队在尾部进行,出队在头部进行。链表天然没有容量限制,也不需要判断满不满。
cpp复制#include <iostream>
#include <stdexcept>
using namespace std;
template<typename T>
class LinkedQueue {
private:
struct Node {
T data;
Node* next;
Node(const T& val, Node* nxt = nullptr) : data(val), next(nxt) {}
};
Node* front;
Node* rear;
int count;
public:
LinkedQueue() : front(nullptr), rear(nullptr), count(0) {}
~LinkedQueue() {
while (front != nullptr) {
Node* old = front;
front = front->next;
delete old;
}
}
void push(const T& val) {
Node* newNode = new Node(val);
if (front == nullptr) {
front = rear = newNode;
} else {
rear->next = newNode;
rear = newNode;
}
++count;
}
void pop() {
if (front == nullptr) {
throw runtime_error("队列为空,无法出队");
}
Node* old = front;
front = front->next;
if (front == nullptr) {
rear = nullptr;
}
delete old;
--count;
}
T& frontValue() {
if (front == nullptr) {
throw runtime_error("队列为空,无法访问队头");
}
return front->data;
}
bool isEmpty() const {
return front == nullptr;
}
int size() const {
return count;
}
};
链表队列里有一个非常容易漏的小细节:当pop删掉最后一个节点后,rear会变成野指针。所以必须在front变成nullptr的情况下把rear也置为nullptr。很多新手会想到pop里只操作front,忘了rear,导致下一个push时访问了一个已经delete掉的节点,直接崩溃或者产生未定义行为。这种bug特别隐蔽,因为只有当你把队列删到空再继续入队时才会触发。
3.3 队列实现的取舍比较
| 维度 | 环形队列 | 链表队列 |
|---|---|---|
| 容量上限 | 有固定上限,需要预分配 | 无上限,动态扩展 |
| 空间浪费 | 最多浪费一个存储单元 | 每个节点多一个next指针 |
| 性能 | 内存连续、缓存友好、无动态分配 | 每次入队都要new,性能有波动 |
| 代码复杂度 | 模运算逻辑稍绕 | 逻辑直观,但需管好两个指针 |
| 适用场景 | 固定容量、高频操作(如缓冲区) | 元素个数不确定、并发场景 |
如果做项目里常见的固定容量消息缓冲,环形队列几乎是首选。因为缓冲区的大小通常事先就能定好,用环形队列可以避开频繁的new/delete,性能稳定。如果做消息列表,元素数量可能无限增长,那就必须用链表队列,比如任务调度队列、请求队列。
4. 栈和队列到底用在哪些地方:从函数调用、搜索算法到消息分发
聊完实现,来点实际的。栈和队列不是只在教科书里存在的抽象结构,它们真的是现代软件工程的地基。理解它们的落地场景,你会发现学数据结构不只是在背概念。
4.1 函数调用栈与递归
你写的每个C++函数在运行时都会占用一块栈帧(Stack Frame),里面保存了局部变量、参数、返回地址。当一个函数调用另一个函数时,新的栈帧压入调用栈;当函数返回时,栈帧弹出,控制权交还给上一层。整个过程就是栈的push和pop。
这一点和递归强相关。递归之所以能一层层展开又一层层回溯,就是依赖调用栈。如果递归深度过大——比如经典的斐波那契数列递归实现,当n超过一定值,栈就会爆掉,程序直接崩溃。这就是所谓的“栈溢出”(Stack Overflow)。了解了这一点,你自然就理解了为什么递归不能无限写,为什么尾递归优化有意义,甚至能理解为什么某些算法需要手动维护一个栈来模拟递归而不是直接递归。
4.2 表达式求值与编译器的语法分析
栈在表达式求值里的应用,是很多高校数据结构实验的经典项目。比如中缀表达式“3 + 5 * 2”转换成后缀表达式“3 5 2 * +”,然后基于后缀表达式求值。整个转换和求值过程都离不开栈。
用栈处理表达式的核心逻辑是:遇到操作数输出,遇到运算符进栈;如果栈顶运算符优先级不低于当前运算符,则弹出栈顶运算符。一句话总结就是——用栈的LIFO特性来控制运算符的优先级和括号层级。不管你是手写计算器、实现一个简单脚本解释器,还是做编译器的语法分析,这个思路都是通用的。
4.3 搜索算法:深搜用栈,广搜用队列
学习图的遍历时,你会遇到两种经典算法:深度优先搜索(DFS)和广度优先搜索(BFS)。有趣的是,DFS天然对应栈的特性,BFS天然对应队列的特性。
- DFS:沿着一条路径走到黑,再回退换一条路继续走。这个“回退”就是回溯,天然是栈的pop。手动实现DFS可以不用递归,直接维护一个栈即可。
- BFS:从起点开始,按层展开邻居节点。先被访问的节点先扩展它的邻居,这个“先进先出”的次序就是队列。BFS在迷宫最短路径、社交网络好友推荐、路由协议里都有应用。
我自己在给一个路径规划模块写代码时,就用队列维护待搜索的节点列表,每次从队头取出当前层节点,将其邻居压入队尾。这个设计让搜索行为严格按层展开,任何时候都不会乱序。
4.4 操作系统、浏览器与消息队列
再往上看,栈和队列是操作系统的老朋友。
浏览器的前进后退按钮是栈:每访问一个新页面就压栈,点击后退就弹栈。撤销重做功能也是栈:操作记录压入撤销栈,撤销时弹出并压入重做栈。消息循环是队列:操作系统把鼠标点击、键盘输入、窗口重绘等事件依次排进队列,程序逐个处理。线程池的任务队列、RPC的请求队列也都是队列。消息队列(Message Queue)中间件——像RabbitMQ、Kafka——底层的数据组织方式,本质就是一种分布式队列。
所以,栈和队列在系统编程里已经不是“要不要用”的问题,而是操作系统和框架帮你写好了,你只需要理解它、使用它。当你写一个并发程序时,如果任务需要按顺序处理,队列就来了;如果任务需要“后到的先处理”,栈就来了。这个选型逻辑一定要养成肌肉记忆。
5. 刷题和面试中栈与队列的高频坑:从空栈判断到队列环路的边界条件
无论笔试还是面试,栈和队列都是出镜率极高的数据结构。但是很多人能把原理讲得头头是道,一到写代码就各种边缘情况踩雷。我根据自己的经验,把那些公认的、容易出问题的点集中盘一下。
5.1 空栈判断:什么时候该用empty而不是size
一个特别常见的坑是:访问栈顶或队头前不检查是否为空。很多初学者在代码里写stack.top(),以为top操作对空栈有友好处理。实际上标准库里的top在栈为空时行为是未定义的,有些编译器可能直接崩溃,有些返回垃圾值。所以在任何生产代码里,访问栈顶前必须检查isEmpty()。
同理,pop()也必须在非空条件下调用。需要注意的是,C++标准库里的pop()只负责弹出,不返回被弹出的值。如果你需要弹出的值,必须先用top()拿到值再pop()。这和Python里的pop()返回值的习惯不同,从Java转来的同学特别容易踩这个坑。
5.2 环形队列的循环边界:循环不变量要一致
环形队列最大的调试噩梦,就是队空队满判断条件写反或者写错。为了不晕,我在编码时给自己定了一条铁律:坚定一个循环不变量——front指向队头元素,rear指向队尾元素的下一个空位。凡是符合这个约定的操作,全部基于它推导。
- 空:
front == rear - 满:
(rear + 1) % capacity == front - 入队:
data[rear] = val; rear = (rear + 1) % capacity; - 出队:
front = (front + 1) % capacity;
只要你每次写代码前先把这四个公式写在注释里,几乎不会再出错。反过来,如果你一会儿觉得rear指向队尾元素,一会儿觉得它指向下一个空位,队列为空和满的判断就会全部乱套。这种逻辑不一贯的问题,代码能编译通过,但运行结果会非常迷惑。
5.3 内存管理和拷贝控制
C++里手写栈和队列时,最容易被忽视的是拷贝构造函数和拷贝赋值运算符。我上面的实现里没有写,因为只是示例,但如果在项目里真正使用,就必须处理——否则默认的浅拷贝会让两个对象共享同一块堆内存,导致double free或者多次删除。
解决办法有三个层次:
- 直接使用
std::vector或std::deque作为底层容器,交给标准库管理。 - 手写类时遵循“三之法则”(Rule of Three):定义析构函数时同时定义拷贝构造和拷贝赋值。
- 使用智能指针(
std::unique_ptr)管理链表节点,减少手动释放的负担。
对于大多数项目,我更推荐直接用标准库容器,而不是自己写。手写栈和队列的意义在于理解原理,而不是在真实工程中替代标准库。
5.4 栈溢出与队列的长度设计
这一节讲两个极端场景。
第一个是栈溢出。当你的递归深度过大、或者手动在栈上分配超大数组时,程序会爆栈。破解这个问题的思路包括:改用循环、使用堆内存、扩大线程栈大小。比如你需要在代码里维护一个“大的临时数组”,千万不要在函数内声明局部数组,要把它改成堆上的vector,因为局部数组放在栈上,而栈空间通常只有几MB。
第二个是队列的长度设计。用环形队列时要考虑容量和实际元素数之间的关系。如果容量是8,实际最多只能放7个元素,因为要空一个位置来区分空和满。以前有个同事在通信模块里用环形队列做接收缓冲,容量设为1024,但在生产环境发现总在快满的时候丢数据。排查了半天,才发现丢数据是因为他以为容量是1024就能存1024个,结果(rear + 1) % capacity == front这个队满条件提前触发了,最后把容量改成1025就解决问题了。所以,如果业务上明确需要一个能存N个元素的队列,环形队列的物理容量要设为N+1。
5.5 经典面试题实操:用栈实现队列、用队列实现栈
面试官非常喜欢考“用栈实现队列”和“用队列实现栈”这两个互逆题。它们考察的正是你对两个结构特性的理解程度。
用栈实现队列的核心思想是双栈法:入队时往inStack压,出队时如果outStack为空,就把inStack里的所有元素挨个弹出并压入outStack,然后从outStack弹出栈顶。这样两次反转抵消了LIFO,整体就变成了FIFO。均摊时间复杂度O(1)。
cpp复制class MyQueue {
private:
stack<int> inStack;
stack<int> outStack;
void transfer() {
if (outStack.empty()) {
while (!inStack.empty()) {
outStack.push(inStack.top());
inStack.pop();
}
}
}
public:
void push(int x) {
inStack.push(x);
}
int pop() {
transfer();
int val = outStack.top();
outStack.pop();
return val;
}
int peek() {
transfer();
return outStack.top();
}
bool empty() {
return inStack.empty() && outStack.empty();
}
};
用队列实现栈稍微复杂一点。常见思路是在入栈时就把顺序调整好:每次新元素加入后,把前面的元素全部出队再重新入队,这样队头永远是最新元素。或者用两个队列来回倒腾。
这两道题做一遍,你会真正体会到LIFO和FIFO的差异在操作层面到底是怎么体现的。
6. 别重复造轮子:C++标准库的stack和queue你应该知道的封装细节
手写一遍栈和队列之后,你一定要知道:在真实的工程开发里,几乎不会自己手写这两个结构。C++标准库已经提供了封装好的容器适配器:std::stack和std::queue。
它们的核心特点是:不是独立从零开始的容器,而是基于底层容器的“适配器”。std::stack默认基于std::deque,std::queue也默认基于std::deque。可以通过模板参数指定底层容器,比如std::stack<int, std::vector<int>>或std::queue<int, std::list<int>>。
为什么默认是deque而不是vector?deque(双端队列)允许在头部和尾部都能高效插入删除,同时支持随机访问。对于stack来说,只需要尾部操作;对于queue来说,需要头部删除、尾部插入。deque恰好两头都能高效操作,所以它成了默认选择。如果底层容器换成list,那么stack和queue操作也都没问题,但性能上多了一层节点指针跳转。
使用标准库的做法很简单:
cpp复制#include <iostream>
#include <stack>
#include <queue>
using namespace std;
int main() {
stack<int> st;
st.push(1);
st.push(2);
cout << st.top() << endl; // 2
st.pop();
cout << st.top() << endl; // 1
queue<int> q;
q.push(1);
q.push(2);
cout << q.front() << endl; // 1
cout << q.back() << endl; // 2
q.pop();
cout << q.front() << endl; // 2
return 0;
}
std::queue提供了front()和back()访问队头和队尾,但注意它没有top()。std::stack则只提供top(),没有front()/back()。这两个容器的API差异反映了它们各自的语义——栈只能看到栈顶,队列能看到队头和队尾。
还有一点值得一提:std::stack和std::queue都要求底层容器支持特定的操作,比如stack需要push_back、pop_back、back,queue需要push_back、pop_front、front、back。所以如果你尝试把std::forward_list当底层容器传给stack,编译就会报错,因为forward_list没有push_back。理解了适配器的含义,你就能预料到这类编译错误的原因,而不用对着报错信息瞎猜。
在刷LeetCode或写算法题时,栈和队列直接无脑用标准库就行。在真实项目中,面对海量数据和高并发请求,标准库容器通常也是首选。除非你需要自己控制底层内存布局,或者做一些特殊优化,不然手写版的好好留在学习笔记里就行。
另外一个面试常问的进阶点:什么时候用栈模拟递归,什么时候用队列做BFS。我现在的习惯是,如果题目里说“按顺序遍历所有可能的状态”,优先想队列做BFS;如果说“回溯到上一层节点继续尝试”,优先想栈模拟递归或者直接递归。先用这个思路建立方向感,再动手写代码,能少走很多弯路。
聊到这里,栈和队列的核心内容基本说透了——原理、手写实现、标准库封装、应用场景、坑位清单,全都过了一遍。如果你看完能动手把这两个结构实现一遍,再去找几道相关的算法题练练手,这些东西就会真正成为你的基本功,而不是背完就忘的名词。
