点击蓝色“五分钟学算法”关注我哟

加个“星标”,一起学算法

面试官,你再问我滑动窗口问题试试?我有解题模板,不怕!


前言

滑动窗口类问题是面试当中的 高频题 ,问题本身其实并不复杂,但是实现起来细节思考非常的多,想着想着可能因为变量变化,指针移动等等问题,导致程序反复删来改去,有思路,但是程序写不出是这类问题最大的障碍。

本文会将 LeetCode 里面的大部分滑动窗口问题分析、总结、分类,并提供一个可以参考的模版,相信可以有效减少面试当中的算法实现部分的不确定性。

本文涉及的例题与代码较多,建议先收藏后再仔细阅读,相信你肯定能攻克 滑动窗口问题


滑动窗口

滑动窗口这类问题一般需要用到 双指针 来进行求解,另外一类比较特殊则是需要用到特定的数据结构,像是 sorted_map。

后者有特定的题型,后面会列出来,但是,对于前者,题形变化非常的大,一般都是基于字符串和数组的,所以我们重点总结这种基于双指针的滑动窗口问题。

题目问法大致有这几种:

  • 给两个字符串,一长一短,问其中短的是否在长的中满足一定的条件存在,例如:

  • 求长的的最短子串,该子串必须涵盖短的的所有字符

  • 短的的 anagram 在长的中出现的所有位置

  • 给一个字符串或者数组,问这个字符串的子串或者子数组是否满足一定的条件,例如:

  • 含有少于 k 个不同字符的最长子串

  • 所有字符都只出现一次的最长子串

除此之外,还有一些其他的问法,但是不变的是,这类题目脱离不开主串(主数组)和子串(子数组)的关系,要求的时间复杂度往往是 O(n) ,空间复杂度往往是常数级的。

之所以是滑动窗口,是因为,遍历的时候,两个指针一前一后夹着的子串(子数组)类似一个窗口,这个窗口大小和范围会随着前后指针的移动发生变化。

面试官,你再问我滑动窗口问题试试?我有解题模板,不怕!
双指针确定一个窗口



解题思路与模板


根据前面的描述,滑动窗口就是这类题目的重点,换句话说,窗口的移动 就是重点!

我们要控制前后指针的移动来控制窗口,这样的移动是有条件的,也就是要想清楚在什么情况下移动,在什么情况下保持不变。

我的思路是保证右指针每次往前移动一格,每次移动都会有新的一个元素进入窗口,这时条件可能就会发生改变,然后根据当前条件来决定左指针是否移动,以及移动多少格。

我写来一个模版在这里,可以参考:

//authour:P.yh
//Editor:程序员小吴
public int slidingWindowTemplate(String[] a, ...) {
    // 输入参数有效性判断
    if (...) {
        ...
    }

    // 申请一个散列,用于记录窗口中具体元素的个数情况
    // 这里用数组的形式呈现,也可以考虑其他数据结构
    int[] hash = new int[...];

    // 预处理(可省), 一般情况是改变 hash
    ...

    // l 表示左指针
    // count 记录当前的条件,具体根据题目要求来定义
    // result 用来存放结果
    int l = 0, count = ..., result = ...;
    for (int r = 0; r < A.length; ++r) {
        // 更新新元素在散列中的数量
        hash[A[r]]--;

        // 根据窗口的变更结果来改变条件值
        if (hash[A[r]] == ...) {
            count++;
        }

        // 如果当前条件不满足,移动左指针直至条件满足为止
        while (count > K || ...) {
            ...
            if (...) {
                count--;
            }
            hash[A[l]]++;
            l++;
        }

        // 更新结果
        results = ...
    }

    return results;
}

这里面的 “移动左指针直至条件满足” 部分,需要具体题目具体分析,其他部分的变化不大。


具体题目分析与代码



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


题目来源于 LeetCode 上第 438 号问题:找到字符串中所有字母异位词。题目难度为 Easy,目前通过率为 43.6% 。

题目描述

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

字符串只包含小写英文字母,并且字符串 sp 的长度都不超过 20100

题目解析

别看这是一道 easy 难度的题目,如果限定你在 O(n) 时间复杂度内实现呢?

按照模版会很简单!

首先窗口是固定的,窗口长度就是输入参数中第二个字符串的长度,也就是说,右指针移动到某个位置后,左指针必须跟着一同移动,且每次移动都是一格,模版中 count 用来记录窗口内满足条件的元素,直到 count 和窗口长度相等即可更新答案。

代码实现

// 使用上面的模板进行解题,so easy !
public List<Integer> findAnagrams(String s, String p) {
    // 输入参数有效性判断
    if (s.length() < p.length()) {
        return new ArrayList<Integer>();
    }

    // 申请一个散列,用于记录窗口中具体元素的个数情况
    // 这里用数组的形式呈现,也可以考虑其他数据结构
    char[] sArr = s.toCharArray();
    char[] pArr = p.toCharArray();

    int[] hash = new int[26];

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

    // l 表示左指针
    // count 记录当前的条件,具体根据题目要求来定义
    // result 用来存放结果
    List<Integer> results = new ArrayList<>();
    int l = 0, count = 0, pLength = p.length();

    for (int r = 0; r < sArr.length; ++r) {
        // 更新新元素在散列中的数量
        hash[sArr[r] - 'a']--;

        // 根据窗口的变更结果来改变条件值
        if (hash[sArr[r] - 'a'] >= 0) {
            count++;
        }

        // 如果当前条件不满足,移动左指针直至条件满足为止
        if (r > pLength - 1) {
            hash[sArr[l] - 'a']++;

            if (hash[sArr[l] - 'a'] > 0) {
                count--;
            }

            l++;
        }

        // 更新结果
        if (count == pLength) {
            results.add(l);
        }
    }

    return results;
}



2
最小覆盖子串


题目来源于 LeetCode 上第 76 号问题:最小覆盖子串。题目难度为 Hard,目前通过率为 35.8% 。

题目描述

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

示例:

输入: S = "ADOBECODEBANC", T = "ABC"
输出: "BANC"

题目解析

同样是两个字符串之间的关系问题,因为题目求的最小子串,也就是窗口的最小长度,说明这里的窗口大小是可变的,这里移动左指针的条件变成,只要左指针指向不需要的字符,就进行移动。

依旧使用上面的模板解题!

代码实现

// 使用上面的模板进行解题,受篇幅限制下面的代码就不添加注释了
public String minWindow(String s, String t) {
    if (s.length() < t.length()) {
        return "";
    }

    char[] sArr = s.toCharArray();
    char[] tArr = t.toCharArray();

    int[] hash = new int[256];

    for (int i = 0; i < tArr.length; ++i) {
        hash[tArr[i]]++;
    }

    int l = 0, count = tArr.length, max = s.length() + 1;
    String result = "";
    for (int r = 0; r < sArr.length; ++r) {
        hash[sArr[r]]--;

        if (hash[sArr[r]] >= 0) {
            count--;
        }

        while (l < r && hash[sArr[l]] < 0) {
            hash[sArr[l]]++;
            l++;
        }

        if (count == 0 && max > r - l + 1) {
            max = r - l + 1;
            result = s.substring(l, r + 1);
        }
    }

    return result;
}



3
无重复字符的最长子串


题目来源于 LeetCode 上第 3 号问题:无重复字符的最长子串。题目难度为 Medium,目前通过率为 29.0% 。

题目描述

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

示例 1:

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

题目解析

输入只有一个字符串,要求子串里面不能够有重复的元素,这里 count 都不需要定义,直接判断哈希散列里面的元素是不是在窗口内即可,是的话得移动左指针去重。

具体操作如下:

建立一个 256 位大小的整型数组 freg ,用来建立字符和其出现位置之间的映射。

维护一个滑动窗口,窗口内的都是没有重复的字符,去尽可能的扩大窗口的大小,窗口不停的向右滑动。

  • (1)如果当前遍历到的字符从未出现过,那么直接扩大右边界;

  • (2)如果当前遍历到的字符出现过,则缩小窗口(左边索引向右移动),然后继续观察当前遍历到的字符;

  • (3)重复(1)(2),直到左边索引无法再移动;

  • (4)维护一个结果 res,每次用出现过的窗口大小来更新结果 res,最后返回 res 获取结果。

动画描述

面试官,你再问我滑动窗口问题试试?我有解题模板,不怕!

代码实现

public int lengthOfLongestSubstring(String s) {
    if (s == null || s.length() == 0) {
        return 0;
    }

    char[] sArr = s.toCharArray();
    int[] hash = new int[256];

    int l = 0, result = 1;
    for (int r = 0; r < sArr.length; ++r) {
        hash[sArr[r]]++;

        while (hash[sArr[r]] != 1) {
            hash[sArr[l]]--;
            l++;
        }

        result = Math.max(result, r - l + 1);
    }

    return result;
}


4
字符串的排列


题目来源于 LeetCode 上第 567 号问题:字符串的排列。题目难度为 Medium,目前通过率为 31.8% 。

题目描述

给定两个字符串 s1s2,写一个函数来判断 s2 是否包含 s1 的排列。

换句话说,第一个字符串的排列之一是第二个字符串的子串。

示例1:

输入: s1 = "ab" s2 = "eidbaooo"
输出: True
解释: s2 包含 s1 的排列之一 ("ba").

示例2:

输入: s1= "ab" s2 = "eidboaoo"
输出: False

题目解析

和 438 那题很类似,但是这里不需要记录答案了,有就直接返回 true。

代码实现

public boolean checkInclusion(String s1, String s2) {
    if (s1.length() > s2.length()) {
        return false;
    }

    char[] s1Arr = s1.toCharArray();
    char[] s2Arr = s2.toCharArray();

    int[] hash = new int[26];

    for (int i = 0; i < s1Arr.length; ++i) {
        hash[s1Arr[i] - 'a']++;
    }

    int l = 0, count = 0;
    for (int r = 0; r < s2Arr.length; ++r) {
        hash[s2Arr[r] - 'a']--;

        if (hash[s2Arr[r] - 'a'] >= 0) {
            count++;
        }

        if (r >= s1Arr.length) {
            hash[s2Arr[l] - 'a']++;

            if (hash[s2Arr[l] - 'a'] >= 1) {
                count--;
            }

            l++;
        }

        if (count == s1Arr.length) {
            return true;
        }
    }

    return false;
}



5
K 个不同整数的子数组


题目来源于 LeetCode 上第 992 号问题:字符串的排列。题目难度为 Hard,目前通过率为 26.4% 。

题目描述

给定一个正整数数组 A,如果 A 的某个子数组中不同整数的个数恰好为 K,则称 A 的这个连续、不一定独立的子数组为好子数组。

(例如,[1,2,3,1,2] 中有 3 个不同的整数:1,2,以及 3。)

返回 **A **中好子数组的数目。

示例 1:

输出:A = [1,2,1,2,3], K = 2
输入:7
解释:恰好由 2 个不同整数组成的子数组:[1,2], [2,1], [1,2], [2,3], [1,2,1], [2,1,2], [1,2,1,2].

示例 2:

输入:A = [1,2,1,3,4], K = 3
输出:3
解释:恰好由 3 个不同整数组成的子数组:[1,2,1,3], [2,1,3], [1,3,4].

题目解析

看完了字符串类型的题目,这次来看看数组类型的。

题目中的 subarray 已经明确了这个题可以考虑用滑动窗口,这题比较 trick 的一个地方在于,这里不是求最小值最大值,而是要你计数。

但是如果每次仅仅加 1 的话又不太对,例如 A = [1,2,1,2,3], K = 2 这个例子,假如右指针移到 index 为 3 的位置,如果按之前的思路左指针根据 count 来移动,当前窗口是 [1,2,1,2],但是怎么把 [2,1] 给考虑进去呢?

可以从数组和子数组的关系来思考!

假如 [1,2,1,2] 是符合条件的数组,如果要计数的话,[1,2,1,2] 要求的结果是否和 [1,2,1] 的结果存在联系?这两个数组的区别在于多了一个新进来的元素,之前子数组计数没考虑到这个元素,假如把这个元素放到之前符合条件的子数组中组成的新数组也是符合条件的,我们看看这个例子中所有满足条件的窗口以及对应的满足条件的子数组情况:

[1,2,1,2,3]  // 窗口满足条件
 l r         // 满足条件的子数组 [1,2]

[1,2,1,2,3]  // 窗口满足条件
 l   r       // 满足条件的子数组 [1,2],[2,1],[1,2,1]

[1,2,1,2,3]  // 窗口满足条件
 l     r     // 满足条件的子数组 [1,2],[2,1],[1,2,1],[1,2],[2,1,2],[1,2,1,2]

[1,2,1,2,3]  // 窗口不满足条件,移动左指针至满足条件
 l       r   

[1,2,1,2,3]  // 窗口满足条件
       l r   // 满足条件的子数组 [1,2],[2,1],[1,2,1],[1,2],[2,1,2],[1,2,1,2],[2,3]

你可以看到对于一段连续的数组,新的元素进来,窗口增加 1,每次的增量都会在前一次增量的基础上加 1。

当新的元素进来打破当前条件会使这个增量从新回到 1,这样我们左指针移动条件就是只要是移动不会改变条件,就移动,不然就停止。

代码实现

public int subarraysWithKDistinct(int[] A, int K) {
    if (A == null || A.length < K) {
        return 0;
    }

    int[] hash = new int[A.length + 1];

    int l = 0, results = 0, count = 0, result = 1;
    for (int r = 0; r < A.length; ++r) {
        hash[A[r]]++;

        if (hash[A[r]] == 1) {
            count++;
        }

        while (hash[A[l]] > 1 || count > K) {
            if (count > K) {
                result = 1;
                count--;
            } else {
                result++;
            }
            hash[A[l]]--;
            l++;
        }

        if (count == K) {
            results += result;
        }
    }

    return results;
}



6
替换后的最长重复字符


题目来源于 LeetCode 上第 424 号问题:替换后的最长重复字符。题目难度为 Medium,目前通过率为 37.3% 。

题目描述

给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 k 次。在执行上述操作后,找到包含重复字母的最长子串的长度。

题目解析

这道题想 accept 的话不难,但是问题在于怎么知道当前窗口中数量最多的字符的数量,因为需要替换的字符就是当前窗口的大小减去窗口中数量最多的字符的数量。

最简单的方法就是把 哈希散列 遍历一边找到最大的字符数量,但是仔细想想如果我们每次新进元素都更新这个最大数量,且只更新一次,我们保存的是当前遍历过的全局的最大值,它肯定是比实际的最大值大的,我们左指针移动的条件是 r - l + 1 - maxCount > k,保存的结果是 result = Math.max(r - l + 1, result); 这里 maxCount 比实际偏大的话,虽然导致左指针不能移动,但是不会记录当前的结果,所以最后的答案并不会受影响。

代码实现

public int characterReplacement(String s, int k) {
    if (s == null || s.length() == 0) {
        return 0;
    }

    char[] sArr = s.toCharArray();

    int[] hash = new int[26];

    int l = 0, maxCount = 0, result = 0;
    for (int r = 0; r < sArr.length; ++r) {
        hash[sArr[r] - 'A']++;

        maxCount = Math.max(maxCount, hash[sArr[r] - 'A']);

        while (r - l + 1 - maxCount > k) {
            hash[sArr[l] - 'A']--;
            l++;
        }

        result = Math.max(r - l + 1, result);
    }

    return result;
}



总结

双指针类的滑动窗口问题思维复杂度并不高,但是出错点往往在细节。

记忆常用的解题模版还是很有必要的,特别是对于这种变量名多,容易混淆的题型。

有了这个框架,思考的点就转化为 “什么条件下移动左指针”,无关信息少了,思考加实现自然不是问题。


END

面试官,你再问我滑动窗口问题试试?我有解题模板,不怕!

 原 创 热 文 推 荐 


毕业十年后,我忍不住出了一份程序员的高考试卷

一道腾讯面试题:厉害了我的杯

十大经典排序算法动画与解析,看我就够了!(配代码完全版)

这或许是东半球分析十大排序算法最好的一篇文章

面试官,我会写二分查找法!对,没有 bug 的那种!


面试官,你再问我滑动窗口问题试试?我有解题模板,不怕!
你点的每个“在看”,我都认真当成了喜欢