最小窗口子序列
字数 3575 2025-12-11 10:25:27

好的,我注意到“最小窗口子序列”这个题目还没有给你讲过。这是一个经典的线性动态规划问题,在字符串匹配和序列处理中有着重要应用。

最小窗口子序列

题目描述:

给定一个较长的字符串 S 和一个较短的字符串 T,请找出 S 中的一个最短的连续子串(窗口),使得这个子串是 T 的一个“子序列”。

更正式的定义:

  • 输入:两个字符串 S (长度 n) 和 T (长度 m),且满足 n >= m
  • 输出:返回 S 中长度最短的一个连续子串 S[i:j] (即从索引 i 到 j-1),满足 TS[i:j] 的一个子序列。如果存在多个这样的最短窗口,返回起始索引最小的那个。如果不存在,返回空字符串。

注意:

  1. 子序列不要求连续,但窗口必须是连续的。我们要在 S 的一段连续区间内,找到一个能按顺序匹配完整个 T 的字符序列。
  2. 这与“最小覆盖子串”(Minimum Window Substring)不同,后者要求窗口包含 T 的所有字符(可乱序),而本题要求按顺序匹配。

例子:

S = "abcdebdde", T = "bde"
输出: "bcde"
解释:
在S中,有多个窗口包含"bde"作为子序列,例如"bcde"(索引1-4)和"bdde"(索引5-8)。
"bcde"的长度是4,比"bdde"(长度4)起始索引更小(或相等时取起始小的),所以答案是"bcde"。

解题过程循序渐进讲解

第一步:理解问题与暴力解法思路

最直观的想法是枚举 S 中所有可能的子串,然后检查 T 是否是该子串的子序列,并记录最短的符合条件的子串。

如何检查子序列?可以使用双指针:一个指针 p 指向 T 的当前待匹配字符,一个指针 q 扫描当前子串。如果 S[q] == T[p],则 p 向后移动一位。如果最终 p 能走到 T 的末尾,则匹配成功。

但这样时间复杂度极高:枚举所有子串是 O(n²),对每个子串检查子序列是 O(子串长度) ≈ O(n),总复杂度 O(n³),无法接受。

我们需要一个更聪明的方法,利用动态规划来高效地“回溯”匹配起点。

第二步:动态规划状态定义

我们换个角度思考:对于 S 中的每一个位置 iT 中的每一个位置 j,我们想问:“如果我想在 S[0..i] 这个前缀中,匹配到 T[0..j] 这个前缀,那么最近的匹配起点在哪?”

这引导我们定义一个二维DP数组:
dp[i][j] 表示:在 S 的前 i 个字符(即 S[0..i-1])中,匹配到 T 的前 j 个字符(即 T[0..j-1]),这个匹配窗口的起始索引(在 S 中的下标)。如果无法匹配,则设为 -1。

状态含义详解:

  • ij 都是从1开始计数的,dp[0][0] = 0 表示空串匹配空串,起始点在索引0(这是一个虚拟起点,方便计算)。
  • dp[i][j] 的值表示:在必须使用 S[i-1] 这个字符来匹配 T[j-1] 的前提下,整个匹配窗口是从 S 的哪个位置开始的。
  • 最终,对于一个成功的匹配(j == m),窗口就是 S[dp[i][m] : i],长度为 i - dp[i][m]。我们遍历所有 i,找到长度最小的这个窗口即可。

第三步:状态转移方程推导

情况分为两种:

  1. S[i-1] == T[j-1] (当前字符匹配):

    • 如果 j == 1:这意味着我们正在匹配 T 的第一个字符。那么当前窗口的起点就是 i-1(因为我们要用 S[i-1] 来匹配它)。所以 dp[i][1] = i-1
    • 如果 j > 1:为了匹配 T[0..j-1],我们不仅要用 S[i-1] 匹配 T[j-1],还要在前面的部分 S[0..i-2] 中匹配完 T[0..j-2]。而“前面的部分匹配 T[0..j-2] 的最近起点”就存储在 dp[i-1][j-1] 中。所以,当前整个匹配的起点就是 dp[i-1][j-1]
    • 但是要注意!dp[i-1][j-1] 可能是 -1,表示前面无法匹配 T[0..j-2],那么即使当前字符相等,也无法完成匹配,dp[i][j] 仍应为 -1。
  2. S[i-1] != T[j-1] (当前字符不匹配):

    • 当前字符 S[i-1] 不能用于匹配 T[j-1]。那我们只能尝试在 S[0..i-2] 中寻找匹配 T[0..j-1] 的窗口。这个窗口的起点信息就存储在 dp[i-1][j] 中。所以 dp[i][j] = dp[i-1][j]
    • 同样,如果 dp[i-1][j] 是 -1,那 dp[i][j] 也是 -1。

总结状态转移方程:

如果 S[i-1] == T[j-1]:
    if j == 1:
        dp[i][j] = i - 1
    else:
        dp[i][j] = dp[i-1][j-1]  # 如果 dp[i-1][j-1] != -1
否则 (S[i-1] != T[j-1]):
    dp[i][j] = dp[i-1][j]

在编程实现时,我们需要判断 dp[i-1][j-1] 是否为 -1 来决定是否赋值。

第四步:DP计算与答案获取

  1. 初始化:

    • 创建一个 (n+1) x (m+1) 的二维数组 dp,用 -1 填充。
    • dp[0][0] = 0。对于所有 i >= 0, dp[i][0] = i?等等,这里需要小心。dp[i][0] 表示匹配空串 T,空串是任何字符串的子序列,但我们的状态定义要求窗口的起始点。通常我们设定 dp[i][0] = i,表示匹配空串的窗口是空窗口,起点在 i。但这在后续计算中,当 j=1 且字符匹配时,我们会用 dp[i-1][0] 的值,即 i-1,这是合理的(表示从 i-1 开始匹配第一个字符)。一个更安全的初始化是:dp[i][0] = i 对于所有 i 从 0 到 n。dp[0][j] = -1 对于所有 j 从 1 到 m (空S无法匹配非空T)。
  2. 递推填充:

    • 按照 i 从 1 到 nj 从 1 到 m 的顺序循环。
    • 根据上面的转移方程计算 dp[i][j]
  3. 寻找答案:

    • 在填充过程中,每当 j == mdp[i][m] != -1 时,我们就找到了一个以 S[i-1] 结尾的、包含 T 作为子序列的窗口。
    • 这个窗口的起始索引是 start = dp[i][m],结束索引是 i-1,长度为 len = i - start
    • 我们维护一个全局最小的窗口长度 min_len 和对应的起始索引 min_start。每当找到一个合法窗口,就更新它们。
  4. 最终输出:

    • 如果 min_len 仍然是初始化的一个大值(如 n+1),说明没找到,返回空字符串。
    • 否则,返回 S.substr(min_start, min_len)

第五步:复杂度与示例推演

  • 时间复杂度: O(n * m),需要填充一个 (n+1) x (m+1) 的 DP 表。
  • 空间复杂度: O(n * m)。可以优化到 O(m) 或 O(n),因为我们递推时只依赖上一行 dp[i-1][:]。但为了清晰,我们先理解二维版本。

用例子 S="abcdebdde", T="bde" 推演关键步骤 (n=9, m=3):

初始化后,我们计算几个关键状态:

  • 计算 dp[2][1] (i=2, j=1, 对应 S[1]='b', T[0]='b'):

    • 字符相等,且 j==1,所以 dp[2][1] = i-1 = 1。这意味着,在S的前2个字符中匹配T的第一个字符'b',最近起点是索引1。
  • 计算 dp[4][3] (i=4, j=3, 对应 S[3]='d', T[2]='e'):

    • 字符不相等 (d != e),所以 dp[4][3] = dp[3][3]dp[3][3] 可能来自更早的计算。
  • 计算 dp[5][3] (i=5, j=3, 对应 S[4]='e', T[2]='e'):

    • 字符相等,且 j=3>1,所以 dp[5][3] = dp[4][2]。假设我们之前算得 dp[4][2] 是某个值,比如 1(这表示在S[0..3]中匹配"bd"的起点是1)。
    • 那么 dp[5][3] = 1。这意味着,在S0..4中匹配整个"bde",窗口起点是1,终点是4,长度是4。这就是我们找到的候选答案"bcde"。
  • 之后,我们还会在 i=8, j=3 (匹配 S[7]='e') 时,可能得到 dp[8][3] = 5,对应窗口"bdde",长度也是4,但起始索引5大于1,所以不更新最优解。

最终,我们得到的最短窗口就是起点为1,长度为4的"bcde"。

希望这个从暴力思路到状态定义,再到转移方程和计算过程的详细拆解,能帮助你完全理解“最小窗口子序列”这个线性动态规划问题的解法。

好的,我注意到“最小窗口子序列”这个题目还没有给你讲过。这是一个经典的线性动态规划问题,在字符串匹配和序列处理中有着重要应用。 最小窗口子序列 题目描述 : 给定一个较长的字符串 S 和一个较短的字符串 T ,请找出 S 中的一个最短的连续子串(窗口),使得这个子串是 T 的一个“子序列”。 更正式的定义: 输入:两个字符串 S (长度 n) 和 T (长度 m),且满足 n >= m 。 输出:返回 S 中长度最短的一个连续子串 S[i:j] (即从索引 i 到 j-1),满足 T 是 S[i:j] 的一个 子序列 。如果存在多个这样的最短窗口,返回起始索引最小的那个。如果不存在,返回空字符串。 注意 : 子序列不要求连续,但窗口必须是连续的。我们要在 S 的一段 连续区间 内,找到一个能按顺序匹配完整个 T 的字符序列。 这与“最小覆盖子串”(Minimum Window Substring)不同,后者要求窗口包含 T 的所有字符(可乱序),而本题要求按顺序匹配。 例子 : 解题过程循序渐进讲解 第一步:理解问题与暴力解法思路 最直观的想法是枚举 S 中所有可能的子串,然后检查 T 是否是该子串的子序列,并记录最短的符合条件的子串。 如何检查子序列?可以使用 双指针 :一个指针 p 指向 T 的当前待匹配字符,一个指针 q 扫描当前子串。如果 S[q] == T[p] ,则 p 向后移动一位。如果最终 p 能走到 T 的末尾,则匹配成功。 但这样时间复杂度极高:枚举所有子串是 O(n²),对每个子串检查子序列是 O(子串长度) ≈ O(n),总复杂度 O(n³),无法接受。 我们需要一个更聪明的方法,利用 动态规划 来高效地“回溯”匹配起点。 第二步:动态规划状态定义 我们换个角度思考:对于 S 中的每一个位置 i 和 T 中的每一个位置 j ,我们想问:“如果我想在 S[0..i] 这个前缀中,匹配到 T[0..j] 这个前缀,那么 最近 的匹配起点在哪?” 这引导我们定义一个二维DP数组: dp[i][j] 表示:在 S 的前 i 个字符(即 S[0..i-1] )中,匹配到 T 的前 j 个字符(即 T[0..j-1] ),这个匹配 窗口的起始索引 (在 S 中的下标)。如果无法匹配,则设为 -1。 状态含义详解 : i 和 j 都是从1开始计数的, dp[0][0] = 0 表示空串匹配空串,起始点在索引0(这是一个虚拟起点,方便计算)。 dp[i][j] 的值表示:在必须使用 S[i-1] 这个字符来匹配 T[j-1] 的前提下,整个匹配窗口是从 S 的哪个位置开始的。 最终,对于一个成功的匹配( j == m ),窗口就是 S[dp[i][m] : i] ,长度为 i - dp[i][m] 。我们遍历所有 i ,找到长度最小的这个窗口即可。 第三步:状态转移方程推导 情况分为两种: S[i-1] == T[j-1] (当前字符匹配) : 如果 j == 1 :这意味着我们正在匹配 T 的第一个字符。那么当前窗口的起点就是 i-1 (因为我们要用 S[i-1] 来匹配它)。所以 dp[i][1] = i-1 。 如果 j > 1 :为了匹配 T[0..j-1] ,我们不仅要用 S[i-1] 匹配 T[j-1] ,还要在前面的部分 S[0..i-2] 中匹配完 T[0..j-2] 。而“前面的部分匹配 T[0..j-2] 的最近起点”就存储在 dp[i-1][j-1] 中。所以,当前整个匹配的起点就是 dp[i-1][j-1] 。 但是要注意! dp[i-1][j-1] 可能是 -1,表示前面无法匹配 T[0..j-2] ,那么即使当前字符相等,也无法完成匹配, dp[i][j] 仍应为 -1。 S[i-1] != T[j-1] (当前字符不匹配) : 当前字符 S[i-1] 不能用于匹配 T[j-1] 。那我们只能尝试在 S[0..i-2] 中寻找匹配 T[0..j-1] 的窗口。这个窗口的起点信息就存储在 dp[i-1][j] 中。所以 dp[i][j] = dp[i-1][j] 。 同样,如果 dp[i-1][j] 是 -1,那 dp[i][j] 也是 -1。 总结状态转移方程 : 在编程实现时,我们需要判断 dp[i-1][j-1] 是否为 -1 来决定是否赋值。 第四步:DP计算与答案获取 初始化 : 创建一个 (n+1) x (m+1) 的二维数组 dp ,用 -1 填充。 dp[0][0] = 0 。对于所有 i >= 0 , dp[i][0] = i ?等等,这里需要小心。 dp[i][0] 表示匹配空串 T ,空串是任何字符串的子序列,但我们的状态定义要求窗口的起始点。通常我们设定 dp[i][0] = i ,表示匹配空串的窗口是空窗口,起点在 i 。但这在后续计算中,当 j=1 且字符匹配时,我们会用 dp[i-1][0] 的值,即 i-1 ,这是合理的(表示从 i-1 开始匹配第一个字符)。一个更安全的初始化是: dp[i][0] = i 对于所有 i 从 0 到 n。 dp[0][j] = -1 对于所有 j 从 1 到 m (空S无法匹配非空T)。 递推填充 : 按照 i 从 1 到 n , j 从 1 到 m 的顺序循环。 根据上面的转移方程计算 dp[i][j] 。 寻找答案 : 在填充过程中,每当 j == m 且 dp[i][m] != -1 时,我们就找到了一个以 S[i-1] 结尾的、包含 T 作为子序列的窗口。 这个窗口的起始索引是 start = dp[i][m] ,结束索引是 i-1 ,长度为 len = i - start 。 我们维护一个全局最小的窗口长度 min_len 和对应的起始索引 min_start 。每当找到一个合法窗口,就更新它们。 最终输出 : 如果 min_len 仍然是初始化的一个大值(如 n+1),说明没找到,返回空字符串。 否则,返回 S.substr(min_start, min_len) 。 第五步:复杂度与示例推演 时间复杂度 : O(n * m),需要填充一个 (n+1) x (m+1) 的 DP 表。 空间复杂度 : O(n * m)。可以优化到 O(m) 或 O(n),因为我们递推时只依赖上一行 dp[i-1][:] 。但为了清晰,我们先理解二维版本。 用例子 S="abcdebdde", T="bde" 推演关键步骤 (n=9, m=3): 初始化后,我们计算几个关键状态: 计算 dp[2][1] ( i=2, j=1 , 对应 S[1]='b', T[0]='b' ): 字符相等,且 j==1,所以 dp[2][1] = i-1 = 1 。这意味着,在S的前2个字符中匹配T的第一个字符'b',最近起点是索引1。 计算 dp[4][3] ( i=4, j=3 , 对应 S[3]='d', T[2]='e' ): 字符不相等 ( d != e ),所以 dp[4][3] = dp[3][3] 。 dp[3][3] 可能来自更早的计算。 计算 dp[5][3] ( i=5, j=3 , 对应 S[4]='e', T[2]='e' ): 字符相等,且 j=3>1,所以 dp[5][3] = dp[4][2] 。假设我们之前算得 dp[4][2] 是某个值,比如 1(这表示在S[ 0..3 ]中匹配"bd"的起点是1)。 那么 dp[5][3] = 1 。这意味着,在S 0..4 中匹配整个"bde",窗口起点是1,终点是4,长度是4。这就是我们找到的候选答案"bcde"。 之后,我们还会在 i=8, j=3 (匹配 S[7]='e' ) 时,可能得到 dp[8][3] = 5 ,对应窗口"bdde",长度也是4,但起始索引5大于1,所以不更新最优解。 最终,我们得到的最短窗口就是起点为1,长度为4的"bcde"。 希望这个从暴力思路到状态定义,再到转移方程和计算过程的详细拆解,能帮助你完全理解“最小窗口子序列”这个线性动态规划问题的解法。