☰
栈(Stack)数据结构详解:从原理到应用与实战避坑
2026/10/11 15:28:16 网站建设 项目流程

如果你在一本技术书或者面试题库里看到“Stack栈”这几个字,脑海里冒出来的多半是两件事:LIFO(后进先出),以及一堆入栈出栈的选择题。我不会否认这就是栈的核心,但工作这些年,我越来越觉得,栈的份量远不是一句“后进先出”能概括的。程序一跑起来,背后就有调用栈在支撑;编译器解析表达式、浏览器记录历史、编辑器撤销操作,全都指着栈。这篇文章打算把Stack栈从头到尾拆开聊一遍:从最底层的原理到两种手写实现,从经典应用场景到我在真实项目里踩过的坑。适合刚开始学数据结构的人,也适合那些想系统复盘一遍基础的老手。

1. 栈的本质:不只是“后进先出”四个字

很多人聊栈,开口就是“先进后出”,然后就开始刷题。但栈真正厉害的地方,恰恰是它给自己套上的那层限制。理解了这个限制,后面的所有应用场景都会变得顺理成章。

1.1 栈的抽象模型:一根只有一个开口的箱子

先做一个简单的思想实验:你面前有一个只能从顶部放入和取出的箱子。往里依次放书A、书B、书C,此时要拿到书A,必须先把C和B拿出来。这个“箱子”就是栈,最后放进去的最先被取走,所以叫后进先出(Last In First Out,LIFO)。

从抽象数据类型(ADT)的角度看,一个最小可用的栈应该包含五个操作:push向栈顶压入一个元素;pop从栈顶弹出一个元素并返回;peek或者top查看栈顶元素但不弹出;isEmpty判断是否为空;size返回栈的大小。注意,规范实现里栈通常不提供“按索引访问中间元素”的接口,它刻意把能力收敛到最小,只保留对栈顶的操作。很多人不理解这种“自废武功”的设计,其实是牺牲灵活性换取了两个东西:一是任何操作都只需要触碰栈顶,时间复杂度稳定为O(1);二是状态约束非常清晰,你永远只需要关心栈顶和栈的大小,不会出现操作一半栈就乱掉的情况。

日常里最像栈的例子是叠盘子:最后放上去的盘子总是最先被拿走。还有弹簧弹匣,后压入的子弹先出膛。这些例子都在强调同一件事——栈的结构约束本身就是它的灵魂。它放弃了对中间元素的随机访问能力,却换来了极致的操作效率和清晰的状态边界,这种取舍在数据结构和系统设计里都很有启发性。

1.2 函数调用栈:栈在程序运行时的“本能”

栈在计算机里最典型、也最容易被忽略的应用,就是函数调用栈。程序执行时,每次调用一个函数,操作系统就会在内存里划出一块区域叫栈帧,里面保存函数的局部变量、参数、返回地址等。函数返回时,这个栈帧被弹出,控制权交还给调用方。一层层调用,栈帧一层层往上压;递归调用也是这样,所以递归深度太大时,内存栈会被占满,于是抛出我们常说的栈溢出(StackOverflow)。

我们平常见到的异常堆栈(stack trace),其实就是某一时刻函数调用栈的快照。排查线上问题的时候,打开日志里面那一长串“at xxx()”就是栈帧列表,从下往上读就是完整的调用链路。可以说,程序员的日常排错工作,本质上每天都在阅读栈。

这里有个很关键的认知:递归本质上就是“隐式使用栈”。函数递归调用时,每次递归都在系统调用栈上压入一个新的栈帧,直到触发终止条件,再一层层返回。既然递归和栈是等价的,那么所有递归算法理论上都可以改成显式栈的迭代版本。反过来,很多用栈解决的问题,比如浏览器历史记录的回退,也可以反过来想成一种“函数式的展开”。理解了这个对应关系,再看栈的应用就不会觉得散。

顺带一提,某些语言实现了尾递归优化,可以在满足特定条件时复用当前栈帧,从而让递归在常数栈空间内进行。但对于大多数场景,递归深度还是要心里有数,别指望编译器替你兜底。

2. 手写栈实现:数组栈与链表栈的工程取舍

理论说再多,最后还是落到代码上。我见过不少同学在纸上能默写出栈的五个操作,但真让他实现一次,会发现很多细节做不好,比如扩容怎么扩、空了怎么办、线程安全要不要考虑。这个章节就把实现层面的选择和取舍一次聊透。

2.1 数组栈:动态扩容的均摊复杂度

栈的数组实现是指用一块连续内存保存元素,用一个整数top记录当前栈顶位置。push时先给元素赋值再移动top,pop时反向操作。直接写一个带动态扩容的Python版本:

class ArrayStack: def __init__(self, capacity=16): self.data = [None] * capacity self.top = 0 # 指向下一个可写入位置 self.size = 0 def _ensure_capacity(self): if self.top == len(self.data): new_capacity = max(1, len(self.data) * 2) new_data = [None] * new_capacity for i in range(self.top): new_data[i] = self.data[i] self.data = new_data def push(self, value): self._ensure_capacity() self.data[self.top] = value self.top += 1 self.size += 1 def pop(self): if self.top == 0: raise IndexError("pop from empty stack") value = self.data[self.top - 1] self.data[self.top - 1] = None # 帮助GC回收引用 self.top -= 1 self.size -= 1 return value def peek(self): if self.top == 0: raise IndexError("peek from empty stack") return self.data[self.top - 1] def is_empty(self): return self.top == 0

扩容为什么按倍数扩张?因为如果只加一格,那么连续压入N个元素的复杂度就是O(N²)。用倍增策略,平均每个push操作只需常数次搬运,均摊复杂度仍是O(1)。缩容也讲究时机,常见做法是当实际元素数量降到容量的四分之一时才缩到一半,避免在阈值附近反复扩容缩容造成抖动。

2.2 链表栈:每次入栈都是一次节点分配

链表栈的实现也很直观:每个节点保存值和指向前一个节点的next,head始终指向栈顶。

class Node: def __init__(self, value): self.value = value self.next = None class LinkedStack: def __init__(self): self.head = None self.cnt = 0 def push(self, value): node = Node(value) node.next = self.head self.head = node self.cnt += 1 def pop(self): if self.head is None: raise IndexError("pop from empty stack") value = self.head.value self.head = self.head.next self.cnt -= 1 return value def peek(self): if self.head is None: raise IndexError("peek from empty stack") return self.head.value def is_empty(self): return self.head is None

两种实现选择哪一版?我列个对比表讲清楚。

对比维度数组栈链表栈
内存存储连续一段内存分散的节点,每个节点带指针
扩容行为需要搬移数据不需要,直接申请新节点
额外开销少量索引变量每个节点多一个next指针
缓存友好度高,顺序访问低,节点散落各处
适用场景通用业务、高频读写深度不可预测、内存按节计算的场景

我的默认选择是数组栈。原因很简单:绝大多数服务端场景都是在高并发下高频调用,连续内存对CPU缓存友好,速度更快;链表栈的优势在于不需要预估容量,深度完全动态,但每个节点都带着指针开销。在内存以字节计算的嵌入式设备上,或者栈的深度不可预测且不能在一次分配中给出上限的场景,链表栈会更稳妥。

2.3 多线程下用栈要注意什么

单线程栈写起来毫无压力,但一旦多线程共享一个栈,危险就来了。最常见的错误是“先判断再操作”的组合被并发打断,两个线程都以为栈非空,结果一个pop时另一个已经把元素取走了,于是抛异常。解决思路有三条:加锁、用无锁的并发栈,或者干脆让每个线程持有自己的栈。

以我在某任务回放系统里踩过的经历为例,当时多个线程同时往一个栈里压操作记录,由于并发push破坏了“压入顺序”的预期,回放的时候顺序全乱了。查了半天才意识到,问题不在算法,而在并发下的顺序不保证。后来改成单写者写入,读侧只读,问题立刻消失。栈的结构简单,但在并发模型里同样不能想当然,选错同步方式,后患无穷。

3. 栈的经典应用场景:从编译器到浏览器

如果只会写栈的增删改查,那它只是一个玩具。栈之所以能在计算机系统里无处不在,是因为大量场景在结构上天然就是“回溯式”的。这一章我挑了四个最典型的应用,覆盖了编译器、文本解析、浏览器和算法优化四个方向。

3.1 表达式求值:中缀表达式转后缀表达式

平时我们写“3 + 4 * 2”,叫中缀表达式,人类看着舒服,计算机却很难直接处理,因为运算符有优先级、括号会改变结合顺序。比较好的办法是先转换成后缀表达式(也叫逆波兰表达式):“3 4 2 * +”。计算机拿到后缀表达式,只需要一个栈就能无脑求值。

转换过程借助两个栈,一个放运算符,一个放最终输出。核心规则:遇到数字直接输出;遇到运算符时,只要运算符栈栈顶的优先级不低于当前运算符,就把栈顶弹出输出,再把当前运算符压入;遇到左括号直接压栈,遇到右括号则弹栈输出直到遇见左括号;遍历结束后把剩余运算符全部弹出。

拿3 + 4 * 2 - 7举例:3输出,+入运算符栈,4输出,优先级高于+,入栈,2输出,遇到-,运算符栈顶的和+优先级都不低于-,全部弹出到输出,-入栈,7输出,最后把-弹出。输出是3 4 2 * + 7 -。求值阶段同样用栈:数字入值栈,运算符弹出两个值计算,结果压回。

直接看代码:

def evaluate(expr): # 输入已经是形如 ['3','4','2','*','+','7','-'] 的后缀表达式 stack = [] for token in expr: if token.isdigit(): stack.append(int(token)) else: b = stack.pop() a = stack.pop() if token == '+': stack.append(a + b) elif token == '-': stack.append(a - b) elif token == '*': stack.append(a * b) elif token == '/': stack.append(a // b) # 只处理整除的情况 return stack.pop()

这段代码不长,但把所有关于优先级和顺序的复杂度都隐含在了“栈”这个操作里。你在任何解释型语言里敲一句复杂算术表达式,背后基本都跑着类似的过程。

3.2 括号匹配与HTML标签闭合

括号匹配是另一个高频题。算法极其简单:遇到左括号压栈,遇到右括号时看栈顶是不是对应的左括号,是则弹出,否则说明不匹配;遍历结束后栈为空才说明全部匹配。

def is_valid(s): pairs = {')': '(', ']': '[', '}': '{'} stack = [] for ch in s: if ch in '([{': stack.append(ch) elif ch in ')]}': if not stack or stack[-1] != pairs[ch]: return False stack.pop() return not stack

栈在解析领域更广泛的应用是标签闭合校验。解析HTML或XML时,遇到开始标签入栈,遇到结束标签检查是否与栈顶匹配,本质和括号匹配完全一样,只是从单个字符换成了标签名。很多前端工具里报“标签没有闭合”的错误,就是靠这种机制检测出来的。写爬虫的时候,如果你要手写一个简单的HTML解析器,栈几乎是绕不开的组件。

3.3 浏览器前进后退与编辑器撤销:双栈模型

浏览器里点后退,当前页面从后退栈弹出,同时压入前进栈;点前进则反向移动。如果到达某个页面后你点开了一个新链接,前进栈会被清空,因为浏览器认为你开启了新的历史分支。这个模型在编辑器里同样存在:undo栈记录每次操作,redo栈记录被撤销的操作,新操作产生时把redo栈清掉。

用两个栈管理“过去”和“未来”,实现起来十几行代码,但表达力很强。后退栈放历史状态,前进栈放被撤销的未来状态;每次新状态产生,未来栈清空,因为历史已经重新分叉。这个双栈模型是我个人最喜欢的栈应用之一,因为它把抽象的数据结构和用户可直接感知的交互行为对应起来了。你可以试着用这个模型去理解IDE里的Ctrl+Z和Ctrl+Shift+Z,瞬间就通透了。

3.4 单调栈:被低估的优化神器

先说清楚什么是单调栈:栈内元素按照从栈底到栈顶递增或递减的规律排列。它最大的价值是解决“下一个更大/更小元素”的问题,在线性时间内得到结果,而不是暴力O(N²)。

经典题目:给你每天的天气温度,返回每一天需要等多少天才能等到更高的温度。用递减栈,遇到一个新温度,不断把栈顶比它小的弹出,弹出的那天到当天的天数就是答案。

def daily_temperatures(temperatures): n = len(temperatures) answer = [0] * n stack = [] # 存下标,栈内温度单调递减 for i, t in enumerate(temperatures): while stack and t > temperatures[stack[-1]]: prev = stack.pop() answer[prev] = i - prev stack.append(i) return answer

为什么这个算法快?因为每个下标最多入栈一次、出栈一次,总操作次数是O(N)。暴力解法里每个元素都可能跟后面所有元素比较,而单调栈通过记住“哪些元素还没找到答案”,把重复比较省掉了。接雨水这类困难题也可以用单调栈处理,思路类似,只是计算的是面积,这里不展开。想提醒一句:单调栈的边界情况很多,比如算距离时下标怎么取、相等元素要不要弹出,建议先用小样例手推一遍再上代码。

4. 我在项目中踩过的栈相关坑:一次复盘

理论知识说完了,说点实际的。这几年我在真实项目里遇到过三次和栈直接相关的坑,每次排查完都感慨“要是早点意识到这里是栈在起作用就好了”。写出来供大家参考。

4.1 递归改迭代:栈溢出的完整排查链路

某批处理程序负责按目录树生成文件清单,最初用递归函数遍历目录。数据量小的时候一切正常,某天目录层级特别深,程序跑着跑着直接抛StackOverflow。我拿到报错后先看堆栈信息,发现递归深度已经好几千层,而系统给默认线程分配的栈内存有限。排查结论很清楚:不是逻辑错误,是递归深度撞上了系统栈上限。

改造方案就是把递归改成显式栈的迭代:自己定义一个存放目录路径的栈,push目录、pop目录后处理文件并push子目录,整个过程不占用系统调用栈。改完后再跑,深度再大也只是堆内存增长,不再崩。这个坑提醒我:递归写起来很优雅,但它的空间成本是隐性的。凡是数据规模或者嵌套深度不受控制的场景,用显式栈代替递归是一种更稳健的写法。

4.2 栈和队列混用:回放顺序为什么反了

另一个项目需要把用户的N个操作按时间顺序记录下来,之后批量重放。当时负责模块的同事图方便用了栈,结果重放时间序完全颠倒。原因一句话:栈是LIFO,你最早记录的操作被压在最下面,重放的时候当然最后出来。

这类“按原始顺序处理”的需求,正确选择是队列(FIFO)。这个错误很典型,它说明选数据结构不能光看“增删快不快”,还要检查读写顺序是否符合业务语义。简单判断:需要回退、撤销、递归路径用栈;需要排队、按序消费用队列。数据结构本身没有好坏,用错了场景就是灾难。

4.3 深拷贝中的循环引用:递归栈被递归撑爆

某服务有一次深拷贝配置对象,对象内部有互相指向的引用关系。拷贝函数写得挺自然,递归处理每个字段,但遇到环形引用,递归永远不会终止,栈很快被打满。解决方式是把递归改成显式栈加一个visited集合:已经访问过并拷贝好的对象记录到集合,再次遇到就直接复用,不再继续递归。

这个经验也值得记下来:凡是涉及图结构或对象引用的遍历,第一反应就该做去重,否则不只是栈溢出,还可能死循环。你写任何递归之前,都应该先问一句:这个结构里有没有环?如果有,必须带记忆。

4.4 两个栈实现的队列与O(1)最小值栈:实用的变体练习

顺着上面踩坑的思考,如果想在没有现成Queue的环境里实现队列,双栈解法是最经典的:一个in栈负责接收新元素,一个out栈负责弹出。push时直接压入in栈;pop时若out栈非空就弹out,否则把in栈元素全部倒入out再弹。两次倒栈摊还下来,每个push/pop仍是O(1)。

class QueueByStack: def __init__(self): self.in_stack = [] self.out_stack = [] def push(self, value): self.in_stack.append(value) def pop(self): if not self.out_stack: while self.in_stack: self.out_stack.append(self.in_stack.pop()) return self.out_stack.pop()

O(1)最小栈也值得写一遍:主栈存数据,辅助栈存当前最小值。push时,如果新元素小于等于辅助栈栈顶,就同时压入辅助栈;pop时如果弹出的元素等于辅助栈栈顶,辅助栈也弹。这样返回最小值只需要peek辅助栈。变体设计的共通点在于:用一个栈保存原始数据,用另一个栈保存“历史状态”,把原本需要遍历查找的工作量提前分摊到写入阶段。

回头再看这整篇内容,栈的所有魅力几乎都来自同一个约束:只有一个开口。因为这个约束,操作变得高效且可预测;因为这个约束,它天然适合回溯、匹配和状态记录。我在实际工作中,每次遇到“需要回到上一步”或者“需要追踪历史”的场景,第一反应都先想到栈。如果你想练习,我的建议是:先手写一遍数组栈和链表栈,再吃透表达式求值和单调栈这两类题,最后用双栈把队列实现一次。一个晚上足够把这些核心场景都过一遍。等你真正写熟了会发现,栈确实是最简单也最常见的“隐藏引擎”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询