文章目录
- 一、前言
- 二、哈希表
-
- 1、哈希表概念
-
- 2、简单下标哈希
- 3、散列哈希
-
- 1)哈希值离散
- 2)除留余数法
- 3)哈希冲突
- 4)负载因子
- 5)rehash
- 6)取模位运算优化
- 4、散列哈希的实现
- 5、字符串哈希
-
- 1)B 进制
- 2)取模
- 3)自然溢出
- 4)双哈希
- 5)子串哈希值
- 三、哈希表的应用
-
- 1、代替排序
- 2、多元方程的整数解数
- 3、状态哈希
-
- 1)动态规划的状态哈希
- 2)广度优先搜索的状态哈希
- 4、最长回文串
- 5、最长公共子串
- 四、哈希题集整理
一、前言
谈起哈希表,学过数据结构的同学,应该都已经耳熟能详了,因为太基础所以一直没有单独拿出来讲,然而 广度优先搜索 和 动态规划 里面都涉及到了状态哈希,所以还是有必要拿出来讲一下的。所谓的 状态 到底是一个什么概念,为什么要对状态进行 哈希。希望读者看完本章内容,能够有一个大概的概念,能对后续要讲到的 广度优先搜索 以及 状态压缩动态规划 起到一定的铺垫作用。
虽然,我曾经一度认为自己对哈希表的理解已经很透彻了,但是今天我在总结这篇文章的时候,突然领悟了几个之前比较模糊的概念,而且就在那一瞬间,犹如醍醐灌顶,茅塞顿开,这种感觉实在是太棒了!
二、哈希表
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 一一对应,所以在没有取模之前,至少是没有冲突的,这样就从本原上减少了冲突。
-
雪崩效应是为了让哈希值更加符合随机分布的原则,哈希表中的键分布的越随机,利用率越高,效率也越高。

图二-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) (1、3、4、6) 相对于 ( 1 、 2 、 3 、 4 ) (1、2、3、4) (1、2、3、4) 就是离散的。如图二-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 S−1=(k 111...111)2 ,所以和 位与 S − 1 S-1 S−1 的效果是一样的。
x % S = = x & ( S − 1 ) ; x \% S == x \& (S - 1); x%S==x&(S−1);
4、散列哈希的实现
- 这里介绍一种简单的哈希再散列的实现,为了尽量简化代码,假设了几个问题:
- 1)不涉及 rehash:因为哈希数组长度足够大,元素个数可控;
- 2)不考虑负载因子:因为不进行 rehash ,自然也不用考虑负载因子了;
- 3)采用开放寻址法:不用链地址法,避免申请堆内存的开销;
- 先给出代码,再进行讲解:
#define HashValueType long long
const int MAXH = (1 << 20);
bool hashkey[MAXH];
HashValueType hashval[MAXH];
int getKey(HashValueType val) {
int key = (val & (MAXH-1) );
while (1) {
if (!hashkey[key]) {
hashkey[key] = true;
hashval[key] = val;
return key;
}
else {
if (hashval[key] == val) {
return key;
}
key = (key + 1) & (MAXH - 1);
}
}
}
- 这个函数实现的是:通过给定的哈希值 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 1∗103+3∗102+1∗101+4∗100=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 1∗83+3∗82+1∗81+4∗80=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 1∗163+3∗162+1∗161+4∗160=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]∗Bk−1+s[2]∗Bk−2+...+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]∗Bk−1+s[2]∗Bk−2+...+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]k−1+s[2]∗B[0]k−2+...+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]k−1+s[2]∗B[1]k−2+...+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=B3∗hash(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])−B3∗hash(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)−Br−l+1∗h(l−1))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 2∗106 的哈希数组,然后将所有输入的数字加上一个偏移量 1 0 6 10^6 106 后,哈希到数组中进行标记,最后来一次全范围的扫描输出即可。
2、多元方程的整数解数
【例题2】给定一个方程 x ∗ a + y ∗ b + z ∗ c = d x*a + y*b + z*c = d x∗a+y∗b+z∗c=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 x,y,z,然后判断计算结果是否为 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 x∗a+y∗b=d−z∗c
- 我们可以通过枚举 z z z,将所有计算得到的 d − z ∗ c d - z*c d−z∗c 的值映射到哈希表中,记录下每个结果出现的次数,然后两层循环枚举 ( x , y ) (x, y) (x,y) ,看枚举计算得到的值 x ∗ a + y ∗ b x*a + y*b x∗a+y∗b 在哈希表出现的次数,累加所有的这些和就是最后的答案了,整个算法的时间复杂度即枚举的时间复杂度,为 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<=ja[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(n∗m<=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[i−1][j],dp[i][j−1])
- 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=106,m=1 或者 n = 1 , m = 1 0 6 n=1,m=10^6 n=1,m=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)→(i∗109+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(len−i+1,i)] ,二分这个长度,然后判断原字符串的子串 [ i − l + 1 , i ] [i-l+1, i] [i−l+1,i] 和 逆序字符串的子串 [ l e n − i − l + 2 , l e n − i + 1 ] [len-i-l+2, len-i+1] [len−i−l+2,len−i+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(len−i,i)] ,二分这个长度,然后判断原字符串的子串 [ i − l + 1 , i ] [i-l+1, i] [i−l+1,i] 和 逆序字符串的子串 [ l e n − i − l + 1 , l e n − i ] [len-i-l+1, len-i] [len−i−l+1,len−i] 是否相等,相等则扩大二分区间;否则,减少;
图三-4-2
5、最长公共子串
【例题8】给定两个长度不超过 400000 的字符串,求两个串的最长公共子串的长度。
- 二分一个长度 L,在第一个串上作给定长度 L 的所有子串的字符串哈希,并且散列到哈希数组中,然后在第二个子串上进行枚举长度为 L 的子串,看哈希数组中是否存在,一旦存在,说明最长公共子串的长度至少为 L,二分的答案扩大;否则,答案缩小;
- 本文所有示例代码均可在以下 github 上找到:github.com/WhereIsHeroFrom/模板/HASH

四、哈希题集整理
题目链接 |
难度 |
解法 |
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 4908 BestCoder Sequence |
★★☆☆☆ |
统计类下标哈希 |
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 |
★★★★★ |
散列哈希 |