字符串搜索算法 KMP

36 阅读4分钟

  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 为字符串 ABCABLPS

虽然一个字符串可以作为本身的前缀和后缀,但一个字符串的 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。

i01234
subStr[i]ABABD
lps[i]00120

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 = 4j = 4 时子串与文本不匹配,因为我们已经知道 lps[3] = 2,所以新的一轮匹配中 j 的初始值为 2,这样就跳过了对子串中前两个字符的匹配。

  如上图,在新一轮匹配中,i = 4j = 2,此时文本中的字符与子串中的字符仍然不匹配。根据 LPS 中的信息,下一轮匹配中 j 的初始值为 0。当 i = 4j = 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 ++
			}
		}
	}
}