哈希算法

哈希算法

无论是开放寻址还是链式地址,它们只能保证哈希表可以在发生冲突时正常工作,而无法减少哈希冲突的发生

如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。对于链式地址哈希表,理想情况下键值对均匀分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都存储到同一个桶中,时间复杂度退化至 O(n) 。

键值对的分布情况由哈希函数决定。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:

ini 复制代码
index = hash(key) % capacity

观察以上公式,当哈希表容量 capacity 固定时,哈希算法 hash() 决定了输出值,进而决定了键值对在哈希表中的分布情况。

这意味着,为了降低哈希冲突的发生概率,我们应当将注意力集中在哈希算法 hash() 的设计上。

哈希算法的目标

为了实现"既快又稳"的哈希表数据结构,哈希算法应具备以下特点。

确定性:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。

效率高:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。

均匀分布:哈希算法应使得键值对均匀分布在哈希表中。分布越均匀,哈希冲突的概率就越低。

实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。

密码存储:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹配,那么密码就被视为正确。

数据完整性检查:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整。

哈希算法的设计

哈希算法的设计是一个需要考虑许多因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简单的哈希算法。

加法哈希:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。

乘法哈希:利用乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。

异或哈希:将输入数据的每个元素通过异或操作累积到一个哈希值中。

旋转哈希:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作。

go 复制代码
/* 加法哈希 */
func addHash(key string) int {
    var hash int64
    var modulus int64
​
    modulus = 1000000007
    for _, b := range []byte(key) {
        hash = (hash + int64(b)) % modulus
    }
    return int(hash)
}
​
/* 乘法哈希 */
func mulHash(key string) int {
    var hash int64
    var modulus int64
​
    modulus = 1000000007
    for _, b := range []byte(key) {
        hash = (31*hash + int64(b)) % modulus
    }
    return int(hash)
}
​
/* 异或哈希 */
func xorHash(key string) int {
    hash := 0
    modulus := 1000000007
    for _, b := range []byte(key) {
        fmt.Println(int(b))
        hash ^= int(b)
        hash = (31*hash + int(b)) % modulus
    }
    return hash & modulus
}
​
/* 旋转哈希 */
func rotHash(key string) int {
    var hash int64
    var modulus int64
​
    modulus = 1000000007
    for _, b := range []byte(key) {
        hash = ((hash << 4) ^ (hash >> 28) ^ int64(b)) % modulus
    }
    return int(hash)
}

每种哈希算法的最后一步都是对大质数 1000000007 取模,以确保哈希值在合适的范围内。为什么要强调对质数取模,或者说对合数取模的弊端是什么?

先给出结论:使用大质数作为模数,可以最大化地保证哈希值的均匀分布。因为质数不与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。

举个例子,假设我们选择合数 9 作为模数,它可以被 3 整除,那么所有可以被 3 整除的 key 都会被映射到 0、3、6 这三个哈希值。

ini 复制代码
            modulus=9
​
                key={0,3,6,9,12,15,18,21,24,27,30,33,...}
​
                hash={0,3,6,0,3,6,0,3,6,0,3,6,...}

如果输入 key 恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。现在,假设将 modulus 替换为质数 13 ,由于 keymodulus 之间不存在公约数,因此输出的哈希值的均匀性会明显提升。

ini 复制代码
            modulus=13
                key={0,3,6,9,12,15,18,21,24,27,30,33,...}
                hash={0,3,6,9,12,2,5,8,11,1,4,7,...}

如果能够保证 key 是随机均匀分布的,那么选择质数或者合数作为模数都可以,它们都能输出均匀分布的哈希值。而当 key 的分布存在某种周期性时,对合数取模更容易出现聚集现象。

我们通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期性模式,提升哈希算法的稳健性。

常见哈希算法

以上介绍的简单哈希算法都比较"脆弱",远远没有达到哈希算法的设计目标。例如,由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,并引起一些安全问题。

在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2 和 SHA-3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。

MD5 SHA-1 SHA-2 SHA-3
推出时间 1992 1995 2002 2008
输出长度 128 bit 160 bit 256/512 bit 224/256/384/512 bit
哈希冲突 较多 较多 很少 很少
安全等级 低,已被成功攻击 低,已被成功攻击
应用 已被弃用,仍用于数据完整性检查 已被弃用 加密货币交易验证、数字签名等 可用于替代 SHA-2

数据结构的哈希值

哈希表的 key 可以是整数、小数或字符串等数据类型。编程语言通常会为这些数据类型提供内置的哈希算法,用于计算哈希表中的桶索引。我们可以调用 hash() 函数来计算各种数据类型的哈希值,但是Go中没有提供内置的hash code函数。

只有不可变对象才可作为哈希表的 key 。假如我们将列表(动态数组)作为 key ,当列表的内容发生变化时,它的哈希值也随之改变,我们就无法在哈希表中查询到原先的 value 了。

虽然自定义对象(比如链表节点)的成员变量是可变的,但它是可哈希的。这是因为对象的哈希值通常是基于内存地址生成的,即使对象的内容发生了变化,但它的内存地址不变,哈希值仍然是不变的。

相关推荐
在下木子生28 分钟前
SpringBoot条件装配注解
java·spring boot·后端
huan991 小时前
Obsidian 插件篇 - 插件汇总简介
后端
周Echo周1 小时前
5、vim编辑和shell编程【超详细】
java·linux·c++·后端·编辑器·vim
AronTing1 小时前
03-深入解析 Spring AOP 原理及源码
后端
逻辑重构鬼才1 小时前
AES+RSA实现前后端加密通信:全方位安全解决方案
后端
卤蛋七号1 小时前
JavaSE高级(一)
后端
Java中文社群1 小时前
SpringAI用嵌入模型操作向量数据库!
后端·aigc·openai
暴力袋鼠哥2 小时前
基于Flask的跨境电商头程预警分析系统
后端·python·flask
一只爱撸猫的程序猿2 小时前
防止外部API服务不可用拖垮系统的解决方案
spring boot·后端·程序员
白露与泡影2 小时前
SpringBoot 最大连接数及最大并发数是多少?
spring boot·后端·firefox