我接触过不少写后端、做数据分析、甚至折腾脚本的朋友。聊到数据结构的时候,大家通常都会背两句口诀:字典查得快,集合能去重。可真到现场,被追问“为什么字典按键取值不是一个个遍历而像点了魔法一样直接命中”“为什么集合里的元素不能是 list”“为什么都说 Python 的字典和 Java 的 HashMap 不一样,差在哪儿”,很多人就一下子卡住了。这篇文章就围绕字典和集合的原理及应用来做一次系统拆解。第一篇的重点会放在底层共性上:哈希表。搞懂哈希表,就等于同时拿到了字典和集合的底层设计图纸。学完之后你能明白 dict 的 O(1) 查找到底建立在什么机制上,也能看懂 set 去重、取交集为什么那么快。本篇以 Python 为主,途中会对照 Java 的 HashMap / HashSet 和 C++ 的无序容器,适合正在学 Python、准备校招面试、或者在工作中已经把字典用得很熟练但还想更深一层的人。读完后你可能不会立刻改很多代码,但面对性能问题、哈希相关报错,你会比大多数人更早定位到原因。
1. 一个查询问题引发的技术路线:为什么“找东西”能快到这个程度
1.1 数组:用“位置”换时间的第一个杀手锏
先回到最朴素的场景:内存里有一排数据,怎么最快找到某一个?如果数据是按顺序排放的,并且你提前知道它的下标,那么一次访问就能结束战斗。数组就是这种模型的典型代表。CPU 访问数组元素时,本质上做了一次“基地址 + 下标 × 单个元素大小”的地址计算,直接跳到目标内存。整个过程没有比较,没有循环,时间近乎是恒定的。这也是计算机基础课里反复说的“随机访问”能力。你可以把数组想象成一栋每层只有一间房的公寓,门牌号都是固定编号。只要知道房号是 502,你不需要先从 101 挨个看牌子。下标本身就是“位置”,这是数组查找快的根本原因。
但是实际业务里,我们很少按“下标”查东西。用户要的是“根据用户名查积分”“根据订单号查状态”,而不是“根据数组位置查元素”。用户名和订单号是内容,不是位置。这时候数组的瞬间定位优势就发挥不出来了。
1.2 线性查找的老大难:数据规模一上来就现形
如果数组里只存了一堆英文单词,而我希望知道某个单词是否存在,最简单粗暴的办法是写一个循环从头到尾逐个比较。这个策略叫线性查找。线性查找的优点是省事,但代价也很直观:数据量越大,平均比较次数越多。比如一万个元素,运气最差要比较一万次;一百万个元素,最差要一百万年? 其实是一百万次,虽然不至于用“年”计时,但对计算机来说已经不是小数目了。尤其当这种查找发生在循环内部、一个请求要被执行几万次时,性能差距就会被急剧放大。
Python 里最典型的就是 list 类型的 in 操作。看到一个常见问题:“为什么我在列表里判断一个元素是否存在,数据一多就慢?”原因就在这里。列表不会自动保证元素有序,也不可能让元素根据内容直接跳转到某个位置,所以它只能老老实实做线性扫描。即使列表里刚好有你要找的元素,你也不得不从第一个开始挨个看。
这让我想到一个更直观的比喻。去一个没有索引的图书馆找一本特定的书,唯一的办法是沿着书架一本一本翻;可如果图书馆提供“按书名首字母定位到某个分区”,你就能直接略过大量无关区域。哈希思想做的事情,本质上就是给内容配一个合适的“分区号”甚至“精确位置”,让查找从“挨个问”变成“直接去”。
1.3 把内容变成位置:哈希思想顺理成章地出现
既然下标可用但不符合业务,内容符合业务但没法用来定位,能不能找到一个函数,把“内容”转换为“下标”?这就是哈希函数。哈希函数接收任意对象,输出一个整数,这个整数被称作哈希值。只要计算足够快,且能保持稳定,就能用这个整数去访问一个类似数组的存储区域。
“字典”和“集合”这两个数据结构,就是在这个想法上长出来的。它们在不同语言里的名字各不相同,比如 Python 里的 dict 和 set、Java 里的 HashMap 和 HashSet、C++ 里的 unordered_map 和 unordered_set。名字五花八门,但底层基本都是哈希表。哈希表为键值查找提供了一条高速通路,所以字典和集合才敢说自己查找、插入、删除的平均时间复杂度是 O(1)。这里的 O(1) 并不是“一次都不需要比较”,而是说比较次数不会随着数据规模线性增长。哈希表把大头开销花在了哈希值的计算和桶位置的寻找上,这部分和表里已有的元素数量没有直接关系。
想明白这件事,再看有些老同事的疑问就有意思了:为什么有人用 list 做成员判断时越跑越慢,换成 set 后速度突飞猛进?差距并不是 Python 对 set 做了什么神级优化,而是数据结构本身的工作原理决定了它不需要遍历全部数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哈希表是怎么运转的:从哈希函数到冲突处理
2.1 哈希函数的三个底线
哈希表的地基就是哈希函数。理解一个哈希函数设计得好不好,我一般会看三件事:确定性、计算速度、分布均匀程度。
确定性是说同一个对象在同一个进程里多次调用哈希函数,必须得到同样的整数结果。否则今天插进去的时候放在 5 号桶,明天查的时候哈希变成了 6 号桶,这个表就没法用了。计算速度好理解,哈希函数本身要是很慢,那 O(1) 的优势就被抵消了。很多资深工程师喜欢用哈希值来快速比较两个大对象,本质上也是在赌“哈希计算比逐字段比较更便宜”。分布均匀这条最容易被忽略:如果哈希值分布很差,所有键都落到同一个桶,哈希表就会退化成链表,查询复杂度重新变回 O(n),那还不如一开始就用列表。
要注意,哈希函数和加密算法不是一回事。哈希函数不需要做到“不可逆”,它只负责把对象映射成整数。Python 内建的 hash() 函数每次启动时可能得到不同结果?不完全对。整数对象的哈希值通常就是它自身,但字符串为了保证安全性会加入随机种子。这里不要试图依赖 hash() 的具体返回值,它属于解释器内部细节。
python复制# 在不同进程里运行,字符串的 hash 值可能不同
print(hash
