Hash算法思想和应用

一、哈希定义
哈希表(也可以叫做散列表),是根据  键(Key)而直接访问在内存存储位置的数据结构,也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做哈希表
二、基本概念
  • 若关键字为 k {\displaystyle k},则其值存放在 f ( k ) {\displaystyle f(k)} 的存储位置上。由此,不需比较便可直接取得所查记录。称这个对应关系 f {\displaystyle f} 为散列函数,按这个思想建立的表为散列表。
  • 对不同的关键字可能得到同一散列地址,即 k 1 ≠ k 2 {\displaystyle k_{1}\neq k_{2}},而 f ( k 1 ) = f ( k 2 ) {\displaystyle f(k_{1})=f(k_{2})} ,这种现象称为冲突。具有相同函数值的关键字对该散列函数来说称做同义词。综上所述,根据散列函数 f ( k ) {\displaystyle f(k)} 和处理冲突的方法将一组关键字映射到一个有限的连续的地址集(区间)上,并以关键字在地址集中的“像”作为记录在表中的存储位置,这种表便称为散列表,这一映射过程称为散列造表或散列,所得的存储位置称散列地址。
  • 若对于关键字集合中的任一个关键字,经散列函数映象到地址集合中任何一个地址的概率是相等的,则称此类散列函数为均匀散列函数,这就是使关键字经过散列函数得到一个“随机的地址”,从而减少冲突。
三、构造散列函数

散列函数能使对一个数据序列的访问过程更加迅速有效,通过散列函数,数据元素将被更快定位。

  1. 直接定址法:取关键字或关键字的某个线性函数值为散列地址。即 h a s h ( k ) = k {\displaystyle hash(k)=k} h a s h ( k ) = a ⋅ k + b {\displaystyle hash(k)=a\cdot k+b} ,其中 a b {\displaystyle a\,b} 为常数(这种散列函数叫做自身函数)
  2. 数字分析法:假设关键字是以r为基的数,并且哈希表中可能出现的关键字都是事先知道的,则可取关键字的若干数位组成哈希地址。
  3. 平方取中法:取关键字平方后的中间几位为哈希地址。通常在选定哈希函数时不一定能知道关键字的全部情况,取其中的哪几位也不一定合适,而一个数平方后的中间几位数和数的每一位都相关,由此使随机分布的关键字得到的哈希地址也是随机的。取的位数由表长决定。
  4. 折叠法:将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址。
  5. 随机数法
  6. 除留余数法:取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 h a s h ( k ) = k mod p {\displaystyle hash(k)=k\,{\bmod {\,}}p},p ≤ m {\displaystyle p\leq m}。不仅可以对关键字直接取模,也可在折叠法、平方取中法等运算之后取模。对p的选择很重要,一般取素数或m,若p选择不好,容易产生冲突。
四、处理冲突

为了知道冲突产生的相同散列函数地址所对应的关键字,必须选用另外的散列函数,或者对冲突结果进行处理。而不发生冲突的可能性是非常之小的,所以通常对冲突进行处理。常用方法有以下几种:

  • 开放定址法: h a s h i = ( h a s h ( k e y ) + d i ) mod m {\displaystyle hash_{i}=(hash(key)+d_{i})\,{\bmod {\,}}m},i = 1 , 2... k ( k ≤ m − 1 ) {\displaystyle i=1,2...k\,(k\leq m-1)},其中 h a s h ( k e y ) {\displaystyle hash(key)} 为散列函数, m {\displaystyle m} 为散列表长, d i {\displaystyle d_{i}} 为增量序列, i {\displaystyle i} 为已发生冲突的次数。增量序列可有下列取法:
d i = 1 , 2 , 3... ( m − 1 ) {\displaystyle d_{i}=1,2,3...(m-1)}称为线性探测;即 d i = i {\displaystyle d_{i}=i} ,或者为其他线性函数。相当于逐个探测存放地址的表,直到查找到一个空单元,把散列地址存放在该空单元。
d i = ± 1 2 , ± 2 2 , ± 3 2 . . . ± k 2 {\displaystyle d_{i}=\pm 1^{2},\pm 2^{2},\pm 3^{2}...\pm k^{2}}( k ≤ m / 2 ) {\displaystyle (k\leq m/2)}称为平方探测。相对线性探测,相当于发生冲突时探测间隔 d i = i 2 {\displaystyle d_{i}=i^{2}} 个单元的位置是否为空,如果为空,将地址存放进去。
d i = {\displaystyle d_{i}=}伪随机数序列,称为伪随机探

聚集的意思是,在函数地址的表中,散列函数的结果不均匀地占据表的单元,形成区块,造成线性探测产生一次聚集和平方探测的二次聚集,散列到区块中的任何关键字需要查找多次试选单元才能插入表中,解决冲突,造成时间浪费。对于开放定址法,聚集会造成性能的灾难性损失,是必须避免的。

  • 单独链表法(拉链法):拉链法解决冲突的做法是将所有通过哈希函数计算出来的哈希地址相同的结点存放在一个单链表之中,实现原理就是将哈希表定义为一个由N个头指针组成的指针数组,经过哈希函数计算得到的哈希地址相同的数据全部连在对于的头指针下面。它继承了数组的易于查找和链表便于删除插入的特点。拉链法因为加入了链表,所以在查找删除的时候时间复杂度也退化为了O(N),但是如果在哈希函数给的合理,并且哈希表的容量足够的时候,拉链法的时间复杂度肯定是远远小于O(N)。
  • 双散列。
  • 再散列: h a s h i = h a s h i ( k e y ) {\displaystyle hash_{i}=hash_{i}(key)},i = 1 , 2... k {\displaystyle i=1,2...k} h a s h i {\displaystyle hash_{i}} 是一些散列函数。即在上次散列计算发生冲突时,利用该次冲突的散列函数地址产生新的散列函数地址,直到冲突不再发生。这种方法不易产生“聚集”,但增加了计算时间。
  • 建立一个公共溢出区
五、查找效率和时间复杂度

散列表的查找过程基本上和造表过程相同。一些关键码可通过散列函数转换的地址直接找到,另一些关键码在散列函数得到的地址上产生了冲突,需要按处理冲突的方法进行查找。在介绍的三种处理冲突的方法中,产生冲突后的查找仍然是给定值与关键码进行比较的过程。所以,对散列表查找效率的量度,依然用平均查找长度来衡量。

查找过程中,关键码的比较次数,取决于产生冲突的多少,产生的冲突少,查找效率就高,产生的冲突多,查找效率就低。因此,影响产生冲突多少的因素,也就是影响查找效率的因素。影响产生冲突多少有以下三个因素:

  1. 散列函数是否均匀;
  2. 处理冲突的方法;
  3. 散列表的载荷因子(英语:load factor)。

载荷因子

散列表的载荷因子定义为: = 填入表中的元素个数 / 散列表的长度

是散列表装满程度的标志因子。由于表长是定值,与“填入表中的元素个数”成正比,所以,越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子的函数,只是不同处理冲突的方法有不同的函数。

对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。


无冲突的hash table复杂度是O(1),一般是O(c),c为哈希关键字冲突时查找的平均长度,最坏情况仍然是O(N)。
六、哈希应用举例
//输入两个字符串,从第一个字符串中删除第二个字符串中所有的字符。
//示例
//输入:They are students aeiou
//输出:Thy r stdnts
void deleteFirstInSecond(string& d,const char* dst)
{
	char* src = (char*)d.c_str();

	char* slow = src;
	bool count[256] = {false};
	while(*dst)//O(n)
	{
		count[*dst++] = true;
	}
	/*
	初始的时候都指向第一字符的起始位置,当pFast指向的字符是需要删除的字符,
	则pFast直接跳过,指向下一个字符。如果pFast指向的字符是不需要删除的字符,
	那么把pFast指向的字符赋值给pSlow指向的字符,并且pFast和pStart同时向后移动指向下一个字符。
	这样,前面被pFast跳过的字符相当于被删除了
	*/
	//while (*src!='\0')
	//{
	//	if (count[*src] == false)
	//	{
	//		*slow = *src;
	//		slow++;
	//	}
	//	src++;
	//}
	//*slow = '\0';
	int len = 0;
	for (int i = 0; src[i]; i++)
	{
		if (!count[src[i]])
			src[len++] = src[i];
	}
	src[len] = 0;


}


运用Hash思想,直接将这个时间复杂度降为O(n),再比如下面这个例子
        if (n > 2)
	{
		printf("Yes");
	}
	else
	{
		printf("No");
	}


	//上面的代码可以用下面两句替换
	int a[2][] = { "No","Yes" };
	printf(a[n > 2]);



七、优缺点
1.开放散列(open hashing)/ 拉链法(针对桶链结构)
1)优点:
①对于记录总数频繁可变的情况,处理的比较好(也就是避免了动态调整的开销)
②由于记录存储在结点中,而结点是动态分配,不会造成内存的浪费,所以尤其适合那种记录本身尺寸(size)很大的情况,因为此时指针的开销可以忽略不计了
③删除记录时,比较方便,直接通过指针操作即可
2)缺点:
①存储的记录是随机分布在内存中的,这样在查询记录时,相比结构紧凑的数据类型(比如数组),哈希表的跳转访问会带来额外的时间开销
②如果所有的 key-value 对是可以提前预知,并之后不会发生变化时(即不允许插入和删除),可以人为创建一个不会产生冲突的完美哈希函数(perfect hash function),此时封闭散列的性能将远高于开放散列
③由于使用指针,记录不容易进行序列化(serialize)操作
2.封闭散列(closed hashing)/ 开放定址法
1)优点:
①记录更容易进行序列化(serialize)操作
②如果记录总数可以预知,可以创建完美哈希函数,此时处理数据的效率是非常高的
2)缺点:
①存储记录的数目不能超过桶数组的长度,如果超过就需要扩容,而扩容会导致某次操作的时间成本飙升,这在实时或者交互式应用中可能会是一个严重的缺陷
②使用探测序列,有可能其计算的时间成本过高,导致哈希表的处理性能降低
③由于记录是存放在桶数组中的,而桶数组必然存在空槽,所以当记录本身尺寸(size)很大并且记录总数规模很大时,空槽占用的空间会导致明显的内存浪费
④删除记录时,比较麻烦。比如需要删除记录a,记录b是在a之后插入桶数组的,但是和记录a有冲突,是通过探测序列再次跳转找到的地址,所以如果直接删除a,a的位置变为空槽,而空槽是查询记录失败的终止条件,这样会导致记录b在a的位置重新插入数据前不可见,所以不能直接删除a,而是设置删除标记。这就需要额外的空间和操作。
3.哈希表相对于其他数据结构的优缺点
1)优点: 记录数据量很大的时候,处理记录的速度很快,平均操作时间是一个不太大的常数
2)缺点:
①好的哈希函数(good hash function)的计算成本有可能会显著高于线性表或者搜索树在查找时的内部循环成本,所以当数据量非常小的时候,哈希表是低效的
②哈希表按照 key 对 value 有序枚举(ordered enumeration, 或者称有序遍历)是比较麻烦的(比如:相比于有序搜索树),需要先取出所有记录再进行额外的排序
③哈希表处理冲突的机制本身可能就是一个缺陷,攻击者可以通过精心构造数据,来实现处理冲突的最坏情况。即:每次都出现冲突,甚至每次都出现多次冲突(针对封闭散列的探测),以此来大幅度降低哈希表的性能。这种攻击也被称为基于哈希冲突的拒绝服务攻击

参考:
https://zh.wikipedia.org/wiki/%E5%93%88%E5%B8%8C%E8%A1%A8  维基百科


你可能感兴趣的:(数据结构)