【数据结构讲解】【集合:哈希】——哈希算法和map介绍及题目推荐
1.哈希算法的引入
请大家来看一看这样一个问题:(洛谷P3370 【模板】字符串哈希)
给定 N N N 个字符串(第 i i i 个字符串长度为 M i M_i Mi,字符串内包含数字、大小写字母,大小写敏感),请求出 N N N 个字符串中共有多少个不同的字符串。
想要解决这样一个问题,最简单的暴力法就是使用一个字符串数组储存每一个出现过的字符串,然后遍历一一比对。代码如下:
#include<bits/stdc++.h>
using namespace std;
#define MAXN 10010
int n,ans;
string s[MAXN];
int main(){
cin>>n;
while(n--){
string tmp;cin>>tmp;
bool flag=0;
for(int i=1;i<=ans;i++)
if(s[i]==tmp){
flag=1;
break;
}
if(!flag)s[++ans]=tmp;
}
cout<<ans;
return 0;
}
由于这道题比较水,这段代码也能过。但是如果 n n n比较大就不一定了,接下来我们来学习一种更加高效的算法(和数据结构)哈希算法(和哈希表)。
2.字符串哈希——用一个例子走近哈希表
首先将上面的问题抽象一下,我们需要做两件事:
- 查找一个字符串集合内是否有字符串
tmp- 若有,不做处理。若没有,将
tmp加入集合当中。
至于为什么是集合而不是序列,因为这里的字符串顺序都是无关紧要的,所以用“集合”来描述更好。
想象一下,如果能维护这样一个函数(或数据结构),传入一个字符串tmp,能够自动得到tmp是否在这个集合中就好了。
将问题简单化。如果tmp不是字符串而是整数,我们就可以使用类似于“计数排序”(校门外的树)的思想,令a[i]表示数字i是否出现过,出现过即为1,反之则为0。这样直接把tmp当做下标传入就可以得到结果了。操作2也很简单,这里不再赘述。
再“更进一步”,若tmp是很大的整数
[
0
,
10
18
]
[0,10^{18}]
[0,1018],但是数字个数很少,无法开这么大的数组,怎么办?可以考虑将整数映射到较小的整数,不难想到一个经典的运算:取模。

图出自《深入浅出》课件
回到问题上来,我们可不可以考虑把字符串tmp映射成一个唯一确定的数字,然后套用上面的方法呢?完全可以!!!很好,现在你已经掌握哈希算法的基本思想了。
考虑设计一个函数,int gethash(string s),对于字符串s,返回一个唯一确定的数字。现在的问题就是如何实现这个函数了。
使用随机数法再记录每个字符串匹配的数字?荒谬!不确定性太大了,似乎又绕回来了。
可以分析每一个字符串一定都是abcdef这样的形式组成的。如果我们对这个字符串的每一个字符做手脚呢?一个字符->一个唯一确定的数字,ASCII码!那么如何将这些ASCII码组织起来呢?如果只是简单相加,可能导致ABCDEF和FEDCBA这样的字符串造成“冲突”。这种组织方式需要体现“顺序”这一核心。
回忆一下“十六进制转十进制”的方法。从低到高枚举十六进制数x的每一位
x
i
x_i
xi,采用“位权展开”法,伪代码如下:
int ans; // ans表示转化过来的十进制数
for(int i = x的每一位)
// to_ten表示将十六进制数的一位转化成十进制数,如x[0]=0,x[5]=5,x[A]=10,x[F]=15
ans = ans * 10 + to_ten[x[i]];
再结合“ASCII”码,可以将字符串tmp转化为一个任意进制base的数字(base最好应当选择不小于字符集数的质数。例如,
a
−
z
a-z
a−z字符串为
26
26
26,任意ASCII字符串为
256
256
256。至于为什么,请看后文。)
最后最后,再将tmp转化过来的数字取模即可。代码如下:
int gethash(string s){
int hash = 0; // hash表示字符串s映射过来的数
for (int i = 0; s[i]; i++) // 计算base进制下模mod的值作为hash值
hash = (hash * base + s[i]) % mod;
}
这里分步取模是为了防止溢出,参考是这个公式(a+b)%m=a%m+b%m=(a%m+b)%m即“和的余数等于余数的和”。
那么哈希算法的答题思路就讲解完毕。请读者好好理解。请不要离开!!!下面将带你看看上面一些没讲透的概念,帮助你更好理解哈希算法的本质。
2.1.哈希函数,哈希冲突与哈希表的概念
接下来补充上面的一些概念。
哈希函数,作用是把一个键(
k
e
y
key
key)映射为一个值(
v
a
l
u
e
value
value)(也可以叫哈希值)。上面的gethash函数就是一个哈希函数。
什么是键,什么是值?键,是你用来查找的依据,就像字典里的单词。值,是你通过键找到的结果,就像单词对应的释义。总的来讲,一个键可以找到一个
对应的值。
哈希函数有很多种形式,上面的gethash函数采用的就是多项式滚动哈希,更常用的叫法是“字符串哈希”或“进制哈希”。其它的哈希函数的设计思想包括“滚动哈希”,“Rabin-Karp 哈希”等。若后续例题中提到会着重讲解。堵着目前只需要掌握这种算法竞赛最常用的哈希即可。
还记得我们上面所说的“唯一确定”吗?事实上,可以证明即使是上面设计的如此精妙的哈希函数,也不能完全实现“一个确定的tmp对应一个唯一确定的值”。如果出现“两个不一样的tmp对应一样的哈希值”,那么就称这样的现象为哈希冲突。如何解决或者说减少哈希冲突就是设计哈希函数的一个重难点。
哈希冲突是很难完全避免的。我们只能考虑减少。但是如果出现哈希冲突,应该有一个相应的解决办法。下面会详细介绍。那么能够解决“哈希冲突”问题的,通过哈希函数将每一个数据映射成一个“确定的值”的数据结构,我们就把它叫做哈希表。
解决哈希冲突有三种常用的方法:
- 使用稳健的哈希函数,效率最高,冲突率最高
- 使用十字链表,完全解决冲突,效率较低
- 使用
Multi-Hash,折中的方法
2.2.十字链表与多哈希(Multi-Hash)以及减少哈希冲突的办法
接下来介绍两种解决哈希冲突的办法。图均取自《深入浅出》课件。
首先是十字链表。

这是解决哈希冲突最常见的方法,也是最正宗的“哈希表”。我们通过将所有“哈希冲突”(即键不同,值相同)的元素保存起来,随后一一比对。这样做时间复杂度会略微上升,但是在可接受范围内。
这种解决哈希冲突的方式下,插入一个元素的代码如上图所示。
这种解决哈希冲突的办法叫做
链地址法。事实上,还有一种与它相像的方法叫做“开放寻址”法。核心思想是,若产生Hash冲突,直接向后找“空位”,找到空位就把当前的值“放上去”。当然,真正的开放寻址法远比这复杂得多,这里不做赘述。但是读者还是应当积累,因为此知识点在 C S P − J / S CSP-J/S CSP−J/S初赛上考过。
接下来是多哈希。

这样一来,一个数字就有一组对应的哈希值了,共两个。甚至可以扩展到更加多的哈希值的个数,但是空间开销过大且还是不可以完全避免Hash冲突,示例代码如下:
#define base 135
#define mod1 199997
#define mod2 10007
string mp[mod1+2][mod2+2]; // mp为哈希表
pair<int,int> gethash1(string s){ // 同时获取字符串s的哈希值1,2
int hash1=0,hash2=0;
for(int i=0;i<s.size();i++){
hash1=(hash1*base+s[i])%mod1;
hash2=(hash2*base+s[i])%mod2;
}
return {hash1,hash2};
}
pair<int,int> p=gethash(s);
mp[p.first][p.second]=s; // 这样存储
无论是上面哪种办法,我们都需要优化使哈希冲突尽量减少。减少哈希冲突有以下几种方法:
- 扩大值域(选择大模数):这是最直接的方法。值域越大,随机碰撞的概率越低。
- 选取好的基数 (base),并随机化。一个好的基数有以下几个特征:
· 基数要大于字符集大小:确保每个字符都能被区分。比如小写字母用131、13331等。
· 基数最好是质数(模数同理):质数在模运算中循环周期长,分布更均匀。- 使用均匀分布的哈希函数
2.3.例题完整代码
上述例题完整代码如下:(十字链表)
#include<bits/stdc++.h>
using namespace std;
#define base 135
#define mod 10007
int n,ans;
string tmp;
vector<string> linker[mod+2];
inline int gethash(string s){ // 获取字符串s的哈希值
int hash=0;
for(int i=0;i<s.size();i++) // 记得*1ll可以防止数值爆int
hash=(hash*1ll*base+s[i])%mod;
return hash;
}
inline void insert(string s){ // 将字符串s插入哈希表
int hash=gethash(s);
for(int i=0;i<linker[hash%mod].size();i++)
if(linker[hash%mod][i]==s)
return;
linker[hash%mod].push_back(s);
ans++;
}
int main(){
cin>>n;
for(int i=1;i<=n;i++)
cin>>tmp,insert(tmp);
cout<<ans;
return 0;
}
3.STL中的哈希表——map,unordered_map和multimap
接下来介绍
S
T
L
STL
STL中的映射。类比set即可。map是偏序集,这里直接介绍map的常用方法。
| 常用方法 | 解释 |
|---|---|
m.insert(a) | 向映射m中插入一个元素a(或者像{1,2,3,4,5}这样的参数列表),返回pair<iterator,bool>,表示是否插入成功以及插入之后的迭代器位置 |
m[key]=value | 插入或更新键值对。若key不存在则插入,存在则更新其value |
m.find(key) | 查找key在map中的位置,返回其迭代器,不存在则返回m.end() |
m.count(x) | 查看key是否存在于map中,存在返回1,不存在返回0 |
m.erase(key) | 删除映射m中键为x的元素 |
m.erase(it) | 删除迭代器it所指向的元素 |
m.erase(it1,it2) | 删除这两个迭代器所指向的区间 [ i t 1 , i t 2 ) [it1,it2) [it1,it2) |
m.lower_bound(key) | 查找第一个key
≥
\ge
≥给定值的元素的位置 |
m.upper_bound(key) | 查找第一个key
>
>
>给定值的元素的位置 |
m.empty() | 返回映射m是否为空 |
m.size() | 返回映射m的大小 |
m.begin() | 返回映射m第一个元素的迭代器 |
m.end() | 返回映射m最后一个元素下一个位置的迭代器 |
需要注意的是m[key]=value,这是map独有的语法,需要读者牢记用法,很常用。
unordered_set,无序集合,内部元素不自动排序,不支持lower_bound和upper_bound操作,部分操作时间复杂度似乎比set稍微低一些。
multiset,可重集合,部分函数功能有所变动,不支持m[key]下标操作。
| 常用方法 | 解释 |
|---|---|
m.find(key) | 如果key存在,返回的是按中序遍历顺序第一个匹配元素的迭代器。如果key不存在,返回m.end()。 |
m.count(key) | 返回键=key的元素个数 |
m.erase(key) | 删除所有键为key的元素 |
map和multimap本质是红黑树,unordered_map本质是哈希表。对于复杂数据类型,需要手动重载偏序关系,即<运算符。示例如下:
struct node{
int a,b;
const bool operator<(const node& res)const{ // 按照a为第一关键字,b为第二关键字排序
if(a==res.a)return b<res.b;
return a<res.a
}
}
4.题目推荐
map在算法竞赛中的应用范围比较广泛,常被应用在算法优化之中(尤其是贪心和dp)。读者需要牢记map的功能:下标是任意数据类型的数组。基本上知道了这点就能做题了。
至于字符串哈希,堵着也许要稍微注意,但是考频没那么高。遇到需要手写哈希函数的时候读者要会写。
·洛谷
P1097[NOIP2007 提高组] 统计数字————通往洛谷的传送门
·洛谷P1102A-B 数对————通往洛谷的传送门
·洛谷B4241[海淀区小学组 2025] 统计数对————通往洛谷的传送门
·洛谷P3370【模板】字符串哈希————通往洛谷的传送门
·洛谷P2957[USACO09OCT] Barn Echoes G————通往洛谷的传送门
·洛谷P3879[TJOI2010] 阅读理解————通往洛谷的传送门
·洛谷P4305[JLOI2011] 不重复数字————通往洛谷的传送门
·洛谷P1918保龄球————通往洛谷的传送门
·洛谷P5250【深基17.例5】木材仓库————通往洛谷的传送门
·洛谷P5266【深基17.例6】学籍管理————通往洛谷的传送门
·洛谷P14359[CSP-J 2025] 异或和————通往洛谷的传送门
最后,制作不易,希望大家多多点赞收藏,关注下微信公众号,谢谢大家的关注,您的支持就是我更新的最大动力!
公众号上会及时提供信息学奥赛的相关资讯、各地科技特长生升学动态、还会提供相关比赛的备赛资料、信息学学习攻略等。
1959

被折叠的 条评论
为什么被折叠?



