字符串匹配:从朴素算法到KMP

引言:我们为什么需要高效的字符串匹配算法

什么是字符串匹配?通俗来说,就是在一大段文字(文本字符串)中寻找一个词(模式字符串)。专业地说,字符串匹配是在文本字符串中查找与模式字符串匹配的子串的位置。

高效的字符串匹配算法至关重要,因为日常的文本处理中涉及大量的文本查找操作,算法的效率直接影响处理速度。

朴素算法:从最简单的思路开始

最自然的思路是什么?

显然是从文本串的开头开始向后移动,逐个对比当前字符和模式串的字符是否相同——这就是 “朴素算法”

尽管简单易懂,朴素算法的效率却不高。其主要问题在于:匹配失败后只向后移动一个字符再尝试匹配。如果匹配位置靠后,这种做法无疑浪费了大量时间。

计算机科学家们发现,在每一次匹配时,模式串的长度已匹配字符的信息可以被利用起来。如果能利用这些信息,直接跳过明显不可能匹配成功的地方,从可能成功的位置开始尝试,显然能大大提高效率。

改进:KMP算法

KMP算法主要通过利用部分匹配信息和字符串自身的自重复性,实现了匹配失败时更高效的跳转。

因为有时候匹配不是从开头就失败,而是在一个或多个字符匹配成功后才失败。

匹配在中间失败时,我们可以利用已经成功匹配的前缀信息。KMP算法的核心思想是找出模式串自身的最长前缀后缀Longest Proper Prefix which is also Suffix, LPS),并利用它加速跳转。

最长前缀后缀(LPS)的定义

一个字符串的LPS是:既是这个字符串的前缀也是这个字符串的后缀的最长子串。

例如:字符串 GOOGLEGOOG

  • G 既是前缀也是后缀,长度为 1。
  • GOOG 既是前缀也是后缀,长度为 4。
  • 因此,GOOG 是该字符串的LPS。

LPS如何加速匹配?

比如说文本串是GOOGLEGOOGLEGOODGOOGLE,模式串是GOOGLEGOOD

第一次查找

文本串:GOOGLEGOOGLEGOODGOOGLE
模式串:GOOGLEGOOD

显然失败了,而且很可惜,模式只有最后一个字符没匹配成功。

如果是朴素算法,会这样继续对比:

文本串:GOOGLEGOOGLEGOODGOOGLE
模式串: GOOGLEGOOD

显然又失败了,这次甚至不一样的字符更多了。

但是即使是肉眼也能看到,第一次匹配失败后,明明只需要向后移动到第二个GOOGLE那里就成功了,但是朴素算法还偏要从第二个字符开始重新比。

所以KMP算法会这样做:

首先处理出模式串GOOGLEGOODLPS表

奇怪,为什么是LPS表?

因为我们需要利用到的是“部分匹配”成功的信息。

如第一次查找过程,模式串除了最后一个字符D,前面部分都匹配成功,而恰好我们发现前面的部分是GOOGLEGOO,它不仅有最长前缀后缀GOO,而且这个LPS还不短。

这个例子只是体现了一个极端的情况,如果是别的情景,也可能是前两个、前三个字符匹配成功,也可能一个字符都没匹配成功,所以我们要预先处理出模式串的每个前缀的LPS是多长。

第二次查找

发现前一次匹配部分的LPS长度是3,根据LPS的性质,发现模式串开头的3个字符GOO不仅和自身匹配成功部分的尾部GOO相同,也和文本串匹配成功部分的尾部GOO相同,所以这次不是把模式串开始匹配的位置向后移动一位,而是移动到上次匹配的结尾GOO对齐

文本串:GOOGLEGOOGLEGOODGOOGLE
模式串:      GOOGLEGOOD

这个时候再对比,发现已经成功了,我们省去了好几次移动和比较

细节:如何构建LPS表?

构建LPS表,就是对模式串的所有前缀,都找出它的LPS并记录长度。

同样,从最简单的朴素算法开始。

显然,需要一个循环依次处理每个前缀,而对于每个前缀字符串,找LPS的过程其实是一样的。

最简单的思路就是从每个字符串的两端开始,依次比较当前长度的前后缀,并从1开始不断增加长度,直到前后缀不同为止。

对于每个前缀字符串,都重复如上操作。

显然,这种方法效率不高,如果模式串较长,甚至会导致KMP光是在预处理LPS上就浪费了太多时间。

那么怎么改进呢?还是利用字符串的自重复性

每次增加比较的前后缀长度时,朴素算法将新的前后缀又从头到尾对比了一遍,其实完全没必要,因为长度增加1的时候,前缀只增加末尾的字符,后缀只增加开头的字符。

但是只是知道这个信息还是无法快速比较,因为后缀的第一个字符改变,意味着原来前后缀相同的部分又错开了。

这时候我们突然意识到,不应该把每个前缀字符串的找LPS过程独立来看,因为每个长度较短的前缀字符串也是长度较长的前缀字符串的前缀!也就是说,在求长度较长的前缀字符串的LPS时,我们可以利用比它短一点的那个前缀字符串的LPS!

举个例子,还是用GOOGLEGOOD

前缀长度为1时,G的LPS长度是0;

前缀长度为2时,GO的LPS长度显然也是0;

前缀长度为3时,GOO的LPS长度还是0;

前缀长度为4时,GOOG的LPS长度是1;

前缀长度为5时,GOOGL的LPS长度又变回了0,因为我们发现如果要在GOOG的基础上继续增加LPS长度,那么末尾的G后面必须要跟上O,字符串变成GO O GO才行——似乎发现了什么?再多列几个;

前缀长度为6时,GOOGLE的LPS长度显然还是0,同样,只有新增加的字符是G才能能满足;

前缀长度为7时,GOOGLEG的LPS长度变成了1,原因是新增加的字符正好就是G——G刚好是上一个LPS,这难道有什么关系?

前缀长度为8时,GOOGLEGO的LPS长度继续增加,变为2,因为新增加的字符正好是O,和上一次匹配的LPSG的后一个字符O一样;

前缀长度为9时,GOOGLEGOO的LPS长度继续增加,变为3,同样因为新增加的字符正好是O,和上一次匹配的LPSGO的后一个字符O一样;

到这里,就可以发现一些东西,那就是字符串的自重复性导致其前缀的前缀同时也是后缀的后缀

符号来表示就是:

模式串P当前的前缀长度iP[0..i]表示当前的前缀LPS[i]表示P[0..i]LPS长度

那么,如果上一次成功匹配的LPS长度为len,可以得出:

P[0..len-1] = P[i-len-1..i-1]

那么如果要让这一次也能成功匹配,即:

P[0..len] = P[i-len-1..i]

只需要让:

P[0..len-1] + P[len] = P[i-len-1..i-1] + P[i]

也就是新加入的字符P[i]和上一次匹配成功的LPS的下一个字符P[len]相同,如果相同,那么就让len增加1并计入LPS[i]

如果不相同,匹配失败了怎么办?

很简单,当前的len需要回退到上上次成功的地方,因为这次的失败导致上次成功的长度不能延续了,比如刚刚的GOOGGOOGL,只有再次遇到G的时候才能继续增加LPS长度;

再举一个例子说明多次匹配失败导致回退的情况:

假设模式串为PATTERNPATPAT。这个模式串的特点在于,它内部存在明显的自重复结构:前面是PATTERN,后面又出现了多次PAT

假如我们现在读到了这一位字符:

PATTERNPATPAT
  ↑↑      ↑
  ||      └ 当前失败位置
  |└ 上一次成功位置
  └ 上上次成功位置

发现这里的P和上一次的LPSPAT的后一位T不一样,匹配失败了,回退一位

PATTERNPATPAT
 ↑↑       ↑
 ||       └ 当前失败位置
 |└ 上一次成功位置
 └ 上上次成功位置

还是失败,再回退;

PATTERNPATPAT
↑↑        ↑
||        └ 当前失败位置
|└ 上一次成功位置
└ 上上次成功位置

又失败了,再退;

PATTERNPATPAT
↑         ↑
|         └ 当前失败位置
└ 上一次成功位置

这次成功了,顺利得到这次的LPSPAT

伪代码来描述这个过程:

Algotithum: BuildLPS(P)
  Input: 模式串P
  Output: 最长前缀后缀表LPS
  m := length(P)                       # 模式串长度
  LPS[0] := 0                          # 初始化结果数组,规定第一个字符的LPS长度是0
  len := 0                             # 上一次匹配成功的LPS长度
  for i from 0 to m - 1 do
    while len > 0 and P[i] != P[len] do # 匹配失败,循环直到成功或LPS长度为0
      len = LPS[len - 1]               # 回退到上一次成功的位置
    end
    if P[i] == P[len] then             # 判断匹配成功,因为前一个循环可能因为len为0退出
      len := len + 1                   # 更新上一次匹配成功的LPS长度
    end
    LPS[i] = len                       # 更新P[0..i]的LPS长度
  end
  return LPS

细节:如何根据LPS表匹配?

KMP算法的匹配过程利用了LPS表进行高效的指针跳转

伪代码如下:

Algorithum: KMP(T, P, LPS)
  Input:文本串T, 模式串P, 模式串的LPS表
  Output:匹配位置数组result
  n := length(T)              # 文本串长度
  m := length(P)              # 模式串长度
  i := 0                      # 文本串指针
  j := 0                      # 模式串指针
  result := []                # 结果数组
  while i < n do
    if T[i] == P[i] then      # 匹配成功
      i := i + 1              # 文本串指针后移
      j := j + 1              # 模式串指针后移
      if j == m then          # 到达模式串末尾
        result append (i - m) # 计入结果数组
        j := LPS[j - 1]       # 模式串指针回退到上一个匹配成功的位置
      end
    else                      # 匹配失败
      if j > 0 then           # 模式串指针还没回到开头
        j := LPS[j - 1]       # 模式串指针回退到上一个匹配成功的位置
      else                    # 模式串指针已在开头,但是仍不匹配
        i := i + 1            # 文本串指针后移,跳过当前位置
      end
    end
  end
  return result

时间复杂度分析:KMP快在哪?

时间复杂度是衡量算法效率的核心指标,我们用大O符号来表示,它描述了随着输入规模增大,算法执行时间增长的趋势。

假设:

  • N:文本串 T 的长度(比如:一本书的字数)。
  • M:模式串 P 的长度(比如:你要查找的词的字数)。

1. 朴素算法的时间复杂度:O(N×M)

在最坏的情况下,朴素算法的效率非常低。

最坏情况:当文本串和模式串大部分字符都相同,但在最后一个字符不匹配时。例如:

文本串:AAAA...A B (有 N 个 A)
模式串:AAAA...A C (有 M 个 A)

在每一次对齐中,都需要进行 M 次比较。由于模式串可能移动 N 次,总的比较次数大约是 N×M。

因此,朴素算法的最坏时间复杂度是O(N×M)。如果文本串很长,模式串也较长,这个乘法关系会让计算时间急剧膨胀。

2. KMP 算法的时间复杂度:O(N+M)

KMP 算法将匹配过程分为了两个独立阶段,然后将它们的时间复杂度相加:

  1. 构建 LPS 表 (预处理阶段): O(M)

    • 分析:构建 LPS 表时,模式串指针 i 从 0 遍历到 M−1(即 i 只增加 M 次)。虽然内部有循环,但模式串中 len 指针的回退总次数,永远不会超过它前进的总次数(最多 M 次)。
    • 结论:LPS 表的构建时间复杂度是线性的,即O(M)
  2. KMP 匹配过程: O(N)

    • 分析:在 KMP 匹配过程中,文本串指针 i 永不回退,最多只移动 N 次。模式串指针 j 的总移动(前进和回退)次数也被限制在 2N 次以内。
    • 核心优势:KMP 算法确保了 i 指针永不回退,这是效率提升的关键。
    • 结论:KMP 匹配过程的时间复杂度也是线性的,即O(N)

因此,KMP算法是时间复杂度是O(N+M),即使在最坏情况下也能保证线性时间复杂度。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值