散列(hash)
是常用的算法思想之一,在很多程序中都会有意无意地使用到。
先来看一个简单的问题:给出N个正整数,再给出M个正整数,问这M个数中的每个数分别是否在N个数中出现过,其中N,M都小于等于105,且所有正数均不超过105。例如N=5,M=3,N个正整数为{8,3,7,6,2},欲查询单的M个正整数为{7,4,2},于是后者之中有7和2在N个正整数中出现过,而4是没有出现的。
方法一:
对这个问题,最直观的思路是:对每个欲查询的正整数x,遍历所有N个数,看是否有一个数与x相等。这种做法的时间复杂度为O(MN),当M和N都很大(105级别)时,显然是无法承受的。
该如何做呢?
方法二(哈希思想):
不妨用空间换时间,即设定一个bool类型的数组hashTable[100010],其中hashTable[x]==true表示正整数x在N这个正整数中出现过,而hashTable[x]==false表示整数x在N个正整数里面没有出现过。这样就可以在一开始读入N个正整数时就进行预处理,即当读入的数为x时,就令tableTable[x]==true(初始化的时候权威false)。于是,对于M个欲查询的数,就能直接通过hashTable数组判断出每个数是否出现过。显然这种做法的时间复杂度为O(N+M)
代码如下:
#include<cstdio>
const int maxn = 100010;
bool hashTable[maxn] = {false};
int main()
{
int n,m,x;
scanf("%d%d",&n,&m);
for(int i = 0;i<n;i++)
{
scanf("%d",&x);
hashTable[x] = true;
}
for(int i =0;i<m;i++)
{
scanf("%d",&x);
if(hashTable[x]){
printf("YES\n");
}else{
printf("NO\n");
}
}
}
上面的两个问题都有个特点, 那就是直接把输入的数作为数组的下标来对这个的性质进行统计(这种做法非常实用,请务必掌握).这是一个很好的用空间换时间的策略,因为它将查询的复杂度降到了O(1)级别。但是,这个策略暂时还有一个问题一上面的题目中出现的每个数都不会超过105,因此直接作为数组下标是可行的,但是如果输入可能是109大小 的整数(例如111)或者甚至是一个字符串(例如"I Love You"),就不能将它们直接 作为数组下标了。要是有种做法, 可以把这些乱 七八糟的元素转换为一个在能接受范围内的整数,那该多么美好呀!
这样的做法当然是存在的,那就是散列(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直接取成与TSie相等。
但是稍加思考便可以注意到,通过除留余数法可能会有两个不同的数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(P )=x* Range +y,这样对数据范围内的任意两个整点P1与P2, H(P )都不会等于H(P2) 就可以用H§来唯一地代表该整点 P,接着便可以通过整数hash的方法来进一步映射到较小的范围。
本节的重点在于字符串hash。字符串hash是指将一一个字符串 s映射为-一个整数,使得 该整数可以尽可能唯一地代表字符串 S。本节只讨论将字符串转换为唯一的整数。
为了讨论问题方便,先假设字符串均由大写字母A~Z构成。在这个基础上,不妨把A~Z视为0~25,这样就把26个大写字母对应到了二十六进制中。接着,按照将二十六进制转换为十进制的思路,由进制转换的结论可知,在进制转换过程中,得到的十进制肯定是唯一的, 由此便可实现将字符串映射为整数的需求(注意: 转换成的整数最大为是26len-1,其中len为字符串长度)。代码如下:
int hashFunc(char S[], int len) {//hash函数,将字符串s转换为整数
intid=0;
for(inti= 0; i< len; 1++)
id=id* 26+(S[i]- 'A');/ /将二十六进制转换为十进制
return id;
显然,如果字符串S的长度比较长,那么转换成的整数也会很大,因此需要注意使用时 Ien 不能太长。果字将单中出现小写字母: 那么可以把A~Z作为0~25. 而把a~z作为 26~51,这样就变成了五十二进制转换为进制的问题,做法也是相同的:
int hashFunc(char S[],int len)//hash函数,将字符串s转换为整数
int id= 0;
for(int i= 0; i <len;i++){
if(S[i] >= 'A' && S[i] <='Z')
id= id* 52+(S[i]-'A');
} else if(S[i] >= 'a' && s[i]<='z'){ id=id* 52+ (S[i]-'a')+ 26;
return id;
}
而如果出现了数字,一般有两种处理方法:
①按照小写字母的处理方法,增大进制数至62。
②如果保证在字符串的末尾是确定个数的数字,那么就可以把前面英文字母的部分按上面的思路转换成整数,再将末尾的数字直接拼接上去。例如对由三个字符加一位数字组成的字符串“BCD4”来说,就可以先将前面的“BCD”转换为整数731,然后直接拼接上末位的4变为7314即可。下面的代码体现了这个例子:
int hashFunc(char S[], int len) ({村//hash函数,将字符串s转换为整数
int id= 0;
for(int i=0; i< len -1; i++) {
//末位为数字,因此除外末位
id = id*26+(S[i]- 'A') ;
}
id = id *10+(S[len- 1]-'0') ;
return id;
}
以一个问题结尾:给出N个字符串(由恰好三位大写字母组成),再给出M个查询字符串,问每个查询字符串在N个字符串中出现的次数。
下面直接给出使用字符串hash的代码,读者可以自己体会一下:
#include <cstdio>
const int maxn = 100;
char S [maxn][5],temp[5] ;
int hashTable[26* 26* 26+10] ;
int hashFunc (char S[],int len) {//hash函数,将字符串s转换为整数 int id= 0;
for(inti=0;i<len;i++){
id=id*26+(S[i]-'A');
}
return id;
}
int main() {
int n, m;
scanf ("d&d", &n,&m);
for(inti =0;i< n;i++) {
scanf ("号s", S[i]) ;
int id = hashFunc(S[i], 3);//将字符串S[i]转换为整数
hashTable[id]++;//该字符 串的出现次数加1
}
for (int i=0;i<m; i++){
scanf("%d",temp) ;
int id = hashFunc(temp, 3) ;//将字符串temp转换为整数
printf ("%d\n",, hashTable[id]); / /输出该字符串的出现次数
}
return 0;
}