小红书面试中我这样解释 KMP,面试官点头了

KMP 算法全称 Knuth-Morris-Pratt,是一个经典的字符串匹配算法。

本文将结合我学习《算法4》一书的理解,从暴力匹配开始,逐步引出 KMP 的核心思想和实现细节。

KMP算法的全称是Knuth-Morris-Pratt,是一个经典的字符串匹配算法。它解决了什么问题呢:在一个非常长的字符串txt 中查找子串pattern,如果存在,返回这个子串的起始索引,否则返回-1。

KMP 就是为了解决这个问题,它比最基础的暴力方法高效得多:

  • 暴力匹配时间复杂度:O(m * n)
  • KMP 匹配时间复杂度:O(m + n)

其中 m是子串长度,n是主串长度。

先看一下最基础的办法😮 :暴力,本质就是从txt的每一个字符开始,逐个匹配,如果不同,就再测试下一个字符,比如从 aaacaaab(i)中匹配 aaab(j),最坏的时间复杂度是O(m*n)

ini 复制代码
function bruteForce(haystack, needle) {
  const n = haystack.length;
  const m = needle.length;
  for (let i = 0; i <= n - m; i++) {
    let j = 0;
    while (j < m && haystack[i + j] === needle[j]) {
      j++;
    }
    if (j === m) {
      return i;
    }
  }
  return -1;
}

但是很明显 pat 中根本没有字符 c,在前面根本没必要回退指针 i 去重复匹配,如果字符串中重复的字符比较多,这个方法就显得很蠢。

KMP 算法的核心在于:

它永不回退主串 txt 的指针,而是借助模式串 pat 的结构,跳过无效比较,直接定位下一步的匹配位置。

KMP 会花费空间来记录一些信息,在上述情况中就会显得很聪明。

继续上面那个例子,如果我们知道 pat中重复的字符可以"复用",就可以在失败时跳转到一个合理的位置继续匹配。

例如:从 aaacaaab(i)中匹配 aaab(j),当txt中的c第一次与pat中的b不匹配时,在下一步时会变成这样:

又比如,在字符串 aaaaaaab 中匹配 aaab,暴力解法还会和上面那个例子一样蠢蠢地在每次无法匹配a和b时回退指针 i,但是kmp就会聪明的在第一次 a 和 b 不匹配时保留 pat 的状态指向第三个a,因为 KMP 算法知道字符 b 之前的字符 a 都是匹配的,所以每次只需要比较字符 b 是否被匹配就行了。

所以大家发现没有,KMP 算法永不回退 txt 的指针 i ,而是借助某种已经算好的信息把 pat 移到正确的位置继续匹配 ,这样时间复杂度只需 O(n) 即可。那么问题来了,如何计算pat下一步要从哪里开始匹配?这时就需要确定有限状态自动机来辅助了,别急,虽然是个新名词,其实本质和动态规划的状态数组是一个思路。

什么是状态机呢🏗️,在这个算法里,就是对于pat字符串,每多匹配一位就是一种新状态,都没匹配是状态0,匹配了第一位就是状态1,这样,然后通过 txt 的每一位确定 pat 串从当前状态转移到什么状态。

可以把模式串 pat想象成一个"状态机":

  • 没有匹配任何字符时是状态 0;
  • 匹配了第一个字符后是状态 1;
  • 依此类推,直到匹配全部字符进入"终止状态"。

画个图理解一下,假设我们的 pat 字符串是ABABC

圆圈内的数字就是状态,开始匹配时 pat 处于起始状态 0,一旦转移到终止状态 5,就说明在 txt 中找到了 pat

比如说如果当前处于状态 2,就说明字符 "AB" 被匹配,注意 j 不是索引,而是状态

当然处于某个状态时,遇到不同的字符,pat 状态转移的行为也不同,

比如在这种情况下怎么办呢,已经匹配了四个字符了:

好,如果状态转移的那些信息已经算出来了,那么我们就会知道:

如果txt的下一个是A,那么回到状态3,都有ABA,所以转移到状态3是最聪明的

如果遇到了字符 "B",只能转移到状态 0

如果遇到了字符 "C",那么当然意味着匹配完成,转移到终止状态5

当然了,还可能遇到其他字符,比如 "D",但是显然应该转移到起始状态 0,因为 pat 中根本都没有字符 "D",匹配不了一点

如果状态转移的信息都已知,我们能获得这样一张图:注意,状态的转移只与 pat 字符串有关,无论要从多复杂的文本中找子串,只要 pat 是一样的,状态机就是一样的

上面详细的展示了什么状态如何转移,也是kmp的核心逻辑,描述出这张图,就是kmp的算法实现。

为了实现状态机🧩,我们首先需要构建 DFA 表。我们先定义一个二维数组

scss 复制代码
let dfa: number[][] = Array.from({ length: pat.length}, () => Array(256).fill(0));

显然,要确定状态转移的行为,必须明确两个变量,一个是当前的匹配状态 ,另一个是遇到的字符 ,而这个的 dfa[cur][c] 代表在当前状态 cur 下,遇到 txt 中的字符 c 的话,下一步状态是什么。这个256表示字符可能的ascii码值。

比如 dfa[4]['A'] = 3 表示当前是状态 4时,如果遇到字符 A,pat 应该转移到状态 3

基于此可以很快的写出搜索部分的代码,当然这里的前提是 dfa 数组要提前算出来

ini 复制代码
for(let i=0;i<txt.length;i++){
  cur=dfa[cur][txt.charCodeAt(i)]
    if(cur===pat.length){
      return i-pat.length //如果txt长度为5,pat长度也为5,那么起始位置就是0
    }
}
return -1;

到这里,应该还是很好理解的吧,那么下一步,如何算出状态转移的信息呢?

刚刚在状态定义时我们就已经知道,dfa两个维度的含义,当前的状态和遇到的字符,那么我们可以搭一个像动归的架子,来转移这些状态

ini 复制代码
dfa[0][pat.charCodeAt(0)] = 1; //初始状态,如果在状态0匹配了了pat的第一的字符,则跳到状态1
for (let cur = 1, restartPos = 0; cur < pat.length; cur++) {
  for (let c = 0; c < 256; c++) {
    dfa[cur][c] = next;
  }
}

这个 next 状态应该怎么求呢?

显然,如果遇到的字符 cpat[cur] 匹配的话,状态就应该向前推进一个,也就是说:

ini 复制代码
// 在当前状态,又匹配了 pat 的字符,就可以前进状态
dfa[cur][pat.charCodeAt(cur)] = cur + 1;

如果遇到的字符 c pat[cur] 不匹配的话,状态就要回退(或者原地不动)。

那么,如何得知要回退到那个状态呢,我们不妨定义一个新变量 re,指向要退到的那个状态。这个 re 状态有一个非常特殊的性质,就是和当前状态具有相同的前缀。什么意思呢,就比如ABABC,状态4的回退状态是2,因为都有 "AB" 前缀。

当状态4遇到了 "A",会将这个 "A" 交给状态2处理,即:

ini 复制代码
dfa[cur][c] = dfa[re][c];

为什么可以这样呢,因为 KMP 算法就是要尽可能少的回退,如果有相同前缀,又能匹配,就可以少匹配那些前缀了嘛!

你要问这个回退状态的再前一个回退状态是什么,那当然是已经算出来了,因为这种动态规划的思路就是利用过去的结果解决现在的问题,只要指定初始状态,就可以一直推算下去,即:

ini 复制代码
re = dfa[re][pat.charCodeAt(cur)];

因为在256种字符中只有一个是匹配的,所以我们在for循环中指定回退状态,然后在外面覆盖匹配的状态即可。

有了 DFA 表之后,搜索过程非常直接:

ini 复制代码
function kmpSearch(txt, pat) {
    const dfa = buildDFA(pat);
    let j = 0;
    for (let i = 0; i < txt.length; i++) {
        j = dfa[j][txt.charCodeAt(i)];
        if (j === pat.length) return i - j + 1; // 匹配成功
    }
    return -1;
}

🧠一旦理解状态机,KMP 就成为了一种顺理成章的设计。

如果你看懂了这篇文章,不妨点个赞或者转发一下;如果还有疑问,也欢迎评论交流。

📌 作者简介|前端方向本科生,已拿到小红书 offer,内容聚焦:高频算法、面试手撕题、前端系统设计。博客长期更新中。

相关推荐
Eloudy1 小时前
简明量子态密度矩阵理论知识点总结
算法·量子力学
点云SLAM1 小时前
Eigen 中矩阵的拼接(Concatenation)与 分块(Block Access)操作使用详解和示例演示
人工智能·线性代数·算法·矩阵·eigen数学工具库·矩阵分块操作·矩阵拼接操作
算法_小学生3 小时前
支持向量机(SVM)完整解析:原理 + 推导 + 核方法 + 实战
算法·机器学习·支持向量机
iamlujingtao3 小时前
js多边形算法:获取多边形中心点,且必定在多边形内部
javascript·算法
算法_小学生3 小时前
逻辑回归(Logistic Regression)详解:从原理到实战一站式掌握
算法·机器学习·逻辑回归
DebugKitty4 小时前
C语言14-指针4-二维数组传参、指针数组传参、viod*指针
c语言·开发语言·算法·指针传参·void指针·数组指针传参
qystca4 小时前
MC0241防火墙
算法
行然梦实7 小时前
粒子群优化算法(Particle Swarm Optimization, PSO) 求解二维 Rastrigin 函数最小值问题
算法·机器学习·数学建模
XH华7 小时前
C语言第六章函数递归
c语言·开发语言·算法
斯安7 小时前
LRU(Least Recently Used)原理及算法实现
算法