Hash
作者:互联网
试想这样的场景,你很想学太极拳,听说学校有个叫张三的人打得特别好,于是你到学校学生处找人,学生处的工作人员可能会拿出学生名单,一个一个地查找,最终告诉你,学校没这个人,并说张三几百年前就已经在武当山作古了。可如果你找对了人,比如在操场上找那些爱运动的同学,人家会告诉你,"哦,你找张三呀,有有有,我带你去。“于是他把你带到了体育馆内,并告诉你,那个教大家打太极的小伙子就是张三,原来"张三丰”是因为他太极拳打得好而得到的外号。
学生处的老师找张三丰,那就是顺序表查找,依赖的是姓名关键字的比较。而询问爱好运动的同学时,没有遍历,没有比较,就凭他们"欲找太极'张三丰',必在体育馆当中"的经验,直接告诉你位置。
从某种程度上来讲,这种通过关键值来直接查找数据的思想,就是散列。
一、必备知识
1. 算法简介
哈希(Hash)也称为散列,就是把任意长度的输入,通过散列算法,变换成 固定长度 的输出,这个输出值就是散列值。
哈希表(Hash table,也叫散列表)是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
哈希表的做法其实很简单,就是把Key通过一个固定的算法函数,即所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间里。
而当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位。
2. 算法特征
简单来说,哈希的本质就是映射,可以将数据映射为固定长度的串。
一个好的 Hash 函数应该具备以下特征:
1. 一致性 —— 同样的key必然会产生相等的hash code。
2. 高效性 —— 高效的计算,省时省力。
省时——使用O(1)的时间进行数据的插入、删除和查找,但是 hash 表不保证表中数据的有序性,因此,在 hash 表中查找最大数据或者最小数据的时间是 O(N) 。
省空间——与时间复杂度同样是O(1)的桶相比,桶数组中是有很多桶是没有用到的,也就浪费了空间。比如说现在有4个数 (2 4 7 10001) ,如果用桶存储,就必须开一个长度为10001的数组,而用 Hash 则只需开一个长度为 4 的数组即可,它们的位置分别为 2 % 4 = 2,4 % 4 = 0,7 % 4 = 3,10001 % 4 = 1 。
在 Hash 表中表示为
index | 0 | 1 | 2 | 3 |
4 | 10001 | 2 | 7 |
3. 均匀性 —— 相对均匀地散列所有的key。
4. 单向性 —— 对于给定的散列值,很难计算出它的原始输入。
5. 抗碰撞性 —— 能够有效处理不同的关键字得到同一存储位置的情况(即解决冲突)。
举个大神提过的例子
小星与阿呆聊天中
阿呆:小星,今天来我家玩吗?路上有一家披萨店,很好吃,顺便带点来呗。
小星:哦,要不你来我家玩吧,你顺便带上披萨。
阿呆:既然你都这么说了,看来只能抛硬币解决了。
小星:嗯?这个怎么抛,我怎么知道你有没有搞鬼。
阿呆:嗯,也是。要不这样,我心中想一个数,假设为A,然后A在乘以一个数B,得到结果C。A是我的密钥,我把结果C告诉你。你来猜A是奇数还是偶数,猜中了,就算你赢。
小星:这不行,如果你告诉我C是12,我猜A是奇数,你可以说A是4,B是3。我猜A是偶数,你可以说A是3,B是4。要不你告诉我C是多少的时候,也告诉我B是多少。
阿呆:那这不行,告诉你C和B,不等于告诉你A是多少了,还猜啥呀,我们换个方式。
阿呆:要不这样,我想一个A,经过下面的过程:
1. A+123=B
2. B^2=C
3. 取C的第2~4位数,组成一个3位数D
4. 让 D%12 得到E
阿呆:我把E和上述计算方式都告诉你,你猜A是奇数还是偶数,然后我告诉你A是多少,你可以按上述的计算过程来验证我有没有说谎。
小星:嗯,我想想,假如阿呆你想的A为5,那么:5 + 123 = 128 --> 128 ^ 2 = 16384 --> D = 638 --> E = 638 % 12 = 53
小星:这样的话,一个A值对应一个唯一的E值,根据E还推算不出来A。优秀!这个还算公平,谁撒谎都能看出来。
这种丢掉一部分信息的加密方式称为“单向加密”,也叫哈希算法。
3. 建立Hash表
在建立一个哈希表之前需要解决两个问题:
1> 构造均匀的哈希函数
常见的构造哈希函数的方法有:
1. 直接寻址法(常用)。取关键字或关键字的某个线性函数值为散列地址。即 H(key) = key 或 H(key) = a * key + b ,其中a和b为常数(这种散列函数叫做自身函数)。
优点:简单、均匀。
缺点:需要事先知道关键字的分布情况。
使用场景:适合查找比较小且连续的情况。
2. 除留余数法(常用)。取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 Hash(key) = key MOD p, p <= m 。不仅可以对关键字直接取模,也可在折叠、平方取中等运算之后取模。
注意:这里对p的选择是很重要的,一般取不大于m,但最接近或者等于m的质数,若p选的不好,容易产生碰撞。
3. 平方取中法。取关键字平方后的中间几位作为散列地址。
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如说关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址。
适用场景:不知道关键字的分布,而位数又不是很大的情况。
4. 折叠法。针对原始值为数字的情况,将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址。
使用场景:事先不需要知道关键字的分布,而关键字位数比较多的情况。
5. 随机数法。选择一随机函数,取关键字作为随机函数的种子生成随机值作为散列地址,如 H(key) = random(key) ,其中 random 为随机数函数
使用场景:关键字长度不同的情况。
6. 数字分析法。分析一组数据,比如一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体相同,这样的话,出现冲突的几率就会很大,但是我们发现年月日的后几位表示月份和具体日期的数字差别很大,如果用后面的数字来构成散列地址,则冲突的几率会明显降低。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。
2> 处理冲突的方法
如果在之前提到的存储4个数的那个例子中,出现第五个数 1 需要被存入,那么由于 1 % 4 = 1 ,就会出现在`Hash[1]`中要同时存储 10001 和 1 两个数据,即发生了冲突。需要明确的一点是,无论如何设计 Hash 函数,总是存在特殊的 key 导致 Hash 冲突,而我们要做的是尽量减少冲突的几率并巧妙地处理冲突。
- 常用方法:
1. 拉链法(即开散列),我们可以理解为基于链表的Hash —— 当产生冲突的时候,将多个数据用链表的形式放在同一个 Hash 存储单元中。
大致思路是首先对关键码集合用哈希函数计算哈希地址,把具有相同哈希地址的关键码用一个小链表链接起来,若用头插的方式实现,则链表的第一个节点会被存放在哈希表中。
(这里应该注意的是,当一个存储单元的链表中有多个数据的时候,对于数据的插入、删除和查找就不是严格意义上的 O(1) 了。一个好的 Hash 函数可以使这个链表很短。当所有的数据都保存在同一个 Hash 单元指定的链表中时,这个 hash 就等同于一个链表了。)
2. 开放定址法(即闭散列),就是一旦发生了冲突,就去寻找下一个空的地址,只要散列表足够大,空的散列地址总能找到,并将记录存入 。常用公式为: h(key) = (h(key) + di) MOD m (其中di = 1,2,3,……,m-1)
用开放定址法解决冲突的做法是:当冲突发生时,使用某种探测技术在散列表中形成一个探测序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止。
若要插入,在探查到开放的地址,则可将待插入的新结点存人该地址单元。
查找时探测到开放的地址则表明表中无待查的关键字,视为查找失败。
比如说,我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12。
我们用散列函数 h(key) = key mod l2 ,当计算前5个数{12,67,56,16,25}时,都是没有冲突的散列地址,直接存入:
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 25 | 16 | 67 | 56 |
计算 key = 37 时,发现 h(37) = 1 ,与25所在的位置发生冲突。
于是我们应用上面的公式 f(37) = (f(37)+1) mod 12 = 2 。于是将37存入下标为2的位置:
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 25 | 37 | 16 | 67 | 56 |
- 两种方法的优缺点:
拉链法的优点:
①拉链法处理冲突简单,且无堆积现象,即**非同义词决不会发生冲突**,因此平均查找长度较短;
②由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
③开放定址法为减少冲突,要求装填因子α较小,故**当结点规模较大时**会浪费很多空间。而拉链法中可取α≥1,且结点较大时,**拉链法**中增加的指针域可忽略不计,因此**节省空间**;
④在用拉链法构造的散列表中,**删除结点的操作易于实现**。只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在用**开放地址法**处理冲突的散列表上执行删除操作,只能在被删结点上**做删除标记**,而不能真正删除结点。
开放定址法的优点:
指针需要额外的空间,故**当结点规模较小时,开放定址法较为节省空间**,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度。
二、算法实现
1. 基本哈希
这里通过一道例题看看链表哈希的实现过程:
题目描述
不少航空公司都会提供优惠的会员服务,当某顾客飞行里程累积达到一定数量后,可以使用里程积分直接兑换奖励机票或奖励升舱等服务。现给定某航空公司全体会员的飞行记录,要求实现根据身份证号码快速查询会员里程积分的功能。
输入格式
首先给出两个正整数N(N <= 1e5)和K(K <= 500)。其中K是最低里程,为照顾乘坐短程航班的会员,航空公司还会将航程低于K公里的航班也按K公里累积。
随后N行,每行给出一条飞行记录。飞行记录的输入格式为:18位身份证号码 飞行里程(如 330106199010080419499 )。其中身份证号码由17位数字加最后一位校验码组成,校验码的取值范围为 0~9 和 x共11个符号;飞行里程单位为公里,是 (0, 15 000] 区间内的整数。然后给出一个正整数M(M <= 1e5),随后给出M行查询人的身份证号码。
输出格式
对每个查询人,给出其当前的里程累积值。如果该人不是会员,则输出No Info。每个查询结果占一行。
输入样例4 500 330106199010080419 499 110108198403100012 15000 120104195510156021 800 330106199010080419 1 4 120104195510156021 110108198403100012 330106199010080419 33010619901008041x
输出样例
800 15000 1000 No Info
首先这里用结构体存储数据,由于用拉链法构建Hash表,要有指针next,用来表明与数组中该位置的数据发生冲突的下一个数据的存储单元。
typedef struct Node * Hash;//哈希链表中的小单元
struct Node{
char arr[20]; // 身份证号
int fen; // 积分
Hash next; // 下一个单元,没有的话是NULL
};
接下来解决数组下标 idex的确定方法——结合身份证号中数字的特点设计Hash函数,以求得下标。
int deal(char *arr) //处理身份证号
{
int idex;
idex = (arr[5] - '0') * 10000 + (arr[9] - '0') * 1000 + (arr[13] - '0') * 100 + (arr[15] - '0') * 10 + (arr[16] - '0');
if(arr[17] == 'x') idex = idex * 10 + 10;
else idex = idex * 10 + (arr[17] - '0');
return idex;
}
这里在用取模的方法将数据放入数组时,将除数 p 定义为一个大于总数 n 又是最小的素数,目的是建立哈希链表后,链表不一定塞满但是查找的效率较高。
#define MAX 200000
int nextprime(int N) //p的确定
{
int p = (N % 2) ? N + 2 : N + 1;//此时p为大于n的奇数(这里不考虑偶数是因为比2大的偶数必定不是素数)
while(p < MAX)
{
for(int i = (int)sqrt(N);i >= 2; i--)
if(!(p % i)) break;
if(i < 2) break; //即退出循环时i为1,p为素数
else p += 2; //p不是素数,就找下一个奇数
}
return p;
}
最重要的是链表哈希的插入方法,采用头插法进行插入的优点:在Hash表查找某一身份证号的时候,我们首先访问到的是存储在数组中的那个,接下来会凭借它的 next 指针向下遍历链表。因此,只有用头插法,使得存储在数组中的数据就是链表的起点,才能保证满足条件的每一个数据都被遍历到。
Hash insert(Hash h,int x,char *s) //哈希链表的插入
{
Hash p = h;
while(p) //若数组中该位置已经有人了
{
if(!strcmp(p->arr, s)) //若身份证号已经存在,则在原有公里数的基础上加
{
p->fen += x;
return h;
}
else p = p->next; //找到下一个小单元,如果最后没有了,会是NULL--循环会结束
}
//接下来剩下两种情况:指针为空,或者此指针指向的小链表中没有此身份证号
p = (Hash)malloc(sizeof(struct Node)); //新建一个
strcpy(p->arr, s); //存储身份证号
p->fen = x;//存储公里数
p->next = h; //链表的头插法,可以避免访问不到链表中之前节点的情况
return p; //返回新建的节点给h,此节点的next已经指向了原h所指的东西
}
下面是完整的代码实现:
#include <stdio.h>
#include <string.h>
#include <math.h>
#include <stdlib.h>
#define MAX 200000
typedef struct Node * Hash;//哈希链表中的小单元
struct Node{
char arr[20]; // 身份证号
int fen; // 积分
Hash next; // 下一个单元 没有的话是NULL
};
int deal(char *arr) //处理身份证号
{
int idex;
idex = (arr[5] - '0') * 10000 + (arr[9] - '0') * 1000 + (arr[13] - '0') * 100 + (arr[15] - '0') * 10 + (arr[16] - '0');
if(arr[17] == 'x') idex = idex * 10 + 10;
else idex = idex * 10 + (arr[17] - '0');
return idex;
}
int nextprime(int N) //需要一个(大于总数N又是最小的)素数,为了是以此大小建立哈希链表后,链表不一定塞满但是查找的效率会提高不少
{
int i, p = (N % 2) ? N + 2 : N + 1;
while(p < MAX)
{
for(i = (int)sqrt(N);i >= 2; i--)
if(!(p % i)) break;
if(i < 2) break; //是素数,返回
else p += 2; //不是的话,就找下一个奇数
}
return p;
}
Hash insert(Hash h,int x,char *s) //哈希链表的插入
{
Hash p = h;
while(p) //若数组中该位置已经有人了
{
if(!strcmp(p->arr, s)) //若身份证号已经存在
{
p->fen += x;
return h;
}
else p = p->next; //找到下一个小单元,如果最后没有了,会是NULL--循环会结束
}
//接下来剩下两种情况:指针为空,或者此指针指向的小链表中没有此身份证号
p = (Hash)malloc(sizeof(struct Node));
strcpy(p->arr, s);
p->fen = x;
p->next = h; //链表的头插法
return p; //返回新建的节点给h,此节点的next已经指向了原h所指的东西
}
void display(Hash h,char *s) //符合条件的身份证号
{
while(h)
{
if(!strcmp(h->arr, s))
{
printf("%d\n",h->fen);
return;
}
h = h->next;
}
printf("No Info\n");
}
int main(){
int n, k;
scanf("%d %d\n", &n, &k);
int p = nextprime(n);
Hash *h = (Hash*)malloc(p*sizeof(Hash)); //这是不占内存,又可以超级多的指针数组,此处h是指针的指针,其实可以当指针数组的名字
for(int i = 0; i < p; i++) //初始化指针数组
{
h[i]=NULL;
}
int x, idex; //x为输入的飞行里程,idex为数组下标
char arr[20];//身份证号数组
while(n--)
{
scanf("%s %d\n", arr, &x);
if(x < k) x = k;//依题,将航程低于k公里的航班也按k公里累积
idex = deal(arr) % p;//计算下标
h[idex] = insert(h[idex], x, arr);//进行元素插入
}
int m;
scanf("%d\n",&m);
while(m--)
{
scanf("%s", arr);
idex = deal(arr) % p;
display(h[idex], arr);
}
return 0;
}
2. 字符串哈希
哈希的过程,其实可以看作对一个串的单向加密过程,并且需要保证低碰撞性(就像不能让隔壁老王轻易地用他家的钥匙打开你家门一样),通过这种方式来替代一些很费时的操作。
其中,在字符串的操作中,最常见的就是判断几个串是否相同,可以通过哈希数组来实现。基本思路是通过一个固定的转换方式,使相同的串的加密结果一定相同,不同的串尽量不同,在一定的错误率的基础上达到省时的目的。既然如此,直接先比对字符串长度,然后比对ASCLL码之和可不可以呢?显然不行,这样做发生碰撞的概率太高(比如ab和ba判断结果为两串相同)。那么如何有效地减少冲突呢?
这里介绍最常见的一种哈希:进制哈希。进制哈希的核心便是给出一个固定进制base,将一个串的每一个元素看做一个进制位上的数字,所以这个串就可以看做一个base进制的数,那么这个数就是这个串的哈希值。我们通过比对每个串的的哈希值,即可判断两个串是否相同。
base进制的实现如下:
typedef unsigned long long ull;
ull base = 131;
int ans = 1;
ull mod = 212370440130137957ll;
ull Hash(char s[])
{
int len = strlen(s);
ull ans = 0;
for (int i = 0; i < len; i++)
ans = (ans * base + (ull)s[i]) % mod;
return ans;
}
mod的选择
绝大多数情况下,不要选择一个 10^9 级别的数,因为这样随机数据都会有Hash冲突,根据生日悖论,随便找上 sqrt(10^9) 个串就有大概率出现至少一对Hash值相等的串。
如果能背过或在考场上找出一个 10^18 级别的质数(用Miller-Rabin),也相对靠谱。
偷懒的写法就是直接使用unsigned long long,它溢出时会自动对 2^64 进行取模,如果出题人比较良心,这种做法也不会被卡。
当然最稳妥的办法是选择两个 10^9 级别的质数,只有模这两个数都相等才判断相等,即用双哈希。
下面是求n个字符串中共有多少个不同的字符串的代码:
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
typedef unsigned long long ull;
ull base = 131;
struct data
{
ull x,y;
}a[10010];//双哈希,只有当x,y均相等的时,才判断两串相同
char s[10010];
int n, ans = 1;
ull mod1 = 19260817;
ull mod2 = 19660813;
ull hash1(char s[])
{
int len = strlen(s);
ull ans = 0;
for (int i = 0; i < len; i++)
ans = (ans * base + (ull)s[i]) % mod1;
return ans;
}
ull hash2(char s[])
{
int len = strlen(s);
ull ans = 0;
for (int i = 0; i < len; i++)
ans = (ans * base + (ull)s[i]) % mod2;
return ans;
}
bool comp(data a, data b)
{
return a.x < b.x;
}
int main()
{
scanf("%d", &n);//字符串个数
for (int i = 1; i <= n; i++)
{
scanf("%s",s);
a[i].x = hash1(s);
a[i].y = hash2(s);
}
sort(a+1, a+n+1, comp);
for (int i = 2; i <= n; i++)
if (a[i].x != a[i-1].x || a[i-1].y != a[i].y)
ans++;
printf("%d\n",ans);
}
附:
1. 这里提供一些相关的讲解链接:
从头到尾解析Hash表算法
哈希表,哈希算法(C语言)
2. 常用Hash质数表
一千以下:
61,83,113,151,211,281,379,509,683,911
一万以下:
1217,1627,2179,2909,3881,6907,9209
十万以下:
12281,16381,21841,29123,38833,51787,69061,92083
百万以下:
122777,163729,218357,291143,388211,517619,690163,999983
千万以下:
1226959,1635947,2181271,2908361,3877817,5170427,6893911,9191891
一亿以下:
12255871,16341163,21788233,29050993,38734667,51646229,68861641,91815541
十亿左右:
1e9+7,1e9+9
十亿以下:
122420729,163227661,217636919,290182597,386910137,515880193,687840301,917120411
十亿以上:
1222827239,1610612741,3221225473ul,4294967291ul
标签:arr,Hash,int,链表,idex,哈希 来源: https://blog.csdn.net/smyc0408/article/details/113794142