当前位置: 代码网 > it编程>前端脚本>Python > Python list作为内置栈从内存布局到7个真实场景详解

Python list作为内置栈从内存布局到7个真实场景详解

2026年09月22日 Python 我要评论
官方文档把这件事写得很轻描淡写——「list methods make it very easy to use a list as a stack」。轻描淡写到大部分人根本不觉

官方文档把这件事写得很轻描淡写——「list methods make it very easy to use a list as a stack」。轻描淡写到大部分人根本不觉得这是个知识点:不就是 append 加、pop 取吗?

先说一个几乎人人都踩过的直觉误判。appendpop 看着是一对完美对称的操作,于是很自然地会推出一个"顺带结论":那把 pop() 换成 pop(0),不就从栈(lifo)变成队列(fifo)了?反正都是 o(1) 的两端操作。

这个推断错得不算离谱,因为它在"两端"这个词上是对的,在"代价"这个词上是错的。下面这组本机实测数字,是我认为解释这件事最有效的方式(长度保持恒定,取 7 次运行的最小值):

操作len=10len=1,000len=100,000
s.insert(0,1); s.pop(0)88.8 ns1,298.8 ns168,563.3 ns
s.append(1); s.pop(0)64.2 ns342.6 ns42,005.9 ns
deque.appendleft(1); popleft()56.6 ns54.1 ns44.1 ns

同样叫 pop,尾部那个耗时始终在几十纳秒量级横着走,头部那个却随着长度一路涨到 168 微秒——涨了约 1900 倍。原因不神秘:list连续内存上的动态数组pop(0) 之后所有元素都得往前挪一格。所谓"两端操作",一端是 o(1),另一端是 o(n)。

而官方文档其实早就把这两个用法并排放在同一页:5.1.1 using lists as stacks5.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 so(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 里 listpylistobject:一个定长的对象头 + 一个指向 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

这里有两个可以直接读出来的工程事实:

  1. 容量跳变的间隔越来越大——从 4→8→16→24→32 的粗放增长,到 973→1100 时每次只多 127 个槽位。后者恰好是 973 >> 3 = 121,即新容量的约 12.5%。
  2. 同一个容量会被多个长度复用:len=17 到 len=24 共用 alloc=24,期间一次内存分配都不发生。

我把实测的 15 个点回代到一个候选公式上验证:

new_allocated = (newsize + (newsize >> 3) + 6) & ~3
len实测 alloc公式结果
144
588
91616
172424
334040
415252
536464
779292
109128128
149172172
309352352
97311001100

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=0allocated 归零,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.pop171.5 ns18.6 ~ 21.0 ns
deque.append + deque.pop37.0 ns25.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=1088.8 ns64.2 ns56.6 ns
len=1,0001,298.8 ns342.6 ns54.1 ns
len=100,000168,563.3 ns42,005.9 ns44.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 次单元素追加的总耗时):

ns.append(i)s += [i]s = s + [i]
2,0000.0002 s0.0005 s0.0169 s(慢 70.4 倍)
20,0000.0023 s0.0050 s2.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,上限只受内存约束。所以两条经验:

  1. 深度不可控的遍历(深目录树、解析深层嵌套的 json/html、用户可控的表达式)→ 一律用显式栈;
  2. 只是懒得写迭代 → 优先 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_subscrs[-1]binary_subscr。也就是说 peek 和"清空"在解释器眼里是两件完全不同量级的事。

dis.opname 里以 pop_ 开头的指令有一大批(pop_toppop_exceptpop_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.dequelen=100000 时 44.1 ns vs list.pop(0) 42,005.9 ns
需要按下标随机访问 + 尾部进出list连续内存,s[-1] 17.3 ns
只存同类型数值,极致省内存array / numpylist 的 8 字节/元素全是指针,无压缩
深度不可控的遍历显式栈(list)实测 1,000,000 层正常,递归受 c 栈限制
需要固定容量、丢弃时希望报错自定义封装 + 显式长度检查list 会自动扩容,不会"满"

一个通用判断顺序:

  1. 先进后出?list(单线程)/lifoqueue(多线程)
  2. 先进先出 或 前后都要动?deque
  3. 要按下标随机访问? → 回到 list,但确保你的插入删除都在尾部
  4. 内存敏感且类型单一?array / numpy
  5. 以上都不是? —— 大概率你要的不是栈。

复现说明:本文所有数字均在 cpython 3.11.9 / macos arm64 上实跑得到;基准测试统一使用 min(timeit.repeat(..., repeat=7))。跨版本、跨架构的绝对值会漂移,请以你本机的复现结果为准——这也是本文唯一希望你带走的方法 论:不要相信任何没有环境说明的性能数字,包括本文这些。

以上就是python list作为内置栈从内存布局到7个真实场景详解的详细内容,更多关于python list内置栈布局到使用的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com