滑動窗口算法技巧


本文詳解「滑動窗口」這種高級雙指針技巧的算法框架,帶你秒殺幾道高難度的子字符串匹配問題。

LeetCode 上至少有 9 道題目可以用此方法高效解決。但是有幾道是 VIP 題目,有幾道題目雖不難但太復雜,所以本文只選擇點贊最高,較為經典的,最能夠講明白的三道題來講解。第一題為了讓讀者掌握算法模板,篇幅相對長,后兩題就基本秒殺了。

本文代碼為 C++ 實現,不會用到什么編程方面的奇技淫巧,但是還是簡單介紹一下一些用到的數據結構,以免有的讀者因為語言的細節問題阻礙對算法思想的理解:

unordered_map 就是哈希表(字典),它的一個方法 count(key) 相當於 containsKey(key) 可以判斷鍵 key 是否存在。

可以使用方括號訪問鍵對應的值 map[key]。需要注意的是,如果該 key 不存在,C++ 會自動創建這個 key,並把 map[key] 賦值為 0。

所以代碼中多次出現的 map[key]++ 相當於 Java 的 map.put(key, map.getOrDefault(key, 0) + 1)

本文大部分代碼都是圖片形式,可以點開放大,更重要的是可以左右滑動方便對比代碼。下面進入正題。

一、最小覆蓋子串

題目鏈接

題目不難理解,就是說要在 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 中字符出現次數和窗口中的相應字符的出現次數。

初始狀態:

0

增加 right,直到窗口 [left, right] 包含了 T 中所有字符:

0

現在開始增加 left,縮小窗口 [left, right]。

0

直到窗口中的字符串不再符合要求,left 不再繼續移動。

0

之后重復上述過程,先移動 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;
unordered_map<char, int> needs;
for (char c : t) needs[c]++;

// 記錄 window 中已經有多少字符符合要求了
int match = 0; 

while (right < s.size()) {
    char c1 = s[right];
    if (needs.count(c1)) {
        window[c1]++; // 加入 window
        if (window[c1] == needs[c1])
            // 字符 c1 的出現次數符合要求了
            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])
                // 字符 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;
                minLen = right - left;
            }
            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),M 和 N 分別是字符串 S 和 T 的長度。因為我們先用 for 循環遍歷了字符串 T 來初始化 needs,時間 O(N),之后的兩個 while 循環最多執行 2M 次,時間 O(M)。

讀者也許認為嵌套的 while 循環復雜度應該是平方級,但是你這樣想,while 執行的次數就是雙指針 left 和 right 走的總路程,最多是 2M 嘛。

二、找到字符串中所有字母異位詞

題目鏈接

這道題的難度是 Easy,但是評論區點贊最多的一條是這樣:

How can this problem be marked as easy???

實際上,這個 Easy 是屬於了解雙指針技巧的人的,只要把上一道題的代碼改中更新 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()) {
                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 的所有字符,但上一道題要找長度最短的子串,這道題要找長度相同的子串,也就是「字母異位詞」嘛。

三、無重復字符的最長子串

題目鏈接

遇到子串問題,首先想到的就是滑動窗口技巧。

類似之前的思路,使用 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 條件,為了實現這個條件的實時更新,我們可能會寫很多代碼。比如前兩道題,看起來解法篇幅那么長,實際上思想還是很簡單,只是大多數代碼都在處理這個問題而已。

我最近精心制作了一份電子書《labuladong的算法小抄》,分為【動態規划】【數據結構】【算法思維】【高頻面試】四個章節,共 60 多篇原創文章,絕對精品!限時開放下載,在我的公眾號 labuladong 后台回復關鍵詞【pdf】即可免費下載!

目錄

歡迎關注我的公眾號 labuladong,技術公眾號的清流,堅持原創,致力於把問題講清楚!

labuladong


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM