引言:我们为什么需要高效的字符串匹配算法
什么是字符串匹配?通俗来说,就是在一大段文字(文本字符串)中寻找一个词(模式字符串)。专业地说,字符串匹配是在文本字符串中查找与模式字符串匹配的子串的位置。
高效的字符串匹配算法至关重要,因为日常的文本处理中涉及大量的文本查找操作,算法的效率直接影响处理速度。
朴素算法:从最简单的思路开始
最自然的思路是什么?
显然是从文本串的开头开始向后移动,逐个对比当前字符和模式串的字符是否相同——这就是 “朴素算法”。
尽管简单易懂,朴素算法的效率却不高。其主要问题在于:匹配失败后只向后移动一个字符再尝试匹配。如果匹配位置靠后,这种做法无疑浪费了大量时间。
计算机科学家们发现,在每一次匹配时,模式串的长度和已匹配字符的信息可以被利用起来。如果能利用这些信息,直接跳过明显不可能匹配成功的地方,从可能成功的位置开始尝试,显然能大大提高效率。
改进: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算法会这样做:
首先处理出模式串GOOGLEGOOD的LPS表。
奇怪,为什么是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,当前的前缀长度为i,P[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需要回退到上上次成功的地方,因为这次的失败导致上次成功的长度不能延续了,比如刚刚的GOOG到GOOGL,只有再次遇到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 算法将匹配过程分为了两个独立阶段,然后将它们的时间复杂度相加:
-
构建 LPS 表 (预处理阶段): O(M)
- 分析:构建 LPS 表时,模式串指针
i从 0 遍历到 M−1(即i只增加 M 次)。虽然内部有循环,但模式串中len指针的回退总次数,永远不会超过它前进的总次数(最多 M 次)。 - 结论:LPS 表的构建时间复杂度是线性的,即O(M)。
- 分析:构建 LPS 表时,模式串指针
-
KMP 匹配过程: O(N)
- 分析:在 KMP 匹配过程中,文本串指针
i永不回退,最多只移动 N 次。模式串指针j的总移动(前进和回退)次数也被限制在 2N 次以内。 - 核心优势:KMP 算法确保了
i指针永不回退,这是效率提升的关键。 - 结论:KMP 匹配过程的时间复杂度也是线性的,即O(N)。
- 分析:在 KMP 匹配过程中,文本串指针
因此,KMP算法是时间复杂度是O(N+M),即使在最坏情况下也能保证线性时间复杂度。
519

被折叠的 条评论
为什么被折叠?



