概述
今天我们来聊一聊字符串匹配的问题。
比如有字符串str1 = “豫章故那,洪都新府。星分翼轸,地接衡庐。襟三江而带五湖,控蛮荆而引瓯越。”,字符串str2 = “襟三江而带五湖”。
现要判断str1是否含有str2,如果有则的返回第一次出现的位置,如果没有返回-1。
面对字符串匹配问题,我们通常都会直观的选择暴力匹配算法来解决,这固然能够满足业务要求但不可否认,它效率极低。
暴力匹配算法
如果用暴力匹配的思路,并假设现在str1匹配到 i 位置,子串str2匹配到 j 位置,则:
- 如果当前字符匹配成功(即str1 [ i ] == str2 [ j ] ) ,则i++,j++,继续匹配下一个字符;
- 如果失配(即str1 [ i ] != str2 [ j ]) ,令i=i - ( j - 1 ),j = 0。相当于每次匹配失败时,i 回溯,j 被置为0;
- 用暴力方法解决的话就会有大量的回溯,每次只移动一位,若是不匹配,移动到下一位接着判断,浪费了大量的时间。
代码实现
虽然暴力匹配算法效率极低,面临实际业务时基本不会采用这种方式,但是我还是将思路落实成代码,也方便与下文的KMP算法作比较。
public class ViolenceMatch {
public static void main(String[] args) {
String str1 = "豫章故那,洪都新府。星分翼轸,地接衡庐。襟三江而带五湖,控蛮荆而引瓯越。";
String str2 = "襟三江而带五湖";
int index = violenceMatch(str1,str2);
System.out.println("index="+index);
}
//暴力匹配算法
public static int violenceMatch(String str1,String str2){
char[] s1 = str1.toCharArray();
char[] s2 = str2.toCharArray();
int s1Len = s1.length;
int s2Len = s2.length;
int i = 0; //i索引指向s1
int j = 0; //j索引指向s2
while (i<s1Len&&j<s2Len){//保证匹配时不越界
if (s1[i]==s2[j]){
i++;
j++;
}else {//没有匹配成功
//失配(即str1[i]!=str2[j]),令i = i-(j-1), j=0。 即让i回到最初位置的后一位
i = i - (j-1);
j = 0;
}
}
//判断是否匹配成功
if (j==s2Len){
return i-j;
}else {
return -1;
}
}
}
看似时间复杂度不高,O(n)似乎可以接受,但实际上,循环中的回溯
i = i - (j-1);
j = 0;
真正运行起来是天大的灾难,str1长度越长,效率越低。
解决字符串匹配问题还是采用KMP算法更合适一些。
KMP算法
- KMP是一个解决 求模式串在文本串是否出现过,如果出现过,求最早出现的位置的经典算法;
- Knuth-Morris-Pratt 字符串查找算法,简称为KMP算法,常用于在一个文本串S内查找一个模式串P 的出现位置,这个算法由Donald Knuth、Vaughan Pratt、James H.Morris三人于1977年联合发表,故取这3人的姓氏命名此算法
- KMP算法就利用之前判断过信息,通过一个next数组,保存模式串中前后最长公共子序列的长度,每次回溯时,通过next数组找到,前面匹配过的位置,省去了大量的计算时间。
思路分析
举例来说,有一个字符串 str1 = “BBCABCDAB ABCDABCDABDE”,判断,里面是否包含另一个字符串str2=“ABCDABD”。
- 首先,用 str1 的第一个字符和 str2 的第一个字符去比较,不符合,关键词向后移动一位
- 重复第一步,依旧不符合,继续后移
- 持续匹配,直到出现匹配字符
- 接着匹配字符串与模式串的下一个字符
- 直到遇到二者不匹配的位置
- 这时候,大概率会想到继续遍历 str1 的下一个字符,重复第 1步。(其实是很不明智的,因为此时 BCD 已经比较过了,没有必要再做重复的工作,一个基本事实是,当空格与 D 不匹配时,你其实知道前面六个字符是”ABCDAB”。KMP 算法的想法是,设法利用这个已知信息,不要把”搜索位置”移回已经比较过的位置,继续把它向后移,这样就提高了效率。)
这里需要注意,所谓的“BCD已经比较过了,没必要做重复的工作”指的是:在已知ABCD互不匹配的前提下,我们此轮已经将str2的前几位ABCD…与str1的部分字符匹配上了,可惜的是后面没有匹配上,那么我们可以认为,从str2 的A 开始的这些匹配字段(ABCD)肯定不会再与A匹配了(因为ABCD互不匹配而后面的与BCD匹配,所以不可能再与A匹配)。
上面的关键在于如何确定A与BCD不同。 - 那么问题来了,怎么将刚刚重复的步骤省略掉呢?不妨对str2计算一张部分匹配表
- 已知空格与 D不匹配时,前面六个字符”ABCDAB”是匹配的。查表可知,最后一个匹配字符 B 对应的”部分匹配值”为 2,因此按照下面的公式算出向后移动的位数:
移动位数 = 已匹配的字符数 - 对应的部分匹配值,因为 6-2 等于4,所以将搜索向后移动 4 位。 - 因为空格与C不匹配,搜索词还要继续往后移。这时,已匹配的字符数为 2(”AB”),对应的”部分匹配值”为 0。所以,移动位数 = 2-0,结果为 2,于是将搜索词向后移 2 位。
部分匹配值:A=0, AB=0, ABC=0, ABCD=0, ABCDA=1, ABCDAB=2 - 因为空格与A不匹配,继续后移一位
- 逐位比较,直到发现c与D不匹配。于是,移动位数 = 6-2,继续将搜索词向后移动 4 位。
- 逐位比较,直到搜索词的最后一位,发现完全匹配,于是搜索完成。如果还要继续搜索(即找出全部匹配),移动位数 =7-0,再将搜索词向后移动 7 位,这里就不再重复了。
部分匹配表的产生
经过上面的思路拆解,相信大家都感受到了KMP算法的精妙,但是有一个很关键的问题,部分匹配表是怎么来的?
要理解这个问题,首先要说以下前缀、后缀的概念
比如对字符串:bread
前缀: b br bre brea
后缀: d ad ead read
“部分匹配值”就是”前缀”和”后缀”的最长的共有元素的长度。以 ABCDABD为例,
- A的前缀和后缀都为空集,共有元素的长度为 0;
- AB的前缀为[A],后缀为[B],共有元素的长度为 0;
- ABC的前缀为[A,AB],后缀为[BC,C],共有元素的长度 0;
- ABCD的前缀为[A,AB,ABC],后缀为[BCD,CD,D],共有元素的长度为 0;
- ABCDA的前缀为[A,AB,ABC,ABCD],后缀为[BCDA, CDA,DA,A],共有元素为”A”,长度为1;
- ABCDAB的前缀为[A,AB,ABC,ABCD,ABCDA],后缀为[BCDAB,CDAB,DAB,AB,B],共有元素为”AB”
长度为 2; - ABCDABD的前缀为[A,AB,ABC,ABCD,ABCDA,ABCDAB], 后缀为[BCDABD,CDABD,DABD,ABD,BD,D].共有元素的长度为 0。
也就是说
• 部分匹配值是“前缀”数组和“后缀”数组中最长的相同元素的长度;
• 部分匹配表中左起第一个A表示字符串为 “A”时的部分匹配值为0, 即前后缀无共有元素;
• 部分匹配表中左起第一个D表示字符串为 “ABCD”时的部分匹配值为0, 即前后缀无共有元素;
• 部分匹配表中左起第二个A表示字符串为 “ABCDA”时的部分匹配值为1, 即前后缀最长共有元素"A";
• 部分匹配表中左起第二个B表示字符串为 “ABCDAB”时的部分匹配值为2, 即前后缀最长共有元素"AB";
• 部分匹配表中左起第二个D表示字符串为 “ABCDABD”时的部分匹配值为0, 即前后缀无共有元素;
KMP代码实现
KMP方案
- 先得到字串的部分匹配表
- 使用部分匹配表完成KMP匹配
实现
public class KMPAlgorithm {
public static void main(String[] args) {
String str1 = "BBC ABCDAB ABCDABCDABDE";
String str2 = "ABCDABD";
int [] next = kmpNext(str2);
System.out.println("next="+Arrays.toString(next));
int inx = kmpSearch(str1, str2, next);
System.out.println("index="+inx);
}
/**
* kmp搜索算法
* @param str1 源字符串
* @param str2 字串
* @param next 字串对应的 部分匹配表
* @return -1即无匹配,否则返回第一个匹配的位置
*/
public static int kmpSearch(String str1,String str2,int [] next){
//遍历,i在源字串,j在子串
for (int i = 0,j=0; i < str1.length(); i++) {
//需要处理str1.charAt(i)!=str2.charAt(j)不相等,调整j的大小
//kmp算法核心
while (j>0&&str1.charAt(i)!=str2.charAt(j)){
j = next[j-1];
}
//str1.charAt(i)==str2.charAt(j)相等
if(str1.charAt(i)==str2.charAt(j)){
j++;
}
if (j==str2.length())
return i-j+1;//找到了
}
return -1;
}
/**
* 获取到一个字符串(子串)的部分匹配值(表)
* @param dest 匹配子串
* @return 部分匹配值(表)对应数组
*/
public static int[] kmpNext(String dest){
//创建一个next数组保存部分匹配值
int [] next = new int[dest.length()];
next[0] = 0;//如果字符串长度为1,则其部分匹配值必为0
for (int i=1,j=0;i<dest.length();i++){
//当dest.charAt(i)!=dest.charAt(j)时,我们需要从next[j-1]获取新的j
//直到发现有dest.charAt(i)==dest.charAt(j)成立是才退出
//这是kmp算法的核心点
while (j>0&&dest.charAt(i)!=dest.charAt(j)){
j = next[j-1];
// j--;//? 类似于j--,但不知道为什么不能用
}
//当dest.charAt(i)==dest.charAt(j),满足时,部分匹配值就是+1
if (dest.charAt(i)==dest.charAt(j)){
j++;
}
next[i] = j;
}
return next;
}
}
KMP代码的核心在于:
while (j>0&&dest.charAt(i)!=dest.charAt(j)){
j = next[j-1];
// j--;//? 类似于j--,但不知道为什么不能用
}
这里类似于回溯,在两匹配字符相等时,i与j同时++;在两匹配字符不等时,i不动,j通过next数组进行回溯比较(这里类似于j–,但是并不能等价,底层还要探究)。
小结
对KMP算法的简单介绍就到这里了,感兴趣的同学可以就相关问题做更深入的研究。
关注我,共同进步,每周至少一更。——Wayne