徐姣 2021年4月21日
一、滑动窗口相关概念
1.1 概念
滑动窗口算法可以用以解决数组/字符串的子元素问题,它可以将嵌套的循环问题,转换为单循环问题,降低时间复杂度。
1.1 应用场景的特点
- 需要输出或比较的结果在原数据结构中是连续排列的
- 每次窗口滑动时,只需观察窗口两端元素的变化,无论窗口多长,每次只操作两个头尾元素,当用到的窗口比较长时,可以显著减少操作次数
- 窗口内元素的整体性比较强,窗口滑动可以只通过操作头尾两个位置的变化实现,但对比结果时往往要用到窗口中所有元素
二、滑动窗口的解题思路
2.1 常见需要使用滑动窗口的算法
-
无重复字符的最长子串:给定一个字符串,请你找出其中不含有重复字符的最长子串的长度
-
长度最小的子数组:给定一个含有 n 个正整数的数组和一个正整数 target 。找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0
-
滑动窗口最大值: 给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。
-
最小覆盖子串: 给你一个字符串s, 一个字符串t 。返回s 中涵盖t所有字符的最小子串。如果s中不存在涵盖 t所有字符的子串,则返回空字符串'' 。
2.2 解题步骤
- 初始化滑动窗口左右指针:最小覆盖子串求解中,left = 0; right = t.length
- 边界条件判断,异常处理: 最小覆盖子串求解中,s.lenght < t.length情况处理;
- 一般需要循环,确定跳出循环的条件: 最小覆盖子串求解中右指针大于字符串、数组长度 right > s.length, 跳出循环
- 确定窗口左右指针移动的条件:
- 什么场景下左指针移动: 最小覆盖子串求解中,滑动窗口中包含子串t, 左指针移动
- 什么场景下右指针移动:最小覆盖子串求解中,滑动窗口中不包含子串t,右指针移动
三、滑动窗口经常用到的知识点有哪些
3.1 如何判断a字符串包含b字符串
- 首先a.length >= b.length
- 排序后:暴力判断,基本超时
- sortA = a.split('').sort().join('')
- sortB = b.split('').sort().join('');
- sortA.indexOf(sortB) > -1
- 使用对象:提高查找效率
- 将a, b字符串处理成{字符:个数}的形式
- b中的字符在a中都存在, 切b中同一个字符的个数<= a字符串中的数量
- 使用JavaScript中的Map对象
- Map.prototype.size: 返回Map对象中的个数
- Map.prototype.set(value): 添加一个元素
- Map.prototype.get(value): 获取一个元素
- Map.prototype.has(value): 是否存在一个元素
- Map.prototype.keys(): 返回一个引用的 Iterator对象。它包含按照顺序插入Map 对象中每个元素的key值
- Map.prototype.values(): 返回一个引用的 Iterator对象。它包含按照顺序插入Map 对象中每个元素的values值
3.2 如何找出数组中最大值和最小值
- 排序后: 暴力解决,基本超时
- const sortA = a.sort((x, y) => x - y);
- 最大值:sortA[a.length - 1]
- 最小值:sortA[0]
- 使用Math方法
- 最大值:Math.max.apply(null, a);
- 最小值:Math.min.apply(null, a);
- ES6方法:Math.max(...a);
四、常见滑动窗口 -- 最小子串求解
4.1 描述:
给你一个字符串s, 一个字符串t 。返回s 中涵盖t所有字符的最小子串。如果s中不存在涵盖 t所有字符的子串,则返回空字符串'' 。
4.2 示例:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
输入:s = "a", t = "a"
输出:"a"
4.3 图解步骤:
-
初始化阶段:s = "ADBECOEBAC" t = "ABC"
-
初始化窗口左右指针:left = 0; right = 2(t.length - 1)
-
初始化最小字符串:minLength = "ADBECOEBACABC"; (s + t)
-
-
第1轮判断:窗口字符串ADB是否包含ABC;不包含:right指针右移;
-
第2轮判断: 窗口字符串ADBE是否包含ABC;不包含:right指针右移
-
第3轮判断:窗口字符串ADBEC是否包含ABC;包含:
-
minStr取初始值“ADBECOEBACABC”和当前窗口字符串“ADBEC”长度最小的,即此时minStr = "ADBEC";
-
left指针右移
-
-
第4轮判断:窗口字符串DBEC是否包含ABC, 不包含:right指针右移
-
第5轮判断:窗口字符串DBECO是否包含ABC, 不包含:right指针右移
-
第6轮判断:窗口字符串DBECOE是否包含ABC, 不包含:right指针右移
-
第7轮判断:窗口字符串DBECOEB是否包含ABC, 不包含:right指针右移
-
第8轮判断:窗口字符串DBECOEBA是否包含ABC;包含:
-
minStr取初上次值“ADBEC”和当前窗口字符串“DBECOEBA”长度最小的,即此时minStr = "ADBEC";
-
left指针右移
-
-
第9轮判断:窗口字符串BECOEBA是否包含ABC;包含:
-
minStr取初上次值“ADBEC”和当前窗口字符串“BECOEBA”长度最小的,即此时minStr = "ADBEC";
-
left指针右移
-
-
第10轮判断:窗口字符串ECOEBA是否包含ABC;包含:
-
minStr取初上次值“ADBEC”和当前窗口字符串“ECOEBA”长度最小的,即此时minStr = "ADBEC";
-
left指针右移
-
-
第11轮判断:窗口字符串COEBA是否包含ABC;包含:
-
minStr取初上次值“ADBEC”和当前窗口字符串“COEBA”长度最小的,即此时minStr = "ADBEC";
-
left指针右移
-
-
第12轮判断:窗口字符串OEBA是否包含ABC;不包含:right指针右移
-
第13轮判断:窗口字符串OEBAC是否包含ABC;包含:
-
minStr取初上次值“ADBEC”和当前窗口字符串“OEBAC”长度最小的,即此时minStr = "ADBEC";
-
left指针右移
-
-
第14轮判断:窗口字符串EBAC是否包含ABC;包含:
-
minStr取初上次值“ADBEC”和当前窗口字符串“EBAC”长度最小的,即此时minStr = "EBAC";
-
left指针右移
-
-
第15轮判断:窗口字符串BAC是否包含ABC;包含:
-
minStr取初上次值“ADBEC”和当前窗口字符串“BAC”长度最小的,即此时minStr = "BAC";
-
left指针右移
-
-
第16轮判断:right指针达到最大值s.length -1 ; 窗口字符串AC长度小于比较字符串ABC, 跳出循环
- 返回最终结果minStr = "BAC"
4.4 解题中需要注意的点:如何判断窗口字符串a包含需要比较的字符串b;
## 方法一:字符串排序后,用indexOf
/**
* 暴力排序解决,sort很耗费性能,基本超时用例过不了
*/
const isInclude = (a, b) => {
const sortA = a.split('').sort((x, y) => x -y).join('');
const sortB = b.split('').sort((x, y) => x -y).join('');
return sortA.indexOf(sortB) > -1;
};
## 方法二:用Map方法,统计a中是否包含B中的字符个数,同一个字符a中要大于等于b 【也可以直接使用JavaScript中的object对象】, 对象的存取效率高
function createMap (b) {
const map = new Map();
[...b].forEach(res => {
map.has(res) ? map[res]++ : map(res) = 1;
});
return map;
}
function isContainerStr(a, b) {
const mapA = createMap(a);
const mapB = createMap(b);
const keys = mapB.keys();
}
4.5 实现
function createMap (b) {
const map = new Map();
[...b].forEach(res => {
const value = map.has(res) ? map.get(res) + 1 : 1;
map.set(res, value);
});
return map;
}
function isContainedStr(mapA, mapB) {
let isContainer;
mapB.forEach((value, key) => {
if (!mapA.has(key) || mapA.get(key) < value) {
isContainer = false;
}
});
return isContainer === undefined;
}
function minWindow (s, t) {
const tLength = t.length;
const sLength = s.length;
const con = s + t;
if (sLength < tLength) {return '';}
// 初始化滑动窗口左右指针
let left = 0;
let right = tLength;
let minStr = con;
const tObj = createMap(t);
while (right <= sLength) {
const subStr = s.substring(left, right);
const subObj = createMap(subStr);
const isContainer = isContainedStr(subObj, tObj);
// 包含子串t
if (isContainer) {
minStr = subStr.length < minStr.length ? subStr : minStr;
left++;
} else {
right++;
}
}
return minStr === con ? '' : minStr;
};
4.6 上面的解法超时了,寻找优化点:
- s = ADBECOEBAC 包含 t = ABC最新小的串是最后的BAC, 他的特点是: 连续的,我们中间计算时, 不需要记录中间串值,只需要记录left指针,和最小串的长度minLen。那么最终结果是:minStr = s.substr(left, minLen);
- isContainedStr比较的时候使用forEach无法跳出循环,事实上只需要有一个key不满足要求,就可以终止循环并跳出。
- 在循环s字符串循环过程中维护一个object(subObj),right++时添加添加字符或者字符数量加1,left++时删除字符或者字符数量减1。
4.7 优化实现
function minWindow (s, t) {
// 窗口字符串生成的字符对象
let subObj = {};
// 比较字符串t所生成的字符对象,如:t="ABC",则objT = {A: 1, B: 1, C: 1}
let objT = {};
[...t].forEach(char => objT[char] ? objT[char]++ : objT[char] = 1);
// 初始化窗口的左右指针,因为需要再循环中生成窗口对象subObj,所以right要从0开始循环了
let left = 0;
let right = 0;
// 初始化最小字符串的长度(默认无限大)
let minLen = Infinity;
// 初始化最小子串的起始点
let start = -1;
let keyLen = Object.keys(objT).length;
// 记录窗口字符串中和objT中字符数量一致的有多少字符,如果数量和keyLen一致,我们就认为当前的窗口字符串包含t
let count = 0;
while (right < s.length) {
// 右边指针当前指向的字符
let char = s[right++];
// 循环中维护窗口字符串对象,无需内部再次循环比较
subObj[char] ? subObj[char]++ : subObj[char] = 1;
/** 循环过程中,如果t中的字符在窗口对象中都找到了,且数量相等。
* 我们记住此时的起始值start: left, 因为left后续会变动
** 则此时的窗口字符串:minStr = s.substring(left, right)包含我们t
**/
if (subObj[char] === objT[char]) {count++;}
while(count === keyLen) {
if (right - left < minLen) {
start = left;
minLen = right - left;
}
// 满足条件下尽量收缩求自小子串: left指针右移
let c2 = s[left++];
if (subObj[c2]-- === objT[c2]) {count--;}
}
}
return start === -1 ? '' : s.substr(start, minLen)
};