Hash算法入门Hash冲突解决方案

📑 Hash算法入门指南

  • [一、 什么是哈希算法?](#一、 什么是哈希算法?)
  • [二、 Hash 值是如何计算的?(通用思路)](#二、 Hash 值是如何计算的?(通用思路))
  • [三、 图解:HashMap 的 Hash 映射全过程](#三、 图解:HashMap 的 Hash 映射全过程)
  • [四、 核心揭秘:JDK 1.8 的扰动函数](#四、 核心揭秘:JDK 1.8 的扰动函数)
  • [五、 哈希冲突与存储结构](#五、 哈希冲突与存储结构)
  • [六、 总结](#六、 总结)
一、 什么是哈希算法?

哈希(散列)是一种单向映射算法 。它接收任意长度、任意类型的输入数据(字符串、对象、数字等),通过特定的数学函数运算,输出一个固定长度的二进制数值,这个结果被称为**哈希值(Hash Value)**或摘要。

用一个简单的公式表示:

原始数据 → Hash函数 Hash值 \text{原始数据} \xrightarrow{\text{Hash函数}} \text{Hash值} 原始数据Hash函数 Hash值

核心特性:

  1. 单向不可逆:你无法通过哈希值反推出原始数据(这对于密码存储至关重要)。
  2. 确定性:相同的输入,永远得到相同的输出。
  3. 雪崩效应:输入哪怕只改变一个比特,输出的哈希值也会发生巨大变化。
  4. 冲突不可避免:由于输入空间无限而输出空间有限,不同的输入可能会产生相同的哈希值(虽然优秀的算法会让这种情况极难发生)。
二、 Hash 值是如何计算的?

计算 Hash 值通常分为两步:原生哈希计算区间映射(寻址)

1. 原生哈希计算(通俗演示)

  • 场景 A:纯数字
    最简单的规则:Hash = 数字本身
    • 输入:1024 → Hash 值:1024
  • 场景 B:字符串(简易版)
    规则:将每个字符的 ASCII 码相加。
    • 输入 abc:a(97) + b(98) + c(99) = 294
    • 输入 acb:a(97) + c(99) + b(98) = 29·4

注意 :这里出现了哈希冲突 。虽然 abcacb 不同,但算出的 Hash 值相同。这就是为什么简单的累加算法不能用于生产环境,因为它的离散性太差。

2. 映射到数组下标(HashMap 核心寻址)

HashMap 中,底层是一个固定长度的数组。假设数组长度(容量)为 16,下标范围是 0~15。我们需要把算出来的巨大 Hash 值(比如 294)映射到这个范围内。

  • 方法一:传统取模运算(%)(低性能)

    公式:下标 = Hash值 % 数组长度

    • 例子:294 % 16 = 6。数据存入数组下标 6 的位置。
    • 缺点:取模运算(%)涉及除法,在计算机底层运算中相对较慢。
  • 方法二:HashMap 专属位运算(高性能)

    前提 :HashMap 的容量(len)永远是 2 的整数次幂 (如 16, 32, 64...)。

    公式下标 = Hash值 & (数组长度 - 1)

    • 例子:长度 16,16-1=15(二进制 1111)。
    • 294 & 15 的结果同样只保留二进制的后 4 位,效果等同于取模,但位运算(&)的速度远高于取模(%)
三、 图解:HashMap 的 Hash 映射全过程

HashMap 中,从 Key 到数组下标的映射过程比简单的取模要复杂,它包含了一个关键的"扰动"步骤。

整体流程链路:

java 复制代码
[原始 Key 对象]
        ↓
1. 调用 Object.hashCode() → 得到原生 int 哈希码
        ↓
2. 哈希扰动函数(高低位混合)→ 优化后 Hash 值
        ↓
3. 位运算寻址 hash & (容量-1) → 算出数组桶下标
        ↓
4. 存入对应下标位置(链表/红黑树)

图 1:哈希映射链路示意图

java 复制代码
[Key: "Java"]
     |
     ▼
原生hashCode = 123456
     |
     ▼
扰动函数计算 → 优化Hash = 78901
     |
     ▼
数组长度=16 → 16-1=15 (二进制 1111)
     |
     ▼
78901 & 15 → 下标 = 5
     |
     ▼
存入 table[5] 哈希桶

但是上述方法有个问题:当数组长度较小时(例如默认初始容量 16),length - 1 的二进制表示为 0000 0000 0000 0000 0000 0000 0000 1111(只有低 4 位是 1,注意java 中int是4个字节32位)。我们知道按位与运算规则如下:

  • 和 0 相与:结果一定是 0
  • 和 1 相与:保留原 bit 值

这意味着,在做 & 运算时,哈希值的高 28 位完全被丢弃了,只有低 4 位参与了索引的计算如果多个对象的 hashCode() 只是在高位不同,而低位相同,它们就会发生严重的哈希碰撞,全部挤在同一个链表或红黑树里 ,导致 HashMap 退化为 O(n) 的时间复杂度。

所以需要对原生的 hashCode(),进行"扰动"。

四、 核心揭秘:JDK 1.8 的扰动函数

HashMap 的源码中有一个极简但高效的函数:

java 复制代码
static final int hash(Object key) {
    int h;
    // 原生哈希码 异或 (右移16位后的哈希码)
     // 如果 key 为 null,直接返回 0(HashMap 允许 null 键,且默认放在数组下标 0 的位置)
    // 否则,获取原始哈希码 h,并将其与自身无符号右移 16 位后的值进行异或运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 
}

为什么要右移 16 位?为了把"高位"的信息也利用起来,JDK 设计了 (h = key.hashCode()) ^ (h >>> 16) 这一行代码。

  • 16(无符号右移 16 位):因为 Java 的 int 类型正好是 32 位。右移 16 位刚好把高 16 位移到了低 16 位的位置,高 16 位补 0。
  • ^(异或运算):将原始哈希值的低 16 位与高 16 位进行异或
  • 异或操作把高位的特征"融合"到了低位中。即使两个对象的高位不同、低位相同,经过异或后,它们的最终低位也会变得不同。

图 2:JDK 1.8 扰动原理图解(二进制演示)

java 复制代码
原 hashCode: [高16位 ABCD] [低16位 EFGH]
右移16位:    [0000 0000] [高16位 ABCD]  (高位补0)
-------------------------------------------------
异或运算(^): [高16位 ABCD] [低16位 EFGH ^ ABCD]

通俗解释

这行代码 (h >>> 16) 的作用是将哈希码的高 16 位 移动到低 16 位,并与原来的低 16 位进行异或(^)混合。

  • 目的:让高位的信息参与到寻址运算中来,弥补原生哈希低位重复多的问题。
  • 效果:只做一次位运算,既保证了高性能,又极大地增加了数据的离散度,减少了哈希冲突。
  • 最终新哈希值的低 16 位= 原高 16 位 ^ 原低 16 位。而下标计算 hash & (n-1) 只取用低位,相当于:原本被抛弃的高 16 位哈希特征,通过异或,叠加到了参与下标运算的低 16 位上。

再来举个直观的例子,假设 32 位 int 简化成 16 位演示(原理一致):

go 复制代码
原h(16位): 1101 0110  0011 1001
高8位A:1101 0110,低8位B:0011 1001

h >>> 8:   0000 0000  1101 0110
异或 h:    1101 0110 ^ 0011 1001 = 1110 1111

低位不再只是原来的 B,而是 A 和 B 混合后的新值,高位差异成功传递到决定下标的低位。

五、 哈希冲突与存储结构

尽管有了扰动函数,冲突依然无法完全避免(因为无限的输入映射到有限的数组下标)。

HashMap 如何解决冲突?

JDK 1.8 采用了链表 + 红黑树的组合结构。

图 3:哈希冲突示意图

java 复制代码
Key1: "abc" → Hash=294 → 下标=6
Key2: "acb" → Hash=294 → 下标=6
                ↓
        同一数组下标 bucket[6]
                ↓
           链表串联节点
    [ bucket[6] ] → Node("abc") → Node("acb") → null
  • 链表:当发生冲突时,新节点以链表形式挂在数组元素后面。
  • 红黑树 :当链表长度超过 8 且数组容量大于 64 时,链表会转换为红黑树,将查询时间从 O(n) 降低到 O(log n)。
六、 总结
  1. 计算两步走HashMap 的 Hash 计算分为"原生 hashCode"和"扰动函数优化"两步。
  2. 高性能寻址 :利用容量为 2 的幂次方特性,使用 hash & (len-1) 位运算替代取模运算,提升性能。
  3. 扰动是关键 :JDK 1.8 的 hash ^ (hash >>> 16) 是经典设计,通过混合高低位来减少冲突。
  4. 冲突不可避免:使用"数组 + 链表 + 红黑树"的数据结构来兜底解决冲突问题。
相关推荐
终端域名2 小时前
密码学哈希函数:区块链 “不可篡改” 的核心数字指纹技术
区块链·密码学·哈希算法
洛水水2 小时前
【力扣100题】81.寻找两个正序数组的中位数
数据结构·算法·leetcode
happymaker06263 小时前
LeetCodeHot100——155.最小栈
算法
洛水水3 小时前
【力扣100题】85.每日温度
算法·leetcode·职场和发展
Coder-magician3 小时前
《代码随想录》刷题打卡day15:二叉树part05
数据结构·c++·算法
Kurisu_红莉栖3 小时前
力扣56合并区间
算法·leetcode
Irissgwe3 小时前
算法的时间复杂度和空间复杂度
数据结构·c++·算法·c·时间复杂度·空间复杂度
随意起个昵称3 小时前
区间dp-基础题目3(永别)
c++·算法
周末也要写八哥3 小时前
有向图Hierholzer算法的另一种实现
算法