简要介绍
数据结构Hash属于查找算法中的一种,在比赛中通常会占据一定的比例,难度相对较高。
知识点
· Hash的概念
· 构造方法
· 冲突处理
为什么要使用哈希表
简单来说,就是判断现有数据集合中是否有这个元素,或者是否有满足条件的元素。
其中的Hash算法则可以帮助我们判断是否有这个元素,虽然功能简单,但是其时间复杂度只有 O(1)且具有高性能。通过在记录的存储地址和它的关键码之间建立一个确定的对应关系。这样,不经过比较,依次读取就能得到所查元素的查找方法。相比普通查找算法来说,仅仅在比较的环节,就会大大减少查找或映射所需要的时间。
什么是哈希表
我们采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间即称为散列表。下图为散列表的实现过程:
我们也可以把它理解为数学函数,即Y = F(X),其中,X为自变量也就是这里的key,F()对应图中的H(),也就是一个映射关系,Y因变量也就是对应的值的存放位置,此处应留意。
思考:
· 散列技术仅仅是一种查找技术吗?
应该说,散列即是一种查找技术,也是一种存储技术。
· 散列是一种完整的存储结构吗?
散列只是通过记录的关键码定位该记录,没有完整地表达记录之间地逻辑关系,即通过关键码能推出Key值,但是通过关键码对应的值(即位置处的值)不能推出关键码,所以散列存储的关键码和值之间并不对称,因此散列主要是面向查找的存储结构。
哈希表的缺点
1. 散列技术一般不适合在允许多个记录有同样关键码的情况下使用。
因为这种情况下,通常会有冲突存在,将会降低查找效率,体现不出散列表查找概率高的优点。
并且如果一定要在这个情况下使用的话,还需要想办法消除冲突,这将会费大量时间,那么就失去了O(1)时间复杂度的优势,所以在存在大量的冲突情况下,我们就要弃用哈希表。
2. 散列方法也不适用于范围查找,比如以下两个情况。
· 查找最大值或者最小值
因为散列表的值是类似函数的,映射函数一个变量只能对应一个值,不知道其他值,也不能查找最大值、最小值,RMQ(区间最值问题)可以采用ST算法、树状数组和线段树解决。
· 也不可能找到在某一范围内的记录
比如查找小于N的数有多少个,是不能实现的,原因也是映射函数一个变量只能对应一个值,不知道其他值。
散列技术的关键问题
在使用哈希表的时候,我们有两个关键的技术问题需要解决:
1. 散列函数的设计,如何设计一个简单、均匀、存储利用率高的散列函数?
2. 冲突的处理,如何采取合适的处理冲突方法来解决冲突。
如何设计实现散列函数
在构建散列函数时,我们需要秉持两个原则:
1. 简单
· 散列函数不应该有很大的计算量,否则会降低查找效率。
2. 均匀
· 函数值要尽量均匀散布在地址空间,这样才能保证存储空间的有效利用并减少冲突。
散列函数实现的三种方法
1. 直接定址法
散列函数是关键码(Key)的映射的线性函数,形如:
H(Key) = a * Key + b
来看一个小案例:
如果关键码的集合已知且为 [11,22,33,66,88,44,99]
H(key) = 1 / 11 * key + 0
如图:
缺点:
· 我们是看到了这个集合,然后想到他们都是11的倍数才想到这Hash函数。我们在平常的使用中一般不会提前知道Key值集合,所以使用较少。
适用范围:
· 事先知道关键码,关键码集合不大且较为连续而不离散。
2. 除留余数法
H(key) = key mod p
来个小例子:
H(key) = key mod 21
会发现产生了很多相同的H(K), 这就是发生冲突,因为一个位置只能放一个数,有两个值对应这里的一个位置,是不可以的。
这种方法是最常用的方法,这个方法的关键在于如何选取P,使得利用率较高并且冲突率较低,一般情况下,我们会选取最接近表长且小于等于表长的最大素数。
缺点:
· P选取不当,会导致冲突率上升。
适用范围:
· 除留余数法是一种最简单、也是最常用的构造散列函数的方法,并且不要求事先知道关键码的分布,也因此较常用,大部分算法实现也都是选取这一种方式。
3. 数字分析法
比如我将我的集合全部转化为16进制数,根据关键码在各个位上的分布情况,选取分布比较均匀的若干位组成散列地址。或者将N位10进制数,观察各各位的数字分布,选取分布均匀的散列地址。
举个小例子:
首先我们考虑一位作为散列函数,发现都是很多冲突,选取两位时,百位和十位组合最适宜,分布均匀且没有冲突。
当然,我们说的是这一方法的一个具体实例,既然叫做数字分析法,那么只有对于不同数据的不同分析,才能写出更是适配的H(x)。
4. 平方取中法
平方取中法即取关键字平方的中间位数作为散列地址
比如,假设关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671,也可以是710,用作散列地址
平方取中法比较适合于不知道关键字的分布,而位数又不是很大的情况。
5. 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些)然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
比如假设关键字是9876543210,散列表表长为三位
则我们可以将它分为四组987|654|321|0
然后将它们叠加求和 987+654+321+0 = 1962
再取后三位得到散列地址即为962
有时可能这还不能够保证分布均匀,那么也可以尝试从一端到另一端来回折叠后对齐相加
比如将987和321反转,再与654和0相加,变成789+654+123+0 = 1566,此时散列地址为566
折叠式事先不需要知道关键字的分布,适合关键字位数较多的情况。
冲突的处理方法
1. 开散列方法:
open hashing 也称为拉链法,separate chaining 称为链地址法,简单来说,就是由关键码得到的散列地址一旦产生了冲突,就去寻找下一个空的散列地址,并将记录存入。
寻找下一个空的散列地址的方法:
· 线性探测法
当发生冲突时,从冲突位置的下一个位置起,依次寻找空的散列地址。
对于键值 key,设 H(key)=d,闭散列表的长度为 m,则发生冲突时,寻找下一个散列地址的公式为:
Hi = (H(key)+di) MOD m(di = 1,2,... ,m - 1)
堆积现象:
在处理冲突的过程中出现的非同义词之间对同一个散列地址争夺的现象。
例子:
Key 集合为 47, 7, 29, 11, 27, 92, 22, 8, 3。
P 值为 11,进行 Hash 映射,采用线性探测法处理冲突。
· 二次探测法
即当发生冲突时,寻找下一个散列地址的公式为:
Hi = (H(key)+di)
其中( di = 1^2,-1^2,2^2,-2^2,... ,q^2,-q^2 且 q ≤ m / 2)
· 随机探测法
当发生冲突时,下一个散列地址的位移量是一个随机数列,即寻找下一个散列地址的公式为:
Hi = (H(key) + round), 其中 round 为随机数
· 再hash 法
注意:用开放定址法处理冲突得到的散列表叫闭散列表。
2. 闭散列方法
closed hashing 也称为开地址方法,open addressing 开放地址法,开放地址法中涵盖了以下两种实现方式;
· 拉链法(链地址法)
将所有散列地址相同的记录即 Key 值相同的项目,坠成一个链表,每个链表的头指针存放位置为 Key 值对应的位置。
举一个小例子:
· 建立公共溢出区
散列表包含基本表和溢出表两部分(通常溢出表和基本表的大小相同),将发生冲突的记录存储在溢出表中。
查找时,如果在基本表里找的到就返回成功,没找到就在溢出区顺序查找,注意这里不再是映射而是顺序查找,放置时也是按照顺序的方式。
算法流程
1. 假设给定的值为 K,根据所设定的散列函数 h,计算出散列地址 h(K);
2. 如果将该地址中的值与 K 比较,若相等则检索成功,跳转到第 5 步;
3. 否则,按建表时设定的处理冲突方法查找探查序列的下一个地址,反复执行并检查
· 如果某个地址空间未被占用(查找不成功,可以插入),跳转到第 5 步;
· 如果关键码比较相等(有重复记录,不需要插入)为止 ,跳转到第 5 步;
虽然在解题过程中,如果依据表长质数 h 设置得当,则很少会出现冲突情况,但是基本的解决方法我们也须得掌握,以往常经验来看,公共溢出区的方式更加简洁高效率(在冲突次数远小于元素数量时),所以本节实验中,我们主要掌握如何设置公共溢出区的方法。
典型例题
弗里的语言
小发明家弗里想创造一种新的语言,众所周知,发明一门语言是非常困难的,首先你就要克服一个困难就是,有大量的单词需要处理,现在弗里求助你帮他写一款程序,判断是否出现重复的两个单词。
要求:有重复的单词,就输出重复单词,没有重复单词,就输出 NO,多个重复单词输出最先出现的。
第 1 行,输入 N,代表共计创造了多少个单词
第 2 行至第 N+1 行,输入 N 个单词
格式如下:
fjsdfgdfsg
fdfsgsdfg
bcvxbxfyres
输入1:
6
1fagas
dsafa32j
lkiuopybncv
hfgdjytr
cncxfg
sdhrest
输出1:
NO
输入2:
5
sdfggfds
fgsdhsdf
dsfhsdhr
sdfhdfh
sdfggfds
输出2:
sdfggfds
下面我们来分析一下解题思路
第一步,首先我们需要创建一个散列表和一个公共溢出区。
const long long h=1e8+7;
string Value[h+5];
string UpValue[h+5];
第二步,我们要定义散列表映射函数:
我们这里介绍一种在算法竞赛中特别常用的字符串映射成数字的方式。
实现原理:
-
将字符串中的每一个字母都看做是一个数字(例:从 a-z ,视为 1-26 );
-
选取两个合适的互质常数 b 和 h,其中 h 要尽可能的大一点,为了降低冲突的概率。b 常用 131,h 常用 1e9+7,这里我们需要设置公共溢出区所以,我们需要随便找一个 string 数组能开出来的数字,这里选取 999983。
-
定义哈希函数:
处理方式:
1. C 代表一个字符串,用 C =c1 c2 c3 c4..cm 表示该字符串,其中 ci 表示从前向后数的第 i 个字符;
2. C 当做 b 进制数 来处理,b 是基数;
3. 关于对 h 取模,若 b、h 有公因子,那么不同的字符串取余之后的结果发生冲突的几率将大大大增加(冲突:不同的字符串但会有相同的 hash 值)。
举一个例子:
现在有一字符串 S = s1s2s3s4s5
hash[1] = s1
hash[2] = s1∗p + s2
hash[3] = s1∗p2 + s2∗p + s3
hash[4] = s1∗p3 + s2∗p2 + s3∗p + s4
hash[5] = s1∗p4 + s2∗p3 + s3∗p2 + s4∗p + s5
所以 S 的哈希值为 Hash[5]
实现
const long long h = 999983;
int Hx(string s)
{
int n = s.size();
int sum1 = 0;
for (int i = 0; i < n; i++)
{
sum1 = sum1 * 131 % h + (s[i] - 'a' + 1) % h;
}
return (sum1 + h) % h;
}
在比赛按此方法设计 Hash 函数一般不需要设置冲突的公共溢出区,这里我们为了方便讲解,才进行设置,在比赛中我们不用设置溢出区,所以可以设置很大的 h,避免出现冲突。
第三步,我们定义查询函数:
通过散列表顶指针大小即可判断。
bool isAt(string s)
{
int n=Hx(s);
if(Value[n]=="")
return false;
else if(Value[n]==s)
return true;
else
{
for(int i=0;i<UpValueCount;i++)
if(UpValue[n]==s) return true;
return false;
}
}
第四步,定义插入散列表函数:
1. 按照散列表的映射方式设计即可;
2. 需要传入一个参数来表示放什么数据。
bool in(string s)
{
int n=Hx(s);
if(Value[n]=="")
{
Value[n]=s;
return true;
}
else if(Value[n]==s) return false;
else
{
for(int i=0;i<UpValueCount;i++)
if(UpValue[n]==s) return false;
UpValue[UpValueCount++]=s;
return true;
}
}
第五步,编写主函数代码。
主函数代码我们有三种定义方式
· 方法一
中规中矩定义法,设置flag变量用于跳过找到答案后的输入处理。
int main()
{
int n;
bool flag = 0;
string ans = "NO";
cin >> n;
for (int i = 0; i < n; i++)
{
string word;
cin >> word;
if (flag)
continue;
if (isAt(word))
{
flag = 1;
ans = word;
}
else
{
in(word);
}
}
cout << ans << endl;
}
· 方法二
由于我们设置的插入函数也具有查询功能,插入成功即为没有重复值,插入失败即为有重复值,我们这里不存在单独查询的操作,所以我们可以将查询省略。
int main()
{
int n;
bool flag = 0;
string ans = "NO";
cin >> n;
for (int i = 0; i < n; i++)
{
string word;
cin >> word;
if (flag)
continue;
if (!in(word))
{
flag = 1;
ans = word;
}
}
cout << ans << endl;
}
· 方法三
在法二的基础上,利用 OJ 的特性,OJ 是判定输出的答案是否与答案相同进行判定,当我们知道答案之后直接输出,结束程序那么就会使得程序运行时间大幅度减少。
int main()
{
int n;
string ans = "NO";
cin >> n;
for (int i = 0; i < n; i++)
{
string word;
cin >> word;
if (!in(word))
{
cout << word << endl;
return 0;
}
}
cout << ans << endl;
}
解决方案
#include <iostream>
#include <stack>
using namespace std;
const int h = 999983;
string Value[h];
string UpValue[h];
int UpValueCount = 0;
int Hx(string s)
{
int n = s.size();
int sum1 = 0;
for (int i = 0; i < n; i++)
{
sum1 = sum1 * 131 % h + (s[i] - 'a' + 1) % h;
}
return (sum1 + h) % h;
}
bool isAt(string s)
{
int n = Hx(s);
if (Value[n] == "")
return false;
else if (Value[n] == s)
return true;
else
{
for (int i = 0; i < UpValueCount; i++)
if (UpValue[n] == s)
return true;
return false;
}
}
bool in(string s)
{
int n = Hx(s);
if (Value[n] == "")
{
Value[n] = s;
return true;
}
else if (Value[n] == s)
return false;
else
{
for (int i = 0; i < UpValueCount; i++)
if (UpValue[n] == s)
return false;
UpValue[UpValueCount++] = s;
return true;
}
}
int main()
{
int n;
string ans = "NO";
cin >> n;
for (int i = 0; i < n; i++)
{
string word;
cin >> word;
if (!in(word))
{
cout << word << endl;
return 0;
}
}
cout << ans << endl;
}