LeetCode Day9

217 阅读9分钟

kmp详解&总结篇

kmp算法

KMP算法(Knuth-Morris-Pratt算法)是一种用于字符串匹配的算法,特别是用于查找一个词(或模式)在另一个字符串中的所有出现位置。与传统的暴力搜索方法相比,KMP算法更加高效。 KMP算法的核心思想是利用已经匹配的部分信息来避免不必要的字符比较。为了实现这一点,KMP算法使用了一个名为nextfailure function的部分匹配表(PMT)。

以下是KMP算法的简略步骤:

  1. 部分匹配表(PMT)的构建:
  • PMT是一个数组,其中每个位置的值表示模式字符串的子串的最长公共前缀和后缀的长度。
  • 例如,对于模式“ABCDABD”,其PMT为[0, 0, 0, 0, 1, 2, 0]。
  1. 字符串匹配:
  • 当在文本中匹配模式时,如果发现字符不匹配,可以使用PMT来确定下一个应该比较的字符。
  • 如果模式中的某个字符与文本不匹配,可以查找PMT中的相应位置,然后将模式移动到该位置后的位置,从而跳过不必要的字符比较。

举例: 假设我们有文本“BBC ABCDAB ABCDABCDABDE”和模式“ABCDABD”。使用KMP算法,当我们在文本的第7个位置(即“D”)与模式的第7个位置(即“D”)发现不匹配时,我们可以直接将模式移动到文本的第2个位置,因为我们知道前面的“AB”已经匹配。

相信你已经对kmp算法有了一个大概的了解,但是对于PMT的构建和字符串匹配的过程仍需要进一步解释。

在解释PMT的构建前,我希望你能先明白一个关键概念:公共前后缀

什么是公共前后缀?

“公共前后缀”是指一个字符串的某个子串,它同时是该子串的前缀和后缀。我们来通过一个例子来更好地理解这个概念: 假设我们有一个字符串“ABAB”,我们可以找到以下的公共前后缀:

  1. “A”:这是字符串的第一个字符(前缀)和最后一个字符(后缀)。
  2. “AB”:这是字符串的前两个字符(前缀)和后两个字符(后缀)。

在KMP算法中,我们主要关注的是“最长公共前后缀”,它是字符串中具有最大长度的这样一个子串。 构建部分匹配表(PMT)时,我们会为模式字符串中的每个子串找到其最长公共前后缀的长度。例如,对于模式字符串“ABCDABD”,其PMT将是:

  • A: 0 (没有公共前后缀)
  • AB: 0 (没有公共前后缀)
  • ABC: 0 (没有公共前后缀)
  • ABCD: 0 (没有公共前后缀)
  • ABCDA: 1 (公共前后缀是“A”)
  • ABCDAB: 2 (公共前后缀是“AB”)
  • ABCDABD: 0 (没有公共前后缀)

因此,PMT为[0, 0, 0, 0, 1, 2, 0]。 这个“最长公共前后缀”的概念是KMP算法的核心,它允许算法在发现不匹配时跳过不必要的比较,因为我们已经知道了一些关于模式字符串的结构信息。 现在你已经知道构建PMT的原理,但是你不可能直接一个字符一个字符地数,我们有另外的算法来算出PMT,接下来是详细的构建步骤

部分匹配表(PMT)的构建

步骤1:初始化

  • 过程:初始化len为0(表示当前的最长公共前后缀长度)和i为1(表示当前字符的位置)。
  • 理由:我们从模式字符串的第二个字符开始比较,因为一个字符的最长公共前后缀长度显然是0。
  • 效果:为算法的开始设置了基线。

步骤2:字符比较

  • 过程:在循环中,比较模式字符串中位置ilen的字符。
  • 理由:我们试图找到最长的公共前后缀。
  • 效果:确定是否有可能的公共前后缀存在。

步骤3:如果字符匹配

  • 过程:如果字符匹配,则增加len的值,并将len的值存储在PMT的i位置,然后增加i的值。
  • 理由:我们找到了一个更长的公共前后缀,所以我们更新len并将其记录在PMT中。
  • 效果:PMT在位置i处被正确地更新,我们移动到下一个字符进行比较。

步骤4:如果字符不匹配且len不为0

  • 过程:将len设置为PMT中len-1位置的值。
  • 理由:当前的len值没有提供匹配,所以我们回退到前一个可能的最长公共前后缀。
  • 效果len被更新,我们尝试一个较短的公共前后缀。

步骤5:如果字符不匹配且len为0

  • 过程:将PMT的i位置设置为0,并增加i的值。
  • 理由:我们没有找到任何公共前后缀,所以我们将PMT的当前位置设置为0,并继续检查下一个字符。
  • 效果:PMT在位置i处被设置为0,我们移动到下一个字符进行比较。

通过这种方式,我们逐步构建了PMT,它记录了模式字符串中每个位置的最长公共前后缀长度。这个表将被用于主匹配算法,以避免不必要的字符比较,从而提高匹配的效率。 现在你已经知道了PMT的构建方法,我们将用PMT来查找我们的目标字符串。

字符串匹配

1. 初始化指针

  • 过程:使用两个指针,i用于遍历文本字符串,j用于遍历模式字符串。初始时,两者都设置为0。
  • 理由:我们从文本和模式的开始位置开始匹配。
  • 效果:为匹配过程设置了起始点。

2. 比较字符

  • 过程:比较文本中位置i的字符和模式中位置j的字符。
  • 理由:我们要检查当前位置的字符是否匹配。
  • 效果:决定了下一步的操作。
case1. 如果字符匹配
  • 过程:增加ij的值,即移动到下一个字符。
  • 理由:我们找到了一个匹配的字符,所以我们继续检查下一个字符。
  • 效果ij都向前移动一位。
case2. 完全匹配
  • 过程:如果j达到模式字符串的长度,这意味着我们找到了一个完全匹配的模式。
  • 理由:所有模式字符都与文本中的字符匹配。
  • 效果:记录匹配的位置,并使用PMT将j重置为前一个可能的匹配位置,以查找其他可能的匹配。
case3. 如果字符不匹配
  • 过程:如果文本的i位置和模式的j位置的字符不匹配,我们使用PMT来调整j的位置。
  • 理由:我们希望跳过不必要的字符比较,所以我们使用PMT来找到下一个可能的匹配位置。
  • 效果j被设置为PMT中j-1位置的值,这意味着我们跳过了一些字符,并且没有回到模式的开始位置。

3. 如果j为0

  • 过程:如果j为0(即我们已经在模式的开始位置),我们只增加i的值。
  • 理由:我们不能再回退模式字符串了,所以我们只移动文本的指针。
  • 效果i向前移动一位,而j保持不变。

这个过程会一直持续,直到文本字符串被完全遍历。 现在你已经完全明白kmp算法的全过程,来看一个示例

示例

#include <iostream>
#include <vector>
#include <string>

// 构建部分匹配表
std::vector<int> buildPMT(const std::string& pattern) {
    int m = pattern.size();
    std::vector<int> pmt(m, 0);  // 初始化PMT
    int len = 0;  // 最长公共前后缀的长度
    int i = 1;  // 从模式的第二个字符开始

    while (i < m) {
        // 当前字符与len位置的字符匹配
        if (pattern[i] == pattern[len]) {
            len++;
            pmt[i] = len;  // 更新PMT
            i++;
        } else {
            if (len != 0) {
                // 如果不匹配且len不为0,回退
                len = pmt[len - 1];
            } else {
                // 如果不匹配且len为0,将PMT的当前位置设置为0
                pmt[i] = 0;
                i++;
            }
        }
    }
    return pmt;
}

// KMP字符串匹配
void KMP(const std::string& text, const std::string& pattern) {
    int n = text.size();
    int m = pattern.size();
    std::vector<int> pmt = buildPMT(pattern);  // 获取PMT
    int i = 0;  // 文本的指针
    int j = 0;  // 模式的指针

    while (i < n) {
        // 字符匹配
        if (pattern[j] == text[i]) {
            i++;
            j++;
        }
        // 完全匹配
        if (j == m) {
            std::cout << "Pattern found at index " << i - j << std::endl;
            j = pmt[j - 1];  // 重置j
        } else if (i < n && pattern[j] != text[i]) {
            if (j != 0) {
                j = pmt[j - 1];  // 使用PMT调整j
            } else {
                i++;
            }
        }
    }
}

int main() {
    std::string text = "BBC ABCDAB ABCDABCDABDE";
    std::string pattern = "ABCDABD";
    KMP(text, pattern);
    return 0;
}

双指针法总结

  1. 数组去重:当需要从有序数组中移除重复元素时,可以使用双指针法。一个指针用于遍历数组,另一个指针用于指向不重复的最后一个元素。
  2. 两数之和:在有序数组中查找两个数,使它们的和等于给定的目标值。一个指针从数组的开始位置开始,另一个指针从数组的末尾开始。根据两个指针所指向的元素之和与目标值的关系,移动一个或另一个指针。
  3. 链表中的环检测:使用“快慢”双指针法来确定链表中是否存在环。慢指针每次移动一步,而快指针每次移动两步。如果存在环,两个指针最终会相遇。
  4. 回文字符串检测:使用两个指针,一个从字符串的开始位置开始,另一个从字符串的末尾开始。比较两个指针所指向的字符是否相同,并同时移动两个指针,直到它们相遇。
  5. 合并两个有序数组或链表:当需要将两个有序数组或链表合并成一个有序数组或链表时,可以使用双指针法。每个数组或链表都有一个指针,根据这两个指针所指向的元素的大小,将较小的元素添加到结果中,并移动相应的指针。
  6. 滑动窗口问题:例如,查找数组中的最小子数组,其和大于或等于给定值。使用两个指针表示窗口的开始和结束位置,并根据窗口内的元素之和与目标值的关系,移动一个或另一个指针。