集合

用可哈希元素表达唯一成员、成员关系与集合代数,并避开无序输出、输入突变和相等值合并等陷阱。

难度 入门 时长 标准深度约 14分钟
版本 Python 3.14
what

set 保存互不相等的可哈希元素,适合表达唯一成员、成员关系以及并集、交集、差集等集合运算。

trap

集合没有可依赖的迭代顺序,而且相等且哈希值相同的对象会合并;把列表直接转成集合可能同时丢失顺序和出现次数。

fix

需要稳定输出时显式排序,需要保留顺序或计数时选择列表、字典或 Counter;修改集合前先确认是否允许改变原对象。

是什么,为什么存在

Python 集合是一组互不相等的 可哈希元素(hashable element) set 可以修改,frozenset 不能修改;两者都不支持按位置索引,也不承诺迭代顺序。集合字面量会自动合并重复项,但这种合并只保留成员关系,不保留原始位置或出现次数。

集合解决的是「某个值是否属于这一组」以及「两组成员如何组合」的问题。权限名、功能开关、已访问节点和两份配置的键,通常都天然是集合。订单序列、事件日志或排行榜则不是,因为这些数据的顺序和重复次数本身有意义。

集合运算把容易写错的嵌套循环变成明确的代数。required - granted 表示缺少的权限,old & new 表示两边共有的成员,left ^ right 表示只出现在一边的成员。表达式说明了关系,也迫使你先决定结果是否应当去重。

set 与其他常见容器的边界如下。这里的「无顺序」不是说每次迭代一定变化,而是说程序不能把观察到的顺序当成契约。

特性setfrozensetlist
元素是否唯一
是否承诺顺序
能否修改成员
能否作为集合元素

工作原理

集合用元素的哈希值和相等关系组织成员。插入或查询对象时,Python 先取得哈希值,再在可能匹配的位置检查相等性。两个对象只有在比较相等并满足相同哈希约束时才代表同一个成员;哈希碰撞本身不会让不同对象被错误合并。

创建非空集合可以使用 {value1, value2},创建空集合必须使用 set(),因为 {} 表示空字典。set(iterable) 会消费一个 可迭代对象(iterable) 并加入其中的成员。集合推导式 {transform(item) for item in source if condition(item)} 则把转换、筛选和去重放在一次构造中。

构造与复制

集合字面量会先计算各个元素表达式,再按哈希与相等规则加入成员。某个元素不可哈希时,构造立即抛出 TypeError,不会返回一个只包含前面元素的集合。推导式也可能在处理到不可哈希结果时中止。

set(source) 的输入遵循普通迭代规则,而不是为每种容器猜测业务含义。字符串会产出字符,字典会产出键,字典视图则按其视图类型产出键、值或键值对。调用者应明确传入的迭代单位是否就是集合成员。

  • set(existing_set) 创建一个新的可变集合。
  • frozenset(existing_frozenset) 可以返回原来的不可变对象。
  • set(mapping) 收集映射的键,不收集值。
  • 集合构造是浅层的,不会复制成员对象。

浅层构造意味着新旧集合的成员关系可以独立变化,但两边可能仍引用同一个成员对象。由于成员必须可哈希,内置可变容器不能直接成为这种共享成员;默认按对象标识哈希的自定义实例仍可能出现这种别名关系。

修改成员

set 的修改方法作用于原对象。任何指向该对象的别名都会观察到变化,所以选择原地方法之前要先确认所有权。

操作含义缺失成员时的行为
items.add(value)加入一个成员不适用
items.update(values)加入一个或多个可迭代对象中的成员不适用
items.remove(value)删除指定成员抛出 KeyError
items.discard(value)删除指定成员不执行操作
items.pop()删除并返回任意成员空集合抛出 KeyError
items.clear()删除全部成员不适用

remove() 适合把「成员本应存在」当成不变量的代码,因为缺失会暴露错误。discard() 适合幂等清理,即重复执行删除仍得到相同最终状态。pop() 返回的是任意成员,不能把它当成队列或栈操作。

集合代数

四个二元运算都返回新集合,不修改左右操作数。差集有方向,对称差集没有方向。

表达式方法形式结果
`leftright`left.union(right)
left & rightleft.intersection(right)两边共有的成员
left - rightleft.difference(right)只在左边的成员
left ^ rightleft.symmetric_difference(right)只在其中一边的成员

对应的 update()intersection_update()difference_update()symmetric_difference_update() 会原地修改接收者。|=&=-=^= 也表达原地修改。函数接收调用方集合时,除非契约明确允许,否则应优先返回新集合。

关系与操作数

left <= right 判断子集,left < right 判断真子集;>=> 分别判断超集与真超集。left.isdisjoint(right) 在两边没有共同成员时返回 True。集合只定义部分顺序,因此 sorted() 才表示元素排序,< 并不表示字典序比较。

方法形式与运算符形式对操作数的要求不同。items.intersection(sequence) 可以接收任意可迭代对象,而 items & sequence 要求另一边也是集合类型。这一区别能让方法避免不必要的转换,也能让运算符及时暴露把列表误当集合的错误。

setfrozenset 混合进行二元运算时,结果类型跟随左操作数。mutable | frozen 得到 setfrozen | mutable 得到 frozenset。如果结果随后要作为字典键或另一个集合的成员,应让不可变集合位于左侧,或显式构造 frozenset

setfrozenset 的选择

需要逐步加入、撤销或同步成员时使用 set。如果成员集合构造后不应改变,或者它本身要成为字典键或集合元素,就使用 frozenset。冻结只阻止成员集合变化,并不会递归复制或冻结成员对象。

两种类型共享非修改型运算和关系判断。把公开配置暴露为 frozenset 可以表达「调用者不能通过这个接口增删成员」,但它不能替代领域验证,也不能自动让成员指向的对象变成只读。

示例

下面四个例子从基本构造逐步走到差异计算、策略检查和不可变集合。凡是要显示集合内容,代码都会先排序,因此记录的输出不依赖本次进程采用的集合迭代顺序。

去重与成员测试

这个例子把访问记录转换成唯一客户 ID 集合。原始列表仍保留请求次数和到达顺序,集合只负责唯一成员和成员测试。

unique_customers.py
visits = [
    "c-101",
    "c-102",
    "c-101",
    "c-103",
    "c-102",
    "c-104",
]

unique_customer_ids = set(visits)

# 对输出排序,避免依赖集合迭代顺序。
print("requests:", len(visits))
print("unique customers:", sorted(unique_customer_ids))
print("c-103 seen:", "c-103" in unique_customer_ids)

empty_customer_ids = set()
print("empty type:", type(empty_customer_ids).__name__)
requests: 6
unique customers: ['c-101', 'c-102', 'c-103', 'c-104']
c-103 seen: True
empty type: set

len(visits) 是六,因为列表保留重复请求;len(unique_customer_ids) 如果读取则会是四。两种容器回答不同问题,所以不要在完成计数前覆盖原始列表。

空集合使用 set()。如果写成 {},最后一行会显示 dict,后续调用 add() 时才暴露类型错误。

计算部署差异

集合差异很适合描述期望状态与当前状态之间的迁移。方向写在变量名和表达式中,比单独保留一个对称差集更能说明下一步动作。

deployment_diff.py
deployed = {"search", "billing", "profile"}
desired = {"billing", "profile", "reports"}

added = desired - deployed
removed = deployed - desired
unchanged = deployed & desired
changed = deployed ^ desired

# 计算返回新集合,两个输入保持不变。
print("added:", sorted(added))
print("removed:", sorted(removed))
print("unchanged:", sorted(unchanged))
print("changed:", sorted(changed))
print("reports ready:", {"billing", "reports"} <= desired)
added: ['reports']
removed: ['search']
unchanged: ['billing', 'profile']
changed: ['reports', 'search']
reports ready: True

desired - deployed 是要添加的服务,反向差集则是要移除的服务。changed 适合回答「哪些成员不同」,却不能单独告诉部署器应该添加还是删除。

最后的 <= 判断一组依赖是否都在期望状态中。它允许左右集合相等;如果业务要求右边还必须包含其他服务,才应使用真子集运算符 <

检查访问策略

函数在边界处把输入转成集合,再分别计算缺失权限与冲突权限。返回值使用排序列表,让日志、测试和 API 响应保持稳定。

access_policy.py
def evaluate_access(granted, required, blocked):
    granted_set = set(granted)
    required_set = set(required)
    blocked_set = set(blocked)

    missing = required_set - granted_set
    conflicts = granted_set & blocked_set
    return {
        "missing": sorted(missing),
        "conflicts": sorted(conflicts),
        "allowed": not missing and not conflicts,
    }


result = evaluate_access(
    ["invoice.read", "invoice.export", "account.suspend"],
    ["invoice.read", "invoice.refund"],
    ["account.suspend"],
)

print("missing:", result["missing"])
print("conflicts:", result["conflicts"])
print("allowed:", result["allowed"])
missing: ['invoice.refund']
conflicts: ['account.suspend']
allowed: False

required_set - granted_set 保留方向信息:它只列出调用方还缺什么。若改用对称差集,额外授予但并未禁止的权限也会混进结果,改变策略含义。

这个函数没有修改三个输入。即使调用方传入的本来就是集合,边界处的 set(...) 也会创建浅副本,使函数内部的后续修改不会泄漏给调用方。

frozenset 表示无序组合

一个无向组合没有「第一个」和「第二个」成员。frozenset 同时表达无序、唯一和不可修改,因此可以直接作为映射键。

frozen_groups.py
route_owners = {
    frozenset({"billing", "exports"}): "finance-platform",
    frozenset({"search", "catalog"}): "discovery",
}

requested_route = frozenset({"exports", "billing"})
print("owner:", route_owners[requested_route])

mutable_group = {"billing"}
frozen_group = frozenset(mutable_group)
mutable_group.add("audit")

print("frozen:", sorted(frozen_group))
print("mutable:", sorted(mutable_group))
print("mixed type:", type(frozen_group | {"audit"}).__name__)
print("equivalent count:", len({True, 1, 1.0}))
owner: finance-platform
frozen: ['billing']
mutable: ['audit', 'billing']
mixed type: frozenset
equivalent count: 1

构造 frozen_group 会复制当时的成员,所以之后修改 mutable_group 不会改变它。混合运算的左操作数是 frozenset,结果也保持为 frozenset

最后一行展示了集合依赖相等与哈希,而不是依赖类型名称。True == 1 == 1.0,这些数值也满足相等对象的哈希约束,所以集合只保留一个成员。

陷阱

{} 创建空集合

修复: 使用 set(),并在类型影响控制流时测试空输入。非空的 {member} 才是集合字面量;字典字面量包含冒号,例如 {"id": 1}

把「不可变」当成「可哈希」

修复: 按可哈希协议判断:对象生命周期内哈希值必须不变,而且相等对象必须具有相同哈希值。嵌套成员集合可以使用 frozenset;结构化记录应选择明确、稳定的不可变键,而不是随意把外层换成元组。

把迭代顺序当成输出契约

修复: 可比较的元素使用 sorted(a_set) 生成稳定输出;元素不能直接比较时,提供明确的排序键。若要求保留首次出现顺序,应遍历原序列并用辅助集合记录已见键,不能先转换整个序列。

写反差集或意外原地修改

修复: 使用 added = desired - currentremoved = current - desired 这类带方向的名称。函数接收外部集合时默认使用非原地运算;只有契约声明所有权转移或允许修改时才使用更新运算。

迭代时改变集合大小

修复: 用集合推导式构造新结果,先计算待删除集合再执行差集更新,或迭代 members.copy()。需要暴露意外缺失时保留 remove();只有缺失本来就允许时才用 discard()

丢失次数和相等类型

修复: 先写清楚唯一性的键和所需输出。要计数时使用 collections.Counter;要按业务键保留第一条记录时,遍历原序列并维护 seen_keys;类型必须区分时,把类型标签纳入键并测试跨类型相等值。

深入 哈希与相等性

哈希与相等性

可哈希对象必须提供一个在其生命周期内保持不变的哈希值,并且能够参与相等比较。两个比较相等的对象必须返回相同哈希值,反过来却不成立:不同对象允许发生哈希碰撞。集合会在哈希定位之后继续用相等性区分这些对象。

「内置不可变对象通常可哈希」比「不可变等于可哈希」更准确。数字、字符串和只含可哈希成员的元组可以进入集合;列表和字典等可变容器不可以。元组本身没有修改方法,但只要其中包含列表,它就仍然不可哈希。

用户自定义类增加了另一层约束。类实例默认通常按对象标识参与哈希与相等;一旦实现基于字段的 __eq__(),就必须一起设计与它一致的 __hash__()。绝不能在对象已经成为集合成员后,修改参与哈希或相等判断的字段,否则查询和删除可能再也找不到逻辑上仍在集合里的对象。

集合判断的是值是否相等,不要求类型相同。boolint 的子类,True1 比较相等;整数 1 与浮点数 1.0 也比较相等。它们满足相同哈希约束,因此放进同一个集合时只形成一个成员。领域需要区分这些输入时,可以使用 (type(value), value) 这样的复合键,但先要确认类型对象本身就是预期的领域标签。

frozenset 的不可变性只覆盖成员关系:创建后不能加入或删除成员。构造它时,所有成员仍须可哈希。一个默认按对象标识哈希的自定义实例可能拥有可变属性,所以 frozenset 不能保证整个对象图深度不可变。

集合之间的相等只比较成员,不比较可变性。因此,成员相同的 setfrozenset 可以比较相等,但只有 frozenset 能作为映射键或集合成员。选择类型时应根据所有权和后续用途,而不是期望两者表达不同的数学值。

集合形数据的边界

集合最适合作为程序内部的关系模型,但外部输入与输出通常是序列或对象。边界代码需要决定如何处理重复项、顺序、错误和所有权;直接调用 set(...) 只是实现选择,不是完整的数据契约。

规范化输入

先验证还是先去重会产生不同结果。如果重复权限表示上游配置错误,立即转换为集合会掩盖该错误;如果重复事件应累计次数,转换会永久丢失计数。只有在重复确实没有领域含义时,入口处去重才正确。

规范化还应发生在建立集合之前。邮箱、文件扩展名或不区分大小写的标识符,可能要先去除空白并统一大小写。否则,语法不同但领域等价的输入会成为不同成员。

  • 声明空输入是有效空集合,还是缺失配置。
  • 声明重复项应合并、计数,还是拒绝。
  • 声明大小写、空白和 Unicode 规范化规则。
  • 在转换前报告无法哈希或格式无效的成员。

错误信息应指向原始输入位置,而不是只转发 unhashable type。先枚举并验证记录,再构造集合,通常比在一个推导式中同时解析、转换和去重更容易给出有用诊断。

稳定的外部表示

集合没有 JSON 原生表示,因为 JSON 数组有顺序并允许重复。把集合暴露为数组时,API 必须额外声明顺序:按值排序、按领域键排序,或保留另一份原始序列的首次出现顺序。客户端不应从一次响应猜测规则。

边界需求合适的表示
只需成员关系内部使用 set
稳定且可比较的输出sorted(members)
保留首次出现顺序原序列加 seen 集合
需要出现次数Counter 或显式映射
无序组合作为键内部使用 frozenset

缓存键需要同样的明确性。成员已经可哈希且无序组合就是业务含义时,frozenset 是合适的进程内键;跨进程存储时,应使用带排序和编码规则的稳定序列化格式,不能依赖 Python 的集合显示文本。

返回值与所有权

返回内部 set 会把修改能力交给调用方。调用方执行 clear()|= 后,拥有该内部集合的对象也可能被改变。需要快照语义时返回副本,需要只读且可哈希的成员快照时返回 frozenset

返回副本只隔离外层成员关系,不复制成员对象。成员对象有可变属性时,调用方仍可能通过这些对象改变共享状态。API 必须分别说明集合本身和成员对象的所有权。

  • 参数是否会被原地修改。
  • 返回值是否与内部状态共享同一个集合对象。
  • 成员对象是否由调用方继续拥有。
  • 并发访问时由哪一层负责同步。

类型注解 set[str]frozenset[str] 能表达成员类型和可变性意图,但普通 Python 调用不会在运行时执行这些检查。外部数据仍需显式验证,内部接口仍需测试别名和修改行为。

选择唯一性键

记录通常不能整条放进集合,而且「整条记录相等」往往也不是业务上的重复定义。客户可能按规范化邮箱唯一,订单可能按订单 ID 唯一,文件可能按内容摘要唯一。选择键时要写出哪些字段参与相等判断,以及发生冲突时保留哪一条记录。

保序去重通常维护一个键集合和一个结果列表。遍历每条记录,计算并验证键;键未出现时,同时加入 seen_keys 和结果。这个结构保留首次出现位置,也为「保留最后一条」或「重复即报错」留下明确的修改点。

不要只用样例中的普通字符串验证唯一性代码。空字符串、缺失键、大小写差异、规范化后碰撞以及不同 Python 类型之间的相等,都可能改变最终成员数。测试应直接断言冲突策略,而不只是断言结果长度。

延伸阅读

以下链接均指向本主题验证时使用的 Python 3.14 官方文档。

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

下一篇 Collections 即将上线 列表推导式 元组
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?