题目网址:https://leetcode-cn.com/problems/minimum-window-substring/
题目分析
这道题目,明显之处在于,我们需要在字符串 s
中框出一个窗口,来判断这个窗口中的子串是否覆盖了 t
,如下图所示。就是要判断黄色窗口内的子串,有没有覆盖字符串 t
。
由此一来,我们需要解决两个很重要的问题。一是如何判断黄色窗口子串有没有覆盖 t
,二是如何得到滑动窗口。
如何判断是否覆盖
假设我们有两个字符串,分别为 s1 = "OBECOEB"
(上图中的黄色区域)和 t = "ABC"
。如何判断 s1
有没有覆盖 t
呢?
我们可以分别统计字符串 s1
和 t
中各字符的个数,得到下图。我们可以看到,t
中有 1 个 A
,而 s1
中没有,因此 s1
不能覆盖 t
。
再比如,有字符串 s2 = "BANC"
,t
包含的字符均在 s2
中出现,且 t
中各字符的数量,都小于等于 s2
中相应字符的数量。因此,s2
覆盖了 t
。
并且,我们只需要关注那些出现在 t
中的字符,可以忽视掉那些不出现在 t
中的字符。比如,s1
中的 E
和 O
,它没有出现在 t
中,我们可以忽略它。同时为了节省空间,我们实际上保存好 t
和 s1
各字符个数的差值就好。
我们只关心出现在 t
中的字符,把 t
中这些字符的个数减去 s1
中相应字符的个数,结果如下图。字符 A
的结果是正数,说明还有一个 A
没有被覆盖,因此字符串 s1
未能覆盖 t
。
但是,窗口是在不断变化的。如果每次变化窗口,都需要重新计算窗口中各字符的数量,且都要与 t
中相应字符的数量做差,这样做是很低效的。但事实上并非如此。
假设我们已经有了一个滑动窗口,窗口左、右下标分别为 l
和 r
。我们可以观察到,无论是向右移动 l
还是 r
,都是只有一个字符的改变。例如,把 l
向右移动一位,字符 O
减少一个。实际上,我们只要得到一个窗口的各字符数量与 t
的差值,就可以快速地得到下一个窗口与 t
各字符数量的差值。
再进一步。
现在我们可以得到 t
与各个窗口中字符的差值。这是不是意味着我们每得到一个窗口,都需要来看看差值中每个数都小于等于 0?
其实,我们可以使用空间换时间的方法,再用一个变量 cnt
来表示覆盖的数量。还是字符串 s1 = "OBECOEB"
和 t = "ABC"
,s1
实际上只覆盖 t
的 B
和 C
两个字符,此时 cnt
等于 2,小于 t
的长度。很明显,s1
未能覆盖 t
。
而窗口 s2 = "BANC"
的 cnt
为 3,恰好等于 t
的长度,因此 s2
覆盖了 t
。
到这里,我们已经解决了第一个问题,如何判断子串有没有覆盖。接下来解决第二个问题,如何得到滑动窗口。
滑动窗口
同样,我们用下标 l
和 r
来表示区间 [l, r]
中的子串。当然,我们可以枚举,把所有可能的子串都枚举出来,但这没有必要。
我们让 l
和 r
都从 0 出发,并且可以得到字符差值,如下图所示。
此时 cnt
为 1,只满足了一个 A
,不满足覆盖的条件。当不满足覆盖条件时,我们要尽可能地扩大窗口,也即让下标 r
向右移动。
当下标 r
向右移动一位,指向字符 D
,D
并没有出现在字符串 t
中,它是我们不关心的字符,我们可以直接忽略,继续向下移动,遇到字符 O
时同样忽略。
当 r
向右移动指向 B
时,如下图。此时 cnt
为 2,也不满足覆盖条件,继续向右移动 r
,直到指向 C
。这时 cnt
等于 t
的长度,满足覆盖条件。
当 r
指向 C
时,如下图所示。此时 cnt
等于 t
的长度,我们找到了一个答案。我们的目的是找到更短地字符串,因此找到答案之后,我们要缩小窗口,即把 l
向右移动。
当 l
向右移动一位,指向字符 D
,由于失去了字符 A
,因此 cnt
减为 2。实际上,D
不在字符串 t
中,我们可以忽略,同理忽略 O
,l
一直向右移动指向 B
,如下图所示。
此时 cnt
2,不满足覆盖条件,我们继续把 r
向后移动,如下图。
这时的 r
指向 B
,虽然 B
在字符串 t
中,但窗口中 B
的个数超过了 t
中 B
的个数,而 A
又没有覆盖,因此 cnt
不用修改,还是 2。r
继续向后移动。
这个时候 cnt
等于 3,满足覆盖条件了,因此 l
向右移动。这时与上一次满足条件时的情况略有不同,这次 cnt
无需减 1。因为在差值中,B
为 -1,表示窗口中的子串相比于 t
多了一个 B
。虽然,l
向右移动少了一个 B
,但依旧满足覆盖条件。因此,l
只需向右移动,且 cnt
无需减 1。在这两步中,体现了差值的作用。
当 l
向右移动到下图,此时 cnt
依旧等于 3,满足条件,l
继续向右移动。
当 l
向下移动到这个情况时,cnt
等于 2,不满足条件,r
向后移动。
此时又满足了覆盖条件,l
继续向后移动,直到字符串末尾,程序结束。
综上所述,我们可以借用字符差值和 cnt
来判断覆盖情况。当不满足覆盖条件时,r
向右移动。当满足覆盖条件时,l
向右移动。现在进行代码实现。
代码实现
这里使用 C++ 实现,可以利用代码中的注释进行辅助理解,在实现细节上可能与上述过程略有出入。
class Solution {
public:
string minWindow(string s, string t) {
unordered_set<char> st; // 字符 t 中的字符
unordered_map<char, int> hash; // 字符 t 中各字符的数量
for (const char & c : t)
{
hash[c]++;
st.insert(c);
}
// 记录答案
string ans = "";
int min_len = s.length() + 1;
// 左边界 l,覆盖个数 cnt
int l = 0, cnt = 0;
// 不满足覆盖条件时,r 向后移动
for (int r = 0; r < s.length(); r++)
{
// 如果字符在 t 中,则执行;不存在则跳过
if (st.find(s[r]) != st.end())
{
// 修改差值和 cnt
hash[s[r]]--;
if (hash[s[r]] >= 0)
cnt++; // 属于覆盖条件,cnt++;当差值小于 0 的时候,说明这个字母是多出来的,不用 cnt++
// 如果满足答案
while (cnt == t.length())
{
// 如果此时答案更佳,则修改答案
if (r - l + 1 < min_len)
{
min_len = r - l + 1;
ans = s.substr(l, min_len);
}
// l 要准备向后移动一位,修改差值 和 cnt
hash[s[l]]++;
if (st.find(s[l]) != st.end() && hash[s[l]] > 0)
cnt--; // 当差值大于 0 的时候,说明这个字符无法覆盖了,cnt--
l++; // 满足条件,l 向后移动一位
}
}
}
return ans;
}
};