从简单到复杂,一文带你搞懂滑动窗口在数组及字符串中的应用

今天分享的内容是滑动窗口在数组中的应用。

分享的题目是LeetCode中的:

  • 209.长度最小的子数组 难度 中等

  • 438.找到字符串中所有字母异位词 难度 中等

  • 76.最小覆盖子串 难度 困难

接下来,逐一看下如何用滑动窗口的思想来解答这三道题目。

01

LeetCode #209 

长度最小的子数组

题目描述:

给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的 连续 子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。

示例:

输入:s = 7, nums = [2,3,1,2,4,3]

输出:2

解释:子数组 [4,3] 是该条件下的长度最小的子数组。


思路分析:

对于该题目可以用暴力解法来解决,使用双重for循环,第一重for循环遍历整个数组,用于标定起始考察元素。然后在开始第二重for循环之前,定义变量sum,用于表示所考察元素的总和。

在第二重for循环里,先计算总和,然后和正整数s比较,如果大于等于正整数s,则记录当前考察过的元素之间的长度。详细代码如下:

public int minSubArrayLen(int s, int[] nums) {
    // 初始化子数组长度为数组长度+1
    int result = nums.length + 1;
    for(int i = 0; i < nums.length; i++) {
        int sum = 0;
        for(int j = i; j < nums.length; j++) {
            sum += nums[j];
            // 如果当前累加和大于正整数s,则更新子数组长度
            if (sum >= s) {
                result = Math.min(result, j - i + 1);
            }
        }
    }
    // 如果最终result的值等于数组长度+1,则表示不存在符合条件的子数组
    return result == nums.length + 1 ? 0 : result;
}

接下来看下如何用滑动窗口的思想来解决该题目。首先,明确几个变量的定义,变量start表示窗口的起始位置,变量end表示窗口的结束位置,区间[start,end]用于记录当前窗口中的元素,变量sum表示窗口中所有元素的总和,变量result表示符合题意的子数组长度。

需要注意的是在这里,滑动窗口的起始位置start=0,结束位置end=-1,表示,初始状态下窗口中没有元素,因为区间[0,-1]并不存在。result的初始值给定数组长度加1。

接着,先扩大滑动窗口的右侧边界,即指针end向右移动一个位置。这时窗口区间为[0,0],窗口内的元素是2,即sum=2。

由于sum=2小于目标值s=7,因此需要继续扩大窗口右侧边界,即指针end继续向右移动一个位置。这时,窗口区间为[0,1],窗口内元素是2,3,即sum=5。

这时,由于sum=5依旧小于目标值s=7,因此需要继续扩大窗口右侧边界。也就是说,对于该题目,只要窗口区间[start,end]内元素总和小于目标值s=7,就需要继续扩大窗口右边界,来使sum变大。

如下图,当指针end指向索引3的位置时,窗口区间[0,3]内的元素2、3、1、2总和为8大于目标值s=7,因此需要更新result的值为4。

这时,请思考一个问题:窗口的右侧边界还有必要继续向右扩大吗?

答案是没有必要。因为,此时窗口内的元素总和已经大于等于目标值s=7,也就是已经找到一个连续的子数组{2,3,1,2}使得其和大于等目标值s=7了。如果继续向右扩大窗口右侧边界,只能是让窗口区间内的元素总和继续增大,伴随着的就是连续子数组的长度也在增大,而题目要求的是长度最小的连续子数组,因此,当窗口内所有元素的总和大于等目标值s=7时,就需要停止继续扩大窗口右侧边界这一动作。

这一点其实正是上面提到的暴力解法双重for循环的不足之处,即双重for循环会存在冗余的计算。如下图,最后两次计算,即j=4和j=5,在i=0时,是没必要参与计算的。

双重for循环,当i=0时,计算效果示意图

当窗口右侧边界不能扩大时,接着要做的就是缩小窗口的左侧边界,来看缩小左侧边界之后,窗口内的元素总和是否依旧大于等于目标值s=7,如果是,则继续缩小窗口左侧边界,如果不是则扩大窗口右侧边界,直到数组末尾。

动画演示

代码实现

public int minSubArrayLen(int s, int[] nums) {
    if (nums.length == 0) {
        return 0;
    }
    // window [start...end]
    int start = 0;
    int end = -1;
    int sum = 0;
    int result = nums.length + 1;
    while (start < nums.length) {
        // 还有剩余元素未考察并且窗口内元素总和小于目标值s
        if (end + 1 < nums.length && sum < s) {
            end++;
            sum += nums[end];
        } else { // 尝试缩小窗口
            sum -= nums[start];
            start++;
        }


        // 窗口内元素总和大于等于目标值s则更新结果值
        if (sum >= s) {
            result = Math.min(result, end - start + 1);
        }
    }


    return result == nums.length + 1 ? 0 : result;
}

02

LeetCode #438 

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

题目描述:

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

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

说明

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

  • 不考虑答案输出的顺序。

示例:

输入: s: "cbaebabacd" p: "abc"

输出: [0, 6]

解释:

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

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


思路分析:

该题目用滑动窗口思想解决的基本思路是:

首先,计算字符串p中各个元素出现的次数,由于字符串只包含小写英文字母,所以可以用数组来记录每个元素出的次数。

接着,用变量start表示窗口的起始位置,变量end表示窗口的结束位置,区间[start,end]用于记录当前窗口中的元素。在这里扩大窗口右侧边界的条件是——字符串s还有剩余元素为考察且窗口[start,end]内的字符长度小于字符串p的长度;缩小窗口左侧边界的条件是——窗口[start,end]内字符的长度等于字符串p的长度。

当,窗口[start,end]内字符的长度等于字符串p的长度时,接着要做的就是判断窗口内的字符串是不是字符串p的字母异位词(每个字母出现次数相同,只是顺序不同)。

具体逻辑可看如下动画演示或代码实现。

动画演示

代码实现

public List<Integer> findAnagrams(String s, String p) {
    List<Integer> resultList = new ArrayList<>();


    // 计算字符串p中各元素的出现次数
    int[] pFreq = new int[26];
    for(int i = 0; i < p.length(); i++) {
        pFreq[p.charAt(i)-'a']++;
    }


    // 窗口区间为[start,end]
    int start = 0, end = -1;
    while (start <s.length()) {
        // 还有剩余元素未考察,且窗口内字符串长度小于字符串p的长度
        // 则扩大窗口右侧边界
        if (end+1 < s.length() && end-start+1 <p.length()) {
            end++;
        }else {
            // 右侧边界不能继续扩大或窗口内字符串长度等于字符串p的长度
            // 则缩小左侧边界
            start++;
        }


        // 当窗口内字符串长度等于字符串p的长度时,则判断其是不是字符串p的字母异位词子串
        if (end-start+1 == p.length() && isAnagrams(s.substring(start,end+1), pFreq)) {
            resultList.add(start);
        }
    }
    return resultList;
}


// 判断当前子串是不是字符串p的字母异位词
private boolean isAnagrams(String window, int[] pFreq) {
    // 计算窗口内字符串各元素的出现次数
    int[] windowFreq = new int[26];
    for(int i = 0; i < window.length(); i++) {
        windowFreq[window.charAt(i)-'a']++;
    }


    // 比较窗口内各元素的出现次数和字符串p中各元素的出现次数是否一样
    // 如果都一样,则说明窗口内的字符串是字符串p的字母异位词子串
    // 如果不一样,则说明不是其子串
    for(int j = 0; j < 26; j++) {
        if (windowFreq[j] != pFreq[j]) {
            return false;
        }
    }
    return true;
}

03

LeetCode #76

最小覆盖子串

题目描述:

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。

注意:如果 s 中存在这样的子串,我们保证它是唯一的答案。

示例:

输入:s = "ADOBECODEBANC", t = "ABC"

输出:"BANC"


思路分析:

最小覆盖子串是这样的子串,不仅字符串t中的每个字符都在该子串中出现,同时该子串中那些和字符串t中字符一样的字符其出现次数要大于等于其在字符串t中出现的次数。该题目用滑动窗口思想来解决的整体思路是:

首先,统计字符串t中每个字符的出现次数。

然后,扩大窗口右侧边界,直到窗口内的字符串包含了字符串t中的每个字符,同时字符串t中的每个字符在窗口内的字符串中的出现次数要大于等于字符串t中每个字符的出现次数。

接着,尝试缩小窗口的左侧边界,看窗口内字符串能不能覆盖字符串t。

这里为了方便讲解,我们以字符串s=“BCEBA"和字符串t=”ABC"为例来看一下滑动窗口的具体实现过程。

同样的,用变量start表示窗口的起始位置,变量end表示窗口的结束位置,区间[start,end]用于记录当前窗口中的字符。其余定义如下图:

首先,向右移动指针end来扩大窗口右边界。在扩大窗口边界后,窗口内字符B的出现次数更新为1,同时该字符在字符串t中有出现,因此更新windowSameCount为1。

继续向右移动指针end来扩大窗口右边界,直到字符串t中的每个字符在窗口中都有出现,且窗口内和字符串t中的字符相同的字符A、B、C出现次数大于等于其本身在字符串t中的出现次数。

如下图,窗口[0,4]内那些和字符串t中一样的字符A、B、C其出现次数分别为1、2、1。这时字符串"BCEBA"就是满足题意的一个子串。

接着,要做的就是移动指针start来缩小窗口左侧边界。看窗口内的字符串是否依旧可以覆盖字符串t="ABC"。

如下图,当移动指针start缩小窗口左侧边界后,窗口[1,4]内的字符串依旧可以覆盖字符串t。因为字符串t中的字符A、B、C在窗口[0,4]内都有出现,且出现次数依次为1、1、1。

由于指针end已经指向字符串s最后一个字符,因此接着要做的就是移动指针start来缩小窗口范围继续看窗口内的字符串是否可以覆盖字符串t。直到指针start也指向字符串s最后一个字符。

动画演示

代码实现

public String minWindow(String s, String t) {
    // 统计字符串t中每个字符的出现次数
    int[] tFreq = new int[256];
    for(int i = 0; i < t.length(); i++) {
        tFreq[t.charAt(i)]++;
    }


    // 用于记录窗口中每个字符的出现次数
    int[] windowFreq = new int[256];
    // 当前窗口中和字符串t中的字符相同的字符个数
    int windowSameCount = 0;


    int minWindowLength = s.length()+1;
    int startIndex = -1;


    int start = 0;
    int end = -1;
    while (start < s.length()) {
        if (end+1<s.length() && windowSameCount < t.length()) {
            windowFreq[s.charAt(end+1)]++;
            // 窗口中当前考察的字符的出现次数小于等于字符串t中字符出现次数
            // 则窗口中当前考察的字符是在字符串t中出现的
            if (windowFreq[s.charAt(end+1)] <= tFreq[s.charAt(end+1)]) {
                windowSameCount++;
            }
            end++;
        }else {
            // 字符串t中的字符在窗口中全部出现
            if (windowSameCount == t.length() && end - start + 1 < minWindowLength) {
                minWindowLength = end - start + 1;
                startIndex = start;
                System.out.println(s.substring(start,end+1));
            }
            // 窗口左移
            windowFreq[s.charAt(start)]--;
            if (windowFreq[s.charAt(start)] < tFreq[s.charAt(start)]) {
                windowSameCount--;
            }
            start++;
        }
    }


    if (startIndex != -1) {
        return s.substring(startIndex, startIndex+minWindowLength);
    }
    return "";
}

今天的分享就到这里了

感谢您的阅读

如有错误

欢迎在公众号后台留言指出

下一篇我们将学习新的内容,敬请期待


题图:blende12 / Pixabay

参考资料:

https://github.com/liuyubobobo/Play-Leetcode/blob/master/0438-Find-All-Anagrams-in-a-String/cpp-0438/main.cpp

https://github.com/liuyubobobo/Play-Leetcode/blob/master/0076-Minimum-Window-Substring/cpp-0076/main.cpp

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值