首页 / 资讯中心 / 文章详情

Python列表底层原理与高效操作:从切片、深浅拷贝到遍历删除

Python列表底层原理与高效操作:从切片、深浅拷贝到遍历删除 ★ FEATURED ARTICLE
先问一个基础但奇怪的问题你写 Python 的时候哪个数据结构用的频率最高答案大概率是列表。但你再问自己一句——列表底层到底怎么存的切片为什么老是漏最后一个元素遍历时删除为什么总是漏删copy()和深拷贝在嵌套列表面前有什么区别insert(0)为什么慢得让人抓狂这些问题听起来零散其实全是围绕同一个核心数据结构展开的。我见过不少写了三五年 Python 的人在上面这些问题上照样翻车。这篇文章不打算讲什么高深的内核源码就是以一个老开发者的视角把 Python 列表从头到尾捋一遍底层存储、常用操作的代价、遍历删除的坑、深浅拷贝、推导式、性能真相以及日常实战里最高频的几种玩法。适合刚入门想系统梳理的朋友也适合写了几年代码但一直在凭感觉用列表的老手来查漏补缺。1. 列表的底层身份为什么它能装下任何东西1.1 可变序列的定位Python 的内置类型里有两条重要的分类轴一条是序列 vs 映射另一条是可变 vs 不可变。列表属于可变序列这个定位决定了它的两大核心特性一是可以通过整数索引按顺序取值二是在创建之后还能自由修改内容。这两点看着普通放在一起几乎决定了列表的所有行为。对比一下经常被拿来和列表比较的类型就更能看清它的性格。元组是不可变序列内容定死之后不用担心被意外修改适合当字典的键、或者当作一批固定配置传递字符串是不可变的字符序列每次拼接字符串其实都在创建新对象而列表没有这些限制所以它在日常开发里成了默认容器。但这种便利也不是没有代价的因为列表可变它就没有哈希值不能作为字典的键也不能放进集合里。凡是需要hashable的场景列表都得让位给元组。这个限制不算大坑但确实是我见过很多新手突然撞到TypeError: unhashable type: list的常见原因。1.2 引用存储机制几乎所有教程都会告诉你列表可以存不同类型的数据但很少有人解释为什么。真相是CPython 里的列表是一个动态数组数组里每个槽位存的并不是数据本身而是指向数据对象的一个引用指针。所以一个列表里同时装着整数、字符串、对象甚至另一个列表从底层角度看完全正常——它本质上是维护一组引用。这个机制会带来两个很关键的推论。第一列表里的元素是对象引用而不是对象值所以用比较两个列表比较的是元素的值用is比较的才是身份。平时写代码感觉不到这个差异但一旦牵扯到拷贝、函数传参它就会主导很多诡异行为。第二如果你在列表里放了同一个可变对象好几次修改一个就会连坐其他引用。最经典的例子是lst [[]] * 3你会得到一个包含三个相同空列表引用的列表往其中一个里塞数据另外两个也会跟着出现——因为它们压根是同一个对象。这个坑在各种源码评审里反复出现值得刻在脑子里。1.3 清空和换新不是一回事既然列表存的是引用那么清空列表和重新赋值列表就是两件不同的事。lst.clear()是把列表里的引用全部清空这个列表对象本身还在lst []是让变量指向一片新的内存原来的列表如果没被别的变量引用就会被垃圾回收。这两个操作在业务代码里的区别非常现实。假设有一个全局列表shared_listA 函数用shared_list []清空了它B 函数如果还持有对原列表的引用看到的数据依然是旧的但如果你用shared_list.clear()所有引用方看到的都是空列表。很多团队协作项目里出现过我明明清空了其他人怎么还看到旧数据的困惑基本都源于这个语义差异。2. 创建与增删改每个 API 背后都有成本2.1 创建列表的常见姿势列表的创建方式五花八门我整理了一个对照表写代码时可以根据场景直接选方式示例适用场景字面量[]、[1, 2, 3]最常用性能最好构造函数list(abc)、list(range(5))从可迭代对象转换推导式[x * x for x in range(10)]需要对源数据做变换或筛选重复操作[0] * 5生成固定长度的占位列表拆分操作a,b,c.split(,)字符串转列表这里有个细节值得注意list(abc)会拆成[a, b, c]因为字符串本身是可迭代对象但如果你传一个整数进去会直接报错因为整数不可迭代。同理list(range(5))是生成[0, 1, 2, 3, 4]这是把range这种惰性对象落成列表的标准做法。另外[0] * 5这种重复操作对于不可变对象整数、字符串完全没问题但对可变对象要格外小心——它会重复引用同一个对象这在第 1 节已经讲过了。2.2 append、extend、insert 的边界要分清增删改是列表使用频率最高的操作但很多人分不清append和extend的区别。append(x)是把x当作一个元素追加到末尾哪怕x本身是个列表它也只会把这个列表当作一个整体放进去extend(iterable)则是把可迭代对象里的每个元素逐个展开再追加到末尾。举两个例子lst [1, 2] lst.append([3, 4]) # [1, 2, [3, 4]] lst_ext [1, 2] lst_ext.extend([3, 4]) # [1, 2, 3, 4]这个区别在我看来是新手最容易踩的第一个列表坑。很多人想拼接两个列表结果用append套出一个嵌套结构后面遍历数据时才发现元素类型不对又花时间 debug。insert(i, x)比append贵得多因为它要把插入位置之后的所有元素往后挪一位时间复杂度是 O(n)。如果你有一大批数据要不断在头部插入列表并不是好选择后面第 6 节会专门对比。同理删除操作里也暗藏复杂度差异pop()删除末尾是 O(1)pop(0)删除头部是 O(n)remove(x)要先线性查找再删除最坏也是 O(n)。2.3 删除元素到底有几种姿势删除列表元素常见的姿势有四种remove(x)按值删除第一个匹配项pop(i)按下标删除并返回该元素del lst[i]按下标删除但不返回clear()全清空。选择哪个不只看需求还要看成本。remove需要遍历比较意味着它不适合在循环里反复调用——每调用一次都是从头开始找很容易写出 O(n²) 的代码而不自知。pop的好处是能拿到被删的值适合实现栈结构del更像是我不要了的声明适合配合切片删除一段区域比如del lst[1:4]。删除这里还有一个很容易被忽略的问题在遍历过程中删除元素。这不是操作符选错的问题而是迭代机制和列表可变性之间天然存在的矛盾。因为背后牵扯到的原理比较有意思我单独用一章来讲。3. 边遍历边删除漏删问题的复现与根治3.1 问题复现为什么跳着删先看一段非常常见的标准错误示范——需求是把列表里所有偶数的元素删掉numbers [1, 2, 3, 4, 5, 6] for num in numbers: if num % 2 0: numbers.remove(num) print(numbers) # 你以为输出什么如果没跑过这段代码你可能以为结果是[1, 3, 5]。但实际上跑出来是[1, 3, 5]在某些变体场景下甚至会出现意外保留。比如换成下面这种写法numbers [1, 2, 3, 4, 5, 6] for num in numbers: if num % 2 0: numbers.remove(num) print(numbers) # [1, 3, 5]这里看起来结果碰巧对了但如果把数据改成[2, 4, 6, 8]结果会变成[2, 6]——2 和 6 被保留下来了这显然不是想要的效果。问题就出在修改列表的同时又在遍历同一个列表上。3.2 根因迭代器按索引推进Python 的for ... in ...遍历列表时底层维护了一个按整数索引递增的迭代器。每轮循环取当前索引指向的元素然后索引加一。问题在于你一旦用remove删除了中间某元素后面的元素会整体前移一位。迭代器却不知道这件事它依然按原来的步调向后移动于是就会越过紧跟在被删元素后面的那个元素导致它根本不会被检查到。用[1, 2, 3, 4, 5, 6]来推演索引 0 检查 1不是偶数索引变 1索引 1 检查 2是偶数删掉 2列表变成[1, 3, 4, 5, 6]但索引继续变 2索引 2 检查到的是 4因为 3 被前移到了索引 14 是偶数删掉索引变 3后面的元素又前移此时索引 3 指向 66 是偶数删掉。所以 3 和 5 因为被前移但被迭代器错过反而幸存了下来。这种跳着删的玄学行为在数据量一大的时候会让结果变得完全不可预测。3.3 三种可靠的替代方案如果你有遍历时删除的需求我不建议继续在原地删除这个方向上死磕老老实实换一种写法更稳妥。第一种用推导式生成新列表过滤掉不需要的元素。这是最推荐的方式代码简洁且性能好numbers [1, 2, 3, 4, 5, 6] numbers [num for num in numbers if num % 2 ! 0] print(numbers) # [1, 3, 5]第二种倒序遍历。因为删除元素只会影响被删元素之后的位置倒着遍历时前面的索引不受影响可以实现安全原地删除numbers [1, 2, 3, 4, 5, 6] for i in range(len(numbers) - 1, -1, -1): if numbers[i] % 2 0: del numbers[i] print(numbers) # [1, 3, 5]第三种如果确定要原地删除且不在乎顺序可以用while循环配合remove但要注意每次remove都是 O(n) 查找总复杂度通常不理想。我个人平时最常用的是第一种因为推导式表达留下什么比删掉什么更直观而且不修改原始对象在函数式风格更强的代码里也更安全。4. 深浅拷贝赋值、[:]、copy()到底复制了什么4.1 赋值不拷贝很多人在需要复制一个列表的时候随手写new_lst old_lst。这其实不是复制只是让new_lst和old_lst指向同一个列表对象。修改其中一个另一个同步变化因为它们根本就是同一个东西。要区分引用和拷贝最简单的方法是看id()id(old_lst) id(new_lst)就意味着没有新对象产生。如果你只是想给列表换个名字继续用赋值没问题如果你想独立修改副本就必须显式创建新列表。创建新列表的方式有三种切片old_lst[:]、old_lst.copy()、list(old_lst)。这三种本质相同都是浅拷贝。4.2 浅拷贝对嵌套列表的意外共享浅拷贝最大的坑是它只复制列表最外面一层里面的元素还是原来的引用。如果你的列表元素全是整数、字符串这类不可变对象浅拷贝完全够用但如果元素里有嵌套列表、字典、或者自定义对象浅拷贝就只是拷贝了壳没拷贝核。matrix [[1, 2], [3, 4]] copy_by_slice matrix[:] copy_by_slice[0].append(99) print(matrix) # [[1, 2, 99], [3, 4]] 原列表被影响了代码里只是改了copy_by_slice里的嵌套列表结果matrix也跟着变了。原因就是切片只复制了外层指针数组内层[1, 2]和[3, 4]这两个列表对象依然是被共享的。我在实际项目里见过一个很经典的线上事故某配置模块用浅拷贝复制了全局配置列表然后在一个子任务里改了自己的副本结果所有任务的配置全被改了——排查了很久才发现是嵌套字典被共享导致的连锁副作用。4.3 deepcopy 才是嵌套结构的救星如果结构里有嵌套的可变对象且你需要完全独立的副本直接上标准库的copy.deepcopy()from copy import deepcopy matrix [[1, 2], [3, 4]] real_copy deepcopy(matrix) real_copy[0].append(99) print(matrix) # [[1, 2], [3, 4]] 不受影响 print(real_copy) # [[1, 2, 99], [3, 4]]deepcopy会递归地复制所有层次的可变对象代价是性能和内存开销更高而且遇到循环引用时虽然能处理但部分复杂对象里的资源句柄比如打开的文件、数据库连接可能无法被干净复制。所以我的习惯是数据只有一层用copy()或切片数据嵌套层级深才考虑deepcopy如果只是一份只读配置连拷贝都不需要直接共享原对象反而更省。5. 列表推导式简洁的代价与正确姿势5.1 执行顺序与嵌套逻辑列表推导式是 Python 里最Pythonic的语法之一它能用一行代码代替好几行的循环逻辑。但反过来一行推导式写得太花哨可读性会急剧下降。理解推导式的关键是记住一个执行顺序规则for子句从左到右依次嵌套最左边的for是最外层循环。pairs [(x, y) for x in range(3) for y in range(3)] # 等价于 pairs [] for x in range(3): for y in range(3): pairs.append((x, y))这个嵌套顺序是推导式最容易出错的地方。你看到一个for x in ... for y in ...的结构要能立刻反应过来它是先循环 x、再内层循环 y。如果写反了生成的结果顺序会完全不一样。我的建议是一旦嵌套超过两层就不要硬写推导式了老老实实展开成循环没人会因为你写循环而鄙视你但绝对会因为一个六层嵌套推导式 debug 到怀疑人生而记恨你。5.2 if 的位置决定了是筛选还是映射推导式里的if放在两个不同的位置含义完全不同。放在for之后是过滤条件只有满足条件的元素才会进入最终结果放在表达式内部比如三元表达式是对每个元素做变换所有元素都会保留。nums range(10) # 过滤只要偶数 evens [n for n in nums if n % 2 0] # [0, 2, 4, 6, 8] # 映射变换所有元素都经过处理 labels [n if n % 2 0 else -1 for n in nums] # [0, -1, 2, -1, 4, -1, 6, -1, 8, -1]第一个例子是偶数保留奇数丢弃第二个例子是所有数字都在只是偶数保留自己奇数替换成 -1。这两种写法经常被混在一起用我建议在写之前先问自己一句我是想减少元素数量还是只改变元素值想清楚这个if该放哪里自然就明白了。5.3 可读性优先何时不该用推导式推导式虽好但它有几个天生的弱点没有变量名、不能中途打断、逻辑复杂时难以加注释、报错时回溯信息也不够友好。我在实际工作中总结了一条简单粗暴的边界线如果表达式里同时出现了嵌套循环、多个条件、函数调用这三样中的两样就不要用推导式了。比如下面这个例子虽然能写成一行但谁能一眼看懂它result [[func(x, y) for y in ys if y 0] for x in xs if x is not None for ys in data.get(x, [])]这种代码写出来的时候很爽review 的时候谁都不想碰。换成多行循环加几个中间变量每个步骤都有名字后面维护的人会真心感谢你。推导式的目标是简洁地表达意图如果简洁变成了晦涩就已经背离了它的初衷。6. 性能真相为什么 append 稳得像条狗6.1 动态扩容机制很多人对列表性能的认知停留在列表就是数组这个层面但 Python 列表实际上是一个动态数组当容量不够时会自动申请更大的内存把现有元素搬过去。这个扩容不是一个个地来而是按倍数申请通常扩容后的大小是原来的 1.125 倍左右具体倍数随版本和实现有差异。扩容虽然偶尔需要 O(n) 的搬移但因为扩容次数呈指数级减少摊还下来每次append的平均时间复杂度是 O(1)。也就是说你在循环里连续 append 十万个元素绝大多数追加操作都不需要搬移只有少数几次触发了扩容。这就是为什么列表的append在实际使用中稳得很——它不需要预先申请容量又几乎总能以常数时间完成。6.2 insert(0) 和 pop(0) 的 O(n) 之痛和append形成鲜明对比的是insert(0, x)和pop(0)。因为列表是连续存储的在头部插入意味着要把所有现有元素往后挪一个位置头部删除则需要把后面所有元素往前挪。这两者的时间复杂度都是 O(n)。如果你有一个队列需求不断从头部取出数据、从尾部追加数据用 Python 列表实现会在数据量大时明显变慢。比如处理 10 万条消息每条都pop(0)总代价大约是 O(n²)数据一多基本就卡死了。这时候更合适的选择是用collections.deque它是双端队列头部和尾部的插入删除都是 O(1)。模块名是collections引用方式from collections import deque即可。我做个简单对比表方便你选型操作list 时间复杂度deque 时间复杂度建议尾部追加appendO(1)O(1)都行尾部弹出pop()O(1)O(1)都行头部插入insert(0)O(n)O(1)用 deque头部弹出pop(0)O(n)O(1)用 deque按下标索引lst[i]O(1)O(n)用 list中间插入O(n)O(n)数据小随意deque也不是万能的它按下标取元素是 O(n)因为它在内存里是分块链表结构不能像数组那样直接跳转。所以如果你的核心操作是按下标随机访问还是得用列表。6.3 sort 与 sorted 的稳定性排序是列表的高频操作list.sort()是原地排序返回Nonesorted(lst)返回新列表。两者都不只是时间复杂度问题还有一个常被忽略的点Python 的排序是稳定的——两个键相等的元素排序后相对位置不变。这个性质在多级排序先按 A 字段排再按 B 字段排时非常有用因为稳定的排序保证第二级排序不会破坏第一级的相对顺序。如果你要对一组对象按多个字段排序最 Pythonic 的写法是利用key参数返回一个元组。比如先按年龄再按姓名字母序people.sort(keylambda p: (p[age], p[name]))key函数只会在排序前调用一次和每次比较都调用比较函数的写法相比性能差距很大尤其数据量大的时候明显。排序这块的经验是永远优先考虑key而不是写复杂的比较器。7. 最高频的实战模式排序、去重、分组、展平7.1 排序key 参数用的好能省一半代码排序除了按原值排大部分实战场景都是按某个属性或某个变换后的值排序。key参数接受一个函数作用于每个元素以函数返回值作为排序依据。比如按字符串长度排序words [apple, kiwi, banana, pear] words.sort(keylen) # [kiwi, pear, apple, banana]如果需要降序加一个reverseTrue而不是靠key函数取相反数这种反直觉操作。还有一种常见需求是乱序排列用random.shuffle(lst)注意它是原地打乱。7.2 保持顺序的去重方案去重最简单的方案是set(lst)但会丢失原有顺序。如果顺序很重要需要做一个保留第一次出现顺序的去重。经典写法是用set记录已见过的元素seen set() result [] for item in data: if item not in seen: seen.add(item) result.append(item)这段代码的时间复杂度是 O(n)因为set的in判断是 O(1)。如果拿它和if item not in result这种写法比后者每次都要线性查找数据一多就从 O(n) 退化成 O(n²)。很多人在处理几万条列表去重时莫名卡顿基本都是栽在这个细节上。如果数据本身可以哈希且不要求顺序直接list(set(lst))是最快的去重对象如果不可哈希比如字典列表就得用其他方案比如按某个唯一字段去重写法变成seen set() result [] for d in dict_list: key d[id] if key not in seen: seen.add(key) result.append(d)7.3 分组与展平分组在数据处理里非常常见。一种思路是itertools.groupby但它只对连续相同值的元素分组使用前必须先排序另一种思路是直接用字典手动分组更灵活也更直观。按某字段分组的标准写法groups {} for item in items: groups.setdefault(item[category], []).append(item)用setdefault可以省掉先判断键是否存在的一整段逻辑比if key not in groups: groups[key] []短好几行。如果你经常这么写也可以直接上collections.defaultdict(list)第一次访问不存在的键时会自动创建空列表代码更清爽。展平嵌套列表也是高频需求。一层嵌套直接写双重循环多层嵌套可以写递归函数def flatten(nested): result [] for item in nested: if isinstance(item, list): result.extend(flatten(item)) else: result.append(item) return result这种写法能处理任意深度的嵌套。如果你确定只有一层列表推导式[x for sub in nested for x in sub]是最高效的方案但嵌套加深之后就不建议了。7.4 分块处理数据批量处理数据时经常需要把一个大列表切成固定大小的小块。这个需求很容易写出丑陋的循环但其实一行切片就能搞定def chunk(lst, size): return [lst[i:i size] for i in range(0, len(lst), size)]比如chunk(list(range(10)), 3)会得到[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]。这里最核心的知识点还是切片lst[i:i size]的结束索引是可以越界的Python 切片会自动截断到列表末尾这正好避免了手工处理最后一段不足 size 的边界情况。很多语言里切片越界会报错但 Python 的切片设计在这里体现出非常顺手的一面。切片还有几个值得记住的操作技巧lst[::-1]可以反转列表lst[::2]可以取偶数位置元素del lst[::2]可以删除所有偶数位置元素。高效、简洁而且不需要写循环。但要注意如果切片落在嵌套可变对象上它只是浅拷贝——这也是我们第 4 节反复强调过的底层规则的具体体现。8. 结尾关于列表我最后想说的几句体己话列表看起来是 Python 里最没门槛的数据结构但恰恰是这种没门槛让很多人忽视了它的底层逻辑。我这些年看过的代码里真正把列表用明白的人都有一个共同特征他们不是背 API而是理解列表存的是引用 连续存储这两个底子。切片是浅拷贝、遍历删除会漏、[[]] * 3会共享、insert(0)很慢——这些现象全部可以从这两个底子推出来。如果在阅读过程中你只打算记住三件事那我建议是第一需要独立副本时先问一句里面有没有嵌套可变对象有就用deepcopy第二遍历时不要原地删除优先用推导式生成新列表第三头部频繁增删的场景换deque别再让列表硬扛。掌握这三条日常项目里列表相关的坑基本都能避开。最后再分享一个小技巧遇到复杂的列表操作先拿一小段样本数据在交互式环境里跑一遍打印每一步的中间结果。很多列表问题都是我以为的语义和实际语义不一致造成的跑一遍比干想快得多。列表这东西说到底就是个工具用熟了自然就顺了但前提是别在它最基础的地方栽跟头。
阅读完成 · 觉得有帮助?
咨询建站