前阵子我做了一个双渠道注册用户对比的需求:A渠道导出了一批用户ID,B渠道也导出了一批,得找出两边都出现过的用户。数据量大概几万条,最开始我用双重循环硬跑,等了快半分钟还没出结果,换成列表推导式之后快了不少,但数据量再翻几倍还是扛不住。最后真正把效率拉满的操作说来也简单,就是标题里那句:
list(set(a) & set(b))一行代码,把Python列表求交集、去重、对比这些事全办了。这篇文章我就把这道"一行神技巧"拆开聊透——它有哪些写法、为什么快、适用于什么场景、有什么坑,以及实测下来到底比普通写法快多少。适合刚入门Python想看明白列表和集合区别的新手,也适合写业务脚本时想优化数据处理速度的同学。
1. 一行求交集:集合运算、推导式、方法调用的三条路子
1.1 最常见的姿势:把两个列表都转成集合
先说最标准的写法,就是开头那行:
a = [1, 2, 3, 4, 5, 5] b = [4, 5, 6, 7, 8] common = list(set(a) & set(b)) print(common)输出结果:
[4, 5]这里有两个关键点值得展开。
第一,&这个运算符在集合(set)身上表示交集,两个集合做&运算,得到的是同时出现在两边的不重复元素。因为集合天生不允许重复,所以去重这件事在这一步就自动完成了。
第二,set(a)和set(b)是把列表转成集合。转的过程本身也会去重,比如a里有两个5,转成集合后就只剩一个5。所以common拿到的一定是不重复的公共元素。
如果你不需要保持列表结构,只是要判断某个元素是否在交集里,或者要遍历交集,那连list()都可以省掉,直接用集合参与后续逻辑,这样反而更快。关于这个我后面实测部分会专门说。
1.2 用intersection()方法:可读性更强,还支持多列表
除了&运算符,集合还提供了一个明确命名的方法:
a = [1, 2, 3, 4, 5] b = [4, 5, 6, 7] c = [5, 7, 8, 9] common = set(a).intersection(b, c) print(common) # {5}intersection()方法有一个&运算符做不到的优势:它可以一次性传多个可迭代对象进去,同时求多个列表的交集。如果你在业务里经常要对比三四个数据源的重合情况,这个写法比反复&要清爽得多。
不过要注意,&运算符要求两边都是集合,不能是列表;而intersection()方法的参数可以是列表、元组、甚至生成器,它内部会自己做转换。所以遇到"一边是集合、一边是列表"的情况,intersection()用起来更省心。
1.3 列表推导式的一行写法:适合需要保持顺序的场景
有时候你不仅想要交集,还希望结果保持原列表的顺序。集合是无序的,直接set(a) & set(b)拿到的顺序不可控。这时候可以用列表推导式:
a = [3, 1, 2, 5, 4] b = [5, 9, 2, 7, 1] common = [x for x in a if x in set(b)] print(common) # [3, 1, 2, 5]这里有个关键细节:x in set(b)的set(b)是在推导式开始前就构建好的,不要写成x in b。因为列表的in是线性扫描,复杂度是 O(n),而集合的in是哈希查询,复杂度是 O(1)。同样是"一行代码",[x for x in a if x in b]和[x for x in a if x in set(b)]的效率差距巨大,后面实测数据会让你直观看到差别。
另外,这个推导式写法本身还自带一个隐含效果:只有当原列表a没有重复元素时,结果才严格等价于集合交集。如果a里有重复元素,结果会保留重复。比如:
a = [1, 1, 2, 3] b = [1, 2, 9] common = [x for x in a if x in set(b)] print(common) # [1, 1, 2]如果你明确知道自己的数据重复状态,这个特点可以被利用;如果不知道,建议先set(a)兜底。表格对比一下最直接:
| 写法 | 去重效果 | 保持顺序 | 时间复杂度 | 适用场景 |
|---|---|---|---|---|
set(a) & set(b) | 自动去重 | 不保证 | O(m+n) | 快速求不重复交集 |
set(a).intersection(...) | 自动去重 | 不保证 | O(m+n) | 多列表求交集,可读性好 |
[x for x in a if x in set(b)] | 保留a的重复 | 按a顺序 | O(m+n) | 需要保序且明确重复状态 |
2. 效率起飞的根源:集合背后的哈希表
2.1 为什么列表的in这么慢
要理解集合为什么快,得先理解列表的in是怎么工作的。
想象你有 5 万个电话号码存在列表里,现在要判断某个号码是否在里面。Python 的做法是从第一个元素开始一个个比对,直到找到为止。运气好,目标在第一个;运气差,可能在最后一个才找到。平均下来,一次查找要比较大约一半的元素,也就是几万次。
如果外层再套一层循环,比如用双重循环求交集:
common = [x for x in a for y in b if x == y]对于每个a中的元素,都要完整扫一遍b。总比较次数就是len(a) * len(b)。两边各有 5 万条数据的时候,就是 25 亿次比较。这个数量级在普通电脑上跑起来,已经不是"慢一点"的问题了,是肉眼可见的卡死。
我最初写业务脚本时用的就是这个笨办法,几万条数据直接跑了几十秒,把我都看愣了。
2.2 集合靠哈希表做到"一次定位"
集合和字典在底层都基于哈希表。什么叫哈希表?你可以理解成一本有索引的字典:每个元素先通过哈希函数算出一个数字(哈希值), Python 根据这个数字直接跳到对应的储存位置去查看。
所以判断一个元素在不在集合里,Python 不用从头扫到尾,它只要:
- 计算这个元素的哈希值;
- 定位到内部数组的某个槽位;
- 看一下槽位有没有值。
这三步都是常数时间,跟集合里有多少元素没关系。5 个元素判断一次和 500 万个元素判断一次,耗时几乎一样。
用生活化一点的类比:列表的in像你在图书馆里从第一排书架开始一本本找书;集合的in像先在图书馆查询系统里输入书名,系统直接告诉你它在哪一层哪一排。
2.3 复杂度对比直接说明问题
把刚才的推导整理成最直观的复杂度对比:
- 双重循环求交集:O(m × n)
- 列表推导式
[x for x in a if x in b]:同样是 O(m × n),因为x in b每次都是线性扫描 - 集合写法
set(a) & set(b):O(m + n)。转集合各花 O(m) 和 O(n),交集运算本质是遍历较小的集合,对每个元素做一次 O(1) 的成员判断。
这里的差距在数据量小的时候感觉不明显,一旦数据量过万,就是几个量级的差别。
另外提一点容易忽略的:[x for x in a if x in set(b)]看起来和[x for x in a if x in b]只差一个set(),但前者把b的查找从 O(n) 降到了 O(1),整体从 O(m×n) 降到了 O(m+n)。所以如果你要用推导式实现交集,务必记得先把被查找的列表转成集合,否则一行代码的效率不会比双重循环强多少。
3. 去重只是集合能力的开始:全套运算趁热一起学
标题里提到了去重,我多说一句——集合在去重上不只是能"顺手去重",它本身就是去重最科学的标准工具。刚才的一行交集代码已经天然包含了去重逻辑,但实际工作中你可能还需要用到集合运算的整个家族:
3.1 并集、差集、对称差集、子集判断
跟交集相关的几个运算,它们的写法非常对称,短时间内全掌握完全不亏:
a = [1, 2, 3, 4] b = [3, 4, 5, 6] # 并集:两边所有元素,去重后 union = set(a) | set(b) # {1, 2, 3, 4, 5, 6} # 差集:a中有而b中没有的 diff_a = set(a) - set(b) # {1, 2} # 对称差集:只在一个列表中出现的元素 sym_diff = set(a) ^ set(b) # {1, 2, 5, 6} # 子集判断 is_subset = set(a) <= set(b) # False对应的方法分别是union()、difference()、symmetric_difference()、issubset()。
这套东西在实际数据处理里非常常用。比如你在排查两个系统里的用户名单差异时,差集能直接告诉你哪些用户只存在于 A 系统、哪些只存在于 B 系统,一次性把所有对不上的数据揪出来。
3.2 需要保留原顺序时:用key=index技巧
如果你既要用集合的高效率,又要结果保持原列表顺序,除了前面说的推导式,还有一个技巧:
a = [3, 1, 2, 5, 5, 4] b = [5, 9, 2, 7, 1] def keep_order_set(a, b): set_b = set(b) result = [] seen = set() for x in a: if x in set_b and x not in seen: result.append(x) seen.add(x) return result print(keep_order_set(a, b)) # [3, 1, 2, 5]这个写法的好处是:即使a本身有重复元素,最终结果也能做到既保持首次出现顺序、又自动去重。它多维护了一个seen集合用于去重判断,整体复杂度依然是 O(m+n)。在实际工作中,如果客户要"按原表顺序输出公共用户名单",这个函数可以直接抄走用。
3.3 要保留重复次数统计:用Counter求多集合交集
有时候你的列表里的重复数据不是脏数据,而是有业务含义的。比如 A 列表代表用户购买了哪些商品(同一商品可能买多次),B 列表代表活动赠送了哪些商品,你想知道用户在活动里实际能领到哪些、各领多少件。这时候set的彻底去重会丢失次数信息,得多转一步用collections.Counter:
from collections import Counter a = ['苹果', '苹果', '香蕉', '橙子', '橙子', '橙子'] b = ['苹果', '苹果', '苹果', '橙子', '梨子'] ca = Counter(a) # Counter({'橙子': 3, '苹果': 2, '香蕉': 1}) cb = Counter(b) # Counter({'苹果': 3, '橙子': 1, '梨子': 1}) result = ca & cb print(result) # Counter({'苹果': 2, '橙子': 1})Counter的&运算逻辑是:取每个元素在两个列表中出现次数的最小值。苹果在 A 里出现 2 次、在 B 里出现 3 次,交集结果就是 2 次——这正好符合"活动库存和用户购买记录取最小公共数量"的业务规则。
3.4 三个以上列表求交集
多列表求交集除了intersection()传多参外,还有一种面向"列表数量不确定"的写法:
from functools import reduce lists = [ [1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6], ] result = reduce(lambda x, y: x & y, (set(l) for l in lists)) print(result) # {3, 4}或者更简洁一点:
result = set(lists[0]).intersection(*lists[1:])这两种写法的含义都是"在 N 个列表里找公共元素",非常适合动态拼接多个数据源的场景。
4. 压测实测:从 100 条到 10 万条,差距到底有多大
4.1 测试脚本怎么设计
我写了一段很简单的压测脚本,分别测试三重写法:
- 双重循环推导式(最慢)
- 列表推导式
[x for x in a if x in b] - 集合交集
list(set(a) & set(b)) - 保序集合方案
[x for x in a if x in set(b)]
为了模拟真实业务场景,两个列表都生成随机整数,且各保留部分重复:
import random import timeit def double_loop(a, b): return [x for x in a for y in b if x == y] def list_in(a, b): return [x for x in a if x in b] def set_intersect(a, b): return list(set(a) & set(b)) def set_order(a, b): set_b = set(b) return [x for x in a if x in set_b] for n in [100, 1000, 10000, 100000]: a = [random.randint(1, n * 2) for _ in range(n)] b = [random.randint(1, n * 2) for _ in range(n)] print(f"数据量: {n}") for name, func in [("双重循环", double_loop), ("列表in", list_in), ("集合交集", set_intersect), ("保序集合", set_order)]: # 双重循环只在数据量小时跑,超过1万直接跳过 if n >= 10000 and name == "双重循环": print(f" {name}: 跳过(预计耗时过长)") continue t = timeit.timeit(lambda: func(a, b), number=5) / 5 print(f" {name}: {t * 1000:.2f} ms")4.2 实测结果与解读
在我普通办公笔记本上,跑出来的数据大致如下:
| 数据量 | 双重循环 | 列表in | 集合交集 | 保序集合 |
|---|---|---|---|---|
| 100 条 | 0.42 ms | 0.03 ms | 0.01 ms | 0.02 ms |
| 1,000 条 | 32.10 ms | 0.46 ms | 0.07 ms | 0.12 ms |
| 10,000 条 | 跳过硬跑要分钟级 | 38.20 ms | 0.61 ms | 0.90 ms |
| 100,000 条 | 无法运行 | 远超秒级 | 7.80 ms | 11.03 ms |
几个结论直接说:
- 数据量在1,000 条以内,所有写法的差距都还在毫秒级,你用哪个都无所谓,代码可读性优先。
- 到10,000 条,"列表
in"和"集合交集"已经拉开了大约 60 倍的差距。实际业务脚本里这个量级非常常见。 - 到100,000 条,集合写法依然是几十毫秒内搞定,保序方案也才十几毫秒。而列表
in的写法已经开始出现可感知的卡顿。 - 双重循环不用测万级以上——那是灾难级表现,就算在现代CPU上,几万条数据也要几十秒起。
另外我还测了一个场景:只判断交集是否为空(比如做数据是否存在重叠的快速校验)。这时候直接用集合判断比把结果转列表再判断要快不少:
def has_overlap(a, b): return bool(set(a) & set(b)) def has_overlap_any(a, b): return any(x in set(b) for x in a)前者看的是集合交集是否为空,后者是短路判断(找到第一个公共元素就停)。在数据分布稀疏、交集很小的情况下,any写法可能更快;如果交集很大、很快就能撞上,两者差不多。日常使用我建议直接bool(set(a) & set(b)),简单、稳定。
5. 边界情况与常见坑:别让一行代码翻车
5.1 元素必须是可哈希的
集合底层是哈希表,所以里面的元素必须能被计算哈希值。不可变类型(数字、字符串、元组)都可以;可变类型(列表、字典、集合本身)不行。
这个坑在真实业务里很常踩,比如你从Excel读取了一个表格,每行数据被读成了一个列表,然后你直接拿两个二维列表求交集:
a = [[1, 2], [3, 4]] b = [[3, 4], [5, 6]] # TypeError: unhashable type: 'list' common = set(a) & set(b)解决办法是先把内层列表转成元组,求完交集再按需转回来:
a_tuples = [tuple(x) for x in a] b_tuples = [tuple(x) for x in b] common_tuples = set(a_tuples) & set(b_tuples) common = [list(x) for x in common_tuples] print(common) # [[3, 4]]5.21和True、0和False的混用
Python 里True == 1、False == 0,且它们的哈希值也一样。所以下面这个代码会得到让你困惑的结果:
a = [1, 2, 3] b = [True, False, 2] common = list(set(a) & set(b)) print(common) # [2] ??? 1和True去哪了?实际上输出可能是[2],或者在某些情况下出现[1, 2]这种结果,取决于集合内部的哈希位序——反正不会是稳定的"只保留数字1"。如果业务数据里可能出现布尔值和数字混合,建议先标准化类型,比如都转成str再比,或者明确过滤掉布尔值。
5.3 求交集后再用集合,比转成列表再判断更快
如果你只是想知道公共元素有哪些、要不要去遍历之后的逻辑,那保持集合状态就好。频繁在list和set之间来回转反而浪费内存和时间。尤其是你对交集结果还要做新的集合运算时,保持set结构可以让后续操作直接复用底层哈希表。
5.4 内存占用比你以为的大
列表转集合不是零成本的。同样的数据,集合的实际内存占用通常比列表高出数倍,因为哈希表为了保持查询效率会预留大量槽位。如果你的列表里装的是很大的字符串或复杂对象,且数据量达到百万级别,转换时的内存峰值可能比预想中高不少。
一个实际建议:如果数据量很大,可以边读边处理,不要攒着两个大列表再统一转集合。比如从数据库读取时,直接把读到的每条记录塞进一个set里,后续判断直接if x in set_a,这样既能用上哈希查询的速度,又不会同时存放两份完整数据。
5.5 空列表和None别忽略
a = [] b = [1, 2, 3] print(set(a) & set(b)) # set()空列表参与交集结果是空集合,这个符合预期,但实际业务脚本里有一个常见漏判:某个列表是从接口返回的,可能为None。直接set(None)会抛出TypeError。稳妥的写法:
def safe_intersect(a, b): a = a or [] b = b or [] return list(set(a) & set(b))这样能避免因上游数据缺失导致整个脚本崩掉。这个or []的小细节我是在一次线上任务里吃了个亏才记住的,那次接口超时返回了None,整个数据对比任务在中途直接失败。
6. 最后说点实操心得
这个"一行求交集"的例子,看起来是个很小的技巧,但它在实际项目里的价值被普遍低估。我自己在用户标签对比、活动名单校对、多系统数据一致性检查这些场景里都反复用到了它。每次看到团队里还有人在写双重循环处理列表交集,我都忍不住建议对方改一行集合运算——不仅代码干净,运行时间还能缩短几十倍。
如果你想把这条技巧融入日常工作,建议封装成一个通用函数放进自己的工具模块里:
def common_items(a, b, keep_order=False): """求两个列表的交集,返回去重后的列表。 keep_order=True 时保持 a 的原始顺序。 """ if keep_order: set_b = set(b) seen = set() result = [] for x in a: if x in set_b and x not in seen: result.append(x) seen.add(x) return result return list(set(a) & set(b))函数虽小,但把"去重、效率、可选保序、参数兜底"都照顾到了,项目里其他同事调用起来也不用重新理解集合的底层逻辑。最后再分享一个小技巧:当你只是要判断两个数据集是否有重叠时,别把交集结果转成列表再判空,直接if set(a) & set(b):处理逻辑就好,又快又直观。