7、滑動窗口套路算法框架——Go語言版

前情提示:Go語言學習者。本文參考//labuladong.gitee.io/algo,代碼自己參考抒寫,若有不妥之處,感謝指正

關於golang算法文章,為了便於下載和整理,都已開源放在:

方便的話,請分享,star!備註轉載地址!歡迎一起學習和交流!

涉及題目

Leetcode 76. 最小覆蓋子串

Leetcode 567.字符串的排列

Leetcode 438.找到字符串中所有字母異位詞

Leetcode 3.無重複字符的最長子串

鑒於前文 [二分搜索框架詳解] 的那首《二分搜索升天詞》很受好評,並在民間廣為流傳,成為安睡助眠的一劑良方,今天在滑動窗口算法框架中,我再次編寫一首小詩來歌頌滑動窗口算法的偉大:

img

關於雙指針的快慢指針和左右指針的用法,可以參見前文 雙指針技巧套路框架,本文就解決一類最難掌握的雙指針技巧:滑動窗口技巧。總結出一套框架,可以保你閉着眼睛都能寫出正確的解法。

說起滑動窗口算法,很多讀者都會頭疼。這個算法技巧的思路非常簡單,就是維護一個窗口,不斷滑動,然後更新答案么。LeetCode 上有起碼 10 道運用滑動窗口算法的題目,難度都是中等和困難。該算法的大致邏輯如下:

left := 0
right := 0
for right < len(s){
    // 增大窗口
    window = append(window, s[right])
    right++
    
    for window needs shrink{
        // 縮小窗口
        window.remove(s[left]) // 偽碼
        left++
    }
}

這個算法技巧的時間複雜度是 O(N),比字符串暴力算法要高效得多。

其實困擾大家的,不是算法的思路,而是各種細節問題。比如說如何向窗口中添加新元素,如何縮小窗口,在窗口滑動的哪個階段更新結果。即便你明白了這些細節,也容易出 bug,找 bug 還不知道怎麼找,真的挺讓人心煩的。

所以今天我就寫一套滑動窗口算法的代碼框架,我連再哪裡做輸出 debug 都給你寫好了,以後遇到相關的問題,你就默寫出來如下框架然後改三個地方就行,還不會出 bug

// 滑動窗口算法框架
func slidingWindow(s string, t string){
    need, window := map[byte]int{}, map[byte]int{} // go中無char.還有注意不能只聲明,不創建
    for i:=0;i<len(t);i++{ // 使用range遍歷得到是rune,使用t[i]得到的是byte
        need[t[i]]++ // map[key]訪問哈希表中鍵對應的值。如果key不存在,自動創建這個key,並把map[key]賦值為0
    }
    left := 0
    right := 0
    valid := 0
    for right < len(s){
        // c是將移入窗口的字符
        c := s[right]
        // 右移窗口
        right++
        // 進行窗口內數據的一系列更新
        ...

        // debug輸出的位置
        fmt.Print("windows: [%d,%d]\n",left,right)
        //

        // 判斷左側窗口是否要收縮
        for window needs shrink{
            // d是將一處窗口的字符
            d := s[left]
            // 左移窗口
            left++
            // 進行窗口內數據的一系列更新
            ...
        }
    }
}

其中兩處 ... 表示的更新窗口數據的地方,到時候你直接往裏面填就行了

而且,這兩個 ... 處的操作分別是右移和左移窗口更新操作,等會你會發現它們操作是完全對稱的。

說句題外話,我發現很多人喜歡執着於表象,不喜歡探求問題的本質。比如說有很多人評論我這個框架,說什麼散列錶速度慢,不如用數組代替散列表;還有很多人喜歡把代碼寫得特別短小,說我這樣代碼太多餘,影響編譯速度,LeetCode 上速度不夠快。

我服了。算法看的是時間複雜度,你能確保自己的時間複雜度最優,就行了。至於 LeetCode 所謂的運行速度,那個都是玄學,只要不是慢的離譜就沒啥問題,根本不值得你從編譯層面優化,不要捨本逐末……

本文重點在於算法思想,你把框架思維瞭然於心,然後隨你魔改代碼好吧,你高興就好。

言歸正傳,下面就直接上四道 LeetCode 原題來套這個框架,其中第一道題會詳細說明其原理,後面四道就直接閉眼睛秒殺了。

因為滑動窗口很多時候都是在處理字符串相關的問題,Java 處理字符串不方便,原始參考文章使用C++實現,但本文代碼為 Go 實現。不會用到什麼編程方面的奇技淫巧,但是還是簡單介紹一下一些用到的數據結構,以免有的讀者因為語言的細節問題阻礙對算法思想的理解:

go的map實現方式和C++中unordered_map一樣,都是哈希表(字典),Go和C++可以使用方括號訪問鍵對應的值 map[key]。需要注意的是,如果該 key 不存在,Go和C++ 會自動創建這個 key,並把 map[key] 賦值為 0。

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

一、最小覆蓋子串

題目鏈接

題目不難理解,就是說要在 S(source) 中找到包含 T(target) 中全部字母的一個子串,順序無所謂,但這個子串一定是所有可能子串中最短的。

如果我們使用暴力解法,代碼大概是這樣的:

for i:=0; i<len(s);i++{
    for j:=i+1; j<len(s);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 步在優化這個「可行解」,最終找到最優解,也就是最短的覆蓋子串。左右指針輪流前進,窗口大小增增減減,窗口不斷向右滑動,這就是「滑動窗口」這個名字的來歷。

下面畫圖理解一下,needswindow 相當於計數器,分別記錄 T 中字符出現次數和「窗口」中的相應字符的出現次數。

初始狀態:

img

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

img

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

img

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

img

之後重複上述過程,先移動 right,再移動 left…… 直到 right 指針到達字符串 S 的末端,算法結束。

如果你能夠理解上述過程,恭喜,你已經完全掌握了滑動窗口算法思想。現在我們來看看這個滑動窗口代碼框架怎麼用

首先,初始化 windowneed 兩個哈希表,記錄窗口中的字符和需要湊齊的字符:

var need,window map[char]int
for _,c := range t{
    need[c]++
}

然後,使用 leftright 變量初始化窗口的兩端,不要忘了,區間 [left, right) 是左閉右開的,所以初始情況下窗口沒有包含任何元素:

left := 0
right := 0
valid := 0
for right < len(s){
	// 開始滑動
}

其中 valid 變量表示窗口中滿足 need 條件的字符個數,如果 validneed.size 的大小相同,則說明窗口已滿足條件,已經完全覆蓋了串 T

現在開始套模板,只需要思考以下四個問題

1、當移動 right 擴大窗口,即加入字符時,應該更新哪些數據?

2、什麼條件下,窗口應該暫停擴大,開始移動 left 縮小窗口?

3、當移動 left 縮小窗口,即移出字符時,應該更新哪些數據?

4、我們要的結果應該在擴大窗口時還是縮小窗口時進行更新?

如果一個字符進入窗口,應該增加 window 計數器;如果一個字符將移出窗口的時候,應該減少 window 計數器;當 valid 滿足 need 時應該收縮窗口;應該在收縮窗口的時候更新最終結果。

下面是完整代碼:

func minWindow(s string, t string) string{
    need, window := map[byte]int{}, map[byte]int{} // go中無char.還有注意不能只聲明,不創建
    for i:=0;i<len(t);i++{ // 使用range遍歷得到是rune,使用t[i]得到的是byte
        need[t[i]]++
    }
    
    left := 0
    right := 0
    valid := 0
    // 記錄最小覆蓋子串的起始索引及長度
    start := 0
    temp := math.MaxInt32  // 保存長度
    for right < len(s){
        // c是將移入窗口的字符
        c := s[right]
        // 右移窗口
        right++
        // 進行窗口內數據的一系列更新
        if need[c]!=0{
            window[c]++
            if window[c] == need[c]{
                valid++
            }
        }
        // 判斷左側窗口是否要收縮
        for valid == len(need){
            // 在這裡更新最小覆蓋字串
            if right - left < temp{
                start = left
                temp = right - left
            }
            // d是將移除窗口的字符
            d := s[left]
            // 左移窗口
            left++
            // 進行窗口內數據的一系列更新
            if need[d]!=0{
                if window[d]==need[d]{
                    valid--
                }
                window[d]--
            }
        }
    }
    // 返回最小覆蓋字串
    if temp == math.MaxInt32{
        return ""
    }else{
        return s[start:start+temp]
    }
}

PS:使用 Java 的讀者要尤其警惕語言特性的陷阱。Java 的 Integer,String 等類型判定相等應該用 equals 方法而不能直接用等號 ==,這是 Java包裝類的一個隱晦細節。所以在左移窗口更新數據的時候,不能直接改寫為 window.get(d) == need.get(d),而要用 window.get(d).equals(need.get(d)),之後的題目代碼同理。

需要注意的是,當我們發現某個字符在 window 的數量滿足了 need 的需要,就要更新 valid,表示有一個字符已經滿足要求。而且,你能發現,兩次對窗口內數據的更新操作是完全對稱的。

valid == need.size() 時,說明 T 中所有字符已經被覆蓋,已經得到一個可行的覆蓋子串,現在應該開始收縮窗口了,以便得到「最小覆蓋子串」。

移動 left 收縮窗口時,窗口內的字符都是可行解,所以應該在收縮窗口的階段進行最小覆蓋子串的更新,以便從可行解中找到長度最短的最終結果。

至此,應該可以完全理解這套框架了,滑動窗口算法又不難,就是細節問題讓人煩得很。以後遇到滑動窗口算法,你就按照這框架寫代碼,保准沒有 bug,還省事兒

下面就直接利用這套框架秒殺幾道題吧,你基本上一眼就能看出思路了。

二、字符串排列

LeetCode 567 題,Permutation in String,難度 Medium:

img

注意哦,輸入的 s1 是可以包含重複字符的,所以這個題難度不小。

這種題目,是明顯的滑動窗口算法,相當給你一個 S 和一個 T,請問你 S 中是否存在一個子串,包含 T 中所有字符且不包含其他字符

首先,先複製粘貼之前的算法框架代碼,然後明確剛才提出的 4 個問題,即可寫出這道題的答案:

// 滑動窗口算法框架——判斷s中是否存在t的排列
func checkInclusion(t string, s string) bool{
    need, window := map[byte]int{}, map[byte]int{} // go中無char.還有注意不能只聲明,不創建
    for i:=0;i<len(t);i++{ // 使用range遍歷得到是rune,使用t[i]得到的是byte
        need[t[i]]++ // map[key]訪問哈希表中鍵對應的值。如果key不存在,自動創建這個key,並把map[key]賦值為0
    }
    left := 0
    right := 0
    valid := 0
    for right < len(s){
        // c是將移入窗口的字符
        c := s[right]
        // 右移窗口
        right++
        // 進行窗口內數據的一系列更新【關鍵】
        if need[c]!=0{
            window[c]++
            if window[c]==need[c]{
                valid++
            }
        }

        // 判斷左側窗口是否要收縮
        for right - left >= len(t){
            // 在這裡判斷是否找到合法的字串【關鍵】
            if valid == len(need){
                return true
            }
            // d是將一處窗口的字符
            d := s[left]
            // 左移窗口
            left++
            // 進行窗口內數據的一系列更新【關鍵】
            if need[d]!=0{
                if window[d] == need[d]{
                    valid--
                }
                window[d]--
            }
        }
    }
    // 未找到符合條件的子串
    return false
}

對於這道題的解法代碼,基本上和最小覆蓋子串一模一樣,只需要改變兩個地方:

1、本題移動 left 縮小窗口的時機是窗口大小大於 t.size() 時,應為排列嘛,顯然長度應該是一樣的。

2、當發現 valid == need.size() 時,就說明窗口中就是一個合法的排列,所以立即返回 true

至於如何處理窗口的擴大和縮小,和最小覆蓋子串完全相同。

三、找所有字母異位詞

這是 LeetCode 第 438 題,Find All Anagrams in a String,難度 Medium:

img

呵呵,這個所謂的字母異位詞,不就是排列嗎,搞個高端的說法就能糊弄人了嗎?相當於,輸入一個串 S,一個串 T,找到 S 中所有 T 的排列,返回它們的起始索引

直接默寫一下框架,明確剛才講的 4 個問題,即可秒殺這道題:

// 滑動窗口算法框架——找所有字母異位詞
func findAnagrams(s string, t string) []int{
    need, window := map[byte]int{}, map[byte]int{} // go中無char.還有注意不能只聲明,不創建
    for i:=0;i<len(t);i++{ // 使用range遍歷得到是rune,使用t[i]得到的是byte
        need[t[i]]++ // map[key]訪問哈希表中鍵對應的值。如果key不存在,自動創建這個key,並把map[key]賦值為0
    }
    left := 0
    right := 0
    valid := 0
    res := []int{}  // 【重要】
    for right < len(s){
        // c是將移入窗口的字符
        c := s[right]
        // 右移窗口
        right++
        // 進行窗口內數據的一系列更新【重要】
        if need[c]!=0{
            window[c]++
            if window[c] == need[c]{
                valid++
            }
        }

        // 判斷左側窗口是否要收縮
        for right - left >= len(t){
            // 窗口符合條件時,將起始索引加入res【重要】
            if valid == len(need){
                res = append(res, left)
            }
            // d是將一處窗口的字符
            d := s[left]
            // 左移窗口
            left++
            // 進行窗口內數據的一系列更新【重要】
            if need[d]!=0{
                if window[d] == need[d]{
                    valid--
                }
                window[d]--
            }
        }
    }
    return res
}

跟尋找字符串的排列一樣,只是找到一個合法異位詞(排列)之後將起始索引加入 res 即可。

四、最長無重複子串

這是 LeetCode 第 3 題,Longest Substring Without Repeating Characters,難度 Medium:

img

這個題終於有了點新意,不是一套框架就出答案,不過反而更簡單了,稍微改一改框架就行了:

// 滑動窗口算法框架——最長無重複子串
func lengthOfLongestSubstring(s string) int{
    window := map[byte]int{} // go中無char.還有注意不能只聲明,不創建
    left := 0
    right := 0
    res := 0  // 記錄結果
    for right < len(s){
        // c是將移入窗口的字符
        c := s[right]
        // 右移窗口
        right++
        // 進行窗口內數據的一系列更新【重要】
        window[c]++

        // 判斷左側窗口是否要收縮
        for window[c]>1{
            // d是將一處窗口的字符
            d := s[left]
            // 左移窗口
            left++
            // 進行窗口內數據的一系列更新【重要】
            window[d]--
        }
        // 在這裡更新答案[重要]
        if res < right-left{
            res = right -left
        }
    }
    return res
}

這就是變簡單了,連 needvalid 都不需要,而且更新窗口內數據也只需要簡單的更新計數器 window 即可。

window[c] 值大於 1 時,說明窗口中存在重複字符,不符合條件,就該移動 left 縮小窗口了嘛。

唯一需要注意的是,在哪裡更新結果 res 呢?我們要的是最長無重複子串,哪一個階段可以保證窗口中的字符串是沒有重複的呢?

這裡和之前不一樣,要在收縮窗口完成後更新 res,因為窗口收縮的 while 條件是存在重複元素,換句話說收縮完成後一定保證窗口中沒有重複嘛。

五、最後總結

建議背誦並默寫這套框架,順便背誦一下文章開頭的那首詩。以後就再也不怕子串、子數組問題了好吧。

Tags: