
Python 列表List几乎可以说是 Python 里最常用的数据结构没有之一。无论是写脚本、做数据分析、写爬虫还是搭 Web 后端列表都是绕不开的基本功。很多人学 Python 的时候列表的增删改查几天就上手了但真正遇到性能问题、深拷贝浅拷贝搞混、遍历时删元素翻车才发现自己只是“会按快捷键”并没有搞懂列表的本质。这篇博文我想结合自己的实际经验把 Python 列表从底层机制到高频操作再到那些文档里不会明说的坑一次性聊透。不管你是刚接触 Python 的新手还是已经写了一阵子代码、想查漏补缺的开发者这篇文章都值得花十几分钟认真读一遍。我会尽量少讲空泛的理论多给可以直接复制的代码和方案毕竟列表这东西只有用起来才知道哪里会疼。1. 为什么说列表是 Python 的“万能容器”1.1 列表的本质动态数组 对象指针数组要理解列表先抛弃 C 语言里那种“数组”的思维定式。Python 的列表在底层实现上其实是一个“数组”但里面存的是 PyObject 指针指向的是实际对象的引用。也就是说列表本身不真正“持有”那些整数、字符串之类的数据它只负责记录“这些对象被我按顺序管着”。这个设计带来的直接结果是一个列表里可以同时塞整数、字符串、对象、甚至嵌套另一个列表。比如mix_list [1, hello, 3.14, [a, b], {name: Tom}]之所以能这样混搭正是因为列表存的是引用而不是把每个元素复制一份。这也解释了另一个经典现象alist blist之后修改其中一个另一个也会变。因为两个变量只是指向同一个列表对象并没有复制真正的内容。1.2 列表和元组、集合、字典的选型边界虽然列表好用但“万能”不等于“所有场景都用它”。我见过不少新手在需要去重的时候用列表硬写循环在需要快速查找的时候也用列表in判断结果数据量一大就卡得怀疑人生。选错数据结构往往是性能问题的根源。做个简单对比特性列表 List元组 Tuple集合 Set字典 Dict是否可变可变不可变可变可变是否有序有序有序无序有序Python 3.7是否允许重复允许允许不允许key 不允许按索引访问O(1)O(1)不支持按 key O(1)成员判断O(n)O(n)平均 O(1)平均 O(1)实际开发里我的选型原则很简单需要按顺序存、频繁增删改选列表需要固定不变的数据结构选元组需要做去重或集合运算选集合需要“键值对”映射关系选字典。列表确实通用但通用意味着它在某些专项能力上不如其他结构。比如成员判断列表是逐个遍历的几万条数据可能还感觉不到到几百万条的时候x in big_list和x in big_set的差距就是“秒级”和“毫秒级”的差别。2. 列表切片比“截取一部分”更强大的能力2.1 切片的基本语法与边界规则Python 列表的切片语法是list[start:stop:step]很多人记不住边界规则其实只需要记住一句口诀左闭右开。也就是 start 位置的元素包含在内stop 位置的元素不包含切片到 stop 的前一位为止。举个例子nums [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(nums[2:5]) # [2, 3, 4] print(nums[:4]) # [0, 1, 2, 3] print(nums[6:]) # [6, 7, 8, 9]这个“左闭右开”的设计其实不是 Python 独创的很多计算机领域都有这种习惯比如 C 语言的数组区间、Rust 的 Range都是这个套路。好处是区间可以直接做减法算出长度而且[:n] [n:]可以无缝拼回原列表。2.2 负索引与步长的组合玩法负索引是从列表末尾往前数的-1就是最后一个元素-2是倒数第二个。这个表达方式在处理不知道长度的列表时非常省心比如拿到列表最后三个元素直接items[-3:]即可不需要先算len(items)再减。步长step是一个容易被忽略但威力很大的参数。常见的操作包括nums [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # 每隔一个取一个 print(nums[::2]) # [0, 2, 4, 6, 8] # 反转列表 print(nums[::-1]) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] # 每隔两个取一个 print(nums[::3]) # [0, 3, 6, 9]这里有个面试里经常考的点nums[::-1]和reversed(nums)有什么区别。nums[::-1]会生成一个全新的列表整个拷贝一份而reversed(nums)返回的是一个反向迭代器并不会立刻复制数据适合只是临时遍历一轮的场景。如果你只是想倒序循环一次、不需要保留结果用reversed更省内存。for num in reversed(nums): print(num)2.3 切片赋值你会用吗切片不仅能“取”还能“改”。这也是很多 Python 教程讲得比较少的地方。切片赋值可以用一个列表替换掉原列表中的一段arr [1, 2, 3, 4, 5] arr[1:3] [20, 30, 40] print(arr) # [1, 20, 30, 40, 4, 5]需要注意的是切片赋值的左右两端长度不需要一致。也就是说你可以用三个元素替换掉两个位置列表长度会随之变化。这个特性在实现类似“子列表替换”的场景时非常好用。但如果只是想让列表变长用insert或extend会更清晰。注意切片赋值的目标必须是一个可迭代对象。即使你只替换一个元素右边也要写成[value]而不是value否则会报TypeError。3. 列表内存机制引用、拷贝与扩容3.1 浅拷贝还是深拷贝别傻傻分不清这是一个大概率踩坑的点。很多新手以为写new_list old_list就是拷贝实际上这只是给同一个列表多起了一个名字。真正要拷贝常见的有三种方式list_a [1, [2, 3], 4] # 方式一切片 list_b list_a[:] # 方式二list() 构造 list_c list(list_a) # 方式三copy 模块 import copy list_d copy.copy(list_a) list_e copy.deepcopy(list_a)前三种方式切片、list()、copy.copy都是浅拷贝。浅拷贝的意思是外层列表是新对象但里面的元素仍然是引用原对象。如果列表里的元素本来就是不可变类型整数、字符串、元组浅拷贝用起来完全没问题但如果元素里嵌套了可变对象比如list_a里有个[2, 3]子列表那么修改list_b中的这个子列表时list_a也会跟着变list_b[1][0] 99 print(list_a) # [1, [99, 3], 4]如果需要完全独立的副本就得用copy.deepcopy。它会把嵌套的每一层都递归复制一遍。代价是更慢、更占内存所以“够用就好”不要动不动就 deepcopy。3.2 列表扩容背后的性能逻辑列表作为动态数组最大的设计难点在于“要装的数据超过当前容量怎么办”。Python 的做法是额外预留一部分空间当超过容量时自动扩容而不是每添加一个元素就重新分配一次内存。CPython 的实现里列表扩容一般会按照allocated size (size 3) (size 9 ? 3 : 6)这样的规律来增加容量。大白话就是小列表多预留几个位置大列表按大约 12.5% 的增幅扩容。这种做法的好处是append操作在绝大多数时候是 O(1) 的只有偶尔触发扩容时才会有一次较大的拷贝开销平摊下来依然接近 O(1)。但这也意味着一个重要的工程结论如果预先知道列表会存很多元素用预分配的方式可以显著减少扩容次数。比如# 不推荐频繁触发扩容 result [] for i in range(1000000): result.append(i) # 更推荐预留容量 result [0] * 1000000 for i in range(1000000): result[i] i后者的性能优势在百万量级上非常明显因为省去了多次重新分配和拷贝的开销。如果不知道具体多大也可以先粗估一个上限再按需截断。3.3 在列表头部插入为什么慢你可能听人说过“往列表头部插元素很慢”但未必清楚慢在哪。列表是连续存储的动态数组往头部插入一个元素意味着后面的所有元素都要整体往后挪一位耗时是 O(n)。# 频繁在头部插入性能差 nums [] for i in range(100000): nums.insert(0, i)对比一下用append在尾部插入只需要 O(1)这 10 万次插入的耗时差距会非常大。如果确实需要频繁在头部插入正确姿势是改用collections.deque它在双端插入都是 O(1) 的from collections import deque nums deque() for i in range(100000): nums.appendleft(i)实操心得判断“慢”之前先想想自己的数据量。几十条数据用insert(0, x)完全无所谓但它上了十万、百万级别差的就是几十倍运算时间。写代码之前对数据量级有个预判能让很多性能问题直接消失。4. 列表推导式与生成器表达式让代码更 Pythonic4.1 列表推导式的基础语法列表推导式List Comprehension是 Python 里很受欢迎的一种语法糖本质上就是“用一行紧凑的写法生成一个新列表”。基础形式是new_list [expression for item in iterable if condition]举个例子取出 0 到 19 之间的所有偶数并乘以 2evens [x * 2 for x in range(20) if x % 2 0]这行代码等价于evens [] for x in range(20): if x % 2 0: evens.append(x * 2)列表推导式的可读性在小场景下显然更好而且因为它是基于 C 层面的循环优化的执行速度通常比手写 for 循环快一些。4.2 列表推导式 vs 生成器表达式列表推导式会一次性生成并保存所有结果。如果数据量很大比如 1000 万个元素这一步就会占用极大的内存。这时候应该考虑生成器表达式它把[]换成()结果是惰性计算的每次只产出下一个值# 列表推导式占内存 squares [x * x for x in range(10000000)] # 生成器表达式节省内存 squares_gen (x * x for x in range(10000000))要想拿到生成器里的值可以用sum(squares_gen)或者直接遍历它。用列表推导式还是生成器表达式的取舍标准很简单需要马上随机访问这个结果集就选列表只是需要逐个处理一遍生成器是更好的选择。4.3 嵌套推导式与可读性之争列表推导式还可以嵌套写比如展平一个二维列表matrix [[1, 2, 3], [4, 5, 6], [7, 8, 9]] flat [num for row in matrix for num in row] # 结果[1, 2, 3, 4, 5, 6, 7, 8, 9]嵌套推导式的执行顺序和普通 for 循环一致先执行外层循环再执行内层循环。但我个人的编码风格是如果嵌套超过两层或者条件表达式太复杂就不再强行用列表推导式改回普通 for 循环更够维护。代码是写给同事和未来的自己看的不是拿来炫技的。5. 高频操作实战排序、去重与反转的几种写法5.1 排序sort()和sorted()的差异列表排序是日常开发中最常碰到的操作之一。list.sort()是原地排序直接修改原列表返回Nonesorted(list)是排序并返回新列表原列表不变。选择哪个取决于你是否需要保留原列表nums [5, 2, 9, 1] # 原地排序原列表被改变 nums.sort() print(nums) # [1, 2, 5, 9] # 新列表原列表不变 nums [5, 2, 9, 1] sorted_nums sorted(nums) print(sorted_nums) # [1, 2, 5, 9] print(nums) # [5, 2, 9, 1]比较隐蔽的点是sort()的key参数。很多人只会用默认排序碰到“按字符串长度排序”或“按对象某个属性排序”时又回去写复杂循环。其实这些场景全部可以一行搞定words [banana, apple, cherry, date] words.sort(keylen) # 结果[date, apple, banana, cherry] # 按字典的某个字段排序 students [{name: Tom, age: 20}, {name: Jerry, age: 18}] students.sort(keylambda s: s[age])key参数的核心思想是排序前先对每个元素调用一次 key 函数用它返回的值来决定顺序。这样既能保持代码简洁又不用改原始数据。需要注意key和reverse可以同时使用reverseTrue表示降序。5.2 去重保持顺序还是不在乎顺序列表去重是过滤重复数据的常用操作。最简洁的写法是利用集合的特性但这种写法会丢失列表原本的顺序items [3, 1, 2, 3, 4, 1] unique_items list(set(items)) # 结果可能是 [1, 2, 3, 4]顺序无法保证如果既要保留列表的顺序又想去重比较推荐的做法是用字典的fromkeys方法items [3, 1, 2, 3, 4, 1] unique_items list(dict.fromkeys(items)) # 结果[3, 1, 2, 4]顺序保持第一次出现的顺序dict.fromkeys从 Python 3.7 起可以保证顺序因为字典本身有序利用“字典的 key 不重复”这个特性两次转换搞定。这个方法比手写if x not in result的循环高效因为字典查找是 O(1) 的。5.3 反转列表的多种姿势反转列表看起来简单实际有几种不同写法各有适用场景nums [1, 2, 3, 4] # 方式一切片生成新列表 reversed_copy nums[::-1] # 方式二原地反转 nums.reverse() # 方式三迭代器不复制 for x in reversed(nums): print(x)如果只是需要倒序遍历尽量用reversed(nums)如果需要把结果保存下来用切片如果不再需要原顺序直接nums.reverse()最省内存、速度也快。6. 遍历时删除元素经典翻车现场与正确解法6.1 为什么边遍历边删除会出问题先看这段代码你觉得它会输出什么nums [1, 2, 3, 4, 5, 6] for num in nums: if num % 2 0: nums.remove(num) print(nums)很多人凭直觉以为结果是[1, 3, 5]但实际输出是[1, 3, 5, 6]。问题在于列表在遍历过程中被修改索引和元素之间的对应关系变了。for 循环本质上是按索引递增取元素删掉第 2 个元素后原来第 3 个元素顶上来了而下一次循环又从新的第 3 个位置开始取等于跳过了它。6.2 正确解法一构建新列表最推荐、也最不容易出错的做法是不要边遍历边删除而是把要保留的元素收集到一个新列表里nums [1, 2, 3, 4, 5, 6] nums [num for num in nums if num % 2 ! 0] # 结果[1, 3, 5]列表推导式的清晰度和性能在这里都很能打。唯一需要注意的是如果还有其他变量引用着旧列表那么把nums重新赋值并不会修改旧列表对象这种情况可以考虑用切片赋值nums[:] [num for num in nums if num % 2 ! 0]这样是在原列表对象上做修改所有引用这个列表的变量都会看到新内容。6.3 正确解法二从后往前遍历如果出于某种原因必须在原列表上删除可以倒着遍历nums [1, 2, 3, 4, 5, 6] for num in reversed(nums): if num % 2 0: nums.remove(num)从后往前删的好处是删除前面的元素不会影响后面尚未遍历到的元素的索引位置因此不会跳过任何元素。但如果删除的目标比较复杂、量又大构建新列表的方案依然是更好的第一选择因为remove每次都要从列表头开始扫描目标值时间复杂度是 O(n)在大列表上反复调用代价不低。6.4 额外提醒remove和pop的行为差异list.remove(x)删除的是“第一个值为 x 的元素”所以如果列表里有重复元素它只删一个需要再调用才会继续删。pop(index)则是按下标删除并返回被删除的值当列表里内容很多也没关系反正删不存在的值会抛ValueError按下标删除风险就小得多。实际场景里如果是“查找之后删除”的需求建议先判断是否存在或直接用异常处理。7. 列表性能优化清单从写对到写优7.1 用in判断成员时先想一想数据量这是很多同学在数据量上来之后遇到的第一个性能瓶颈。x in list是线性扫描而x in set是哈希查找时间复杂度一个是 O(n)、一个是 O(1)。如果频繁做成员判断而且列表长度能上万直接先把列表转成集合再判断# 慢每次都是 O(n) if target in big_list: pass # 快仅转换一次后续 O(1) big_set set(big_list) if target in big_set: pass7.2append和列表拼接的差别拼接列表常见的写法有、extend和append。不要混淆它们的行为a [1, 2] a a [3] # 创建新列表重新赋值 a.extend([3]) # 原地扩展修改 a a.append(3) # 添加一个元素不是把列表拆开添加a a [3]每次都会创建新列表如果在一个循环里不断执行会带来大量的拷贝和内存分配性能很差。extend是原地操作效率高得多。append和extend也容易搞混append是把参数当成一个元素添加而extend是把参数里的每个元素分别添加进来。a [1, 2] a.append([3, 4]) print(a) # [1, 2, [3, 4]] b [1, 2] b.extend([3, 4]) print(b) # [1, 2, 3, 4]7.3 用enumerate而不是手写索引很多从其他语言转过来的开发者习惯这样遍历for i in range(len(items)): print(i, items[i])Python 里更地道的写法是用enumeratefor i, item in enumerate(items): print(i, item)如果还想自定义起始下标enumerate(items, start1)就够了不需要手动i 1。这个习惯的好处不仅是代码少写几行更重要的是避免了手写索引时的取值错误。7.4 列表是空间换时间的典型有时候你会看到[0] * n这种初始化方式它和[0 for _ in range(n)]结果一样但速度快很多因为底层会直接做内存填充而不是每次调用一次列表推导逻辑。这也说明列表的很多浅层优化都建立在“连续内存、预分配空间”的基础上。8. 列表相关的常见报错与排错日志8.1IndexError: list index out of range这是新手遇到最多的报错之一。原因很简单访问了一个不存在的下标。比如len(items)是 5你却访问了items[5]。这里记住一条原则合法的下标范围是0到len(items)-1以及负数-1到-len(items)。排查思路很简单print(len(items)) print(items)先看列表长度和内容再判断下标是否越界。若在循环里出现重点检查循环边界是不是用了或者range(len(items) 1)。8.2ValueError: list.remove(x): x not in list当remove目标值不存在时Python 会抛 ValueError。如果业务上“删不掉也无所谓”可以先判断再删除if target in items: items.remove(target)不过这样一来就有两次遍历先in后remove追求性能的话可以用try/excepttry: items.remove(target) except ValueError: pass8.3TypeError: NoneType object is not iterable这个报错常见于把sort()的返回值直接拿去遍历。很多新手会写成for x in items.sort(): ...sort()返回None所以要养成习惯先items.sort()再遍历items。类似地append、extend、reverse都是原地操作并返回None不能链式调用。8.4 可变默认参数def f(a[])的陷阱这是一个非常经典的 Python 坑。函数的默认参数只在定义时创建一次所以如果默认值是列表那么每次调用时如果不传入该参数用的都是同一个列表对象def add_item(item, container[]): container.append(item) return container print(add_item(1)) # [1] print(add_item(2)) # [1, 2]而不是 [2]多次调用之间状态被意外保留下来。正确做法是默认参数写成None函数内部再重新创建def add_item(item, containerNone): if container is None: container [] container.append(item) return container8.5 列表转字符串的误区列表直接str(list)得到的是 Python 表示形式[1, 2, 3]很多时候不是业务想要的展示效果。如果需要用逗号拼接字符串应该用joinitems [a, b, c] print(,.join(items)) # a,b,c需要注意join是字符串的方法它的参数必须是一个可迭代对象而且里面的元素必须是字符串。如果列表里有整数需要先转换items [1, 2, 3] print(,.join(map(str, items))) # 1,2,39. 列表嵌套二维列表与矩阵操作9.1 创建二维列表的正确方式有些同学这样创建 3x3 的零矩阵结果发现修改一个元素整列都变了matrix [[0] * 3] * 3 matrix[0][0] 1 print(matrix) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]原因非常隐蔽[[0] * 3] * 3把同一个内部列表对象复制了三份三个外层元素其实指向同一个列表。正确的创建方式是matrix [[0] * 3 for _ in range(3)]这就保证每次循环都创建了一个全新的[0, 0, 0]子列表。这个坑我见过不少从业者踩过写的时候一定要多长一个心眼。9.2 二维列表的遍历与列提取遍历二维列表for row in matrix: for value in row: print(value)提取某一列用列表推导式column [row[1] for row in matrix]转置矩阵可以用zip一行搞定transposed list(zip(*matrix))这些写法在数据处理、表格统计的场景里经常用到能明显减少 for 循环的嵌套层级。10. 列表与map、filter、reduce的组合拳Python 内置函数里map和filter可以和列表无缝配合。map把一个函数映射到可迭代对象上返回一个迭代器nums [1, 2, 3, 4] squares list(map(lambda x: x * x, nums)) # [1, 4, 9, 16]filter则是按条件过滤evens list(filter(lambda x: x % 2 0, nums)) # [2, 4]不过在大多数情况下列表推导式写起来更直观squares [x * x for x in nums] evens [x for x in nums if x % 2 0]我个人更推荐在简单逻辑里用推导式在需要复用函数尤其是有名字的函数时用map和filter代码会更有表达力。reduce在 Python 3 里被移到了functools模块它做的事是把一个序列累积成一个值from functools import reduce total reduce(lambda acc, x: acc x, nums, 0)不过在求和场景下直接sum(nums)更简单。reduce更适合复杂一点的累积逻辑比如按规则合并列表元素这种时候才值得引入。11. 实际开发里的列表应用场景11.1 数据清洗批量处理原始数据我在做数据分析相关工作时列表是绕不开的第一站。从文件或接口拿到的原始数据经常是一个列表套列表的结构先要做的是统一格式和去脏数据raw_rows [ [Tom, 20, male], [Jerry, None, male], [Lisa, 22, female], ] clean_rows [ [row[0].strip(), row[1] if row[1] is not None else 0, row[2]] for row in raw_rows ]这种“先清洗成长度一致的列表再转成 DataFrame”的模式几乎每天都在用。列表不仅好写还容易调试打印出来就能直接看。11.2 分页场景中的切片思想分页是 Web 开发里最常见的需求。切片和分页天然契合def paginate(items, page, page_size): start (page - 1) * page_size end start page_size return items[start:end]如果start超出列表长度切片并不会报错只会返回空列表这种“宽容”特性让切片非常适合分页场景不用像其他语言那样写一堆边界判断。11.3 去重、排序、过滤一条链实际业务里经常遇到“从一个列表出发经过筛选、去重、排序再输出”的流程。链式写法配合列表推导式非常顺滑raw_ids [3, 1, 2, 3, 4, 1, 5, 2] result sorted( set(raw_ids), reverseTrue ) # [5, 4, 3, 2, 1]如果数据更多、逻辑更复杂可以拆成多步变量方便排查中间结果。不要追求“一行到底”的视觉效果保证可读性永远是第一位的。12. 实操总结与避坑重点列表本身不难但把它放到真实项目中坑总是出在“引用”和“性能”这两个大方向上。我把这些年最值得记住的几点再整理一遍列表存的是引用不会拷贝copy.copy只是浅拷贝嵌套可变对象要用copy.deepcopy。切片左闭右开负索引从末尾数起[::-1]是反转新列表reversed()是惰性迭代。扩容是自动的但大量添加元素时应考虑预分配append尾部插入是 O(1)头部插入insert(0, x)是 O(n)。边遍历边删除是高风险操作优先用列表推导式构建新列表实在要在原列表上操作就倒序遍历。sort()和append()返回 None别拿来链式操作sort有key参数不要手动写复杂比较逻辑。x in list是 O(n)频繁成员判断时先转成set。函数默认参数不要用可变对象def f(a[])是经典陷阱。创建二维列表不能用[[0] * n] * m每一行都要独立创建。列表推导式和生成器表达式的选择标准是“是否要一次性持有全部数据”数据量很大时优先考虑生成器。我个人在实际操作中的体会是Python 列表看起来简单但它涉及的底层机制引用语义、动态扩容、浅拷贝和深拷贝很容易在项目规模变大以后反噬你。与其等出了问题再查不如在一开始就把这些核心机制理解透。列表的基本操作一天就能学会但真正掌握它的边界、性能和坑靠的是大量写代码和踩坑换来的经验。希望这篇文章能帮你少走一些弯路。