KMP算法和Manacher算法详解
KMP算法(字符串匹配)
给定两个字符串str和match,长度分别为N和M。实现一个算法,如果字符串str中含有子串match,则返回match在str中的开始位置。不含有则返回-1.
例如:
str="acbc",match="bc",返回2;
str="acbc",match="bcc",返回-1;
要求:如果match的长度大于str的长度(M>N),str必然不会含有match,可直接返回-1.但如果N≥M,要求算法复杂度为O(N).
思考:
最普通的解法是从左到右遍历str的每一个字符,然后看如果以当前字符作为第一个字符开始出发是否匹配match,普通算法的时间复杂度较高,从每个字符出发时,匹配的代价都可能是O(N),那么一共有N个字符,所以整体的时间复杂度为O(N*M)。普通解法的时间复杂度这么高,是因为每次遍历到一个字符时,检查工作相当于从无开始,之前的遍历检查不能优化当前的遍历检查。
使用KMP算法快速解决字符串匹配问题:
- 首先生成match字符串的nextArr数组,这个数组的长度与match字符串的长度一样,nextArr[i]的含义是在match[i]之前的字符串match[0...i-1]中,必须以match[i-1]结尾的后缀子串(不能包含match[0])与必须以match[0]开头的前缀子串(不能包含match[i-1])最大的匹配长度是多少。这个长度就是nextArr[i]的值。
- 假设从str[i]字符出发时,匹配到j位置的字符发现与match中的字符不一致。也就是说,str[i]与match[0]一样,并且从这个位置开始一直可以匹配,即str[i,,,j-1]与match[0...,j-i-1]一样,知道发现str[j]!=match[j-1],匹配停止。因为现在已经有了match字符串的nextArr数组,nextArr[j-1]的值表示match[0...j-i-1]这一段字符串前缀和后缀的最大匹配。下一次直接让str[j]与match[k]进行匹配检查。对于match来说,相当于向右滑动,让match[k]滑动太str[j]同一个位置上,然后进行后续的匹配检查。直到在str的某一个位置把match完全匹配完,就说明str中有match。如果match滑到最后也没有匹配出来,就说明str中没有match.
- 匹配过程分析完毕,str中匹配的位置是不退回的,match则一直向右滑动,如果在str中的某个位置完全匹配出match,整个过程停止。否则match滑到str的最右侧过程也停止,所以滑动的长度最大为N,所以时间复杂度为O(N)。
如何快速得到match字符串的nextArr数组,并且要证明得到nextArr数组的时间复杂度为O(M)。对于match[0]来说,在它之前,没有字符,所以nextArr[0]规定为-1。对于match[1]来说,在它之前有match[0],但nextArr数组的定义要求任何子串的后缀不能包括第一个字符(match[0]),所以match[1]之前的字符串只有长度为0的后缀字符串,所以nextArr[1]为0,之后对match[i](i>1)来说,求解过程如下:
- 因为从左到右依次求解nextArr,所以在求解nextArr[i]时,nextArr[0...i-1]的值都已经求出。通过nextArr[i-1]的值可以知道B字符串的最长前缀和后缀匹配区域。设L区域为最长匹配的前缀子串,K区域为最长匹配的后缀子串,C为L区域之后的字符,B为K区域之后的字符,A是B字符之后的字符。然后查看字符C与字符B是否相等。
- 如果字符C与字符B相等,那么A字符之前的字符串的最长前缀和后缀匹配区域就可以确定,前缀子串为L区域+C字符,后缀子串为K区域+B字符,即nextArr[i] = nextArr[i-1]+1.
- 如果字符C与字符B不相等,就看字符C之前的前缀和后缀匹配情况,假设字符C是第cn个字符(match[cn]),那么nextArr[cn]就是其最长前缀和后缀匹配的长度。m区域和n区域分别是字符C之前的字符串的最长匹配的后缀与前缀区域,这是通过nextArr[cn]的值确定的。当然两个区域是相等的,m'区域为k区域最后的区域且长度与m区域一样,因为k区域和L区域是相等的,所以m区域和m'区域也是相等的,字符D为n区域之后的第一个字符,接下来比较字符D是否与字符B相等。
- 如果相等,A字符之前的字符串的最长前缀与后缀匹配区域就可以确定,前缀子串为n区域+D字符,后缀子串为m'区域+B字符,则令nextArr[i]=nextArr[cn]+1。
- 如果不等,继续往前跳到字符D,之后的过程与跳到字符C类似,一直进行这样的跳过程,跳的每一步都会有一个新的字符和B比较(就像C字符和D字符一样),只要有相等的情况,nextArr[i]的值就能确定。
- 如果向前调到最左位置(即match[0]的位置),此时nextArr[0]=-1,说明字符A之前的字符串不存在前缀和后缀匹配的情况,则令nextArr[I]=0。用这种不断向前跳的方式可以算出正确的nextArr[I]值的原因还是因为每跳到一个位置cn,nextArr[cn]的意义就表示它之前字符串的最大匹配长度。
代码:
package NowCoder2;
import java.util.Scanner;
public class KMP {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
while (sc.hasNext()) {
String str = sc.nextLine();
String match = sc.nextLine();
System.out.println(getIndexOf(str, match));
}
sc.close();
}
private static int getIndexOf(String str, String match) {
if (str == null || match == null || match.length() < 1 || str.length() < match.length()) {
return -1;
}
char[] ss = str.toCharArray();
char[] ms = match.toCharArray();
int si = 0;
int mi = 0;
int[] next = getNextArray(ms);
while (si < ss.length && mi < ms.length) {
if (ss[si] == ms[mi]) {
si++;
mi++;
} else if (next[mi] == -1) {
si++;
} else {
mi = next[mi];
}
}
return mi == ms.length ? si - mi : -1;
}
private static int[] getNextArray(char[] ms) {
if (ms.length == 1) {
return new int[]{-1};
}
int[] next = new int[ms.length];
next[0] = -1;
next[1] = 0;
int pos = 2;
int cn = 0;
while (pos < next.length) {
if (ms[pos - 1] == ms[cn]) {
next[pos++] = ++cn;
} else if (cn > 0) {//没匹配上,cn还可以往前跳
cn = next[cn];
} else {//没配上,cn已经跳到了0的位置,不能往前跳了
next[pos++] = 0;
}
}
return next;
}
}
相似题目
给定一个字符串s,请计算输出含有连续两个s作为子串的最短字符串,注意这两个s可能有重叠部分,例如“ababa”包含两个“aba”。
输入描述:输入包括一个字符串s,字符串长度length(1<length<50),s中每个字符都是小写字母。
输出描述:输出一个字符串,即含有连续两个s作为子串的最短字符串。
输入样例:
abracadabra
输出样例:
abracadabracadabra
思考:题目中说是包含两个s作为子串,而且可以有重叠部分,所以说,如果没有重叠的部分,最后输出的便是最长的字符串,即两个s拼接在一起。如果有重叠的部分,也是仅限于s的前n位和s的后n位是一样的才可以。因此,我们可以用最简单的方式,把两个s当成s1和s2,从s1的第2(n)位往后开始和s2的第一位往后的每一个字符进行比对,如果直到s1的结果都是一致的,则最后输出的字符串便是s1的前1(n-1)个字符拼接s2.如果有不一致的,则从s1的第3(n+1)位开始重复比对,以此类推。
代码:
import java.util.Scanner;
public class JD_Code01 {
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
String str = sc.nextLine();
System.out.println(getString(str));
}
private static String getString(String str) {
if (str==null||str.length()==0){
return null;
}
char[] charArr = str.toCharArray();
for (int i = 1; i < charArr.length; i++) {
//每次从s1的第i位与s2的第0位开始比较
int j = 0;
int tempi = i;
//如果相等则继续比较s1和s2的下一位字符
while (charArr[j]==charArr[tempi]){
//如果到了s1的最后一位都是相等的,则返回最短字符串
if (tempi==charArr.length-1){
String res = str.substring(0,i);
return res+str;
}
j++;
tempi++;
}
}
//如果没有重复,则返回两个s拼接的字符串
return str+str;
}
}
Manacher算法
题目:给定一个字符串str,返回str中最长回文子串的长度。
例如:
str="123",其中的最长回文子串为“1”、“2”或者“3”,所以返回1.
str="abc1234321ab",其中最长回文子串为“1234321”,所以返回7.
进阶题目:给定一个字符串str,想通过添加字符的方式使得str整体都变成回文字符串,但要求只能在str的末尾添加字符,请返回在str后面添加的最短字符串。
例如:str="12"。在末尾添加“1”之后,str变成“121”,是回文串。在末尾添加“21”之后,str变成“1221”,也是回文串,但“1”是所有添加方案中最短的,所以返回“1”。
要求:如果str的长度为N,解决原问题和进阶问题的时间复杂度都达到O(N)。
思考:
使用Manacher算法解决原问题的过程:
- 因为奇回文和偶回文在判断时比较麻烦,所以对str进行处理,把每个字符开头、结尾和中间插入一个特殊字符‘#’来得到一个新的字符串数组,比如str="bcbaa",处理后为“#b#c#b#a#a#”,然后从每个字符左右扩出去的方式找最大回文子串就方便多了。通过这样的处理方式,最大回文子串无论是偶回文还是奇回文,都可以通过统一的“扩”过程找到,解决了差异性的问题。同时要说的是,这个特殊字符是什么无所谓,甚至可以是字符串中出现的字符,也不会影响最终的结果,就是一个辅助性的功能。
- 假设str处理之后的字符串记为charArr,对每个字符(包括特殊字符)都进行“优化后”的扩过程。首先解释如下三个辅助变量的意义。
- 数组pArr。长度与charArr长度一样。pArr[i]的意义是以i位置上的字符(charArr[i])作为回文中心的情况下,扩出去得到的最大回文半径是多少。
- 整数pR。这个变量的意义是之前遍历的所有字符的所有回文半径中,最右即将到达的位置。换句话说,pR就是遍历过的所有字符中向右扩出来的最大右边界。只要右边界更往右,pR就更新。
- 整数index。这个变量表示最近一次pR更新时,那个回文中心的位置。
- 只要能够从左到右依次算出数组pArr每个位置的值,最大的那个值实际上就是处理后的charArr中最大的回文半径,根据最大的回文半径,再对应回原字符串的话,整个问题就解决了。步骤3就是从左到右依次计算出pArr数组每个位置的值的过程。
- 假设现在计算到位置i的字符charArr[i],在i之前位置的计算过程中,都会不断地更新pR和index的值,即位置i之前的index这个回文中心扩出了一个目前最右的回文边界pR。
- 如果pR-1位置没有包住当前i位置,也就是说,右边界在1位置,1位置为最右回文半径即将到达但还没有达到的位置,所以当前的pR-1位置没有包住当前的i位置。此时和普通做法一样,从i位置字符开始,向左右两侧扩出去检查,此时的“扩”过程没有获得加速。
- 如果pR-1位置包住了当前的i位置。这种情况下,检查过程是可以获得优化的。这也是manacher算法的核心内容。位置i是要计算回文半径(pArr[i])的位置,pR-1位置此时是包住位置i的。同时根据index的定义,index是pR更新时那个回文中心的位置,所以如果pR-1位置以index为中心对称,即“左大”位置,那么从“左大”位置到pR-1位置一定是以index为中心的回文串,称之为大回文串,同时把pR-1位置称为“右大”位置。既然回文半径数组pArr是从左到右计算的,所以位置i之前的所有位置都已经算过回文半径。假设位置i以index为中心向左对称过去的位置为i',那么位置i'的回文半径也是计算过的。那么以i'为中心的最大回文串大小(pArr[i'])必然只有三种情况,假设以i’为中心的最大回文串的左边界和右边界分别记为“左小”和“右小”。
- 情况三:“左小”和“左大”是同一位置,即以i'为中心的最大回文串压在了以index为中心的最大回文串的边界上。
- 情况二:“左小”和“右小”的左侧部分在“左大”和“右大”的外部。
- 情况一:“左小”和“右小”完全在“左大”和“右大”内部,即以i’为中心的最大回文串完全在以index为中心的最大回文串的内部。
- 按照步骤3的逻辑从左到右计算出pArr数组,计算完成后再遍历一遍pArr数组,找出最大的回文半径,假设位置i的回文半径最大,即pArr[i] == max。但max只是charArr的最大回文半径,还得对应回原来的字符串,求出最大回文半径的长度(其实就是max-1)。
Manacher算法的时间复杂度为O(N)。关键之处在于估算扩出去检查这一行为发生的数量。原字符串在处理后的长度由N变为2N。要么在计算一个未知的回文半径时完全不需要扩出去检查,要么每一次扩出去检查都会导致pR变量的更新。扩出去检查时都让回文半径到达更右的位置,当然会使pR更新。然而pR最多是从-1增加到2N(右边界),并且从不减少,所以扩出去检查的次数就是O(N)级别的。所以Manacher算法的时间复杂度为O(N)。
代码:
package NowCoder2;
import java.util.Scanner;
public class Manacher {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
while (sc.hasNext()) {
String str = sc.nextLine();
System.out.println(maxLcpsLength(str));
}
sc.close();
}
private static int maxLcpsLength(String str) {
if (str == null || str.length() == 0) {
return 0;
}
char[] charArr = manacherString(str);// 12321--> #1#2#3#2#1#
int[] pArr = new int[charArr.length];
int index = -1;
int pR = -1;
int max = Integer.MIN_VALUE;
for (int i = 0; i != charArr.length; i++) {
pArr[i] = pR > i ? Math.min(pArr[2 * index - i], pR - i) : 1;
while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
pArr[i]++;
else {
break;
}
}
if (i + pArr[i] > pR) {
pR = i + pArr[i];
index = i;
}
max = Math.min(max, pArr[i]);
}
return max - 1;
}
private static char[] manacherString(String str) {
char[] charArr = str.toCharArray();
char[] res = new char[str.length() * 2 + 1];
int index = 0;
for (int i = 0; i != res.length; i++) {
res[i] = (i & 1) == 0 ? '#' : charArr[index++];
}
return res;
}
}
进阶问题:在字符串的最后添加最少字符串,使整个字符串都称为回文串,其实就是查找在必须包含最后一个字符的情况下,最长的回文子串是什么。那么之前不是最长回文子串的部分逆序过来,就是应该添加的部分。具体做法:从左到右计算回文半径时,关注回文半径最右即将到达的位置(pR),一旦发现已经到达最后(pR==charArr.length),说明必须包含最后一个字符的最长回文半径已经找到,直接退出检查过程,返回该添加的字符串即可。
/**
* 在字符串的最后添加最少字符,使整个字符串都成为回文串
* @param str
* @return
*/
private static String shortestEnd(String str) {
if (str == null || str.length() == 0) {
return null;
}
char[] charArr = manacherString(str);
int[] pArr = new int[charArr.length];
int index = -1;
int pR = -1;
int maxContainsEnd = -1;
for (int i = 0; i != charArr.length; i++) {
pArr[i] = pR > i ? Math.min(pArr[2 * index - i], pR - i) : i;
while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
pArr[i]++;
else {
break;
}
}
if (i + pArr[i] > pR) {
pR = i + pArr[i];
index = i;
}
if (pR == charArr.length) {
maxContainsEnd = pArr[i];
break;
}
}
char[] res = new char[str.length() - maxContainsEnd + 1];
for (int i = 0; i < res.length; i++) {
res[res.length - 1 - i] = charArr[i * 2 + 1];
}
return String.valueOf(res);
}