ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

KMP算法next数组的两种定义:从原理到代码实现详解

KMP算法next数组的两种定义:从原理到代码实现详解 1. 项目概述一个困扰无数初学者的“1”之差如果你正在学习数据结构与算法尤其是准备考研、期末复习或者面试刷题那么KMP算法绝对是一个绕不开的坎。而在这个坎里最让人头疼的莫过于那个神秘的next数组。更让人困惑的是你会发现不同的教材、不同的博客、甚至不同的老师给出的next数组定义和计算方法竟然不一样最典型的区别就是数组下标从0开始还是从1开始以及计算出的值整体相差1。标题中提到的“李春葆、严蔚敏关于KMP算法的next数组值差1”正是戳中了这个经典痛点。李春葆老师的《数据结构教程》和严蔚敏老师的《数据结构C语言版》作为国内数据结构领域的权威教材它们对next数组的定义就存在这个“1”的差异。这绝不是简单的印刷错误而是背后对算法逻辑的不同理解和实现习惯的体现。这个“差1”的问题轻则让你在对照不同资料时一头雾水怀疑人生重则导致你手写的next数组无法正确驱动KMP算法进行匹配代码调试到崩溃。今天我们就来彻底掰扯清楚这个“1”到底差在哪里为什么会有这种差异以及如何在不同定义间自由切换做到“手中有粮心中不慌”。无论你是看王道考研资料、做实验报告还是用C、Java实现算法理解了这个核心差异KMP算法就不再是玄学。2. 核心概念解析next数组到底是什么在深入“差1”问题之前我们必须夯实基础明白next数组究竟为何物。KMP算法之所以高效核心在于当主串与模式串在某一位匹配失败时模式串不是傻傻地回退到开头重新匹配而是利用已经匹配成功的那部分前缀信息滑动到一个特定的位置继续尝试。这个“特定的位置”就是由next数组指导的。2.1 next数组的数学定义与核心思想next数组是针对模式串我们要查找的字符串生成的。对于模式串P的每一个位置j通常j从0或1开始next[j]的值定义为当模式串中第j个字符与主串相应字符失配时下一步应该用模式串的第next[j]个字符去与主串当前失配的字符进行比较。那么next[j]具体怎么算呢它的核心思想是寻找“最长相等真前缀和真后缀”。真前缀是指不包括字符串本身的前缀子串真后缀同理。对于模式串在位置j之前的子串即P[0...j-1]或P[1...j-1]取决于下标起点我们需要找到这个子串中最长的那个相等的真前缀和真后缀的长度。这个长度值就是next[j]的候选。举个例子模式串“ababc”。我们来看位置j3假设下标从0开始对应字符‘a’之前的子串是“aba”。“aba”的真前缀有“a”,“ab”。“aba”的真后缀有“ba”,“a”。相等的真前缀和真后缀是“a”其长度为1。 因此对于j3next[3]的值就与这个长度1有关。至于具体是等于1还是等于112这就是“差1”问题的根源。2.2 两种主流next数组定义对比现在我们来直面核心矛盾。主要有两种定义方式我称之为“右移一位”定义和“直接记录”定义。为了方便对比我们以模式串“ababc”为例分别用两种方式计算其next数组。定义一严蔚敏教材/王道考研风格通常下标从1开始这种定义可以理解为“右移一位”或“整体加1”。其规则是next[1] 0。这是一个特殊约定表示模式串第一个字符就失配时主串指针后移模式串指针重置。对于j 1next[j]模式串P[1...j-1]这个子串的最长相等真前后缀长度 1。按照这个定义计算“ababc”我们假设存储为P[1]a, P[2]b, P[3]a, P[4]b, P[5]cj1:next[1] 0。j2: 子串“a”无真前后缀长度为0next[2] 01 1。j3: 子串“ab”最长相等真前后缀长度为0next[3] 01 1。j4: 子串“aba”最长相等真前后缀是“a”长度为1next[4] 11 2。j5: 子串“abab”最长相等真前后缀是“ab”长度为2next[5] 21 3。 所以next数组为[0, 1, 1, 2, 3]下标1到5。定义二李春葆教材/部分算法实现通常下标从0开始这种定义更“直接”它记录的就是那个长度值本身。其规则是next[0] -1。这也是一个特殊约定功能上与next[1]0类似但用-1作为一个标志位在代码实现时更方便。对于j 0next[j]模式串P[0...j-1]这个子串的最长相等真前后缀长度。按照这个定义计算“ababc”存储为P[0]a, P[1]b, P[2]a, P[3]b, P[4]cj0:next[0] -1。j1: 子串“a”最长相等真前后缀长度为0next[1] 0。j2: 子串“ab”最长相等真前后缀长度为0next[2] 0。j3: 子串“aba”最长相等真前后缀是“a”长度为1next[3] 1。j4: 子串“abab”最长相等真前后缀是“ab”长度为2next[4] 2。 所以next数组为[-1, 0, 0, 1, 2]下标0到4。对比表格特征严蔚敏风格 (右移1)李春葆风格 (直接记录)数组下标起点通常为 1通常为 0首项值next[1] 0next[0] -1核心定义最长相等前后缀长度 1最长相等前后缀长度本身示例“ababc”[0, 1, 1, 2, 3](下标1~5)[-1, 0, 0, 1, 2](下标0~4)关系严蔚敏_next[j]≈李春葆_next[j-1] 1李春葆_next[j]≈严蔚敏_next[j1] - 1注意这里说的“严蔚敏风格”和“李春葆风格”是一种简化的归类实际上市面上资料混杂。关键是要识别出定义的本质区别而不是死记教材名字。有些博客或代码可能下标从0开始但用的却是“1”的定义这会更让人混乱。3. 两种定义下的next数组生成算法详解理解了定义我们来看如何用代码生成这两种next数组。这里会给出清晰的代码和逐步推演你会发现它们的核心逻辑惊人地一致只是初始化和赋值时有“1”的差别。3.1 基于“右移1”定义严蔚敏风格的生成这种算法通常假设模式串p的下标从1开始p[0]可能存放串长度。我们用i表示后缀的末尾索引j表示前缀的末尾索引也即next[i]的候选值。算法步骤初始化next[1] 0i 2j 0。这里j可以理解为已匹配的前缀长度。循环i从2到模式串长度m a. 比较p[i]和p[j1]。注意是j1因为j表示的是长度指向的是前缀末尾的下一个位置不这里需要仔细理解在这个定义下j更准确地说是“上一个已计算出的next值”它指向的是下一次应该比较的模式串字符位置。所以当p[i]与p[j1]比较时是在检查当前后缀末尾字符是否能扩展当前的最长相等前后缀。 b. 若p[i] p[j1]则匹配成功最长相等前后缀长度可以增加1。令j j 1然后next[i] j。 c. 若p[i] ! p[j1]且j 0说明匹配失败但之前有部分匹配。此时令j next[j]回退到上一个可能匹配的位置然后回到步骤a继续比较。 d. 若p[i] ! p[j1]且j 0说明已无路可退。则next[i] 1因为根据定义此时最长相等前后缀长度为0next[i]011然后i加1继续下一个位置。手动推演“ababc”下标1开始i1:next[1]0。i2,j0:p[2]b,p[1]a不等且j0next[2]1i3。i3,j1:p[3]a,p[2]b不等jnext[1]0。回退后j0比较p[3]a和p[1]a相等不此时因j0触发规则dnext[3]1i4。i4,j1:p[4]b,p[2]b相等j2,next[4]2i5。i5,j2:p[5]c,p[3]a不等jnext[2]1。回退后j1比较p[5]c和p[2]b不等jnext[1]0。回退后j0触发规则dnext[5]1等等这里推演出错了根据我们之前的定义计算next[5]应该是3。错误在于规则d当j0时next[i]应该等于1吗我们重新审视定义next[j] 最长相等真前后缀长度 1。当j0时意味着对于P[1...i-1]其最长相等真前后缀长度为0所以next[i]应该等于011。但我们计算next[5]时子串是“abab”其最长相等真前后缀是“ab”长度为2所以next[5]应该是3。这说明上面的算法描述在j0且p[i]!p[1]时不能简单赋值为1。实际上标准的严蔚敏教材算法是next[1]0; j0; i1;while(i m) {if(j0 || p[i]p[j]) { i; j; next[i]j; }else { j next[j]; }}注意这个算法中i和j是同步增长的next[i]记录的是j的值。用这个算法重推i1, j0: 条件j0成立i2, j1, next[2]1。i2, j1:p[2](b) ! p[1](a)jnext[1]0。i2, j0: 条件j0成立i3, j1, next[3]1。i3, j1:p[3](a) ! p[1](a)相等所以i4, j2, next[4]2。i4, j2:p[4](b) p[2](b)i5, j3, next[5]3。 得到next [?, 0, 1, 1, 2, 3]next[0]未使用符合预期。3.2 基于“直接记录”定义李春葆风格的生成这种算法模式串下标从0开始逻辑更清晰。i表示后缀末尾索引j表示前缀末尾索引同时也是已匹配的长度。算法步骤C语言风格下标0开始初始化next[0] -1i 0j -1。循环i从0到模式串长度m-1实际上循环生成next[i1] a. 如果j -1或者p[i] p[j]则i; j; next[i] j;。 b. 否则j next[j];。手动推演“ababc”下标0开始初始next[0] -1,i0,j-1。i0, j-1: 条件j-1成立i1, j0, next[1]0。i1, j0:p[1](b) ! p[0](a)jnext[0]-1。i1, j-1: 条件成立i2, j0, next[2]0。i2, j0:p[2](a) p[0](a)i3, j1, next[3]1。i3, j1:p[3](b) p[1](b)i4, j2, next[4]2。 得到next [-1, 0, 0, 1, 2]符合预期。对比与心得仔细观察你会发现两个算法的核心循环体几乎一模一样都是“if(j标志位 || p[i]p[j]) {i; j; next[i]j;} else {jnext[j];}”。唯一的区别就是初始化和标志位严蔚敏风格下标从1开始j初始为0标志位是0next[1]0。李春葆风格下标从0开始j初始为-1标志位是-1next[0]-1。实操心得这个发现非常重要。它意味着你不需要记忆两套算法。你只需要掌握其中一种核心逻辑比如李春葆的下标0风格然后理解它们之间的对应关系。当遇到另一种定义的代码或题目时你可以在脑中完成下标和值的转换。很多网上代码混乱就是因为混用了定义和下标起点。我个人的习惯是使用下标从0开始、next[0]-1的风格因为这与C/C、Java等语言的数组习惯一致代码更简洁不易出错。4. KMP匹配算法如何适配不同的next数组生成了next数组最终目的是要用在KMP主匹配算法中。两种不同的next数组对应的匹配算法代码也有细微差别。但核心思想不变主串指针i不回溯模式串指针j在失配时根据next[j]回退。4.1 使用“右移1”定义next数组的KMP算法假设主串S下标从1开始模式串P下标从1开始next数组也已按此定义生成next[1]0。算法伪代码i 1; // 主串指针 j 1; // 模式串指针 while (i n j m) { // n为主串长m为模式串长 if (j 0 || S[i] P[j]) { i; j; } else { j next[j]; } } if (j m) { return i - m; // 匹配成功返回起始位置 } else { return 0; // 匹配失败 }关键点解析j 0这是一个关键条件。当j被回退到0时即next[某值]0意味着模式串的第一个字符就与当前主串字符失配。此时按照算法逻辑if条件成立会执行i; j;。这相当于主串指针i前进一步因为本次失配模式串指针j从0变成1指向模式串第一个字符准备用模式串的第一个字符与主串的下一个字符进行比较。这实现了主串指针的后移。匹配过程当S[i] P[j]双指针齐步走。失配时j根据next[j]回退i不动。由于next数组值是“长度1”所以回退到的位置j可以直接用于下一轮比较。4.2 使用“直接记录”定义next数组的KMP算法假设主串S和模式串P下标都从0开始next数组按此定义生成next[0]-1。算法伪代码C语言风格int i 0; // 主串指针 int j 0; // 模式串指针 while (i n j m) { if (j -1 || S[i] P[j]) { i; j; } else { j next[j]; } } if (j m) { return i - j; // 匹配成功 } else { return -1; // 匹配失败 }关键点解析j -1作用与上一种情况的j0完全等效。当j回退到-1表示已无法回退当前主串字符S[i]与模式串开头都无法匹配。此时条件成立执行i; j;后i后移一位j从-1变为0即指向模式串开头开始下一轮匹配。返回值因为下标从0开始成功时匹配的起始位置是i - j。对比与适配技巧标志位不同一个是0一个是-1。这直接对应了next数组首项的不同。循环条件一个用下标1开始一个用下标0开始这是数组下标的常规差异。成功判断一个判断j m一个判断j m。返回值计算一个i - m一个i - j。在成功时j等于m所以i - j等价于i - m。注意事项绝对不要混用如果你用严蔚敏风格生成了next数组值如[0,1,1,2,3]就必须搭配使用下标从1开始的匹配算法且循环中判断j0。如果你用李春葆风格生成了next数组值如[-1,0,0,1,2]就必须搭配使用下标从0开始的匹配算法且循环中判断j-1。混用必然导致匹配逻辑错误。这是考试和面试中常见的陷阱。5. 从原理到实践next数组的优化nextval数组基础的next数组已经能大幅提升效率但它还有优化空间。考虑模式串“aaaab”和主串“aaabaaaab”。按“直接记录”法计算next数组[-1, 0, 1, 2, 3]。匹配过程当i3, j3时主串‘b’模式串‘a’失配。j回退到next[3]2发现P[2]还是‘a’必然继续失配。j再回退到next[2]1P[1]仍是‘a’再次失配。最后回退到next[1]0。这里发生了多次不必要的回退和比较。优化的思路是在计算next数组的过程中如果发现回退后的字符与当前字符相同那么这个回退是无效的应该直接回退到与当前字符不同的那个位置。由此得到优化后的数组常被称为nextval数组。nextval数组生成算法基于“直接记录”风格在生成next数组的代码基础上稍作修改nextval[0] -1; for (int i 1; i m; i) { int j next[i]; if (P[i] P[j]) { nextval[i] nextval[j]; // 关键优化相同则继承 } else { nextval[i] j; } } // 注意nextval[0] 保持为 -1对于“aaaab”next [-1, 0, 1, 2, 3]计算nextvali1:P[1](a) P[next[1]0](a)所以nextval[1] nextval[0] -1。i2:P[2](a) P[next[2]1](a)所以nextval[2] nextval[1] -1。i3:P[3](a) P[next[3]2](a)所以nextval[3] nextval[2] -1。i4:P[4](b) ! P[next[4]3](a)所以nextval[4] next[4] 3。 得到nextval [-1, -1, -1, -1, 3]。使用nextval进行匹配当j3失配时直接跳转到nextval[3]-1然后进入j-1的分支i和j同时加1效率更高。对于“右移1”风格的优化 思路完全一致只是下标和值有偏移。假设next数组为[0,1,2,3,4]对于“aaaab”计算nextvalnextval[1] 0。for i2 to m: 如果P[i] P[next[i]]则nextval[i] nextval[next[i]]否则nextval[i] next[i]。 最终得到nextval [0,0,0,0,4]。在匹配算法中j回退时使用nextval[j]即可。实操心得在笔试或面试中如果题目没有明确要求写出基础的next数组通常就能得分。但如果能提到nextval优化并简要说明原理绝对是加分项。在实际的工程代码中特别是高性能字符串匹配库中使用nextval是标准做法。自己实现KMP时也强烈建议直接实现nextval它增加的代码复杂度很小但能避免一些最坏情况下的性能退化。6. 常见问题与排查技巧实录在实际学习、做题和编码中关于next数组的“差1”问题会引发各种bug和困惑。这里我总结几个最典型的场景和解决方法。6.1 问题一手算next数组结果和标准答案对不上场景给定模式串“ababaaababaa”你手算的next数组和教材答案差1或者整体对不上。排查步骤确认下标起点首先看题目或教材的约定。数组下标是从0开始还是从1开始这决定了你计算的是next[0]还是next[1]以及子串的取法。确认定义是“最长相等前后缀长度”本身还是“长度1”最直接的判断方法是看第一个值。如果第一个值是0且下标从1开始很可能是“1”定义如果第一个值是-1且下标从0开始则是“直接记录”定义。如果第一个值是0但下标从0开始那可能就是“直接记录”定义此时next[0]固定为-1或0取决于具体实现需看第二个值。重新计算按照确定的定义和起点一步一步、一个字符一个字符地计算最长相等真前后缀长度。对于复杂串可以画表格辅助列出每个位置j写出对应的前缀子串然后列出所有真前缀和真后缀找最长的公共部分。使用已知简单串验证用“ababc”或“aaaab”这种简单串先验证你的计算方法和理解是否正确再套用到复杂串上。6.2 问题二自己实现的KMP代码编译通过但运行结果错误或死循环场景你参考了某个博客的代码实现了getNext和KMP函数但匹配结果不对或者在某个测试用例下陷入死循环。排查技巧检查next数组生成与使用的一致性最高频错误这是“差1”问题导致的直接bug。用printf或调试器打印出你生成的next数组。然后对照你KMP匹配函数中的逻辑如果你的next[0] -1匹配循环中必须有if (j -1 ...)。如果你的next[1] 0且下标从1开始匹配循环中必须有if (j 0 ...)。重点确保getNext函数中next数组的赋值逻辑与KMP函数中j next[j]这句代码所期待的next数组含义完全匹配。一个快速验证方法是用一个非常简单的模式串如“ab”和主串如“aab”单步调试观察每次失配后j的回退值是否符合预期。检查数组越界确保你的循环条件i n等正确没有访问S[n]或P[m]。特别是在使用“下标从1开始”的风格时要确保数组实际分配的长度是m1。检查字符串存储你的字符串是存在char[]里吗char[0]存放的是什么是长度还是第一个字符这直接影响下标计算。对于C风格字符串以‘\0’结尾通常从0开始存字符这时使用“直接记录”定义next[0]-1最自然。验证next数组的正确性不要相信你的感觉用我们第2部分介绍的方法手动计算几个位置的next值与程序输出对比。或者在网上找一个公认正确的KMP在线可视化工具输入你的模式串对比生成的next数组。6.3 问题三面对不同资料如何快速转换和理解场景考研看王道视频用的是A定义教材是B定义刷LeetCode题解看到的是C写法完全混乱。应对策略掌握核心以不变应万变深入理解next数组的本质——“当第j位匹配失败时模式串应该滑动到哪个位置k继续与主串当前位比较”。这个k的值就是由已匹配部分P[0...j-1]的最长相等真前后缀长度t决定的。不同的定义只是表述这个k的方式不同k t直接记录或者k t 1右移1。建立“桥梁”在心里记住这个转换关系。当你看到一组next值尝试将其减1或加1看看是否变得更“自然”比如出现-1或者值变小。这能帮你快速判断它用的是哪种定义。固定自己的“母语”选择一种你理解最透彻、觉得最顺手的定义和实现方式我个人推荐下标0开始、next[0]-1的风格作为你的“基准”。在阅读其他资料时先将其转换到你的“基准”体系下来理解。例如看到王道书上的next[1]0, next[2]1,...你就在心里默念“哦这对应我体系里的next[0]-1, next[1]0,...整体值减了1下标加了1。”关注算法逻辑而非单纯记数字无论是哪种定义KMP匹配算法的核心流程是不变的初始化双指针循环比较相等则共进失配则模式串指针j按next[j]回退并处理回退到边界的情况。只要把握住这个流程不同的next数组只是在这个流程中填入不同的“回退地图”而已。最后再分享一个我自己的记忆技巧我把“直接记录”法next[0]-1想象成**“失败指针”。next[j]直接告诉你j位置失败后应该跳转到哪个位置。而“右移1”法next[1]0我把它想象成“前缀长度表”**它记录的是“有效前缀的长度1”需要一次转换才能得到跳转位置。前者更贴近代码实现的思维后者更贴近数学推导的思维。理解这两者的等价性你就真正掌握了KMP的精髓。
返回列表