算法学习10:字符串算法(KMP算法和Manacher算法)
KMP算法
KMP算法解决的问题
KMP算法用来解决字符串匹配问题: 找到长串
中短串
出现的位置.
KMP算法思路
暴力比较与KMP的区别
-
暴力匹配: 对长串的每个位,都从头开始匹配短串的所有位.
-
KMP算法: 将短字符串前后相同的部分存储在
next
数组里,让之前匹配过的信息指导之后的匹配.
next
数组及其求法
next
数组
next
数组体现字符串某位前边子串最长匹配前缀
和最长匹配后缀
的匹配长度(可以有交叉).next[i]
表示第i
位前边的最长匹配后缀
对应的最长匹配前缀
的后一位.
下面是一个next
数组的例子:
下标索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
字符 | a | b | a | b | a | b | c | a |
next数组对应位值 | -1 | 0 | 0 | 1 | 2 | 3 | 4 | 0 |
next
数组的求法
next
数组的求法用到了递推思想:
-
第
0
位前边没有匹配前后缀
,因此next[0]=-1
-
第
1
位前缀字符串
的长度为1,因此next[1]=0
-
站在第
pos
位上,就要考虑以第i-1
位结尾的最长匹配(也就是不断地求nums[pos-1]
与next[next[...next[pos-1]]]
之间的关系):
- 将
cn
指针指向前一个匹配后缀的下一位,即cn=next[pos-1]
.- 若前一个匹配后缀的下一位字符刚好与待匹配字符相同,即
ms[cn]==ms[pos-1]
,则更新next
数组,将next[pos]
指向cn
的下一位next[pos]=cn+1
. - 若前一个匹配后缀的下一位字符与待匹配字符不同,即
ms[cn]!=ms[pos-1]
,则应该找前一个匹配数组,即cn=next[cn]
.
- 若前一个匹配后缀的下一位字符刚好与待匹配字符相同,即
- 若最终找到
cn==-1
也没找到,则pos-1
位实在没有匹配后缀了,将其next
数组置0,即next[pos]=0
.
// 计算ms字符串的next[]数组
public static int[] getNextArray(char[] ms) {
if (ms.length == 1) {
return new int[] { -1 };
}
int[] next = new int[ms.length];
next[0] = -1; // 第0位前边没有前缀字符串,因此返回-1
next[1] = 0; // 第1位前缀字符串的长度为1,若该位匹配不上则只能去匹配首位,因此返回0
// 从第2位开始,递推出后面位的next[]值
for (int pos = 2; pos < ms.length; pos++) {
// 一直向前找前边位的匹配前缀且该前缀的后一位与本位相同
int cn = next[pos - 1];
while (cn != -1 && ms[cn] != ms[pos-1]) {
// 将前缀的后一位与当前位的前一位进行对比
// 注意容易写错,花了两个小时排查这个玩意
cn = next[cn];
}
// 判断是否能找到匹配前缀
//if (cn != -1) {
// // 若能找到匹配前后缀,则返回匹配前缀的后一位
// next[pos] = cn + 1;
//} else {
// // 若找不到匹配前后缀,返回-1
// next[pos] = 0;
//}
// 上述判断语句可以简化为一句
next[pos] = cn + 1;
}
return next;
}
在写入
next
数组的pos
位时要注意: 匹配前缀延伸的时候,要判断的是ms[cn]
与ms[pos-1]
之间是否相等(因为
next
数组保存的是本位前一位的匹配前缀的下一位
,因此ms[cn]
指向匹配前缀的下一位
,ms[pos-1]
指向上次计算未进行匹配的位
)
KMP算法匹配步骤leetcode 28
算法步骤:
-
对
短串
计算出next
数组. -
对
长串
进行匹配:比较长串第i位
与短串第j位
进行比较匹配(初始时i=j=0
).- 若
长串第i位
与短串第j位
相等,则这两位匹配了,i
,j
都向后移动一位,即i+=1,j==1
. - 若
长串第i位
和短串第j位
不匹配 且短串
前边的所有位都能匹配,为避免重复比较,我们要去找短串第j
位前边最长匹配后缀
对应的最长匹配前缀
.则将短串
向后推,将长串第i位
和短串第next[j]位
进行匹配,即j=next[j]
. - 若
短串
被推至第一位,即next[j]==-1
,可以说明长串第i位
不可能匹配上了,因此放弃长串
这一位,转而寻求长串下一位
,即i+=1
.
- 若
public static int getIndexOf(String s, String m) {
if (s == null || m == null || m.length() < 1 || s.length() < m.length()) {
return -1;
}
char[] ss = s.toCharArray();
char[] ms = m.toCharArray();
int[] next = getNextArray(ms);
int si = 0; // 长串指针
int mi = 0;// 短串指针
// 两个指针分别从字符串首位开始,遍历字符串1并进行匹配
while (si < ss.length && mi < ms.length) {
if (ss[si] == ms[mi]) {
// 若两指针指向字符相等,则匹配上了一位,两指针分别滑向下一位
si++;
mi++;
} else if (next[mi] != -1) {
// 两指针指向字符不相等,但短串指针之前部分是匹配好的
// 因此短串指针指向其上一个其匹配前缀,即next[i]
// 这里要注意是next[mi]!=-1而不是mi!=-1,因为mi若为-1则发生索引错误,因此mi退到0就退无可退了
mi = next[mi];
} else {
// 短串指针指向了首位也没匹配上,则此时只能将长串指针向后移动一位了
si++;
}
}
// 若子串已经被遍历完了,说明其每一位都能匹配上了,否则就没匹配上
return (mi == ms.length) ? (si - mi) : -1;
}
证明匹配过程的正确性: 反证法
若匹配不成功,将短串向后移动至next[i]
位,这本质上否定了中间区域匹配成功的可能性.其正确性可以用反证法来证明:
若后推小于i-next[i]
位就能匹配上,那么第i
位的最长匹配前后缀应该更长.
应用
应用1: 倍增字符串
题目: 在原字符串后添加尽量少个字符,使得生成的新字符串包含两个等于原字符串
的子串(可以重叠).
如: 原字符串为abcabc
,在其后添加abc
,构成abcabcabc
,其包含两个abcabc
子串
解法: 本质上就是求整个字符串的最长匹配前后缀
,因此构造扩展next数组
,计算到第字符串长度
位. 然后根据整个字符串的最长匹配前后缀
补位.
应用2: 扩充成回文串leetcode 214
问题: 给定一个字符串,在其前边添加尽量少个字符使之成为回文串.
解法: 求出原字符串的逆序串
,将逆序串
与原字符串
进行KMP匹配,匹配结束后两字符串遍历指针
勾勒出了逆序串
与原字符串
的最长匹配部分
,将最长匹配部分
之外的位进行扩充生成新字符串.
应用3: 重复子字符串leetcode 459
问题: 给定一个非空的字符串,判断它是否可以由它的一个子串重复多次构成.
解法: 先构造因此构造扩展next数组
,计算到第字符串长度
位.
- 若
next[s.length()]
为0,说明整个字符串都没有最长匹配前后缀
,则其必然不是由一个字串多次重复而成 - 若
next[s.length()]
不为0,则计算delta = s.length() - next[s.length()]
, 若delta
能整除s.length()
,则说明前后缀错开delta
位也能匹配上,这说明该字符串必然能被分为多个重复子字符串.
应用4: 寻找匹配子树
题目: 给出两棵树,判断其中一棵树是否是另一棵树的子树
解法: 将两棵树分别序列化成字符串,然后根据KMP算法查找子串
Manacher算法
Manacher算法解决的问题
Manacher算法用来寻找字符串的最长回文子串
.
Manacher算法思路
暴力查找方法
- 回文串按照长度的奇偶性分为两种:
奇回文
和偶回文
,奇回文
: 好判断,以某个字符为对称轴向两边延展,找到最长回文子串.偶回文
: 不好判断,其对称中心并不是某个具体字符位.
- 其中
奇回文
比较好辨认,因此我们将字符串的所有偶回文
都改成奇回文
. - 具体做法: 向整个字符串当中每两个字符之间加一个
标志位
,得到扩展串
.以扩展串每一位为中心,向两边扩展,查找回文. 这样找到的所有回文
都是奇回文
,将(回文长度-1)/2
得到真实回文长度
.
中间加入的
标志位
字符不一定
非得是没出现过的字符,因为在扩展串中,虚轴永远只会和虚轴比对,实轴永远只和实轴比对,不会产生混淆.
回文串的相关概念
-
回文右边界
: 当前所有回文串中,回文右边界所能到达的最右位置. -
回文右边界中心
: 以回文右边界
结尾的最长回文串的回文中心.
manacher算法步骤
从第一位开始,遍历字符串的所有位.初始时回文右边界
为-1
.
当遍历到第pos
位时,有以下两种情况:
-
pos
不在回文右边界rightBorder
以内(pos > rightBorder
): 以该位为中心向两边暴力扩展回文串. -
pos
在回文右边界rightBorder
以内(pos <= rightBorder
): 找到回文右边界中心rightCenter
,对应的回文左边界
,以及pos
关于回文右边界中心
的对称点pos'
.这样pos'
的回文半径
可以用来指导pos
的回文半径
.-
若
pos'
回文串关于rightCenter
的对称串在回文右边界
以内(pos + curRadius[pos] -1 <= rightBorder
),说明pos
的回文半径
与pos'
的回文半径
相同. -
若
pos'
回文串关于rightCenter
的对称串在回文右边界
以外(pos + curRadius[pos] -1 > rightBorder
),由对称性可知,对称串
超出回文右边界
的部分一定不能构成以pos
为中心的回文.因此pos
的回文半径即为pos
到回文右边界
之间.
-
在遍历过程中维护的
回文右边界串
并不一定是最长回文子串
,如上面演示图中第一种情况为例: 遍历结束时,回文右边界串
为"abcba"
,最长回文子串为"abcbadabcba"
.
代码实现leetcode 5
// 在字符串中间加标志位,将所有 偶回文 变为 奇回文
public 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;
}
// 返回最长回文长度
public int maxLcpsLength(String s) {
char[] charArr = manacherString(s); // 扩展字符串
int[] curRadius = new int[charArr.length]; // 回文半径数组,记录以每一位为中心的回文半径
Arrays.fill(curRadius, 1);
int rightBorder = -1; // 回文右边界,初始时为-1,表示没找到任何回文串
int rightCenter = -1; // 回文右边界中心
int maxRadius = -1; // 最长回文半径长度
// 依次遍历所有位,计算其回文半径
for (int pos = 0; pos != charArr.length; pos++) {
// 判断该位是否位于 `回文右边界` 以内:
// 若 `当前位置` 在 `回文右边界` 之外, 则暴力搜索,向两侧扩展计算其回文半径
// 若 `当前位置` 在 `回文右边界` 之内, 则根据其 关于回文右边界中心的对称位 来计算其回文半径
if (pos <= rightBorder) {
// 只保证 回文右边界串 之内的部分的回文性,不保证 回文右边界串 之外的回文性
curRadius[pos] = Math.min(curRadius[2 * rightCenter - pos], rightBorder-pos+1);
}
// 暴力扩展回文半径(若 `当前位置` 在 `回文右边界` 之内,也扩不动,刚进入循环就跳出了)
while ((pos + curRadius[pos] < charArr.length) && (pos - curRadius[pos] > -1)) {
if (charArr[pos + curRadius[pos]] == charArr[pos - curRadius[pos]]) {
curRadius[pos]++;
} else {
break;
}
}
// 刷新 回文边界,回文边界中心,以及最大回文半径(注意回文右边界串未必是最长回文子串)
if (pos + curRadius[pos] > rightBorder) {
rightBorder = pos + curRadius[pos] - 1;
rightCenter = pos;
}
maxRadius = Math.max(maxRadius, curRadius[pos]);
}
// `扩展串`的`回文半径`正好是`真实回文长度+1`
return maxRadius - 1;
}
应用:
应用1: 扩充成回文串leetcode 214
问题: 给定一个字符串,在其前边添加尽量少个字符使之成为回文串.
解法: 进行Manacher
匹配,直到回文右边界
到达字符串末尾.