Python中集合运算全面详解与实例演示
前言集合并、交、差、对称差这四类运算是 Python 里少见的「直接用数学符号写代码」的地方。a | b表示并集a b表示交集a - b表示差集a ^ b表示对称差集读起来和数学课本几乎一致。但真正写起来最容易出错的不是符号记不住而是分不清运算符版和方法版的区别。同一个并集a | b要求两边都是集合而a.union(b)可以吃任何可迭代对象前者对字符串会直接抛异常后者却能正常工作。还有一组*_update方法会原地修改集合返回None把它们当成「返回新集合」用就会得到一堆None。本文把四类运算、它们的运算符形式与方法形式、原地更新版本、以及子集/超集/无交集判断逐一对齐配上一份可运行的实例最后列出真会踩的坑。一、四类基本运算官方文档对四类运算的定义很清晰并集是「在 a 或 b 中的所有元素」交集是「a 和 b 共有的元素」差集是「在 a 中但不在 b 中的元素」对称差集是「只在其中一侧出现的元素」。运算运算符方法数学含义并集a \ba.union(b)交集a ba.intersection(b)A ∩ B差集a - ba.difference(b)A \ B对称差集a ^ ba.symmetric_difference(b)A △ B# 适用于 Python 3.8a {1, 2, 3, 4}b {3, 4, 5, 6}print(sorted(a | b)) # [1, 2, 3, 4, 5, 6] 并集print(sorted(a b)) # [3, 4] 交集print(sorted(a - b)) # [1, 2] 差集在 a 不在 bprint(sorted(b - a)) # [5, 6] 差集不对称print(sorted(a ^ b)) # [1, 2, 5, 6] 对称差集注意a - b和b - a结果不同而a ^ b和b ^ a结果相同——这也提示了对称性的差别。运算不改变操作数本身返回的都是新集合。这一点和后面的*_update系列正好相反。# 适用于 Python 3.8a {1, 2}b {2, 3}result a | bprint(sorted(a)) # [1, 2]a 没变print(sorted(b)) # [2, 3]b 没变print(sorted(result)) # [1, 2, 3]二、运算符版 vs 方法版参数类型的差别官方文档里有一句关键说明union()、intersection()、difference()、symmetric_difference()、issubset()、issuperset()这些非运算符版本接受任意可迭代对象作为参数而运算符版要求参数必须是集合。文档给出的理由也很直白——这是为了让set(abc).intersection(cbs)这种可读写法替代容易写错的set(abc) cbs。# 适用于 Python 3.8a {1, 2, 3}# 方法版参数可以是列表、字符串、range 等任意可迭代对象print(sorted(a.union([4, 5]))) # [1, 2, 3, 4, 5]print(sorted(a.intersection([2, 3, 9]))) # [2, 3]print(sorted(a.difference(range(2)))) # [2, 3]# 运算符版右边也必须是集合print(sorted(a | {4, 5})) # [1, 2, 3, 4, 5]try:a | [4, 5]except TypeError as e:print(报错, e) # unsupported operand type(s) for |: set and list方法版还有一个运算符版没有的能力并集、交集、差集的方法接受*others可以一次传多个可迭代对象。# 适用于 Python 3.8a {1, 2}print(sorted(a.union({3}, {4}, [5]))) # [1, 2, 3, 4, 5]print(sorted(a.intersection({1, 9}, [1, 2]))) # [1]print(sorted(a.difference({1}, [2]))) # []注意symmetric_difference()的参数是单个otherother, /不像前三个是*others。想连续做对称差只能一步步来。三、原地更新与原地运算符除了返回新集合的版本还有一组原地方法update()、intersection_update()、difference_update()、symmetric_difference_update()。它们直接修改调用者并返回None。对应的原地运算符是|、、-、^。方法运算符作用s.update(*others)s \ others.intersection_update(*others)s other只留交集s.difference_update(*others)s - other移除s.symmetric_difference_update(other)s ^ other只留单侧# 适用于 Python 3.8s {1, 2, 3}ret s.update([3, 4])print(ret) # None原地方法返回 Noneprint(sorted(s)) # [1, 2, 3, 4]s.intersection_update([2, 3, 4, 5])print(sorted(s)) # [2, 3, 4]s.difference_update({2})print(sorted(s)) # [3, 4]s.symmetric_difference_update({4, 5})print(sorted(s)) # [3, 5]方法版同样接受任意可迭代对象这一点和运算符版不同——s | [4, 5]会抛TypeError因为|的右边要求是集合。# 适用于 Python 3.8s {1, 2}s.update([3]) # 方法版可以吃列表print(sorted(s)) # [1, 2, 3]s2 {1, 2}try:s2 | [3]except TypeError as e:print(报错, e) # unsupported operand type(s) for |: set and list四、子集、超集与无交集关系判断有六个issubset子集、issuperset超集、isdisjoint无交集以及运算符、、、。其中和是「真子集」「真超集」要求两边不相等。# 适用于 Python 3.8small {1, 2}big {1, 2, 3}print(small.issubset(big)) # Trueprint(small big) # Trueprint(small big) # True真子集print(big.issuperset(small)) # Trueprint(big small) # Trueprint({1, 2} {1, 2}) # False不是真子集print({1, 2} {1, 2}) # True相等也算子集print(small.isdisjoint({9, 8})) # True没有共同元素print(small.isdisjoint(big)) # False有共同元素isdisjoint(other)的方法是唯一接受任意可迭代对象的——注意它没有运算符版本只能调方法。这个方法在「先判断有没有交集再决定要不要做昂贵处理」的场景里很好用。子集关系只能构成偏序而不是全序。文档明确指出两个不相交的非空集合既不相等也不互为子集所以a b、a b、a b会同时为False。由此得出的结论是——对一组集合排序是没有定义的行为list.sort()在元素是集合时结果未定义不要这么写。五、实例标签tag与权限运算下面是一个用集合运算做「文章标签筛选」的完整例子覆盖交集、差集和子集判断。# 适用于 Python 3.8ARTICLES {a1: {python, async, web},a2: {python, packaging},a3: {python, async, test},a4: {go, web},}def by_all_tags(*wanted):要求包含全部标签交集 需求集合。wanted set(wanted)return [k for k, tags in ARTICLES.items() if wanted tags]def by_any_tag(*wanted):只要包含任一标签即可。wanted set(wanted)return [k for k, tags in ARTICLES.items() if not wanted.isdisjoint(tags)]def by_exact_extra(*wanted):包含全部需求标签且没有额外标签。wanted set(wanted)return [k for k, tags in ARTICLES.items() if tags wanted]if __name__ __main__:print(含 python 和 async, by_all_tags(python, async))print(含 packaging 或 web, by_any_tag(packaging, web))print(恰好是 python,packaging, by_exact_extra(python, packaging))逐行推演by_all_tags(python, async)把wanted变成{python, async}然后对每篇文章判断wanted tags。a1的标签是{python, async, web}是超集命中a3同理命中a2缺async不命中。结果为[a1, a3]。by_any_tag(packaging, web)用isdisjoint取反a1有web、a2有packaging、a4有web命中a3两者都没有跳过。结果为[a1, a2, a4]。by_exact_extra(python, packaging)用相等判断只有a2的标签集合与{python, packaging}完全相同。结果为[a2]。六、运算结果类型与集合的等价有一类细节容易被忽略集合运算结果的类型。官方文档规定set与frozenset混用做二元运算时结果的类型取第一个操作数。# 适用于 Python 3.8print(type(frozenset({1}) | {2}).__name__) # frozensetprint(type({1} | frozenset({2})).__name__) # set另外set和frozenset之间按成员比较相等所以set(abc) frozenset(abc)返回True而且set(abc) in {frozenset(abc)}也为True。这条规则让「集合的集合」得以用frozenset表达。常见坑点坑点 1用运算符对非集合做运算。❌{1, 2} | [3]——运算符要求两边是集合抛TypeError。 ✅ 用方法版接收可迭代{1, 2}.union([3])得到{1, 2, 3}。坑点 2把原地方法的结果赋回去。❌s s.intersection_update(other)——原地方法返回Nones变成None。 ✅ 直接s.intersection_update(other)想要新对象就用s s.intersection(other)。坑点 3以为-也能吃列表。❌s - [1, 2]——原地运算符右边要求是集合抛TypeError。 ✅ 改成方法版s.difference_update([1, 2])。坑点 4把真子集和子集混为一谈。❌ 用a b判断「a 是否被 b 包含」——两个相等的集合a b返回False。 ✅ 要「包含即可」用a b或a.issubset(b)确实要排除相等才用a b。坑点 5对集合列表排序。❌sorted([{1}, {2}, {1, 2}])——集合只定义偏序官方明确说明排序结果是未定义的。 ✅ 先转成可全序比较的键比如sorted(sets, keylambda s: (len(s), sorted(s)))。坑点 6以为isdisjoint有运算符版本。❌a % b之类地去猜一个运算符——判断无交集只有方法a.isdisjoint(b)。 ✅ 老老实实调方法只想快速判断「有没有交集」也可以写not (a b)但要注意这会真的算一次交集数据大时不如isdisjoint直接。坑点 7对称差集当成差集。❌ 想求「在 a 不在 b」却写了a ^ b——^会把「在 b 不在 a」的部分也算进来。 ✅ 求单向差集用a - b只有确实要「两边独有的全部」时才用a ^ b。坑点 8忘了运算不改原集合就急着断言。❌ 写完a | b后直接用a以为a已经被并入——运算返回新集合a原封不动。 ✅ 想原地生效就用a | b或a.update(b)。总结需求推荐写法参数接受范围求并集不改原集合a.union(b)任意可迭代求交集不改原集合a.intersection(b)任意可迭代求差集不改原集合a.difference(b)任意可迭代求对称差不改原集合a.symmetric_difference(b)任意可迭代原地并入a.update(b)或 a \ b判包含a.issubset(b)或a b方法任意可迭代判无交集a.isdisjoint(b)任意可迭代无运算符版集合运算的记忆口诀是要新对象用方法或运算符要原地生效用*_update或原地运算符运算符只认集合方法什么可迭代都吃。把这条分清|、、-、^四兄弟就不会再咬人了。