Map的存储设计
哈希函数不可避免的会出现碰撞,如果我要实现一个Map,key是一个哈希函数,,如果key计算出来的哈希值发生了碰撞,怎么设计存储Map的值
哈希碰撞 + Map(key=哈希值) 完整存储设计方案
这个问题是哈希表的核心底层问题,也是所有语言(Java/C#/Go)中HashMap/Dictionary的核心实现逻辑:当不同的Key计算出相同哈希值(哈希碰撞)时,如何正确存储、查找、区分对应的值,保证数据读写无误。
先给你一个核心结论:哈希碰撞是必然的,无法杜绝,但所有碰撞问题都有成熟、高效的解决方案,而且这两套方案是行业标准,没有更好的替代方案。
前置核心认知(必须先懂)
- 你说的「key是一个哈希函数」,准确表述是:Map的Key(任意数据:字符串/对象/数字等),通过哈希函数计算出「哈希值」,Map是通过这个哈希值来加速查找的,最终要存储的是
原始Key + Value键值对; - 哈希碰撞的定义:两个不同的原始Key,经过哈希函数计算后,得到了完全相同的哈希值(比如 key1="abc"、key2="xyz",哈希后都是 10086),这就是碰撞;
- 碰撞的本质问题:Map会根据哈希值找到「数据存放的位置」,如果哈希值相同,就会找到同一个位置,此时如果直接存值,就会覆盖掉之前的数据,这是绝对要避免的。
一、哈希表(Map)的基础存储结构:数组 + 哈希寻址
所有高性能Map的底层都是「数组」作为主存储容器,这是基础,所有碰撞解决方案都基于这个结构,必须先理解:
- Map初始化时,会创建一个固定容量的数组(哈希桶数组,也叫
Bucket Array),数组的每个位置称为一个「哈希桶(Bucket)」; - 当插入
(Key, Value)时,先对原始Key计算哈希值; - 通过一个「取模运算」做哈希寻址:
数组下标 = 哈希值 % 数组容量,得到这个键值对应要放入的「数组位置」; - 读取时,用同样的方式计算哈希值+寻址,直接定位数组下标,理想情况O(1)时间复杂度,这也是哈希表快的原因。
✅ 举个例子:数组容量=16,key1哈希值=20 → 下标=20%16=4;key2哈希值=36 → 下标=36%16=4 → 哈希碰撞,两个key要放入同一个数组下标=4的桶里。
二、解决哈希碰撞的「两大行业标准方案」(全部必学,无其他)
所有编程语言的Map,解决碰撞只采用这2种方案,没有第三种主流方案,二者各有优劣,覆盖所有业务场景,你可以根据自己的需求二选一实现即可。
✅ 方案一:链地址法 (Separate Chaining) 【推荐优先实现,最常用】
1. 核心设计思想
给哈希桶数组的「每个下标位置」,挂一个「链表/红黑树」。
- 数组的每个桶,不再只存一个
(Key,Value),而是存一个「链表的头节点」; - 当发生哈希碰撞时(多个Key寻址到同一个数组下标),不会覆盖数据,而是把这些「碰撞的键值对」依次追加到这个下标对应的链表中;
- 链表中存储的不是「哈希值」,而是 完整的原始Key + Value。
2. 完整读写流程(重点,必看)
✔ 插入数据 put(Key, Value)
- 计算原始Key的哈希值 → 哈希寻址得到数组下标 index;
- 检查数组[index]位置的链表是否为空:
- 为空 → 直接创建新节点,存入 (Key,Value),作为链表头节点;
- 不为空 → 遍历这个链表,逐个对比「原始Key」:
- 如果找到「相同的原始Key」→ 用新Value覆盖旧Value(Map的key唯一特性);
- 如果遍历完没找到相同Key → 在链表尾部追加新节点,存入 (Key,Value)。
✔ 查询数据 get(Key)
- 计算原始Key的哈希值 → 哈希寻址得到数组下标 index;
- 遍历数组[index]位置的链表,逐个对比「原始Key」;
- 找到「相同的原始Key」→ 返回对应的Value;
- 遍历完链表没找到 → 返回空(Key不存在)。
3. 关键细节(避坑+优化)
✅ 为什么要对比「原始Key」而不是哈希值?
因为哈希碰撞的本质就是「不同Key→相同哈希值」,如果只对比哈希值,就无法区分这两个不同的Key,原始Key是唯一的标识,哈希值只是「加速寻址的索引」,最终的唯一性校验必须用原始Key!
✅ 性能优化点:
- 当链表长度很短时(比如<8),遍历链表的效率极高,几乎还是O(1);
- 当链表过长(比如>8),把链表升级为「红黑树」,查询效率从O(n)优化为O(logn),这是Java HashMap的核心优化逻辑,大厂标配。
4. 优点&缺点(结论:首选)
✅ 优点:实现简单、容错性强、扩容成本低、不会出现「哈希堆积」,适合绝大多数场景,是所有编程语言Map的默认实现方案(Java HashMap/C# Dictionary/Go map);
❌ 缺点:链表会占用少量额外内存(存储节点指针),极端情况链表过长会略降性能(可通过红黑树优化)。
✅ 方案二:开放寻址法 (Open Addressing) 【极简场景可选】
1. 核心设计思想
数组的每个桶,只存一个 (Key,Value) 键值对,没有链表/红黑树,所有数据都存在数组本身。
- 当发生哈希碰撞(寻址到的数组下标已经有数据了),不往这个位置存,而是通过「探测规则」向后寻找「空的哈希桶」,直到找到空位置再存入;
- 核心逻辑:碰撞了就「挪个位置放」,所有数据都在数组里,无额外结构。
2. 核心探测规则(最常用的3种)
所有探测规则的前提:哈希寻址得到的下标是 index = hash(key) % capacity
① 线性探测
最简单的规则:依次向后找空桶 → index+1 → index+2 → index+3 ... 直到找到空位置。
例:寻址到下标4,4被占→查5,5被占→查6,6为空→存入6。
② 平方探测
优化线性探测的缺陷:index+1² → index+2² → index+3² ... (+1、+4、+9),避免连续堆积。
③ 二次哈希探测(推荐)
最优秀的探测规则:index = (hash1(key) + i * hash2(key)) % capacity
- 用两个不同的哈希函数计算哈希值;
- i是探测次数(0,1,2...);
- 优点:探测的位置分布更均匀,几乎不会堆积,性能最优。
3. 完整读写流程
✔ 插入数据 put(Key, Value)
- 计算原始Key的哈希值 → 哈希寻址得到初始下标 index;
- 检查数组[index]是否为空:为空则直接存入;
- 不为空 → 按「探测规则」依次寻找下一个下标,直到找到空位置存入;
- 注意:如果遍历中找到「相同的原始Key」,直接覆盖Value。
✔ 查询数据 get(Key)
- 计算原始Key的哈希值 → 哈希寻址得到初始下标 index;
- 检查数组[index]的Key是否等于原始Key:相等则返回Value;
- 不相等 → 按「探测规则」依次找下一个下标,直到找到匹配的Key 或 找到空桶(Key不存在)。
4. 优点&缺点
✅ 优点:内存利用率极高(无链表/指针开销)、实现也比较简单,适合「数据量小、碰撞概率低」的极简场景;
❌ 缺点:
- 当数组容量快存满时,探测次数会急剧增加,性能从O(1)暴跌到O(n);
- 存在「哈希堆积」问题:一个碰撞会引发后续多个碰撞,恶性循环;
- 删除数据复杂:不能直接置空,否则会破坏探测链路,导致后续查询失败。
✅ 选型建议
开放寻址法只适合「小容量、低碰撞、读多写少」的场景,比如嵌入式开发、极简缓存;绝大多数业务场景,优先选链地址法。
三、核心设计原则(重中之重,绝对不能违反)
无论你用哪种方案,这3个原则是铁律,违反任何一个,你的Map一定会出Bug(存错值、查不到值、覆盖数据),这也是你问题的核心答案:
✅ 原则1:哈希值只用来「找位置」,不能用来「判相等」
哈希值的唯一作用是:快速定位到数组的某个下标,仅此而已。
❌ 错误做法:判断两个键值对是否是同一个Key,只对比哈希值;
✅ 正确做法:必须对比「原始Key」是否完全相等,哈希值相同只是巧合(碰撞),原始Key相同才是真正的同一个键。
✅ 原则2:Map中必须存储「完整的原始Key + Value」,缺一不可
很多新手会犯的错误:只存哈希值+Value,不存原始Key。
这样的后果是:一旦碰撞,你永远无法区分两个不同的Key,因为你没有原始数据做对比,这是致命错误!
✅ 存储单元的标准结构:
Bucket = { hash值(可选), 原始Key, Value }
✅ 原则3:哈希值相同 ≠ Key相同,Key相同 ⇒ 哈希值一定相同
这是哈希函数的「确定性」决定的:
- 相同的原始Key,用同一个哈希函数计算,哈希值必然相同;
- 相同的哈希值,原始Key大概率不同(碰撞);
这是所有逻辑的底层依据。
四、补充:让哈希碰撞「更少」的优化技巧(锦上添花)
前面的方案是「解决碰撞」,这里的技巧是「减少碰撞概率」,二者结合,你的Map会达到极致性能,也是工业级实现的标配:
1. 选择优秀的哈希函数(比如你之前问的xxHash)
垃圾的哈希函数(比如简单取模)碰撞概率极高,优秀的哈希函数能让哈希值均匀分布,碰撞概率低到可以忽略不计:
- 推荐:xxHash(XXH64)、MurmurHash3、FNV-1a,这三个都是非加密哈希,速度极快+分布均匀;
- 避坑:不要自己写哈希函数,大概率写的哈希值分布不均,碰撞多。
2. 哈希桶数组的容量,设计为「质数/2的幂次」
- 链地址法:数组容量设为 2的幂次(16、32、64、128),哈希寻址的取模运算可以换成「位运算」,速度更快(
index = hash & (capacity-1)); - 开放寻址法:数组容量设为 质数(7、13、17、31),能让哈希值分布更均匀,减少碰撞。
3. 实现「动态扩容」+ 负载因子控制
- 负载因子 = Map中已存数据量 / 数组容量;
- 当负载因子超过阈值(行业标准:0.75),就把数组容量扩容为原来的2倍,并把所有旧数据重新计算哈希值、重新寻址、存入新数组;
- 作用:数组越空,碰撞概率越低,0.75是「空间利用率」和「碰撞概率」的黄金平衡点,Java/C#的Map都是这个值。
五、极简伪代码实现(链地址法,最易上手,可直接参考)
给你一份核心逻辑伪代码,没有语言限制,C/C#/Java/Go都能直接照搬实现,包含完整的put/get核心逻辑,你能一眼看懂:
# 哈希桶数组:每个元素是一个链表,链表节点存储 (原始Key, Value)
hash_bucket = [[] for _ in range(16)] # 数组容量=16,初始为空链表
# 哈希函数:用你熟悉的xxHash64,返回哈希值
def hash_func(key):
return xxhash64(key)
# 1. 插入键值对
def put(key, value):
hash_val = hash_func(key)
index = hash_val % len(hash_bucket) # 哈希寻址
bucket_list = hash_bucket[index]
# 遍历链表,判断key是否已存在,存在则覆盖
for i in range(len(bucket_list)):
old_key, old_val = bucket_list[i]
if old_key == key: # 核心:对比原始Key!!!
bucket_list[i] = (key, value)
return
# 不存在则追加到链表尾部
bucket_list.append((key, value))
# 2. 查询值
def get(key):
hash_val = hash_func(key)
index = hash_val % len(hash_bucket)
bucket_list = hash_bucket[index]
# 遍历链表,对比原始Key
for old_key, old_val in bucket_list:
if old_key == key:
return old_val
# 没找到返回空
return None
✅ 这份伪代码是完整可运行的核心逻辑,你只需要把语言语法替换成你用的语言,再加上扩容逻辑,就是一个工业级的Map了。
最终总结(精华提炼,看完就忘不掉)
- 哈希碰撞必然存在,不用害怕,有2套标准方案解决:链地址法(首选)、开放寻址法(极简场景);
- 核心矛盾:哈希值相同 → 寻址到同一个位置,解决方案:存原始Key,遍历对比区分;
- 铁律3条:哈希值只寻址、必须存原始Key、Key相同哈希必相同;
- 性能优化:好哈希函数+数组扩容+负载因子0.75,碰撞概率低到忽略不计;
- 所有语言的HashMap,底层都是这套逻辑,你理解了这个,就理解了所有Map的本质。