目录
- 一、基本概念
- 1.1 哈希冲突
- 二、整数哈希
- 2.1 哈希函数的设计
- 2.2 解决哈希冲突
- 2.2.1 开放寻址法
- 2.2.2 拉链法
- 三、字符串哈希
- 3.1 应用:重复的DNA序列
- References
一、基本概念
哈希表又称散列表,一种以「key-value」形式存储数据的数据结构。所谓以「key-value」形式存储数据,是指任意的键值 key 都唯一对应到内存中的某个位置。只需要输入查找的键值,就可以快速地找到其对应的 value。可以把哈希表理解为一种高级的数组,这种数组的下标可以是很大的整数,浮点数,字符串甚至结构体。
哈希表存储的基本思路是:设要存储的元素个数为 n n n,设置一个长度为 m ( m ≥ n ) m\,(m\geq n) m(m≥n) 的连续内存单元,以每个元素的关键字 k i ( 1 ≤ i ≤ n ) k_i\,(1\leq i\leq n) ki(1≤i≤n) 为自变量,通过一个哈希函数 h h h 把 k i k_i ki 映射为内存单元的地址 h ( k i ) h(k_i) h(ki),并把该元素存储在这个内存单元中。
例如,我们可以开辟一个长度大于等于
n
n
n 的数组 a
,并以 a[h(key)] = value
的方式来存储键值对 (key, value)
。
哈希表最常见的两种操作是插入和查找。
1.1 哈希冲突
注意到当 k i ≠ k j k_i\neq k_j ki=kj 时是有可能出现 h ( k i ) = h ( k j ) h(k_i)=h(k_j) h(ki)=h(kj) 的,这种现象称为哈希冲突。我们将具有不同关键字但具有相同哈希地址的元素称为「同义词」,这种冲突也称为同义词冲突。
在一般的哈希表中哈希冲突是很难避免的,但我们又不得不解决冲突,否则后面插入的元素会覆盖前面已经插入的元素。解决冲突的方法有很多,可分为「开放寻址法」和「拉链法」两大类,下文会详细介绍。
二、整数哈希
当 key
为整数时,h(key)
称为整数哈希。
2.1 哈希函数的设计
构造哈希函数的目标是使得到的 n n n 个元素的哈希地址尽可能均匀地分布在 m m m 个连续内存单元地址上,同时使计算过程尽可能简单以达到尽可能高的时间效率。
构造哈希函数有许多种方法,这里只介绍最常用的「除留余数法」。
不妨设哈希表要存储的元素个数为 n n n,于是我们可以开一个长度为 n n n 的数组并定义
h ( k ) = k mod p h(k)=k\;\text{mod}\;p h(k)=kmodp
其中 p p p 是不大于 n n n 且最接近 n n n 的质数。
但这样做的弊端在于,如果 n n n 不是质数(通常都不是),则有 0 ≤ h ( k ) ≤ p − 1 < n − 1 0\leq h(k) \leq p-1 <n-1 0≤h(k)≤p−1<n−1,从而一定会造成哈希冲突。为避免这一现象,我们可以寻找大于等于 n n n 且最接近 n n n 的质数,不妨设为 m m m,然后开一个长度为 m m m 的数组并取 p = m p=m p=m。
事实上,若数组长度 m m m(即质数 p p p)能够离 2 2 2 的幂尽可能地远,则冲突概率可以进一步降低,详情见[3]。这里我们可以根据 n n n 的数量级来给出一个简化版的表格:
n n n | m m m |
---|---|
1 0 3 10^3 103 | 1543 1543 1543 |
1 0 4 10^4 104 | 12289 12289 12289 |
1 0 5 10^5 105 | 196613 196613 196613 |
1 0 6 10^6 106 | 1572869 1572869 1572869 |
注意到 k k k 可能是负数,因此我们需要将哈希函数修改成
h ( k ) = ( k mod m + m ) mod m h(k)=(k\;\text{mod} \; m+m)\;\text{mod}\; m h(k)=(kmodm+m)modm
来确保 h ( k ) ≥ 0 h(k)\geq 0 h(k)≥0。
2.2 解决哈希冲突
哈希冲突无法彻底避免,因此我们必须要考虑如何解决哈希冲突。
2.2.1 开放寻址法
开放寻址法就是在插入一个关键字为 k k k 的元素时,若发生哈希冲突,则通过某种哈希冲突解决函数(也称为再哈希)得到一个新空闲地址再插入该元素的方法。
再哈希的设计有很多种,常见的有「线性探测法」和「平方探测法」,本文只讲解前者,后者可类比得到。
线性探测法是从发生冲突的地址开始,依次探测下一个地址,直到找到一个空闲单元为止。当到达下标为 m − 1 m-1 m−1 的哈希表表尾时,下一个探测地址是表首地址 0 0 0。当 m ≥ n m\geq n m≥n 时一定能找到一个空闲单元。
使用开放寻址法时, m m m 通常取 n n n 的 2 ∼ 3 2\sim 3 2∼3 倍左右。例如若 n ≤ 1 0 5 n\leq 10^5 n≤105,则我们可以取上表中的 196613 196613 196613 作为哈希表的大小。
基于开放寻址法的哈希表实现如下(以下均假定 k = v ≜ x k=v\triangleq x k=v≜x):
const int N = 196613, INF = 0x3f3f3f3f; // 假定数据范围不超过1e9
struct HashTable {
int h[N];
HashTable() { memset(h, 0x3f, sizeof(h)); } // 未存储元素的地方均为INF
int hash(int x) {
int idx = (x % N + N) % N;
while (h[idx] != INF && h[idx] != x) idx = (idx + 1) % N;
return idx;
}
void insert(int x) {
h[hash(x)] = x;
}
bool query(int x) {
return h[hash(x)] == x;
}
};
线性探测法的优点是解决冲突简单,但一个重大的缺点是容易产生堆积问题。平方探测法虽然可以避免出现堆积问题,但是其不一定能探测到哈希表上的所有单元(至少能探测到一半单元)。
2.2.2 拉链法
拉链法是把所有的同义词用单链表链接起来的方法。在这种方法中,哈希表的每个单元存储的不再是元素本身,而是相应同义词单链表的头指针(注意是头指针而不是头节点)。
对于单链表,我们可以采用数组的方式进行实现。此外,使用拉链法时, m m m 的大小通常和 n n n 差不多。例如,若 n ≤ 1 0 5 n\leq 10^5 n≤105,我们可以寻找大于等于 1 0 5 10^5 105 的第一个质数,即 m = 100003 m=100003 m=100003。
基于拉链法的哈希表实现如下:
const int N = 100003;
struct HashTable {
int h[N], val[N], nxt[N], p; // p是指向待插入位置的指针
HashTable() : p(0) { memset(h, -1, sizeof(h)); } // 空指针用-1表示
int hash(int x) {
return (x % N + N) % N;
}
void insert(int x) {
int idx = hash(x);
val[p] = x, nxt[p] = h[idx], h[idx] = p++;
}
bool query(int x) {
for (int i = h[hash(x)]; ~i; i = nxt[i])
if (val[i] == x)
return true;
return false;
}
};
三、字符串哈希
⚠️ 本节讨论的下标均从 1 1 1 开始。
当 key
为字符串时,h(key)
称为字符串哈希。
这里我们介绍「多项式哈希方法」,对于一个长度为 l l l 的字符串 s s s 来说,哈希函数定义如下
h ( s ) = ∑ i = 1 l s [ i ] × p l − i ( mod M ) h(s)=\sum_{i=1}^{l} s[i]\times p^{l-i}\;(\text{mod}\; M) h(s)=i=1∑ls[i]×pl−i(modM)
其中 s [ i ] s[i] s[i] 为字符的ASCII码, p p p 通常取 131 131 131 或 131313 131313 131313, M M M 取 2 64 2^{64} 264(使用这种取值,哈希冲突的概率几乎为 0 0 0,故下文不再考虑哈希冲突)。
注意到使用 unsigned long long
这个变量类型存储哈希值,溢出时就相当于对
M
M
M 取模。记
h
(
s
)
≜
h
(
s
[
1..
l
]
)
h(s)\triangleq h(s[1..l])
h(s)≜h(s[1..l]),不难发现
h ( s [ 1.. l ] ) = ∑ i = 1 l − 1 s [ i ] × p l − i + s [ l ] = p × h ( s [ 1.. l − 1 ] ) + s [ l ] h(s[1..l])=\sum_{i=1}^{l-1}s[i]\times p^{l-i}+s[l]=p\times h(s[1..l-1])+s[l] h(s[1..l])=i=1∑l−1s[i]×pl−i+s[l]=p×h(s[1..l−1])+s[l]
接下来分别开两个数组 h h h 和 p p p,其中 h [ i ] ≜ h ( s [ 1.. i ] ) h[i]\triangleq h(s[1..i]) h[i]≜h(s[1..i]) 用来存储原串长度为 i i i 的前缀的哈希值, p [ i ] p[i] p[i] 用来存储 p i p^i pi,于是得到递推式:
{ h [ i ] = h [ i − 1 ] ⋅ p + s [ i ] , 1 ≤ i ≤ l p [ i ] = p [ i − 1 ] ⋅ p , 1 ≤ i ≤ l h [ 0 ] = 0 , p [ 0 ] = 1 \begin{cases} h[i]=h[i-1]\cdot p+s[i],\quad 1\leq i\leq l \\ p[i] = p[i-1]\cdot p,\quad 1\leq i\leq l\\ h[0]=0,\quad p[0]=1 \end{cases} ⎩ ⎨ ⎧h[i]=h[i−1]⋅p+s[i],1≤i≤lp[i]=p[i−1]⋅p,1≤i≤lh[0]=0,p[0]=1
从而, h [ l ] h[l] h[l] 就代表字符串 s s s 的哈希值。在求解的过程中我们还得到了 s s s 的所有前缀哈希值。
那前缀哈希值有什么用呢?利用它,我们可以在 O ( 1 ) O(1) O(1) 的时间内求出 s s s 的任一子串的哈希值。具体来说,设子串为 s [ l . . r ] s[l..r] s[l..r](这里的 l l l 并非指长度,而是left的意思),注意到
h [ l − 1 ] = ∑ i = 1 l − 1 s [ i ] × p l − 1 − i h [ r ] = ∑ i = 1 r s [ i ] × p r − i \begin{aligned} h[l-1]&=\sum_{i=1}^{l-1}s[i]\times p^{l-1-i} \\ h[r]&=\sum_{i=1}^rs[i]\times p^{r-i} \end{aligned} h[l−1]h[r]=i=1∑l−1s[i]×pl−1−i=i=1∑rs[i]×pr−i
不难看出
h [ r ] − p r − l + 1 ⋅ h [ l − 1 ] = ∑ i = 1 r s [ i ] × p r − i − ∑ i = 1 l − 1 s [ i ] × p r − i = ∑ i = l r s [ i ] × p r − i \begin{aligned} h[r]-p^{r-l+1}\cdot h[l-1]&=\sum_{i=1}^rs[i]\times p^{r-i}-\sum_{i=1}^{l-1}s[i]\times p^{r-i} \\ &=\sum_{i=l}^rs[i]\times p^{r-i} \\ \end{aligned} h[r]−pr−l+1⋅h[l−1]=i=1∑rs[i]×pr−i−i=1∑l−1s[i]×pr−i=i=l∑rs[i]×pr−i
正是子串 s [ l . . r ] s[l..r] s[l..r] 的哈希值。
基于多项式哈希法的字符串哈希实现如下(假定字符串长度 ≤ 1 0 5 \leq 10^5 ≤105):
typedef unsigned long long ULL;
const int N = 1e5 + 10, P = 131313;
struct HashTable {
ULL h[N], p[N];
HashTable(string s) {
h[0] = 0, p[0] = 1;
for (size_t i = 1; i <= s.size(); i++) {
h[i] = h[i - 1] * P + s[i - 1]; // 注意下标的转换
p[i] = p[i - 1] * P;
}
}
ULL get(int l, int r) {
return h[r] - h[l - 1] * p[r - l + 1];
}
};
3.1 应用:重复的DNA序列
原题链接:LeetCode 187. 重复的DNA序列
AC代码(本题如果取 p = 131 p=131 p=131 会WA):
typedef unsigned long long ULL;
const int N = 1e5 + 10, P = 131313;
struct HashTable {...}; // 这里省略
class Solution {
public:
vector<string> findRepeatedDnaSequences(string s) {
if (s.size() <= 10) return {};
HashTable ht(s);
unordered_map<ULL, int> cnt;
vector<string> ans;
for (int i = 1; i + 9 <= s.size(); i++) {
int j = i + 9, hash = ht.get(i, j);
if (cnt[hash] == 1) ans.push_back(s.substr(i - 1, 10));
cnt[hash]++;
}
return ans;
}
};
References
[1] https://oi-wiki.org/ds/hash/
[2] https://www.acwing.com/activity/content/11/
[3] https://planetmath.org/goodhashtableprimes
[4] 数据结构教程(Python语言描述)
[5] https://oi-wiki.org/string/hash/