1. 栈:比你想象中更常见的“后进先出”
如果你写过几行代码,或者正在学数据结构,那你一定绕不开“栈”这个词。很多教材喜欢把栈定义成“只允许在一端进行插入和删除操作的线性表”,然后画一堆箭头和方块。说实话,这种定义看完了容易忘,因为脱离了实际。
我更喜欢打一个比方:栈就像你办公桌上那一摞待处理的文件。你总是把新文件放在最上面,处理的时候也是从最上面开始拿。放在最下面的那份文件,往往要等到最上面所有文件都处理完,才有机会被翻到。这个“后来者居上,先来者垫底”的规则,就是栈的核心逻辑——后进先出,英文缩写 LIFO(Last In First Out)。
为什么这个东西重要?因为在真实的软件开发里,栈几乎无处不在。函数调用要用栈,浏览器的后退按钮要用栈,表达式求值要用栈,甚至你编辑器的“撤销”功能,底层也是栈。理解了栈,你才能真正看懂程序运行时发生了什么,而不是只会调接口。
这篇文章就围绕数据结构里那个经典题目“1-2栈”往下聊,完整拆解什么是栈、怎么用代码实现、有哪些应用场景,以及面试和考试里常踩的坑。不管你是刚接触数据结构的学生,还是想补基础的开发者,这篇文章都适合你。
2. 栈的核心概念:不只是“先进后出”这么简单
2.1 栈的本质与两个关键操作
栈本质上是一种操作受限的线性表。所谓“受限”,意思是它不提供随机存取的能力,你只能在一端——也就是栈顶,进行插入和删除。另一端叫栈底,通常固定不动。
栈只有两个最核心的操作:
- 入栈(Push):把元素放到栈顶。
- 出栈(Pop):把栈顶元素取走。
除此之外,还有一个常用操作叫 Peek(或者 Top),意思是看一眼栈顶元素,但不把它取出来。
这三个操作,基本上就构成了栈的全部。有些教材还会提到是否为空(isEmpty)和栈大小(size),但底层逻辑就是这老三样。
这里有个容易混淆的点:栈的实现方式并不影响它的逻辑特性。也就是说,“栈”是一种抽象逻辑结构,“数组”和“链表”是实现它的具体手段。就像你可以用木头做椅子,也可以用金属做椅子,但椅子的功能是一样的。
2.2 栈的物理实现:顺序栈与链式栈
栈在计算机里落地时,常见有两种方式:顺序栈和链式栈。
顺序栈说白了就是用一块连续的存储空间,通常是一个数组来存数据。栈顶指针指向当前栈顶元素的位置。入栈时,把元素写到指针所指位置,然后指针加一;出栈时,指针减一,然后把元素返回。
链式栈则是用链表来实现,每个节点包含数据和指向下一个节点的指针。栈顶就是链表的头节点,入栈相当于在头部插入节点,出栈相当于删除头节点。
这两种实现各有优劣,后面的章节我会给你完整的代码示例,到时候再展开对比。
2.3 一个生活化的类比帮你彻底记住
我再给你一个更精确的类比:餐厅里叠盘子。
想象后厨有一摞盘子,新洗好的盘子总是摞在最上面。客人取盘子时,服务员也是从最上面拿走一个。如果你往这摞盘子的最底部塞一个盘子,不仅费劲,而且完全不符合流程。
这就是栈的物理直觉:入口和出口是同一个位置,而且这个位置永远只处理“最后进来的那一个”。
3. 顺序栈的完整实现与关键代码解析
3.1 为什么先学顺序栈
顺序栈实现简单、容易理解,而且绝大部分情况下性能足够好,所以它是学习栈的首选。下面我用 Python 写一个完整的顺序栈实现,注释写在代码里,方便你边看边跟着敲。
class ArrayStack: def __init__(self, capacity=10): self.capacity = capacity # 栈的最大容量 self.data = [None] * capacity # 用固定大小数组存储数据 self.top = -1 # 栈顶指针,初始为 -1 表示空栈 def push(self, value): if self.top >= self.capacity - 1: raise OverflowError("栈已满,无法入栈") self.top += 1 self.data[self.top] = value def pop(self): if self.top == -1: raise IndexError("栈为空,无法出栈") value = self.data[self.top] self.data[self.top] = None # 释放引用,避免内存泄漏 self.top -= 1 return value def peek(self): if self.top == -1: raise IndexError("栈为空,无法查看栈顶") return self.data[self.top] def is_empty(self): return self.top == -1 def size(self): return self.top + 1这段代码看起来简单,但有几个细节值得注意。
第一个细节是top指针的初始值为-1。很多初学者会疑惑,为什么不是 0?因为-1代表数组中还没有任何元素。当第一个元素入栈时,top变成 0,数据正好放在数组下标 0 的位置,这个设计非常巧妙。
第二个细节是出栈时把原位置置为None。这一步很多人会忽略,但如果不这样做,数组中会一直持有已经出栈对象的引用,在 Python 的 GC 机制下可能造成不必要的内存占用,在 Java 中则更加明显。这是一个很好的代码洁癖。
第三个细节是栈满和栈空的判断条件。栈满条件是top == capacity - 1,栈空条件是top == -1。这种边界条件写错了,程序就会莫名其妙出错,所以每次写栈都要刻意检查一遍。
3.2 动态扩容:让顺序栈不再固定死
上面的实现有个明显缺陷:一旦容量满了就不能再入栈。实际开发中,我们通常希望栈能自动扩容。本质思路是:当栈满时,申请一个更大的数组,把旧数据复制过去。
class DynamicArrayStack: def __init__(self, capacity=2): self.capacity = capacity self.data = [None] * capacity self.top = -1 def push(self, value): if self.top >= self.capacity - 1: self._resize(self.capacity * 2) self.top += 1 self.data[self.top] = value def _resize(self, new_capacity): new_data = [None] * new_capacity for i in range(self.top + 1): new_data[i] = self.data[i] self.data = new_data self.capacity = new_capacity # pop、peek、is_empty、size 与前面相同,此处省略扩容系数通常取 2,也就是每次容量翻倍。为什么是 2 而不是 1.5 或者其他?因为翻倍扩容能保证整体入栈操作的时间复杂度均摊为 O(1)。如果每次只增加一个位置,那么插入 n 个元素的时间复杂度会退化为 O(n²),这对于追求效率的算法题是致命的。
3.3 顺序栈的性能特点
顺序栈的入栈和出栈,时间复杂度都是 O(1),因为只需要移动指针和赋值数组元素。空间上,数组本身是有固定容量的,即使栈内元素很少,也要占用整块数组空间,所以空间利用率略差。但在绝大多数场景下,这个开销可以接受。
4. 链式栈的完整实现与对比
4.1 为什么还需要链式栈
顺序栈的缺点在于容量固定(不扩容时)或者扩容开销(扩容时)。链式栈则没有这个问题,理论上只要内存够用,它就能无限增长。
链式栈的本质是单链表,只在头部进行插入和删除。这也意味着它不需要维护数组下标,只需要维护一个头节点指针。
4.2 链式栈代码实现
class LinkedStack: class _Node: __slots__ = ("data", "next") def __init__(self, data, next_node=None): self.data = data self.next = next_node def __init__(self): self._head = None self._size = 0 def push(self, value): new_node = self._Node(value, self._head) self._head = new_node self._size += 1 def pop(self): if self._head is None: raise IndexError("栈为空,无法出栈") value = self._head.data self._head = self._head.next self._size -= 1 return value def peek(self): if self._head is None: raise IndexError("栈为空,无法查看栈顶") return self._head.data def is_empty(self): return self._head is None def size(self): return self._size链式栈的入栈,本质上就是链表的头插法。理解这句话,你就掌握了链式栈的全部精髓。
4.3 顺序栈和链式栈怎么选
很多初学者喜欢问:“到底哪个更好?”我的回答是:看场景。
如果提前能估算出最大容量,并且操作频繁,顺序栈更好,因为数组的缓存局部性好,访问速度快。如果栈的长度不稳定,可能很小也可能很大,那链式栈更灵活,不用担心扩容问题。
| 对比维度 | 顺序栈 | 链式栈 |
|---|---|---|
| 时间复杂度 | 入栈/出栈 O(1) | 入栈/出栈 O(1) |
| 空间开销 | 固定容量,可能浪费 | 每个节点多一个指针,额外开销 |
| 扩容机制 | 需要扩容,有复制开销 | 无需扩容,动态分配 |
| 适用场景 | 容量可预估、追求速度 | 容量不确定、需要灵活分配 |
5. 栈的核心应用场景:从函数调用到表达式求值
5.1 函数调用与调用栈
这是栈最典型、最底层的应用。每次程序调用一个函数,系统会把当前函数的局部变量、返回地址、参数等压入一个叫做“调用栈”的结构。当函数执行完毕,系统再把栈顶的这些数据弹出,恢复到调用前的状态。
递归函数特别能体现这一点。很多人学递归时觉得“递归就是自己调用自己”,但从来没想过底层如何实现不丢失状态。其实就是每一层递归都会创建独立的栈帧,一层层压栈,直到终止条件满足,再一层层出栈返回。
如果你在递归函数里没有写终止条件,程序会报“栈溢出”。这就是因为调用栈的容量是有限的,无限压栈必然导致溢出。
5.2 浏览器的前进后退机制
你在浏览器里点开一篇文章,再点开另一篇,然后点后退按钮,就能回到上一篇。这个机制的底层就是两个栈。
当前页面所在的浏览历史可以看作一个栈,当你访问新页面时,旧的页面被压入“后退”栈。点后退时,从后退栈弹出当前页面的上一个页面,同时把当前页面压入“前进”栈。点前进时,反向操作。
这个场景非常直观,也容易在面试中考察,你只要记住“双栈模拟前进后退”这个套路即可。
5.3 括号匹配与表达式求值
给你一串字符串:{[()]},如何判断括号是否合法?用栈的思路如下:
- 遇到左括号,入栈。
- 遇到右括号,弹出栈顶元素,检查是否匹配。
- 遍历结束后,栈为空说明全部匹配。
这个算法时间复杂度 O(n),空间复杂度 O(n)。它是栈在字符串处理里最常见的应用,也是面试的入门级题目。
表达式求值稍微复杂一点,核心思路是把中缀表达式转换为后缀表达式(逆波兰表达式),然后用栈进行计算。整个过程其实就是在模拟人脑的计算顺序——先乘除后加减、括号优先。
这里我就不写完整代码了,但你可以把“中缀转后缀 + 栈求值”这段话当作一个课题去研究。
5.4 撤销操作与文本编辑器
编辑器的 Ctrl+Z 撤销功能,底层是一个栈。你每次操作,比如输入、删除、格式化,都会把操作记录压入栈。当用户按撤销时,弹出栈顶的操作并执行逆操作。
要注意的是,很多编辑器会限制撤销栈的深度,比如最多只能撤销 100 步。这就是栈的容量限制在现实中的体现。
6. 经典题目“1-2栈”:从理论到实践
6.1 什么是“1-2栈”
结合标题来看,“数据结构1-2栈”大概率是指数据结构课程中第二个实验或第二个重点专题。在很多高校的课程编排里,第一章讲线性表,第二章就是栈和队列。而“1-2栈”这个叫法,指的其实是“第1章第2节”或者“实验1.2”,核心内容就是栈。
因此,这篇文章本质上就是在给这个实验/专题做完整的知识铺垫和代码示范。
6.2 用栈实现进制转换
这是一个大学实验里最经典的题目之一:输入一个十进制数,把它转换成二进制、八进制或十六进制。
转换的思路很简单:不断除以目标进制,取出余数,最后把余数逆序排列。这个“逆序”操作,天生就是栈的强项。
def decimal_to_base(n, base): stack = [] if n == 0: return "0" while n > 0: stack.append(n % base) n //= base digits = "0123456789ABCDEF" result = "" while stack: result += digits[stack.pop()] return result print(decimal_to_base(10, 2)) # 1010 print(decimal_to_base(255, 16)) # FF这个例子短小精悍,完整展示了栈的“后进先出”如何模拟“逆序输出”的需求。
6.3 用栈判断回文字符串
回文字符串指的是正着读和反着读都一样,比如"abcba"。利用栈,可以把字符串前半部分入栈,然后和后半部分逐字符比较。
6.4 最小栈:如何快速拿到栈中最小值
这是一个非常经典的进阶题目。要求是设计一个栈,除了支持 push、pop、peek 之外,还要支持 getMin,并且时间复杂度 O(1)。
实现思路很巧妙:用两个栈,一个普通栈存数据,一个辅助栈存当前最小值。每次 push 的时候,比较当前值和辅助栈栈顶值,把较小的那个压入辅助栈。这样辅助栈的栈顶始终是全局最小值。
class MinStack: def __init__(self): self.stack = [] self.min_stack = [] def push(self, value): self.stack.append(value) if not self.min_stack or value <= self.min_stack[-1]: self.min_stack.append(value) def pop(self): if self.stack: value = self.stack.pop() if value == self.min_stack[-1]: self.min_stack.pop() return value def top(self): return self.stack[-1] def get_min(self): return self.min_stack[-1]这里要注意一个细节:辅助栈只有在新元素小于等于当前最小值时才入栈。出栈时,如果当前出栈元素等于辅助栈栈顶元素,说明最小值被移除了,辅助栈也要同步弹出。
这个题目在面试中出现的频率很高,强烈建议你亲手敲一遍。
7. 常见误区与排查技巧:那些年我踩过的坑
7.1 误区一:把栈和队列搞混
栈是先进后出,队列是先进先出。这个区别说起来简单,但实际做题时,尤其是题目里同时涉及两种结构,一紧张就容易写错。
我的排查技巧是:自己念一遍口诀——“栈是后进先出,队列是先进先出;后进先出的是栈,先进先出的是队列。”口诀念三遍,再阴险的题目也不容易坑到你。
7.2 误区二:忽视栈的判空和判满
很多初学者写代码时,只考虑正常路径,完全忽略栈空和栈满的情况。结果就是,一旦栈空了还去 pop,程序直接崩溃。
写栈相关代码时,标准动作是:入栈前先判满(如果定容),出栈前先判空。
7.3 误区三:递归写多了导致栈溢出
递归的层数太深,会栈溢出。这时候不要怀疑人生,应该立即想到改成迭代写法,或者使用显式栈来模拟递归过程。
比如二叉树的前序遍历,可以用递归,也可以用栈模拟。两者逻辑等价,但显式栈没有系统调用栈的限制。
7.4 排查工具推荐
排查栈相关 bug 时,我常用的方式是在 push、pop、peek 的关键位置打印日志,打印栈顶指针和当前栈内元素数量。数据规模小的时候,手动推演一遍再对比日志,是最快的方式。
8. 实战练习建议:三个方向快速巩固
8.1 方向一:纯手写栈
不要看任何参考代码,自己实现一个顺序栈和链式栈。写完再用随机数据做测试,确保 push、pop、peek 全部正确。这一步能锻炼你独立编码的能力,也能暴露边界条件上的思维盲区。
8.2 方向二:用栈解决 5 道经典题
我给你列一个推荐清单,按难度递增排列:
- 括号匹配
- 用栈实现队列
- 最小栈
- 逆波兰表达式求值
- 柱状图中最大矩形
这五题刷完,你对栈的理解会从“听说过”变成“真的会”。
8.3 方向三:对比栈与递归的关系
找一道二叉树遍历题目,分别用递归和显式栈实现一遍,对比两者的代码结构和运行结果。这个练习能让你彻底理解“递归底层就是栈”这句话。
9. 我的实操总结与几点建议
栈这个东西,短小精悍,看起来简单,但它背后的思想非常深刻。它不仅是一种数据结构,更是一种思考框架——当你遇到需要“逆序处理”或者“回溯状态”的问题时,第一时间想到栈,往往就是最优解。
我在实际学习和面试中最大的感受是:不要因为栈简单就轻视它。恰恰是这些基础结构,构成了复杂系统的地基。你对栈的熟悉程度,会直接影响后续学习二叉树、深度优先搜索、单调栈等进阶内容时的速度。
最后再分享一个小技巧:学数据结构的时候,每学完一个结构,都把它对应到至少一个现实场景中,并且亲手写一遍代码。真实场景会帮你建立直觉,亲手实现会帮你巩固细节。不要只背定义,更不要只看别人写的代码。
栈只是起点,后面还有队列、链表、树、图等着你。把这篇文章里的代码和题目吃透,打好地基,后面的路会越走越顺。