实现语言:C++

1. 散列表

散列表,英文名称为Hash Table,又称哈希表、杂凑表等。

线性表和树表的查找是通过比较关键字的方法,查找的效率取决于关键字的比较次数。

而散列表是根据关键字直接访问的数据结构。散列表通过散列函数将关键字映射到存储地址,建立了关键字和存储地址之间的一种直接映射关系。

例如:关键字集key = (17, 24, 48, 25),散列函数H(key) = key % 5,散列函数将关键字映射到存储地址下标,将关键字存储到散列表的对应位置。

理想情况下,散列表查找的时间复杂度是O(1)。但是,散列函数可能会把两个或两个以上的关键字映射到同一地址,发生“冲突发生冲突的不同关键字称为“同义词”,也就是具有相同函数值的关键字。

接上例,如果13也要存入散列表,就会和48产生冲突:

所以,使用散列表需要解决好两个问题:构造合适的散列函数,以及制定一个好的解决冲突的方案。

2. 散列函数的构造方法

在构造散列函数时需要考虑诸多因素:执行速度、关键字长度、散列表大小、关键字的分布情况、查找频率等。

根据元素集合的特性构造,我们对散列函数有以下要求:

  1. n的数据源仅占n个地址,虽然散列查找是以空间换时间,但仍希望散列的地址空间尽量小
  2. 无论用什么方法存储,目的都是尽量均匀地存放元素,以避免冲突。

常见的构造方法有:直接定址法、除留余数法、数字分析法、平方取中法、折叠法、随机数法。

2.1 直接定址法

以关键码key的某个线性函数值作为散列地址

  Hash(key) = a * key + b (a,b为常数)

优点是线性关系,不会产生冲突。但是要占用连续的地址空间,空间效率低下。

适合查找表较小且连续的情况。

例如:使用直接定址法存储序列 {100, 300, 500, 700, 800, 900},选择散列函数 Hash(key) = key / 100 (a=1/100, b=0)

2.2 除留余数法

此方法是最常用的散列函数构造方法

  Hash(key) = key mod p (p是一个整数)

关键:如何选取合适的p?

技巧:设表长为m,取p≤m,且p为质数

例:{15, 23, 27, 38, 53, 61, 70},散列函数 Hash(key) = key mod 7

2.3 数字分析法

如果关键字是位数较多的数字(比如手机号),且这些数字部分存在相同规律,则可以采用抽取剩余不同规律部分作为散列地址。

比如手机号前三位是接入号,中间四位是 HLR 识别号,只有后四位才是真正的用户号。也就是说,如果手机号作为关键字,那么极有可能前 7 位是相同的,此时我们选择后四位作为散列地址就是不错的选择。同时,对于抽取出来的数字,还可以再进行反转、右环位移、左环位移等操作,目的就是为了提供一个能够尽量合理地将关键字分配到散列表的各个位置的散列函数。

数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。

2.4 平方取中法

以关键字平方的中间位数作为散列地址。

比如假设关键字是 4321,那么它的平方就是 18671041,抽取中间的 3 位就可以是 671,也可以是 710,用做散列地址。

适合于不知道关键字的分布,而位数又不是很大的情况。

2.5 折叠法

折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。

比如假设关键字是 9876543210,散列表表长为三位。

有时可能这还不能够保证分布均匀,那么也可以尝试从一端到另一端来回折叠后对齐相加,比如将 987 和 321 反转,再与 654 和 0 相加,变成 789+654+123+0=1566,此时散列地址为 566。

折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。

2.6 随机数法

选择一个随机数,取关键字的随机函数值作为它的散列地址:

  hash(key) = random(key)

当关键字的长度不等时采用这个方法构造散列函数是比较适合的。

3. 处理哈希冲突的方法3.1 开放定址法(开地址法)

基本思想:有冲突时就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将数据元素存入。

常用的开放定址法有线性探测法、二次探测法、伪随机探测法等。

3.1.1 线性探测法

  Hi = (Hash(key) + di) mod m,(1 ≤ i ≤ m)

其中,m为散列表长度,di = i(i为1,2,…,m-1 线性序列)

例:关键码集为{47, 7, 29, 11, 16, 92, 22, 8, 3},散列表长度为m=11,散列函数为Hash(key)=key mod 11,使用线性探测法处理冲突,存入过程如下:

3.1.2 二次探测法

  增量序列di为12,-12,22,-22,…,q2 二次序列

同样是上边的例子,使用二次探测法处理冲突,存入过程如下:

注意:二次探测法是跳跃式探测,效率较高,但是会出现命名有有空间却探测不到的情况,因而存储失败,而线性探测只要有空间就一定能探测到。

3.1.3 伪随机探测法

  增量序列di为伪随机数

其存入原理和上边两种方法一致,这里不多做介绍。

3.2 链地址法(拉链法)

基本思想:将相同散列地址的记录(即同义词)链成一单链表。

m个散列地址就是m个单链表,然后用一个数组将m个单链表的表头指针存储起来,形成一个动态的结构。

例如:关键字为{19, 14, 23, 1, 68, 20, 84, 27, 55, 11, 10, 79},散列函数为Hash(key) = key mod 13,使用链地址法存储如下所示:

链地址法建立散列表的步骤:

  1. 取数组元素的关键字key,计算其散列函数值(地址)。若该地址对应的链表为空,则将该元素插入此链表;否则执行下一步解决冲突;
  2. 根据选择的冲突处理方式,计算关键字key的下一个存储地址。若改地址对应的链表不为空,则利用链表的前插法或后插法将该元素插入此链表。

优点:

  1. 非同义词(同义词是指具有相同函数值的关键字)不会冲突,无“聚集”现象;
  2. 链表上结点空间动态申请,更适合于表长不确定的情况

3.3再散列法

就是同时构造多个不同的哈希函数:

  Hi= Hashi(key) i= 1,2,3 … k;

当H1 = Hash1(key) 发生冲突时,再用H2=Hash2(key)进行计算,直到冲突不再产生,这种方法不易产生聚集,但是增加了计算时间。

3.4 建立一个公共溢出区

将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一存放到溢出区。

4. 散列表的查找

给定查找值k,查找过程如图所示:

散列表的查找效率分析

一般我们使用平均查找长度ASL来衡量查找效率,散列表ASL的值取决于:散列函数、处理冲突的方法、散列表的装填因子α(α = 表中填入的记录数 / 哈希表的长度,α越大,表中记录数越多,发生冲突的可能性就越大,查找对比次数就越多)。

  线性探测法:ASL ≈ 1 / 2 * (1 + 1 / (1 – α))

  拉链法:ASL ≈ 1 + α / 2

  随机探测法:ASL ≈ -1 / α * ln(1 – α)

例:对于关键字集{19, 14, 23, 1, 68, 20, 84, 27, 55, 11, 10, 79},n=12,散列函数为:H(key) = key mod 13,散列表表长为m = 16,设每个记录的查找概率相等。则使用不同查找算法的平均查找效率如下:

  线性探测法:ASL ≈1 / 2 * (1 + 1 / (1 – 0.75)) = 2.5 (装填因子α = n / m = 0.75)

  拉链法:ASL ≈ 1 + 0.75 / 2 = 1.375

  随机探测法:ASL ≈ -1 / α * ln(1 – α) = 1.85

对比无序表查找和有序表折半查找:

  无序表:ASL = (n +1) / 2 = 6.5

  有序表折半查找:ASL = lg2(n + 1) – 1 = 2.7

总结以下几点:

  1. 散列表技术具有很好的平均性能;
  2. 链地址法优于开地址法;
  3. 除留取余法做散列函数优于其它类型函数。

C++代码

#include #include using namespace std;#define m 15// 哈希表的表长#define NULLKEY 0// 单元为空的标记int HT[m], HC[m];// 哈希函数int H(int key) {return key % 13;}// 线性探测int LineDetect(int HT[], int H0, int key, int& cnt) {int Hi;for (int i = 0; i < m; i++) {cnt++;Hi = (H0 + i) % m;// 按照线性探测法计算下一个哈希地址Hiif (HT[Hi] == NULLKEY || HT[Hi] == key)return Hi;// 若单元Hi为空,则所查元素不存在}return -1;}// 二次探测int SecondDetect(int HT[], int H0, int key, int& cnt) {int Hi;for (int i = 1; i <= m / 2; ++i) {int i1 = 1 * i;int i2 = -i1;cnt++;Hi = (H0 + i1) % m;// 按照二次探测法去计算下一个哈希地址if (HT[Hi] == NULLKEY || HT[Hi] == key)// 若单元Hi为空或者查找成功return Hi;cnt++;Hi = (H0 + i2) % m;// 按照二次探测法去计算下一个哈希地址if (Hi < 0)Hi += m;if (HT[Hi] == NULLKEY || HT[Hi] == key)// 若单元Hi为空或者查找成功return Hi;}return -1;}// 哈希表中查找关键字keyvoid SearchHash(int HT[], int key) {int H0 = H(key);// 计算哈希地址int Hi, cnt = 1;if (HT[H0] == NULLKEY)cout << "查找失败" << endl;else if (HT[H0] == key)cout << "查找成功。" << "在第" << H0 + 1 << "位置。" << "比较次数:" << cnt << endl;else {Hi = LineDetect(HT, H0, key, cnt);// Hi = SecondDetect(HT, H0, key, cnt);if (HT[Hi] == key)cout << "查找成功。" << "在第" << H0 + 1 << "位置。" << "比较次数:" << cnt << endl;elsecout << "查找失败。比较次数:" << cnt << endl;}}// 插入元素bool InsertHash(int HT[], int key) {int H0 = H(key);// 根据哈希函数H(key)计算哈希地址int Hi, cnt = 1;if (HT[H0] == NULLKEY) {HC[H0] = 1;// 统计比较次数HT[H0] = key;// 放入H0中return 0;}else {Hi = LineDetect(HT, H0, key, cnt);// Hi = SecondDetect(HT, H0, key, cnt);if (Hi != -1 && HT[Hi] == NULLKEY) {HC[Hi] = cnt;// 统计比较次数HT[Hi] = key;// 放入H0中return 1;}}return 0;}void print(int HT[]) {for (int i = 0; i < m; i++)cout << HT[i] << "\t";cout << endl;}int main() {int x;memset(HT, 0, sizeof(HT));memset(HC, 0, sizeof(HC));print(HT);cout << "输出12个关键字,存入哈希表中:" << endl;for (int i = 0; i > x;if (!InsertHash(HT, x)) {cout << "创建哈希表失败!" << endl;return 0;}}cout << "输出哈希表:" << endl;print(HT);print(HC);cout << "输入要查找的关键字" <> x;SearchHash(HT, x);return 0;}// 测试数据1:14 36 42 38 40 15 19 12 51 68 34 25// 测试数据2:14 36 42 38 40 15 19 12 51 68 34 18

参考资料

1. 数据结构与算法基础(青岛大学王卓)

2.数据结构—— 构造散列函数的六种方法【直接定址法-数字分析法-平方取中法-折叠法-除留余数法-随机数法】

3.哈希冲突常用解决方法

4. 书籍:算法训练营