☰
Python列表底层原理与高效操作:从切片、深浅拷贝到遍历删除
2026/10/12 19:54:15 网站建设 项目流程

先问一个基础但奇怪的问题:你写 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_list,A 函数用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 被前移到了索引 1),4 是偶数,删掉,索引变 3;后面的元素又前移,此时索引 3 指向 6,6 是偶数,删掉。所以 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()是原地排序,返回None;sorted(lst)返回新列表。两者都不只是时间复杂度问题,还有一个常被忽略的点:Python 的排序是稳定的——两个键相等的元素,排序后相对位置不变。这个性质在"多级排序"(先按 A 字段排,再按 B 字段排)时非常有用,因为稳定的排序保证第二级排序不会破坏第一级的相对顺序。

如果你要对一组对象按多个字段排序,最 Pythonic 的写法是利用key参数返回一个元组。比如先按年龄再按姓名字母序:

people.sort(key=lambda p: (p["age"], p["name"]))

key函数只会在排序前调用一次,和每次比较都调用比较函数的写法相比,性能差距很大,尤其数据量大的时候明显。排序这块的经验是:永远优先考虑key,而不是写复杂的比较器。

7. 最高频的实战模式:排序、去重、分组、展平

7.1 排序:key 参数用的好能省一半代码

排序除了按原值排,大部分实战场景都是按某个属性或某个变换后的值排序。key参数接受一个函数,作用于每个元素,以函数返回值作为排序依据。比如按字符串长度排序:

words = ["apple", "kiwi", "banana", "pear"] words.sort(key=len) # ['kiwi', 'pear', 'apple', 'banana']

如果需要降序,加一个reverse=True,而不是靠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,别再让列表硬扛。掌握这三条,日常项目里列表相关的坑基本都能避开。

最后再分享一个小技巧:遇到复杂的列表操作,先拿一小段样本数据在交互式环境里跑一遍,打印每一步的中间结果。很多列表问题都是"我以为的语义"和"实际语义"不一致造成的,跑一遍比干想快得多。列表这东西,说到底就是个工具,用熟了自然就顺了,但前提是别在它最基础的地方栽跟头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询