夜深人静写算法(九)- 哈希表

一、前言

  谈起哈希表,学过数据结构的同学,应该都已经耳熟能详了,因为太基础所以一直没有单独拿出来讲,然而 广度优先搜索 和 动态规划 里面都涉及到了状态哈希,所以还是有必要拿出来讲一下的。所谓的 状态 到底是一个什么概念,为什么要对状态进行 哈希。希望读者看完本章内容,能够有一个大概的概念,能对后续要讲到的 广度优先搜索 以及 状态压缩动态规划 起到一定的铺垫作用。
  虽然,我曾经一度认为自己对哈希表的理解已经很透彻了,但是今天我在总结这篇文章的时候,突然领悟了几个之前比较模糊的概念,而且就在那一瞬间,犹如醍醐灌顶,茅塞顿开,这种感觉实在是太棒了!在这里插入图片描述

二、哈希表

1、哈希表概念

  • 哈希表(Hash table)的初衷是为了将关键字值 (key - value) 映射到数组中的某个位置,这样就能够通过数组下标访问该数据,省去了遍历整个数据结构的过程,从而提高了数据的查找速度,查找的平均期望时间复杂度是 O ( 1 ) O(1) O(1) 的。
  • redis 中的键值对、python 中的 dict 、lua 中的 table、C++ STL 中的 unordered_map 等等,底层都是采用哈希表来实现的,可见哈希表在实际应用中还是很广泛的。
  • 首先,介绍几个概念来对哈希表有一个初步的认识。

1)哈希数组

  • 为了方便下标索引,哈希表的底层实现结构是一个数组,数组类型可以是任意类型,每个位置被称为一个槽(slot)。如图二-1-1所示,它代表的是一个长度为 8 的哈希数组。

图二-1-1

2)关键字

  • 关键字(key)是任意类型,可以是整型、长整型、字符串甚至是结构体或者类;如下的 a、b、o 都可以是关键字;
int a = 5;
string b = "Hello World!";
class Obj {
    
     };
Obj o;
  • 哈希表的实现过程中,我们需要通过一些手段,将一个非整型的关键字转换成整型,然后再对哈希数组的长度进行取模,转换为下标,从而找到它所对应的位置,实现快速关键字查找。如图二-1-2所示:

图二-1-2

  • 而将一个非整型的关键字转换成整型的手段就是 哈希函数。

3)哈希函数

  • 哈希函数可以简单的理解为就是小学课本上那个函数,即 y = f ( x ) y = f(x) y=f(x),这里的 f ( x ) f(x) f(x) 就是哈希函数, x x x 是关键字, y y y 是值。好的哈希函数应该具备以下两个特质:

  • 1)单射(或者叫 一一映射);

  • 2)雪崩效应:输入值 ( x ) (x) (x) 的 1 位的变化,能够造成输出值 ( y ) (y) (y) 1/2 的位的变化;

  • 单射很容易理解,如图二-1-3所示。图 ( a ) (a) (a) 中已知哈希值 y y y 时,键 x x x 可能有两种情况,不是一个单射;而图 ( b ) (b) (b) 中已知哈希值 y y y 时,键 x x x 一定是唯一确定的,所以它是单射。由于 x x x y y y 一一对应,所以在没有取模之前,至少是没有冲突的,这样就从本原上减少了冲突。

    扫描二维码关注公众号,回复: 12393914 查看本文章
  • 雪崩效应是为了让哈希值更加符合随机分布的原则,哈希表中的键分布的越随机,利用率越高,效率也越高。

    图二-1-3

  • 整数的哈希函数比较简单,可以为自身: h a s h ( x ) = x hash(x) = x hash(x)=x

  • 字符串的哈希函数设计的时候,一般是遍历整个字符串进行某种运算,最后得到的是一个长整型;
    h a s h ( s ) = 9456043234891890 l l hash(s) = 9456043234891890ll hash(s)=9456043234891890ll

  • 类的哈希函数,设计的时候可以先实现一个 toString 接转化成字符串,然后再对这个字符串进行字符串哈希;

4)值

  • 这里的值 (value),就对应了上文提到的哈希数组的类型;
  • 整个哈希过程就是通过 关键字 (key) 找 值 (value) 的过程。

2、简单下标哈希

  • 简单下标哈希就是利用关键字直接访问数组元素,省去了计算哈希值、取模、以及寻址的过程,如图二-2-1所示:

图二-2-1

  • 查找时间复杂度 O ( 1 ) O(1) O(1) 。但是对关键字要求较高,首先必须是整数,其次是关键字的范围必须严格控制在哈希数组范围内。

图二-2-2

  • 上图中,圆形代表了关键字,方形格子则代表了哈希数组,箭头表示下标访问。
  • 例如:一共 4 个人,编号为 ( 1 , 3 , 4 , 6 ) (1, 3, 4, 6) (1,3,4,6),现在要存储每个人的年龄,那么用一个数组int age[8]就可以存储了。访问的时候直接通过下标就能 获取/设置 对应编号的人的年龄,这个存取的过程就是最简单的下标哈希了。
int age[8];
age[1] = 34;
printf("%d\n", age[3]);
  • 这种简单下标哈希在之前的章节已经有大量的应用,比如:
  • 1)并查集:对每个元素映射到对应集合的时候采用的就是下标哈希,fset[i] = i;代表 i i i 这个元素所属的集合编号;
const int MAXN = 300010;
int fset[MAXN];

void init(int n) {
    
    
    for (int i = 1; i <= n; ++i) {
    
    
        fset[i] = i;
    }
}
  • 2)字典树:在对子结点nodes_[]进行存储的时候,字母减去了一个偏移量后映射到数组中,采用的也是下标哈希;
const int TRIE_NODE_COUNT = 26;
class TrieNode {
    
    
private:
    int nodes_[TRIE_NODE_COUNT];
};
  • 3)二分图:在染色算法中,每个结点的颜色存储到color_[]数组时,用到的也是简单下标哈希;
    if (color_[v] == -1) {
    
    
        color_[v] = 1 - color_[u];
        Q.push(v);
    }

3、散列哈希

  • 接下来,我们来介绍一下更加一般的情况,即通过一个不在数组范围内的整型(或长整型),通过计算得到它的值。如图二-3-1所示:

图二-3-1

1)哈希值离散

  • 实际问题中,我们的数组可能没有那么大,或者哈希值比较离散,离散的反义词是连续,例如: ( 1 、 3 、 4 、 6 ) (1、3、4、6) (1346) 相对于 ( 1 、 2 、 3 、 4 ) (1、2、3、4) (1234) 就是离散的。如图二-3-2所示:

图二-3-2

  • 数组的长度只有 4,但是我们的哈希值分别为 1、3、4、6,无法采用下标进行映射;

2)除留余数法

  • 由于数组长度为 4,所以我们可以将哈希值 模 4 再进行映射,如图二-3-3所示:

图二-3-3

  • 比如用 x x x 代表哈希值, f ( x ) f(x) f(x) 代表实际映射的下标,则有如下公式: f ( x ) = x m o d    4 f(x) = x \mod 4 f(x)=xmod4
  • 这样做虽然解决了哈希值离散的问题,同时也带来了另一个问题,那就是 哈希冲突。

3)哈希冲突

  • 所谓哈希冲突,就是两个不同的哈希值通过取模映射到了同一个下标。这样就会产生二义性,如图二-3-4 所示:

图二-3-4

  • 图中 1 和 9 模 4 的余数都为 1,所以都映射到了下标为 1 的位置,这样取的时候就无法知道原哈希值到底是 1 还是 9 了。于是,就需要有一些应对哈希冲突的解决方案,常用的有:链地址法、开放寻址法、再散列法。

a. 链地址法

  • 数组存储值数据的链表头,将所有取模后一样的哈希值用链表串起来,查找的时候先取模找到对应下标位置,然后在对应链表上遍历找到对应哈希值的数据。如图二-3-5所示:

图二-3-5

  • 这种方法对哈希值要求比较高,必须尽量平均分布。考虑一种极端情况:所有哈希值都模 4 同余,那么它们会映射到同一个下标,导致最后的结构退化成了链表,查找效率退化为 O ( n ) O(n) O(n)

b. 开放寻址法

  • 数组存储值数据,如果遇到取模后发现已经有数据,则往数组后移一位,如果还有继续移动,直到找到一个空闲位置,如图二-3-6所示:
    图二-3-6
  • 哈希值 9 对 4 取模以后值为 1,但是发现下标为 1 的位置上已经有元素了,于是往后继续找一个,找到下标为 2 的位置,于是产生映射 f ( 9 ) = 2 f(9) = 2 f(9)=2
  • 这种方法对不同的哈希值的个数要求有限制,必须小于等于哈希数组大小,否则永远找不到就会产生死循环。而且随着哈希值增多,插入和查找效率下降。

4)负载因子

  • 无论是链地址法还是开放地址法都会遇到一个问题,就是一旦数据量上去以后,都会导致查找效率下降,于是,这里引入一个负载因子的概念:
    负 载 因 子 = 哈 希 值 个 数 / 数 组 长 度 负载因子 = 哈希值个数 / 数组长度 =/
  • 对于链地址法来说,负载因子 > 5 就要考虑 rehash 了;而对于开放寻址法,负载因子 > 0.7 时,考虑 rehash,那么什么是 rehash 呢?

5)rehash

  • 所谓 rehash,就是申请一块新的空间,空间的大小为原哈希数组的两倍,然后把原有的数据全部取出来映射到新的哈希数组里,再释放原有哈希数组。
  • 实际实现的时候,为了减少申请空间带来的开销,一般是预先就一直有两个哈希数组(指针),然后采用滚动的方式进行扩容,扩容完毕交换指针。
  • 并且由于一次 rehash 的耗时可能较长,一般采用渐进式 rehash,分散 CPU 的执行时间,具体细节可以参考 redis 源码的实现,这里不再展开来说了。

6)取模位运算优化

  • 哈希数组的长度一般选择 2 的幂,因为我们知道取模运算是比较耗时的,而位运算相对较为高效;
  • 选择 2 的幂作为数组长度,可以将 取模运算 转换成 二进制位与(&);
  • S = 2 k S = 2^k S=2k,那么它的二进制表示就是: S = ( 1 000...000 ⏟ k ) 2 S = (1\underbrace{000...000}_{\rm k})_2 S=(1k 000...000)2,任何一个数模上 S S S,就相当于取了 S S S 的二进制低 k k k 位,而 S − 1 = ( 111...111 ⏟ k ) 2 S-1 = (\underbrace{111...111}_{\rm k})_2 S1=(k 111...111)2 ,所以和 位与 S − 1 S-1 S1 的效果是一样的。
    x % S = = x & ( S − 1 ) ; x \% S == x \& (S - 1); x%S==x&(S1);

4、散列哈希的实现

  • 这里介绍一种简单的哈希再散列的实现,为了尽量简化代码,假设了几个问题:
  • 1)不涉及 rehash:因为哈希数组长度足够大,元素个数可控;
  • 2)不考虑负载因子:因为不进行 rehash ,自然也不用考虑负载因子了;
  • 3)采用开放寻址法:不用链地址法,避免申请堆内存的开销;
  • 先给出代码,再进行讲解:
#define HashValueType long long 
const int MAXH = (1 << 20);
bool hashkey[MAXH];                        // 1)
HashValueType hashval[MAXH];               

int getKey(HashValueType val) {
    
    
    int key = (val & (MAXH-1) );           // 2)
    while (1) {
    
    
        if (!hashkey[key]) {
    
                   // 3)
            hashkey[key] = true;
            hashval[key] = val;
            return key;
        }
        else {
    
    
            if (hashval[key] == val) {
    
    
                return key;               // 4)
            }
            key = (key + 1) & (MAXH - 1); // 5)
        }
    }
}
  • 这个函数实现的是:通过给定的哈希值 v a l val val,找到哈希表中哈希值对应的下标索引 k e y key key,如果找不到则进行插入;
  • 1)bool hashkey[key]表示映射后 k e y key key 这个下标位置是否有元素,HashValueType hashval[key]表示下标为 k e y key key 这个位置的元素的值,可以是任意类型,HashValueType是一个宏定义,代表哈希数组值的类型。
  • 2)除留余数法对传进来的元素进行一次取模,并且采用 位与 代替,利用位运算加速;
  • 3)如果对应的 k e y key key 在这个位置没有出现过,则代表找到了一个合法位置,则 k e y key key 的槽位留给 v a l val val
  • 4)如果对应的 k e y key key 的槽位正好和 v a l val val 匹配,则说明哈希表已经存在过 v a l val val 这个元素,返回 key;
  • 5)没有找到合适的 k e y key key 位置, 进行二次寻址;
  • 那么,我们可以根据类似的方法实现一个只查找不插入的方法,实现如下:
bool hasKey(HashValueType val) {
    
    
    int key = ( val & (MAXH-1) );
    while (1) {
    
    
        if (!hashkey[key]) {
    
    
            return false;
        }
        else {
    
    
            if (hashval[key] == val) {
    
    
                return true;
            }
            key = (key + 1) & (MAXH - 1);
        }
    }
}

5、字符串哈希

  • 最后,我们来了解下对于字符串类型的关键字,如何计算哈希值,也就是如图二-5-1所示的这一步。

图二-5-1

1)B 进制

  • 对于一个字符串:“1314”,我们可以认为它是一个十进制数,那么转化成十进制整数就是:
    1 ∗ 1 0 3 + 3 ∗ 1 0 2 + 1 ∗ 1 0 1 + 4 ∗ 1 0 0 = 1314 1*10^3 + 3*10^2 + 1*10^1 + 4*10^0 = 1314 1103+3102+1101+4100=1314
    也可以认为它是个 8 进制数,那么转化成十进制就是:
    1 ∗ 8 3 + 3 ∗ 8 2 + 1 ∗ 8 1 + 4 ∗ 8 0 = 716 1*8^3 + 3*8^2 + 1*8^1 + 4*8^0 = 716 183+382+181+480=716
    同样,也可以认为它是个 16 进制数,那么转化成十进制就是:
    1 ∗ 1 6 3 + 3 ∗ 1 6 2 + 1 ∗ 1 6 1 + 4 ∗ 1 6 0 = 4884 1*16^3 + 3*16^2 + 1*16^1 + 4*16^0 = 4884 1163+3162+1161+4160=4884
  • 更加一般的,所有大于 4 的进制都是可以唯一表示这个字符串的;
  • 对于任意一个字符串,其实都是由 ASCII 字符组成,而每个字符都用 1 个字节表示,即它的范围是 [ 0 , 255 ] [0, 255] [0,255],所以我们可以用大于 255 的数来代替进制 B,即任意一个长度为 k k k 的字符串 s s s 可以表示为唯一的整数如下(其中 s [ i ] s[i] s[i] 代表第 i i i 个字符的 ASCII 码值, i i i 下标从 1 开始): h a s h ( s ) = s [ 1 ] ∗ B k − 1 + s [ 2 ] ∗ B k − 2 + . . . + s [ k ] ∗ B 0 hash(s) = s[1]*B^{k-1} + s[2]*B^{k-2} + ... + s[k]*B^{0} hash(s)=s[1]Bk1+s[2]Bk2+...+s[k]B0 ( B > = 256 ) (B >= 256) (B>=256)

2)取模

  • 随着字符串长度不断变大,算出来的哈希值会越来越大,从而产生溢出,所以一般采取模上一个较大的素数的形式,如下:
    h a s h ( s ) = ( s [ 1 ] ∗ B k − 1 + s [ 2 ] ∗ B k − 2 + . . . + s [ k ] ∗ B 0 ) m o d    P hash(s) = ( s[1]*B^{k-1} + s[2]*B^{k-2} + ... + s[k]*B^{0} ) \mod P hash(s)=(s[1]Bk1+s[2]Bk2+...+s[k]B0)modP
  • 这样做仍然能够保证相同的字符串计算得到的哈希值是一样的,但是却无法保证不相同的字符串计算的哈希值不同,所以为了尽量不让不同的字符串映射到相同的整数, P P P 的取值很关键,一般采取较大的素数的形式,进一步的, B B B 也选择一个和 P P P 互素的素数;

3)自然溢出

  • 根据补码的性质, C++ 中如果定义 unsigned long long,溢出的部分等同于对 P = 2 64 P = 2^{64} P=264 取模,这样就可以无视取模,任其自然溢出了。
  • 自然溢出有利有弊:好处就是效率会高出不少,而且能够表示的范围已经是长整型能够表示的最大范围,很大程度上减少哈希冲突;坏处就是取模效果没有素数来的好,对于一些特殊构造的数据,容易造成不相同的字符串计算出相同的哈希值的情况;

4)双哈希

  • 当有大量字符串时,这种冲突会被放大,我们可以通过取两对 ( B [ 0 ] , P [ 0 ] ) , ( B [ 1 ] , P [ 1 ] ) (B[0], P[0]), (B[1], P[1]) (B[0],P[0]),(B[1],P[1]) 的值,进行双哈希,然后取两次哈希的值组成一个新的哈希值,从而大大减少冲突的概率。
    h a s h ( 0 , s ) = ( s [ 1 ] ∗ B [ 0 ] k − 1 + s [ 2 ] ∗ B [ 0 ] k − 2 + . . . + s [ k ] ∗ B [ 0 ] 0 ) m o d    P [ 0 ] hash(0, s) = ( s[1]*B[0]^{k-1} + s[2]*B[0]^{k-2} + ... + s[k]*B[0]^{0} ) \mod P[0] hash(0,s)=(s[1]B[0]k1+s[2]B[0]k2+...+s[k]B[0]0)modP[0] h a s h ( 1 , s ) = ( s [ 1 ] ∗ B [ 1 ] k − 1 + s [ 2 ] ∗ B [ 1 ] k − 2 + . . . + s [ k ] ∗ B [ 1 ] 0 ) m o d    P [ 1 ] hash(1, s) = ( s[1]*B[1]^{k-1} + s[2]*B[1]^{k-2} + ... + s[k]*B[1]^{0} ) \mod P[1] hash(1,s)=(s[1]B[1]k1+s[2]B[1]k2+...+s[k]B[1]0)modP[1] h a s h ( s ) = h a s h ( 0 , s ) ∗ m a x ( P [ 0 ] , P [ 1 ] ) + h a s h ( 1 , s ) hash(s) = hash(0, s) * max(P[0], P[1])+ hash(1, s) hash(s)=hash(0,s)max(P[0],P[1])+hash(1,s)
  • 得到的哈希值再进行散列哈希映射到下标即可。

5)子串哈希值

  • 对于一个字符串 s s s s [ l : r ] s[l:r] s[l:r] 代表 s s s l l l r r r 的子串;
  • h a s h ( s [ 1 : 1 ] ) = ( s [ 1 ] ∗ B 0 ) m o d    P hash(s[1:1]) = ( s[1]*B^{0} ) \mod P hash(s[1:1])=(s[1]B0)modP
  • h a s h ( s [ 1 : 2 ] ) = ( s [ 1 ] ∗ B 1 + s [ 2 ] ∗ B 0 ) m o d    P hash(s[1:2]) = ( s[1]*B^{1} + s[2]*B^{0} ) \mod P hash(s[1:2])=(s[1]B1+s[2]B0)modP
  • h a s h ( s [ 1 : 3 ] ) = ( s [ 1 ] ∗ B 2 + s [ 2 ] ∗ B 1 + s [ 3 ] ∗ B 0 ) m o d    P hash(s[1:3]) = ( s[1]*B^{2} + s[2]*B^{1} + s[3]*B^{0}) \mod P hash(s[1:3])=(s[1]B2+s[2]B1+s[3]B0)modP
  • h a s h ( s [ 1 : 4 ] ) = ( s [ 1 ] ∗ B 3 + s [ 2 ] ∗ B 2 + s [ 3 ] ∗ B 1 + s [ 4 ] ∗ B 0 ) m o d    P hash(s[1:4]) = ( s[1]*B^{3} + s[2]*B^{2} + s[3]*B^{1} + s[4]*B^{0}) \mod P hash(s[1:4])=(s[1]B3+s[2]B2+s[3]B1+s[4]B0)modP
  • h a s h ( s [ 1 : 5 ] ) = ( s [ 1 ] ∗ B 4 + s [ 2 ] ∗ B 3 + s [ 3 ] ∗ B 2 + s [ 4 ] ∗ B 1 + s [ 5 ] ∗ B 0 ) m o d    P hash(s[1:5]) = ( s[1]*B^{4} + s[2]*B^{3} + s[3]*B^{2} + s[4]*B^{1} + s[5]*B^{0}) \mod P hash(s[1:5])=(s[1]B4+s[2]B3+s[3]B2+s[4]B1+s[5]B0)modP
  • 那么我们如何求 h a s h ( s [ 3 : 5 ] ) hash(s[3:5]) hash(s[3:5]) 呢?
  • 直接对字符串遍历,得到的结果为 h a s h ( s [ 3 : 5 ] ) = ( s [ 3 ] ∗ B 2 + s [ 4 ] ∗ B 1 + s [ 5 ] ∗ B 0 ) m o d    P hash(s[3:5]) = ( s[3]*B^{2} + s[4]*B^{1} + s[5]*B^{0}) \mod P hash(s[3:5])=(s[3]B2+s[4]B1+s[5]B0)modP,那么通过如下减法,得到:
    h a s h ( s [ 1 : 5 ] ) − h a s h ( s [ 3 : 5 ] ) = ( s [ 1 ] ∗ B 4 + s [ 2 ] ∗ B 3 ) m o d    P = B 3 ∗ ( s [ 1 ] ∗ B 1 + s [ 2 ] ∗ B 0 ) m o d    P = B 3 ∗ h a s h ( s [ 1 : 2 ] ) m o d    P \begin{aligned}hash(s[1:5]) - hash(s[3:5]) &= ( s[1]*B^{4} + s[2]*B^{3} ) \mod P \\ &= B^3 * ( s[1]*B^{1} + s[2]*B^{0} ) \mod P \\ &= B^3 * hash(s[1:2]) \mod P \end{aligned} hash(s[1:5])hash(s[3:5])=(s[1]B4+s[2]B3)modP=B3(s[1]B1+s[2]B0)modP=B3hash(s[1:2])modP
  • 移项后整理式子,得到:
    h a s h ( s [ 3 : 5 ] ) = ( h a s h ( s [ 1 : 5 ] ) − B 3 ∗ h a s h ( s [ 1 : 2 ] ) ) m o d    P hash(s[3:5]) = ( hash(s[1:5]) - B^3 * hash(s[1:2]) ) \mod P hash(s[3:5])=(hash(s[1:5])B3hash(s[1:2]))modP
  • 那么对于更加一般的情况,令 h ( r ) = h a s h ( s [ 1 : r ] ) h(r) = hash(s[1:r]) h(r)=hash(s[1:r]),有:
    h a s h ( s [ l : r ] ) = ( h ( r ) − B r − l + 1 ∗ h ( l − 1 ) ) m o d    P hash(s[l:r]) = ( h(r) - B^{r-l+1} * h(l-1) ) \mod P hash(s[l:r])=(h(r)Brl+1h(l1))modP
  • 其中 h ( i ) h(i) h(i) B i B^i Bi 都可以事先一次线性扫描预处理后放在数组中,则每次取子串哈希值的时间复杂度为 O ( 1 ) O(1) O(1)

三、哈希表的应用

1、代替排序

【例题1】给定 n ( n < 1 0 6 ) n(n <10^6) n(n<106) [ − 1 0 6 , 1 0 6 ] [-10^6,10^6] [106,106] 范围内的整数 ,请按从大到小的顺序输出其中前 m m m 大的数。

  • 这是一个经典排序问题。时间复杂度 O ( n l o g n ) O(nlogn) O(nlogn),基本也能接受。
  • 但是,还有一种更加简单的办法,就是开一个 2 ∗ 1 0 6 2*10^6 2106 的哈希数组,然后将所有输入的数字加上一个偏移量 1 0 6 10^6 106 后,哈希到数组中进行标记,最后来一次全范围的扫描输出即可。

2、多元方程的整数解数

【例题2】给定一个方程 x ∗ a + y ∗ b + z ∗ c = d x*a + y*b + z*c = d xa+yb+zc=d,其中 a , b , c , d a,b,c,d a,b,c,d 已知, ( 0 < = x , y , z < = 1000 ) (0 <= x,y,z<=1000) (0<=x,y,z<=1000),求满足条件的 x , y , z x,y,z x,y,z 的解数。

  • 这是一个可以利用哈希表来求解的经典问题,朴素的做法就是三层循环枚举所有满足条件的 x , y , z x,y,z xyz,然后判断计算结果是否为 d d d,这样的时间复杂度为 O ( n 3 ) O(n^3) O(n3),肯定是无法接受的。
  • 可以将等式进行移项,变成如下形式:
    x ∗ a + y ∗ b = d − z ∗ c x*a + y*b = d - z*c xa+yb=dzc
  • 我们可以通过枚举 z z z,将所有计算得到的 d − z ∗ c d - z*c dzc 的值映射到哈希表中,记录下每个结果出现的次数,然后两层循环枚举 ( x , y ) (x, y) (x,y) ,看枚举计算得到的值 x ∗ a + y ∗ b x*a + y*b xa+yb 在哈希表出现的次数,累加所有的这些和就是最后的答案了,整个算法的时间复杂度即枚举的时间复杂度,为 O ( n 2 ) O(n^2) O(n2)
  • 再来看一个简单的变种。

【例题3】给定 5 个 n ( n < = 200 ) n(n<=200) n(n<=200) 个整数的集合 a [ i ] [ j ] ( 0 < = i < 5 , 0 < = j < n ) a[i][j](0<=i<5, 0<=j<n) a[i][j](0<=i<5,0<=j<n) ,问是否存在一个下标五元组 ( i , j , k , l , m ) (i,j,k,l,m) (i,j,k,l,m),满足如下等式: a [ 0 ] [ i ] + a [ 1 ] [ j ] + a [ 2 ] [ k ] + a [ 3 ] [ l ] + a [ 4 ] [ m ] = 0 a[0][i] + a[1][j] + a[2][k] + a[3][l] + a[4][m] = 0 a[0][i]+a[1][j]+a[2][k]+a[3][l]+a[4][m]=0

  • 朴素的做法就是枚举这个五元组 ( i , j , k , l , m ) (i,j,k,l,m) (i,j,k,l,m),对数组中的五个数加和后进行判零,但是这样做的时间复杂度为 O ( n 5 ) O(n^5) O(n5)

  • 考虑将等式做一个变换如下:
    a [ 0 ] [ i ] + a [ 1 ] [ j ] = − ( a [ 2 ] [ k ] + a [ 3 ] [ l ] + a [ 4 ] [ m ] ) a[0][i] + a[1][j] = - ( a[2][k] + a[3][l] + a[4][m] ) a[0][i]+a[1][j]=(a[2][k]+a[3][l]+a[4][m])

  • 那么我们如果把前两个数组的数字加和都枚举出来,然后加到哈希表中,然后就可以通过枚举后面三个数组的加和,取相反数以后去哈希表里面找,如果找到一个就算满足条件了,时间复杂度为 O ( n 3 ) O(n^3) O(n3)

  • 再来看一个更加复杂点的情况,原理还是一样,都是运用了哈希表的特性。

3、状态哈希

  • 状态哈希 在 动态规划 和 广度优先搜索 中有着广泛的应用,不理解也没有关系,来日方长,这一章先简要介绍一下,毕竟我当年理解状态的概念,也花了很久的时间。

1)动态规划的状态哈希

  • 之前在讲动态规划的时候,强调了状态的概念,那么这一章我们再强化一下。

【例题5】一个 n × m ( n ∗ m < = 1 0 6 ) n \times m(n*m<=10^6) n×m(nm<=106) 的棋盘,作者从左上角出发,只能往右或者往下,每个格子颜色不同,不同颜色对应不同分数,求到达右下角的最大分数。

图三-3-1

  • 这个问题是最简单的 二维DP 了,基本上一眼就能看出状态转移方程: d p [ i ] [ j ] = v a l ( i , j ) + m a x ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) dp[i][j] = val(i,j) + max(dp[i-1][j], dp[i][j-1]) dp[i][j]=val(i,j)+max(dp[i1][j],dp[i][j1])
  • d p [ i ] [ j ] dp[i][j] dp[i][j] 代表从左上角 ( 1 , 1 ) (1,1) (1,1) 走到 ( i , j ) (i, j) (i,j) 的最大分数, ( i , j ) (i, j) (i,j) 代表位置也代表状态。
  • 但是,由于 n n n m m m 的最大乘积为 1 0 6 10^6 106,所以极端情况下: n = 1 0 6 , m = 1 n=10^6,m=1 n=106m=1 或者 n = 1 , m = 1 0 6 n=1,m=10^6 n=1m=106 的情况都是有可能出现的,这样就不能用二维数组了,那么我们能不能拿 i i i j j j 的乘积作为状态表示呢?
  • 答案是不能!
  • 因为 ( i , j ) (i, j) (i,j) ( j , i ) (j, i) (j,i) 不是同一个状态。
  • 于是,我们结合今天学习的知识,联想到了可以将 i i i j j j 作为一个二元组,映射到一个长整型,即:
    ( i , j ) → ( i ∗ 1 0 9 + j ) (i, j) \to (i * 10^9 + j) (i,j)(i109+j)
  • 然后就可以用散列哈希进行状态存储了。

2)广度优先搜索的状态哈希

  • 广度优先搜索往往用来求解一些最短路问题,比如 迷宫问题、数码问题、推箱子游戏 等等,要求用最少的步数,到达某个位置或者完成某个目标,这里举一个最简单的例子。

【例题6】一个 n × m ( n , m < = 100 ) n \times m(n,m <=100) n×m(n,m<=100) 的迷宫,绿色的格子代表可以走,红色的格子是岩浆不能走,地图上有两个人他们以相同的方式往四个方向 上、下、左、右 走,每走一格需要一刻时间,问两人相遇的最短时间为多少。

图三-3-2

  • 这是个经典的广度优先搜索问题,如果用深度优先搜索来做,状态空间太大,时间复杂度是指数级的。而广搜的时间复杂度为 O ( n m ) O(nm) O(nm),之所以能够把时间复杂度控制在多项式级别,是因为走过的位置会被标记掉。
  • 这个问题只要考虑一个人从起点走到终点就行,对最后的时间进行除二处理,考虑下奇偶性。
  • 每个位置 ( i , j ) (i,j) (i,j) 就代表了状态,用 t i m e [ i ] [ j ] time[i][j] time[i][j] 代表从 ( 1 , 1 ) (1,1) (1,1) ( i , j ) (i,j) (i,j) 花费的最短时间,初始化为最大值,利用队列扩展状态,每走到一个点判断当前的时间是否比 t i m e [ i ] [ j ] time[i][j] time[i][j] 小,如果大于等于的话就没必要入队了,直到队列为空或者到达目的地则搜索完毕。
  • 这个问题就告一段落了。
  • 但是,并不是所有问题中,位置代表状态,来看一个经典的游戏 —— 推箱子。
    图三-3-3
  • 这个问题中,推箱子的人两次访问同一个位置时,整个地图的状态是不一样的,因为箱子的位置变了。
  • 没错!在这个问题中,状态要用 人 和 所有箱子 的位置(6个坐标)来表示。限于篇幅,就不再展开了。这个问题会在讲解广度优先搜索的时候再进行详细讲解。

4、最长回文串

【例题7】给定一个字符串,最多 1 0 6 10^6 106 个字符,求最长回文子串的长度。例如字符串 “abacdcbaaaab”,最长回文子串的长度为 “baaaab”,所以答案为 6 。

  • 思路就是枚举一个中心,然后二分长度,对于二分到的长度用字符串哈希在 O ( 1 ) O(1) O(1) 的时间判断两边的字符串是否相等。由于字符串哈希是单向的,而回文串的方向是往相反方向扩散,所以需要将字符串预处理哈希后,逆序再预处理一次哈希。
  • 对于字符串从下标 1 开始,罗列如下:
1 2 3 4 5 6 7 8 9 10 11 12
a b a c d c b a a a a b
  • 将字符串进行逆序后得到:
1 2 3 4 5 6 7 8 9 10 11 12
b a a a a b c d c a b a
  • 回文子串的长度有可能是奇数,也有可能是偶数,所以需要分情况讨论:
  • 对于奇数的情况,如果枚举的中心下标为 i i i,则能够扩散的长度为 l ∈ [ 1 , m i n ( l e n − i + 1 , i ) ] l \in [1, min(len - i +1, i)] l[1,min(leni+1,i)] ,二分这个长度,然后判断原字符串的子串 [ i − l + 1 , i ] [i-l+1, i] [il+1,i] 和 逆序字符串的子串 [ l e n − i − l + 2 , l e n − i + 1 ] [len-i-l+2, len-i+1] [lenil+2,leni+1] 是否相等,相等则扩大二分区间;否则,减少;

图三-4-1

  • 对于偶数的情况,如果枚举的中心下标为 i i i,则能够扩散的长度为 l ∈ [ 0 , m i n ( l e n − i , i ) ] l \in [0, min(len - i, i)] l[0,min(leni,i)] ,二分这个长度,然后判断原字符串的子串 [ i − l + 1 , i ] [i-l+1, i] [il+1,i] 和 逆序字符串的子串 [ l e n − i − l + 1 , l e n − i ] [len-i-l+1, len-i] [lenil+1,leni] 是否相等,相等则扩大二分区间;否则,减少;
    图三-4-2

5、最长公共子串

【例题8】给定两个长度不超过 400000 的字符串,求两个串的最长公共子串的长度。

  • 二分一个长度 L,在第一个串上作给定长度 L 的所有子串的字符串哈希,并且散列到哈希数组中,然后在第二个子串上进行枚举长度为 L 的子串,看哈希数组中是否存在,一旦存在,说明最长公共子串的长度至少为 L,二分的答案扩大;否则,答案缩小;



四、哈希题集整理

题目链接 难度 解法
HDU 1264 Counting Squares ★☆☆☆☆ 简单下标哈希
HDU 1425 sort ★☆☆☆☆ 简单下标哈希
HDU 2523 SORT AGAIN ★☆☆☆☆ 简单下标哈希
HDU 2217 Visit ★☆☆☆☆ 简单下标哈希
HDU 2220 Encode the tree ★☆☆☆☆ 简单下标哈希
HDU 2240 考研路茫茫——人到大四 ★☆☆☆☆ 简单下标哈希
HDU 2265 Encoding The Diary ★☆☆☆☆ 简单下标哈希
HDU 2270 How Many Friends Will Be Together With You ★☆☆☆☆ 简单下标哈希
HDU 2341 Tower Parking ★☆☆☆☆ 简单下标哈希
HDU 2369 Broken Keyboard ★☆☆☆☆ 简单下标哈希
HDU 2946 Letter Cookies ★☆☆☆☆ 简单下标哈希
HDU 3107 A Walk in the Park ★★☆☆☆ 简单坐标哈希
HDU 1496 Equations ★★☆☆☆ 等式的整数散列哈希
PKU 1186 方程的解数 ★★☆☆☆ 等式的整数散列哈希
HDU 1880 魔咒词典 ★★☆☆☆ 字符串哈希
HDU 2428 Stars ★★☆☆☆ 简单下标哈希
HDU 4334 Trouble ★★☆☆☆ 等式的整数散列哈希
HDU 5269 ZYB loves Xor I ★★☆☆☆ 位运算 + 统计散列哈希
P3370 【模板】字符串哈希 ★★☆☆☆ 字符串哈希模板
PKU 3349 Snowflake Snow Snowflakes ★★☆☆☆ 字符串哈希模板
HDU 3763 CD ★★☆☆☆ 散列哈希模板题
PKU 3974 Palindrome ★★★☆☆ 二分答案 + 字符串哈希
HDU 4080 Stammering Aliens ★★★☆☆ 二分答案 + 字符串哈希
PKU 2758 Checking the Text ★★★☆☆ 二分答案 + 字符串哈希
PKU 2774 Long Long Message ★★★☆☆ 二分答案 + 字符串哈希
HDU 4961 Boring Sum ★★★☆☆ 枚举因子 + 哈希
HDU 5701 中位数计数 ★★★☆☆ 离散化 + 哈希
HDU 5416 CRB and Tree ★★★☆☆ 位运算 + 哈希
HDU 5908 Abelian Period ★★★☆☆ 枚举 + 哈希
HDU 2969 Skyscrapers ★★★☆☆ 贪心 + 哈希
HDU 6768 The Oculus ★★★☆☆ 字符串哈希 + 枚举可行位
HDU 5183 Negative and Positive (NP) ★★★★☆ 较为复杂的等式整数哈希
HDU 5469 Antonidas ★★★★☆ 树的分治 + 字符串哈希
HDU 4622 Reincarnation ★★★★★ 字符串哈希 或 后缀自动机
HDU 6646 A + B = C ★★★★★ 字符串哈希
PKU 3274 Gold Balanced Lineup ★★★★★ 散列哈希

猜你喜欢

转载自blog.csdn.net/WhereIsHeroFrom/article/details/112756337