☰
Python集合交集完全指南:intersection()用法、性能与实战
2026/10/5 3:33:20 网站建设 项目流程

处理数据时最常遇到的一类需求就是找交集:两个列表里都有哪些元素、两个人群里哪些用户同时出现。Python 的集合(set)为此专门提供了一个方法叫intersection(),一行代码就能算完任意多个集合的共同元素。我最早处理这类需求时习惯写双层 for 循环,后来在十万级数据上被性能打脸,才彻底改用 set 的这套集合运算。这篇把intersection()从用法、运算符关系、性能原理到实际坑位完整过一遍,新手能直接照着写,老手也能看看底层实现和边界条件。

1. 为什么找交集这件事,值得用集合而不是循环

1.1 新手的自然解法:两层循环与去重地狱

先还原一个很常见的场景。你拿到了两个列表,一个是用户 ID,一个是另一张表里的用户 ID,需求是找出两边同时出现的元素。很多人的第一反应是:

list_a = [1, 2, 3, 4, 5, 4, 3] list_b = [4, 5, 6, 7, 8] common = [] for x in list_a: if x in list_b and x not in common: common.append(x) print(common) # [4, 5]

这段代码在逻辑上没错,但它同时踩了三个低效的坑。

第一,x in list_b这一步是在列表里做线性扫描。列表不是为查询设计的,Python 的 list 是顺序表,判断一个元素在不在里面,最坏情况要遍历整个列表。外层再套一层for x in list_a,整体复杂度就是 O(n*m)。当两边数据量都是几万的时候,这段代码跑起来会肉眼可见地卡。

第二,由于list_a自身可能有重复元素,你还得额外用x not in common去重,而这个去重操作本身又对common做了一次线性扫描,复杂度进一步恶化。

第三,代码读起来并不直观。如果你不写注释,别人要看一会儿才知道你是在"求交集"。

这时候如果你知道 set 的存在,情况会完全不一样。

1.2 集合的哈希表结构让“成员判断”变成 O(1)

Python 的 set 底层是哈希表,也就是把每个元素通过哈希函数映射到一个槽位上。判断一个元素在不在集合里,平均只需要 O(1) 的时间,相当于你直接通过索引去书架上拿一本书,而不是一本一本翻。

哈希表还顺带解决了一个问题:集合天然不允许重复。同一个元素哈希到同一个位置,重复插入会直接被忽略。所以set([1, 2, 2, 3])的结果是{1, 2, 3}。

这两个特性叠加在"找交集"这个需求上非常合适:交集本身就是"去重后的共同元素",而判断"某个元素在不在另一个集合里"又恰好是哈希查找的强项。

1.3 intersection() 真正解决的是“语义表达”与“性能”两个问题

intersection()这个方法的意义不止是省掉几行代码。它把一个复杂的算法逻辑,封装成了一个语义极其清晰的方法名:intersection,交集。

同样的需求,用集合写出来是这样:

list_a = [1, 2, 3, 4, 5, 4, 3] list_b = [4, 5, 6, 7, 8] common = set(list_a).intersection(list_b) print(common) # {4, 5}

如果你需要列表结果,再包一层list()就行。相比前面的双层循环,这个写法既没有重复元素的问题,也不需要在循环里手动维护结果列表。更关键的是,set(list_a)建立哈希表后,后续的成员判断、交集计算都是 C 语言层面的集合运算,性能远好于 Python 层的手写循环。

从这一节开始,后面所有讨论都围绕intersection()方法本身展开,包括基础用法、参数限制、运算符差异、底层性能和最常见的坑。

2. 基础用法拆解:传参、返回值与四种调用姿势

2.1 最小用例:A.intersection(B)

intersection()的官方定义是:返回一个新集合,包含该集合与所有传入集合共有的元素。

最简单的调用是传一个参数:

a = {1, 2, 3, 4} b = {2, 3, 4, 5} result = a.intersection(b) print(result) # {2, 3, 4} print(a) # {1, 2, 3, 4},原集合没有被修改 print(b) # {2, 3, 4, 5}

注意两点。

一是方法名是intersection,不是intersect,也不是common。拼写对新手来说容易踩,我见过有人写a.intersect(b)直接报AttributeError。

二是这个方法不会修改原来的集合。它在内部计算完结果后,返回的是一个全新的 set 对象。谁调用它、谁的数据都不会被破坏。这一点和下面要讲的intersection_update()完全不同。

2.2 一次传多个集合,避免链式运算的临时对象

很多人不知道intersection()支持一次性传入多个集合:

a = {1, 2, 3, 4} b = {2, 3, 4, 5} c = {3, 4, 5, 6} print(a.intersection(b, c)) # {3, 4} print(a & b & c) # {3, 4}

从结果上看,a.intersection(b, c)等价于a & b & c,但背后的执行方式有区别。

a & b & c是左结合的,Python 会先计算a & b生成一个临时集合,再拿这个临时集合去和c做运算。每一步都会产生中间对象。

a.intersection(b, c)是直接在内部对b和c做多路过滤,一次性得到最终结果,不会为b、c分别生成临时 set。数据量大的时候,这种写法更省内存。

2.3 参数不一定是 set:list、tuple、str、dict 都可以

这是intersection()容易被忽视的一个点。虽然方法叫"集合的交集",但它的参数可以是任意可迭代对象:

s = {1, 2, 3} print(s.intersection([2, 3, 4])) # {2, 3},list 可以 print(s.intersection((3, 4))) # {3},tuple 可以 print(s.intersection(range(2, 5))) # {2, 3},range 可以

字符串也可以,但要注意字符串迭代的单位是"单个字符":

t = {"a", "b", "c"} print(t.intersection("bcd")) # {"b", "c"}

如果你原本想把"bcd"当成一个整体元素去比较,这个结果会吓你一跳。因为"bcd"被拆成了'b'、'c'、'd'三个字符,分别判断在不在t里。

字典作为参数时,默认迭代的是它的键:

u = {"x", "y"} print(u.intersection({"x": 1, "z": 2})) # {"x"}

这里比较的是字典的键"x"、"z",而不是值1、2。如果你需要比较字典的值,得先手动取出来,比如set(d.values())。

2.4 返回的是新集合,原集合保持不变

我再用id()做一个更直观的验证,避免有人看到结果类似就以为是同一个对象:

a = {1, 2, 3} b = {2, 3, 4} r = a.intersection(b) print(r) # {2, 3} print(r is a) # False print(r is b) # False print(a is b) # False

结果r是一个全新对象。所以如果你既需要保留原数据集,又需要拿到交集结果,可以放心用intersection(),两边不冲突。

如果调用时不传任何参数,返回的是原集合的一个副本:

s = {1, 2, 3} print(s.intersection()) # {1, 2, 3}

这种情况实际用得少,但要知道它的存在,避免在函数默认参数场景下产生误解。

3. 与 & 运算符的关系:底层相通,边界条件并不相同

3.1 符号版本的底层回调

Python 里a & b本质上会调用a .__and__(b)。set 实现了这个魔法方法,内部执行的交集逻辑和a.intersection(b)指向的是同一套核心算法。

所以日常写代码时,「看个人习惯」二选一就好:

a = {1, 2, 3} b = {2, 3, 4} print(a & b) # {2, 3} print(a.intersection(b)) # {2, 3}

两者在绝大多数常见场景下结果一致,性能也没有本质差别。

但如果你把这两个写法当成"完全等价",就掉进陷阱了。

3.2 关键差异:运算符两边必须都是 set,方法可以吃任意可迭代对象

这是&运算符和intersection()最明显的分界线。

&运算符要求两边的操作数都是 set 或 frozenset。如果你把一个 list 放在右边,会直接报错:

a = {1, 2, 3} try: print(a & [2, 3, 4]) except TypeError as exc: print(exc)

输出:

unsupported operand type(s) for &: 'set' and 'list'

原因很简单:set 在实现__and__时,规定参数必须是另外一个 set 对象;list 没有对应的__rand__来配合反向运算,所以两边都对不上。

而intersection()方法则宽容得多,它内部会先对参数做可迭代对象转换,再逐个元素判断。list、tuple、range、字符串、dict、生成器都可以直接往里塞:

a = {1, 2, 3} print(a.intersection([2, 3, 4])) # {2, 3}

这个差异在实际代码里的意义是:如果你手里的数据本身就是 list,用intersection()可以少写一层set()转换。但如果你写的是运算符版本,就老老实实先转成集合。

3.3 运算符优先级和括号问题:一次代码 review 里的教训

&的优先级高于==、in这类比较运算符。这个特性在写复杂条件表达式时非常容易埋雷。

举个例子,你想判断两个集合的交集是否为空,新手可能随手写成:

a = {1, 2} b = {3, 4} if a & b == set(): print("没有交集")

这段代码实际解析为(a & b) == set(),因为&的优先级高于==。这里碰巧符合意图,所以没有出错。

但如果你写的是:

if a & b == c: print("交集等于 c")

阅读代码的人很可能会误以为是a & (b == c),虽然 Python 实际执行的是(a & b) == c。这种歧义在团队协作里是隐形的坑。

我经历过一次线上 review,有同事写了类似这样的判断:

if user_a.roles & user_b.roles == admin_role:

它确实等价于(user_a.roles & user_b.roles) == admin_role,功能上没错。但后来同事自己 debug 时,一度以为自己是拿user_a.roles去和user_b.roles == admin_role这个布尔值做与运算,绕了好一阵。问题不在运算符,在于代码的可读性。

我的建议是:只要&和比较运算符同时出现在一个表达式里,一律加括号。哪怕你知道优先级,也要假设看代码的人不一定知道。

4. 实战场景:用户筛选、文本关键词与权限交集

4.1 用户运营里的“付费且活跃”人群

运营系统里最典型的交集需求:找出同时满足两个条件的用户。比如「在近 30 天内活跃过」且「购买过会员」的用户,这批人适合做精准召回。

假设数据存在数据库里,查出来是两个大列表:

paid_user_ids = ["u001", "u002", "u003", "u004"] active_user_ids = ["u002", "u004", "u005", "u006"] targets = set(paid_user_ids).intersection(active_user_ids) print(targets) # {'u002', 'u004'}

如果这里用双层循环去筛,几万用户列表可能就要卡顿。转成 set 再求交集,基本是毫秒级。

有人会问:为什么不在 SQL 里直接做 INNER JOIN?因为很多场景下两个数据源不一定在同一个库里。比如一个来自订单库,一个来自埋点日志库,拉出来在 Python 层做集合运算反而是最省事的方式。

4.2 多篇文档的公共关键词

做文本分析时,常需要看两篇文章共同出现了哪些词。把每篇文章分词后去重成一个 set,然后求交集就行:

def get_words(text): return set(text.lower().replace(".", "").replace(",", "").split()) doc1 = "Python set intersection performance guide" doc2 = "Python set operations intersection union difference" common = get_words(doc1).intersection(get_words(doc2)) print(common) # {'python', 'set'},intersection 也算一个词,取决于分词规则

如果是一次给多篇文章求"所有人共同出现的词",intersection()的多参数能力就派上用场了:

common = get_words(doc1).intersection( get_words(doc2), get_words(doc3), get_words(doc4), )

按我的经验,这种写法非常适合做「文档去重」和「子主题关键词筛选」。比如从 100 篇文章里找出始终被反复提及的词,这些词往往能代表这批文本的核心主题。

4.3 RBAC 权限模型中的多重角色交集

在 RBAC(基于角色的访问控制)系统里,一个用户可能会有多个角色,每个角色对应一组权限。有时某个操作要求用户同时具备两个角色才允许访问,这时要计算用户权限的交集:

role_editor = {"read", "edit", "comment"} role_vip = {"read", "vip_badge", "comment"} # 假设用户同时拥有这两个角色 user_permissions = role_editor & role_vip print(user_permissions) # {'read', 'comment'}

如果后端逻辑校验的是「用户权限是否覆盖某个操作所需权限」,通常还会配合issubset():

required = {"read", "comment"} if required.issubset(user_permissions): print("允许访问")

这里intersection()更多是用于计算多重角色叠加后的公共权限,而issubset()用于做最终授权判断。两者配合使用,权限模型可以写得非常清晰。

顺便给一个常用集合运算的速查表,方便随手查阅:

运算目的运算符写法方法写法
交集a & ba.intersection(b)
并集a | ba.union(b)
差集a - ba.difference(b)
对称差集a ^ ba.symmetric_difference(b)
子集判断a <= ba.issubset(b)
超集判断a >= ba.issuperset(b)

5. 性能分析:谁小遍历谁,预分配只做一次

5.1 平均时间复杂度是 O(min(len(A), len(B)))

两个集合求交集,标准做法是:遍历较的那个集合,对每个元素在较大的集合中做一次哈希查找。

所以平均时间复杂度是 O(min(len(A), len(B)))。这个结论有点反直觉,很多人以为是 O(len(A) + len(B)) 或者 O(len(A) * len(B)),实际上只需要遍历小集合就够了。

一个具体的推导过程:A = {1, 2, 3},B = {2, 3, 4, 5, 6, 7}。A 里有 3 个元素,B 里有 6 个。只需要拿 A 的 3 个元素分别去 B 里查是否在,查到2和3属于共同元素,整个过程 3 次哈希查找。反过来拿 B 的 6 个元素查 A,代价翻倍,没必要。

5.2 setobject.c 里的真实策略:小集合遍历 + 结果集容量预分配

如果你看过 CPython 的setobject.c源码,会发现set_intersection的实现思路和我上面说的完全一致。

它在处理两个 set 时,会比较两个集合的大小,选较小的那个作为遍历对象。对较小集合中的每个元素,去较大集合里做哈希查找,命中就加入结果集合。

还有一步容易被忽略的优化:结果集合在开始插入元素之前,会预先分配容量。预分配的容量大约是较小集合的大小。这样做的原因是为了避免插入过程中频繁触发哈希表的扩容机制,让内存分配只做一次。

多参数场景会走set_intersection_multi_iter之类的逻辑。流程大概是:先拿第一个参数作为标准交集,然后逐个处理剩余参数,每处理一个参数就过滤一轮。由于每过滤一轮,结果集合的大小都会缩小,所以后续比较「谁大谁小」时,遍历的成本会更低。

5.3 实测:10 万级数据上 intersection 比列表推导快多少

我用一个接近实际的例子做了对比:

import timeit a = set(range(100_000)) b = set(range(50_000, 150_000)) def via_intersection(): return a.intersection(b) def via_comprehension(): return {x for x in a if x in b} t1 = timeit.timeit(via_intersection, number=1000) t2 = timeit.timeit(via_comprehension, number=1000) print(f"intersection: {t1:.4f}s") print(f"set comprehension: {t2:.4f}s") print(f"ratio: {t2 / t1:.2f}x")

在我本机(普通笔记本,Python 3.11)上是这样的趋势:a.intersection(b)耗时大约在 0.5 秒级别(跑 1000 次),而{x for x in a if x in b}要慢 2 到 4 倍左右。

差距的原因主要有两个:

一是intersection()的循环在 C 语言层面执行,而列表推导虽然也是 C 层迭代,但循环体里的每个元素访问、哈希查找都要和 Python 对象层交互,开销更高。

二是intersection()做了容量预分配,省去了重复扩容。

说实话,在几百万级以内的数据量上,这两个写法的绝对时间差不会造成明显体感差异。但如果这是一个被循环调用几千次的接口,累积起来就相当可观了。

5.4 什么时候不该用集合求交集

集合求交集虽好,但不是万能。下面这些情况它不适用。

第一,元素不可哈希。list、dict 这类可变对象无法放进 set,强行放会报TypeError: unhashable type: 'list'。这种情况需要先把不可哈希对象转换成可哈希表示,比如把 list 转成 tuple。

第二,必须保留原始顺序。set 是无序的,intersection()结果不保证顺序。如果业务要求输出顺序和第一个列表一致,就得改用列表推导配合seen集合:

order_a = [5, 1, 3, 2, 4] set_b = {2, 3, 4} seen = set_b.copy() ordered = [x for x in order_a if x in seen] print(ordered) # [3, 2, 4],保持 order_a 的顺序

第三,需要保留重复元素计数。集合天然去重,无法表达"某个元素出现了 3 次"这种信息。如果真的要计算多重集合的交集,用collections.Counter:

from collections import Counter a = Counter([1, 1, 2, 3]) b = Counter([1, 2, 2, 3]) print(a & b) # Counter({1: 1, 2: 1, 3: 1}),每个元素保留较小计数

Counter也实现了&运算符,语义是"对应键取小值",这是文本词频交集常用的技巧。

6. 踩坑记录:四类生产环境里真实见过的错误

6.1 AttributeError:list 当成接收者

有个很常见的错误类型:把intersection()当成所有序列都能调用的方法。

list_a = [1, 2, 3] list_b = {2, 3, 4} # 错误写法 list_a.intersection(list_b)

报错:

AttributeError: 'list' object has no attribute 'intersection'

原因很直白:intersection()是 set 的方法,list 没有。正确写法要么把接收者转成 set,要么让 set 作为接收者、list 作为参数:

set(list_a).intersection(list_b) # 或者 set(list_b).intersection(list_a)

6.2 NoneType:把 None 当作集合传进去

我在一个数据处理脚本里见过这种写法:

def get_common(a, b): return a.intersection(b) common = get_common(some_set, maybe_none_result)

当maybe_none_result为None时,intersection()会抛出:

TypeError: 'NoneType' object is not iterable

因为在处理非 set 参数时,intersection()需要把参数转成可迭代对象,而None不可迭代。

排查这类问题,最快的办法是在调用前加一句类型检查或防御性判断:

if b is None: return set()

或者干脆用更安全的写法:

common = some_set.intersection(b or [])

6.3 intersection_update 返回 None:原地修改与返回值混淆

intersection_update()和intersection()只差一个_update,语义却完全不一样。

intersection()返回新集合,原集合不变。

intersection_update()原地修改接收者,把接收者变成交集结果,返回None。

很多人第一次用intersection_update()时,会写出这种代码:

s = {1, 2, 3} result = s.intersection_update({2, 3, 4}) print(result) # None print(s) # {2, 3}

结果result是None,然后在后续逻辑里拿到None直接开始遍历,一脸懵。

我的经验是:如果你要继续链式调用或保留原始对象,用intersection();如果你确定原集合已经不需要了,用intersection_update()可以节省一次新建对象的开销。

6.4 迭代时修改集合触发 RuntimeError

这个坑和intersection()没有直接关系,但属于集合运算中最常见的副作用错误。比如你想遍历一个集合,删除不满足条件的元素:

s = {1, 2, 3, 4, 5} for x in s: if x % 2 == 0: s.remove(x)

报错:

RuntimeError: Set changed size during iteration

集合在迭代期间不允许增删元素,因为哈希表的内部结构可能会重新排列,导致迭代器失效。

正确做法是先算出要保留的结果,再重新赋值:

s = {1, 2, 3, 4, 5} s = {x for x in s if x % 2 == 1} print(s) # {1, 3, 5}

回到intersection()场景,如果你想让一个集合只保留与另一个集合共同的部分,直接这样写就行:

s = {1, 2, 3, 4} other = {2, 3, 5} s = s.intersection(other) print(s) # {2, 3}

不要在 for 循环里手动 remove。

6.5 空集合与空参数的边界

最后说一个容易被忽略的边界情况。

空集合和任何集合的交集都是空集:

print(set().intersection({1, 2, 3})) # set()

这一点符合数学直觉,不会有问题。

真正需要注意的是intersection()不传参数的情况。它不会报错,而是返回原集合的一个新副本。比如:

s = {1, 2, 3} print(s.intersection()) # {1, 2, 3}

如果在代码里把这个结果当作某个逻辑的处理结果,而调用方本来期望传入了多个集合,就很容易产生隐蔽的 bug。我建议在生产代码中保持显式传参,不要让这个边界情况成为默认行为。

7. 进阶玩法:frozenset、dict_keys 视图与一次性迭代器

7.1 frozenset 也能参与交集,结果保持 frozenset

frozenset 是不可变的 set,它可以放进另一个 set,因为它是可哈希的。它自己也支持intersection():

fs = frozenset({1, 2, 3}) result = fs.intersection({2, 3, 4}) print(result) # frozenset({2, 3}) print(type(result)) # <class 'frozenset'>

注意这里返回类型是frozenset,不是set。因为intersection()会保持接收者类型。这是一个很容易被忽视的细节:如果后续代码对结果做原位修改,比如add(),在frozenset上会报AttributeError。

7.2 dict.keys() 视图直接用 & 运算符,简化键筛选

这是我在实际工程里用得比较多的技巧之一。

字典的keys()返回的是一个视图对象dict_keys,它实现了集合接口,可以直接参与&运算:

d1 = {"a": 1, "b": 2} d2 = {"b": 3, "c": 4} common_keys = d1.keys() & d2.keys() print(common_keys) # {'b'}

注意dict_keys没有intersection()方法,不能写d1.keys().intersection(d2.keys()),但它可以配合&运算符使用,结果是一个set。

这个写法的典型应用场景是:比较两个 JSON 对象或两个配置文件里都出现了哪些相同的键,一行搞定,不需要先set(d1.keys())再set(d2.keys())。

如果你更习惯方法写法,用set(d1) & set(d2)也是一样的效果。Python 里直接迭代 dict,默认就是迭代键。

7.3 生成器作为参数时,要小心它被“消费”一次

intersection()接受任意可迭代对象,生成器也包含在内。但生成器是一次性的,这个方法会把它完整迭代一遍:

def gen(): yield from [1, 2, 3, 4] s = {2, 3, 4, 5} print(s.intersection(gen())) # {2, 3, 4}

如果你生成器后面还想继续用同一个对象,会发现它已经空了:

g = gen() print(s.intersection(g)) # {2, 3, 4} print(list(g)) # []

这不是intersection()的 bug,而是所有会迭代生成器的函数共有的行为。所以,如果生成器本身很昂贵,或者后面还有其他地方要用,最好先转成 set 或 list 缓存一份:

g = gen() g_set = set(g) r = s.intersection(g_set) print(g_set) # {1, 2, 3, 4},缓存之后还能复用

7.4 组合技巧:混合数据结构的统一转换

实际工程中数据来源五花八门,这个接口返回 list,那个接口返回 tuple,还有一个返回的是 dict 的键。统一求交集时可以这样:

from typing import Iterable def to_set(values): if isinstance(values, dict): return set(values.keys()) return set(values) list_a = [1, 2, 3] tuple_b = (2, 3, 4) dict_c = {"a": 1, "b": 2, "c": 3} result = to_set(list_a).intersection( to_set(tuple_b), to_set(dict_c), ) print(result) # set(),因为三种数据没有共同元素

如果数据本身没有可哈希问题,统一转 set 是求交集前最稳妥的预处理步骤。不要试图让intersection()去猜你的数据格式,显式转换一次,后续逻辑会好写很多。

至于dict的键和 list 里的字符串能不能混交,取决于业务语义。集合运算不关心元素来源,只关心值是否相等、是否可哈希。

最后再分享一个我自己的习惯。凡是遇到"两边都满足"的筛选需求,我先停下来想想:能不能把数据转成 set,然后用intersection()一行解决?这个思维惯性帮我省下了大量手写循环的时间。数据量小的时候差异不明显,数据量一大,集合运算的优势立刻显现。如果你刚开始用 set,建议先在本地把intersection()、union()、difference()这几个基础操作各跑一遍,感受一下不同写法的边界条件。踩过几次坑之后,你对集合运算的掌控会比背语法要扎实得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询