应用:在大文本中寻找字符串
朴素的模式匹配思想
将模式s从文本t的开头位置匹配,不匹配则往后移一个位置,重新匹配,时间复杂度为 O ( ∣ s ∣ ∗ ( ∣ t ∣ − ∣ s ∣ ) ) O(|s|*(|t|-|s|)) O(∣s∣∗(∣t∣−∣s∣)),约为 O ( ∣ s ∣ ∗ ∣ t ∣ ) O(|s|*|t|) O(∣s∣∗∣t∣).
滚动哈希
考虑模式s本次匹配的子串与下一次匹配的子串的区别,仅仅在于移除了它的头以及添加了一个尾,如果能有效利用这个特点来计算字符串的哈希值,使得中间部分不变时,哈希值的变化只与首尾的变化有关,那么计算下一次匹配的子串的哈希值时,只需要移除头部带来的影响以及增添尾部带来的影响即可,这些操作的时间复杂度都是
O
(
1
)
O(1)
O(1)的。
考虑数据结构r,它维护了一个字符串x,这个r有如下操作:
- r.append(c):追加字符c到x的尾部
- r():计算x的哈希值
- r.skip(c):删除x的首字符。我们认为删除的首字符等于c,而不是说从x中删除字符c。
用rs表示维护s的数据结构,用rt表示维护文本t的数据结构。
首先,将s的每个字符添加到rs中:
for c in s: rs.append(c)
然后从t的开始位置0处,添加一个长度为 ∣ s ∣ |s| ∣s∣的子串:
for c in t[:len(s)]: rt.append(c)
现在,rs()就是s的滚动哈希值,rt()就是t的滚动哈希值。
然后就能开始匹配了:
if rs() == rt():. . .
# 如果相等,待会儿再说
# 否则继续匹配
for i in range(len(s), len(t)):
rt.skip(t[i - len(s)]) # 删除首字符
rt.append(t[i])
if rs() == rt():
# s可能匹配上了,因为存在哈希碰撞
check whether s == t[i - len(s) + 1: i + 1]
if equal:
found match
else:
发生的可能性 <= 1/|s|
整个匹配的时间复杂度为
O
(
∣
s
∣
+
∣
t
∣
+
#
m
a
t
c
h
∗
∣
s
∣
)
O(|s| + |t| + \#match*|s|)
O(∣s∣+∣t∣+#match∗∣s∣)
#match是哈希值相等的次数,每一次都要检查是否匹配上了,这只需要花费常数时间。
那么哈希函数到底用的是什么呢?
我们把字符串视为一个多位数,基数为字母表的大小,如果是ASCII,那么
∣
∑
∣
=
256
|∑|=256
∣∑∣=256。添加、删除字符等价于一些四则运算,这些运算耗费常数时间。
示例
以力扣题库第28题为例进行算法实现。
class Solution {
public:
int strStr(string s, string t) {
if (t == "")
return 0;
int len1 = s.length(), len2 = t.length();
if (len1 < len2)
return -1;
unsigned long long hash1 = 0, hash2 = 0, base = 26, m = 1, M = (1ULL << 53) - 1;
for (int i = 1; i < len2; i++)
m = (m * base) % M; //计算最高位的权重,即26的(len2 - 1)次幂
for (int i = 0; i < len2; i++) {
hash1 = (hash1 * base + t[i] - 'a') % M; //模式串的哈希值
hash2 = (hash2 * base + s[i] - 'a') % M; //目标串的哈希值
}
for (int i = 0; i <= len1 - len2; i++) {
if (hash1 == hash2 && s.substr(i, len2) == t) //短路原则,如果哈希值不相等则不会匹配
return i;
hash2 = (hash2 - (s[i] - 'a') * m + M) % M;
hash2 = (hash2 * base + (s[i + len2] - 'a')) % M;
}
return -1;
}
};