Manacher算法——查找一个字符串中的最长回文子串

目录

一、了解Manacher

二、方法一:暴力匹配

1.不适用于偶数回文串

2.时间复杂度O(n2n2)

二、方法二:Manacher

1.Manacher对字符串的预处理

2.Manacher算法核心

3.时间复杂度

4.实现代码


一、了解Manacher

        Manacher算法是一个用来查找一个字符串中的最长回文子串(不是最长回文序列)的线性算法。它的优点就是把时间复杂度为O(n2n2)的暴力算法优化到了O(n)。首先先让我们来看看最原始的暴力扩展,分析其存在的弊端,以此来更好的理解Manacher算法。

二、方法一:暴力匹配

        暴力匹配算法的原理很简单,就是从原字符串的首部开始,依次向尾部进行遍历,每访问一个字符,就以此字符为中心向两边扩展,记录该点的最长回文长度。那么我们可以想想,这样做存在什么弊端,是不是可以求出真正的最长回文子串?

答案是显然不行的,我们从两个角度来分析这个算法

1.不适用于偶数回文串

        我们举两个字符串做例子,它们分别是 "aba","abba",我们通过肉眼可以观察出,它们对应的最长回文子串长度分别是3和4,然而我们要是用暴力匹配的方法去对这两个字符串进行操作就会发现,"aba" 对应的最长回文长是 "131","abba" 对应的最长回文长度是 "1111",我们对奇数回文串求出了正确答案,但是在偶数回文串上并没有得到我们想要的结果,通过多次测试我们发现,这种暴力匹配的方法不适用于偶数回文串

2.时间复杂度O(n2n2)

        这里的时间复杂度是一个平均时间复杂度,并不代表每一个字符串都是这个复杂度,但因为每到一个新位置就需要向两边扩展比对,所以平均下来时间复杂度达到了O(n*n)。

二、方法二:Manacher

        Manacher算法本质上也是基于暴力匹配的方法,只不过做了一点简单的预处理,且在扩展时提供了加速

1.Manacher对字符串的预处理

        我们知道暴力匹配是无法解决偶数回文串的,可Manacher算法也是一种基于暴力匹配的算法,那它是怎么来实现暴力匹配且又不出错的呢?它用来应对偶数字符串的方法就是——做出预处理,这个预处理可以巧妙的让所有字符串都变为奇数回文串,不论它原本是什么。操作实现也很简单,就是将原字符串的首部和尾部以及每两个字符之间插入一个特殊字符,这个字符是什么不重要,不会影响最终的结果(具体原因会在后面说),这一步预处理操作后的效果就是原字符串的长度从n改变成了2*n+1,也就得到了我们需要的可以去做暴力扩展的字符串,并且从预处理后的字符串得到的最长回文字符串的长度除以2就是原字符串的最长回文子串长度,也就是我们想要得到的结果。

        这里解释一下为什么预处理后不会影响对字符串的扩展匹配

        比如我们的原字符串是 "aa",假设预处理后的字符串是 "#a#a#",我们在任意一个点,比如字符 '#',向两端匹配只会出现 'a' 匹配 'a','#' 匹配 '#' 的情况,不会出现原字符串字符与特殊字符匹配的情况,这样就能保证我们不会改变原字符串的匹配规则。通过这个例子,你也可以发现实际得到的结果与上述符合。

2.Manacher算法核心

        Manacher算法的核心部分在于它巧妙的令人惊叹的加速,这个加速一下把时间复杂度提升到了线性,让我们从暴力的算法中解脱出来,我们先引入概念,再说流程,最后提供实现代码。

概念:

        ManacherString:经过Manacher预处理的字符串,以下的概念都是基于ManasherString产生的。

        回文半径和回文直径:因为处理后回文字符串的长度一定是奇数,所以回文半径是包括回文中心在内的回文子串的一半的长度,回文直径则是回文半径的2倍减1。比如对于字符串 "aba",在字符 'b' 处的回文半径就是2,回文直径就是3。

        最右回文边界R:在遍历字符串时,每个字符遍历出的最长回文子串都会有个右边界,而R则是所有已知右边界中最靠右的位置,也就是说R的值是只增不减的。

        回文中心C:取得当前R的第一次更新时的回文中心。由此可见R和C时伴生的。

        半径数组:这个数组记录了原字符串中每一个字符对应的最长回文半径。

流程:

步骤1:预处理原字符串

        先对原字符串进行预处理,预处理后得到一个新的字符串,这里我们称为S,为了更直观明了的让大家理解Manacher的流程操作,我们在下文的S中不显示特殊字符(这样并不影响结果)。

步骤2:R和C的初始值为-1,创建半径数组pArr

        这里有点与概念相差的小偏差,就是R实际是最右边界位置的右一位。

步骤3:开始从下标 i = 0去遍历字符串S

        分支1:i > R ,也就是i在R外,此时没有什么花里胡哨的方法,直接暴力匹配,此时记得看看C和R要不要更新。

        

        分支2 :i <= R,也就是i在R内,此时分三种情况,在讨论这三个情况前,我们先构建一个模型

        L是当前R关于C的对称点,i'是i关于C的对称点,可知 i' = 2*C - i,并且我们会发现,i'的回文区域是我们已经求过的,从这里我们就可以开始判断是不是可以进行加速处理了

                情况1:i'的回文区域在L-R的内部,此时i的回文直径与 i' 相同,我们可以直接得到i的回文半径,下面给出证明

        红线部分是 i' 的回文区域,因为整个L-R就是一个回文串,回文中心是C,所以i形成的回文区域和i'形成的回文区域是关于C对称的。

                情况2:i'的回文区域左边界超过了L,此时i的回文半径则是i到R,下面给出证明

        首先我们设L点关于i'对称的点为L',R点关于i点对称的点为R',L的前一个字符为x,L’的后一个字符为y,k和z同理,此时我们知道L - L'是i'回文区域内的一段回文串,故可知R’ - R也是回文串,因为L - R是一个大回文串。所以我们得到了一系列关系,x = y,y = k,x != z,所以 k != z。这样就可以验证出i点的回文半径是i - R。

                情况3:i' 的回文区域左边界恰好和L重合,此时i的回文半径最少是i到R,回文区域从R继续向外部匹配,下面给出证明

        因为 i' 的回文左边界和L重合,所以已知的i的回文半径就和i'的一样了,我们设i的回文区域右边界的下一个字符是y,i的回文区域左边界的上一个字符是x,现在我们只需要从x和y的位置开始暴力匹配,看是否能把i的回文区域扩大即可。

        总结一下,Manacher算法的具体流程就是先匹配 -> 通过判断i与R的关系进行不同的分支操作 -> 继续遍历直到遍历完整个字符串

3.时间复杂度

我们可以计算出时间复杂度为何是线性的,分支一的情况下时间时间复杂度是O(n),分支二的前两种情况都是O(1),分支二的第三种情况,我们可能会出现O(1)——无法从R继续向后匹配,也可能出现O(n)——可以从R继续匹配,即使可以继续匹配,R的值也会增大,这样会影响到后续的遍历匹配复杂度,所以综合起来整个算法的时间复杂度就是线性的,也就是O(n)。

4.实现代码

public class Manacher {

	
	//	1) i在r外		暴力
	//				2.1)回文区间在R内	pArr[2*c-i]
	//	2) i在r内		2.2)回文区间在R外	R-i
	//				2.3)回文区间在R边界	暴力
	public static int manacher(String s) {
		if (s == null || s.length() == 0) {
			return 0;
		}
		// "12132" -> "#1#2#1#3#2#"
		char[] str = manacherString(s);
		// 回文半径的大小
		int[] pArr = new int[str.length];
		int C = -1;
		// 讲述中:R代表最右的扩成功的位置
		// coding:R是最右的扩成功位置的,再下一个位置
		int R = -1;  
		int max = Integer.MIN_VALUE;//回文半径
		for (int i = 0; i < str.length; i++) { // 0 1 2
			
			// R第一个违规的位置,i>= R
			// i位置扩出来的答案,i位置扩的区域,至少是多大。
			pArr[i] = R > i ? Math.min(pArr[2 * C - i], R - i) : 1;
			while (i + pArr[i] < str.length && i - pArr[i] > -1) {
				if (str[i + pArr[i]] == str[i - pArr[i]])
					pArr[i]++;
				else {
					break;
				}
			}
			
			if (i + pArr[i] > R) {
				R = i + pArr[i];
				C = i;
			}
			
			max = Math.max(max, pArr[i]);
		}
		return max - 1;
	}

	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;
	}

	// for test
	public static int right(String s) {
		if (s == null || s.length() == 0) {
			return 0;
		}
		char[] str = manacherString(s);
		int max = 0;
		for (int i = 0; i < str.length; i++) {
			int L = i - 1;
			int R = i + 1;
			while (L >= 0 && R < str.length && str[L] == str[R]) {
				L--;
				R++;
			}
			max = Math.max(max, R - L - 1);
		}
		return max / 2;
	}

	// for test
	public static String getRandomString(int possibilities, int size) {
		char[] ans = new char[(int) (Math.random() * size) + 1];
		for (int i = 0; i < ans.length; i++) {
			ans[i] = (char) ((int) (Math.random() * possibilities) + 'a');
		}
		return String.valueOf(ans);
	}

	public static void main(String[] args) {
		int possibilities = 5;
		int strSize = 20;
		int testTimes = 5000000;
		System.out.println("test begin");
		for (int i = 0; i < testTimes; i++) {
			String str = getRandomString(possibilities, strSize);
			if (manacher(str) != right(str)) {
				System.out.println("Oops!");
			}
		}
		System.out.println("test finish");
	}

}

  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
### 回答1: 回文子串是指正着读和倒着读都一样的字符串。要找出一个字符串最长的回文子串。 解决这个问题的方法有很多,其一种比较简单的方法是暴力枚举。具体来说,我们可以枚举所有可能的子串,然后判断每个子串是否是回文串,最后找出最长的回文子串。 具体实现时,我们可以从字符串的第一个字符开始,依次枚举所有可能的子串。对于每个子串,我们可以使用双指针法来判断它是否是回文串。具体来说,我们可以使用两个指针分别指向子串的首尾字符,然后依次向间移动,判断对应的字符是否相等。如果所有字符都相等,那么这个子串就是回文串。 在判断完所有子串之后,我们就可以找出最长的回文子串了。具体来说,我们可以用一个变量来记录当前找到的最长回文子串的长度,以及它的起始位置。在枚举所有子串的过程,如果找到了一个更长的回文子串,那么就更新这个变量。 需要注意的是,由于字符串可能存在偶数长度的回文子串,因此我们需要分别枚举以每个字符为心的奇数长度回文子串和以每两个相邻字符为心的偶数长度回文子串。 这种暴力枚举的方法时间复杂度为O(n^3),其n是字符串的长度。虽然时间复杂度比较高,但是这种方法比较简单易懂,可以作为其他更高效的算法的基础。 ### 回答2: 回文子串是指正着读和倒着读一样的字符串。例如,'level'和'noon'就是回文子串。编写程序来寻找给定字符串最长的回文子串。 一种常见的方法是暴力枚举字符串的所有子串并检查它们是否是回文。这种方法的时间复杂度为O(n^3),不适用于长字符串。另一种常见的方法是动态规划。这种方法的时间复杂度为O(n^2),适用于较长的字符串。 动态规划方法的主要思路如下: 1.定义状态:dp[i][j]表示从i到j的子串是否为回文。 2.初始化状态:对于所有i,dp[i][i]都是true。 3.状态转移:当s[i] = s[j]时,如果dp[i+1][j-1]是true,那么dp[i][j]也是true。 4.记录最长回文子串的起始位置和长度:遍历dp数组,找到值为true且长度最大的子串即可。 下面是一个Python实现的例子: ```python def longest_palindrome(s:str) -> str: if not s: # 处理输入为空的情况 return "" n = len(s) dp = [[False] * n for _ in range(n)] # 初始化dp数组 start, max_len = 0, 1 # 记录最长回文子串的起始位置和长度 for i in range(n): # 初始化对角线上的状态 dp[i][i] = True for j in range(1, n): for i in range(0, j): if s[i] == s[j]: if j - i < 3: # 特判长度小于3的情况 dp[i][j] = True else: dp[i][j] = dp[i+1][j-1] else: dp[i][j] = False if dp[i][j]: # 记录最长回文子串的起始位置和长度 if j - i + 1 > max_len: max_len = j - i + 1 start = i return s[start:start+max_len] ``` 该算法的时间复杂度为O(n^2),空间复杂度也为O(n^2)。 ### 回答3: 回文串指正着读和倒着读都一样的字符串,例如 "level", "noon" 等。在一个字符串,可能不存在回文子串,也可能存在多个长度相同的回文子串。现在给定一个字符串,需要我们出其最长的回文子串。 解决此问题,可以用动态规划算法。假设字符串的长度为 n,定义一个二维数组 dp (n * n),其 dp[i][j] 表示从 i 到 j 这一段字符串是否为回文串。当字符串为空或长度为 1 时,皆为回文串(即 dp[i][i] = true)。当字符串长度大于等于 2 时,若第 i 个字符和第 j 个字符相等,且从 i + 1 到 j - 1 的字符串也是回文串,那么从 i 到 j 的字符串也是回文串(即 dp[i][j] = dp[i + 1][j - 1] && s[i] == s[j])。此外,记录回文子串的起始位置和长度,最终找到最长的回文子串即可。 以下是 Python 代码实现: ``` class Solution: def longestPalindrome(self, s: str) -> str: n = len(s) dp = [[False] * n for _ in range(n)] res = "" for i in range(n - 1, -1, -1): for j in range(i, n): dp[i][j] = s[i] == s[j] and (j - i < 3 or dp[i + 1][j - 1]) if dp[i][j] and j - i + 1 > len(res): res = s[i:j+1] return res ``` 时间复杂度为 O(n^2),空间复杂度为 O(n^2)。总的来说,动态规划算法是解决最长回文子串问题的一种行之有效的方法。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值