首页 / 资讯中心 / 文章详情

Python集合set完全指南:从哈希表原理到去重与性能优化

Python集合set完全指南:从哈希表原理到去重与性能优化 ★ FEATURED ARTICLE
聊到py里的集合set很多人第一反应是“哦就是那个能做交集并集的东西”然后转头就忘了。我见过不少写了两三年py的人遇到去重用循环判断元素在不在也只会写if x in list明明set一行就能解决的事非要写五行业。这篇不是从文档里抄定义而是想把“用集合的思维”讲透set到底快在哪、坑在哪、哪些场景应该第一时间想起它。这篇东西适合两类人一类是刚入门py、知道set但一直不确定它有什么用的人另一类是已经写了一阵子脚本、想优化代码里那些“判重”“筛选”逻辑的人。不需要高深背景会基本的list和dict就能跟下来。文里涉及的操作我都会放可直接跑的示例你在自己机器上敲一遍效果比单纯看强十倍。1. 集合到底解决什么问题设计初衷与核心特性1.1 为什么有了列表还要学集合列表list是py里最常用的容器什么东西都能往里塞有序、可重复、好索引。但“什么都能塞”也意味着它不够克制你要找某个元素在不在里面得从头到尾遍历万一列表有一万条最坏情况要比较一万次。集合的定位恰好相反它只回答“某个东西在不在里面”这一个问题而且用哈希表的方式来回答复杂度压到O(1)。另一个叫板场景是去重。列表允许重复元素去重就得自己想办法集合天生不允许重复往里塞五百条数据相同的只会留一个。用集合本质上是在跟py说“我只要唯一的、能快速判断是否存在的元素”剩下的细节别让我操心。生活化类比列表像一条没贴标签的鞋柜所有鞋按顺序排着找一双鞋得一双双看集合像一面用磁铁贴标签的墙每个标签只有一块磁铁贴重复的会被挤掉想知道某个标签在不在扫一眼就知道。不是鞋柜不好是“收藏”和“快速查找”这两个需求本来就应该用不同的东西。1.2 set的底层逻辑为什么说它是“没有value的dict”集合底层实现是哈希表你可以直接把它理解成“一个只有key、没有value的字典”。正因为key存在哈希表里所以set有以下三个特性无序性元素存储位置由哈希值决定跟插入顺序没有必然关系普通set本质上不保证顺序。互异性同一个哈希值会去重元素只会出现一次。元素必须可哈希所以int、str、tuple这些可以塞进去list、dict、set本身不行。这三个特性不是官方拍脑袋定的而是哈希表这个数据结构的必然结果。理解这一点后面所有“怪现象”都能解释为什么去重后顺序变了因为哈希表顺序不意味着插入顺序。为什么list不能放进set因为它没有稳定的哈希值。为什么有时候小int和字符串的性能差距那么小因为哈希算法对常见类型做了优化基本一次定位。如果你学过一点点数学里的集合概念py的set和它是照应的确定性、互异性、无序性三条你都能在py里找到对应表现。所以说学集合不只是学API是在学一种“用唯一性来组织数据”的思维方式。2. 新手必看集合的创建、增删与常用操作2.1 创建集合的几种姿势与空集合陷阱花括号大法适合已知固定元素的情况。如果数据在列表里直接用set()函数传进去。字符串也能直接转注意它会按字符拆开相同字符自动去重。一个高频错误是空集合写{}——这样得到的是空字典不是空集合。想创建空集合必须写set()。我见过不少人拿{}当空集合然后add的时候直接报错TypeError: dict object has no attribute add。看到这个报错就说明你手里是个空字典不是空集合。s1 {1, 2, 3} # 花括号直接创建 s2 set([1, 2, 2, 3]) # 从列表转集合自动去重 s3 set(hello) # 结果 {h, e, l, o} s4 set() # 空集合注意不是 {}集合也支持常见的长度和遍历操作。len(s)返回元素个数x in s判断成员for循环直接遍历所有元素。这些操作和list用起来差不多区别只在于存储和查找的底层机制不同。创建完集合就能立刻感受到打印出来的顺序和你塞进去的顺序大概率对不上。2.2 增删元素时最容易踩的三个坑add()只能添加单个元素update()可以塞入一个可迭代对象相当于批量合并。增加的时候有个非常经典的坑试图把list或dict放进set直接报TypeError: unhashable type: list。这个报错本质是说“这个类型没有稳定哈希值不能作为集合的key”。解决方法是要么用元组代替列表要么用frozenset代替set要么重新设计数据结构。删除元素有remove()和discard()两个方法前者对不存在的元素会抛KeyError后者静默跳过。新手喜欢用remove但如果你只是在清理数据、不关心某个元素是否存在discard()更省心。pop()会从集合里弹出一个元素因为集合无序你拿到的不是“第一个”而是“任意一个”——这个特性后面会提到要谨慎依赖。s {1, 2, 3} s.add(4) s.update([5, 6]) # 批量添加 s.discard(99) # 不存在也不报错 s.remove(1) # 存在才能删否则 KeyError x s.pop() # 弹出任意一个元素 s.clear() # 清空集合2.3 集合推导式一行代码生成数据跟列表推导式几乎一样的写法把方括号换成花括号生成的就是集合。比如我有一个一百万条的用户ID列表想筛出其中所有偶数ID一行搞定。注意区分{x for x in range(10)}是集合推导式{x: x*x for x in range(10)}是字典推导式不要看混。even_ids {i for i in id_list if i % 2 0}集合推导式还有个很常见的用途从一堆数据里快速提取某个字段的不重复取值。例如统计一批日志里都出现过哪些错误码一条set(log[code] for log in logs)就得到了所有不重复错误码。这种用法在处理接口返回、日志分析、清洗数据时几乎天天用到。3. 集合的高频应用场景去重、判断与数学运算3.1 列表去重为什么别用循环去写最典型的需求是给列表去重。最简单写法是sorted(set(lst))一行完成。问题是集合无序去重后顺序可能被打乱如果数据有顺序要求就得保留顺序去重。我自己最常用的保序去重方案是“set 新列表”的组合items [a, b, a, c] seen set() result [] for item in items: if item not in seen: seen.add(item) result.append(item) print(result)这比“边遍历边删除原列表”的方案安全得多。边遍历边删列表元素是出了名的坑索引会错位、容易漏元素属于新手经常踩的雷。把“看过的元素”交给set维护新列表只做append逻辑清晰原列表不会被改坏。这个模式在数据处理里太常用了建议直接背下来。另一个可用的小技巧是利用py3.7的dict有序性list(dict.fromkeys(lst))也能保序去重因为dict的key天然不重复且当前版本的dict会保持插入顺序。适合想一行解决的场景但可读性不如set版本直观。3.2 成员判断O(1)和O(n)差在哪里判断一个元素在不在集合里是set对列表最明显的性能碾压。列表是线性结构查找一个元素相当于把整条链从头摸到尾平均O(n)。set是哈希表计算哈希值后直接定位平均O(1)。数据量小的时候感觉不到数据量涨到十万百万级差距立竿见影。我自己写过一个粗略对比一万条数据里反复做五千次成员判断set方案基本在毫秒级响应list方案会慢上几十上百倍。具体数值和机器有关但数量级的差距是稳定的。你在自己电脑上跑timeit也能复现不用迷信我说的数字试一试就知道。我的建议很直接只要代码里出现“某个元素是否存在”这种语义一律优先想set。列表的in语法虽然也能用但O(n)代价是实打实的。尤其当你后续还要做交集、差集用set更是顺水推舟。3.3 交集并集差集把集合用成数据筛选工具集合最贴合数学名字的操作是一组运算符交集、并集|、差集-、对称差^。不需要写循环一行直接出结果。我举一个贴近生活的例子假设你有A组是全部注册用户B组是本月有订单的用户想找出“注册了但没下单”的人群就是A - B想找出“又在A又在B”的人群就是A B想知道两边总共覆盖了多少人去掉重复就是A | B。操作运算符方法名含义交集A BA.intersection(B)A和B共有的元素并集A | BA.union(B)A和B全部元素去重差集A - BA.difference(B)在A里但不在B里的元素对称差A ^ BA.symmetric_difference(B)两边不同时存在的元素运算符版本和函数版本选一个顺手的即可很多人喜欢运算符因为一眼能看懂。判断子集用issubset、issuperset判断两集合是否有交集用isdisjoint返回True表示没有共同元素这几个方法在写权限、规则、标签筛选逻辑时能省下不少循环。实际项目里我经常从数据库查出两个时间段的用户ID列表转成set后直接做差集或交集十分钟能想清楚的逻辑代码往往只有四五行。这种“批量集合运算”思维是set带给你的最大价值。3.4 实战拆解小蓝的fibonacci集合这题考的是set的无序性有个很经典的入门题题面说“小蓝定义了一个fibonacci集合f集合的元素如下定义最小的5个fibonacci数……”后面让求的东西五花八门有的问有几个元素有的问元素和有的问交集差集。虽然这句话经常被截断但考点其实很集中就是集合的无序性和去重性。注意fibonacci数列如果从1、1、2、3、5开始前5项里有两个1。如果直接用f set(...)这个集合实际只有4个元素。我在带人做题时发现很多人对“数列里有重复项但集合去重后数量变了”完全没有概念踩一脚就想不通。正确做法是先搞清楚题目里fibonacci数列从第几项开始定义如果从0、1、1、2、3开始去重后同样会减少如果题目明确“最小的5个不重复的数”那就需要用while循环生成等集合长度满5再停。f set() a, b 1, 1 while len(f) 5: f.add(a) a, b b, a b print(sorted(f))这段代码不会死循环但你会得到一个很有意思的结果因为1被去重了while会一路加到8才凑满5个元素最终得到{1, 2, 3, 5, 8}。这就是“最小的5个fibonacci数”和“最小的5个不重复fibonacci数”之间的本质差别。这个坑如果能在初学阶段踩过你对集合去重的理解会扎实很多。4. 集合运算与链表差集一次看懂底层思路4.1 数据结构课那道“基于链表的集合差集”到底在考什么热词里有一条“基于链表的两个集合的差集”这其实是数据结构教材里的经典题C语言时代经常考。题目大意是两个链表分别代表集合A和B求A-B也就是找出出现在A里但不在B里的所有元素。这道题的本意不是让你在py里用set一行秒杀而是考察你有没有意识到“链表本身不支持随机查找”。链表找某个元素只能从头部一个个遍历如果直接用两层循环来求差集时间复杂度是O(m*n)。老师想看到的优化思路是把其中一个集合的元素放进哈希表或辅助数组让查找变成O(1)整体复杂度降到O(mn)。这个“用哈希表换查找速度”的思路跟py里set的底层机制如出一辙。4.2 用py手写一个链表集合差集为了把思路讲清楚我在py里手写一个极简链表。逻辑分三步遍历A链把元素装进set遍历B链同样装进另一个set最后做差集。代码很直接class Node: def __init__(self, val, next_nodeNone): self.val val self.next next_node def to_set(head): s set() cur head while cur: s.add(cur.val) cur cur.next return s def difference(a_head, b_head): set_a to_set(a_head) set_b to_set(b_head) return set_a - set_b这段代码写出来就是标准答案遍历链表时只做set的add操作查重交给哈希表。时间复杂度O(mn)注意遍历两个链表各一遍空间复杂度O(mn)因为额外申请了两个set。如果面试官追问能不能省空间你可以说“只需要把其中一条链表转成set然后遍历另一条做判断”这样空间可以降到O(m)或O(n)具体取决于哪条更大。这种“时间换空间”的权衡是数据结构的经典考法。4.3 从链表差集回到set一个思想两种载体如果你已经看过第3章的A - B写法再看这个链表版会发现核心思想完全一样只要两个“元素容器”能快速判断包含关系差集就能高效算出。链表的难点在于“取元素麻烦”py里的set替你把它封装好了数据结构课让你手写链表是为了让你理解那层封装下面到底发生了什么。所以学集合的时候别只停留在“会用-运算符”这个层面。你可以试着想想如果数据源是链表、文件、数据库返回的cursor你能不能先把它们转成set再批量做集合运算我处理过不少日志合并的活都是把两批ID各转成set然后直接交并差十几行代码完成原来几十行的循环逻辑。理解底层结构你才能知道set适合从哪里切入。5. 常见问题速查去重乱序、可变元素报错、版本兼容5.1 为什么set去重后顺序全变了如果你用list(set(lst))去重发现结果顺序和原列表不一样这不是bug是集合无序性的体现。set内部用哈希表存储元素的存储位置由哈希函数决定插入顺序无从谈起。需要保序去重时就用3.1里“set 列表”的方案。如果你想更省事list(dict.fromkeys(lst))这个写法也值得记住它既有去重能力又能保持顺序底层原因是py3.7的dict是有序的。不过这个写法比较隐晦别人读代码时得想一下set加列表的写法反而更直白。5.2 TypeError: unhashable type 报错怎么办前面提到set要求元素可哈希最常见的报错就是尝试把list放入set或者把set作为元素放入另一个set。遇到这个报错先看你塞进去的元素类型再决定怎么改。列表可以改成元组普通set可以改成frozenset。frozenset是一个不可变集合它本身可以作为另一个set的元素也可以作为dict的key。如果你需要“集合的集合”这个结构比如一批用户每组都有自己的一堆标签就得靠frozenset来实现。还有一个更容易忽略的隐藏坑True和1。二者哈希值相同所以{1, True}实际上只有一个元素False和0同理。这个知识平时不太用得上但面试题或练习题里如果出现“集合去重后还剩几个元素”这种题它常常是那个“陷阱开关”。5.3 老py文件在Python 3.12上运行出错的排查思路热词里提到“旧的py文件在python3.12上运行出错”这属于版本迁移问题我自己也踩过几次。排查时先看报错信息的前两行区分三类问题ImportError某个库在新版本里被移除或改名。比如distutils在3.12被移出标准库很多老项目直接pip install就会挂。SyntaxError老代码用了新版本不再支持的语法比如Python 2风格的print语句这类问题很少见因为大部分项目早迁移完了。运行时行为变化代码不报错但结果和预期不一致比如dict遍历顺序、除法精度等随版本改变。针对set这个主题有一个比较隐蔽的坑老代码可能依赖set.pop()的顺序做“近似随机处理”但set本身不保证顺序升级环境后“随机”效果可能变化导致数据分桶或抽样的结果漂移。如果业务对随机性和可复现性有要求别用set做顺序相关的逻辑用random模块里的shuffle或sample更可靠。5.4 常见集合问题排查清单用一张表把新手和迁移场景里最常踩的问题集中列一下方便排查时按表搜索。现象原因解法{}不能add得到的是空字典用set()创建空集合去重后顺序乱set基于哈希表存储用setlist保序或dict.fromkeys(lst)Unhashable type: listlist不可哈希换成tuple或用frozenset{1, True}只保留一个True的哈希值和1相同按0和1区分避免用Truepop()结果不稳定set无序别用pop做“取第一个”的逻辑老代码新版本报ImportError标准库被移除/改名升级第三方库或用社区替代方案大列表in判断很慢列表查找O(n)转成set做成员判断6. 入门之外的三个实用技巧运行、打包与脚本间传参6.1 Windows下双击就能运行py文件很多初学者在Windows上跑py脚本总要先打开命令行敲python其实配置好之后双击.py文件就能直接运行。安装Python时如果勾选了“Add Python to PATH”和文件关联选项.py文件默认会用Python启动。如果双击后只是闪一下黑框就没了那是因为程序运行完自动关闭了窗口在脚本末尾加一行input()或者os.system(pause)就能看到输出。更规范的做法是在cmd里用py xxx.py运行。Windows的py启动器会自动选择当前环境的Python版本比直接敲python更稳定。如果你装了多个Python版本py --list能看到所有已安装的版本。右键“打开方式”里选Python如果被改掉了可以在文件上右键属性把打开方式重新设置为Python之后双击就能恢复。6.2 在pycharm里把py程序变成exe热词里提到“pycharm中把py程序变成exe”这个需求通常是“我想打包一个给没装Python的人也能跑的软件”。做法分几步先在pycharm的终端里安装pyinstaller再执行打包命令。注意在pycharm里操作时要用底部Terminal面板而不是Python Console。pip install pyinstaller pyinstaller --onefile --windowed main.py参数含义--onefile表示生成单个exe文件方便分发--windowed表示不弹出黑色命令行窗口适合GUI程序。如果是纯命令行工具可以去掉--windowed。生成结果在项目目录下的dist文件夹里直接把这个exe拷给别人就能跑。我第一次打包时犯过糊涂以为命令要在Python Console里跑结果一直报错。后来才明白打包本质是在操作系统命令行里调用pyinstaller工具。需要提醒的是pyinstaller打包不改变脚本本身只是把解释器和依赖一起封装。第一次打包可能比较慢属于正常现象。如果你的程序依赖外部文件比如配置文件、图标或模型文件打包时要用--add-data把那些文件带进去否则exe跑起来会提示找不到文件。6.3 一个py脚本给另一个py脚本传递参数脚本间传参有几类常见方式最简单的就是命令行参数。脚本运行后用sys.argv获取参数以空格分隔。另一个脚本里要调用它可以用subprocess模块启动子进程# a.py import sys if len(sys.argv) 1: print(拿到参数:, sys.argv[1])# b.py import subprocess subprocess.run([python, a.py, --target, list.txt])这里有个细节Windows下如果路径里带空格列表里的字符串必须完整写成一条否则会被PATH拆开。另一个更省心的替代方案是json文件脚本A把结果写到临时json脚本B再读取。数据量不大时这个方法比命令行参数更清晰尤其适合传集合、列表这种结构化数据。结合前面set的应用场景举一个例子主脚本从一批文件里筛选出符合条件的文件名集合然后调用另一个统计脚本把筛选后的集合作为参数传过去。命令行参数只能传字符串集合就转成逗号分隔的字符串子进程里再split后转回set。数据结构再复杂一点直接上json两边都用json.loads和json.dumps既清楚又不容易出错。最后分享一个我自己的习惯写脚本时一旦遇到“去重”“求交集”“判断在不在”这些词我会先把list方案写出来然后再想一遍“这里能不能换成set”。不是list不行而是set能让代码更快、更短、更不容易出bug。初学时觉得集合只是多背几个方法用多了会发现自己处理数据的方式都变了。你要是也在某个项目里用过很妙的集合操作欢迎回来聊聊我挺想知道大家在py里都拿set干过哪些有意思的事。
阅读完成 · 觉得有帮助?
咨询建站