ARTICLE DETAIL

资讯详情

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

KMP算法详解:从暴力匹配到线性时间复杂度的字符串查找

KMP算法详解:从暴力匹配到线性时间复杂度的字符串查找 1. 从“暴力匹配”的困境说起如果你写过字符串查找的代码大概率是从一个简单的双重循环开始的外层循环遍历主串的每个位置作为起始点内层循环逐个字符与模式串进行比较。一旦发现不匹配外层循环就跳到下一个位置模式串从头再来。这就是所谓的“暴力匹配”Brute-Force。它的逻辑直观代码简单但效率是硬伤。假设主串长度为n模式串长度为m最坏情况下比如主串是“aaaaaaaaab”模式串是“aaab”时间复杂度会达到O(n*m)。当处理大量文本数据时这种性能开销是难以接受的。问题的核心在于“回溯”。每次匹配失败主串的指针我们称之为i和模式串的指针j都要回退。i回退到本轮起始位置的下一个点j则直接归零。这意味着之前已经比较过的、可能包含有效信息的字符被完全丢弃了下一次匹配又从头开始“盲猜”做了大量重复且无效的比较。那么有没有一种方法能在匹配失败时利用已经匹配成功的那部分前缀信息让模式串“智能地”滑动一段距离同时主串指针i绝不后退从而避免重复劳动呢这就是KMP算法要解决的核心问题。它通过一个神奇的“部分匹配表”常被称为next数组将模式串的自我匹配信息预先计算好在匹配失败时指导j指针进行精确回退实现了O(nm)的线性时间复杂度。今天我们就来彻底拆解这个被誉为字符串匹配领域里程碑的算法不仅看懂它怎么工作更要理解它为什么这样设计。2. KMP的核心理解“最长相等前后缀”与next数组KMP算法的精髓完全蕴含在那个预先计算的next数组里。要理解next数组必须先理解一个关键概念最长相等前后缀。对于一个字符串ababc前缀集合a,ab,aba,abab后缀集合c,bc,abc,babc注意前后缀都不包括字符串本身。所谓“最长相等前后缀”就是找出一个字符串中相等的、最长的那个前缀和后缀的长度。对于ababc长度为1前缀a后缀c不相等。长度为2前缀ab后缀bc不相等。长度为3前缀aba后缀abc不相等。长度为4前缀abab后缀babc不相等。 所以ababc的最长相等前后缀长度为0。再看abab长度为1avsb不等。长度为2abvsab相等长度为2。长度为3abavsbab不等。 因此abab的最长相等前后缀长度为2。next数组的定义对于模式串Pnext[j]表示子串P[0...j-1]即模式串中从开头到第j-1个字符构成的子串的“最长相等前后缀”的长度。这里有一个关键点也是初学者最容易混淆的地方next[j]的值对应的是已匹配前缀的下一个匹配位置或者说当在P[j]处匹配失败时j应该回退到的新位置。更具体一点假设我们在模式串的第j位从0开始计数匹配失败了。那么我们已经成功匹配了j个字符即子串P[0...j-1]。这个子串的最长相等前后缀长度假设为k。这意味着这个子串的前k个字符P[0...k-1]和后k个字符P[j-k...j-1]是完全一样的。既然主串中与P[j-k...j-1]对齐的部分已经匹配成功因为整个P[0...j-1]都匹配了那么它必然也和P[0...k-1]一样。所以我们可以直接把模式串向右滑动让P[0]对齐到主串中原来与P[j-k]对齐的位置并且可以确信P[0...k-1]这一段无需再比较一定是匹配的。接下来我们只需要从模式串的P[k]位置开始与主串当前指针i继续比较即可。因此next[j]的实际作用就是当模式串P在第j个字符匹配失败时j指针应该跳转到的下一个比较位置。通常我们令j next[j]。特别的我们规定next[0] -1这表示如果模式串的第一个字符就不匹配那么模式串整体右移一位主串指针i前进j从0开始通过j next[0] 1实现。注意next数组有不同的定义版本有的版本next[j]直接表示跳转后的索引此时next[0] -1有的版本表示长度值此时next[0] 0。本文采用前一种更常见的定义因为它与代码中的回退操作j next[j]直接对应逻辑更清晰。3. 手动推导next数组以“ababc”为例理论可能有些抽象我们通过一个完整的例子来亲手计算一遍。设模式串P ababc。j 0: 子串为空我们规定next[0] -1。j 1: 子串为a。一个字符的字符串没有真前缀和真后缀因为不能是自身所以最长相等前后缀长度为0。根据我们的定义next[1] 0。这意味着如果在P[1]即b匹配失败j应回退到0即从P[0]a开始比较。j 2: 子串为ab。长度为1的前缀a后缀b不相等。最长相等前后缀长度为0。所以next[2] 0。j 3: 子串为aba。长度为1前缀a后缀a相等。长度为2前缀ab后缀ba不相等。最长相等前后缀长度为1。所以next[3] 1。如果在P[3]即第二个a失败j回退到1即P[1]b。j 4: 子串为abab。长度为1avsb不等。长度为2abvsab相等。长度为3abavsbab不等。最长相等前后缀长度为2。所以next[4] 2。最终得到的next数组为[-1, 0, 0, 1, 2]。这个计算过程是理解的基础但在实际代码中我们不会每次都去截取子串再比较前后缀那样效率太低。KMP算法的高明之处在于next数组的求解本身也是一个“字符串匹配”过程模式串既是主串也是模式串我们可以用类似动态规划的思想在O(m)的时间内完成计算。4. next数组的高效构建算法构建next数组的算法是KMP学习的第二个难点。其核心思想是“递推”和“自我匹配”。我们定义两个指针i和j在构建next数组的语境下为了避免混淆有些资料会用i和k。这里我们统一用i和j但请注意此处的i指向的是当前要计算next值的位置即“主串”指针而j指向前缀的末尾即“模式串”指针同时j也代表了next[i]的候选值。算法步骤如下初始化next[0] -1。令i 0,j -1。循环当i m-1时m为模式串长度 a. 如果j -1或者P[i] P[j]则i,j然后设置next[i] j。 -j -1是边界条件意味着要重新开始匹配前缀。 -P[i] P[j]意味着在当前位置前缀和后缀可以扩展一位所以最长相等前后缀长度增加1。 b. 否则即P[i] ! P[j]令j next[j]。 - 这一步是精髓它利用已经计算好的next值进行回溯避免了j直接归零的暴力回溯和主匹配过程的思想完全一致。让我们用模式串ababc来走一遍这个过程m5。初始化next[0] -1,i0,j-1。第1轮i0j-1满足j -1。执行i(1),j(0)设置next[1] j 0。第2轮i1j0比较P[1](b)和P[0](a)不相等。执行j next[0] -1。第3轮i1j-1满足j -1。执行i(2),j(0)设置next[2] j 0。第4轮i2j0比较P[2](a)和P[0](a)相等执行i(3),j(1)设置next[3] j 1。第5轮i3j1比较P[3](b)和P[1](b)相等执行i(4),j(2)设置next[4] j 2。循环结束i4不小于m-14。得到next [-1, 0, 0, 1, 2]与手动推导一致。这个过程的时间复杂度是O(m)。实操心得理解这个构建过程的关键在于把P[0...j]看作已匹配的“前缀”P[i-j...i]看作正在考察的“后缀”。当P[i]和P[j]相等时前后缀可以同步延长不相等时就利用已知的next信息将前缀指针j回退到一个可能匹配的位置而不是傻傻地回到开头。这其实就是KMP主算法的一个“预演”。5. 主匹配流程详解与代码实现有了next数组主匹配过程就非常清晰了。我们设主串为S模式串为P长度分别为n和m。指针i遍历主串指针j遍历模式串。匹配过程初始化i 0,j 0。循环当i n且j m时 a. 如果j -1或者S[i] P[j]则i,j。 -j -1表示模式串已经滑到最左边需要将主串指针和模式串指针都向前推进一位。 - 字符相等自然双双后移。 b. 否则即S[i] ! P[j]且j ! -1令j next[j]。 - 这就是KMP的“跳跃”精髓。利用next数组j回退i不动。循环结束后判断如果j m说明模式串完全匹配返回匹配起始位置i - j。否则匹配失败返回-1。下面是用Python实现的完整KMP算法def kmp_search(main_string, pattern): KMP字符串匹配算法 :param main_string: 主串 :param pattern: 模式串 :return: 模式串在主串中首次出现的索引未找到返回-1 def build_next(p): 构建next数组 m len(p) next_arr [-1] * m # 初始化next数组 i, j 0, -1 # i是后缀末尾j是前缀末尾也代表next[i]的值 while i m - 1: # 注意循环条件因为next[i]是看P[0...i-1] if j -1 or p[i] p[j]: i 1 j 1 # 这里是一个可以优化的点见后续章节 next_arr[i] j else: j next_arr[j] # 前缀指针回溯 return next_arr n, m len(main_string), len(pattern) if m 0: return 0 # 空模式串约定俗成返回0 if n m: return -1 next_arr build_next(pattern) i, j 0, 0 # i主串指针j模式串指针 while i n and j m: if j -1 or main_string[i] pattern[j]: # 匹配成功或j已在最左端双指针右移 i 1 j 1 else: # 匹配失败根据next数组移动模式串指针 j next_arr[j] # 匹配成功判断 if j m: return i - j else: return -1 # 测试 if __name__ __main__: s BBC ABCDAB ABCDABCDABDE p ABCDABD pos kmp_search(s, p) print(f在主串 {s} 中查找模式串 {p}) print(f首次出现位置从0开始: {pos}) # 输出: 首次出现位置从0开始: 15 # 更多测试 print(kmp_search(hello, ll)) # 2 print(kmp_search(aaaaa, bba)) # -1 print(kmp_search(, )) # 0 print(kmp_search(a, )) # 0让我们用经典的例子SBBC ABCDAB ABCDABCDABDE,PABCDABD来模拟一下。首先计算P的next数组为[-1, 0, 0, 0, 0, 1, 2]。匹配过程关键点初始i0, j0S[0]B,P[0]A不匹配j next[0] -1。j -1执行i(1),j(0)。... 跳过若干步直到i4, j0S[4]A,P[0]A开始匹配。一路匹配到i10, j6此时已匹配ABCDABS[10] ,P[6]D不匹配。关键跳跃j next[6] 2。这意味着我们已经匹配的ABP[0-1]可以作为下一轮匹配的前缀i保持不变i10j从2即P[2]C开始比较。此时S[10] ,P[2]C不匹配j next[2] 0。S[10] ,P[0]A不匹配j next[0] -1。j -1执行i(11),j(0)。... 最终在i15, j0处重新开始并成功匹配到i22, j7匹配成功。整个过程主串指针i从未回退一直向前这正是KMP高效的原因。6. next数组的优化nextval数组细心的你可能已经发现上述标准KMP算法还有优化空间。看这个例子模式串PAAAAAB其next数组为[-1, 0, 1, 2, 3, 4]。假设在主串SAAAAAAC...中匹配。当i5, j5时已匹配AAAAAS[5]C,P[5]B不匹配。根据next数组j next[5] 4。然后比较S[5]C和P[4]A不匹配。j next[4] 3再比较S[5]C和P[3]A... 你会发现由于P[4]、P[3]、P[2]、P[1]、P[0]都是A且都与S[5]C不匹配所以j会按照4 - 3 - 2 - 1 - 0 - -1的路径一步步回退做了多次无意义的比较。问题的根源在于当P[j] ! S[i]时我们跳到了next[j]但如果P[next[j]] P[j]那么这次跳跃后的比较S[i]和P[next[j]]必然还是会失败因为P[j]已经和S[i]比较过且不相等而P[next[j]]和P[j]相同。既然如此我们何不一步到位直接跳到第一个与P[j]不同的字符位置呢这就是nextval数组的优化思想。它在构建next数组的过程中额外增加一次判断如果P[i] P[next[i]]那么nextval[i] nextval[next[i]]因为回退后比较的字符相同必然再次失败所以用更早的回退值。否则nextval[i] next[i]。优化后的构建函数如下def build_next_val(p): 构建优化后的nextval数组 m len(p) next_val [-1] * m i, j 0, -1 while i m - 1: if j -1 or p[i] p[j]: i 1 j 1 # 优化点如果回退后的字符与当前字符相同则继续回退 if p[i] p[j]: next_val[i] next_val[j] else: next_val[i] j else: j next_val[j] return next_val对于PAAAAAB标准next:[-1, 0, 1, 2, 3, 4]优化nextval:[-1, -1, -1, -1, -1, 4]在刚才的例子中当j5匹配失败时j nextval[5] 4。但此时我们发现P[5](B) ! P[4](A)所以nextval[4]没有因为相等而被优化仍是-1等等让我们仔细计算一下nextval[4]。根据规则在计算nextval[4]时i4, j3P[4](A) P[3](A)所以nextval[4] nextval[3]。递归地nextval[3] nextval[2],nextval[2] nextval[1],nextval[1] nextval[0] -1。所以最终nextval[4] -1。因此当j5失败跳转到4后紧接着j nextval[4] -1直接跳过了中间所有相同的A效率大幅提升。在实际应用中特别是模式串中有大量重复字符时使用nextval数组可以进一步提升匹配效率。它并没有改变算法的时间复杂度上界依然是O(nm)但减少了常数因子是KMP算法一个非常经典的优化。7. 复杂度分析与KMP的适用场景时间复杂度构建next或nextval数组O(m)其中m是模式串长度。这个过程只遍历模式串常数次。主匹配过程O(n)其中n是主串长度。虽然主串指针i可能在某些轮次不增加当发生失配且j ! -1时但i从不减少而j的变化增加或通过next数组减少是与i的增加相关联的。j增加的总次数不会超过n因为每次i增加j才可能增加而j通过next数组减少的总次数也不会超过j增加的总次数因为j必须非负。因此整个主匹配过程是线性的。总时间复杂度O(n m)。空间复杂度主要是next数组O(m)。KMP算法的适用场景与局限优势场景主串和模式串都非常长且匹配失败经常发生在模式串的靠后位置。此时KMP避免主串回溯的优势非常明显。需要多次用同一个模式串匹配不同的主串。next数组只需计算一次即可重复使用摊销了预处理成本。流式数据匹配。因为主串指针i不回溯所以可以处理无法随机访问或只能单向读取的数据流如网络数据包、实时日志流这是暴力算法无法做到的。局限与权衡实现复杂度比暴力算法复杂得多理解和实现都有一定门槛。常数因子虽然时间复杂度优但涉及额外的数组访问和判断在模式串很短、主串不长或者匹配往往在模式串开头就失败的情况下其实际运行速度可能不如高度优化的暴力算法例如使用系统库的memcmp或strstr。内存开销需要额外的O(m)空间存储next数组。对于极长的模式串例如上百万字符这可能是个问题。并非所有情况都最快在实际的字符串搜索库如Python的find、C的strstr中通常采用更复杂的混合算法如Boyer-Moore、Sunday算法等这些算法在平均情况下往往比KMP更快因为它们利用了“坏字符”规则进行更大幅度的跳跃。KMP的优势在于最坏情况下的线性保证。因此在一般的应用开发中我们通常直接使用语言内置的字符串查找函数。但在一些特殊的底层系统、文本编辑器、生物信息学DNA序列匹配或算法竞赛中理解并能够实现KMP算法仍然是一项重要的技能。8. 从KMP到更广阔的字符串匹配世界KMP算法是理解现代字符串匹配算法的一把钥匙。它引入了“利用已匹配信息避免回溯”的核心思想启发了后续许多算法。Boyer-Moore (BM) 算法它采用了两种启发式规则——“坏字符规则”和“好后缀规则”从模式串的末尾开始向前匹配。当发生不匹配时它可以根据这两个规则计算出更大的滑动距离在实践中平均性能往往优于KMP特别是在字符集较大如英文文本时。不过它的最坏情况时间复杂度可能是O(n*m)。Sunday 算法可以看作是BM算法“坏字符规则”的一个简化且高效的变种。它关注的是主串中参与匹配的窗口的下一个字符即“周日字符”根据这个字符在模式串中的位置进行跳跃。实现简单且在实际应用中通常有很好的效果。Aho-Corasick (AC) 自动机可以看作是KMP算法在多模式匹配场景下的扩展。它能够同时搜索多个模式串其核心数据结构Trie树加上失败指针的思想与KMP的next数组一脉相承。失败指针的作用就是在某个节点匹配失败时跳转到另一个可能的最长匹配前缀节点这正是KMP思想在多模式下的体现。Rabin-Karp 算法采用了完全不同的思路——哈希。它计算模式串的哈希值以及主串中每个等长子串的哈希值通过比较哈希值来快速筛选可能匹配的位置。虽然需要处理哈希冲突但其思路简单且在某些场景下如二维模式匹配有优势。理解KMP不仅仅是学会了一个算法更是掌握了一种“预处理模式串以加速匹配”的范式。当你下次遇到需要高效处理字符串匹配的问题时无论是单模式还是多模式是精确匹配还是近似匹配你都能从KMP所代表的这种“空间换时间”、“利用历史信息”的思想中找到灵感。
返回列表