稀土掘金AI刷题第233题:小U的好字符串 解析|豆包MarsCode AI刷题

117 阅读9分钟

算法题解析:计算不含回文子串的“好字符串”子序列数量

问题描述

小U定义了一个“好字符串”,其要求是该字符串中不包含任意长度不小于 2 的回文子串。现在小U拿到了一个字符串,她想知道有多少个非空的子序列是“好字符串”。你的任务是帮助她计算出这些子序列的数量。

注意:由于答案可能非常大,你需要对结果取 109+710^9 + 7 进行输出。

示例

  • 示例 1:

    输入:s = "aba"

    输出:5

    解释:

    "aba" 的所有非空子序列如下:

    1. 'a'(第一个 'a')
    2. 'b'
    3. 'a'(第二个 'a')
    4. 'a' 'b' -> 'ab'
    5. 'b' 'a' -> 'ba'
    6. 'a' 'a' -> 'aa'(含回文子串,排除)
    7. 'a' 'b' 'a' -> 'aba'(含回文子串,排除)

    除了 'aa''aba' 之外,其余五个子序列都是“好字符串”。

  • 示例 2:

    输入:s = "aaa"

    输出:3

  • 示例 3:

    输入:s = "ghij"

    输出:15

解题思路

1. 问题分析

我们需要计算给定字符串的所有非空子序列中,不包含长度不小于 2 的回文子串的数量。

  • 子序列:可以通过删除零个或多个字符而不改变剩余字符的相对位置得到的新字符串。
  • 回文子串:字符串中的一个连续子字符串,其反转后与原字符串相同。

挑战

  • 直接枚举所有子序列不可行,因为字符串长度可能很大,枚举的时间复杂度为 O(2n)O(2^n)。
  • 需要一种高效的方法来统计满足条件的子序列数量。

2. 思路概述

我们可以通过动态规划(Dynamic Programming)的方法,结合记忆化搜索(Memoization) ,来解决这个问题。

核心思想

  • 状态定义:使用递归函数 dfs(i, prev1, prev2),其中:

    • i:当前处理到字符串的第 i 个字符。
    • prev1:上一个选择的字符编码(-1 表示未选择)。
    • prev2:上上个选择的字符编码。
  • 转移方程

    • 不选择当前字符dfs(i + 1, prev1, prev2)

    • 选择当前字符(需满足条件):

      • 当前字符与 prev1 不同(避免长度为 2 的回文子串)。
      • 当前字符与 prev2 不同(避免长度为 3 的回文子串)。
      • 满足条件时,递归调用:dfs(i + 1, curr, prev1)
  • 递归结束条件

    • 当遍历完整个字符串(i == n)时:

      • 如果至少选择了一个字符(prev1 != -1),返回 1
      • 否则返回 0

为什么需要记录前两个选择的字符?

  • 避免长度为 2 的回文子串:需要确保当前选择的字符与前一个选择的字符不同。
  • 避免长度为 3 的回文子串:需要确保当前选择的字符与前前一个选择的字符不同。

3. 详细算法

3.1 状态定义
  • dfs(i, prev1, prev2) :表示从位置 i 开始,前一个选择的字符为 prev1,前前一个选择的字符为 prev2 时的“好字符串”子序列数量。

  • 参数说明

    • i:当前处理到的字符串位置,0 ≤ i ≤ n
    • prev1:上一个选择的字符编码,-1 表示未选择。
    • prev2:上上个选择的字符编码,-1 表示未选择。
3.2 转移方程
  1. 不选择当前字符

    dfs(i,prev1,prev2)=dfs(i+1,prev1,prev2)\text{dfs}(i, \text{prev1}, \text{prev2}) = \text{dfs}(i + 1, \text{prev1}, \text{prev2})

  2. 选择当前字符(需满足条件):

    • 条件

      • 当前字符编码为 curr
      • curr ≠ prev1(避免长度为 2 的回文子串)。
      • curr ≠ prev2(避免长度为 3 的回文子串)。
    • 转移

      dfs(i,prev1,prev2)+=dfs(i+1,curr,prev1)\text{dfs}(i, \text{prev1}, \text{prev2}) += \text{dfs}(i + 1, \text{curr}, \text{prev1})

3.3 递归结束条件
  • i == n(遍历完整个字符串)时:

    dfs(i,prev1,prev2)={1,if prev1≠−10,if prev1=−1\text{dfs}(i, \text{prev1}, \text{prev2}) = \begin{cases} 1, & \text{if } \text{prev1} ≠ -1 \ 0, & \text{if } \text{prev1} = -1 \end{cases}

    • 如果 prev1 != -1,说明已经选择了至少一个字符,形成了一个非空子序列。
    • 如果 prev1 == -1,说明未选择任何字符,不计入结果。

4. 图解示例

以字符串 "aba" 为例,展示递归过程:

Start: dfs(0, -1, -1)
|
|-- 不选 'a': dfs(1, -1, -1)
|   |
|   |-- 不选 'b': dfs(2, -1, -1)
|   |   |
|   |   |-- 不选 'a': dfs(3, -1, -1) -> return 0 (未选择任何字符)
|   |   |-- 选 'a': dfs(3, 'a', -1) -> return 1
|   |
|   |-- 选 'b': dfs(2, 'b', -1)
|       |
|       |-- 不选 'a': dfs(3, 'b', -1) -> return 1
|       |-- 选 'a': dfs(3, 'a', 'b') -> return 1
|
|-- 选 'a': dfs(1, 'a', -1)
    |
    |-- 不选 'b': dfs(2, 'a', -1)
    |   |
    |   |-- 不选 'a': dfs(3, 'a', -1) -> return 1
    |
    |-- 选 'b': dfs(2, 'b', 'a')
        |
        |-- 不选 'a': dfs(3, 'b', 'a') -> return 1

结果汇总

  • 总共有 5 条路径返回 1,表示有 5 个“好字符串”子序列。

5. 代码实现

def solution(s):
    MOD = 10**9 + 7
    n = len(s)
    from functools import lru_cache

    # 将字符转换为数字编码,方便比较
    s_codes = [ord(c) - ord('a') for c in s]

    @lru_cache(maxsize=None)
    def dfs(i, prev1, prev2):
        if i == n:
            return 1 if prev1 != -1 else 0  # 如果至少选择了一个字符
        # 不选择当前字符
        res = dfs(i + 1, prev1, prev2) % MOD
        curr = s_codes[i]
        # 选择当前字符,检查是否满足条件
        if curr != prev1 and curr != prev2:
            res = (res + dfs(i + 1, curr, prev1)) % MOD
        return res

    result = dfs(0, -1, -1)
    return result % MOD

# 测试样例
print(solution("aba"))   # 输出: 5
print(solution("aaa"))   # 输出: 3
print(solution("ghij"))  # 输出: 15

6. 代码详解

6.1 预处理
  • 字符编码转换:将字符串中的字符转换为数字编码,'a' 对应 0'b' 对应 1,依此类推。

    s_codes = [ord(c) - ord('a') for c in s]
    
  • 记忆化递归:使用 functools.lru_cache 装饰器,对递归函数进行记忆化,避免重复计算。

6.2 递归函数
  • 函数定义dfs(i, prev1, prev2)

    • 参数

      • i:当前处理到的字符位置。
      • prev1:上一个选择的字符编码,-1 表示未选择。
      • prev2:上上个选择的字符编码。
  • 递归结束条件

    if i == n:
        return 1 if prev1 != -1 else 0
    
    • 当遍历完整个字符串时,如果至少选择了一个字符,返回 1,否则返回 0
  • 不选择当前字符

    res = dfs(i + 1, prev1, prev2) % MOD
    
  • 选择当前字符(需满足条件):

    curr = s_codes[i]
    if curr != prev1 and curr != prev2:
        res = (res + dfs(i + 1, curr, prev1)) % MOD
    
    • 当前字符编码为 curr
    • 如果 curr != prev1curr != prev2,则可以选择当前字符,递归调用 dfs(i + 1, curr, prev1),并累加结果。
  • 返回结果

    return res
    
6.3 主函数
  • 调用递归函数:

    result = dfs(0, -1, -1)
    
    • 从字符串的第一个字符开始,prev1prev2 初始化为 -1,表示尚未选择任何字符。
  • 输出结果:

    return result % MOD
    

7. 时间和空间复杂度分析

  • 时间复杂度:O(n×26×26)O(n \times 26 \times 26)

    • 由于 prev1prev2 的取值范围为 -125,共有 27 × 27 = 729 种状态。
    • 每个位置最多访问一次,总的状态数为 n×729n \times 729。
  • 空间复杂度:O(n×26×26)O(n \times 26 \times 26)

    • 记忆化递归缓存了 n×729n \times 729 个状态。

8. 测试样例验证

样例 1
  • 输入s = "aba"
  • 输出5

验证过程

  • 通过递归遍历,计算出符合条件的子序列数量为 5,符合预期。
样例 2
  • 输入s = "aaa"
  • 输出3

验证过程

  • "aaa" 的所有非空子序列为:

    1. 'a'(第一个 'a')
    2. 'a'(第二个 'a')
    3. 'a'(第三个 'a')
    4. 'a''a' -> 'aa'(含回文子串,排除)
    5. 'a''a' -> 'aa'(含回文子串,排除)
    6. 'a''a' -> 'aa'(含回文子串,排除)
    7. 'a''a''a' -> 'aaa'(含回文子串,排除)
  • 只有长度为 1 的子序列符合条件,数量为 3。

样例 3
  • 输入s = "ghij"
  • 输出15

验证过程

  • 字符串长度为 4,所有非空子序列数量为 24−1=152^4 - 1 = 15。
  • 因为字符各不相同,不存在回文子串,因此所有非空子序列都是“好字符串”。

9. 总结

本题的关键在于:

  • 动态规划状态的设计:需要记录前两个选择的字符,以避免形成长度为 2 和 3 的回文子串。
  • 递归转移的条件:在选择当前字符时,必须确保不会与前一个或前前一个选择的字符形成回文子串。
  • 记忆化递归的应用:通过缓存递归状态,避免了大量的重复计算,提高了算法效率。

通过以上方法,我们成功地解决了这个复杂的计数问题,在合理的时间和空间复杂度内得到了正确的结果。


附录:完整代码

def solution(s):
    MOD = 10**9 + 7
    n = len(s)
    from functools import lru_cache

    # 将字符转换为数字编码,方便比较
    s_codes = [ord(c) - ord('a') for c in s]

    @lru_cache(maxsize=None)
    def dfs(i, prev1, prev2):
        if i == n:
            return 1 if prev1 != -1 else 0  # 如果至少选择了一个字符
        # 不选择当前字符
        res = dfs(i + 1, prev1, prev2) % MOD
        curr = s_codes[i]
        # 选择当前字符,检查是否满足条件
        if curr != prev1 and curr != prev2:
            res = (res + dfs(i + 1, curr, prev1)) % MOD
        return res

    result = dfs(0, -1, -1)
    return result % MOD

# 测试样例
print(solution("aba"))   # 输出: 5
print(solution("aaa"))   # 输出: 3
print(solution("ghij"))  # 输出: 15

常见问题解答

Q1:为什么需要记录前两个选择的字符?

A1:为了避免形成长度为 2 和 3 的回文子串:

  • 长度为 2 的回文子串:由相邻的两个相同字符构成,需要确保当前选择的字符与前一个选择的字符不同。
  • 长度为 3 的回文子串:由首尾字符相同的三字符串构成,需要确保当前选择的字符与前前一个选择的字符不同。

Q2:为什么在递归结束时,当 prev1 == -1 时返回 0

A2:当 prev1 == -1 时,表示未选择任何字符,形成了一个空子序列。题目要求计算非空的子序列数量,因此返回 0

Q3:使用记忆化递归会不会导致栈溢出?

A3:由于 Python 具有默认的递归深度限制(通常为 1000),对于较长的字符串,可能会超过递归深度限制。可以通过以下方式增加递归深度:

import sys
sys.setrecursionlimit(1000000)

但是要注意,过大的递归深度可能会导致程序运行效率下降,甚至崩溃。因此,这种方法适用于题目数据规模适中的情况。

Q4:能否使用迭代的方式来实现?

A4:可以尝试使用迭代和动态规划数组来实现,但由于需要维护三个维度(iprev1prev2),实现起来会比较复杂,而且需要较大的内存空间。在本题中,记忆化递归已经能够高效地解决问题。