☰
Python列表求交集一行set语句搞定去重与性能优化
2026/10/10 17:41:53 网站建设 项目流程

前阵子我做了一个双渠道注册用户对比的需求: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 不用从头扫到尾,它只要:

  1. 计算这个元素的哈希值;
  2. 定位到内部数组的某个槽位;
  3. 看一下槽位有没有值。

这三步都是常数时间,跟集合里有多少元素没关系。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 ms0.03 ms0.01 ms0.02 ms
1,000 条32.10 ms0.46 ms0.07 ms0.12 ms
10,000 条跳过硬跑要分钟级38.20 ms0.61 ms0.90 ms
100,000 条无法运行远超秒级7.80 ms11.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):处理逻辑就好,又快又直观。

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

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

立即咨询