去重用 set 到底能快多少?实测差距接近三百倍

「Python 进阶之路」系列 Day27

写在前面

Day26 讲透了 dict 的哈希表实现,今天讲它的近亲------set。很多人知道"去重要用 set",但没实测过到底能快多少、为什么快,也容易搞混一个细节:setdict 底层都是哈希表,为什么 3.7 之后 dict 有序了,set 却没有?


一、是什么:set 是只存 key 的 dict

set 底层也是哈希表,和 Day26 讲的 dict 是近亲------可以理解成"只有 key、没有 value 的 dict",元素本身既是数据也是"key",同样需要满足可哈希的条件。

flowchart TB subgraph dict结构 DH[哈希表存索引] --> DA[紧凑数组存键值对] end subgraph set结构 SH[哈希表存索引] --> SA[数组只存键] end
python 复制代码
{[1, 2], 3}
# TypeError: unhashable type: 'list'   ------ 和dict的key一样,set的元素必须可哈希

二、为什么:去重和成员判断为什么这么快

因为 set 底层是哈希表,判断"这个元素在不在集合里"不需要遍历,直接用哈希值算出槽位就能判断,平均时间复杂度是 O(1);而 list 判断"元素在不在"(in 操作、或者去重时的重复检查)需要从头到尾逐个比较,是 O(n)。这就是"用 set 去重比用 list 去重快得多"的根本原因:list 去重本质是对每个新元素都做一次 O(n) 的存在性检查,总体退化成 O(n²);set 去重每次检查是 O(1),总体是 O(n)。


三、怎么用

1. set 不保持插入顺序

和 Day26 讲的 dict(3.7 起保证插入顺序)不一样,set 不保证遍历顺序:

python 复制代码
s = set()
s.add("c")
s.add("a")
s.add("b")
print(list(s))   # ['a', 'b', 'c'] ------ 不是插入顺序 c, a, b!

d = {}
d["c"] = 1
d["a"] = 1
d["b"] = 1
print(list(d.keys()))   # ['c', 'a', 'b'] ------ dict对比:一定是插入顺序

Python 3.6+ 给 dict 做的紧凑字典改造(哈希表只存索引,数据按插入顺序存进紧凑数组),并没有同步应用到 set 上------set 的设计目标始终是集合运算和成员判断的效率,不承诺、也不应该依赖它的遍历顺序。

2. list 去重 vs set 去重的性能差距

python 复制代码
import timeit

data = [i % 1000 for i in range(20000)]   # 2万个元素,1000个不同值

def dedup_list(data):
    result = []
    for x in data:
        if x not in result:   # O(n)的成员检查
            result.append(x)
    return result

def dedup_set(data):
    return list(set(data))

t1 = timeit.timeit(lambda: dedup_list(data), number=3)
t2 = timeit.timeit(lambda: dedup_set(data), number=3)
# 用list去重: 0.1410s
# 用set去重: 0.000480s
# set快了 294 倍

3. 成员判断 in 操作的性能差距

python 复制代码
big_list = list(range(100000))
big_set = set(range(100000))

t3 = timeit.timeit(lambda: 99999 in big_list, number=1000)
t4 = timeit.timeit(lambda: 99999 in big_set, number=1000)
# list的in操作(1000次): 0.5266s
# set的in操作(1000次): 0.000031s
# set快了 16805 倍

结论很明确:只要涉及"判断某个元素在不在一堆数据里"(去重、成员判断、求交并差集),只要元素可哈希,优先用 set 而不是 list

4. 集合运算与 frozenset

set 直接支持数学集合运算,底层同样利用哈希表加速:

python 复制代码
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}

print(a & b)   # 交集 {3, 4}
print(a | b)   # 并集 {1, 2, 3, 4, 5, 6}
print(a - b)   # 差集 {1, 2}
print(a ^ b)   # 对称差 {1, 2, 5, 6}

frozensetset 的不可变版本,因为不可变,所以本身是可哈希的------可以作为 dict 的 key,或者作为另一个 set 的元素(普通 set 做不到,因为普通 set 本身不可哈希):

python 复制代码
fs = frozenset([1, 2, 3])
print(hash(fs))   # 可以哈希

d = {fs: "value"}   # 可以作为dict的key
print(d[fs])          # value

s = {frozenset([1, 2]), frozenset([3, 4])}   # 可以作为set的元素
print(s)

hash({1, 2, 3})
# TypeError: unhashable type: 'set'   ------ 普通set做不到

四、面试追问

Q1:set 的底层实现是什么,和 dict 有什么关系?

set 底层也是哈希表,可以理解成"只有 key 没有 value 的 dict",元素本身就是要存储和判断的数据,同样要求可哈希,Day26 讲的哈希表、哈希冲突相关知识对 set 完全适用。

Q2:为什么用 set 去重比用 list 去重效率高?

list 去重时每次都要对已收集的结果做一次 O(n) 的重复检查,总体时间复杂度退化成 O(n²);set 基于哈希表判断元素是否存在是平均 O(1),总体去重是 O(n)。实测 2 万个元素去重,set 比手写 list 去重快了 294 倍。

Q3:set 和 dict 都是哈希表实现,为什么 dict 在 3.7 后有序而 set 不是?

dict 在 Python 3.6+ 做了紧凑字典改造:哈希表只存指向数据的索引,真正的键值对数据按插入顺序存进一个紧凑数组,遍历这个数组自然就是插入顺序。这个改造没有同步应用到 set 上,set 的设计目标始终是集合运算和成员判断的效率优先,不保证、也不应该依赖它的遍历顺序。

Q4:frozenset 和 set 的区别,什么时候用 frozenset?

frozenset 是不可变版本的 set,因为不可变所以本身是可哈希的,能作为 dict 的 key 或者另一个 set 的元素;普通 set 本身不可哈希,做不到这两件事。需要"把一个集合本身当成不可变数据来用"的场景(比如集合套集合、用集合本身当字典的 key)就该用 frozenset

Q5:set 的集合运算有哪些,时间复杂度如何?

常见运算有交集 &、并集 |、差集 -、对称差 ^,底层都基于哈希表实现,平均时间复杂度与参与运算的集合规模相关(通常与较小的那个集合的大小同量级),比用列表手写循环逐个判断快得多。


下一篇预告

Day28 讲 collections 模块------defaultdictCounterOrderedDictnamedtuple 这几个高频出现的工具类,各自解决了什么标准 dict/tuple 不够方便的问题。

相关推荐
岁月宁静17 分钟前
二、《从零手撸 Agent》 — 聊聊 LLM 的失忆真相
前端·python·agent
未秃头的程序猿19 分钟前
一次秒杀把服务打挂了,我用Sentinel规则配置化救了回来
java·后端·spring cloud
Zane199422 分钟前
明明没删字段,反序列化却报错:都是隐式 serialVersionUID 惹的祸
java·后端
0xBADCODE24 分钟前
动态DEX加载+反射+DES硬编码密钥:安卓三层逆向实战
android·java·python·安全·网络安全·逆向·ctf
Gopher_HBo24 分钟前
beego启动流程
后端
fliter26 分钟前
Go Map 详解:键值对实际上是如何存储的
后端
万物智能26 分钟前
设备树DTS-【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
后端·算法
CadeCode28 分钟前
Oracle 逗号拼接字段处理
数据库·后端·性能优化
岁月宁静36 分钟前
一、《从零手撸 Agent》 我用 10 行代码跑通了第一次大模型调用(顺便踩了 4 个坑)
前端·python·agent