栈和队列,这两个名字在数据结构里算是基础中的基础,但后面所有复杂的算法、系统设计,几乎都能拆解成它们的组合。我这些年见过太多人把代码背得滚瓜烂熟,一到写业务逻辑就不知道自己到底用的是什么,更别提在最该用栈的地方拿数组硬顶,或者明明确实是先进先出的场景却用了栈导致一锅粥。所以这篇就把这两个结构的逻辑、实现、应用、进阶玩法以及我实操中踩过的坑一次讲清楚,包括代码细节和背后的“为什么”,只要你看完,起码以后提起栈和队列不会是“哦我学过”,而是“我知道这玩意儿能怎么用、什么时候用、出问题怎么查”。
这次的内容不只是面向刚学数据结构的学生,也适合那些想补基础、准备面试,或者写了几年业务代码又回来夯实内功的开发者。我不搞那种看一眼图就过去的伪解析,所有关键操作都配上可以直接复制的代码和完整的推导过程。至于常见的坑,比如循环队列为什么一定要浪费一个空间、top指针到底该从-1开始还是0开始、递归爆栈到底是怎么回事,这些也会一个个展开聊。
1. 栈和队列到底在解决什么问题,为什么非要用它们
1.1 栈:后进先出的“历史回放”机制
栈很像一个只有顶部开口的箱子,你往里面放东西只能放到最上面,取东西也只能从最上面取。专业说法就叫后进先出(LIFO,Last In First Out)。这个特性决定了栈特别适合做所有跟“回退”“回溯”相关的事情。
我举几个现实里天天碰到的例子。代码编辑器里的撤消操作(Ctrl+Z),就是典型栈结构:你每一步操作被压入栈中,每撤消一次就从栈顶弹出一个操作,所以撤消的顺序永远是你操作顺序的反向。浏览器后退也是栈:你访问的每个页面被依次压栈,后退就是弹栈。递归调用更是如此,函数A调用函数B,B调用函数C,CPU里的调用栈会把C放在最顶层,B次之,A在底层,C返回时先被销毁,然后B再到A,完全的后进先出。没有栈,递归这种优雅的编程方式根本无法实现。
看一个最小可用的栈定义。核心操作就五个:压栈(push)、弹栈(pop)、取栈顶(peek/top)、判空(isEmpty)、判满(如果是固定容量)。时间复杂度上,数组实现的压栈弹栈O(1)平均开销,链表实现也是O(1),这个效率是栈能成为基座结构的原因之一。空间上,要额外存的只是几个指针或计数器,收益却远超开销。
对于很多初学者来说,最难理解的是top指针从哪开始。有人从0开始,有人从-1开始。其实两种都没错,关键是所有操作逻辑要自洽。我习惯让top指向栈顶元素的位置,初始为-1表示空栈,压栈时先++top再放入,弹栈时先取data[top]再--top。如果初始为0,那压栈就变成data[top++]=value,取栈顶是data[top-1]。两种思路实际就是“指针指向元素”与“指针指向下一个空位”的差别,选择哪一种并不重要,重要的是别一会儿指向元素一会儿指向空位,那是调试地狱的开始。
1.2 队列:先进先出的“接力棒”模型
队列的特性则是先进先出(FIFO),就像食堂排队打饭,先排的人先打饭,后来的排在队尾。相比栈那种“到底取谁”的简单直接,队列天然就跟“顺序”“等待”“缓冲”这些概念绑定。
操作系统里的任务调度,CPU分配时间片给进程,进程按到达顺序排队,先到的先获得服务,是队列。打印任务列表、网络数据包传输、消息中间件里的消息在服务端堆积,全部是队列模型。队列还天然适合做生产和消费的缓冲层——生产数据的速度可能瞬时远超消费速度,队列把波峰波谷削平,让下游系统不至于被打崩。
队列的核心操作:入队(enqueue)、出队(dequeue)、取队头(front)、判空(isEmpty)。实现上比栈稍微复杂一点,因为需要同时维护队头和队尾两个位置。一个细节需要留意:假如用数组从头到尾依次存放元素,那么每出队一个元素,后面的所有元素都往前挪一位,是O(n)操作。大多数人第一次接触队列的时候都掉进过这个坑里,写出来的队列出队一次就要循环整体前移。
更好的方案是用循环队列思路:不让元素物理前移,而是让front指针往后移动,等到数组尾部没位置时,再绕到头部寻找空位。这样入队出队都变成O(1),代价是代码复杂度和边界条件增多。下文的代码会专门演示循环队列,因为这是面试和课程作业最喜欢考的点。
1.3 栈还是队列?一张决策表讲清楚
判断一个场景用栈还是队列,核心追一问:数据的访问模式是什么?后进来的先用,是栈;先进来的先用,是队列。
| 维度 | 栈 | 队列 |
|---|---|---|
| 访问顺序 | 后进先出(LIFO) | 先进先出(FIFO) |
| 修改元素位置 | 仅栈顶 | 队尾插入,队头删除 |
| 典型场景 | 函数调用、括号匹配、表达式计算、撤消操作 | 缓存队列、消息队列、广度优先搜索 |
| 线性实现时的核心关注点 | top指针、栈满 | front/rear指针、空满判断 |
| 当数据有“优先级”时 | 不适合 | 优先队列(堆)是变体 |
我之前带过的一个实习生做过一个批处理系统,一个模块负责接收订单数据,另一个模块负责分析。他一开始用栈来暂存订单,结果最新来的订单总是最先被分析,老订单一直积压。换成队列后,系统瞬间就“正常”了。很多类似的问题其实都不是复杂度问题,而是结构就没选对。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手把手实现:数组与链表的双方案,核心细节全解
2.1 数组实现栈,每一步都不能马虎
先上最简洁的数组栈代码,C语言版,后面讲解关键决策。假设是整数栈。
c复制#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int top; // top指向栈顶元素位置
} Stack;
void initStack(Stack *s) {
s->top = -1; // 空栈
}
int isEmpty(Stack *s) {
return s->top == -1;
}
int isFull(Stack *s) {
return s->top == MAX_SIZE - 1;
}
void push(Stack *s, int val) {
if (isFull(s)) {
printf("栈满,无法压栈 %d\n", val);
return;
}
s->data[++(s->top)] = val;
}
int pop(Stack *s) {
if (isEmpty(s)) {
printf("栈空,无法弹栈\n");
return -1;
}
return s->data[(s->top)--];
}
int peek(Stack *s) {
if (isEmpty(s)) {
printf("栈空,无法取栈顶\n");
return -1;
}
return s->data[s->top];
}
几个容易被忽略的细节。
第一,data[++(s->top)] 这种写法会先自增top再取下标,正是top初始为-1时指针指向栈顶元素的标准做法。如果你改成data[s->top++],那top初始应为0,pop时候则要data[--s->top]。两种写法实现最终效果一样,但混用必出bug。
第二,栈满的判断。top == MAX_SIZE - 1 等价于栈里元素数量是top + 1个,第MAX_SIZE个位置是最后一个下标MAX_SIZE-1。当top等于MAX_SIZE-1时,说明数组的最后一个位置都被占用了。因为数组下标从0开始,容量是100,有效下标是0到99,所以栈满时top必然等于99,即MAX_SIZE-1。
第三,pop时要不要把原位置的数据清掉?严格来说不需要,因为下次push会覆盖。但如果你栈内存的是指针、对象引用,那建议手动置空,防止内存泄漏或悬垂引用。这是很多写业务代码的朋友容易忽视的地方。
2.2 数组实现循环队列:指针问题和绕圈逻辑
循环队列是整个数据结构里最容易写崩的一个模块,没有之一。先看一个标准实现:
c复制#define MAX_SIZE 5 // 故意用个小容量方便理解
typedef struct {
int data[MAX_SIZE];
int front; // 队头下标,指向队头元素
int rear; // 队尾下标,指向队尾元素的下一个位置
} Queue;
void initQueue(Queue *q) {
q->front = 0;
q->rear = 0;
}
int isEmpty(Queue *q) {
return q->front == q->rear;
}
int isFull(Queue *q) {
return (q->rear + 1) % MAX_SIZE == q->front;
}
void enqueue(Queue *q, int val) {
if (isFull(q)) {
printf("队列满,无法入队 %d\n", val);
return;
}
q->data[q->rear] = val;
q->rear = (q->rear + 1) % MAX_SIZE;
}
int dequeue(Queue *q) {
if (isEmpty(q)) {
printf("队列空,无法出队\n");
return -1;
}
int val = q->data[q->front];
q->front = (q->front + 1) % MAX_SIZE;
return val;
}
int frontValue(Queue *q) {
if (isEmpty(q)) {
printf("队列空\n");
return -1;
}
return q->data[q->front];
}
这里有个核心且必须理解的设计:front == rear 代表队列空,(rear + 1) % MAX_SIZE == front 代表队列满。为什么满了不能是rear == MAX_SIZE - 1之类的?因为如果不做任何预留,当队列满时front和rear会指向同一个位置,就没办法区分空队列和满队列了。因此循环队列在数组实现里,往往需要牺牲一个存储单元,最多存MAX_SIZE-1个元素。
rear指向的是“下一个待插入的位置”,不是最后一个元素本身。换句话说,enqueue之后rear会再往后挪一位。这个约定能保证空队列状态下front和rear相等,逻辑最为简洁。如果你让rear指向最后一个元素,那空队列时rear应该等于front - 1,条件判断会很绕。
还需要留意的是% MAX_SIZE取模操作。这是循环的核心,每挪动一步都要先判断是否到了数组末尾,到了就绕回0。C语言里%对正整数没问题,但如果front或rear是负数就会出问题,所以初始化一定从0开始,取模之后永远是非负的。
我把容量设为5,实际最多只能存4个元素。很多同学第一次跑这个代码都疑惑,不是开5个位置吗?怎么只能存4个?这就是上面说的“牺牲一个单元”来区分空满。如果你希望全部容量都能用,就得额外加一个size字段记录当前元素个数,或者加一个flag标记,但那样边界判断更多,新手更容易崩。
2.3 链表实现:什么时候用链表版本
数组栈和数组队列的问题在于容量固定,一旦压满就凉了。链表版本的优势就在于动态扩容,按需分配节点。以链表栈为例:
c复制typedef struct Node {
int data;
struct Node *next;
} Node;
typedef struct {
Node *top;
} LinkedStack;
void pushLinked(LinkedStack *s, int val) {
Node *newNode = (Node *)malloc(sizeof(Node));
if (newNode == NULL) {
printf("内存分配失败\n");
return;
}
newNode->data = val;
newNode->next = s->top;
s->top = newNode;
}
int popLinked(LinkedStack *s) {
if (s->top == NULL) {
printf("栈空\n");
return -1;
}
int val = s->top->data;
Node *tmp = s->top;
s->top = s->top->next;
free(tmp);
return val;
}
链表栈的压栈,是把新节点插在头部,head就是栈顶。弹栈删除头部并返回数据。因为是动态分配,理论上没有栈满概念,只要内存够。代价是每个节点都有一个next指针的额外内存开销,以及malloc的分配耗时。链表队列入队在尾部,出队在头部,也类似。
那到底什么时候用链表?我的建议是:数据规模不确定、需要频繁增删且删的不是中间节点,用链表。数据规模可以预判、追求极致的CPU缓存访问效率,用数组。循环队列和数组栈的连续内存对CPU缓存友好,遍历和随机访问性能比链表高一个量级。写高性能组件时,数组版本推荐优先考虑。
2.4 工程上的封装思路:别让具体操作散落各处
在真实工程项目里,很少像上课那样直接操作底层的数组或者Node。一般会抽一层抽象接口,比如:
c复制typedef struct Stack Stack; // 抽象类型
Stack *stack_create(void);
void stack_destroy(Stack *s);
void stack_push(Stack *s, void *data);
void *stack_pop(Stack *s);
int stack_size(Stack *s);
之所以要把接口和实现分离,是为了让上层调用代码不依赖具体实现。今天用数组写,明天改成链表,上层代码一行不用动。这也是数据结构课程里反复强调,但很多人到写工程才体会到的东西。
如果你是写Java或Python的,比如Java里ArrayDeque、LinkedList都能当栈和队列用,Python的list配合append/pop就是栈,collections.deque配合append/popleft就是队列。直接用成熟的容器类没有任何问题,但底层的指针逻辑依然需要懂,否则遇到复杂度问题、或者要自己实现特殊版本(比如支持扩容的栈),就无从下手。
3. 经典应用场景与实战:从括号匹配到系统设计
3.1 括号匹配:一道必会的入门题
括号匹配是栈最经典的入门场景。给定一个只包含(){}[]的字符串,判断括号是否匹配。
思路:遇到左括号(( { [)就压栈;遇到右括号,检查栈顶是否是对应的左括号,如果是就弹栈,如果不是或栈已经空,则匹配失败。遍历完整个字符串后,栈必须为空才说明所有左括号都找到了配对。
c复制int isValidBrackets(const char *s) {
Stack st;
initStack(&st);
for (int i = 0; s[i] != '\0'; i++) {
char c = s[i];
if (c == '(' || c == '{' || c == '[') {
push(&st, c);
} else {
if (isEmpty(&st)) return 0;
char topVal = pop(&st);
if ((c == ')' && topVal != '(') ||
(c == '}' && topVal != '{') ||
(c == ']' && topVal != '[')) {
return 0;
}
}
}
return isEmpty(&st);
}
注意三种括号混用时,必须用栈而不是用计数器。计数器只能统计数量,无法判断顺序。比如([)],左右数量都对,但顺序是错位的,栈就能检测出来,计数器不行。这是我每次面试必考点之一,也是区分到底理不理解栈这个结构的试金石。
3.2 浏览器的前进后退:双栈模型
浏览器的前进后退并不是一个栈,而是两个栈。实现逻辑非常有意思,用两个栈back和forward来管理历史。
- 当访问新页面时,把当前页压入back栈,并清空forward栈。
- 点击后退时,把当前页从back栈弹出,压入forward栈,然后显示弹出的页面。
- 点击前进时,从forward栈弹出,压入back栈,显示页面。
- back栈和forward栈都为空,则不能后退或前进。
整个模型可以用栈的表达来理解:后退栈维护了已经访问过的页面顺序,前进栈维护了后退后又被弹出的那些页面。一旦有新页面被访问,前进栈必须清空,因为历史已经“分叉”,再前进就不是原来的那串路径了。
这个双栈模型在面试里出现概率很高。很多人在浏览器做了个后退之后,又开始访问新页面,误以为到处访问还能前进回旧页面,其实按照这个栈模型,那个旧路径已经从前进栈清空了,自然回不去,这是符合实际浏览器行为的。
3.3 表达式求值:中缀转后缀,栈的巅峰应用
表达式求值是另一个绕不开的栈应用。我们日常写的1+2*3是中缀表达式,计算机计算时需要转换成后缀表达式123*+,转换过程就是靠栈来管理操作符。
中缀转后缀的规则:遇到数字直接输出;遇到左括号压栈;遇到右括号弹栈并输出,直到遇到左括号(左括号不输出也不入栈);遇到操作符时,只要栈顶操作符优先级不低于当前操作符,就弹栈输出,然后把当前操作符压栈。结束后把栈里剩余操作符全部弹出。
按这个思路,1+2*3转换过程:
- 输出1
+入栈(栈为空)- 输出2
*入栈(*优先级高于+,所以不弹+)- 输出3
- 遍历结束,弹出
*、+,得到后缀表达式123*+
计算后缀表达式时再开一个操作数栈:遇到数字压栈,遇到操作符弹出两个数计算再压回栈。整个过程,操作符栈和操作数栈各司其职。很多人学编译原理时看着递归下降和状态机头大,其实最基础的计算器核心就藏在这个后缀转换里。
3.4 队列在真实系统中的位置:从任务对列到树的层次遍历
队列最常见的工程形态就是消息队列和任务队列。电商订单创建后,订单系统把数据塞进队列,库存系统、物流系统、积分系统各自从队列里取任务处理。这样做有几个直接好处:异步解耦、缓冲削峰、失败重试。数据先落地在队列里,不会因为某个下游系统挂了就导致整个订单链路失败。这种场景下,队列的特性就是先进先出,保证处理的先后顺序,消费者和生产者可以不同的速率运行,队列在中间用容量吸收压力波动。
树的层序遍历也是队列的经典应用。宽度优先遍历时,先把根节点入队,然后循环出队,并且把当前节点的左孩子和右孩子依次入队。这样每一层的节点会按照从左到右的顺序出队,恰好是层序。深度优先遍历用栈(或者递归函数隐式调用栈)来模拟,一个BFS一个DFS,一队列一栈,对应关系极其自然。
我试着实现过银行排队叫号系统的小Demo,本质就是队列。号码不停入队,窗口办完一个就出队一个。但真实系统还要考虑VIP客户优先,这就把基本的队列升级成了优先队列。优先队列底层通常是堆,不是普通队列,但应用思路依然是“队列”的延展。
4. 进阶玩法:单调栈与单调队列,面试加分项
4.1 单调栈解决“下一个更大元素”
很多准备过面试的朋友会碰到一个经典算法题:给定一个数组,找到每个元素右边第一个比它大的元素,没有就返回-1。朴素解法是两层循环,每个元素都向右扫描,时间复杂度O(n²)。数组长一点就撑不住。
单调栈的思路是维持一个栈,栈内元素严格单调递减(从栈底到栈顶)或者单调递增,取决于题目。以下一个更大元素为例,从左向右遍历:
- 当前元素小于栈顶元素时,直接压栈。
- 当前元素大于栈顶元素时,说明当前元素就是栈顶元素的“下一个更大元素”,记录结果并弹栈,然后继续比较新的栈顶,直到当前元素不大于栈顶或栈空。
- 最后把当前元素压栈。
python复制def next_greater_element(nums):
n = len(nums)
res = [-1] * n
stack = [] # 存元素下标,不是值
for i in range(n):
while stack and nums[i] > nums[stack[-1]]:
idx = stack.pop()
res[idx] = nums[i]
stack.append(i)
return res
这里存下标而不是值是个隐蔽但关键的细节。有些题目不仅要右边的值,还要右边元素的位置距离;存下标就能通用得到。而且判断用的是nums[stack[-1]]访问值,并不是直接拿栈里存的数比较,这样更灵活。单调栈让每个元素最多被压入和弹出一次,整体O(n),比起暴力两层循环是质的提升。
我记得有一个比较典型的面试演化是:不是简单找右边更大,而是求每个位置“左右最近更小元素的距离乘积”,这就是直方图最大矩形面积的基础思路。直方图最大矩形那题,核心也是单调栈维护高度的递增性。一旦理解单调栈在维护“最近更大/更小”关系,就懂了它跟堆栈处理回退逻辑是一脉相承的。
4.2 单调队列解决“滑动窗口最大值”
题目:给定数组和一个窗口大小k,滑动窗口每右移一步,求窗口内最大值。思路简单但别被O(nk)锁死,因为更大的数据规模需要O(n)。
单调队列的思路是让队列存储可能成为窗口最大值的元素下标,并且保证队列内元素对应的值单调递减。窗口滑动时:
- 移除窗口之外的下标,即如果队头下标小于等于当前窗口的左边界,就弹出。
- 移除队尾所有值小于等于当前新元素的元素,因为它们至少不比新元素更大,且在窗口内永远更先过期,没有保留价值。
- 新元素下标入队。
- 当前窗口最大值就是队头元素。
python复制from collections import deque
def max_sliding_window(nums, k):
dq = deque() # 存下标,值单调递减
res = []
for i, num in enumerate(nums):
# 移除窗口外的下标
while dq and dq[0] <= i - k:
dq.popleft()
# 移除队尾较小元素
while dq and nums[dq[-1]] <= num:
dq.pop()
dq.append(i)
# 当窗口形成后,记录最大值
if i >= k - 1:
res.append(nums[dq[0]])
return res
为什么队尾“小于等于”当前元素的都要清掉,而大于的要留着?因为滑动窗口是右移的,清掉的元素永远无法翻身,而队内剩下的那些大于当前值的元素,还有可能在后续窗口中继续做最大值。这些被清掉的元素确实还有机会成为后续某个窗口的最大值吗?答案是:不可能了。当前元素比它们更新且更大,窗口右移时它们会先过期,当前元素则更晚过期,所以它们没有任何继续存在的意义。这就是单调队列原理,把“过期且更小”的数据淘汰掉,剩下的始终是当前窗口里“最大且最晚过期”的候选者。
这个结构在计算机网络里也有影子,比如TCP拥塞控制的滑动窗口统计,虽然底层不是直接用单调队列,但这种维护“滑动窗口最值”的思想在很多实时统计系统里都能复用。
4.3 为什么说栈和队列是万物基座
学完单调栈和单调队列,很多人会意识到:栈不只处理“后进先出”的字面逻辑,它更大的威力在于维护“最近相关性”和“回退”。单调队列也不只是FIFO,它额外维护了一个有序的候选集,淘汰掉未来无用的数据。
所以在看很多源码时,看到栈或者队列不要只是“哦,这里用了栈”。要问自己:这个栈在维护什么顺序?栈顶代表什么?压栈/弹栈的时机是什么?队列的队头代表什么?哪里在淘汰数据?一旦能从这一层去读,就具备了从数据结构到算法设计的桥梁思维。
5. 常见问题与排查技巧实录
5.1 关于“栈溢出”,最常见的几个真凶
栈溢出在递归里最典型。每个递归调用都要在系统栈里压一帧函数信息,包括参数、局部变量、返回地址。如果递归层数过多,系统栈空间被压爆,直接报StackOverflow。排查这类问题,一看递归终止条件写没写对,二看递归深度是不是太大。
在实际业务代码里,我用过一个递归处理目录树的函数,数据层级很深时程序直接挂掉。排查步骤是先打印递归深度,发现在第几千层就崩了,确认是栈溢出而不是死循环后,改写成显式栈迭代,用一个Stack模拟递归压栈,问题解决。这里有两个常用技巧:
- 递归深度超过几千就要警惕(具体数量跟平台和栈大小有关)。
- 把递归改写成显式栈迭代,是处理未知深度递归的保底方案,比如可以用栈存储“待访问节点”挨个处理。
不过要注意,显式栈虽然能绕开系统调用栈,但堆上分配过大依然可能内存不足,仍要控制压栈规模。
5.2 循环队列的“假溢出”和空满判断,写错必现
前面提到循环队列牺牲一个空间来区分空满。如果不用这个方案,用size字段或者flag,逻辑也成立,但不要写一半混用一个方案。我见过有人同时用size字段和front==rear判断,结果队列空时size==0 && front==rear,队列满时front==rear但size>0,导致一些分支逻辑混乱,时不时的崩溃。
假溢出是说:用非循环普通队列时,rear跑到数组末尾,即使前面还有空位,也无法入队。循环队列就是为了解决这个问题。写的时候最容易错的是忘了取模,或者取模之前没加MAX_SIZE导致负数。假如用queue->rear = (queue->rear + 1) % MAX_SIZE这种写法,rear不会变成负数,就安全。
调试循环队列的小技巧:容量设置得特别小,比如5,然后全填满不停出队入队几百次,观察front和rear的值变化是否始终在0到4之间跳动,以及每次isEmpty和isFull判断是否符合预期。
5.3 top指针的“从-1开始”还是“从0开始”,混用就崩
栈写崩有个极高概率的原因是top的语义不统一。要么从头到尾都用“top指向已存元素”,要么用“top指向下一个空位”。如果压栈用一个,弹栈用另一个,就会出现压进去的值覆盖掉前一个值之类的诡异问题。
思考示例:初始化top=-1,压栈写data[top++] = value,第一次调用时data[-1]直接越界。反过来,初始化top=0,弹栈写return data[top--],会跳过第一个元素。这种问题特点就是本地测试几次没问题,数据一多样就崩。每个栈的函数开头都强行打印一次当前top的值,有助于看清操作序列到底怎么变的。
5.4 别拿队列当栈用,也别拿栈当队列用
这句话听起来像废话,但实际编码里特别容易发生。比如你写了一个请求处理链路,希望请求按先来后到处理,却用了某个封装好的容器Append/Last,实际是栈的逻辑,结果后到的请求先被处理。排查这种问题,最快的方法是日志里记录每个元素的入队顺序和处理顺序,看是否完全一致,如果不一致,就要怀疑到底层的容器类是不是队列语义。
还有更隐蔽的一种:“出队时队头总是返回最新插入的元素”——这种情况多数发生在有人用List实现队列但误把尾部当成了队头。检查代码时注意取出元素的那一端,必须和你插入元素的那一端是相对的两端,否则就是栈语义。
5.5 内存泄漏:链表版本最容易犯的错
链表栈和链表队列每个节点都是malloc出来的,不free就会泄漏。很多同学写链表栈弹栈只让top指针指向下一个节点,忘记free旧节点,长时间运行必然内存暴涨。正确写法一定先把旧节点地址保存到临时指针,移动top后再free。同理,销毁整个栈时需要循环弹栈并free所有节点,不能只free一个头节点。
数组版本的内存泄漏往往发生在动态扩容时。如果用realloc调整数组容量,扩容后忘掉更新容量字段,下一次push检查时依然用旧容量,就会出现边界误判。如果扩容时复制到新数组后没有释放旧数组,也是泄漏。动态扩容不止把逻辑写清楚,还要把清理旧内存的位置放对。
5.6 语言差异与实际容器选择的几个坑
不同语言里栈和队列的实现各有差异。Python中list.append/list.pop实现栈非常顺手,但如果在list头部插入删除,是O(n),因为要移动所有元素。Python中真正常用的队列容器是collections.deque,双向队列,两端的插入删除都O(1)摊销,适合高频入队出队。Java中Stack类官方注释都建议优先用ArrayDeque,因为Stack同步开销和继承机制设计都有些旧时代痕迹,ArrayDeque性能更好且接口完整。
还有一个很常见的坑:拿数组当作队列用时,很多人会随手pop(0),这在Python里直接让所有元素前移,O(n)。数据量小看不出问题,数据量上了百万就等着耗时爆炸。同样是“功能表现对”,复杂度却从O(1)变成O(n),这属于隐性的错误。
5.7 栈和队列在并发环境中的注意事项
并发环境下,普通栈和队列都不是线程安全的。多个线程同时push或pop,轻则数据丢失,重则崩溃。工程上要加锁,要么使用语言内置的线程安全版本,比如Java的ConcurrentLinkedQueue、LinkedBlockingQueue,Python的queue.Queue。其中阻塞队列还带容量限制,满时入队等待,空时出队等待,是生产者消费者模式的基础组件。
对于“高性能并发队列”,通常会用无锁队列(lock-free),利用CAS操作,尽量把并发压力打散。很多高性能消息中间件的底层就是无锁队列。学数据结构时如果还能意识到并发场景对结构本身的约束,就基本超越了纯粹“会写”的层面。
尾巴:数据结构的价值在“用得对”,而不只是“会写”
我不太喜欢把栈和队列当成面试八股来学。它俩一个是“回放历史”,一个是“维护秩序”,组合起来能解决大量真实世界的排队与回溯问题。我每年写代码时都会回到这两个最基础的结构上重做一遍实现,每次都会发现新的盲区。尤其当你看源码时越来越熟练,就会发现很多复杂架构里到处是栈和队列的影子。比如解析器里维护语法树节点访问的栈,比如并发框架里的任务队列,比如图形学里的深度缓冲都暗含LIFO思想。
如果你现在刚学会写栈和队列,建议先动手把数组栈、数组循环队列、链表栈各自手写一遍,故意把容量设小,把数据打印出来盯住每一步。等你能徒手写出一个不报错、不越界、内存不泄漏的版本,再往单调栈、BFS等进阶方向走,会顺很多。这个过程没法速成,但绝对是性价比极高的投资。
