哈希表:为什么它能做到 O(1)
哈希表(Hash Table)可能是日常写代码里用得最多的数据结构:Java 的 HashMap、Go 的 map、Python 的 dict,本质上都是它。
它的核心卖点是:增删查平均都是 O(1)。
但”为什么是 O(1)”,恰恰是最多人答不清楚的地方。今天把它彻底搞懂。
哈希表的本质:数组 + 哈希函数
哈希表底层是一块数组。数组按下标访问是 O(1),这是它快的根本原因。
问题在于:现实中的 key 通常不是整数下标(比如”用户 ID 是字符串”),怎么把一个任意的 key 映射成数组下标?
答案是哈希函数:
1 | hash(key) → 数组下标 |
理想情况下,不同的 key 映射到不同的下标,那增删查就都是 O(1)。
但理想情况几乎不可能——因为 key 的取值空间通常远大于数组的大小,所以必然出现哈希冲突:两个不同的 key,算出了同一个下标。
冲突怎么解决
解决哈希冲突有两种主流思路:
1. 链地址法(拉链法)
同一个下标上挂一条链表,冲突的 key 都放在这条链表里。
查找时:先算哈希找到下标,再在链表里逐个比对 key。
Java 的 HashMap 用的就是这种方法。
2. 开放寻址法
冲突时,按照一定规则往后找下一个空位置。
查找时:先算哈希,如果位置被占了且 key 不对,就往后探测,直到找到空位或目标 key。
ThreadLocal 用的是这种方法。
为什么说”平均 O(1)”
关键在于负载因子(load factor)。
负载因子 = 已存储元素数 / 数组容量。
- 负载因子低:冲突少,链表短,接近 O(1)
- 负载因子高:冲突多,链表长,退化到 O(n)
所以哈希表会动态扩容:当负载因子超过阈值(比如 Java HashMap 是 0.75),就把数组翻倍,重新计算所有元素的哈希并放进去(rehash)。
正是靠”控制负载因子 + 扩容”,哈希表才能维持平均 O(1)。
注意”平均”二字——最坏情况下(所有 key 都冲突到同一个位置),它会退化成链表,O(n)。
Java HashMap 的一个进化细节
早期 Java 的 HashMap,冲突就是纯链表,最坏退化 O(n)。
Java 8 之后做了个优化:当链表长度超过 8 时,链表会转换成红黑树,把最坏情况从 O(n) 降到 O(log n)。
这背后的思路值得记住:没有任何数据结构是银弹,工程上总是用”组合”来兜底最坏情况。
哈希函数的要求
一个好的哈希函数要满足:
- 计算快——哈希本身的开销不能比直接遍历还大
- 分布均匀——让 key 尽量分散到不同下标,减少冲突
- 确定性——同一个 key,永远算出同一个结果
顺带提一个经典面试题:为什么 String 的 hashCode 用 31 作为乘数?
因为 31 是质数,能让结果分布更均匀,减少冲突;而且 31 可以用移位优化(31 * i = (i << 5) - i),计算快。
哈希表的应用
- 缓存(如 Redis、本地缓存)
- 去重(HashSet)
- 计数(统计词频)
- 快速查找(判断元素是否存在)
- 数据库索引的哈希索引
一个小提醒
哈希表虽然快,但不是万能:
- 不能范围查询——“找 key 在 100 到 200 之间的所有元素”,哈希表做不了,得用有序结构(如跳表、B+ 树)
- 内存开销大——要预留一定空间维持低负载因子
- 无序——遍历顺序不保证
所以 Redis 的 ZSet 用跳表,MySQL 索引用 B+ 树,而不是一味用哈希表。
小结
记住这条主线就够了:
哈希表 = 数组(快)+ 哈希函数(映射)+ 冲突解决(兜底)+ 负载因子控制(维持 O(1))。
搞懂这条主线,”为什么是 O(1)”这种问题就再也难不住你了。
