[知识点] 7.2 哈希表
总目录 > 7 数据结构 > 7.2 哈希表
前言
很久很久以前经常听到哈希这个词,后来多多少少有所接触,但并未系统地了解过哈希到底是怎么回事。
更新日志
20200804 - 进行部分调整,增加例子
子目录列表
1、哈希表与数组
2、哈希函数
3、构造哈希函数
4、哈希冲突
5、字符串 hash
6、应用
7.2 哈希表
1、哈希表与数组
哈希表(hash table),又称为散列表,是根据关键码值(key)直接进行访问的一种数据结构,也就是说,给定一个 key,则可以通过哈希表的映射关系快速找到其对应的值(value)。这听起来似乎和数组是一个意思 —— 对于数组 a = {2, 5, 8},其元素 a[1] = 2, a[2] = 5, a[3] = 8,没错,数组本身就是一种 key-value 的对应关系,每个元素的编号为 key,值为 value —— 而哈希表在数组的基础上有什么改进?
2、哈希函数
给定一个哈希表,存在函数 f(key),对任意给定的 key 值,通过代入这个函数就能得到包含该 key 的记录在表中的地址,则这个函数叫做哈希函数(hash function)。hash 的目的是让本来复杂的数据以简单的方式体现或访问,举个例子:给定 10 个同年同月同日同地出生的人的身份证号码,并为这些号码编一个号以便以后使用,比如:
a[320115********0105] = 1 a[320115********8577] = 2 ... a[320115********5201] = 10
显然,开一个 18 位数大小的数组是不现实的,我们决定将这些数进行 hash —— 由于这些号码前 14 位都是相同的,我们将这 18 位的 key 值取后 4 位来替换原 key 值进行各类操作,一下就变得现实起来了:
a[105] = 1 a[8577] = 2 ... a[5201] = 10
而后我们需要输出或其他情况时,把前 14 位还原即可。
所以,在生活中,我们常说的手机尾号便是手机号的哈希值,假设尾号为 4 位,其 hash 函数为 f(key) = key % 10 ^ 4;小明在学校的学号为 1810141728,而在班上提交作业时使用的学号为 28,本质是完整学号的哈希值,hash 函数为 f(key) = key % 100。
而对于普通的数组,可以理解成哈希函数为 f(key) = key。
那么,如何构造一个 hash 函数?有什么要求?
3、构造哈希函数
① 除留余数法
取 key 被某个不大于表长 m 的数取模后得到的余数作为 hash 值,即 f(key) = key % p。p 的取值非常关键,一般选择质数或者 m,能够降低错误率。
最为常用的构造法,因为我们使用哈希,最关键的原因就在于原值范围太大,难以存储与访问,那么最简单的就是取模以降低数据大小。比如上述学号与手机号的 hash 值便是使用的这种方法(也可以认为结合了数学分析法)。但更多情况下我们碰到的可能是没有太多可循规律的数据,模数的取值就没有限定了。
这里提供一些常用的质数模数:
1e9 + 7, 12255871, 16341163, 21788233, 29050993, 38734667, 51646229, 68861641, 91815541, 1e9 + 9
(看完下面的若干种构造法后也不难发现,除留余数法也是实现起来最简单的)
② 直接寻址法
取 key 或 key 的某个线性函数值作为 hash 值,即 f(key) = a * key + b。
构造方便,但适用范围不广。
举例:
小明统计这次高数考试成绩,每 10 分为一个分段。分数为 key,分数段人数为 value,则 hash 函数可以设定为 f(key) = 0.1 * key,通过 hash 使 key 范围缩小至 1 / 10。
③ 数学分析法
通过对 key 值的分析,找到最不可能出现冲突的构造方式,具体情况具体分析。
④ 平方取中法
求出 key 值的平方,取该平方值的中间几位作为 hash 值。听起来也是个比较玄学的构造方法,当然取最中间的数也是不无道理的 —— 它们和 key 的每一位都会相关,出现冲突的概率较低。由于存在平方操作,key 值不能过大。
举例:
key = 77777,key ^ 2 = 6049261729,f(key) 可以取 9261。
⑤ 折叠法
将 key 值按照数位平均切割为若干部分,求出每一部分各个数位之和,最后将这些和首尾相连,得到 f(key)。
举例:
key = 123456789,可以拆分成 123, 456, 789,分别求和为 6, 15, 24,再合并成 61524,即 f(key)。
⑥ 随机数法
选择一个随机函数,取随机值作为 hash 值,即 f(key) = random(key)。
随机大法好。
4、hash 冲突
在 2 中举的几个例子,10 个身份证号码的后 4 位理论上是不会有重复的;小明在班上交作业,班上也不会有和他一样尾号为 28 的;但对于手机尾号,假设张三的手机号为 155****1666,李四的手机号为 189****1666,那么他们在讨论手机号时,肯定不会用后 4 位尾号,因为并不能分清到底是谁的手机号,对于这种两个不同的原值通过哈希函数得到的 hash 值相同的情况,我们称之为 hash 冲突。
题目对于 hash 函数如何定义并无规定,上面给出的构造 hash 函数的方法任你选择,但显然,我们要保证不出现 hash 冲突的情况,或者尽可能少到忽略不计,即保证其 hash 结果的正确性。而正确性与空间占用往往是成反比的,其正确性越高,hash 值范围越大, 所占用的空间也就越大,所以我们需要在其中找到平衡点,选择最合适的 hash 函数。
而理论上,不论 hash 函数设计得对于契合,对于巧妙,只要数据范围和数据量够大,必然会出现 hash 冲突的情况,那么在遇到冲突时,有如下几种办法解决:
① 开放寻址法
对于 hash 函数 f(key) 和 key 值序列 k[i],假设存在 f(k[1]) = f(k[i]),则将 f(k[i]) 重新构造为:
f(k[i]) = (f(k[i]) + d[i]) % m,m 为表长。
d[i] 可以取:
> 线性探测再哈希:d[i] = c * i,c 为常数
> 平方探测再哈希:d[i] = 1 ^ 2, -1 ^ 2, 2 ^ 2, -2 ^ 2...
要求表长 m 为 4 * j + 3 的质数
> 随机探测再哈希:d[i] 为一组伪随机数列
要求 m 和 d[i] 没有公因子
具体例子暂略。
② 挂链 / 链地址法
所有 hash 值视作一个链表,将所有生成该哈希值的 key 值链在所属链表中。查询时把对应 hash 值的整个链表遍历一遍,对比是否与查询的 key 值相等。
5、例子
【例子】给出 10 ^ 6 个数,数据范围为 [1, 10 ^ 9],判定是否出现重复的数。
这里我们使用除留余数法构造 hash 表 + 挂链法解决冲突。
代码:
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 #define MAXN 1000005 5 #define MOD 12255871 6 7 int n, h[MAXN], a[MAXN], nxt[MAXN]; 8 9 int main() { 10 cin >> n; 11 for (int i = 1; i <= n; i++) { 12 cin >> a[i]; 13 int x = a[i] % MOD; 14 if (!h[x]) h[x] = i; 15 else { 16 for (int o = h[x]; o; o = nxt[o]) 17 if (a[o] == a[i]) 18 cout << "yp", exit(0); 19 nxt[i] = h[x], h[x] = i; 20 } 21 } 22 cout << "nob"; 23 return 0; 24 }
h[i] 表示 hash 值为 i 链表头的数,nxt[i] 表示数值为 i 的数所在的链表的下一个数的数值。
5、字符串 hash
相比普通的 hash,字符串 hash 多了个字符串转整数的步骤。
详细参见:5.2 字符串 Hash
6、应用
密码 hash 函数(Cryptographic Hash Function),是 hash 函数的一种。它是单向函数,也就是说只能从 key 值计算出 hash 值,而很难由 hash 值破译出 key 值,所以可以用于加密,在密码学中使用广泛。
大名鼎鼎的 MD5 便是属于密码 hash 函数。MD5(Message-Digest Algorithm),中文名为信息摘要算法,一种被广泛使用的密码 hash 函数,用于对信息加密,同时保证信息传输完整一致。它在 1992 年公开,用以取代另一种加密算法 MD4。目前,MD5 被广泛用于密码管理,电子签名,垃圾邮件筛选,文件校验等,不过尽管它在 MD2/3/4 的基础上进行大量改进,其安全性并非坚不可摧,在 2004 年已经被证明存在弱点而能被破解,无法防止碰撞,所以不适用于更高级别的安全防护。
举个例子,之前很火的 P2P(Peer-to-peer)对等网络技术被应用于文件下载和共享时,下载软件对文件的识别便是使用的 MD5 值,每一个被上传和下载的文件有其独一无二的 MD5 值,相当于其身份牌,通过比较 MD5 值能轻松对文件进行识别和校验。