散列(哈希 hash)

前言

散列(hash)是常用的算法之一。
为了了解hash我们先看一个简单的题。
题目:

给出N个正整数,再给出M个正整数,问这M个数中的每个数分别是否在N个数中出现过,
其中N,M<=100000,且每个正整数均不超过100000。
例:
M=3  (1,2,4)   N=3  (1,2,7)
1,2出现过  7没有出现过

最简单的思路当输入M中的数时就遍历一次N。这样算法的复杂度为O(MN)
当M和N很大的时候,算法显然太慢了。
那么如何做呢? 不妨用空间换时间
设定一个bool型数组hashTable[100010] , 其中hashTable[x]=true表示正整数x在N中出现过,
而hashTable[x]=false表示正整数x在N中没有出现过。
这样就可以在一开始读入N个正整数时就进行预处理,即当读入的数为x时,就令hashTable[x]=true
(说明:hashTable数组需要初始化为false,表示初始化状态下所有数均未出现过)。
于是,对M个欲查询的数,就能直接通过hashTable数组判断出每个数是否出现过。
这种算法的复杂度为O(N+M)。
代码如下:

#include<cstdio>
#include<algorithm>
using namespace std;
bool a[100010]={
    
    false};
int main(void)
{
    
    
  	int M,N;
  	int N_number,M_number;
  	int i,j;
  	scanf("%d%d",&N,&M);
  	for(i=0;i<N;i++)
  	{
    
    
  		scanf("%d",&N_number);
  		a[N_number]=true;
  	}
  	for(i=0;i<M;i++)
  	{
    
    
  		scanf("%d",&M_number);
  		if(a[M_number]==true)
  			printf("Yes\n");
  		else
  			printf("No\n");
  	}
    return 0;
}

如果问的是求M中的数字,在N中的数字中出现的次数。
只需要把数组定义为int型,找到一次就hashTable[x]++就可以了
代码如下:

#include<cstdio>
#include<algorithm>
using namespace std;
int a[100010]={
    
    0};
int main(void)
{
    
    
  	int M,N;
  	int N_number,M_number;
  	int i,j;
  	scanf("%d%d",&N,&M);
  	for(i=0;i<N;i++)
  	{
    
    
  		scanf("%d",&N_number);
  		a[N_number]++;
  	}
  	for(i=0;i<M;i++)
  	{
    
    	
  		scanf("%d",&M_number);
  		printf("%d\n",a[M_number]);
  	}
    return 0;
}

上面的两个问题都有一个特点,那就是直接把输入的数作为数组的下标来对这个数的性质进行统计(这种做法非常实用,请务必牢记)。

hash介绍

接着上面的习题,你会发现一个问题就是输入的数太大(109)或者输入的是一个一个字符串,就不能直接作为数组的下标了。
要是有一种方法可以把这些乱七八糟的元素转换为一个在能接受范围内的整数,那该多好啊。

于是散列(hash) 就诞生了。一般来说,散列可以浓缩成一句话"将元素通过一个函数转换为整数,使得该整数可以尽量唯一地代表这个元素“。其中把这个转换函数称为散列函数H ,也就是说,如果元素在转换前为key,那么转换后就是一个整数H(key)。
那么对key是整数的情况来说,有哪些常用的散列函数呢?
一般来说,常用的有:直接定址法、平方取中法、除留余数法等,
其中直接定址法是指恒等变换(即H(key)=key,本节开始的问题就是直接把key作为数组下标,是最常见最实用的散列应用)或是线性变换(即,H(key)=a*key+b);而平方取中法是指key的平方的中间若干位作为hash值(很少用)。
除留余数法是比较实用的。
陈留余数法是指把key除以一个数mod得到的余数作为hash值的方法,即 H(key)=key%mod
通过这个散列函数,可以把很大的数转换为不超过mod的整数,这样就可以将它作为可行的数组下标(注意 : 表长TSize必须不小于mod,否则会产生越界)。显然,当mod是一个素数时,H(key)能尽可能覆盖 [0,mod) 范围内的每一个数.因此一般为了方便起见,下文中取TSize是一个素数,而mod直接取成与TSize相等。

其实你会发现一个问题,通过除留余数法可能会有两个不同的数key1和key2,它们的hash值H(key1)与H(key2)是相同的,这样当key1已经把表中位置为H(key1)的单元占据时,key2便不能再使用这个位置了。我们把这种情况叫做 “冲突”

既然冲突不可避免,那就要想办法解决冲突。下面以三种方法来解决冲突为例,其中第一种和第二种都计算了新的hash值,又称为开放定址法

  1. 线性探查法(Linear Probing)
    当得到key的hash值H(key),但是表中下标为H(key)的位置已经被某个其他元素使用了,那么就检查下一个位置H(key) +1是否被占,如果没有,就使用这个位置;否则就继续检查下一个位置(也就是将hash值不断加1),如果检查过程中超过了表长,那么就回到表的首位继续循环,直到找到一个可以使用的位置,或者是发现表中所有位置都已被使用。显然,这个做法容易导致扎堆即表中连续若干个位置都被使用,这在一定程度上会降低效率。
  2. 平方探查法(Quadratic probing)
    在平方探查法中,为了尽可能避免扎堆现象,当表中下标为H(key)的位置被占时,将按下面的顺序检查表中的位置: H(key)+ 12、H(key)-12、H(key)+22、H(key)-22、H(key) + 32、…如果检查过程中H(key)+k2超过了表长TSize,那么就把H(key)+ k2对表长TSize取模;如果检查过程中出现H(key)- k2<0的情况(假设表的首位为0),那么将(H(key) -k2%TSize +TSize)% TSize作为结果(等价于将H(key)- k2不断加上TSize直到出现第一个非负数)。如果想避免负数的麻烦,可以只进行正向的平方探查。可以证明,如果k在[0, TSize)范围内都无法找到位置,那么当k≥TSize时,也一定无法找到位置。
  3. 链地址法(拉链法)
    和上面两种方法不同,链地址法不计算新的hash值,而是把所有H(key)相同的key连接成一条单链表。这样可以设定一个数组 Link,范围是Link[0] ~ Link[mod- 1], 其中Link[h]存放H(key)= h的一条单链表,于是当多个关键字key 的hash值都是h时,就可以直接把这些冲突的key直接用单链表连接起来,此时就可以遍历这条单链表来寻找所有H(key)=h的key。
    当然,一般来说,可以使用标准库模板库中的map 来直接使用hash的功能(C++11以后可以用unordered_map, 速度更快),因此除非必须模拟这些方法或是对算法的效粹要求比较高,一般不需要自己实现上面解决冲突的方法。

字符串hash初步

如果key不是整数,那么又应当如何设计散列函数呢?

一个例子是: 如何将一个二维整点P的坐标映射为一个整数,使得整点P可以由该整数唯一地代表。假设一个整点P的坐标是(x,y),其中0≤x, y≤Range,那么可以令hash函数为H§=X* Range+ y,这样对数据范围内的任意两个整点P与P2, H(P1)都不会 等于H(P2),就可以用H( P )来唯一地代表该整点 P,接着便可以通过整数hash的方法来进一步映射到较小的范围。

字符串hash是指将一个字符串 S映射为一个整数,使得该整数可以尽可能唯一地代表字符串 S。
为了讨论问题方便,先假设字符串均由大写字母A-Z构成。在这个基础上,不妨把A~ Z 视为0~25,这样就把26个大写字母对应到了二十六进制中。接着,按照将二十六进制转换为十进制的思路,由进制转换的结论可知,在进制转换过程中,得到的十进制肯定是唯一的,由此便可实现将字符串映射为整数的需求(注意:转换成的整数最大为是26len-1, 其中len 为字符串长度)。
代码如下:

int hushFunc(char s[],int len)//将字符串转换成整数
{
    
    
	int id=0;
	for(int i;i<len;i++)
	{
    
    
		id=id*26+s[i]-'A';//将26进制转换为10进制
	}
	return id;
}

显然,如果字符串S的长度较长,那么转换成的整数也会很大,因此需要注意使用时len不能太长。如果字符串中出现了小写字母,那么可以把 A~Z作为0-25,而把a-z作为26-51,这样就变成了52进制转换为10进制的问题,做法也是相同的;

int hushFunc(char s[]),int len)
{
    
    
	int id=0;
	for(int i;i<len;i++)
	{
    
    
		if(s[i]>='a'&&s[i]<='z')
		id=id*52+s[i]-'A'+26;
		else if(s[i]>='A'&&s[i]<='Z')
		id=id*52+s[i]-'A';
	}
	return id;
}

而如果出现了数字,一般有两种处理方法:

  1. 按照小写字母的处理方法,增大进制数至62。
  2. 如果保证在字符串的末尾是确定个数的数字,那么就可以把前面英文字母的部分按上面的思路转换成整数,再将末尾的数字直接拼接上去。例如对由三个字符加一位数字组成的字符串“BCD4"来说,就可以先将前面的“BCD"转换为整数(12626+2*26+3)731,然后直接拼接上末位的4变为7314即可

下面的代码体现了这个例子:

int hushFunc(char s[]),int len)
{
    
    
	int id=0;
	for(int i;i<len-1;i++)//末位为数字,所以排除末位
	{
    
    
		if(s[i]>='a'&&s[i]<='z')
		id=id*52+s[i]-'A'+26;
		else if(s[i]>='A'&&s[i]<='Z')
		id=id*52+s[i]-'A';
	}
	id=id*10+s[len-1]-'0';
	return id;
}

练习题

给出N个字符串(由恰好三位大写字母组成),再给出M个查询字符串,问每个查询字符串在N个字符串中出现的次数。

#include<cstdio>
int hushF(char a[],int len)
{
    
    
	int id=0;
	for(int i=0;i<len;i++)
	{
    
    
		id=id*26+a[i]-'A';
	}
	return id;
}
int hush[26*26*26+10];
char s[100][5],temp[5];
int main(void)
{
    
    
	int M,N;
	int i;
	scanf("%d%d",&N,&M);
	for(i=0;i<N;i++)
	{
    
    
		scanf("%s",s[i]);
		hush[hushF(s[i],3)]++;
	}
	for(i=0;i<M;i++)
	{
    
    
		scanf("%s",temp);
		printf("%d\n",hush[hushF(temp,3)]);
	}
}

猜你喜欢

转载自blog.csdn.net/qq_46527915/article/details/114670948