ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python集合set实战:去重与对比的高效利器

Python集合set实战:去重与对比的高效利器 我第一次在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把花括号字面量先给了dictset只能用第一期里的set()。s {} print(type(s)) # class dict我一开始写代码时在这个地方翻过车。如果你要初始化一个空set老老实实用set()。顺便说一句frozenset是set的不可变版本它可以作为元素放进另一个set里这个特性在后面讲嵌套场景时会很有用。1.3 高频方法快速过一遍set常用的方法其实就那么几个add添加单个元素update批量添加remove删除元素但元素不存在会抛KeyErrordiscard删除元素且不存在时静默跳过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, encodingutf-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相等却没写__hashPython会在你定义__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 BA.intersection(B)同时在A和B中{2, 3}并集A | BA.union(B)合并去重{1, 2, 3, 4}差集A - BA.difference(B)在A中但不在B中{1}对称差集A ^ BA.symmetric_difference(B)只属于其中一个{1, 4}同时还有三个判断用的方法issubset判断子集issuperset判断超集isdisjoint判断是否完全没有交集。运算符也对应有、以及真子集的和真超集的。A {1, 2, 3} B {1, 2, 3, 4, 5} print(A B) # TrueA是B的子集 print(A.issubset(B)) # True和上面等价 print(A B) # TrueA是B的真子集 print(A.isdisjoint({6, 7})) # TrueA和{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, number1000) # 在set里查找同样的元素 t_set timeit.timeit(lambda: 499999 in sdata, number1000) print(flist查找耗时: {t_list:.4f}s) print(fset查找耗时: {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可以理解成只有键没有值的dict5. 那些让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、dictpy3.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循环里边遍历边改setset是可变对象直接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, encodingutf-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玄学。
返回列表