天天看點

7、滑動視窗套路算法架構——Go語言版

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

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

https://github.com/honlu/GoLabuladongAlgorithm

https://gitee.com/dreamzll/GoLabuladongAlgorithm

友善的話,請分享,star!備注轉載位址!歡迎一起學習和交流!

Leetcode 76. 最小覆寫子串

Leetcode 567.字元串的排列

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

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

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

7、滑動視窗套路算法架構——Go語言版

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

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

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

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

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

其中兩處 <code>...</code> 表示的更新視窗資料的地方,到時候你直接往裡面填就行了。

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

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

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

本文重點在于算法思想,你把架構思維了然于心,然後随你魔改代碼好吧,你高興就好。

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

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

go的map實作方式和C++中<code>unordered_map</code>一樣,都是哈希表(字典),Go和C++可以使用方括号通路鍵對應的值 <code>map[key]</code>。需要注意的是,如果該 <code>key</code> 不存在,Go和C++ 會自動建立這個 key,并把 <code>map[key]</code> 指派為 0。

是以代碼中多次出現的 <code>map[key]++</code> 相當于 Java 的 <code>map.put(key, map.getOrDefault(key, 0) + 1)</code>。

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

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

思路很直接,但是顯然,這個算法的複雜度肯定大于 O(N^2) 了,不好。

滑動視窗算法的思路是這樣:

1、我們在字元串 <code>S</code> 中使用雙指針中的左右指針技巧,初始化 <code>left = right = 0</code>,把索引左閉右開區間 <code>[left, right)</code> 稱為一個「視窗」。

2、我們先不斷地增加 <code>right</code> 指針擴大視窗 <code>[left, right)</code>,直到視窗中的字元串符合要求(包含了 <code>T</code> 中的所有字元)。

3、此時,我們停止增加 <code>right</code>,轉而不斷增加 <code>left</code> 指針縮小視窗 <code>[left, right)</code>,直到視窗中的字元串不再符合要求(不包含 <code>T</code> 中的所有字元了)。同時,每次增加 <code>left</code>,我們都要更新一輪結果。

4、重複第 2 和第 3 步,直到 <code>right</code> 到達字元串 <code>S</code> 的盡頭。

這個思路其實也不難,第 2 步相當于在尋找一個「可行解」,然後第 3 步在優化這個「可行解」,最終找到最優解,也就是最短的覆寫子串。左右指針輪流前進,視窗大小增增減減,視窗不斷向右滑動,這就是「滑動視窗」這個名字的來曆。

下面畫圖了解一下,<code>needs</code> 和 <code>window</code> 相當于計數器,分别記錄 <code>T</code> 中字元出現次數和「視窗」中的相應字元的出現次數。

初始狀态:

7、滑動視窗套路算法架構——Go語言版

增加 <code>right</code>,直到視窗 <code>[left, right]</code> 包含了 <code>T</code> 中所有字元:

7、滑動視窗套路算法架構——Go語言版

現在開始增加 <code>left</code>,縮小視窗 <code>[left, right]</code>:

7、滑動視窗套路算法架構——Go語言版

直到視窗中的字元串不再符合要求,<code>left</code> 不再繼續移動:

7、滑動視窗套路算法架構——Go語言版

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

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

首先,初始化 <code>window</code> 和 <code>need</code> 兩個哈希表,記錄視窗中的字元和需要湊齊的字元:

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

其中 <code>valid</code> 變量表示視窗中滿足 <code>need</code> 條件的字元個數,如果 <code>valid</code> 和 <code>need.size</code> 的大小相同,則說明視窗已滿足條件,已經完全覆寫了串 <code>T</code>。

現在開始套模闆,隻需要思考以下四個問題:

1、當移動 <code>right</code> 擴大視窗,即加入字元時,應該更新哪些資料?

2、什麼條件下,視窗應該暫停擴大,開始移動 <code>left</code> 縮小視窗?

3、當移動 <code>left</code> 縮小視窗,即移出字元時,應該更新哪些資料?

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

如果一個字元進入視窗,應該增加 <code>window</code> 計數器;如果一個字元将移出視窗的時候,應該減少 <code>window</code> 計數器;當 <code>valid</code> 滿足 <code>need</code> 時應該收縮視窗;應該在收縮視窗的時候更新最終結果。

下面是完整代碼:

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

需要注意的是,當我們發現某個字元在 <code>window</code> 的數量滿足了 <code>need</code> 的需要,就要更新 <code>valid</code>,表示有一個字元已經滿足要求。而且,你能發現,兩次對視窗内資料的更新操作是完全對稱的。

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

移動 <code>left</code> 收縮視窗時,視窗内的字元都是可行解,是以應該在收縮視窗的階段進行最小覆寫子串的更新,以便從可行解中找到長度最短的最終結果。

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

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

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

7、滑動視窗套路算法架構——Go語言版

注意哦,輸入的 <code>s1</code> 是可以包含重複字元的,是以這個題難度不小。

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

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

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

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

2、當發現 <code>valid == need.size()</code> 時,就說明視窗中就是一個合法的排列,是以立即傳回 <code>true</code>。

至于如何處理視窗的擴大和縮小,和最小覆寫子串完全相同。

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

7、滑動視窗套路算法架構——Go語言版

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

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

跟尋找字元串的排列一樣,隻是找到一個合法異位詞(排列)之後将起始索引加入 <code>res</code> 即可。

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

7、滑動視窗套路算法架構——Go語言版

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

這就是變簡單了,連 <code>need</code> 和 <code>valid</code> 都不需要,而且更新視窗内資料也隻需要簡單的更新計數器 <code>window</code> 即可。

當 <code>window[c]</code> 值大于 1 時,說明視窗中存在重複字元,不符合條件,就該移動 <code>left</code> 縮小視窗了嘛。

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

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

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