分割回文串 IV

标签: 字符串 动态规划

难度: Hard

给你一个字符串 s ,如果可以将它分割成三个 非空 回文子字符串,那么返回 true ,否则返回 false 。

当一个字符串正着读和反着读是一模一样的,就称其为 回文字符串

 

示例 1:

输入:s = "abcbdd"
输出:true
解释:"abcbdd" = "a" + "bcb" + "dd",三个子字符串都是回文的。

示例 2:

输入:s = "bcbddxy"
输出:false
解释:s 没办法被分割成 3 个回文子字符串。

 

提示:

  • 3 <= s.length <= 2000
  • s​​​​​​ 只包含小写英文字母。

Submission

运行时间: 96 ms

内存: 16.4 MB

"""马拉车算法:利用dp加速中心扩展法
用途:
1. O(n)找到每个字符为中心的最长(任意长度)回文串;
2. O(n)预处理后,O(1)判断一个区间是否是回文。
简介:
1. 通过插入扩展字符,使每个回文串长度都是奇数(保证有中心)
2. 记录dp[i]和当前最右回文串,若当前i<=R,则可以直接取对称位置的dp[i],加速扩展
复杂度:初始化O(n)
1. 由于用dp对称位置加速,我们发现当dp[i]的右端点<R时,镜像位置左端点一定>L,那么当前位置无需继续扩展(否则对称位置也能扩展)。
2. 因此每个位置只会作为右边界被扩展成功一次,每个中心点只会扩展失败一次。因此总体是O(n)
一篇很清晰的讲解:https://leetcode.cn/problems/palindrome-partitioning-ii/solutions/369625/manacher-o1pan-duan-ren-yi-zi-chuan-shi-fou-hui-we/
例题:
1. 找最长回文串:https://leetcode.cn/problems/longest-palindromic-substring/description/
2. 计数所有回文串,注意ans += (v//2+1)//2:https://leetcode.cn/problems/palindromic-substrings/description/
3. 按回文分割,求具体方案,dp/状压枚举:https://leetcode.cn/problems/palindrome-partitioning/description/
4. 按回文分割,求最少分割数,dp+马拉车O(1)判回文:https://leetcode.cn/problems/palindrome-partitioning-ii/description/
5. O(n)分割成3个回文串,注意利用S=py或者xq的性质,hexun佬的题解:https://leetcode.cn/problems/palindrome-partitioning-iv/solutions/584373/manacherxian-xing-shi-jian-fu-za-du-by-h-sj24/
"""


class Manacher:
    def __init__(self, s):
        """马拉车用dp加速中心扩展法"""
        self.s = s
        s1 = '#' + '#'.join(s) + '#'  # 增加扩展字符
        n = len(s1)
        f = self.f = [1] * n  # 扩展串上以i为中心的最长回文串长度
        l, r = 0, -1
        for i in range(n):
            if i <= r:  # 如果i包含在已知回文串里,那么可以用对称点信息加速,但不能超过边界
                f[i] = min((r - i) * 2 + 1, f[r + l - i])
            l1, r1 = i - f[i] // 2 - 1, i + f[i] // 2 + 1
            while 0 <= l1 and r1 < n and s1[l1] == s1[r1]:  # 继续尝试扩展
                l1, r1 = l1 - 1, r1 + 1
            f[i] = r1 - l1 - 1
            if r1 > r:  # 更新r,这里存疑,如果变短也要更新吗?随便测了一个r1>r+1也能过。
                l, r = l1 + 1, r1 - 1

    def get_longest_palindrome(self):  # O(n)
        return max(self.f) // 2  # 原串上的最长回文串,去掉扩展符

    def get_all_pal_of_len(self, d):  # 获取所有长度为d的回文串起止位置[闭区间]O(n)
        d2 = d * 2 + 1  # 转化成在扩展串上的长度
        for i, v in enumerate(self.f):
            if v >= d2:
                yield (i - d) >> 1, (i + d - 1) >> 1  # 注意右边要-1

    def is_palindrome(self, l, r):
        return self.f[l + r + 1] >= (r * 2 - l * 2 + 1)  # 原串上[l,r]是否是回文;在扩展串上映射的下标是l*2+1和r*2+1;f[mid]>=r1-l1+1即可

    def true_pre_suffix_pal(self):  # 预处理每个l为起点的最长回文'真'前缀的r(即r<n-1)和以n-1为右端点的后缀
        f, n = self.f, len(self.f) // 2
        pre, suf = list(range(n)), [n-1] * n  # pre[l]表示以l为左端点的最长回文真前缀的右端点,suf[i]表示s[i:]的最长后缀回文串的左端点,即右端点都是n-1
        for i, v in enumerate(f):
            if v > 1:  # 以i为中心存在非空回文
                l, r = (i - v // 2) >> 1, (i + v // 2 - 1) >> 1  # 这个回文串的下标
                if r == n - 1:  # 需要是最长'真'前缀,
                    suf[l] = l
                    l, r = l + 1, r - 1
                if l < n and pre[l] < r:
                    pre[l] = r
        for i in range(n - 2, -1, -1):  # 向前延伸,要么继承要么更远
            suf[i] = min(suf[i], suf[i + 1])
        for i in range(1, n):  # 如果i包含在i-1的回文串里,那么右端点可以是pre[i-1]-1
            pre[i] = max(pre[i], pre[i - 1] - 1)
        return pre, suf


class Solution:
    def checkPartitioning(self, s: str) -> bool:
        n = len(s)
        mnc = Manacher(s)
        pre, suf = mnc.true_pre_suffix_pal()
        for i in range(n - 2):
            if mnc.is_palindrome(0, i):
                if mnc.is_palindrome(pre[i + 1] + 1, n - 1) or mnc.is_palindrome(i + 1, suf[i + 2] - 1):  # 注意+1-1
                    return True
        return False
#
#
# class Solution:
#     def minCut(self, s: str) -> int:
#         n = len(s)
#         # f = [[0]*n for _ in range(n)]
#         # for l in range(n-1, -1, -1):
#         #     f[l][l] = 1
#         #     if l:
#         #         f[l][l-1] = 1
#         #     for r in range(l+1, n):
#         #         if s[l] == s[r] and f[l+1][r-1]:
#         #             f[l][r] = 1
#         mnc = Manacher(s)
#         dp = [0] * (n + 1)
#         for i in range(n):
#             dp[i + 1] = dp[i] + 1
#             for j in range(i - 1, -1, -1):
#                 if mnc.is_palindrome(j, i):
#                     dp[i + 1] = min(dp[i + 1], dp[j] + 1)
#
#         return dp[-1] - 1

Explain

题解采用了马拉车算法(Manacher's Algorithm)来加速回文串的判断。首先对原字符串进行预处理,插入特殊字符以保证回文串长度都为奇数。然后利用已知回文串的信息来加速新回文串的寻找。对于分割成三个回文串的问题,首先预处理出以每个位置为起点的最长回文真前缀和以每个位置为终点的最长回文真后缀,然后遍历每个位置,判断是否能将字符串分割成三个回文串。

时间复杂度: O(n)

空间复杂度: O(n)

class Manacher:
    def __init__(self, s):
        # 马拉车用dp加速中心扩展法
        self.s = s
        s1 = '#' + '#'.join(s) + '#'  # 增加扩展字符
        n = len(s1)
        f = self.f = [1] * n  # 扩展串上以i为中心的最长回文串长度
        l, r = 0, -1
        for i in range(n):
            if i <= r:  # 如果i包含在已知回文串里,那么可以用对称点信息加速,但不能超过边界
                f[i] = min((r - i) * 2 + 1, f[r + l - i])
            l1, r1 = i - f[i] // 2 - 1, i + f[i] // 2 + 1
            while 0 <= l1 and r1 < n and s1[l1] == s1[r1]:  # 继续尝试扩展
                l1, r1 = l1 - 1, r1 + 1
            f[i] = r1 - l1 - 1
            if r1 > r:  # 更新r,这里存疑,如果变短也要更新吗?随便测了一个r1>r+1也能过。
                l, r = l1 + 1, r1 - 1

    def get_longest_palindrome(self):  # O(n)
        return max(self.f) // 2  # 原串上的最长回文串,去掉扩展符

    def get_all_pal_of_len(self, d):  # 获取所有长度为d的回文串起止位置[闭区间]O(n)
        d2 = d * 2 + 1  # 转化成在扩展串上的长度
        for i, v in enumerate(self.f):
            if v >= d2:
                yield (i - d) >> 1, (i + d - 1) >> 1  # 注意右边要-1

    def is_palindrome(self, l, r):
        return self.f[l + r + 1] >= (r * 2 - l * 2 + 1)  # 原串上[l,r]是否是回文;在扩展串上映射的下标是l*2+1和r*2+1;f[mid]>=r1-l1+1即可

    def true_pre_suffix_pal(self):  # 预处理每个l为起点的最长回文'真'前缀的r(即r<n-1)和以n-1为右端点的后缀
        f, n = self.f, len(self.f) // 2
        pre, suf = list(range(n)), [n-1] * n  # pre[l]表示以l为左端点的最长回文真前缀的右端点,suf[i]表示s[i:]的最长后缀回文串的左端点,即右端点都是n-1
        for i, v in enumerate(f):
            if v > 1:  # 以i为中心存在非空回文
                l, r = (i - v // 2) >> 1, (i + v // 2 - 1) >> 1  # 这个回文串的下标
                if r == n - 1:  # 需要是最长'真'前缀,
                    suf[l] = l
                    l, r = l + 1, r - 1
                if l < n and pre[l] < r:
                    pre[l] = r
        for i in range(n - 2, -1, -1):  # 向前延伸,要么继承要么更远
            suf[i] = min(suf[i], suf[i + 1])
        for i in range(1, n):  # 如果i包含在i-1的回文串里,那么右端点可以是pre[i-1]-1
            pre[i] = max(pre[i], pre[i - 1] - 1)
        return pre, suf


class Solution:
    def checkPartitioning(self, s: str) -> bool:
        n = len(s)
        mnc = Manacher(s)
        pre, suf = mnc.true_pre_suffix_pal()
        for i in range(n - 2):
            if mnc.is_palindrome(0, i):
                if mnc.is_palindrome(pre[i + 1] + 1, n - 1) or mnc.is_palindrome(i + 1, suf[i + 2] - 1):  # 注意+1-1
                    return True
        return False

Explore

在马拉车算法中,通过在原始字符串的每个字符之间(以及首尾)插入相同的特殊字符(通常是一个不会在原始字符串中出现的字符,如'#')来确保每个回文的长度都为奇数。例如,原字符串为'abc',插入特殊字符后变为'#a#b#c#'。这样做的好处是简化了回文中心的处理,使得算法可以统一处理奇数长度的回文,无需另外编写处理偶数长度回文的逻辑。此外,这种方法还可以避免边界检查,因为特殊字符充当了边界的角色,简化了代码的复杂性。

在题解中,最长回文真前缀是指从字符串开始到某个位置的最长子串,并且这个子串是一个回文串,但它不包括整个字符串(即前缀的右端点必须小于字符串的最后一个字符的位置)。最长回文真后缀则是从某个位置到字符串结束的最长子串,这个子串是一个回文串,并且不包括整个字符串的开始字符。与普通的前缀和后缀区别在于,'真'前缀和'真'后缀强调了它们不能是整个字符串,即它们是字符串中真正的一部分,不覆盖整个字符串。

预处理每个位置的最长回文真前缀和最长回文真后缀在算法中起到了关键作用,尤其是在解决分割字符串为多个回文子串的问题中。具体来说,在尝试将字符串分割为三个回文串时,如果知道了每个位置的最长回文真前缀和最长回文真后缀,可以有效地判断在某个位置分割后的子字符串是否为回文,从而快速检查是否存在符合条件的分割方法。这大大降低了尝试和错误的次数,提高了算法的效率和实用性。