「这是我参与2022首次更文挑战的第8天,活动详情查看:2022首次更文挑战」。
题目
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。
注意:
- 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
- 如果 s 中存在这样的子串,我们保证它是唯一的答案。
示例1
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
示例2
输入:s = "a", t = "a"
输出:"a"
示例3
输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。
思路
首先,按照题目的要求,如果 len(t) > len(s)
那么结果就是 ""
对于 s = "ADOBECODEBANC", t = "ABC"
,由于现在思路不是很清晰,可以尝试寻找一些规律:
字符串 s
每个位置的字符对应的索引坐标如下:
A D O B E C O D E B A N C
0 1 2 3 4 5 6 7 8 9 10 11 12
那么对于 t = "ABC"
,每个字符在 t
中的位置可能如下:
A: [0, 10]
B: [3, 9]
C: [5, 12]
那么涵盖 t
字符串的组合如下:
A B C
0 3 5
0 3 12
0 9 5
0 9 12
10 3 5
10 3 12
10 9 5
10 9 12
突然就发现,这不就变成了一个排列组合的问题么,找出所有组合中,最大值与最小值差距最小的组合,即为题目所求,于是便有了下面的解法:
/**
* @param {string} s
* @param {string} t
* @return {string}
*/
var minWindow = function (s, t) {
let minAnsLength = Number.MAX_VALUE, minAns = '', sLen = s.length, tLen = t.length;
if(tLen > sLen) return '';
// 用于将 s 转换为 word: num 的形式
let sMap = {};
for(let i = 0; i < sLen; i++) {
if(!sMap[s[i]]) {
sMap[s[i]] = [];
}
sMap[s[i]].push(i);
}
// 取出 t 中需要的 key
let vaildMap = {};
for(let j = 0; j < tLen; j++) {
// 如果 t 中有而 s 中没有,则 s 不可能涵盖 t
if(!sMap[t[j]]) {
return '';
}
vaildMap[t[j]] = sMap[t[j]];
}
// 存储所有的排列组合
let vaildArr = []
// 每次从map 取出 map[t.charAt(idx)], 是一个 Array
// 大概意思就是,对于上面的 例子 `s = "ADOBECODEBANC", t = "ABC"`
// 先选出 A,之后再选 B,再选 C,当 currentSet == tLen 说明选择完成,放入 vaildArr
const getDfs = (currentSet, idx, map) => {
// console.log('currentSet :', currentSet)
if(currentSet.length == tLen) {
vaildArr.push(currentSet);
return;
}
// 取出 A 所有的可能
let key = t.charAt(idx);
let codeArr = [...map[key]];
for(let i = 0; i < codeArr.length; i++) {
let copyArr = [...map[key]]
// 将当前已选择的 A 去除
let newItem = copyArr.splice(i, 1)[0];
staticMap[key] = copyArr;
let newSet = [...currentSet, newItem];
// console.log(`now is picker ${key}, choose ${newItem}`)
// 将idx + 1,去选择下一个位置 B
getDfs(newSet, idx + 1, staticMap)
}
}
// 从 A 开始选择
getDfs([], 0, vaildMap);
// 在所有的排列组合里,寻找最小的那个窗口
for(let i = 0; i < vaildArr.length; i++) {
let currentAnsStart = Math.min(...vaildArr[i]);
let currentAnsEnd = Math.max(...vaildArr[i]);
if(currentAnsEnd - currentAnsStart < minAnsLength) {
minAns = s.substring(currentAnsStart, currentAnsEnd + 1);
minAnsLength = currentAnsEnd - currentAnsStart;
}
}
return minAns;
};
反思: 上述代码的时间复杂度很高,在提交 LeetCode
的时候,果不其然的超时了~;分析其原因,是在寻找所有排列组合的时候,假设 t
有五种元素,每种元素在 s
中都有两种可能,那么时间复杂度就是 O(2 ^ 5)
,每当元素的种类增加,或者元素可能的位置增加,时间复杂度呈指数级增加的关系,而且好像还不太好优化~
再思考: 其实可以用一个变化的窗口,来解决当前问题,步骤如下:
-
1、初始化窗口的位置
left = 0, right = 0
,定义needs、window
为Map
类型,用于分别存储t、窗口
中每种元素的数量,定义valid
为当前窗口已匹配s
中种类的数量 -
2、遍历
t
,初始化needs
-
3、当窗口的右边界
right < len(s)
-
判断当前的
s[right]
是否在needs
中- 存在则将窗口中
window
当前元素的数量window[s[right]] + 1
,同时接着判断当前种类的元素s[right]
是否全部都在窗口中window[s[right]] == needs[s[right]]
,若相等,则将valid++
- 存在则将窗口中
-
如果这个时候
valid === Object.keys(needs)
,说明当前窗口涵盖了t
-
计算当前窗口的大小和之前窗口的大小,取较小的
-
尝试将窗口的左边界右移,减小窗口的长度,过程中需要判断右移之后,当前窗口是否还仍然涵盖
t
,不满足后,需要将valid--
,用于退出left
右移的循环
-
-
移动窗口解法
/**
* @param {string} s
* @param {string} t
* @return {string}
*/
var minWindow = function(s, t) {
let needs = {};
let window = {};
let left = 0, right = 0, len = Number.MAX_VALUE, start = 0;
let valid = 0;
// 初始化 needs
for(let i of t) {
needs[i] = (needs[i] || 0) + 1;
}
let keyLen = Object.keys(needs).length;
// 循环
while(right < s.length) {
let c = s[right];
// 将窗口右移
right++;
// 判断当前元素是否在needs中
if(needs[c]) {
// 将窗口中的元素标识也 + 1
window[c] = (window[c] || 0) + 1;
// 判断当前元素种类是否匹配完成
if(window[c] == needs[c]) {
valid++;
}
}
// 当前窗口涵盖了 t
while(valid === keyLen) {
// 保留最小窗口
if(right - left < len) {
len = right - left;
start = left;
}
let d = s[left];
// 尝试减小窗口
left++;
if(needs[d]) {
// 窗口不能完全涵盖 s
if(window[d] == needs[d]) {
valid--;
}
window[d]--
}
}
}
return len == Number.MAX_VALUE ? "" : s.substr(start, len);
};
小结
递归还是需要慎用,一不小心便会 out of memory
移动可变窗口,对于涵盖问题,可能是一个不错的思路~
# LeetCode 👉 HOT 100 👉 最小覆盖子串 - 中等题 ✅
合集:LeetCode 👉 HOT 100,有空就会更新,大家多多支持,点个赞👍
如果大家有好的解法,或者发现本文理解不对的地方,欢迎留言评论 😄