KMP 是另一种高效的字符串搜索算法。相较于 Boyer-Moore 算法,KMP 算法实现了更低的时间复杂度,但同时逻辑也相对更加复杂。
KMP 算法的核心理念在于最小化遇到不匹配字符时的回溯次数。
以上图为例,从文本开头开始匹配子串,子串中字符 D 与文本中字符 C 不匹配。子串中,在字符 D 之前 AB 重复出现。并且,如果不考虑子串末尾的字符 D(即只考虑 subStr[0:3]
),那么 AB 既是前缀,同时也是后缀,此时我们称 AB 为子串 subStr[0:3]
的 LPS。因此,在后续的匹配过程中,我们可以跳过子串开头的 AB 而从子串中索引为 2 的字符开始匹配。
⒈ LPS
1.1 简介
LPS 全称 the longest proper prefix which is also a suffix
,即一个字符串 S
的一个子串 s
,子串 s
既是字符串 S
的前缀,同时也是字符串 S
的后缀,并且在 S
中再找不到比 s
更长的子串同时既是 S
的前缀又是 S
的后缀。以字符串 ABCAB
为例,子串 AB
既是前缀,同时也是后缀,此时我们称子串 AB
为字符串 ABCAB
的 LPS。
虽然一个字符串可以作为本身的前缀和后缀,但一个字符串的 LPS 不能是其本身。
在 KMP 算法中,要尽量减少字符不匹配导致的回溯,需要明确的知道子串中每个字符之前的子串的 LPS ,以便在每次出现字符不匹配时重新确定子串中开始匹配的位置。
以子串 ABABD
为例,以 i 为子串中字符的索引,i ∈ [0, 4]
;用 lps[i] 记录 subStr[0:i] 的 LPS 的长度 length
。由于 LPS 不能为字符串本身,所以 length = 0, lps[0] = 0
。
当 i = 1 时,此时 length = 0,由于 subStr[i] != subStr[length],故 length = 0, lps[1] = 0;
当 i = 2 时,此时 length = 0,subStr[i] == subStr[length],故 length += 1,lps[2] = 1;
当 i = 3 时,此时 length = 1,subStr[i] == subStr[length],故 length += 1,lps[3] = 2;
当 i = 4 时,此时 length = 2,subStr[i] != subStr[length],故 length = lps[length - 1] = lps[1] = 0,lps[4] = 0。
i | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
subStr[i] | A | B | A | B | D |
lps[i] | 0 | 0 | 1 | 2 | 0 |
1.2 代码实现
func buildLPS(pattern string) []int {
length, i := 0, 0
lps := make([]int, len(pattern))
lps[i] = length
i ++
for i < len(pattern) {
if pattern[i] == pattern[length] {
length += 1
lps[i] = length
i ++
} else if length == 0 {
lps[i] = length
i ++
} else {
// 此处直接 length -= 1 也可以实现相同的效果
// 但如果此时 length 的值比较大,那么采用 length -= 1 的方式可能会增加无效循环
// 以 abcdabeabf 为例,当 i = 6 时,此时 length = 2,但 pattern[2] != pattern[6]
// 如果此时 length -= 1, length = 1,pattern[1] != pattern[6],然后继续 length -= 1, length = 0 ……
// 相反,如果此时 length = lps[length - 1],length = 0,pattern[0] != pattern[6],然后 i ++
// 后者比前者少了一次循环
length = lps[length - 1]
}
}
return lps
}
⒉ KMP 算法
以 i
为文本的索引,j
为子串的索引,如果文本中的字符与子串中的字符匹配,则 i、j
依次递增。当文本中的字符与子串中的字符不匹配时:
- 如果此时
j = 0
,说明子串的第一个字符即匹配失败,此时只需要将i
的值加 1,然后重新进行匹配 - 如果此时
j > 0
,说明subStr[0:j - 1]
与text[i - j:i - 1]
是匹配的。根据子串预处理阶段生成的 LPS 信息,lps[j - 1]
即为新一轮匹配中j
的初始值
如上图,子串的 LPS 为 [0, 0, 1, 2, 0]
。当 i = 4
,j = 4
时子串与文本不匹配,因为我们已经知道 lps[3] = 2
,所以新的一轮匹配中 j
的初始值为 2,这样就跳过了对子串中前两个字符的匹配。
如上图,在新一轮匹配中,i = 4
,j = 2
,此时文本中的字符与子串中的字符仍然不匹配。根据 LPS 中的信息,下一轮匹配中 j
的初始值为 0。当 i = 4
,j = 0
时,文本中的字符与子串中的字符仍然不匹配,由于此时 j = 0
,所以在下一轮的匹配中,需要将 i
的值加 1,即 i = 5
。
package main
import (
"fmt"
)
func main() {
text := "ABABDABACDABABCABAB"
subStr := "ABABCABAB"
fmt.Printf("text = %+v\n", text)
fmt.Printf("subStr = %+v\n", subStr)
// 构建 LPS
lps := buildLPS(subStr)
fmt.Printf("lps = %+v\n", lps)
// 字符串搜索
n, m := len(text), len(subStr)
i, j := 0, 0
for i < n && j < m && n - i >= m - j {
if text[i] == subStr[j] {
i ++
j ++
}
if j == m {
// 子串匹配成功
fmt.Printf("subStr is found in text at position %+v\n", i - j)
} else if text[i] != subStr[j] {
// 在某个位置,文本中的字符与子串中的字符不匹配
if j > 0 {
j = lps[j - 1]
} else if j == 0 {
i ++
}
}
}
}