哈希表全解(简介+构造+冲突处理+查找分析计算+诸多要点)
整理网上大牛和课本教材,争取为所有想要初学散列表的少年们打造一篇绝对看得懂,看完就懂的知识型博客,也算是顺便当作我的复习材料吧
1.哈希表(散列表)简介:
我们都知道在查找领域有三种主要的查找方式,线性表查找,树形查找(类似BST),还有一种就是我们这次要提及的散列表查找,首先我们来解释一下为什么散列表又叫哈希表(可呢个是因为哈希的意思是杂糅吧),这个其实不是重点
先说内容吧:
1.哈希函数
2.哈希表
3.数据记录
我们一条一条来分析:
其实说实话,哈希表查找就相当于我们的函数,对于每一个输入的键值,唯一映射一个存储地址,地址终究存放着我们的数据
这么一说可能会让我们更好理解一点
下面我们就开始讲解诸多术语了:(之后可能还会有更多术语,我们在江街道的时候会再次引出,这里现在没有必要全部都说出来)
1)哈希函数是一个映射,因此我们的核心就是设计完美的哈希函数尽量减少冲突(下面就会讲解,现在没必要懂),只要是的任意关键字经过哈希函数的处理之后获得的哈希函数值都落在表长允许的范围内就好
2)哈希函数:对任意的键值返回出相应的唯一的内存地址一共我们对数据的插入删除和存储
3)冲突:就像函数中会出现周期函数一样,我们完全也是就对会碰见的一种情况就是,针对不同的关键字我们可能会获得相同的内存地址,这就是冲突,相应的这两个关键字我们也称作同义词
4)哈希表:哈希表就是根据我们设定的哈希函数和我们设定的解决冲突的方法将一组关键字映射到一个连续的地址集(区间)上,并以关键字在地址中的像作为记录在表中的存储位置,这就叫做哈希表,这一映射过程我们也叫做建立哈希表或者散列,我们得到的存储位置叫做哈希地址或者散列地址
综上我们会发现哈希表的两个核心了:
1.建立哈希(构建哈希函数)根据哈希函数,建立映射
2.处理冲突:我们很难设计出完全没有冲突的哈希函数,所以我恶魔你对冲突的处理是非常有必要的
我们首先需要明确,哈希表这种方法是典型的空间换时间的做法
2哈希函数:
核心:简单+均匀(简单好理解,生成的映射过程简单)
首先我们对于哈希函数要有一个叫做 “均匀” 的认识,什么是均匀呢,我们知道如果我们设计的哈希函数如果在某些内存取值得时候会具有偏向性,那么我们会发现就算我们的哈希的内存开的非常的,冲突发生的概率还是很高,这就意味着我们的朝朝和插入的效率会变得越来越低下,所以说,均匀的意思是,对于每一个关键字我们都可以等概率的映射到内存地址上,从而使冲突最小化
我们设计出好的哈希函数可以为我们减少冲突的机会,从而使得我们的朝朝和插入效率都大大的提升,接近于完美的O(1)
哈希函数的构造的方法是非常多的,我们根据实际情况可以自己构造出不哦那个的哈希函数(并且因为哈希函数求解过程是不可逆的,所以说我们可以将其利用到加密公钥方面,好吧这是后话)
2.1直接定址法:
术语介绍:自身函数:我们通过直接定制的方法确定的哈希函数也叫做自身函数
这种哈希函数非常的简单,但是我们不常用
1.hashfunction(key)=key
2.hashfunction(key)=a*key+b(a,b是常数)
这种情况下,我们我们所需要的内存地址的大小和关键字的集合大小基本是一致的,所以这种情况是基本不会发生冲突的,但是应用较少
2.2除留取余法:
这是我们最常用的方法,其他的有些方法也是该方法拓展的得到的:
我们先来考虑如果关键字是正整数的情况下,这样好理解一点:
我们设哈希表长为M,p为模数(p<=M) (这里我们先知道一下,哈希表长一般都开的很大,并且是一个大素数,至于为什么素数,我不得其解)
哈希函数:hashfunction(key)=key % p (p<=M)
该哈希函数是我们最常用的也是应用最多的,我们不仅可以直接取模,还可以折叠取模或者平方取模,情况非常的多
这里我们要注意p的选择是非常重要的,p选的不好的话,可能已造成冲突的现象,影响我们的效率(p可以尽量的接近M,尽可能利用哈希表的大的存储空间,但是如果哈希表的存储空间开的太大,远远超过了关键字的范围则会造成空间浪费)
对于字符串的关键字,我们完全可以通过设计我们设计的哈希函数,将其每一位按照机器码来生成正整数然后我们在用除留取余的思路求解就好了
2.3目前为止不常用的方法:
数字分析法:我们通过分析关键字中的数位,发现如果数位中有几位的变化范围太小的话,那么这样我们生成的随机性就会下降,冲突的几率就会大大提高,所以我们根据对关键字数列的分析,找出随机性比较大的那几位数提取出来作为我们的新的关键字,然后设定我们的哈希函数
平房取中法:我们将关键字编码平方然后选取中间纪委做为新的关键字,具体方法:先通过求关键字的平方值扩大相近数的差别,然后根据表长度取中间的几位数作为散列函数值。又因为一个乘积的中间几位数和乘数的每一位都相关,所以由此产生的散列地址较为均匀
相乘取整法
该方法包括两个步骤:首先用关键字key乘上某个常数A(0<A<1),并抽取出key.A的小数部分;然后用m乘以该小数后取整。即:
该方法最大的优点是选取m不再像除余法那样关键。比如,完全可选择它是2的整数次幂。虽然该方法对任何A的值都适用,但对某些值效果会更好。Knuth建议选取
该函数的C代码为:
int Hash(int key){
double d=key *A; //不妨设A和m已有定义
return (int)(m*(d-(int)d));//(int)表示强制转换后面的表达式为整数
}
折叠法和随机数法:.........................
3.冲突处理:
我们对于冲突必须要有合适的处理方式:(先介绍一些不常用的,之后着重介绍常用的)
3.1建立公共溢出区:
我们哈希表长是m,先建立一个哈希表,然后再建立一个溢出表,如果我们每次插入记录的时候发现都是空,那我们就直接插入记录就好,凡是一旦我们发现冲突,同意都放进公共溢出区中
在查找的时候,如果我们计算出来的内存地址上的关键字值不匹配,只能说明一点,当时这里发生了冲突,我们要查找的记录在公共溢出区中,这样子的话,我们就只需要再花费O(k)(k是公共溢出区的大小)来线性遍历一遍公共溢出区就好,如果发现关键字值是匹配的那么就只用O(1)就找到了要找的记录,这种情况在冲突较少,哈希函数设定均匀额时候,操作效率非常高
3.2再哈希法:
实际上看上去每年工资很高打上,其实很好理解,再哈希的方法实际上就是我们对缠身冲突的时候,再次调用另一个哈希函数生成关键字对应的映射,知道我们的冲突不再发生为止,但是这种情况只适用于冲突发生比较低的情况,如果哦冲突发生比较高的话,我们就压迫设计很多的再哈希函数,这样实际上非常的累,也无用,而且增加了计算时间
3.3链地址法(拉链法)
这里我们应用数组+链表的形式,感觉这好像是线性表的知识点
连续的数组用来保存指定的关键字对应的映射位置
每个数组后面都是一个线性链表,我们处理冲突的方式就是,在发生冲突的时候,直接插入链表的新的节点就可以了,这个方法非常的常用
3.4开放定址法:(构建方法我们采用常用的除留取余法)
3.4.1线性探测再散列:
名字非常的高大上,实际上我来解释一下是什么一个原理
首先我们先介绍两个术语:
1.m哈希表长
2.d增量序列(我们每次要对d递增一次,马上就会讲到)
hashfunction(key)=(hashfunction(key)+d)%m
公式很简单,但是这里我来解释一下原理,初始的时候d是0,没有增量,如果我们一次就求出了关键字地址,并且很开心的发现,这个地址是空的,那么我们可以放心的直接在这个地址上插入我们的节点了
但是很不走运,如果我们关键字指定的地点非空(就是发生冲突),那么我们就回退到计算哈希值的过程上,出现一次冲突,增量序列+1,知道我们找到了空的地方为止(这里我们必须要加入一个控制因子来记录已经带插入的元素记录的个数,当超过装载因子的时候我们重新弄分配哈希表的大小,这一回在后面我们会讲到,现在只是提一下)
这个就是线性探测再散列的过程,可能有的同学会有疑问,那么我们下一次查找的时候怎么办呢,又不知道到底冲突了几次,根本没法找到啊(这里我下面在查找的时候会讲,其实是可以找到的,只要我们保证和BST 的性质一样,哈希表中不存在几点相同的节点)!!!这也引出来了我的问题
3.4.2二次探测再散列:
我们有的时候会发现,可能先行探测再散列太笨了,比若说不仅i号位置冲突了,i+1,i+2号位置都冲突了,我们就必须要让增量序列递增很多次才可以找到空位插入,但是如果这时候恰好i-1号位置是空的我们为什么就不能利用呢
这里我先道个歉,因为本身的能力问题,我不知道为什么是平方探测再散列,但是我知道了政府好的作用,求解于大神,这是我的一个问题
平方探测在三列种的增量序列d是这么变化的(d的变化都是有着固定的顺序的,所以查找的时候都是有规律的一定可以查找到)
d=1*1,-1*1,2*2,-2*2,....k*k,-k*k(k<=m/2) 这里为什么要小于一半,超表可以取余挽救,这个就看不懂了?
3.4.3伪随机数序列法:不常用
这里我们要介绍一个叫做二次聚集的术语:
可以这么解释:上面的例子中i,i+1,都已经占位了,我们这时候如果计算的哈希值是i,或者i+1的话,都会导致冲突,我们在查找的时候(你可以看完查找回来再看这一句话)如果i+2存储的是i的冲突之后的记录,那么我们i+1冲突之后下一个查找位置也是i+2,但是i+2和i+1的关键字没有任何关系,i+2是i的关键字指定的记录,这时候我们又要继续向后探查,增加了哦我们的冲突个数,降低了我们的效率,但是实际上,线性探查是避免不了这个缺陷的,就像拉链法也有自己的缺陷一样,线性探查的方法是冲突会过多
按照上面的例子来看,开放寻址的法会造成我们的二次聚集现象
4.查找分析
这里我们通常认为哈希表的理想的查找速度是O(1),但是实际上,因为存在着哈希冲突现象和我们的不同的冲突处理的方法,我们大多数情况下的哈希查找速度是常数级的O(c),但是这已经是非常优秀的了,毕竟空间换时间还是已经付出了我们的内存的代价了的
我们现在来分析一下我们的查找操作,我们按照主流的两个方式来讲解
1.拉链法
2.线形态测再散列(二测探测再散列):两者本质上其实是一样的
这里我在插一句嘴,我们在选取先行探测和拉链法的时候要有个判断标准,如果我们的记录的内容非常的多,那么我们可以采用拉链法,指针的大小可以忽略不计了,如果我们的记录内容非常的小,那么指针的大小会影响我们的效率和空间,我们还不如拿出来这些空间用来扩展我们的哈希表并且选取线性探测再散列的方法
4.1拉链法:
先附图:
左图就算是正常情况吧,右图是坏的哈希函数造就的哈希表退化成单链表
查找策略:
1.利用关键字计算哈希值,映射到对应的单链表上
2.对单链表进行遍历查找
2.1遍历找到返回true / 指向该内存位置的指针
2.2遍历结束没有找到,返回false / NULL
与开放定址法相比,拉链法有如下几个优点:
(1)拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
(2)由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
(3)开放定址法为减少冲突,要求装填因子α较小,故当结点规模较大时会浪费很多空间。而拉链法中可取α≥1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;
(4)在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。
(3)拉链法的缺点
拉链法的缺点是:指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度。
4.2线性探查:(线性探查需要我们不断地动态控制大小,否则会死循环(下面就会讲到))
附图好看:
线性探查的查找技术和我们插入技术是一样的
1.如果哈希值和我们找到的低智商的记录是匹配的,那么我们用O(1)的时间找到了
2.如果不匹配,那么我们增量序列+1,继续探查
3.如果我们发现找到的位置是空的话(说明该记录不存在于哈希表中),如果哈希表美满的话,改查找过程一定会终止(非常低效),但是如果满了的话会造成死循环,不断地便利我们的哈希表,导致程序崩溃
所以 为了判断我们的装填程度,我们这里引入一个术语:
装填因子=表中存在的记录数/哈希表大小(我们人为设定装填因子的大小,经验之谈是0.72,一旦超过了这个大小我们就要为线性探查技术的哈希表重新确定大小,这样我们要花费大量的时间,因为要遍历复制)
在这里我们要小心,线性探测再散列中我们删除节点的时候不能直接抹去节点,因为这个节点可能包含有冲突的信息,我们家是哪一个删除标记就好了,当然,正如你们所想的,这样子的话,会占用我们的空间,所以说一般涉及到删除操作多的时候我们都用拉链法
4.3.ASL计算(平均查找长度和平均查找失败长度)
平均查找长度ASL是有计算公式的,ASL的大小可以反映出处理冲突选择的方法的效率
ASL=(查找每个节点我们需要便利的节点的数目之和)/已经插入的节点的数目
平均查找失败的长度:查找不成功的情况下我们发现这一代你要遍历的数目的平均值(这个还没有做过相应的题,求谅解,还真不熟)
列举常见的方法的ASL和UNL:(都是≈,a代表装填因子)
线性探测再散列ASL≈(1+1/(1-a))/2
再哈希+随即探测再散列+二次探测再散列≈ -ln(1-a)/a
拉链法≈1+a/2
5.代码实现:(为了简单起见,哈希函数构造采用除留取余法)
talk is cheap,show you the code!
C++代码类封装,在代码中我还会讲解,还是没有看懂的,再看看代码吧:
包含操作:
1.建立哈希表
2.删除哈希表
3.重新构造哈希表(在查过装填因子的限制后,C++代码实现中我们实现重载一个=就好了,或者写个复制函数,复制结束后,删除原来的拥挤的哈希表就可以了)
4.插入节点
5.删除节点
6.查找结点
动态控制大小
static int prime_array[] = {
17, /* 0 */
37, /* 1 */
79, /* 2 */
163, /* 3 */
331, /* 4 */
673, /* 5 */
1361, /* 6 */
2729, /* 7 */
5471, /* 8 */
10949, /* 9 */
21911, /* 10 */
43853, /* 11 */
87719, /* 12 */
175447, /* 13 */
350899, /* 14 */
701819, /* 15 */
1403641, /* 16 */
2807303, /* 17 */
5614657, /* 18 */
11229331, /* 19 */
22458671, /* 20 */
44917381, /* 21 */
89834777, /* 22 */
179669557, /* 23 */
359339171, /* 24 */
718678369, /* 25 */
1437356741, /* 26 */
2147483647 /* 27 (largest signed int prime) */
};
这个是大小的素数集,每次我们挑选素数集大小,为什么是素数,我也不能讲清楚
5.1拉链法代码:
#include"iostream"
#include"cstdio"
#include"cstdlib"
#include"cstring"
using namespace std;
static int primarray[] = { //0-27 ,我们设计的装填因子是 0.7
17, /* 0 */
37, /* 1 */
79, /* 2 */
163, /* 3 */
331, /* 4 */
673, /* 5 */
1361, /* 6 */
2729, /* 7 */
5471, /* 8 */
10949, /* 9 */
21911, /* 10 */
43853, /* 11 */
87719, /* 12 */
175447, /* 13 */
350899, /* 14 */
701819, /* 15 */
1403641, /* 16 */
2807303, /* 17 */
5614657, /* 18 */
11229331, /* 19 */
22458671, /* 20 */
44917381, /* 21 */
89834777, /* 22 */
179669557, /* 23 */
359339171, /* 24 */
718678369, /* 25 */
1437356741, /* 26 */
2147483647 /* 27 (largest signed int prime) */
};
//记录信息的节点
typedef struct kkk
{
int data; //存储信息,也是关键字
struct kkk* next;
}point;
//设计结构体
typedef struct node
{
//在这里,哈希值就是数组的下标
point* next;
}pnode;
class hash
{
public:
hash() //相当于建立哈希表的操作
{
size=0;
number=0;
for(int i=0;i<primarray[size];i++)
{
hashtable[i].next=NULL;
}
}
~hash() //相当于删除清空哈希表的操作
{
for(int i=0;i<primarray[size];i++)
{
if(hashtable[i].next==NULL) continue;
point* head=hashtable[i].next;
while(hashtable[i].next!=NULL)
{
hashtable[i].next=hashtable[i].next->next;
free(head);
head=hashtable[i].next;
}
}
}
void operator==(hash&); //这里采用引用,就不会发生我们的函数传递值得时候发生的指针丢失的事件,该函数是重新分配哈希表大小的时候的复制函数
int hashfunction(int); //传入关键字,计算出哈希值
bool add(int); //因为这里关键字就是记录内容,所以这里简化了,添加操作,超过装填容量,返回1,否则返回0
bool del(int); //删除操作,删除成功,返回1,否则返回0
point* find(int); //查找函数,成功返回指向性指针,否则返回NULL
void visit()
{
for(int i=0;i<primarray[size];i++)
{
point* head=hashtable[i].next;
while(head!=NULL)
{
cout<<head->data<<' ';
head=head->next;
}
}
cout<<endl;
}
private:
pnode hashtable[9999]; //哈希表
int number; //已经装填的记录的数目
int size; //记录size在primarray中的编号,初始化时0,如果还小的话,我们再复制函数中递加一次就好
};
int hash::hashfunction(int key)
{
return key%primarray[size]; //充分利用所有的空间 ,减少冲突
}
bool hash::add(int p)
{
int key=hashfunction(p);
point* head=new point;
head->data=p;
head->next=hashtable[key].next;
hashtable[key].next=head; //采用头插法
number++;
if(number>=(int)primarray[size]*0.7) return 1;
else return 0;
}
bool hash::del(int p)
{
int key=hashfunction(p);
point* head=hashtable[key].next;
point* help=NULL;
while(head!=NULL)
{
if(head->data==p&&help!=NULL)
{
help->next=head->next;
free(head);
return 1;
}
else
{
if(head->data==p&&help==NULL)
{
hashtable[key].next=hashtable[key].next->next;
free(head);
return 1;
}
}
help=head;
head=head->next;
}
if(head==NULL)
{
cout<<"哈希表中没有该元素"<<endl;
return 0;
}
}
point* hash::find(int p)
{
int key=hashfunction(p);
point* head=hashtable[key].next;
while(head!=NULL)
{
if(head->data==p) return head;
head=head->next;
}
return NULL;
}
void hash::operator==(hash& k)
{
size=k.size+1;
for(int i=0;i<primarray[k.size];i++)
{
point* head=k.hashtable[i].next;
while(head!=NULL)
{
this->add(head->data);
head=head->next;
}
}
}
int main()
{
hash* my=new hash;
hash my2;
for(int i=1;i<=13;i++) //17*0.7≈12
{
int k;
cin>>k;
if(my->add(k)==1)
{
cout<<"到达装填上限,请扩充哈希表!";
my2==*my;
cout<<my->find(2)->data<<endl;
my->del(2);
my->visit();
delete my;
break;
}
}
cout<<"开始测试删除函数"<<endl;
int k;
my2.visit();
cin>>k;
cout<<my2.find(k)->data<<endl;
my2.del(k);
cout<<my2.find(k)->data<<endl;
return 0;
}
5.2线性探测再散列代码:
#include"iostream"
#include"cstdio"
#include"cstdlib"
#include"cstring"
#define N 1000
using namespace std;
static int primarray[] = {
17, /* 0 */
37, /* 1 */
79, /* 2 */
163, /* 3 */
331, /* 4 */
673, /* 5 */
1361, /* 6 */
2729, /* 7 */
5471, /* 8 */
10949, /* 9 */
21911, /* 10 */
43853, /* 11 */
87719, /* 12 */
175447, /* 13 */
350899, /* 14 */
701819, /* 15 */
1403641, /* 16 */
2807303, /* 17 */
5614657, /* 18 */
11229331, /* 19 */
22458671, /* 20 */
44917381, /* 21 */
89834777, /* 22 */
179669557, /* 23 */
359339171, /* 24 */
718678369, /* 25 */
1437356741, /* 26 */
2147483647 /* 27 (largest signed int prime) */
};
typedef struct node
{
int data; //保存信息 + 关键字
bool del; //删除标记,0代表存在,1代表已删除
}point;
class hash
{
public:
hash()
{
memset(hashtable,0,sizeof(hashtable));
number=size=0;
}
void operator=(hash&);
bool add(int);
bool del(int);
int find(int);
int hashfunction(int);
void visit(); //用于检测的遍历函数
private:
point hashtable[N]; //哈希表
int number; //目前已经插入的记录的数目
int size; //primarray数组的标号,记录哈希表的容量
};
void hash::operator=(hash& k)
{
size=k.size+1;
number=0;
memset(hashtable,0,sizeof(hashtable));
for(int i=0;i<primarray[k.size];i++) hashtable[i]=k.hashtable[i];
}
int hash::hashfunction(int p)
{
return p%primarray[size];
}
bool hash::add(int p)
{
int key=hashfunction(p);
if(hashtable[key].data==0) hashtable[key].data=p;
else
{
int d=1; //增量序列
while(hashtable[key].data!=0)
{
key=(hashfunction(p)+d)%primarray[size];
d++;
}
hashtable[key].data=p;
}
number++;
if(number>=(int)primarray[size]*0.7) return 1;
else return 0;
}
bool hash::del(int p)
{
int key=hashfunction(p);
if(hashtable[key].data==p) hashtable[key].del=1;
else
{
int d=1;
while(hashtable[key].data!=0&&hashtable[key].data!=p)
{
key=(hashfunction(p)+d)%primarray[size];
d++;
}
if(hashtable[key].data==p)
{
hashtable[key].del=1;
return 1;
}
else
{
cout<<"没有找到该节点"<<endl;
return 0;
}
}
}
int hash::find(int p)
{
int d=1;
int key=hashfunction(p);
while(hashtable[key].data!=0&&hashtable[key].data!=p)
{
key=(hashfunction(p)+d)%primarray[size];
d++;
}
if(hashtable[key].data==0) return -1;
else
{
if(hashtable[key].del==1)
{
cout<<"该节点曾经存在,但目前已经删除"<<endl;
return -1;
}
else return key;
}
}
void hash::visit()
{
for(int i=0;i<primarray[size];i++)
{
if(hashtable[i].del==0&&hashtable[i].data!=0) cout<<hashtable[i].data<<' ';
}
cout<<endl;
}
int main()
{
hash* my1=new hash;
hash* my2=new hash;
for(int i=1;i<=13;i++)
{
int k;
cin>>k;
if(my1->add(k)==1)
{
cout<<"目前装载的记录已经超过我们的装载上限,请扩充哈希表!"<<endl;
*my2=*my1;
my2->visit();
break;
}
}
cout<<my2->find(4)<<endl;
my2->del(4);
cout<<my2->find(4)<<endl;
return 0;
}
6.优缺点:
优点是查找快于树和线性表
缺点是散乱,如果我们还要求按照某个顺序排列的话,我们最好不要用散列表,因为还要牵扯到排序算法,而树和线性表我们都有有序的类型来解决这个问题
7.遗留问题:
1.和BST一样,是不是散列表中也要求不能有数据相同
2.还是不理解散列表的大小是素数的原因
3.平均失败查找长度
4.还可以如何优化重新构造散列表的操作
5.为了减少冲突,构造时候的模数一定要接近表长大小吗
以上问题欢迎有好思路和想法的大神在评论区告知我哦,感激不尽
以上原文!