字符串匹配(KMP)

· · 算法·理论

字符串匹配

字符串匹配算法有多种,目的是查找模式串在主串中第一次出现的位置。最常见的有 KMP 和 BM 算法。
规定待匹配串叫模式串,被匹配串叫主串。

为简化描述,我们规定主串长度为 n,用 \text{s[ ]} 表示。模式串长度为 m,用 \text{p[ ]} 表示。

一、KMP 算法:

1. 主要思想:

对比暴力算法,在匹配的时候能否优化呢?

假设从 c 点开始匹配,在 \text{p}[i] 匹配的时候失配了,思考模式串应向右移动到哪里呢?

由于是到 \text{p}[i] 时失配的,那么主串中的 \text{s}[c...c+i-1] = \text{p}[0...i-1]

显然,盲目向右移动一位是无脑的。

既然我们已经知道了主串中 \text{s}[c...c+i-1] 段的字符,那么在这一段字符中,我们可以找一段字符串,使该字符串和模式串尽可能匹配,移动模式串和那个字串对齐,接着匹配。这样可以减少很多无用的比较。

如下图,是一个移动操作的示意:

显然,我们可以将该操作转换为一个问题:

找出一个字符串(长为 l)中的最长前缀,使其与后缀相等,该长度记为 hh \ne l)。

比如串 ababa,他的 h3

为什么要求最长前缀?因为可能会漏情况(不解释了,太简单)。

2. 算法分析 ①(求 \text{pre}[\ ] 数组):

我们需要预处理出解决如上问题的数组 \text{pre[ ]}

**根据定义:令 $k = \text{pre}[j]$ ,则 $\text{p}[0...k-1] = \text{p}[j-k...j-1]$。** ------------ 若 $\text{p}[0...i-1]$ 没有相等的前缀和后缀,记 $\text{pre}[i] = 0$ 。 特别地,令 $\text{pre}[0] = -1$ (后面讲为什么)。 ------------ **如何求 $\text{pre}[\ ]$ 数组呢?** 我们可以使用递推的思想。 考虑已经知道了 $\text{pre}[0...j]$,如何求出 $\text{pre}[j+1]$ 的值? ![](https://cdn.luogu.com.cn/upload/image_hosting/egbx06d7.png) ![](https://cdn.luogu.com.cn/upload/image_hosting/euhsewdz.png) ### (1)令 $k = \text{pre}[j]$。 根据定义,可知 $\text{pre}[0...k-1] = \text{pre}[j-k...j-1]$。 ### (2)那么假如 $\text{pre}[k] = \text{pre}[j]$,显然可知 $\text{pre}[j+1] = \text{pre}[j]+1$。 ------------ ### (3)现在考虑 $\text{p}[k] \ne \text{p}[j]$ 的情况。 由于 $\text{pre}[0...k-1] = \text{pre}[j-k...j-1]$,我们可以直接舍去一段,转换成子问题求解。 对于串 $\text{p}[0....j-1]$,能否找到除 $\text{pre}[j]$ 外的,**第二小的满足前缀后缀相等的子串**,这样不会影响串 $\text{p}[0....j-1]$ 的性质。 ![](https://cdn.luogu.com.cn/upload/image_hosting/79c35d2z.png) **由于我们寻找次小答案的时候,可以将次小后缀转移到最大前缀的后缀(如图中箭头)。** **也就是在 $\text{p}[0....k-1]$ 中寻找最优前后缀相等子串,也就是 $\text{pre}[k]$。** ### (4)于是我们更新 $k = \text{pre}[k]$ ,再次判断 $\text{p}[k] = \text{p}[j]$,不成立则继续寻找次小答案。 当始终 $\text{p}[k] \ne \text{p}[j]$,最后一定 $k = 0$,此时一定有 $\text{p}[0] \ne \text{p}[j]$,倘若 $\text{pre}[0] = 0$ 则会一直陷入循环,所以我们可以将 $\text{pre}[0]$ 设为 $-1$,再加一个特判,防止这个问题。 同时,求 $\text{pre}[1]$ 时,由于 $k = \text{pre}[0] = -1$,跳出循环,直接赋值 $\text{pre}[1] = 0$。 ```cpp void init() { big j = 0; big m = p.length(); // m是模式串p的长度 pre[0] = -1; // 初始条件 while(j < m-1) // 已知 pre[0...j],求 pre[j+1]。 { big k = pre[j]; while(k != -1) { if(p[k] == p[j]) { pre[++j] = k+1; // 匹配到了,更新数组 break; } else if(p[k] != p[j]) { k = pre[k]; // 否则继续重复,寻找更次小前后缀 } } if(k == -1) { pre[++j] = 0; // 匹配不成功,pre[j+1] = 0 } } } ``` **我们对该代码进行优化,让其变得更加简洁:** **合并 $k = -1$ 和 $\text{p}[k] = \text{p}[j]$ 两种情况。** 1. 当 $k = -1$ 时,应该让 `pre[++j] = 0`,这里 `k++` 刚好使得 $k = 0$ 。 2. 当 $\text{p}[k] = \text{p}[j]$ 时,需要 `pre[j++] = k+1` ,改成 `pre[j++] = k++`,直接更新 $k$,省去了 开始时 $k = \text{pre}[j]$ 的过程。 ```cpp void init() { big j = 0, k = -1, m = p.length(); pre[0] = -1; while(j < m-1) { if(k == -1 || p[j] == p[k]) { pre[++j] = ++k; // 更新了 k = pre[j] } else if(p[j] != p[k]) { k = pre[k]; } } } ``` ### 3. 算法分析②(KMP 主体): 如图,我们令一开始匹配的长度为 $j$ ,浅蓝色的部分长度为 $k = \text{pre}[j]$ ,$\text{p}[j]$ 与 $\text{s}[i]$ 失配。 ![](https://cdn.luogu.com.cn/upload/image_hosting/lwx8djsb.png) 此时我们需要移动模式串至 $j-k$ 的位置,也就是移动 $j-k$ 格。 ![](https://cdn.luogu.com.cn/upload/image_hosting/ydhnxcpn.png) 此时,$j' = k = \text{pre}[j]$。所以我们可以直接更新 $j = \text{pre}[j]$。 从 $\text{p}[j']$ 与 $\text{l}[i]$ 匹配开始,一直往下匹配,重复这个过程。 假如此时 $\text{pre}[j] = 0$,会判断 $\text{p}[0]$ 与 $\text{l}[i]$ 匹配,若仍不匹配,此时 $j = \text{pre}[0] = -1$,需要手动移动到下一位(也就是让 $\text{p}[0]$ 与 $\text{l}[i+1]$ 匹配,更新 $i = i+1$,$j=0$)。 ```cpp while(i < n && j < m) { if(j == -1) // 手动对齐 { i++; j = 0; } if(s[i] == p[j]) // s[i]与 p[j] 匹配成功,继续往下 { i++; j++; } else if(s[i] != p[j]) { j = pre[j]; // 移动 p,继续递归。 } } if(j == m) // 匹配成功 { return i-j; } ``` **同样方法考虑优化:** 合并 $j = -1$ 和 $\text{s}[i] = \text{p}[j]$ 两种情况。其中 $j = -1$ 时 `++j`。 ```cpp big KMP() { big n = s.length(), m = p.length(); big i = 0,j = 0; while(i < n && j < m) { if(j == -1 || s[i] == p[j]) { i++, j++; } else if(s[i] != p[j]) { j = pre[j]; } } if(j == m) { return i-j; } return -1; } ```