列表list这东西几乎每个写过 Python 的人都用过但真到了面试、写算法、处理数据的时候能把它彻底讲明白的人并不多。你在网上搜“python 列表”通常只能看到 append、pop、len 这类基础语法可实际工程里真正让你头疼的往往是切片边界、浅拷贝、嵌套初始化、遍历时删除元素这些细节。这篇文章我想从一个“用坏过不少内存、也改过不少烂代码”的过来人角度把列表的底层逻辑、常用操作、性能边界和算法落地场景重新捋一遍。不管你是刚学 Python 的入门读者还是已经被列表切片和引用拷贝坑过几次的进阶玩家下面这些内容都值得你花几分钟读完读完你大概率会发现自己以前写列表的某些姿势确实还能优化。1. 列表的底层逻辑引用数组而不是“数组”1.1 列表里到底存的是什么很多从 C、Java 转过来的朋友第一次学 Python 列表时会下意识地把它当成数组。严格来说CPython 的 list 是一个“动态对象指针数组”也就是数组里每个槽位存的并不是对象本身而是这个对象在内存里的地址引用。这意味着同一份数据可以同时出现在好几个列表里它们对应的只是同一个对象的不同“入口”。理解这一点能解释很多现象。比如你写下a [1, 2, 3]紧接着写下b a看起来只是把列表赋值给 b但 b 并没有复制出新的数组它只是把 a 的引用又存了一遍。你再去修改b.append(4)a 也会跟着变成[1, 2, 3, 4]。这个行为只要动手实践一次就会印象深刻但真正危险的是它发生在函数参数里。你写def f(items): items.append(x)调用时传入一个外层列表函数结束后外层列表已经被改了这就是“副作用”经验不足时很容易被这种“隐式修改”坑得不轻。正是因为列表存的是引用所以列表本身可以混装任意类型[1, a, None, [2, 3]]完全合法因为槽位里只是一堆 8 字节指针64 位系统下至于指针指向的对象是整数、字符串还是另一个列表都无所谓。这也是 Python 列表灵活性的来源但也是它内存开销比 numpy 数组大的原因——numpy 数组存的是连续的同类型数值根本没有指针这一层。1.2 浅拷贝和深拷贝copy 到底在复制什么如果你想真的复制一份列表而不是复制引用常规做法有list(a)、a[:]、a.copy()这三个写法效果一样都生成一个新的列表对象但里面的元素引用没有复制。这句话很多人听过却不一定真的理解我们拆开看a [[1, 2], [3, 4]] b a.copy() b[0].append(99) print(a) # [[1, 2, 99], [3, 4]]看到没有b 自己新增或删除元素不会影响 a但 b 内部的子列表一旦发生变化a 里那个子列表也会变因为它们共享嵌套列表的同一份引用。所以当你需要完全独立的副本时要用copy.deepcopy(a)import copy a [[1, 2], [3, 4]] b copy.deepcopy(a) b[0].append(99) print(a) # [[1, 2], [3, 4]]我的经验是绝大多数业务代码里你其实不需要深拷贝因为深拷贝会递归复制所有嵌套对象慢且容易碰到不可复制对象比如文件句柄、socket。真正需要深拷贝的场景通常是构造算法初始状态后又需要在不同分支各自演化比如棋盘类回溯、动态规划备忘录初始化。遇到这种情况宁可deepcopy也不要自己手写多层循环复制能省下很多排查引用混乱的时间。1.3 动态扩容为什么 append 这么快列表底层是数组但又是“动态”的当你反复 appendCPython 并不会每次追加都重新分配一个刚好大一格的数组那样时间复杂度会变成 O(n^2)。实际做法是预分配——当容量不够时按一定比例扩容把旧数据整体拷贝到新内存里。所以摊还下来单次 append 的平均时间复杂度是 O(1)这也是为什么 Python 官方风格指南总推荐“能 append 就 append不要用lst[i]硬塞越界位置”。如果你好奇扩容比例CPython 的历史实现里大致是 0, 4, 8, 16, 24, 32, 40, 52, 64, 80... 简化的逻辑是容量不足时按约 12.5% 的增长策略调整加上内存分配器对齐最终呈现出“指数增长但略有余量”的模式。对写业务代码的人来说这个机制不需要背但有个结论值得记住如果你预先知道列表规模可以用[None] * n或lst []加“预估容量”的写法避免中途反复扩容尤其在循环里一次 append 几百万个元素时性能差异非常明显。2. 列表切片最容易吃透也最容易踩坑的操作2.1 切片的基本规则和步长切片是 Python 列表最有“辨识度”的语法lst[start:stop:step]三个参数里 start 包含、stop 不包含step 默认为 1。这个左闭右开的设计起初有点反直觉但它跟range(0, n)完全不重复、天然适合把列表对半切开。比如lst[:n//2]取前半段lst[n//2:]取后半段两边不会重叠也不会漏掉中间元素。步长为负时表示反向取值这个方向容易把人绕晕。你可以这样理解负步长本质上还是从 start 往 stop 方向“跨步”只是跨的方向变成了从右往左。比如lst[::-1]表示从尾部到头部反向取整个列表等价于反转lst[5:0:-1]表示从索引 5 往回取到索引 1不包含 0即[lst[5], lst[4], lst[3], lst[2], lst[1]]。我自己记这个边界的方法很简单先不看 step确定 start 和 stop 的常规区间再根据 step 负号把整个区间反过来最后检查 stop 永远不会被包含。切片的性能也值得注意。切片返回的是新列表复杂度 O(k)k 是切片长度所以在大列表上频繁切片但只需要逐个访问其中几个元素时直接下标访问更划算切片操作会白白复制一份数据。但反过来说切片也能当“浅拷贝”用这就是copy lst[:]的来历一个切片语法搞定复制。2.2 切片赋值一种很多人不知道的“原地修改”切片不仅能取数据还能在左值位置出现lst[2:5] [10, 20, 30]会把原列表索引 2、3、4 三个位置替换成右边列表。更妙的是右边列表长度不需要和左边切片长度一致它可以比切片长也可以比切片短甚至可以是空列表此时效果相当于删除这一段。这段特性可以用在很多地方。比如你想在列表中间插入多个元素不一定用循环 insert直接lst[2:2] [a, b, c]即可这个写法会把三个新元素插在索引 2 前面又是原地修改又省掉了多次 insert 导致元素不断后移的性能开销。把lst[i:i] [x]当成单元素插入等价于lst.insert(i, x)但前者的意图更偏向“连续插入一段”。切片赋值有个常见误区它不会因为右侧列表长而自动把原列表尾巴“吞掉”多余的新元素会直接扩展列表。实测下来很多人以为lst[0:3] [1,2,3,4,5]之后 lst 只剩这 5 个元素实际是原来前三个被替换后面再追加 4、5列表长度反而增加了。这个行为只要在本地跑一遍就能记住不用死背。2.3 切片、del 与“副本”三者的区别有时你需要删除列表里的连续一段除了lst[2:5] []更直观的写法是del lst[2:5]两者效果一样。del也可以只删除单个元素del lst[0]。区别于lst.pop(0)del不返回被删的值也不需要你接收结果代码意图更干净。但这里有个大坑一直有人踩你想用切片复制列表然后删掉切片里的元素却发现自己改的是原列表。原因很简单切片本身已经新建了列表对新列表执行修改当然不影响原列表但如果你拿lst[start:stop]切出来的对象继续被别的代码引用这个“副本”就只是浅拷贝层级的副本嵌套对象仍然是共享的。我判断一个操作“是否安全”的经验是三步先看它有没有返回新对象切片、copy 都是新对象再看它是不是原地修改append、sort、reverse 是原地最后看嵌套层级深不深超过一层就要考虑 deepcopy。3. 列表推导式与生成器写代码的两种腔调3.1 推导式怎么写什么时候值得用列表推导式是 Python 里辨识度最高、也最容易写上瘾的语法之一。基本形态是[expr for x in iterable if condition]它把 for 循环、append、条件判断压缩成一行。比如把 1 到 100 里的偶数平方收集起来squares [x * x for x in range(1, 101) if x % 2 0]这段代码等价于传统写法squares [] for x in range(1, 101): if x % 2 0: squares.append(x * x)推导式的优势不只是短而是“声明式”它直接告诉你结果是什么形态而不是一步步怎么凑出来。代码评审时一眼就能看清数据变换逻辑出错概率反而低。我自己的经验是两层循环以内的推导式放心用三层以上的嵌套推导式阅读成本急剧上升不如拆成普通循环加注释维护起来轻松。性能上推导式通常比循环快 10%~30%因为它在 CPython 里有一层专门的字节码优化减少了每次循环中 LOAD_ATTR、CALL_METHOD 的开销。但写代码不是为了极限微操遇到逻辑复杂的场景先用普通循环写清楚再优化才是正路。记住一句话推导式是语法糖不是银弹适合简单映射和过滤。3.2 从推导式到生成器大数据量时的选择如果列表推导式生成的列表只是被迭代一次用比如求和、传参、再遍历那我建议你把方括号改成圆括号变成生成器表达式(x * x for x in range(1, 101))。差别在于它不会一次性生成并保存所有结果而是逐个“惰性”地产出内存占用从 O(n) 降到 O(1)。举个实际场景。假设你有一个 1000 万行的日志文件需要统计里面包含某个关键字的行数。如果lines [line for line in f]内存直接被拉满但count sum(1 for line in f if error in line)只有一行一个消耗内存毫无压力。这就是生成器表达式比列表推导式更适合“流水线”操作的典型例子。不过要注意生成器只能遍历一次不能反复访问也不能随机下标。如果你需要同一份数据做多次遍历或索引访问老老实实生成列表别因为图省内存把自己绕进“迭代器已耗尽”的坑里。3.3 遍历列表时不要随便动手改遍历列表时修改列表是新手期最容易出 bug 的场景。比如你想删除列表里所有偶数写出这样的代码lst [1, 2, 3, 4, 5, 6] for x in lst: if x % 2 0: lst.remove(x)实测结果会变成[1, 3, 5, 6]6 没删掉。原因在于 remove 让元素前移而循环索引继续往后走相当于“跳过”了原本紧接着的下一个元素。这是典型的迭代器失效问题只是 Python 没有直接报错反而更难察觉。正确做法有好几种。最简单的是“逆向遍历删除”从尾部往前删就不存在索引跳位也可以先收集要删的元素循环结束后再统一删除最优雅的是用新列表承载结果[x for x in lst if x % 2 ! 0]。我在工程里绝大多数情况下选第三种因为它既清晰又不会在遍历过程中修改原列表。4. 列表的性能边界什么时候该换数据结构4.1 插入、删除的代价与 bisect / deque 的取舍列表看起来什么都能干但它在“中间位置高频插入/删除”的场景里表现很差。数组的插入需要把插入点之后的所有元素整体右移删除则整体左移时间复杂度 O(n)。如果你要维护一个不断按顺序插入的新元素集合列表很快会变成性能瓶颈。举一个我自己踩过的例子。当时写一个实时排行榜每秒来几千条分数数据需要插入到已排序列表中的正确位置。一开始用lst.insert(i, score)结果数据量到几万条后整个系统的延迟肉眼可见地飙高。后来换成bisect模块 列表的组合定位插入位置本身用二分查找但插入动作还是需要元素移位。数据量再大一点老老实实换成了heapq或sortedcontainers这类更适合有序动态数据的结构。如果你需要一个能从两端高效进出元素的“队列”式容器别用列表在头部pop(0)那是 O(n) 操作。正确选择是collections.deque它底层是双向链表式的实现两端操作都是 O(1)。热词里有人搜“python队列queue不堵塞”其实标准库queue.Queue是线程安全的 FIFO 队列如果你不需要线程间通信只用deque轻量快捷得多。4.2 排序、去重与乱序常见操作的时间复杂度列表排序直接用lst.sort()原地排序并返回 None需要新列表的话sorted(lst)。sort 基于 TimSort最好情况下 O(n)平均 O(n log n)。TimSort 对“部分有序”的数据有很好的优化所以如果你知道数据大体有序不必先打乱再排序直接把 sort 扔上去就行。去重是高频操作但要分场景。如果你不在乎顺序list(set(lst))是最快的方法时间复杂度 O(n)代价是结果顺序变得不可预期。如果必须保持原有顺序常见姿势是借助字典def dedup(lst): seen set() result [] for x in lst: if x not in seen: seen.add(x) result.append(x) return result去重时注意元素必须是 hashable 的字符串、数字、元组没问题但列表本身不可哈希需要先转成元组或使用其他方案。把列表乱序用random.shuffle(lst)这是原地操作时间复杂度 O(n)。如果你要在不确定大小的随机抽样中选 k 个元素random.sample(lst, k)会直接返回一个新列表比反复 shuffle 再切片更高效。4.3 大列表内存占用与 array 模块Python 列表的灵活性是有代价的每个元素都是 PyObject 指针加上对象本身的开销存储一千万个整数轻松吃掉几百 MB 内存。如果你需要“紧凑的数值数组”标准库array模块能存 C 类型的连续数值比列表省内存得多。实际上更常见的做法是用 numpy 的 ndarray但它已经属于第三方科学计算栈这里不展开。我做数据分析时的原则是单纯装数值并且要做向量化运算直接用 numpy只做简单的顺序读写且数据量不大列表完全没问题担心内存又不想引入 numpy可以考虑array(i, data)或memoryview切片。不过多数业务系统里列表的内存问题没有你想的那么严重真正需要警惕的是“列表里套列表再套列表”的深多层嵌套这种结构不仅访问慢复制和序列化都会跟着失控。5. 算法场景里的列表矩阵、邻接表与递推5.1 构建二维矩阵的三个大坑提到列表在算法中的应用最经典的就是二维矩阵构建。很多初学者会写出这段代码去创建 3x3 的全零矩阵matrix [[0] * 3] * 3初看效果没问题[[0, 0, 0], [0, 0, 0], [0, 0, 0]]。但当你matrix[0][1] 5会发现三行全变了。原因就是[0] * 3生成了一个长度为 3 的列表外面* 3复制的是这个列表的引用三个“行”其实指向同一个对象。正确写法是matrix [[0] * 3 for _ in range(3)]这里每一行都重新调用[0] * 3生成新列表互不干扰。第二个坑是矩阵的“按列访问”Python 的二维列表本质是“行优先”存储你想取第 j 列所有元素需要写成[row[j] for row in matrix]不存在matrix[:][j]这种天然按列切片的写法。第三个坑是矩阵初始化时的填充不满比如想构建稀疏矩阵但随手matrix [[None] * n for _ in range(n)]如果预期大多数位置为空这种写法会浪费大量 None 引用占位空间不如用字典{(i, j): val}或defaultdict更省心。5.2 用列表存图邻接表如何优雅初始化图论算法里有一道经典坎构建邻接表。所谓邻接表就是用列表存下每个顶点能到达的邻居顶点比如graph[0] [1, 2]表示 0 号顶点连向 1、2。初始化邻接表最常见的错误写法graph [[]] * n和矩阵那个坑一模一样所有顶点共享同一个空列表你在graph[0].append(1)时所有顶点都多了邻居。正确做法graph [[] for _ in range(n)]这个写法同样适用于带权图graph [[] for _ in range(n)]后每条边存成(neighbor, weight)元组。如果你需要在稀疏图上反复判断两个顶点是否相邻邻接表要扫一遍邻居列表O(degree)而邻接矩阵能 O(1) 判断但空间 O(n^2)。工程里我一般默认邻接表除非顶点数很少几百以内否则 n 上万时邻接矩阵的内存直接爆掉。5.3 斐波那契、递推与动态规划列表就是“缓存”递推类问题简直是列表的“主场”。以斐波那契数列为例用列表可以把每次计算结果存下来避免重复递归展开。经典写法n 1000 fib [0, 1] for i in range(2, n): fib.append(fib[i-1] fib[i-2])这段代码看起来平淡但它体现了列表在动态规划中最核心的用途按索引存储子问题的答案后续计算直接查表。你做爬楼梯、背包问题、最长公共子序列时本质都是拿一个二维列表当 DP 表逐行逐列填充。这类题写多了你就会形成条件反射阶段变量放外层循环状态维度映射到列表的维度递推公式写成对列表的查表赋值。只要初始化和边界处理不失误列表的索引语义能帮你把抽象的转移方程一步步落到可运行的代码上。另一个生动例子是“李白打酒”这类递推/回溯题依次枚举每一步的动作把中间状态存成列表根据上一步结果推算下一步。这种题本质是状态机列表既当“历史记录”又当“当前状态的前缀”配合递归回溯能把整个决策树都铺在内存里方便剪枝和回退。6. 常见问题速查与避坑清单6.1 高频报错与排查我身边同事和学员最常遇到的几个列表问题整理成一个速查表几乎覆盖了 80% 的日常踩坑。现象根因解决方案IndexError: list index out of range越界访问stop 没算好边界检查索引是否落到[0, len-1]不确定时先if lst:判空修改b后a也跟着变了浅拷贝共享嵌套对象的引用嵌套结构用copy.deepcopy或显式逐层构造遍历列表删除元素结果漏删删除导致元素前移迭代索引跳过用推导式生成新列表或倒序遍历删除list.remove(x)速度很慢从头线性查找O(n)如果频繁删除改用集合或记录待删索引统一删除列表初始化后所有“行”联动[[0] * n] * m是引用复制改用[[0] * n for _ in range(m)]lst.sort()返回 Nonesort 是原地操作需要新列表用sorted(lst)在列表头部 pop 很慢pop(0)触发整体移位 O(n)高频两端操作改用collections.deque把列表作为函数默认参数默认参数在定义时求值并被共享默认参数写None函数内再赋新列表这份表我每次做 Python 培训都会发一遍因为里面每一项都来自真实工单或教学答疑比官方文档更贴近日常。6.2 手写“实战口诀”最后送上一段我平时写列表代码前会默念的口诀也算这些年踩坑经验的浓缩能切片不硬取能推导不循环能生成器不全量。要原地改就append、sort、reverse要新对象就切片、copy、sorted。复制列表先问一句里面嵌套对象要不要一起复制不要就浅拷贝要就 deepcopy。函数参数传列表等于传引用想清要不要动原数据不想动先 copy 进来。写矩阵、邻接表、DP 表初始化一律[ [默认值] for _ in range(n) ]永远别偷懒写外层* n。高频在头部操作数据结构换deque高频查“是否存在”换set高频按值取下标换 dict 做反向索引。我个人在实际操作中的体会是列表用久了你会发现它不像“最终解决方案”更像一把万能瑞士军刀大多数时候够用、顺手、可读性好但一旦数据规模或操作复杂度上去就得果断切换到更专用的数据结构。真正的高手不是在列表上炫技而是知道什么时候列表够用、什么时候该换。这份判断力比记住几十个 API 有用得多也只有在一次次被性能和 bug 教训之后才能真正沉淀下来。
阅读完成 · 觉得有帮助?