官方文档把这件事写得很轻描淡写——「list methods make it very easy to use a list as a stack」。轻描淡写到大部分人根本不觉得这是个知识点:不就是 append 加、pop 取吗?
先说一个几乎人人都踩过的直觉误判。append 和 pop 看着是一对完美对称的操作,于是很自然地会推出一个"顺带结论":那把 pop() 换成 pop(0),不就从栈(lifo)变成队列(fifo)了?反正都是 o(1) 的两端操作。
这个推断错得不算离谱,因为它在"两端"这个词上是对的,在"代价"这个词上是错的。下面这组本机实测数字,是我认为解释这件事最有效的方式(长度保持恒定,取 7 次运行的最小值):
| 操作 | len=10 | len=1,000 | len=100,000 |
|---|---|---|---|
s.insert(0,1); s.pop(0) | 88.8 ns | 1,298.8 ns | 168,563.3 ns |
s.append(1); s.pop(0) | 64.2 ns | 342.6 ns | 42,005.9 ns |
deque.appendleft(1); popleft() | 56.6 ns | 54.1 ns | 44.1 ns |
同样叫 pop,尾部那个耗时始终在几十纳秒量级横着走,头部那个却随着长度一路涨到 168 微秒——涨了约 1900 倍。原因不神秘:list 是连续内存上的动态数组,pop(0) 之后所有元素都得往前挪一格。所谓"两端操作",一端是 o(1),另一端是 o(n)。
而官方文档其实早就把这两个用法并排放在同一页:5.1.1 using lists as stacks 和 5.1.2 using lists as queues,后一节第一句话就是 “lists are not efficient for this purpose”。这就是本文要展开的那句话。
本文所有数字、字节码、异常文案与代码输出,均来自本机真实运行,环境如下:
python: 3.11.9 (main, apr 15 2024, 17:28:11) [clang 17.0.6 ] platform: macos-26.5.2-arm64-arm64bit pointer size: 64 bits
不同版本、不同架构(x86 / arm64)上的绝对值会有漂移,但量级与相对关系稳定。文中每个结论都标了它是"实测"还是"文档"。
一、先说清楚:list 当栈,官方给的就是这四个动作
cpython 官方教程原文(docs.python.org/3/tutorial/datastructures.html,5.1.1):
the list methods make it very easy to use a list as a stack, where the last element added is the first element retrieved (“last-in, first-out”). to add an item to the top of the stack, use append(). to retrieve an item from the top of the stack, use pop() without an explicit index.
翻译过来就一句:栈 = 尾部进出。所以"list 当栈"的完整 api 面只有这么点东西:
| 栈语义 | list 写法 | 复杂度 | 备注 |
|---|---|---|---|
| push(x) | s.append(x) | 摊销 o(1) | 满了才 realloc |
| pop() | s.pop() | o(1) | 空栈抛 indexerror |
| peek() | s[-1] | o(1) | 需自行判空 |
| isempty() | not s | o(1) | 走 __bool__,比 len(s) == 0 略快 |
| size() | len(s) | o(1) | 读 ob_size 字段 |
| clear() | s.clear() | o(n) | 实测会连容量一起归零 |
注意 pop() 的签名是 list.pop(index=-1, /)——不传参数才是栈语义。传了参数,语义没变,但代价可能从 o(1) 变成 o(n)。
一个容易被忽略的细节:del s[-1] 也能弹出栈顶,而且语义等价,区别是它不返回被删元素。实测:
s = [1, 2, 3] s.pop() # 返回 3,s 变成 [1, 2] del s[-1] # 无返回值,s 变成 [1]
如果你只关心"丢掉栈顶",del 省一次返回值搬运;要取值,只能用 pop()。
二、内存视角:为什么它敢承诺摊销 o(1)
2.1 一个 list 对象到底占多少字节
cpython 里 list 是 pylistobject:一个定长的对象头 + 一个指向 pyobject* 数组的指针 + 一个 allocated(已申请槽位数)字段。这里不展开源码结构,直接上实测——空 list 与不同长度的开销:
len=0 getsizeof=56 (getsizeof-56)/8=0 len=1 getsizeof=72 (getsizeof-56)/8=2 len=5 getsizeof=104 (getsizeof-56)/8=6 len=17 getsizeof=200 (getsizeof-56)/8=18 len=100 getsizeof=856 (getsizeof-56)/8=100
规律非常干净:getsizeof(s) = 56 + 8 × allocated,56 字节是对象头固定开销,8 字节是 64 位下一个指针槽。长度 100 的列表,对象本身 856 字节,但里面装的 int 对象是另外算的——列表本身永远只是"指针数组"。
这一点在栈场景下很重要:一个存 100 万个整数的 list,sys.getsizeof 实测是 7.63 mib,正好是 8 × 1,000,000 的指针数组,元素对象的开销不在这里。
2.2 增长阶梯:容量不是逐个涨的
逐个 append 并记录 getsizeof 跳变点(本机实测,从 len=0 到 len=973):
len=0 size=56 alloc=0 len=1 size=88 alloc=4 len=5 size=120 alloc=8 len=9 size=184 alloc=16 len=17 size=248 alloc=24 len=25 size=312 alloc=32 len=33 size=376 alloc=40 len=41 size=472 alloc=52 len=53 size=568 alloc=64 len=65 size=664 alloc=76 len=77 size=792 alloc=92 len=93 size=920 alloc=108 len=109 size=1080 alloc=128 len=149 size=1432 alloc=172 len=173 size=1656 alloc=200 len=309 size=2872 alloc=352 len=973 size=8856 alloc=1100
这里有两个可以直接读出来的工程事实:
- 容量跳变的间隔越来越大——从 4→8→16→24→32 的粗放增长,到 973→1100 时每次只多 127 个槽位。后者恰好是
973 >> 3 = 121,即新容量的约 12.5%。 - 同一个容量会被多个长度复用:len=17 到 len=24 共用
alloc=24,期间一次内存分配都不发生。
我把实测的 15 个点回代到一个候选公式上验证:
new_allocated = (newsize + (newsize >> 3) + 6) & ~3
| len | 实测 alloc | 公式结果 |
|---|---|---|
| 1 | 4 | 4 |
| 5 | 8 | 8 |
| 9 | 16 | 16 |
| 17 | 24 | 24 |
| 33 | 40 | 40 |
| 41 | 52 | 52 |
| 53 | 64 | 64 |
| 77 | 92 | 92 |
| 109 | 128 | 128 |
| 149 | 172 | 172 |
| 309 | 352 | 352 |
| 973 | 1100 | 1100 |
15 个采样点全部吻合。这个公式的直觉是:新容量 = 目标长度 + 12.5% 余量 + 6,再向下取整到 4 的倍数(& ~3)。余量保证摊销成本恒定,取整对齐是为了减少内存碎片。这就是"摊销 o(1)"的代价来源——不是每次 append 都快,而是 n 次 append 的总代价是 o(n)。
2.3 缩容阶梯:它同样会还内存
从 list(range(1000)) 开始逐个 pop,记录每次容量跳变(实测):
(len, allocated) (1000, 1000) → (499, 564) → (281, 320) → (159, 184) → (91, 108) → (53, 64) → (31, 40) → (19, 24) → (11, 16) → (7, 12) → (5, 8) → (1, 4) → (0, 0)
规律:当长度掉到已申请容量的一半以下时,就收缩一次(1000 → 499 触发,因为 499 < 500)。收缩后的新容量同样走上面那个公式,并且最终 len=0 时 allocated 归零,getsizeof 回到 56。
所以"list 占着内存不还"这个说法只对一半:它会在合适的时机还,但不是你一删它就还。想立刻还,就 clear() 或切片重建——这一点在第 6 节有专门的坑。
2.4 一个反直觉的补充:容量还取决于"你怎么造出来的"
同样是长度 1 的列表,三条构造路径给出三种不同容量(实测):
逐个 append 到 len=1 -> allocated = 4 list(range(1)) -> allocated = 2 字面量 [0] -> allocated = 1 字面量 [0,1,2] -> allocated = 4
list() 构造走的是按已知长度预分配的路径,字面量走的是精确分配,而逐个 append 走的是 2.2 节的渐进增长路径——三条路径的容量策略不同。这个差异平时无害,但在"内存敏感 + 大量小列表"的场景(比如做成百万个 list 当小栈)里,[x] 和 list([x]) 就不是一回事了。
三、七个真实场景:list 栈在工程里到底干什么
以下每个例子的输出都是本机实跑结果,直接可复现。
场景 1:括号匹配校验(编译器 / 配置校验的日常)
最经典的栈应用。注意我特意返回了错误位置,而不是只返回 true/false:
def check_brackets(s):
pairs = {')': '(', ']': '[', '}': '{'}
st = []
for i, ch in enumerate(s):
if ch in "([{":
st.append((ch, i))
elif ch in pairs:
if not st:
return false, "位置 %d 的 '%s' 多余(栈已空)" % (i, ch)
top, ti = st.pop()
if top != pairs[ch]:
return false, "位置 %d 的 '%s' 与位置 %d 的 '%s' 不匹配" % (i, ch, ti, top)
if st:
ch, ti = st[-1]
return false, "位置 %d 的 '%s' 未闭合" % (ti, ch)
return true, "全部匹配"
实测输出:
{[()()]} -> (true, '全部匹配')
([]{}) -> (true, '全部匹配')
{[()] -> (false, "位置 0 的 '{' 未闭合")
({[)]} -> (false, "位置 3 的 ')' 与位置 2 的 '[' 不匹配")
(( -> (false, "位置 1 的 '(' 未闭合")
abc -> (true, '全部匹配')
三个失败分支全部命中:栈空时来右括号(多余)、弹出后不配对(交叉嵌套)、扫描完栈非空(未闭合)。把索引一起压栈是这里的关键技巧——只压字符的话,报错时你没法告诉用户问题出在第几个字符。
场景 2:逆波兰表达式求值
栈的教科书用法,但实际写起来最容易被 / 的语义绊住:
def eval_rpn(tokens):
st = []
for t in tokens:
if t in ("+", "-", "*", "/"):
b = st.pop(); a = st.pop() # 注意顺序:先出来的是右操作数
st.append({"+": a + b, "-": a - b, "*": a * b,
"/": int(a / b) if b else none}[t])
else:
st.append(int(t))
return st.pop() if len(st) == 1 else none
实测:
2 1 + 3 * = 9 4 13 5 / + = 6 10 6 9 3 + -11 * / * 17 + 5 + = 22
两个细节值得写进你的代码规范:
b = st.pop(); a = st.pop()的顺序不能反。栈顶是右操作数,a - b写反了减法除法全错,而乘法加法恰好还能跑——所以这个 bug 特别容易漏测。- 最后一个用例(含负数
-11)能跑通,靠的是"只按运算符白名单判断"而不是tk[0].isdigit()之类的写法。真实表达式里负号和数据你必须交给分词器处理,别在求值器里猜。
场景 3:调度场算法——中缀转后缀,两个栈配合的经典
这是栈在编译原理领域的入场券,shunting-yard 算法。它同时用到一个输出列表和一个运算符栈:
def to_rpn(expr):
prec = {'+': 1, '-': 1, '*': 2, '/': 2}
out, ops = [], []
for tk in expr.split():
if tk.isdigit():
out.append(tk)
elif tk in prec:
while ops and ops[-1] != '(' and prec[ops[-1]] >= prec[tk]:
out.append(ops.pop())
ops.append(tk)
elif tk == '(':
ops.append(tk)
elif tk == ')':
while ops and ops[-1] != '(':
out.append(ops.pop())
ops.pop() # 弹掉左括号
while ops:
out.append(ops.pop())
return out
实测(to_rpn 接场景 2 的 eval_rpn,一条链路走完):
3 + 4 * 2 -> rpn: 3 4 2 * + = 11.0 ( 1 + 2 ) * ( 3 + 4 ) -> rpn: 1 2 + 3 4 + * = 21.0 10 - 2 * 3 + 4 -> rpn: 10 2 3 * - 4 + = 8.0 100 / ( 2 + 3 ) * 2 -> rpn: 100 2 3 + / 2 * = 40.0
注意第一行和第三行的区别:3 + 4 * 2 转出来是 3 4 2 * +,10 - 2 * 3 + 4 转出来是 10 2 3 * - 4 +——左结合运算符在相等优先级时也要弹栈,这正是 prec[ops[-1]] >= prec[tk] 里那个 >= 的全部意义。写成 >,10 - 2 - 3 这类表达式就会算出错误结果。
场景 4:单调栈——把 o(n²) 压成 o(n)
"每日温度"是单调栈最干净的入门题:对每天,找出还要等几天才会更暖。
def daily_temperatures(t):
res = [0] * len(t)
st = [] # 存下标,栈内温度单调递减
for i, t in enumerate(t):
while st and t[st[-1]] < t:
j = st.pop()
res[j] = i - j # 出栈那一刻结算
st.append(i)
return res
实测:
t = [73, 74, 75, 71, 69, 72, 76, 73] res = [1, 1, 4, 2, 1, 1, 0, 0] t = [30, 40, 50, 60] -> res = [1, 1, 1, 0] t = [30, 60, 90] -> res = [1, 1, 0]
关键点是栈里存下标而不是温度值——存下标才能算距离,存值就只能算个数。这个"存下标"的习惯在单调栈类问题里是通用套路。整个算法每个元素最多入栈一次、出栈一次,所以是 o(n)。同样的思路可以直接平移到柱状图最大矩形、接雨水、下一个更大元素。
场景 5:用显式栈把递归"翻译"成迭代(汉诺塔)
递归的本质就是借用了语言运行时的调用栈。当递归深度可能超过 sys.getrecursionlimit(),或者你想在中间步骤做暂停/恢复时,就得自己拿一个 list 当栈。做法是把"还有哪些活没干"也压进栈:
def hanoi_stack(n, src, mid, dst):
moves = []
stack = [("go", n, src, mid, dst)]
while stack:
op, k, a, b, c = stack.pop()
if op == "move":
moves.append("盘%d: %s -> %s" % (k, a, c))
continue
if k == 0:
continue
# 递归序:hanoi(k-1, a, c, b) -> move k a->c -> hanoi(k-1, b, a, c)
stack.append(("go", k - 1, b, a, c))
stack.append(("move", k, a, b, c))
stack.append(("go", k - 1, a, c, b))
return moves
实测 3 层输出 7 步:
盘1: a -> c 盘2: a -> b 盘1: c -> b 盘3: a -> c 盘1: b -> a 盘2: b -> c 盘1: a -> c 2^n-1 = 7
最反直觉的地方是三次 append 的顺序:因为是 lifo,想先执行 hanoi(k-1, a, c, b),就必须把它最后压进去。把递归调用顺序倒着写进栈——这个"倒序压栈"是手写迭代栈时 90% 的 bug 来源。
场景 6:迭代式 dfs——顺序必须和递归一字不差
把树遍历从递归改写成显式栈时,最容易出的错是访问顺序变了(宽搜还是深搜、子节点谁先谁后)。验证方法很简单:两条路都跑一遍,比结果。
tree = {"a": ["b","c"], "b": ["d","e"], "c": ["f"], "d": [], "e": ["g"], "f": [], "g": []}
def dfs_rec(node, seen=none):
seen = seen if seen is not none else []
seen.append(node)
for ch in tree[node]:
dfs_rec(ch, seen)
return seen
def dfs_stack(start):
seen, st = [], [start]
while st:
node = st.pop()
if node in seen:
continue
seen.append(node)
for ch in reversed(tree[node]): # reversed 是为了抵消 lifo
st.append(ch)
return seen
实测两条路径输出完全一致:
递归 dfs : ['a', 'b', 'd', 'e', 'g', 'c', 'f'] 显式栈 dfs: ['a', 'b', 'd', 'e', 'g', 'c', 'f']
reversed(tree[node]) 那一行是重点。栈是后进先出,要想让 b 先于 c 被访问,就得让 b 后入栈——入栈顺序必须与期望访问顺序相反。很多人这里忘了反转,结果 dfs 出来的顺序是反的,而反的顺序在"只求可达性"的场景里跑得通,在"要求固定顺序"的场景里就翻车。
另外 if node in seen: continue 这句在处理有环图时是必须的——树上写着无害,图上是救命。
场景 7:撤销 / 重做——双栈模型
文本编辑器、画板、配置管理系统里的 undo/redo,本质就是两个栈:
class editor:
def __init__(self):
self.text = ""
self.undo_st = [] # 历史状态
self.redo_st = [] # 被撤销掉的状态
def write(self, s):
self.undo_st.append(self.text)
self.text += s
self.redo_st.clear() # 关键:新操作作废 redo 链
def undo(self):
if not self.undo_st:
return "无可撤销"
self.redo_st.append(self.text)
self.text = self.undo_st.pop()
return "撤销后: %r" % self.text
def redo(self):
if not self.redo_st:
return "无可重做"
self.undo_st.append(self.text)
self.text = self.redo_st.pop()
return "重做后: %r" % self.text
实测状态流转:
write("hello") -> 当前: 'hello' undo=1 redo=0
write(", marvis") -> 当前: 'hello, marvis' undo=2 redo=0
undo() -> 撤销后: 'hello' undo=1 redo=1
undo() -> 撤销后: '' undo=0 redo=2
redo() -> 重做后: 'hello' undo=1 redo=1
write("!") -> 当前: 'hello!' undo=2 redo=0 <- redo 被清空
这里唯一的"业务逻辑"就是 self.redo_st.clear():一旦产生新操作,原来的 redo 链就永久失效了。少了这一行,“撤销 → 新输入 → 重做"会把你带到一个从未存在过的历史分支上。另外注意压栈的是完整状态而不是"操作增量”——状态小就用快照,状态大(比如几十 mb 的文档)就得改成存增量 / 命令对象。
四、性能基准:什么该用 list,什么坚决不该
4.1 先说方法 论:为什么不能只跑一次 timeit
我第一次跑基准时得到的是这样一组数:list append+pop 171.5 ns,deque append+pop 37.0 ns,看起来"list 比 deque 慢 4 倍"。改用 timeit.repeat(..., repeat=7) 取最小值重跑后:
| 操作(长度 1000) | 单次测量 | 7 次取最小 |
|---|---|---|
list.append + list.pop | 171.5 ns | 18.6 ~ 21.0 ns |
deque.append + deque.pop | 37.0 ns | 25.1 ~ 28.6 ns |
list[-1](peek) | — | 17.3 ns |
deque[-1] | — | 19.2 ns |
list.append 单独 | — | 12.6 ns |
结论直接反转了:在尾部进出这个场景上,list 比 deque 还快(18.6 ns vs 25.1 ns,同一进程内对照)。原因是 list 的尾部操作不涉及块索引换算,而且是纯指针数组 + 摊销增长;deque 是分块双向队列,append/pop 需要处理块边界。
这就是基准测试的基本纪律:单次 timeit 要经历解释器预热、内存分配器冷启动、gc 抖动,只跑一次得到的数字不能下结论。本系列的规矩是 min(timeit.repeat(...)),输出同时给绝对值和对照组。
4.2 那什么时候 list 必须让位
一旦你需要的不是"一端进出",list 立刻从一个优秀的数据结构变成一个事故现场。实测(同一进程,7 次取最小,长度保持恒定):
| 规模 | list.insert(0)+pop(0) | list.pop(0) | deque.appendleft+popleft |
|---|---|---|---|
| len=10 | 88.8 ns | 64.2 ns | 56.6 ns |
| len=1,000 | 1,298.8 ns | 342.6 ns | 54.1 ns |
| len=100,000 | 168,563.3 ns | 42,005.9 ns | 44.1 ns |
端到端再用一个更贴近生产的测法验证一次:n=20000 的元素依次入队、再依次出队,重复 20 次:
list.pop(0) 队列: 0.9866 s deque.popleft 队列: 0.0241 s 慢 41.0 倍
deque 的耗时在三个规模上几乎不动(56.6 → 54.1 → 44.1 ns),而 list.pop(0) 从 64 ns 涨到 42 μs——它们不是"快慢之别",是 o(1) 与 o(n) 之别。
4.3 线程安全版:代价是多少
queue.lifoqueue 提供的是线程安全的栈。实测(与 list 同进程、同一次运行内对照):
list append + pop : 18.6 ns/op deque append + pop : 25.1 ns/op lifoqueue put + get : 1217.4 ns/op (慢 65 倍)
lifoqueue 慢 65 倍,买到的是两件事:互斥锁保护 + 阻塞语义(空了会 block 等待而不是抛 indexerror)。所以选型很清楚:
- 单线程算法(括号匹配、单调栈、表达式求值)→ 裸 list,性能最优;
- 多线程生产者/消费者,且需要"队列空时等待"→
lifoqueue,别自己用 list + 锁手搓,if not st: st.pop()这种"先查再弹"在你自己的锁之外并不原子。
五、七个必须知道的坑(全部实测复现)
坑 1:空栈 pop 的异常文案,别在日志里猜
[].pop() -> indexerror('pop from empty list')
[].pop(0) -> indexerror('pop from empty list') # 注意:文案一样
[].pop(-1) -> indexerror('pop from empty list')
[][0] -> indexerror('list index out of range') # 索引越界是另一套文案
pop 的越界和取值的越界,异常类型相同但消息不同。写日志过滤 / 告警规则时按 indexerror 类型抓,别按字符串匹配。
栈的正确判空写法是"先看再弹"或"依赖异常",两者风格不同,别混着写:
# 风格 a:lbyl
if st:
v = st.pop()
# 风格 b:eafp
try:
v = st.pop()
except indexerror:
... # 栈空分支
坑 2:把 list 当队列(本文开篇那个)
已经在第 4.2 节用 41 倍和 1900 倍的数据说完了。工程上的判断标准是:只要出现 pop(0) 或 insert(0, ...),就应该立刻想到 deque。代码 review 时这是条很好用的红线。
坑 3:循环里写s = s + [x]—— 从 o(n) 退化到 o(n²)
+= 和 + 看起来只差一个字符,实测差了三个数量级(n 次单元素追加的总耗时):
| n | s.append(i) | s += [i] | s = s + [i] |
|---|---|---|---|
| 2,000 | 0.0002 s | 0.0005 s | 0.0169 s(慢 70.4 倍) |
| 20,000 | 0.0023 s | 0.0050 s | 2.9957 s(慢 1301.6 倍) |
原因:+= 对 list 走的是原地 extend(复用缓冲区),+ 每次都新建一个列表再整体拷贝,于是 n 次循环就变成了 o(n²)。n 从 2000 涨到 20000(10 倍),慢的倍数从 70 涨到 1300——增长曲线比线性陡得多,这正是二次复杂度的指纹。
坑 4:迭代中删除元素 —— 经典的"跳过"
这个是唯一一个不能靠"看数据"发现的坑,必须看行为。对 [1, 2, 2, 3] 删掉所有 2:
a = [1, 2, 2, 3]
for x in a:
if x == 2:
a.remove(x)
# 实测结果:a = [1, 2, 3] ← 残留一个 2
删除元素后后续元素整体前移,但迭代器内部索引已经前进一步,于是紧跟着被删元素的下一个元素被跳过。正确写法二选一:
# 写法 1:在副本上迭代
for x in a[:]:
if x == 2:
a.remove(x)
# 写法 2(推荐):重建列表
a = [x for x in a if x != 2]
两种写法实测都得到 [1, 3]。注意写法的性能差别:a.remove(x) 本身是 o(n),在副本上迭代删除整体是 o(n²),元素多时应优先用列表推导。
顺带提醒一个常见的错误自我安慰:[1,2,3,4,5,6] 删偶数这个用例,两种写法都会得到 [1,3,5]——因为跳过的恰好是"下一个也是偶数"的情况被后续迭代补上了。别用这个数据点验证你的代码,用 [1,2,2,3]。
坑 5:浅拷贝——栈里存可变对象时
list() / s[:] 都是浅拷贝。如果栈里存的是可变对象:
a = [[1, 2], [3, 4]] b = list(a) # 浅拷贝 c = [x[:] for x in a] # 逐元素再拷一层 b[0].append(99) # 实测:a = [[1, 2, 99], [3, 4]] ← a 被"隔空"改了 # c = [[1, 2], [3, 4]] ← c 安全
做快照式 undo(场景 7 那种"压完整状态")时,如果状态里含嵌套结构,self.undo_st.append(self.text) 压进去的只是引用。可变状态必须深拷贝或存增量,否则你的"历史版本"会随当前状态一起变化。
坑 6:内存不会因为你 pop 了就立刻还
实测对比三条路径:
list(range(100000)) -> len=100000 alloc=100000 size=800056 del a[1000:] -> len=1000 alloc=1128 size=9080 b[:] = [1, 2, 3] -> len=3 alloc=8 size=120 c = c[:1000] -> len=1000 alloc=1000 size=8056
可以看到:
del a[1000:]会按第 2.3 节规则收缩(alloc 从 100000 掉到 1128),剩下 8.8 kib;- 而
b[:] = [1,2,3]这种用切片赋值替换全部内容,容量直接降到 8; c = c[:1000]是切片重建,容量精确等于长度 1000。
对照 clear() 的行为(前文实测):list(range(1000000)) 是 7.63 mib,clear() 之后 getsizeof 回到 56 字节,alloc 归零。
一个长生命周期的"大栈"如果只是被 pop 空,它仍会按收缩规则保留一部分容量;要立刻把内存还给系统,明确调用 s.clear()。
坑 7:递归深度 vs 显式栈
栈溢出在 python 里表现为 recursionerror,而显式栈不会。实测:
sys.setrecursionlimit(10000) rec(5000) # -> 5001 (把上限调高后可以跑)
而显式栈压入 1,000,000 个元素再全部弹出:
显式栈 1_000_000 层 -> 正常完成, 弹出 1000000 次
差别在于:递归的每一层都要占 c 栈帧,受 sys.setrecursionlimit() 和真实线程栈大小双重限制;显式栈是堆上的 list,上限只受内存约束。所以两条经验:
- 深度不可控的遍历(深目录树、解析深层嵌套的 json/html、用户可控的表达式)→ 一律用显式栈;
- 只是懒得写迭代 → 优先
sys.setrecursionlimit()调大 + 写清楚为什么,而不是默认它安全。
六、字节码视角:push/pop 在解释器里长什么样
把栈操作编译到 cpython 3.11 的字节码,能看到"栈"这个概念在解释器层面同样无处不在(本机 dis 实跑输出,节选):
--- push --- 2 load_fast 0 (s) 4 load_method 0 (append) 26 load_fast 1 (v) 28 precall 1 32 call 1 42 pop_top <- 丢掉 append 的返回值 none 44 load_const 0 (none) 46 return_value --- pop --- 2 load_fast 0 (s) 4 load_method 0 (pop) 26 precall 0 30 call 0 40 return_value
两个可以直接读出来的点:
s.append(x)的字节码里有pop_top——因为append返回none,而表达式语句的返回值需要被弹掉。这是"list 的变更类方法返回 none"设计原则在字节码层的直接体现,也是为什么s = s.append(x)会得到none的根源。del s[:]编译成build_slice+delete_subscr,s[-1]是binary_subscr。也就是说peek和"清空"在解释器眼里是两件完全不同量级的事。
dis.opname 里以 pop_ 开头的指令有一大批(pop_top、pop_except、pop_jump_forward_if_false…),这正是 cpython 求值循环本身也是用一个操作数栈来执行字节码的证据。需要说清楚的是:cpython 求值循环里的那个值栈是 c 层的预分配数组,不是 pyobject 类型的 list 对象;但"用栈来组织计算"的思路,和你用 list 写算法时是同一套。
七、选型决策表
| 需求 | 首选 | 理由(本文实测依据) |
|---|---|---|
| lifo,单线程 | list | 尾部 append+pop 18.6 ns,比 deque 还快 |
| lifo,多线程 / 需要阻塞等待 | queue.lifoqueue | 慢 65 倍,但换取锁保护与 block 语义 |
| fifo / 两端都要进出 | collections.deque | len=100000 时 44.1 ns vs list.pop(0) 42,005.9 ns |
| 需要按下标随机访问 + 尾部进出 | list | 连续内存,s[-1] 17.3 ns |
| 只存同类型数值,极致省内存 | array / numpy | list 的 8 字节/元素全是指针,无压缩 |
| 深度不可控的遍历 | 显式栈(list) | 实测 1,000,000 层正常,递归受 c 栈限制 |
| 需要固定容量、丢弃时希望报错 | 自定义封装 + 显式长度检查 | list 会自动扩容,不会"满" |
一个通用判断顺序:
- 先进后出? →
list(单线程)/lifoqueue(多线程) - 先进先出 或 前后都要动? →
deque - 要按下标随机访问? → 回到
list,但确保你的插入删除都在尾部 - 内存敏感且类型单一? →
array/numpy - 以上都不是? —— 大概率你要的不是栈。
复现说明:本文所有数字均在 cpython 3.11.9 / macos arm64 上实跑得到;基准测试统一使用 min(timeit.repeat(..., repeat=7))。跨版本、跨架构的绝对值会漂移,请以你本机的复现结果为准——这也是本文唯一希望你带走的方法 论:不要相信任何没有环境说明的性能数字,包括本文这些。
以上就是python list作为内置栈从内存布局到7个真实场景详解的详细内容,更多关于python list内置栈布局到使用的资料请关注代码网其它相关文章!
发表评论