字符串匹配是计算机科学中最基础也最常用的算法之一,广泛应用于文本编辑器、搜索引擎、生物信息学等领域。简单来说,字符串匹配就是在主串(通常称为文本串)中查找一个子串(通常称为模式串)出现的位置。
最直观的字符串匹配方法是暴力匹配(Brute-Force),即逐个字符比较文本串和模式串。这种方法虽然简单直接,但时间复杂度高达O(n×m),其中n是文本串长度,m是模式串长度。当处理大规模数据时(如10^6量级),这种算法显然无法满足性能需求。
暴力匹配的低效主要源于其"全或无"的匹配策略:一旦发现某个字符不匹配,就完全放弃当前比对位置,从下一个位置重新开始整个模式串的匹配。这种策略没有利用已经匹配的部分信息,造成了大量重复计算。
针对暴力匹配的缺陷,计算机科学家们提出了多种优化算法,其中最具代表性的就是KMP算法和字符串哈希算法:
KMP算法:由Knuth、Morris和Pratt三位科学家共同提出,通过预处理模式串构建next数组(或称部分匹配表),利用已匹配部分的信息避免不必要的回溯,将时间复杂度优化至O(n+m)
字符串哈希:将字符串转换为数字(哈希值),通过比较哈希值而非原始字符串来判断匹配,配合滚动哈希技术可以实现O(1)时间的子串比较,整体复杂度为O(n+m)
这两种算法各有优劣,选择哪种取决于具体应用场景和开发者偏好。接下来我们将深入探讨这两种算法的原理和实现细节。
字符串哈希的核心思想是将字符串映射为一个数字(哈希值),使得:
这种映射通过多项式哈希(Polynomial Rolling Hash)实现,其数学表达式为:
code复制hash(s) = (s[0]×P^(m-1) + s[1]×P^(m-2) + ... + s[m-1]×P^0) mod M
其中:
直接计算每个子串的哈希值仍然是O(n)操作,无法带来性能提升。滚动哈希技术通过预处理文本串的前缀哈希,使得任意子串的哈希值可以在O(1)时间内计算得到。
具体实现需要两个预处理数组:
hash[i]:存储s[0..i-1]的哈希值power[i]:存储P^i的值子串s[l..r]的哈希值计算公式为:
code复制hash(s[l..r]) = (hash[r+1] - hash[l]×power[r-l+1]) mod M
这种计算方式类似于前缀和技巧,通过预先存储中间结果避免了重复计算。
尽管精心选择P和M,哈希冲突(不同字符串产生相同哈希值)仍可能发生。处理冲突的常用方法包括:
在实际编程竞赛中,通常会采用unsigned long long自然溢出(相当于M=2^64)来简化实现,但需要注意这可能被特殊构造的数据破解。
以下是完整的字符串哈希实现(以洛谷P3375为例):
cpp复制#include <iostream>
#include <string>
using namespace std;
const int P = 131;
const int MAXN = 1e6 + 5;
using ull = unsigned long long;
ull hashS[MAXN], power[MAXN];
void preprocess(const string &s) {
power[0] = 1;
for (int i = 1; i <= s.size(); ++i) {
hashS[i] = hashS[i-1] * P + s[i-1];
power[i] = power[i-1] * P;
}
}
ull getHash(int l, int r) {
return hashS[r] - hashS[l-1] * power[r-l+1];
}
int main() {
string s, p;
cin >> s >> p;
// 预处理文本串
preprocess(s);
// 计算模式串哈希值
ull hashP = 0;
for (char c : p) {
hashP = hashP * P + c;
}
int n = p.size();
for (int i = 1; i + n - 1 <= s.size(); ++i) {
if (getHash(i, i + n - 1) == hashP) {
cout << i << "\n";
}
}
return 0;
}
注意事项:
- 数组索引通常从1开始,避免边界条件处理
- power数组必须预先计算,否则每次计算pow(P,n)会严重影响性能
- 在实际比赛中,建议使用双哈希或更大的质数来避免被hack
KMP算法的精髓在于:当出现字符不匹配时,利用已匹配部分的信息,确定模式串可以跳过多少个字符后继续匹配,避免不必要的回溯。这种信息存储在next数组(或称部分匹配表)中。
next数组的定义:对于模式串p,next[i]表示子串p[0..i]的最长公共前后缀(border)长度。这里的"公共前后缀"指既是前缀又是后缀的真子串。
构建next数组的过程实际上是模式串与自身的匹配过程。以下是关键步骤解析:
这个过程的时间复杂度是O(m),其中m是模式串长度。
有了next数组后,实际的字符串匹配过程与构建next数组类似:
这个过程的时间复杂度是O(n),整体算法复杂度为O(n+m)。
以下是完整的KMP算法实现(包含next数组计算和匹配过程):
cpp复制#include <iostream>
#include <vector>
#include <string>
using namespace std;
vector<int> computeNext(const string &p) {
int m = p.size();
vector<int> next(m, 0);
for (int i = 1, j = 0; i < m; ++i) {
while (j > 0 && p[i] != p[j]) {
j = next[j-1];
}
if (p[i] == p[j]) {
++j;
}
next[i] = j;
}
return next;
}
void kmpSearch(const string &s, const string &p) {
auto next = computeNext(p);
int n = s.size(), m = p.size();
for (int i = 0, j = 0; i < n; ++i) {
while (j > 0 && s[i] != p[j]) {
j = next[j-1];
}
if (s[i] == p[j]) {
++j;
}
if (j == m) {
cout << i - j + 2 << "\n"; // 题目要求输出1-based位置
j = next[j-1];
}
}
}
int main() {
string s, p;
cin >> s >> p;
kmpSearch(s, p);
// 输出next数组(题目要求)
auto next = computeNext(p);
for (int i = 0; i < p.size(); ++i) {
cout << next[i] << " ";
}
cout << "\n";
return 0;
}
实操心得:
- next数组的计算和匹配过程非常相似,可以理解为模式串与自身的匹配
- 回退操作是KMP算法的关键,需要理解"最长公共前后缀"的含义
- 匹配成功后,j需要回退到next[j-1]而非0,以处理重叠匹配的情况
| 特性 | 字符串哈希 | KMP算法 |
|---|---|---|
| 预处理时间 | O(n) | O(m) |
| 匹配时间 | O(n) | O(n) |
| 空间复杂度 | O(n) | O(m) |
| 额外要求 | 处理哈希冲突 | 理解next数组 |
| 适用场景 | 多模式匹配 | 精确匹配 |
选择字符串哈希当:
选择KMP算法当:
字符串哈希常见问题:
KMP算法常见问题:
在实际开发中,选择哪种算法取决于具体需求。对于初学者,建议先掌握字符串哈希,因为它更直观易懂;当需要更精确的匹配或解决特定问题时,再深入学习KMP算法及其变种。理解这两种算法的核心思想,将为解决更复杂的字符串问题打下坚实基础。