算法学习10: 字符串算法
KMP算法
KMP算法用来解决字符串匹配问题: 找到长串
中短串
出现的位置.
KMP算法思路
暴力比较与KMP的区别
普通算法: 对长串的每个位都从头开始匹配短串的所有位
KMP算法: 将短字符串前后相同的部分存储在next
数组里,让前边匹配过的信息指导后边
next
数组求法
next
数组体现字符串某位前边子串最长匹配前缀
和最长匹配后缀
的匹配长度(可以有交叉),next[i]
表示第i
位前边的最长匹配后缀
对应的最长匹配前缀
的后一位.
next
数组的求法用到了递推思想:
- 第0位前边没有
匹配前后缀
,因此next[0]=-1
- 第1位
前缀字符串
的长度为1,若该位匹配不上,只能去匹配首位了,因此next[1]=0
- 若
next[1:i]
已经确定,则next[i+1]
可以确定出来,比较方法:- 不断去寻找已经匹配好
前后缀
的部分,若找到某个匹配前缀的后一位与本位前一位相同,则最长匹配前后缀
的长度加一 - 若一直寻找到
短串首位
也没有匹配上,则说明没有匹配前后缀
,因此next[i+1]=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) { // // 若找不到匹配前后缀,返回-1 // next[pos] = cn + 1; //} else { // // 若能找到匹配前后缀,则返回匹配前缀的后一位 // 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
位的最长匹配前后缀应该更长.
应用
倍增字符串
题目: 在原字符串后添加尽量少个字符,使得生成的新字符串包含两个等于原字符串
的子串(可以重叠).
如: 原字符串为abcabc
,在其后添加abc
,构成abcabcabc
,其包含两个abcabc
子串
解法: 本质上就是求整个字符串的最长匹配前后缀
,因此构造扩展next数组
,计算到第字符串长度
位. 然后根据整个字符串的最长匹配前后缀
补位.
扩充成回文串leetcode 214
问题: 给定一个字符串,在其前边添加尽量少个字符使之成为回文串.
解法: 求出原字符串的逆序串
,将逆序串
与原字符串
进行KMP匹配,匹配结束后两字符串遍历指针
勾勒出了逆序串
与原字符串
的最长匹配部分
,将最长匹配部分
只玩的位进行扩充生成新字符串.
重复子字符串leetcode 459
问题: 给定一个非空的字符串,判断它是否可以由它的一个子串重复多次构成.
解法: 先构造因此构造扩展next数组
,计算到第字符串长度
位.
– 若next[s.length()]
为0,说明整个字符串都没有最长匹配前后缀
,则其必然不是由一个字串多次重复而成
– 若next[s.length()]
不为0,则计算delta = s.length() - next[s.length()]
, 若delta
能整除s.length()
,则说明前后缀错开delta
位也能匹配上,这说明该字符串必然能被分为多个重复子字符串.
寻找匹配子树
题目: 给出两棵树,判断其中一棵树是否是另一棵树的子树
解法: 将两棵树分别序列化成字符串,然后根据KMP算法查找子串
Manacher算法
Manacher算法用来寻找字符串的最长回文子串
Manacher算法思路
暴力解法
- 按照回文串长度的奇偶性分为两种:
奇回文
和偶回文
,奇回文
: 好判断,以某个字符为对称轴向两边延展,找到最长回文子串偶回文
: 不好判断,其对称中心并不是某个具体字符位
- 其中
奇回文
比较好辨认,因此我们将字符串的所有偶回文
都改成奇回文
- 具体做法: 向整个字符串当中每两个字符之间加一个
标志位
,得到扩展串
.以扩展串每一位为中心,向两边扩展,查找回文. 这样找到的所有回文
都是奇回文
,将(回文长度-1)/2
得到真实回文长度
中间加入的
标志位
字符不一定
非得是没出现过的字符,因为在扩展串中,虚轴永远只会和虚轴比对,实轴永远只和实轴比对,不会产生混淆.
扩展串
的回文半径
正好是真实回文长度+1
回文串的相关概念
回文半径
: 以该字符位为中心的回文串的长度回文右边界
: 所有回文半径中,回文串到达的最右的位置
初始时为-1,表示还没有找到任何回文串回文右边界中心
: 第一次取得回文右边界
的中心(就是最左的回文右边界中心)
manacher算法步骤
从第一位开始,遍历字符串的所有位.初始时回文右边界
为-1.
当前遍历到第i
位,由以下两种情况:
i
不在回文右边界
以内: 以该位为中心向两边暴力扩展回文串i
在回文右边界
以内: 找到回文右边界中心c
,对应的回文左边界
,以及i
关于回文右边界中心
的对称点i'
.这样i'
的回文半径
可以用来指导i
的回文半径
.- 若
i'
回文串关于c
的对称串在回文右边界
以内,说明i
的回文半径
与i'
的回文半径
相同. - 若
i'
回文串关于c
的对称串在回文右边界
以外,由对称性可知,对称串
超出回文右边界
的部分一定不能构成以i
为中心的回文.因此i
的回文半径即为i
到回文右边界
之间.
- 若
代码实现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 static int maxLcpsLength(String str) {
if (str == null || str.length() == 0) {
return 0;
}
char[] charArr = manacherString(str); // 扩展串
int[] pArr = new int[charArr.length]; // 回文半径数组,记录以每一位欸为中心的回文半径
int pR = -1; // 最右回文边界,初始时为-1,表示没找到任何回文串
int index = -1; // 最右回文边界中心
int max = Integer.MIN_VALUE;
// 依次遍历所有位,找到其回文半径
// 若该位位于 回文右边界 以外,则暴力扩展,计算其回文半径
// 若该位位于 回文右边界 以内,则根据其 关于回文中心对称串 来计算其回文半径
for (int i = 0; i != charArr.length; i++) {
// 判断该位是否位于 最右回文边界 以内
// 若在 最右回文右边界 以内,则可以直接判断其回文半径
// 若在 最右回文右边界 以外,则将其回文半径设置为1,然后在下一步暴力扩展
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.max(max, pArr[i]);
}
// `扩展串`的`回文半径`正好是`真实回文长度+1`
return max - 1;
}
应用:
扩充成回文串leetcode 214
问题: 给定一个字符串,在其前边添加尽量少个字符使之成为回文串.
解法: 进行Manacher
匹配直到回文右边界
到达字符串末尾