哈希表
哈希表的主要内容是两大块。第一大块是哈希表的存储结构,第二大块是介绍一个常用的字符串哈希的方式。
哈希表的存储结构一般来说分成两大类,第一大类是开放寻址法,第二大类是拉链法。
哈希表最主要的作用是把一个比较庞大的一个空间或者是一个值域,把它映射到一个比较小的空间,一般情况下映射到从零到n,n一般来说可能是十的五次方或十的六次方这种级别。
例题:
维护一个集合,支持如下几种操作:
I x
,插入一个整数 x;Q x
,询问整数 x 是否在集合中出现过;
现在要进行 N 次操作,对于每个询问操作输出对应的结果。
输入格式
第一行包含整数 N,表示操作数量。
接下来 N 行,每行包含一个操作指令,操作指令为 I x
,Q x
中的一种。
输出格式
对于每个询问指令 Q x
,输出一个询问结果,如果 x在集合中出现过,则输出 Yes
,否则输出 No
。
每个结果占一行。
数据范围
1≤N≤10的5次方
−10的9次方≤x≤10的9次方
输入样例:
5
I 1
I 2
I 3
Q 2
Q 5
输出样例:
Yes
No
假设这个函数是h(x),这个函数可以把一个从-10的9次方到10的9次方的一个数映射到一个从0到10的五次方之间的一个数。这个函数一般就被称为哈希函数。一般要考虑这么几个问题,首先第一个问题就是一般这个哈希函数怎么写?比方说这里这个哈希函数的话,可以直接取成x%10的十的五次方,就可以把一个值域比较大的一个数,映射到从0到10的五次方之间的一个数了,哈希函数一般都是把它直接取模就可以了。第二点是这里可能有冲突,因为x定义域比较大,但是映射的结果比较小,因此这里面就必然会产生冲突,就是把两个不一样的数。映射成了同一个数,因为x的范围比较大,但是值域的范围比较小,这个是有很大概率发生的。就是可能会把若干不同的数映射到同一个数,比方说举个例子,比方说我们可能会把h5映射到二,然后10也映射到二了,这是有可能发生的,那发生的话,我们要去处理冲突,然后按照这个冲突的处理方式,把哈希表分成两种,一种是开放寻址法,一种是拉链法。之前讲的离散化是一种极其特殊的哈希方式,这里讲的哈希是一般意义的哈希方式,是不太一样的。离散化可以发现,有一个很重要的特点,是需要保序的,就是相当于h函数需要单调递增的。它是一种非常特殊的哈希方式,这里讲的是一般的哈希方式。
首先来看拉链法,顾名思义,就是我们首先开一个一维数组来存储所有的哈希值,比方说hx会把x映射到从0到10的五次方减一的一个数,那么开这个数组的时候就开一个长度是十的五次方的一个数组就可以了,下标从0到10的五次方减一。每一次当把一个x映射到某一个数的时候,比方说第一次把h11映射到了3,那么就在3的下面拉一条链,然后这个节点里面存11。每一个位置可以看成是一个槽,每一个槽上都拉了一条链,用来存储这个槽上当前已经有的所有的数。比方说第一次把11映射到了3,那就把11在3下面拉一条链,然后比方说非常不巧,第二次把23也映射到3。那么,就在三的这条链的末尾的位置或开头的位置,在这个链上再增加一个数23。也就是说如果两个数是冲突的,那么就会用一条链把它们全部存下来。拉链法最后的形状就每一个槽上可能会拉很多树下来啊,每一个槽上都会拉很多树下来。
哈希表是一种期望算法,就是说虽然每个槽上会拉一条链,但是在平均情况下来看,每一条链的长度可以看成是常数。可以看成非常短。所以说在一般情况下,哈希表的时间复杂度都是很好的,都可以看成O1。在算法题里面,一般情况下是不需要从哈希表里面删除元素,一般就只有添加和查找两个操作。添加一个数很简单,比方说要添加x,就先求一下hx,然后看一下hx对应的是哪个槽,然后就把x插到这个槽对应的这个链上就可以了,这个链就是前面学过的单链表。查找也很简单,比如说要查找x的话,就先看一下hx在哪个槽上,然后遍历一下这个槽对应的这个链表里边存不存在x就可以了,那么算法题里面如果说要实现删除的话,不会真的把这个点删掉,一般情况下是开一个数组。在每一个点上打一个标记,比方说开一个布尔变量,如果要把它删掉的话,就在这个标记上记录一下就可以了。这是拉链法。
小模板(1) 拉链法
int h[N], e[N], ne[N], idx;
// 向哈希表中插入一个数
void insert(int x)
{
int k = (x % N + N) % N;
e[idx] = x;
ne[idx] = h[k];
h[k] = idx ++ ;
}
#include <cstring>
#include <iostream>
using namespace std;
const int N = 100003; //大于100000的第一个质数
//有一点要强调一下,就是一般来说做哈希的时候,这个数组长度,也就是模的这个数,一般来说,要取成一个质数。这个是比较有讲究的,就是要模的这个数要取成一个质数,而且这个质数要离二的整次幂尽可能的远。它在数学上可以证明,如果这么取,冲突的概率是最小的。
int h[N], e[N], ne[N], idx; //h是槽,e,ne,idx与单链表相同
void insert(int x) //插入
{
int k = (x % N + N) % N; //x的值远大于n,+N模N的意义是让它的余数变成正数,k就是哈希值
e[idx] = x; //单链表插入,先存值
ne[idx] = h[k]; //让新的点的next指针等于h k
h[k] = idx ++ ; //h k指向它
}
bool find(int x) //查询
{
int k = (x % N + N) % N;
for (int i = h[k]; i != -1; i = ne[i]) //h[k]存的是第一个链表的下标,ne[i]是下一个点的下标
if (e[i] == x) //e[i]表示的是当前这个点的值是多少,从一个点到下一个点就是e[i]到ne[i]
return true;
return false;
}
int main()
{
int n;
scanf("%d", &n);
memset(h, -1, sizeof h); //把所有的槽清空,空指针用-1来表示
while (n -- )
{
char op[2];
int x;
scanf("%s%d", op, &x);
if (*op == 'I') insert(x); //插入
else //查询
{
if (find(x)) puts("Yes");
else puts("No");
}
}
return 0;
}
作者:yxc
链接:https://www.acwing.com/activity/content/code/content/45308/
来源:AcWing
下面来看第二种做法,开放寻址法。开放寻址法处理冲突的思路是什么呢?首先它只开了一个一维数组,没有开链表。因此形式看起来就会简单一些,它只开了一个一维数组。但是这个一维数组的长度经验上一般来说要开到题目数据范围的2到3倍。比方说题目里面输入了十万个数,那么这个数组长度一般要至少开到20万。一般是2到30万的一个范围,就是开到这个范围的话,冲突的概率就比较低了,那它是如何来处理冲突的呢?其实跟大家上厕所差不多,比方说求出来了一个x,它的一个差异值是k的话,先看一下第k个位置有没有人?如果有一个人的话,那我们就去下一个坑位,以此类推,直到找到一个没有人的坑位的时候,我们就把x放进去。这是开放寻址法的一个基本思路,其实跟上厕所是一个道理,就是从前往后,从第k个坑位开始找。如果这个坑位有人的话,我们就去下一个坑位。来看一下这几个操作,首先添加一个数,这比较简单,就是先找到k,然后从第k个坑位开始,往后找,直到找到第一个空的坑位为止,然后把它插进去,那查找的话,也比较简单,查找的话就是也是从第k个坑位开始,然后从前往后找。每一次先看一下当前坑位有没有人啊,如果当前坑位有人并且是x的话,就找到了x。然后如果当前坑位有人不是x,那就再看下一个坑位,然后如果当前坑位没人,说明x不存在。就是查找。然后删除的话,就跟拉链法的删除类似,就是从前往后找,按照查找的方式来找x,如果找到了x的话,一般来说不会把x真的删掉,一般是在这个数组里边打一个特殊的标记,标记下x有没有被删掉,这是删除,90%的情况只会用到查找和添加,删除其实可以看成是查找的一种,就是找到之后打一个标记。这是开放寻址法的一个基本的思路。
小模板(2) 开放寻址法
int h[N];
// 如果x在哈希表中,返回x的下标;如果x不在哈希表中,返回x应该插入的位置
int find(int x)
{
int t = (x % N + N) % N;
while (h[t] != null && h[t] != x)
{
t ++ ;
if (t == N) t = 0;
}
return t;
}
#include <cstring>
#include <iostream>
using namespace std;
const int N = 200003, null = 0x3f3f3f3f; //两倍,大于20万的最小质数
//null是约定的标志,如果一个数等于这个数,那么就说明这个位置是空的,不在x的数据范围内
int h[N];
int find(int x) //核心,如果x已经在哈希表中存在,就返回x所在的位置,如果不存在,那么返回应该存储的位置
{
int t = (x % N + N) % N; //把x映射到数组下标范围内
while (h[t] != null && h[t] != x) //这个坑位有人,且这个人不等于x
{
t ++ ; //往后看下一个坑位
if (t == N) t = 0; //已经看完了最后一个坑位,就循环看第一个坑位
}
return t;
}
int main()
{
memset(h, 0x3f, sizeof h); //memset是按字节来初始化的,h是一个int型的数组,一共有四个字节,每一个字节都是0x3f,每个数就是四个0x3f,使h中每一个数初始都等于null
int n;
scanf("%d", &n);
while (n -- )
{
char op[2];
int x;
scanf("%s%d", op, &x);
if (*op == 'I') h[find(x)] = x; //插入,先找到k
else //查询
{
if (h[find(x)] == null) puts("No");
else puts("Yes");
}
}
return 0;
}
作者:yxc
链接:https://www.acwing.com/activity/content/code/content/45308/
来源:AcWing
哈希表的时间复杂度都是O1的,用到哈希表的题目都是On的一个时间复杂度,如果sort,时间复杂度就会从On变成nlog n,就会变慢。
字符串哈希
有很多字符串的问题,都可以用哈希来做,不一定非要写kmp。这里讲的字符串的哈希方式其实是一种特殊的哈希方式,叫字符串前缀哈希法。
比方说有一个字符串str等于ABCABCDEYXCACwing,求哈希的时候,首先预处理出来所有前缀的哈希,比方说h1,它表示的就是A这个字符的哈希,h2表示的是前两个字符的哈希,h3对应的是前三个字母的哈希,以此类推,h0特殊定义为零,表示的是前零个字符的哈希,h[N]等于这个字符串的哈希值。这里就有两个问题了,首先第一个问题是如何来定义某一个前缀的哈希值,就是把这个字符串看成是一个p进制的数,那么每一位上的字母就表示这个p进制数的每一位数字。这个是指的它的AS2码,比方说现在想求一下ABCD这个字符串的哈希值的话,首先第一步,把它看成是一个p进制的数,然后它一共有四个字母,那就看成有四位,第一位上的数是A。第二位置上的数是B,第三位上的数是C,第四位上的数是D。假设所有的字母都是从大写字母A到Z,那么先把这个A当成数字一。把B当成二,把C当成三,把D当成四,以此类推,Z就是26。那么ABCD这个字符串就可以看成是p进制的1234。那么它对应的十进制的数就是1×p的三次方,加上2×p^2加上3×p的一次方,再加上4×p的零次方,可以通过这样的一个方式,把一个字符串变成一个数字,转化成数字之后这个数字的话可能会非常大,因为这个字符串的长度可能是十万二十万,那么它实际上是一个可能有几十万位的一个数,不好存下来了,所以最后的时候要对整个数字模上一个比较小的数,比方说模上一个Q,通过取模就可以把整个数字映射到从零到Q- 1的一个数了,这个就是整个字符串哈希的一个方式,
这里有这么几点要注意一下,首先第一点就是不能把某一个字母映射成零,一般情况下不能映射成零,举一个例子,比方说把字母A映射成零,那么A的哈希值应该是零,因为a是零,零的p进制的数就等于十进制的零,那AA也是零。那这样的话,就会把不同的字符串映射成同样一个数字了,就冲突了,所以说就把它映射成从1开始的就比较好了,这是第一点要注意的。
第二点要注意的是:前面哈希数字的时候是可能会存在冲突的,有一个机制来避免冲突,注意这里的字符串哈希方法是假定不存在冲突,完全不考虑冲突的情况。然后有个经验值,经验值是当p取131或者13331的时候,q取成二的64次方。如果这么取的话,在一般情况下,可以假定是不会出现冲突的。
第二个问题,来看一下,用这样的一种哈希方式,再配合上前缀哈希。有什么样的一个好处,好处就是可以利用前缀哈希。算出来任意一个子串的哈希值。可以利用前面求得的这个所有前缀的哈希,用一个公式计算出来所有子段的哈希值。
假设想求一下这个字符串从l到r这一段的子串的哈希值,首先已知的是从一到r的哈希值以及从一到l的哈希值,已知的是r的哈希值以及l- 1的哈希值,目标是算出来从l到r的哈希值,左边是高位,右边是低位,hr里面的话r就是第0位,h1是r- 1位,,在hl- 1里面的话l- 1是第0位,那么一的话就是l减二位,因此首先第一步,需要把hl- 1这一段从这个最高位是p的l- 2次方,最低位是p的零次方,hr的最高位是p的r- 1次方。最低位是p的零次方,首先第一步需要先把hl- 1这一段,把它往左移,移到和hr对齐为止,因此第一步就是把它乘上一个p的r-l+1倍,因此第一步先把hl- 1这一段乘上一个p的r-l+1次方。作用就是把hl这一段往左移若干位,让它和hr对齐,然后第二步是让hr减去这一部分。最终的公式就是hr-hl乘上p的r-l+1次方,表示的含义就是从l到r这段的哈希值。这里就有一个技巧了,就是直接用这个unsigned long long来存储所有的h,就不需要取模了,它会溢出,溢出就等价于模上的一个二的64次方,因此,预处理完每一个前缀的哈希值之后,就可以用O1的时间算出来任意一个子段的哈希值了。预处理前缀的哈希值的话也很简单,比方说hi等于hi- 1,然后乘上一个p再加上第i位的这个字母stri就可以了,这个就是字符串哈希的一个基本思路。
例题:
给定一个长度为 n 的字符串,再给定 m 个询问,每个询问包含四个整数 l1,r1,l2,r2,请你判断 [l1,r1] 和 [l2,r2]这两个区间所包含的字符串子串是否完全相同。
字符串中只包含大小写英文字母和数字。
输入格式
第一行包含整数 n和 m,表示字符串长度和询问次数。
第二行包含一个长度为 n 的字符串,字符串中只包含大小写英文字母和数字。
接下来 m 行,每行包含四个整数 l1,r1,l2,r2表示一次询问所涉及的两个区间。
注意,字符串的位置从 1开始编号。
输出格式
对于每个询问输出一个结果,如果两个字符串子串完全相同则输出 Yes
,否则输出 No
。
每个结果占一行。
数据范围
1≤n,m≤10的5次方
输入样例:
8 3
aabbaabb
1 3 5 7
1 3 6 8
1 2 1 2
输出样例:
Yes
No
Yes
首先先把原字符串的所有前缀的哈希值求出来,然后当想询问两个区间的时候,就分别用刚才那个公式算出来这两个区间的字符串的哈希值。这两个哈希值都是从0到2的64次方减一的一个数,如果两个哈希值相同的话,就认为这两个字符串相同,如果两个哈希值不同的话,就认为这两个字符串不同。
核心思想:将字符串看成P进制数,P的经验值是131或13331,取这两个值的冲突概率低
小技巧:取模的数用2^64,这样直接用unsigned long long存储,溢出的结果就是取模的结果
typedef unsigned long long ULL;
ULL h[N], p[N]; // h[k]存储字符串前k个字母的哈希值, p[k]存储 P^k mod 2^64
小模板:// 初始化
p[0] = 1;
for (int i = 1; i <= n; i ++ )
{
h[i] = h[i - 1] * P + str[i];
p[i] = p[i - 1] * P;
}
// 计算子串 str[l ~ r] 的哈希值
ULL get(int l, int r)
{
return h[r] - h[l - 1] * p[r - l + 1];
}
作者:yxc
链接:https://www.acwing.com/blog/content/404/
来源:AcWing
#include <iostream>
#include <algorithm>
using namespace std;
typedef unsigned long long ULL; //重新定义unsigned iong long
const int N = 100010, P = 131;
int n, m;
char str[N];
ULL h[N], p[N];
ULL get(int l, int r) //存公式
{
return h[r] - h[l - 1] * p[r - l + 1];
}
int main()
{
scanf("%d%d", &n, &m);
scanf("%s", str + 1);
p[0] = 1;
for (int i = 1; i <= n; i ++ ) //预处理
{
h[i] = h[i - 1] * P + str[i];
p[i] = p[i - 1] * P;
}
while (m -- )
{
int l1, r1, l2, r2;
scanf("%d%d%d%d", &l1, &r1, &l2, &r2);
if (get(l1, r1) == get(l2, r2)) puts("Yes");
else puts("No");
}
return 0;
}
作者:yxc
链接:https://www.acwing.com/activity/content/code/content/45313/
来源:AcWing
快速判断两个字符串是不是相等的时候,用哈希的话就可以用O1的时间来做。是kmp做法的一个劲敌,但是有些个别题目是只能拿kmp来做的,比方说举个例子,kmp可以用来求一个字符串的循环节但哈希不能。
C++STL容器常用用法
vector, 变长数组,数组长度可以动态变化,支持随机寻址,支持比较运算,可以判断两个vector大小,比较方式是按字典序比,4个3小于3个4,基本思想是倍增的思想
需要加vector的头文件
倍增:系统为一个程序分配空间时,所需时间与空间大小无关,与申请次数有关,一次1000与1000次1,后者可能是前者的1000倍,所以变长数组需要尽量减少申请空间的次数,倍增的思想是第一次开一个长度为32的数组,等到这个数组装不下了后,就乘2变成64的数组,再把32数组中的元素全部拿过来,假设n=10的6次方,一开始开长度为一,一直倍增到5乘10的5次方,相加等于10的6次方,就相当于开10的6次方长度的数组时,额外copy的数的次数大概就是10的6次方,时间复杂度O1,可以看成每插入一个数平均复杂度是O1的,假设总共有n个,申请空间的次数是log n的
初始化:vector<int>a(长度,初始化的数),也可vector<int>a[长度]
size() 返回元素个数 //a.size
empty() 返回是否为空 //a.empty,空就ture,否则false
//size和empty所有容器都有,时间复杂度是O1的
clear() 清空 //并不是所有容器都有
front()/back() //返回第一个//最后一个数
push_back()/pop_back() //向最后插入一个数/把最后一个数删掉
begin()/end() //迭代器,可以看成指针解引用,第0个数/最后一个数的后面一个数
[]
pair<int, int> //可以存储一个二元组,前后两个类型可以任意,常用存储两个属性
first, 第一个元素
second, 第二个元素
支持比较运算,以first为第一关键字,以second为第二关键字(字典序)
构造:pair p=make.pair(,); / p={,};
也可存三个属性,pair<int,pair<int,int>>p;
string,字符串
size()/length() 返回字符串长度
empty() //判断是否为空
clear() //清空
可以用+=
substr(起始下标,(子串长度)) 返回子串,当输入的子串长度超过原串长度,就输出完原串为止,也可不输入长度,直接返回原串
c_str() 返回字符串所在字符数组的起始地址
queue, 队列
size()
empty() //没有clear,想清空可以重建
push() 向队尾插入一个元素
front() 返回队头元素
back() 返回队尾元素 //队尾插入,队头弹出,先进先出
pop() 弹出队头元素
priority_queue, 优先队列,默认是大根堆,需要加头文件quene
size()
empty() //没有clear
push() 插入一个元素
top() 返回堆顶元素
pop() 弹出堆顶元素
定义:priority_queue<int>heap
定义成小根堆的方式:1.直接插入一个负数,-x从大到小排序相当于x从小到大排序
2.定义时直接定义成小根堆 priority_queue<int, vector<int>, greater<int>> q;
stack, 栈
size()
empty() //没有clear
push() 向栈顶插入一个元素
top() 返回栈顶元素
pop() 弹出栈顶元素
deque, 双端队列 //队头队尾都可以插入弹出,支持随机访问,相当于加强板的vector
但效率低下
size()
empty()
clear()
front()/back() //第一个/最后一个·
push_back()/pop_back() //向最后插入一个元素/弹出最后一个元素
push_front()/pop_front() //向队首插入一个元素/弹出队首一个元素
begin()/end()
[]
set, map, multiset, multimap, 基于平衡二叉树(红黑树),动态维护有序序列
size()
empty()
clear()
begin()/end()
支持++, -- 返回前驱和后继,有序序列里的前驱是前面一个数,后继是后面一个数,时间复杂度 O(logn)
set/multiset //加set头文件,set不能有重复元素但multiset可以,时间复杂度是log n
insert() 插入一个数
find() 查找一个数,如果不存在,返回end迭代器
count() 返回某一个数的个数
erase()
(1) 输入是一个数x,删除所有x(对multiset) O(x的个数 + logn)
(2) 输入一个迭代器,删除这个迭代器
lower_bound()/upper_bound()
lower_bound(x) 返回大于等于x的最小的数的迭代器
upper_bound(x) 返回大于x的最小的数的迭代器 //含义不是相反,如果不存在,返回end
map/multimap //需要包含map头文件,map存的是一个映射,从a映射到b,可以像数组一样来用map
//map<string,int>a; a[“yxc”]=1;cout<<a["yxc"];第一个参数表示的是第一种定义的变量,返回值就是第二种定义的变量
insert() 插入的数是一个pair,两个数
erase() 输入的参数是pair或者迭代器
find()
[] 注意multimap不支持此操作。 时间复杂度是 O(logn)
lower_bound()/upper_bound()
unordered_set, unordered_map, unordered_multiset, unordered_multimap, 基于哈希表
和上面类似,增删改查的时间复杂度是 O(1),需要包含头文件
不支持 lower_bound()/upper_bound(),因为内部是无序的, 不支持迭代器的++,--
bitset, 圧位 //假设要存一个10000乘10000的bool矩阵,大概需要100MB空间,可以用bitset来存,一般一个字节可以存4个bool,bitset一个可以存8个字节,相当于可以省8倍的空间
定义:bitset<个数> s;
可以看成一个整数, 支持 ~, &, |, ^
>>, <<
==, !=
[] //取出来一位是0或1
count() 返回有多少个1
any() 判断是否至少有一个1
none() 判断是否全为0
set() 把所有位置成1
set(k, v) 将第k位变成v
reset() 把所有位变成0
flip() 把所有位取反,等价于~
flip(k) 把第k位取反
作者:yxc
链接:https://www.acwing.com/blog/content/404/
来源:AcWing