处理数据时最常遇到的一类需求就是找交集:两个列表里都有哪些元素、两个人群里哪些用户同时出现。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 & b | a.intersection(b) |
| 并集 | a | b | a.union(b) |
| 差集 | a - b | a.difference(b) |
| 对称差集 | a ^ b | a.symmetric_difference(b) |
| 子集判断 | a <= b | a.issubset(b) |
| 超集判断 | a >= b | a.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()这几个基础操作各跑一遍,感受一下不同写法的边界条件。踩过几次坑之后,你对集合运算的掌控会比背语法要扎实得多。