一文快速入门哈希表

一、基本概念

哈希表又称散列表,一种以「key-value」形式存储数据的数据结构。所谓以「key-value」形式存储数据,是指任意的键值 key 都唯一对应到内存中的某个位置。只需要输入查找的键值,就可以快速地找到其对应的 value。可以把哈希表理解为一种高级的数组,这种数组的下标可以是很大的整数,浮点数,字符串甚至结构体。

哈希表存储的基本思路是:设要存储的元素个数为 n n n,设置一个长度为 m   ( m ≥ n ) m\,(m\geq n) m(mn)连续内存单元,以每个元素的关键字 k i   ( 1 ≤ i ≤ n ) k_i\,(1\leq i\leq n) ki(1in) 为自变量,通过一个哈希函数 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 0h(k)p1<n1,从而一定会造成哈希冲突。为避免这一现象,我们可以寻找大于等于 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 m1 的哈希表表尾时,下一个探测地址是表首地址 0 0 0。当 m ≥ n m\geq n mn 时一定能找到一个空闲单元。

使用开放寻址法时, m m m 通常取 n n n 2 ∼ 3 2\sim 3 23 倍左右。例如若 n ≤ 1 0 5 n\leq 10^5 n105,则我们可以取上表中的 196613 196613 196613 作为哈希表的大小。

基于开放寻址法的哈希表实现如下(以下均假定 k = v ≜ x k=v\triangleq x k=vx):

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 n105,我们可以寻找大于等于 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=1ls[i]×pli(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=1l1s[i]×pli+s[l]=p×h(s[1..l1])+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[i1]p+s[i],1ilp[i]=p[i1]p,1ilh[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[l1]h[r]=i=1l1s[i]×pl1i=i=1rs[i]×pri

不难看出

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]prl+1h[l1]=i=1rs[i]×prii=1l1s[i]×pri=i=lrs[i]×pri

正是子串 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/

猜你喜欢

转载自blog.csdn.net/raelum/article/details/128793474