滑动窗口技巧
滑動窗口技巧
本文詳解「滑動窗口」這種高級雙指針技巧的算法框架,帶你秒殺幾道高難度的子字符串匹配問題。
一、最小覆蓋子串
題目不難理解,就是說要在 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 符合要求) {// 如果這個窗口的子串更短,則更新 resres = 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]++; // 加入 windowif (window[c1] == needs[c1])//因為t中的某個字符并不是只出現一次,//不僅要出現還要次數相同// 字符 c1 的出現次數符合要求了//不能是 >= 會多次match++match++;//一個字符滿足要求}right++;//更新滑動窗口的大小// window 中的字符串已符合 needs 的要求了,開始縮小滑動窗口while (match == needs.size()) {// 更新結果 resres = minLen(res, window);char c2 = s[left];if (needs.count(c2)) {window[c2]--; // 移出 windowif (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)O(M+N),M 和 N 分別是字符串 S 和 T 的長度。因為我們先用 for 循環遍歷了字符串 T 來初始化 needs,時間 O(N),之后的兩個 while 循環最多執行 2M 次,時間 O(M)O(M)O(M)
二、找到字符串中所有字母異位詞
只要把上一道題的代碼改中更新 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,也就是「字母異位詞」嘛。
三、無重復字符的最長子串
遇到子串問題,首先想到的就是滑動窗口技巧。
類似之前的思路,使用 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 條件,為了實現這個條件的實時更新,我們可能會寫很多代碼。比如前兩道題,看起來解法篇幅那么長,實際上思想還是很簡單,只是大多數代碼都在處理這個問題而已。
總結
- 上一篇: 回溯算法团灭子集、排列、组合问题
- 下一篇: 双指针技巧总结