AI 技术博客
返回首页
Python 基础 20 分钟阅读

集合:去重、成员测试与集合运算

Python集合哈希表去重

引言

这是第六章「列表、元组与字典」的第 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() 固定顺序。下一篇将聚焦「拷贝与引用」——集合这章的哈希视角会为那里「为什么可变对象共享会传染」提供底层直觉。

练习与思考题

  1. clicked = ["A", "B", "A", "C", "B", "D"],用一行代码统计「点击过多少个不同按钮」,再找出「只点击过一次」的按钮(提示:结合 ^ 或比对计数)。
  2. 判断两个字符串 s1 = "listen"、s2 = "silent" 是否互为字母重排(anagram)。提示:用 set(s1) == set(s2) 够吗?想清楚「aaab」与「abbb」的反例——这引出第 7 篇的计数工具。
  3. 给定用户可访问的模块集合 allowed = {"report", "export", "admin"} 与当前用户 mine = {"report"},用一行代码判断「用户是否能访问 admin 模块」,并解释为什么这个判断不依赖任何顺序。
  4. 思考题:为什么集合的迭代顺序不稳定,而列表的索引访问顺序永远稳定?结合哈希表的存储方式回答;再想想:如果你往同一个集合里先 add("x") 再 add("y"),为什么也不能依赖「x 在 y 前面」。
  5. 挑战题:用 frozenset 实现「标签组合→优惠方案」的映射表,要求:任意三个标签的组合都能命中(或明确返回「无方案」),并说明为什么普通 set 无法完成这个需求。