【算法分享】哈希入门:不同子串统计与哈希冲突构造
一、字符串哈希(Rolling Hash)简介与基础实现 🚀
在处理字符串比较、查找子串等问题时,字符串哈希 是一项高效而常用的技巧。它能将一个字符串或子串映射为一个整数(哈希值),从而在 O(1) 时间内判断两个子串是否相等。
字符串哈希本质上是将字符串看做一个进制数 用如下的公式计算哈希值:
\(\mathrm{Hash}(s) = \left(\sum_{i=0}^{n-1} (s[i] - 'a') \cdot \text{base}^i \right) \bmod \text{mod}\)
哈希函数的一般形式:
我们给一个字符串比如 "abc",按照以下方法计算哈希值:
res = 0;
for ( c : s)
{
res = (res *base + c)% mod;
}
在 C++ 中我们用的是 ASCII 值,也就是 'a' == 97,'b' == 98,以此类推。
设base = 131,所以 "abc" 的哈希过程是:
res = 0
res = 0 * 131 + 'a' = 0 + 97 = 97
res =97 * 131 + 'b' = 12715 + 98 = 12813
res = ... * 131 + 'c' = ...
二、字符串哈希经典例题🚀
📚题目描述
如题,给定 \(N\) 个字符串(第 \(i\) 个字符串长度为 \(M_i\),字符串内包含数字、大小写字母,大小写敏感),请求出 \(N\) 个字符串中共有多少个不同的字符串。
(链接:https://www.luogu.com.cn/problem/solution/P3370)
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ll; // 定义无符号长整型别名,方便后续使用
// 哈希函数参数,b是基数,m是取模数
const ll b=131;
const ll m=1e9+7;
// 字符串哈希函数,计算字符串s的哈希值
ll hash_kk(string s) {
ll res=0; // 结果变量初始化为0
for (char c : s) {
// 每个字符依次乘以基数b后加上当前字符ASCII码,再对m取模,避免溢出
res=(res*b+c)%m;
}
return res; // 返回字符串的哈希值
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
ll n;
cin>>n; // 读取字符串个数
vector<ll> kk; // 用于存储所有字符串的哈希值
for(ll i=0;i<n;i++) {
string s;
cin>>s; // 读取字符串
kk.push_back(hash_kk(s)); // 计算哈希值并存入kk中
}
ll ans=1; // 计数不同字符串的数量,至少有一个
sort(kk.begin(),kk.end()); // 将所有哈希值排序,方便去重
for(ll i=1;i<kk.size();i++) {
// 遍历排序后的哈希值数组,统计不重复的哈希值数量
if(kk[i]!=kk[i-1])
ans++;
}
cout<<ans<<endl;
return 0;
}
⚠️ 注意事项
-
该模板使用的是单哈希,虽然简单高效,但存在哈希冲突风险。
-
在极端测试数据下可能出现错误判定(哈希碰撞),通常加双哈希/多哈希解决。
-
unsigned long long自然溢出模拟模\(2^{64}\) 的效果。
三、哈希的风险与哈希冲突
❗ 哈希冲突是什么?
单哈希在理论上是把字符串映射到一个大整数范围内,但这个映射并不是完美的“一对一”。不同的字符串可能计算出相同的哈希值,这种现象称为哈希冲突(Hash Collision)。
我们来看一道题 BZOJ Hash Killer I
🔍 题目背景
VFleaKing 采用的是单哈希:
-
采用
unsigned long long(64位无符号整数)存储哈希值; -
不取模,使用自然溢出模拟 模 \(2^{64}\);
-
通过哈希排序去重判断子串是否相同。
🚩 风险点
这种写法存在隐患:
-
虽然64位空间很大,但任意输入都可能有不同子串碰撞哈希值;
-
一旦发生碰撞,程序会误判不同子串为相同,导致结果错误(WA);
-
攻击者或测试者可以构造特殊数据,使程序在实际测试中失败。
🎯 冲突构造思路
利用以下性质:
\(Hash(s)=\sum\limits_{i=0}^{l-1}(s[i]−′a′)×base^{l-1-i}\mod 2^{64}\)
若找到两个不同的字符串 s 、t,满足:
\({Hash}(s) \equiv {Hash}(t) \pmod{2^{64}}, \quad s \neq t \)
\({Hash}(s) -{Hash}(t)=k*{2^{64}}, \quad k \neq 0 \)
我们现在已经理解VFleaKing 的哈希逻辑,接着就是寻找哈希冲突的两个子串,(即不同的子串,哈希值相同)
可以基于数学构造”哈希冲突“
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ll;
const ll base = 2;
//计算无符号64位哈希
ll hashk(string s) {
ll res=0;
for (char c:s) {
res=(res*base)+(c-'a');
}
return res;
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int l = 64;
int m = 3; // 拼接次数
int n = l * m;
//构造两个字符串
string s(l,'a');
s[0]='c';//'c'-'a'=2
string t(l,'a');
ll r1=hashk(s);
ll r2=hashk(t);
//构造哈希冲突
string k;
if (r1==r2) {
for (int i = 0; i < m; i++) {
if (i % 2 == 0)
k += s;
else
k += t;
}
cout << n << " " << l << "\n";
cout << k<< "\n";
}
return 0;
}
🔐 四、提高哈希稳定性的做法 — 双哈希
🧮 1. 双哈希(Double Hashing)
双哈希就是对同一字符串使用两个不同的哈希函数 计算哈希值,通常选择不同的base 和(或)不同的模数 mod。
\(hash(s)=\sum\limits_{i=0}^{n-1}s[i]⋅base^{i} \mod mod\)
const int b1=131,b2=1331;
const int m1=1e9+7,m2=1e9+9;
ll hashk(string s,ll b,ll m) {
ll res=0;
for (char c:s) {
res=(res*b)%m;
}
return res;
}
以洛谷 字符串 Hash(数据加强)为例
说明/提示
设第 \(i\) 个字符串长度为 \(m_i\)。
本题评测由三个子任务构成:
第一个子任务(卡大模数 Hash):
| 测试点 | $n = $ | $m_i = $ |
|---|---|---|
| \(1\sim 6\) | \(10^6\) | \(6\) |
第二个子任务(卡底数为偶数的自然溢出 Hash):
| 测试点 | \(n=\) | \(m_i\approx\) | \(m_{max}=\) |
|---|---|---|---|
| \(1\sim 6\) | \(10^4\) | \(1000\) | \(1500\) |
第三个子任务:(卡奇数底数哈希)
| 测试点 | \(n=\) | \(m_i\approx\) |
|---|---|---|
| \(1\sim 6\) | \(2^{11}+1\) | \(2^{11}\) |
为了避免哈希冲突,我们选用双哈希
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
//定义两个基数和模
const ull b1=131;
const ull b2=1331;
const ull m1=1e9+7;
const ull m2=1e9+9;
//计算字符串的哈希值
ull hashf(string s,ull b,ull m) {
ull res=0;
for (char c:s) {
res=(res*b+c)%m;
}
return res;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n;
cin>>n;
vector<ull> v;
int sum=1;
while(n--) {
string s;
cin>>s;
//组合双哈希,简单拼接或者异或(冲突概率高)
ull ans1=hashf(s,b1,m1);
ull ans2=hashf(s,b2,m2);
//把两个哈希值拼成一个大数,保证组合后的的唯一
ull ans=ans1*m2+ans2;
v.push_back(ans);
//寻找无重复的hash值
}
sort(v.begin(),v.end());
for (int i=1;i<v.size();i++) {
if (v[i]!=v[i-1]) {
sum++;
}
}
cout<<sum<<endl;
return 0;
}
📝 比较
| 方案 | 优点 | 缺点 |
|---|---|---|
| 单哈希 | 实现简单,速度快 | 容易碰撞,安全性较低 |
| 双哈希 | 碰撞概率极低,实战中最常用 | 稍复杂,计算开销略增 |
| 取模策略 | 保证数值范围,避免溢出 | 模运算较慢 |
一般来说,我们使用单哈希就足够了,双哈希的题在竞赛中出现的并不是很多。

浙公网安备 33010602011771号