NeFut Logo NeFut
EN 管理员登录

字符串哈希与KMP周期性定理的高效应用

发布于:2026-05-27 09:17 最后更新:2026-06-10 09:18
#C++ #Tutorial

核心逻辑与数学原理

在 NOIP 级别的字符串测试中,当面临极高的数据规模或多维度的子串判定时,单靠 KMP 往往会受限于单指针匹配的拓扑单一性。本节将拆解两大竞赛级破局利器:多进制大素数 Hash 的抗碰撞设计KMP 前缀函数的周期性定理

1. 字符串 Hash 的双模数抗碰撞设计

字符串 Hash 的本质是将任意长度的字符串映射为固定大小的整型数字。设字符串 $S = s_1s_2\dots s_n$,选择一个固定基数 $Base$(通常选用大质数,如 $131$ 或 $13331$)以及模数 $M$。其单哈希值递推公式为:

$$H[i] = (H[i-1] \times Base + s_i) \pmod M$$

由此,任意子串 $S[l \dots r]$ 的 Hash 值可在 $O(1)$ 时间内由前缀和差分式公式切出:

$$Hash(S[l \dots r]) = (H[r] - H[l-1] \times Base^{r-l+1}) \pmod M$$

生日攻击与哈希碰撞定理:根据生日悖论,在 $O(\sqrt{M})$ 个不同字符串中,发生哈希碰撞(即两个不同字符串具有相同哈希值)的概率将极速逼近 $1$。如果选用单 unsigned long long 自然溢出(相当于 $M = 2^{64}$),或者选用单质数 $10^9+7$,由于竞赛测例中常常存在故意构造的“卡自然溢出”恶性数据(如抗溢出黑客滑块机制),极易引发数据碰撞导致 WA

双 Hash 设计:选取两组完全独立的基数与大质数对 $(Base_1, M_1)$ 和 $(Base_2, M_2)$,每个字符串用一个二元组 $(Hash_1, Hash_2)$ 唯一标记。有效的抗碰撞模数通常从以下工业级大素数中选取:

2. KMP 前缀函数的周期性定理(最小循环节定理)

前缀函数 $next$(或称前缀函数 $\pi$)不仅可以用于匹配,其内部封存了强烈的局部拓扑周期性特征。

周期性定理:设一个字符串的长度为 $N$,其前缀函数末尾位置的值为 $next[N-1]$(0-based 下对应整个串的最长相等前后缀长度)。令 $L = N - next[N-1]$。

证明:已知长度为 $next[N-1]$ 的前缀与后缀完全相同。这意味着对原串进行向右平移 $L = N - next[N-1]$ 位后,平移部分的重叠区域完全错位对齐。若 $N$ 能被 $L$ 整除,说明错位滑块刚好无缝拼接了整段序列,定理成立。


状态设计与算法推导

1. 双 Hash 前缀和差分状态

2. 任意子串哈希抽取推导

抽取 $S[l \dots r]$(下标从 1 开始)的双哈希特征值:

$$ans_1 = (H1[r] - H1[l-1] \times P1[r-l+1]) \pmod {M_1}$$

$$ans_2 = (H2[r] - H2[l-1] \times P2[r-l+1]) \pmod {M_2}$$

若运算结果为负数,需强行执行补码修正:$ans = (ans + M) \pmod M$。


C++ 标准源码(NOIP风格)

下面提供一套融合了“双哈希子串精确判定”与“KMP周期性秒杀”的高阶工业源码。

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>

using namespace std;

const int MAXN = 1000005;

// 双 Hash 参数设计:严格选用两套不同的大素数与基数
const long long BASE1 = 131;
const long long MOD1 = 1000000007;
const long long BASE2 = 13331;
const long long MOD2 = 1000000009;

long long h1[MAXN], p1[MAXN];
long long h2[MAXN], p2[MAXN];
int nxt[MAXN];

// ================== CORE 1: 双 Hash 系统预处理与局部抽取 ==================
void init_hash(const string& s, int n) {
    p1[0] = 1; p2[0] = 1;
    h1[0] = 0; h2[0] = 0;

    for (int i = 1; i <= n; ++i) {
        p1[i] = (p1[i - 1] * BASE1) % MOD1;
        p2[i] = (p2[i - 1] * BASE2) % MOD2;

        // s[i-1] 是因为 C++ string 0-based 下标映射
        h1[i] = (h1[i - 1] * BASE1 + s[i - 1]) % MOD1;
        h2[i] = (h2[i - 1] * BASE2 + s[i - 1]) % MOD2;
    }
}

// 抽取 1-based 下标区间 [l, r] 的双哈希特征值,打包为 pair 返回
pair<long long, long long> get_sub_hash(int l, int r) {
    long long res1 = (h1[r] - h1[l - 1] * p1[r - l + 1]) % MOD1;
    if (res1 < 0) res1 += MOD1; // 致命踩坑点:C++ 取模负数必须手动补正

    long long res2 = (h2[r] - h2[l - 1] * p2[r - l + 1]) % MOD2;
    if (res2 < 0) res2 += MOD2;

    return make_pair(res1, res2);
}

// ================== CORE 2: KMP 周期性秒杀算子 ==================
int get_min_period(const string& s, int n) {
    nxt[0] = 0;
    int j = 0;
    for (int i = 1; i < n; ++i) {
        while (j > 0 && s[i] != s[j]) j = nxt[j - 1];
        if (s[i] == s[j]) j++;
        nxt[i] = j;
    }

    int max_len = nxt[n - 1]; // 整个字符串的最长公共前后缀长度
    int l = n - max_len;      // 潜在的最小正周期长度

    if (n % l == 0) {
        return l; // 致命踩坑点:唯有当整除条件成立时,l 才是合法合规的循环节
    }
    return n; // 否则,说明无整周期循环,自身即为唯一最小周期
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string s;
    if (cin >> s) {
        int n = s.length();

        // 测试 1:KMP 最小周期秒杀
        int period = get_min_period(s, n);
        cout << "Min Period Length: " << period << " (Repeated " << n / period << " times)\n";

        // 测试 2:双 Hash 系统初始化
        init_hash(s, n);
        if (n >= 4) {
            // 抽取前两个字符组成的子串,与随后两个字符组成的子串,进行哈希对冲验证
            pair<long long, long long> sub1 = get_sub_hash(1, 2);
            pair<long long, long long> sub2 = get_sub_hash(3, 4);
            if (sub1 == sub2) {
                cout << "Substrings [1,2] and [3,4] are strictly equal.\n";
            } else {
                cout << "Substrings [1,2] and [3,4] are different.\n";
            }
        }
    }
    return 0;
}

NOIP 实战避坑指南

1. 差分取模时直接返回负数引发死WA

在执行 (h1[r] - h1[l - 1] * p1[r - l + 1]) % MOD1 时,由于减法操作在模运算下可能导致前半部分小于后半部分,算出的中间结果为负数。在 C++ 寻址与算术标准中,负数取模结果依旧是负数。如果不进行 if (res < 0) res += MOD 的强行修正,该负数哈希值在与别处的正数哈希值进行对冲判断时,将发生不可逆转的误判,导致整段代码大面积 WA 爆零。

2. KMP 周期判定中忽略“整除”约束导致逻辑伪证

很多选手在利用 $next$ 数组求字符串最小循环节时,无脑认为 $L = N - next[N-1]$ 就是答案。警告:若原串为 abaab,其 $N = 5$,最长公共前后缀为 ab(长度为 2),算出的 $L = 5 - 2 = 3$。然而 abaab 显然无法由 aba 连续重复拼出。 必须严格执行 if (n % l == 0) 的前置约束判定。若不整除,最小循环节长度只能是原串总长 $N$。忽略此步将直接在周期性计数题目中被特殊构造的测例精准狙击。


经典 NOIP/洛谷 真题

1. 洛谷 P3370 【模板】字符串哈希

2. 洛谷 P4391 [BJOI2006] Radio Transmission 无线传输

原文链接: local://8.4

[h] 返回首页