作为一个常年和数据处理打交道的Python使用者我几乎可以说列表是这门语言里最容易被低估、也最容易写错的数据结构。写爬虫时要攒网页链接清洗数据时要分批处理字段写自动化脚本时要收集文件路径列表几乎无处不在。很多人学Python的第一步就是接触方括号但真正到了项目里列表的切片、拷贝、排序、推导式每一环都藏着讲究。这篇文章不打算做成教科书式的知识点罗列而是把我这些年写Python时踩过的坑、验证过的经验围绕列表的创建、索引切片、增删改查、遍历推导、排序性能等核心操作掰开揉碎讲一遍。无论你是刚入门的新手还是写了好几年脚本想回头补补底子的老手都能从中拿到点能直接用的东西。1. 列表的创建与基础认知先搞清楚原材料1.1 从方括号到list()几种创建姿势最直接的当然是lst [1, 2, 3]这种字面量写法解释器看到方括号就会直接创建一个列表对象速度快、可读性也最好。另一种常见姿势是用list()构造函数比如要把一个元组、字符串或者字典的键转成列表时会用到。这里有个很多人一开始不太注意的点list()可以接收任意可迭代对象所以list(abc)得到的是[a, b, c]而不是[abc]。如果你想的是后者那得写成[abc]或者用abc.split()。我还见过有人用list(range(10))快速生成一个连续整数列表这在实际开发里非常顺手。要注意range是惰性对象直接打印它不会输出一串数字只有被list()消费掉才会真正展开成列表。另一种创建方式是通过乘法重复列表比如[0] * 5得到[0, 0, 0, 0, 0]用来初始化一个定长列表很省事。但这个操作有个大坑如果列表里装的是可变对象比如[[]] * 3你会得到三个指向同一个空列表的引用改其中一个全变。这个后面讲拷贝的时候再细说。从阅读源码的习惯来说创建列表我基本只认两种能用字面量解决绝不用list()因为字面量最直观需要从已有对象转换时才用构造器。你要是看到有人写a list()多半是Java或者C#转过来的Python里直接a []就行了性能上也更优。1.2 同构与异构列表到底能装什么列表最大的特点是可以装任意类型整数、字符串、字典、函数、类实例、甚至另一个列表统统可以塞进去。所以有人说Python列表是“异构容器”这句话既对也不对。因为它确实能装不同类型但在真实项目里一个列表最好只装一种类型的数据。这不是语法限制而是为了你的 sanity。举个例子假设你从Excel里读了一列数据有的单元格是数字有的被读成了字符串。列表本身不报错但当你后面执行sum(data)或者max(data)时Python立刻给你抛个TypeError。我在数据处理脚本里经常遇到这种情况后来学乖了读进来之后先做一次类型规整确保列表里所有元素都是可比较的同类。使用python做数据分析时这个习惯能替你省掉一大半“为什么运行到一半挂了”的问题。另外要理解列表的存储本质。列表里存的不是对象本身而是对象的引用。这意味着a [1, 2]然后b a其实 b 和 a 指向的是同一个列表改哪个另一个都会变。很多新手在这里栽跟头不是因为笨而是因为没意识到“等号只是复制了引用”。记住这一点后面理解浅拷贝、深拷贝、append 嵌套列表的问题就顺理成章了。2. 索引与切片基本功决定你能走多远2.1 正负索引与“左闭右开”的切片规则索引是列表操作的地基。正向索引从0开始这大家都知道但很多人对负索引不够敏感或者用起来不自信。lst[-1]是最后一个元素lst[-2]是倒数第二个这个规则其实非常优雅尤其是从右往左取数据的时候远比写lst[len(lst) - 1]干净。我写代码时有个习惯凡是取末尾元素一律用负数因为可读性好而且不会犯“循环边界差一”的错误。切片则是列表操作里最常见的进阶技能语法是lst[start:stop:step]。这里最关键的是左闭右开start 位置的元素会包含stop 位置的元素不包含。比如lst[1:3]在[0, 1, 2, 3, 4, 5]上取到的是[1, 2]没有3。这个设计初看反直觉但仔细想其实是为了让多个切片的拼接不重叠、不丢元素。例如lst[:2]是前两个lst[2:]是从第二个下标开始两者拼起来正好是完整列表没有任何一个元素被重复或漏掉。切片的 start 和 stop 都可以省略且允许越界。这意味着lst[2:100]不会报错只会取到尾部为止。这个特性在不知道列表长度时特别有用比如处理长度不确定的文本行时line[5:]随便取永远安全。负数索引和切片结合时lst[-3:]取最后三个lst[:-1]取除最后一个外的所有元素这两个是我写代码时的高频片段。2.2 步长、反转与切片拷贝的底层逻辑切片还有第三个参数 step步长。lst[::2]隔一个取一个lst[1::2]从第二个开始隔一个取一个这两个组合起来能轻松完成奇偶位分离。步长也可以是负数lst[::-1]就是反转列表。很多教程会说“反转列表用 reverse 方法”但如果你不想原地修改原列表而是想产生一个新的反转结果lst[::-1]是更符合 Python 风格的做法后面讲排序和反转时我会再对比。关于切片的一个关键认知切片操作返回的是一个新列表本质上是把原列表的一部分引用复制到新的容器里。这里的“复制”是浅层的意味着你得到了一个新的外层列表但里面的元素引用没有变。比如a [[1, 2], [3, 4]]执行b a[:]后b 和 a 的外层确实是两个不同列表但 b[0] 和 a[0] 指向的还是同一个内层列表。修改 b[0][0] 会影响 a[0][0]。初学者经常在这里踩坑误以为“我都切片拷贝了怎么还会传染”。这个问题背后的内存模型我建议每个写Python的人都至少花半小时认真推演一遍。切片的时间复杂度是 O(k)k 是切片的长度。也就是说你把一个100万元素的列表全部切片拷贝一份内存会直接翻倍。在某些高性能场景下这可能拖垮程序。所以操作超大列表时能用索引安全地查看就用索引不要动不动就切片生成新列表除非你真的需要那份副本。3. 增删改查列表操作的原子能力3.1 append与extend追加与扩展别用混列表最常见的操作之一就是往里面加元素。append(x)把 x 作为单个元素添加到末尾哪怕 x 本身是个列表它也会作为整体塞进去这导致lst.append([1, 2])之后得到的是一个嵌套列表。而extend(iterable)则是把一个可迭代对象里的每个元素逐个展开、追加到列表末尾因此lst.extend([1, 2])是往原列表里加两个整数。这两者的区别我在给新人讲的时候常用一个类比append 就像把一盒鸡蛋直接放进冰箱冰箱里多了一盒鸡蛋extend 则像把鸡蛋一个个拿出来放进原本的空格里冰箱里多了两个鸡蛋的位置。你用错一次跑出来的数据结构和心理预期的偏差能让你调试到怀疑人生。而且这种错误编译器不会报因为列表允许嵌套程序往往能继续跑很久直到你在某个环节对一个嵌套元素做了“不是列表”的操作才突然崩溃。运算符对列表来说等价于extend不是append。比如lst [4, 5]之后lst 变成了原列表加两个元素而不是原列表尾部多出一个列表对象。这一点区别于元组元组是不可变对象t (1, 2)是生成一个全新元组并重新绑定变量名。列表的是在原地修改的因为列表实现了__iadd__方法。理解这个区别有助于你解释为什么列表可以用而某些其他序列不行。3.2 insert、pop、remove与del改删操作的时间账insert 可以在任意位置插入元素语法是lst.insert(index, item)。但 insert 不是没有代价的它需要把 index 之后的所有元素整体向后挪一位时间复杂度是 O(n)。如果你在一个非常大的列表头部频繁 insert性能会非常难看。碰到这种需求我建议你换个思路要么用collections.deque这种双向队列它支持两头 O(1) 的插入弹出要么干脆倒序构建最后再reverse一次。别等到程序卡成幻灯片了才想起来这件事。pop 是最常用的弹出方法lst.pop()删除并返回最后一个元素时间复杂度 O(1)lst.pop(index)则删除并返回指定位置元素它同样需要把后面的元素往前挪复杂度 O(n)。remove 是按值删除lst.remove(item)会删除第一个匹配项如果找不到指定值会抛出ValueError。注意 remove 只删第一个不是把所有匹配值都删光。如果你要清掉所有相同的元素要么用循环配合try/except删除要么用后面要讲的列表推导式重新构建列表那种方式更 Pythonic。del 则是一个语句不是方法你没法对它做链式调用。del lst[0]按索引删除del lst[1:4]删除切片范围内的所有元素del lst则直接删除整个变量。在实际工程中del 配合切片删除的效率比单元素逐个删要高出很多因为切片删除底层是一次性批量移动内存逻辑上更紧凑。3.3 in、index、count查找与统计的正确姿势判断元素在不在列表里最直观的就是item in lst。但这个操作的时间复杂度是 O(n)因为列表是连续内存结构没有索引加速。如果你需要频繁做成员判断而且列表元素又很多务必要把列表转成 set 来查item in set(lst)的时间复杂度降到 O(1)。这是一个成本极低、收益极高的优化我在写推荐算法候选过滤时经常用这招原本几秒的循环立刻变毫秒级。index 方法用于查找元素第一次出现的位置lst.index(item)找不到时抛ValueError。count 方法则统计某个元素在列表中出现的次数。这两个方法都属于线性扫描时间复杂度都是 O(n)。如果列表非常大且你频繁要 count可以考虑用collections.Counter预先统计好所有元素频次然后一次查询 O(1)。这个思路在文本分析、日志统计场景下会非常省时间。另外说一个实际经验item in lst和lst.index(item)都存在一个隐患就是如果 item 是一个需要复杂__eq__比较的对象每次比较的代价可能不低。比如列表里存的是自定义类实例而该类的相等判断逻辑很重那么线性查找的耗时会成倍增加。这种情况下我通常会说先想清楚你到底需不需要自定义相等或者把查找键单独抽出来映射到索引上别让“查个元素”变成全表扫描。4. 遍历与推导式让代码像说话一样自然4.1 列表推导式一行顶三行的魔力列表推导式大概是 Python 里最能体现“Pythonic”的语法之一它的结构是[expression for item in iterable if condition]。一个典型例子想得到0到9之间所有偶数的平方用传统写法要三行用推导式只需要一行[x ** 2 for x in range(10) if x % 2 0]。这个写法读起来几乎和英语句子一样顺畅这也是为什么我说“像说话一样自然”。推导式背后的逻辑是先遍历可迭代对象逐个元素执行筛选条件再对通过筛选的元素做表达式运算最后收集成一个列表。它的本质是一次遍历只是把常用模式封装成了语法糖。在实际项目里我用推导式做数据清洗的频率非常高。比如从一个包含脏数据的列表里过滤掉空字符串和 None一行clean [x for x in raw if x is not None and x ! ]就能解决。对比用 for 循环加 append 的写法推导式不仅短而且因为少了额外的函数调用和边界变量执行速度通常也更快。当推导式里嵌套推导式时要特别注意可读性。比如矩阵转置可以用[[row[i] for row in matrix] for i in range(4)]这种结构但是嵌套层数一多读起来就很费劲。我的原则是嵌套超过两层就别硬用推导式拆成普通循环加注释反而更好维护。毕竟代码是写给人看的不是用来显摆一行流技巧的。Python 3.8 以后推导式里还可以使用海象运算符实现“先计算、后筛选”的效果。比如[y for x in data if (y : x * 2) 10]这个写法里变量 y 在 if 条件中被赋值同时又能在外层表达式中被引用。这个特性很实用但我不建议新手一开始就用因为它的可读性偏低而且在不同 Python 版本间兼容性有问题。如果你想在主流的python环境里保持代码好移植海象运算符最好只在需要性能又不想写两遍表达式时用。4.2 enumerate与zip遍历时的黄金搭档遍历列表最基础的写法是for item in lst但如果你需要知道当前下标很多新手会写成这样for i in range(len(lst)): print(i, lst[i])这个写法不能说错只是不够优雅。Python 提供了enumerate它同时产出下标和元素写法变成for i, item in enumerate(lst): print(i, item)enumerate还支持start参数比如从1开始编号时写enumerate(lst, start1)。我在写输出报告、生成序号列表时这个 start 参数特别实用不用每次手写i 1。zip则是用来并行遍历多个列表的利器。比如你有两个列表一个存姓名一个存成绩想配对打印传统写法是for i in range(len(names)): print(names[i], scores[i])。用zip只需要for name, score in zip(names, scores): print(name, score)zip会按最短列表的长度截断这是个很重要特性。如果你的两个列表长度不一致而你想让缺失位置补默认值就要用itertools.zip_longest。我曾经在一次数据处理脚本里因为没注意到两个列表长度不同导致最后几行数据被静默丢弃排查了很久才发现是zip的“截断原则”在起作用。另外zip生成的并不直接是列表而是一个迭代器。如果需要列表形式可以用list(zip(a, b))转换它会把每组对应的元素打包成元组。这在把多个平行列表组合成“列对齐”的二维结构时非常顺手比如转置矩阵或者构造表格数据。4.3 循环中修改列表一个经典的运行时报错在遍历列表的同时直接修改列表这是一个非常经典的坑。很多时候你会想删掉某些满足条件的元素于是自然而然地写了for x in lst: if x 0: lst.remove(x)表面上看没毛病但运行时你会发现总有漏网之鱼。原因是 for 循环在遍历时是按下标推进的每删除一个元素后面的元素就会往前移动一位而循环计数器并不知道这件事于是跳过了原来紧接着的下一个元素。我见过很多人在这个坑里反复挣扎甚至开始怀疑 Python 的 remove 是不是有 bug。解决方案其实很多。最简单的是遍历原列表的副本for x in lst[:]:这样删除操作针对原列表而遍历仍然基于未变化的副本不会漏项。另一种做法是先用推导式筛选出要保留的元素然后赋值回去lst [x for x in lst if x 0]。这种做法我个人最推荐它是函数式思维不修改正在遍历的容器逻辑最清晰。还有一个更底层的控制方式是用 while 循环自己维护下标。比如i 0 while i len(lst): if lst[i] 0: lst.pop(i) else: i 1注意删元素时下标不能自增因为后面的元素已经顶上来了。这种写法能找到 bug 就说明你对列表的内存结构理解已经很扎实了。实际项目中如果数据量不大我一般直接用推导式重建列表数据量大且有复杂的删除条件时则会把“需要保留”的索引先搜集出来再一次性重建这样最稳。5. 排序、反转与进阶玩法把列表用出体系感5.1 sort与sorted原地排序与副本排序怎么选列表有两个排序入口一个是lst.sort()一个是内置函数sorted(lst)。它们的核心区别在于是不是原地修改sort()直接在原列表上排返回Nonesorted()返回一个新的排好序的列表原列表保持原样。按需选择但有一点必须重视如果你还希望保留原始顺序做后续处理千万别用sort()它一旦执行无法撤销。我自己的选择标准很简单如果这个列表是在函数内部临时构造的而且后续不再需要原始顺序就用lst.sort()因为它省一次内存分配。如果这是一份业务数据需要保留原顺序或者这个列表来自外部函数作为参数传入那坚决用sorted()返回一个新列表原数据不动。这背后是函数式编程“不修改共享状态”的原则。还有一个容易被忽略的点sort()是列表独有的方法而sorted()可以对任意可迭代对象排序比如元组、字典的键、生成器。所以你没法t (3, 1, 2); t.sort()但可以sorted(t)。在写通用工具函数时我会优先考虑sorted()因为这样函数可以接受多种容器而不必担心内部是否有 sort 方法。5.2 key参数按规则排序的完整案例sort()和sorted()都接受一个key参数这是排序功能里最强大也最常用的部分。key接收一个函数这个函数会在排序前被应用到每个元素上然后按函数的返回值大小来排。比如想按字符串长度排序直接sorted(words, keylen)就行不需要你写 lambda 再返回len(x)因为len本身就是接收单个参数、返回整数的函数天然匹配key的签名。更常见的场景是按字典的某个键排序。假设你有一个学生列表每个学生是{name: 小明, score: 88}这样的字典想按分数从高到低排sorted(students, keylambda s: s[score], reverseTrue)lambda 在这里很方便但它不够通用可读性也因人而异。如果排序规则比较复杂我更倾向于提前定义一个命名函数或者用operator.itemgetter。from operator import itemgetter之后sorted(students, keyitemgetter(score))和上面的 lambda 等价但执行效率更高、写法更简洁这也是operator模块存在的意义。关于 Python 排序还有一个保证是稳定性。Python 的排序算法是 Timsort稳定排序意味着如果两个元素的 key 相等它们在原列表中的相对顺序会被保留。这个特性非常有用你可以先按次要条件排一遍再按主要条件排一遍最终结果会在主条件相同的情况下保持按次条件有序。比如先用 score 排序再用 name 排序name相同的人之间 score 仍然是从大到小这种“复合排序”就是在实战里利用稳定性的典型手法。5.3 组合拳拆包、去重、拼接与类型转换列表的进阶玩法往往是把多个操作组合起来。先说拆包给定lst [1, 2, 3]可以直接a, b, c lst分别拿到三个元素。这个写法在做多返回值接收、交换变量时特别好用。如果列表长度不固定还可以用带星号的拆包first, *middle, last lst这样 middle 会自动收集中间所有元素即使你完全不知道长度也能稳定拆包。这个特性在解析日志行、分割配置项时非常常见。去重是最常被问到的需求。如果不在乎元素顺序用set(lst)一转换就完事但很多人需要保留原始顺序去重。最简单的做法是list(dict.fromkeys(lst))利用 dict 的键唯一性同时保留第一次出现的顺序。这个方法从 Python 3.7 起依托字典的有序性保证是可靠且高效的。如果不想依赖字典也可以手工维护一个seen集合result [] seen set() for x in lst: if x not in seen: seen.add(x) result.append(x)这个写法逻辑更直白适合在代码里被别人读的时候一眼就能理解。拼接列表有多种方式。lst1 lst2会生成一个新列表原列表不变lst1.extend(lst2)是原地把 lst2 展开追加到 lst1 尾部[*lst1, *lst2]则用解包语法创建新列表在 Python 3.5 可用。如果你要把字符串列表拼接成一个总体字符串一定要用.join(lst)而不是在循环里不停用拼接因为后者每次都会新建字符串对象时间复杂度是 O(n^2)数据量大时慢到怀疑人生。6. 避坑指南与性能优化老手的实操清单6.1 浅拷贝与深拷贝嵌套列表的“引用”陷阱前面我已经多次提到浅拷贝和引用现在统一讲清楚。把一个列表赋值给另一个变量本质是复制引用两者指向同一个对象。要做到克隆出一个独立但内容相同的列表有三种常见手段lst.copy()、lst[:]、list(lst)。这三者在效果上基本等价都是浅拷贝也就是外层列表是新对象但里面的元素引用依然共享。浅拷贝绝大多数时候够用因为列表元素多半是字符串、数字这类不可变对象修改它们本来就会替换引用不会影响原列表。但一旦元素本身是可变对象如嵌套列表、字典浅拷贝就会出问题。举例来说matrix [[1, 2], [3, 4]] copy_matrix matrix.copy() copy_matrix[0][0] 99执行完你会发现matrix[0][0]也变成了99。这就是“浅”的含义只复制外层容器不深挖内部对象。要真正彻底复制一个嵌套结构需要用到copy.deepcopy()。它的原理是递归地复制所有内部对象并且内部维护一个 memo 字典来避免循环引用和重复复制。代价是 deepcopy 比较慢占用内存也高。所以我的使用原则是列表只有一层且元素不可变就用切片或copy()确认存在嵌套可变对象且必须独立操作时才用deepcopy()。不要盲目地用 deepcopy 复制大列表很多 Python 程序变慢不是因为算法而是因为毫无必要的深度拷贝。6.2 可变默认参数与Python 3.8后的新写法Python 里有个著名的反模式可变对象作为函数默认参数。看这段代码def add_item(item, lst[]): lst.append(item) return lst第一次调用add_item(1)返回[1]第二次调用add_item(2)你可能会以为返回[2]但实际上返回的是[1, 2]。原因是函数定义时lst[]这个空列表只在定义那一刻被创建一次之后每次调用如果不传第二个参数用的都是同一个列表对象。这是 Python 的“隐式共享状态”也是面试题里常考的点。解决方式很简单默认参数写成Nonedef add_item(item, lstNone): if lst is None: lst [] lst.append(item) return lst这样每次不传lst时都会创建一个全新的空列表行为符合直觉。我在设计工具函数时凡是需要“可选容器参数”的一律遵循这个惯例避免将来被调用方莫名踩坑。关于 Python 3.8顺带提一个新特性海象运算符和位置参数语法。你说不定看过别人用:写的一行筛选代码比如验证密码强度或做缓存判断。在列表操作场景里它能在推导式中一次计算、多次复用结果减少重复运算。但这类代码对比较旧的 Python 环境不友好如果你的项目还有 Python 3.7 兼容需求我建议先别用等满环境都是 3.8 之后再尝鲜也不迟。6.3 性能优化从遍历到拼接的几条经验列表性能优化这件事我总结下来就几条实在经验。第一优先用列表推导式而不是 for 循环加 append。推导式底层对迭代细节做了优化而且不需要频繁调用 append 方法每次 append 都有属性查找和方法调用的开销。实测在百万级数据量下推导式通常比传统循环快 10% 到 20%。如果你用map/filter搭配内置函数可能还会更快一点但可读性就因人而异了。第二字符串拼接别用循环。在一个列表里攒了一堆字符串片段如果循环里s x因为字符串是不可变对象每次都是新建字符串再拷贝旧内容总体是 O(n^2)。正确的做法是先收集所有片段最后.join(parts)一次完成。这个优化我在处理大日志文件时感受特别深差距是肉眼可见的卡顿和流畅的区别。第三需要频繁查找成员时把列表转成集合。item in lst是 O(n)item in set是 O(1)。如果你有个大型列表并且要对其中大量元素反复做存在性判断不要犹豫先s set(lst)然后所有判断都对着 s 来。这个优化几乎是白捡来的但要注意如果列表里有不可哈希的对象比如嵌套列表或字典就无法直接转成集合需要先把元素转成可哈希形式。第四适度使用map、filter、reduce这类函数式工具。它们并非天生比推导式快但在配合内置函数和生成器时可以减少中间列表的产生降低内存占用。数据量不大时无所谓数据量大了内存就是性能瓶颈。我用map去批量清洗字符串时生成的是迭代器而不是完整列表可以边消费边释放这个特性在流式处理的场景下价值很高。最后再分享一个我在实际项目中的体会列表的很多坑归根结底是对“引用”这个底层概念理解不够。哪怕你把今天提到的所有方法和语法都背下来遇到嵌套列表、循环修改、浅拷贝这些问题时依旧容易翻车。所以我的建议很朴素动手写代码之前先在草稿纸上画出变量名指向对象的箭头图把引用关系捋顺了再动手。带过很多新人之后我发现凡是列表操作写得很稳的人脑子里基本都有这样一张清晰的“指针地图”。只要把这个习惯保持下去列表相关的绝大多数问题都不会再找上你。
阅读完成 · 觉得有帮助?