字符串匹配问题描述
现有主串 s 和模式串 t,要求通过算法寻找 t 串在 s 串中第一次出现的位置keyIndex,若 s 中不包含 t 则返回 -1。 一般地, s 的长度 n 大于 t 的长度 m。
- 例如:
s:abcabc t:cab => keyIndex = 2s:abcabc t:abc => keyIndex = 0s:abcabc t:aaa => keyIndex = -1
本篇涉及的的代码在这里,你可以参看代码帮助理解。
BF 算法
这个问题不难想到最简单的方法就是一位一位依次比较,如果失配(就是不匹配的意思),那么往后继续匹配下一位就是,这就是 BF 算法,即暴力(Brute Force)算法。
例如 s = abcacabdc; t = abd; 中,整个过程就如下图所示:
整个过程比较容易理解,代码实现如下:
/*
1. BF 算法--暴力匹配算法
思路:
1.分别利用 i 和 j 遍历主串 s 和模式串 t,i
初值pos(指定的其实位置,一般都是 1,从头开始找);j的初值为 1
2.如果 2 个串均未到尾,则循环执行以下操作:
* s[i] 与 t[j] 比较,若相等,i、j 均跳步,重复操作。
* 若不想等,指针退后重新开始比较,
从主串的下一个字符开始:(i = i - j + 2),模式串从头开始 (j = 1)。
3.跳出循环:j > t.length 说明模式串已经匹配结束,匹配成功。
(res = i - t.length);
否则匹配失败,返回-1。
*/
int stringMatch_BF(String s, String t, int pos){
// 开始匹配的位置
int i = pos;
int j = 1;
// s 和 t 都没有匹配到结束
while (i <= s[0] && j <= t[0]) {
if (s[i] == t[j]) {
i++;
j++;
} else {
// 不相等,那么 i、j都需要重新定位
// i 后退到上次开始匹配的后一位
// i - j + 1为上次开始的位置,
// 再加 1 即为下一位
i = i - j + 2;
// j 回退到模式串的首位
j = 1;
}
}
if (j > t[0]) {
// 匹配成功了
return i - t[0];
}else{
return -1;
}
}
这是一个比较简单,易于理解的算法,这个算法已经可以应对平时的大部分应用场景了。但是在数据量比较大的情况下,算法效率效率低的问题就会暴露出来,多次的回退操作使得本算法并不是十分优秀。对此有很多新的算法对这个问题做了更优的解决。
RK 算法
我们先来说一个概念。
字符串的哈希值
我们首先假定主串s和模式串t仅含有26个小写字母。这样假设是为了好理解,其他情况换掉这里的 26 为 N 即可。
由于只有 26 个不同的字符,所以每个字符串等价为使用了 26 进制来表示的数字。
如果我们使得 1 <=> a, 2 <=> b,....,那么我们可以计算出这个 26 进制的数在十进制下的值。
例如 adc 就相当于是 26 进制下的数字,转为 10 进制下的值为:
abc = 1 * 26^2 + 2 * 26^1 + 3 * 26^0 = 731
我们可以把这个值记为这个字符串的标志值。其实这就是哈希的思想,这样的转化就是一个哈希算法,这个值称为这个字符串的哈希值。
注意了,
哈希算法有很多种,这里是巧借了进制的思想,如果你有更好的算法能够完成hash(字符串) = 常数值这个过程,那么这样的函数hash()也是一个哈希算法。
RK 算法的思想阐述
RK 的算法正是基于此:
假设我们有某个
hash函数可以将字符串转换为一个整数,则 hash 结果不同的字符串肯定不同,但hash结果相同的字符串则很有可能相同(存在小概率不同的可能)。
哈希值相同,但是源串却不同,这样的现象较哈希冲突。即为:
hash(a) = hash(b)但是a != b。当然一个好的哈希算法应该尽量减少哈希冲突。
由此易得:如果哈希值不同,那么字符串一定不同;出现哈希冲突时我们可以增加一次校验`即可。
将两个字符串的比较转为两个数字的比较,这是 RK 算法的亮点。
在这样前提下,设 主串长度为 n; 模式串长度为 m,
那么主串可以拆解为(n - m + 1) 个与模式串长度相等的子串。如图所示:
例如:主串s为:126783 模式串t为 678
n = 6; m = 3;
主串拆解的子串有:
126 = 1 * 10^2 + 2 * 10^1 + 6 * 10^0
267 = 2 * 10^2 + 6 * 10^1 + 7 * 10^0
678 = 6 * 10^2 + 7 * 10^1 + 8 * 10^0
783 = 7 * 10^2 + 8 * 10^1 + 3 * 10^0
为了讲解方便,我们先来定义两个概念:
首先看 10 进制下的126表示一百二十六。
它的计算方式为1 * 10^2 + 2 * 10^1 + 6 * 10^0 = 126。
其中百位上的数字都需要乘以10^2,这个值与进制和所在位数有关,我们把这个值叫单位进制权重。
百位上是1所以计算值为1 * 10^2,把这个值叫该位的进制权重,它与单位进制权重和该位的数值有关。
这是小黑自创的两个概念,希望能够帮助你们理解嗷。
在计算中我们不难发现,我们在计算了126的值之后需要再计算右移一位的子串267值,其中26的计算其实十分类似,只是由于其所在的位不同所以进制权重有所变化而已。
即:
v_i+1 = N * (v_i - (s_i 最高位的进制权重)) + (s_i+1 最末位的进制权重)
例如:上面的由 126 计算 267,已知hash(126) = 126;
那么hash(267) = 10 * (126 - 1 * 10^2) + 7 * 10^0 = 267;
来看一个字母的例子,我们细细讲一遍:
例如
s = abcde,n = 5;t = cde,m = 3
那么 s 可以拆解为 (n - m + 1) = 3个和 t 等长的子串,分别是:
abc、bcd、cde。
这三个子串要分别于 t = cde 进行匹配,首先我们计算 hash(t):
hash(t) = 3 * 26^2 + 4 * 26^1 + 5 * 26^0 = 2028 + 104 + 5 = 2137
这就是 t = cde 在 26 进制下的值,也就是 t 的哈希值,然后我们需要计算出 s 拆解的三个子串的哈希值,找到哈希值与 t 相同的,那么他们就极有可能匹配成功。
开始计算:
上面我们算过了 hash(abc) = 731,
hash(abc) = 1 * 26^2 + 2 * 26^1 + 3 * 26^0 = 731
我把字母放进去和 hash(bcd) 对比一下:
hash(abc) = a * 26^2 + b * 26^1 + c * 26^0 = 731
hash(bcd) = ---------- b * 26^2 + c * 26^1 + d * 26^0 = ?
我故意留出了一些空,上下对比一下,我们发现关于 bc 的计算上下两式的区别就是单位进制权重不同而已,我们稍作变换:
hash(bcd) = 26 * ( b * 26^1 + c * 26^0 ) + d * 26^0
注意观察括号内的这一部分( b * 26^1 + c * 26^0 ),它刚好出现在 hash(adc) 的计算过程中,我们可以快速算出来:
( b * 26^1 + c * 26^0 ) = hash(abc) - a * 26^2
再带入 hash(bcd) 的计算:
hash(bcd) = 26 * ( hash(abc) - a * 26^2 ) + d * 26^0
这样们就通过 abc 的哈希值快速计算出了后移一位的子串 bcd 的哈希值,同理,cde 的哈希值也能算出来:
hash(cde) = 26 * ( hash(bcd) - b * 26^2 ) + e * 26^0
即我们找到了如何根据 (下列表达式中 “_” 后的表达式为下标,x 为进制)
hash(Si-m+1...Si)
来计算
hash(Si-m+2...Si+1)
如果 s_i 的哈希值是 v_i,那么 v_i 减去 s_i 最高位的进制权重,然后剩下的值就是 除去最高位字符 的 进制权重和,在新的子串(新子串是后移一位的,原来的个位变十位,十位变百位,个位是新进来的)里,他们要集体左移一位(权重变高了,类似十位移动到百位了),那么这个值就需要乘以进制,再加上新补的最后一位的权重就好了。如图所示:
抽象成公式就是(看不懂的宝宝可以不看,理解就行了嗷😊):
hash(S_(i-m+1)...S_i)
=
S_(i-m+1)*x^(m-1) + S_(i-m+2)*x^(m-2) + ... + S_(i-1)*x + Si
=>
hash(S_(i-m+2)...S_(i+1))
=
S_(i-m+2_*x^(m-1) + S_(i-m+3)*x^(m-2) + ... + S_i*x + S_(i+1)
=
(hash(S_(i-m+1)...S_i) - S_(i-m+1)*x^(m-1)) * x + S_(i+1)
)
只需要计算出第一个子串的哈希值,就能在 o(1) 的时间内计算出下一个,使得算法的效率大大提升,这是 RK 算法的第二个亮点。
RK 算法实现
由此我们对 RK 算法进行编码实现:
// 这是包含字符集合的长度
#define N 26
typedef int Status;
#define SUCCESS 1
#define ERROR 0
/// 哈希值一样的时候可能原串并不相等,这就是哈希冲突。为了解决这个问题,我们需要再次校验一下。
/// @param s 主串
/// @param keyIndex 主串中匹配到的模式串的起始位置
/// @param t 模式串
/// @param m 模式串的长度
Status isCheckMatch(char *s, int keyIndex, char *t, int m){
int index_s, index_t;
for (index_s = keyIndex, index_t = 0; index_s != keyIndex + m && index_t != m; index_s++, index_t++) {
if (s[index_s] != t[index_t]) {
return ERROR;
}
}
return SUCCESS;
}
/// 获取 N 进制下 单位最高位进制权重
/// 例如: 321 中 10 进制下 单位最高位进制权重 就是 3 所在的位置的权重为 10^(3-1) = 100;
/// @param m 模式串的长度
int getHightValue(int m){
int value = 1;
for (int i = 0; i < m - 1; i++) {
value *= N;
}
return value;
}
/// 字符串匹配 RK 算法
/// @param s 主串
/// @param t 模式串
/// @param keyIndex 找到的结果
Status stringMatch_RK(char *s, char *t, int *keyIndex){
// 获取两个串的长度
int n = (int)strlen(s);
int m = (int)strlen(t);
printf("主串长度为:%d,模式串长度为:%d\n", n, m);
// 模式串的哈希值
long long t_hashValue = 0;
// 子串的哈希值 注意:子串是由主串拆解为的 与模式串等长 的字符串
long long s_sub_hashValue = 0;
// 求得模式串和第一个子串的哈希值 第一个子串就是主串的前 m 位构成的字符串,后面的每个子串就是这个长度一直往后移位就是。
for (int i = 0; i < m; i++) {
t_hashValue = (N * t_hashValue + (t[i] - 'a'));
s_sub_hashValue = (N * s_sub_hashValue + (s[i] - 'a'));
}
// 这是最高位的单位进制权重
int hightValue = getHightValue(m);
for (int i = 0; i <= n - m; i++) {
if (t_hashValue == s_sub_hashValue) {
// 如果相等了需要考虑到哈希冲突
if (isCheckMatch(s, i, t, m)) {
// 加 1 是位数,不加 1 就是下标值,任君抉择
// *keyIndex = i + 1;
*keyIndex = i;
return SUCCESS;
}
}
// 如果不相等那么就将子串往后移动一位
s_sub_hashValue = (s_sub_hashValue - (s[i] - 'a') * hightValue) * N + (s[i + m] - 'a');
}
*keyIndex = -1;
return ERROR;
}
KMP 算法
通过上面的学习,我们发现 RK 算法是通过将问题进行了转化,把字符串中多个字符需要逐位比较的操作转化为只需要比较字符串的哈希值来优化了算法。但其实在逐位比较的思想里,我们也可以对算法进行优化。
KMP 算法的思想阐述
我们先来看一个示例:
abcde 都匹配,在第 6 位,主串字符s[6] = f 与模式串 t[6] = x 失配,按照 BF 的思想,我们需要将 i 回退到第 2 位 s[2] = b 处,然后 j 回退到第一位 t[1] = a 处开始新一轮的比较,即下图所示的情况:
是不是已经开始烦躁了?
对的,我们可以明显发现这里面好像是有一些问题的,但是好像又感觉说不太清楚对吧?
我们试着来分析一下:
在图 ① 中,s[6]与 t[6]失配,这个情况的出现其实蕴含了很多信息:
s[6]和t[6]不相等。(这不是废话么?🙄)s[5]和t[5]相等,s[4]和t[4]相等,s[3]和t[3]相等.....
第二条信息就不是废话了嗷,到 6 号位才失配,那么说明,从1~5 号位,t 和 s 都是匹配的。 然后我们把目光聚焦在前两位上: 已知
s[1] = t[1]; s[2] = t[2];
然后我们再把目光聚焦在模式串本身上的前两位上:
t[1] = a; t[2] = b;
所以
t[1] != t[2]
结合这两个小结论我们可以得到什么?
s[2] = t[2] != t[1]; 即
s[2] != t[1];
在这里我希望你好好梳理一下上面的推论,搞清楚这个是怎么得到的,这很重要!!
如果你清楚了,那么我们再来继续推导。
现在我们知道了 s[2] != t[1]; ,那么我们是如何让知道的呢?
对的,我们仅从图①就能知道,而且在这个推导里面不难看出,这个与主串的内容无关,我只需知道:
- 6 号位失配
t[1]!=t[2]我们就能得出:
s[2] != t[1];
也就是说,我把图①改成这样:
s[2] != t[1];
可能有人要问了,我得出这个干什么嘛?有什么用嘛?别念了,我已经开始烦了🙄
稍安勿躁,我们看第二张图:
我请问你第二张图在干什么?
对了,在 BF 算法中,这个步骤在比较 s[2]和 t[1],那么你回去瞅一眼上面的结论:
s[2] != t[1];
从图①中我们已经得知了 s[2]!=t[1]了,那这里还比个毛线啊比,所以第二步可以跳过了。
同理第 ③④⑤ 都可以跳过了。
也就是从①我们可以直接跳到⑥,如下图所示:
也就是 j 直接定位到 6 了,
那 i 呢?i 要回退么?
因为 s[2] != t[1] ,那么 i 也没有必要回退到 2 了,同理可以推论出:i 直接可以不动就在 6 号位。
是不是很神奇??? 😁
这就是 KMP 算法的中心思想,他通过已经对比过的信息来分析模式串本身的特点,从而对失配后的定位进行了优化,大大提升了算法的效率。如图所示:
但是为了哔哔清楚它的原理我们还要再看一个示例,这一次,我会哔哔的快一点,如果你感觉跟不上,你可以试着暂停一下,多想一会儿嗷,上示例:
不好意思,上错了。为了更好的理解 KMP,我决定把主串全涂成马赛克。
你可以这样假设,主串存在服务器,你不知道主串内容。你每次都携带(
i 和 t)问服务器:
老大啊?在不在?我请问一下,从i下标开始,主串和我的t匹配么?
老大回话了:从第 6 位开始失配。
那么 6 号位失配上面说过了代表什么来着,回忆一下嗷,一会要用的。
由上面推论:
因为s[1] != s[2],所以跳过 2 号位比较; s[1] != s[3],所以跳过 3 号位比较; ......
s[1] = s[4],所以 emmm,对了,4 号位相等了,那么我们就需要继续比较 4 号位,也就是 j 要定位到 4了。
那 i 呢?我们要从 4 号位开始比较了,那么 i 就需要退回 4 号位。
但是我们把目光聚焦到子串:
s[1] == s[4];
再加上聪明的你刚刚回忆到的: (不聪明也没有关系,我写出来~😁)
s[4] = t[4];
所以可得:
s[1] = s[4] = t[4];
所以其实 s[4] 和 t[1] 根本就没有必要比较,同理 s[5] 和 t[2] 也没有必要比较。
怎么同理的?聪明的你好好想一想┗|`O′|┛ 嗷~~,提示:t[2] = t[5]
所以 i 重新定位到了 6,j 重新定位到了 3。
综上所述:
整个流程中
i都不需要回退,j在失配后需要回退到和前缀不一样的字符处。示例中的前缀是ab。
我们把主串内容显示出来,你可以发现,和我们的料想丝毫不差!
这个时候我们再回头想想刚刚那个服务器老大的例子,其实 每一次应该回退的位置和主串没有关系,这是模式串 t 自身的特点决定的,那么我们不如在找老大的时候就把自己的特性描述清楚算了。例如,如果有一个数组能说明失配发生时 j 应该回退到哪一个下标位置就好了是吧,我直接查询数组多方便。
next 数组
emmm,巧不巧?人家早就想到了,这样的数组就叫做 next 数组。next[j] 的值含义表示为在 j 下标位失配时,j 需要重新定位的下标值。先直接上你看不懂的公式,我也不能一眼就看懂嗷😊:
请注意!从现在开始下面讨论的问题不是
KMP 算法,而是根据模式串t求出上述的next 数组,整个算法逻辑和主串 s没有关系。
如果前面的推导你都理解了,解释起来就简单多了:
这个模式串比较特别,它没有相同的字符,所以每次失配之后 j 都要从头来匹配。我们不看它了,看另一个嗷:
如果你觉得还是有些难度,为什么突然要说什么前缀和后缀,什么鬼?好烦啊🙄
那你可以带着这个模式串重复上面的分析,不难得出:
如果模式串在
j位置失配了,j位以前的字符肯定已经对比过了,而且都是匹配的,这时候,如果模式串的尾巴恰好和头相同了,也就是 t[j-1] = t[1] 了,又由于t[j-1] = s[i-1],所以 s[i-1] = t[1]我们第一位就不用比了,从第二位开始,所以 j 定位到了 2,那么这是前缀和后缀只有一位相同的情况,位数要是增多了,那么这个定位就会往后更多,为了最优,我们就取最长的,这就是上面公式中Max的含义。(小字里的推断我们已经重复了 N 遍了,希望你已经烂熟于心了嗷~)
其实我们在编码求解这个数组的时候思想是比较简单的。
我们只需要关注在 j 位失配的时候他前面的字符有没有相同的前后缀,有的话找到最大的即可。
我们再看个示例:
按照这个流程就能得出 next 数组:
总结一下:
注意此处的
i和j都是在模式串的下标。
重申一下这是在求解next 数组,和KMP 算法无关,这是KMP 算法的准备工作。 整体思想就是使用i和j标记对比过的下标,在模式串中找到最长的前后缀相同的下标位置,如果没有找到,那就回溯到上一次找到的位置。
至此我们的 next 数组就求解完成了。我们终于可以拿来进行 KMP 算法了。
j = 5 的时候失配;
根据 next 数组,next[5] = 4; 开始新的比较:
j 重新定位到了 4,我们发现前面的 1~3 都不需要比较了,算法的确让比较过程变简单了。😊
j = 4 的时候失配;
根据 next 数组,next[4] = 3; 开始新的比较:
重复这个过程我们发现:
j 虽然根据 next 数组重新定位了,但是一直不停地失配,最终 j 被重新定位到了 1,开始了新的比较。
至此,KMP 算法的思想已经讲完了,整体的流程下来,我们可以发现————什么?你说这个地方有点烦人???
都已经这么简化了你还要怎样啊摔?
什么?你说这个地方还能简化?
好好好行行行是是是,那么我勉为其难来试着继续分析一下吧:
由于笔者的失误,举了一个不好的了例子,所以自讨苦吃地继续下面的讲解☹️
next 数组的优化
在上面的例子中,我们虽然根据 next 数组重新定位了 j,相对于 BF 算法已经十分优化了,但其实这个 j 的位置貌似并不是十分合适,因为 j 在后续的比较中接连失配,再加上这个模式串t前面一连串的aaaa,我们有理由怀疑这个地方是有问题的😁
事实也正是如此,首先这个模式串中:
t[1] = t[2] = t[3] = t[4] = t[5] = a
再由①中,s[5] = b与 t[5] = a失配,可得
s[5] != t[5]
但是我们注意到 t[4] = t[5],
那么我们可得
s[5] != t[4]
让我们看看②在干什么?
s[5] 和 t[4],但是其实我从①已经得出这俩不同了,你还比较个毛啊,果断跳过!
同理可得
s[5] != t[4] = t[3] = t[2] = t[1]
所以 ②③④⑤其实都可以因为①跳过!
哇!是不是非常神奇啊😊!
总结一下其实就是:
我们在求前
i - 1个元素前缀与后缀相等个数的时候,是不考虑第t[ i ]的,但是如果要是最大相等前缀的后一个字符和t[ i ]相等的话,不就表明即使你移动了之后,当前字符依然没变,也就依然不能匹配,所以要再接着移吗?
我们将经过这样优化的 next 数组记为 nextval 数组,他是对 next 数组的优化,你不这样做 KMP 算法也已经很优秀了嗷,当然你优化了它会更优秀😊!
我们通过一个示例梳理一下 nextval 数组的求解过程:
对这个过程进行总结:
至此,KMP 算法算是终终终终终终终于讲解结束了😭
KMP 算法的代码就不在文章中贴了,上面的链接里有,你需要做到的就是好好理解了 KMP 整个过程。我在这里提出几个问题你看你能回答么?
KMP 算法执行时 主串标记i会后退么?为什么?
KMP 算法执行时 模式串标记j会后退么?为什么?
next 数组是什么?干什么用的?为什么在求解它的时候要说什么劳什子前缀和后缀?
nextval 数组又是干什么的?他是为了解决问题的?
总结
其实说了这么多,我们在平时应用时,99.9999%的情况下,我们都会使用 BF 算法,因为我们还要考虑到代码的维护成本,写的代码还需要让猪猪队友(没错我就是那个^(* ̄(oo) ̄)^)能够阅读,并针对业务加以调整。
那我们学习这个烦人的东西意义是什么???
emmmm....这么说吧。如果你现在心里有了一股子懂了 RK 和 KMP 算法这么复杂的东西的自豪感,而且还神经病一样地想把他讲给女朋友和基友听,那么它的意义就实现了一丢丢。
开个玩笑哈,讲给女朋友还是要谨慎的,可能会挨打。
算法存在的意义更多是为了告诉人们这儿还有一条路,一条高处不胜寒的道路,当你走在平凡的道路上时,你会知道有那么一条远在高处的路,它和你的终点一致,它更优雅,它更高级,它汇聚了前人无尽的努力和研究。而现在,你可以轻而易举地走上去,并且通过它,走向更高更好的远方!
神经病啊,写这么诗意有毛病?
----我写这么诗意就是要让你知道,你要是没懂,你就好好再多看几遍,往懂了看,这条路上也可以有你的影子,只要你想,只要你努力,加油┗|`O′|┛ 嗷~~