[LeetCode] 滑动窗口题总结

写在前面

滑动窗口题也是面试中常考的题,滑动窗口的知识点来源我的理解是《计算机网络》,在网络中控制拥塞和流量是通过滑动窗口来控制的,因此这类题比较受面试官喜爱。滑动窗口题常见的解法是Two Pointers,另外为了找出滑动窗口内某极值(问题),会想到排序或者说有序,对待有序我们有两个办法即sort(),时间复杂度为O(nlgn),另一种是采取rb-tree,比如multiset/set,或者heap,比如priority_queue,边插入边排序,n被原始的遍历消耗掉,此时找出极值时间复杂度为O(lgn),因此,稍微演变即可出非常复杂的问题。

备注:

  • 有些题滑动窗口条件比较好创设,但是有些题条件并不明显,而在这些场景中,需要我们引入外部条件,创设滑动窗口边界指针移动条件,比如题395;

239. 滑动窗口最大值

解法: 最简单的想法是,对每个窗口线性遍历一次求max,但是我们会发现时间复杂度为O(n*k),OJ会TLE,然后想到排序,最自然的想法是,让数据插入的时候动态有序,在数据退出窗口动态删除,OK,我们可以用multiset或者priority_queue,因此获取窗口内极值的时间复杂度为O(lgk),因此整个时间复杂度为O(nlgk),侥幸能AC;但是题目要求线性时间复杂度,这里我参考了这篇博客解法,如果没记错,应该是《剑指offer》上的题,一看解法就想起来了,看来是时候开始再刷《剑指》了,OK,大致的思路是,维护一个双端队列,让队头元素为窗口内最大元素,具体的做法是,遍历数组过程中,若队尾元素小于要加入的元素,那么把队列元素去掉,(为什么呢?因为我们在除去队头元素,i.e.,出窗口,会担心后面加了最大元素,(1)但是这个元素又不在队头,而且,队尾小于当前的元素去掉,也是因为它在窗口内时,(2)当前元素一定在窗口内,队尾元素不会成为最大元素,出于(1)和(2)考虑,我们会清空队尾元素,直至队尾元素大于当前元素),然后添加当前元素至队尾,这种解法时间复杂度为O(n),出队和入队操作时间复杂度为O(1)。请看两种解法的代码:

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        multiset<int> ms;
        vector<int> res;
        int n = nums.size();
        for (int i = 0; i < n; ++i) {
            ms.insert(nums[i]);
            if (ms.size() == k) {
                auto a = *(ms.rbegin());
                res.push_back(a);
                auto it = ms.find(nums[i - k + 1]);
                ms.erase(it);
            }
        }
        return res;
    }
};
class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        int n = nums.size();
        vector<int> res;
        deque<int> q;
        for (int i = 0; i < n; ++i) {
            if (i >= k) {
                res.push_back(nums[q.front()]); 
                if (i - q.front() >= k) q.pop_front();
            }
            while (!q.empty() && nums[q.back()] < nums[i]) q.pop_back();
            q.push_back(i);
        }
        if (!q.empty()) res.push_back(nums[q.front()]); 
        return res;
    }
};

76. 最小覆盖子串

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

解题思路: 此题最自然的想法是,首先用双指针截取S的一段子串,然后用哈希判断T中每个字符对应的个数是否均小于它在S中对应的个数,然后双指针在移动时遵守滑动窗口规则(i.e.,每次将右边界指针所指元素加入统计窗口,若满足条件,则尝试着右移左边界指针以收缩窗口直至条件不满足,在收缩的过程中记录满足条件的最小窗口) ,依据上述解法,解出此题不难,代码请看解法1,但是我们不难发现解法1每次在判断条件是否满足时,均需扫描一次哈希,这导致整个算法的时间复杂度为O(n*k),k为滑动窗口宽度。
1.解法1

class Solution {
public:
    bool isOK(unordered_map<char, int> &ms, unordered_map<char, int> &mt) {
        for (auto &it : mt) {
            if (it.second > ms[it.first]) return false;
        }
        return true;
    }
    string minWindow(string s, string t) {
        unordered_map<char, int> m1;
        for (auto &a : t) ++m1[a];
        int i = 0, n = s.size();
        unordered_map<char, int> m2;
        int mnlen = INT_MAX, idx = 0;
        for (int j = 0; j < n; ++j) {
            ++m2[s[j]];
            while (i <= j && isOK(m2, m1)) {
                if (mnlen > j - i + 1) {
                    mnlen = j - i + 1;
                    idx = i;
                }
                --m2[s[i]];
                ++i;
            } 
        }
        return mnlen == INT_MAX ? "" : s.substr(idx, mnlen);
    }
};

进一步降低时间复杂度的算法我没想出来,直接参考了以前的解法,发现,往往我们在解滑动窗口题时,若给了像子串条件题,思路很容易陷入,分别求两个字符串的字频,然后在伸缩窗口时将每个字频做比较(也即对应上述的解法1),但是我们看下面的解法,发现,思维做了转变,对资源进行统计,怎么说呢?tcnt即为我们要从s中提供的资源,s中非t中字符视为无效资源,当无效资源进入窗口时,并不能时tcnt大于等于0,只有当在t中的s字符(即有效资源)才能使tcnt大于等于0,而cnt即是用来统计窗口内有效资源的个数,当有效资源格式等于t长度时,则找到一个符合要求的子串,在收缩窗口时,则要把收入窗口的资源还回去,即tcnt自加,对于无效资源,还回去后它对应的tcnt为0,只有有效资源的tcnt才大于0。想清楚tcnt的作用,然后借助滑动窗口思想解题就不难了。

当然关于此题的tcnt还有两点疑问未解:

  • 为什么说窗口内有效资源个数等于t.size()就可认为满足条件即有没有可能漏掉t中元素?对于无效资源因为它不会使tcnt大于等于0,因此cnt统计有效资源,当t中对应元素未消耗完时扫描到对应字符串,则tcnt[s[i]]>0,若消耗完时,tcnt[s[i]]=0,只有当t中所有字符串都消耗完时,才能使--tcnt[s[i]] >= 0满足t.size()次,至此,问题1可解;
  • 为什么++tcnt[s[left]] > 0时,要将–cnt,而等于0时不做?因为换回去的时候,无效资源对应的tcnt为0,若中间有它的重复元素,还好迫使它小于0,另外为什么大于0的是有效元素,这个也可以这么理解,因为tcnt对有效资源在起始时均做了t中对应个数的偏移,而无效资源均为0.

2.解法2

class Solution {
public:
    string minWindow(string s, string t) {
        unordered_map<char, int> tcnt;
        for (auto &a : t) ++tcnt[a];
        int cnt = 0, left = 0, mnLen = INT_MAX;
        string res;
        for (int i = 0; i < s.size(); ++i) {
            if (--tcnt[s[i]] >= 0) ++cnt;
            while (left <= i && cnt == t.size()) {
                if (mnLen > i - left + 1) {
                    mnLen = i - left + 1;
                    res = s.substr(left, mnLen);
                }
                if (++tcnt[s[left]] > 0) --cnt;
                ++left;
            }
        }
        return res;
    }
};

632. 最小区间

你有 k 个升序排列的整数数组。找到一个最小区间,使得 k 个列表中的每个列表至少有一个数包含在其中。

我们定义如果 b-a < d-c 或者在 b-a == d-ca < c,则区间 [a,b][c,d] 小。

解题思路: 此题我想出的时候暴力解法,先求出k个序列的最小值和最大值,将其作为带求区间的初始状态,接下里就是要讨论当满足条件时是由左边界右移还是由右边界左移,这里没想到一个好的判断标准,于是想用回溯解法(可以想象成二叉树)假设此次左移不成就右移,递归试探。但是我没写,略加思考即可发现这种解法时间复杂度非常高,OJ会TLE,OK至此我没其他思路了,于是参考了这篇博客的解题思路+上一题的解法(题76),会发现此题可转化到题76的解法场景。具体的思路是,首先将k个序列合成一个序列,然后给每个元素打上所属组的标签,然后对合成序列按照元素值大小升序排序,那么题目所求的问题"找最小区间,使得包含k个序列中至少一个元素"可以转化为问题"求合成序列最小窗口,使得窗口内的元素来自k个不同序列",那么此题由属于满足某个条件的最小滑动窗口问题,看懂了题76解法,解此题不难。请看代码。

class Solution {
public:
    vector<int> smallestRange(vector<vector<int>>& nums) {
        vector<pair<int, int>> tmp;
        for (int i = 0; i < nums.size(); ++i) {
            for (int j = 0; j < nums[i].size(); ++j) {
                tmp.push_back(make_pair(i, nums[i][j]));
            }
        }
        sort(tmp.begin(), tmp.end(), [](auto a, auto b) {
            return a.second < b.second;
        });
        unordered_map<int, int> intervalCnt;
        int left = 0, cnt = 0, totalInterval = nums.size(), mnLen = INT_MAX, idx1 = 0, idx2 = 0;
        for (int i = 0; i < tmp.size(); ++i) {
            int intervalIdx = tmp[i].first;
            int val = tmp[i].second;
            if (++intervalCnt[intervalIdx] == 1) ++cnt;
            while (cnt == totalInterval) {
                int leftVal = tmp[left].second;
                int rightVal = tmp[i].second;
                if (mnLen > rightVal - leftVal + 1) {
                    mnLen = rightVal - leftVal + 1;
                    idx1 = leftVal;
                    idx2 = rightVal;
                }
                if (--intervalCnt[tmp[left].first] == 0) --cnt;
                ++left;
            }
        }
        return {idx1, idx2};
    }
};

395. 至少有K个重复字符的最长子串

原题链接

解题思路: 此题起初也想到了用滑动窗口解题,但是我们知道滑动窗口成功解题的关键在于是否能找到左、右边界移动的判断条件,我们可以试着分析一下此题,当我们移动i(右边界)时,用hashmap统计窗口内子串是否满足K个重复字符的条件(条件A),若满足,我们虽然可以计算并用当前窗口宽度更新结果,但是从理论上讲,因为求最长,所以i是可以继续往右移的,但是我们什么时候收缩窗口呢(i.e.,右移start,左边界),当不满足条件A的时候吗?显然不合理,因为如果移动的话,start移动到i之前的任何一个位置,都不可能消除因i引入导致条件A不满足,因为start和i移动不明朗,i.e.,窗口的扩张和收缩标准不明确,那么滑动窗口在此条件下就无法解题了。然后我开始向,出现了「 子串」是不是需要考虑用Dynamic Programming来解?不过状态转移方程不好找。OK,至此找不出合理解题方案,参考了博客解题思路,发现他为构造滑动窗口解题条件,引入了外部限定,i.e.,为窗口收缩创造条件,加入的外部条件是,假设题目要求最长子串中不同字符的个数不超过cnt,在此条件下找符合条件A的最长子串,然后将外加条件的cnt从1变化到26,这个外部条件是此题成功解题的点睛之笔。

// 滑动窗口解题,解滑动窗口题的关键是,如何移动左、右边界,此题
// 没有显式提供此条件,但是我们需要创建这个条件,即引入外部限定--
// 子串中出现的最大不同元素个数,然后将此限定条件放在[1,26]范围内求解即可
// Time: O(n), space: O(1)
class Solution {
public:
    bool helper(vector<int> &charCnt, int k) {
        for (int r = 0; r < 26; ++r) {
            if (charCnt[r] > 0 && charCnt[r] < k) {
                return false;
            }
        }
        return true;
    }
    int longestSubstring(string s, int k) {
        if (s.empty() || s.size() < k) return 0;
        int n = s.size(), res = 0;
        for (int cnt = 1; cnt <= 26; ++cnt) {
            int i = 0, start = 0, uniqueCnt = 0;
            vector<int> charCnt(26, 0);
            while (i < n) {
                if (charCnt[s[i++] - 'a']++ == 0) {
                    ++uniqueCnt;   
                }
                while (uniqueCnt > cnt) {
                    if (--charCnt[s[start] - 'a'] == 0) --uniqueCnt;
                    ++start;
                } 
                if (helper(charCnt, k)) {
                    res = max(res, i - start);
                }
            }
        }
        return res;
    }
};
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值