ARTICLE DETAIL

资讯详情

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

Python列表与元组完全解析:从原理到实战避坑指南

Python列表与元组完全解析:从原理到实战避坑指南 开始之前先说一个我观察到的现象在Python社群混久了你会发现列表和元组这对孪生兄弟是新手翻车率最高的两个数据类型。明明写起来都是方括号小括号一包顶多一个能改一个不能改怎么一上项目就处处踩坑这篇文章我就把它们从头到尾拆一遍包括创建、索引、切片、增删改查、排序、去重再到怎么选型、怎么躲开那些隐蔽的坑最后用一个入门练习题把知识点串起来。不管你是刚打开Python教程的新手还是已经写过一阵子但没系统梳理过这两类序列的人都可以对照着看看还有没有盲区。1. 先搞清楚列表和元组在Python里到底是什么1.1 从有序序列这个底层概念说起很多人学列表时第一个误区就是把它当成数组然后拿C语言那套思路往上面套。实际上Python里的list比传统数组要灵活得多。它底层是一个动态数组但为了支持异构元素每个槽位存的是对象的引用所以同一个列表里可以同时放整数、字符串、甚至是另一个列表。真正需要先建立的认知是列表和元组都属于序列类型sequence。所谓序列就是元素之间有先后顺序、可以通过位置编号来访问的一类数据结构。Python里的字符串、range对象也都是序列。序列的几个通用特性——有序性、索引访问、切片、拼接、重复、成员判断——列表和元组全都继承了下来这也是为什么你经常看到两兄弟在某些用法上出奇地一致。很多新手会纠结列表是有序序列吗这个问题。答案是肯定的而且是严格有序。这里的有序不是指元素按大小排序而是指插入顺序会被记住、永远有一个确定的编号位置。比如你往列表里塞了一个苹果、一个香蕉这个先后关系就会一直保留下去除非你主动修改它。这一点和集合set有本质区别集合为了追求哈希查找效率故意不去记录顺序。1.2 可变与不可变列表和元组最本质的差异如果只让我用一句话概括两者的区别那就是列表是可变的mutable元组是不可变的immutable。可变意味着你可以对列表做增、删、改操作列表的长度和内容都可以动态变化。你往列表里append一个新元素内存地址没有变但对象内容变了。元组则恰恰相反一旦创建完毕你就不能再往里面添加元素、删除元素或者把某个位置的值改成别的值。任何这类操作都会直接抛出异常。我见过不少同学用一元硬币来理解可变与不可变列表就像现金钱包你随时可以往里面塞钱、抽钱出来元组则像一张已签字的合同条款定死之后就不能再改了。这个类比其实还算贴切但有一点要注意合同的不能改只是说纸张上的文字不能变如果合同里夹着一张活页纸这张纸上的内容还是可以换的。对应到元组就是元组里的元素如果是可变对象比如列表那这个列表内部的元素依然可以增删改。这是个非常隐蔽的细节后面我会专门展开。1.3 为什么Python同时需要两种长得像的类型既然列表这么全能为什么Python不干脆只要一个列表这个问题背后其实藏着设计哲学。不可变类型有几个天然优势。第一安全。你把一个元组传给函数或别人使用不用担心对方不小心改了你的数据如果传的是列表被改了你可能还完全不知情排查起来非常痛苦。第二可哈希。Python里只有不可变类型才能被哈希所以元组能当作字典的键、放进集合里而列表不行。第三确定性。在并发、多线程场景下不可变对象天然线程安全不会出现数据竞争问题。第四性能。元组结构更简单创建和访问的微观性能通常优于列表内存占用也更小。所以这俩不是一个能打一个不能打的关系而是各司其职。列表负责动态变化的数据集元组负责固定结构、不容篡改的数据记录。理解了这一层后面选型时就不用纠结了。2. 列表的创建、索引与切片2.1 创建列表的四种方式你常用哪一种第一种也是最直观的用方括号直接写出元素lst [1, 2, 3]第二种用list()构造函数把一个可迭代对象转成列表。字符串、元组、range、字典的键、集合都可以传进去lst1 list(hello) # [h, e, l, l, o] lst2 list(range(5)) # [0, 1, 2, 3, 4] lst3 list((1, 2, 3)) # [1, 2, 3]第三种是列表推导式这个后面专门讲先留个印象squares [x**2 for x in range(10)]第四种是创建空列表再往里面添加比如先lst []再用append()慢慢填充。这种方式在循环读数据时最常用。创建列表时有个常见坑想做一个二维列表很多人会写[[0] * 3] * 3结果发现改一个元素整列都跟着变。原因就是*操作符做的是浅复制三个子列表其实指向了同一个对象。正确写法是[[0] * 3 for _ in range(3)]每个子列表独立创建。2.2 索引正着数、倒着数都是0开头列表是有序序列访问单个元素用方括号加索引。索引从0开始这是Python和很多编程语言的通行规则。从前往后数第一个元素是lst[0]第二个是lst[1]。Python还提供了一个极其方便的负数索引机制从后往前数最后一个元素是lst[-1]倒数第二个是lst[-2]。lst [10, 20, 30, 40] print(lst[0]) # 10 print(lst[-1]) # 40负数索引的本质是len(lst) 负索引例如lst[-1]就等于lst[3]。它在处理取最后一个元素这种高频场景时非常顺手不用先算长度再用正索引。索引越界会直接抛IndexError: list index out of range这是入门阶段遇到最多的报错之一。判断一个索引是否合法核心就是看它是否落在[-len(lst), len(lst)-1]这个区间里。如果你不确定列表是否为空访问前先做条件判断或者用if lst:来判断非空列表。2.3 切片一次取一段还能跳着取切片是列表操作里的重头戏语法是lst[start:stop:step]。它的理解诀窍只有一个含头不含尾也就是取了start位置但不取stop位置。它返回的是一个新列表原列表不会受影响。lst [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(lst[2:5]) # [2, 3, 4] print(lst[:4]) # [0, 1, 2, 3]start省略默认从0开始 print(lst[4:]) # [4, 5, 6, 7, 8, 9]stop省略默认到末尾 print(lst[::2]) # [0, 2, 4, 6, 8]步长为2跳着取 print(lst[::-1]) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]步长为负数实现反转切片还支持负步长这是很多入门教材一笔带过的玩法。lst[::-1]能一行代码反转列表看起来非常优雅。再比如lst[-3:]取最后三个元素lst[:-3]取除最后三个以外的所有元素这些写法在数据处理中很常见。需要特别提醒的是切片返回新列表这个特性可以用来做浅拷贝new_lst lst[:]。很多新人不知道这个写法想要复制列表时直接写new_lst lst结果修改新列表把原列表也改了。这个问题我在6.3节会仔细讲这里先记住直接赋值复制的是引用切片复制的是内容。2.4 列表拼接与重复的隐藏细节两个列表直接用号可以拼成一个新列表a [1, 2] b [3, 4] c a b # [1, 2, 3, 4]*号则让列表重复若干次d [0] * 5 # [0, 0, 0, 0, 0]这两个操作看起来平平无奇但隐藏着一个性能问题拼接会创建一个全新的列表并把两边元素逐个复制进去。如果你在循环里用lst lst [x]这种写法时间复杂度是O(n²)列表越长越慢。有经验的开发者会在循环里用append或者先收集到一个列表再一次性扩展比如用extend方法。后者我会在下一节讲方法时细说。3. 列表的增删改查与排序3.1 增删改查四大类方法用对场景效率翻倍列表方法按功能分成几组我按日常使用频率给你捋一遍。增加元素append(x)在末尾追加一个元素原地修改列表返回None。extend(iterable)把一个可迭代对象中的每个元素逐个追加到末尾。insert(i, x)在指定位置插入一个元素原位置及之后的元素都往后挪。append和extend的区别是新手最容易弄混的点。append([1, 2])是把[1, 2]作为一个整体元素放进去结果变成[..., [1, 2]]extend([1, 2])是把两个元素拆开追加结果变成[..., 1, 2]。记住这一点调试的时候能省不少事。删除元素remove(x)从左往右找到第一个等于x的元素并删除如果找不到抛ValueError。pop(i)删除并返回指定位置的元素不传参数时默认删除并返回最后一个元素。它是唯一一个既能删又能取回值的方法。clear()清空所有元素。del语句del lst[i]删除指定位置元素del lst[a:b]删除一段切片。这里插一个很好用的技巧当你需要边遍历边删除时直接for x in lst: lst.remove(x)会出大问题。因为遍历过程中列表长度变了元素会漏掉。常见的替代方案是倒序遍历删除或者先拷贝一份副本再遍历例如for x in lst[:]:。修改元素直接对索引赋值即可例如lst[0] 100。需要注意lst[5] x并不会在索引5没有元素时自动扩展列表那样会抛IndexError。查找元素index(x)返回第一个等于x的索引不存在则抛ValueError。count(x)统计x在列表中出现的次数。in操作符判断元素是否存在返回布尔值。这个不常用到方法但查询频率极高。查找类操作都是线性扫描时间复杂度O(n)元素多的时候要考虑性能。如果查询极其频繁建议换成集合或者字典来加速。3.2 sort和sorted排序的两种姿势list.sort()是列表的方法原地排序直接把原列表排好返回None。sorted()是内置函数返回一个新列表原列表不受影响。选哪个取决于你是否需要保留原顺序。lst [3, 1, 2] lst.sort() print(lst) # [1, 2, 3] lst2 [3, 1, 2] sorted_lst sorted(lst2) print(lst2, sorted_lst) # [3, 1, 2] [1, 2, 3]这两个函数都支持两个关键参数key和reverse。key接收一个函数用来指定排序依据。比如有一个列表每个元素是(名字, 分数)元组你想按分数从高到低排students [(张三, 88), (李四, 92), (王五, 75)] students.sort(keylambda x: x[1], reverseTrue)这里的lambda x: x[1]就是lambda表达式在列表操作中最经典的使用场景之一。它定义了一个匿名函数传入每个元素取出该元素的第二个字段作为排序键。其实Python排序是稳定的如果分数相同会保留原始相对顺序。reverseTrue实现降序等价于排序后再反转。但注意对于字符串列表直接排序默认按字典序对于数字字符串像10、9结果可能不符合你按数值排的预期。此时需要keyint先把字符串转成数字再比。3.3 列表推导式与lambda写代码的快速通道列表推导式list comprehension是我个人认为Python最优雅的语法之一。它能把一个循环加一个条件判断压缩成一行表达式。基本结构是[表达式 for 变量 in 可迭代对象 if 条件]比如从0到9里挑出偶数并计算平方squares [x**2 for x in range(10) if x % 2 0] # [0, 4, 16, 36, 64]这行代码等价于squares [] for x in range(10): if x % 2 0: squares.append(x**2)可读性上熟练之后推导式其实更清晰。但新手经常踩的坑是为了炫技把推导式写得太复杂比如嵌套两层for加两个if。这种时候可读性反而大幅下降。我的原则是推导式的复杂度控制在能一眼看懂的范围内更复杂的逻辑就老老实实写普通循环。lambda表达式常常和推导式、filter、map、sorted这些函数式编程风格的写法混在一起用。比如list(map(lambda x: x * 2, lst))把一个列表的每个元素翻倍。这种写法适合处理流式逻辑但和列表推导式比起来可读性并没有优势反而多一层函数调用的开销。Python社区现在的审美倾向是能用推导式就用推导式lambda更多用在key参数这种不需要返回值名字的场合。4. 元组不可变带来的确定性4.1 创建元组别掉进单元素陷阱元组的创建通常用圆括号t (1, 2, 3)也可以用tuple()构造函数把其他可迭代对象转成元组t1 tuple([1, 2, 3]) t2 tuple(abc) # (a, b, c)有一个细节必须提醒创建单元素元组时一定要加逗号。(1)在Python里只是整数1外面套了一个括号而不是元组。正确写法是(1,)。空元组则可以直接用()。wrong (1) right (1,) print(type(wrong)) # class int print(type(right)) # class tuple这个小陷阱看起来不起眼实际写代码时却经常导致数据结构和预期不符。我在评审别人代码时见过不止一次一个函数返回了(result)你以为你拿的是元组对它做解包操作结果报TypeError: cannot unpack non-iterable int object。4.2 元组解包一行代码完成多变量赋值元组最实用的特性之一就是支持解包unpacking。你可以把元组里的元素一次性赋给多个变量point (3, 5) x, y point print(x, y) # 3 5这个特性让多返回值函数实现得非常自然。比如写一个函数返回商和余数def divmod_pair(a, b): return a // b, a % b q, r divmod_pair(17, 5) print(q, r) # 3 2函数返回的是一个元组调用方用解包语法直接拿到两个值代码干净利落。Python官方内置的divmod()函数就是这种模式。解包还有一些进阶用法。第一是交换变量a, b b, a一行就能完成不需要中间临时变量原理就是右边的b, a先构成一个元组再解包。第二是用*收集多余元素比如first, *rest (1, 2, 3, 4)rest会拿到[2, 3, 4]。第三是在循环里解包比如遍历一个保存坐标的元组列表for x, y in points:提取字段非常直观。4.3 元组的哈希特性能当字典键的身份证明元组不可变所以它是可哈希的。也就是说元组可以用来做字典的键也可以放进集合里。这是列表做不到的列表可变不具备哈希能力。d {} d[(1, 2)] 坐标一这个特性在需要把多个字段组合成一个复合键的场景下特别好用。比如统计二维坐标点出现的次数(x, y)这个元组天然就可以作为字典的键。但注意一个边界情况如果元组内部包含可变元素比如t (1, [2, 3])这时候这个元组不可哈希因为[2, 3]可变Python无法保证元组的哈希值稳定。你做hash(t)会直接抛TypeError。所以真正能当字典键的元组必须是所有元素都不可变的那种。Python标准库里还有一个非常有用的工具叫namedtuple它能让元组字段具备名字。比如from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(3, 5) print(p.x, p.y) # 3 5它本质上仍然是元组支持索引、解包、不可变但字段可以按名字访问可读性远高于普通元组。用来表示一条固定结构的数据记录非常合适比如配置项、坐标点、小型数据行。5. 选型指南什么时候用列表什么时候用元组5.1 性能与内存元组真的比列表省吗既然元组不可变结构更简单那么性能上确实有一定优势。元组的创建通常比列表略快内存占用也更小因为它不需要预留额外的扩容空间也不存储与动态增删相关的元数据。一个只存几个元素的元组和一个列表相比内存差异肉眼可见但实际业务中不用过分纠结这点差距。真正需要重视性能的场景是大量小对象。比如你有十万条坐标记录每条用元组存比用列表存整体省下不少内存。如果每条记录还要存进列表里做二次操作那么大列表套小元组就是一种很常见的优化模式。但是从日常开发角度看选列表还是选元组性能不是首要考虑因素语义清晰才是。如果这个数据是一个会随着程序运行不断变化的集合比如用户列表、日志列表直接用列表如果这个数据是一组固定不变的字段比如一张表的行记录、函数的返回值用元组更合适还能避免被别人意外篡改。换句人话说列表描述的是我需要管理的数据元组描述的是我已经确定下来的数据。5.2 可变默认参数的经典陷阱Python函数的默认参数有一个非常经典的反直觉设计默认值在函数定义时只计算一次。如果你写def func(lst[])这个列表只创建一次之后每次调用函数时如果没有传入新列表用的都是同一个列表对象。def add_item(item, lst[]): lst.append(item) return lst print(add_item(1)) # [1] print(add_item(2)) # [1, 2]而不是期望的 [2]这就是可变默认参数陷阱。多个调用之间共享了同一个列表数据互相污染。解决办法是用None作为默认值在函数内部判断并创建新列表def add_item(item, lstNone): if lst is None: lst [] lst.append(item) return lst这个坑是所有Python面试基本都会考的。很多人觉得不就是一个默认参数嘛但实际项目里因为这个bug产生的数据错乱非常难排查因为每次调用看起来都好像有点不对劲但又不报错。我的建议很简单默认参数永远不要用可变类型统一用None占位。5.3 浅拷贝与深拷贝列表复制的坑上一节我提过lst2 lst1只是把lst1的引用赋值给了lst2两个名字指向同一个列表对象。这时候你改lst2lst1也会跟着变。要复制内容可以用切片lst1[:]或者list(lst1)这两个方法得到的是一个浅拷贝。浅拷贝的意思是外层列表是新的但列表里的元素还是原来那些对象的引用。如果元素本身是可变对象那么修改内层对象两个拷贝还是会被同时影响。lst1 [[1, 2], [3, 4]] lst2 lst1[:] lst2[0].append(99) print(lst1) # [[1, 2, 99], [3, 4]]lst2[0]和lst1[0]指向的是同一个内层列表所以内层变化会体现在两边。对于嵌套列表你需要用copy模块的deepcopyimport copy lst1 [[1, 2], [3, 4]] lst2 copy.deepcopy(lst1) lst2[0].append(99) print(lst1) # [[1, 2], [3, 4]]不受影响判断到底用浅拷贝还是深拷贝核心就看列表里有没有可变的嵌套结构。如果只是存了一堆整数、字符串这种不可变对象浅拷贝已经完全够用了。深拷贝虽然安全但递归复制整个对象树性能开销大还可能遇到递归引用问题不能无脑用。6. 常见报错与排查技巧实录6.1 高频报错速查表以下是我在教学和项目里见到的、和列表元组相关的高频报错整理成一张速查表建议存下来报错信息出现原因排查方法IndexError: list index out of range访问了不存在的索引位置检查索引是否在[-len(lst), len(lst)-1]范围内先len(lst)看看实际长度确认列表是否为空ValueError: list.remove(x): x not in listremove删了一个不存在的值删除前先判断if x in lst或用pop根据索引删ValueError: tuple.index(x): x not in tupleindex查找的值不存在用x in t先判断或捕获ValueError异常TypeError: list object is not callable变量名把内置list函数覆盖了搜代码里有没有list [...]这种命名改掉变量名TypeError: cannot unpack non-iterable int object对非序列类型做解包检查返回的到底是不是元组确认没把单元素元组漏写逗号TypeError: unhashable type: list把列表当字典键或放进集合改用元组或确保数据确实需要可变性AttributeError: tuple object has no attribute append对元组调用列表的方法重新确认类型如果确实需要修改把元组转成列表list(t)这张表里unhashable type: list可能让新手最摸不着头脑。它往往发生在写set(lst)去重的时候但列表里的元素又恰好是列表。比如lst [[1, 2], [1, 2]] set(lst) # TypeError: unhashable type: list这种情况就需要考虑你的数据设计是不是合理了。6.2 列表去重保序是个技术活列表去重是高频需求思路有很多但在面试和实际编码中顺序保持是一个经常被忽略的点。最简洁的写法是list(set(lst))。它利用了集合的去重能力但缺点是结果顺序不保证大概率会和你原来的顺序不一样。如果顺序无所谓这一行就够。如果要保持原顺序常见方案有两种。第一种是用dict.fromkeys()利用字典键的唯一性和Python 3.7字典保持插入顺序的特性lst [3, 1, 3, 2, 1, 4] deduped list(dict.fromkeys(lst)) # [3, 1, 2, 4]第二种是循环加set判断seen set() result [] for x in lst: if x not in seen: seen.add(x) result.append(x)两种方法都能保序第一种代码更短第二种更容易理解。如果列表元素是不可哈希的可变对象只能走第二种并配合自定义的相等判断逻辑。还有一个反直觉的坑lst.remove(x)只删除第一个匹配项。如果你想删除所有等于x的元素可以用前面提到的列表推导式重新构造列表lst [i for i in lst if i ! x]。这种筛选重建的方式处理批量删除非常干净特别推荐。6.3 嵌套引用与数据污染的排查思路如果代码逻辑看起来没错但数据总被莫名其妙修改十有八九是引用共享问题。我最常用的排查路径是先想清楚这条数据是从哪来的——是函数返回的还是从别的列表切片得到的还是直接赋值的然后检查是否有多个变量名指向同一个可变对象。一个非常有效的调试工具是id()函数它能返回对象的内存地址标识。你可以比较几个变量名的id如果完全一致说明它们引用同一个对象。另外也可以用is判断比如lst1 is lst2返回True意味着它们就是同一个列表。这个排查思路对列表、字典、集合所有可变类型都适用。一旦定位到是引用共享问题解决方案就是回到5.3节确认用浅拷贝还是深拷贝把数据彻底切开。7. 综合实战处理一个浮点数列表的入门经典题7.1 真实场景从py202.py出发的浮点数列表处理网上流传的Python考试题里有一类特别典型它说在考生文件夹下有个文件py202.py定义了一个6个浮点数的一维列表l1和一个包含3个子列表的二维列表然后要求做各种统计、排序、筛选。这类题看起来是考试题其实考察的全是列表和元组最核心的知识点。我把它稍微改编成一个更有实战感的场景假设你现在拿到一组温度采样数据里面既有单日温度也有按周分组的历史温度记录需要你完成几件事计算单日温度列表的最大值、最小值和平均值。用切片取出前三天的温度以及最后一天的温度。对温度列表做升序排序并保留原始数据的备份。把每个周组的平均温度算出来存成一个包含(周次, 平均温度)形式的元组列表再按平均温度降序排列。这几件事做下来列表的遍历、切片、统计、排序、元组打包、解包、按key排序这些知识点就全练到了。7.2 代码实现把列表切片、排序、元组打包一起用我直接给一份可运行的参考代码import random # 模拟6个浮点数的一维列表 l1 [36.5, 37.1, 36.8, 37.3, 36.9, 37.0] # 1. 统计最大值、最小值、平均值 max_temp max(l1) min_temp min(l1) avg_temp sum(l1) / len(l1) print(f最高温: {max_temp}, 最低温: {min_temp}, 平均温: {avg_temp:.2f}) # 2. 切片前三天和最后一天 first_three l1[:3] last_day l1[-1] print(f前三天温度: {first_three}, 最后一天温度: {last_day}) # 3. 排序保留原始数据用 sorted 返回新列表 sorted_l1 sorted(l1) print(f排序后: {sorted_l1}) print(f原列表: {l1}) # 4. 模拟二维列表3个周组每周7个温度 weekly [ [36.6, 37.0, 36.7, 36.9, 37.2, 36.8, 37.1], [37.3, 36.5, 36.8, 37.0, 36.6, 36.9, 37.2], [36.9, 37.1, 37.0, 36.7, 36.8, 37.3, 36.5], ] # 计算每个周组的平均温度打包成元组列表 weekly_avg [] for i, week_temps in enumerate(weekly, start1): avg sum(week_temps) / len(week_temps) weekly_avg.append((i, avg)) # (周次, 平均温度) # 按平均温度降序排列 weekly_avg.sort(keylambda item: item[1], reverseTrue) for week_no, avg in weekly_avg: print(f第{week_no}周平均温度: {avg:.2f})这段代码里用到的技巧都很典型。max、min、sum都是Python内置函数直接操作列表f{avg:.2f}是格式化字符串的写法保留两位小数。sorted创建一个新列表原列表l1不受影响这也回应了5.3的引用问题。enumerate在遍历二维列表时同时取周次和温度列表start1让周次从1开始计数。最后weekly_avg.sort(keylambda item: item[1], reverseTrue)就是3.2节讲的按元组第二项排序的具体应用。整个流程下来列表和元组在同一个真实场景里各司其职列表承载动态数据元组记录固定结构。7.3 扩展想法给新手的三点建议运行上面的代码之后我建议你再动手做三个小变形把知识点真正内化第一把weekly_avg改成用列表推导式生成试着用一行代码算出所有周的平均温度元组。第二把week_temps的数据类型从列表改成元组看看程序哪里会报错体会一下元组不可变带来的限制。第三给这段代码加一个输入入口让你可以手动录入一周温度并实时计算均值这样就把输入输出和数据处理串起来了。我自己的体会是列表和元组的难点不在语法而在什么时候用谁。多写几个像这样的小程序遇到报错后亲自查一次id()、亲手做一次浅拷贝和深拷贝对比你对它们的理解就能超越绝大多数只会背语法的人。后面你学字典、集合时也会发现很多坑的根源依然是可变与不可变、引用与拷贝这两条线。把这两条线吃透了Python数据结构这块的地基就算真正打牢了。
返回列表