好的,我注意到“最小窗口子序列”这个题目还没有给你讲过。这是一个经典的线性动态规划问题,在字符串匹配和序列处理中有着重要应用。
最小窗口子序列
题目描述:
给定一个较长的字符串 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 = "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 中的每一个位置 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。
- 当前字符
总结状态转移方程:
如果 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计算与答案获取
-
初始化:
- 创建一个
(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。
- 字符相等,且 j==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"。
- 字符相等,且 j=3>1,所以
-
之后,我们还会在
i=8, j=3(匹配S[7]='e') 时,可能得到dp[8][3] = 5,对应窗口"bdde",长度也是4,但起始索引5大于1,所以不更新最优解。
最终,我们得到的最短窗口就是起点为1,长度为4的"bcde"。
希望这个从暴力思路到状态定义,再到转移方程和计算过程的详细拆解,能帮助你完全理解“最小窗口子序列”这个线性动态规划问题的解法。