标签搜索

目 录CONTENT

文章目录

滑动窗口技巧.md

小小城
2021-08-22 / 0 评论 / 0 点赞 / 13 阅读 / 4,498 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2022-05-22,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

滑动窗口技巧

本文详解「滑动窗口」这种高级双指针技巧的算法框架,带你秒杀几道高难度的子字符串匹配问题。

一、最小覆盖子串
https://leetcode.cn/problems/minimum-window-substring/

在这里插入图片描述

题目不难理解,就是说要在 S(source) 中找到包含 T(target) 中全部字母的一个子串,顺序无所谓,但这个子串一定是所有可能子串中最短的

如果我们使用暴力解法,代码大概是这样的:

for (int i = 0; i < s.size(); i++)
    for (int j = i + 1; j < s.size(); j++)
        if s[i:j] 包含 t 的所有字母:
            更新答案

这个算法的复杂度肯定大于 O(N^2) 了,不好。

滑动窗口算法的思路 是这样:

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 的末端,算法结束

上述过程可以简单地写出如下伪码框架:

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;

现在就剩下一个比较棘手的问题:如何判断 window 即子串 s[left…right] 是否符合要求,是否包含 t 的所有字符呢?

可以用两个哈希表当作计数器解决用一个哈希表 needs 记录字符串 t 中包含的字符及出现次数,用另一个哈希表 window 记录当前「窗口」中包含的字符及出现的次数如果 window 包含所有 needs 中的键,且这些键对应的值都大于等于 needs 中的值,那么就可以知道当前「窗口」符合要求了,可以开始移动 left 指针了

现在将上面的框架继续细化:

string s, t;
// 在 s 中寻找 t 的「最小覆盖子串」
int left = 0, right = 0;
string res = s;

// 相当于两个计数器
unordered_map<char, int> window;//记录窗口中在need当中某个字符出现的次数
unordered_map<char, int> needs;//记录t中每个字符出现的次数

for (char c : t) needs[c]++;

// 记录 window 中已经有多少字符符合要求了
int match = 0; 

while (right < s.size()) {

    char c1 = s[right];
    if (needs.count(c1)) {//判断键 c1 是否存在,不存在就不用计数,没啥用
    	//反而影响后面的判断
    	
        window[c1]++; // 加入 window
        if (window[c1] == needs[c1])//因为t中的某个字符并不是只出现一次,
        	//不仅要出现还要次数相同
            // 字符 c1 的出现次数符合要求了
           //不能是 >= 会多次match++
            match++;//一个字符满足要求
    }
    right++;//更新滑动窗口的大小

    // window 中的字符串已符合 needs 的要求了,开始缩小滑动窗口
    while (match == needs.size()) {
        // 更新结果 res
        res = minLen(res, window);
        
        char c2 = s[left];
        if (needs.count(c2)) {
            window[c2]--; // 移出 window
            if (window[c2] < needs[c2])//不能是 != ,因为Win个数可能大于ned
                // 字符 c2 出现次数不再符合要求
                match--;
        }
        left++;
    }
}
return res;

上述代码已经具备完整的逻辑了,只有一处伪码,即更新 res 的地方,不过这个问题太好解决了,直接看解法吧!

string minWindow(string s, string t) {
    // 记录最短子串的开始位置和长度
    int start = 0, minLen = INT_MAX;
    int left = 0, right = 0;

    unordered_map<char, int> window;
    unordered_map<char, int> needs;
    
    for (char c : t) needs[c]++;

    int match = 0;

    while (right < s.size()) {
    
        char c1 = s[right];
        if (needs.count(c1)) {
            window[c1]++;
            if (window[c1] == needs[c1]) 
                match++;
        }
        right++;

        while (match == needs.size()) {
            if (right - left < minLen) {
                // 更新最小子串的位置和长度
                start = left;//因为结果是采用substr的方式返回的,
                //要记录开始下标
                minLen = right - left;//更新minLen长度
            }
            char c2 = s[left];
            if (needs.count(c2)) {
                window[c2]--;
                if (window[c2] < needs[c2])
                    match--;
            }
            left++;
        }
    }
    return minLen == INT_MAX ?
                "" : s.substr(start, minLen);
}

这个算法的时间复杂度是 O(M+N)O(M + N),M 和 N 分别是字符串 S 和 T 的长度。因为我们先用 for 循环遍历了字符串 T 来初始化 needs,时间 O(N),之后的两个 while 循环最多执行 2M 次,时间 O(M)O(M)

二、找到字符串中所有字母异位词
https://leetcode.cn/problems/find-all-anagrams-in-a-string/

在这里插入图片描述

只要把上一道题的代码改中更新 res 部分的代码稍加修改就成了这道题的解:

vector<int> findAnagrams(string s, string t) {
    // 用数组记录答案
    vector<int> res;
    
    int left = 0, right = 0;
    unordered_map<char, int> needs;
    unordered_map<char, int> window;
    
    for (char c : t) needs[c]++;
    
    int match = 0;

    while (right < s.size()) {
        char c1 = s[right];
        if (needs.count(c1)) {
            window[c1]++;
            if (window[c1] == needs[c1])
                match++;
        }
        right++;

        while (match == needs.size()) {
            // 如果 window 的大小合适
            // 就把起始索引 left 加入结果
            if (right - left == t.size()) {//window中存的是满足t中的符,
            //虽然在[left,right]区间中满足need的所有情况,但是可能不连续
            //这种情况不符合题意,所以要进行判断
                res.push_back(left);
            }
            
            char c2 = s[left];
            if (needs.count(c2)) {
                window[c2]--;
                if (window[c2] < needs[c2])
                    match--;
            }
            left++;
        }
    }
    return res;
}

因为这道题和上一道的场景类似,也需要 window 中包含串 t 的所有字符,但上一道题要找长度最短的子串,这道题要找长度相同的子串()只需要多加一个if,也就是「字母异位词」嘛。

三、无重复字符的最长子串
https://leetcode.cn/problems/longest-substring-without-repeating-characters/

在这里插入图片描述

遇到子串问题,首先想到的就是滑动窗口技巧。

类似之前的思路,使用 window 作为计数器记录窗口中的字符出现次数,然后先向右移动 right,当 window 中出现重复字符时,开始移动 left 缩小窗口,如此往复:

int lengthOfLongestSubstring(string s) {
    int left = 0, right = 0;
    unordered_map<char, int> window;
    
    int res = 0; // 记录最长长度

    while (right < s.size()) {
    
        char c1 = s[right];
        window[c1]++;
        right++;
        
        // 如果 window 中出现重复字符
        // 开始移动 left 缩小窗口,相当于去重
        while (window[c1] > 1) {
            char c2 = s[left];
            window[c2]--;
            left++;
        }
        res = max(res, right - left);
    }
    return res;
}

需要注意的是,因为我们要求的是最长子串,所以需要在每次移动 right 增大窗口时更新 res,而不是像之前的题目在移动 left 缩小窗口时更新 res

最后总结

通过上面三道题,我们可以总结出滑动窗口算法的抽象思想:

int left = 0, right = 0;

while (right < s.size()) {
    window.add(s[right]);
    right++;

    while (valid) {
        window.remove(s[left]);
        left++;
    }
}

其中 window 的数据类型可以视具体情况而定,比如上述题目都使用哈希表充当计数器,当然你也可以用一个数组实现同样效果,因为我们只处理英文字母。

稍微麻烦的地方就是这个 valid 条件,为了实现这个条件的实时更新,我们可能会写很多代码。比如前两道题,看起来解法篇幅那么长,实际上思想还是很简单,只是大多数代码都在处理这个问题而已。

0

评论区