数据结构 Hash表(哈希表)--附C语言实现源码

  在顺序表查找时,如果要查找某个关键字的记录,就是从表头开始,挨个的比较记录a[i]key的值是=还是, 直到有相等才算是查找成功,返回i
  有序表查找时,我们可以利用a[i]key<>来折半查找,直到相等时,查找成功返回i。最终的目的都是为了查找那个i,其实也就是相对应的下标,再通过顺序存储位置计算的方法,LOC(ai)=LOC(a1)+(i-1) * c,得到最终的内存地址。
 可以发现,为了查找到结果,用顺序表和有序表查找的方式,都会进行比较,这种比较是否真的有必要?能否通过关键字key得到要查找的记录的存储位置LOC(ai)?

一、哈希(Hash)的基本概念

  我们需要一种技术,使得不需要经过比较式的查找,就能获得记录的存储位置。这种技术就是哈希技术,也称作散列技术(哈希就是散列,哈希是音译,散列是字译)。
  哈希技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。查找时,根据这个确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则必定在f(key)的位置上。
换成数学的表达方式就是
  存储位置=f(关键字)
  即:LOC(ai)=f(key)
  这种对应关系f称为称为哈希(Hash) 函数
  采用哈希技术将记录存储在一块连续的存储空间中,这块连续存储空间称为哈希表(Hash table)。关键字对应的记录存储位置我们称为哈希地址
哈希表或者哈希技术最主要的用途就是查找与给定值相等的元素,具体来说是:查找与给定值相等的记录。对于查找来说,简化了比较过程(时间复杂度为O(n)),降低时间复杂度,效率就会大大提高(时间复杂度仅为O(1))。
  但万事有利就有弊,哈希技术不具备很多常规数据结构的能力。同时,相当于牺牲空间换时间。本文暂时不讨论哈希表的弊端。

二、哈希表查找

2.1哈希查找步骤概要

  (1)在存储时,通过哈希函数计算记录的哈希地址,并按此哈希地址存储该记
录。
  (2)当查找记录时,我们通过同样的哈希函数计算记录的哈希地址,按此哈希地址访问该记录。
说起来很简单,在哪存的,上哪去找,由于存取用的是同一个哈希函数,因此结果当然也是相同的。
由此可见,哈希技术既是一种存储方法,也是一种查找方法
然而它与线性表、树、图等结构不同的是,前者,数据元素之间都存在某种逻辑关系,可以用连线图示表示出来,而哈希技术的记录之间不存在什么逻辑关系,它只与关键字有关。

2.2 设计哈希函数

  同样的关键字,它能对应很多记录的情况,不适合用哈希技术。
  在理想的情况下,每一个关键字,通过哈希函数计算出来的地址都是不一样的。但我们时常会碰到两个关键字key1≠key2,但是却有f(key) =f(key2), 这种现象我们称为冲突(collision), 并把key1, 和key2称为这个哈希函数的同义词(synonym)。 可以通过精心设计的哈希函数让冲突尽可能的少,但是不能完全避免。
设计哈希函数遵循以下原则:

  1. 计算简单:降低比较的时间复杂度
  2. 哈希地址分布均匀:减少冲突

2.2.1直接定址法

  取关键字的某个线性函数值为哈希地址,即
f(key) = a × key + b (a、b为常数)

  这样的哈希函数优点就是简单、均匀,也不会产生冲突。但是需要事先知道关键字的分布情况,适合查找表较小且连续的情况。
  如:对某个指定的数组进行查找关键字。此时关键字就是数组元素的值,

2.2.2数字分析法

  如果我们的关键字是位数较多的数字,比如我们的11位手机号"130xxxx1234",其中前三位是接入号,一般对应不同运营商公司的子品牌,如130是联通如意通、136 是移动神州行、153 是电信等;中间四位是HLR识别号,表示用户号的归属地;后四位才是真正的用户号。如表2-2-1所示:

表2-2-1
重复分布太集中的几个数字 分布均匀可以作为哈希地址
130xxxx 1234
130xxxx 2345
138xxxx 4829
138xxxx 2396
138xxxx 8354

  若我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的。那么我们选择后面的四位成为哈希地址就是不错的选择。如果这样的抽取工作还是容易出现冲突问题,还可以对抽取出来的数字再进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环位移、甚至前两数与后两数叠加(如1234改成12+34=46)等方法。总的目的就是为了提供一个哈希函数,能够合理地将关键字分配到哈希表的各位置。
   抽取方法是使用关键字的一部分来计算哈希存储位置的方法,这在哈希函数中是常常用到的手段。
   数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。

2.2.3平方取中法

 这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227, 用做哈希地址。再比如关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671,也可以是710,用做哈希地址。平方取中法比较适合于不知道关键字的分布,而位数又不是很大的情况。

2.2.4折叠法

  折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按哈希表表长,取后几位作为哈希地址。
  比如我们的关键字是9876543210,哈希表表长为三位,我们将它分为四组,987|654|321|0,然后将它们叠加求和987+654+321+0=1962,再求后3位得到哈希地址为962。
  有时可能这还不能够保证分布均匀,不妨从一-端向 另一端来回折叠后对齐相加。比如我们将987和321反转,再与654和0相加,变成789+654+123+0=1566,此时哈希地址为566。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。

2.2.5除留余数法

  此方法为最常用的构造哈希函数方法。对于哈希表长为m的哈希函数公式为:
f(key) = key mod p (p≤m)

  mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模。

  很显然,本方法的关键就在于选择合适的p, p如果选得不好,就可能会容易产生同义词。
例如表2-2-2,对于有12个记录的关键字构造哈希表时,就用了f (key)=key mod 12的方法。比如29 mod 12=5,所以它存储在下标为5的位置。

表2-2-2
下标 0 1 2 3 4 5 6 7 8 9 10 11
关键字 12 25 38 15 16 29 78 67 56 21 22 47

不过这也是存在冲突的可能的,因为12=2×6=3×4。如果关键字中有像18 (3×6)、30 (5×6)、42 (7×6)等数字,它们的余数都为6,这就和78所对应的下标位置冲突了。

甚至极端一些,对于表2-2-3的关键字,如果让p为12的话,就可能出现下面的情况,所有的关键字都得到了0这个地址数。

表2-2-3
下标 0 0 0 0 0 0 0 0 0 0 0 0
关键字 12 24 36 48 60 72 84 96 108 120 132 144

因此根据前辈们的经验,若哈希表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。

2.2.5随机数法

  选择一个随机数,取关键字的随机函数值为它的哈希地址。也就是f(key)=random (key)。 这里random是随机函数。当关键字的长度不等时,采用这个方法构造哈希函数是比较合适的。

2.3 处理哈希冲突的方法

2.3.1开放定址法

  所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的哈希地址,只要散
列表足够大,空的哈希地址总能找到,并将记录存入。
它的公式是:

f(key)=(f(key) +di) MOD m ( di=1,2…m-1 )

比如:

  给定关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12。
我们用哈希函数f (key) =key mod 12。

当计算前5个数{12,67,56,16,25}时, 都是没有冲突的哈希地址,直接存入,如表2-3-1所示。

下标 0 1 2 3 4 5 6 7 8 9 10 11
关键字 12 25 16 67 56

  计算key=37时,发现f (37) =1,此时就与25所在的位置冲突。于是我们应用上面的公式f(37) = (f(37) +1) mod 12=2。于是将37存入下标为2的位置。这其实就是房子被人买了于是买下一间的作法,如表2-3-2所示。

下标 0 1 2 3 4 5 6 7 8 9 10 11
关键字 12 25 37 16 67 56

  接下来22,29,15,47都没有冲突,正常的存入,如表2-3-3所示。

下标 0 1 2 3 4 5 6 7 8 9 10 11
关键字 12 25 37 15 16 29 67 56 22 47

  到了key=48,我们计算得到f(48) =0,与12所在的0位置冲突了,然后f(48)=(f(48)+1)mod 12=1, 此时又与25所在的位置冲突。于是f(48) = (f (48) +2) mod 12=2,还是冲突…直到f (48) = (f (48) +6) mod 12=6时,才有空位,存入后如表2-3-4所示。

下标 0 1 2 3 4 5 6 7 8 9 10 11
关键字 12 25 37 15 16 29 48 67 56 22 47

  把种解冲突的开放定址法称为线性探测法。在解决冲突的时候,会碰到如48和37这种本来都不是同义词却需要争夺一个地址的情况, 称这种现象为堆积。堆积的出现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低。

  注意到,数组现在还差最后一个元素34没有存入哈希表。

  key=34, f(key)=10, 与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置, 尽管可以
不断地求余数后得到结果,但效率很差。因此我们可以改进d=1^2, -1^2, 2^2, -22…q2, -q^2, (q≤m/2),这样就等于是可以双向寻找到可能的空位置。对于34来说,我们取di=-1即可找到空位置了。

  增加平方运算的目的是为了不让关键字都聚集在某一块区域。称这种方法为二次探测法

f(key)=(f(key) +di) MOD m (di=1^2, -1^2, 2^2, -22…q2, -q^2, q≤m/2)

这里有个疑问,为什么q≤m/2,不是q≤m或者q ≤m^(1/2)向上取整

  还有一种方法是,在冲突时,对于位移量di采用随机函数计算得到,称之为随机探测法

f(key)=(f(key) +di) MOD m (di是一个随机数列)

  此时一定有人问,既然是随机,那么查找的时候不也随机生成吗?如何可以获得相同的地址呢?这是个问题。这里的随机其实是伪随机数。伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的d; 当然可以得到相同的哈希地址。

2.3.2再散列函数

  对于我们的哈希表来说,我们事先准备多个哈希函数。fi(key) =RHi (key) (i=1,2,…,k)这里RH就是不同的哈希函数,可以把前面说的什么除留余数、折叠、平方取中全部用上。每当发生哈希地址冲突时,就换一个哈希函数计算,相信总会有一个可以把冲突解决掉。这种方法能够使得关键字不产生聚集,当然,相应地也增加了计算的时间。

2.3.3链地址法

  将所有关键字为同义词的记录存储在一一个 单链表中,我们称这种表为同义词子表,在哈希表中只存储所有同义词子表的头指针。对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},我们用前面同样的12为除数,进行除留余数法,可得到如图2-3-5结构,此时,已经不存在什么冲突换址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。图片来源:《大话数据结构》Page.364.图8-11-1
  链地址法对于可能会造成很多冲突的哈希函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗。

2.3.4公共溢出区法

  这个方法其实就更好理解,不就是冲突吗?凡是冲突的都跟我走,我给你们这些冲突找个地儿待着。为所有冲突的关键字建立了一个公共的溢出区来存放。
  就前面的例子而言,我们共有三个关键字{37,48,34}与之前的关键字位置有冲突,那么就将它们存储到溢出表中,如图2-3-6所示。

基本表 0 1 2 3 4 5 6 7 8 9 10 11
\ 12 25 ^ 15 16 29 ^ 67 57 ^ 22 47
溢出表 0 1 2 3 4 5 6 7 8 9 10 11
\ 37 48 34

  在查找时,对给定值通过哈希函数计算初哈希地址后,先与基本表进行比对,如果相等则查找成功;如果不相等再到溢出表进行顺序查找。

  相对于基本表而言,在有冲突数据很少的情况下,公共溢出区法的查找效率还是非常高的。

三、哈希表查找实现

代码以C语言为例。

3.1开放地址法

/* 头文件和头部声明 */
#include <stdio.h>
#include <stdlib.h>

#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12
#define NULLKEY -32768
#define STATUS unsigned int
#define ELEMTYPE int
int M = 0; 		   /* 哈希表表长,全局变量 */
/* 定义哈希表结构体 */
typedef struct HashNode
{
  ELEMTYPE *elem;  /* 数据元素存放基址,动态分配数组 */
  int count;       /* 当前数据元素个数 */
}HashTable;
/* 初始哈希表 */
STATUS InitHashTable(HashTable *_hashTable)
{
  int ii;
  M = HASHSIZE;
  _hashTable->count = M;
  _hashTable->elem = (int *)malloc(sizeof(int) * M);

  for (ii=0; ii<M; ii++)
  {
    _hashTable->elem[ii] = NULLKEY;
  }
    
  return SUCCESS;
}
/* 定义哈希函数 */
int Hash(ELEMTYPE key)
{
  return key % M;                             /* 除留余数法 */
}
/* 将关键字插入哈希表 */
void InsertHash(HashTable *_hashTable, ELEMTYPE key)
{
  int addr = Hash(key);                       /* 求哈希地址 */

  while (_hashTable->elem[addr] != NULLKEY)   /* 哈希表某个位置上的值不为空,则冲突 */
  {
    addr = (addr+1) % M;                      /* 开放地址法的线性探测 */
  }

  _hashTable->elem[addr] = key;
}
/* 函数功能:利用哈希表查找关键字
 * 参数列表:
 * 1、h     初始化过的哈希表
 * 2、key   查找的关键字
 * 3、*addr 用于保存关键字位置的指针
 * 函数返回值:
 * SUCCESS:1  UNSUCCESS:0
*/
Status SearchHash(HashTable *h, int key, int *addr)
{
  *addr = Hash(key);

  while (h->elem[*addr] != key)                 /* 不匹配,则冲突 */
  {
    *addr = (*addr+1) % M;
    if (h->elem[*addr]==NULLKEY || *addr==Hash(key))
    {
      return UNSUCCESS;
    }
  }
    
  return SUCCESS;
}
/* 测试代码 */
int main(void)
{
  int arr[12] = {12,67,56,16,25,37,22,29,15,47,48,34};
  HashTable hashTable;
  InitHashTable(&hashTable);

  int ii;
  for (ii=0; ii<12; ii++)
  {
    InsertHash(&hashTable,arr[ii]);
  }
  /* 查看初始化后的哈希表 */
  for (ii=0; ii<12; ii++)
  {
    printf(" %2d |",ii);
  }
  printf("\n------------------------------------------------------------\n");
  for (ii=0; ii<12; ii++)
  {
    printf(" %2d |",hashTable.elem[ii]);
  }
  printf("\n");

  /* 在哈希表中搜索关键字 69*/
  int addr = 0;
  int key = 69;
  if (SearchHash(&hashTable, key, &addr))
  {
    printf("找到关键字!位置在%d\n",addr);
  }else
  {
    printf("未找到关键字\n");
  }
    
    return 0;
}

运行结果:
在这里插入图片描述

四、参考文献

  1. 程杰. 大话数据结构[M]. 清华大学出版社, 2011.
  2. 张乃孝, 陈光, 刘筠,等. 算法与数据结构——C语言描述[M]. 高等教育出版社, 2002.
  3. 王翠茹, 袁和金, 刘军. 数据结构, C语言版[M]. 中国电力出版社, 2012.
发布了5 篇原创文章 · 获赞 3 · 访问量 229

猜你喜欢

转载自blog.csdn.net/changyi9995/article/details/105787195
今日推荐