算法与数据结构——哈希算法
哈希算法
前面介绍了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链式地址,它们只能保证可以在发生冲突时正常工作,而无法减少哈希冲突的发生。
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如下图所示,对于链式哈希表,理想情况下键值对均匀分布在各个桶中,达到最佳查询效率;最差情况所有键值对都存储到同一个桶中,时间复杂度退化至 O(n) 。
键值对的分布情况由哈希函数决定。在前面的哈希表实现中,哈希函数是直接对键取数组长度的模:
/*哈希函数*/ int hashFunc(int key){ int index = key % capacity; return index; }
index = hash(key) % capacity
观察以上公式,当哈希表容量capacity
固定时,哈希算法hash()
决定了输出值,进而决定了键值对在哈希表中的分布情况。
为降低哈希冲突发生的概率,我们应当将注意力集中在哈希算法hash()
的设计上 。
哈希算法的目标#
为了实现“既快又稳”的哈希表数据结构,哈希算法应该具备以下特点:
- 确定性: 对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表时可靠的。
- 效率高: 计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
- 均匀分布: 哈希算法应使得键值对均匀分布在哈希表中。分布越均匀,哈希冲突的概率就越低。
实际上哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。
- 密码存储:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹配,那么密码就被视为正确。
- 数据完整性检查:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整。
对于密码学的相关应用,为了防止从哈希值推导出原始密码登逆向工程,哈希算法需要具备更高等级的安全特性。
- 单向性:无法通过哈希值反推出关于输入数据的任何信息。
- 抗碰撞性:应当极难找到两个不同的输入,使得它们的哈希值相同。
- 雪崩效应:输入的微小变化应当导致输出的显著且不可预测的变化。
注意:“均匀分布”与“抗碰撞性”是两个独立的概念,满足均匀分布不一定满足抗碰撞性。例如,在随机输入key
下,哈希函数key % 100
可以产生均匀分布的输出,但该哈希算法过于简单,所有后两位相等的key
的输出都相同,因此我们可以很容易地从哈希值反推出可用的key
,从而破解密码。
哈希算法的设计#
哈希算法的设计是需要考虑多项因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简单的哈希算法。
- 加法哈希:对输入的每个字符的ASCII码进行相加,将得到的总和作为哈希值。
- 乘法哈希:利用乘法的不相关性,每轮乘一个常数,将各字符的ASCII码积累到哈希值中。
- 异或哈希:将输入的每个元素通过异或操作累积到一个哈希值中。
- 旋转哈希:将每个字符的ASCII码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作。
/*加法哈希*/ int addHash(string key){ long long hash = 0; /*1000000007 是一个质数,使用质数作为模数可以减少哈希冲突的概率*/ /*避免内存溢出,保证结果在int整数范围内*/ const int MODULE = 1000000007; /*使用unsigned char保证字符转换后都为正数,避免了减法的发生*/ for (unsigned char ch : key){ hash = (hash + (int)ch) % MODULE; } return (int)hash; } /*乘法哈希*/ int mulHash(string key){ long long hash = 0; const int MODULE = 1000000007; for (unsigned char ch : key){ hash = (31 * hash + (int)ch) % MODULE; } return (int)hash; } /*异或哈希*/ int xorHash(string key){ long long hash = 0; const int MODULE = 1000000007; for (unsigned char ch : key){ hash ^= (int)ch; } return (int)hash; } /*旋转哈希*/ int rotHash(string key){ long long hash = 0; const int MODULE = 1000000007; for (unsigned char ch : key){ hash = ((hash << 4)^(hash >> 28) ^ (int)ch) % MODULE; } return (int)hash; }
观察发现每一种哈希算法的最后一步都是对大质数1000000007取模,以确保哈希值在合适的范围内。
原因:使用大质数作为模数,可以最大化地保证哈希值均匀分布。因为质数不与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。
举个例子,假设我们选择合数9作为模数,它可以被3整除,那么所有可以被3整除的key
都会被映射到0、3、6这三个哈希值。
如果输入key
恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。现在,假设将module
替换为质数13,由于key
和module
之间不存在公约数,因此输出的哈希值的均匀性会明显提升。
说明:如果能保证key是随机均匀分布的,那么选择质数或者合数作为模数都是可以的,它们都能输出均匀分布的哈希值。而当key
的分布存在某种周期性时,对合数取模更容易出现聚集现象。
总而言之,我们通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期模式,提升哈希算法的稳健性。
常见哈希算法#
可以发现,上面实现的简单哈希算法都比较“脆弱”,远远没有达到哈希算法的设计目标。例如,由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,并引起一些安全问题。
在实际中,我们通常会用到一些标准哈希算法,例如MD5、SHA-1、SHA-2和SHA-3等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
近一个世纪以来,哈希算法在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一部分研究人员和黑客则致力于寻找哈希算法的安全性问题。
- MD5和SHA-1已多次被成功攻击,因此它们被各类安全应用弃用。
- SHA-2系列中的SHA-256是最安全的哈希算法之一,仍未出现成功攻击的案例,因此常用在各类安全应用与协议中。
- SHA-3相较于SHA-2的实现开销更低、计算效率更高,但目前使用的覆盖度不如SHA-2系列。
数据结构的哈希值#
哈希表的key
可以是整数、小数或字符串等数据类型。编程语言通常会认为这些数据类型提供内置的哈希算法,用于计算哈希表中的桶索引。以Python为例,我们可以调用hash()
函数来计算各种数据类型的哈希值。
- 整数和布尔量的哈希值就是其本身。
- 浮点数的字符串的哈希值计算比较复杂。
- 元组的哈希值是对其中每一个元素进行哈希吗,然后将这些哈希值组合起来,得到单一的哈希值。
- 对象的哈希值基于其内存地址生成。通过重写对象的哈希方法,可以实现基于内容生成哈希值。
在C++中,内置 std:: hash() 方法,仅提供基本数据类型的哈希值计算
数组、对象的哈希值计算需要自行实现
int num = 3; size_t hashNum = hash<int>()(num); // 整数 3 的哈希值为 3 bool bol = true; size_t hashBol = hash<bool>()(bol); // 布尔量 1 的哈希值为 1 double dec = 3.14159; size_t hashDec = hash<double>()(dec); // 小数 3.14159 的哈希值为 4614256650576692846 string str = "Hello 算法"; // 字符串“Hello 算法”的哈希值为 15466937326284535026
在许多编程语言中,只有不可变对象才可作为哈希表的key。假如入门将列表(动态数组)作为key
,当列表内容发生变化时,它的哈希值也随之改变,我们就无法再哈希表中查询到原先的value
了。
虽然自定义对象(比如链表节点)的成员变量是可变的,但它是可哈希的。**这是因为对象的哈希值通常是基于内存地址生成的,即使对象内容发生了变化,但它的内存地址不变,哈希值仍然是不变的。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)