很多初学者在接触C++数据结构时都有过类似的困惑:概念背得滚瓜烂熟,知道栈是“后进先出”、队列是“先进先出”,可一到写代码或者做实验报告就发懵——栈到底怎么定义?队列的数组实现为什么绕来绕去?更别提在项目里自己设计一个容器来用了。这篇文章我想换个讲法,不只给定义和代码,更要把“为什么这样设计”“实际用的时候会踩什么坑”一起讲透,让小学期、期末复习、日常开发的人都能从中找到自己需要的部分。
我会从栈和队列的本质入手,拆开顺序存储和链式存储的实现思路,一直聊到它们在系统底层、算法竞赛和业务开发里的真实形态。整个过程里涉及到C++代码我都给了可编译的完整写法,顺手也整理了一些我这么多年写代码时实际踩过的坑。内容不挑基础,没学过C++的也能看懂思路,会用STL的人也能在里面找到一些容易忽略的细节。
1. 先聊清楚:栈和队列凭什么值得专门学
很多教材开头都喜欢说“栈和队列是两种重要的线性结构”,这话没错,但太抽象,听完和没听一样。我更喜欢用另外一个角度切入:你写的每一段C++代码,在机器眼里其实就是一场不断进出栈的表演。
1.1 函数调用栈:你的程序每时每刻都在用栈
当你调用一个函数时,系统会在内存的栈区里给这次调用分配一段空间,用来存放参数、局部变量以及函数结束后要返回的地址。这段空间叫作栈帧。函数里如果再调用其他函数,新栈帧继续压栈;等内层函数返回,它的栈帧就被弹出,控制权交还给外层。整个流程完全符合“后进先出”的规则——后调用的函数先返回。
这也是为什么当代码出现深层递归时会报栈溢出。递归的本质就是函数不停地调用自己,栈帧一层层往上叠,叠到超出栈区大小就崩了。很多人在LeetCode上写DFS(深度优先搜索)时遇到过这种情况,其实并不是算法错了,而是系统栈被压满了。
理解了这一点,你就知道栈不是一个只存在于教材里的抽象概念,它是程序能够正常运行的地基之一。甚至排查程序崩溃时常用到的“调用栈回溯”,也是基于这一层机制展开的。
1.2 队列:系统与业务里无处不在的排队逻辑
队列解决的是“先进先出”的公平问题。操作系统里的进程调度要排队,线程池里的任务要排队,网络请求到了服务器要排队,甚至你在食堂打饭都要排队。
我一个做后台开发的朋友说过一句话,让我印象很深:很多看似需要“推送”的功能,底层都是队列在撑着。 你在网站上下单,订单不会立刻被处理完,而是被扔进一个消息队列,由后端的消费者进程一个一个拉取处理。用户体感上觉得是实时的,实际上是一次异步排队。
所以栈和队列学得好不好,直接影响你后面理解操作系统、编译器、网络协议、后端架构这些高阶内容。这也是为什么几乎所有学校的计算机专业都把这两个结构放在数据结构课程最前面的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈和队列到底在描述什么:从生活场景到抽象模型
抽象概念之所以难学,是因为它们刻意剔除了生活化的东西。我们反着来,先把生活场景立起来,再往抽象模型上靠。
2.1 栈:后进先出的叠盘子模型
栈可以想象成一个只能从顶部操作的弹簧储物筒,就像自助餐厅里那种放盘子的装置。你往里放的盘子会压在最上面,取出时也只能先取最上面那个。
这句话翻译成更标准的说法就是:栈是一个只允许在表尾进行插入和删除操作的线性表。 这个“表尾”就是我们常说的栈顶,表头叫栈底。往栈顶放元素叫入栈(push),从栈顶取元素叫出栈(pop),只看栈顶元素不取走叫取栈顶(top)。
我见过不少初学者会问:为什么栈偏偏不让你从中间或者底部操作?原因很简单——限定操作就是栈的核心价值。 如果允许随意从中间取元素,那栈就退化成数组了,也就失去“后进先出”这个确定性了。很多程序设计上的灵活性其实来源于限制,而不是放开。
2.2 队列:先进先出的排队模型
队列就是现实中的排队。你排在队尾,队首的人先离开,新来的人只能站到队尾。
在数据结构里,队列被定义为只允许在表的一端插入、在另一端删除的线性表。插入的一端叫队尾(rear),删除的一端叫队首(front)。入队操作叫enqueue,出队操作叫dequeue。
有一个细节值得特别留意:在很多实现里,“队首指针”指向的是队首元素所在的位置,而“队尾指针”往往指向队尾元素的下一个空位。这个细微的差异在写循环队列时会直接影响判空和判满的写法,后面我会专门展开。
2.3 抓住骨架:受限的线性表,通用的三种操作
如果只记两句话,那就是:栈和队列都是操作受限的线性表,栈是后进先出,队列是先进先出。 如果用数学化的方式描述,它们都支持三种基本操作:插入、删除、获取当前可操作的端点元素。
这个“操作受限”的定位非常关键。同样是存一组数据,数组和链表允许你随意访问任意位置,而栈和队列故意设置了一道门,只留一个出入口或两个独立的出入口。这种限制带来的直接好处是行为的确定性——任何人在任何时候对一个栈执行pop,得到的一定是最后进去的那一个;对一个队列执行dequeue,得到的一定是最早进去的那一个。这种确定性在并发编程、系统调度、数据流处理等场景里价值极高。
3. 从零手写实现:顺序存储与链式存储完整拆解
概念讲完就该动手了。C++实现栈和队列,底层你只有两种选择:用数组(顺序存储)或链表(链式存储)。我建议两种都至少手写一遍,因为你在理解“动态扩容”“环形缓冲”“指针操作”这些关键点的时候,动手过程是不可替代的。
3.1 顺序栈:数组+栈顶下标,最简单的数据结构实现
顺序栈的底层是一个数组,再加一个记录栈顶位置的变量。我们规定top的初值为-1,表示空栈;每次入栈,先top++,再把元素放进数组;每次出栈,取出arr[top],再top--。
cpp复制#include <iostream>
#include <stdexcept>
class SeqStack {
private:
int* data;
int capacity;
int top; // 栈顶下标,-1表示空栈
public:
SeqStack(int cap = 128) : capacity(cap), top(-1) {
data = new int[capacity];
}
~SeqStack() {
delete[] data;
}
void push(int val) {
if (top == capacity - 1) {
// 扩容:数据结构和算法里常见的“倍增”策略
int newCap = capacity * 2;
int* newData = new int[newCap];
for (int i = 0; i <= top; ++i) {
newData[i] = data[i];
}
delete[] data;
data = newData;
capacity = newCap;
}
data[++top] = val;
}
void pop() {
if (empty()) {
throw std::runtime_error("pop from empty stack");
}
--top;
}
int peek() const {
if (empty()) {
throw std::runtime_error("peek from empty stack");
}
return data[top];
}
bool empty() const {
return top == -1;
}
int size() const {
return top + 1;
}
};
这里有几个为什么可以展开讲一下:
top为什么初始化为-1?因为这样push时先加一再存,逻辑刚好和数组下标对应;如果初始化为0,就得先存再加一,两种都行但写法要统一,否则很容易产生差一错误。- 为什么扩容选择翻倍而不是加一?因为加一扩容会导致频繁的复制操作,摊还复杂度会变成O(n);翻倍扩容能让多次push的均摊复杂度维持在O(1)。
peek和pop为什么要分离?因为在实际场景里,很多时候只需要查看栈顶元素而不希望改变栈的状态,比如括号匹配算法里就要反复查看已入栈的符号来和当前符号匹配。
3.2 链栈:用链表头插法实现,不需要扩容
顺序栈虽然简单,但它的数组容量在初始化时就得定好,即使做了动态扩容,大对象拷贝的时候也会有成本。链栈则完全没有这个问题,它本质上就是一条单链表,只在头部插入和删除。
如果你熟悉C++的STL,可以这么理解:std::stack的默认底层容器是deque,但你可以显式传入std::list或std::vector作为底层容器。自己手写链栈,其实就是用一个链表封装出栈的接口。
cpp复制#include <iostream>
#include <stdexcept>
struct Node {
int val;
Node* next;
Node(int v, Node* n = nullptr) : val(v), next(n) {}
};
class LinkStack {
private:
Node* head; // 始终指向栈顶节点
int count;
public:
LinkStack() : head(nullptr), count(0) {}
~LinkStack() {
while (head) {
Node* tmp = head;
head = head->next;
delete tmp;
}
}
void push(int val) {
head = new Node(val, head);
++count;
}
void pop() {
if (empty()) {
throw std::runtime_error("pop from empty stack");
}
Node* tmp = head;
head = head->next;
delete tmp;
--count;
}
int peek() const {
if (empty()) {
throw std::runtime_error("peek from empty stack");
}
return head->val;
}
bool empty() const {
return head == nullptr;
}
int size() const {
return count;
}
};
链栈的优势在于永远不会因为容量不足而需要复制整个容器,代价是每个节点多存一个指针,而且节点在内存里不连续,遍历和缓存友好性都不如数组。性能上,顺序栈通常比链栈快,但在无法预知最大容量、或者元素本身很大的场景下,链栈更省心。
3.3 顺序队列的一个大坑:为什么必须改成环形队列
如果直接用数组实现队列,会出现一个很尴尬的情况:不停地入队出队,front和rear都在往数组末尾方向移动,前面空出来的位置根本用不上,没多久rear就碰到数组边界了,看起来队列“满了”,可实际上数组前一半都是空的。
解决思路很自然:把数组首尾相接成环,让rear绕回数组开头继续用。 这就是环形队列。它并不神秘,逻辑上就是把一个一维数组当成一个环来处理,取模操作%负责下标绕圈。
3.4 环形队列精讲:判空、判满、长度计算
环形队列的实现细节比较多,我把完整代码放在这里,然后逐条解释容易搞混的地方。
cpp复制#include <iostream>
#include <stdexcept>
class CircleQueue {
private:
int* data;
int capacity;
int front; // 队首元素下标
int rear; // 队尾元素的下一个空位下标
public:
CircleQueue(int cap = 10) : capacity(cap), front(0), rear(0) {
data = new int[capacity];
}
~CircleQueue() {
delete[] data;
}
bool empty() const {
return front == rear;
}
bool full() const {
return (rear + 1) % capacity == front;
}
void enqueue(int val) {
if (full()) {
throw std::runtime_error("queue is full");
}
data[rear] = val;
rear = (rear + 1) % capacity;
}
int dequeue() {
if (empty()) {
throw std::runtime_error("queue is empty");
}
int val = data[front];
front = (front + 1) % capacity;
return val;
}
int peek() const {
if (empty()) {
throw std::runtime_error("queue is empty");
}
return data[front];
}
int size() const {
return (rear - front + capacity) % capacity;
}
};
判断队列空很简单,front == rear就是空。但判断队列满就不能用front == rear了,因为那样和空队无法区分。所以这里采用了一个常见策略:牺牲一个存储单元,让rear再走一步如果碰到front就认为队满。 也就是说数组实际能存capacity - 1个元素。
队列长度为什么是(rear - front + capacity) % capacity?因为正常情况下rear >= front,长度就是rear - front;但环形队列绕了一圈之后可能rear < front,这时候直接相减是负数,加一个capacity再取模就是正确长度。这条公式我建议死记下来,真的非常常用。
如果你想把这个“浪费一个格子”的缺点也省掉,可以用一个count变量记录当前元素个数,入队count++、出队count--,判空判满就变成count == 0和count == capacity。我给初学者推荐先掌握牺牲一个格子的写法,因为它能更好地训练对下标循环的理解。
3.5 链队:需要同时维护队首和队尾指针的链表
链式队列和链栈很相似,唯一的不同是栈只在一端操作,维护一个头指针就够;队列在两端操作,因此需要两个指针——一个指向队首节点,另一个指向队尾节点。
cpp复制#include <iostream>
#include <stdexcept>
struct QueueNode {
int val;
QueueNode* next;
QueueNode(int v, QueueNode* n = nullptr) : val(v), next(n) {}
};
class LinkQueue {
private:
QueueNode* front; // 队首指针
QueueNode* rear; // 队尾指针
int count;
public:
LinkQueue() : front(nullptr), rear(nullptr), count(0) {}
~LinkQueue() {
while (front) {
QueueNode* tmp = front;
front = front->next;
delete tmp;
}
}
void enqueue(int val) {
QueueNode* node = new QueueNode(val);
if (rear) {
rear->next = node;
} else {
front = node;
}
rear = node;
++count;
}
int dequeue() {
if (empty()) {
throw std::runtime_error("dequeue from empty queue");
}
QueueNode* tmp = front;
int val = tmp->val;
front = front->next;
if (front == nullptr) {
rear = nullptr;
}
delete tmp;
--count;
return val;
}
int peek() const {
if (empty()) {
throw std::runtime_error("peek from empty queue");
}
return front->val;
}
bool empty() const {
return front == nullptr;
}
int size() const {
return count;
}
};
这里的一个易错点是dequeue时,如果删掉了最后一个节点,front变成nullptr,必须同时把rear也置空,否则后续enqueue时会因为rear还指向已被释放的内存而出错。
3.6 顺序与链表,到底怎么选:一张对照表
从学习角度,两种实现都要会写;从实际工程角度,可以按下面的逻辑来选。
| 维度 | 顺序存储(数组) | 链式存储(链表) |
|---|---|---|
| 内存布局 | 连续内存,缓存友好 | 节点分散,缓存不友好 |
| 扩容 | 需要重新分配并复制 | 天然动态,不涉及整体复制 |
| 随机访问 | O(1) | O(n) |
| 内存占用 | 可能浪费未使用空间 | 每个节点多一个指针开销 |
| 适用场景 | 容量可预估、对性能敏感 | 容量不确定、元素大而复杂 |
我个人的实践经验是:在做算法题和纯计算任务时优先用顺序结构,性能稳定可控;在做业务代码里需要频繁增删、且数据量无法预估时,链式结构写起来更省心。现代C++里你完全可以直接用std::deque当底层容器来组装std::stack和std::queue,但了解底层实现机制仍然很重要——面试问你不问只会用,是减分项。
4. 栈和队列挂在真实业务上的样子:从括号匹配到线程池
如果本章只讲“栈和队列是什么”就收手,那这篇文章和教材也没区别了。现在我们把目光转向实际应用场景。学数据结构最爽的时刻,就是你突然发现某个经典算法或者项目里的某个模块,原来底层就是这些简单结构的组合。
4.1 算法题里的栈:括号匹配、表达式求值与单调栈
括号匹配是最入门的栈应用,核心思想是:遇到左括号就入栈,遇到右括号就检查栈顶是否是匹配的左括号,如果不是直接判定非法;扫描完整个字符串后,栈必须为空才说明所有括号都被正确匹配。
cpp复制#include <string>
#include <stack>
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 == '{')) {
st.pop();
} else {
return false;
}
}
}
return st.empty();
}
这个算法我经常用来给初学者演示栈的“撤销”能力——每匹配成功一对,就把栈顶信息撤掉,回到之前的状态。表达式求值(中缀转后缀)本质上也是一套更复杂的栈操作,理解括号匹配后那套逻辑就顺理成章了。
单调栈是栈的进阶用法。它的特点是栈内元素从栈底到栈顶保持单调递增或递减。经典问题是“下一个更大的元素”:给定一个数组,求每个元素右边第一个比它大的元素下标。暴力做是O(n²),用单调栈可以压到O(n)。核心思路是维护一个递减栈,遍历数组时不断把不满足单调性的元素弹出,弹出的瞬间就是它找到了右侧第一个更大元素。
我在面试中见过很多候选人,一看到“下一个更大/更小”“滑动窗口最值”这类题目就绕道走,其实只要熟练掌握了单调栈、单调队列的模板,这些题都是一马平川。
4.2 队列在搜索与调度里的角色:BFS与层级遍历
**广度优先搜索(BFS)**天然就是队列的主场。BFS要求先访问的所有节点,下一轮访问时依然按“先进先出”的顺序扩展。
cpp复制#include <queue>
#include <vector>
void bfs(const std::vector<std::vector<int>>& graph, int start) {
std::queue<int> q;
std::vector<bool> visited(graph.size(), false);
q.push(start);
visited[start] = true;
while (!q.empty()) {
int cur = q.front();
q.pop();
// 处理当前节点
for (int next : graph[cur]) {
if (!visited[next]) {
visited[next] = true;
q.push(next);
}
}
}
}
BFS与队列的绑定是必然的——先进来的节点必须先扩展,才能保证按层向外铺开。二叉树层序遍历、最短路径问题、拓扑排序,底层都是同一套逻辑。
4.3 工程里的“消息队列”和这里的队列,是一回事吗
很多人在后端开发里听到“消息队列”这个词,会误以为它就是我们数据结构里学的队列。实际上,消息队列(Message Queue)是一个更复杂的中间件概念,它解决的是分布式系统里服务间通信和异步解耦的问题,里面往往包含网络通信、持久化、消费确认、重试机制等一大堆内容。但从最朴素的角度看,一个最简单的消息队列,本质确实就是一个能跨进程共享的队列结构。
与数据结构队列更贴近的工程概念是阻塞队列(BlockingQueue)。Java的ArrayBlockingQueue、C++里用std::condition_variable配合std::queue实现的线程安全队列,本质上就是给队列加上了“满则等待,空则等待”的能力。热搜词里“线程池的阻塞队列选择”问的就是这个东西——线程池中的任务队列需要让多个生产者线程往队列里提交任务,多个消费者线程从队列里取任务执行,这个队列不仅要满足先进先出,还必须处理并发竞争和阻塞唤醒。
所以在学会纯数据结构队列之后,再去看生产者消费者模型、线程池实现,你会发现原来内核你已经吃透了。
4.4 浏览器、编辑器、GUI事件循环:你身边的栈和队列
栈的经典应用离我们很近:浏览器的后退按钮就是把访问过的页面压入一个栈,每访问新页面就入栈,点击后退就是出栈。文本编辑器的撤销功能同理,每步操作压栈,Ctrl+Z就是pop。Qt里的消息循环本质上是事件队列,鼠标点击、键盘输入、定时器事件都会排队等待主循环逐个分发。
你可能觉得这些例子太“小”,但它们揭示了一个规律:凡是需要“回到上一个状态”的场景,几乎都能用栈;凡是需要“按到达顺序处理”的场景,几乎都能用队列。 这个心智模型建立起来之后,你面对一个不熟悉的需求时,第一反应就不再是“用什么高大上的技术”,而是“这个流程到底像栈还是像队列”。
5. 踩坑实录:写栈和队列时最容易翻车的四个细节
说实话,栈和队列的逻辑本身不难,真正让初学者掉头发的是一些实现层面的细节问题。这些坑我自己基本都踩过,这里集中拿出来说一遍,能帮你省下大量调试时间。
5.1 环形队列的rear到底指向哪,直接决定你的一切逻辑
前面我在代码注释里特意写了一句:rear表示“队尾元素的下一个空位”。这句话是理解环形队列的关键。
有些教材里把rear定义成队尾元素本身,这样一来入队时就要先移动rear再赋值,出队时取的也是found位置,判空和判长的公式也全都变了。如果某天你在看别人代码时发现长度公式和你背的不一样,先别急着争对错,检查一下ta的rear指向的是元素还是空位。
我的建议是:统一用“rear指向下一个空位”这套约定,因为入队时不需要特殊处理空队情况,代码写起来更自然。
5.2 判空判满前的越界:最容易出现的段错误和死循环
环形队列里最容易犯的错误就是:没有判空就dequeue,没有判满就enqueue。虽然我的示例代码里都会显式抛出异常,但很多初学者自己写的时候会省略检查,导致出现“操作了未初始化内存”“环被写穿了”这类问题。
真实代码里还有一种情况:把front和rear拿去取模时,直接写成rear + 1 == front,而忘了取模。如果rear在数组末尾还要绕回开头,这个判断就是错的,必须写成(rear + 1) % capacity == front。这种差一错误真的很难肉眼发现,调试的时候建议打印每一步的front、rear和取模后的值。
5.3 直接存放对象时的拷贝开销:为什么有时候代码会变慢
如果用我自己上面的int版本写栈,性能问题不大。但如果你用顺序栈直接存放比较大的对象,比如std::string或自定义的复杂类,入栈出栈会频繁触发拷贝构造和移动构造。如果对象没有被正确实现移动语义,拷贝开销会非常可观。
解决办法有两条:一是栈里存指针或智能指针,而不是直接存对象;二是给顺序容器预留足够的容量,减少扩容时的复制次数。C++的std::stack底层用std::deque作为默认容器,一个原因就是deque在扩容时对已有元素的影响比vector小得多,在栈这种频繁push/pop的场景里更稳。
5.4 递归太深,栈爆了怎么办
第一节提到过函数调用栈溢出,这里给一个实际可操作的缓解方案:如果递归深度可能很大(比如超过几十万层),直接把它改写成显式的栈结构,用std::stack模拟递归调用。也就是说,你不再依赖系统栈去压入函数调用帧,而是自己控制一个数据结构来保存“待处理的状态”。
cpp复制struct Frame {
int state;
int value;
};
int sumIterative(int n) {
std::stack<Frame> st;
st.push({0, n});
int result = 0;
while (!st.empty()) {
Frame& f = st.top();
if (f.state == 0) {
if (f.value == 0) {
result = 0;
st.pop();
} else {
f.state = 1;
st.push({0, f.value - 1});
}
} else {
result += f.value;
st.pop();
}
}
return result;
}
这段代码演示的不是最优解法(纯递归也不是),而是展示“如何把递归改写成显式栈”这个通用手法。显式栈开销可控、容量限制比系统栈宽松得多,这也是很多高性能框架里不用递归的原因之一。
6. 适合自己的练习路线:从实现到应用的分阶段自测
说到底,数据结构是一门“看懂了不算会,能写出来才算懂一半,能用它解决真实问题才算真会”的课程。我建议你按下面的路径进行练习,每一阶段都有比较明确的检验标准。
阶段一:手写四种结构,不加STL。 顺序栈、链栈、环形队列、链队,各写一遍。写完后用随机序列做一次压测,验证push/pop全过程的正确性。
阶段二:给结构加一个新功能。 比如给栈加一个getMin(),要求O(1)时间;给队列加一个max(),同样是O(1)。这些扩展会让你重新思考数据结构内的信息组织方式。
阶段三:做经典算法题。 括号匹配、十进制转二进制(用栈)、走迷宫BFS最短路径、二叉树层序遍历。每道题尽量自己先想,再对题解,重点看别人的实现里如何处理边界条件。
阶段四:写一份实验报告。 把环形队列和链队做一个性能对照实验,记录不同操作规模下的耗时,分析为什么顺序结构在缓存局部性上占优。这份报告去当数据结构课的实验作业完全够用,也方便期末复习时快速找回记忆。
自测的时候可以设计一些小实验,比如实现一个“检查括号是否匹配”的命令行工具,或者一个“用队列模拟银行叫号”的小程序,再烂的需求都能逼你去处理边界条件。我自己带新人时最喜欢问一个问题:“你的栈在空的时候调用pop会发生什么?”,能答上来的人,基本就把这个数据结构掌握得差不多了。
说点题外话。每次带学生或者带新人学数据结构,我发现进步最快的人都有一个共同习惯:拿到一个新结构不是去背代码,而是先动手画图。 画三四张图把空栈、入栈、出栈、满栈的状态转变搞清楚,再去看数组下标的移动方式,代码自然就能写出来了。栈和队列虽然简单,但它们是后续学习二叉树、图、递归等内容的基石,在这一章多花点时间,后面会走得顺畅很多。
