动态规划、马拉车算法
题目描述
给你一个字符串 s
,找到 s
中最长的回文子串。
示例 1:
输入: s = "babad"
输出: "bab"
解释: "aba" 同样是符合题意的答案。
示例 2:
输入: s = "cbbd"
输出: "bb"
示例 3:
输入: s = "a"
输出: "a"
示例 4:
输入: s = "ac"
输出: "a"
提示:
1 <= s.length <= 1000
-
s
仅由数字和英文字母(大写和/或小写)组成
思路
这是一个经典的算法问题,解决该问题的方法有很多。典型的解法有双指针动态规划,中心求臂展解法,以及臂展解法的进阶解法马拉车算法。
三种解法的复杂度如下表。(设 n = s.length
)
解决方法 | 时间复杂度 | 空间复杂度 |
---|---|---|
普通动态规划 | O(n^2) | O(n^2) |
臂展计算法 | O(n^2) | O(1) |
拉马车算法 | O(n) | O(n) |
下面分别介绍下三种算法的解题思路。
普通动态规划解法
由于本问题要求的回文子串是一个子字符串,我们很自然的就会用s[i...j]
定义该子串,其中j >= i
。
那么,进一步思考的问题就是,如何判断s[i...j]
是一个子串?
可以分三步来分析。
- 当子串长度为
1
,即j - i == 0
时,因为只有一个字符,该子串必然是回文子串。 - 当子串长度为
2
,即j - i == 1
时,显然只要s[i] == s[j]
,该子串则为回文子串,否则就不是回文子串。 - 当子串长度大于
2
,即j - i > 1
时,要考察子串是否为回文子串,则需要进行如下考察。
针对情况3
的考察。
- 如果
s[i] != s[j]
,则该子串必然不是回文子串。 - 如果
s[i] == s[j]
,则该子串是否是回文子串,取决于s[i+1...j-1]
是否是回文子串。
经过以上的分析,我们成功的把该问题转换成了一个动态规划问题。
我们还可以对长度为1
和2
的边界条件做进一步优化,可以假定当字符串为空时,我们认为空字符串是特殊的回文子串。这么一来,情况2
就可以被合并到情况3
里。
将以上思路用代码进行表达。可以通过这样一个编码思路:
- 通过循环来遍历回文子串。
- 为了保证能通过动态规划缓存上一次的计算结果,我们先从长度为
2
的回文子串开始进行查找(长度为1
没必要查找),一直查到长度为n
为止。 - 当我们查找长度为
l
的子串时,由于循环迭代的关系,我们已经找过长度小于l
的子串计算结果了,这样就可以通过读取上一次的计算结果进行本次计算。
代码如下。
class Solution {
func longestPalindrome(_ s: String) -> String {
let cs = s.cString(using: .ascii)!
let n = strlen(cs)
// 二维数组`c`用来缓存长度大于等于2的回文子串判定
// 默认值为false
var c: [[Bool]] = Array(repeating: Array(repeating: false, count: n),
count: n)
// 确保n > 1,否则n <= 1的话直接返回字符串即可
// 因为它肯定是回文子串
guard n > 1 else { return s }
// 保存一个最长回文子串的长度和子串范围索引
var maxlen = 0
var rng: (Int, Int) = (0, 0)
// 寻找从2到n长度的回文子串
for l in 2...n {
// 开始遍历索引
for i in 0.. maxlen {
rng = (i, i+l-1)
maxlen = l
}
}
}
}
return String(cString: cs[rng.0...rng.1].map{$0} + [0])
}
}
由于该算法明显用了两层循环进行计算,而且还开辟了一个二维缓存数组。显然时间复杂度是O(n^2)
,空间复杂度也是O(n^2)
。
臂展计算法
因为回文串有一个特性,从串的中间往左右观察的话,可以发现左右两边是相等的,从这个思路出发就可以考虑到,任何回文串都存在一个中间界,当从中间界往左右扩散,必然可以得出左右两边的字符是相等的。
假定有一个回文串s[i..k..j]
,且其长度为奇数,其中i<=k<=j
,且k=(i+j)/2
,那么k
就是该回文串的中位数,当回文串足够长时,我们有s[k+1]=s[k-1]
,s[k+2]=s[k-2]
,s[k+3]=sk[k-3]
……直到碰到i
和j
为止。
以上分析仅限于回文串长度为奇数时,那么当长度为偶数时呢,我们可以这样定义,对于一个回文串s[i..k..j]
,且其长度为偶数,其中i<=k<=j
,且k=(i+j-1)/2
,那么k
就是该回文串的中位数,当回文串足够长时,我们有s[k]=s[k+1]
,s[k-1]=s[k+2]
,s[k-2]=s[k+3]
……直到碰到i
和j
为止。
通过对中位数k
的分析,可以把循环的思路从“找i
”引到“找k
”,即我们扫描数组中的所有元素,并且我们寻找它是回文串的中位数时最长的回文串有多长,该长度我们称之为它的“臂展“。
由于前面分析的中位数k
存在两种情况,即臂展为偶数和臂展为奇数的情况,所以我们需要设计一个函数来计算偶数和奇数情况下的臂展。
func searchSpan(_ k: Int, _ x: Bool) -> Int {
var i = 1
let t = x ? 0 : 1
while k - i + t >= 0 && k + i < n && cs[k-i+t] == cs[k+i] {
i += 1
}
return x ? i * 2 - 1 : (i - 1) * 2
}
在该函数searchSpan
中,其参数k
为中位数,参数x
为布尔类型,知名所求回文串长度为奇数还是偶数。cs
代表着字符串。
当循环i
从1开始计数时,对于奇数臂展,求的是cs[i-1]
和cs[i+1]
的比较,而对于偶数臂展,求的是cs[i]
和cs[i+1]
的比较。
对于奇数和偶数,通过一个变量t
来进行边界增量计算。
最后输出臂展的长度,同样是分奇数和偶数来计算。
有了求臂展函数,接下来的操作就相对简单了,我们只要遍历整个字符串,求得臂展,然后保存最大臂展和臂展的范围,当循环结束时,最大的臂展范围已经求得。
完整代码如下。
class Solution {
func longestPalindrome(_ s: String) -> String {
let cs = s.cString(using: .ascii)!
let n = strlen(cs)
func searchSpan(_ k: Int, _ x: Bool) -> Int {
var i = 1
let t = x ? 0 : 1
while k - i + t >= 0 && k + i < n && cs[k-i+t] == cs[k+i] {
i += 1
}
return x ? i * 2 - 1 : (i - 1) * 2
}
var maxLen = 0
var rng = (0, 0)
for k in 0.. maxLen {
rng = l1 > l2 ? (k-l1/2, k+l1/2) : (k-l2/2+1, k+l2/2)
maxLen = l
}
}
return String(cString: cs[rng.0...rng.1].map{$0}+[0])
}
}
该算法总共有两层循环,不难分析出其时间复杂度为O(n^2)
,由于该算法额外开辟的空间是常量级别,故其空间复杂度为O(1)
。
马拉车算法
马拉车算法的英文名叫Manacher‘s Algorithm
,是一个卡内基梅隆大学的计算机教授Glenn Keith Manacher
发明的算法,该算法可以实现在O(n)
时间复杂度内解决最大回文子串的查找问题。
马拉车算法事实上是臂展算法的改进版。它的起点思路和臂展算法是一致的,即计算每个遍历节点的臂展,但是它会保存臂展的结果以便于下次可以”跳过”不必要的计算。
我们假设对于字符串s
,遍历i
求其臂展,并且位置i
的臂展长度是奇数,那么s[i]
必然位于该回文子串的正中央位置,将臂展长度分为左右两侧的等臂,取右臂的长度为l
,那么,当我们将i
向后遍历到j
时,如果j
还在臂展范围内,我们会发现当l
足够长时,因为i
左右两侧的数值是镜像关系,对于s[j]
,必然存在臂展左边的镜像s[i-(j-i)]
,而因为左侧的对应位置我们已经计算过其臂展,那么对于位置j
,我们就可以“跳过”镜像已经计算过的部分。
举例字符串:"pkabacabacu"
对于该字符串,我们可以算出位置5
,即字符c
的位置,其臂展为7
,即回文串“abacaba”,我们假设已知位置5
,即c
字符的臂展为7
,那么再往后查找时,当我们查找到右侧的位置a
时,由于左边“镜像”的存在,我们提前知道了左侧a
的臂长为1
,那么在计算右侧的a
的时候,我们自然知道它的臂长“至少为1”,这样我们只要跳过1
的位置去比较即可。同理,对于b
,由于左侧镜像臂展是3
,即右侧单臂长度为1
,我们在求臂展时就可以跳过这个臂展去计算新的位置,而不用从头进行计算。但是要注意,可以跳过的位置必须是在“臂展笼罩范围内”。
有了以上的“跳过”思路,我们从新审视求臂展算法,就可以知道,当我们对字符串进行遍历去求臂展时,我们每次都可以得到臂展最长的“染指”部分,比如假设i
的位置为5
,而它的臂展为7
,那么它的右侧单臂长度就是3
,它的“手臂”可以延伸到位置8
,那么这就意味着,位置6
、7
,8
都被“笼罩在”5
的臂展范围,这样我们在计算它们时始终有对应的镜像进行“跳过”参照。根据贪心思路,我们在遍历数组时始终都会保留那个最大的笼罩范围进行计算。
问题分析到这里,我们还遇到一个问题,就是我们之前讨论的是臂展长度为奇数的情况,那么对于臂展为偶数的情况怎么计算?
这里提供了一个解决问题的思路:“填充法”。举字符串“baab”为例,它是一个回文串,且臂展为偶数。接下去看用填充法怎么做到用奇数的思路去找到它,首先对字符串的每个间隔添加字符#
,字符串就变成“b#a#a#b”,那么,新的字符串对于位置3
的字符#
,我们找到了以它为中心的奇数回文串,而只要我们把得到的回文串清除占位符#
,即可得到原回文串“baab”。
基于该算法思路编写的完整swift
代码如下:
class Solution {
func longestPalindrome(_ s: String) -> String {
let cr = s.cString(using: .ascii)!
var n = strlen(cr)
// 由于已知字符串是ascii字符,那么可以用ascii值0作为占位符,不需要用到算法讲解中的`#`
var cs: [CChar] = Array(repeating: 0, count: n*2)
var i = 0
while cr[i] != 0 {
cs[i*2] = cr[i]
i += 1
}
n = n * 2 - 1
var c: [Int] = Array(repeating: 0, count: n)
// 搜索单臂长,例如臂长为3,则单臂长为1,如果臂长为1,则单臂长为0
// 参数o给定可以跳过的位移
func searchPalindrome(_ p: Int, _ o: Int) -> Int {
var i = o
while p - i >= 0 && p + i < n && cs[p - i] == cs[p + i] {
i += 1
}
return i - 1
}
var j = 0
var l = 0 // 计算到目前为止被计算的臂展覆盖的最高索引位
var maxlen = 0
var rng: (Int, Int) = (0, 0)
for i in 0.. maxlen {
maxlen = len
rng = (i-k, i+k)
}
if i + k > l {
l = i + k
j = i
}
c[i] = k
}
var ans: [CChar] = []
for i in rng.0...rng.1 {
if cs[i] != 0 {
ans.append(cs[i])
}
}
ans.append(0)
return String(cString: ans)
}
}
该算法由于每次都利用了原先的计算结果进行跳步,其时间复杂度为O(n)
,而因为需要和n
同等规模的存储空间,空间复杂度也为O(n)
。
如果对马拉车算法感兴趣想进一步延伸阅读,可以看看一些介绍比较全面的国外文章,比如:https://vnspoj.github.io/wiki/string/manacher 。
小结
该题解法可难可易,一般如果是作为竞赛或者面试,方法一和二足矣,马拉车算法更适合平时学习和尝试,其实现过程并不简单,涉及到不少边界计算,也考验编码严谨程度。
所有代码均可以在 github
下载 : https://github.com/FengHaiTongLuo/LeetCode4Swift/blob/main/5.%20Longest%20Palindromic%20Substring.swift