引言
这是第六章「列表、元组与字典」的第 4 篇。前三篇已经把 Python 内建容器中的序列(列表、元组)与映射(字典)讲透,今天补上第三种内建容器——集合(set)。它解决的问题很明确:去重(把一堆重复数据变成「每个只出现一次」)、成员测试(判断某个元素在不在里面)与集合运算(并、交、差、对称差)。这三个问题在实际工程里出现的频率高得惊人:统计日志里出现过的 IP、清洗用户标签、判断两个权限集合的关系,几乎每天都要写。
阅读前提:第一章讲过的 in 运算符、第三章第 7 篇的集合推导式({x for x in ...} 与 set() 的关系,本篇会复用而不重复展开)、第二章的可变与不可变对象概念。另外强调一点:集合的成员测试是 O(1),而列表是 O(n)——这一条性能差异能在百万级数据上把程序从「几秒」变成「几毫秒」,是集合最容易被低估的价值。
概念与原理
集合是什么:无序且元素唯一
集合在数学上指「确定的一堆互不相同的对象」,Python 的 set 忠实实现了这一定义:每个元素最多出现一次(重复添加会被忽略),并且没有顺序——它不是序列,不支持下标 s[0],迭代顺序由内部存储布局决定而不由插入顺序决定。这一点与列表、元组截然不同,也是本节最需要建立的直觉。
set 与 dict:同源的两兄弟
理解 set 的关键是理解它与字典的血缘关系。在 CPython 里,set 和 dict 的底层是同一套数据结构——哈希表(hash table):一个数组,配一个哈希函数,把元素的哈希值(hash value)映射到数组下标。dict 存的是一条条「键 → 值」记录,set 存的则是「只有键、没有值」的记录。可以粗浅地认为:set 是一个丢弃了值的 dict,或者是「把元素当键用」的字典。
哈希表查找的平均复杂度是 O(1):给定一个元素,先算出它的哈希值,直接定位到数组的某个槽位,再看槽位里是不是它。最坏情况(剧烈冲突)会退化到 O(n),但 Python 对字符串、整数等内置类型的哈希函数设计良好,实际使用中平均就是常数时间。为了让这条「哈希→定位」的链路成立,集合元素必须是可哈希的(hashable):对象要提供 __hash__ 方法,并且值在生命周期内不许变——所以列表、字典、集合本身不能当集合元素,而整数、字符串、元组(内容不含可变对象时)、frozenset 可以。这一点第二章讲不可变对象时埋过伏笔,现在接上了。
hash 与 eq 的关系值得多说一句:Python 要求「相等的对象哈希值必然相同」(否则放进集合里会出现「明明相等却去不掉重」的 bug)。自定义类时如果重写了 __eq__ 而不重写 __hash__,对象会变得不可哈希或哈希语义错乱,这是后话,但请记住这条约束。
为什么集合「无序」:哈希布局与随机化
哈希表有序吗?迭代时集合把表里非空的槽位按物理顺序一个个吐出来——这个顺序由「元素哈希值经过取模后的落点」决定,跟插入顺序没有关系。更微妙的是,CPython 从 3.3 起对字符串启用哈希随机化(PYTHONHASHSEED 环境变量可固定种子):同一个字符串集合,在不同进程里打印顺序都可能不同。所以「打印一个集合看看」永远得不到稳定输出,想要稳定展示必须先 sorted()。整数稍微好一些(整数的哈希值就是它自己,跨进程稳定),但槽位布局受插入历史影响,多元素时迭代顺序依旧难以预测。结论:凡是要「给用户看顺序」的场景,先转换排序;凡是依赖顺序的逻辑,根本不该用集合。
空花括号的归属:{} 是字典的私产
集合字面量 {1, 2, 3} 在 Python 2.7/3.0 才加入(与字典推导式同期,见第三章第 7 篇),而 {} 的空花括号语义早被字典占用——所以空集合没有字面量,必须写 set(),{} 永远得到一个空字典。这是个只讲一次但永远有人踩的细节,我们把它放在易错点第一条。
操作与实现
创建集合:字面量与 set()
花括号字面量直接列出元素;内置函数 set() 接受任何可迭代对象(字符串、列表、元组、生成器),并顺便完成去重。
# 字面量:花括号内的元素用逗号分隔
colors = {"red", "green", "blue"}
print(sorted(colors))
print(type(colors).__name__)
# set():从任意可迭代对象构造,字符串会拆成单个字符
vowels = set("aeiou")
print(sorted(vowels))
# 列表转集合:重复元素自动合并
nums = set([1, 2, 2, 3, 3, 3])
print(sorted(nums))
# 空集合必须用 set();{} 是空字典
empty_set = set()
empty_dict = {}
print(type(empty_set).__name__, type(empty_dict).__name__)
print(len(empty_set), len(empty_dict))
输出:
['blue', 'green', 'red']
set
['a', 'e', 'i', 'o', 'u']
[1, 2, 3]
set dict
0 0
注意 set("aeiou") 把字符串当字符序列处理,得到的是五个单字符元素的集合——想得到「整个字符串为一个元素」,要写 {"aeiou"}。另外,前面反复强调的无序在这里第一次具象化:{"red", "green", "blue"} 按字面量顺序写,打印时却按另一套顺序出现,这里用了 sorted() 才得到稳定的字母序列表。
去重与无序:现场演示
去重是集合「出圈」的头号能力,把列表丢进 set 再取出来,重复项消失:
words = ["apple", "banana", "apple", "cherry", "banana", "date"]
unique = set(words)
print(len(words), len(unique))
print(unique) # 直接打印:顺序不稳定,本机这轮是 date 开头
print(sorted(unique)) # 稳定展示:先排序再看
输出(本机 CPython 3.12.10 某次运行):
6 4
{'date', 'banana', 'apple', 'cherry'}
['apple', 'banana', 'cherry', 'date']
6 个词去重后只剩 4 个。直接打印 unique 时,{'date', 'banana', 'apple', 'cherry'} 只是这一次运行的布局结果——换个进程、换个 Python 版本,顺序多半不一样,甚至同一个程序跑两遍都可能不同。只有 sorted(unique) 的 ['apple', 'banana', 'cherry', 'date'] 是可靠的。这正是「无序」的真实含义:不能用「我上次打印是这个顺序」去推断任何行为。
如果只是统计唯一元素有多少个,有更省事的写法:len(set(words)) 一步到位,不需要存中间变量。
成员测试:O(1) 与 O(n) 的实测对比
x in s 在集合上走哈希查找(平均 O(1)),在列表上走线性扫描(O(n))。当数据量大、且元素位于列表尾部时,差距是数量级的。下面在本机实测:100 万元素的列表与集合,查同一个位于末尾的目标元素,重复查找取单次均值:
import timeit
n = 1_000_000
target = n - 1
data = list(range(n))
st = set(data)
# 列表里 target 在末尾,每次 in 都要从头扫到尾:只测 2000 次
t_list = timeit.timeit(lambda: target in data, number=2_000)
# 集合是哈希查找:测 100 万次才能量出稳定数值
t_set = timeit.timeit(lambda: target in st, number=1_000_000)
print(f"list 单次查找约 {t_list / 2_000 * 1e6:.0f} 微秒")
print(f"set 单次查找约 {t_set / 1_000_000 * 1e9:.0f} 纳秒")
print(f"差距约 {t_list / (2_000 * t_set / 1_000_000):.0f} 倍")
输出(本机 CPython 3.12.10,Intel 平台):
list 单次查找约 7473 微秒
set 单次查找约 50 纳秒
差距约 150448 倍
数字本身会随机器与负载波动,但量级是稳定的:列表每次要比较约 100 万个元素,单次毫秒级;集合单次 50 纳秒量级,差了 5 个数量级(约 15 万倍)。所以「这个 IP 来过吗」「这个 ID 是否被拉黑」这类高频判断,一律先建集合再查询;对列表做同样的事,数据一涨就肉眼可见地卡。反过来,如果只需要遍历数据、不查「在不在」,列表和集合无所谓——但集合还会顺带去重。
集合运算:| & - ^ 与对应方法
四个运算符对应四种经典集合运算:| 并集(union)、& 交集(intersection)、- 差集(difference)、^ 对称差(symmetric difference,只在其中一个集合里出现的元素)。算子版和方法版等价,| 可以连着用多个集合,- 可以连续减掉多个集合:
backend = {"python", "go", "java"}
frontend = {"javascript", "typescript", "java"}
print(sorted(backend | frontend)) # 并集:两边所有语言
print(sorted(backend & frontend)) # 交集:两边都会的语言
print(sorted(backend - frontend)) # 差集:只在 backend
print(sorted(frontend - backend)) # 反过来
print(sorted(backend ^ frontend)) # 对称差:只会一边的
print(sorted(backend.union(frontend))) # 方法版,与 | 相同
print(sorted(backend.intersection(frontend)))
print(sorted(backend.difference(frontend)))
print(sorted(backend.symmetric_difference(frontend)))
输出:
['go', 'java', 'javascript', 'python', 'typescript']
['java']
['go', 'python']
['javascript', 'typescript']
['go', 'javascript', 'python', 'typescript']
['go', 'java', 'javascript', 'python', 'typescript']
['java']
['go', 'python']
['go', 'javascript', 'python', 'typescript']
运算结果是新的集合,参与运算的两个集合原封不动。注意 - 不对称:backend - frontend 是「backend 有而 frontend 没有」,反过来是另一份名单。方法版与运算符完全等价,但有个隐藏差异:方法版(如 union())可以接受列表、元组等任意可迭代对象,运算符版要求两侧都是集合——实际用哪个看场景,运算符更直观、方法版更宽松。
子集、超集与不相交:issubset / issuperset / isdisjoint
关系判断是工程里常被忽略的一组方法:a.issubset(b)(a ⊆ b,a 的每个元素都在 b 里)、b.issuperset(a)(b ⊇ a,与上一句互为镜像)、a.isdisjoint(b)(两个集合没有公共元素)。运算符 <、<= 也能表达严格子集与子集,但方法名更自解释:
a = {1, 2, 3}
b = {1, 2, 3, 4, 5}
c = {3, 4, 5, 6}
print(a.issubset(b)) # a 的元素全在 b 里
print(b.issuperset(a)) # b 包含 a 的全部元素
print(a.issubset(a)) # 集合是自身的子集
print(a.isdisjoint(c)) # 有公共元素 3,所以不是不相交
print({1}.isdisjoint({2}))
print(a < b) # 严格子集(且两个集合不相等)
print(c > a)
输出:
True
True
True
False
True
True
False
权限系统里的典型用法:user_roles.issubset(allowed_roles) 判断「用户的所有角色都在允许列表内」;blacklist.isdisjoint(group_members) 快速排查一个群组是否含被拉黑的人——都是 O(1)~O(m) 的哈希操作,比嵌套 for 循环的 O(n×m) 快出几个数量级。
增删元素:add / remove / discard / pop
集合是可变容器,可以原地增删。四个方法的分工要记牢:add 加一个元素(重复加无副作用);remove 删一个元素,不存在时抛 KeyError;discard 删一个元素,不存在时安静返回;pop 弹出任意一个元素并返回它,空集合 pop 抛 KeyError:
tasks = {"backup", "cleanup"}
tasks.add("deploy") # 添加
print(sorted(tasks))
tasks.remove("backup") # 删除:元素必须在
print(sorted(tasks))
tasks.discard("cleanup") # 删除:在不在都行
tasks.discard("missing") # 不存在也不报错
print(sorted(tasks))
tasks.discard("deploy")
box = {"a"}
item = box.pop() # 弹出任意一个元素
print(item)
print(box)
输出:
['backup', 'cleanup', 'deploy']
['cleanup', 'deploy']
['deploy']
a
set()
remove 与 discard 的选择标准:如果「删不掉」是程序错误(比如任务本该存在),用 remove 让它立刻暴露 KeyError;如果删除只是「尽力而为」(比如清理过程数据),用 discard 免去 try/except。pop 常用于「把集合当待办池,处理完一件弹一件」,但不要假设pop 弹出的是「第一个」元素——集合没有第一个,弹出的具体是哪个由哈希布局决定,详见易错点。
frozenset:不可变的集合
frozenset(冻结集合)与 set 的元素规则完全一样,区别在开头那句话的镜像:它不可变——没有 add/remove/discard/pop,创建后只能查、只能做集合运算。代价换来的是它可哈希,因此可以放进另一个集合、或当字典的键:
fs = frozenset([3, 1, 2])
print(fs)
print(len(fs))
print(1 in fs) # 查询照常可用
try:
fs.add(4) # 冻结集合没有 add
except AttributeError as e:
print("AttributeError:", e)
d = {fs: "frozen key"} # 可哈希:能当字典键
print(d[fs])
输出:
frozenset({1, 2, 3})
3
True
AttributeError: 'frozenset' object has no attribute 'add'
frozen key
适用场景:想「以集合为键」做缓存(比如把一组标签集合映射到配置)、或者传给不可变数据栈(函数式写法,第十五章会展开)时,用 frozenset。日常去重和运算,set 就够。
易错点与陷阱
陷阱一:{} 是空字典,不是空集合
empty = {} 得到的是 dict,往里面 add 会 AttributeError。空集合必须 set()。这个坑的根源在语言历史:{} 的字面量先被 dict 占用,set 的字面量语法晚到(2.7/3.0),新语法不能破坏旧语义。检查类型是最快的排障手段:type(x) is set。
陷阱二:集合元素必须可哈希
列表、字典、set 本身不能当集合元素,{[1, 2]} 直接 TypeError: unhashable type。需要「一组列表」时,把列表换成元组或 frozenset 再进集合。这个限制不是 bug 而是设计:哈希查找要求元素在集合里的整个生命周期值不变,若允许可变元素,存入后被改掉,哈希槽位就对不上了,去重和查找全部失效。顺带提醒:自定义的类放进集合前,先想清楚它的 __hash__ 与 __eq__ 契约(概念部分提到过),这是集合相关 bug 的高发区。
陷阱三:remove 删不存在的元素会抛 KeyError
tasks.remove("missing") 不提醒就静默?不——它抛 KeyError 中断程序。想要「存在就删、不存在拉倒」必须用 discard。初学阶段最常见的现场是把 remove 当 discard 用,程序在边缘数据上崩溃。记住分工:remove 是「必须删掉」,discard 是「最好删掉」。
陷阱四:迭代集合时修改它
「边遍历边删」在列表上偶尔能忍(虽然也不推荐),在集合上是直接崩溃:
s = {1, 2, 3}
try:
for x in s:
s.remove(x)
except RuntimeError as e:
print("RuntimeError:", e)
输出:
RuntimeError: Set changed size during iteration
原因:哈希表的迭代器依赖表的内存布局,删除元素会让表收缩或重排(Python 采用「删除时标记、必要时重建」的策略),迭代器无法继续保证「下一个元素」有效,于是主动抛错而不是给脏数据。正确姿势是先取快照再删:for x in list(s): s.remove(x),或者干脆用集合运算一步到位:s = s - {要删的}。
陷阱五:拿「集合的打印顺序」当行为依据
字符串集合跨进程打印顺序可能不同(哈希随机化),同进程内也随插入历史变化。凡是把顺序当成契约的代码——比如「集合里第一个元素是默认选项」——都是定时炸弹。该排序就排序,该用列表就用列表。反过来,如果你需要「记住插入顺序的去重容器」,标准库的 dict 在 3.7+ 保证插入有序,拿 dict 当有序集合用也是常见技巧(第 7 篇会看到 collections 模块的正规军)。
小结
集合是 Python 内建容器里「以哈希表为唯一底层」的一族(与字典同源):元素唯一、无序、必须可哈希。它带来三个不可替代的能力:O(1) 的成员测试(实测比列表的线性扫描快约 5 个数量级)、自动去重、以及 | & - ^ 四则集合运算与 subset/superset/isdisjoint 关系判断。创建用字面量 {1, 2, 3} 或 set(可迭代对象),空集合只有 set();增删用 add/remove/discard/pop,注意 remove 与 discard 的语义差异;frozenset 是不可变量,可当键、可嵌套。打印集合永远用 sorted() 固定顺序。下一篇将聚焦「拷贝与引用」——集合这章的哈希视角会为那里「为什么可变对象共享会传染」提供底层直觉。
练习与思考题
clicked = ["A", "B", "A", "C", "B", "D"],用一行代码统计「点击过多少个不同按钮」,再找出「只点击过一次」的按钮(提示:结合^或比对计数)。- 判断两个字符串
s1 = "listen"、s2 = "silent"是否互为字母重排(anagram)。提示:用set(s1) == set(s2)够吗?想清楚「aaab」与「abbb」的反例——这引出第 7 篇的计数工具。 - 给定用户可访问的模块集合
allowed = {"report", "export", "admin"}与当前用户mine = {"report"},用一行代码判断「用户是否能访问 admin 模块」,并解释为什么这个判断不依赖任何顺序。 - 思考题:为什么集合的迭代顺序不稳定,而列表的索引访问顺序永远稳定?结合哈希表的存储方式回答;再想想:如果你往同一个集合里先
add("x")再add("y"),为什么也不能依赖「x 在 y 前面」。 - 挑战题:用 frozenset 实现「标签组合→优惠方案」的映射表,要求:任意三个标签的组合都能命中(或明确返回「无方案」),并说明为什么普通 set 无法完成这个需求。