有同学拿着课本问我:栈这种东西除了应付考试到底有什么用?我说你把两个函数嵌套调用一下,看看调试器里的Call Stack面板,那就是栈的活例子。后来又追问顺序栈和链式栈怎么选,干脆我用Python把栈的基本操作完整实现了一遍,包括顺序栈和链式栈两种写法,把代码、原理、坑和选型逻辑都捋一遍。
这篇文章适合三类人:刚学数据结构、想搞懂栈到底是什么的初学者;会写链表但没想过怎么用链表实现栈的人;以及写代码时不确定该直接用list还是自己封装类的人。我会先讲清楚栈存在的意义,再分别给出顺序栈和链式栈的完整代码,然后做对比和实测,最后用几个经典应用验证这两种实现到底好不好用。
1. 先理解栈:一个"后进先出"的底层逻辑
1.1 从函数调用到浏览器后退:栈的现实形态
栈不是被人为设计出来的抽象概念,而是计算机运行时自动产生的结构。你写Python的时候,每次调用函数,运行时都会把这次调用的参数、局部变量和返回地址压进调用栈;函数执行完,再把这些信息弹出去。所以调试器里那个Call Stack面板,从上往下看依次是"当前正在执行"→"谁调用了它"→"再往上一层是谁",你逐层看完,就是一条函数的调用链。
这个过程中的关键约束是嵌套调用必须后进先出:最内层的函数要先执行完,外层函数才能继续,所以最晚压入栈的信息最先被弹出。浏览器后退、编辑器撤销、消息队列里的最近优先处理,全是同一个道理。你从A页面点进B,再从B点进C,后退时先回到B而不是A,因为C是最后进入历史记录的。心里记住"最新出现的最先处理"这句话,栈的直觉就建立了。
1.2 栈的五个基本接口
栈对外只暴露一个操作口——栈顶。数组可以随机访问任意下标,栈不行,这是栈与普通序列最大的区别。一个完整的栈至少要提供五个基本操作:
- push(value):把元素压入栈顶
- pop():弹出栈顶元素并返回它
- peek():只看栈顶元素,不动栈
- is_empty():判断栈内是否为空
- size():返回栈内元素个数
这些操作都要求O(1)完成。为什么这么严格?因为栈是很多底层机制的基础设施,如果pop要遍历一遍才能找到栈顶,那函数调用、括号匹配这些场景的性能就不能接受了。后面的实现里我会反复提到这个O(1)约束,它是判断栈写得好不好的第一把尺子。
1.3 顺序栈和链式栈的定位差异
顺序栈用连续内存存储元素,Python里就是list;链式栈用节点加指针串联,Python里就是一个个Node对象。前者的优势是缓存友好、实现简单,代价是可能预留空间或需要扩容搬迁;后者的优势是没有容量上限、结构灵活,代价是每个节点多一个指针的开销。这两种实现各有各的舞台,删掉哪一个都不完整。
2. 顺序栈实现:用Python列表撑起一个LIFO世界
2.1 基于list的顺序栈代码
顺序栈的第一直觉就是用Python的list,因为list底层是动态数组,天生就是一块连续内存。我的实现里同时支持"带容量上限"和"不带上限"两种模式,max_size传None表示不限制。
class SeqStack: """基于Python list实现的顺序栈""" def __init__(self, max_size=None): if max_size is not None and max_size < 0: raise ValueError("max_size不能为负数") self._data = [] self._max_size = max_size def push(self, value): if self._max_size is not None and len(self._data) >= self._max_size: raise OverflowError("栈已满,无法入栈") self._data.append(value) def pop(self): if self.is_empty(): raise IndexError("空栈不能执行pop操作") return self._data.pop() def peek(self): if self.is_empty(): raise IndexError("空栈不能执行peek操作") return self._data[-1] def is_empty(self): return len(self._data) == 0 def size(self): return len(self._data) def clear(self): self._data.clear() def __repr__(self): return f"SeqStack({self._data})" def __len__(self): return len(self._data)代码很短,但细节都在操作方式里。push用append而不是insert(0),因为append直接向尾部追加,尾部就是栈顶,常数时间可访问;如果非要把头部当栈顶,insert(0)和pop(0)虽然也能实现同样逻辑,但每次操作都会把后面所有元素整体挪位,复杂度直线变成O(n)。这个选择不是风格差异,是性能底线。
pop用list自带的pop(),删除并返回最后一个元素,正好就是"弹出栈顶"。peek用data[-1]直接读最后一个元素,不改动结构。is_empty和size直接交给len()处理。之所以多写一个__len__方法,是为了让len(stack)也能正常工作,很多Python开发者习惯这么判断容器长度,给数据结构加上这个魔术方法是有价值的。
2.2 为什么append/pop是O(1):均摊分析的功劳
有一个常被忽略的细节:Python列表的append并不是每次严格O(1)。底层动态数组空间不够时,会申请一块更大的连续内存,把旧元素整体拷过去,单次扩容是O(n)。但Python列表扩容时通常把容量翻倍,可以算一笔账:假设初始容量是C,经历k次扩容后总容量是C * 2^k,所有元素总拷贝次数大约是C + 2C + ... + C * 2^(k-1) = C * (2^k - 1),和最终容量同一个量级。换句话说,把多次append的开销平均到每个元素头上,每个元素只被拷贝常数次。这就是均摊O(1)的含义。
C++的vector、Java的ArrayList也都是同一个扩容思路。理解这一点,你就明白为什么有些高性能代码要预先申请容量了:虽然均摊是O(1),但扩容那一刻的瞬间拷贝延迟是真实存在的,在实时性要求高的场景里,这个毛刺不能忽略。
2.3 带容量上限与不带上限的两种写法
我封装了max_size参数,传None就是无限容量版本,传入具体数字就变成固定容量顺序栈。为什么要留这个参数?因为在真实系统里,栈空间从来不是无限的。递归调用栈会被操作系统固定一个上限,超过就栈溢出;嵌入式环境内存按KB计算,维护一个带上限的栈反而能提前暴露问题。满栈时抛出OverflowError,语义上和递归过深抛出的RecursionError同源,都是"容量到极限"。
但如果是写算法题、做小Demo,我更推荐用不带上限的版本。Python列表自动扩容,强行限制容量可能会干扰你的核心逻辑;而且少一个分支判断,代码更清爽。工程上到底用哪种,取决于你更想模拟真实系统的固定容量约束,还是更想利落地完成任务。两种都是合理设计,关键是把max_size的语义说清楚,别让人误以为不带上限的栈永远不会出错。
2.4 顺序栈的几个隐蔽坑
第一个坑是pop和peek时忘记判空。我的代码统一抛IndexError,和Python内置list在空列表上调用pop的行为一致,调用方可以用一套异常处理逻辑。更忌讳的做法是空栈时返回None——如果栈里恰好存了None值,调用方就永远分不清"栈真的空了"还是"栈里有一个None"。第二个坑是用insert(0)和pop(0)模拟入栈出栈,前面已说过复杂度灾难。第三个坑是把列表的头当栈底、尾当栈顶之外的另一种极端——在头部插入然后靠自动补位,逻辑通但性能不可用。顺序栈表面上人畜无害,误用反而最容易出性能事故。
3. 链式栈实现:节点之间的栈视角
3.1 单向链表版链式栈代码
链式栈没有整块连续内存,每个元素单独用节点保存,节点之间通过next指针串联。因为栈只操作栈顶,只需要维护一个top指针指向栈顶节点,不需要像普通双向链表那样维护tail。入栈出栈都发生在链表头部,天然符合LIFO。
class Node: """链表节点""" def __init__(self, value): self.value = value self.next = None class LinkedStack: """基于单向链表实现的链式栈""" def __init__(self): self._top = None self._size = 0 def push(self, value): node = Node(value) node.next = self._top self._top = node self._size += 1 def pop(self): if self.is_empty(): raise IndexError("空栈不能执行pop操作") value = self._top.value self._top = self._top.next self._size -= 1 return value def peek(self): if self.is_empty(): raise IndexError("空栈不能执行peek操作") return self._top.value def is_empty(self): return self._top is None def size(self): return self._size def clear(self): self._top = None self._size = 0 def __repr__(self): result = [] cur = self._top while cur is not None: result.append(cur.value) cur = cur.next return f"LinkedStack(top={self._top.value if self._top else None}, items={result})"核心逻辑全在push和pop。push新建节点,让新节点的next指向当前栈顶,再把top更新为新节点;pop先把栈顶节点的value取出来,再把top指向原栈顶的next,最后把节点交给Python的垃圾回收。每个操作只动少数指针,都是O(1)。链式栈理论上没有容量上限,只要内存够,压多少都行,这是它和顺序栈最本质的差异。
3.2 头插法为什么天然适合栈
链表有头插和尾插两种方向,链式栈必须用头插法,这不是偏好,是复杂度约束。栈顶就是唯一进出口,头插让新节点直接成为top,出栈时top跳到next,两步完成。如果用尾插法,入栈要遍历到链表尾部找尾节点,出栈要找到倒数第二个节点并把它的next置空,复杂度全是O(n),栈的核心价值就没了。所以"头部就是栈顶"在链式栈里是结构决定的必然。
push时还要注意指针更新顺序:先让新节点的next指向当前top,再把top更新为新节点。很多人会写反,先更新top再连next,结果新节点彻底和旧链断开了,或者两个节点互相指,把自己绕成环。正确顺序一句话:先接好新节点与后继的关系,再让它顶替top的位置。
3.3 链式栈的内存开销与边界情况
链式栈灵活是有代价的。每个节点除了存value,还得存一个next指针,Python里这还意味着每个Node对象都有对象头信息。数据量小无所谓,如果栈里要放几百万个元素,链式栈的额外内存开销可能比顺序栈高数倍,这在内存敏感场景里必须提前算账。
边界情况主要三层。第一层是空栈操作,push没问题,pop和peek必须检查top是否为None。第二层是栈只剩一个元素时pop,top从节点变成None,这个跳转要写对。第三层是__repr__遍历整个链表打印元素,调试时很方便,但如果栈非常大,光打印就是O(n),别在生产代码里动不动就调用它。
3.4 链式栈常见的翻车模式
我见过最多的问题是三种。第一种又是尾插法,功能看起来对,但pop要遍历链表,时间直接变O(n),丢了栈的性能价值。第二种是push时指针顺序写反或者忘记更新_size计数器,导致pop拿到旧节点或者size永远是错的。第三种是外部拿到内部节点引用后直接修改next字段,比如有人把stack._top.next改掉,整个栈结构就崩了。链表这种结构最忌讳对外暴露内部节点,对外暴露的应该是操作,不是数据组织方式。
4. 顺序栈 vs 链式栈实测对比
4.1 关键指标对比表
把两种实现放在一起,可以从存储结构、空间利用、时间性能、扩展性几个维度看清差异。
| 比较维度 | 顺序栈 | 链式栈 |
|---|---|---|
| 底层存储 | 连续内存(动态数组) | 分散的节点对象 |
| 额外内存 | 扩容时可能预留空闲空间 | 每个节点有next指针开销 |
| push时间复杂度 | 均摊O(1) | O(1) |
| pop时间复杂度 | O(1) | O(1) |
| 是否有容量上限 | 可设置max_size | 理论上无上限 |
| 缓存友好度 | 高,连续内存利于预取 | 低,节点地址分散 |
| 实现复杂度 | 简单,代码集中 | 需要节点类和指针维护 |
这张表能对上教科书,但实际选型不能只看O(1),因为同样是O(1),常数大小完全不同。
4.2 为什么顺序栈在Python里通常更快
我在本机反复跑过10万次入栈出栈循环,顺序栈比链式栈快出数倍甚至一个数量级,差距是实打实的。原因有两个层面。第一,顺序栈的push就是list.append,走的是C语言层面的动态数组逻辑,调用路径短;链式栈每次push都要new一个Node对象,Python对象创建这个动作本身就有固定开销,数据量越大,这个开销越明显。第二,顺序栈的数据在内存里连续排列,CPU缓存预取极其友好,遍历和访问都高效;链式栈的节点新建在堆上,地址散布各处,访问时不断跳指针,缓存命中率低,虽然理论上都是O(1),但常数比别人大得多。
4.3 选型建议
我给自己定过一套选型逻辑,基本能覆盖大部分场景。初学阶段,两种都手写一遍,顺序栈重点理解连续内存和均摊复杂度,链式栈重点理解指针操作和异常边界。刷题和写一次性脚本,直接用列表list当栈用,它已经是经过编译优化的顺序栈,没必要自己封装。做底层库、系统模块,优先选顺序栈,性能可预测、内存开销小。数据量完全不可预知、长期运行且元素数量波动剧烈的场景,链式栈更合适,因为不需要预留空间,也没有扩容搬迁的瞬时卡顿。
4.4 一个粗略的内存估算
有人喜欢问两种实现哪个省内存,我给个粗略思路。顺序栈有一个动态数组,元素存的是引用,Python的int本身还额外占对象空间,但这些对象是公用的,list里只存指针;链式栈的每个节点是一个Node对象,加上Node内部的next引用,每个元素都要多背一个对象头和指针字段。你往栈里放10万个整数,顺序栈的额外结构开销大致是固定比例,链式栈的每个节点都指望不上连续内存。真要精确测量,建议在自己机器上用memory_profiler或tracemalloc去跑,不同Python版本的结果都会有差异。我通常只关注一个结论:元素量越大,链式栈的内存劣势越明显,但换来的是无限容量和扩容无抖动。
5. 栈的真正用途:括号匹配与进制转换实操
5.1 括号匹配:编译器都在用的栈
栈最经典的应用之一是括号匹配检查。编译器拿到一份源码,必须能判断各种括号有没有配对,这个逻辑就是遍历代码,遇到左括号压栈,遇到右括号弹栈,弹出来的左括号要和当前右括号配得上;中途发现栈空,或者遍历完栈里还有未匹配的左括号,都说明括号有问题。
def check_brackets(text): stack = [] pairs = {')': '(', ']': '[', '}': '{'} opening = set(pairs.values()) for ch in text: if ch in opening: stack.append(ch) elif ch in pairs: if not stack or stack.pop() != pairs[ch]: return False return len(stack) == 0 samples = [ "(a + b) * (c - d)", "{[()()]}", "[(])", "((()))", "(a + b]", ] for s in samples: print(s, "->", check_brackets(s))这个例子完整展示了栈的特征:你永远只关心最新出现、还没被匹配的符号,所以栈是天然的数据结构。写测试时要覆盖两种失败模式:一种是"栈空却遇到右括号",比如字符串以右括号开头;另一种是"遍历完栈里还有左括号",比如漏写了右括号。两个都测到才算稳。
5.2 进制转换:短除法与栈的天然配合
另一个教科书级例子是十进制转其他进制。短除法不断取余,但余数的出现顺序和最终结果顺序相反,最早算出来的余数是结果的最低位,这正好是后进先出。用栈保存余数,最后逐个弹出,就是正确结果。
def dec_to_base(num, base): if base < 2 or base > 16: raise ValueError("base必须在2到16之间") digits = "0123456789ABCDEF" stack = [] if num == 0: return "0" while num > 0: stack.append(digits[num % base]) num //= base result = "" while stack: result += stack.pop() return result for i in [0, 1, 10, 255, 1024]: print(i, "->", dec_to_base(i, 2), "二进制") print(i, "->", dec_to_base(i, 16), "十六进制")如果你之前不理解为什么要把余数倒序输出,这个例子会彻底讲透:倒序就是LIFO,栈把这个"反转"过程变成了两个O(1)操作。把这段代码里手动维护的栈替换成list的append和pop,你会发现自己其实一直在用栈,只是没意识到。
5.3 递归的隐式栈:手动栈模拟递归
递归函数执行时,每个未返回的递归调用都会在调用栈上占一层。递归深度太大导致的栈溢出,本质上是一种"栈满"异常。某些场景下为了规避这个限制,或者想对递归过程有更多控制,可以用手动栈把递归改成迭代版本。最经典的案例是二叉树前序遍历:递归版极短,但深度大的树可能溢出;迭代版显式用一个栈,每次从栈里取节点,按逆序压入左右孩子。
class TreeNode: def __init__(self, value): self.value = value self.left = None self.right = None def preorder_recursive(root): if root is None: return print(root.value, end=" ") preorder_recursive(root.left) preorder_recursive(root.right) def preorder_iterative(root): stack = [root] while stack: node = stack.pop() if node is None: continue print(node.value, end=" ") stack.append(node.right) stack.append(node.left)对比递归版和迭代版,你会对"递归压栈"四个字有全新的理解。递归写法是系统隐式帮你维护了栈,迭代写法是把这层逻辑搬到你面前,亲自压库、亲自弹出。这个转换过程能极大加深对栈的信任。
6. 实现栈时的常见翻车点与测试心得
6.1 四种典型错误实现
我总结过新手写栈最常见的四种错误。第一种,用列表头部当栈顶,insert(0)和pop(0)实现,功能正确但复杂度是O(n)。第二种,链式栈用尾插法,pop要遍历链表,复杂度也是O(n)。第三种,忘记更新size计数器,导致size永远不对,判断is_empty也可能出错。第四种,pop空栈时没有检查,直接取self._top.value,抛出来的是AttributeError而不是规范的IndexError,异常语义混乱,调用方很难处理。这些错误都不是什么高深逻辑,但就是会在测试时暴露出来。我建议写完一种栈,立即用空栈、单元素栈、大量元素栈三种状态各跑一遍push/pop/peek。
6.2 用unittest写一组边界测试
数据结构这东西,写一个完整测试模块的成本其实比写主逻辑还高,但收益非常稳。我通常会把空栈操作、满栈操作、单个元素状态、重复push/pop后的clear,这几类边界都覆盖掉。
import unittest from seq_stack import SeqStack from linked_stack import LinkedStack class TestStacks(unittest.TestCase): def setUp(self): self.seq = SeqStack(max_size=3) self.linked = LinkedStack() def test_empty_pop_raises(self): with self.assertRaises(IndexError): self.seq.pop() with self.assertRaises(IndexError): self.linked.pop() def test_push_pop_order(self): for s in [self.seq, self.linked]: for i in range(3): s.push(i) self.assertEqual(s.pop(), 2) self.assertEqual(s.pop(), 1) self.assertEqual(s.pop(), 0) def test_seq_stack_overflow(self): for i in range(3): self.seq.push(i) with self.assertRaises(OverflowError): self.seq.push(99) def test_clear(self): for s in [self.seq, self.linked]: s.push(1) s.clear() self.assertTrue(s.is_empty()) self.assertEqual(s.size(), 0) if __name__ == "__main__": unittest.main()这些测试跑通了,说明基本行为是可靠的;跑不通,往往就是因为某个边界没想清楚。写数据结构先从测试开始,比先写主流程再补测试更有效率。这个习惯我保持了很久,在其它链表、队列相关代码里也同样好用。
6.3 用timeit做一次简单性能实测
理论说得再清楚,不如自己跑一次实验。Python的timeit模块最适合做这种小规模对比,我对两种栈各执行10万次push,再各执行10万次pop,看耗时差异。
import timeit from seq_stack import SeqStack from linked_stack import LinkedStack def bench_push(stack, n): for i in range(n): stack.push(i) def bench_pop(stack, n): for i in range(n): stack.pop() n = 100_000 seq = SeqStack() linked = LinkedStack() t1 = timeit.timeit(lambda: bench_push(seq, n), number=1) t2 = timeit.timeit(lambda: bench_push(linked, n), number=1) print("顺序栈 push 10万次耗时:", round(t1, 4)) print("链式栈 push 10万次耗时:", round(t2, 4))我在电脑上跑过这类测试,顺序栈几乎每次都明显领先,原因前面已经拆开讲过:一个是C层列表操作加连续内存,一个是Python对象分配加指针跳转。但跑完之后不要急着下结论说链式栈没用,它在无上限、无扩容抖动、结构灵活性这些东西上是顺序栈比不了的。选型本质是取舍,不是单纯的"谁快选谁"。
写到这里我想多提一句:顺序栈和链式栈的代码量都不大,真正难的是把边界条件、复杂度意识、异常语义这些"看不见的东西"写进去。很多人觉得自己会写栈了,就是能背出两个类名,结果一测试就露馅。所以写完第一件事不是写业务逻辑,而是写一组覆盖边界条件的测试。栈这种结构,恰好是暴露"考虑不周"的最佳工具,把这一课补上,后面写队列、树、图都会轻松很多。