Skip to content

Commit cb02923

Browse files
committed
285
1 parent 7e1837d commit cb02923

File tree

2 files changed

+182
-1
lines changed

2 files changed

+182
-1
lines changed

readme.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
前端界的好文精读,每周更新!
88

9-
最新精读:<a href="./算法/284.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%E9%A2%98%20-%20%E7%BB%9F%E8%AE%A1%E5%8F%AF%E4%BB%A5%E8%A2%AB%20K%20%E6%95%B4%E9%99%A4%E7%9A%84%E4%B8%8B%E6%A0%87%E5%AF%B9%E6%95%B0%E7%9B%AE%E3%80%8B.md">284.精读《算法题 - 统计可以被 K 整除的下标对数目》</a>
9+
最新精读:<a href="./算法/285.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%E9%A2%98%20-%20%E6%9C%80%E5%B0%8F%E8%A6%86%E7%9B%96%E5%AD%90%E4%B8%B2%E3%80%8B.md">285.精读《算法题 - 最小覆盖子串》</a>
1010

1111
素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2)
1212

@@ -303,6 +303,7 @@
303303
- <a href="./算法/203.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%20-%20%E4%BA%8C%E5%8F%89%E6%90%9C%E7%B4%A2%E6%A0%91%E3%80%8B.md">203.精读《算法 - 二叉搜索树》</a>
304304
- <a href="./算法/283.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%E9%A2%98%20-%20%E9%80%9A%E9%85%8D%E7%AC%A6%E5%8C%B9%E9%85%8D%E3%80%8B.md">283.精读《算法题 - 通配符匹配》</a>
305305
- <a href="./算法/284.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%E9%A2%98%20-%20%E7%BB%9F%E8%AE%A1%E5%8F%AF%E4%BB%A5%E8%A2%AB%20K%20%E6%95%B4%E9%99%A4%E7%9A%84%E4%B8%8B%E6%A0%87%E5%AF%B9%E6%95%B0%E7%9B%AE%E3%80%8B.md">284.精读《算法题 - 统计可以被 K 整除的下标对数目》</a>
306+
- <a href="./算法/285.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%E9%A2%98%20-%20%E6%9C%80%E5%B0%8F%E8%A6%86%E7%9B%96%E5%AD%90%E4%B8%B2%E3%80%8B.md">285.精读《算法题 - 最小覆盖子串》</a>
306307

307308
### 可视化搭建
308309

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
今天我们看一道 leetcode hard 难度题目:[最小覆盖子串](https://leetcode.cn/problems/minimum-window-substring/description/)
2+
3+
## 题目
4+
5+
给你一个字符串 `s` 、一个字符串 `t` 。返回 `s` 中涵盖 `t` 所有字符的最小子串。如果 `s` 中不存在涵盖 `t` 所有字符的子串,则返回空字符串 `""`
6+
7+
注意:
8+
9+
对于 `t` 中重复字符,我们寻找的子字符串中该字符数量必须不少于 `t` 中该字符数量。
10+
如果 `s` 中存在这样的子串,我们保证它是唯一的答案。
11+
12+
示例 1:
13+
```
14+
输入:s = "ADOBECODEBANC", t = "ABC"
15+
输出:"BANC"
16+
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
17+
```
18+
19+
## 思考
20+
21+
最容易想到的思路是,s 从下标 0~n 形成的子串逐个判断是否满足条件,如:
22+
23+
- ADOBEC..
24+
- DOBECO..
25+
- OBECOD..
26+
27+
因为最小覆盖子串是连续的,所以该方法可以保证遍历到所有满足条件的子串。代码如下:
28+
29+
```js
30+
function minWindow(s: string, t: string): string {
31+
// t 剩余匹配总长度
32+
let tLeftSize = t.length
33+
// t 每个字母对应出现次数表
34+
const tCharCountMap = {}
35+
36+
for (const char of t) {
37+
if (!tCharCountMap[char]) {
38+
tCharCountMap[char] = 0
39+
}
40+
tCharCountMap[char]++
41+
}
42+
43+
let globalResult = ''
44+
45+
for (let i = 0; i < s.length; i++) {
46+
let currentResult = ''
47+
let currentTLeftSize = tLeftSize
48+
const currentTCharCountMap = { ...tCharCountMap }
49+
50+
// 找到以 i 下标开头,满足条件的字符串
51+
for (let j = i; j < s.length; j++) {
52+
currentResult += s[j]
53+
54+
// 如果这一项在 t 中存在,则减 1
55+
if (currentTCharCountMap[s[j]] !== undefined && currentTCharCountMap[s[j]] !== 0) {
56+
currentTCharCountMap[s[j]]--
57+
currentTLeftSize--
58+
}
59+
60+
// 匹配完了
61+
if (currentTLeftSize === 0) {
62+
if (globalResult === '') {
63+
globalResult = currentResult
64+
} else if (currentResult.length < globalResult.length) {
65+
globalResult = currentResult
66+
}
67+
break
68+
}
69+
}
70+
}
71+
72+
return globalResult
73+
};
74+
```
75+
76+
我们用 `tCharCountMap` 存储 `t` 中每个字符出现的次数,在遍历时每次找到出现过的字符就减去 1,直到 `tLeftSize` 变成 0,表示 `s` 完全覆盖了 `t`
77+
78+
这个方法因为执行了 n + n-1 + n-2 + ... + 1 次,所以时间复杂度是 O(n²),无法 AC,因此我们要寻找更快捷的方案。
79+
80+
## 滑动窗口
81+
82+
追求性能的降级方案是滑动窗口或动态规划,该题目计算的是字符串,不适合用动态规划。
83+
84+
那滑动窗口是否合适呢?
85+
86+
该题要计算的是满足条件的子串,该子串肯定是连续的,滑动窗口在连续子串匹配问题上是不会遗漏结果的,所以肯定可以用这个方案。
87+
88+
思路也很容易想,即:**如果当前字符串覆盖 `t`,左指针右移,否则右指针右移**。就像一个窗口扫描是否满足条件,需要右指针右移判断是否满足条件,满足条件后不一定是最优的,需要左指针继续右移找寻其他答案。
89+
90+
这里有一个难点是如何高效判断当前窗口内字符串是否覆盖 `t`,有三种想法:
91+
92+
第一种想法是对每个字符做一个计数器,再做一个总计数器,每当匹配到一个字符,当前字符计数器与总计数器 +1,这样直接用总计数器就能判断了。但这个方法有个漏洞,即总计数器没有包含字符类型,比如连续匹配 100 个 `b`,总计数器都 +1,此时其实缺的是 `c`,那么当 `c` 匹配到了之后,总计数器的值并不能判定出覆盖了。
93+
94+
第一种方法的优化版本可能是二进制,比如用 26 个 01 表示,但可惜每个字符出现的次数会超过 1,并不是布尔类型,所以用这种方式取巧也不行。
95+
96+
第二种方法是笨方法,每次递归时都判断下 s 字符串当前每个字符收集的数量是否超过 t 字符串每个字符出现的数量,坏处是每次递归都至多多循环 25 次。
97+
98+
笔者想到的第三种方法是,还是需要一个计数器,但这个计数器 `notCoverChar` 是一个 `Set<string>` 类型,记录了每个 char 是否未 ready,所谓 ready 即该 char 在当前窗口内出现的次数 >= 该 char 在 `t` 字符串中出现的次数。同时还需要有 `sCharMap``tCharMap` 来记录两个字符串每个字符出现的次数,当右指针右移时,`sCharMap` 对应 `char` 计数增加,如果该 `char` 出现次数超过 `t``char` 出现次数,就从 `notCoverChar` 中移除;当左指针右移时,`sCharMap` 对应 `char` 计数减少,如果该 `char` 出现次数低于 `t``char` 出现次数,该 `char` 重新放到 `notCoverChar` 中。
99+
100+
代码如下:
101+
102+
```js
103+
function minWindow(s: string, t: string): string {
104+
// s 每个字母出现次数表
105+
const sCharMap = {}
106+
// t 每个字母对应出现次数表
107+
const tCharMap = {}
108+
// 未覆盖的字符有哪些
109+
const notCoverChar = new Set<string>()
110+
111+
// 计算各字符在 t 出现次数
112+
for (const char of t) {
113+
if (!tCharMap[char]) {
114+
tCharMap[char] = 0
115+
}
116+
tCharMap[char]++
117+
notCoverChar.add(char)
118+
}
119+
120+
let leftIndex = 0
121+
let rightIndex = -1
122+
let result = ''
123+
let currentStr = ''
124+
125+
// leftIndex | rightIndex 超限才会停止
126+
while (leftIndex < s.length && rightIndex < s.length) {
127+
// 未覆盖的条件:notCoverChar 长度 > 0
128+
if (notCoverChar.size > 0) {
129+
// 此时窗口没有 cover t,rightIndex 右移寻找
130+
rightIndex++
131+
const nextChar = s[rightIndex]
132+
currentStr += nextChar
133+
if (sCharMap[nextChar] === undefined) {
134+
sCharMap[nextChar] = 0
135+
}
136+
sCharMap[nextChar]++
137+
// 如果 tCharMap 有这个 nextChar, 且已收集数量超过 t 中数量,此 char ready
138+
if (
139+
tCharMap[nextChar] !== undefined &&
140+
sCharMap[nextChar] >= tCharMap[nextChar]
141+
) {
142+
notCoverChar.delete(nextChar)
143+
}
144+
} else {
145+
// 此时窗口正好 cover t,记录最短结果
146+
if (result === '') {
147+
result = currentStr
148+
} else if (currentStr.length < result.length) {
149+
result = currentStr
150+
}
151+
// leftIndex 即将右移,将 sCharMap 中对应 char 数量减 1
152+
const previousChar = s[leftIndex]
153+
sCharMap[previousChar]--
154+
// 如果 previousChar 在 sCharMap 数量少于 tCharMap 数量,则不能 cover
155+
if (sCharMap[previousChar] < tCharMap[previousChar]) {
156+
notCoverChar.add(previousChar)
157+
}
158+
// leftIndex 右移
159+
leftIndex++
160+
currentStr = currentStr.slice(1, currentStr.length)
161+
}
162+
}
163+
164+
return result
165+
};
166+
```
167+
168+
其中还用了一些小缓存,比如 `currentStr` 记录当前窗口内字符串,这样当可以覆盖 `t` 时,随时可以拿到当前字符串,而不需要根据左右指针重新遍历。
169+
170+
## 总结
171+
172+
该题首先要排除动态规划,并根据连续子串特性第一时间想到滑动窗口可以覆盖到所有可能性。
173+
174+
滑动窗口方案想到后,需要想到如何高性能判断当前窗口内字符串可以覆盖 `t``notCoverChar` 就是一种不错的思路。
175+
176+
> 讨论地址是:[精读《算法 - 最小覆盖子串》· Issue #496 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/496)
177+
178+
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
179+
180+
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)

0 commit comments

Comments
 (0)