精讀《算法 - 滑動窗口》

滑動窗口算法是較爲入門題目的算法,通常是一些有規律數組問題的最優解,也就是說,若是一個數組問題能夠用動態規劃解,但又可使用滑動窗口解決,那麼每每滑動窗口的效率更高。前端

雙指針也並不侷限在數組問題,像鏈表場景的 「快慢指針」 也屬於雙指針的場景,其快慢指針滑動過程當中自己就會產生一個窗口,好比當窗口收縮到某種程度,能夠獲得一些結論。git

所以掌握滑動窗口很是基礎且重要,接下來按照個人經驗給你們介紹這個算法。github

精讀

滑動窗口使用雙指針解決問題,因此通常也叫雙指針算法,由於兩個指針間造成一個窗口。算法

什麼狀況適合用雙指針呢?通常雙指針是暴力算法的優化版,因此:數組

  1. 若是題目較爲簡單,且是數組或鏈表問題,每每能夠嘗試雙指針是否可解。
  2. 若是數組存在規律,能夠嘗試雙指針。
  3. 若是鏈表問題限制較多,好比要求 O(1) 空間複雜度解決,也許只有雙指針可解。

也就是說,當一個問題比較有規律,或者較爲簡單,或較爲巧妙時,能夠嘗試雙指針(滑動窗口)解法。微信

咱們仍是拿例子說明,首先是兩數之和。優化

兩數之和

兩數之和是一道簡單題,實際上和滑動窗口沒什麼關係,但爲了引出三數之和,仍是先講這道題。題目以下:指針

給定一個整數數組 nums 和一個整數目標值 target,請你在該數組中找出 和爲目標值 target  的那 兩個 整數,並返回它們的數組下標。code

你能夠假設每種輸入只會對應一個答案。可是,數組中同一個元素在答案裏不能重複出現。cdn

暴力解法就是窮舉全部兩數之和,發現和爲 target 結束,顯然這種作法有點慢,咱們換一種思路。

因爲能夠用空間換時間,又只有兩個數,咱們能夠對題目進行轉化,即經過一次遍歷,將 nums 每一項都減去 target,而後找到後面任意一項值爲前面的結果,即表示它們和爲 target

能夠用哈希表 map 加速查詢,即將每一項 target - num 做爲 key,若是後面任何一個 num 做爲 key 能夠在 map 中找到,則得解,且上一個數的原始值能夠存在 map 的 value 中。這要僅需遍歷一次,時間複雜度爲 O(n)。

之因此說這道題,是由於這道題是單指針,即只有一個指針在數組中移動,並配合哈希錶快速求解。對於稍微複雜的問題,單指針就不夠了,須要用雙指針解決(通常來講不會用到三或以上指針),那複雜點的題目就是三數之和了。

三數之和

三數之和是一道中等題,別覺得只是兩數之和的增強版,其思路徹底不一樣。題目以下:

給你一個包含 n 個整數的數組  nums,判斷  nums 中是否存在三個元素 abc ,使得  a + b + c = 0 ?請你找出全部和爲 0 且不重複的三元組。

因爲超過了兩個數,因此不能像雙指針同樣求解了,由於即使用了哈希表存儲,也會在遍歷時遇到 「兩數之和」 的問題,而哈希表方案沒法繼續嵌套使用,即沒法進一步下降複雜度。

爲了下降時間複雜度,咱們但願只遍歷一次數組,這就須要數組知足必定條件咱們才能用滑動窗口,因此咱們對數組進行排序,使用快排的時間複雜度爲 O(nlogn),時間複雜度已超出兩數之和,不過由於題目複雜,這個犧牲是沒法避免的。

假設從小到大排序,那咱們就拿到一個遞增數組了,此時經典滑動窗口方法就可用了!怎麼滑動呢?首先建立兩個指針,分別叫 leftright,經過不斷修改 leftright,讓它們在數組間滑動,這個窗口大小就是符合題目要求的,當滑動完畢時,返回全部知足條件的窗口便可,記錄其實很簡單,只要在滑動過程當中記錄一下就行。

首先排除異常值,即數組長度太小,而後對於常規狀況,咱們拿一個全局變量存儲當前窗口數的和,這樣 right + 1 只要累加 nums[right+1]left + 1 只要減去 nums[left] 便可快速拿到求和。

因爲須要考慮全部狀況,因此須要一次數組遍歷,對於每次遍歷的起始點 i,若是 nums[i] > 0 則直接跳過,由於數組排序後是遞增的,後面的和只會永遠大於 0;不然進行窗口滑動,先造成三個點 [i, i+1, n-1],這樣保持 i 不動,不斷包夾後兩個數字便可,只要它們的和大於 0,就將第三個點左移(數字會變小),不然將第二個點右移(數字會變大),其實第二個和第三個數就是滑動窗口。

這樣的話時間複雜度是 O(n²),由於存在兩次遍歷,忽略快排較小的時間複雜度。

那麼四數之和,五數之和呢?

四數之和

該題和三數之和徹底同樣,除了要求變成四個數。

首先仍是排序,而後雙重遞歸,即肯定前兩個數不變,不斷包夾後兩個數,後兩個數就是 i+1n-1,算法和三數之和同樣,因此最終時間複雜度爲 O(n³)。

那麼 N 數之和(N > 2)均可以採用這個思路解決。

爲何沒有更優的方法呢?我想可能由於:

  1. 不管幾數之和,快排一次時間複雜度都是固定的,因此沿用三數之和的方案其實佔了排序算法便宜。
  2. 滑動窗口只能用兩個指針進行移動,而沒有三指針但又保持時間複雜度不變的窗口滑動算法存在。

因此對於 N 數之和,經過排序付出了 O(nlogn) 時間複雜度以後,能夠用滑動窗口,將 2 個數時間複雜度優化爲 O(n),因此總體時間複雜度就是 O(N - 2 + 1 個 n),即 O(N-1 個 n),而最小的時間複雜度 O(n²) 比 O(nlogn) 大,因此老是忽略快排的時間複雜度,因此三數之和時間複雜度是 O(n²),四數之和時間複雜度爲 O(n³),依此類推。

能夠看到,咱們從最簡單的兩數之和,到三數之和、四數之和,跨入了滑動窗口的門檻,本質上是利用排序後數組有序的特性,讓咱們在不用遍歷數組的前提下,能夠對窗口進行滑動,這是滑動窗口算法的核心思想。

爲了增強這個理解,再看一道相似的題目,無重複字符的最長子串。

無重複字符的最長子串

無重複字符的最長子串是一道中等題,題目以下:

給定一個字符串,請你找出其中不含有重複字符的 最長子串 的長度。

因爲最長子串是連續的,因此顯然能夠考慮滑動窗口解法。其實肯定了滑動窗口解法後,問題很簡單,只要設定 leftright,並用一個哈希 Set 記錄哪些元素存在過,在過程當中記錄最大長度,並嘗試 right 右移,若是右移過程當中發現出現重複字符,則 left 右移,直到消除這個重複字符爲止。

解法並不難,但問題是,咱們要想清楚,爲何用滑動窗口遍歷一次就能夠作到 不重不漏?即這道題時間複雜度只有 O(n) 呢?

只要想明白兩個問題:

  1. 因爲子串是連續的,既然不存在跳躍的狀況,只要一次滑動窗口內能包含全部解,就涵蓋了全部狀況。
  2. 一次滑動窗口內不包含什麼?因爲咱們只將 right 右移,且出現重複後嘗試將 left 右移到不重複後,right 再繼續右移,這忽略了出現重複後, right 左移的狀況。

咱們重點看二個問題,顯然,若是 abcd 這四個連續的字符不重複,那麼 left 右移後,bcd 也顯然不重複,因此若是此時就能夠將 right 右移造成 bcda 的窗口繼續找下去,而不須要嘗試 bc 這種狀況,由於這種狀況雖然不重複,但必定不是最優解。

好了,經過這個例子咱們看到,滑動窗口如何縮小窗口範圍其實不難,但更要注重的是,背後對於爲何能夠用滑動窗口的思考,滑動窗口有沒有作到不重不漏,若是沒有想清楚,可能整個思路都錯了。

那麼滑動窗口的應用已經說透了?其實沒有,咱們上面只說了縮小窗口這種比較單一的腦回路,其實雙指針構成的滑動窗口不必定都是那麼正常滑的,一種有意思的場景是快慢指針,便是以相對速度決定窗口如何滑動。

關於快慢指針,經典的題目有環形鏈表、刪除有序數組中的重複項。

環形鏈表

環形鏈表是一道簡單題,題目以下:

給定一個鏈表,判斷鏈表中是否有環。

若是不是進階要求空間複雜度 O(1),咱們能夠在遍歷時稍稍 「污染」 一下原始鏈表,這樣總能發現是否走了回頭路。

但要求空間開銷必須是常數,咱們不得不考慮快慢指針。說實話第一次看到這道題時,若是能想到快慢指針的解法,絕對是至關聰明的,由於必需要有知識遷移的能力。怎麼遷移呢?想象學校在開運動會,相信每次都有一個跑的最慢的同窗,慢到被最快的同窗追了一圈。

等等,操場不就是環形鏈表嗎?只要有人跑得慢,就會被跑得快的追上,追上不就是相遇了嗎? 因此快慢指針分別跑,只要相遇則斷定爲環形鏈表,不然不是環形鏈表,且必定有一個指針先走完。

那麼細枝末節就是優化效率了,慢指針到底慢多少呢?

有人會說,運動會上,跑步慢的人若是想被快的人追上,最好就不要跑。對,但環形鏈表問題中,鏈表不是操場,可能只有某一段是環,也就是跑步慢的人至少要跑到環裏,纔可能與跑得快人的相遇,但跑得慢的人又不知道哪裏開始成環,這就是難點。

你有沒有想過,爲何快排用二分法,而不是三分法?爲何每次中間來一刀,能夠最快排完?緣由是二分能夠用最小的 「深度」 將數組切割爲最小粒度。那麼同理,快慢指針中,慢指針要想被儘快追上,速度可能最好是快指針的一半。那從邏輯上分析,爲何呢?

直觀來看,若是慢指針太慢,可能大部分時間都在進入環形以前的位置轉悠,快指針雖然快,但永遠在環裏跑,因此老是沒法遇到慢指針,這給咱們的啓示是,慢指針不能太慢;若是慢指針太快,幾乎速度和快指針同樣,就像兩個運動員都各執己見的爭奪第一同樣,他們真的想相遇,估計得連續跑幾個小時吧,因此慢指針也不能過快。因此這樣分析下來,慢指針只能取折中的一半速度。

但用一半的慢速真的能最快相遇嗎?不必定,舉一個例子,假設鏈表是完美環形,一共有 [1,6] 共 6 個節點,那麼慢指針一次走 1 步,快指針一次走 2 步,那麼一共是 2,3 3,5 4,1 5,3 6,5 1,1 共走 6 步,但若是快指針一次走 3 步呢?一共是 2,4 3,1 4,4 3 步。這麼說通常速度不必定最優?其實不是的,計算機在鏈表尋址時,節點訪問的消耗也要考慮進去,後者雖然看上去更快,但其實訪問鏈表 next 的次數更多,對計算機來講,還不如第一種來得快。

因此準確來講,不是快指針比慢指針快一倍速度,而是慢指針一次走一步,快指針一次走兩步最優,由於相遇時,總移動步數最少。

再說一個簡單問題,即用快慢指針判斷鏈表中倒數第k個節點或者鏈表中點。

判斷鏈表中點

快指針是慢指針速度 2 倍,當快指針到達尾部,慢指針的位置就是鏈表中點。

鏈表中倒數第k個節點

鏈表中倒數第k個節點是一道簡單題,題目以下:

輸入一個鏈表,輸出該鏈表中倒數第 k 個節點。爲了符合大多數人的習慣,本題從 1 開始計數,即鏈表的尾節點是倒數第 1 個節點。

這道題就是判斷鏈表中點的變種,只要讓慢指針比快指針慢 k 個節點,當快指針到達末尾時,慢指針就指向倒數第 k+1 個節點了。這道題注意一下數數別數錯了便可。

接下來終於說道快慢指針的另外一種經典用法題型,刪除有序數組中的重複項了。

刪除有序數組中的重複項

刪除有序數組中的重複項是一道簡單題,題目以下:

給你一個有序數組 nums ,請你 原地 刪除重複出現的元素,使每一個元素 只出現一次 ,返回刪除後數組的新長度。

這道題,要原地刪除重複元素,並返回長度,因此只能用快慢指針。但怎麼用呢?快多少慢多少?

其實這道題快多少慢多少並不像前面題目同樣預設好了,而是根據遇到的實際數字來判斷。

咱們假設慢指針是 slow 快指針是 fast,注意變量命名也有意思,一樣是雙指針問題,有的是 slow right,有的是 slow fast,重點在於用何種方法移動指針。

咱們只要讓 fast 掃描徹底表,把全部不重複的挪到一塊兒就行了,這樣時間複雜度是 O(n),具體作法是:

  1. slowfast 初始都指向 index 0。
  2. 因爲是 有序數組,因此就算有重複也必定連在一塊兒,因此可讓 fast 直接日後掃描,只有遇到和 slow 不一樣的值,才把其和 slow+1 交換,而後 slow 自增,繼續遞歸,直到 fast 走到數組尾部結束。

作完這套操做後,slow 的下標值就是答案。

能夠看到,這道題對於慢指針要如何慢,實際上是根據值來判斷的,若是 fast 的值與 slow 同樣,那麼 slow 就一直等着,由於相同的值要被忽略掉,讓 fast 走就是在跳太重複值。

說完了常見的雙指針用法,咱們再來看一些比較難啃的特殊問題,這裏主要講兩個,分別是 盛最多水的容器接雨水

盛最多水的容器

盛最多水的容器是一道中等題,題目以下:

給你 n 個非負整數 a1,a2,...,an,每一個數表明座標中的一個點  (i, ai) 。在座標內畫 n 條垂直線,垂直線 i 的兩個端點分別爲  (i, ai)(i, 0) 。找出其中的兩條線,使得它們與  x 軸共同構成的容器能夠容納最多的水。

<img width=400 src="https://z3.ax1x.com/2021/06/12/25WZZt.png">

建議先仔細讀一讀題目再繼續,這道題相對比較複雜。

好了,爲何說這是一道雙指針題目呢?由於咱們看怎麼計算容納水的體積?其實這道題就簡化爲長乘寬。

長度就是選取的兩個柱子的間距,寬就是其中最短柱子的高度。問題就是,雖然柱子間距越遠,長度越大,但寬度不必定最大,一眼是無法看出來最優解的。

因此仍是得屢次嘗試,那怎麼樣能夠用最少的嘗試次數,但又不重不漏呢?定義 left right 兩個指針,分別指向 0n-1 即首尾兩個位置,此時長度是最大的(柱子間距離是最遠的),接下來嘗試一下別的柱子,試哪一個呢?

  • 較長的那個?若是新的比較短的更短,那麼寬度更短了;若是新的比較短的更長,也沒用,由於較短的決定了水位。
  • 較短的那個?若是新的較長,那麼纔有機會總體體積更大。

因此咱們移動較短的那個,並每次計算一下體積,最後當兩根柱子相遇時結束,過程當中最大致積就是全局最大致積。

這道題雙指針的移動規則比較巧妙,與上面普通題目不同,重點不是在是否會運用滑動窗口算法,而是可否找到移動指針的規則。

固然你可能會說,爲何兩個指針要定義在最兩端,而非別的地方?由於這樣就沒法控制變量了。

若是指針選在中間位置,那麼指針外移時,柱子的間距與柱子長度同時變化,就很難找到一條完美路線。好比咱們移動較短的柱子,是由於較短的柱子肯定了最低水位,改變它,可能讓最低水位變高,但問題是兩根柱子的間距也在變大,這樣移動較短仍是較長的柱子哪一個更優就說不許了。

說實話這種方法不太容易想到,須要多找幾種選擇嘗試才能發現。固然,算法若是按照固定套路就能推導出來,也就沒有難度了,因此要接受這種思惟跳躍。

接下來咱們看一道更特殊的滑動窗口問題,接雨水,它甚至分爲多段滑動窗口。

接雨水

接雨水是一道困難題,題目以下:

給定 n 個非負整數表示每一個寬度爲 1 的柱子的高度圖,計算按此排列的柱子,下雨以後能接多少雨水。

<img width=400 src="https://z3.ax1x.com/2021/06/12/25OejP.png">

與盛雨水不一樣,這道接雨水看的是總體,咱們要算出能接的全部水的數量。

其實相比上一道題,這道題還算比較好切入,由於咱們從左到右計算便可。思考發現,只有產生了 「凹槽」 才能接到雨水,而凹槽由它兩邊最高的柱子決定,那什麼範圍算一段凹槽呢?

顯然凹槽是能夠明確分組的,一個凹槽也沒法被分割爲多個凹槽,就像你看水坑同樣,不管有多少,多深的坑在一塊兒,總能一個一個數清楚,因此咱們就從左到右開始數。

怎麼數凹槽呢?用滑動窗口辦法,每一個窗口就是一個凹槽,那麼窗口的起點 left 就是左邊第一根柱子,有如下狀況:

  • 若是直接相鄰的右邊柱子更高(或同樣高),那從它開始向右看,根本沒法接雨水,因此直接拋棄,left++
  • 若是直接相鄰的右邊柱子更矮,那就有產生凹槽的機會。

    • 那麼繼續往右看,若是右邊一直都更矮,那也接不到雨水。
    • 若是右邊出現一個高一些的,就能夠接到雨水,那問題是怎麼算能接多少,以及找到哪結束呢?

      • 只要記錄最左邊柱子高度,右邊柱子的結束判斷條件是 「遇到一個與最左邊同樣高的柱子」,由於一個凹槽能接多少水,取決於最短的柱子。固然,若是右邊沒有柱子了,雖然比最左邊低一點,但只要比最深的高,也算一個結束點。

這道題,一旦遇到凹槽結束點,left 就會更新,開始新的一輪凹槽計算,因此存在多個滑動窗口。從這道題能夠看出,滑動窗口題型至關靈活,不只判斷條件因題而異,窗口數量可能也有多個。

總結

滑動窗口本質是雙指針的玩法,不一樣題目有不一樣的套路,從最簡單的按照規律包夾,到快慢指針,再到無固定套路的因題而異的特殊算法。

其實按照規律包夾的套路屬於碰撞指針範疇,通常對於排序好的數組,能夠一步一步判斷,或者用二分法判斷,總之不用根據總體遍從來判斷,效率天然高。

快慢指針也有套路可循,但具體快多少,或者慢多少,可能具體場景要具體看。

對於無固定套路的滑動窗口,就要根據題目仔細品味啦,若是全部套路都能總結出來,算法也少了樂趣。

討論地址是: 精讀《算法 - 滑動窗口》· Issue #328 · dt-fe/weekly

若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公衆號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權聲明:自由轉載-非商用-非衍生-保持署名( 創意共享 3.0 許可證
相關文章
相關標籤/搜索