数据结构与算法--子字符串查找
字符串的一种基本操作就是子字符串查找了,给定一段长度为N的文本字符串(主串)和长度为M的模式字符串(子串),在文本中找到和模式相符的子字符串,返回子字符串开头在主串中的索引。如果不存在和模式匹配的子字符串则按照惯例返回-1。
如ABCDABCE
中查找ABCD
,那么应该返回4。ABCDABCE
中查找ABCDE
找不到,返回-1。
有一种思路很容易想到,比较“暴力”。
暴力法查找子字符串
该方法首先将子串p和主串t在索引0处对齐——即比较它们的第一个字符p[0]和t[0]相不相同。如果相同,同时将p和t的索引增大1,比较下一位字符p[1]和t[1]是否相等......当然期间会遇到主串和子串某个字符不相同的情况,这种情况称为“失配”,该处称为“失配位置”。一旦失配,不再比较之后的字符,将子串整体向后移动一位,此时p[0]与t[1]对齐,再次比较p[0]是否与t[1]相同,比如在第5个字符处失配了,比较p[0]和t[2]是否相同...重复以上操作。可以看到,每次失配,子串索引始终回退到0。而主串的索引回到上轮比较中主串开始比较处的下一位
上面是用通俗的语言来描述,如果将其转化为代码实现,像“将子字符串往后移一位”,“将p[0]与t[1]对齐”这样的操作,其实是为了帮助理解,实际上我们从未移动子字符串,只是错位比较而已——当我们比较p[0]和t[1]时,我们的眼睛不太习惯错位比较,我们可以人为的将字符串向后移动,让p[0]与t[1]对齐了,这更方便观察。所以,上面的说法只是为了方便人的理解,机器则不需要这些“人为加上去的操作”。我们先给出代码实现,下面结合着图来说明。
package Chap5;
public class BFSearch {
public static int search(String p, String t) {
int N = t.length();
int M = p.length();
int i = 0; // 主串的索引
int j = 0; // 字串的索引
// 若没到任一字符串的末尾,循环之
while (i < N && j < M) {
// 字符相同时,索引都加1
if (p.charAt(j) == t.charAt(i)) {
i++;
j++;
} else {
i = i - j + 1; // 这句是关键
j = 0;
}
}
// 跳出循环的时候不是i == N(没找到)就是j == M(找到)
if (j == M) {
return i - j;
}
else {
return -1;
}
}
public static void main(String[] args) {
int index = BFSearch.search("good", "gootgoodgoopt");
System.out.println(index); // 4
}
}
上面有句i = i - j + 1
,这是失配后i
应该回退的位置,为什么是这个值呢?说可能说不清楚,动笔画一画就一目了然。i
和j
表示的是失配时,主串的位置和子串的位置。不管失配处之前有几个字符匹配成功了,或者第一个字符就失配,i - j
的含义始终是本轮匹配中主串的开始比较处(与p[0]比较)的索引,那么i - j + 1
就表示下轮匹配中主串的开始比较处的索引。既然理解了i - j
的含义,当模式匹配成功时,也应该返回的是i - j
—— 子字符串开头在主串中的索引。
ABCABCABX
ABX
比如上面失配位置i = j = 2
则下轮匹配应该让p[0]和t[1]比较,如下图。i - j
确实是本轮比较中,主串开始比较处的索引0,下轮比较从i - j + 1 = 1
处开始。如下
ABCABCABX
ABX
看上面,第一个字符就失配,i = 1, j = 0
,下轮比较i = i - j + 1 = 2
开始。接着看
ABCABCABX
ABX
i = 5, j = 2
,下轮比较从i = i - j + 1 = 4
处,即p[0]与t[4]比较。最后成功匹配时
ABCABCABX
ABX
此时j == M
跳出循环,i = 8, j = 2
。i - j = 6
,确实是在索引为6的位置,找到了和模式字符串匹配的子字符串。
暴力法另一种实现
上面的代码如果找不与模式字符串匹配的子字符串,i
会一直增大直到等于N才跳出循环,可是我们真的需要将主串的每个字符都与p[0]比较吗?不需要!
假设一直失配,顶多到子串的末尾和主串的末尾对齐时,进行最后一次匹配。这次再失配,子串向后移动一位,主串的最后一位会和子串的倒数第二位进行比较,子串的最后一位在主串中没有与之比较的字符了!说白了就是,字符串长度都不同,不用比较了。比如ABCDE
查找CDF
。
这将是最后一次匹配
ABCDE
CDF
这次的匹配没有意义,de和cdf不需比较
ABCDE
CDF
也就是说p[0]最多和t[N - M]比较。基于以上事实,我们可以将代码换种实现。
public static int search(String p, String t) {
int N = t.length();
int M = p.length();
for (int i = 0; i <= N - M; i++) {
int j;
for (j = 0; j < M; j++) {
if (p.charAt(j) != t.charAt(i + j)) {
break;
}
}
if (j == M) {
return i;
}
}
return -1;
}
上面已经分析过i
不会超过N - M,这体现在了第一层for循环中,第二层for循环中,如果两个字符相同,那么j++
,由于实现中p.charAt(j)
和t.charAt(i + j)
比较,效果等同于第一种实现中的i++, j++
。即拿p
的下一个字符和t
的下一个字符继续比较,不同的是i
的索引值并没有改变,始终保持为主串开始比较处的索引。这里i
的值就等于第一种实现的i - j
,所以当匹配成功时,也是返回的i
。这样当失配时,break跳出第二层循环,i
只需加1,进行下一轮匹配就好,进入第二层循环时j = 0
。效果等同于第一种实现种的i = i - j + 1;j = 0;
暴力法查找中,有一种最坏的情况。比如在pppppppppt
种查找ppt
,每一轮匹配中总是要比较到最后一位j = M -1
时候才发现失配,即每一轮匹配都比较了M次。直到i
的索引为N - M即第N - M + 1轮匹配(也就是最后一轮匹配了)才匹配成功。总共比较了(N - M + 1) * M
次,时间复杂度为O((N - M + 1) * M)
。
第二种实现中,其实i
从未减小过,但是从t.charAt(i + j)
可以看出,i
位置之后的若干个字符我们都比较过了,失配了还是回到i + 1
的位置重新匹配,已经比较过的字符串信息没有利用起来,有些比较步骤必然是多余的 (下面会说到),这和第一种实现的原理是一模一样的——第一种实现,i
确实是先变大,失配后再减小,称为显式回退。那么第二种实现可以认为是隐式回退,只是i
没有减小罢了。
看个例子,如果给定文本串T“BBC ABCDAB ABCDABCDABDE
”,和模式串P“ABCDABD
”,现在要拿模式串P去跟文本串T匹配,过程如下所示:
以下图片取自这篇文章,别人作的图挺好的,我就直接引用了。
1、匹配开始,p[0]
与t[0]
比较,不相同,i = 1, j = 0
,下轮用p[0]和t[1]比较
2、p[0]与t[1]依然不相同,i = 2, j = 0
,重复之,一直到有字符相同时
3、此时有相同的字符串, 比较下一个字符
4、继续比较,直到出现失配
5、前面的ABCDAB已经匹配,直到D与空串失配。
6、i = 5, j = 0
,从p[0]与t[5]比较开始。
后面还有若干步骤...先省略了。
仔细思考最后一步,在步骤5中发现了失配处D之前的字符串ABCDAB
已经匹配。且p中第一个字符A和之后的B不相同,那么按暴力法的方法(即步骤6做的那样),移动一位后肯定也是失配的。为什么呢?步骤5中已知t[5] = p[1] = B
,且p[0]显然不等于p[1]。所以步骤6中移动一位,p[0]和t[5]比较即p[0]和p[1]比较,肯定不相同的,这信息事先我们就知道了。等于说,这步根本就是多余的。对于第一个字符A后面的C、D字符也是一个道理。我们应该可以猜到,直接让p[2]即字符C与失配处(ABCDAB后的空串)对齐,从这里开始比较,相当于p向右移动了4位。因为p中字符C之前的AB和t中失配位置之前的AB字符串是相同的,在这个地方开始比较时有可能可以匹配成功的,值得一试。所以我们最好不要直接跳到p[0]和失配处对齐的位置。那么如何确定子串p的哪个位置和失配处对齐,继而从该位置开始比较呢?
通过上面的分析,可以发现,当失配位置之前已经有若干字符匹配时,暴力法很多步骤是多余的。因此提出了改进算法——KMP算法,该算法可以利用失配位置之前已经匹配的字符串这些已经比较过的信息。KMP算法的主串指针i
永远也不回退,它充分利用了已比较过的字符串所包含的信息;而且和暴力法的第二种实现比起来, 可以将子串一次移动多位,省去了不必要的比较。
KMP算法i
的值不回退,j
肯定是不回到0了,那j
应该取多少呢?也就是说原本p[j]和t[i]这两个字符失配了,现在为了避免多余的比较需要找到一个新的j
,让p[j]再和t[i]比较,在人的角度看来相当于直接移动子串p
使p[j]和失配处t[i]对齐。关键是这个新的j
怎么求了。这将在下一节KMP算法中揭晓。
by @ sunhaiyu
2017.8.3