暴力字符串匹配失配就要把文本指针往回拉,KMP凭什么能让它一直往前走

「算法与数据结构」系列 Day06

写在前面

在一段文本里找一个子串出现的位置,暴力思路谁都会写:从文本每个位置开始,跟模式串逐字符比对,一旦对不上,就挪到下一个起始位置重新比。问题是"重新比"这三个字背后藏着大量浪费------前面已经比对过、确认相等的那部分信息,被原地丢弃了。KMP算法能把复杂度从O(n·m)降到O(n+m),靠的就是把这部分本该被丢掉的信息利用起来,做到文本指针自始至终只往前走,从不回退。这篇就讲清楚这件事是怎么做到的,以及那个总让人头晕的next数组到底在记录什么。


一、是什么:不回头重扫文本,靠模式串自身的结构跳位置

字符串匹配问题就是:给定一个主串(text)和一个模式串(pattern),判断pattern有没有在text里出现过、出现在哪个位置。

暴力解法的动作是:从text的每一个起始位置出发,尝试把pattern对齐上去逐字符比较,只要中途有一个字符对不上,就放弃这次尝试,起始位置往后挪一位,重新从pattern的第一个字符开始比较。这个"重新开始"意味着文本指针会跟着回退------上一次已经比对过的那些字符,很可能要被重新扫描一遍。

KMP的动作完全不同:失配发生时,它不会让文本指针后退,而是去查一张提前算好的表------next数组 (也叫部分匹配表),根据这张表把模式串的指针直接跳到一个已知仍然成立的位置,继续往下比较。文本指针i从头到尾只增不减。

flowchart LR A[匹配到某位置<br/>发生失配] --> B[已匹配的这段等于<br/>pattern的一段前缀] B --> C[查pattern自身的<br/>最长公共前后缀长度] C --> D[模式串指针跳到<br/>这个长度对应的位置] D --> E[文本指针不回退<br/>继续往后比较]

二、为什么:暴力法在浪费已知信息,KMP把这部分信息用上了

暴力匹配最坏情况是O(n·m),触发条件也很具体:比如text是一长串重复的"a",pattern是"aaa...ab"(前面一长串a,最后一个字符不同)。每次对齐比较,都要一路匹配到pattern倒数第二个字符才发现最后一位对不上,n-m+1个起始位置,每个都要花费O(m),总量级就是O(n·m)。

问题出在哪:失配点之前,其实已经确认了文本这一段和pattern的前面部分完全相等,这个信息在暴力解法里被直接扔掉了 ,换到下一个起始位置又要从零开始逐字符验证一遍。KMP的关键洞察是:既然已经匹配了pattern的前j个字符、在第j+1个字符处失配,就可以问一个问题------pattern的前j个字符里,有没有一段长度为kk<j)的子串,既是这j个字符的前缀、又是它的后缀 ?如果有,说明已经匹配到的文本这一段,它的最后k个字符天然就等于pattern的前k个字符------不需要重新验证,直接把模式串指针跳到k,拿文本当前字符继续跟pattern[k]比较即可。

next数组就是提前把pattern每一个前缀"最长公共前后缀长度"都算出来存好,失配时查表就能拿到这个跳转位置,不需要现场重新扫描文本。用源码里测试的例子pattern = "ABABCABAB"走一遍:假设文本比对到"ABAB"这4个字符后,第5个字符失配了(比如文本这里是'D',pattern期望的是'C')。这时已匹配长度j=4,查next[3](下标从0开始,j=4对应前4个字符"ABAB",它的最长公共前后缀长度记录在next[3]里)等于2------意味着"ABAB"这4个字符里,前2个字符"AB"和后2个字符"AB"相等。于是模式串指针直接跳到2,文本指针不动,继续用当前文本字符和pattern[2]比较,相当于省掉了重新扫描前面已经确认过的字符。

这样一来,主循环里文本指针只会自增,最多走n步;构建next数组时同样利用了"pattern自己匹配自己"这个技巧,避免了重复扫描,是O(m)。两者相加,总复杂度是O(n+m),跟暴力法的O(n·m)完全不是一个量级。


三、怎么用:代码实现 + 常见坑

构建next数组:本质是pattern拿自己当文本,匹配自己

java 复制代码
public static int[] buildNext(String pattern) {
    int m = pattern.length();
    int[] next = new int[m];
    next[0] = 0;
    int j = 0;
    for (int i = 1; i < m; i++) {
        // 失配就回溯:这一步跟主搜索循环的回溯逻辑几乎一样
        while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
            j = next[j - 1];
        }
        if (pattern.charAt(i) == pattern.charAt(j)) {
            j++;
        }
        next[i] = j; // next[i]:pattern[0..i]里最长公共前后缀的长度
    }
    return next;
}

pattern = "ABABCABAB"跑一遍,结果是[0,0,1,2,0,1,2,3,4]。挑一个验证一下:next[8]=4,对应完整的9个字符"ABABCABAB",它最前面4个字符"ABAB"和最后面4个字符"ABAB"相等,且找不到更长的公共前后缀,跟计算结果对得上。

第一个坑是next数组的下标含义容易搞混 :这份实现里next[i]表示pattern[0..i](下标闭区间,包含第i个字符)的最长公共前后缀长度,所以后面搜索阶段查表时要用next[j-1]而不是next[j]------因为已匹配长度是j,对应的是pattern[0..j-1]这一段,要查的是这一段的next值。不同教材、不同实现对下标的定义方式不完全一致,照抄代码时一定要确认自己用的是哪种约定,两种混着抄最容易出错。

第二个坑是没意识到构建next数组的过程和主搜索过程结构高度相似不是巧合:构建next数组本质上就是拿pattern自己当text、自己当pattern,对自己做一次"自匹配",用的是同一套失配回溯逻辑。理解了其中一个,另一个基本就通了。

主搜索循环:j为0时的分支容易漏写导致死循环

java 复制代码
public static int kmpSearch(String text, String pattern) {
    int[] next = buildNext(pattern);
    int i = 0, j = 0;
    while (i < text.length()) {
        if (text.charAt(i) == pattern.charAt(j)) {
            i++;
            j++;
        } else if (j > 0) {
            j = next[j - 1]; // 回退模式串指针,文本指针不动
        } else {
            i++; // j已经是0,没法再回退了,只能推进文本指针
        }
        if (j == pattern.length()) {
            return i - j; // 完全匹配,返回起始下标
        }
    }
    return -1;
}

最容易漏掉的坑就是else分支里的i++:如果失配发生时j已经是0(模式串指针没法再往前跳了),必须让文本指针自增,否则ij都不变,下一轮循环还是同样的比较,会死循环 。这个分支因为很少在小规模测试里触发(大部分测试数据失配时j往往大于0),容易被忽略。

算法 时间复杂度 额外空间 失配后文本指针是否回退
暴力匹配 O(n·m) O(1) 会回退,从下一个起始位置重新扫描
KMP O(n+m) O(m)(next数组) 不回退,只推进模式串指针

四、面试追问

Q1:暴力字符串匹配最坏情况O(n·m),具体是什么样的输入会触发?

比如text是一长串重复的字符(如全是"a"),pattern是前面一长串相同字符、最后一位不同(如"aaa...ab")。每次对齐比较都要匹配到pattern倒数第二个字符才发现最后一位失配,n-m+1个起始位置每个都要花费O(m)的比较,总量级就是O(n·m)。

Q2:next数组里next[i]具体表示什么?

表示pattern[0..i]这个子串里,最长的"既是它的前缀、又是它的后缀"的长度(不包括子串本身)。比如pattern="ABABCABAB"next[8]=4,因为完整的9个字符里最前面4个"ABAB"和最后面4个"ABAB"相等,这是能找到的最长公共前后缀。

Q3:失配之后,为什么KMP可以直接让模式串指针跳转,而不需要把文本指针往回移?

因为失配点之前,已经确认了文本这一段和pattern已匹配的部分完全相等,这个信息不需要重新验证。查next数组能知道这段已匹配内容里最长公共前后缀的长度k,也就是说这段文本的最后k个字符天然等于pattern的前k个字符,只需要把模式串指针跳到k,用文本当前字符继续跟pattern[k]比较即可,不用重新扫描文本已经比对过的部分。

Q4:构建next数组的代码为什么和主搜索循环的结构几乎一样?

因为构建next数组的本质就是拿pattern自己当文本、自己当模式串,对自己做一次"自匹配",找每个前缀的最长公共前后缀,用的是同一套失配回溯逻辑(j = next[j - 1])。理解了主搜索循环怎么利用next数组跳转,构建next数组的过程也就顺理成章了。


下一篇预告

Day07 图的两种遍历:BFS与DFS分别适合解决什么问题。

相关推荐
Tim_101 小时前
【LeetCode】338、比特位计数
c++·算法·leetcode
木子算法1 小时前
不是重心也不是中点:费马—韦伯点、它的最优性条件与迭代求解
人工智能·算法·目标跟踪
tryxr3 小时前
矩阵的几种基础变换
java·数据结构·算法·矩阵
mmmmath_33 小时前
LeetCode.028.找出字符串中第一个匹配项的
数据结构·算法·leetcode
Selvaggia3 小时前
DMD(Distribution Matching Distillation,分布匹配蒸馏)
算法
Navigator_Z3 小时前
LeetCode //C - 1255. Maximum Score Words Formed by Letters
c语言·算法·leetcode
All for pursuit.3 小时前
【栈-4】739.每日温度
数据结构·c++·算法·leetcode
All for pursuit.4 小时前
【栈-5】84.柱状图中最大的矩形
数据结构·c++·算法·leetcode
wzdark4 小时前
数据压缩算法的时间复杂度与压缩率权衡4
算法