数据结构:HASH (散列表)查找

禁止码迷,布布扣,豌豆代理,码农教程,爱码网等第三方爬虫网站爬取!

“主角座位”

在很多动漫作品中,我们发现可以看到主角的座位在倒数第二或最后排靠窗。“主角座位”的动漫梗就因此而来,当你看动漫的时候会去留意主角坐在班级的那个角落吗(笑)?其实在生活中也是如此,当班级调完座位之后,当我们想要找一位同学聊天,就会很自然地去这位同学的座位旁边等候。或许久而久之,座位也会成为某位同学在班级里的标志吧。
现在我们来思考,通过座位找到一位同学的思想,能不能为查找提供一些想法。如果有一种查找方法,我们能一下子就知道其存储位置,并能进行快速地访问,那么时间复杂度就是 O(1),这样查找的效率就太高了。

哈希表

散列技术

上文所提及的就是今天要谈的散列技术,所谓散列技术就是数据保存的存储位置和关键字之间存在一个映射关系,使得关键字可以和存储位置直接对应起来。对于这种映射关系,我们称之为散列函数,形如:

存储位置 = F(关键字)

通过散列技术将数据存储起来的所建成的存储结构称之为哈希表(Hash table)。

相关术语

散列函数

在记录的存储位置 p 和关键字 key 之间确定一个对应关系 H,使得 p = H(key),则称关系 H 为散列函数,p 为散列地址。

散列表

散列表是一个有限连续的地址空间,用以存储按散列函数计算得到相应散列地址的数据记录。通常散列表的存储空间是一个一维数组,散列地址是数组的下标。

冲突

对不同的关键字通过散列函数得到的散列地址可能死相同的,即 key1 != key2,而 H(key1) = H(key2)。这种现象称为冲突,具有相同函数值的关键字对该散列函数来说称作同义词.

散列函数构造法

构造原则

对于散列函数的构造,往往需要具体问题具体分析,一下是一些考虑因素:

  1. 计算散列地址所需的时间;
  2. 关键字的长度;
  3. 散列表的大小;
  4. 关键字的分布情况;
  5. 记录查找的频率。

由于冲突问题的存在,目前没有也很难有一种构造法可以完全消除冲突,因此我们要尽可能得到一个好的散列函数。构造散列函数可以遵循以下原则:

  1. 计算方法简单:得到存储地址的计算方法应该越简单越好,因为当每次查找时,都要使用散列函数进行存储位置的计算。如果每次查找进行的计算都较为复杂的话,会使得查找的时间开销变得更大,从而降低了效率;
  2. 地址均匀分布:当得到的地址可以尽可能均匀分布在存储结构中时,可以尽量地减少冲突问题,并且可以让有限的存储空间得到充分的应用,减少冲突处理的开销。

除留余数法

所谓除留余数法就是通过余数来决定存储的地址,是一种简单且使用范围广泛的方法。具体操作手法是,假设散列表表长为 m,选择一个不大于 m 的数 p,将关键字对 p 取模所得的余数作为散列地址。所得的散列函数为:

F(key) = key mod p (p ≤ m)

为了满足“地址均匀分布”这个原则,选取合适的 p 非常重要。根据经验,在一般情况下 p 选择小于表长的最大质数,例如当 m = 10 时选择 7 作为 p。在取余数之前也可以进行折叠、平方取中等操作。
举个例子,将关键字序列 {7,8,10,11,5,9,14} 散列存储到散列表中,散列表的存储空间是一个下标从 0 开始的一维数组,散列函数为:H(key) = (key × 3) mod 7,通过这种方式构造的哈希表为:

直接定址法

直接定址法的操作最为简单,也就是直接使用某个数据项来作为地址。例如学生信息表,每一位同学在班级都有座位号,可以直接使用座位号来作为地址,则散列函数为:

F(key) = key - 1

此时也可以用学号来映射,散列函数为:

F(key) = key ÷ 3 - 1


虽然这种手法简单而且没有冲突,但是限制也很明显,因为我们往往不知道数据的存储规模和内在联系。

数字分析法

所谓数字分析法,就是使用一组数据中的某种特征来进行地址的确定。在实现知道关键字的情况下,且关键字的位数比散列表的地址码位数多,每个关键字都是由相同的位数组成,则可以提取数字分布均匀的若干位作为散列地址。
例如如果是同一个子网下的主机,它们的前缀是相同的,这个时候就可以使用所谓“主机号”来作为地址的确定。

平方取中法

不过我们一般对关键字的具体情况并不能很好地确定,因此选定散列函数时取其中几位都不一定合适,而一个数平方后的中间几位数和数的每一位都相关,如果取关键字平方后的中间几位或其组合作为散列地址,则使随机分布的关键字得到的散列地址也是随机的,具体所取的位数由表长决定。
举个例子,例如对于关键字 “1111”,那么其平方为 “1234321”,取中间 3 位作为散列地址就是 “343”。由于对于一个数平方后提取中间的某几位数字,2 个步骤都增加了随机性,因此得到的地址也会分散开来,关键字位数较少时合适。

折叠法

有了平方取中法,我们知道了当一个数字通过某种手段增加随机性之后,就可以作为地址生成的一种依据。折叠法就是将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为散列地址。根据数位叠加的方式,可以分为两种——移位叠加是将分割后每一部分的最低位对齐,然后相加;边界叠加是将两个相邻的部分沿边界来回折叠,然后对齐相加。
例如关键字 “12345678”,我们把它分成 4 组——“12|34|56|78”,然后求和 “12 + 34 + 56 + 78 = 180”,则 180 就是散列地址。也可以将 “12” 和 “56” 反转再来求和 “21 + 34 + 65 + 78 = 198”,用 198 作为散列地址。由此可见折叠法也不需要先知道关键字的分布情况,当关键字位数较多时使用。

随机数法

所有的散列地址通过随机函数分配,一切随缘。

冲突处理

开放地址法

探测思路

开放地址法的思路很简单,就是一旦发生冲突就探测下一个空的散列地址,因为在哈希表设计时,我们都会预留一些空间,因此往往可以找到新的空间来填入。寻找下一个空闲的地址称之为探测,通过开放地址法得到的散列函数为:

Fi(key) = [F(key) + di] % m, i = 1,2,3 … k (k ≤ m - 1)

通过数列 di 的选择不同,可以分为以下 3 种常见的探测法。

探测法

线性探测法

所谓线性探测法就是老实人——一个一个地找。发生冲突时就按地址的递增顺序找下一个空闲地址,若到了最后一个地址仍然没找到,就去从表头开始找。若找到了就填充数据,在预留空间的条件下绝对找得到。

di = 1,2,3 … m - 1

例如将关键字序列 {18,2,10,6,78,56,45,50,110,8} 散列存储到散列表中,散列表的存储空间是一个下标从 0 开始的一维数组,散列函数为:H(key) = key mod 11,处理冲突采用线性探测法。

此时我们也用 ASL 度量一下构造的散列表。

ASL成功 = (1 + 1 + 1 + 3 + 4 + 1 + 1 + 3 + 2 + 1) / 10 = 1.8
ASL失败 = (6 + 5 + 4 + 3 + 2 + 1 + 6 + 5 + 4 + 3 + 2 + 1 + 1) / 13 = 43/13

二次探测法

通过平方运算让关键字变得更为分散,就称之为二次探测法。

di = 12,-12,22,-22,32…k2,-k2 (k ≤ m / 2)

伪随机探测法

伪随机序列可以通过随机种子来获得。

di = 伪随机序列

堆积现象

所谓堆积现象就是散列地址已被占用的情况下,利用开放地址法探测时得到的地址和其他数据探测后的地址结果相同,产生了二次聚集。从上面的例子中就能看出这种现象的存在。由此可见线性探测法的优点是只要散列表未填满,总能找到一个不发生冲突的地址。不过这种探测法很容易产生“二次聚集”现象。而二次探测法和伪随机探测法就可以避免“二次聚集’’现象,但是有可能找不到不发生冲突的地址。

链地址法

链地址法建成的哈希表,很像树结构中的孩子表示法。它的存储手法是把具有相同散列地址的记录放在同一个单链表中,称之为同义词链表。例如我们把上面构造的哈希表改造成哈希链来看看:

由此可见哈希链是一种非常保险的做法,你也不用担心找不找得到空闲地址的问题了,只不过你需要承担单链表操作的开销。
链地址法很值得讲细一些,左转博客HASH 链与“航空公司VIP客户查询”题解!

公共溢出区法

就是把发生冲突的关键字统统都送到另一个结构存储,查找过程为:对给定值通过散列函数计算出散列地址后.先与原表的相应位置进行比对,如果相等则查找成功,如果不相等就去溢出表去进行遍历。
例如将关键字序列 {1,2,4,5} 散列存储到散列表中,散列表的存储空间是一个下标从 0 开始的一维数组,散列函数为:H(key) = key mod 3,处理冲突采用公共溢出区法。

再散列函数法

所谓再散列函数法就是准备好几个散列函数,发生冲突就换一种。

HASH 代码实现

结构体定义

和顺序表类似,需要通过数组来构造。

#define MaxSize 100      //定义最大哈希表长度
typedef int KeyType;      //关键字类型
typedef char * InfoType;      //其他数据类型
typedef struct node
{
      KeyType key;      //关键字域
      InfoType data;      //其他数据域
      int count;      //探查次数域
} HashTable[MaxSize];      //哈希表类型

插入数据

该函数使用除留余数法作为散列函数,线性探测法进行冲突处理。

void InsertHT(HashTable ha, int& count, KeyType k, int p)
{                              //count 表示哈希表数据个数,k 插入关键字,p 除数
      int idx;      //存储散列函数计算得到的地址
      int fre = 1;

      idx = k % p;      //除留余数法作为散列函数
      while (1)
      {
	      if (ha[idx].key == NULLKEY)      //该地址空闲
	      {
		      ha[idx].key = k;
		      ha[idx].count = fre;
		      break;      //结束探测
	      }
	      else
	      {
		      idx = (idx + 1) % p;      //线性探测法找空闲空间
		      fre++;
	      }
      }
      count++;
}

构造哈希表

构造方式就是循环调用插入操作函数,将已有关键字填充入数组中。

void CreateHT(HashTable ha,KeyType num[],int n,int m,int p) 
{                              //num 为输入数组,n 输入数据个数,m 为哈希表长度
      int count = 0;

      for (int i = 0; i < m; i++)      //初始化
      {
	    ha[i].key = NULLKEY;
            ha[i].count = 0;
      }
      for (int i = 0; i < n; i++)
      {
            InsertHT(ha, count, x[i], p);      //循环插入数据
      }
}

查找操作

在哈希表中查找关键字 k,找不到返回 -1,找到返回查找地址。

int SearchHT(HashTable ha, int p, KeyType k)
{
      int idx = k % p;      //用除留余数法得出查找地址
    
      while (1)
      {
            if (ha[idx].key == k)      //查找成功
            {
	          break;
	    }
	    else if (ha[idx].key == NULLKEY)      //探测到空地址,说明数据不存在
	    {
	          idx = -1;
                  break;
	    }
	    else      //没有找到数据,继续探测
	    {
	        idx = (idx + 1) % p;      //用线性探测法的规律查找数据
            }
      }
      return idx;
}

实例:整型关键字的散列映射

题目说明

题目解析

这道题目主要是涉及了除留余数法作为散列函数,线性探测法处理冲突的知识,只要这 2 种手法的基操不出错,这道题目就能够解决。
比较容易出错的地方还是在重复数据的问题上,测试数据中明确有重复数据的出现。遇到重复数据就直接给出其所在地址即可,无需冲突处理。虽然这道题目比较简单,但是也点明了一个值得关注的地方。因为在实际问题中我们往往需要处理多关键字的数据,在我们构造哈希表时遇到重复数据怎么办?是要换关键字还是要覆盖数据?这就需要具体问题具体分析了。

伪代码

代码实现

#include<iostream>
using namespace std;
int main()
{
    int count, prime;
    int* hash;      //定义哈希表
    int idx,num;
    int flag = 0;

    cin >> count >> prime;
    hash = new int[prime];      //给哈希表动态分配空间
    for (int i = 0; i < prime; i++)
    {
        hash[i] = 0;      //哈希表初始化
    }
    for (int i = 0; i < count; i++)
    {
        cin >> num;
        idx = num % prime;      //除留余数法得到散列地址
        while (1)
        {
            if (hash[idx] == 0 || hash[idx] == num)    //注意判断相等问题
            {
                hash[idx] = num;
                break;
            }
            else
            {
                idx = (idx + 1) % prime;      //线性探测法找到合适地址
            }
        }
        if (i == 0)      //这题直接输出就行,存储在另一个结构一起输出也可以
            cout << idx;
        else
            cout << " " << idx;
    }
    return 0;
}

调试遇到的问题


Q1:无法处理冲突
A1:第一次应用线性探测法不熟练,导致探测时地址不会自增,使得开始探测之后会直接把源数据覆盖掉。将线性探测法表达正确就解决了这个问题。
Q2:无法处理重复关键字
A2:测试数据中明确有重复数据的出现,遇到重复数据就直接给出其所在地址即可,无需冲突处理。通过修改数据填充的判断条件,让这种情况和直接填充是一个出口即可。

知识总结

  1. 使用除留余数法作为散列函数,线性探测法处理冲突的基础操作,这 2 个都是较为简单且实用的哈希表构造法,这道题目可以用来巩固基础;
  2. 对于重复数据我们产生了思考,在实际问题中我们往往需要处理多关键字的数据,在我们构造哈希表时遇到重复数据时,应该用什么样的写法进行实现?这是值得后续讨论,进行适当地实践的。

参考资料

《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社
HASH 链与“航空公司VIP客户查询”题解

posted @ 2020-05-26 23:26  乌漆WhiteMoon  阅读(698)  评论(0编辑  收藏  举报