cpp
/* 【多模字符串匹配算法】
N: len(big), 10e3
M: len(smalls), 10e6~10e3
S: Σlen(small), 10e3~10e6
L: len(small), 10e3
我们可以粗略的认为,MS=10e9,当然这是不严谨的
一、朴素做法
对于每个短字符串 small(smalls[i]),在长字符串 big 中做暴力匹配(KMP)
每次匹配的时间复杂度为 O(N + len(small))
总的时间复杂 O(N + len(smalls[1])) + O(N + len(smalls[2])) + ... + O(N + len(smalls[M]))
等于 O(NM + S)
但当 M=10e6,S=10e3 时,也即每个 small 都很短时,时间复杂度最坏,达到 10e9
也即对于每个非常短的字符串 small,我们都需要遍历一遍 big
二、Trie
注意到题目所说:smalls 中的每一个较短字符串
并且 len(smalls[i])<=1e3,且 Σlen(small)<=1e6
可能出现下面场景:每个 smalls[i] 的长度很短,可能 <= 10,但 len(smalls) 可能很大,为
26 + 26^2 + 26^3 + ... + 26^10
显然,此时存在大量 smalls[i] 是 smalss[j] 的前缀,因此我们可以用 Trie 优化
首先针对 smalls 建立 Trie,然后以 i=[0,len(big)) 作为起始点遍历 trie
总的时间复杂度为 O(S + NL) = O(10e6)
三、AC 自动机
时间复杂度 O(S + N)
*/
class Solution {
class Trie {
private:
struct Node {
vector<unique_ptr<Node>> childrens;
int idx;
Node() : childrens(26), idx(-1) {}
};
public:
Trie() : tr(new Node) {}
~Trie() { delete tr; }
void insert(const string &str, int idx) {
Node *p = tr;
for(auto &s : str) {
int u = s - 'a';
if(p->childrens[u] == nullptr) {
p->childrens[u] = std::make_unique<Node>();
}
p = p->childrens[u].get();
}
p->idx = idx;
}
void search(const string &s, int start, vector<vector<int>> &res) {
Node *p = tr;
for(int i = start; i < s.size(); i ++ ) {
int u = s[i] - 'a';
if(p->childrens[u] == nullptr) return ;
p = p->childrens[u].get();
if(p->idx != -1) res[p->idx].emplace_back(start);
}
}
private:
Node *tr;
};
public:
vector<vector<int>> multiSearch(string big, vector<string>& smalls) {
Trie tr;
vector<vector<int>> res(smalls.size());
for(int i = 0; i < smalls.size(); i ++ ) tr.insert(smalls[i], i);
for(int i = 0; i < big.size(); i ++ ) tr.search(big, i, res);
return res;
}
};
对于多模字符串匹配算法,从简单到复杂,从低效到高效有三种方法:
- 暴力 KMP 匹配
- Trie 优化
- AC 自动机(了解)
时间复杂度分析
设:
- N = ∣ b i g ∣ N = |big| N=∣big∣ (大字符串长度,约 1 0 3 ∼ 1 0 4 10^3 \sim 10^4 103∼104)
- M = ∣ s m a l l s ∣ M = |smalls| M=∣smalls∣ (模式串数量,可达 1 0 6 10^6 106)
- S = ∑ ∣ s m a l l s [ i ] ∣ S = \sum |smalls[i]| S=∑∣smalls[i]∣ (所有模式串总长度, 1 0 3 ∼ 1 0 6 10^3 \sim 10^6 103∼106)
- L = max ∣ s m a l l s [ i ] ∣ L = \max |smalls[i]| L=max∣smalls[i]∣ (最长模式串长度, ≤ 1 0 3 \le 10^3 ≤103)
在极端情况下, M M M 很大且每个模式串都很短,可粗略认为:
- M ⋅ L ≈ 1 0 9 M \cdot L \approx 10^9 M⋅L≈109(仅用于复杂度规模估计)
一、朴素做法(逐个 small 在 big 中匹配)
对于每个 smalls[i] 在 big 中进行一次暴力或 KMP 匹配:
-
单次匹配复杂度:
O ( N + ∣ s m a l l s [ i ] ∣ ) O(N + |smalls[i]|) O(N+∣smalls[i]∣) -
总复杂度:
∑ i = 1 M ( N + ∣ s m a l l s i ∣ ) = N M + S \sum_{i=1}^{M} (N + |smalls_i|) = NM + S i=1∑M(N+∣smallsi∣)=NM+S
当存在大量极短 small(例如: M = 1 0 6 , S = 1 0 3 M = 10^6, S = 10^3 M=106,S=103)时:
N M = 1 0 3 ⋅ 1 0 6 = 1 0 9 NM = 10^3 \cdot 10^6 = 10^9 NM=103⋅106=109
即:每个短 small 都完整扫描一次 big,效率极低。
二、Trie(字典树)优化
由于:
- ∣ s m a l l s [ i ] ∣ ≤ 1 0 3 |smalls[i]| \le 10^3 ∣smalls[i]∣≤103
- S ≤ 1 0 6 S \le 10^6 S≤106
可能出现大量前缀关系,如:
26 , 2 6 2 , 2 6 3 , ... , 2 6 10 26, 26^2, 26^3, ..., 26^{10} 26,262,263,...,2610
此时可用 Trie 来共享前缀结构,减少重复匹配。
- 构建 Trie:O ( S ) O(S) O(S)
- 从 big 的每个起点向右沿 Trie 匹配,最多前进 L L L 步:O ( L ) O(L) O(L)
总匹配复杂度:
O ( N L ) O(NL) O(NL)
整体复杂度为:
O ( S + N L ) O(S + NL) O(S+NL)
在题目规模下约为 1 0 6 10^6 106 ,比朴素方法的 1 0 9 10^9 109 快两个数量级。
三、Aho--Corasick 自动机(AC 自动机)
在 Trie 的基础上增加 fail 指针,使失配时可以跳至最长后缀前缀,从而避免重复回溯:
- 构建 Trie:O ( S ) O(S) O(S)
- 构建 fail 链:O ( S ) O(S) O(S)
- 匹配 big:O ( N + matches ) ≈ O ( N ) O(N + \text{matches}) \approx O(N) O(N+matches)≈O(N)
整体复杂度:
O ( S + N ) \boxed{O(S + N)} O(S+N)
这是多模式匹配的最优级别,并且与模式串数量 M M M 无关。
总结
| 方法 | 时间复杂度 | 在 M = 1 0 6 M=10^6 M=106 时表现 |
|---|---|---|
| 朴素多次匹配 | O ( N M + S ) O(NM + S) O(NM+S) | 最差可达 1 0 9 10^9 109 次操作 |
| Trie 匹配 | O ( S + N L ) O(S + NL) O(S+NL) | 稳定在 1 0 6 10^6 106 左右 |
| AC 自动机 | O ( S + N ) O(S + N) O(S+N) | 接近线性时间,最优 |