1. 项目概述与核心思路
1.1 从“万物皆可栈”说起
先问你一个问题:日常写代码的时候,有没有遇到过“撤销上一步操作”这种需求?或者浏览器那个“后退”按钮,又或者写代码时那让人又爱又恨的函数递归调用——这些场景背后,都在用一个叫**栈(Stack)**的 数据结构。它极其朴素,只有两个基本动作:往顶上加元素(push)和从顶上取元素(pop),讲究的就是一个“先进后出”的规矩。但就是这么一个看似简单的结构,几乎是所有计算机专业课程的必修内容,也是面试中出现频率极高的考点。
这次我分享的项目,就是要把这个“简单又不简单”的栈,用 Python 完整实现一遍,而且一次性做两种:顺序栈(用连续内存数组实现)和链式栈(用离散内存节点实现)。这不是什么高深的工程,但恰恰是这个“基础不基础”的分水岭,能把很多人的问题暴露出来——明明概念都懂,一动手写代码就各种边界条件出错。所以我希望这篇文章能帮你把这块地基彻底夯实。
这篇文章适合谁看呢?如果你是刚学数据结构的初学者,可以从头到尾通读;如果你是准备面试的求职者,重点看第 4 章的对比分析和第 5 章的踩坑记录;如果你只是想快速拿代码去用,可以直接跳到第 3 章的完整实现部分。但我的建议是,别跳,这个项目值得你跟着走一遍。
1.2 为什么用 Python 来实现栈
很多教材用 C 语言讲栈,因为可以直观地操作内存、看指针怎么移动。但 Python 有它自己独特的好处:语法足够简洁,让你能把全部注意力放在“栈”这个逻辑结构上,而不是纠结于 malloc/free 或者数组越界。而且 Python 里其实已经内置了栈的“半成品”——list 类型天然支持 append 和 pop 操作,理论上你直接用 list 就能当栈用。
可问题来了:既然 Python 的 list 都能当栈用,为什么我们还要学习用 Python 实现顺序栈和链式栈?这里我得说点自己的理解。直接用 list 当栈,当然省事,但你会错过很多东西:你不知道栈顶指针这个概念怎么来的,不知道动态扩容是怎么做的,更不知道“引用”和“对象”在 Python 里是怎么回事。换句话说,用 Python 实现栈,不是为了造轮子,而是为了借着“栈”这个场景,把 Python 对象模型、递归思想、边界处理这些底层能力一次性打通。
而且,等你去学 C、Java 或者 Go 的时候,栈的实现逻辑是通用的。现在用 Python 把思路理清了,到其他语言里你只是换个语法而已,核心逻辑完全可以直接搬过去。这就是为什么我建议每个人都认认真真用 Python 手写一遍栈,而不是简单调 API。
2. 核心概念解析与方案设计
2.1 栈的基本操作与核心语义
先把栈的“规矩”理清楚。一个合格的栈,至少要有以下几个操作:
- push(item):压栈,把元素放到栈顶。
- pop():出栈,把栈顶元素取出来,同时从栈中移除。
- peek():查看栈顶元素,但不移除它。有些教材里叫 top()。
- is_empty():判断栈是否为空。
- size():返回栈中元素数量。
这四个操作,加上一个“先进后出”的顺序约束,就是栈的全部灵魂。你注意,栈的操作永远只发生在“一端”——叫栈顶。栈底是永远不动的,除非栈被清空。
一个容易混淆的点是 pop 和 peek 的区别。很多人写代码的时候一紧张就把这两个搞混,导致 debug 半天。它们的区别就一句话:pop 是“看一眼再拿走”,peek 是“只看不拿”。生活里也有类似的例子——你在食堂门口看今天有什么菜,这叫 peek;你决定打一份红烧肉端走,这叫 pop。在写代码时,凡是“取出元素还要继续用”的场景,比如括号匹配中的左括号出栈后要和右括号配对,你得用 pop;凡是“只想看看栈顶是什么来决定下一步”的场景,比如递归转非递归时判断当前状态,多用 peek。
还有一点要注意,有些教材把“判空”这个操作当成理所当然,但在实战中,所有错误几乎都出在这个“理所当然”上。你想想,对一个空栈执行 pop,会发生什么?高级语言里一般会抛异常,但在 C 语言里你直接操作指针,那就是访问了非法内存。所以,我后来写任何涉及栈的代码,第一行永远是检查栈是否为空,这个习惯可以帮你避免无数崩溃问题。
2.2 顺序栈:为什么用 Python 列表实现也有讲究
顺序栈,顾名思义,就是用一块连续的内存空间来存储数据。在 C 语言里,你会用数组来实现,定义一个固定大小的数组,再加一个 top 变量记录栈顶位置。每次 push 一个元素,top 加一;每次 pop,top 减一。就这么简单。
到了 Python 这里,最贴近的顺序栈实现方式是直接用 list。但这里就涉及一个问题:Python 的 list 是动态的,它本身就有自动扩容机制,你 append 的时候根本不用担心容量满不满。这确实省心,但如果你真的想理解“栈满”这个概念怎么办?我的做法是,用一个额外参数 max_size 来模拟容量上限,在 push 之前检查当前元素数量是否达到上限,达到了就抛异常或者拒绝入栈。这样虽然“多此一举”,但能让你体会到固定容量栈的边界约束感。
当然,如果你想在这层之上再进一步,可以尝试用 Python 的 array 模块或直接预分配一个固定大小的列表(比如[None] * 10),然后手动维护一个 top 指针(在 Python 里其实是用一个整数下标)。这样更接近 C 语言的数组栈形态,代码也更加“原汁原味”。我在下面的完整实现里,就用了这种预分配的方式,目的就是让你能看到“指针”在 Python 里是怎么被模拟出来的。
说到扩容策略,还有一个值得展开的点。如果使用 list 本身的动态扩容,Python 采用的是一个经典的倍增策略——当容量不够时,会重新申请一块更大的内存(通常是当前容量的 1.125 倍左右,具体数值看 Python 版本),然后把旧元素全部拷贝过去。这就是均摊 O(1) 时间复杂度的来源。如果你是自己实现扩容逻辑,建议也遵循倍增策略,而不是每次只加一个位置,否则连续 push 会退化成 O(n²)。
2.3 链式栈:为什么用“头插法”而不是“尾插法”
链式栈,从名字就能看出来,它是用链表来实现的。链表的特点是内存不连续,每个节点存一个数据域和一个指向下一个节点的指针。在 Python 里,这个“指针”其实就是对象引用,用一个嵌套的 Node 类就能表示。
这里有一个关键设计点:链式栈的入栈和出栈操作,应该发生在链表的哪一端?很多初学者会下意识地选“尾端”,因为受队列的影响。但栈的正确做法,应该是把链表头部当作栈顶。为什么?因为链表头部操作的时间复杂度是 O(1),你只需要改头节点的指针就行;而尾部操作需要从头遍历到末尾,时间复杂度是 O(n)。用“头插法”实现 push,每次新节点都插到链表头部;pop 时直接把头部节点摘下来。整个过程干净利落,有手就行。
那也有人问了:“既然都是 O(1),为什么我偏要用链表来实现栈,而不是直接用 list?”这其实是在讨论两种实现的适用场景。如果你的程序里压栈和出栈操作特别频繁,而且数据量无法提前预估、波动很大,链式栈就比固定容量的顺序栈更合适——因为链式栈没有“栈满”这个概念,只要内存够,你随便压。反过来,如果数据量你心里有数,而且栈的操作主要集中在“栈顶”这个区域,顺序栈因为局部性更好、不需要维护额外指针,速度反而更快。这个问题我放到第 4 章再详细展开,这里先留个印象。
3. 完整实现与实操步骤
3.1 顺序栈的 Python 实现
下面这段代码,是我个人比较推荐初学者模仿的顺序栈版本。它保留了“栈顶指针”的概念,也用参数模拟了固定容量,还实现了自动扩容版本作为对照。
class ArrayStack: """模拟固定容量的顺序栈(类似C语言的数组栈)""" def __init__(self, max_size=10): # 预分配一个固定大小的列表,初始全部为None self.max_size = max_size self.data = [None] * max_size # top表示下一个元素要存放的位置,同时等于当前元素个数 self.top = 0 def push(self, item): # 检查栈是否已满 if self.top >= self.max_size: raise OverflowError("栈已满,无法压入元素") self.data[self.top] = item self.top += 1 def pop(self): if self.is_empty(): raise IndexError("栈为空,无法弹出元素") self.top -= 1 value = self.data[self.top] # 释放引用,帮助垃圾回收,避免内存滞留 self.data[self.top] = None return value def peek(self): if self.is_empty(): raise IndexError("栈为空,无法查看栈顶元素") return self.data[self.top - 1] def is_empty(self): return self.top == 0 def size(self): return self.top def __repr__(self): # 调试时方便打印栈的内容 return f"ArrayStack({self.data[:self.top]})"我来逐段拆解这段代码的考点。
首先是构造函数中的top变量。这里它等于 0,表示栈是空的,也代表下一个元素应该存放的下标。当你有 3 个元素时,top 就是 3,元素分别在下标 0、1、2。这个约定非常重要——top不是“最后一个元素的下标”,而是“下一个空位在哪里”。如果你把 top 定义成“栈顶元素的下标”,那 push 和 pop 的边界逻辑会完全不一样,容易搞混。我看过太多人在这里栽跟头了,所以我建议新手就统一用“top 等于元素个数”这个约定,把所有逻辑都围绕它展开。
push 里的判断条件self.top >= self.max_size就是“栈满”的判据。你看,因为 top 是元素个数,所以当元素个数等于最大容量时,栈就满了,不能再往里塞东西。如果你用的是>而不是>=,那你就会允许第 max_size + 1 个元素入场,导致数组越界——这在 C 里面是严重错误,在 Python 里虽然不会崩,但逻辑已经错了。
pop 里有个细节值得注意:我在取出元素之后,把data[top]重新设置为 None。这一步在很多快速实现的例子里被省略掉了,但它其实是一个好习惯。如果你存储的是大对象,不及时把引用断开,Python 的垃圾回收就无法回收它们,容易造成内存滞留。尤其是在写长驻服务或处理大量数据时,这种小细节的累积效应非常明显。
然后是size(),直接返回 top,因为 top 就是元素个数。这里不再单独维护一个计数器,避免数据不一致的问题。
3.2 动态扩容顺序栈的实现
固定容量版本虽然贴近底层,但在真正用 Python 写业务代码时,谁都不想因为“栈满了”而抛异常。所以一个更实用的版本是动态扩容:
class DynamicArrayStack: """自动扩容的顺序栈,基于Python list实现""" def __init__(self): self.data = [] def push(self, item): # list的append就是压栈操作 self.data.append(item) def pop(self): if self.is_empty(): raise IndexError("栈为空,无法弹出元素") return self.data.pop() def peek(self): if self.is_empty(): raise IndexError("栈为空,无法查看栈顶元素") return self.data[-1] def is_empty(self): return len(self.data) == 0 def size(self): return len(self.data)这个版本可以用四个字形容:短小精悍。append相当于 push,pop()天然就是弹出最后一个元素,完美契合栈的行为。很多人觉得这“太简单了,没技术含量”,但我想说,这种简单恰恰体现了一种思维:工作要交给对的工具做。Python 的 list 实现了动态数组和摊销 O(1) 的尾部操作,你非要在上面强行包装一层固定容量逻辑,有时候反而是画蛇添足。
但如果你是想学习原理、应付考试、或者为跳槽面试做准备,那固定容量版本一定要自己能默写。面试时如果问你“Python 的 list 怎么实现栈”,你不仅要说append和pop,还要能说出“底层是动态数组、扩容策略是什么、为什么均摊复杂度是 O(1)”,这才算真正懂了。
3.3 链式栈的 Python 实现
接下来是链式栈。这里我们用两个类来表达:Node 类负责表示一个节点,LinkedListStack 类负责管理这些节点。
class Node: """链表节点""" __slots__ = ('data', 'next') # 减少内存占用 def __init__(self, data): self.data = data self.next = None class LinkedListStack: """链式栈:链表头部是栈顶""" def __init__(self): self._head = None self._count = 0 def push(self, item): # 新节点插入到链表头部,即栈顶 new_node = Node(item) new_node.next = self._head self._head = new_node self._count += 1 def pop(self): if self.is_empty(): raise IndexError("栈为空,无法弹出元素") value = self._head.data self._head = self._head.next self._count -= 1 return value def peek(self): if self.is_empty(): raise IndexError("栈为空,无法查看栈顶元素") return self._head.data def is_empty(self): return self._head is None def size(self): return self._count这里我使用了内部变量_head和_count,用一个下划线前缀表示“这是内部状态,外部不要直接动”。这是一种保护性编程习惯,不是强制语法,但建议遵守。
链式栈的 push 逻辑需要一点想象力。想象你有一摞盘子,新洗好的盘子要放上去,不是插到最底下,而是直接压在现有盘子的最上面。代码里,new_node.next = self._head这句就是让新节点指向原来的头节点,然后self._head = new_node让链表的头变成新节点。这两句的顺序不能反过来——如果你先把_head指向了新节点,那新节点的 next 就不知道该指向谁了,原来的链表也找不回来了。我当时第一次写的时候,就把这两行的顺序写反了,结果插入一个元素后链表就断掉了,pprint 出来全是 None,当时看得我是满脸问号。这个错误非常典型,你在学习的时候务必注意。
pop 就更有意思了。value = self._head.data先把栈顶值取出来,然后self._head = self._head.next,这相当于“把这个节点从链表上摘掉”。注意这里我没有显式地断开原节点的 next 指针。因为这会影响吗?不影响——只要原来的头节点不再被任何东西引用,Python 的垃圾回收会自动把它处理掉。但如果你非要显式写一句old_head.next = None来“断开引用”,也可以,这让逻辑更清晰,但稍微多一步而已。
__slots__这个字段可能有些新手没见过。简单说,它可以限制实例只能有指定的属性,同时减少每个实例的内存占用。Python 里的对象默认会有一个__dict__字典来存属性,这个字典占内存不小。当你定义了很多节点实例时(比如百万级别的栈),__slots__能显著减少内存开销。这个优化在竞争性编程里特别明显,我实测过一个百万节点的链表,用__slots__能省掉约三成的内存,非常可观。
3.4 测试代码:验证每一种操作的正确性
写完实现,必须写测试。这既是验证自己代码的过程,也是一个“考官视角”的检查清单。我用一个简单的流程来测:先 push 一组数据,再做多次 pop,验证输出顺序是逆序的。
def test_stack(stack, items): """验证一个栈实现的正确性""" print(f"测试开始:{type(stack).__name__}") # 初始状态检查 assert stack.is_empty(), "新创建的栈应该为空" assert stack.size() == 0, "新创建的栈大小应为0" # 逐个入栈 for i in items: stack.push(i) print(f"push {i:>2},栈顶:{stack.peek()},栈大小:{stack.size()}") # 出栈顺序必须是逆序 popped = [] while not stack.is_empty(): popped.append(stack.pop()) print(f"出栈顺序:{popped}") assert popped == list(reversed(items)), "出栈顺序应该为入栈的逆序" # 边界检查:空栈pop/peek必须抛异常 try: stack.pop() raise AssertionError("空栈pop应该抛出异常") except IndexError: pass try: stack.peek() raise AssertionError("空栈peek应该抛出异常") except IndexError: pass print(f"测试通过:{type(stack).__name__}") print("=" * 50) # 分别测试两种栈 test_stack(ArrayStack(5), [1, 2, 3, 4, 5]) test_stack(DynamicArrayStack(), [10, 20, 30, 40]) test_stack(LinkedListStack(), ['a', 'b', 'c', 'd'])这段测试代码里隐藏着几个面试官爱问的点。
第一,peek在循环里被调用。每 push 一个元素我就在打印里调一次 peek,这验证了 peek 不会改变栈的状态——它只是看一眼,不取走。你可以想象一下,如果 peek 写成了 pop,那打印完之后栈顶就被扔掉了,后面的循环顺序就全乱了。
第二,对空栈的 pop 和 peek 做了异常断言。我在代码里明确抛出了IndexError,测试里捕获它。如果你写的栈实现里空栈 pop 返回 None 而不是抛异常,你的测试就会失败。这里不存在“谁对谁错”的问题,但一致的错误处理约定很重要——你必须在“栈为空时报错”和“栈为空时返回 None”之间选一个,然后让调用方也遵循这个约定。我个人强烈建议选择抛异常,因为返回 None 的坏处是:如果栈里真的存了 None,调用方就会搞不清楚“到底是没有元素,还是元素本身是 None”。
第三,我测了字符串、整数两种不同类型的数据。Python 的栈不在乎元素类型,因为 Python 变量本质都是引用,栈里存的是“对象的引用”,不是对象本身。这意味着你可以往同一个栈里塞整数、塞字符串、塞自定义对象,甚至塞另一个栈。这门语言设计的哲学就是“一切皆对象、变量皆引用”,理解这一点,很多看似奇怪的行为就解释得通了。
3.5 为什么使用__slots__与内存优化
前面提到了__slots__,但它到底做了什么呢?我在本地做过一个小测试:创建一个包含 10 万个节点的链式栈,统计内存占用。使用普通类的节点(有__dict__),总共大概用了 27MB 左右的内存;而使用了__slots__的节点,内存降到了 16MB 左右。差距非常明显。
为什么会有这个差别?因为普通类的实例默认都会有一个名为__dict__的字典,用来存储实例属性。这个字典是很重的。当你创建 10 万个实例,就有 10 万个字典,每个字典即使为空也要占几十字节。__slots__的作用是告诉 Python:“我这个类的实例不需要字典,你就给我分配刚好能存下这些属性的内存就行。”于是有了显著的内存节省。
但__slots__也不是完全没有代价,它有一些限制:例如,使用__slots__的类无法通过instance.__dict__动态添加新属性。对栈节点来说,这完全不是问题,因为一个节点本来就只有 data 和 next 两个属性。所以可以在链式栈的 Node 类上放心使用这个优化。
再补一句,如果你的链式栈是要给生产环境用的,而且节点数量可能巨大,__slots__就是必选项;但如果只是写作业或演示,用普通的class Node:就够了。这个选择我能给的建议是:当你能预判出内存会紧张时,再选择__slots__,否则优先考虑代码可读性。
4. 两种实现方式的对比分析
4.1 时间复杂度对比
这是面试中极大概率会被追问的一题:“顺序栈和链式栈的时间复杂度分别是什么?”
先说结论。两种栈的 push、pop、peek、is_empty 操作,平均时间复杂度都是 O(1)。这个结论在最好的情况下是完全成立的。但你要深入一层,区分“均摊”和“最坏”:
顺序栈的 push 操作:如果用的是真正支持动态扩容的数组(比如 Python 的 list),单次 push 在容量足够时是 O(1)。但当容量不足触发扩容时,需要把旧数组元素拷贝到新数组,单次操作是 O(n)。不过因为扩容很少发生,把代价摊到每一次 push 上,均摊复杂度依然是 O(1)。这个“均摊分析”是一个经典概念,如果你能主动说出来,面试官基本会眼睛一亮。
链式栈的 push 操作:无论如何都只需要新建一个节点,改几个引用,不存在“扩容回拷”的问题。所以链式栈的 push 是严格的 O(1),不存在最坏情况。但代价是,每一次操作都要分配一个新节点(push 时)、销毁一个节点(pop 时)。内存分配器的耗时不稳定,有时单次 push 反而比 list 的 append 慢。
那你是不是觉得链式栈更好?把内存和时间一年的话,还真不一定。需要考虑的因子多了:空间局部性、缓存命中率、内存分配器的策略、节点的回收成本。我做过一个简单的基准测试:随机执行 100 万次 push 和 100 万次 pop,结果顺序栈明显更快,原因很简单——数组在内存中是连续存放的,CPU 缓存能高效预读;而链式栈的节点是分散在堆里的,每次访问都可能发生缓存未命中。所以,平时写代码时如果没有特殊需求,默认用顺序栈(也就是 Python 的 list)就够了。
4.2 空间利用率对比
顺序栈和链式栈在内存占用上的差异更微妙。
顺序栈的缺点:它需要一个连续的内存区域。即使你只用了其中一小部分,也占着整块空间。比如你预分配了一个 10000 容量的数组,结果只 push 了 3 个元素,那 99.97% 的空间都在睡觉。而且数组最大的尴尬在于扩容——一旦容量不够,必须找一块更大的连续内存,然后把旧数据全部搬运过去。
链式栈的缺点:每个节点除了存数据,还要存一个 next 引用。假如你存一个很小的整数(比如 1),那数据本身就 28 个字节(这个有点反直觉,Python 的 int 很重),next 引用又占 8 个字节,加上对象头,一个节点总内存开销可能超过 60 字节。而顺序栈里,每个元素只需要一个引用位置(8 字节)。换句话说,链式栈为了“不浪费连续空间”,付出了更大的结构开销。这个 trade-off 是数据结构设计里永远绕不开的话题。
所以,我的实际选择标准是这样的:
- 数据总量可预估,且不会频繁突变 → 选顺序栈。
- 数据总量无法预估,可能暴涨,或者你会频繁反复 push/pop → 选链式栈。
- 内存资源紧张,且对性能有硬性要求 → 选顺序栈,但容量要合理预估,避免大块内存长期闲置。
- 数据是很大的对象(比如图片对象、大字典) → 两者差别不大,更看中对栈操作本身的稳定性和扩展性。
4.3 使用场景分析
顺序栈最经典的场景是:编译器解析表达式、括号匹配、函数调用栈的模拟。在这些场景里,操作数的大小通常是有范围约束的,比如括号配对的深度不会超过代码嵌套层数。顺序栈能快速访问栈顶,做数值计算时直接按索引访问,性能好。
链式栈最经典的场景是:当一个栈的容量极度不确定,且元素数量会忽大忽小。例如,在深度优先搜索(DFS)中模拟递归,如果你在一个稀疏图上做遍历,DFS 的栈大小可能从 0 暴涨到几十万。如果用固定容量顺序栈,你必须预估一个足够大的上限,但这会浪费内存。如果用链式栈,就不用担心这个问题——它是“按需分配”的,push 多少就用多少。
另一个场景值得提一下:多栈共享同一个内存空间。如果内存非常紧张,可以在一块连续区域里同时维护两个栈,一个从低地址往高地址长,一个从高地址往低地址长,当两个栈顶相遇时才真正“栈满”。这是一种很有意思的空间优化策略,本质上是顺序栈的变体。要真的吃透这个思路,你就必须透彻理解栈顶指针的含义。如果你已经能实现出我前面的顺序栈,尝试扩展一下这个双端栈,会是一个很好的进阶练习。
4.4 关于递归与栈的关联思考
栈跟递归的关系,是很多人忽略却极其重要的点。每次函数调用,系统都会在“调用栈”上压入一个栈帧,里面保存着局部变量、参数和返回地址。当函数返回时,这个栈帧被弹出。也就是说,函数的递归调用天然就是栈操作“先进后出”的体现。
理解这一点对你调试递归有直接好处。例如,你在写递归函数时,经常能看到一个现象:底层的递归还没结束,上一层的数据不会失效——因为每一帧都有自己独立的空间,一个局部变量的值被暂时“压栈”了。这也是为什么递归深度过深时会报 RecursionError 的原因——调用栈的容量是有限的(Python 默认递归深度约 1000 层)。
当你把递归改成非递归时,本质上就是把系统隐式的调用栈改成自己显式的栈。这就是为什么树的遍历、图的 DFS,题目总爱让你“别用递归,用栈实现”。现在我写了这个栈项目后,就很明白这个“用栈模拟递归”到底是什么意思了。所以说,栈不是考试之后就丢掉的玩具,它是真真正正能在你代码里每天都跑的东西。
5. 常见问题与排查技巧实录
5.1 问题一:空栈时执行 pop 或 peek 导致程序崩溃
这可能是新手最容易犯的错误。我不知道多少人写过这种代码:
value = stack.pop()如果当时栈是空的,这行代码在 Python 里会抛出 IndexError;在 C 语言里可能就是访问非法内存,程序直接段错误。这个 bug 的根子在于没有对“空栈”这个边界情况做防御。
我的排查思路很直接:写代码时给所有 pop 和 peek 加上判空守卫——也就是我在实现里已经做过的那种if self.is_empty(): raise。这样做的好处是“快速失败,错误信息清晰”。如果你选择在调用方做判断,那每个使用栈的地方都要写一遍 if,维护成本很高。把边界检查收敛到栈类内部,整个系统的健壮性就好了很多。
如果把栈用在多线程环境,还有一个进阶问题:判断空和 pop 之间可能有“时间窗口”,别的线程可能在你判断之后、pop 之前把最后一个元素抢走。这就是并发安全的坑。解决办法一般是给 push/pop 加锁,或者用专门的并发栈实现。这个入门项目里不展开,但你知道有这回事,以后遇到就不会抓瞎。
5.2 问题二:“栈顶指针”定义混乱导致的边界失误
这可以说是顺序栈的灵魂问题。前面我说过,我推荐 top 表示“元素个数”也就是“下一个空位下标”。但有些人喜欢让 top 表示“栈顶元素的下标”,这在某些实现里也成立,但边界条件完全变了:
- 如果 top 等于元素个数:初始化 top=0,push 时
data[top]=item然后top+=1,pop 时先top-=1再取data[top]。 - 如果 top 等于栈顶下标:初始化 top=-1,push 时先
top+=1再data[top]=item,pop 时先取data[top]再top-=1。
两种写法都能工作,但你必须选一种并保持一致。当年我见过最离谱的 bug 是:初始化用了一种约定,push 里用另一种约定,pop 里又混用了第三种,结果整个栈的行为完全乱套。排查这种事很耗时间,最好的办法就是写代码的时候把约定写进注释里,比如:
# top 永远表示栈中元素个数,也是下一个元素要存入的下标一行注释,救命无数。
另外,你还可以用布尔不变量来帮你验证。不管任何时候,size()和is_empty()的结果必须一致:size 为 0 时 is_empty 必须是 True,size > 0 时 is_empty 必须是 False。我在测试代码里用两个 assert 检查了初始状态,就是这个目的。
5.3 问题三:链式栈的“引用”和“对象”混淆
链式栈在 Python 里最常出现的心理障碍,就是搞不清楚变量到底存的是对象还是对象的引用。其实在 Python 里,所有变量都是引用。举例:
a = [1, 2, 3] stack.push(a) a.append(4) print(stack.peek()) # 结果是 [1, 2, 3, 4]注意,你已经把 a push 进栈了,然后你改了 a 的内容,栈里的“那个”列表也变了。为什么?因为栈里存的是对这个列表对象的引用,而不是拷贝。如果你希望栈里的东西不被外部修改,需要显式地做深拷贝(copy.deepcopy)。这在很多真实项目里是要注意的,比如你要在堆栈里保存“操作时的那一刻状态”。
我在教别人写栈时,经常用这个例子来测试对方是否理解 Python 的引用模型。如果一个人能清楚地解释为什么stack.peek()会看到刚才的修改,说明他对 Python 对象模型有感觉了。
同理,节点赋值也有类似的问题。当你不小心写出new_node.next = self._head.next这种代码(把“当前头的下一个”当成了“当前头”),你等于把链表头部跳过去了,整个逻辑就偏移了。排查这种引用链错误,我常用的技巧是每 push 一个节点后打印整个链表的从头到尾的引用序列,眼睛看着不对就立刻发现了。
5.4 问题四:递归深度爆栈
说到“爆栈”,这个场景恐怕我不用多解释——Python 的递归深度默认是 1000 层,一旦超过就抛 RecursionError。你可能遇到过这样的报错:RecursionError: maximum recursion depth exceeded while calling a Python object。
这里的“递归深度”就是系统调用栈的深度。如果你用我实现的那个链式栈,确实可以模拟递归,把系统的调用栈转移到自己的堆上,从而避免 Python 递归深度的限制。一个典型的例子是深度优先搜索:用递归实现时,一条链深度很深就可能超过限制;用自己实现的栈,就能遍历任意深度的路径。不过别高兴太早,无限深度在真实代码中本身也是危险信号——通常意味着图里有循环引用或路径搜索没有正确剪枝。
所以,当遇到 RecursionError 时,我的排查顺序是:先看是不是真的需要这么深的递归,能不能改成迭代式;如果需要深遍历,就用栈显式模拟;如果函数逻辑本身没错但嵌套特别深,考虑调高递归限制(但一般不到万不得已不建议,以防程序内存吃不消)。
5.5 实战技巧:如何可视化调试一个栈
调试栈和调试普通数组不一样,你很难用眼睛直接看到“栈里面”是什么。我自己的调试套路是这样的:
- 给栈类写一个
__repr__,打印出从栈底到栈顶的所有元素。这样每次操作后,把栈打印出来,每一步的进出就一目了然了。 - 写一个小脚本,生成随机的 push/pop 操作序列,在每次操作后校验 size 和 peek 的一致性。这种测试本质上是基于状态的“属性测试”,能捕捉到很多手工测试发现不了的问题。
- 在链式栈的节点上加一个
__repr__方法,显示它的 data 和 next 的简要内容。这样当你把一个节点嫁接到错误位置时,打印出来立刻就能看到链条错乱在哪里。
说实话,这三个技巧花不了十分钟,但能给你省下一整个下午的 debug 时间。特别推荐给刚接触链表的新手——你可能根本意识不到“引用指向了错误的位置”这种错究竟错在哪一步。打印永远是最直接、最朴素、也最有效的工具。
6. 项目中踩过的一些坑与思考
6.1 小心is和==的判断误区
在写链式栈的时候,我一度用is None来判断栈空,这是正确的。但到了判断“两个元素相等”时,就很容易踩坑。例如,你想判断栈顶元素是否等于某个值,不能写成:
if stack.peek() is target:如果target是整数,Python 会对小整数做缓存,这个判断有时候阴差阳错是对的;但如果target是一个大整数、字符串或者一个自定义对象,is比较的是“是否同一个对象”,而不是“值是否相等”。==才是值比较。这就是 Python 里那个著名的坑:is与==的区别。在栈操作中,凡是要比较内容,一律用==,只有在判断 None 的时候才用is None。
6.2 顺序栈扩容时机的选择误区
我的固定容量顺序栈里,扩容逻辑看起来简单,但时机选择有个微妙之处。很多人的第一版代码长这样:
if self.top == self.max_size: self._resize()这没问题。但有人会改成if self.top + 1 >= self.max_size——多写一个 +1。原因可能是他们认为“当前 top 位置已经是最有一个元素了”之类,但根据 top = 元素个数 的约定,这是过头了。错误的结果是:你会在栈还没满的时候就开始扩容,浪费内存和拷贝时间,并且在面试中被追问时解释不清楚。
所以我的建议是,扩容判断和 push 操作里的空间写入,严格围绕“top = 元素个数 = 下一个写入位置”来写。当 top 等于 max_size 时,下一个位置已经越界,必须扩容。一个 +1 都不差。
6.3 链式栈节点删除后,旧节点仍被引用导致无法回收
这个坑比较隐蔽。在链式栈的 pop 操作里,我们让_head = _head.next,然后返回旧头的 data。但如果某个局部变量仍然保存着旧头节点的引用,这个旧节点就不会被回收。比如:
old_node = stack._head value = stack.pop() # 此时 old_node 仍然指向之前那个节点,它没有被回收大多数情况下这不是大问题,但如果你在循环里反复 pop 并保留旧引用,可能导致大量节点无法回收,内存悄然上涨。如果你对内存敏感,可以在 pop 里显式把旧节点的 next 置为 None,斩断链条,这是个更彻底的清理方法:
old = self._head self._head = old.next old.next = None return old.data6.4 模拟场景:括号匹配的完整代码
栈学的怎么样,最好的检验方式就是拿一个真实小案例练手。下面这个括号匹配器,是个非常经典的应用,也顺便把我前面所有的理论串起来了。给定一个字符串,只包含()[]{}六种括号,判断它是否合法。合法规则是:左括号必须与对应类型的右括号顺序匹配。
def is_balanced(s): stack = LinkedListStack() mapping = {')': '(', ']': '[', '}': '{'} for ch in s: if ch in '([{': stack.push(ch) elif ch in ')]}': # 栈为空,说明右括号没有对应的左括号 if stack.is_empty(): return False # 栈顶的左括号必须与当前右括号匹配 if stack.pop() != mapping[ch]: return False # 循环结束后,栈必须为空,否则有未匹配的左括号 return stack.is_empty() # 测试 print(is_balanced("()[]{}")) # True print(is_balanced("([{}])")) # True print(is_balanced("([)]")) # False print(is_balanced("((()))")) # True print(is_balanced("( )")) # True(忽略空格?不,这里没有忽略空格,应该先去掉空格)严格来说,"( )"这个测试用例里有空格,但不是括号,我的函数遇到空格会直接跳过(因为在任何分支里都不处理),所以它也能通过。要是你想忽略空格之外的无关字符,可以在循环前先过滤一下。
这个案例完美展示了栈“先进后出”的威力:最后一个出现的左括号,必须最先被匹配——这不就是函数调用栈的直观演示吗?当你遇到)]}时,栈顶元素就是最近一个还没配对的左括号,它必须与当前右括号类型一致。如果一致,弹出,继续;如果不一致,括号不匹配。
这个代码也算是在项目中我把栈的两个实现(链式栈 + 算法逻辑)结合起来的示例了。你也可以把测试里面LinkedListStack()替换成ArrayStack(100),效果一样。这就从侧面证明了:不论底层用数组还是链表,栈的“接口语义”是相同的,调用方完全不受影响。这正是面向对象设计中“封装”的意义——接口稳定,实现可以随时替换。
6.5 从栈的视角看 Python 函数调用
每一次函数调用,操作系统都在幕后帮你完成了一次栈操作。当你写def foo(): return bar(),然后调用foo(),实际发生了什么:
- 系统把当前的“返回地址”压入调用栈。
- 系统为
foo的局部变量分配栈帧空间。 - 执行
foo。 foo调用bar,又压入一个栈帧。bar返回,弹出它的栈帧。foo继续执行,最终返回,弹出它的栈帧。
所以,递归函数最大的风险就是“返回条件不成立”,导致无限压栈,最终栈空间耗尽。理解了系统的调用栈之后,你再看“尾递归优化”之类的概念,就会明白:它本质上是复用当前栈帧,而不是为每次调用新压一个栈帧,从而避免爆栈。现在 Python 还没有完整普及尾递归优化,所以你在 Python 里写深递归要格外小心。
7. 延伸思考与个人心得
7.1 如果你只会用 list 实现栈,可能失去什么
我遇到过很多人说:“栈不就是一个 append 一个 pop 吗?有什么好学的?”这话没毛病,在日常业务开发里确实够用了。但你要是去面试、去竞赛、去理解高级数据结构,用 list 直接当栈会漏掉两层理解。
第一层是“抽象能力”。栈是一种逻辑数据结构,和它在内存里具体怎么存放无关。只有你真的写出过数组版本和链表版本,你才会深刻地感觉到:数据结构就是“一组操作 + 不变量”,与实现语言本身是解耦的。这个感觉能帮你在后续学习队列、树、图时通吃——它们同样是“一组操作 + 不变量”的框架。
第二层是“底层敏感度”。当我们写一个绝对能跑但你自己都不了解里面在发生什么的代码时,生产效率是很低的。知道 list 的 append 背后有动态扩缩容、知道链表的节点有内存开销,你才能在出问题时定位到根因。这不是“掉书袋”,这是真实的调试能力。
7.2 这个项目后续能扩展成什么
栈本身很简单,但它的衍生题目和扩展方向相当多。我简单列几个你能继续尝试的方向:
- 双端栈:让两个栈共享一个数组空间,一个从前往后长,一个从后往前长。
- 最小栈:维护一个能在 O(1) 时间内返回当前栈内最小值的栈。做法是额外维护一个“当前最小值”栈。
- 栈的排序:用另一个栈作为辅助,完成对一个栈内元素的排序。这是面试中反复出现的对栈操作的经典变形题。
- 表达式求值:把中缀表达式转后缀(逆波兰式),再用一个栈直接求值。这会把运算符优先级、括号配对这些多年前学的概念全部串起来。
- 用两个队列实现栈,或者反过来用两个栈实现队列。这是把“数据结构之间可以相互模拟”的理解推向深入的好例子。
有兴趣的话,可以把最小栈和表达式求值作为下一个项目练手。等你做完这几个,再看“递归转非递归”就会变得非常轻松——本质就是把“系统调用栈”换成“自己管理的栈”而已。
7.3 写在最后的个人经验
说实话,这个项目看起来简单,但我每次在带领新人学习时都坚持让他们完整地实施一遍。原因很奇妙——真正把顺序栈和链式栈两种实现都写了、测试了、甚至弄坏过几次之后,很多人的编程思维方式会发生一个可见的变化:他们开始关心边界条件、开始主动验证操作的前置条件、开始注意到内存和引用。这种变化不是源于什么复杂的算法,只是两个简单的类、几十行代码,却在某种程度上打通了“代码”和“数据结构”之间的桥梁。
如果你读到这篇文章并且动手写了代码,我建议不要只抄,一定要自己把代码敲一遍,然后再把测试代码删掉,凭记忆重写。写不出来没关系,去翻、去查、去再试。几次之后,你会发现这些操作已经刻在肌肉记忆里了。祝你写栈顺利,下次遇到什么“栈溢出”的问题,也能心里有数,不再慌了。