大话数据结构学习笔记 - 查找之散列表查找(哈希表)及C
实现
概述
顺序表查找时,可以使用 ==或 来遍历比较元素与查找值,有相等则查找成功; 有序表查找时, 可以使用<<和>>, 来折半查找,相等时则查找成功. 最终得到元素的存储位置, 但有没有直接通过关键字key
得到要查找的记录内存存储位置呢?
- 散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系
f
, 使得每个关键字key
对应一个存储位置f(key)
。 - 散列函数: 即对应关系
f
, 又称为 哈希(hash
)函数。 - 散列表(哈希表
Hash table
):采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表 - 散列技术既是一种 存储方法, 也是一种 查找方法
- 散列技术最适合的求解问题是查找与给定值相等的记录,而不适合范围查找以及非唯一关键字查找, 即某关键字对应多个记录的情况
- 冲突(
collision
): 理想状态下,不同的关键字通过散列函数计算的地址也不同。但现实中,时常会碰到两个关键字, 但却有 f(key1)==f(key2)f(key1)==f(key2), 这种现象被称为 冲突(collision
), 并把key1key1和key2key2称为这个散列函数的同义词。故如何处理冲突也是很重要的问题散列函数的构造方法
散列函数选择的两个原则
- 计算简单
- 散列地址分布均匀
直接定址法
取关键字的某个线性函数值作为散列地址
f(key)=a∗key+b(a,b为常数)f(key)=a∗key+b(a,b为常数),这种三里函数优点是简单、均匀、也不会产生冲突。但需要事先知道关键字的分布情况,适合查找表较小且连续的情况。故不常用数字分析法
即抽取关键字的一部分计算散列存储地址,比如手机号码
11
位,取中间若干位计算。适合处理关键字位数比较大得到情况,但需要事先知道关键字的分布以及关键字的若干位分布均匀平方取中法
假如关键字为整数,可以对其平方,然后取若干位作为散列地址,适合于不知道关键字的分布,而位数又不是很大得到情况
折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够可以短些),然后将这几部分叠加求和,并按散列表表长,去后几位作为散列地址。 适合于不知道关键字的分布,且关键字位数较多的情况
除留余数法
此方法为最常用的构造散列函数方法,对于散列表长为
m
的散列函数公式为f(key)=key mod p (p≤m)f(key)=key mod p (p≤m),mod
即取模, 当然也可与对折叠、平方取中后再取模本方法的关键在于选择合适的
p
, 否则容易产生同义词。 比如下图,有12
个记录的关键字,散列函数为 f(key)=key mod 12f(key)=key mod 12, 比如29 mod 12 = 5
, 故存储在下标为5
的位置对于上述散列函数,若关键字存在
18
等数字,余数为6
,则与78
的存储位置冲突随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址,散列函数为f(key)=random (key)f(key)=random (key),
random
为随机函数, 当关键字的长度不等时,比较适合处理散列冲突的方法
开放定址法
开放定址法即一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入, 公式为
fi(key)=(f(key)+di) MOD m (di=1,2,3,......,m−1)fi(key)=(f(key)+di) MOD m (di=1,2,3,......,m−1)线性探测法
冲突后,寻找下一位置, 这种解决冲突的开放定址法称为线性探测法
比如关键字集合为
{12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34}
, 表长为12
, 则散列函数为f(key)=key mod 12f(key)=key mod 12计算前
5
个数{12, 67, 56, 16, 25}
, 都没有冲突,直接存入计算
37
, 余数为1
,与25
冲突, 故使用公式f(37)=(f(37)+1)) mod 12=2f(37)=(f(37)+1)) mod 12=2, 故存储位置为2
接下来
{22, 29, 15, 47}
都没有冲突, 正常存入对于关键字
48
, 计算得到f(48)=0f(48)=0, 与12
冲突,然后寻找下一地址f(48)=(f(48)+1)) mod 12=1f(48)=(f(48)+1)) mod 12=1,与25
冲突, … , 直到f(48)=(f(48)+6)) mod 12=6f(48)=(f(48)+6)) mod 12=6, 有空位,存入
二次探测法
与线性探测法类似,只不过是增加平方运算,目的是为了不让关键字都聚焦在某一块区域,公式为
fi(key)=(f(key)+di) MOD m (di=12,−12,22,−22,......,q2,−q2,q≤m/2)fi(key)=(f(key)+di) MOD m (di=12,−12,22,−22,......,q2,−q2,q≤m/2)随机探测法
随机探测法 即在冲突时,对于位移量didi 采用随机函数计算得到, 这里的随机函数为伪随机函数,即随机种子相同的话,每次得到的数列相同, 公式为
fi(key)=(f(key)+di) MOD m (di是一个随机数列)fi(key)=(f(key)+di) MOD m (di是一个随机数列)再散列函数法
选择多个散列函数, 每当发生散列地址冲突时,就换一个散列函数计算,知道解决冲突。这种方法使得关键字不产生聚焦,但增加了计算时间, 公式为
fi(key)=RHi(key) (i=1,2,...,k)fi(key)=RHi(key) (i=1,2,...,k), RHiRHi为不同的散列函数链地址法
链地址法即将所有关键字为同义词的记录存储在一个单链表中, 这种表为同义词子表, 在散列表中只存储所有同义词字表的头指针。比如对于关键字集合
{12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34}
, 进行除留余数法, 可得到如下图所示的结构,此时,不存在冲突换址的问题,因为有冲突,只需要在当前位置给单链表增加节点即可链地址法对于可能造成很多冲突的散列函数来说,保证了绝不会找不到地址,但也出现了查找是需要的便利单链表的性能损耗
散列表查找实现
下列代码为线性探测法实现的散列表查找
散列表结构
#define HASHSIZE 12 /* 定义散列表长为数组的长度 */ #define NULLKEY (-32768) typedef int Status; typedef struct { int *elem; // 数据元素存储基址 int count; // 当前数据元素个数 }HashTable; int m = 0; // 散列表长,全局变量
初始化
/* * 初始化散列表 */ Status InitHashTable(HashTable *H) { m = HASHSIZE; H->elem = (int *)malloc(m * sizeof(int)); H->count = m; for(int i = 0; i < m; i++) H->elem[i] = NULLKEY; return OK; }
散列函数
/* * 散列函数 */ int hash(int key) { return key % m; // 除留余数法 }
插入
/* * 插入关键字到散列表 */ void InsertHash(HashTable *H, int key) { int addr = hash(key); // 求关键字的散列地址 while(H->elem[addr] != NULLKEY) // 如果不为空,则冲突 addr = (addr + 1) % m; // 开放定址法的线性探测 H->elem[addr] = key; // 发现空位,插入关键字 }
查找
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; }
性能分析
- 若没有冲突,则时间复杂度为O(1)O(1)
- 装填因子: 装填因子α=填入表中的记录个数散列表长度α=填入表中的记录个数散列表长度, αα 标志着散列表的装满程度,αα越大,产生通过冲突的可能性越大
结语
关于查找的知识点,学习了几下几种
- 顺序表查找:比如顺序查找以及哨兵的思想
- 有序表查找:着重讲了折半查找的思想,以及插值查找和斐波那契查找
- 二叉排序树:动态查找的最重要的数据结构,查找、插入和删除效率都很高,但为了最优的状态,二叉排序树最好是构造成平衡二叉树
AVL
树才最佳 - 散列表:非常高效的查找数据结构,一步到位,对于查找性能要求高,且记录之间关系无要求的数据非常适用
但是还有很多查找的知识点未学习,比如
B
树,B+
树,红黑树等等。继续加油,Fighting