Python列表进阶操作:排序、切片、推导式与嵌套列表详解
列表这个东西可以说是Python入门阶段最实在、最常用的数据结构了。很多初学者在学完基础的变量、循环和条件判断之后碰到的第一个有点复杂但又天天用的概念就是列表搞定它后面学习字典、元组、集合都会顺很多。既然是列表2我默认你已经知道列表的基本语法中括号包着元素、下标从0开始、切片的基本用法。这一篇我们往前再走一步把列表的方法、排序、嵌套、列表推导式这些进阶但必须掌握的操作讲透顺带把我自己踩过的一些坑也交代清楚。这篇适合刚学完列表基础、想系统过一遍常用操作的读者内容不多解释原理直接给方案和细节。1. 列表增删改查入门必须拿下的基本功不管什么数据操作无非四件事加、删、改、查。列表作为最常用的容器之一这四个操作的方法也是最多的很多人一开始记不住这么多名字其实按用途分好类就清晰了。1.1 添加元素append、extend、insert三兄弟的区别这三个方法都是往列表里加东西但使用场景完全不同。append是最常用的把参数当作一个整体加到列表末尾。注意当作一个整体这六个字加一个数字就是一个数字加一个列表就是把整个列表作为末尾的一个元素。a [1, 2, 3] a.append(4) # 结果是 [1, 2, 3, 4] a.append([5, 6]) # 结果是 [1, 2, 3, 4, [5, 6]]extend则是把一个可迭代对象里的每个元素依次追加进去。如果你想把两个列表拼起来用 extend 而不是 appendb [1, 2, 3] b.extend([4, 5]) # 结果是 [1, 2, 3, 4, 5]insert可以在指定位置插入元素第一个参数是下标第二个参数是值c [1, 2, 4] c.insert(2, 3) # 结果是 [1, 2, 3, 4]这里有个实际开发中经常踩的坑如果 insert 的下标超过了列表长度元素会被追加到末尾不会报错。这在某些需要必须在第几位插入的场景下可能造成隐蔽的逻辑错误写代码的时候要留心。1.2 删除元素del、pop、remove的选择困境删除操作最容易让新手纠结因为手段多但各有各的脾气。del是按索引删也是唯一能直接删除列表片段的方式。pop按索引删但它会把删除的值返回给你当你既要删又需要那个值时pop 是首选。remove按值删只删第一个匹配到的元素而且如果要删的值不存在会直接抛出 ValueError 异常。d [10, 20, 30, 20] d.pop(1) # 删除下标1的值20返回20结果是 [10, 30, 20] d.remove(20) # 删除第一个20结果是 [10, 30]一个常见的需求是把列表里所有等于某个值的元素都删掉很多人一开始会写 for 循环加 remove然后发现删不干净。原因很简单remove 删除元素后后面的元素会往前移动一位但你循环的下标还在按原来的顺序走就会跳过一些元素。正确的做法是用列表推导式过滤或者先统计再循环删除。这个问题我后面在讲列表推导式的时候还会细说。1.3 修改直接索引赋值与切片替换修改列表元素没什么好说的list[下标] 新值是最基础的操作。稍微进阶一点的是切片替换e [1, 2, 3, 4, 5] e[1:3] [20, 30] # 结果是 [1, 20, 30, 4, 5]切片替换有个特性值得注意替换的切片长度可以和原切片长度不一致。这意味着你可以用这个特性做批量插入或者批量删除。比如f [1, 2, 3, 4, 5] f[1:2] [20] # 用单个元素替换长度一致 f[1:1] [15, 16] # 在下标1的位置插入两个元素 f[2:4] [] # 删除下标2到3的元素刚开始不熟练的时候可能觉得切片赋值这个语法有点绕但等你看了别人的代码会发现这种写法在处理批量数据的时候非常高效。2. 排序与反转sort、sorted和reverse的边界问题排序应该是列表操作里最容易出问题的一块因为很多人分不清 sort 和 sorted 的区别也不知道排序的默认规则和自定义规则怎么玩。2.1 sort 与 sorted原地操作和返回新列表nums [3, 1, 4, 1, 5] nums.sort() # nums 本身变成 [1, 1, 3, 4, 5] nums2 [3, 1, 4, 1, 5] new_nums sorted(nums2) # nums2 保持不变new_nums 是 [1, 1, 3, 4, 5]sort 是列表对象自带的方法原地排序直接修改原列表返回值是 None。sorted 是 Python 内置函数接受一个可迭代对象返回一个新的排好序的列表。这就引出一个让很多人头痛的问题为什么print(nums.sort())会打印出 None因为你拿到的返回值本来就是 None啊排序是发生在 nums 内部的nums 本身已经变了你再打印返回值自然是 None。想看到排序结果就 print(nums)而不是 print(nums.sort())。2.2 reverse参数与降序排列sort 和 sorted 都支持一个 reverse 参数默认是 False 表示升序。想降序只要传入reverseTrue即可nums [3, 1, 4, 1, 5] nums.sort(reverseTrue) # 结果是 [5, 4, 3, 1, 1]这里顺便一提列表对象还有个 reverse() 方法是反转列表顺序注意它和 sort(reverseTrue) 是两回事。reverse() 只是把元素逆序排不做排序比如[1, 3, 2].reverse()结果是[2, 3, 1]不是[3, 2, 1]。2.3 自定义排序规则key参数到底怎么用sort 和 sorted 的另一个常用参数是 key它接受一个函数这个函数会作用于每个元素返回一个排序用的值。这是排序操作里最灵活、也最考验理解的部分。words [banana, apple, cherry, date] words.sort(keylen) # 按字符串长度排序结果是 [date, apple, banana, cherry]更复杂的场景比如按列表中字典的某个字段排序students [ {name: A, score: 90}, {name: B, score: 85}, {name: C, score: 95}, ] students.sort(keylambda s: s[score], reverseTrue)用 lambda 表达式是最常见的做法本质就是告诉 sort 函数我用什么值来比大小。初学者可能觉得 lambda 看着吓人其实它就是一个匿名函数输入一个元素输出一个排序依据用多了就自然了。关于 key 还有一个常见的坑如果你的列表里是字符串和数字的混合类型直接 sort 会报 TypeError因为不同类型的比较规则不存在。解决办法就是你得想清楚自己的排序逻辑用 key 把它统一成同类型的值。3. 查找与统计index、count和in的实用细节查一个元素在不在列表里、在第几个位置、出现了几次这个需求太常见了但入门教程一般就是一笔带过。这里我展开讲讲因为细节里藏了很多坑。3.1 index方法找到一个元素的首次出现位置colors [red, green, blue, green] idx colors.index(green) # idx 为 1如果元素不存在会抛出 ValueError。很多新手在不确定元素是否存在时直接调用 index导致程序崩溃。安全的写法是这样if green in colors: idx colors.index(green) else: idx -1不过话说回来如果你想找所有的出现位置而不仅是第一次用 index 就得配合循环positions [] start 0 while True: try: i colors.index(green, start) positions.append(i) start i 1 except ValueError: breakindex 的第二个参数支持从指定下标开始搜索这一点很多人不知道但它确实是个很好用的工具。3.2 count方法统计出现次数count 就简单了统计一个值在列表中出现了多少次不存在就返回 0不会报错n colors.count(green) # 结果是 2有一个典型场景是获取列表中的众数遍历列表用 count 统计每个元素的出现次数然后找出最大值对应的元素。虽然效率一般但胜在易懂数据量小的时候完全够用。3.3 in 和 not in判断存在性的本质x in list这个操作的时间复杂度是 O(n)因为它需要逐个比较。如果只是判断是否存在这一个需求而且你的列表很大考虑转成 set 再判断会更快set 的哈希查找是 O(1)。但这个优化要看你场景和数据量别一上来就过度优化理解原理比记住结论重要。另外还有个细节in 和 index 在查找的时候用的都是相等性比较也就是运算符。对于自定义对象如果你想控制什么样算相等你需要实现对象的__eq__方法。这一点在入门阶段通常涉及不到但了解底层的比较规则后期不会那么迷茫。4. 切片再深入步长与浅拷贝的坑切片这个操作看起来很简单但里面藏着一个很重要的概念浅拷贝。4.1 步长切片的灵活使用基础切片写法是list[start:end]但完整的形式是list[start:end:step]。step 可以让你的切片跳过一些元素nums [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] evens nums[::2] # 从开头到结尾每隔一个取一个 [0, 2, 4, 6, 8] rev nums[::-1] # 步长为负数从后往前取 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]步长为负是初学者容易蒙的地方。我的记忆方法是步长为负数时start 和 end 的默认值会变成列表的末尾和开头方向是倒着取。[::-1]是反转列表最快的写法比写循环优雅多了。还有一个细节切片不会越界报错。nums[10:20]会返回空列表而不是崩溃这在很多语言里是不可想象的但在 Python 里这反而是一个特性。利用这个特性可以从容地做分段处理而不需要手动判断边界。4.2 切片复制为什么切片是浅拷贝切片返回的是一个新列表但里面的元素只是原列表元素的引用。对于数字、字符串这种不可变对象你感觉不到区别但如果列表里装的是可变对象比如子列表就会出问题matrix [[1, 2], [3, 4]] copy matrix[:] copy[0][0] 100 print(matrix) # matrix 是 [[100, 2], [3, 4]]原列表也被改了因为 copy 里的第一个元素和 matrix 里的第一个元素都指向同一个子列表。你改的是子列表内部的数据当然会传染到原列表。这就是浅拷贝的含义拷贝了容器本身但没有拷贝容器里的可变元素。如果需要真正的深拷贝也就是嵌套的列表也完全复制一份需要用 copy 模块的 deepcopy 函数import copy matrix [[1, 2], [3, 4]] deep copy.deepcopy(matrix) deep[0][0] 100 print(matrix) # 保持原样 [[1, 2], [3, 4]]这个坑很隐蔽很多人是写矩阵操作、或者用列表存对象列表时才发现程序行为怪异。提前知道这个原理调试的时候能省一大把时间。4.3 反转和排序的复制陷阱反转列表常见的写法有三种a [1, 2, 3] b a[::-1] # 返回反转的新列表a 不变 a.reverse() # 原地反转a 变成 [3, 2, 1] c sorted(a, reverseTrue) # 降序新列表a 不变a[::-1]是生成一个反转副本原列表不动a.reverse()是原地反转。排序同样是这个道理sorted(a)得到新列表a.sort()原地修改。搞清楚是否原地和是否返回新对象这两个维度列表的操作就不会再混淆了。5. 列表推导式从循环到一行代码的思维转变列表推导式是 Python 里非常能体现语言特色的语法也是很多新手觉得看着像天书、自己写不出来的地方。其实拆开来看它的结构非常固定[表达式 for 变量 in 可迭代对象 if 条件]。5.1 推导式的基本结构拆解把普通循环改写成推导式本质上就是把循环的 5 个部分重新排了一下顺序# 普通写法 result [] for i in range(10): if i % 2 0: result.append(i * i) # 推导式写法 result [i * i for i in range(10) if i % 2 0]理解它的关键就一条第一个位置是你想往结果列表里放什么后面是你从哪来取这些值、要过滤什么条件。我见过不少初学者纠结为什么表达式写在 for 前面。我的回答是你就把它想成是append 的参数位置。循环写法里append(表达式) 在 for 语句的下一行推导式里append 括号里的那个表达式就是你要写的位置。5.2 推导式的实用场景最常见的场景之一是过滤和转换组合words [hello, world, python, list] upper_words [w.upper() for w in words if len(w) 4]另一个好用的场景是嵌套循环的推导式用于生成坐标对pairs [(x, y) for x in range(3) for y in range(3) if x ! y]这个读法可以理解为外层循环写在前面内层循环写在后面顺序和普通两层循环一致。英文的 for 顺序不习惯就按从外层到内层来记基本不会错。5.3 什么时候不该用推导式推导式虽好但也不是万能的。我个人的原则是如果推导式写出来超过两行或者嵌套超过两层那就退回用普通循环。代码从来都是给维护者看的不只是给执行器跑的。一行推到底的炫技代码过两个星期你自己都可能看不懂。另外想用推导式做条件不同、表达式不同的操作时要小心# 这种需求用推导式就很别扭 result [] for x in nums: if x 0: result.append(x) else: result.append(0)虽然可以用推导表达式加条件表达式来模拟但可读性会明显变差。这种时候老老实实写循环就是对后来维护者最大的友善。6. 嵌套列表与内存引用进阶路上的第一个绊脚石列表里套列表这种结构其实前面已经接触过了因为你学的二维数组、矩阵、游戏地图、表格数据本质都是嵌套列表。但这里面的引用问题是很多学了半年 Python 的人都会迷糊的点。6.1 创建二维列表的正确方式与常见错误很多新手想创建一个 3 行 4 列的二维列表会写[[0] * 4] * 3。看起来没问题但实际上 Python 的乘法操作是重复同一个对象的引用而不是复制内容matrix [[0] * 4] * 3 matrix[0][0] 1 print(matrix) # [[1, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0]]看到了吗改一个位置三行全变了。因为那三行本来就是同一个列表对象的三个引用指向的是同一块内存。正确的初始化方式有两种一种是列表推导式matrix [[0] * 4 for _ in range(3)]另一种是老老实实写循环matrix [] for _ in range(3): matrix.append([0] * 4)每次循环都调用一次[0] * 4这个操作会创建新的列表对象所以三行互不干扰。为什么[0] * 4内部没问题呢因为 0 是不可变对象你改不了它本身。数字、字符串、元组这类不可变对象引用多少次都无所谓但列表是可变对象共享引用就会出问题。理解这一点以后写代码遇到奇怪的现象很多都能自己判断出原因了。6.2 嵌套列表的遍历与访问访问嵌套列表的元素用的是连续取下标matrix [ [1, 2, 3], [4, 5, 6], [7, 8, 9], ] matrix[1][2] # 第2行第3列结果是 6遍历嵌套列表最直白的是两层循环for row in matrix: for element in row: print(element)如果还想知道行列索引可以用 enumeratefor i, row in enumerate(matrix): for j, value in enumerate(row): print(i, j, value)这里有个值得一提的遍历技巧当你想对嵌套列表做按列操作时可以用 zip 和星号解包columns list(zip(*matrix)) # 得到每列的元组zip(*matrix)的本质是解包列表把每一行作为 zip 的参数然后按列合并成元组。这个技巧看起来有点魔法但它经常出现在真实项目的代码里值得记一下。6.3 扁平化嵌套列表把二维列表展开成一维也是高频需求。最简单的方式是双层循环flat [] for row in matrix: for item in row: flat.append(item)或者用嵌套推导式一行搞定flat [item for row in matrix for item in row]注意推导式的顺序外层循环在前面内层循环在后面这个顺序还是得上手多写几次。遇到不规则嵌套比如有的行多一个元素列表推导式一样能处理因为它只是逐个取出来并不关心行长度是否一致。7. 拷贝、比较与身份判断list操作里容易被忽略的细节到了这一节要讨论的问题更偏向原理层面两个列表怎么比大小拷贝到底拷贝了什么如何判断两个列表是不是同一个东西这些细节在写复杂逻辑时非常关键。7.1 与 is比较内容还是比较身份a [1, 2, 3] b [1, 2, 3] print(a b) # True内容相同 print(a is b) # False不是同一个对象 比较的是内容是否相等逐个元素比较。is 比较的是两个变量是否指向同一个对象内存地址是否相同。还有一个重要的注意点列表比较大小是有定义意义的。Python 里的列表和字符串一样比较是从第一个元素开始依次比较找到第一对不相等的元素谁大谁说了算。比如[5, 1] [4, 999]结果是 True因为第一个元素 5 4后面的元素都不用看了。这个特性在某些排序算法中会用到理解它有助于读懂一些奇怪的比较代码。7.2 copy方法、[:] 与赋值之间的区别三个东西长得像但效果完全不同a [1, 2, 3] b a # 赋值b 和 a 是同一个对象改一个另一个也变 c a.copy() # 浅拷贝新列表但元素引用相同 d a[:] # 切片拷贝等价于 copy刚开始接触时想区分赋值和拷贝确实有难度。我的建议是记住一句话赋值不产生新对象拷贝产生新对象新对象里的元素是否完全独立取决于元素自身是否可变以及是否用了 deepcopy。7.3 id函数用内存地址辅助理解理解列表的引用机制可以用 id 函数观察对象的内存地址a [1, 2, 3] b a print(id(a) id(b)) # True同一个对象 c a.copy() print(id(a) id(c)) # False不同对象id 返回一个整数代表对象在内存中的唯一标识。配合这个工具去理解赋值、拷贝、浅拷贝深拷贝的区别比死记硬背结论有效得多。当你对某个列表操作为什么会产生这个结果有疑惑时先打印几个 id 看看往往豁然开朗。8. 列表性能特性从应用到原理的最后一公里列表不是银弹它有它的优势和局限。了解这些写入门代码时就能避免一些无意识的性能浪费。8.1 列表的底层结构动态数组列表底层是连续内存上的动态数组每个元素存放的是指向对象的指针。它在按下标随机访问上非常快时间复杂度 O(1)在末尾添加元素上平均也是 O(1)因为预分配了额外空间但在头部插入或删除是 O(n) 的操作因为所有元素都必须后移或前移。这就解释了一个实际现象如果你需要频繁在序列开头插入数据用列表会很吃力。这时候应该考虑 collections 包里的 deque它的双端队列设计在两端操作上都是 O(1)。不过入门阶段数据量小列表完全够用知道这个原理是为了后期优化有方向。8.2 查找的时间复杂度列表的包含判断 x in list以及 index、count 操作时间复杂度都是 O(n)因为它们只支持顺序查找。如果你有频繁的查找需求而且元素是唯一的set 才是更合适的容器。这里补充一个实用的对比两个列表求交集、并集、差集用列表写循环是可以实现的但转换成 set 之后集合运算一行搞定而且数据量越大性能优势越明显。不过要注意 set 是无序的如果你对结果的顺序有要求还得额外排序这时候要权衡一下。8.3 列表与生成器的选择当你要处理大范围数据时比如 range(10**8)直接用列表推导式生成完整列表可能非常耗内存。这时候可以用生成器表达式把外层的中括号换成圆括号即可gen (x * x for x in range(10**8)) # 生成器惰性计算生成器一次只产生一个值不会一次性创建全部数据内存占用极小。不过代价是你无法随机访问元素只能按顺序迭代。知道什么时候用列表、什么时候用生成器是入门走向熟练的一个重要标志。9. 从列表到元组、set、dict数据结构的思维切换到这里得说一句实话列表很重要但不是所有场景都用列表最好。很多入门学习者容易形成列表万能的思维惯性这可以理解毕竟它是第一个学得比较深入的数据结构。但换个角度想当你开始主动考虑该不该用列表时才是真正理解它的时候。9.1 用元组还是列表可变性是唯一的判断标准元组和列表在结构和用法上几乎一致唯一的区别是元组不可变。正因为不可变元组可以作为字典的键而列表不行。另外元组因为不可变在多线程或多人协作的项目里作为常量数据传来传去不用担心被意外修改是更好的设计选择。# 字典的键不能是列表 # {[1, 2]: x} # TypeError {(1, 2): x} # 这个是合法的9.2 用set判断成员、用dict建立映射如果需求是判断元素是否存在且结果不能重复set 天然去重且查找 O(1)。如果需求是根据某个键找到对应的值dict 比在列表中线性查找高效得多。这些选择在数据量小的时候看不出差异但一旦数据量上去性能差距就是数量级的。入门阶段很多题目和练习都是用列表来实现的比如用列表模拟字典、用列表做去重意图是让你理解底层逻辑。但实际项目中能够做出正确的数据结构选择比会写复杂的列表操作要重要得多。9.3 列表的深浅拷贝在实际项目中的经典案例最后再举一个项目里真实出现过的案例。某位开发者需要维护一个最近的 N 条日志列表他会写logs [] def log(msg): for old in logs: if old msg: return logs.append(msg) if len(logs) 10: logs.pop(0)这个实现有两个问题一是判断重复用的是 O(n) 的列表查找二是 pop(0) 是 O(n) 的操作每次删除第一个元素后续所有元素都要往前挪。日志多了以后性能会明显下降。改进方案是换用 deque 来维护固定大小的队列并用 set 辅助去重。这个案例说明列表在小型、临时的数据方案里非常好用但当你意识到性能瓶颈时第一步不是优化列表操作本身的细节而是考虑换成别的数据结构。这个思维转变大约就是从会写列表到会用列表的分水岭。学列表到这里操作层面的东西其实已经差不多了。剩下的是多写、多用、踩坑、总结。我自己是在写了很多小工具之后才真正理解哪些场景用列表是顺手拈来哪些场景换一种容器会更清晰。希望你也可以带着这个思路在写代码的过程中慢慢体会。