我第一次在Python里用set,是在处理一批从接口拉回来的用户ID。列表里有大量重复ID,我当时第一反应是维护一个list再逐个用if判断,数据量只有几万条,却慢得离谱。后来看到同事的代码,一行list(set(x))就解决了,我当时愣了好几秒。也就是从那次之后,我开始认真研究Python里的集合(set)这种数据结构——它天生就是为了去重和集合对比这两件事而设计的,只要你手里有"列出有哪些不同""找共同点""快速判断某个元素在不在"这类需求,set都是最顺手、最不容易出错的工具。这篇内容就围绕set的去重与对比展开,结合我在实际项目里用过的代码和踩过的坑,给刚接触Python、或者已经在写业务代码但很少用集合的读者,一份可以直接抄作业的参考。
1. set不是"高级数组",底层其实是一张哈希表
很多人学set时,只记住了一句"set是无序、不重复的集合",然后就拿它当"用来去掉重复项的数组"用。这种理解能应付最简单的场景,但只要稍微深入一点,遇到"为什么去重后顺序乱了""为什么list不能放进set""为什么两个自定义对象明明属性一样,set却认为它们不同"这类问题,就会卡壳。所以我想先把set的本质讲透。
1.1 三个一眼就能记住的性质
第一个性质:无序。set不是序列,它没有下标,你不能写出s[0]这种代码。你在创建set时写的元素顺序,存储时完全取决于元素哈希值的分布。
第二个性质:不重复。同一个set里,任何两个元素都不能相等。注意这里说的是"相等",不是"完全相同"。1和1.0,或者1和True,它们在Python里是相等的,所以set会认为它们是同一个元素。
s = {1, 1.0, True} print(s) # {1}这个细节我后面会专门展开,因为它在实际业务里很容易翻车。
第三个性质:元素必须是可哈希的。什么叫可哈希?简单说,这个对象有一个能稳定换算成整数的__hash__方法,同时配套定义了__eq__,可以判断两个对象是否相等。int、str、tuple这类不可变对象天然可哈希,list、dict、set这类可变容器不行。
s = {[1, 2]} # TypeError: unhashable type: 'list'这就像酒店房间的钥匙卡——只有能给你算出固定房间号的东西才能办入住,而那些"内容随时会变"的行李(list)没法作为身份标识。
1.2 三种创建方式和一个经典误会
创建一个set有几种常见写法:
s1 = {1, 2, 3} # 字面量方式 s2 = set([1, 2, 3]) # 用list构造 s3 = set() # 创建空set这里有个新手必踩的雷:{}创建出来的是空字典,不是空集合。因为历史原因,Python把花括号字面量先给了dict,set只能用第一期里的set()。
s = {} print(type(s)) # <class 'dict'>我一开始写代码时,在这个地方翻过车。如果你要初始化一个空set,老老实实用set()。顺便说一句,frozenset是set的不可变版本,它可以作为元素放进另一个set里,这个特性在后面讲嵌套场景时会很有用。
1.3 高频方法快速过一遍
set常用的方法其实就那么几个:add添加单个元素,update批量添加,remove删除元素但元素不存在会抛KeyError,discard删除元素且不存在时静默跳过,pop弹出一个"随机"元素,clear清空。
s = set() s.add(1) s.update([2, 3, 4]) s.discard(10) # 不报错 s.remove(10) # KeyError: 10del的细节不需要死记,用几次就熟了。真正值得花精力理解的,是set为什么能做到O(1)查找,这是它高效的核心,第四部分我会单独讲。
2. 去重实战:从一行代码到保序去重、对象去重
去重是set用得最多的功能,但"去重"这件事没有想象中那么简单。不同场景对去重的要求不同,有的只在乎结果有多少项,有的必须保留原顺序,还有的去重对象是自定义类实例。下面我按场景拆开讲。
2.1 一行代码利落地去重
最基础的写法:
raw = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5] unique = list(set(raw)) print(unique) # 结果可能是 [1, 2, 3, 4, 5, 6, 9],顺序不保证这段代码做两件事:先把list丢给set去重,再把set转回list。大部分时候,我们要的只是"这批数据里有哪些不同项",比如统计独立用户数、拿到所有出现的错误码,顺序根本不重要。
raw_ids = [102, 108, 102, 201, 108, 302] independent_visitor_count = len(set(raw_ids))这种场景下,list转set再问长度,一行就够。
但如果你需要保序去重,直接用set就满足不了了。我下面专门说。
2.2 保序去重:用set当"已见过的登记表"
set天然不记录插入顺序,但你可以用set做登记表,自己控制输出顺序。核心思路:遍历原序列,如果元素没在seen集合里,就加入结果,同时登记到seen里;如果已经在seen里,说明是重复项,跳过。
def dedupe(items): seen = set() result = [] for item in items: if item not in seen: seen.add(item) result.append(item) return result raw = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5] print(dedupe(raw)) # [3, 1, 4, 5, 9, 2, 6]为什么这个方案是高效的?关键在于item not in seen这一步。seen是set,判断元素在不在set里是O(1)平均时间。如果你用一个list来做seen,那每次判断都要从头扫到尾,数据量一大就是O(n)乘以遍历次数,整体变成O(n^2),这就是我在开头说的"几万条数据慢得离谱"的根源。
如果你想在保留原顺序的同时处理大文件,可以把它改成生成器,逐行读、逐行yield,内存里只维护一个seen集合:
def dedupe_lines(file_path): seen = set() with open(file_path, encoding='utf-8') as f: for line in f: line = line.strip() if line and line not in seen: seen.add(line) yield line我在清洗一批几十万行的日志时,就是用这个生成器顺便把重复行去掉的,内存占用很稳定。
2.3 对象去重:理解__hash__和__eq__的配合
带id的字典数组去重大家可能遇到过。比如接口返回了一批订单,每个订单有id和其他字段,我想按id去重。最快思路是用set,但set判断重复靠的是哈希和相等性,字典本身不可哈希,直接报错。那怎么办?两种常见做法。
做法一:用key函数把对象映射成可哈希值。
orders = [ {"id": 1, "name": "a"}, {"id": 2, "name": "b"}, {"id": 1, "name": "a改过的名字"}, ] def dedupe_by_key(items, key): seen = set() result = [] for item in items: k = key(item) if k not in seen: seen.add(k) result.append(item) return result unique_orders = dedupe_by_key(orders, lambda x: x["id"])这样既能按id去重,又能保留第一次出现的那条完整记录。我在实际处理外部接口数据时,经常遇到同一条单据返回多次但后续字段被修改的情况,这种方法最实用。
做法二:如果你用的是自定义类,直接定义__eq__和__hash__,让对象本身"可以比较、可以哈希"。
class Order: def __init__(self, order_id, name): self.order_id = order_id self.name = name def __eq__(self, other): return isinstance(other, Order) and self.order_id == other.order_id def __hash__(self): return hash(self.order_id) orders = [Order(1, "a"), Order(2, "b"), Order(1, "a改过的名字")] seen = set() result = [] for o in orders: if o not in seen: seen.add(o) result.append(o)这里有一个人人容易踩的坑:eq__和__hash__必须同时保持一致性。Python的哈希契约规定,两个对象如果相等,它们的哈希值必须相等。如果你只写了__eq__让两个id相同的Order相等,却没写__hash,Python会在你定义__eq__时把__hash__自动设为None,那么这个对象就变得不可哈希,放进set会直接报TypeError。反过来,如果你只做了哈希却没做相等判断,set判断两个对象是否重复时,会用__eq__,没有的话就用默认的"地址一致才算相同",那照样去不了重。所以,自定义对象要进set做去重,这两个方法要一起实现,且逻辑基准要相同。
3. 数据对比的正确打开方式:交集、并集、差集这样用
如果说去重是set的"单人技能",那么对比就是set的"团战技能"。两个集合之间的运算,能非常优雅地解决"找相同、找不同、找新增、找删除"这些日常需求。我见过太多人处理两份数据差异,用两层for循环嵌套慢慢比,数据量稍微上去就卡成PPT。换成set,基本上就是一行的事。
3.1 集合运算速查表
先把最常用的四种运算列个表,左边是运算符写法,右边是等价的方法写法,后面跟一个直观的例子。
| 运算 | 运算符 | 方法 | 含义 | 示例(A={1,2,3},B={2,3,4}) |
|---|---|---|---|---|
| 交集 | A & B | A.intersection(B) | 同时在A和B中 | {2, 3} |
| 并集 | A | B | A.union(B) | 合并去重 | {1, 2, 3, 4} |
| 差集 | A - B | A.difference(B) | 在A中但不在B中 | {1} |
| 对称差集 | A ^ B | A.symmetric_difference(B) | 只属于其中一个 | {1, 4} |
同时还有三个判断用的方法:issubset判断子集,issuperset判断超集,isdisjoint判断是否完全没有交集。运算符也对应有<=、>=,以及真子集的<和真超集的>。
A = {1, 2, 3} B = {1, 2, 3, 4, 5} print(A <= B) # True,A是B的子集 print(A.issubset(B)) # True,和上面等价 print(A < B) # True,A是B的真子集 print(A.isdisjoint({6, 7})) # True,A和{6,7}没有共同元素这组方法不需要背,理解了交集差集的概念,用得多了自然就记住了。
3.2 对比两份名单:找出新增、减少和不变的部分
比较"昨天和今天的IP名单"是很多运维脚本的必修课。用集合写出来,逻辑看得一清二楚:
yesterday = {"192.168.1.10", "192.168.1.20", "192.168.1.30"} today = {"192.168.1.20", "192.168.1.40", "192.168.1.50"} added = today - yesterday # 新增了哪些 removed = yesterday - today # 减少了哪些 stayed = yesterday & today # 哪些是两天都有的 print("新增:", added) print("减少:", removed) print("保持:", stayed)这个场景换成任意业务都一样:一批历史权限和现在的权限对比、两张报表里的订单号对比、两轮测试跑出来的失败用例对比,背后都是同一个套路。用差集比用循环判断少的代码不是一点点,而且可读性高得多——任何人看到today - yesterday都知道是在算新增。
3.3 用子集判断做权限和标签校验
另一个高频场景是判断"一个集合是否被另一个集合完全覆盖"。比如用户当前拥有的权限标签,必须满足某操作的最低要求才允许执行。
required_permissions = {"read", "write"} user_permissions = {"read", "write", "execute"} if required_permissions.issubset(user_permissions): print("有权限") else: print("缺少权限:", required_permissions - user_permissions)类似地,还可以检查一个推荐系统和另一个结果列表是否完全没交集,判断两个分组是否互斥。这些操作如果用list来做,首先要处理重复项,然后要嵌套循环,代码又啰嗦又容易错判;用set,一个方法调用就把判断做完了。我在做简单的黑白名单匹配时也习惯先把名单转set,然后用交集长度判断命中多少,比逐个遍历效率高很多。
3.4 一个细节:<=和<你别混了
A <= B表示A是B的子集,包括二者相等的情况;A < B表示真子集,要求A必须严格小一点。这个区别在边界条件下很关键。
A = {1, 2} B = {1, 2} print(A <= B) # True print(A < B) # False,因为相等在写判断逻辑时,比如"用户权限完全覆盖要求权限",应该用issubset或<=,不是<。否则当两者恰好相等时,条件会意外地变成False,排查半天才发现是这个边界问题。
4. 性能为什么能打:50万条数据下list和set的真实差距
去重和对比都依赖同一个底层能力:快速判断元素是否在集合里。很多人知道set快,但不知道快在哪,也不知道代价是什么。我用一个简单实验和一个哈希表原理解答这两个问题。
4.1 实测:in操作在list和set里的差别
直接用timeit做个对比:生成50万个数字,分别放进list和set,然后测查找一个元素的时间。
import timeit data = list(range(500000)) sdata = set(data) # 在list里查找一个肯定存在的元素 t_list = timeit.timeit(lambda: 499999 in data, number=1000) # 在set里查找同样的元素 t_set = timeit.timeit(lambda: 499999 in sdata, number=1000) print(f"list查找耗时: {t_list:.4f}s") print(f"set查找耗时: {t_set:.4f}s")在我本机上,list查找1000次要几秒的量级,set查找1000次可能连0.01秒都不到,差距是几百倍。原因不难理解:list的in是逐一遍历,最坏情况要找50万次;set的in只需要计算一次哈希,直接定位到对应的"格子",看一眼有没有东西。
真正让我印象深刻的场景是一次接口联调:对方请求参数里带了1万个ID,需要判断这1万个ID里有多少在我方20万条库存ID中。第一版用list嵌套判断,跑了十几分钟没出来;改成把库存ID转成set,单个请求毫秒级返回。从那以后,凡是"一个大数据集里做成员判断"的需求,我默认先考虑set。
4.2 底层原理:哈希表是怎么做到O(1)的
set在CPython底层用的就是哈希表(hash table)。它的核心思想是:存储元素时,先对元素做一个哈希运算得到一个整数,再用这个整数模上表长,算出应该存到哪个槽位。判断元素在不在集合里时,同样算出哈希值,直接去那个槽位看有没有东西,不需要遍历所有元素。
这里有两个需要理解的点。第一,哈希函数要求稳定,同一个对象每次计算都要得到相同的整数,所以可变对象不能进set,因为它们变了之后哈希值会变,原来的槽位就找不到了。第二,哈希冲突不可避免,Python的哈希表用开放寻址法处理冲突,即如果目标槽位已有元素,就按一定规则找下一个空闲槽位。最坏情况下所有元素都冲突,退化成O(n),但正常业务数据经过精心设计的哈希值分布,冲突率很低,平均可以认为是O(1)。
set的扩容策略也值得了解。当哈希表装载的元素达到容量的三分之二左右,Python会分配一块更大的表,把所有旧元素重新哈希一遍塞到新位置。这解释了为什么给set逐步add大量元素时,偶尔会看到某一次操作略有停顿——那是扩容在背后发生。
4.3 空间换时间:set的内存开销比list大
set快是有代价的——内存占用偏高。哈希表为了保证冲突率低,必须维持一定的空闲槽位,装载因子过高会触发扩容,所以一张50万元素的哈希表,实际分配的槽位可能远多于50万个。每个槽位还需要额外存储哈希值等信息。对比list那种紧凑的连续数组,同一个数据集,set占用的内存通常是list的2到4倍。
所以在选择数据结构时,我的习惯是:
- 只要做成员判断、去重、集合运算,优先用set
- 如果只需要按顺序遍历数据,用list更省内存
- 如果需要按键查找,用dict,它是"带值的哈希表";set可以理解成只有键没有值的dict
5. 那些让set"翻车"的边角情况,逐一拆给你看
set用起来简单,但边角情况特别多。我自己踩过的坑可以列出长长一张单子,下面挑几个最实用的,每一个都配了真实场景。
5.1 空集合的{}陷阱
前面提过一次,但值得再强调:在任何代码review里看到x = {}然后后面又做x.add(1),这必然是bug,因为{}是空字典,根本没有add方法。正确的空集合初始化只有一个写法:set()。这是所有set坑里出现率最高的一个。
5.2 想给set里放frozenset而不是普通set
如果业务上需要"集合的集合",比如要记录多组标签组合,直接放set会报错,因为set不可哈希。这时要用frozenset,它是set的不可变版本,可以自由哈希。
tags_a = frozenset({"python", "后端"}) tags_b = frozenset({"python", "爬虫"}) tag_set = {tags_a, tags_b} print(tag_set) # {frozenset({'python', '后端'}), frozenset({'python', '爬虫'})}frozenset不支持add和remove,但它能参与所有集合运算,所以在很多场景下功能不受影响,只是少了修改能力。如果需要在frozenset上做变更,可以用set(frozenset_instance)转回来。
5.3 Python 3中1和True是同一个元素
这个坑隐蔽但致命。因为Python里True和1相等,哈希值也相同,所以set认为它们是同一个元素。
confusing = {1, True} print(confusing) # {1},或者 {True},取决于谁先来的 # 一个更贴近业务的例子 options = {0, False, 1, True, 2} print(options) # {0, 1, 2},False被0吸收,True被1吸收如果你的数据来源是混合类型,像"状态码"这种字段,有的是0、1,有的是False、True,去重前一定要先统一类型,否则结果会比你预期的少几个值。我可以负责任地说,这个坑排查起来相当痛苦,因为输出结果看起来是"正常的",只是少了一项。
5.4 pop()弹出的元素是"说不准的"
set.pop()删除并返回一个元素,但那个元素是哪个没有保证。这跟list.pop()完全不一样,list.pop()默认弹最后一个,set.pop()是"弹一个哈希表里的任意元素"。如果你依赖pop顺序,比如写一个调度器,指望每次弹出同一个元素,set会让你措手不及。想按插入顺序或某种确定顺序处理元素,应该用list、dict(py3.7起保序)或者collections.OrderedDict,不要用set。
5.5 字符串哈希随机化导致跨进程顺序不稳定
字符串在Python里的哈希值默认是随机化的。同一个程序两次运行,同一组字符串在set里的存储顺序可能都不一样。这跟安全机制有关,Python解释器启动时会生成一个随机种子,影响str的哈希值。如果你把set序列化并打印出来,或者在不同进程间比较set顺序,会看到顺序不一致的情况。如果业务上依赖字符串集合的顺序,应该显式用sorted排序输出,或者根本不把顺序当回事。
s = {"apple", "banana", "cherry"} print(s) # 两次运行结果顺序可能不同 print(sorted(s)) # 稳定的输出5.6 在for循环里边遍历边改set
set是可变对象,直接for遍历set的同时做remove或add,会触发RuntimeError: Set changed size during iteration。正确做法是先遍历副本,再修改原集合。
s = {1, 2, 3, 4, 5} for element in list(s): # 遍历副本 if element % 2 == 0: s.discard(element) print(s) # {1, 3, 5}这个坑和字典遍历时的禁忌是同一类,记住"遍历可变容器时不要改大小",能省掉很多线上事故。
6. 综合小案例:用set分析两份日志的增删变化
前面分的知识点比较散,最后我把set的去重和对比能力组合起来,做一个完整的实战脚本:分析一个服务连续两天的访问日志,找出新增IP、消失IP和稳定IP,并输出一份简短报告。
from pathlib import Path def load_ips(log_path): ip_set = set() path = Path(log_path) if not path.exists(): return ip_set with open(path, encoding='utf-8') as f: for line in f: # 假设每行只有IP或者用空格分隔的日志里包含IP parts = line.strip().split() if parts: ip_set.add(parts[0]) return ip_set yesterday_ips = load_ips("access_yesterday.log") today_ips = load_ips("access_today.log") added = today_ips - yesterday_ips removed = yesterday_ips - today_ips stayed = yesterday_ips & today_ips print(f"昨日IP数: {len(yesterday_ips)}") print(f"今日IP数: {len(today_ips)}") print(f"新增IP数: {len(added)},列表: {sorted(added)[:10]}") print(f"消失IP数: {len(removed)},列表: {sorted(removed)[:10]}") print(f"持续在线IP数: {len(stayed)}")这个脚本虽然短,但把set的几个核心能力全用上了:load_ips读文件时add,天然完成去重;today - yesterday算新增;yesterday & today算常驻;结合sorted保证输出稳定有序。整个分析过程不需要任何循环嵌套,数据量即使上百万行,跑起来也就几秒。
把这个思路迁移到业务上,思路是通用的:两份配置对比差异、两批白名单对比、两个版本的错误码集合对比、用户标签集合的新增与流失分析,本质上都是同一套集合运算逻辑。
我个人的习惯是,凡是遇到"需要知道A和B差在哪""批量数据里有重复""某个元素在不在大集合里"这三类需求,第一反应就是先想想能不能用集合。并不是说set能解决所有问题,而是它能让不少常见问题从"一层层写判断逻辑"变成"一行集合运算"。希望这篇关于set去重和对比的梳理,能帮你少走一点我当年走过的弯路。最后再补充一个小建议:如果哪天你觉得set的一些行为诡异,比如顺序错乱、元素数量对不上,先检查数据里有没有混合类型,再检查是不是自定义对象没同时实现__hash__和__eq__,这两个方向能解决掉大部分"set玄学"。