【算法分享】哈希入门:不同子串统计与哈希冲突构造

一、字符串哈希(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;
}

📝 比较

方案 优点 缺点
单哈希 实现简单,速度快 容易碰撞,安全性较低
双哈希 碰撞概率极低,实战中最常用 稍复杂,计算开销略增
取模策略 保证数值范围,避免溢出 模运算较慢

一般来说,我们使用单哈希就足够了,双哈希的题在竞赛中出现的并不是很多。

posted @ 2025-08-08 16:22  开珥  阅读(58)  评论(0)    收藏  举报