滑动窗口
这一节,我们给大家提供一个比较容易思考的方向,以解决这一类问题。
- 绝大多数「滑动窗口」问题,一般而言,总是先从最容易想到的情况入手,即先思考一个最容易想到的解决方案,但是这样的方案,通常来说执行效率并不高,接下来,就要想方设法优化这个朴素的解法;
- 「暴力枚举」通常以「二重循环」、「三重循环」的形式出现,而通常优化的思考路径是:
-
以空间换时间:在遍历的过程中,记录变量的值,以使得枚举“相邻”问题规模的计算不必从头开始;
-
利用题目给出的性质,在枚举的过程中,能够一下子排除很多不必要的可选方案,以降低时间复杂度。
来源:力扣(LeetCode)
给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。
示例 1:
输入: "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入: "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
分析:这道题给我们一个字符串,要求我们找出这个字符串中不含有重复字符的 最长子串 的长度。
首先我们得弄清楚一个概念:「子串」,它区别于「子序列」。
-
子串(substring):一定连续,保持顺序。
-
子序列(subsequeue):不一定连续,保持顺序。
方法一:暴力解法
一个最直接的思考方案是:
1、枚举这个字符串的所有子串;
2、对于每一个子串都判断一下这个子串是否有重复字符;
3、再从没有重复字符的所有子串中找出长度最长的那个,返回即可。
伪代码如下:
public class Solution {
public int lengthOfLongestSubstring(String s) {
int len = s.length();
int maxLen = 1;
for (int left = 0; left < len - 1; left++) {
for (int right = 0; right < len; right++) {
String subString = s.substring(left, right + 1);
if (subString 不包含重复元素){
maxLen = Math.max(maxLen, subString.length());
}
}
}
return maxLen;
}
}
这是一个二重循环,并且最里层的判断「subString
不包含重复元素」这个方法的时间复杂度是
的,所以整体的复杂度就变为
。
下面我们就来分析,是否有不去枚举左右边界。
方法二:滑动窗口
一开始的时候,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
有没有必要收缩呢?)
我们分析一下,这个过程为什么比暴力枚举要快。
1、当我们得到了一个有重复元素的子串的时候,和它有相同前缀的所有子串都会一下子被排除;(做动画,画删除线);
2、在判断子区间 [left, right]
是否有重复字符的时候,我们不必每一次都做扫描;事实上,我们只需要开辟一个字符频数数组,让右边界进来的时候,字符频数加 1,此时检测是否有重复。当左边界滑出的时候,字符频数减 1,此时检测是否无重复。
- 有重复字符的时候,尝试左边界右移,试图让区间内无重复字符;
- 无重复的时候,尝试右边界右移,以试图让区间长度更长。
以上就是解决这道问题的基本思路。
这种 right
主动向前移动,left
被动向前移动的方式就是滑动窗口的思想,也叫「尺取法」。这个名字可以说是非常形象了,一个资深的裁缝为你量体裁衣,他很可能就是用右手大拇指在你的肩膀上做「滑动窗口」的样子,例如你现在看到的这个视频片段。
在编码的时候设计循环不变量:[left, right)
。注意,区间是「左闭右开」的,在一开始的时候,right
之前的元素已知,在本轮循环中,希望把 right
纳入,保持区间内无重复元素这一性质。
Java 代码:
public class Solution {
public int lengthOfLongestSubstring(String s) {
int len = s.length();
// 特判
if (len < 2) {
return len;
}
// 当 window 中某个字符的频数为 2 时,表示滑动窗口内有重复字符
int[] cnt = new int[128];
// 右边界滑动到刚刚好有重复的时候停下
// 左边界滑动到刚刚好没有重复的时候停下
int left = 0;
int right = 0;
// 滑动窗口内是否重复
boolean repeating = false;
int res = 1;
// 循环不变式,保持不变的性质是:[left, right) 内没有重复元素
while (right < len) {
// 不能写在后面,因为数组下标容易越界
if (cnt[s.charAt(right)] == 1) {
repeating = true;
}
cnt[s.charAt(right)]++;
right++;
// 此时 [left, right) 内如果没有重复元素,就尝试扩张 right
// 否则缩小左边界,while 循环体内不断缩小边界
while (repeating) {
if (cnt[s.charAt(left)] == 2) {
// 如果满足滑动窗口内有重复的元素,尝试不断删除左边元素
repeating = false;
}
// 只有有重复元素,就得缩短左边界
cnt[s.charAt(left)]--;
left++;
}
// 此时 [left, right) 内没有重复元素
res = Math.max(res, right - left);
}
return res;
}
}
复杂度分析:
时间复杂度:
,left
和 right
各扫过数组一次。
空间复杂度: ,字符是有限的,因此与字符长度无关。
技巧:
1、创建字符频数数组,ACSII 码表;
2、右边界移动做加法:只要字符频数数组超过 1,刚刚好等于 2 的时候,就说明子区间内有重复元素;
3、左边界移动做减法:因为我们的算法在子区间刚刚好有 1 个重复字符的时候,就想方设法让子区间没内有重复元素,因此重复元素的个数有且仅有 1 个,字符频数数组内单个字符的个数最多为 2,
当左边界指向字符刚刚好减到 1 的时候,就说明子区间没内有重复元素了。
4、在右边移动的过程中记录最大值。
- 要特别注意这里记录最大值的位置,不能在
while (repeating)
之前,因为此时滑动窗口内可能有重复元素,因此,只能在while (repeating)
之后。注意边界。
方法三:滑动窗口(优化)
一步一步来到重复元素出现过的地方太慢了,我们是不是可以一下子来到重复元素的后面呢?完全可以的。
我们在遍历的过程中,不是记录元素的频数,而是记录一下元素出现的位置。
当有重复元素出现的时候,只要这个元素之前出现的位置在当前滑动窗口左边界(可以等于左边界)的右边,就可以直接跳过来;如果重复元素之前出现的位置在在当前滑动窗口左边界的左边,左边界不用移动;
这两种情况,都要更新位置为当前的最新位置。
Java 代码:滑动窗口的优化
public class Solution {
public int lengthOfLongestSubstring(String s) {
// 重复元素上一次出现的位置很重要
int len = s.length();
if (len < 2) {
return len;
}
int[] window = new int[128];
for (int i = 0; i < 128; i++) {
window[i] = -1;
}
int res = 1;
int left = 0;
for (int i = 0; i < len; i++) {
if (window[s.charAt(i)] != -1) {
left = Math.max(left, window[s.charAt(i)] + 1);
}
res = Math.max(res, i - left + 1);
window[s.charAt(i)] = i;
}
return res;
}
}
事实上,对于建立字符和某个数值的映射,可以数值上做,也可以使用哈希表做。相信大家不难体会它们二者的差别。