ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python列表从入门到精通:底层原理、常用方法与性能避坑指南

Python列表从入门到精通:底层原理、常用方法与性能避坑指南 做Python开发这些年我见过太多人把列表用得“差不多就行”——会append、会for循环遍历、会下标取值就觉得已经掌握list了。可真到了面试、写业务、做数据处理的时候才发现有太多细节能让你当场翻车sort()为什么返回Noneremove删不掉重复值copy()复制完了改一个列表另一个也跟着变pop(0)在大列表上为什么卡成狗这些坑我几乎每周都能在技术群里看到有人问。列表是Python里最基础、最常用、也最容易被低估的数据结构。这篇文章我想用自己实际写代码的经验把Python列表的常用方法从底层原理到实战用法完整梳理一遍。不管你刚入门Python还是写了两三年代码但一直靠“CtrlC/V”对付列表操作这篇都值得好好看一遍。我会把每个方法的适用场景、返回值和踩坑点都讲透最后再给几个平时项目里真正用得到的实战案例。1. 先从底层搞明白List到底是什么很多教程上来就教你怎么用append、pop却没人告诉你列表的底层实现是什么。不理解底层你就永远只能靠死记硬背来学API遇到问题也不知道从哪排查。这里我用最简单的话把底层讲清楚。1.1 动态数组append为什么快insert(0, x)为什么慢Python的list底层实现是动态数组不是链表。这句话信息量很大。动态数组的意思是list在内存里是一块连续的空间每个元素按顺序排列所以通过下标访问元素的复杂度是O(1)——直接按偏移量算地址就行这也是list最引以为傲的能力。但连续空间有个问题你往里面塞东西的时候如果当前这块空间满了怎么办动态数组的策略是分配一块更大的新空间(通常是当前容量的1.125倍左右)把老数据全部拷贝过去再释放旧空间。这个扩容操作是O(n)的但Python做了优化——它不是每次append都扩容而是预留了额外空间所以均摊下来append依然是O(1)。反过来看insert(0, x)它在头部插入一个元素需要把后面所有元素都往后挪一位复杂度是O(n)。列表越长这个操作越慢。所以如果你频繁在列表头部插入数据list其实不是好选择应该考虑collections.deque。这一点后面实战部分我会再展开。1.2 为什么list能装不同类型的数据C语言数组要求元素类型一致Python的list不需要。原因在于list里存的不是对象本身而是对象的引用指针。每个元素都是一个指向堆内存中实际对象的引用8个字节64位系统下。所以你完全可以写[1, hello, 3.14, [1, 2], {a: 1}]这种混搭列表本质上就是在连续空间里存了一排指针。这也解释了一个重要问题list做等值比较时比较的是元素的值不是内存地址。[1, 2] [1, 2]返回True正是因为Python会逐个比较元素指向的内容。理解了“存的是引用”这一点后面讲浅拷贝坑的时候你就能豁然开朗。1.3 List、Tuple、Set、Dict怎么选很多人分不清这四种容器其实按需求选就行List有序、可变、可重复要维护顺序且需要频繁增删改时用。Tuple有序、不可变、可重复定义后不会再改的数据用它比如坐标点、数据库查询返回的单行记录。不可变意味着可以作为dict的key。Set无序、可变、元素唯一去重、求交集并集差集是它的主场。Dict键值对存储通过key做O(1)查找时用它。记一个简单口诀要顺序用List要不可变用Tuple要去重用Set要映射用Dict。选错数据结构后面每一步都别扭。2. 高频方法逐个拆解用法、返回值、坑这一节是全文的核心。我按照自己实际开发中使用的频率来排序每个方法都会讲清楚三件事怎么用、注意什么、什么时候别用它。2.1 append和extend别再搞混了append和extend是初学者最容易混淆的一对。append(x)是把x作为一个整体元素追加到列表末尾不管x是数字、字符串还是列表都当成一个元素塞进去lst [1, 2, 3] lst.append([4, 5]) print(lst) # [1, 2, 3, [4, 5]]extend(iterable)是把iterable里的每个元素依次追加进去lst [1, 2, 3] lst.extend([4, 5]) print(lst) # [1, 2, 3, 4, 5]我见过不少人在循环里用lst.append(item)结果item本身是个列表最后得到一个嵌套列表然后再写一堆代码去扁平化。如果目的是“把返回的多个元素合并进当前列表”应该用extend。补充一个细节extend的参数可以是任何可迭代对象lst.extend(abc)会把字符串拆成[a, b, c]加进去。如果我只想追加一个字符串整体得用append。这个区别在写爬虫拼URL参数的时候很容易踩到。2.2 insert功能强大但别乱用insert(index, x)在指定位置插入元素lst [1, 2, 3] lst.insert(1, a) print(lst) # [1, a, 2, 3]注意两点。第一insert支持负索引lst.insert(-1, x)是在倒数第一个元素之前插入不是插到末尾。想插到末尾就用append。第二insert(0, x)在头部插入是O(n)操作如果你在一个大循环里不断往列表头部插入性能会非常难看。遇到这种情况我的建议是改用deque的appendleft或者换一种思路先全部append最后再整体reverse。2.3 remove、pop、del三种删除方式的取舍remove(x)按值删除第一个匹配项。注意是“第一个”列表里有多个相同值时它只删掉最先出现的那个。如果找不到值直接抛ValueError。lst [1, 2, 3, 2] lst.remove(2) print(lst) # [1, 3, 2]pop(index-1)按位置删除并返回被删除的元素。不传参数时默认删除末尾元素这是O(1)操作传中间或头部位置则是O(n)。lst [1, 2, 3] last lst.pop() print(last, lst) # 3 [1, 2]del这是Python语句不是list的方法。可以按位置删除单个元素也可以切片删除lst [1, 2, 3, 4, 5] del lst[0] # 删除下标0 del lst[1:3] # 删除下标1到2 print(lst) # [2, 5]我的使用习惯需要返回被删元素时用pop只删不关心返回值时用remove或del。千万别在一个循环里用remove删除所有匹配项——因为删除元素后列表长度变了索引会错乱而且remove只删第一个循环很容易漏删或者越界。正确做法是用列表推导式重建列表或者先收集要删除的下标再倒序删除。2.4 index和count查找与计数记得处理异常index(x)返回第一个匹配项的索引找不到抛ValueError。count(x)返回x出现的次数。这两个方法本身不难但有个问题index找不到就抛异常而异常处理是有开销的。如果你只是想知道“这个元素在不在列表里”用x in lst判断比try...except index更直观、性能也更好。如果你已经知道元素存在想要它的位置再调用index。lst [apple, banana, cherry] if banana in lst: idx lst.index(banana) print(idx) # 12.5 sort和sorted排序的两种姿势别搞混这是重头戏也是出错频率最高的地方。lst.sort()原地排序直接修改原列表返回None。很多新手写new_lst lst.sort()然后发现new_lst是None原列表反而被改了——因为sort()不返回新列表。sorted(lst)返回一个新的排好序的列表原列表不变。如果原列表不再需要用sort省内存如果需要保留原列表用sorted。两个方法都支持两个关键参数key和reverse。key指定排序依据reverseTrue表示降序。words [banana, apple, cherry, date] words.sort(keylen) # 按长度排序 print(words) # [date, apple, banana, cherry] students [{name: Tom, score: 85}, {name: Jerry, score: 92}] students.sort(keylambda s: s[score], reverseTrue) print(students) # [{name: Jerry, score: 92}, {name: Tom, score: 85}]关于多字段排序sort是稳定排序底层实现是Timsort这意味着如果两个元素的key相等它们会保持原来的相对顺序。利用这个特性可以分多次排序实现多级排序。比如先按姓名排再按分数排最终结果是按分数为主、姓名在分数相同时生效data.sort(keylambda x: x[name]) # 先按姓名 data.sort(keylambda x: x[score]) # 再按分数分数相同保持姓名序如果用operator.itemgetter一行就能实现多级排序from operator import itemgetter data.sort(keyitemgetter(score, name)) # 先按score再按name2.6 reverse和切片翻转lst.reverse()原地翻转返回None。另一种翻转方式是用切片lst[::-1]它返回一个新的翻转列表不修改原列表。注意lst[::-1]这种写法创建了一个完整的新列表如果列表很大内存占用会翻倍。只是临时需要倒序遍历的话用reversed(lst)更好——它返回迭代器不复制数据for item in reversed(lst): print(item)2.7 copy()浅拷贝的陷阱lst.copy()返回一个新列表但只拷贝了一层。什么意思列表里的元素如果是可变对象比如嵌套列表、字典新列表里的这些元素依然指向同一块内存。lst [[1, 2], [3, 4]] new_lst lst.copy() new_lst[0].append(99) print(lst) # [[1, 2, 99], [3, 4]] 原列表也被改了要真正实现深拷贝得用copy模块的deepcopyimport copy lst [[1, 2], [3, 4]] new_lst copy.deepcopy(lst) new_lst[0].append(99) print(lst) # [[1, 2], [3, 4]]这个坑在项目里出现频率非常高。特别是从数据库查出嵌套结构、或者处理配置信息时随手一个copy()就以为自己复制了一份独立数据结果改了一处所有地方都跟着变。记住一句话只要列表里有可变对象copy()就不够用请用deepcopy。3. 实战案例这些场景你迟早会遇到方法都认识了下面串联几个我在实际项目中真正写过、也帮别人排查过的场景每个都是可以直接抄作业的代码。3.1 列表去重并保持顺序Python里set天生去重但它不保留顺序。如果既要去掉重复元素又要保持第一次出现的顺序网上的“一行代码”方案很常见lst [3, 1, 2, 3, 1, 4, 5, 2] seen set() result [x for x in lst if not (x in seen or seen.add(x))] print(result) # [3, 1, 2, 4, 5]但这种写法可读性差or短路逻辑对新人很不友好。我一般推荐更直白的写法lst [3, 1, 2, 3, 1, 4, 5, 2] seen set() result [] for x in lst: if x not in seen: seen.add(x) result.append(x)注意一个小坑如果列表里的元素本身是字典或列表不可哈希类型没法直接放进set。这时候可以给元素加一层转换比如把字典转成json.dumps的字符串再判断去重。3.2 数据清洗批量转换类型并过滤非法值处理Excel、CSV导入的数据时经常遇到“看起来是数字其实是字符串”的情况。下面这个例子把字符串列表批量转成整数遇到转换失败的自动跳过raw_data [12, 34, abc, 56, , 78, None] cleaned [] for item in raw_data: try: cleaned.append(int(item)) except (ValueError, TypeError): continue print(cleaned) # [12, 34, 56, 78]这里面有个细节int(None)会抛TypeError而不是ValueError所以except要同时捕获这两种异常。还有如果你确实需要保留“哪一行数据失败”的信息可以在循环里打印索引for idx, item in enumerate(raw_data): try: cleaned.append(int(item)) except (ValueError, TypeError): print(f第{idx}行数据不合法: {item!r})3.3 用List模拟栈和队列栈后进先出用list的append和pop配合就是天然的实现stack [] stack.append(1) stack.append(2) stack.append(3) top stack.pop() # 3队列先进先出用list做就有性能隐患了因为出队要pop(0)这是O(n)操作。数据量小无所谓数据量大就明显变慢。正确姿势是用collections.dequefrom collections import deque queue deque() queue.append(1) queue.append(2) head queue.popleft() # 1O(1)我实测过10万条数据用list做队列pop(0)连续操作耗时是deque.popleft()的几百倍。所以在写队列场景时别把list当default choice。3.4 嵌套列表扁平化把[[1, 2], [3, 4, 5], [6]]变成[1, 2, 3, 4, 5, 6]列表推导式一行搞定nested [[1, 2], [3, 4, 5], [6]] flattened [x for sub in nested for x in sub] print(flattened) # [1, 2, 3, 4, 5, 6]注意推导式的书写顺序for sub in nested在外层for x in sub在内层和普通for循环的嵌套顺序一致别写反了。如果嵌套层数不固定比如[1, [2, [3, [4]]]]就得用递归或while循环def flatten(items): result [] for item in items: if isinstance(item, list): result.extend(flatten(item)) else: result.append(item) return result print(flatten([1, [2, [3, [4]]]])) # [1, 2, 3, 4]3.5 按条件排序与分组对列表里的字典按字段排序、再分组是数据处理的高频操作。排序用key参数分组可以用itertools.groupby但groupby要求数据已经按分组键排好序否则分不干净from itertools import groupby students [ {name: Tom, class: A, score: 85}, {name: Jerry, class: B, score: 92}, {name: Alice, class: A, score: 78}, {name: Bob, class: B, score: 88}, ] # 先按class排序再分组 students.sort(keylambda s: s[class]) for cls, group in groupby(students, keylambda s: s[class]): scores [s[score] for s in group] print(f{cls}班: {scores})如果只是想知道“每个班有哪些人”用dict.setdefault更直接不需要提前排序grouped {} for s in students: grouped.setdefault(s[class], []).append(s) print(grouped)4. 性能对比与常见坑这些教训都是真金白银4.1 常用操作的时间复杂度速查很多性能问题不是代码写错了而是数据结构用错了。下面这张表是我总结的list常用操作时间复杂度建议收藏操作时间复杂度说明lst[i]O(1)按下标访问lst.append(x)O(1)均摊O(1)lst.pop()O(1)末尾弹出lst.pop(i)/del lst[i]O(n)中间位置删除lst.insert(0, x)O(n)头部插入lst.remove(x)O(n)先查找再移动x in lstO(n)线性查找lst.sort()O(n log n)Timsort排序lst.reverse()O(n)原地反转lst.copy()O(n)浅拷贝看到没x in lst是O(n)。如果你在一个大循环里反复判断元素是否在列表里列表越长越慢。正确做法是多次查询时先把list转换成setO(1)查找lst [1, 2, 3, 4, 5] lookup set(lst) for i in range(10000): if i in lookup: pass # O(1) 判断4.2 可变默认参数的坑这个坑面试常问实际开发中也容易犯。给函数定义默认参数时如果默认值是空列表会导致所有调用共享同一个列表def add_item(item, lst[]): lst.append(item) return lst print(add_item(1)) # [1] print(add_item(2)) # [1, 2] ← 为什么会带上前一次的结果原因在于默认参数在函数定义时只创建一次之后所有不传该参数的调用都用同一个列表对象。正确写法是用None占位def add_item(item, lstNone): if lst is None: lst [] lst.append(item) return lst4.3 遍历列表时删除元素经典翻车现场这个我见过太多次了。想在循环里删除所有偶数lst [1, 2, 3, 4, 5, 6] for item in lst: if item % 2 0: lst.remove(item) print(lst) # [1, 3, 5]不对再看实际输出是[1, 3, 5, 6]——6没删掉。因为删除元素后列表缩水遍历索引却还在往后走跳过了部分元素。解决方式有三种第一种倒序遍历lst [1, 2, 3, 4, 5, 6] for i in range(len(lst) - 1, -1, -1): if lst[i] % 2 0: del lst[i] print(lst) # [1, 3, 5]第二种列表推导式重建推荐最Pythoniclst [1, 2, 3, 4, 5, 6] lst [x for x in lst if x % 2 ! 0] print(lst) # [1, 3, 5]如果逻辑复杂推导式写不开就用“先收集再统一删”的思路lst [1, 2, 3, 4, 5, 6] to_remove [] for item in lst: if item % 2 0: to_remove.append(item) for item in to_remove: lst.remove(item)4.4 切片是复制不是视图lst[start:end]返回的是一个新的列表修改切片不会影响原列表。这一点和numpy的切片完全不同——numpy切片是视图改切片会改原数组。用惯numpy的人切list时容易犯迷糊。lst [1, 2, 3, 4, 5] sub lst[1:3] sub[0] 99 print(lst) # [1, 2, 3, 4, 5]原列表没变切片复制这个特性也意味着大列表切片会创建大块新数据内存敏感的场景要注意。比如lst[:]相当于copy()如果只需要遍历别写这个。4.5 列表推导式 vs 循环不只是写法差异列表推导式除了简洁性能上也普遍优于等价的for循环。原因是推导式在底层做了更少的Python字节码操作整体开销更小。我简单测过10万次带条件的列表构建推导式比for循环快大约20%~30%。但性能提升不是绝对说推导式永远更好。情况复杂时比如循环体内有很多行逻辑、有break条件推导式可读性很差这时候用普通for循环更合理。原则是简单逻辑用推导式复杂逻辑用循环不要为了快而牺牲可维护性。4.6 pop(0)的性能杀手场景很多人在写队列逻辑时习惯while lst: head lst.pop(0)数据量小感觉不出来数据量一大就卡到怀疑人生。前面也说了pop(0)是O(n)操作每次弹出都要移动后面所有元素。应对方案数据量小几千条以内无所谓怎么写都行。数据量中等可以用collections.dequepopleft()是O(1)。数据量很大最好直接用queue.Queue线程安全或multiprocessing对应的队列别自己用list硬扛。5. 几句大实话收尾写到这里Python列表的常用方法基本都过了一遍。说实在的list这些方法看文档很快就会但真正拉开差距的是你对“每个操作背后是什么代价”的理解。我在实际写代码、面试别人、帮人排查问题的时候发现绝大多数列表相关的bug都能追溯到几个原因没搞清原地修改和返回新列表的区别、浅拷贝陷阱、遍历时修改结构、以及用错了数据结构。我个人一直觉得Python列表是你最早接触的工具也是最值得花时间彻底吃透的工具。它不复杂但细节非常多。把这篇文章里的坑都踩过一遍、或者提前避开后面写代码能省不少事。最后再分享一个我自己的习惯凡是超过三行的列表处理逻辑我都会先停下来想想——这个需求是不是用set、dict或者deque会更合适列表确实万能但万能不等于最优。选对数据结构代码的效率和可读性都会上一个台阶。
返回列表