算法学习10: 字符串算法

算法学习10: 字符串算法

KMP算法

KMP算法用来解决字符串匹配问题: 找到长串短串出现的位置.

KMP算法思路

暴力比较与KMP的区别

普通算法: 对长串的每个位都从头开始匹配短串的所有位
KMP算法: 将短字符串前后相同的部分存储在next数组里,让前边匹配过的信息指导后边

next数组求法

next数组体现字符串某位前边子串最长匹配前缀最长匹配后缀的匹配长度(可以有交叉),next[i]表示第i位前边的最长匹配后缀对应的最长匹配前缀的后一位.
next数组的求法用到了递推思想:

  1. 第0位前边没有匹配前后缀,因此next[0]=-1
  2. 第1位前缀字符串的长度为1,若该位匹配不上,只能去匹配首位了,因此next[1]=0
  3. next[1:i]已经确定,则next[i+1]可以确定出来,比较方法:
    1. 不断去寻找已经匹配好前后缀的部分,若找到某个匹配前缀的后一位与本位前一位相同,则最长匹配前后缀的长度加一
    2. 若一直寻找到短串首位也没有匹配上,则说明没有匹配前后缀,因此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

步骤:

  1. 短串计算出next数组.
  2. 长串进行匹配:比较长串第i位短串第j位进行比较匹配(初始时i=j=0)
    1. 长串第i位短串第j位相等,则这两位匹配了,i,j都向后移动一位,即i+=1,j==1.
    2. 长串第i位短串第j位不匹配 且 短串前边的所有位都能匹配,为避免重复比较,我们要去找短串第j位前边最长匹配后缀对应的最长匹配前缀.则将短串向后推,将长串第i位短串第next[j]位进行匹配,即j=next[j]
    3. 短串被推至第一位,即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. 回文半径: 以该字符位为中心的回文串的长度
  2. 回文右边界: 所有回文半径中,回文串到达的最右的位置
    初始时为-1,表示还没有找到任何回文串
  3. 回文右边界中心: 第一次取得回文右边界的中心(就是最左的回文右边界中心)

manacher算法步骤

从第一位开始,遍历字符串的所有位.初始时回文右边界为-1.
当前遍历到第i位,由以下两种情况:

  1. i不在回文右边界以内: 以该位为中心向两边暴力扩展回文串
  2. i回文右边界以内: 找到回文右边界中心c,对应的回文左边界,以及i关于回文右边界中心的对称点i'.这样i'回文半径可以用来指导i回文半径.
    1. i'回文串关于c的对称串在回文右边界以内,说明i回文半径i'回文半径相同.
    2. 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匹配直到回文右边界到达字符串末尾

    原文作者:ncepu_Chen
    原文地址: https://blog.csdn.net/ncepu_Chen/article/details/88866664
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞