# Python-LeetCode 581 第二招 Sliding Window
「把小小的窗戶推呀推呀推,修改左界,維護不變,答案就出現!」
所謂Sliding window(滑動視窗)是指在處理序列問題時,我們關注在一個$[left,right]$的區域(視窗),運算過程中我們逐步滑動此視窗並保持住此視窗的某些迴圈不變性。LeetCode中有接近100題有sliding window的標籤,此法與Two pointers有時難以區分。
## 簡單例題
挑選幾題來說明Python的sliding window寫法。先上場的是
[(題目連結) 3. Longest Substring Without Repeating Characters(難度Medium)](https://leetcode.com/problems/longest-substring-without-repeating-characters/)。題目非常簡單:給一個字串$s$,找出不含重複字元的最長子字串長度。
在大方向上,每個可能的解都是一個substring,substring是一個連續區間,我們找出每一個可能右端的最長字串,在所有可能右端中取最大值就是答案。
以右端$right$從前往後逐一掃過,對於$(left,right]$這個視窗區間,維護好以下的迴圈不變性:
**left為視窗內沒有重複的字元的最小左端**。
如何維護呢?
假設前一個右端時此特性是符合的。當右端右移時,如果違反上述不變性,也就是視窗內有重複字元,那麼就必須將左端右移直到沒有重複字元為止。事實上,重複的字元必然是因為剛移入的$s[right]$撞到與視窗內某字元,也就是存在唯一的$j$,滿足 $left<i<right$ and $s[j]==s[right]$,而我們必須把$left$移到$j$才能繼續維持該迴圈不變性。
思考清楚了,程式是簡單的。為了要偵測哪個字元重複了,我們可以用一個紀錄器紀錄視窗內所有字元出現次數(或是否出現),這裡我們採用一個長度256的list,因為字元的ascii必在0~255之間,當然也可以用字典來做。
注意此處我們用的是$(left,right]$半區間,當然也可以定義為閉區間或開區間。寫題時必須定義好自己的區間端點是否包含,才不會搞錯。
```python=
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
left = -1
cnt = [0]*256 # counter of char
longest = 0 #global opt
# sweep right one by one
for right,ch in enumerate(s):
p = ord(ch) # ASCII code
cnt[p] += 1
while cnt[p] > 1: #repeated
left += 1 # move step by step
cnt[ord(s[left])] -= 1
longest = max(longest, right-left)
return longest
```
時間複雜度是多少呢?這裡雖然有雙迴圈但是與前一個單元講過的amortized analysis一樣。這裡的$left$與$right$都沒有回頭,時間複雜度是$O(n)$,$n$是字串長度。
講一下**迴圈不變性**這個重要的觀念。
很多人剛學程式時,學了語法面對題目卻不知所措。也許有人說,應該多看題目,多學習資料結構與算法。也對啦,人面對問題時的思考來自分析與歸納,多看多學,你會從過去的經驗歸納出解決面臨的問題。但推理思考也是很重要的。我們可以反過來問一個問題,如果你針對一個問題設計出一支程式,你如何說服別人與你自己,這支程式是對的?
不要只是說:跑了多少測資都是對的。
其實證明的過程就是思考的過程。
複雜的問題與程式當然需要分成很多步驟,但對於一個區段或一個小副程式,答案往往就在**迴圈不變性**,背後的道理其實也就是數學歸納法。
以我們現在這個問題來說,這段程式為什麼是對的,你再回頭去看看上面我們維護的迴圈不變性,如果右端移動前該性質是對的,那麼我們採取的動作就是有維護好該不變性,那根據數學歸納法在任何一個$right$時,他的區間就是最長的,答案就是對的。
再用一個更簡單的例子說明,在一序列中以迴圈找最大值。
```python=
imax = s[0]
for p in s[1:]:
imax = max(imax,p)
```
這是大部分人剛學程式都會學過的例子。
他為什麼是對的?
我們維護的迴圈不變性是:
$imax$是看到目前為止的最大值。
初始是對的。
若對$s[:i]$是對的,則對$s[:i+1]$也是對的。
在解決問題時,思考我們要維護好什麼迴圈不變性,剩下的是利用語言工具去完成它,這樣設計程式就會比較有脈絡可循,寫出來的程式邏輯也會清晰不紊亂。
### 另一個寫法
上面的範例程式中,我們用計數器紀錄字元是否出現,當重複時,用while迴圈移動左端直到重複消失。看起來有點笨,因為我們明明就已經知道重複的是新右端那個字元呀!那還需要一路慢慢摸過去嗎?其實複雜度沒有甚麼壞,因為那個while迴圈的測試條件成本是低的,如果換個場景那個while條件的測試成本(時間)極高,那就的確不是個好寫法。
我們換一個寫法,大格局不變,我們紀錄每一個字元在視窗內出現的位置,因為只能出現一次,所以就是他最後的位置,叫做$last[\;]$,若值為-1表示沒出現。那麼上面的程式會改成下面的樣子。當重複發生時,我們用一個for迴圈(第9~10行)一股腦兒地將這些位置全部清為-1。
```python=
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
left = -1
last = [-1]*256 # not existing
longest = 0
for right,ch in enumerate(s):
p = ord(ch)
if last[p] >= 0: # existing in window
for c in s[left+1:last[p]]: # clear
last[ord(c)] = -1
left = last[p]
last[p] = right
longest = max(longest, right-left)
return longest
```
## 固定視窗長度的範例
前一個例子的左右端區間長度是變來變去的,雖然可以歸類在sliding window,但也可以歸類到雙指標爬行(two pointers)。我們再舉一例,視窗長度是固定的,
[(題目連結) 1456. Maximum Number of Vowels in a Substring of Given Length(難度Medium)](https://leetcode.com/problems/maximum-number-of-vowels-in-a-substring-of-given-length/)
本題中,輸入是一個小寫字母組成的字串以及一個整數$k$,要找出長度$k$的子字串最多含有多少個母音。
同樣的,我們關注一個區段,也就是視窗,然後將這個視窗逐步往右推過去,每次我們要做的是計算出視窗內母音的個數,對所有視窗取最大值就是答案。迴圈不變性是
**cnt是視窗內母音的個數**。
對於一個字母是否是母音我們可以用一個 if 取5個判斷式的or,這裡我們聰明一點用集合來判斷。下面是範例程式。
```python=
class Solution:
def maxVowels(self, s: str, k: int) -> int:
d = {'a','e','i','o','u'}
cnt = 0
for c in s[:k]:
if c in d: cnt += 1
imax= cnt
for i in range(k,len(s)):
if s[i] in d: cnt += 1
if s[i-k] in d: cnt -= 1
if cnt>imax: imax = cnt
return imax
```
因為視窗是固定長度,所以不需要用兩個變數來維護左右端,我們用一個變數維護右端即可。第5~6行是初始的視窗,先計算視窗內母音個數。第8行開始的迴圈將右端逐步右移,如果移進來一個母音,cnt+1;如果左端移出去一個母音,cnt-1。
時間複雜度顯然是$O(n)$,集合成員判斷可以視作$O(1)$而且他還只有5個。
## 較複雜的例子
再挑一題比較難的。
[(題目連結) 30. Substring with Concatenation of All Words(難度Hard)](https://leetcode.com/problems/substring-with-concatenation-of-all-words/description/)。
這個題目是說有一個可能很長的字串$s$,另外有一個字串陣列$words$,其中$words$裡面的每個字串長度都是$k$,$k\leq 30$。要找的是$s$中有哪些substring剛好是words的permutation。所謂permutation是指$words[i]$與$words[j]$的位置可以交換改變,但每一個$words[i]$內部的字元是不可以重排的。請注意,如範例二的情形,$words$中可能有相同重複的字串。
假設我們先把$words$看成一群整數,$s$看成整數序列,那麼題目就是找出連續的一段是$words$這群整數的排列,所謂排列其實就是每個出現的東西的出現次數一樣,或是說相同的重集合(允許相同元素的集合)。
顯然,一個區間要滿足要求,他的長度必須與$words$一樣,所以用sliding window的概念,固定視窗寬度,紀錄視窗內各個成員的出現次數,只要視窗內沒有不是成員的傢伙,而且成員每個出現次數也不超過該有的次數,那就是每個成員出現次數都剛剛好。
以下是這樣想法寫出來的程式。要把words中的字串轉換成整數,我們使用一個字典。第3行的Counter()是一種dict的子類別,用來計算相同物的數量很方便,當然丟進去的東西必須是可以放進字典的(可雜湊),例如整數、字串、tuple。
**Python dict 小小說明:** Python的字典很好用,用起來有個小毛病,key值不存在的時候不能取,否則會噴KeyError。defaultdict(XXX)是一種字典,當key值不存在時會給予XXX的default值。例如defaultdict(int)如果key值不存在時就會以int的預設值0來處理,常用的方式有defaultdict(int),defaultdict(list)與defaultlist(set),也可以自訂類別。
回到我們的問題。我們根據Counter()的結果,把每一個$words$中出現的字串,對應到一個0,1,2...的整數$idx$,並儲存他在$words$中出現的次數$req$,這是第4~11行做的事情。
```python=
class Solution:
def findSubstring(self, s: str, words: List[str]) -> List[int]:
d = Counter(words)
idx = {} # new index of each word
req = [] # number of the word we need
m = 0 # num of different words
for t in d:
idx[t] = m
req.append(d[t])
m += 1
#endfor build dict
n,k = len(s),len(words[0])
ans = []
for start in range(k): # starting position
seq = [] # sequence of keys
for p in range(start,n-k+1,k): # cut every k char
t = s[p:p+k]
if t not in idx:
seq.append(-1)
else:
seq.append(idx[t])
# sliding windows
occ = [0]*m
left = 0 # window = [left,right]
for right,x in enumerate(seq):
if x<0: # clear occurence
for i in range(left,right):
occ[seq[i]] -= 1
left = right+1
continue
occ[x] += 1
while occ[x] > req[x]: # too many
occ[seq[left]] -= 1
left += 1
if right-left+1 == len(words): # found
ans.append(start+left*k)
#endfor sliding windows
# end for starting position
return ans
```
接下來第14行開始的外迴圈在嘗試所有可能的起始位置,因為我們要的固然是固定長度的區間,但起始位置可能是$0$ ~ $k-1$的各種可能。
第15~21行每隔$k$就切一段下來,對照轉換成代表的整數,如果不存在則以-1表示。
第23行$occ$表示出現次數,然後開始sliding window,第26 ~ 30行處理不存在時清空的動作,第32~34行處理出現次數超過時,移動左端的動作。當沒有不存在的而且每個次數沒超過時,長度如果是一樣,就表示是個答案了。
時間複雜度是$O(nk+mk)$,$n$是$s$的長度而$m$是$words$內字串的個數。
### 另一種寫法
上面的寫法是分兩大步驟的,先做轉換再掃描整數序列。我們也可以把它合併,就不做轉換的動作,直接用Counter()的結果當作倒數計數器,但因為有多次要做,每次要複製一份下來(第7行),這是下面這支範例程式的寫法,細節就不再重複說明。
```python=
class Solution:
def findSubstring(self, s: str, words: List[str]) -> List[int]:
d = Counter(words)
n,k = len(s),len(words[0])
ans = []
for start in range(k): # starting position
cnt = d.copy()
left = start # sliding windows
for right in range(start,n-k+1,k): # cut every k char
t = s[right:right+k]
if t not in cnt:
while left<right: # clear
cnt[s[left:left+k]] += 1
left += k
left += k
continue
cnt[t] -= 1
while cnt[t] < 0: # too many
cnt[s[left:left+k]] += 1
left += k
if right-left+k == len(words)*k: # found
ans.append(left)
#endfor sliding windows
# end for starting position
return ans
```
## 結語
找子字串或在序列中找區間時,經常可以想想看是否適用sliding window的方式,當然,經常會搭配一些計數器或其他技巧。它的正確性通常都可以用迴圈不變性去想。**證明的方法,就是思考的路徑**。
如果是字串類的問題,字典是很常用的工具,LeetCode裡面非常多需要字典的題目,也非常多字串的題目。
Python在這方面其實是比其他語言佔優勢的。其中一點是Python的字串(與list)的slicing寫起來非常舒服,它甚至可以用負的index表示倒數的位置。另外一點,Python內建的字串搜尋string.find()是非常快速的。這些我們應該會在其他單元再看到。