题意:
- 任意给定一段字符串str(“123abc123abc00abc”)
- 再输入一个关键字key(“abc”)
- 要求返回str中包含key的所有子串的头下标
解法1:暴力法(双指针,不使用String类的substring)
思路:
- 建立一个滑动窗口
- 建立两个指针p1,p2:p1指针扫描滑动窗口中的每个字符,p2指针扫描key串中的每个字符
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;
}
解法2:KMP算法
一、写在前面
-
关于字符串的前缀和后缀
如果字符串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数组
-
求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; }
三、实现KMP算法
-
当我们求出模式串的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(原数组, 移动的起始下标, 原数组, 移动后的起始下标, 数组长度 - 移动步数)