最小覆盖子串

给定两个字符串 st,长度分别是 mn,返回 s 中的 最短窗口子串,使得该子串包含 t 中的每一个字符,包括重复字符。

如果没有这样的子串,返回空字符串 ""

测试用例保证答案唯一。

示例 1:

输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。

示例 2:

输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。

示例 3:

输入:s = "a", t = "aa"
输出:""
解释:t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。

提示:

  • m == s.length
  • n == t.length
  • 1 <= m, n <= 10^5
  • st 由英文字母组成

进阶: 你能设计一个在 O(m + n) 时间内解决此问题的算法吗?

滑动窗口-哈希计数:

class Solution {
public:
    string minWindow(string s, string t) {
        if (s.size() < t.size())
        {
            return "";
        }

        vector<int> need(128, 0);
        vector<int> window(128, 0);

        int required = 0;
        for (char c : t)
        {
            if (need[c] == 0)
            {
                required++;
            }
            need[c]++;
        }

        int formed = 0;
        int left = 0;
        int start = 0;
        int minLen = INT_MAX;

        for (int right = 0; right < s.size(); right++)
        {
            char c = s[right];
            window[c]++;

            if (need[c] > 0 && window[c] == need[c])
            {
                formed++;
            }

            while (formed == required)
            {
                if (right - left + 1 < minLen)
                {
                    minLen = right - left + 1;
                    start = left;
                }

                char d = s[left];
                if (need[d] > 0 && window[d] == need[d])
                {
                    formed--;
                }
                window[d]--;
                left++;
            }
        }

        if (minLen == INT_MAX)
        {
            return "";
        }

        return s.substr(start, minLen);
    }
};

核心思想

这题要求的是 s 中最短的一段连续子串,并且这段子串要覆盖 t 中的所有字符。

注意,这里说的是“包含每一个字符”,而且包括重复字符。

例如:

t = "AABC"

那么合法窗口中必须至少有:

  • 2'A'
  • 1'B'
  • 1'C'

只包含一个 'A' 是不够的。

所以我们不能只判断字符是否出现,而要判断每个字符出现的次数是否达到要求。

核心思路是滑动窗口:

  1. 右边界 right 不断向右移动,扩大窗口,直到窗口覆盖 t
  2. 当窗口已经合法时,左边界 left 尽量向右移动,缩小窗口,寻找当前右边界下的最短合法窗口。
  3. 每次窗口合法时,都用当前窗口长度更新答案。

状态含义

代码中有两个计数数组:

vector<int> need(128, 0);
vector<int> window(128, 0);

其中:

  • need[c]:字符 ct 中需要出现的次数
  • window[c]:字符 c 在当前窗口 [left, right] 中出现的次数

再看两个变量:

int required;
int formed;
  • requiredt 中一共有多少种不同字符需要满足
  • formed:当前窗口中已经有多少种字符满足了需要的次数

当:

formed == required

说明 t 中每一种字符都已经在窗口里出现了足够次数。

也就是说,当前窗口是一个合法窗口。

合法窗口的判断条件

窗口 [left, right] 合法,当且仅当对于 t 中出现过的每个字符 c,都有:

window[c] >= need[c]

也就是说,窗口中每种字符的数量都不少于 t 中要求的数量。

如果每次都遍历所有字符检查这个条件,也可以通过,因为英文字母种类有限。

但代码使用了更高效、更清晰的 formed 来记录有多少种字符已经达标。

当加入一个字符 c 后:

window[c]++;

如果此时刚好满足:

need[c] > 0 && window[c] == need[c]

说明字符 c 从“不达标”变成了“达标”,所以:

formed++;

当移除左端字符 d 前,如果满足:

need[d] > 0 && window[d] == need[d]

说明这个字符现在刚好达标。

如果把它移除,窗口里这个字符就会变成不达标,所以要先:

formed--;

再执行:

window[d]--;

为什么先扩张再收缩

右边界 right 扩张的目的,是让窗口从不合法变成合法。

formed < required 时,说明窗口还缺少某些字符,或者某些字符数量不够。

这时缩小窗口没有意义,因为缩小只会让字符更少,更不可能变合法。

所以必须继续移动 right

formed == required 时,说明当前窗口已经覆盖了 t

但它不一定是最短的,因为左边可能包含一些多余字符。

所以此时不断移动 left,尝试删除窗口左侧字符。

只要删除后窗口仍然合法,就说明原来的窗口还可以更短。

直到某一次删除会导致窗口不合法,当前这一轮收缩才停止。

这样对于每一个右边界,算法都能找到以它为右端点的最短合法窗口。

公式推导

当前窗口范围是:

[left, right]

窗口长度是:

right - left + 1

formed == required 时,窗口合法,可以用当前长度更新答案:

if (right - left + 1 < minLen)
{
    minLen = right - left + 1;
    start = left;
}

然后继续移动 left,尝试得到更短的合法窗口。

如果移除 s[left] 后窗口仍然合法,那么新的窗口长度更短,继续更新。

如果移除 s[left] 后窗口不合法,说明当前右边界下已经不能再缩短了,需要继续移动 right 寻找新的合法窗口。

为什么重复字符也能正确处理

这题最容易出错的地方就是重复字符。

例如:

s = "AAAB"
t = "AAB"

t 里面需要两个 'A'

如果窗口里只有一个 'A',不能算覆盖。

代码通过 need[c]window[c] 的具体次数来判断是否达标:

window[c] == need[c]

只有当窗口里的数量达到 t 的要求时,formed 才会增加。

如果窗口里某个字符数量超过要求,例如需要 2'A',窗口里有 3'A',那么它仍然只算一种字符达标,不会重复增加 formed

这保证了重复字符会被正确处理。

正确性证明

我们证明:算法返回的字符串是 s 中覆盖 t 的最短子串。

结论 1:算法记录的每个候选答案都是合法窗口

算法只会在:

while (formed == required)

内部更新答案。

formed == required 表示 t 中每一种字符在当前窗口中都已经达到需要的次数。

也就是对于所有 t 中出现过的字符 c,都有:

window[c] >= need[c]

所以当前窗口一定覆盖了 t

因此算法记录的每个候选答案都是合法窗口。

结论 2:对于每个右端点,算法都会找到最短合法窗口

固定某个右端点 right

当窗口 [left, right] 第一次满足 formed == required 时,它是一个合法窗口。

接下来算法会不断右移 left

每移动一次,如果窗口仍然合法,就继续更新答案并继续收缩。

直到移除某个左端字符后,窗口不再合法,收缩停止。

因此,在这个固定的 right 下,算法已经尝试了所有可以继续缩短的合法窗口。

最后一次被记录的合法窗口,就是以当前 right 为右端点的最短合法窗口。

结论 3:全局最短窗口一定会被枚举到

设全局最短覆盖子串是:

s[L ... R]

当算法的右端点移动到 R 时,左端点一定会在收缩过程中尽量右移。

因为 s[L ... R] 是合法窗口,所以算法在 right = R 时一定会进入收缩过程。

又因为 s[L ... R] 是以 R 为右端点的最短合法窗口之一,算法收缩到这个位置时会记录它。

题目保证答案唯一,所以最终记录的最短窗口就是这个答案。

得出结论

由结论 1 可知,算法不会记录非法窗口。

由结论 2 可知,对于每个右端点,算法不会漏掉该右端点下的最短合法窗口。

由结论 3 可知,全局最短合法窗口一定会被算法记录。

所以算法返回的结果正确。

举例理解

以:

s = "ADOBECODEBANC", t = "ABC"

为例。

t 中需要:

  • 'A'1
  • 'B'1
  • 'C'1

开始时不断移动 right

当窗口第一次变成:

"ADOBEC"

它已经包含 ABC,所以合法,先记录长度 6

然后尝试移动 left

如果去掉左边的 'A',窗口就不再包含 A,所以收缩停止。

接着继续右移 right,直到后面再次形成合法窗口。

当扫描到:

"BANC"

时,它包含 ABC,长度为 4,比之前更短,所以更新答案。

最终返回 "BANC"

复杂度分析

m = s.lengthn = t.length

初始化 need 数组需要遍历 t,时间复杂度是:

O(n)

滑动窗口中:

  • right 从左到右最多移动 m
  • left 从左到右最多移动 m

所以窗口整体扫描时间复杂度是:

O(m)

因此总时间复杂度是:

O(m + n)

额外使用两个长度为 128 的计数数组,所以空间复杂度是:

O(1)