时,平均查找长度取得最小值n+1.所以,为了使得 ASL 最小,一般我们分块时选择“开方”分法。
散列表|Hash Table
引例
HDU 1425 Sort
Problem Description
给定n 个整数,要求按从大到小的顺序输出其中前m 大的数。
Input
每组测试数据有两行,第一行有两个数n,m(0<n,m<1000000),第二行包含n 个各不相同,且都处于区间[−500000,500000] 的整数。
Output
对每组测试数据按从大到小的顺序输出前m 大的数。
对于本题,很明显能想到使用经典算法的几大排序(比如冒泡排序、选择排序、快速排序等)解决。
亦或者,直接使用STL中提供的 <algorithm>
调用 sort()
函数进行求解。时间复杂度O(nlogn).
然而对于本题,是不能 Accept 的。因此我们需要寻求更快的方法!
在题干中,给出了大区间,这也从另一个角度说明了对本题使用普通排序算法会消耗大量时间;题干中还有明显的“整数”字样,不仅如此还强调每一个整数都 “各不相同”,这就是一个突破口!
考虑用数组下标来映射实际数值,如读取到关键字k 时,对以k 为下标的数组元素置1,即:a[k]←1(初始化数组全零)。
以这种方式来保存数据的话,相当于利用数组(下标)的连续性自动进行了所有排序,于是时间复杂度为O(n)。
事实上,这更类似于 桶排序,当数据存储完毕之时,也是数据排序完毕之时。
那,话不多说,直接贴出AC代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
#include<iostream> #include<string.h> using namespace std; int a[1000001]; int main(void){ int tmp,num,m; while(scanf("%d%d",&num,&m) == 2){ memset(a,0,sizeof(a)); for(int i = 0; i < num; i++){ scanf("%d",&tmp); a[tmp+500000] = 1; } for(int i = 1000000; i >= 0; i--){ if(m && a[i] != 0){ if(m > 1) printf("%d ",i-500000); else printf("%d",i-500000); m--; if(m == 0) break; } } printf("\n"); } return 0; }
|
## 散列算法简介散列算法(Hash Algorithm),又称哈希算法,杂凑算法,是一种从任意文件中创造小的数字「指纹」的方法。简而言之,就是映射。
- 散列函数(Hash 函数):一个把查找表中的关键字映射成该关键字对应的地址的函数,记为:H(key)=Addr。这里的地址可以是数组下标、索引或内存地址等。
- 散列表(Hash 表):根据关键字而直接进行访问的数据结构。也就是说散列表建立了关键字和存储地址之间的一种之间映射关系。
Hash 算法就是对 Hash 函数的设计。使得对任意一个关键字或任意长度的明文可以映射为唯一的内容,并且要求不同的明文很难映射为相同的 Hash 值,即具有较好的抗冲突性。
给定一组范围确定的关键字(个数为n ),并依次将其通过 Hash 函数求得的 Hash 值与之组成唯一的键值对存放于表中,则这个表就是 Hash 表。
如果采用顺序存储,数组记为A[1..n],则可以直接用数组及其下标分别代表关键字及其哈希值,于是就有:
A[H(key)]=key
特点
- 正向快速:给定明文和 Hash 算法,在有限时间和有限资源内能计算得到 Hash 值。
- 逆向困难:给定 Hash 值,在有限时间内很难逆推出明文。
- 输入敏感:原始输入信息发生任何变化,新的 Hash 值都应该出现很大变化。
- 冲突避免:很难找到两段内容不同的明文,使得它们的 Hash 值一致。
种类
常见 Hash 算法有 MD5 和 SHA 系列,目前 MD5 和 SHA1 已经被破解,一般推荐至少使用 SHA2-256 算法。
Hash 函数是把一个大范围映射到一个小范围,目的往往是为了节省空间,使得数据容易保存,另外 Hash 函数也会应用于查找上。
散列函数的构造
直接定位法
直接取关键字的某个线性函数值为散列地址,即:
H(key)=keyorH(key)=a×key+b
此方法计算简单且不会发生碰撞冲突,适合关键字分布基本连续的情况,否则会使得空位较多,造成存储空间的浪费。
在引例中,我们就是用H(x)=x+500000 这样的函数来进行映射的。
除留余数法
除留余数法是一种最简单也是最常用的方法。
假定 Hash 表的表长为n,取一个不大于n 但最接近n 的质数p,则可利用下式构造 Hash表:
H(key)=keymodp
此方法的关键是选取最合适的p,以使得每个关键字都能通过哈希函数转换后能等概率地映射到哈希表的任一位置,以减少冲突的可能性。
数字分析法
假设关键字集合中的每个关键字都是r 进制数,而分析关键字集中的全体,并从中提取r 个数码在每一位上出现的频率,通过频率的分布情况进行哈希值的选择。以均匀分布的若干位或它们的组合作为地址。丢掉分布不均匀的位。
数字分析法只适合于所有关键字值已知的情况。通过分析分布情况把关键字取值区间转化为一个较小的关键字取值区间。
平方取中法
由于整数相除的运行速度通常比相乘要慢,所以有意识地避免使用除余法运算可以提高散列算法的运行时间。
平方取中法的具体实现是:先通过求关键字的平方值,从而扩大相近数的差别,然后根据表长度取中间的几位数(往往取二进制的比特位)作为哈希值。
因为一个乘积的中间几位数与乘数的每一数位都相关,所以由此产生的散列地址较为均匀。
该方法适用于关键字每一位取值都不够均匀或小于散列地址所需位数的情况。
举例如下:
关键字 | 关键字的平方 | 哈希值 |
---|
1234 | 1522756 | 227 |
2143 | 4592449 | 924 |
4132 | 17073424 | 734 |
3214 | 10329796 | 297 |
解决碰撞冲突
不难发现既然输入关键字的大小(不是个数)是不固定的,而输出的哈希值却是固定长度的,这就意味着哈希值是一个有限集合。而输入数据则可以是无穷多个,那么建立一对一关系明显是不现实的。
因此“碰撞”是必然会发生的,所以一个成熟的哈希算法会有较好的抗冲突性,同时在实现哈希表的结构时也要考虑到哈希冲突的问题。
开放定址法
一般地,可以在发生冲突时,为关键字找到下一个“空”的 Hash 地址后,再将此地址作为其哈希值。
开放定址法是指可存放新表项的空闲地址既向它的同义词表项开放(即哈希值等于此地址的关键字),也向它的非同义词表项开放。简而言之无论如何,只要按照某种算法能将某一关键字放入该地址,那么此地址就允许放入,无所谓是否同义。
以Hi 表示对一个关键字的第i 次探测得到的散列地址,在第k 次找到空地址,则有:
Hi=[H(key)+di]modn
其中,i=0,1,2,⋯,k(k≤n−1) ,n 为散列表长度(区别于除留余数法的p ),di 为增量序列。
对于di 的选取方式不同,也可以得到不同的结果,选取方式也对应着不同的方法。
在开放定址情况下,不能物理删除表中已有元素。若删除元素将会截断其他具有相同散列地址的元素查找地址。所以,欲删除元素,可以为其做一个删除标记,进行逻辑删除。
此外,此处理方式也有弊端,执行多次删除后会出现散列表看似很满,实则都是已删除元素的情况,因此需要定期对散列表进行维护。
线性探测法
增量序列di=0,1,2,⋯,n−1 时,称为线性探测法。(也叫线性探测再散列)
即冲突发生时,顺序查看表中的下一个单元,再冲突再查,查到表尾时再冲突则下一步回到表首继续查,直到找到第一个空位或表完全遍历了一遍结束。
线性探测法可能使第i 个散列地址的同义词存入到第i+1 的位置,这就使得原本应该存放在i+1 的关键字又顺延到第i+2 的位置,考虑最坏情况就会导致散列地址上产生大量堆积(也叫“聚集”),大大降低了查找效率。
堆积现象对存储效率、散列函数和填装因子均无影响,只有平均查找程度 ASL 因堆积现象而增大。
平方探测法
增量序列di=02,12,−12,22,−22⋯,k2,−k2 时,称为平方探测法/二次探测再散列。其中,k≤n/2,且散列表长度n 必须是一个可以表示成4k+3 的质数。
平方探测法是一种处理冲突较好的方法,可以避免堆积问题,但不能探测到散列表的所有单元,至少能探测一半的单元。
双散列法
取增量序列di=i×Hash2(key) 时,称为 双散列法。
即使用两个散列函数. 通过第一个散列函数Hash1(key) 发生冲突时,利用第二个散列函数计算地址增量,有:
Hi=[Hash1(key)+i×Hash2(key)]modn
此处的i 是冲突次数,初始为零。
通过该方法最多经过n−1 次探测就会遍历完表中所有位置,回到H0=Hash1(key)%m 处。
伪随机序列法
顾名思义,当di 取伪随机数时,称为伪随机序列法。
拉链法 |Chaining
为了避免非同义词的占用发生的冲突,可以采用链表数组的方式将同义词都存储在同一数组下标所指的一条链表中,该下标由散列函数唯一给出。
拉链法适用于经常插入和删除的情况。
下图是利用除留余数法构造 Hash函数,利用拉链法解决冲突的一个示例图。
散列算法的使用
下面给出一例题,可利用 Hash 函数的思想进行求解:
HDU 1496 Equations(已翻译)
Problem Description
给出如下等式:
ax12+bx22+cx32+dx42=0
a,b,c,d 是范围在[−50,50] 内的整数 ,且均不为 0.
考虑给出一个解:(x1,x2,x3,x4) 使得上述等式成立 , 其中xi∈[−100,100] ,且xi=0,i=1,2,3,4.
对于给定的a,b,c,d 请编程得出解决方案共有多少组。
Input
输入包含多组测试. 每行输入 4 个整数分别表示 a, b, c, d
, 用空格隔开
Output
对于给定的每组数据, 输出一行结果.
Sample Input
Sample Output
本题题目很简单,与最经典的算法题:百元买百鸡 如出一辙。
因此首先会想到最常规的方法,即:使用四层嵌套循环依次得出符合要求的数据,再利用计数变量统计方案数。
显然,从 -100
到 100
每一层循环都要运算201次,时间复杂度非常之高。
因此我们必须考虑使用其他算法……
考虑将原式变形,得到:
ax12+bx22=−(cx32+dx42)
接下来再一步步优化算法。
- 【空间减少】由于题目所给的x 区间[−100,100] 是对称的,所以,其实xi2 是可以只用100个数据代表的(题目中说xi 不为0);
- 【剪枝】 对于ax12+bx22+cx32+dx42=0 ,当所给a,b,c,d 均大于0 或 均小于0时,不用考虑,方案数直接为0;
- 【循环减少】 两层循环保存左边的ax12+bx22,两层循环验证右边的−(cx32+dx42) ,如果相等,计数变量+1;
上述优化中的第三点就比较耐人寻味了,因为如何进行“验证相等”成了一个难点。
这时,我们就可以把Hash表用上来了。
可以建立映射:
MarkNum=Hash(data)
在验证时将右边得到的数据作为变量,通过hash函数得出是否出现过,如果出现,出现了几次。
相应的,我们在计算左边数据的时候需要利用hash函数将数据保存并将其做标记。
这样一来,如果右边得出的数据通过hash能够得出的结果是已经做了标记,就说明二者相等,验证完成。
之后,便可以得到AC代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| #include "stdio.h" #include "memory.h" int xx[101]; int hash[2000003]; int main(void){ int a,b,c,d; int i,j,sum; for(i=1;i<101;i++) xx[i] = i*i; while(scanf("%d %d %d %d",&a,&b,&c,&d)!=EOF){ if((a>0 && b>0 && c>0 && d>0)||(a<0 && b<0 && c<0 && d<0)){ printf("0\n"); continue; } memset(hash,0,sizeof(hash)); for(i=1;i<=100;i++) for(j=1;j<=100;j++) hash[a * xx[i] + b * xx[j] + 1000000]++; sum = 0; for(i=1;i<=100;i++) for(j=1;j<=100;j++) sum += hash[-(c * xx[i] + d * xx[j]) + 1000000]; printf("%d\n",sum*16); } }
|
❔Q:为什么保存 hash 时要+1000000?
✅A:本题中,a,b,c,d 出现负数,最值是 -50,其次,x2 最值是10000。因此,负数的极限值是:50×10000+50×10000
❔Q:为什么 sum 要乘以16?
✅A:算法只给出了x1∼x4 的情况,事实上这4个量全排列可以得到16种情况(题目问的是总方案数)
然而,事实上整个算法还可以再优化!
两层循环最多只可能产生10000个不同的结果,
开200W的数组将会浪费很多初始化的时间,所以开小数组+处理冲突会比较好.
——by LaiLi
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| #include<stdio.h> #include<memory.h>
#define MAX 50021
int f[MAX],g[MAX];
int hash(int data){ int pos = data%MAX; if(pos < 0) pos += MAX; while(f[pos] != 0 && g[pos] != data) pos = (pos+1)%MAX; return pos; }
int main(void){ int a,b,c,d,pos,i,j,data,cnt,xx[101]; for(i = 1; i <= 100; i++) xx[i] = i*i; while(scanf("%d%d%d%d",&a,&b,&c,&d) == 4){ if(a>0&&b>0&&c>0&&d>0||a<0&&b<0&&c<0&&d<0){ printf("0\n"); continue; } memset(f,0,sizeof(f)); cnt = 0; for(i=1;i<=100;i++) for(j=1;j<=100;j++){ data = a*xx[i]+b*xx[j]; pos = hash(data); g[pos] = data; f[pos]++; } for(i=1;i<=100;i++) for(j=1;j<=100;j++){ data = -(c*xx[i]+d*xx[j]); pos = hash(data); cnt += f[pos]; } printf("%d\n",cnt*16); } }
|
散列查找与性能分析
对散列表的查找过程与散列表的构造过程是基本一致的,此处不再赘述。
而因为冲突的解决所使用的方法不同,将导致并不是每次查找都能一次就查找成功,需要查找多次,即探测多次。因此,这个探测次数,即比较关键字的次数成了查找效率主要的指标。
我们还是可以用 平均查找长度(Average Search Length,ASL)来作为主要指标,在等概率情况下,查找成功的ASL有:
ASL=n1i=1∑nki
式中,n 为散列表中含有的关键字个数,ki 为第i 个关键字的查找次数。
而查找失败的ASL有:
ASL=n′1i=1∑n′ki
式中,n′ 是经过散列函数计算所得结果的所有可能性个数。即H=⋯mod? 的 问号处的数字与散列表长度取最小。一般情况下,查找比较次数还需算上查到空地址的一次。(有的教材则不计入此次数,具体到题目中若未说明,则算上一次空地址)
填装因子
散列查找的效率取决于散列函数、处理冲突的方法、填装因子。
填装因子用α 表示,定义为一个散列表装满的程度,即:
α=HashTable′sLengthNumberofKeys
应当注意,散列表的ASL依赖于填装因子α,而不直接依赖于关键字个数和散列表长度。
定性分析,α 越大,散列表装得越满,发生冲突的可能性就越大。
字符串哈希
待更
参考资料
1.十大经典排序算法|博客园
2.哈希算法简介|CSDN
3.HDForum-Hash及其应用|刘春英
4.字符串哈希详解| dgklr
5.常见字符串哈希函数汇总 |CSDN