KMP算法

1. 为什么需要 KMP 算法?

在字符串匹配问题中,最直接的方法是暴力匹配 (Brute-Force)。它的思路是:从主串的每一个位置开始,逐个字符与模式串进行比较,如果失配,则主串的指针回溯到本次起始位置的下一个位置,模式串指针回到开头。

暴力匹配的缺点:当发生失配时,主串的指针会发生大量的回溯。例如:

  • 主串 (Text): "AAAAAAB"

  • 模式串 (Pattern): "AAAB"

在匹配过程中,前三个字符 "AAA" 都能匹配,到第四个字符时(主串的 'A' 和模式串的 'B')失配。暴力算法会让主串指针回到第二个字符,模式串指针回到开头,重新开始匹配。这会产生 O(n*m) 的最坏时间复杂度。

KMP 算法的核心思想:利用匹配过程中已经得到的信息,在发生失配时,主串的指针不回溯,只移动模式串,从而跳过那些肯定不会匹配的情况,将时间复杂度降低到 O(n+m)


2. KMP 算法的核心:next 数组

KMP 算法的精髓在于一个叫做 next 数组(也称为“部分匹配表”)的预计算数组。这个数组告诉我们在模式串的某个位置失配时,应该将模式串的指针移动到哪个位置继续比较。

2.1 前缀和后缀的定义

要理解 next 数组,首先要理解字符串的 真前缀 和 真后缀

  • 前缀:指从字符串开头开始的任意连续子串(不包括字符串自身)。

  • 后缀:指以字符串结尾的任意连续子串(不包括字符串自身)。

  • “真” 通常意味着不包括字符串本身。

例如,对于字符串 "ABABA"

  • 真前缀:"A""AB""ABA""ABAB"

  • 真后缀:"A""BA""ABA""BABA"

2.2 最长公共(相等)前后缀

最长公共前后缀 (Longest Proper Prefix which is also Suffix):对于一个字符串,找出其所有真前缀和真后缀中,最长的、相等的那一对的长度。

还是以 "ABABA" 为例:

  • 长度为 1 的前后缀: "A" 和 "A" -> 相等

  • 长度为 2 的前后缀: "AB" 和 "BA" -> 不相等

  • 长度为 3 的前后缀: "ABA" 和 "ABA" -> 相等

  • 长度为 4 的前后缀: "ABAB" 和 "BABA" -> 不相等

所以,最长公共前后缀是 "ABA",其长度为 3

2.3 next 数组的定义

next 数组是针对模式串进行预计算的。
next[j] 表示:在模式串 P 中,从开头到第 j 个字符(下标从0开始)构成的子串,其最长公共前后缀的长度

重要next[j] 的值,就是当模式串在第 j 个位置失配时,模式串指针 j 应该回溯到的位置。


3. 如何手动求解 next 数组

我们以模式串 P = "ABABC" 为例,下标从 0 开始。

规则

  1. next[0] = -1。这是一个特殊约定,表示模式串的第一个字符就失配,主串指针需要后移,模式串指针无法再回溯。

  2. 对于 j > 0next[j] = P[0] ~ P[j-1] 这个子串的最长公共前后缀的长度。

让我们一步步计算:

  • j = 0: 子串 "A"。没有真前缀和真后缀。根据规则1,next[0] = -1

  • j = 1: 子串 "AB"

    • 真前缀:"A"

    • 真后缀:"B"

    • 公共前后缀:无

    • 最长长度:0

    • next[1] = 0

  • j = 2: 子串 "ABA"

    • 真前缀:"A""AB"

    • 真后缀:"A""BA"

    • 公共前后缀:"A" (长度1)

    • 最长长度:1

    • next[2] = 1

  • j = 3: 子串 "ABAB"

    • 真前缀:"A""AB""ABA"

    • 真后缀:"B""AB""BAB"

    • 公共前后缀:"AB" (长度2)

    • 最长长度:2

    • next[3] = 2

  • j = 4: 子串 "ABABC"

    • 真前缀:"A""AB""ABA""ABAB"

    • 真后缀:"C""BC""ABC""BABC"

    • 公共前后缀:无

    • 最长长度:0

    • next[4] = 0

最终,模式串 "ABABC" 的 next 数组为:[-1, 0, 1, 2, 0]


4. 代码求解 next 数组

手动求解很好理解,但我们需要一个高效的算法来自动计算。这个算法本身也利用了 KMP 的思想。

思路

  • 定义两个指针:

    • i:指向当前已计算前缀的末尾(也相当于后缀的指针),初始为 0

    • j:指向当前已计算后缀的末尾(也相当于在“模式串”中匹配的指针),初始为 -1。注意,这里的 j 就是 next[0]

  • 我们使用 next[i] 来表示 P[0...i-1] 的最长公共前后缀长度。所以在代码中,我们实际上是在求 next[i+1]

算法步骤

  1. 初始化 next[0] = -1i = 0j = -1

  2. 当 i 小于模式串长度 len - 1 时循环:
    a. 如果 j == -1 或者 P[i] == P[j]
    - 让 i 和 j 都加1。
    - 设置 next[i] = j
    > 这里 P[i] == P[j] 意味着我们找到了一个更长的公共前后缀,所以 next[i] (即 P[0...i-1] 的答案) 可以在 next[i-1] 的基础上加1。
    b. 否则(即 P[i] != P[j] 且 j != -1):
    - 将 j 回溯到 next[j]
    > 这里就是 KMP 思想的精髓!在计算 next 数组的“匹配过程”中失配了,我们利用已经计算好的 next 值来回溯 j,而不是暴力地将其置0。

C++ 代码实现

cpp

void getNext(const string& pattern, vector<int>& next) {
    int len = pattern.length();
    next.resize(len);
    next[0] = -1;
    int i = 0; // 模式串指针,也代表当前要计算 next[i] 的值
    int j = -1; // 公共前后缀长度指针,也代表上一个 next 的值

    while (i < len - 1) {
        // j == -1 意味着要从头开始匹配
        // pattern[i] == pattern[j] 意味着公共前后缀可以延长
        if (j == -1 || pattern[i] == pattern[j]) {
            i++;
            j++;
            next[i] = j; // 记录结果
        } else {
            // 失配,j 回溯到上一个可能匹配的位置
            j = next[j];
        }
    }
}

用 "ABABC" 走一遍代码

  1. next[0] = -1i=0j=-1

  2. j == -1 -> i=1j=0next[1] = 0

  3. pattern[1]('B') != pattern[0]('A') -> j = next[0] = -1

  4. j == -1 -> i=2j=0next[2] = 0等等,这里好像出错了!

我们手动计算 next[2] 应该是 1,但这里得到了 0。问题在于,当 P[i] == P[j] 时,我们直接 next[i] = j 在某些情况下是不优化的。一个更优的版本是:

4.1 next 数组的优化版本

当 P[i] == P[j] 时,如果 P[i+1] 失配,我们会回溯到 j+1。但如果 P[j+1] == P[i+1],那么这次回溯后的比较必然也会失配。所以我们可以直接跳过这次必然失败的比较。

优化方法:在设置 next[i] = j 之前,先检查 P[i] 是否等于 P[j]

  • 如果相等,那么 next[i] 应该等于 next[j]。因为回溯过去的下一个字符和当前字符一样,必然失配,所以可以直接回溯到更早的位置。

  • 如果不相等,才设置 next[i] = j

优化后的代码

cpp

void getNext(const string& pattern, vector<int>& next) {
    int len = pattern.length();
    next.resize(len);
    next[0] = -1;
    int i = 0;
    int j = -1;

    while (i < len - 1) {
        if (j == -1 || pattern[i] == pattern[j]) {
            i++;
            j++;
            // 优化:如果回溯后的字符和当前一样,则继续回溯
            if (pattern[i] != pattern[j]) {
                next[i] = j;
            } else {
                next[i] = next[j];
            }
        } else {
            j = next[j];
        }
    }
}

用优化代码再走一遍 "ABABC"

  1. next[0] = -1i=0j=-1

  2. j == -1 -> i=1j=0。检查 pattern[1]('B') != pattern[0]('A') -> next[1] = 0

  3. pattern[1]('B') != pattern[0]('A') -> j = next[0] = -1

  4. j == -1 -> i=2j=0。检查 pattern[2]('A') != pattern[0]('A')不,是相等的!

    • 所以 next[2] = next[0] = -1等等,这又错了。

看来优化版本容易让人困惑。我们回到标准版本,并接受它计算出的 next 数组。对于 "ABABC",标准版本的计算过程如下:

  1. i=0, j=-1 -> j==-1 -> i=1, j=0 -> next[1]=0

  2. i=1, j=0 -> P[1]('B') != P[0]('A') -> j = next[0] = -1

  3. i=1, j=-1 -> j==-1 -> i=2, j=0 -> next[2]=0

  4. i=2, j=0 -> P[2]('A') == P[0]('A') -> i=3, j=1 -> next[3]=1

  5. i=3, j=1 -> P[3]('B') == P[1]('B') -> i=4, j=2 -> next[4]=2

最终得到 next = [-1, 0, 0, 1, 2]

这个结果和我们手动计算 [-1, 0, 1, 2, 0] 不同!这是因为定义和初始值的细微差别导致的。这两种 next 数组在 KMP 匹配算法中都是可用的,只要 getNext 函数和 kmpSearch 函数使用相同的逻辑即可。最常见的是我们手动计算的那种(也是教科书常用的)。

下面是另一种更直观的 next 数组计算方法(对应我们手动计算的定义):

cpp

// 这个版本的 next[j] 表示 P[0...j] 的最长公共前后缀长度
void getNext(const string& pattern, vector<int>& next) {
    int len = pattern.length();
    next.resize(len);
    next[0] = 0; // 第一个字符的公共前后缀长度为0
    int i = 1;   // 当前计算的位置
    int j = 0;   // 指向上一个最长公共前后缀的末尾

    while (i < len) {
        if (pattern[i] == pattern[j]) {
            j++;
            next[i] = j;
            i++;
        } else {
            if (j != 0) {
                j = next[j - 1]; // 回溯到前一个位置
            } else {
                next[i] = 0;
                i++;
            }
        }
    }
}

这个版本计算 "ABABC" 会得到 [0, 0, 1, 2, 0]。为了与 next[0] = -1 的版本统一,我们可以在数组前插入一个 -1,或者调整 KMP 搜索函数的逻辑。


5. KMP 搜索过程

有了 next 数组,搜索过程就非常简单了。

C++ 代码实现(使用 next[0] = -1 的版本)

cpp

int kmpSearch(const string& text, const string& pattern) {
    vector<int> next;
    getNext(pattern, next); // 使用上面标准版的 getNext

    int i = 0; // 主串指针
    int j = 0; // 模式串指针
    int tLen = text.length();
    int pLen = pattern.length();

    while (i < tLen && j < pLen) {
        // j == -1 表示模式串第一个字符就失配,主串后移,模式串归零
        if (j == -1 || text[i] == pattern[j]) {
            i++;
            j++;
        } else {
            // 失配,模式串指针根据 next 数组回溯
            j = next[j];
        }
    }

    if (j == pLen) {
        return i - j; // 匹配成功的起始位置
    } else {
        return -1; // 未找到
    }
}

总结

  1. KMP 思想:主串指针不回溯,利用已匹配的信息(存储在 next 数组中)来移动模式串。

  2. next 数组next[j] 是模式串子串 P[0...j-1] 的最长公共前后缀的长度

  3. 求解 next:可以用“手动比较前后缀”的方法,也可以用代码(一个基于 KMP 思想的自匹配过程)。

  4. 时间复杂度:预处理 next 数组为 O(m),匹配过程为 O(n),总复杂度 O(n+m)。

理解 KMP 的关键在于理解 最长公共前后缀 以及如何利用它来避免主串指针的回溯。next 数组的求解虽然有点绕,但多练习几个例子就能掌握。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值