
我们先把一个很现实的问题摆出来你在写数据处理代码的时候有没有遇到过“列表里全是重复数据想去掉几万条里的重复项”的情况大多数人第一反应是写一个空列表然后一条一条判断“if item not in new_list”再append进去。这段代码在数据量小的时候还能跑数据量一上来慢到你想砸键盘。这时候如果换个思路用集合set去重一次就能搞定。标题里提到的“集合与元组”一个是Python里处理去重和成员判断最趁手的工具一个是保证数据不被意外修改的最稳选择。这篇文章就围绕一个核心问题Python的集合和元组到底在什么时机用、怎么用、以及用了之后能避开哪些坑。适合谁来读如果你是刚开始学Python的这篇文章能帮你理清list、tuple、set三者之间的关系如果你已经写了一阵子业务代码想优化性能、减少隐性bug这里面也有不少从实际项目中踩出来的经验可以直接抄作业。1. 先厘清概念集合和元组在Python里到底是什么1.1 集合一个用哈希表撑起来的“不重复仓库”很多人对set的理解停留在“一个无序的、里面的元素不重复的列表”。这个理解没有错但不完整。真正让set和list拉开差距的是它的底层实现。集合底层是基于哈希表来实现的这一点和字典dict一样。它通过计算元素的哈希值直接把元素放到对应的“格子”里所以查找一个元素是否在集合中只需要计算一次哈希然后直接定位。这种查找方式的时间复杂度是O(1)而列表的成员判断需要逐个遍历时间复杂度是O(n)。这个差异在业务代码里意味着什么呢举个例子你要判断100万个URL里哪些已经被爬过了。如果用一个列表每判断一次新URL可能要遍历几十万次已有URL总操作量爆炸。但如果用set无论集合里有10万条还是100万条判断一个新URL是否存在都只需要一次哈希计算速度基本恒定。这就是set被称为“去重利器”的根本原因不是名字好听是真的靠算法硬撑起来的。另外要注意一条铁律set里的元素必须是可哈希的。数字、字符串、元组这些都可以列表、字典这种可变类型不行一放进去就会抛TypeError: unhashable type。这笔账后面在实战中还要细算。1.2 元组不可变的序列更像是“定好的契约”元组tuple在很多教程里被称为“不可变列表”。从表面上看它的用法确实和列表几乎一样支持索引、切片、遍历、拼接唯独不支持修改。但这个“不能改”的特点恰恰是它的价值核心。打个比方。列表就像白板随时可以擦掉重写元组则像一份签好字的协议内容定了就是定了谁也不能单方面改。这个“谁也不能改”的特性在工程上有几个直接好处第一它能作为字典的键或者set的元素因为哈希值不会因为内容变化而失效第二在多个人协作开发的项目里它天然防止了某个函数内部偷偷修改你传进去的数据第三它占用内存比列表小遍历速度也比列表快一点点虽然不是数量级的差距但积少成多。1.3 到这里你得先建立一个选型思维很多新手选错数据结构不是因为不知道语法而是因为脑子里没有一个判断框架。我把这个框架简化成四句话需要增删改、内容和数量都不固定 → 用list。内容和数量固定且不希望被改动 → 用tuple。需要快速去重、快速判断某个东西在不在里面 → 用set。需要保证元素顺序又要去重 → 通常用dict.fromkeys或者set加list组合直接set会丢失顺序。这四个场景基本覆盖了日常业务里90%的数据结构选择问题。后面每个章节展开说。2. set的实际应用场景去重只是基本功真正的杀手锏是这些2.1 大规模数据去重一行代码解决的事先写最直接的场景。假设你收到了一份日志文件里面有几百万行用户ID你要统计有多少个独立用户访问了系统。user_ids [u_1001, u_1002, u_1001, u_1003, u_1002, ...] # 好几百万条 unique_ids set(user_ids) print(len(unique_ids))List转set一行代码去重完成。这个操作的原理就是把每个元素哈希后放到唯一的位置重复的元素只保留一个。你不需要写循环不需要维护一个“已存在”列表更不需要担心性能。我实际处理过一个类似的项目当时拿到的原始数据有接近300万条里面有大量的重复点击记录。用set去重之后独立用户数几秒钟就算出来了。如果用列表加in判断的方式我估摸着至少要跑十几分钟。这个差距在真实生产环境里不是“慢一点”的问题而是能不能跑完的问题。使用set去重时有一个常见疑问“去重之后顺序变了怎么办”确实set是无序的如果你需要保留原始顺序直接set(user_ids)会把顺序打乱。这时候要换一个姿势# 保留第一次出现的顺序去重 unique_ids list(dict.fromkeys(user_ids))dict.fromkeys会以user_ids中的顺序创建字典相同的键会被后面的覆盖但是位置保留第一次出现的位置再转回list顺序就保住了。这个方法在不同Python版本上都可以用是我做数据清洗时最常用的一招。2.2 成员判断把O(n)变成O(1)集合另一个高频用途是成员判断。判断一个元素是否存在用list的in是线性扫描用set的in是哈希定位。# 一个在线判题系统里需要快速判断某个提交编号是否在“已判题”名单里 judged_set {1001, 1005, 1008, 2003} def is_judged(submission_id): return submission_id in judged_set看起来是小优化但接口如果每秒被调用几万次这个差异就会直接体现在CPU占用上。我见过某个查询接口原本是拿列表存黑名单后来改成集合之后单次查询从几十毫秒降到了个位数毫秒。空间没多占多少时间省了一大截。2.3 集合运算交集、并集、差集一次到位集合之间的关系运算在业务里也有大量应用场景。比如计算两个群里的共同成员、两个权限角色之间的差异、或者一批数据里新增了哪些ID。old_users {u_001, u_002, u_003, u_004} new_users {u_003, u_004, u_005, u_006} # 共同用户交集 common old_users new_users # 新增用户差集 added new_users - old_users # 所有用户并集 all_users old_users | new_users这三个运算符背后的实现也是基于哈希表所以性能比在列表上做嵌套循环高出几个量级。如果这个需求放在列表上做你可能会写出三层循环先共同、再新增还得做去重。用集合一分钟的活变成一秒。2.4 不要只看set的好处它的限制也要心里有数set不是万能的有几个限制我在项目里踩过这里提前说出来。第一是内存开销。set的哈希表结构决定它比列表“胖”同样是存100万个整数set占用的内存可能是list的两倍以上。如果你的数据量特别大且只需要去重一次可以考虑先用set去重再把结果转回list或tuple释放掉哈希表的额外开销。第二是无序。尽管列表是有序的、字典在Python 3.7也是有序的但set的无序性从未被改变。如果你用set之后期望顺序恢复一定要使用dict.fromkeys或者sort。第三是不可哈希类型的问题。set中不能包含list、dict、set。如果你真想“去重一个包含列表的结构”比如去重一堆包含两个点的坐标对那不能直接用set。解决方案是把内部结构转换成tuple再去重。比如点(1, 2)和点(3, 4)先转成元组列表再set。这块和元组有关后面会展开。3. 元组的价值不只是“列表不能改”它的不可变性在工程中意味着更多3.1 什么时候该用元组先从数据结构本身找答案很多人写Python几年几乎不用元组所有的序列都用list问起来就说“元组能做的事列表都能做”。这个说法没错但问题在于反过来也成立——列表能做的事元组其实也能做大部分除了修改。当一个东西“能做”和“该做”是两回事。元组最值得用的第一个场景是数据本身不应该变化的时候。举个例子一个几何工具里定义一个圆的圆心坐标和半径circle (100, 150, 25) # 圆心x, 圆心y, 半径这个值在整个算法运行中不应该被任何函数改变。如果用list某个函数里不小心写了一句circle[0] 200那么这个圆的圆心就悄悄变了调bug的时候非常难找。用tuple之后任何对它的修改都会直接抛异常程序员可以在第一时间发现错误。这种“宁可报错也不要静默出错”的思路适合所有多人协作项目。3.2 元组的隐藏大招成为字典的键或集合的元素list不能作为字典的键但tuple可以这个特性经常被忽略。比如要统计一个二维网格上每个点被访问的次数键是(x, y)坐标visit_count {} for x in range(10): for y in range(10): point (x, y) visit_count[point] visit_count.get(point, 0) 1这里元组充当了“复合键”的角色。你既不想用字符串拼接转来转去容易出错又不能直接用两个分开的键tupled的特殊地理位置就是为这种场景准备的。同理如果要给一组坐标对去重直接set(points)就行前提是points里都是元组而不是列表。points [(1, 2), (3, 4), (1, 2), (5, 6)] unique_points set(points) # 报错unhashable type: list # 正确写法 unique_points set(tuple(p) for p in points)错误信息, 唯一的坑点就是要记得转tuple。3.3 具名元组让代码从“靠位置猜测”升级到“字段语义化”普通元组的缺点是访问元素要依靠下标比如coordinate[0]到底是什么含义时间久了连自己都看不懂。标准库里的collections.namedtuple可以完美解决这个问题。from collections import namedtuple Point namedtuple(Point, [x, y, z]) p Point(10, 20, 30) print(p.x) # 10 print(p.y) # 20具名元组比写一个class轻量得多不可变、可哈希、又能像访问属性一样取值。某开发者的一个可视化项目里用了大量三维坐标点以前是每三个数字放在一个list里后来全部改成具名元组代码的可读性和可维护性提升非常明显。唯一的缺点是性能上比起普通元组有一点点小的开销但在业务代码里完全可忽略。3.4 元组的浅层不可变这个坑务必看一下元组不可变但它内部的元素如果本身是可变对象那这个可变对象仍然可以变。这句话有点绕用例子直接看t (1, [2, 3], 4) t[1].append(100) print(t) # (1, [2, 3, 100], 4)你看tuple本身没有被“替换”但里面的list被改了。这个行为有时候会带来比较大的麻烦因为如果你把一个包含list的tuple作为字典键会直接报unhashable type。所以正确理解是元组的不可变是“浅层不可变”它保证的是引用的数量和顺序不变不保证引用指向的对象内部不变。如果你需要一份真正深层的不可变数据要么确保元组内的所有元素都是不可变类型数字、字符串、元组要么自己写一个专门的不可变容器。我在项目中遇到的一个实际教训是一个配置项是元组其中一个字段是列表某段逻辑里对列表做了排序结果改变了配置的语义。排查了很久才定位到。从那以后凡是要作为“配置契约”的元组我都不放list进去要放也是tuple。3.5 性能上的细节tuple比list省那么一点除了安全和语义元组在性能表现上也有一点点优势。Python在编译时就知道元组的大小内存分配是一次性的而list需要预留额外空间以备将来append。因此相同元素数量下元组占用内存更小创建速度也更快。不至于说用tuple就能让你的程序起飞但能用tuple的场合顺手用tuple积少成多。举个例子一个函数有三个返回值用return x, y, z实际上就是在返回一个元组。这是Python最常见的元组使用方式很多人天天在用但没意识到。def get_user_stats(user_id): login_count 10 last_active 2025-01-01 12:00:00 is_vip False return login_count, last_active, is_vip login_count, last_active, is_vip get_user_stats(123)这种解包操作底层就在使用元组。同理for x, y in points 也是元组解包的日常体现。如果你写习惯了会觉得元组是一种“伴随语法”存在的数据结构自然而然地出现在函数返回、循环遍历、参数传递这些场景里。4. 集合和元组的选择决策从场景出发而不是背规则4.1 一张表说清楚list、tuple、set、frozenset的区别很多面试题喜欢问“list和tuple的区别”、“set和list的区别”其实把它们放在一起对比更清楚。特性listtuplesetfrozenset是否可变可变不可变可变不可变是否有序有序有序无序无序是否允许重复允许允许不允许不允许元素是否可哈希元素可以是任何类型但自身不可哈希元素可为任何类型自身若全为不可变类型则可哈希元素必须可哈希自身不可哈希元素必须可哈希自身可哈希性能特点增删改灵活创建和访问略快查找/去重O(1)查找O(1)可做键典型用途动态数据集合固定结构、函数返回值去重、成员判断、集合运算需要哈希的不可变集合如作为字典的值/键这张表最核心的信息在于“可哈希”这一行。list和set都不能作为字典的键tuple在“内部元素全部可哈希”时可以frozenset可以直接作为键使用。4.2 用四个问题做决策遇到数据结构选择问题就问自己问四个问题顺序不能乱这个数据需要被修改吗如果数据在生命周期内不应该被改选tuple如果确实需要增删改跳转到下一题。这个数据需要快速判断“某个元素是否存在”吗如果需要选set如果只是遍历、索引访问选list或tuple。这个数据需要去重吗需要去重且顺序无所谓选set需要去重但必须保序用dict.fromkeys或者后续排序。这个数据需要作为字典键或另一个set的元素吗如果需要而且内容本身是固定的多元素结构选tuple或frozenset。这个决策流程看起来很简单但真正常年写代码的人会告诉你绝大部分数据结构选型错误就是因为没有先问“这个数据在后面到底会被怎么用”。4.3 一个实战案例日志分析中的组合使用某开发者的数据分析项目需要处理一批订单日志每条日志包含订单号、用户ID、商品ID、下单时间。需求有三个统计当天购买过的独立用户数量、按时间顺序展示订单、统计每个用户购买过的独立商品种类。第一反应是最简单粗暴的方案三个列表。但数据量达到几十万行时去重和成员判断就会拖累整个任务。最终落地的方案是用户ID放set去重计数订单列表用list保持原始顺序每个用户对应的商品集合用dict存set。unique_users set() order_list [] user_products {} for order in raw_orders: order_id, user_id, product_id, ts order unique_users.add(user_id) order_list.append((order_id, user_id, product_id, ts)) if user_id not in user_products: user_products[user_id] set() user_products[user_id].add(product_id) # 独立用户数 print(len(unique_users)) # 每个用户的独立商品数 for uid, products in user_products.items(): print(uid, len(products))这个方案里集合负责去重和快速成员判断列表负责保持顺序元组负责把订单的四个字段打包成一条不可改的记录。三个数据结构各有各的位置没有一个可以被另一个完全替代。这正是标题里“什么时候用它们”这个问题的最好答案——没有谁更高级只有谁更合适。4.4 frozenset当你需要“不可变的集合”时前面提到过set本身是可变的所以它不能作为字典的键也不能作为另一个set的元素。现实中确实有这种需求比如一套角色权限组合需要作为权限矩阵的键。这时frozenset就派上用场了。permission_matrix { frozenset({read, write}): 高级权限, frozenset({read}): 只读权限, } p frozenset({read, write}) print(permission_matrix[p]) # 高级权限frozenset和set的运算规则完全一样唯一区别就是创建之后不能添加和删除元素。我平时用的不算多但一旦遇到“需要把一个集合作为另一个dict的键”这种场景frozenset就是唯一正解。5. 常见问题与排查技巧这些坑我帮你踩过了5.1 为什么set去重之后顺序全变了这个问题几乎每个用set的人都会遇到一次。set底层是哈希表元素的存储位置由哈希值决定根本不是按插入顺序来的。如果你需要顺序就用dict.fromkeys或者拿到去重结果后自行排序。data [3, 1, 2, 3, 1, 5, 4] result list(dict.fromkeys(data)) print(result) # [3, 1, 2, 5, 4]保持第一次出现顺序如果你只是要排序后的去重结果直接sorted(set(data))更简洁。5.2 报错TypeError: unhashable type: ‘list’ 是什么意思这是最容易让新手懵圈的报错。它想说的是你把list这种可变类型放进了set或者作为字典的键。list可以被修改所以它的哈希值不稳定不能作为哈希表的键。解决办法是把list转成tuple再操作。data [[1, 2], [3, 4], [1, 2], [5, 6]] unique set(tuple(i) for i in data)5.3 自定义对象去重失败怎么办set去重对象时判断“两个对象是否重复”除了看哈希值还要看__eq__。如果你定义了一个类只实现了__eq__而没有实现__hash__Python会把这个类的__hash__设置为None结果对象无法放入集合。反之如果你两者都没实现默认按照对象id哈希两个内容相同的对象在set中也会被视为不同元素。某项目里遇到过一次一个用户信息类两个对象user_name一样但因为就是两个不同的实例set去重后两个都在。解决方法是明确实现__hash__和__eq__让内容相等的对象拥有相等的哈希值。class User: def __init__(self, name, age): self.name name self.age age def __hash__(self): return hash((self.name, self.age)) def __eq__(self, other): return (self.name, self.age) (other.name, other.age)5.4 元组里放了可变对象导致“你以为没变其实变了”这在前面讲过一遍但值得放在问题清单里再强调。判断一个元组能不能安全作为字典键最简单的标准是元组中每个元素都必须是不可变类型。如果里面有一个list或者dict直接放弃用它做键。5.5 一个关于性能的常见误解“只要用了set就一定比list快。”这句话在绝大多数场景下成立但不是绝对的。set的哈希计算本身也有开销如果数据量很小比如只有十几个元素list的线性扫描快得根本感知不到差别set反而可能因为哈希计算的固定成本表现得差不多甚至略慢。所以不要为了炫技强行set小数据量时用列表可读性更好大数据量时再切换成set。5.6 速查表症状、原因、解决办法症状原因解决办法set去重后顺序变了set无序dict.fromkeys或sortedTypeError: unhashable type: ‘list’可变类型进了set或dict key转成tuple/frozenset内容相同的对象在set里重复自定义类未实现__hash__实现__hash__和__eq__元组作为键报unhashable元组内含可变对象改为全部不可变元素或frozenset大数据量去重内存暴涨set哈希表开销大去重后转list/tuple释放内存最后再分享一个实际感受我个人在写完不少数据处理代码之后最大的心得体会是Python数据结构选型的关键不是你背了多少定义而是你对数据的“生命周期”有预感。这个列表是会被反复修改还是创建之后各个函数只能读不能写这批数据是要不停查找还是只是排个序展示一遍这些运行时的需求才决定了你该用list、tuple还是set。有一个小技巧可以推荐如果你要向下游模块传递一份“去重后且不可变”的数据可以先set去重再sorted排序最后转成tuple返回。比如tuple(sorted(set(data)))。这样做的好处是上游怎么乱下游拿到的都是一份稳定、不可变、顺序确定的干净数据出问题的概率会低很多。我在某个数据清洗的项目里用了这个组合之后再也没有出现过下游把数据改乱了的情况。希望这些经验能帮你在写代码的时候少走一点我走过的弯路。毕竟数据结构是代码的地基地基选对了上层建筑才稳。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。