字节青训营-最大 UCC 子串编辑优化计算

108 阅读7分钟

字节青训营-最大 UCC 子串编辑优化计算

Hakkali 2025.2.5

题目描述

小C拥有一个由字符 'U''C' 组成的字符串 S,并希望在编辑距离不超过给定值 m 的条件下,尽可能多地在字符串中获得 "UCC" 子串。
编辑距离定义为将字符串 S 转化为其他字符串时所需的最少编辑操作次数。允许的每次编辑操作为:插入、删除或替换单个字符。
要求:在允许最多 m 次编辑操作的前提下,编辑后的字符串中 "UCC" 子串的出现次数尽可能多。

测试样例

  • 样例1
    输入:m = 3, S = "UCUUCCCCC"
    输出:3
    说明:通过适当编辑,可以得到字符串 "UCCUCCUCC"(例如 2 次替换),其中包含 3 个 "UCC" 子串。

  • 样例2
    输入:m = 6, S = "U"
    输出:2
    说明:可在后面插入 "CCUCC" 共 5 次插入操作(以及可能其他组合),使得最终字符串为 "UCCUCC",包含 2 个 "UCC" 子串。

  • 样例3
    输入:m = 2, S = "UCCUUU"
    输出:2
    说明:例如替换最后 2 个字符,可以将字符串修改为 "UCCUCC",从而获得 2 个 "UCC" 子串。


算法思想

本题要求在编辑操作(插入、删除、替换)允许的范围内,设计一种编辑方案使得得到的字符串中 "UCC" 子串的个数最大。
核心思路在于动态规划(DP)结合状态自动机来跟踪 "UCC" 的匹配进度,同时在 DP 状态中记录当前已经消耗的编辑操作数和累计获得的 "UCC" 匹配数。

状态定义

定义 DP 数组 dp,其中

  • dp[i] 表示处理完原字符串前 i 个字符后(i 从 0 到 n),所有可能的状态集合。
  • 每个状态用二元组 (st, cost) 表示:
    • st:当前自动机状态,代表已匹配 "UCC" 的进度。
      • 0 表示未匹配任何字符;
      • 1 表示已匹配 'U'
      • 2 表示已匹配 "UC",下一步如果读到 'C'则可完成一个 "UCC" 子串。
    • cost:从原字符串出发累计使用的编辑操作数。

对应状态的值 occ_val 为:

  • 在达到该状态时,已累计完成的 "UCC" 子串个数。

状态转移

1. 插入闭包

在处理每个位置 i 之前,我们先考虑在当前位置(不推进原字符串下标)是否能通过 插入操作改善状态。

  • 插入操作:
    • 可以选择插入 'U''C',每次插入增加编辑成本 1。
    • 调用自动机转移函数 trans(st, ch),得到新状态 ns 和此次操作产生的 "UCC" 匹配增量 add_occ
  • 利用一个 while 循环对当前 dp[i] 进行闭包更新,直到没有新的状态更新为止。
  • 注意:插入操作不消耗原字符串中的字符,但会消耗编辑预算。
2. 推进原字符串

当在当前位置 dp[i] 的插入闭包结束后,如果未处理完原字符串(i < n),则考虑以下两种操作来消耗当前字符 S[i]

  • 删除操作
    • 跳过 S[i],编辑成本 +1,自动机状态保持不变。
  • 使用(匹配或替换)操作
    • 选择输出一个字符。
      • 如果 S[i] 与目标字符相同(目标字符可以为 'U''C'),则成本为 0;
      • 如果不同,则相当于替换操作,成本 +1。
    • 根据选择的字符调用 trans(st, ch) 得到新状态和匹配增量,更新累计匹配数。
3. 自动机转移函数 trans

函数 trans(st, ch) 定义如下:

  • 状态 0(尚未匹配任何字符):
    • 若读入 'U',转为状态 1;
    • 否则(读入 'C'),仍保持状态 0。
  • 状态 1(已匹配 'U'):
    • 若读入 'U',仍保持状态 1(新的 'U' 可能作为后续匹配的起点);
    • 若读入 'C',转为状态 2。
  • 状态 2(已匹配 "UC"):
    • 若读入 'U',转为状态 1;
    • 若读入 'C',完成 "UCC" 匹配,计数增量 +1,同时状态重置为 0。

解题步骤

  1. 初始化 DP

    • dp[0] 为字典,初始状态为:dp[0][(0, 0)] = 0,表示还未处理任何字符、自动机处于状态 0、编辑成本为 0,且累计匹配数为 0。
  2. 对每个位置 i(从 0 到 n)进行处理:

    • 插入闭包
      对 dp[i] 中的每个状态 (st, cost),如果当前 cost 小于 m,则尝试插入 'U''C'

      • 新成本 new_cost = cost + 1(插入一次);
      • 根据 trans(st, ch) 得到新状态 ns 和匹配增量 add_occ;
      • 新累计匹配数 new_occ = occ_val + add_occ;
      • 更新 dp[i][(ns, new_cost)](如果未记录或新值更优)。 使用 while 循环重复以上操作,直到一次遍历没有任何状态更新为止。
    • 推进原字符串
      如果 i < n,则对 dp[i] 中的每个状态 (st, cost) 考虑:

      • 删除 S[i]
        新成本 = cost + 1,状态保持 st,匹配数不变,更新 dp[i+1]。
      • 使用 S[i]
        遍历目标字符 'U''C'
        • 如果 S[i] 与目标字符相同,成本不变;否则成本 +1(替换操作);
        • 调用 trans(st, ch) 得到新状态 ns 和增量 add_occ;
        • 累计匹配数更新为 occ_val + add_occ;
        • 更新 dp[i+1][(ns, new_cost)]

完整代码

def solution(m: int, s: str) -> int:
    def trans(st, ch):
        """
        自动机转移函数:
        输入:当前状态 st(0,1,2),以及读入字符 ch('U' 或 'C')
        输出:新的状态 ns,以及此次操作增加的 "UCC" 匹配数 add_occ(要么0 要么1)
        """
        if st == 0:
            if ch == 'U':
                return (1,0)
            else:
                return (0,0)
        elif st == 1:
            if ch == 'C':
                return (2,0)
            else:
                return (1,0)
        else st == 2:
            if ch == 'C':
                return (0,1)
            else:
                return (1,0)
            
    n = len(s)
    # dp[i] 为一个字典,键为 (state, cost),值为累计匹配 "UCC" 的次数
    dp = [dict() for _ in range(n+1)]
    dp[0][(0, 0)] = 0  # 初始状态:未处理字符,状态 0,编辑成本 0,匹配数 0

    # 对于每个位置 i(末尾 i=n 允许进行插入操作, 但无法删除或替换)
    for i in range(n+1):
        # 1.插入闭包:在当前位置 i 不消耗原串字符的前提下反复尝试插入操作
        while True:
            updated = False
            for (st, cost) in list(dp[i].keys()):
                occ_val = dp[i][(st, cost)] # 即occrance value,表示'UCC'的出现次数
                if cost < m:
                    for ch in ['U', 'C']:
                        new_cost = cost + 1
                        if new_cost > m:
                            continue
                        new_st, add_occ = trans(st, ch)
                        new_occ = occ_val + add_occ
                        key = (new_st, new_cost)
                        # 注意当操作不为插入时,下面三行中的i会变成i+1,至于为什么可以思考一下
                        if key not in dp[i] or dp[i][key] < new_occ:
                            dp[i][key] = new_occ
                            updated = True
            if not updated:
                break
            
        # 如果 i 已到末尾,则只考虑插入,不考虑删除和替换
        if i == n:
            continue

        # 处理原串字符 s[i],将 dp[i] 状态推进到 dp[i+1]
        for (st, cost) in list(dp[i].keys()):
            occ_val = dp[i][(st, cost)]
            # 2. 删除操作:跳过 s[i],成本 +1,状态不变
            new_cost = cost + 1
            if new_cost <= m:
                key = (st, new_cost)
                if key not in dp[i+1] or dp[i+1][key] < occ_val:
                    dp[i+1][key] = occ_val
            # 2. 使用 s[i]:匹配或替换操作,输出一个字符,可选 'U' 或 'C'
            for ch in ['U', 'C']:
                # 如果 s[i] 与 ch 相同,则无需替换,成本为 0;否则成本 +1
                add_cost = 0 if s[i] == ch else 1
                new_cost = cost + add_cost
                if new_cost > m:
                    continue
                ns, add_occ = trans(st, ch)
                new_occ = occ_val + add_occ
                key = (ns, new_cost)
                if key not in dp[i+1] or dp[i+1][key] < new_occ:
                    dp[i+1][key] = new_occ

    return max(dp[n].values())

if __name__ == '__main__':
    print(solution(3, "UCUUCCCCC") == 3)
    print(solution(6, "U") == 2)
    print(solution(2, "UCCUUU") == 2)

解题心得

这道题的关键在于如何在有限的编辑距离预算内,通过合理的编辑操作最大化字符串中 "UCC" 子串的个数。解决过程中主要有两个核心思想:

  1. 状态自动机
    使用一个简单的自动机来跟踪 "UCC" 的匹配进度(状态 0 → 1 → 2 → 完成匹配并重置)。这样,每一次字符输出(无论是来自原串、替换或插入)都可以通过 trans 函数更新状态和匹配数。

  2. 编辑距离下的动态规划
    定义 DP 状态 (st, cost),在不同阶段记录自动机状态、已经使用的编辑操作数以及累计获得的匹配数。

    • 插入闭包:在同一位置上反复进行插入操作,充分利用剩余编辑预算来“制造”更多匹配机会。
    • 删除和使用操作:同时考虑删除原串字符和利用原字符(匹配或替换)的可能性,保证状态能够向后推进。