【算法】滑动窗口三步走

滑动窗口介绍

对于大部分滑动窗口类型的题目,一般是考察字符串的匹配。比较标准的题目,会给出一个模式串B,以及一个目标串A。然后提出问题,找到A中符合对B一些限定规则的子串或者对A一些限定规则的结果,最终再将搜索出的子串完成题意中要求的组合或者其他

比如:给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。

又或者:给你一个字符串 S、一个字符串 T,请在字符串 S 里面找出:包含 T 所有字母的最小子串。

再如:给定一个字符串 s 和一些长度相同的单词 words。找出 s 中恰好可以由 words 中所有单词串联形成的子串的起始位置。

都是属于这一类的标准题型。而对于这一类题目,我们常用的解题思路,是去维护一个可变长度的滑动窗口。无论是使用双指针,还是使用双端队列,又或者用游标等其他奇技淫巧,目的都是一样的。滑动窗口的原理都是一致的,只不过改变了实现方法而已。

简介

学过计算机网络的同学,都知道滑动窗口协议(Sliding Window Protocol),该协议是 TCP协议 的一种应用,用于网络数据传输时的流量控制,以避免拥塞的发生。该协议允许发送方在停止并等待确认前发送多个数据分组。由于发送方不必每发一个分组就停下来等待确认。因此该协议可以加速数据的传输,提高网络吞吐量。

滑动窗口算法其实和这个是一样的,只是用的地方场景不一样,可以根据需要调整窗口的大小,有时也可以是固定窗口大小。

滑动窗口算法(Sliding Window Algorithm)

Sliding window algorithm is used to perform required operation on specific window size of given large buffer or array.
滑动窗口算法是在给定特定窗口大小的数组或字符串上执行要求的操作。
This technique shows how a nested for loop in few problems can be converted to single for loop and hence reducing the time complexity.
该技术可以将一部分问题中的嵌套循环转变为一个单循环,因此它可以减少时间复杂度。

简而言之,滑动窗口算法在一个特定大小的字符串或数组上进行操作,而不在整个字符串和数组上操作,这样就降低了问题的复杂度,从而也达到降低了循环的嵌套深度。其实这里就可以看出来滑动窗口主要应用在数组和字符串上。

基本示例

如下图所示,设定滑动窗口(window)大小为 3,当滑动窗口每次划过数组时,计算当前滑动窗口中元素的和,得到结果 res。

可以用来解决一些查找满足一定条件的连续区间的性质(长度等)的问题。由于区间连续,因此当区间发生变化时,可以通过旧有的计算结果对搜索空间进行剪枝,这样便减少了重复计算,降低了时间复杂度。往往类似于“ 请找到满足 xx 的最 x 的区间(子串、子数组)的 xx ”这类问题都可以使用该方法进行解决。

需要注意的是,滑动窗口算法更多的是一种思想,而非某种数据结构的使用。

什么是滑动窗口

滑动窗口可以看成数组中框起来的一个部分。在一些数组类题目中,我们可以用滑动窗口来观察可能的候选结果。当滑动窗口从数组的左边滑到了右边,我们就可以从所有的候选结果中找到最优的结果。

对于这道题来说,数组就是正整数序列 \([1, 2, 3, \dots, n]\)。我们设滑动窗口的左边界为 i,右边界为 j,则滑动窗口框起来的是一个左闭右开区间 \([i, j)\)。注意,为了编程的方便,滑动窗口一般表示成一个左闭右开区间。在一开始,\(i=1, j=1\),滑动窗口位于序列的最左侧,窗口大小为零。

滑动窗口的重要性质是:窗口的左边界和右边界永远只能向右移动,而不能向左移动。这是为了保证滑动窗口的时间复杂度是 \(O(n)\)。如果左右边界向左移动的话,这叫做“回溯”,算法的时间复杂度就可能不止 \(O(n)\)

在这道题中,我们关注的是滑动窗口中所有数的和。当滑动窗口的右边界向右移动时,也就是 j = j + 1,窗口中多了一个数字 j,窗口的和也就要加上 j。当滑动窗口的左边界向右移动时,也就是 i = i + 1,窗口中少了一个数字 i,窗口的和也就要减去 i。滑动窗口只有 右边界向右移动(扩大窗口)左边界向右移动(缩小窗口) 两个操作,所以实际上非常简单。

如何用滑动窗口解这道题

要用滑动窗口解这道题,我们要回答两个问题:

  • 第一个问题,窗口何时扩大,何时缩小?
  • 第二个问题,滑动窗口能找到全部的解吗?

对于第一个问题,回答非常简单:

  • 当窗口的和小于 target 的时候,窗口的和需要增加,所以要扩大窗口,窗口的右边界向右移动
  • 当窗口的和大于 target 的时候,窗口的和需要减少,所以要缩小窗口,窗口的左边界向右移动
  • 当窗口的和恰好等于 target 的时候,我们需要记录此时的结果。设此时的窗口为 \([i, j)\),那么我们已经找到了一个 i 开头的序列,也是唯一一个 i 开头的序列,接下来需要找 i+1 开头的序列,所以窗口的左边界要向右移动

对于第二个问题,我们可以稍微简单地证明一下:

我们一开始要找的是 1 开头的序列,只要窗口的和小于 target,窗口的右边界会一直向右移动。假设 \(1+2+\dots+8\) 小于 target,再加上一个 9 之后, 发现 \(1+2+\dots+8+9\) 又大于 target 了。这说明 1 开头的序列找不到解。此时滑动窗口的最右元素是 9。

接下来,我们需要找 2 开头的序列,我们发现,\(2 + \dots + 8 < 1 + 2 + \dots + 8 < \mathrm{target}\)。这说明 2 开头的序列至少要加到 9。那么,我们只需要把原先 1~9 的滑动窗口的左边界向右移动,变成 2~9 的滑动窗口,然后继续寻找。而右边界完全不需要向左移动。

以此类推,滑动窗口的左右边界都不需要向左移动,所以这道题用滑动窗口一定可以得到所有的解。时间复杂度是 \(O(n)\)

注:这道题当前可以用等差数列的求和公式来计算滑动窗口的和。不过我这里没有使用求和公式,是为了展示更通用的解题思路。实际上,把题目中的正整数序列换成任意的递增整数序列,这个方法都可以解。

适用场景

适用于需要以某一窗口范围的元素遍历数组,而不是单个遍历每个元素。

关键词:窗口、范围

滑动窗口三步走

设滑动窗口的左边界为 i,右边界为 j,则滑动窗口框起来的是一个左闭右开区间 \([i, j)\)
滑动窗口前闭后开,也没想象的那么复杂,其实就是一个队列啦!我们写滑动窗口,其实就是手动实现一个队列啦!!!
详情请看:【数据结构】栈与队列

注意,为了编程的方便,滑动窗口一般表示成一个左闭右开区间。在一开始,\(i=1, j=1\),滑动窗口位于序列的最左侧,窗口大小为零

滑动窗口只有 右边界向右移动(扩大窗口)左边界向右移动(缩小窗口) 两个操作,所以实际上非常简单。

特征:

  • 前闭后开,left 元素包含在滑动窗口内,right 元素不包含在滑动窗口内
  • 使用 right 来进行遍历元素,满足条件就 right++,加入滑动窗口
  • 滑动窗口 left、right 只能前进,不能后退,所以一般只用比较最大值即可

操作:

  • 右边界向右移动(扩大窗口):(双指针)right++; 或者 (双端队列)linkedList.addFirst(right);
  • 左边界向右移动(缩小窗口):(双指针)left++; 或者 (双端队列)linkedList.removeLast();

滑动窗口三步走:

1. 明确循环条件

1.明确循环条件:right 如何遍历数组?什么时候保持循环,什么时候结束循环?

注意:由于滑动窗口是利用队列的原理实现的,前闭后开,所以你得思考一下循环的条件是right < s.length()还是right <= s.length()
因为有时候使用 right 来一个一个遍历元素时,我们right < s.length()即可遍历完所有元素;
如果我们有时候不使用 right 来一个一个遍历元素的话,即 right 只充当边界使用,那我们就需要right <= s.length()才能遍历完所有元素,这里需要特别注意了!!!切勿生搬硬套!

2. 寻找扩缩条件

2.寻找扩缩条件:窗口何时扩大,何时缩小?

注意:这个扩大与缩小的时机得根据题意来具体情况具体分析,并没有一个详细的标准。

3. 完成目标功能

3.完成目标功能:当滑动窗口满足条件时,完成目标功能。

滑动窗口法的大体框架

其实,滑动窗口就是通过不断调整子序列的 start 和 end 位置,从而获取满足要求的解。

在介绍滑动窗口的框架时候,大家先从字面理解下:

  • 滑动:说明这个窗口是移动的,也就是移动是按照一定方向来的。

  • 窗口:窗口大小并不是固定的,可以不断扩容直到满足一定的条件;也可以不断缩小,直到找到一个满足条件的最小窗口;当然也可以是固定大小。

为了便于理解,这里采用的是字符串来讲解。但是对于数组其实也是一样的。滑动窗口算法的思路是这样:

  1. 我们在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引闭区间 [left, right] 称为一个「窗口」。

  2. 我们先不断地增加 right 指针扩大窗口 [left, right],直到窗口中的字符串符合要求(包含了 T 中的所有字符)。

  3. 此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right],直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。

  4. 重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。

这个思路其实也不难,第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动。

下面画图理解一下,needs 和 window 相当于计数器,分别记录 T 中字符出现次数和窗口中的相应字符的出现次数。

初始状态:

增加 right,直到窗口 [left, right] 包含了 T 中所有字符:

现在开始增加 left,缩小窗口 [left, right]。

直到窗口中的字符串不再符合要求,left 不再继续移动。

之后重复上述过程,先移动 right,再移动 left…… 直到 right 指针到达字符串 S 的末端,算法结束。

如果你能够理解上述过程,恭喜,你已经完全掌握了滑动窗口算法思想。至于如何具体到问题,如何得出此题的答案,都是编程问题,等会提供一套模板,理解一下就会了。

上述过程对于非固定大小的滑动窗口,可以简单地写出如下伪码框架:

int slidingWindow() {
    string s, t;
    // 在 s 中寻找 t 的「最小覆盖子串」
    int left = 0, right = 0;
    string res = s;
    
    while(right < s.size()) {
        window.add(s[right]);
        right++;
        // 如果符合要求,说明窗口构造完成,移动 left 缩小窗口
        while (window 符合要求) {
            // 如果这个窗口的子串更短,则更新 res
            res = minLen(res, window);
            window.remove(s[left]);
            left++;
        }
    }
    return res;
}

但是,对于固定窗口大小,可以总结如下:

int slidingWindow() {
   // 固定窗口大小为 k
    string s;
    // 在 s 中寻找窗口大小为 k 时的所包含最大元音字母个数
    int  right = 0;
    while(right < s.size()) {
        window.add(s[right]);
        right++;
        // 如果符合要求,说明窗口构造完成,
        if (right>=k) {
            // 这是已经是一个窗口了,根据条件做一些事情
           // ... 可以计算窗口最大值等 
            // 最后不要忘记把 right -k 位置元素从窗口里面移除
        }
    }
    return res;
}

可以发现此时不需要依赖 left 指针了。因为窗口固定所以其实就没必要使用left,right 双指针来控制窗口的大小。

其次是对于窗口是固定的,可以轻易获取到 left 的位置,此处 left = right – k;

实际上,对于窗口的构造是很重要的。具体可以看下面的实例。

实例1

1208. 尽可能使字符串相等

给你两个长度相同的字符串,s 和 t。

将 s 中的第 i 个字符变到 t 中的第 i 个字符需要 |s[i] – t[i]| 的开销(开销可能为 0),也就是两个字符的 ASCII 码值的差的绝对值。

用于变更字符串的最大预算是 maxCost。在转化字符串时,总开销应当小于等于该预算,这也意味着字符串的转化可能是不完全的。

如果你可以将 s 的子字符串转化为它在 t 中对应的子字符串,则返回可以转化的最大长度。

如果 s 中没有子字符串可以转化成 t 中对应的子字符串,则返回 0。

示例 1:

输入:s = “abcd”, t = “bcdf”, cost = 3
输出:3
解释:s 中的 “abc” 可以变为 “bcd”。开销为 3,所以最大长度为 3。
示例 2:

输入:s = “abcd”, t = “cdef”, cost = 3
输出:1
解释:s 中的任一字符要想变成 t 中对应的字符,其开销都是 2。因此,最大长度为 1。
示例 3:

输入:s = “abcd”, t = “acde”, cost = 0
输出:1
解释:你无法作出任何改动,所以最大长度为 1。

代码

class Solution {
    public int equalSubstring(String s, String t, int maxCost) {
        int left = 0, right =0;
        int sum = 0;
        int res = 0;
     // 构造窗口
        while (right < s.length()) {
            sum += Math.abs(s.charAt(right) - t.charAt(right));
            right++;
       // 窗口构造完成,这时候要根据条件当前的窗口调整窗口大小
            while (sum > maxCost) {
                sum -=  Math.abs(s.charAt(left) - t.charAt(left));
                left++;
            }
       // 记录此时窗口的大小
            res = Math.max(res, right -left);
        }
        return res;
    }
}

这里跟前面总结的框架不一样的一个点就是,前面的框架是求最小窗口大小,这里是求最大窗口大小,大家要学会灵活变通。

239. 滑动窗口最大值

给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

进阶:

你能在线性时间复杂度内解决此题吗?

示例:

输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
输出: [3,3,5,5,6,7]
解释:

滑动窗口的位置 最大值
————— —–
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7

提示:

1 <= nums.length <= 10^5
-10^4 <= nums[i] <= 10^4
1 <= k <= nums.length

解答:

class Solution {
    public static int[] maxSlidingWindow(int[] nums, int k) {
        int right =0;
        int[] res = new int[nums.length -k +1];
        int index=0;
        LinkedList<Integer> list = new LinkedList<>();
     // 开始构造窗口
        while (right < nums.length) {
       // 这里的list的首位必须是窗口中最大的那位
            while (!list.isEmpty() && nums[right] > list.peekLast()) {
                list.removeLast();
            }
       // 不断添加
            list.addLast(nums[right]);
            right++;
       // 构造窗口完成,这时候需要根据条件做一些操作
            if (right >= k){
                res[index++]=list.peekFirst();
          // 如果发现第一个已经在窗口外面了,就移除
                if(list.peekFirst() == nums[right-k]) {
                    list.removeFirst();
                }
            }
        }
        return res;
    }
} 

这道题难度是困难。当然我们也会发现,这道题目和前面的非固定大小滑动窗口还是不一样的。

看了一道困难的题目后,接下来看一道中等难度的就会发现是小菜一碟。

1456. 定长子串中元音的最大数目

给你字符串 s 和整数 k 。

请返回字符串 s 中长度为 k 的单个子字符串中可能包含的最大元音字母数。

英文中的 元音字母 为(a, e, i, o, u)。

示例 1:

输入:s = “abciiidef”, k = 3
输出:3
解释:子字符串 “iii” 包含 3 个元音字母。

示例 2:

输入:s = “aeiou”, k = 2
输出:2
解释:任意长度为 2 的子字符串都包含 2 个元音字母。

示例 3:

输入:s = “leetcode”, k = 3
输出:2
解释:”lee”、”eet” 和 “ode” 都包含 2 个元音字母。

示例 4:

输入:s = “rhythms”, k = 4
输出:0
解释:字符串 s 中不含任何元音字母。

示例 5:

输入:s = “tryhard”, k = 4
输出:1

提示:

1 <= s.length <= 10^5
s 由小写英文字母组成
1 <= k <= s.length

解答

class Solution {
    public int maxVowels(String s, int k) {
        int right =0;
        int sum = 0;
        int max = 0;
        while (right < s.length()) {
            sum += isYuan(s.charAt(right)) ;
            right++;
            if (right >=k) {
                max = Math.max(max, sum);
                sum -= isYuan(s.charAt(right-k));
            }
        }
        return max;
    }

    public int isYuan(char s) {
        return s=='a' || s=='e' ||s=='i' ||s=='o' ||s=='u' ? 1:0;
    }
}

实例2

剑指Offer57.和为s的连续正数序列(简单)

题目:输入一个正整数 target ,输出所有和为 target 的连续正整数序列(至少含有两个数)。序列内的数字由小到大排列,不同序列按照首个数字从小到大排列。

示例 1:

输入:target = 9

输出:[[2,3,4],[4,5]]

示例 2:

输入:target = 15

输出:[[1,2,3,4,5],[4,5,6],[7,8]]

答案

这个题目比较简单,典型的一道滑动窗口的题目。

假若我们输入的 target 为 9,大脑中应该有下面这么个玩意:

然后我们通过左右指针来维护一个滑动窗口,同时计算窗口内的值是否是目标值:

如果窗口的值过小,我们就移动右边界。

如果窗口的值过大,我们就移动左边界。

剩下的就是反复上面的操作就可以了。到这里分析过程看似结束了。但是我们可以观察出一丢丢规律,用来优化我们的算法。对于任意一个正整数,总是小于它的中值与中值+1的和。为了让大家直观,用下图举例:

比如这里的100,就一定小于50+51,换成其他数也一样。换句话说,一旦窗口左边界超过中值,窗口内的和一定会大于 target。

根据分析,得到题解:

同时也给一个java版本的:

//java
class Solution {
    public int[][] findContinuousSequence(int target) {
        List<int[]> res = new ArrayList<>();
        int i = 1; 
        int j = 1; 
        int win = 0; 
        while (i <= target / 2) {
            if (win < target) {
                win += j;
                j++;
            } else if (win > target) {
                win -= i;
                i++;
            } else {
                int[] arr = new int[j-i];
                for (int k = i; k < j; k++) {
                    arr[k-i] = k;
                }
                res.add(arr);
                win -= i;
                i++;
            }
        }
        return res.toArray(new int[res.size()][]);
    }
}

答案2

什么是滑动窗口

滑动窗口可以看成数组中框起来的一个部分。在一些数组类题目中,我们可以用滑动窗口来观察可能的候选结果。当滑动窗口从数组的左边滑到了右边,我们就可以从所有的候选结果中找到最优的结果。

对于这道题来说,数组就是正整数序列 \([1, 2, 3, \dots, n]\)。我们设滑动窗口的左边界为 i,右边界为 j,则滑动窗口框起来的是一个左闭右开区间 \([i, j)\)。注意,为了编程的方便,滑动窗口一般表示成一个左闭右开区间。在一开始,\(i=1, j=1\),滑动窗口位于序列的最左侧,窗口大小为零。

滑动窗口的重要性质是:窗口的左边界和右边界永远只能向右移动,而不能向左移动。这是为了保证滑动窗口的时间复杂度是 \(O(n)\)。如果左右边界向左移动的话,这叫做“回溯”,算法的时间复杂度就可能不止 \(O(n)\)

在这道题中,我们关注的是滑动窗口中所有数的和。当滑动窗口的右边界向右移动时,也就是 j = j + 1,窗口中多了一个数字 j,窗口的和也就要加上 j。当滑动窗口的左边界向右移动时,也就是 i = i + 1,窗口中少了一个数字 i,窗口的和也就要减去 i。滑动窗口只有 右边界向右移动(扩大窗口)左边界向右移动(缩小窗口) 两个操作,所以实际上非常简单。

如何用滑动窗口解这道题

要用滑动窗口解这道题,我们要回答两个问题:

  • 第一个问题,窗口何时扩大,何时缩小?
  • 第二个问题,滑动窗口能找到全部的解吗?

对于第一个问题,回答非常简单:

  • 当窗口的和小于 target 的时候,窗口的和需要增加,所以要扩大窗口,窗口的右边界向右移动
  • 当窗口的和大于 target 的时候,窗口的和需要减少,所以要缩小窗口,窗口的左边界向右移动
  • 当窗口的和恰好等于 target 的时候,我们需要记录此时的结果。设此时的窗口为 \([i, j)\),那么我们已经找到了一个 i 开头的序列,也是唯一一个 i 开头的序列,接下来需要找 i+1 开头的序列,所以窗口的左边界要向右移动

对于第二个问题,我们可以稍微简单地证明一下:

我们一开始要找的是 1 开头的序列,只要窗口的和小于 target,窗口的右边界会一直向右移动。假设 \(1+2+\dots+8\) 小于 target,再加上一个 9 之后, 发现 \(1+2+\dots+8+9\) 又大于 target 了。这说明 1 开头的序列找不到解。此时滑动窗口的最右元素是 9。

接下来,我们需要找 2 开头的序列,我们发现,\(2 + \dots + 8 < 1 + 2 + \dots + 8 < \mathrm{target}\)。这说明 2 开头的序列至少要加到 9。那么,我们只需要把原先 1~9 的滑动窗口的左边界向右移动,变成 2~9 的滑动窗口,然后继续寻找。而右边界完全不需要向左移动。

以此类推,滑动窗口的左右边界都不需要向左移动,所以这道题用滑动窗口一定可以得到所有的解。时间复杂度是 \(O(n)\)

注:这道题当前可以用等差数列的求和公式来计算滑动窗口的和。不过我这里没有使用求和公式,是为了展示更通用的解题思路。实际上,把题目中的正整数序列换成任意的递增整数序列,这个方法都可以解。

作者:nettee
链接://leetcode-cn.com/problems/he-wei-sde-lian-xu-zheng-shu-xu-lie-lcof/solution/shi-yao-shi-hua-dong-chuang-kou-yi-ji-ru-he-yong-h/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

本题题解:

class Solution {
    public int[][] findContinuousSequence(int target) {

        // 这得从1开始看起
        // 如果 滑动窗口的数字和sum == target 那么就可以加入列表,算一个
        // 如果大于,那就前进 left,
        // 如果小于,前进 right

        int left = 1;
        int right = 1;
        int sum = 0;

        List<int[]> list = new ArrayList<>();

        // while (right <= target) {
        while (left <= target / 2) {

            if (sum == target) {

                int[] arr = new int[right - left];
                for (int i = left; i < right; i++) {
                    arr[i - left] = i;
                }

                list.add(arr);

                // 通过了就左边界前进,继续遍历
                sum -= left;
                left++;
            } else if (sum < target) {
                sum += right;
                right++;
            } else if (sum > target) {
                sum -= left;
                left++;
            }
        }

        // return list.toArray();   // 这样是不行的,不然返回的是Object[]数组,得指明数组类型
        return list.toArray(new int[list.size()][]);
    }
}

3. 无重复字符的最长子串

在上一题中,我们使用双端队列完成了滑动窗口的一道颇为困难的题目,以此展示了什么是滑动窗口。在本节中我们将继续深入分析,探索滑动窗口题型一些具有模式性的解法。

第3题:给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。

示例 1:

输入: “abcabcbb”
输出: 3
解释: 因为无重复字符的最长子串是 “abc”,所以其长度为 3。

示例 2:

输入: “bbbbb”
输出: 1
解释: 因为无重复字符的最长子串是 “b”,所以其长度为 1。

示例 3:

输入: “pwwkew”
输出: 3
解释: 因为无重复字符的最长子串是 “wke”,所以其长度为 3。

请注意,你的答案必须是 子串 的长度,”pwke” 是一个子序列,不是子串。

答案

直接分析题目,假设我们的输入为“abcabcbb”,我们只需要维护一个窗口在输入字符串中进行移动。如下图:

当下一个元素在窗口没有出现过时,我们扩大窗口。

当下一个元素在窗口中出现过时,我们缩小窗口,将出现过的元素以及其左边的元素统统移出:

在整个过程中,我们记录下窗口出现过的最大值即可。而我们唯一要做的,只需要尽可能扩大窗口。

那我们代码中通过什么来维护这样的一个窗口呢?anyway~ 不管是队列,双指针,甚至通过map来做,都可以。

我们演示一个双指针的做法:

//java
public class Solution {
    public static int lengthOfLongestSubstring(String s) {
        int n = s.length();
        Set<Character> set = new HashSet<>();
        int result = 0, left = 0, right = 0;
        while (left < n && right < n) {
            //charAt:返回指定位置处的字符
            if (!set.contains(s.charAt(right))) {
                set.add(s.charAt(right));
                right++;
                result = Math.max(result, right - left);
            } else {
                set.remove(s.charAt(left));
                left++;
            }
        }
        return result;
    }
}

当然,不使用集合,使用哈希表做也是可以的:这里的哈希表键值对是(字符,出现频数)

class Solution {
    public int lengthOfLongestSubstring(String s) {

        int left = 0;
        int right = 0;  // 使用右边界来遍历数组
        int result = 0; // 用来存放最长子串的长度

        char c = 0; // 用来遍历字符串中每个字符

        Map<Character, Integer> map = new HashMap<>();

        // 前闭后开,滑动窗口
        // 每次遍历都添加频度
        // 如果不重复就加入哈希表,右边界前进,比较最大长度
        // 如果重复,删除哈希表,左边界前进

        while (right < s.length()) {
            // 使用右边界来遍历数组,后面再判断是否加入哈希表中
            c = s.charAt(right);
            int count = map.getOrDefault(c, 0) + 1;

            if (count > 1) {    // 重复,左边界前进,哈希表删除
                map.put(s.charAt(left), map.get(s.charAt(left)) - 1);
                left++;
            } else {    // 右边界前进,哈希表增加
                map.put(c, count);
                right++;
                result = result > (right - left)? result : (right - left);
            }

        }
        return result;
    }
}

通过观察,我们能看出来。如果是最坏情况的话,我们每一个字符都可能会访问两次,left一次,right一次,时间复杂度达到了O(2N),这是不可饶恕的。不理解的话看下图:

假设我们的字符串为“abcdc”,对于abc我们都访问了2次。

那如何来进一步优化呢?

其实我们可以定义字符到索引的映射,而不是简单通过一个集合来判断字符是否存在。这样的话,当我们找到重复的字符时,我们可以立即跳过该窗口,而不需要对之前的元素进行再次访问。

而这里的哈希表的键值对是(字符,字符出现的索引+1)

//java
public class Solution {
    public static int lengthOfLongestSubstring(String s) {
        int n = s.length(), result = 0;
        Map<Character, Integer> map = new HashMap<>(); 
        for (int right = 0, left = 0; right < n; right++) {
            if (map.containsKey(s.charAt(right))) {
                left = Math.max(map.get(s.charAt(right)), left);
            }
            result = Math.max(result, right - left + 1);
            map.put(s.charAt(right), right + 1);
        }
        return result;
    }
}

我的:

//java
// 上面的哈希表记录的是(字符,频数)
// 这里的哈希表记录的是(字符,出现索引+1)
public class Solution {
    public static int lengthOfLongestSubstring(String s) {
        int n = s.length(), result = 0;
        Map<Character, Integer> map = new HashMap<>(); 

        int left = 0;
        int right = 0;  // 使用右边界来遍历数组

        char c = 0;

        while (right < s.length()) {

            c = s.charAt(right);
            int count = map.getOrDefault(c, -1);    // -1代表没有出现过

            if (count == -1) {  // 没有出现过
                map.put(c, right + 1);
                right++;
                result = Math.max(result, right - left);
            } else {    // 出现过
                left = Math.max(count, left);   // 这里需要着重注意,因为滑动窗口left只能前进,不能倒退回去,只能取最大的
                map.put(c, right + 1);
                right++;
                result = Math.max(result, right - left);
            }
        }
        
        // for (int right = 0, left = 0; right < n; right++) {
        //     if (map.containsKey(s.charAt(right))) {
        //         left = Math.max(map.get(s.charAt(right)), left);
        //     }
        //     result = Math.max(result, right - left + 1);
        //     map.put(s.charAt(right), right + 1);
        // }
        return result;
    }
}

修改之后,我们发现虽然时间复杂度有了一定提高,但是还是比较慢!如何更进一步的优化呢?我们可以使用一个256位的数组来替代hashmap,以进行优化。(因为ASCII码表里的字符总共有128个。ASCII码的长度是一个字节,8位,理论上可以表示256个字符,但是许多时候只谈128个。具体原因可以下去自行学习~)

//java
class Solution {
    public int lengthOfLongestSubstring(String s) {
        int len = s.length();
        int result = 0;
        int[] charIndex = new int[256];
        for (int left = 0, right = 0; right < len; right++) {
            char c = s.charAt(right);
            left = Math.max(charIndex[c], left);
            result = Math.max(result, right - left + 1);
            charIndex[c] = right + 1;
        }
        return result;
    }
}

我们发现优化后时间复杂度有了极大的改善!这里简单说一下原因,对于数组和hashmap访问时,两个谁快谁慢不是一定的,需要思考hashmap的底层实现,以及数据量大小。但是在这里,因为已知了待访问数据的下标,可以直接寻址,所以极大的缩短了查询时间。

啰啰嗦嗦
本题基本就到这里。最后要说的,一般建议如果要分析一道题,我们要压缩压缩再压缩,抽茧剥丝一样走到最后,尽可能的完成对题目的优化。不一定非要自己想到最优解,但绝对不要局限于单纯的完成题目,那样将毫无意义!

438. 找到字符串中所有字母异位词

之前的两节讲解了滑动窗口类问题的模式解法,相信大家对该类题型已不陌生。今天将继续完成一道题目,来进行巩固学习。

第438题:给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。
字符串只包含小写英文字母,并且字符串 s 和 p 的长度都不超过 20100。

说明:

字母异位词指字母相同,但排列不同的字符串。

不考虑答案输出的顺序。

示例 1:

输入:

s: “cbaebabacd” p: “abc”

输出:

[0, 6]

解释:

起始索引等于 0 的子串是 “cba”, 它是 “abc” 的字母异位词。

起始索引等于 6 的子串是 “bac”, 它是 “abc” 的字母异位词。

示例 2:

输入:

s: “abab” p: “ab”

输出:

[0, 1, 2]

解释:

起始索引等于 0 的子串是 “ab”, 它是 “ab” 的字母异位词。

起始索引等于 1 的子串是 “ba”, 它是 “ab” 的字母异位词。

起始索引等于 2 的子串是 “ab”, 它是 “ab” 的字母异位词。

答案

直接套用之前的模式,使用双指针来模拟一个滑动窗口进行解题。分析过程如下:

假设我们有字符串为“cbaebabacd”,目标串为“abc”

我们通过双指针维护一个窗口,由于我们只需要判断字母异位词,我们可以将窗口初始化大小和目标串保持一致。(当然,你也可以初始化窗口为1,逐步扩大)

而判断字母异位词,我们需要保证窗口中的字母出现次数与目标串中的字母出现次数一致。这里因为字母只有26个,直接使用数组来替代map进行存储(和上一讲中的ASCII使用256数组存储思想一致)。

pArr为目标串数组,sArr为窗口数组。我们发现初始化数组,本身就满足,记录下来。(这里图示用map模拟数组,便于理解)

然后我们通过移动窗口,来更新窗口数组,进而和目标数组匹配,匹配成功进行记录。每一次窗口移动,左指针前移,原来左指针位置处的数值减1,表示字母移出;同时右指针前移,右指针位置处的数值加1,表示字母移入。详细过程如下:

最终,当右指针到达边界,意味着匹配完成。

代码展示

根据分析,完成代码:(下面pSize相关的忽略,调试忘删了)

class Solution {

    public List<Integer> findAnagrams(String s, String p) {

        if (s == null || p == null || s.length() < p.length()) return new ArrayList<>();

        List<Integer> list = new ArrayList<>();

        int[] pArr = new int[26];
        int pSize = p.length();
        int[] sArr = new int[26];

        for (int i = 0; i < p.length(); i++) {
            sArr[s.charAt(i) - 'a']++;  
            pArr[p.charAt(i) - 'a']++;
        }

        for (int i = 0; i < p.length(); i++) {
            int index = p.charAt(i) - 'a';
            if (pArr[index] == sArr[index]) 
                pSize--;
        }

        int i = 0;
        int j = p.length();

        // 窗口大小固定为p的长度
        while (j < s.length()) {
            if (isSame(pArr, sArr))
                list.add(i);            
            //sArr[s.charAt(i) - 'a']-- 左指针位置处字母减1
            sArr[s.charAt(i) - 'a']--;
            i++;
            //sArr[s.charAt(j) - 'a']++ 右指针位置处字母加1
            sArr[s.charAt(j) - 'a']++;
            j++;
        }

        if (isSame( pArr, sArr))
            list.add(i);

        return list;
    }

    public boolean isSame(int[] arr1, int[] arr2) {
        for (int i = 0; i < arr1.length; ++i)
            if (arr1[i] != arr2[i])
                return false;
        return true;
    }
}

我的:
答案一:使用map,超时

class Solution {
    public List<Integer> findAnagrams(String s, String p) {

        // 固定窗口大小为p.length() - 1
        int left = 0;
        int right = p.length();

        List<Integer> list = new ArrayList<>();

        Map<Character, Integer> mapP = new HashMap<>();
        Map<Character, Integer> mapS = new HashMap<>();


        // 将p的所有字符放入哈希表
        for (int i = 0; i < p.length(); i++) {

            char c = p.charAt(i);
            int count = mapP.getOrDefault(c, 0) + 1;

            mapP.put(c, count);
        }

        while (right <= s.length()) {

            // 从left到right,遍历窗口
            for (int i = left; i < right; i++) {

                char c = s.charAt(i);
                int count = mapS.getOrDefault(c, 0) + 1;

                mapS.put(c, count);
            }

            if (mapP.equals(mapS)) {
                list.add(left);
            }

            // 无论如何都要前进
            left++;
            right++;
            
            // 清理一下mapS,便于下个窗口存入
            mapS.clear();
        }
        return list;
    }
}

答案二:上面的算法面对超长超大的字符串会超时,所以我们把哈希表换成了自己写的

// 上面的算法面对超长超大的字符串会超时,所以我们把哈希表换成了自己写的
class Solution {
    public List<Integer> findAnagrams(String s, String p) {

        int left = 0;
        int right = p.length();
        
        int[] mapP = new int[26];   // p的哈希表
        int[] mapS = new int[26];   // s的哈希表

        List<Integer> list = new ArrayList<>();


        // 将p的所有字符放入哈希表
        for (int i = 0; i < p.length(); i++) {
            mapP[p.charAt(i) - 'a']++;
        }

        // 由于前闭后开,所以right得等于s.length()才算遍历完了所有
        while (right <= s.length()) {

            mapS = new int[26];

            for (int i = left; i < right; i++) {
                mapS[s.charAt(i) - 'a']++;
            }

            if (isSame(mapP, mapS)) {
                list.add(left);
            }

            left++;
            right++;
        }
        return list;
    }

    public boolean isSame(int[] mapP, int[] mapS) {
        for (int i = 0; i < mapP.length; i++) {
            if (mapP[i] != mapS[i]) {
                return false;
            }
        }
        return true;
    }
}

239. 滑动窗口最大值(困难可不做)

第239题:给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值。

给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值所构成的数组。

示例:

输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
输出: [3,3,5,5,6,7]

解释:

滑动窗口的位置 最大值


[1 3 -1] -3 5 3 6 7 3

1 [3 -1 -3] 5 3 6 7 3

1 3 [-1 -3 5] 3 6 7 5

1 3 -1 [-3 5 3] 6 7 5

1 3 -1 -3 [5 3 6] 7 6

1 3 -1 -3 5 [3 6 7] 7

题目分析

本题对于题目没有太多需要额外说明的,应该都能理解,直接进行分析。我们很容易想到,可以通过遍历所有的滑动窗口,找到每一个窗口的最大值,来进行暴力求解。那一共有多少个滑动窗口呢,小学题目,可以得到共有 L-k+1 个窗口。

假设 nums = [1,3,-1,-3,5,3,6,7],和 k = 3,窗口数为6

根据分析,直接完成代码:

//java
class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        int len = nums.length;
        if (len * k == 0) return new int[0];
        int [] win = new int[len - k + 1];
        //遍历所有的滑动窗口
        for (int i = 0; i < len - k + 1; i++) {
            int max = Integer.MIN_VALUE;
            //找到每一个滑动窗口的最大值
            for(int j = i; j < i + k; j++) {
                max = Math.max(max, nums[j]);
            }
            win[i] = max;
        }
        return win;
    }
}


It’s Bullshit!结果令我们很不满意,时间复杂度达到了O(LK),如果面试问到这道题,基本上只写出这样的代码,一定就挂掉了。那我们怎么样优化时间复杂度呢?有没有可以O(L)的实现呢?=_=

线性题解

这里不卖关子,其实这道题比较经典,我们可以采用队列,DP,堆等方式进行求解,所有思路的主要源头应该都是在窗口滑动的过程中,如何更快的完成查找最大值的过程。但是最典型的解法还是使用双端队列。具体怎么来求解,一起看一下。

首先,我们了解一下,什么是双端队列:是一种具有队列和栈的性质的数据结构。双端队列中的元素可以从两端弹出或者插入。

我们可以利用双端队列来实现一个窗口,目的是让该窗口可以做到张弛有度(汉语博大精深,也就是长度动态变化。其实用游标或者其他解法的目的都是一样的,就是去维护一个可变长的窗口)

然后我们再做一件事,只要遍历该数组,同时在双端队列的头去维护当前窗口的最大值(在遍历过程中,发现当前元素比队列中的元素大,就将原来队列中的元素祭天),在整个遍历的过程中我们再记录下每一个窗口的最大值到结果数组中。最终结果数组就是我们想要的,整体图解如下。

假设 nums = [1,3,-1,-3,5,3,6,7],和 k = 3

(个人认为我画的这个图是目前全网对于双端队列本题解法比较清晰的一个…所以我觉得如果不点个赞的话…晤..)

根据分析,得出代码:

//go
func maxSlidingWindow(nums []int, k int) []int {
    if len(nums) == 0 {
        return []int{}
    }
    //用切片模拟一个双端队列
    queue := []int{}
    result := []int{}
    for i := range nums {
        for i > 0 && (len(queue) > 0) && nums[i] > queue[len(queue)-1] {
            //将比当前元素小的元素祭天
            queue = queue[:len(queue)-1]
        }
        //将当前元素放入queue中
        queue = append(queue, nums[i])
        if i >= k && nums[i-k] == queue[0] {
            //维护队列,保证其头元素为当前窗口最大值
            queue = queue[1:]
        }
        if i >= k-1 {
            //放入结果数组
            result = append(result, queue[0])
        }
    }
    return result
}


Perfact~题目完成!看着一下子超越百分之99的用户,是不是感觉很爽呢~

//Java
class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        if(nums == null || nums.length < 2) return nums;
        // 双向队列 保存当前窗口最大值的数组位置 保证队列中数组位置的数值按从大到小排序
        LinkedList<Integer> queue = new LinkedList();
        // 结果数组
        int[] result = new int[nums.length-k+1];
        // 遍历nums数组
        for(int i = 0;i < nums.length;i++){
            // 保证从大到小 如果前面数小则需要依次弹出,直至满足要求
            while(!queue.isEmpty() && nums[queue.peekLast()] <= nums[i]){
                queue.pollLast();
            }
            // 添加当前值对应的数组下标
            queue.addLast(i);
            // 判断当前队列中队首的值是否有效
            if(queue.peek() <= i-k){
                queue.poll();   
            } 
            // 当窗口长度为k时 保存当前窗口中最大值
            if(i+1 >= k){
                result[i+1-k] = nums[queue.peek()];
            }
        }
        return result;
    }
}
Tags: