写会儿KMP

76 阅读5分钟

KMP算法

Knuth-Morris-Pratt(KMP)算法:这种方法避免了朴素字符串搜索算法的回溯。它首先预处理子字符串以创建一个"部分匹配"表,该表用于跳过那些已知不可能匹配的部分。时间复杂度为O(n + m),其中n是长字符串的长度,m是子字符串的长度。

基本步骤

  1. 预处理阶段:首先,根据子字符串构建一个部分匹配表。这个表格在每个位置i记录了子字符串的前缀和后缀的最长的公共元素长度。例如,对于子字符串"ABCDABD",部分匹配表如下:

    0 1 2 3 4 5 6
    A B C D A B D
    0 0 0 0 1 2 0
    ```
    在这个例子中,"ABCDABD"的前6个字符的前缀"ABCDAB"和后缀"BCDAB"2个字符的公共元素"AB",
    所以在位置6的部分匹配值为2。
    同时2也是下一个不为公共元素的下标
    
  2. 搜索阶段:然后,在长字符串中从左到右开始匹配子字符串。如果在某个位置发现不匹配,可以通过查找部分匹配表找到一个位置,从该位置开始继续匹配,而无需从头开始匹配。

在最好的情况下,KMP算法的时间复杂度可以达到O(n),其中n是长字符串的长度。这是因为KMP算法保证了每个字符只需要被检查一次。

构造部分匹配表(hard)

KMP算法最核心的地方就是在理解部分匹配表,构造部分匹配表上。首先说说部分匹配表,为什么需要部分匹配表? 部分匹配表记录了重复出现的前缀后缀长度。

传统的字符串搜索算法在匹配失败后通常会将搜索的起点移动到下一个字符并重新开始搜索。然而,在许多情况下,这种方法会导致算法在文本字符串中多次重复匹配相同的字符序列,从而浪费计算资源。

相比之下,KMP 算法通过使用部分匹配表,可以在匹配失败后将搜索的起点移动到某个已知的可能的匹配位置,从而避免了不必要的重复匹配。具体来说,当匹配失败时,KMP 算法可以查找部分匹配表来确定下一个可能的匹配位置,然后直接将搜索的起点移动到该位置。这样,KMP 算法可以在字符串搜索中实现线性时间复杂度,大大提高了搜索效率。

举个例子,假设我们想在文本字符串 "ABC ABCDAB ABCDABCDABDE" 中搜索模式串 "ABCDABD"。如果我们使用朴素的线性搜索,在第一次找到 "ABCDAB" 但是下一个字符不是 "D" 时,我们会将搜索的起点移动到 "B",然后重新开始搜索。然而,如果我们使用 KMP 算法,我们可以直接将搜索的起点移动到第二个 "AB",因为部分匹配表告诉我们 "ABCDAB" 的最长相同前缀和后缀是 "AB"。这样,我们可以跳过一些明显不可能匹配的位置,从而提高搜索效率。

总的来说,部分匹配表的作用就是帮助我们在匹配失败时找到下一个可能的匹配位置,从而提高字符串搜索的效率。

然后来看一下部分匹配表的构造过程,

  1. 对于长度为0的子串,没有前缀和后缀,所以部分匹配值为0。

  2. 从左到右扫描子字符串,对每个位置i(从1开始),我们尝试找出最长的相等的真前缀和真后缀。"真"的意思是这个前缀和后缀不能等于整个子串。比如真子集

    例如,考虑子串"ABCDABD":

    0 1 2 3 4 5 6
    A B C D A B D
    0 0 0 0 1 2 0
    ````
    在位置40-based index)的字符是"A",考虑到此为止的子串"ABCDA",最长的相等的真前缀和真后缀是"A",其长度为1,所以部分匹配表在位置4的值为1
  3. 对于每个位置,如果当前字符和前一个字符的部分匹配值对应的下一个字符相同,部分匹配值加1,否则部分匹配值为0。

    例如,在"ABCDABD"中,位置5的字符是"B",最长的相等的真前缀和真后缀是"AB",其长度为2。

  4. 重复这个过程直到扫描完整个子字符串。

先来个最暴力的实现,时间复杂度 O(n^3)

`function` buildPartialMatchTable(pattern) {
    let table = [];

    for (let i = 0; i < pattern.length; i++) {
        let maxLen = 0;
        for (let len = 1; len < i; len++) {
            if (pattern.substring(0, len) === pattern.substring(i - len + 1, i + 1)) {
                maxLen = len;
            }
        }
        table.push(maxLen);
    }

    return table;
}

这里的痛点在于对于每个子串,我们都要找到它的最长相同前后缀,这需要O(n)的时间 但其实table已经存储了之前的相同前后缀,在求新的子串的最长相同前后缀时,完全可以利用之前的位置信息

function buildPartialMatchTable(pattern) {
    let table = Array(pattern.length).fill(0);
    let prefixEnd = 0; // 记录前缀字符串的结尾下标
    let suffixStart = 1; // 记录后缀字符串的开始下标

    while (suffixStart < pattern.length) {
        if (pattern[prefixEnd] === pattern[suffixStart]) {
        // 扩展最长公共前后缀字符串
            prefixEnd++;
            table[suffixStart] = prefixEnd;
            suffixStart++;
        } else if (prefixEnd === 0) {
        // 无法扩展时,缩短后缀来满足条件,前缀已经到0了,不能再减了
            table[suffixStart] = 0;
            suffixStart++;
        } else {
        // 缩短前缀,跳到上一次最长的公共前后缀串的位置去
            prefixEnd = table[prefixEnd - 1];
        }
    }

    return table;
}

在得到table之后,后面就遍历主串就完事了

function kmpSearch(text, pattern) {
  const table = buildPartialMatchTable(pattern);
  let textIndex = 0;
  let patternIndex = 0;

  while (textIndex < text.length) {
    if (text[textIndex] === pattern[patternIndex]) {
      // 当前字符匹配,移动到下一个字符
      textIndex++;
      patternIndex++;
    } else if (patternIndex !== 0) {
      // 当前字符不匹配,且模式串位置不是起始位置,移动模式串
      patternIndex = table[patternIndex - 1];
    } else {
      // 当前字符不匹配,且模式串位置是起始位置,移动主串
      textIndex++;
    }

    // 找到了一个匹配
    if (patternIndex === pattern.length) {
      return textIndex - pattern.length;
    }
  }

  // 没有找到匹配
  return -1;
}