如果你写过一段时间 Python大概率遇到过这类需求两个列表找出共同元素、一批用户 ID 去掉重复项、判断某个 IP 是否命中黑名单。刚入门的时候我全是靠循环加 if 硬解代码写得又长又慢直到同事甩过来一句set(a) set(b)我才意识到 Python 集合运算是被严重低估的基础语法。这件事改变了我处理数据的方式——凡是想去重、求交集差集、做成员判断的需求我脑子里的第一反应都是集合而不是循环。这篇文章会把集合的创建约束、四大核心运算、关系判断、性能原理和实战用法完整拆开讲里面穿插的是我这些年写业务代码踩过的坑和总结出的经验适合想夯实 Python 基础的新手也适合想在数据处理上少写几行重复代码的开发同学。1. 集合凭什么值得单独学三个让我改掉写循环的习惯场景1.1 场景一去重别再用循环加 if去重可能是集合最经典、也最实用的入口。我见过很多刚学 Python 的人处理重复订单号时这样写order_ids [o001, o002, o001, o003, o002] seen [] for oid in order_ids: if oid not in seen: seen.append(oid) print(seen)这段代码逻辑没有错问题在于它把一件本身很单纯的事写得变复杂了。更关键的是seen是列表oid not in seen每次都是一次线性扫描数据量到几千上万条时耗时很快就让人坐不住。用集合只要一行unique_ids set(order_ids) # {o001, o002, o003}集合天生不允许重复元素构造过程会自动去重底层哈希表让整个过程是 O(n) 而不是 O(n²)。如果后面要求保留原来的顺序也有专门的技巧我在 5.1 节会细讲。这里先记住一条原则不要求顺序的去重都该让集合来干。1.2 场景二找两个列表的共同元素另一个高频场景是两个列表里都有哪些元素。比如 A 课的选课名单和 B 课的选课名单要找出同时选了两门课的人。新手解法一般是这样common [] for name in course_a: if name in course_b: common.append(name)这版本还不算太糟但如果两个名单各有几千人效率就堪忧了。集合版本一行common set(course_a) set(course_b)注意这里必须先把列表转成set因为这个运算符要求两边都是集合只转一边也同样会报TypeError。掌握这一点之后你的数据处理代码会从一步步告诉计算机怎么做变成直接告诉它要什么语义清晰得多。1.3 场景三十万条数据的快速存在性判断第三个场景是成员判断判断某个元素在不在一个大集合里。我第一次体会到集合的可怕速度是在处理一份十几万条 IP 黑白名单的时候。用列表做target in lst最坏情况要一路扫到尾换成集合之后同一份数据速度提升基本是数量级的差距。原因在于集合的底层实现是哈希表查找平均复杂度是 O(1)而列表是线性扫描 O(n)。这个原理我在第 6 节会专门展开。现在你只需要在心里建立一条经验只要内容不要求保持顺序、元素不重复又需要反复做存在性判断那就该用集合。2. 创建集合时的底层约束无序、不可索引、元素必须可哈希运算符用起来很爽但不理解创建集合时的约束你很快会撞上一堆报错。我整理成三个话题空集合陷阱、可哈希约束、顺序问题。2.1 创建方式和那个经典的{}陷阱先看集合最常见的三种创建方式a {1, 2, 3} # 直接写花括号 b set([1, 2, 3]) # 从列表转换 c set(hello) # 从字符串拆分得到 {h, e, l, o}这里有一个几乎所有 Python 初学者都会踩的坑想创建空集合时写了empty {}。这句话在 Python 里创建出来的是一个空字典不是空集合。如果你检查type(empty)会得到dict后续迭代、添加元素的逻辑都会跟着变味。正确答案是empty_set set()我在代码 review 里见过不止一次因为这个坑导致的线上 bug通常是用{}初始化期望它当集合用结果后续往里面塞键值对时逻辑全乱。所以看到空花括号第一反应应该是这是个 dict。2.2 为什么元素必须可哈希谁可以进集合集合元素有一个硬性要求必须可哈希hashable。简单理解哈希就是把任意内容通过哈希函数映射成一个固定大小的数字集合用这个数字做索引来组织和查找元素。如果元素内容变了它的哈希值也会变那么它在集合里的位置就找不到了整个结构就塌了。这就是为什么 Python 规定可变对象不能进集合。能进集合的通常是整数、浮点数、字符串、元组前提是元组里装的也全是可哈希对象、frozenset。不能进集合的是列表、字典、集合这些可变容器。试一下你就知道s {[1, 2]} # TypeError: unhashable type: list这个报错几乎是新手写集合时的第一道坎。我的习惯是看到unhashable type就先检查是不是不自觉地往集合里塞了列表。2.3 想顺序访问集合做不到别被看起来有序骗了集合本身是无序的你不能用索引访问也不能切片s {10, 20, 30} s[0] # TypeError: set object is not subscriptable有个很迷惑的现象是小整数构成的集合打印出来有时候看起来像是排好序的。这是哈希函数和解释器内部实现的巧合不是语言保证你千万别依赖这个表现。Python 3.7 之后字典是有序的但集合的顺序从来不被保证能被保证的只有集合里没有重复元素这件事。真正需要保持插入顺序去重的场景我在 5.1 和 7.3 会给出两个可靠方案。这里先记住需要索引、切片、有序访问请用列表需要去重和快速查找才用集合。3. 四大核心运算并集、交集、差集、对称差集的符号与方法选择3.1 一张表看完四个运算先建立总览。集合的四大运算都有对应的运算符、同名方法和原地修改版本。我用课程名单来举例python_course是报 Python 课的人java_course是报 Java 课的人。python_course {小明, 小红, 小刚, 小丽} java_course {小红, 小强, 小丽, 小美}四大运算的关系如下运算运算符方法原地版本含义并集aba.union(b)a.update(b)交集a ba.intersection(b)a.intersection_update(b)两门课都报的人差集a - ba.difference(b)a.difference_update(b)只报 Python 不报 Java 的人对称差集a ^ ba.symmetric_difference(b)a.symmetric_difference_update(b)只报了一门课的人代码演示一下结果print(python_course | java_course) # {小明, 小红, 小刚, 小丽, 小强, 小美} print(python_course java_course) # {小红, 小丽} print(python_course - java_course) # {小明, 小刚} print(python_course ^ java_course) # {小明, 小刚, 小强, 小美}3.2 运算符和同名方法的差别比想象中大很多人以为a | b和a.union(b)完全等价其实差别就藏在参数类型上。运算符要求两边都必须是集合方法则宽松得多参数可以是任意可迭代对象python_course.union([小华]) # 正常返回新集合 python_course | [小华] # TypeError: unsupported operand type(s)同理a.intersection([1, 2])能用a [1, 2]会报错。所以我的选型习惯是两边都是现成集合时用符号写起来短一边是列表、元组这类可迭代对象时改用方法避免手动强转。这个细节在数据处理脚本里非常实用能少写很多set(...)包裹。另一个区别是原地版本。a.update(b)这种带update后缀的方法直接修改a本身不返回新集合。新手经常在这里搞错写了result a.intersection_update(b)然后发现result是None。记住需要保留原集合作后续使用时用普通方法明确想原地修改、省内存才用带update的版本。3.3 多集合运算和运算符优先级提醒当你要对三个以上集合做同一类运算时可以连着写all_people set_a | set_b | set_c common_people set_a set_b set_c这比你写三层嵌套循环清晰太多。但要注意集合运算的运算符优先级沿袭了 Python 位运算符的规则比^紧^比|紧。也就是说a | b c会被解析成a | (b c)这往往不是你想要的效果。我的建议就一条混用多种运算时老老实实加括号。# 想表达 (a | b) c 就写 result (a | b) c吃过这个亏之后我在代码里无论什么情况都会给集合运算打上括号看起来是啰嗦了一点但读代码的人和半年后的自己都不会误解。4. 集合关系判断子集、超集、相交权限和配置校验就靠这几行4.1 比较运算符与对应方法集合除了支持四种运算还支持直接比较集合间的包含关系A {1, 2} B {1, 2, 3} A B # TrueA 是 B 的子集 A B # TrueA 是 B 的真子集两者不等 B A # TrueB 是 A 的超集 B A # TrueB 是 A 的真超集 A set([1, 2]) # True判断内容相等 A.isdisjoint({3, 4}) # True两个集合没有交集对应方法也都存在A.issubset(B)等价于A BA.issuperset(B)等价于A B。isdisjoint没有运算符版本它的意思是完全没有任何公共元素。有个容易忽略的细节A B小于号表示真子集排除了两个集合相等的情况A B则包含相等。写业务判断时先想清楚你的语义是完全包含还是满足一部分即可选错会让边界情况漏判。4.2 权限校验和配置完整性的实际例子实际开发里我用到关系判断最多的两个地方权限校验和配置完整性检查。权限校验的典型写法是判断用户拥有的权限集合是否包含这个操作需要的权限集合user_perms {read, write} needed_perms {read, write, admin} if needed_perms user_perms: print(有权限) else: print(缺权限:, needed_perms - user_perms)输出里我直接把缺的权限算出来了这比返回一个笼统的 False 对排错友好得多。配置完整性的场景也类似一批目标机器需要配置 5 个系统参数如果有一台机器只配了 3 个就用差集快速列出缺哪几个required {host, port, timeout, retry, backoff} configured {host, port, timeout} print(required - configured) # {backoff, retry}这种代码在运维脚本里几乎天天用到可读性比写一串if host not in configured好太多。4.3 判断是否相交用 isdisjoint 而不是先求交集还有一个效率相关的经验如果只是想知道两个集合有没有交集别先做再看结果是否为空。用isdisjoint更合适因为它在发现第一个公共元素时就可以提前返回而intersection要完整构造一个新集合再判断空不空。数据量一大这个差异是很实在的。blocked_ips {10.0.0.1, 10.0.0.2, 10.0.0.3} current_ips {10.0.0.9, 10.0.0.10} if current_ips.isdisjoint(blocked_ips): print(这批 IP 都没被拦截) else: print(存在被拦截的 IP)另外isdisjoint也可以接受列表等可迭代对象作为参数和union这类方法一样只有运算符版本才严格要求两边都是集合。5. 实战组合拳数据清洗、用户行为分析、文本处理的集合用法5.1 数据清洗去重和保序去重数据清洗是我日常用集合最多的地方因为脏数据里几乎永远有重复。最简单的一行去重emails [ax.com, bx.com, ax.com, cx.com] unique_emails set(emails)但如果业务要求保留第一次出现的顺序直接set是不行的。这时候有两个方案。第一个是用dict.fromkeys利用字典键不重复且保持插入顺序的特性deduped list(dict.fromkeys(emails)) # [ax.com, bx.com, cx.com]第二个是手写一个小循环判断时用一个集合辅助加速seen set() deduped [] for email in emails: if email not in seen: seen.add(email) deduped.append(email)两个方案效果相同。追求最短代码用dict.fromkeys想要逻辑清楚、方便以后加条件比如按字段去重用手写循环。我自己在正式项目里更常用第二个因为去重规则通常没那么简单很快会演化出按用户 ID 去重但保留金额最大的一条这种需求手写循环更好扩展。5.2 用户行为分析留存、流失、拉新的一次表达集合运算在数据分析和运营场景里是隐藏利器尤其是用户集合的加减。比如上月活跃用户和本月活跃用户你能用三行代码把留存、流失、新增全算出来last_month_users {u1, u2, u3, u4} this_month_users {u3, u4, u5, u6} retained last_month_users this_month_users # 留存两月都活跃 lost last_month_users - this_month_users # 流失上月有本月没了 new_users this_month_users - last_month_users # 新增本月才有同样的套路可以用在很多业务判断上。比如买过 A 商品但没买过 B 商品的用户就是buy_a - buy_b点赞过帖子但没关注博主的用户就是liked - followed。这些场景如果用 SQL 要写 join 和 not exists在 Python 内存数据里用集合一行就完了。我实际写运营数据脚本时经常把多个条件组合起来比如找出在 A 渠道注册、最近 30 天活跃、但没有完成付费转化的用户users (registered_a active_30d) - paid_users注意这里和-混用按上一节的经验加括号写成(registered_a active_30d) - paid_users更稳妥。5.3 文本处理关键词重合和停用词过滤集合运算在文本处理里同样好用。最简单的场景比较两篇文章的关键词重合度kw1 set(article1.split()) kw2 set(article2.split()) overlap kw1 kw2这里默认文本已经按空格分词中文场景一般需要先跑一遍分词器再做转换。如果你分词之后词很多先拿停用词集合过滤一遍也是一行clean_words raw_words - stopwords # stopwords 本身就是 set还有一个我常用来做字符统计的技巧统计一段文本里出现多少个不同的字符直接len(set(text))。要找出两段文本里特有的字就set(text1) - set(text2)。集合在这里远比层层循环直观代码量直接少一半。6. 性能真相O(1) 查找背后的哈希原理以及不能无脑用 set 的原因6.1 哈希表如何让查找变成常数时间很多人背过set 查找是 O(1)list 是 O(n)但不知道背后为什么。这里用一个生活类比列表就像一个没有索引的仓库你要找一件东西只能挨个翻最坏情况要翻完整个仓库集合则像一个带标签货架的仓库每个箱子在进库时按内容算出对应的货架编号找的时候直接走到那个货架前。具体到 Python 里集合内部是一个哈希表。放进集合的每个元素都会先经过哈希函数算出一个整数再被映射到内部的某个槽位。查找元素时只需重新计算它的哈希值、定位到对应槽位然后对比一下是否真的相等。这个定位过程不依赖集合里总共有多少元素所以平均复杂度是 O(1)。这也解释了前面 2.2 节的可哈希约束为什么存在——如果元素放进集合后又变了它再次哈希时算出的槽位就变了原来的位置会留下一个幽灵集合就无法正常工作。6.2 一份简单可复现的 timeit 对比光说原理不够直观我建议你直接在自己的机器上跑这段对比import timeit lst list(range(100000)) st set(lst) t_list timeit.timeit(lambda: 88888 in lst, number1000) t_set timeit.timeit(lambda: 88888 in st, number1000) print(flist: {t_list:.4f}s, set: {t_set:.6f}s)我这边跑出来的结果1000 次判断 88888 在不在里面列表版本要花将近 1 秒集合版本不到 0.001 秒差出好几个数量级。你机器上的具体数字可能不同但量级差距基本是一致的。这个测试数据只有 10 万条如果是百万级数据列表的劣势会更明显。in判断是最直观的例子实际上所有依赖成员判断的算法比如先if x not in seen再插入的流程用 set 替换 list 后整体收益都是肉眼可见的。6.3 set 的代价和选型参考但不能因此就所有地方都用 set它是有代价的。集合为了哈希表的性能内存开销远大于列表同一批 10 万元素set 占的内存可能是 list 的两到三倍。另外set 天生去重且无序如果你的业务需要保留重复数据或者依赖顺序就绝对不能无脑转 set。还要注意构造集合本身是 O(n) 的如果你的数据每次只用一次、量又小set(lst)的开销可能比省下的查找时间还大。我总结的选型参考大致是这样场景推荐要留着重复项、要顺序、要索引list只去重、判断是否存在、求交并差set需要统计每个元素出现次数list Counter见 7.4小数据一次性处理顺序无所谓随意写起来清晰优先一句话数据清洗、去重、集合关系运算用 set其他老老实实用 list。7. 避坑清单与进阶玩法从 TypeError 到 frozenset7.1 最常见的三个报错和误解第一个是空集合陷阱{}得到的是 dict前面已经讲过。第二个是unhashable type报错常见于往集合里塞列表、字典或者塞了一个包含列表的元组。第三个是依赖集合顺序——哪怕打印小整数集合时看到有序的错觉也永远不要在业务里依赖它需要排序就显式调用sorted()。还有一个很隐蔽的坑遍历集合的同时修改集合。Python 的 set 和 list 一样边遍历边增删会触发运行时错误或者得到难以预测的结果。要删掉满足条件的元素安全的做法是用集合推导式重建s {1, 2, 3, 4, 5} # 想删掉所有偶数别写 for x in s: s.discard(x) s {x for x in s if x % 2 0}7.2 集合推导式和 frozenset 的进阶场景集合推导式是 Python 里很顺手的一个语法和列表推导式几乎一样只是外面用花括号squares {x**2 for x in range(1, 10) if x % 2 0} # {64, 4, 16, 36}它能一行完成过滤 去重 构造集合。我在清洗脏数据时经常用它改写冗长的循环。再说frozenset它是不可变版本的集合。因为可哈希它可以作为集合的元素和字典的键。需要集合的集合时绕不开它fs1 frozenset([1, 2, 3]) fs2 frozenset([3, 4, 5]) outer {fs1, fs2}这个特性在做组合去重时很实用。因为 frozenset 内部元素无序frozenset([1, 2])和frozenset([2, 1])完全等价所以如果业务上渠道 A 商品 B和商品 B 渠道 A算同一个组合直接用 frozenset 去重比用 tuple 要省心。7.3 保序去重技巧dict.fromkeys 和它的小限制之前 5.1 提过dict.fromkeys保序去重deduped list(dict.fromkeys(items))这个技巧利用了 Python 3.7 之后字典保持插入顺序的特性非常简洁。但要注意它只适用于元素可哈希的对象因为字典键同样要求可哈希。如果有元素是不可哈希对象还是得写循环配合一个seen集合来做。我还会用一个小变体只对某字段去重。比如一堆字典记录想按id去重但保留第一条records [{id: 1, name: a}, {id: 1, name: b}, {id: 2, name: c}] seen set() unique_records [] for r in records: if r[id] not in seen: seen.add(r[id]) unique_records.append(r)这种按字段去重用dict.fromkeys不好直接写手写循环反而最清楚。7.4 集合和 Counter 的分工很多人处理文本时会在 set 和 Counter 之间纠结。set 只能告诉你有哪些元素给不出每个元素出现几次要统计频次用collections.Counterfrom collections import Counter words [a, b, a, c, a, b] counts Counter(words) # Counter({a: 3, b: 2, c: 1})Counter 可以配合 set 做词频差这类分析。比如找出在文档 A 里出现过、但不在核心词库里的高频词head_words {w for w, c in counter_a.items() if c 10} outside head_words - core_words我通常把两者当成互补工具集合管有没有Counter 管有多少。最后分享一个我写代码时的小习惯凡是涉及集合运算的表达式我都会强制自己加上括号哪怕只有两个集合。这个习惯是从一次线上问题里学到的——那次有人在混用-和时没加括号读代码的人理解错了意图后来改出了 bug。集合本身语法很简单真正的复杂度在于你会不会选对数据结构、有没有把它用在正确的地方。多练几次数据清洗和用户分析的场景你会发现这套基础语法能替你省下非常多的循环代码。