【算法】滑动窗口

【算法】滑动窗口

本文记录了近期在leetcode上刷到的关于滑动窗口的题目。

滑动窗口其实也可以归类为双指针的一种。滑动窗口的基本思想是维护一个窗口,窗口内的元素满足某些条件。窗口的左右边界分别用两个指针表示,窗口的大小可以变化。

解题通用模板:

//外层循环扩展右边界,内层循环扩展左边界
for (int l = 0, r = 0 ; r < n ; r++) {
	//当前考虑的元素
	while (l <= r && check()) {//区间[left,right]不符合题意
        //扩展左边界
    }
    //区间[left,right]符合题意,统计相关信息
}

长度最小的子数组

209. 长度最小的子数组

给定一个含有 n 个正整数的数组和一个正整数 target 。

找出该数组中满足其总和大于等于 target 的长度最小的 子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。

示例 1:
输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。

示例 2:
输入:target = 4, nums = [1,4,4]
输出:1

示例 3:
输入:target = 11, nums = [1,1,1,1,1,1,1,1]
输出:0

提示:
1 <= target <= 109
1 <= nums.length <= 105
1 <= nums[i] <= 104
 
进阶:
如果你已经实现 O(n) 时间复杂度的解法, 请尝试设计一个 O(n log(n)) 时间复杂度的解法。

暴力解法

暴力解法的思路是枚举所有可能的子数组,计算子数组的和是否大于等于target。如果是,更新最小长度。

class Solution {
    public int minSubArrayLen(int s, int[] nums) {
        int n = nums.length;
        if (n == 0) {
            return 0;
        }
        int ans = Integer.MAX_VALUE;
        // 枚举子数组的左边界
        for (int i = 0; i < n; i++) {
            int sum = 0;
            // 枚举子数组的右边界
            for (int j = i; j < n; j++) {
                sum += nums[j];
                if (sum >= s) {
                    ans = Math.min(ans, j - i + 1);
                    break;
                }
            }
        }
        return ans == Integer.MAX_VALUE ? 0 : ans;
    }
}

复杂度分析

  • 时间复杂度:O(n2),其中 n 是数组的长度。需要遍历每个下标作为子数组的开始下标,对于每个开始下标,需要遍历其后面的下标得到长度最小的子数组。
  • 空间复杂度:O(1)。

滑动窗口

滑动窗口的思路是维护一个窗口,窗口的左右边界分别用两个指针表示,窗口的大小可以变化。

初始时,窗口的左右边界都指向数组的第一个元素,sum和设为0。

  • 每次将右指针右移一位,将右指针指向的元素加入sum中。
  • 如果sum大于等于target,说明满足条件了,更新最小长度,同时将左指针右移一位,将左指针指向的元素从sum中减去。
  • 重复以上步骤,直到右指针到达数组的末尾。
class Solution {
public:
    int minSubArrayLen(int s, vector<int>& nums) {
        int n = nums.size();
        if (n == 0) {
            return 0;
        }
        // 因为要找最小值,result设为 INT_MAX 最大值,方便后续比较
        int result = INT_MAX;
        int left = 0;
        int right = 0;
        int sum = 0;
        // 右指针右移,加入元素
        while (right <= n - 1) {
            sum += nums[right];
            // 可以不用判断左右指针相对位置,重合时,减完sum就为0了
            while (sum >= s) {
                result = min(right - left + 1, result);
                // 左指针右移,提前减去这个位置的元素
                sum -= nums[left];
                left++;
            }
            right++;
        }
        // 如果result没有被更新,说明没有符合条件的子数组
        return result == INT_MAX ? 0 : result;
    }
};

无重复字符的最长子串

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

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

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

示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

提示:
0 <= s.length <= 5 * 104
s 由英文字母、数字、符号和空格组成

滑动窗口加unordered_set记录

我们不妨以示例一中的字符串 abcabcbb 为例,找出从每一个字符开始的,不包含重复字符的最长子串,那么其中最长的那个字符串即为答案。对于示例一中的字符串,我们列举出这些结果,其中括号中表示选中的字符以及最长的字符串:

以 (a)bcabcbb 开始的最长字符串为 (abc)abcbb;
以 a(b)cabcbb 开始的最长字符串为 a(bca)bcbb;
以 ab(c)abcbb 开始的最长字符串为 ab(cab)cbb;
以 abc(a)bcbb 开始的最长字符串为 abc(abc)bb;
以 abca(b)cbb 开始的最长字符串为 abca(bc)bb;
以 abcab(c)bb 开始的最长字符串为 abcab(cb)b;
以 abcabc(b)b 开始的最长字符串为 abcabc(b)b;
以 abcabcb(b) 开始的最长字符串为 abcabcb(b)。

可以看到,随着左指针移动,右边界也是在移动的。

我们就可以使用「滑动窗口」来解决这个问题了:

我们使用两个指针表示字符串中的某个子串(或窗口)的左右边界,其中左指针代表着上文中「枚举子串的起始位置」。

在每一步的操作中,我们会将左指针向右移动一格,表示 我们开始枚举下一个字符作为起始位置,然后我们可以不断地向右移动右指针,但需要保证这两个指针对应的子串中没有重复的字符。在移动结束后,这个子串就对应着 以左指针开始的,不包含重复字符的最长子串。我们记录下这个子串的长度;

在枚举结束后,我们找到的最长的子串的长度即为答案。

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        int n = s.size();
        if (n <= 1) {
            return n;
        }
        int ans = 0;
        unordered_set<char> filter;
        int right = 0;
        for (int left = 0; left < n; left++) {
            // 左指针向右移动一格,移除掉遍历检查过的字符
            if (left != 0) {
                // 移除掉遍历检查过的字符
                filter.erase(s[left - 1]);
            }
            // 右指针向右移动,加入元素,直到遇到重复元素
            while (right <= n - 1 && !filter.count(s[right])) {
                filter.insert(s[right]);
                right++;
            }
            // 更新答案,此时右指针指向的元素是重复元素,所以 right - left 是当前窗口的长度
            ans = max(ans, right - left);
        }
        return ans;
    }
};

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

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

给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。

示例 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" 的异位词。

提示:
1 <= s.length, p.length <= 3 * 104
s 和 p 仅包含小写字母

思路 滑动窗口检查异位词

根据题目要求,我们需要在字符串 s 寻找字符串 p 的异位词。因为字符串 p 的异位词的长度一定与字符串 p 的长度相同,所以我们可以在字符串 s 中构造一个长度为与字符串 p 的长度相同的滑动窗口,并在滑动中维护窗口中每种字母的数量

不需要费劲去排列组合,只需要看每种字符的数量是否相等即可。

当窗口中每种字母的数量与字符串 p 中每种字母的数量相同时,则说明当前窗口为字符串 p 的异位词。

而且字符的存储形式是一个整形,所以我们可以用一个 vector<int> 来记录窗口中每种字母的数量。

两个 vector<int> 分别记录字符串 p 中每种字母的数量和窗口中每种字母的数量。数组相等可以直接使用 == 运算符。底层已经实现了。

vector== 运算符,内部已实现,先判断长度,若长度相同再逐个比较元素是否相等。

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        int sSize = s.size();
        int pSize = p.size();

        if (sSize < pSize) {
            return {};
        }

        vector<int> result;

        // 承载s和p中每一个字符的数组,大小固定为26
        vector<int> sDict(26);
        vector<int> pDict(26);
        for (int i = 0; i < pSize; i++) {
            // 填入字母和字符a的差值作为存储判断的数据
            sDict[s[i] - 'a']++;
            pDict[p[i] - 'a']++;
        }
        // 至此,p字符串的结构体已经录入了pDict这个数组

        // 先比较一次s的开头字符串
        if (sDict == pDict) {
            result.emplace_back(0);
        }

        // 比较剩余的元素
        for (int i = 0; i < sSize - pSize; i++) {
            // 补上新的剩余字符计入
            sDict[s[pSize + i] - 'a']++;
            // 删掉之前计算的字符,保持窗口大小不变
            sDict[s[i] - 'a']--;

            if (sDict == pDict) {
                // i 是从减去p长度的后一个位置开始的,在s中的实际下标需要加 1
                result.emplace_back(i + 1);
            }
        }
        return result;
    }
};

emplace_back 是 vector 的一个成员函数,用于在 vector 的末尾直接构造一个元素,而不是先创建一个临时对象再将其复制到 vector 中。适合大对象,复杂对象的构造。这里使用 push_back 也没差。

30.串联所有单词的子串

30. 串联所有单词的子串

这题可以说是438的进阶版,不过一个是字符,一个是字符串版本。

给定一个字符串 s 和一个字符串数组 words。 words 中所有字符串 长度相同。

s 中的 串联子串 是指一个包含  words 中所有字符串以任意顺序排列连接起来的子串。

例如,如果 words = ["ab","cd","ef"], 那么 "abcdef", "abefcd","cdabef", "cdefab","efabcd", 和 "efcdab" 都是串联子串。 "acdbef" 不是串联子串,因为他不是任何 words 排列的连接。
返回所有串联子串在 s 中的开始索引。你可以以 任意顺序 返回答案。

示例 1:
输入:s = "barfoothefoobarman", words = ["foo","bar"]
输出:[0,9]
解释:因为 words.length == 2 同时 words[i].length == 3,连接的子字符串的长度必须为 6。
子串 "barfoo" 开始位置是 0。它是 words 中以 ["bar","foo"] 顺序排列的连接。
子串 "foobar" 开始位置是 9。它是 words 中以 ["foo","bar"] 顺序排列的连接。
输出顺序无关紧要。返回 [9,0] 也是可以的。

示例 2:
输入:s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"]
输出:[]
解释:因为 words.length == 4 并且 words[i].length == 4,所以串联子串的长度必须为 16。
s 中没有子串长度为 16 并且等于 words 的任何顺序排列的连接。
所以我们返回一个空数组。

示例 3:
输入:s = "barfoofoobarthefoobarman", words = ["bar","foo","the"]
输出:[6,9,12]
解释:因为 words.length == 3 并且 words[i].length == 3,所以串联子串的长度必须为 9。
子串 "foobarthe" 开始位置是 6。它是 words 中以 ["foo","bar","the"] 顺序排列的连接。
子串 "barthefoo" 开始位置是 9。它是 words 中以 ["bar","the","foo"] 顺序排列的连接。
子串 "thefoobar" 开始位置是 12。它是 words 中以 ["the","foo","bar"] 顺序排列的连接。

提示:
1 <= s.length <= 104
1 <= words.length <= 5000
1 <= words[i].length <= 30
words[i] 和 s 由小写英文字母组成

滑动窗口检查

使用的还是滑动窗口检查异位词这题的思想。只不过检查的对象成了字符串,并且用来比较的数据集合改用hash表,记录每个字符串出现的次数。

重点在于如何划分窗口。记 words 的长度为 mwords 中每个单词的长度为 ns 的长度为 ls 。首先需要将 s 划分为单词组,每个单词的大小均为 n (首尾除外)。这样的划分方法有 n 种,即先删去前 i (i=0∼n−1) 个字母后,将剩下的字母进行划分,如果末尾有不到 n 个字母也删去。对这 n 种划分得到的单词数组分别使用滑动窗口对 words 进行类似于字母异位词的搜寻。

检查流程如下:

第一次滑动流程
初始窗口 ['bar', 'foo','foo'] 未命中
入'bar'出'bar' ['foo','foo','bar'] 未命中
入'the'出'foo' ['foo','bar', 'the'] 命中 index 6
入'foo'出'foo' ['bar','the', 'foo'] 命中 index 9
入'bar'出'bar' ['the','foo','bar'] 命中index 12
入'man'出'the' ['foo','bar','man'] 未命中
结束
第二次滑动流程
初始窗口 ['arf','oof','oob'] 未命中
划动过程略
第三次滑动流程
初始窗口 ['rfo','ofo','oba'] 未命中
划动过程略
class Solution {
public:
    vector<int> findSubstring(string& s, vector<string>& words) {
        vector<int> res; // 存储结果的向量,记录所有符合条件的起始索引
        
        // 获取关键参数:
        int m = words.size();     // words中单词的个数
        int n = words[0].size();  // 每个单词的长度(题目保证所有单词长度相同)
        int ls = s.size();        // 字符串s的长度
        
        // 外层循环:从0到n-1,处理不同的起始偏移量
        // 这样可以覆盖所有可能的对齐方式
        // 第二个结束条件就是剩余的字符串长度不足以支持分割
        for (int i = 0; i < n && i + m * n <= ls; ++i) {
            // 使用哈希表记录当前窗口内各单词出现次数与words中次数的差异
            unordered_map<string, int> differ;
            
            // 初始化滑动窗口:截取从位置i开始的m个单词
            for (int j = 0; j < m; ++j) {
                // 从s中截取一个单词并加入differ(增加计数)
                ++differ[s.substr(i + j * n, n)];
            }
            
            // 用words中的单词来平衡differ(减少计数)
            for (string& word : words) {
                // 如果某个单词在words中,就在differ中减少其计数
                if (--differ[word] == 0) {
                    // 如果计数减到0,就从differ中移除该单词
                    differ.erase(word);
                }
            }
            
            // 开始逐词滑动窗口遍历
            for (int start = i; start < ls - m * n + 1; start += n) {
                // 初始窗口第一次检查已完成,如果不是初始窗口,需要更新differ
                if (start != i) { 
                    // 添加新进入窗口的单词(窗口右边界新增的单词)
                    string word = s.substr(start + (m - 1) * n, n);
                    if (++differ[word] == 0) {
                        differ.erase(word); // 如果计数变为0,移除
                    }
                    
                    // 移除离开窗口的单词(窗口左边界移出的单词)  
                    word = s.substr(start - n, n);
                    if (--differ[word] == 0) {
                        differ.erase(word); // 如果计数变为0,移除
                    }
                }
                
                // 检查当前窗口是否匹配:如果differ为空,说明窗口内的单词与words完全匹配
                if (differ.empty()) {
                    res.emplace_back(start); // 记录当前起始位置
                }
            }
        }
        return res; // 返回所有找到的起始索引
    }
};

76.最小覆盖子串

76. 最小覆盖子串

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

注意:

对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
如果 s 中存在这样的子串,我们保证它是唯一的答案。

示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。

示例 2:
输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。

示例 3:
输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。

提示:
m == s.length
n == t.length
1 <= m, n <= 105
s 和 t 由英文字母组成
 
进阶:你能设计一个在 o(m+n) 时间内解决此问题的算法吗?

滑动窗口加哈希表动态判断

解题思路,右指针往前遍历,一路扩大检查的范围,直到当前窗口区域内,可以包含 t 字符串的组成字符数量后,再缩小范围,直到不包含,过程中动态更新区间大小,取最短区间。

本地验证OK,问题点是添加了一个hash表来判断对比,超出内存限制了。

class Solution {
public:
    bool checkDict(unordered_map<char, int>& dyDict,
                   unordered_map<char, int>& tDict) {
        auto it = tDict.begin();
        while (it != tDict.end()) {
            if (it->second > dyDict[it->first]) {
                return false;
            }
            it++;
        }
        return true;
    }

    string minWindow(string s, string t) {
        int n = s.size();
        int m = t.size();
        if (n < m) {
            return "";
        }
        // 需要动态更新最小值
        string result = "";
        bool isFound = false;
        unordered_map<char, int> tDict;
        unordered_map<char, int> dynamicDitct;
        // 先填入即将用来比对hash表数据
        for (char c : t) {
            tDict[c]++;
        }
        // 思路,一路扩大检查的范围,直到包含的tDict中的字符数量后,再缩小范围到不包含,记录下区间大小
        int right = 0;
        int left = 0;
        while (right < n) {
            dynamicDitct[s[right]]++;
            // 包含了组成t的字符组合
            while (left < n && left + m <= right + 1 &&
                   checkDict(dynamicDitct, tDict)) {
                string temp = s.substr(left, right - left + 1);
                if (result == "") {
                    result = temp;
                } else {
                    result = result.size() > temp.size() ? temp : result;
                }
                dynamicDitct[s[left]]--;
                left++;
            }
            right++;
        }
        return result;
    }
};

优化内存消耗

内存问题有以下几点:

  • 每次检查都要遍历整个 tDict ,在滑动窗口过程中被调用次数为 O(n²) 级别
  • 每次调用 dyDict[it->first] 可能会在 dyDict 中创建新键值对
  • 频繁截取字符串,字符串可能很长,频繁创建和销毁消耗大量内存

优化思路:

  • 使用两个计数器 requiredformed 来跟踪需要匹配的字符种类数和当前已匹配的字符种类数,避免每次都遍历整个 tDict
class Solution {
public:
    string minWindow(string s, string t) {
        if (s.empty() || t.empty() || s.length() < t.length()) return "";
        
        unordered_map<char, int> tCount;
        for (char c : t) tCount[c]++;
        
        int required = tCount.size(); // 需要匹配的字符种类数
        int formed = 0; // 当前已匹配的字符种类数
        
        unordered_map<char, int> windowCount;
        int left = 0, right = 0;
        int minLen = INT_MAX;
        int start = 0;
        
        while (right < s.length()) {
            char c = s[right];
            windowCount[c]++;
            
            // 检查当前字符是否达到t中的要求
            if (tCount.count(c) && windowCount[c] == tCount[c]) {
                formed++;
            }
            
            // 尝试收缩窗口
            while (left <= right && formed == required) {
                c = s[left];
                
                // 更新最小窗口
                if (right - left + 1 < minLen) {
                    minLen = right - left + 1;
                    start = left;
                }
                
                // 移动左指针
                windowCount[c]--;
                if (tCount.count(c) && windowCount[c] < tCount[c]) {
                    formed--;
                }
                left++;
            }
            
            right++;
        }
        
        return minLen == INT_MAX ? "" : s.substr(start, minLen);
    }
};

官方题解

思路相同,性能更优:

class Solution {
public:
    unordered_map<char, int> ori, cnt;

    bool check() {
        for (const auto& p : ori) {
            if (cnt[p.first] < p.second) {
                return false;
            }
        }
        return true;
    }

    string minWindow(string s, string t) {
        for (const auto& c : t) {
            ++ori[c];
        }

        int l = 0, r = -1;
        int len = INT_MAX, ansL = -1, ansR = -1;

        while (r < int(s.size())) {
            if (ori.find(s[++r]) != ori.end()) {
                ++cnt[s[r]];
            }
            while (check() && l <= r) {
                if (r - l + 1 < len) {
                    len = r - l + 1;
                    ansL = l;
                }
                if (ori.find(s[l]) != ori.end()) {
                    --cnt[s[l]];
                }
                ++l;
            }
        }

        return ansL == -1 ? string() : s.substr(ansL, len);
    }
};