字符串哈希:进制哈希法

1 一句话算法

把字符串看成一个 PP 进制大整数,预处理前缀哈希后,用“整段减去前缀”在 O(1)O(1) 得到任意子串的哈希值。

2 问题模型

给定一个字符串 ss,需要多次判断两个子串是否相同:

s[l1..r1] 是否等于 s[l2..r2]

朴素比较需要逐字符扫描,单次最坏 O(len)O(\text{len})。当查询很多时,总复杂度会很高。

字符串哈希把每个子串映射成整数。比较两个子串时,先比较长度,再比较哈希值。哈希不同可以确定字符串不同;哈希相同时只能把它们视为可能相同。若题目不能接受碰撞风险,还需精确比较或改用确定性算法。

说明哈希冲突

不同字符串可能具有相同哈希值。本文使用固定基数 131131、模 2642^{64} 的单哈希,不提供无碰撞保证;对于固定输入和固定参数,结果是确定的,不能无条件宣称碰撞概率极小。双模数、随机基数等方法可在合适的输入模型下降低风险,仍不能把哈希相等当作数学证明。

3 核心直觉

十进制数可以用前缀相减截取一段数字:

12345 中截出 345 = 12345 - 12 * 1000

字符串哈希也是同一个模型。把字符当成数字,把字符串当成 PP 进制数:

H(s1s2⋯sn)=s1Pn−1+s2Pn−2+⋯+sn H(s_1s_2\cdots s_n) = s_1P^{n-1}+s_2P^{n-2}+\cdots+s_n

如果已经知道前缀 s[1..r] 的哈希值和前缀 s[1..l-1] 的哈希值,就可以把前面的部分减掉,留下 s[l..r]。

4 算法步骤

  1. 选择进制基数 BASE,本文取 131;所有乘加和减法都在模 2642^{64} 的意义下进行。
  2. 预处理:
    • power[i] = BASE^i;
    • prefix[i] = prefix[i-1] * BASE + s[i]。
  3. 查询子串 [l,r]:
hash(l,r) = prefix[r] - prefix[l-1] * power[r-l+1]
  1. 判断两个子串是否相等:
    • 长度不同,直接不同;
    • 长度相同,比较哈希值。

5 算法证明

核心不变量:prefix[i] 表示前缀 s[1..i] 作为 PP 进制数时的哈希值。

预处理时:

prefix[i]=prefix[i−1]⋅P+si prefix[i]=prefix[i-1]\cdot P+s_i

这等价于把旧前缀整体左移一位,再放入新字符,所以不变量成立。

对子串 [l,r],设 len = r-l+1。前缀 s[1..r] 可以拆成两段:

s[1..r]=s[1..l−1]+s[l..r] s[1..r] = s[1..l-1] + s[l..r]

算式推导:

prefix[r]=prefix[l−1]⋅Plen+H(s[l..r])H(s[l..r])=prefix[r]−prefix[l−1]⋅Plen \begin{aligned} prefix[r] &= prefix[l-1]\cdot P^{len}+H(s[l..r]) \\ H(s[l..r]) &= prefix[r]-prefix[l-1]\cdot P^{len} \end{aligned}

所以查询公式能正确得到该子串的哈希值。它证明的是指纹的计算公式,不是「指纹相等就一定是同一个字符串」。

6 复杂度分析

设字符串长度为 nn,查询次数为 qq。

  • 预处理时间复杂度:O(n)O(n)。
  • 单次查询时间复杂度:O(1)O(1)。
  • 总时间复杂度:O(n+q)O(n+q)。
  • 空间复杂度:O(n)O(n)。

7 下标、字符与溢出约定

模板的 get(l, r) 使用 1 起始闭区间。prefix[0] = 0,power[0] = 1;当 l > r 时返回空串哈希 00。非空区间必须满足 1 <= l <= r <= n。

C++ 按无符号字节值编码字符,并通过 unsigned long long 实现模 2642^{64} 的回绕。Python 用掩码 & ((1 << 64) - 1) 模拟相同运算;字符串先编码为 UTF-8 字节,因而两个版本对同一输入有一致的哈希值。区间下标也按字节计算,ASCII 字符串中才与字符下标一致。

8 代码实现

模板输入格式:

s
q
l1 r1 l2 r2
...

每个询问判断两个 1-based 子串是否相同,输出 Yes 或 No。

#include <bits/stdc++.h>
using namespace std;

struct StringHash {
    using ull = unsigned long long;

    static constexpr ull BASE = 131;
    vector<ull> prefix;
    vector<ull> power;

    StringHash(const string &s) {
        int n = (int)s.size();
        prefix.assign(n + 1, 0);
        power.assign(n + 1, 1);

        for (int i = 1; i <= n; i++) {
            power[i] = power[i - 1] * BASE;
            prefix[i] = prefix[i - 1] * BASE + (unsigned char)s[i - 1];
        }
    }

    // 返回 1-based 子串 s[l..r] 的哈希值。
    ull get(int l, int r) const {
        if (l > r) return 0;
        return prefix[r] - prefix[l - 1] * power[r - l + 1];
    }
};

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

    string s;
    cin >> s;

    StringHash hs(s);

    int q;
    cin >> q;
    while (q--) {
        int l1, r1, l2, r2;
        cin >> l1 >> r1 >> l2 >> r2;
        if (r1 - l1 != r2 - l2) {
            cout << "No\n";
            continue;
        }
        cout << (hs.get(l1, r1) == hs.get(l2, r2) ? "Yes" : "No") << '\n';
    }

    return 0;
}
# 字符串哈希:1-based 子串 s[l..r] 的哈希值,O(1) 查询。
# C++ 用 unsigned long long 自然溢出(等价于模 2^64),这里用 MASK 显式模拟回绕,
# 保证与 C++ 数值逐位一致;Python int 本身任意精度,不会溢出。
# get(l, r) 是 1-based 闭区间,l > r(空区间)返回 0,与 C++ 一致。

Seq = list[int]  # prefix / power:长度 n + 1 的哈希序列,下标 0 为哨兵

MASK: int = (1 << 64) - 1  # 低 64 位全 1;& MASK 即截断到 64 位,等价无符号回绕


class StringHash:
    """prefix[i] = s[1..i] 的哈希,power[i] = BASE^i。"""

    BASE: int = 131  # 进制;C++ 的 static constexpr,Python 放类属性

    prefix: Seq
    power: Seq

    def __init__(self, s: str | bytes) -> None:
        data = s.encode("utf-8") if isinstance(s, str) else s
        n = len(data)
        self.prefix = [0] * (n + 1)
        self.power = [0] * (n + 1)
        self.power[0] = 1  # BASE^0 = 1

        for i in range(1, n + 1):
            # C++ 取 (unsigned char)s[i-1],即字节值;Python 用 ord 码点,
            # 对 ASCII 两者相同,字符串先转为 UTF-8 字节,非 ASCII 时区间也按字节计算。
            self.power[i] = (self.power[i - 1] * self.BASE) & MASK
            self.prefix[i] = (self.prefix[i - 1] * self.BASE + data[i - 1]) & MASK

    def get(self, l: int, r: int) -> int:
        """子串 s[l..r] 的哈希(1-based 闭区间);空区间返回 0。"""
        if l > r:
            return 0
        # 把前缀 s[1..l-1] 整体左移 r-l+1 位后减掉,减法同样按 64 位回绕。
        return (self.prefix[r] - self.prefix[l - 1] * self.power[r - l + 1]) & MASK


if __name__ == "__main__":
    import sys
    data = iter(sys.stdin.buffer.read().split())
    s = next(data, None)
    if s is not None:
        hs = StringHash(s)
        q = int(next(data))
        out = []
        for _ in range(q):
            l1, r1, l2, r2 = (int(next(data)) for _ in range(4))
            same = r1 - l1 == r2 - l2 and hs.get(l1, r1) == hs.get(l2, r2)
            out.append("Yes" if same else "No")
        if out:
            print("\n".join(out))

9 测试用例

输入:

abacaba
3
1 3 5 7
1 3 2 4
2 2 6 6

输出:

Yes
No
Yes

解释:

  • s[1..3] = "aba",s[5..7] = "aba"。
  • s[1..3] = "aba",s[2..4] = "bac"。
  • s[2..2] = "b",s[6..6] = "b"。

10 应用分类详解

字符串哈希的本质是“把一段字符串压成可快速比较的指纹”。看到大量子串比较、重复判断、模式匹配时,可以考虑它。

一、 静态子串相等判断

典型模式: 一个字符串固定,多次询问两个子串是否相同。

识别信号: 出现“多次比较子串”“判断两个区间字符串是否一样”。

核心建模: 预处理前缀哈希,每个子串用 hash(l,r) 表示。

应用场景 经典题目 核心思路
子串相等查询 本页模型 长度相同且哈希相同
最长公共前缀 后缀比较类问题 二分长度,每次用哈希判等

二、 去重与集合统计

典型模式: 给出很多字符串或很多子串,统计不同个数。

识别信号: 出现“不同字符串数量”“去重”“重复子串”。

核心建模: 把每个字符串或子串的哈希值放入 set,再统计集合大小。

应用场景 经典题目 核心思路
字符串去重 luogu P3370: 【模板】字符串哈希 每个字符串求哈希后排序去重
重复子串判断 最长重复子串 二分长度,检查是否有重复哈希

三、 模式匹配

典型模式: 在文本串中查找模式串是否出现。

识别信号: 出现“模式串”“文本串”“匹配位置”。

核心建模: 先求模式串哈希,再枚举文本中的等长窗口比较哈希。

四、 回文与循环结构判断

典型模式: 判断一个子串是否回文,或判断字符串是否存在周期。

识别信号: 出现“正反相同”“循环节”“周期”。

核心建模: 回文可比较正向哈希与反向哈希;循环节可比较相邻重复块哈希。

比较不同长度的字符串或把指纹放入集合去重时,建议同时保存 (长度, 哈希值);即使长度一致也仍可能碰撞。

11 经典例题

1. luogu P3370: 【模板】字符串哈希

字符串哈希模板题。对每个字符串求哈希,排序去重后得到不同字符串数量。

2. luogu P4391: [BalticOI 2009] Radio Transmission 无线传输

循环节问题。可以用 KMP,也可以用哈希辅助判断重复块是否一致。

3. luogu P3501

回文相关问题。正反哈希可以快速判断某段是否回文,适合和二分或枚举中心结合。

12 参考

  • Rabin-Karp 字符串匹配思想。

13 旧稿提纲

原稿中的基础知识提纲

13.1 旧稿状态

旧稿仅列出「定义、适用场景、复杂度分析、代码模板」四项提纲,没有独立推导或代码。上述四项已经在本页正式教程中展开;此处保留提纲说明,后续维护以正式教程为准。

14 相关题解

暂无相关题解