我一直觉得,Python里最容易被低估的内置类型就是 set(集合)。很多刚上手 py 的朋友,会把注意力放在列表、字典、字符串上,一提到集合就觉得“不就是数学课上那个集合嘛”。但真正写代码之后你会发现,搞懂集合的底层逻辑,能帮你少写好几个循环,少踩好几个坑。这篇 py 入门之集合,我打算从定义、增删查改、关系运算、推导式、避坑、实战这六块往下讲,目标是让你学完之后,遇到“去重”“快速判断是否存在”“求交集差集”这类需求时,第一反应就是 set。
集合不是 Python 里某个冷门角落,它是和列表、字典平起平坐的核心内置类型。日常写脚本、刷算法题、做数据分析清洗,几乎都绕不开它。我这篇文章不会搞一堆源码分析,就按一个过来人的角度,把集合的“为什么好用”和“哪里容易翻车”一起讲清楚。
1. 先分清集合和列表:定义、无序、适用场景
我之前带过不少刚入门 py 的朋友,大家最容易混淆的不是集合和字典,而是集合和列表。毕竟从写法上看,列表用方括号,集合用花括号,乍一看都是“装了一堆元素的东西”。但在 Python 里,集合和列表的底层逻辑完全不一样:列表是“有顺序、可重复、按下标访问”的线性结构,集合是“无顺序、不重复、用哈希值定位”的散列结构。这一小节先把这个边界划清,后面所有操作都不会跑偏。
1.1 集合的定义和基本写法:{} 与 set() 的区别
集合可以直接用花括号写,也可以用set()构造。
s = {1, 2, 3, 4} print(type(s)) # <class 'set'> empty_set = set() # 不能写成 {},{} 是空字典 print(type(empty_set)) # 从列表创建集合 nums = [1, 1, 2, 3, 3, 4] s2 = set(nums) print(s2) # {1, 2, 3, 4}这里有新手第一个坑:{}在 Python 里是空字典,不是空集合。想要一个空集合,必须写set()。从列表转换是最常见的建集合方式,能够顺带去重,一行代码搞定。需要注意,花括号直接写集合的时候,元素必须是“可哈希”的,后面我会专门讲这个约束。
很多教材会把集合定义成“无序且不重复的元素序列”,这句话听起来很简单,但初学者经常忽略两个关键点:第一,集合不能通过下标访问;第二,集合里的元素天然就是唯一的。这两点决定了集合的使用边界。
1.2 无序和去重:集合最容易被误解的两个特性
先看无序。集合的“无序”指的是:逻辑上,集合里的元素没有下标、没有先后位置。你往集合里加元素,它在内存里不是按添加顺序排的,而是按哈希值散列存放。所以不要试图用s[0]这种方式取元素,会直接报TypeError: 'set' object is not subscriptable。
实际场景里,无序性带来的最大影响是:遍历集合时元素的输出顺序并不保证,尤其字符串集合在不同运行环境下顺序可能不一样。Python 3.7 以后,字典虽然保持插入顺序,但 set 从来没有“保序”的承诺。如果你需要稳定顺序,可以配合sorted()使用:for x in sorted(s)。
再看去重。这是集合最常用的卖点:
duplicated = [1, 2, 2, 3, 3, 3, 4] uniq = set(duplicated) print(uniq) # {1, 2, 3, 4}本质上,集合不允许两个相等的元素存在。这里的“相等”不是长得一样的意思,而是 Python 的==和hash()共同决定的。两个值如果相等,哈希值也应该相等,所以它们没法同时待在同一个集合里。这个特性在很多地方都很有用,但也会带来坑,比如True和1会被当成同一个元素,后面避坑清单里会展开讲。
1.3 什么场景优先选集合,什么场景千万别用
我自己选型的时候,判断标准很简单:如果我对数据的要求是“元素唯一”“只关心在不在”“需要集合运算”,就优先用 set;如果我要保留顺序、要按下标取第几个值、允许重复数据,就老老实实用 list。两者不是替代关系,而是互补关系。
适合集合的场景大概有三类。第一类是去重,比如把用户 ID 列表里的重复项清掉。第二类是成员判断,比如判断某个手机号是否在黑名单里,集合的in判断比列表快得多。第三类是关系运算,比如两个好友列表之间的共同好友,直接用交集操作一下就行。
不适合集合的场景也很明显。如果你需要“第 2 个元素”“按添加顺序遍历”“保存重复成绩单”,那就别用集合。另外,集合本身是可变对象,不能作为另一个集合的元素,也不能作为字典的 key,这个进阶限制要记住。
2. 集合的增删查改:五个方法摸清底层逻辑
集合的方法比列表少,但每一个都很耐琢磨。add、update、remove、discard、pop、clear,看起来简单,用错地方就是线上事故。这一节我把它们的使用差异和底层逻辑讲清楚,尤其是add和update的区别、remove和discard的区别,这是我见过问得最多、也最容易记混的两个点。
2.1 add 与 update:单个添加和批量添加
add是把一个元素加进去,update是把一个可迭代对象里的所有元素批量加进去。
s = {1, 2} s.add(3) print(s) # {1, 2, 3} s.update([4, 5, 6]) print(s) # {1, 2, 3, 4, 5, 6} s.update("hi") print(s) # 会把 'h' 和 'i' 两个字符分别加进去注意最后一行,很多人以为update("hi")会把字符串整体加进去,实际上它会遍历字符串,把每个字符拆开加进集合。如果你真想加一整串字符串,应该用s.add("hi")。这个细节在清洗文本数据时非常容易踩坑,我见过不止一次因为update字符串导致集合里散落一堆字符的案例。
add的底层逻辑比较简单:计算元素的哈希值,找到对应位置,如果位置为空就插进去,不为空就保留已有元素。所以重复添加不会报错,也不会让集合变大,这个“幂等”特性在状态记录里很实用,比如标记“这个批次已经被处理过”。
2.2 remove、discard、pop、clear:四兄弟的使用差异
这四个删除方式很容易混,我列个对照表你就清楚了:
| 方法 | 删除不存在的元素 | 返回值 | 适用场景 |
|---|---|---|---|
remove(x) | 抛出KeyError | 无 | 确定元素必须存在,删除失败要立刻暴露问题 |
discard(x) | 什么都不做 | 无 | 删不删无所谓,健壮性优先 |
pop() | 集合为空时抛KeyError | 返回被弹出的元素 | 需要随机取一个元素出来处理 |
clear() | 无 | 无 | 一次性清空全部元素 |
remove和discard是前端里最常见的二选一。如果你在写一个定时任务,要从待处理集合里删除某个 ID,但另一个任务可能已经处理过这个 ID 了,那用discard更宽容;如果你在扣库存,删不到对应元素说明状态异常,那就用remove让报错赶紧爆出来。
pop在集合里有一个反直觉的点:它是“任意弹出一个元素”,不是“弹出最后一个”。因为集合本身无序,所以根本没有“最后”这个概念。如果你想用它实现一个简单的任务队列,一定要能接受元素顺序不可控。如果你需要严格的先进先出或者按优先级取,请老老实实用collections.deque或者heapq,别拿集合硬扛。
2.3 成员检测 in 为什么快,以及常见误用
集合最吸引人的优势之一,就是in判断非常快。列表的in是线性扫描,一个元素一个元素比对,数据量越大越慢;集合的in是先算哈希,再直接定位,不管集合里有多少元素,判断时间基本稳定。这个差距不是“快一点”,而是数量级上的差异。
import timeit n = 100_000 lst = list(range(n)) st = set(range(n)) # 各测 100 次,判断一个不在容器里的元素 list_time = timeit.timeit(lambda: n in lst, number=100) set_time = timeit.timeit(lambda: n in st, number=100) print(f"list in 耗时: {list_time:.6f}s") print(f"set in 耗时: {set_time:.6f}s")在我自己常用的数据规模下,十万个元素的集合和列表做成员判断,集合通常快几十倍;到了百万、千万级别,列表已经明显卡顿,集合依然稳定。底层原因就是“哈希定位”和“逐个扫描”的算法复杂度不同:列表是 O(n),集合是 O(1)。
误用也集中在这个地方。有人喜欢把黑名单列表不断in判断,跑得慢还找不到原因;有人则反过来,希望用集合取某个“特殊元素”,比如min(s)、max(s)没问题,但s[0]绝对不行。还有新手会把集合当成无序但可排序的名单,直接遍历后按“碰巧的输出顺序”写业务逻辑,下次运行顺序变了才发现问题。
3. 集合关系运算:交集、并集、差集、对称差集一次讲清
如果说去重是集合的“单兵能力”,那么关系运算就是集合最像数学课的地方。交集、并集、差集、对称差集,这四种运算在 Python 里都有两套写法:运算符写法和方法写法。很多人只背了一个,遇到多集合运算就懵。这一节我会把两套写法对照清楚,再给一个实际可比对的数据案例。
3.1 运算符写法与方法写法对照
假设有两个集合a和b,四种核心运算的写法分别如下。
| 运算 | 运算符写法 | 方法写法 | 含义 |
|---|---|---|---|
| 并集 | a | b | a.union(b) | a 或 b 里的元素 |
| 交集 | a & b | a.intersection(b) | a 和 b 都有的元素 |
| 差集 | a - b | a.difference(b) | a 有但 b 没有的元素 |
| 对称差集 | a ^ b | a.symmetric_difference(b) | 只属于其中一个集合的元素 |
a = {1, 2, 3, 4} b = {3, 4, 5, 6} print(a | b) # {1, 2, 3, 4, 5, 6} print(a & b) # {3, 4} print(a - b) # {1, 2} print(a ^ b) # {1, 2, 5, 6}要注意的是,运算符写法要求两边都是集合,方法写法可以接收列表、元组等可迭代对象。比如a.union([5, 6])是可以的,但a | [5, 6]会直接报错。这点在写灵活接口时很有用,我用方法写法用得更多,因为不强求调用方把参数整理成 set。
多集合运算时,方法写法还可以连续调用:a.union(b, c, d)、a.intersection(b, c),可读性反而更好。如果你只是想判断一个集合是不是另一个集合的子集,还可以用a.issubset(b)、a.issuperset(b)、a.isdisjoint(b),这些在日常算法题里也经常出现。
3.2 四个运算对应到生活场景
数学运算听起来抽象,换成生活例子就特别好懂。
拿“朋友圈共同喜好”来举例:你喜欢吃的水果集合me = {"apple", "banana", "grape"},朋友喜欢吃的水果集合friend = {"banana", "orange", "peach"}。你俩都喜欢的水果就是交集me & friend,结果是{"banana"},适合写“我们都喜欢吃什么”的推荐商品功能。所有喜欢的水果合并就是并集me | friend,适合做“熟人一起吃水果”的选品清单。你自己喜欢但朋友不喜欢的,就是差集me - friend,可以做成“你爱吃的专属推荐”。两边各自独有的那部分就是对称差集me ^ friend,比如做“我们口味差异分析”,这个指标特别直观。
我之前做用户标签系统时,就经常用这四种运算。用户标签集合是user_tags,活动要求的标签集合是campaign_tags,判断用户是否满足活动条件,可以直接看campaign_tags.issubset(user_tags);想知道用户还差哪些标签,就用campaign_tags - user_tags。比起写一堆for循环判断,集合运算两行代码搞定,还不会漏边界情况。
3.3 用集合实现两个数据源的差集比对
热搜里有一句是“基于链表的两个集合的差集”,这可能是一道算法题。先澄清一下:如果你真的要自己实现链表集合,那是为了练习数据结构;但实际工程里,求两个集合差集最直接的办法就是用set。
一个非常常见的业务场景是同步用户数据。比如旧系统导出的用户 ID 列表和新系统导出的用户 ID 列表,我想知道哪些用户是新增的、哪些用户流失了、哪些用户两边都有。
old_user_ids = [101, 102, 103, 104, 105] new_user_ids = [103, 105, 106, 107] old_set = set(old_user_ids) new_set = set(new_user_ids) added = new_set - old_set lost = old_set - new_set retained = old_set & new_set print("新增用户:", added) # {106, 107} print("流失用户:", lost) # {101, 102, 104} print("共有用户:", retained) # {103, 105}这个例子比链表差集更贴近日常:我只需要两组 ID 做集合差,完全不需要关心底层是链表还是数组。如果你在面试里真的被问到链表集合差集,我的建议是先说清楚“集合在数学上不要求特定存储结构”,然后再写双指针或哈希集方案。在 Python 里,最自然的哈希集就是set,差别只是要不要手动模拟一个链表容器。
4. 集合推导式与 frozenset:进阶但必学的两个知识点
集合的初级用法学会了,基本可以应付大部分场景。但有两个进阶点我强烈建议早点掌握:一是集合推导式,写起来非常清爽;二是frozenset,它解决了一个“集合不可哈希”的痛点。这两个点不是花架子,在真实代码里经常能派上用场。
4.1 集合推导式:一行代码生成集合
集合推导式和列表推导式很像,只是外层用花括号,并且天然有去重效果。
squares = {x * x for x in range(10) if x % 2 == 0} print(squares) # {0, 4, 16, 36, 64} # 推导式天然去重 mod_three = {x % 3 for x in range(10)} print(mod_three) # {0, 1, 2}第二个例子最能说明问题:明明遍历了 10 个数,结果只有 3 个不同余数,推导式直接帮你去重了。写集合推导式的时候,心里要有个预期:结果集合的元素数量很可能小于迭代次数。
集合推导式比较适合从一堆数据里提取“有哪些分类”。比如有一个记录列表,每条记录都有status字段,我想快速拿到所有出现过的状态值,一行{item["status"] for item in records}就出来了,比建空集合再add要干净很多。
4.2 frozenset:不能增删的集合,安全与哈希
frozenset是集合的“只读版本”。它和集合一样有去重和无序特性,但创建之后不能add、不能remove、不能pop。它的最大价值在于“可哈希”,也就是可以作为字典的 key,也可以放进另一个集合里。
fs = frozenset([1, 2, 3]) d = {fs: "这是一个不可变集合"} print(d[fs]) # 这是一个不可变集合 container = set() container.add(fs) print(container) # {frozenset({1, 2, 3})}为什么可变集合不能哈希,而frozenset能哈希?因为一个元素的哈希值一旦变了,它在集合里的位置就乱了,那整个集合的查找功能就崩了。所以 Python 规定:只有不可变对象才能被可靠哈希。frozenset因为不可变,所以可以放心当 key 用。
实际中我用frozenset比较多的地方是“缓存判断条件”。比如一组功能的开关组合{feature_a, feature_b},如果把它作为缓存 key,必须用frozenset,因为普通的set不可哈希,放进字典会直接报TypeError: unhashable type: 'set'。
4.3 为什么集合里不能放集合
这也是初学者比较容易困惑的点。理论上,一个“包含集合的集合”听起来没问题,但执行s = {1, {2, 3}}一定会报TypeError: unhashable type: 'set'。原因还是回到哈希:{2, 3}是可变对象,它的内容可以随时添加或删除,哈希值就不稳定。集合里所有元素都必须有稳定的哈希值,否则整个集合没法正常工作。
如果你真的想表达“一组集合的集合”,有两种靠谱的替代方案:
# 方案一:用 frozenset 作为内部集合 connected = {frozenset({1, 2}), frozenset({3, 4})} # 方案二:用 tuple 排序后作为集合元素 pair = frozenset([1, 2]) connected2 = {pair}我见过有人为了绕过这个限制,硬把集合转成字符串存进去,用的时候再解析,这其实是把简单问题复杂化了。规范的做法就是frozenset或者排序后的tuple,因为这两个类型都是可哈希的。
5. 新手必看的集合避坑清单
集合本身不难,难点在于它和“可变对象”“相等判断”“迭代修改”这些概念纠缠在一起后出现的各种边界情况。这一节我把自己踩过、也看别人踩过的高频坑集中整理出来,每一条几乎都能对应到一个真实报错或者线上 bug。
5.1 可变元素入集合:TypeError 的根源
最常见的报错就是TypeError: unhashable type: 'list'或者'set'、'dict'。出现的原因很简单:集合要求元素可哈希,而 list、set、dict 都属于可变对象,不可哈希。
# 错误示例 bad = {1, [2, 3]} # TypeError: unhashable type: 'list' # 正确写法:把可变对象转成 tuple good = {1, (2, 3)}很多人不理解为什么 tuple 可以、list 不行。因为 tuple 一旦创建就不能修改,它的哈希值可以稳定存在;list 可以随便增删改,今天[1,2]明天[1,2,3],哈希值怎么稳定?所以如果你想把一组数作为一个整体放进集合,先转成 tuple。如果这组数本身还要保持去重,可以先转成frozenset,再把frozenset放进去。
5.2 遍历集合时修改:RuntimeError 的根源
RuntimeError: Set changed size during iteration也是经典报错。当你在for x in s的循环体里执行s.add()或s.remove(),Python 迭代器检测到容器大小变了,立刻罢工。
s = {1, 2, 3} for x in s: s.remove(x) # RuntimeError: Set changed size during iteration正确的处理方式是遍历拷贝,同时修改原集合:
s = {1, 2, 3} for x in list(s): if x < 3: s.remove(x) print(s) # {3}如果条件比较复杂,更好的模式是“先收集,再一次改”:遍历时只把要删的元素放进一个待删列表,循环结束后统一difference_update。这个模式在批量任务里尤其好用,既避免 RuntimeError,也让代码逻辑更清楚。
5.3 True、1、0.0 的去重陷阱
这个坑藏在 Python 的相等判断里:True == 1,False == 0,而且它们的哈希值也相同。所以当你把布尔值和整数混在同一个集合里,会发生“看起来不同,但只能留一个”的现象。
s = {0, False, 1, True, 2} print(s) # {0, 1, 2}这个例子有可能输出{False, True, 2}或者{0, 1, 2},取决于表示方式,但核心规律就是0和False只能留一个,1和True只能留一个。在你设计状态枚举时,尽量不要用1表示“开启”同时用True表示另一个含义,它们会被混为一谈。同理,0.0和0也是“同一类”,放进集合也会被去重。
5.4 旧代码升级到 Python 3.12 后出错的排查思路
有一条热搜提到“旧的 py 文件在 Python 3.12 上运行出错”,这里我也顺带说下排查思路。Python 3.12 没有推翻集合的核心语法,{}、set()、集合运算、frozenset 这些行为基本保持不变。如果老脚本升级后出错,我会建议先看报错堆栈的最后一行,看看是不是下面这三类原因:
第一类是元素不可哈希,比如旧代码往集合里塞了 list,以前某个版本可能因为写法碰巧没触发,升级后严格校验暴露出来。第二类是remove元素不存在导致的KeyError,这种通常是数据发生了改变。第三类是遍历集合时修改集合大小的 RuntimeError,在旧代码里比较隐蔽。这些问题都和集合的底层规则强相关,但解决起来也不难,通常把可变元素转成 tuple、把remove换成discard、把遍历目标换成拷贝,就能顺利跑通。
5.5 综合练习:用集合表示最小的 5 个斐波那契数
我自己在带新人时,很喜欢用“斐波那契集合”这个小练习来考察对集合的理解。题目是这样的:定义一个小蓝同学想要的集合 f,里面的元素是最小的 5 个斐波那契数。
斐波那契数列通常是 0、1、1、2、3、5……如果直接写:
f = {0, 1, 1, 2, 3} print(f) # {0, 1, 2, 3}会发现集合里的元素个数是 4,不是 5。为什么?因为1被去重了。这就是集合“不包含重复元素”的直观体现。如果你再用循环生成:
f = set() a, b = 0, 1 for _ in range(5): f.add(a) a, b = b, a + b print(len(f)) # 4结果依然是 4 个元素。这个练习非常适合新手亲手做一遍,因为它把“去重”“循环更新”“集合的长度”都串起来了。很多人以为add一个重复元素会让集合变大,其实不会,这正是集合存在的意义。
6. 集合在真实项目里的三个高频用法
基础知识和避坑都讲完了,最后放三个我经常在真实项目里用到的集合技巧。每个都很短,但很实用,属于那种“看文档不一定找得到,但写代码一定用得上”的经验。
6.1 列表去重但保持顺序:一条有点技巧的写法
set(list)虽然能去重,但会打乱原顺序。如果你既要保留首次出现的顺序,又要去重,可以这样写:
def dedupe(items): seen = set() result = [] for item in items: if item not in seen: seen.add(item) result.append(item) return result data = [3, 1, 3, 2, 1, 4] print(dedupe(data)) # [3, 1, 2, 4]这里的核心是:seen负责 O(1) 的成员判断,result列表负责保存顺序。既有集合的速度,又保留列表的顺序。这个方法在清洗爬虫数据、日志去重、订单 ID 去重等场景里都非常好用。
6.2 用集合做权限标签的快速比对
权限系统是集合运算的经典应用场景。假设每个用户有一串权限标签,某个接口要求用户必须具备两个权限,并且不能拥有某个禁用权限,这种校验用集合写起来非常直观:
user_perm = {"read", "write", "export"} required_perm = {"read", "export"} disabled_perm = {"admin"} has_required = required_perm.issubset(user_perm) has_disabled = bool(user_perm & disabled_perm) if has_required and not has_disabled: print("权限校验通过") else: print("权限校验失败")这套逻辑如果用for循环写,代码量大还容易漏掉“两个权限同时判断”的情况。集合的issubset一次就能把“全部包含”表达清楚。权限标签多的系统,尤其适合这种写法。
6.3 集合性能实测:数据量越大优势越明显
最后再补一个关于性能的直观感受。我经常对新人说:如果你在列表上反复写if x in list,并且这个列表有几千几万个元素,那一定要考虑集合。随着数据量从一万增长到十万、百万,列表in的时间线性上涨,集合却几乎是一条水平线。原因就是哈希表查找复杂度是 O(1)。
当然,集合也有代价。它占用的内存通常比列表大,因为它需要额外的哈希表结构来存储位置信息。所以如果你的数据量特别小,只有十几个元素,那列表和集合的性能差异根本感觉不到;但数据量一旦上来,集合的优势就是决定性的。我的原则是:需要频繁成员判断的首选集合,需要顺序和压缩存储的选列表。
我自己的体会是,集合是一个“越用越顺手”的类型。一开始你可能只是为了去重才想到它,慢慢地你会发现在做交集差集判断、去重保持顺序、权限校验、缓存 key 设计时,它都能给出非常漂亮的解法。每当你觉得一个问题需要写多层循环时,先退一步想想:是不是可以用集合表达?往往这一想,代码就短了一半。