哈希(Hash)
哈希的基本概念
- 哈希(Hash)在我的理解中是一种映射关系,例如,将学生映射到学号上、将口红的颜色映射到一个色号上。
- 哈希函数(Hash Function)就是将一种你想要查询的关键字,比如说姓名、手机号码、数字或者字符串等,映射成便于查找的东西(一般来说是一个数字)的函数。
- 一般而言,一个好的哈希函数可以帮我们将我们想要查找的东西,从一个较大集合映射到一个较小集合,然后我们可以将这个较小集合中的信息存储下来,例如存入一个数组,这个用于存储较小集合的数组就称之为哈希表。
- 一张格式如下的表:
学号 姓名 0001 张三 0002 李四 0003 王五 0004 赵六 … … - 这张表就可以理解为一个姓名和学号的哈希表,我们通过学号就可以获得学号对应的人的姓名,即:
学号[0001] -> "张三"
,反映到代码中,可以理解为一个一维数组通过下标直接访问。
- 一张格式如下的表:
- 而在一些加密工作中,可能需要将要简单的组合复杂化,比如密码组合,这时会有一种类似反向哈希(加密)的过程。
- 比较常见的哈希函数的例子:
H ( x ) = x m o d 11 H(x) = x \; mod \; 11 H(x)=xmod11 - 这个函数可以让我们将任意的整数映射到
0 ~ 10
的范围中
哈希的基本操作
- 定义哈希函数
const int modnum = 11; int hashTable[modnum]; // 定义哈希函数 int hash(int x) { return x % modnum; }
- 在哈希表中插入元素
// 在哈希表中插入元素 void insert(int x) { int addr = hash(x); hashTable[addr] = x; }
- 在哈希表中查找元素
// 在哈希表中查找元素 bool isExist(int x) { int addr = hash(x); return hashTable[addr] == x; }
哈希表中解决冲突的方式
不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞,或者称为哈希冲突。
哈希表在存放数据时有可能出现冲突的情况,以上文中的哈希函数为例我们分别向其中插入元素,如下:
因为希望哈希表底层数组的容量小于实际要存储的关键字的数量,这就会导致一个问题:冲突的发生是必然的,我们能做的是尽量的降低冲突率。
冲突的解决方式一般有以下两种:
方式 1:顺延
一种很好想到的解决方式是将哈希表中插入时产生冲突的元素向后顺延,直到找到一个空位置再进行插入。这种方式称为顺延,也有说法称之为线性探测。
此时插入和查找的代码也要发生相应的改变,插入时需要我们需要找到一个空位置来执行插入操作;相对应的查找方式也要做出改变,当我们查询一个数时,也要查询哈希函数对应的位置,并依次比较连续的非空的哈希表中的值:
- 插入操作
void insert(int x) { int addr = hash(x); while(hashTable[addr] NOT NULL) { // 当哈希表的插入位置不为空 addr = (addr + 1) % modnum; } hashTable[addr] = x; }
- 查找操作
void isExist(int x) { int addr = hash(x); while(hashTable[addr] NOT NULL) { // 当哈希表的查询位置不为空(查询一段连续的哈希表) if(hashTable[addr] == x) { // 如果查询到指定元素, 返回 true return true; } addr = (addr + 1) % modnum; } return false; }
可以定义多种解决冲突的顺延函数,即addr = (addr + 1) % modnum
,实际使用中可以是每次
+
k
+k
+k,或者
+
1
2
+1^2
+12,
+
2
2
+2^2
+22,
+
3
2
+3^2
+32,…等。
但是这种顺延方式会存在一定的问题:插入时可能会出现多次冲突,当哈希表即将满的时候,插入操作的冲突可能会出现的更多,此时插入和查询操作都会变成一个非常耗时的操作。
方式 2:哈希链表
我们可以通过链表的方式,来实现在一个位置放置多个元素的操作。在 C++ 的 STL 库中,我们可以使用 STL 库中提供的 vector
来简单的替代链表。
通过这种方式,每次查找元素时,先找到对应的链表头,然后遍历这个位置的整张链表即可。
此时的哈希表的定义、插入操作和查询操作要发生相应的变化:
-
定义哈希函数
#include <iostream> #include <vector> const int modnum = 11; vector<int> hashTable[modnum]; // 定义哈希函数 int hash(int x) { return x % modnum; }
-
插入操作
void insert(int x) { int addr = hash(x); hashTable[addr].push_back(x); }
-
查询操作
void isExist(int x) { int addr = hash(x); int tableSize = hashTable[addr].size(); // 这里不使用 for(int i = 0; i < hashTable[addr].size(); i++) 的写法, 而是首先计算出 hashTable[addr].size() // 因为 vector 的 size() 是一个比较耗时的操作, 他是通过将 vector 按照一个一个数出来的方式来进行计数的 // 在数据量小的时候可能并不明显, 当数据量大的时候可能就会出现较为严重的耗时问题 for(int i = 0; i < tableSize; i++) { if(hashTable[addr][i] == x) { return true; } } return false; }
但是这种方式还是不能彻底解决我们的问题。对于插入操作来说,时间复杂度可以看作是 O ( 1 ) O(1) O(1),对于查询操作来说,时间复杂度和其冲突次数相关联。
哈希函数的设计
面对上面的问题,设计好哈希函数才是解决问题的关键。哈希函数在设计的时候,一般要注意几个原则:
- 在手搓哈希函数时,我们会要求 H ( x ) = x m o d p H(x) = x \; mod\; p H(x)=xmodp,其中的 p p p 为素数
- 哈希函数中的对 p p p 取摸的操作,会使得哈希值落在 0 < = v a l u e < = p − 1 0 <= value <= p-1 0<=value<=p−1 的范围内,这个哈希表的长度 p p p,一般被称为哈希表的容量(Capacity)。
- 插入哈希表的元素总数除以哈希表的容量得到的数,称为负载因子,这里可以用
α
\alpha
α 表示,即:
α = E l u m N u m ÷ p \alpha = ElumNum \div p α=ElumNum÷p - 当负载因子 α \alpha α达到一定程度时(一般认为是 0.7 ∼ 0.8 0.7\sim0.8 0.7∼0.8),则说明再对哈希表继续进行操作,就会面临大量冲突的情况,这时就要考虑增大哈希表的容量以避免出现更多的冲突。
- 哈希函数的冲突率和负载因子的关系一般如下:
字符串哈希
字符串哈希是学习或者工作中经常遇到的一种操作,常用于比较两组字符串的内容等操作。通过比较两个字符串的哈希值就可以完成字符串的比较。
s
=
s
1
s
2
s
3
…
s
n
s
i
∈
a
,
b
…
z
s = s_1s_2s_3 \dots s_n\qquad s_i \in a, b \dots z
s=s1s2s3…snsi∈a,b…z
一个字符串
s
s
s 由
n
n
n 个字符组成,每个字符
s
i
s_i
si 属于
a
∼
z
a \sim z
a∼z。
其哈希函数为:
H
(
S
)
=
(
∑
i
=
1
n
c
i
×
b
a
s
e
n
−
i
)
m
o
d
p
=
(
c
1
×
b
a
s
e
n
−
1
+
c
2
×
b
a
s
e
n
−
2
+
⋯
+
c
n
−
1
×
b
a
s
e
1
)
m
o
d
p
=
b
a
s
e
n
−
(
n
−
1
)
(
b
a
s
e
…
(
b
a
s
e
(
b
a
s
e
×
c
1
+
c
2
)
)
)
m
o
d
p
=
b
a
s
e
1
(
b
a
s
e
…
(
b
a
s
e
(
b
a
s
e
×
c
1
+
c
2
)
+
c
3
)
+
⋯
+
c
n
)
m
o
d
p
\begin{aligned}H(S) &=(\sum_{i=1}^{n} c_i × base^{n-i})\;mod \;p\\ &=(c_1 × base ^ {n-1} + c_2 × base ^ {n-2} + \dots + c_{n-1} × base ^ {1}) \; mod \; p\\ &=base^{n-(n-1)}(base\dots(base(base × c_1 + c_2)))\;mod \;p\\ &=base^{1}(base\dots(base(base × c_1 + c_2)+c_3)+\dots + c_n)\end{aligned}\;mod \;p
H(S)=(i=1∑nci×basen−i)modp=(c1×basen−1+c2×basen−2+⋯+cn−1×base1)modp=basen−(n−1)(base…(base(base×c1+c2)))modp=base1(base…(base(base×c1+c2)+c3)+⋯+cn)modp
其中
c
i
c_i
ci 是一个和
s
i
s_i
si 有关的数字,我们可以将字符映射到数字,例如:
a
→
1
a → 1
a→1、
b
→
2
b → 2
b→2 等。这里不将 a
映射为 0
,因为如果将 a
映射为 0
,字符串 a
和 ab
的哈希值是相等的。
b
a
s
e
base
base 是一个可以自己指定的数字,其值一般是大于字符集中的字符数量(
c
i
c_i
ci的最大值)的素数,这里可以取 31
,常见的选择是 9999971
。
p
p
p 是一个素数,常见的选择是 101
或 137
。
用代码实现为:
int hash(char s[], int n) {
int res = 0;
for(int i = 0; i < n; i++) {
// 为什么是 res * base, 见上文的描述的公式推导
res = (res * base + (s[i] - 'a' + 1)) % p;
}
return res;
}
当
b
a
s
e
base
base 为 31
,
p
p
p 为 101
时。
当
s
s
s 为
a
a
a 时,hash(char s[], int n)
的值为 1。
过程可以描述如下:
[1] res = (0 * base + ('a' - 'a' + 1)) % p
>>> res = 1
当
s
s
s 为
a
b
ab
ab 时,hash(char s[], int n)
的值为 1。
过程可以描述如下:
[1] res = (0 * base + ('a' - 'a' + 1)) % p
>>> res = 1
[2] res = (1 * base + ('b' - 'a' + 1)) % p
>>> res = 33
当我们定义好一个良好的哈希函数之后,因为哈希函数的值相等的概率比较小,当两个字符串的哈希值相同的时候,我们可以认为两个字符串也相同,从而避免使用按位比较,节约时间。