大家好,又见面了,我是你们的朋友全栈君。
1.散列的相关概念
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。查找时,根据这个确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则必定在f(key)的位置上。
这里我们把这种对应关系f称为散列函数,又称为哈希(Hash)函数。按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。那么关键字对应的记录存储位置,我们称为散列地址。
2.散列表查找步骤
(1)在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。
(2)当查找记录时,我们通过同样的散列函数计算记录的散列地址,并按此散列地址访问该记录。
散列技术既是一种存储方法,也是一种查找方法。然而它与线性表、树、图等结构不同的是,前面几种结构,数据元素之间都存在某种逻辑关系,可以用连线图表示出来,而散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联。因此,散列主要是面向查找的存储结构。
散列结束最适合的求解问题是查找与给定值相等的记录。对于查找来说,简化了比较过程,效率就会大大提高。但散列技术不具备很多常规数据结构的能力。
在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是一个理想。我们时常会碰到两个关键字key1≠key2,但是却没有f(key1)=f(key2),这种现象我们称为冲突(collision),并把key1和key2称为这个散列函数的同义词(synonym)。
3.散列函数的构造方法
(1)直接定址法
我们可以取关键字的某个线性函数值为散列地址,即 ∗∗f(key)=a∗key+b(a、b为常数) **
这样的散列函数有点就是简单、均匀,也不会产生冲突,但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的情况。由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用。
(2)数字分析法
如果我们的关键字是位数较多的数字,比如我们的11位手机号”130xxxx1234”,其中前三位是接入号,一般对应不同运营商公司的子品牌,如130是联通如意通、136是移动神州行、153是电信等;中间四位是HLR识别号,表示用户号的归属地;后四位才是真正的用户号。
若我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的。那么我们选择后面的四位称为散列地址就是不错的选择。如果这样的抽取工作还是容易出现冲突问题,还可以对抽取出来的数字再进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环位移、甚至前两数与后两数叠加(如1234改成12+34=46)等方法。总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各位置。
这里我们提到了一个关键词-抽取。抽取方法是使用关键字的一部分来计算散列存储位置的方法,这在散列函数中是常常用到的手段。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑这个方法。
(3)平方取中法
这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用做散列地址。再比如关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671,也可以是710,用做散列地址。平方取中法比价适合于不知道关键字的分布,而位数又不是很大的情况。
(4)折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
比如我们的关键字是9876543210,散列表表长为三位,我们将它分为四组,987|654|321|0,然后将它们叠加求和987+654+321+0=1962,再求后3位得到散列地址为962。
有时可能这还不能够保证均匀分布,不妨从一端向另一端来回折叠后对齐相加。比如我们将987和321反转,再与654和0相加,变成789+654+123+0=1566,此时散列地址为566。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。
(5)除留余数法
此方法为最常用的构造散列函数方法。对于散列表长为m的散列函数公式为:
f(key) = key mod p (p≤m)
mod是取模的意思。事实上,这方法不仅可以对关键字直接取模,也可以折叠、平方取中后再取模。很显然,本方法的关键就在于选择合适的p,p如果选得不好,就可能会容易产生同义词。如下表所示,我们对于有12个记录的关键字构造散列表时,就用了f(key)=key.mod 12的方法。比如29 mod 12 = 5,所以它存储在下标为5的位置。
不过这也是存在冲突的可能的,因为12=2*6=3*4。如果关键字中有像18(3*6)、30(5*6)、42(7*6)等数字,它们的余数都为6,这就和78对应的下标位置冲突了。
甚至极端一些,对于下表的关键字,如果我们让p为12的话,就可能出现下面的情况,所有的关键字都得到了0这个地址数,这未免也太糟糕了点。
我们不选用p=12来做除留余数法,而选用p=11,如下表所示。
此时就只有12和144有冲突,相对来说,就要好很多。
因此根据前辈们的经验,若散列表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
(6)随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址。也就是f(key)=random(key)。这里random是随机函数。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。
4.处理散列冲突的方法
(1)开放定址法
所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
fi(key)=(f(key)+di)MODm(di是一个随机数列)
(2)再散列函数法
对于我们的散列表来说,我们事先准备多个散列函数。
fi(key)=RHi(key)(i=1,2,...,k)
这里 就是不同的散列函数,你可以把前面说的什么除留余数、折叠、平方取中全部用上。每当发生散列地址冲突时,就换一个散列函数计算,相信总会有一个可以吧冲突解决掉。这种方法能够使得关键字不产生聚集,当然,相应地也增加了计算的时间。
(3)链地址法
将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},我们同样的用12为除数,进行除留余数法,可得到如下图所示结构,此时,已经不存在什么冲突换址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。
链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗。
(4)公共溢出去法
这个方法其实就更加好理解,你不是冲突吗?好吧,凡事冲突的都跟我走,我给你们这些冲突找个地儿待着。这就如同孤儿院收留所有无家可归的孩子一样,我们为所有冲突的关键字建立了一个公共的溢出区来存放。
就前面的例子而言,我们共有三个关键字{37,48,34}与之前的关键字位置有冲突,那么将它们存储到溢出表中,如下图所示:
在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表去进行顺序查找。如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。
5.散列表查找实现
(1)散列表查找算法实现
首先是需要定义一个散列表结构以及一些相关的常数。其中HashTable就是散列表结构。结构当中的elem为一个动态数组。
**#define SUCCESS 1**
**#define UNSUCCESS 0**
**#define HASHSIZE 12** /*定义散列表长为数组的长度*/
**#define NULLKEY -32768**
typedef struct
{
int *elem; /*数据元素存储基址,动态分配数组*/
int count; /*当前数据元素个数*/
}HashTable;
int m=0; /*散列表表长,全局变量*/
/*初始化散列表*/
Status InitHashTable(HashTable *H)
{
int i;
m=HASHSIZE;
H->count=m;
H->elem=(int *)malloc(m*sizeof(int));
for(i=0;i<m;i++)
H->elem[i]=NULLKEY;
return OK;
}
为了插入时计算地址,我们需要定义散列函数,散列函数可以根据不同情况更改算法。
/*散列函数*/
int Hash(int key)
{
return key % m; /*除留余数法*/
}
初始化完成后,我们可以对散列表进行插入操作。假设我们插入的关键字集合就是前面的{12,67,56,16,25,37,22,29,15,47,48,34}。
/*插入关键字进散列表*/
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;
}
查找代码与插入的代码非常类似,只需做一个不存在关键字的判断而已。
(2)散列表查找实现代码(Java)
工程目录结构
散列表查找类
package com.red.hash.search;
public class HashSearch {
public static int searchHash(int[] hash, int hashLength, int key) {
//哈希函数
int hashAddress = key % hashLength;
//指定hashAddress对应值存在但不是关键值,则用开放寻址法解决
while(hash[hashAddress] != 0 && hash[hashAddress] != key) {
hashAddress = (++hashAddress) % hashLength;
}
//查找到了开放单元,表示查找失败
if(hash[hashAddress]==0) {
return -1;
}
return hashAddress;
}
//数据插入哈希表
public static void insertHash(int[] hash, int hashLength, int data) {
//哈希函数
int hashAddress = data % hashLength;
//如果key存在,则说明已经被别人占用,此时必须解决冲突
while(hash[hashAddress] != 0) {
//用开放寻址法找到
hashAddress = (++hashAddress) % hashLength;
}
//将data存入字典中
hash[hashAddress] = data;
}
}
测试类
package com.red.hash.search;
public class HashSearchTest {
public static void main(String[] args) {
int hashLength = 12;
int[] hash = new int[hashLength];
HashSearch.insertHash(hash, hashLength, 1);
HashSearch.insertHash(hash, hashLength, 2);
HashSearch.insertHash(hash, hashLength, 3);
HashSearch.insertHash(hash, hashLength, 4);
int location1 = HashSearch.searchHash(hash, hashLength, 1);
int location2 = HashSearch.searchHash(hash, hashLength, 2);
int location3 = HashSearch.searchHash(hash, hashLength, 3);
int location4 = HashSearch.searchHash(hash, hashLength, 4);
System.out.println("1所在的位置:" + location1);
System.out.println("2所在的位置:" + location2);
System.out.println("3所在的位置:" + location3);
System.out.println("4所在的位置:" + location4);
}
}
输出结果
1所在的位置:1
2所在的位置:2
3所在的位置:3
4所在的位置:4
6.复杂度分析
单纯论查找复杂度,对于无冲突的hash表而言,查找复杂度为O(1)(在查找之前需要构建相应的Hash表)。
发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/146581.html原文链接:https://javaforall.cn
【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛
【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...