以数组为载体完成的任务中,有一类问题以「滑动窗口」为标签。这一类问题的思想其实并不复杂,但是在代码实现的时候,会有一些边界和细节的问题需要考虑。
对于绝大多数「滑动窗口」问题,一般而言,都需要先思考暴力解法,进而思考暴力解法是不是有可以优化的地方。
「暴力解法」通常以「二重循环」、「三重循环」的形式出现,优化的思路有:
以空间换时间:在遍历的过程中,记录变量的值,以使得每一次不同规模的区间的相关信息的计算不必从头开始
利用题目给出的性质,在枚举的过程中,能够一下子 排除很多不必要的方案 ,以降低时间复杂度
友情提示:滑动窗口的问题通常给我们的感觉是:这个问题并不难,但是我就是很难把它写对,应对这样的现象其实解决方案并不深奥,也是我们之前向大家多次提到过的:在程序出现问题的时候,一定不要忽视调试代码的作用。看一看我们在编写代码的过程中,是不是遵守了循环不变量,把这些变量的值打印出来看一下,或许问题就得到了解决。
「滑动窗口」算法的两个指针变量的移动方向是相同的,形成了一个「窗口」在直线上「滑动」的效果。我们以「力扣」第 3 题:无重复字符的最长子串为例进行讲解。
例题:「力扣」第 3 题:无重复字符的最长子串
这道题给我们一个字符串,要求我们找出这个字符串中不含有重复字符的 最长子串 的长度。首先我们需要弄清楚一个概念:「子串」,它通常区别于「子序列」。它们的区别是:
枚举这个字符串的所有子串;
对于每一个子串都判断一下这个子串是否有重复字符;
在从没有重复字符的所有子串中找出长度最长的那个,返回即可。
部分代码如下:
const lengthOfLongestSubstring = (s) => {let len = s.length;let maxLen = 1;// 枚举到 len - 2 即可for (let left = 0; left < len - 1; left++) {for (let right = 0; right < len; right++) {let subString = s.slice(left, right + 1);// 如果 subString 不包含重复元素,记录 subString 的长度,并且维护 maxLen// ... 代码省略}}return maxLen;}
说明:首先看到一个二重循环,并且最里层的判断「subString 不包含重复元素」这个方法也是线性的。因此整体的复杂度是 O(N)
下面我们分析,是否有必要暴力枚举左右边界。在枚举的过程中,假设有如下中间状态:




一开始的时候,
left与right重合,left不动,right尝试向右边扩张,直到[left, right]中有恰有 1 个重复元素如果在子区间
[left, right]中有重复元素,[left, right + 1]、[left, right + 2]一直到[left, len - 1]一定包含重复元素,这一点是这问题可以使用「滑动窗口」的原因。此时就得考虑left向右移动,这是因为:left不能向左移动:因为向左移动,仍然不能改变[left, right]中有恰有 1 个重复元素的现状;left只能向右移动,直到left刚刚好越过right指向的那个重复的元素为止。
接着有没有必要继续移动
left呢?答案是不可以,因为我们要求的是最长的子串的长度,此时的子串只是局部最长的。我们应该移动right以期待获得更长的不重复子串;这样的过程一直进行下去,直到
right到达字符串的末尾。
下面我们分析这个过程为什么比暴力枚举要快
- 当我们得到了一个有重复元素的子串的时候,和它 有相同前缀的所有子串 都会一下子被排除;
- 在判断子区间
[left, right]是否有重复字符的时候,我们不必每一次都做扫描。事实上,我们只需要开辟一个字符频数数组,让右边界进来的时候,字符频数加 1,此时检测是否有重复。当左边界滑出的时候,字符频数减 1,此时检测是否无重复; - 有重复字符的时候,尝试让左边界
left右移,尝试让区间内无重复字符。没有重复字符的时候,尝试右边界right右移,以尝试让区间长度更长。
以上就是解决这道问题的基本思路。这种right主动向右移动,left 被动向右移动的方式就是滑动窗口的思想,也叫「尺取法」或者「虫取法」。这个名字可以说是非常形象了,一个资深的裁缝为我们量体裁衣,他很可能就是用右手大拇指在你的肩膀上做「滑动窗口」的样子。下面我们来看一下代码是如何编写的:
说明:编写代码的过程中遵守的 循环不变量 是:[left, right)内不包含重复字符。注意,这个区间是左闭右开的,好处是滑动窗口长度 = right - left。在一开始的时候,right 之前的元素已知,在本轮循环中,希望把 right 纳入,保持区间内无重复字符这一性质。
/*** @param {string} s* @return {number}*/var lengthOfLongestSubstring = function(s) {let len = s.length;if(len < 2) {return len;}const map = new Map(); // 存储元素出现次数let res = 1;for(let left = 0, right = 0; right < len; right++) {map.set(s.charAt(right), ~~map.get(s.charAt(right)) + 1);if(map.get(s.charAt(right)) == 2) {while(map.get(s.charAt(right)) == 2) {map.set(s.charAt(left), ~~map.get(s.charAt(left)) - 1);left++;}}res = Math.max(res, right - left + 1);}return res;};
/*** @param {string} s* @return {number}*/var lengthOfLongestSubstring = function(s) {let len = s.length;if(len < 2) {return len;}// 使用obj对象,代码更简洁一点,性能实际差不多const map = {};let res = 1;for(let left = 0, right = 0; right < len; right++) {if(!map[s.charAt(right)]) {map[s.charAt(right)] = 1;}else {map[s.charAt(right)] ++;}if(map[s.charAt(right)] == 2) {while(map[s.charAt(right)] == 2) {map[s.charAt(left)] --;left++;}}res = Math.max(res, right - left + 1);}return res;};
复杂度分析:
时间复杂度:O(N),这里 N 是输入字符的长度,left 和 right 各扫过数组一次;
空间复杂度:O(M),这里 M 表示字符出现的种类数。
说明:
- 创建字符频数数组,将字符的 ACSII 码作为数组的下标;
- 右边指针移动的过程中做加法:只要字符频数数组超过 11,刚刚好等于 22 的时候,就说明子区间内有重复元素;
- 左边指针移动的过程中做减法:因为我们的算法在子区间刚刚好有 11 个重复字符的时候,就想方设法让子区间没内有重复元素,因此重复元素的个数有且仅有 11 个,字符频数数组内单个字符的个数最多为 22,当左边界指向字符刚刚好减到 11 的时候,就说明子区间内没有重复元素(这样的说法有点绕,希望大家能够通过具体的例子,自己在纸上写写画画想明白);
- 在右边指针移动的过程中记录最大值。要特别注意这里记录最大值的位置,不能在
if(map.get(s.charAt(right)) == 2) {之前,因为此时滑动窗口内可能有重复元素,因此,只能在if(map.get(s.charAt(right)) == 2) {之后。
滑动窗口的优化
仔细思考我们就会发现:一步一步来到重复元素出现过的地方太慢了,我们是不是可以一下子来到重复元素的后面呢?答案是:完全可以。具体的做法是:在遍历的过程中,不是记录元素的频数(其实在上一种做法里,频次最多也只用到 2),而是记录元素出现的位置。
当有重复元素出现的时候,只要这个元素之前出现的下标 大于等于 当前滑动窗口左边界的下标,就可以直接跳过来;如果重复元素之前出现的下标严格小于当前滑动窗口左边界的下标,左边界不用移动。
这两种情况,都需要更新当前看到的字符的下标为最新看到的字符的下标。
/*** @param {string} s* @return {number}*/const lengthOfLongestSubstring = function (s) {let window = {};let left = 0;let right = 0;let res = 0;while (right < s.length) {let c = s[right];// 扩大窗口是为了纳入所有的可行解决方案window[c] = !window[c] ? 1 : window[c] + 1;right++;// 有重复的,那就缩小窗口直到没有重复的while (window[c] > 1) {let d = s[left];left++;// 进行窗口内数据的一系列更新window[d] = window[d] - 1;}res = Math.max(res, right - left);}return res;};
事实上,对于建立字符和某个数值的映射,也可以使用 哈希表 做。相信大家不难体会它们二者的差别。
这就是这一节的内容。下一节,我们再看一个使用「滑动窗口」解决的经典问题:「最小覆盖子串」,感谢大家。
