1. 项目概述与学习思路
1.1 为什么学习C++要从栈和队列开始
我记得第一次接触C++的时候,被指针、引用、内存管理这些东西折磨得不轻。等到自以为把语法啃得差不多了,写出来的程序却总感觉哪里不对——逻辑不清晰、代码杂乱、遇到复杂问题就不知道从哪里下手。后来才明白,缺的那块拼图正是数据结构。
栈(Stack)和队列(Queue)是数据结构里最基础也最实用的两种线性结构。说它们基础,是因为只需要掌握几个核心操作就能完全理解;说它们实用,是因为在操作系统、编译器、网络通信、图形界面开发等几乎所有软件系统中,都能看到它们的身影。整个C++标准库的设计里也处处渗透着这两种结构的思维。
这篇文章我会从零开始,把栈和队列的本质讲透,带着你用C++从底层亲手实现一遍,再看它们在实际项目中到底是怎么用的。无论你是刚学完C++基础语法的小白,还是准备面试的求职者,或者只是想把数据结构的坑补上,这篇文章都能给你一条清晰的学习路径。
1.2 一个类比帮你建立直觉
先抛开枯燥的定义,用生活场景建立直觉。
栈就像一摞盘子。你在饭店后厨看到的那种弹簧托盘架——你放上去一个盘子,它落在最上面;你要拿盘子,只能从最上面拿。后放上去的盘子总是先被拿走。这个特点叫“后进先出”(Last In First Out,LIFO)。
队列就像排队买奶茶。新来的人排在队尾,队头的人先买到奶茶离开。先来的人先被服务。这个特点叫“先进先出”(First In First Out,FIFO)。
就是这么简单的两个概念。但简单不意味着浅薄——正是这两种最简单的规则,构成了无数复杂算法和系统设计的基石。你把这两个直觉记在脑子里,后面所有的代码、场景、代码层面的细节,都不过是这两个直觉的具象化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心概念与底层原理拆解
2.1 栈的定义与核心操作
栈是只允许在一端进行插入和删除操作的线性表。这一端被称为栈顶(Top),另一端被称为栈底(Bottom)。当栈里没有元素时,称为空栈。
栈的核心操作只有四个:
push(x):将元素 x 压入栈顶pop():弹出栈顶元素top():获取栈顶元素的值(不弹出)empty():判断栈是否为空
很多教材还会提一个 size() 操作,返回栈中元素个数,其实是用一个计数变量就能搞定的事。
这里有一个新手容易犯的认知偏差:栈的操作看似简单,但“只能在栈顶操作”这个限制恰恰是它的灵魂所在。这个限制意味着你无法随意访问中间的元素——想要访问栈底的元素,必须先把上面的所有元素都弹出去。这种受限访问换来的好处是操作的时间复杂度全部是 O(1),而且不会出现元素位置错乱的问题。
2.2 队列的定义与核心操作
队列是只允许在一端进行插入、在另一端进行删除操作的线性表。允许插入的一端叫队尾(Rear),允许删除的一端叫队头(Front)。同样,当队列中没有元素时称为空队列。
队列的核心操作:
enqueue(x):将元素 x 插入队尾dequeue():删除队头元素front():获取队头元素的值(不删除)empty():判断队列是否为空
初学者最容易混淆的一点是:队列和栈一样,也不允许从中间操作元素。但栈的插入删除在同一端,队列在两段。这个区别直接决定了两种结构适用的场景完全不同——栈适合做“需要回溯”的场景,队列适合做“需要公平排队”的场景。
2.3 底层存储方案:数组 vs 链表
实现栈和队列,主要有两条路线:数组(顺序存储)和链表(链式存储)。这两种方案没有绝对的好坏,取决于你的使用场景。
数组实现的核心思路是:预先分配一块连续的内存,用下标来追踪栈顶或队头队尾的位置。优点是随机访问快、缓存命中率高、实现简单;缺点是容量固定,需要扩容时就比较麻烦,而且扩容涉及数据的整体搬移。
链表实现的核心思路是:每个节点保存数据和指向下一个节点的指针,用头指针和尾指针维护结构。优点是不需要预先分配大块内存,元素个数可以动态增长;缺点是每个节点需要额外的指针空间,而且节点的内存不连续,对缓存的利用不如数组友好。
一个经常被问到的问题是:既然链表也能实现,为什么很多场景仍然优先用数组?我的看法是,现代计算机的体系结构下,顺序内存访问的速度优势非常明显,数组的连续内存天然有利于CPU缓存预取。所以在大多数应用场景里,数组实现的栈和队列性能更好。链表的价值更多体现在“不知道数据量上限”和“需要频繁插入删除”的场景中。
3. 手把手实现:C++代码实战
3.1 基于数组的栈实现
从最直观的数组栈开始。我用一个动态数组来模拟栈的伸缩,这样可以避开“固定容量”的尴尬。
cpp复制#include <iostream>
#include <stdexcept>
template <typename T>
class ArrayStack {
private:
T* data; // 底层数组
int capacity; // 当前容量
int topIndex; // 栈顶下标,-1表示空栈
void resize(int newCapacity) {
T* newData = new T[newCapacity];
for (int i = 0; i <= topIndex; i++) {
newData[i] = data[i];
}
delete[] data;
data = newData;
capacity = newCapacity;
}
public:
ArrayStack() : capacity(8), topIndex(-1) {
data = new T[capacity];
}
~ArrayStack() {
delete[] data;
}
void push(const T& value) {
if (topIndex + 1 >= capacity) {
resize(capacity * 2); // 容量翻倍
}
data[++topIndex] = value;
}
void pop() {
if (empty()) {
throw std::underflow_error("Stack underflow: pop on empty stack");
}
topIndex--;
}
T& top() {
if (empty()) {
throw std::underflow_error("Stack underflow: top on empty stack");
}
return data[topIndex];
}
const T& top() const {
if (empty()) {
throw std::underflow_error("Stack underflow: top on empty stack");
}
return data[topIndex];
}
bool empty() const {
return topIndex == -1;
}
int size() const {
return topIndex + 1;
}
};
这段代码里有几个细节值得注意。
第一,我用 topIndex 而不是用一个独立的“栈顶指针”,因为数组下标天然就是指针的替身。topIndex == -1 表示空栈,压入第一个元素时 ++topIndex 变成 0,正好落在数组第一个位置。
第二,扩容策略是翻倍扩容。为什么是 2 倍而不是加固定大小?因为翻倍扩容能保证均摊时间复杂度是 O(1)。简单算一下:假设最终栈里有 n 个元素,那么扩容的总代价是一个等比数列求和,结果是 O(n) 的,分摊到 n 次 push 上,每次就是 O(1)。
第三,pop 操作我没有真正删除元素,只是把 topIndex 减一。这个设计在 C++ 里很常见——被“弹出”的元素仍然留在内存里,但已经不会被访问到了。下次 push 的时候,新值会直接覆盖它。
实战一下:
cpp复制int main() {
ArrayStack<int> s;
s.push(1);
s.push(2);
s.push(3);
std::cout << s.top() << std::endl; // 输出 3
s.pop();
s.pop();
std::cout << s.top() << std::endl; // 输出 1
std::cout << s.size() << std::endl; // 输出 1
return 0;
}
运行结果清晰直观。这就是一个最基础的数组栈。
3.2 基于链表的栈实现
链式栈的思路是:用节点指针串成链表,头节点就是栈顶。push 相当于在链表头部插入节点,pop 相当于删除头节点。
cpp复制#include <iostream>
#include <stdexcept>
template <typename T>
class LinkedStack {
private:
struct Node {
T data;
Node* next;
Node(const T& value, Node* n = nullptr) : data(value), next(n) {}
};
Node* head; // 栈顶节点
int count; // 元素个数
public:
LinkedStack() : head(nullptr), count(0) {}
~LinkedStack() {
while (head != nullptr) {
Node* temp = head;
head = head->next;
delete temp;
}
}
void push(const T& value) {
head = new Node(value, head);
count++;
}
void pop() {
if (empty()) {
throw std::underflow_error("Stack underflow");
}
Node* temp = head;
head = head->next;
delete temp;
count--;
}
T& top() {
if (empty()) {
throw std::underflow_error("Stack underflow");
}
return head->data;
}
bool empty() const {
return head == nullptr;
}
int size() const {
return count;
}
};
链式栈的 push 一句话就完成了:head = new Node(value, head)。这行代码创建了一个新节点,把它的 next 指向原来的头节点,然后让 head 指向新节点。新节点“压”到了栈顶,原来在栈顶的节点变成了第二个。整个操作只需要 O(1) 时间,不需要移动任何现有元素。
链表栈的最大优势是没有容量限制,只要内存够,push 多少次都行。
3.3 基于数组的循环队列实现
数组队列比数组栈复杂一些,必须用“循环队列”的思路。如果不用循环,你会发现出队后队头前面的空间就浪费了,数组前面空着一大块,后面的空间却不够用。
循环队列的核心思路是:让队头和队尾在数组里“绕圈”。当队尾到达数组末尾时,如果数组开头还有空闲位置,队尾就绕回到开头。这样整个数组空间就被复用起来了。
cpp复制#include <iostream>
#include <stdexcept>
template <typename T>
class CircularQueue {
private:
T* data;
int capacity;
int frontIndex;
int rearIndex; // 指向队尾的下一个位置
int count; // 当前元素个数
public:
CircularQueue(int cap = 8) : capacity(cap), frontIndex(0), rearIndex(0), count(0) {
data = new T[capacity];
}
~CircularQueue() {
delete[] data;
}
void enqueue(const T& value) {
if (count == capacity) {
throw std::overflow_error("Queue overflow");
}
data[rearIndex] = value;
rearIndex = (rearIndex + 1) % capacity;
count++;
}
void dequeue() {
if (empty()) {
throw std::underflow_error("Queue underflow");
}
frontIndex = (frontIndex + 1) % capacity;
count--;
}
T& front() {
if (empty()) {
throw std::underflow_error("Queue underflow");
}
return data[frontIndex];
}
bool empty() const {
return count == 0;
}
int size() const {
return count;
}
};
这里有两个关键点。
第一个是 rearIndex 的含义。我让 rearIndex 指向队尾的下一个位置,而不是队尾本身。这样做的目的是区分空队列和满队列:空队列时 frontIndex == rearIndex,满队列时无法单纯靠下标区分,所以额外用一个 count 来记录元素个数。这是最清晰的做法,虽然多花了一个变量的空间,但避免了判断逻辑上的各种边界陷阱。
第二个是取模运算 (index + 1) % capacity。当 index 到达 capacity - 1(数组最后一个位置)时,加 1 取模后回到 0,实现“环形”效果。这里有个性能细节:取模运算在 CPU 上比加减法慢,但现代编译器在 capacity 是 2 的幂次时会自动优化成位运算,所以如果你确定容量总是 2 的次方,可以放心写 % capacity。如果追求极致性能,也可以手写 index + 1 == capacity ? 0 : index + 1。
3.4 基于链表的队列实现
链表队列需要两个指针:一个指向队头,一个指向队尾。入队操作在队尾插入,出队操作在队头删除。
cpp复制#include <iostream>
#include <stdexcept>
template <typename T>
class LinkedQueue {
private:
struct Node {
T data;
Node* next;
Node(const T& value, Node* n = nullptr) : data(value), next(n) {}
};
Node* frontNode;
Node* rearNode;
int count;
public:
LinkedQueue() : frontNode(nullptr), rearNode(nullptr), count(0) {}
~LinkedQueue() {
while (frontNode != nullptr) {
Node* temp = frontNode;
frontNode = frontNode->next;
delete temp;
}
}
void enqueue(const T& value) {
Node* newNode = new Node(value);
if (empty()) {
frontNode = rearNode = newNode;
} else {
rearNode->next = newNode;
rearNode = newNode;
}
count++;
}
void dequeue() {
if (empty()) {
throw std::underflow_error("Queue underflow");
}
Node* temp = frontNode;
frontNode = frontNode->next;
delete temp;
count--;
if (frontNode == nullptr) {
rearNode = nullptr; // 队列空了,重置 rearNode
}
}
T& front() {
if (empty()) {
throw std::underflow_error("Queue underflow");
}
return frontNode->data;
}
bool empty() const {
return frontNode == nullptr;
}
int size() const {
return count;
}
};
链式队列有个容易漏掉的细节:出队后如果队列变空了,一定要把 rearNode 也置成 nullptr。否则下一次 enqueue 时,empty() 判断为真,会走 frontNode = rearNode = newNode 分支,此时 rearNode 被重新赋值没问题;但如果在出队后、入队前有人调用了 front(),因为 frontNode 已经是 nullptr,会正常抛出异常。真正的问题在于,如果出队后队列为空但 rearNode 还指向已删除的节点,再入队走 else 分支就会让 rearNode->next 指向一个悬空地址,直接崩溃。
这类细节你不实际写一遍、踩一次坑,光靠看是记不住的。
3.5 直接使用STL容器
实际工程开发中,很少需要自己手写栈和队列。C++ 标准库已经提供了现成的容器,封装得很完善。
std::stack 是最常用的栈容器,底层默认使用 std::deque(双端队列)实现。接口包括 push、pop、top、empty、size。std::queue 则提供 push、pop、front、back、empty、size,底层默认也是 std::deque。
为什么默认用 std::deque 而不是 std::vector?因为 std::deque 支持在头部和尾部都是 O(1) 时间插入删除,而 std::vector 在头部插入是 O(n)。栈只需要尾端操作,vector 其实够用;但 deque 作为底层既照顾了栈的扩展性,又能让你临时用 std::queue 时头部操作也不慢。标准库在“够用”和“通用”之间做了平衡。
用 STL 版本简单很多:
cpp复制#include <iostream>
#include <stack>
#include <queue>
int main() {
std::stack<int> s;
s.push(10);
s.push(20);
std::cout << s.top() << std::endl; // 20
std::queue<int> q;
q.push(10);
q.push(20);
std::cout << q.front() << std::endl; // 10
std::cout << q.back() << std::endl; // 20
return 0;
}
我的建议是:学习阶段一定要手写一遍底层实现,搞清楚每个指针、每个下标是怎么变的;到了做项目、写业务代码阶段,直接用 STL。手写是为了理解原理,用 STL 是为了效率和正确性。
4. 栈和队列的经典应用场景实战
4.1 函数调用栈:理解递归的底层机制
说到栈的最重要应用,非函数调用栈莫属。每次函数调用,系统都会在内存中分配一块区域(栈帧),用来保存函数的局部变量、参数和返回地址。函数调用结束后,这块区域被释放。整个调用链就是这样一层层“压栈”和“出栈”的过程。
这就解释了为什么递归太深会导致栈溢出。每次递归调用都会压入一个栈帧,如果你的递归深度达到几十万层,栈空间被耗尽,程序就会崩溃。我在调试一个递归遍历目录的代码时,遇到过一个目录层级特别深的场景,跑着跑着程序直接崩溃,最后用回溯堆栈定位到栈溢出,才明白是这个原因。从那以后,凡是要遍历深层数据结构,我都优先考虑用显式的栈 + 循环替代递归,或者使用尾递归优化。
4.2 括号匹配:栈的经典算法题
括号匹配是栈应用里最经典的题目,也是面试的高频考点。给出一串包含 (){}[] 的字符串,判断括号是否合法配平。
思路很清晰:遍历字符串,遇到左括号就压栈;遇到右括号,弹出栈顶元素检查是否匹配。如果栈空或者不匹配,说明括号不合法;遍历结束后栈不为空,说明有左括号没闭合。
cpp复制#include <iostream>
#include <stack>
#include <string>
bool isValid(const std::string& s) {
std::stack<char> st;
for (char c : s) {
if (c == '(' || c == '[' || c == '{') {
st.push(c);
} else {
if (st.empty()) return false;
char top = st.top();
if ((c == ')' && top != '(') ||
(c == ']' && top != '[') ||
(c == '}' && top != '{')) {
return false;
}
st.pop();
}
}
return st.empty();
}
int main() {
std::cout << isValid("({[]})") << std::endl; // 1
std::cout << isValid("({[}])") << std::endl; // 0
std::cout << isValid("((()))") << std::endl; // 1
std::cout << isValid("(") << std::endl; // 0
return 0;
}
这个例子把栈的“后进先出”特性体现得淋漓尽致。编辑器、IDE、编译器里语法高亮和代码补全功能,很多都内嵌了类似的括号匹配逻辑。
4.3 表达式求值:中缀转后缀
栈在表达式求值中的应用也特别经典。我们平时写的表达式是中缀表达式,比如 3 + 4 * 2。计算机处理起来不方便,因为要考虑运算符优先级和括号。更好的办法是先把它转换成后缀表达式(逆波兰式),再用栈直接求值。
转换的算法叫“调度场算法”。用两个栈(或一个栈 + 输出列表),遍历中缀表达式的每个 token:遇到数字直接输出;遇到运算符,如果栈顶运算符优先级不低于当前运算符,则弹出栈顶;遇到左括号压栈,遇到右括号弹出直到左括号。
后缀表达式求值就简单多了:遍历后缀表达式,遇到数字压栈,遇到运算符弹出两个操作数计算,结果压栈。最后栈里剩下的就是结果。这个过程中,栈的“暂时保存”和“后进先出”特性被发挥到了极致。
我用这个算法写过一个小的计算器程序,整个过程下来对栈的理解深度完全不一样了。建议你也亲手实现一遍,C++ 里用 std::stack 也就一百行左右。
4.4 操作系统与中间件中的队列应用
队列在系统级应用中的出镜率比栈更高,凡是涉及任务调度、请求排队、流量控制的地方都离不开队列。
典型的例子是消息队列,比如 Redis 的 List 结构、RabbitMQ、Kafka 都是消息队列在不同层面的实现。它们的核心逻辑就是“生产者入队、消费者出队”的 FIFO 模型。再比如线程池的任务队列:当线程池中的所有线程都在忙时,新提交的任务会被放进一个阻塞队列里等待空闲线程来处理。
说到线程池的阻塞队列选择,这里有一个很有意思的点:不同的线程池配置会选择不同类型的队列。如果任务较多、希望执行快的任务优先,可以用 PriorityBlockingQueue(优先队列);如果希望任务严格按提交顺序执行,用 LinkedBlockingQueue。优先队列本质上是“队列”的一种变体,它出队的顺序不是按入队时间,而是按优先级。理解了队列的抽象,再看这些具体实现就非常顺了。
4.5 实际业务场景:前端和后端的队列
很多初学者觉得数据结构是“学院派”知识,跟实际开发没关系。其实队列在前后端开发中无处不在。
以 Web 请求为例,一个服务器同时接收到大量请求,不可能同时处理所有请求,必须把请求放入队列里排队处理。这就是服务器的工作队列模型。你在网上看到的“排队中”“系统繁忙”提示,背后都是队列在起作用。
消息队列的重复消费问题也是后端开发的高频话题。解决方案通常包括:消费者记录已处理消息的 ID,处理前查重;或者利用数据库的唯一约束保证消息只能被成功处理一次。这些方案虽然不直接涉及队列的数据结构实现,但都是围绕“队列”这个核心概念展开的工程实践。如果你未来做全栈或后端方向,这部分知识一定是绕不开的。
5. 常见问题与排查技巧实录
5.1 栈的经典错误:访问空栈
空栈访问是新手最容易踩的坑。当你调用 pop() 或 top() 时,如果栈是空的,程序就会崩溃或者在 Debug 模式下报断言错误。
我在实现上述代码时,所有抛异常的地方都用了 std::underflow_error 或 std::overflow_error,而不是直接返回一个默认值。这是因为“静默失败”比“直接崩溃”更加危险——直接崩溃你很快就能发现并修复,静默失败会让错误的数据一路传播下去,最后在离事发点很远的地方表现出诡异的现象,排查起来费时费力。
5.2 循环队列的判空判满陷阱
手写循环队列时,最容易出问题的是判空和判满。
我在 3.3 中用了 count 变量来区分空和满。如果你不想用 count,还有另一个常见方案:牺牲一个存储单元。也就是说,当数组中有 1 个位置永远空置时,frontIndex == rearIndex 表示空,(rearIndex + 1) % capacity == frontIndex 表示满。这个方案省了一个变量,但会浪费一个数组位置,而且代码的可读性差一些。
我个人更推荐用 count 方案——多一个整数变量,换来的却是清晰明确的逻辑,这笔买卖划算。
5.3 链表栈和队列的内存泄漏
手写链表结构时,内存泄漏是个大问题。new 出来的节点,如果忘了 delete,就会泄漏。最容易漏的地方往往在析构函数和出队/出栈操作里。
我的经验是:每次写 new 的时候,脑子里立刻想一遍“这个对象应该在什么时候 delete”。写完销毁相关代码后,用 Valgrind 或者 ASan(AddressSanitizer)跑一遍,确认没有内存泄漏和非法访问。VS Code 配置 C/C++ 环境时就可以顺手把 ASan 打开,调试阶段能帮你挡掉大量指针问题。
5.4 STL 容器怎么选
实际开发中,选哪个容器也经常让人纠结。给一个我常用的决策参考:
- 只要栈:直接用
std::stack,默认底层是std::deque。如果确定元素数量不大,也可以指定std::vector作为底层。 - 只要队列:直接用
std::queue,默认底层std::deque。 - 需要双端操作(既能从头插/删,也能从尾插/删):直接用
std::deque。 - 需要随机访问 + 只能在尾部插入删除:用
std::vector。
一句话总结:能用 STL 解决的,绝不手写。手写数据结构只存在于学习场景和极少数追求极致性能的项目中。
6. 项目拓展与进阶学习路径
6.1 从栈和队列到更复杂的数据结构
栈和队列是最好的入门敲门砖。掌握了这两种结构之后,你对“数据的组织方式决定了操作的复杂度”这句话会有更深的理解。
紧接着你应该学链表(单链表、双链表、循环链表)和二叉树。链表是栈和队列链式实现的底层基础,二叉树则是栈的前序遍历、中序遍历的天然演练场。再往后就是图、哈希表、堆、树等更复杂的内容,但它们的很多操作仍然依赖你对栈、队列的理解。比如图的深度优先遍历(DFS)显式实现就需要栈,广度优先遍历(BFS)就需要队列。
6.2 结合算法:排序、搜索与递归
数据结构和算法是不分家的。学完栈和队列后,建议配套学习递归和常见排序算法。冒泡排序实现简单,适合热身;快速排序和归并排序更适合练习递归思维和分治策略。栈在后缀表达式求值和括号匹配中的应用,本身就是“用数据结构解决算法问题”的范本。
6.3 工程视角:从数据机构到系统设计
如果目标是做全栈开发或后端开发,栈和队列不只是面试题,更是工程工具。当你开始接触线程池、消息队列、任务调度、网络爬虫时,你会反复用到队列的思想。我见过不少初级工程师写代码时完全没有数据结构的意识,ArrayList 到处用,遇到需要“先进先出”的业务场景也强行用 List 模拟,既写不出清晰的代码,性能也不太理想。
如果能把栈和队列的思维内化成直觉,你会自然地写出更简洁、更高效、更易维护的代码。这一点,只有写多了才能真正体会。
我个人在实际操作中的体会是:学数据结构没有捷径,就是“理解原理 + 亲手实现 + 应用验证”三轮驱动。栈和队列作为最简单也最基础的两个结构,刚好把这三步都完整地走了一遍。等你实现完数组栈、链表栈、循环队列、链表队列这四份代码,再拿 STL 对照着看一遍,你的 C++ 数据结构的底子就算真正打牢了。
再分享一个小技巧:把这份代码留在你的代码库里,以后面试、做项目、带新人的时候随时可以拿出来参考。经典的数据结构实现,值得反复琢磨。
