字符串匹配是计算机科学中最基础也最重要的问题之一。从文本编辑器的查找替换到搜索引擎的关键词检索,从DNA序列比对到网络入侵检测,高效的字符串匹配算法无处不在。本文将深入讲解字符串哈希的核心原理,用Java实现Rabin-Karp滚动哈希算法,并展示其在多模式匹配中的应用。
一、字符串哈希原理
1.1 多项式滚动哈希
将字符串视为一个以字符ASCII码为系数的多项式,在模一个大质数 P 的意义下求值:
H(s) = (s[0]·B^(n-1) + s[1]·B^(n-2) + ... + s[n-1]·B^0) mod P
其中 B 为基数(通常取131或13131),P 为模数(通常取大质数如 10^9+7)。
1.2 哈希冲突与双哈希
哈希冲突是指不同字符串产生相同哈希值。虽然概率极低,但在安全性要求高的场景下,可采用双哈希策略:使用两组不同的 (B, P) 参数分别计算哈希值,将 (h1, h2) 作为指纹,冲突概率降至 1/P²。
1.3 滚动哈希的高效更新
Rabin-Karp算法的精髓在于:已知子串 s[i..i+m-1] 的哈希值,可以在 O(1) 时间内计算出 s[i+1..i+m] 的哈希值。
假设当前哈希值为:
H = s[i]·B^(m-1) + s[i+1]·B^(m-2) + ... + s[i+m-1]·B^0
向右滚动一位后:
H' = (H - s[i]·B^(m-1)) · B + s[i+m]
所有运算在模 P 意义下进行。
二、核心算法设计
2.1 Rabin-Karp单模式匹配
- 计算模式串
pattern的哈希值hashP - 预处理计算
B^(m-1) mod P - 计算文本串
text前m个字符的哈希值 - 从
i=0开始向右滚动,每次比较hashText与hashP - 若哈希相等,进行逐字符验证(防止冲突)
- 使用滚动公式更新哈希值,继续下一轮
时间复杂度:O(n+m) 平均,O(nm) 最坏(冲突频繁时)
2.2 多模式匹配扩展
当有 k 个模式串时,可将所有模式串的哈希值存入哈希集合。遍历文本时,对每个位置检查当前子串哈希是否在集合中。
时间复杂度:O(n + 所有模式串长度之和)
2.3 最长公共前缀(LCP)查询
利用前缀哈希数组,可以在 O(1) 时间内比较任意两个子串是否相等,进而用二分查找在 O(log n) 时间内求LCP。
三、Java完整实现
import java.util.*;
/**
* 字符串哈希算法实现
* 包含Rabin-Karp滚动哈希、多模式匹配与LCP查询
*/
public class StringHash {
// 双哈希参数,大幅降低冲突概率
private static final long B1 = 131;
private static final long P1 = 1_000_000_007L;
private static final long B2 = 13131;
private static final long P2 = 1_000_000_009L;
private long[] pow1, pow2; // 幂次数组
private long[] prefix1, prefix2; // 前缀哈希数组
private String text;
/**
* 构建字符串的前缀哈希数组
* @param s 目标字符串
*/
public StringHash(String s) {
this.text = s;
int n = s.length();
pow1 = new long[n + 1];
pow2 = new long[n + 1];
prefix1 = new long[n + 1];
prefix2 = new long[n + 1];
pow1[0] = pow2[0] = 1;
for (int i = 1; i <= n; i++) {
pow1[i] = (pow1[i - 1] * B1) % P1;
pow2[i] = (pow2[i - 1] * B2) % P2;
}
for (int i = 0; i < n; i++) {
int c = s.charAt(i);
prefix1[i + 1] = (prefix1[i] * B1 + c) % P1;
prefix2[i + 1] = (prefix2[i] * B2 + c) % P2;
}
}
/**
* 获取子串 [l, r] 的双哈希值(0-based,闭区间)
*/
public long[] getHash(int l, int r) {
long h1 = (prefix1[r + 1] - (prefix1[l] * pow1[r - l + 1]) % P1 + P1) % P1;
long h2 = (prefix2[r + 1] - (prefix2[l] * pow2[r - l + 1]) % P2 + P2) % P2;
return new long[]{h1, h2};
}
/**
* 比较两个子串是否相等
*/
public boolean equals(int l1, int r1, int l2, int r2) {
if (r1 - l1 != r2 - l2) return false;
long[] h1 = getHash(l1, r1);
long[] h2 = getHash(l2, r2);
return h1[0] == h2[0] && h1[1] == h2[1];
}
/**
* Rabin-Karp单模式匹配
* @return 所有匹配起始位置
*/
public List<Integer> rabinKarp(String pattern) {
List<Integer> matches = new ArrayList<>();
int m = pattern.length();
int n = text.length();
if (m > n) return matches;
// 计算模式串的哈希值
long[] patternHash = computeHash(pattern);
// 滚动比较
for (int i = 0; i <= n - m; i++) {
long[] currHash = getHash(i, i + m - 1);
if (currHash[0] == patternHash[0] && currHash[1] == patternHash[1]) {
// 哈希相等时进行逐字符验证
if (text.substring(i, i + m).equals(pattern)) {
matches.add(i);
}
}
}
return matches;
}
/**
* 计算整个字符串的双哈希值
*/
private long[] computeHash(String s) {
long h1 = 0, h2 = 0;
for (int i = 0; i < s.length(); i++) {
int c = s.charAt(i);
h1 = (h1 * B1 + c) % P1;
h2 = (h2 * B2 + c) % P2;
}
return new long[]{h1, h2};
}
/**
* 多模式匹配:在文本中查找多个模式串的出现位置
*/
public Map<String, List<Integer>> multiPatternMatch(String[] patterns) {
Map<String, List<Integer>> result = new LinkedHashMap<>();
// 构建模式串哈希集合
Set<Long> patternHashSet = new HashSet<>();
Map<Long, List<String>> hashToPatterns = new HashMap<>();
for (String p : patterns) {
long[] h = computeHash(p);
// 使用双哈希组合:h1 * P2 + h2
long combined = h[0] * P2 + h[1];
patternHashSet.add(combined);
hashToPatterns.computeIfAbsent(combined, k -> new ArrayList<>()).add(p);
}
// 找出最短和最长模式长度
int minLen = Integer.MAX_VALUE, maxLen = 0;
for (String p : patterns) {
minLen = Math.min(minLen, p.length());
maxLen = Math.max(maxLen, p.length());
}
// 对每个可能的长度进行滚动匹配
for (int len = minLen; len <= maxLen; len++) {
for (int i = 0; i <= text.length() - len; i++) {
long[] h = getHash(i, i + len - 1);
long combined = h[0] * P2 + h[1];
if (patternHashSet.contains(combined)) {
for (String p : hashToPatterns.get(combined)) {
if (p.length() == len && text.substring(i, i + len).equals(p)) {
result.computeIfAbsent(p, k -> new ArrayList<>()).add(i);
}
}
}
}
}
return result;
}
/**
* 最长公共前缀(LCP)查询
* 返回 text[l1..] 与 text[l2..] 的最长公共前缀长度
*/
public int lcp(int l1, int l2) {
int n = text.length();
int maxLen = Math.min(n - l1, n - l2);
// 二分查找LCP长度
int left = 0, right = maxLen;
while (left < right) {
int mid = (left + right + 1) / 2;
if (equals(l1, l1 + mid - 1, l2, l2 + mid - 1)) {
left = mid;
} else {
right = mid - 1;
}
}
return left;
}
/**
* 最长重复子串长度
*/
public int longestRepeatedSubstring() {
int n = text.length();
int result = 0;
// 枚举长度,使用哈希集合检测重复
for (int len = 1; len <= n; len++) {
Set<Long> seen = new HashSet<>();
boolean found = false;
for (int i = 0; i <= n - len; i++) {
long[] h = getHash(i, i + len - 1);
long combined = h[0] * P2 + h[1];
if (seen.contains(combined)) {
// 验证避免冲突
String sub = text.substring(i, i + len);
for (int j = 0; j < i; j++) {
if (text.substring(j, j + len).equals(sub)) {
found = true;
result = len;
break;
}
}
if (found) break;
}
seen.add(combined);
}
if (!found) break; // 当前长度无重复,更长长度也不会有
}
return result;
}
public static void main(String[] args) {
String text = "abracadabraabracadabra";
StringHash sh = new StringHash(text);
System.out.println("=== 字符串哈希算法演示 ===\n");
System.out.println("文本: " + text + "\n");
// 1. Rabin-Karp单模式匹配
String pattern = "abra";
List<Integer> matches = sh.rabinKarp(pattern);
System.out.println("模式 '" + pattern + "' 的匹配位置: " + matches);
// 2. 多模式匹配
String[] patterns = {"abra", "cada", "braa", "xyz"};
Map<String, List<Integer>> multiResult = sh.multiPatternMatch(patterns);
System.out.println("\n多模式匹配结果:");
for (Map.Entry<String, List<Integer>> entry : multiResult.entrySet()) {
System.out.println(" '" + entry.getKey() + "': " + entry.getValue());
}
// 3. LCP查询
int lcpResult = sh.lcp(0, 11);
System.out.println("\nLCP(0, 11) = " + lcpResult);
System.out.println(" text[0..] = " + text.substring(0, Math.min(15, text.length())));
System.out.println(" text[11..] = " + text.substring(11, Math.min(26, text.length())));
// 4. 最长重复子串
int longest = sh.longestRepeatedSubstring();
System.out.println("\n最长重复子串长度: " + longest);
// 5. 子串相等测试
boolean eq = sh.equals(0, 3, 11, 14);
System.out.println("text[0..3] == text[11..14]: " + eq);
}
}
四、算法复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 预处理 | O(n) | O(n) | 构建前缀哈希和幂次数组 |
| 子串哈希查询 | O(1) | O(1) | 双哈希值计算 |
| Rabin-Karp | O(n+m) | O(1) | 平均情况,最坏O(nm) |
| 多模式匹配 | O(n+Σ|pᵢ|) | O(k) | k为模式串数量 |
| LCP查询 | O(log n) | O(1) | 二分查找+哈希比较 |
| 最长重复子串 | O(n log n) | O(n) | 枚举长度+哈希集合 |
五、应用场景与扩展
- DNA序列比对:处理由{A,C,G,T}组成的超长字符串,基数B取4
- ** plagiarism检测**:将文档分词后构建哈希指纹,快速比对相似度
- 滚动哈希的模运算技巧:利用
(a - b) % P = (a % P - b % P + P) % P避免负数 - 自然溢出:使用
long类型自然溢出替代显式取模,速度更快但冲突率略高
六、总结
字符串哈希将复杂的字符串比较转化为高效的整数比较,是处理大规模文本问题的利器:
- 多项式哈希 将字符串映射为指纹整数
- 滚动更新 在O(1)时间内滑动窗口哈希值
- 双哈希策略 将冲突概率降至可忽略级别
- 前缀哈希数组 支持任意子串的O(1)哈希查询
Rabin-Karp算法虽在最坏情况下不如KMP稳定,但在实际应用中配合双哈希几乎不可能发生冲突,且实现简洁、易于扩展,是工程实践中的首选方案之一。