ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python四大容器:列表、元组、字典、集合对比与实战

Python四大容器:列表、元组、字典、集合对比与实战 列表、元组、字典、集合这四个词几乎是每个Python开发者的第一课也是面试里问得最勤的基础题。很多教程把它们拆成一章一章讲结果学完列表忘了元组学完字典又分不清集合。我这些年看过的代码和面试题里因为这四个容器用错地方而翻车的例子实在太多了。这篇文章想把它们放到同一个坐标系里对比从底层原理讲到实操细节再分享一些平时文档里不太会写的坑和技巧。不管是刚入门的新手还是写过一段时间想查漏补缺的人应该都能找到点东西。1. 先搞清楚四个类型到底在解决什么问题1.1 列表有序、可变、啥都能装列表是Python里用得最频繁的容器。它的核心特征可以总结成三句话保持插入顺序内容可以修改元素不限类型。这里的有序指的是元素在列表里的位置一旦确定就可以通过下标访问和排好序完全是两码事。比如a [3, 1, 2] print(a[0]) # 3 a[0] 9 print(a) # [9, 1, 2]可变意味着 append、remove、sort 这类操作会直接改原列表而不是生成新列表这一点和字符串完全不同。元素不限类型是说列表里可以同时放整数、字符串、甚至另一个列表因为它本质上是对象的引用集合。你往列表里塞的东西其实存的是指向那个对象的指针所以哪怕元素类型五花八门也不影响使用。从底层看CPython 的列表是一个动态数组里面存的是 PyObject 指针。所谓动态数组就是它不会像 C 语言数组那样长度写死而是在空间不够时自动扩容。这也是为什么在很多语言里数组长度固定Python 里列表却可以随心所欲地 append。这种设计的代价是随机访问快、尾部增删快但中间插入或删除要移动后续所有元素。如果你要频繁在头部插入建议换成 collections.deque否则数据量一大性能会明显吃紧。1.2 元组有序、不可变、省心安全元组和列表长得几乎一模一样唯一硬性的区别是它创建之后不能改。你不能执行 t[0] 1不能 append不能 remove。这种只读特性在保护数据上特别有用函数多值返回、数据库的一条记录、坐标点 (x, y) 这类数据天然应该用元组而不是列表。为什么有了列表还要元组除了防止误改还有两个很实际的原因。第一元组可以作为字典的键或放进集合。Python 要求字典键必须可哈希而可哈希的前提通常是不可变。元组满足这个条件前提是元组里的元素也全部可哈希列表不行。这个区别会在第 3 章详细展开。第二Python 解释器会对元组做一些缓存优化一个不包含可变对象的元组内存占用通常比同样内容的列表更小创建开销也更低。简单说元组就是更节约、更安全的列表。所以当你拿不准该用列表还是元组时我的习惯是如果可以不改就用元组。1.3 字典键值映射查得快字典解决的是按名字取东西的问题。你给它一个键它立刻给你对应的值不用像列表那样从头遍历。Python 字典底层是一张哈希表实现方式类似一本按拼音索引的词典想查字典这个词直接翻到 d 开头那一页而不是从第一页开始逐页找。因为底层是哈希表字典的查找、插入、删除平均复杂度都是 O(1)。注意是平均极端情况下哈希冲突会退化但 Python 对哈希表做了随机化处理和自动扩容日常几乎碰不到。字典键必须是可哈希对象值则可以是任意对象。键相当于索引编号值就是内容编号必须稳定不可变内容无所谓。Python 3.7 以后字典还保留插入顺序这也让很多人拿它当带索引的有序集合用。但我想提醒一句如果你需要明确排序还是建议显式调用 sorted不要依赖插入顺序这种实现层面的特性毕竟在不同环境下行为可能不同。1.4 集合无序、去重、集合运算集合是一个只关心有哪些元素、不关心顺序、也不允许重复的容器。它底层也是哈希表相当于一个只有键没有值的字典。所以集合里的元素也必须可哈希字符串、数字、元组可以放进去列表、字典、集合不能放进去。集合最大的价值有两个。一是去重把一个列表里的重复元素抽出来转成集合自然就没了。但要注意集合本身无序转回来之后顺序不能保证。二是关系运算交集、并集、差集、子集判断这些在数据处理里特别常用比用循环一层层判断要快得多代码也短得多。set 是可变的可以做 add、remove。frozenset 是不可变版本和元组一样可以作为字典键或者作为另一个集合的元素。这些基础概念理清楚之后接下来的实操才有底气。2. 列表的实操细节切片、方法与推导式2.1 列表切片是入门第一道坎切片是列表最常用的操作之一语法是 lst[start:stop:step]取的是从 start 到 stop 前一个位置、每隔 step 步取一个元素。三个参数都可以省略。刚接触的人如果记不住左闭右开可以用一个小技巧切片得到的新列表里根本就不会出现 stop 位置那个元素。nums [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(nums[2:5]) # [2, 3, 4] print(nums[:4]) # [0, 1, 2, 3] print(nums[4:]) # [4, 5, 6, 7, 8, 9] print(nums[::2]) # [0, 2, 4, 6, 8] print(nums[::-1]) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]负数索引也是极其实用的技能nums[-1] 是最后一个元素nums[-2] 是倒数第二个nums[-3:] 就是最后三个元素。切片时 start、stop 是左闭右开只取到 stop 前面一个这个设计是为了让 nums[:n] 和 nums[n:] 拼起来恰好等于原列表也不容易出 off-by-one 的错。还有一个关键区别容易踩坑列表切片返回的是一个新列表浅拷贝不是原列表的视图。也就是说sub nums[2:6] sub[0] 99 print(nums) # 原列表不受影响这跟某些数据分析库比如 numpy 的切片不一样千万别把两者混着记。如果只是想让某个变量指向同一个列表用等号赋值如果想备份一份再修改用切片。2.2 常用方法盘点与坑点列表方法看起来多但核心可以分成三类增删、查找、排序。增append 在末尾加一个元素extend 把一个可迭代对象里的每个元素分别追加进去insert 在指定位置插入。区别一定要搞清楚a [1, 2] a.append([3, 4]) # [1, 2, [3, 4]] b [1, 2] b.extend([3, 4]) # [1, 2, 3, 4]append 是把整块东西当成一个元素塞进去extend 是把整块东西拆开一个个放进列表。这个区别是新手最容易犯的错尤其是往列表里加字符串时extend(abc) 会得到 [a, b, c]。删remove 按值删除第一个匹配项pop 按索引删除并返回被删元素不传索引就删最后一个。clear 清空整个列表。这里有个经验如果你还要用被删的值优先 pop如果你只是想把某个值移除、不关心返回值用 remove。查index(value) 返回第一次出现的位置count(value) 统计出现次数。如果值不存在index 会抛 ValueError所以用之前要么确认值在要么用 try/except 包住。排序sort 是原地排序直接修改列表sorted 是返回新列表。默认都是升序想倒序加 reverseTrue。需要注意 sort 只能用于元素之间可以比较大小的列表混着放字符串和整数会直接报 TypeError。2.3 列表推导式与生成器表达式列表推导式是 Python 里最有特色的写法之一它的本质就是把创建一个新列表、for 循环、逐个 append压缩成一行squares [x * x for x in range(10)] evens [x for x in range(10) if x % 2 0]这段代码读起来几乎是自然语言给每个 x 算一个 x 的平方。它比手写 for 循环加 append 更快因为解释器针对推导式的循环做了内联优化避免了每次迭代都调用 append 方法和查找局部变量的开销。更重要的是可读性高团队协作时看到推导式扫一眼就知道意图。推导式还能嵌套比如把二维列表拍平matrix [[1, 2], [3, 4]] flat [item for row in matrix for item in row] # [1, 2, 3, 4]括号里 for 的顺序和普通循环的嵌套顺序一致先外层后内层记住这个就不会晕。但如果只是想要一个大序列用于迭代而且不需要全部保存在内存里就应该用生成器表达式把方括号换成圆括号total sum(x * x for x in range(10 ** 7))生成器是惰性求值边算边出内存占用基本恒定适合处理大范围数据。不过它只能遍历一次需要反复用的数据还是要老老实实构建成列表。3. 元组与字典不可变与哈希的关键3.1 元组不只是不能改的列表把元组理解成不能改的列表基本没问题但有两个补充理解会帮你少踩坑。第一元组的不可变是浅层不可变它保证的是你不能替换元组里的元素但元素本身如果是可变对象那个对象内部可以继续变化。比如t (1, [2, 3]) t[1].append(4) print(t) # (1, [2, 3, 4])这个元组看起来变了真正变的其实是它引用的那个列表。也正因为如此包含可变对象的元组是不可哈希的没法当字典键。这点在面试题里出现频率极高值得单独记下来。第二创建单元素元组必须加逗号这个坑无数人踩过a (1) # 这是整数 1 b (1,) # 这是元组只有一个元素 1 c 1, # 也是元组不用括号也可以元组的括号其实可有可无真正决定它是元组的是逗号。print((1)) 和 print((1,)) 输出完全不一样很多人以为加个括号就是元组其实大错特错。3.2 元组解包优雅地拆值元组最有味道的用法不是存储而是解包。变量赋值、函数返回值、循环遍历都可以利用元组的解包特性写得很干净。point (3, 5) x, y point a, b b, a # 交换两个变量不用临时变量 first, *rest (1, 2, 3, 4) # rest 是 [2, 3, 4]一个不太起眼的场景是遍历字典时同时拿到键和值for key, value in info.items(): print(key, value)这里的 items() 返回的就是一个个 (key, value) 元组for 循环自动解包成两个变量。看似简单很多人初次接触都会有为什么能这么写的疑问答案就是元组解包。如果你需要一段函数代码返回多个结果在 Python 里最常见的写法是 return x, y它本质是return 一个元组 (x, y)。调用方要么直接接收一个元组要么用解包接住。这比分别返回两个变量、或者传入输出参数的方式都清爽得多。3.3 字典的键、访问与视图字典实操第一课是安全访问。直接使用 d[key] 时如果 key 不存在会抛 KeyError这在处理用户输入、接口返回时特别烦人。更稳的写法是 getinfo {name: 张三, age: 18} print(info.get(city)) # None print(info.get(city, 未知)) # 未知 print(info.setdefault(city, 北京)) # 北京如果不存在就插入setdefault 和 get 的区别是setdefault 在键不存在时会把默认值写进字典get 不会。还有一个 update 方法可以批量合并另一个字典或键值对info.update({score: 88, age: 19})这里 update 会覆盖已有键。keys()、values()、items() 返回的不是新的列表而是视图对象。视图有两个特点一是动态字典变了视图也跟着变二是可以迭代但不能按下标访问。如果你确实需要下标索引可以再包一层 list(...) 转成列表。另外Python 3.7 字典保持插入顺序之后遍历字典时顺序就是插入顺序这点和集合不同。但排序仍然需要 sorted(d.items(), keylambda x: x[1]) 这种写法来做。3.4 字典的实战姿势计数、分组与默认值字典最常见的需求是计数。统计字符串里每个字符出现的次数新手会写count {} for ch in text: if ch in count: count[ch] 1 else: count[ch] 1这种键在不在的判断写多了很啰嗦。collections.defaultdict 就是为这种场景设计的访问不存在的键时会自动用工厂函数生成默认值。from collections import defaultdict, Counter count defaultdict(int) for ch in text: count[ch] 1 print(dict(count))如果只是计数Counter 更省事一行就够Counter(text)。它本身是 dict 的子类可以直接当字典用还能直接做 topNmost_common。分组也很适合 defaultdict(list)比如把一堆人按班级分组键是班级值是从未出现过的班级名单列表。defaultdict 在访问新班级时自动给一个空列表省掉了if not in dict: dict[key][]这一步。合并字典也是高频操作。Python 3.9 以后可以用 | 运算符merged {**a, **b} # 通用写法 merged2 a | b # 3.9两种都会让右侧字典覆盖左侧已有键。4. 集合去重与关系的数学利器4.1 集合的去重原理与顺序问题集合去重看起来很神奇把列表丢进 set()重复元素就消失了。底层原因其实很简单集合是一张哈希表存储元素时先算哈希如果哈希冲突再判断对象是否相等每次插入都天然做了一次这个元素在不在的查重。哈希表查找是 O(1)所以对十万个元素去重也比嵌套循环快几个数量级。不过一定注意集合的去重结果是无序的。如果你转回来发现顺序变了这不是 bug是集合的底层实现决定的data [b, a, b, c] print(set(data)) # 输出顺序可能不是 b,a,c如果你既要保留原始顺序又要去重Python 里最经典的技巧是用 dict.fromkeys利用字典键唯一且 3.7 保持插入顺序unique list(dict.fromkeys(data))这个方法一定要记牢因为它是有序去重的标准答案比循环判断 in 再 append 高效得多。集合里的元素同样要求可哈希。把列表放进 set 会报 TypeError: unhashable type: list。很多人在做数据清洗时想对二维列表去重结果直接被这个报错卡住。解决办法是把内层列表转成元组再放集合。4.2 集合运算与判定集合另一个杀手级应用是关系运算。假设你维护两个集合 a 和 b下面这些运算符可以直接当数学符号用a {1, 2, 3} b {3, 4, 5} print(a b) # 交集 {3} print(a | b) # 并集 {1, 2, 3, 4, 5} print(a - b) # 差集 {1, 2} print(a ^ b) # 对称差集 {1, 2, 4, 5} print(a {1, 2, 3, 4}) # 子集判断 True这几个运算在权限系统、标签过滤、异常数据比对里非常实用。比如两个版本的配置文件分别读成两个集合一减就是新增了哪些、删除了哪些。比用双重循环判断快得多代码也短得多。还有几个方法名容易记混intersection、union、difference、symmetric_difference对应上面的符号。isdisjoint 用来判断两个集合是否完全没有交集返回布尔值。如果你只是判断某个元素在不在集合里用 in 即可这也是哈希表最擅长的事比在列表里线性查找快很多。4.3 set 与 frozenset可变 vs 不可变set 是可变的add、remove、discard、clear 都能直接用。其中 remove 和 discard 的区别要注意remove 删除不存在的元素会抛 KeyErrordiscard 不会。写业务逻辑时如果元素不存在也无所谓discard 更省心。frozenset 是不可变集合一旦创建就不能 add、remove。它存在的最大价值就是可以作为可哈希对象放进字典键、放进另一个集合或者被多个地方安全共享。比如我要给一份数据打多个标签标签集合希望不被误改可以用 frozenset 作为字典的键tags frozenset([python, docker]) info {tags: 技术文章}实际工作里 frozenset 用得相对少但某些需要把集合的集合作为数据结构时它就是唯一选择。理论上集合里能不能放集合是个经典问题set 不能放 set因为 set 不可哈希但可以放 frozenset。5. 四种类型的关键对比与选型指南5.1 核心维度对比表学完单项以后最好把四个容器放到一张表里对比维度列表元组字典集合是否有序有插入顺序可下标有插入顺序可下标3.7 有插入顺序按键访问没有顺序概念是否可变可变不可变可变可变set/ 不可变frozenset是否允许重复允许允许键唯一值可重复元素唯一如何访问索引 / 切片索引 / 切片键成员判断 / 遍历典型用途存储有序序列固定记录、函数多返回值键值映射、配置表去重、集合运算底层实现动态数组数组哈希表哈希表能否做字典键不能元素均不可变时可以不能不能frozenset 可以这张表不是让你背而是当你纠结该用哪个时先回答三个问题需不需要保持顺序需不需要修改需要按什么访问答案会帮你直接排除一半选项。5.2 选型决策这个需求该用哪个我见过最典型的错误是什么都要用列表存。比如想查某个学号对应的学生姓名第一个反应是建一个列表然后 for 循环去匹配数据量一大立刻卡成狗。正确的第一反应应该是按一个键找另一个值这就是字典的专长。列表适合的是维护一个顺序列表比如班级按座位号排的学生名单。给学生成绩管理系统做选型时我的建议是学生名单用列表保存因为座位顺序有意义学号到成绩用字典保存因为你要用学号快速查成绩字典查找是 O(1)选课编号用集合保存因为一门课不需要重复选还要算哪些学生选了两个社团集合交集一步到位一条学生记录里的基本信息用元组或者 namedtuple 保存因为它不应该被随便改动。这样每种数据都放在最顺手的容器里后续代码的复杂度和运行速度都会好很多。5.3 不同类型之间的转换四者之间经常需要转来转去Python 也提供了非常直白的构造方法tuple([1, 2, 3]) # 列表转元组 list((1, 2, 3)) # 元组转列表 dict([(a, 1), (b, 2)]) # 键值对列表转字典 set([1, 2, 2, 3]) # 列表转集合顺便去重 list(set([1, 2, 2, 3])) # 集合转列表 dict.fromkeys([a, b], 0) # 把序列变成字典默认值相同这里有几个细节值得注意。第一dict() 接收的是键值对的可迭代对象最常见的形式是二元组列表。第二直接从 dict 转 set 会丢失值只保留键比如 set({a: 1, b: 2}) 结果是 {a, b}这个行为很多人第一次用会懵。第三如果要用多个序列组成字典zip 是最顺手的工具keys [name, age] values [张三, 18] info dict(zip(keys, values))zip 把两个列表逐对打包成 (key, value) 元组再传给 dict刚好无缝衔接。记住这个组合比手写 for 循环去索引安全得多。6. 常见问题与避坑实录6.1 新手最容易踩的坑第一个坑是可变默认参数。很多人写过这样的函数def add_item(item, lst[]): lst.append(item) return lst print(add_item(1)) # [1] print(add_item(2)) # [1, 2]第二次调用时默认列表已经被第一次污染了。原因是默认参数只在函数定义时创建一次每次调用都复用同一个对象。标准解法是用 None 做占位函数体内再新建列表。第二个坑是遍历列表或字典时直接修改。比如想要删掉列表里所有偶数lst [1, 2, 3, 4, 5] for x in lst: if x % 2 0: lst.remove(x) print(lst) # 结果未必是 [1, 3, 5]可能会漏删因为 remove 会让后面的元素往前移动迭代器索引却继续往后走结果容易漏掉元素。更安全的是遍历副本 lst[:]或者直接用推导式重建列表。遍历字典时如果删除正在迭代的键会直接抛 RuntimeError: dictionary changed size during dictionary iteration需要先 list(d.keys()) 保存快照。第三个坑是空集合的写法。{} 创建的是空字典不是空集合。想创建空集合必须用 set()。这个错误非常隐蔽因为 print({}) 显示的是 {}让人分不清。第四个坑是复制列表时直接用等号。lst2 lst1 并没有复制内容只是让两个名字指向同一个对象。改 lst2 等于改 lst1。要用 lst2 lst1[:] 或者 lst2 lst1.copy() 做浅拷贝。要是列表里还嵌套了列表浅拷贝还不够就得用 copy.deepcopy。第五个坑是元组里加括号不代表不可变。前面说过(1, [2, 3]) 里包含列表列表可修改于是元组内容看起来变了。判断一个容器能不能安全共享不能只看最外层。6.2 排查方法速查表现象可能原因解决方式TypeError: unhashable type: list尝试把列表放进集合或字典键把列表转成元组或使用 frozensetKeyError: xxx直接访问不存在的字典键用 get() 或 setdefault()函数多次调用结果累加可变默认参数被复用默认参数改为 None函数内新建容器遍历列表删除元素后结果不对删除导致索引前移用推导式或遍历副本再删除{} 类型看似集合实际是字典创建了空字典用 set() 建空集合列表复制后改一个全变等号只是引用赋值用 [:] 或 copy() 浅拷贝元组似乎被修改了元组内嵌了可变对象确认不需要可变元素否则换结构append 之后列表变 Noneappend 的返回值是 None不要写 lst lst.append(x)append 是原地操作这个表我平时会贴在电脑边很多问题不是不懂是一着急就忘了。排查时先别急着看报错先确认一下数据类型是不是选对了。6.3 我的几点实操心得最后分享几个写了很多年代码后沉淀下来的经验。第一动手写代码前先花三十秒想清楚数据结构。代码难写、性能差、bug 多很多时候不是算法问题而是容器选错了。看到查找就想到字典或集合看到有序序列才用列表看到不应该改的记录就用元组。第二需要有序去重时第一个想到的答案应该是 dict.fromkeys不是自己写循环。这个技巧在数据清洗里出现频率极高可以算默认工具。第三面向业务开发时能用 namedtuple 或 dataclass 就别用字典套字典表达一条记录。字典的键是字符串敲错一个键名不会提示而 namedtuple 或 dataclass 的属性名是确定的配合编辑器补全和静态检查会更友好。第四别让列表推导式超过一行。包含两个 for 或者一个复杂 if-else 的推导式读起来很费劲不如拆成普通循环。代码是给人看的省一行字符远没有省三分钟理解时间重要。第五性能调优时不要太早优化。绝大多数场景下四种容器的差距都在可接受范围内。真正值得留意的是别让元素在不在集合变成在列表里用 in这一改往往就是 O(n) 变 O(1)效果立竿见影。这些经验没有一条是教科书上会强调的但都是我在项目里反复验证过的。把列表、元组、字典、集合当成工具箱里的四把不同用途的钳子该用哪把用哪把代码自然会好写很多。
返回列表