最近在带新人做练手项目,选了个特别基础但又特别耐琢磨的题:用Class实现一个栈。很多人一听就觉得“这有什么好写的,不就是个列表套个壳吗”,可真动手的时候,self忘写、空栈不知道怎么办、多个对象共享了同一份数据,各种问题全冒出来了。这篇就把我实际带人过程中反复讲的那些东西整理成文,把原理、代码、踩坑、进阶一次性说透。
1. 为什么是class,为什么是栈
1.1 先把Python里class这件事掰开
Python里的class不是一个神秘的东西,它就是一个工厂模板。你写一个Stack类,不是直接得到一个栈,而是拿到一张“怎么造一个栈”的图纸。每次调用Stack()就是在照着图纸盖一间新房子,每间房子都是独立的,互不干扰。
python复制class Stack:
pass
s1 = Stack()
s2 = Stack()
print(s1 is s2) # False
这里s1和s2是两份独立的空间。很多人刚接触面向对象时最大的困惑就是“我写了一个class,为什么不能直接Stack.push()这样调用”,原因很简单:push是设计给实例用的方法,类本身只是一个模板,不参与具体数据存储。你要先造出实例,再通过实例去操作。这个概念如果没扭过来,后面写栈的每个方法都会觉得别扭。
用class实现栈,本质上就是用一个模板把“数据”和“操作数据的方法”绑在一起。栈里存什么、能做什么操作,全部由这个类来定义。好处是:你以后再写十个不同的栈,每个栈都是独立的实例,数据互不污染;别人拿到你的Stack类,不用看内部实现,调用push、pop就完事。这就是封装的价值。
1.2 栈的定义,别和“技术栈”搞混
现在市面上提到“全栈开发”“技术栈”,很多人第一反应是前端后端都会写。但数据结构里说的栈,完全是另一码事。栈是一种“后进先出(LIFO,Last In First Out)”的线性数据结构。
通俗地说,就像一个竖着的弹簧弹夹:你往里面压子弹,最先压进去的在最底下,最后压进去的在最上面;射击时,最上面那颗先被推出去。栈的操作就两个核心动作:
- 入栈(push):往栈顶加一个元素
- 出栈(pop):从栈顶取走一个元素
还有一个常见的查看操作叫peek(也有人叫top),只看栈顶是什么,但不拿走它。这三个操作是栈的看家本领,所有用栈解决的算法题,本质都是在组合这三个动作。
和栈经常一起出现的是“队列”。队列是先进先出(FIFO),像排队打饭,先来的先吃。很多初学者把这两个搞混,我建议你想一个场景:浏览器的后退按钮,你每点一个页面就压入栈中,点后退就是弹出当前页面,回到上一个页面——这就是栈;而打印机任务队列就是队列,先提交的先打印。
1.3 为什么选class,而不是直接用一个列表完事
新人经常问:Python里直接用列表不就能模拟栈吗?append()是入栈,pop()是出栈,还写什么类?
理论上确实可以,但现实中有两个问题。第一,列表能自由地访问任意位置,能切片、能排序、能随便往中间插元素,这些操作对“栈”来说都是多余且危险的。栈的规则是“只能从栈顶进出”,直接用列表等于把门禁拆了,谁都可以从窗户翻进去。比如你写了一个算法,本来依赖栈的严格后进先出特性,结果某处无意中做了个lst[0],逻辑就悄悄改变了,这种Bug特别难查。
第二,直接用列表,代码里到处是data.append(...)和data.pop(),阅读者必须每次都盯着上下文,才知道这是在当栈用。而封装成class之后,调用方看到的是stack.push(item),语义一目了然。再加上你能在类里统一处理空栈、满栈这些边界情况,而不是让每个使用方自己去判断,健壮性会高很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零写一个能上手的栈类
2.1 初始化和入栈:先把地基打牢
python复制class Stack:
def __init__(self):
self._items = []
所有栈操作都围绕self._items这个内部列表展开。我特意在变量名前加了下划线,这是一种约定,表示“这是内部数据,不希望在类外面直接访问”。它不是强制私有,Python没有绝对的私有,但下划线在团队协作中相当于一个无声的提醒。
python复制 def push(self, item):
"""把元素压入栈顶"""
self._items.append(item)
入栈操作直接用append,把新元素追加到列表尾部。这里有个很多人会问的点:列表尾部到底算栈顶还是栈底?
答案:我们把列表尾部当栈顶。因为append和pop都是O(1)操作,在列表尾部增删元素性能最好。如果你把列表头部当栈顶,用insert(0, item)和pop(0),每次都要把后面所有元素挪一位,时间复杂度变成O(n),数据量一大就卡。这个选型不是习惯问题,是性能问题。
2.2 出栈和查看栈顶:处理空栈是设计核心
python复制 def pop(self):
"""弹出栈顶元素"""
if self.is_empty():
raise IndexError("pop from empty stack")
return self._items.pop()
def peek(self):
"""查看栈顶元素,不弹出"""
if self.is_empty():
raise IndexError("peek from empty stack")
return self._items[-1]
这里最大的设计决策是:空栈时该怎么办。
有人习惯返回None,理由是“调用方拿不到元素时给个空值就行”。但我不推荐在栈的具体实现里返回None。设想一下,如果栈里存的本就是None呢?调用方根本分不清“栈是空的”和“栈顶元素就是None”这两种情况。更麻烦的是,如果调用方忘了判断返回值,直接拿None参与运算,程序会在一堆莫名其妙的地方报错。
所以我在空栈时抛IndexError,把问题暴露在出错的那一刻。这样调用方要么先判断is_empty(),要么用try...except兜住异常,逻辑更清晰。这也是很多标准库和主流框架的做法,遵循“快速失败,尽早暴露”的原则。
另外注意peek用self._items[-1],这是Python列表的负索引特性,直接取最后一个元素,不需要弹出。
2.3 判空、大小和打印:把细节补齐
python复制 def is_empty(self):
"""判断栈是否为空"""
return len(self._items) == 0
def size(self):
"""返回栈中元素个数"""
return len(self._items)
def __repr__(self):
"""打印栈的可读表示"""
return f"Stack({self._items})"
这四个方法里,__repr__容易被忽略,但实际调试时特别有用。没有它,你直接打印一个栈对象,看到的是一堆<__main__.Stack object at 0x7f...>,完全不知道里面存了什么。加上__repr__之后,打印出来是Stack([1, 2, 3]),一眼看清状态。
还可以顺手加一个__len__特殊方法,让实例支持len(stack)的调用方式:
python复制 def __len__(self):
return len(self._items)
加了之后,len(stack)和stack.size()两种写法都能工作,兼容不同使用习惯。__repr__和__len__这种带双下划线的方法统称“魔法方法”,它们不是让你直接调用的,而是Python在特定场景下自动触发的钩子。print对象时触发__repr__,len()时触发__len__,理解了这个机制,就不用背语法了。
2.4 边界情况和设计取舍:写栈最容易忽略的地方
把上面的代码拼起来,你已经有了一个能工作的栈。但既然是拿class实现,就要把边界情况想全。我列出实际写代码时最常见的几个决策点:
要不要限制容量?
很多教材里的栈,特别是用数组实现的版本,会限制栈的最大深度,超过容量就抛“栈满”。但Python列表天然支持动态扩容,我们直接用list做底层存储,就不需要手动处理容量问题。如果你要实现一个固定深度的栈,比如限制最多存100个元素,可以在push里加判断:
python复制 def __init__(self, capacity=None):
self._items = []
self._capacity = capacity
def push(self, item):
if self._capacity is not None and len(self._items) >= self._capacity:
raise OverflowError("stack is full")
self._items.append(item)
这样设计的好处是,调用方可以按需决定是否限制容量。不传capacity时是无限栈,传了就变有界栈,一个类适配两种场景。
要不要支持迭代?
默认情况下,for item in stack:是不能直接用的,因为栈没实现迭代协议。我们可以加一个__iter__方法,让栈支持从栈底到栈顶的遍历:
python复制 def __iter__(self):
return iter(self._items)
但这里要谨慎:栈的本质是后进先出,如果你允许随意遍历,就等于又破坏了封装。我个人的建议是,除非确有需要,否则不要让普通栈支持迭代,逼着使用者通过pop按顺序取数据,反而能保证栈的语义不被破坏。如果真的需要“不弹出地遍历所有元素”,可以单独写一个items()方法,返回副本。
底层用list还是collections.deque?
这个问题的答案是:如果只做栈,list就够。list.append和list.pop在尾部都是O(1)的,虽然偶尔会触发扩容,但扩容是均摊下来的,整体依然高效。deque在尾部也是O(1),但它是双端队列,专为前后两端操作设计,用在这里有点杀鸡用牛刀。除非你明确要做的是“双端栈”,否则默认list就行。
3. 拿这个栈做几个经典应用
3.1 括号匹配:栈最经典的练手场景
栈最出名的应用之一就是检查表达式里的括号是否匹配。规则是:每个左括号都要有一个对应的右括号,而且匹配顺序要正确——( [ ] )是合法的,( [ ) ]不合法。
算法的思路很直接:遍历字符串,遇到左括号就压入栈,遇到右括号就弹出栈顶,检查是否匹配。
python复制def is_balanced(s):
stack = Stack()
pairs = {')': '(', ']': '[', '}': '{'}
for ch in s:
if ch in '([{':
stack.push(ch)
elif ch in ')]}':
if stack.is_empty():
return False
if stack.pop() != pairs[ch]:
return False
return stack.is_empty()
print(is_balanced("( [ { } ] )")) # True
print(is_balanced("( [ ) ]")) # False
这里有个细节值得说:为什么最后还要stack.is_empty()?因为有可能遍历完后,栈里还残留着没被匹配的左括号,比如"((())",这种情况下括号数量都不对,必须判空。
我经常跟新人说,括号匹配是练“栈思维”的入门题,它用到的就是栈最朴素的特性:后遇到的左括号先被匹配。这符合语言的嵌套结构,而嵌套结构天然适合栈来维护。
3.2 进制转换:把“后进先出”用出价值
十进制转二进制,用短除法,每次除2取余数。但余数是从低位往高位产生的,写出来是反的。手工算的时候,我们会从下往上读,这个“从下往上读”的动作,正好就是栈的后进先出。
python复制def decimal_to_binary(n):
stack = Stack()
if n == 0:
return "0"
while n > 0:
stack.push(n % 2)
n //= 2
result = ""
while not stack.is_empty():
result += str(stack.pop())
return result
print(decimal_to_binary(10)) # 1010
进阶一点,可以改成任意进制(2到36):
python复制def decimal_to_base(n, base):
digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
stack = Stack()
if n == 0:
return "0"
while n > 0:
stack.push(digits[n % base])
n //= base
result = ""
while not stack.is_empty():
result += stack.pop()
return result
print(decimal_to_base(255, 16)) # FF
这个例子很好地展示了栈的一个特性:它天然能反转顺序。凡是“先算出来的结果要后输出”的场景,都可以想想是不是用栈来存。
3.3 后缀表达式求值:计算机怎么算数学题
我们平时写的是中缀表达式,比如3 + 4 * 2,计算机解析起来要处理优先级和括号,比较麻烦。后缀表达式(逆波兰表达式)把操作符写在数字后面,比如3 4 2 * +,它的好处是完全没有优先级问题,从左到右扫描一遍就能算完。
求值算法如下:
- 遇到数字,压入栈
- 遇到操作符,弹出两个数字,先弹出来的是右操作数,再弹出来的是左操作数
- 计算结果再压入栈
- 扫描完后,栈顶就是最终结果
python复制def eval_postfix(expression):
stack = Stack()
operators = {'+', '-', '*', '/'}
for token in expression.split():
if token not in operators:
stack.push(float(token))
else:
b = stack.pop()
a = stack.pop()
if token == '+':
stack.push(a + b)
elif token == '-':
stack.push(a - b)
elif token == '*':
stack.push(a * b)
else:
stack.push(a / b)
return stack.pop()
print(eval_postfix("3 4 2 * +")) # 11.0
这里有个特别容易踩的坑:弹出两个数字的顺序。假设表达式是8 2 /,栈里先压入8,再压入2,第一次pop()出来的是2,第二次pop()出来的是8。如果你写反了,pop()出来的第一个当成左操作数,结果就变成2 / 8 = 0.25,完全错了。
这类问题我也在面试里出过,很多人算法思路没问题,就是在这里栽跟头。你可以在出栈之后用一个临时变量存下来,写清楚哪个是a,哪个是b,宁可多写一行,不让自己判断错。
3.4 生活中的栈:撤销、前进后退、递归
除了算法题,栈在真实软件系统里到处都是。文本编辑器的“撤销”功能就是把每次操作压入栈,按Ctrl+Z就弹出最近的记录。浏览器的后退按钮,也是把访问过的页面压入栈,后退时弹栈。函数调用本身更是栈在操作系统层面的直接体现:每次调用函数,系统会把返回地址、局部变量压入“调用栈”,函数返回时再弹出,这就是递归容易导致“栈溢出”的原因——递归太深,调用栈塞满了。
理解了这句话,你就明白为什么掌握栈的必要性:你在系统里遇到的各种“回溯”“恢复”“撤销”机制,十有八九都是栈在背后工作。写class实现的这个栈虽然简单,但它把底层逻辑讲透了,以后遇到更复杂的场景,无非是在这个骨架上加东西。
4. 常见错误和排查实录
4.1 忘了写self:新手第一坑
python复制class Stack:
def __init__(self):
items = [] # 错误
self._items = [] # 正确
不写self时,items只是方法内的局部变量,方法一结束就没了,数据根本没存到实例上。我见过最典型的报错是:push完一个元素,接着pop却提示空栈。原因就是push里用的items和pop里用的items压根不是同一个。
排查方法:在push之后直接print(self.__dict__),看实例上到底挂了哪些变量。__dict__是实例的属性字典,等于把实例内部的所有数据亮出来给你看。
4.2 多个实例共享同一份数据:把可变默认值写进了构造参数
python复制class Stack:
def __init__(self, items=[]): # 隐患
self._items = items
这个写法非常隐蔽。Python的默认参数在函数定义时只被求值一次,如果你用了可变对象[]作默认值,所有不传items参数的实例,会共享同一个列表。
python复制s1 = Stack()
s2 = Stack()
s1.push(1)
print(s2.size()) # 竟然是1
s1入栈的元素,影响到了s2,因为它们底层是同一个列表。正确写法:
python复制 def __init__(self, items=None):
self._items = items if items is not None else []
关于可变默认参数,我在带项目时几乎每次都会单独强调。它不只在栈这个例子里会出现,只要是class里接受列表、字典等可变对象作默认参数,都可能踩中。
4.3 pop和peek混用:误把查看当成弹出
有人图省事写道:
python复制top = stack.pop()
# 结果top确实拿到了,但栈里的元素也被删了
如果你只是想看一下栈顶是什么,应该用peek()。我把pop命名为“弹出”,而不是“拿走并删除”,就是为了强调它有副作用,它会改变栈的状态。日常写码时,凡是像remove、delete、pop这种带破坏性的方法,调用前都要想一遍“这个元素我还需不需要留在原处”。
4.4 拿None当空栈的返回值:小心合法值被吞掉
前面说了,我建议空栈抛异常而不是返回None。但如果你在自己的项目里坚持返回None,就要特别注意:栈里可能压入None当作合法数据。
python复制stack.push(None)
value = stack.pop() # 你以为栈空了,其实不是
这种情况会造成状态混乱。我的建议很简单:栈的实现里不偷懒,空栈就抛异常,判断是否为空请用is_empty()方法。调用方逻辑明确,代码反而更干净。
4.5 修改了内部列表:封装被破坏
写了一个class栈后,测试代码里出现了这种写法:
python复制stack = Stack()
stack._items.insert(0, "插队")
这能运行,但完全破坏了栈的语义。你等于从窗户爬进去,绕过了门禁。虽然Python不会阻止你这么做(私有变量下划线只是约定),但一个合格的工程师应该克制这种操作。如果确实要从外部获取栈的内容,就在类里显式提供方法,比如items()返回一个副本:
python复制 def items(self):
"""返回栈内所有元素(副本,不修改原栈)"""
return self._items.copy()
这样既方便调试,又不给外部直接操作内部列表的机会。
5. 再进一步:面试题和系统级栈
5.1 用两个栈实现队列:经典面试题
题目:只能用两个栈,要实现队列的入队和出队。
思路是两个栈分工,一个管入队,一个管出队。入队时直接压入input_stack;出队时,如果output_stack是空的,就把input_stack的元素全部弹出来压入output_stack,再从output_stack弹顶。
python复制class QueueWithTwoStacks:
def __init__(self):
self._input = Stack()
self._output = Stack()
def enqueue(self, item):
self._input.push(item)
def dequeue(self):
if self._output.is_empty():
while not self._input.is_empty():
self._output.push(self._input.pop())
if self._output.is_empty():
raise IndexError("dequeue from empty queue")
return self._output.pop()
关键是“倒腾”的时机:只有在出队栈为空时才搬运,这样均摊下来,每个元素最多入栈两次、出栈两次,整体时间复杂度还是O(1)。很多新手会让入队时就把所有元素倒到另一个栈,再倒回来,结果入队也变成O(n),失去了意义。
5.2 单调栈:用空间换时间的高级用法
单调栈是指栈内元素保持单调递增或单调递减。典型问题是“求数组中每个元素右边第一个比它大的数”,暴力解法是两层循环O(n²)。单调栈可以在O(n)内解决。
python复制def next_greater(nums):
n = len(nums)
result = [-1] * n
stack = Stack()
for i in range(n):
while not stack.is_empty() and nums[stack.peek()] < nums[i]:
idx = stack.pop()
result[idx] = nums[i]
stack.push(i)
return result
print(next_greater([2, 1, 4, 3])) # [4, 4, -1, -1]
这里栈里存的是下标,不是值,因为最后要给每个位置赋值。维护单调栈的核心是:当新元素比栈顶元素大时,说明栈顶元素的“下一个更大值”已经找到了,可以出栈。这个过程保证了每个元素只进栈一次、出栈一次,是典型的时间换空间的优化。
5.3 程序里的调用栈和栈回溯
前面已经提到函数调用栈。这个概念在程序调试时有个对应名词叫“栈回溯”,也就是报错时打印的那一长串调用关系。
python复制def a():
b()
def b():
c()
def c():
raise ValueError("出错了")
a()
运行后会看到从c到b到a再到模块层的调用路径。这就是“栈回溯”,它展示的正是调用栈在崩掉瞬间的现场。学会读栈回溯很重要:从底部往上看,找到第一个属于你自己代码的帧,那通常才是问题的源头。
5.4 栈式虚拟机:JVM和Python解释器都在用
热词里有一堆Java相关的,比如“class jdk.proxy1.$proxy0 cannot be cast to class”“failed to find main class”,这些报错本质上都和类的加载机制有关,而类加载、方法调用依赖的同样是栈结构。
JVM(Java虚拟机)是典型的栈式虚拟机,每个线程都有自己的虚拟机栈,每个方法调用对应一个栈帧。局部变量、操作数栈、方法返回地址都存在栈帧里。Python解释器也是栈式的,执行字节码时通过操作数栈来传递中间结果。你写的每一个算术表达式,翻译成字节码后都涉及“把操作数压栈”“执行运算后弹栈”的循环。这也是为什么用class实现一个栈看起来很简单,学明白了,你就能真正理解那些虚拟机在你眼皮底下做的事。
如果对这块有兴趣,还可以进一步研究“栈式虚拟机”和“寄存器式虚拟机”的区别。栈式指令集短小统一,代码体积小;寄存器式指令集更灵活,但指令长度不稳定。Android早期的Dalvik虚拟机就是寄存器式的,而JVM是栈式的,各有取舍。
写在最后
如果让我给刚开始学class和栈的人一个建议,我会说:不要只把代码抄一遍,然后自我感觉学会了。把这个栈类的__init__、push、pop、peek全部自己推理一遍,想一想为什么pop要抛异常,为什么底层用列表尾部当栈顶,为什么可变默认参数是坑,然后在LeetCode上找几道用栈解决的题目练手。等到你能不看资料,自己写出eval_postfix甚至单调栈,才算真的把class和栈这两个知识点内化了。
我当年学这类基础数据结构时,最大的领悟就是:越是看起来简单的东西,越值得把它彻底搞清楚。因为后面所有的复杂系统,Web框架的路由匹配、编译器的语法分析、虚拟机的指令执行,底层全是这些基础结构在支撑。今天花一下午把栈玩透,后面可以少走很多弯路。
