ARTICLE DETAIL

资讯详情

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

Python列表操作实用清单:切片、排序与深浅拷贝

Python列表操作实用清单:切片、排序与深浅拷贝 不用怀疑列表就是Python里使用频率最高的数据结构没有之一。我见过太多人写了好几年Python还是只会append和for i in list遇到切片边界、深浅拷贝、排序稳定性这些问题就懵。这篇东西不是官方文档的复述是我自己这些年写爬虫、写自动化脚本、写数据处理时从实际代码里提炼出来的列表操作清单。适合刚入门想系统过一遍的人也适合写了段时间代码但总在某些细节上犯嘀咕的人看完可以直接拿去用。1. 列表的基础认知为什么几乎所有Python程序都离不开它1.1 列表在Python里到底扮演什么角色先理清楚一个概念Python的列表list和很多语言里的数组不是一回事。C语言里的数组要求元素类型一致、长度固定而Python列表是动态数组你可以往里面塞任何类型的东西整数、字符串、字典、自定义对象甚至另一个列表而且长度可以随时变。这种灵活性是它成为万金油数据结构的原因。# 一个列表里可以混着放各种类型 mixed [1, hello, 3.14, [1, 2], {name: python}]正因为这个特性Python里很多操作天然就围绕列表展开你split一个字符串得到的是列表你json.loads解析数组得到的是列表你用glob匹配文件名返回的也是列表。可以说掌握了列表操作就掌握了Python数据处理的一半基础。1.2 创建列表的几种方式以及各自的适用场景大多数人创建列表就是一对中括号但实际上有几种常见方式不同场景选对了会顺手很多。# 方式一直接字面量 a [1, 2, 3] # 方式二list() 构造适合把其他可迭代对象转成列表 b list(hello) # [h, e, l, l, o] c list(range(5)) # [0, 1, 2, 3, 4] # 方式三列表推导式适合基于已有序列生成新列表 squares [x**2 for x in range(10)] # 方式四乘法复制适合初始化固定长度的占位列表 zeros [0] * 5 # [0, 0, 0, 0, 0]这里要提醒一下用乘法复制时如果元素是可变对象比如列表、字典会踩一个大坑后面我会专门讲。初学者最容易犯的错误是以为[0] * 3生成了3个独立的空间实际它只是把同一个0引用了3次对不可变对象没问题但对可变对象就麻烦了。1.3 列表和元组、集合、字典的本质区别很多新手总搞不清这四种容器到底怎么选我用自己的理解说列表是可变的、有序的、可重复的适合存储有顺序且会变的数据元组是不可变的、有序的适合存储固定不变的数据还能当字典的键集合是可变的无序集合自动去重适合做成员判断和数学运算字典是键值对映射适合按名字取值的场景判断标准很简单需要保持元素顺序就选列表或元组只需要判断在不在就选集合需要按某个键快速找值就选字典。这个选择影响的不只是代码可读性更是性能。比如频繁用in判断成员列表是O(n)遍历集合是O(1)哈希查找数据量一大差距非常明显。2. 增删改查全套操作从append到del的边界与细节2.1 增append、extend、insert三兄弟的区别日常写代码往列表里加元素最常见的就是这三个方法但很多人其实分不清append和extend到底差在哪。a [1, 2, 3] # append: 把参数当作一个元素整体加进去 a.append([4, 5]) # 结果: [1, 2, 3, [4, 5]]列表长度是4 b [1, 2, 3] # extend: 把参数里的每个元素依次加进去 b.extend([4, 5]) # 结果: [1, 2, 3, 4, 5]列表长度是5我见过一个真实事故有同事想把两个列表合并用append做循环结果生成了一个二维嵌套列表后续遍历逻辑全乱了。记住一句话append加一个箱子extend是把箱子里的东西一个个拿出来放进去。insert则是在指定位置插入元素注意它的效率问题插入位置越靠前后续所有元素的索引都要往后挪所以insert(0, x)是O(n)操作大量使用会明显变慢。如果只往尾部添加元素永远用append。a [1, 2, 3] a.insert(1, 插入) # 结果: [1, 插入, 2, 3]2.2 删remove、pop、del、clear各自的用法和坑删元素的方法有四个很多人只知道pop()能弹出一个其实按场景选才能写干净代码remove(x)按值删除删除列表中第一个等于x的元素。如果不存在直接抛ValueErrorpop(index)按下标删除并返回被删的值。不传参数时默认删除最后一个del list[index]按下标删除不返回被删的值也支持切片删除clear()清空整个列表最容易出问题的场景是循环中删除元素。# 错误示范删除所有偶数 nums [1, 2, 3, 4, 5, 6] for num in nums: if num % 2 0: nums.remove(num) print(nums) # 你以为得到 [1, 3, 5]实际得到 [1, 3, 5]有时候是有时候不是。 # 更典型的错误 nums [1, 2, 3, 4, 5, 6] for i in range(len(nums)): if nums[i] % 2 0: del nums[i] # 这100%会报 IndexError 或者漏删原因是遍历过程中列表长度和索引同时变化迭代器按原索引走删掉一个元素后后续元素整体前移有些元素就被跳过了。正确做法是倒着遍历删除或者创建新列表用推导式过滤。nums [1, 2, 3, 4, 5, 6] # 方法一倒着删除 for i in range(len(nums) - 1, -1, -1): if nums[i] % 2 0: del nums[i] # 方法二推导式生成新列表推荐 nums [x for x in nums if x % 2 ! 0]2.3 查index、count、in的搭配技巧查找元素用in判断存在性用index获取下标用count统计出现次数。这里有个容易被忽略的点index找不到元素会抛ValueError所以要么先判断再取下标要么用try包住。items [a, b, c, b] if b in items: idx items.index(b) # 1只返回第一个 n items.count(b) # 2 # 从指定位置开始查找 idx items.index(b, 2) # 从下标2开始找结果是3另一个实践里比较有用的点index和count本质都是遍历频繁调用时如果列表很大要注意性能。如果既要存在性判断又要频繁按下标访问考虑转换成字典结构来提速。2.4 改下标赋值和切片赋值改元素最直接的写法就是list[index] new_value这个谁都会。但很多人不知道切片赋值这个特性它可以用一个列表替换另一个列表的一段切片长度不要求相等非常灵活。a [1, 2, 3, 4, 5] a[1:3] [20, 30, 40] # 结果: [1, 20, 30, 40, 4, 5] a [1, 2, 3, 4, 5] a[1:3] [] # 结果: [1, 4, 5]相当于删掉了第1、2个元素我之前写配置解析脚本时用切片赋值做数据重组几行代码就搞定了原本要写循环的逻辑。不过要注意切片赋值右边的必须也是可迭代对象如果写成a[1:3] 10会直接报错。3. 切片与排序高频操作背后的几个易错点3.1 切片完整语法start、stop、step的边界规则切片是Python非常优雅的特性也是新手最容易犯迷糊的地方。完整的切片语法是list[start:stop:step]它提取的是从start到stop-1的元素左闭右开。这个规则是理解所有切片问题的钥匙。a [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] a[2:5] # [2, 3, 4]注意不包含下标5 a[:4] # [0, 1, 2, 3]start默认0 a[6:] # [6, 7, 8, 9]stop默认到最后 a[::2] # [0, 2, 4, 6, 8]隔一个取一个 a[::-1] # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]经典反转 a[-3:] # [7, 8, 9]负索引从尾部数 a[:-3] # [0, 1, 2, 3, 4, 5, 6]去掉最后3个几个容易搞错的点step为负数时切片方向是反的a[::-1]是最常用的反转手段不会修改原列表a[5:2]返回的空列表因为start在stop的右边没有重叠区间a[5:2:-1]返回[5, 4, 3]因为负步长是从右往左走3.2 切片是浅拷贝修改切片不会动原列表但要小心嵌套很多人以为切片只是取出一部分数据实际上切片产生的是新的列表对新列表的增删改不会影响原列表。这点在函数传参时特别好用可以安全地传list[:]或者list[1:]进去不用担心函数内部把原数据改了。original [1, 2, 3, 4, 5] sliced original[2:] sliced.append(99) print(original) # [1, 2, 3, 4, 5]不受影响 print(sliced) # [3, 4, 5, 99]但是切片只是浅拷贝。如果列表里装的是可变对象如字典、子列表切片后的新列表里存的还是同一份对象的引用。a [[1, 2], [3, 4]] b a[:] b[0].append(99) print(a) # [[1, 2, 99], [3, 4]]a也变了这时候就需要用copy.deepcopy才能实现真正独立的拷贝。我处理嵌套配置数据时踩过这个坑排查了半天才发现是浅拷贝在背后搞鬼。3.3 sort和sorted就地排序与返回新列表的差异sorted(list)对列表排序并返回新列表原列表不变list.sort()是原地排序直接修改原列表返回None。这个区别会带来某些隐蔽bug尤其是把sort的返回值赋给变量时——你得到的其实是None。nums [3, 1, 4, 1, 5] # sorted 返回新列表 a sorted(nums) # a [1, 1, 3, 4, 5]nums不变 # sort 原地修改 result nums.sort() # nums变成[1, 1, 3, 4, 5]但result是None再往深一层key参数非常强大它可以传入一个函数用函数的返回值作为排序依据。比如按字符串长度排序、按字典的某个键排序、按元组的第二个元素排序words [python, list, a, operations] words.sort(keylen) # 按长度升序 people [{name: bob, age: 30}, {name: alice, age: 25}] people.sort(keylambda p: p[age]) # 按age排序 pairs [(1, 2), (3, 0), (2, 5)] pairs.sort(keylambda p: p[1]) # 按第二个元素排序关于排序稳定性Python的排序是稳定的意思是当两个元素的key值相等时它们原来的相对顺序不变。这在实际中很有用比如你先按姓名排再按成绩排最终结果就是成绩相同的人内部按姓名排。records [(bob, 80), (alice, 80), (eve, 90)] records.sort(keylambda r: r[0]) # 先按名字 records.sort(keylambda r: r[1]) # 再按成绩 # 结果: [(alice, 80), (bob, 80), (eve, 90)]成绩相同的按名字保持排好3.4 reverse反转列表的两种方式反转列表通常用list.reverse()原地反转或者list[::-1]返回新列表。原地反转省内存但丢了原序新列表则反之。另外不要忘了reversed(list)返回的是迭代器需要转换才能用于索引访问。a [1, 2, 3] a.reverse() # a变成[3, 2, 1] b [1, 2, 3] c list(reversed(b)) # c [3, 2, 1]b不变4. 列表推导式让代码从啰嗦变简洁的进阶路径4.1 从for循环到推导式的思维转变刚接触列表推导式时很多人觉得它不直观、难读但用熟了之后你会发现它其实更贴合描述结果而非描述过程的思维方式。看一个对比# 传统for循环 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 变量 in 可迭代对象 if 条件]。翻译成人话就是针对可迭代对象里每个满足条件的元素计算表达式收集成列表。这种写法不仅简洁在多数情况下性能也更好因为循环在C层面执行减少了Python字节码开销。4.2 带if条件、嵌套循环、字典/集合推导式的进阶用法推导式不止能用在列表上字典和集合也有对应的推导式。而且推导式里可以嵌套循环处理二维数组时特别方便。# 带条件筛选 evens [x for x in range(20) if x % 2 0] # 嵌套循环生成笛卡尔积 points [(x, y) for x in range(3) for y in range(3)] # 结果: [(0,0), (0,1), (0,2), (1,0), (1,1), ...] # 嵌套循环条件 matrix [[1, 2, 3], [4, 5, 6], [7, 8, 9]] flat [num for row in matrix for num in row if num % 2 1] # 结果: [1, 3, 5, 7, 9] # 字典推导式 words [apple, banana, cherry] length_map {word: len(word) for word in words} # {apple: 5, banana: 6, cherry: 6} # 集合推导式自动去重 nums [1, 2, 2, 3, 3, 3] unique {x for x in nums} # {1, 2, 3}嵌套推导式的书写顺序要注意for的嵌套顺序和普通循环一致外层循环写在前面。一开始写反了的话跑出来的结果维度就对不上。我的建议是嵌套超过两层就别硬写推导式了可读性会严重下降不如乖乖拆开写循环。4.3 推导式性能什么时候用、什么时候别硬上刚才说推导式性能好但也要看场景。用timeit测一下就知道推导式的性能优势主要体现在数据量大、计算逻辑简单的场景。逻辑复杂比如需要try/except、需要状态变量、需要依赖前一个结果时硬写成推导式既难懂又没快多少。另外生成器表达式是另一个常用替代方案它在遍历时一次只生成一个值不占内存适合处理超大数据。# 列表推导式一次性生成全部 squares [x*x for x in range(1000000)] # 生成器表达式惰性计算遍历时才产出 squares_gen (x*x for x in range(1000000))如果你只是遍历一次用生成器表达式内存开销更小如果你要多次遍历或要按下标访问就只能用列表。我在写数据处理脚本时能明显感觉到千万级数据下这个差异。5. 实战里的坑与性能从copy、到内存管理的经验总结5.1 浅拷贝与深拷贝到底什么时候该用deepcopy前面提到切片是浅拷贝这里再展开讲。浅拷贝copy.copy或[:]只复制最外层容器内部的元素还是共享引用深拷贝copy.deepcopy递归复制所有嵌套对象生成完全独立的副本。import copy original {data: [1, 2, 3], tags: [a, b]} # 浅拷贝 shallow copy.copy(original) shallow[data].append(4) # original[data] 也变成 [1, 2, 3, 4] # 深拷贝 deep copy.deepcopy(original) deep[data].append(5) # original[data] 保持 [1, 2, 3, 4]判断标准很简单列表里存的是不可变对象数字、字符串、元组浅拷贝完全够用如果嵌套了可变对象且你需要独立修改必须用深拷贝。深拷贝本身有额外开销大数据量时别滥用。5.2 和 append 的差异一个让新手指尖发抖的bug对列表来说表面上是把元素加进去但它的行为是extend而不是append。这意味着a [1, 2]会把1和2分别加进去同时它还会原地修改列表不会创建新对象。a [1] b a a [2, 3] # a [1, 2, 3]b [1, 2, 3]因为a和b指向同一个对象 # 对比a a [2, 3] 会创建新列表 a [1] b a a a [2, 3] # a [1, 2, 3]b [1]因为a指向了新的对象这个差异在函数传参时特别容易出事。如果函数内部用了list [x]外面的原列表会被改动如果用了list list [x]外面的原列表不受影响。理解了原地操作 vs 创建新对象这条主线这类问题就不会再糊涂。5.3 删除元素导致索引错位我的排查过程还原这里还原一次实战排查过程方便你理解索引错位的完整链路。之前我写一个去重脚本需求是删除列表中所有重复元素中位置靠后的那些只保留第一次出现的。我最初写的代码是这样的data [apple, banana, apple, cherry, banana] seen set() for i in range(len(data)): if data[i] in seen: del data[i] seen.add(data[i])跑起来直接报IndexError: list index out of range。原因是del data[i]之后列表长度缩短但循环的range(len(data))还是按最初长度走索引到了后面的位置就超出范围了。我当时的第一反应是在循环里动态判断长度改成range(len(data))每轮重新算结果又出现了漏删的情况因为删除元素后后续元素前移下一次循环跳过了原本紧挨着的元素。最后定下来的方案是倒序遍历删除因为删除元素只影响它之后的索引倒序删除时前面索引完全不受影响data [apple, banana, apple, cherry, banana] seen set() for i in range(len(data) - 1, -1, -1): if data[i] in seen: del data[i] seen.add(data[i]) # 结果: [apple, banana, cherry]这个案例的启示是在遍历的同时修改列表永远是危险的。如果不需要保留原列表顺序也可以先反转、正序删除后再反转或者干脆用新列表收集结果。总之效率最高的方式还是推导式或创建新列表避免原地修改的同时做遍历。5.4 内存与性能列表扩容机制、pop(0)为什么慢、join拼接Python列表底层是动态数组它不会每次append都申请一次内存而是按比例扩容通常是约1.125倍或更大。所以频繁append的摊还复杂度仍然是O(1)这点放心用。但头部操作就不一样了。pop(0)和insert(0, x)是O(n)操作因为要把所有元素整体迁移。如果你需要频繁从头部弹出用collections.deque更合适它的两端操作都是O(1)。from collections import deque queue deque([1, 2, 3]) queue.append(4) # 尾部入队 item queue.popleft() # 头部出队O(1)另一个常见性能问题是字符串拼接。在循环里用str_list.append(x)最后统一.join(str_list)比循环里反复s x高效得多。因为字符串不可变每次都会创建新字符串O(n²) 的时间复杂度在数据量大时会缓慢得让人怀疑人生。# 低效写法 s for chunk in chunks: s chunk # 高效写法 parts [] for chunk in chunks: parts.append(chunk) s .join(parts)5.5 列表作为函数参数的默认值陷阱这是一个非常经典的Python陷阱函数默认参数在定义时计算一次之后复用同一个对象。如果你在默认参数里写了def func(items[])然后函数内部对items做修改每次调用不传参的时候操作的其实是同一个列表会累积数据。# 错误示范 def add_item(item, items[]): items.append(item) return items print(add_item(a)) # [a] print(add_item(b)) # [a, b]而不是 [b] # 正确写法 def add_item(item, itemsNone): if items is None: items [] items.append(item) return items我自己就因为在写一个统计插件时用了可变默认参数导致多次调用的数据全攒在一起排查了很久才发现问题出在函数定义那一行。凡是默认参数涉及可变对象一律用None再在函数体内初始化这是行业共识。5.6 快速生成列表的几个实用技巧日常工作中有些快速生成列表的写法很省事分享几个我常用的# 生成连续整数 list(range(1, 11)) # [1, 2, ... 10] # 生成重复值 [*] * 10 # 十个星号 # 生成不重复的随机数 import random random.sample(range(100), 5) # 从0-99随机取5个不重复的数 # 打乱列表 nums list(range(10)) random.shuffle(nums) # 原地打乱 # zip打包多个列表 names [a, b, c] scores [90, 85, 88] pairs list(zip(names, scores)) # [(a, 90), (b, 85), (c, 88)] # 用enumerate同时拿索引和值 for idx, val in enumerate(names): print(idx, val)6. 列表的进阶玩法多维列表与实用模式6.1 二维列表做矩阵操作二维列表指列表里的列表处理矩阵、表格数据时会经常碰到。创建二维列表时有个经典的坑要用对方法# 错误方式三行共享同一份引用 matrix [[0] * 3] * 3 matrix[0][0] 1 # 结果: [[1, 0, 0], [1, 0, 0], [1, 0, 0]]三行全变了 # 正确方式 matrix [[0] * 3 for _ in range(3)] matrix[0][0] 1 # 结果: [[1, 0, 0], [0, 0, 0], [0, 0, 0]]这又是一个别用乘法复制可变对象的活例子。用推导式生成每一行独立的列表才不会互相影响。实际做矩阵索引时matrix[row][col]的方式最直观但要注意matrix[row]拿到的是那一行的引用如果直接用matrix[row].append改的是原列表。遍历二维列表最实用的方法是嵌套循环matrix [[1, 2, 3], [4, 5, 6], [7, 8, 9]] # 遍历所有元素 for row in matrix: for val in row: print(val) # 需要行列索引时 for i in range(len(matrix)): for j in range(len(matrix[i])): print(i, j, matrix[i][j])6.2 排序key的高级用法多字段排序、自定义类排序前面提到了key的基本用法这里补充一些实战中更复杂的场景。最常见的是多字段排序可以用keylambda x: (x[1], x[0])按第二个字段排再按第一个字段排。data [(bob, 80), (alice, 80), (eve, 90), (charlie, 80)] # 先按成绩升序成绩相同按姓名升序 data.sort(keylambda x: (x[1], x[0])) # [(alice, 80), (bob, 80), (charlie, 80), (eve, 90)] # 成绩降序姓名升序 data.sort(keylambda x: (-x[1], x[0]))如果你是给自定义对象排序可以在类里定义__lt__方法或者更简单地用operator.attrgetterfrom operator import attrgetter class Student: def __init__(self, name, score): self.name name self.score score students [Student(bob, 80), Student(alice, 90)] sorted_students sorted(students, keyattrgetter(score), reverseTrue)6.3 列表去重的几种写法与保留顺序问题去重是出镜率极高的需求。最简单的是转成集合再转回列表但顺序会乱因为集合是无序的。需要保留原顺序时有几种做法data [3, 1, 2, 3, 1, 4, 2] # 方法一不保留顺序 unique list(set(data)) # 结果顺序不固定 # 方法二保留顺序经典写法 seen set() unique [] for x in data: if x not in seen: unique.append(x) seen.add(x) # 方法三利用dict.fromkeys保留顺序Python 3.7字典有序 unique list(dict.fromkeys(data))方法三是最简洁的保留顺序去重写法利用字典键唯一且有序的特性。但如果数据量大且去重字段复杂方法二更明确容易扩展。另外如果是二维列表去重子列表不可哈希需要转成元组再走上面流程data [[1, 2], [3, 4], [1, 2], [5, 6]] unique list(dict.fromkeys(map(tuple, data))) unique [list(t) for t in unique]6.4 列表作为栈和队列使用Python列表本身可以完美当栈用append入栈、pop()出栈都是O(1)。当队列用的话append入队、pop(0)出队的话出队是O(n)数据量大改用deque。# 栈 stack [] stack.append(1) stack.append(2) stack.pop() # 2 # 队列推荐deque from collections import deque queue deque([1, 2]) queue.append(3) queue.popleft() # 1 # 双端队列两端都能操作 d deque([1, 2, 3]) d.appendleft(0) # [0, 1, 2, 3] d.append(4) # [0, 1, 2, 3, 4] d.pop() # 4 d.popleft() # 0理解了列表两端操作的复杂度差异写一些中间件、任务队列、历史记录相关的代码时就能选对数据结构不会在数据量上来后才发现性能问题。7. 总结一下我自己用列表的经验习惯写到最后分享几条我实际写代码沉淀下来的习惯。第一凡是要遍历后删除的场景优先考虑推导式生成新列表而不是在原地删除。新列表虽然多占一点内存但逻辑简单、不会漏删、不容易出边界问题性价比很高。数据量特别大、内存紧张时才考虑倒序遍历原地删除。第二列表里的元素类型尽量保持一致。虽然Python允许混合类型但混合之后sort、sum、统计类操作全都会出问题。我在清洗爬虫数据时经常先做一轮类型规整确保列表元素同一类型后面的运算才顺畅。第三使用切片而不是循环来复制列表。new_list old_list[:]一句话搞定浅拷贝比for循环append清晰得多。同时记住任何需要独立副本的嵌套结构直接用copy.deepcopy。第四时刻想着原地修改还是新对象。这个思维主线能帮你避免大量隐蔽bugsort是原地、sorted是新对象、是原地、是新对象、reverse是原地、[::-1]是新对象。想清楚这一点函数传参时列表会不会被外部改动就一目了然。列表这个东西看似基础但它是Python所有数据处理能力的基石。把上面这些操作和坑都过一遍再写代码时你会明显感觉自己对数据在手边怎么摆弄这件事更有把握了。这份清单里的每个例子我都实际跑过你可以直接复制到自己的环境里验证遇到不理解的地方回来对照着看。
返回列表