思路:
public static ArrayList<Integer> match(String str, String key) {
ArrayList<Integer> list = new ArrayList<>();
for (int startIndex = 0; startIndex < str.length(); startIndex++) {
int endIndex;
if ((endIndex = startIndex + key.length() - 1) > str.length() - 1) break;
int p1 = startIndex;
int p2 = 0;
while (p2 < key.length() && str.charAt(p1) == key.charAt(p2)) {
p1++;
p2++;
}
if (p2 == key.length()) list.add(startIndex);
}
return list;
}
关于字符串的前缀和后缀
如果字符串A和B,存在A=BS,其中S是任意的非空字符串,那就称B为A的前缀。例如,”Harry”的前缀包括{”H”, ”Ha”, ”Har”, ”Harr”},我们把所有前缀组成的集合,称为字符串的前缀集合。同样可以定义后缀A=SB, 其中S是任意的非空字符串,那就称B为A的后缀,例如,”Potter”的后缀包括{”otter”, ”tter”, ”ter”, ”er”, ”r”},然后把所有后缀组成的集合,称为字符串的后缀集合。要注意的是,字符串本身并不是自己的后缀。
关于next数组
- 数组长度等于key串长度
- 数组中元素的确定:next[i] = 从key串的下标0开始,长度为i的子串的前缀集和后缀集的交集中最长元素的长度,例如子串’‘abcdabc’'就是 next[7] = 3,表示长度为7的子串的前缀集和后缀集的交集中最长元素的长度为3。这里规定next[0] = -1, next[1] = 0
推荐的参考文章和视频(像我这种笨比都看得懂的):
b站视频:KMP算法原理
文章:如何更好地理解和掌握 KMP 算法?
求next数组之前我们先求出pmt数组,next数组就是通过pmt数组所有元素右移一位后,令pmt[0] = -1得到的。
那么如何求出pmt数组呢?
index = 0 1 2 3 4 5 6 7 8
pattern = "[A][B][A][B][C][A][B][A][A]"
PMT = [0][0][1][2][0][1][2][3][1]
next = [-1][0][0][1][2][0][1][2][3]
计算方法:从0~i截取pattern字符串(i = 0,1,2…pattern.length-1),然后求出每次截取子串的最大公共前后缀(不包含本身)长度
例如:
- 当i=0时,截取子串为"A",最大公共前后缀长度为0,PMT[0]=0
- 当i=1时,截取子串为"AB",最大公共前后缀长度为0,PMT[1]=0
- 当i=2时,截取子串为"ABA",最大公共前后缀长度为1,PMT[2]=1
- 当i=3时,截取子串为"ABAB",最大公共前后缀长度为2,PMT[3]=2
- 当i=4时,截取子串为"ABABC",最大公共前后缀长度为0,PMT[4]=0
- 当i=5时,截取子串为"ABABCA",最大公共前后缀长度为1,PMT[5]=1
- 当i=6时,截取子串为"ABABCAB",最大公共前后缀长度为2,PMT[6]=2
- 当i=7时,截取子串为"ABABCABA",最大公共前后缀长度为3,PMT[7]=3
- 当i=8时,截取子串为"ABABCABAA",最大公共前后缀长度为1,PMT[8]=1
代码实现
第一种:自己想出来的笨比方法
// 思路:
// PMT[0] = 0
// i指针从下标1开始截取pattern
// 建立两个单边扩展窗口
// window1: 左顶点为定为0,右顶点扩展,len为窗口长度,从1扩展到i,startIndex1=0,endIndex1=startIndex1+len-1
// window2: 右顶点定为i,左顶点进行扩展,len为窗口长度,从1扩展到i,endIndex2=i,startIndex2=endIndex2-len+1
// 这两个窗口的作用: window1扫描截取子串的前缀,window2扫描截取子串的后缀,找到前缀与后缀的最大匹配长度
// maxLen: 记录最大公共前后缀长度
public static int[] getNext(String pattern) {
int[] PMT = new int[pattern.length()];
PMT[0] = 0;
for (int i = 1; i < pattern.length(); i++) {
int maxLen = 0;
// 两个扫描窗口的长度默认为1
int len = 1;
// window1
int startIndex1 = 0;
int endIndex1 = startIndex1 + len - 1;
// window2
int endIndex2 = i;
int startIndex2 = endIndex2 - len + 1;
while (len <= i) {
// 如果两个窗口扫描到的前后缀匹配则更新maxLen
if (pattern.substring(startIndex1, endIndex1 + 1).equals(pattern.substring(startIndex2, endIndex2 + 1))) {
maxLen = len;
}
// 两个窗口进行扩展
len++;
endIndex1 = startIndex1 + len - 1;
startIndex2 = endIndex2 - len + 1;
}
PMT[i] = maxLen;
}
return move(PMT);
}
// 把pmt数组右移一位
public static int[] move(int[] pmt) {
System.arraycopy(pmt, 0, pmt, 1, pmt.length - 1);
pmt[0] = -1;
return pmt;
}
第二种:KMP算法中使用的方法,复杂度比上面低,但是有点难懂
public static int[] getNext(String pattern) {
// next数组保存的是i指针前面的子串的最长公共前后缀的长度(包括i当前字符)
// i同时也对应其下标
int[] PMT = new int[pattern.length()];
PMT[0] = 0;
PMT[1] = 0;
// 扫描模式串
int i = 1;
// i指针前面的子串的最长公共前后缀的长度(不包括i当前字符)
// 同时也表示最长公共前后缀中的前缀的后一位字符下标
int len = 0;
while (i < pattern.length()) {
// 如果i指针下的字符与最长公共前后缀中的前缀的后一个字符相同
// 那么next数组此位置记录的最长公共前后缀的长度=之前的+1
if (pattern.charAt(i) == pattern.charAt(len)) {
PMT[i++] = ++len;
} else {
// 谁能告诉我这里为什么要这么写QAQ,
if (len > 0) {
len = PMT[len - 1];
} else {
PMT[i++] = 0;
}
}
}
return move(PMT);
}
// 把pmt数组右移一位
public static int[] move(int[] pmt) {
System.arraycopy(pmt, 0, pmt, 1, pmt.length - 1);
pmt[0] = -1;
return pmt;
}
当我们求出模式串的next数组后,KMP算法差不多就实现了一大半了,next数组中存储了前后缀哪里相同的信息,用来减少比较次数,i就不用回退了,下面来说说如何利用next数组实现比暴力法更快的字符串匹配。
index 0 1 2 3 4 5 6 7 8 9 10 11
源串T: [a][b][a][a][c][a][b][a][b][c][a][c]
模式串P: [a][b][a][b][c] (第一次匹配:T在3处失配,P在3处失配,next对应1,则把P的1处位置移到T的失配处3)
index 0 1 2 3 4
next -1 0 0 1 2
0 1 2 3 4 5 6 7 8 9 10 11
[a][b][a][a][c][a][b][a][b][c][a][c]
[a][b][a][b][c] (第二次匹配:T在3处失配,P在1处失配,next对应0,则把P的0处位置移到T的失配处3)
0 1 2 3 4
-1 0 0 1 2
0 1 2 3 4 5 6 7 8 9 10 11
[a][b][a][a][c][a][b][a][b][c][a][c]
[a][b][a][b][c] (第三次匹配:T在4处失配,P在1处失配,next对应0,则把P的0处位置移到T的失配处4)
0 1 2 3 4
-1 0 0 1 2
0 1 2 3 4 5 6 7 8 9 10 11
[a][b][a][a][c][a][b][a][b][c][a][c]
[a][b][a][b][c] (第四次匹配:T在4处失配,P在0处失配,next对应-1,则把P的-1处位置移到T的失配处4 --- 不存在-1下标这里就是把P右移一位)
0 1 2 3 4
-1 0 0 1 2
0 1 2 3 4 5 6 7 8 9 10 11
[a][b][a][a][c][a][b][a][b][c][a][c]
[a][b][a][b][c] (第五次匹配:匹配成功!记录T下标5,T在9处匹配结束,P在4处匹配结束,next对应2,则把P的2处位置移到T的9处)
0 1 2 3 4
-1 0 0 1 2
0 1 2 3 4 5 6 7 8 9 10 11
[a][b][a][a][c][a][b][a][b][c][a][c]
[a][b][a][b][c] (第六次匹配:T在9处失配,P在2处失配,next对应0,则把P的0处位置移到T的失配处9)
0 1 2 3 4
-1 0 0 1 2
0 1 2 3 4 5 6 7 8 9 10 11
[a][b][a][a][c][a][b][a][b][c][a][c]
[a][b][a][b][c] (P越界,不用再进行后续匹配)
0 1 2 3 4
-1 0 0 1 2
代码实现
public static ArrayList<Integer> kmp_search(String str, String pattern) {
int[] next = getNext(pattern);
ArrayList<Integer> list = new ArrayList<>();
// 窗口左端点
int startIndex = 0;
// 窗口右端点
int endIndex = startIndex + pattern.length() - 1;
// p1扫描str, p2扫描pattern
int p1 = 0;
int p2 = 0;
while (endIndex < str.length()) {
while (p1 <= str.length() - 1 && p2 <= pattern.length() - 1 && str.charAt(p1) == pattern.charAt(p2)) {
p1++;
p2++;
}
// 如果str窗口内的子串与pattern匹配则记录窗口左端点
if (p2 > pattern.length() - 1) {
list.add(startIndex);
System.out.println(startIndex);
p1--;
p2--;
// 更新窗口位置:
// p2向左移动p2 - next[p2]步,那么窗口要向右移动p2 - next[p2]步,保证p1与p2对齐
startIndex += p2 - next[p2];
endIndex = startIndex + pattern.length() - 1;
// 更新p2位置
p2 = next[p2];
} else {
// 当前窗口内的子串与pattern不匹配:
// 1. 失配处在pattern第一个字符,则窗口和p1同时右移一位
if (next[p2] == -1) {
startIndex++;
endIndex = startIndex + pattern.length() - 1;
p1++;
} else {
// 2. 失配处不在pattern第一个字符,则p2移动到next[p2]位置,窗口右移p2 - next[p2]步,保证p1与p2对齐
// |当前下标 - 移动后的下标| = 移动步数
startIndex += p2 - next[p2];
endIndex = startIndex + pattern.length() - 1;
p2 = next[p2];
}
}
}
return list;
}
public static int[] getNext(String pattern) {
// next数组保存的是i指针前面的子串的最长公共前后缀的长度(包括i当前字符)
// i同时也对应其下标
int[] PMT = new int[pattern.length()];
PMT[0] = 0;
PMT[1] = 0;
// 扫描模式串
int i = 1;
// i指针前面的子串的最长公共前后缀的长度(不包括i当前字符)
// 同时也表示最长公共前后缀中的前缀的后一位字符下标
int len = 0;
while (i < pattern.length()) {
// 如果i指针下的字符与最长公共前后缀中的前缀的后一个字符相同
// 那么next数组此位置记录的最长公共前后缀的长度=之前的+1
if (pattern.charAt(i) == pattern.charAt(len)) {
PMT[i++] = ++len;
} else {
//
if (len > 0) {
len = PMT[len - 1];
} else {
PMT[i++] = 0;
}
}
}
return move(PMT);
}
// 把pmt数组右移一位
public static int[] move(int[] pmt) {
System.arraycopy(pmt, 0, pmt, 1, pmt.length - 1);
pmt[0] = -1;
return pmt;
}
大致的核心思路:
- 源串T上建立一个滑动窗口
- 建立两个扫描指针:p1扫描源串T,p2扫描模式串P
- 模式串放在滑动窗口内(逻辑上帮助理解)
- 当指针p2移动到next[p2]位置处时,相当于左移动了p2 - next[p2]步
- 让窗口右移p2 - next[p2]步,始终保持p1与p2齐平
- 只要源串T被滑动窗口截取的子串与模式串P匹配,则记录滑动窗口左端点即可
System.arraycopy(原数组, 移动的起始下标, 原数组, 移动后的起始下标, 数组长度 - 移动步数)