# Python-LeetCode 581 第10招 單調隊列 Monotonic stack and deque ## 基本原理 算法的效率來自 1. 避免不必要的計算 2. 避免重複的計算 例如大小關係的遞移性。若已知$a < b$與$b < c$,那麼不需比較我們就可以知道$a < c$。 貪心法則通常是找到強烈的性質來避免不必要的計算。但有時候性質沒有那麼強,我們無法立刻做決策,但可以維護其某些單調性,減少後面決策時不必要的計算。 本單元介紹某一類題型可以藉由維護一個單調的(子)序列,以避免一些不必要的計算,因而導致一個有效率的算法。這一類的題型進一步可分為兩種:monotonic stack與monotonic deque (queue)。 ### 典型monotonic stack Monotonic stack比較簡單,但兩者原理十分相似,以下我們先說明monotonic stack。基本型的操作很簡單:我們要用stack維護好一個單調遞減的序列,當下一個數字$s$到來時: 1. 將stack中小於等於$s$的全部pop掉; 2. 將$s$加入stack中。 操作的結果會繼續維持一個遞減序列。來看下面的經典裸題。 **Next greater element** 問題是:給一個數列$A[]$,對其中的每一個元素$A[i]$,找到其右方第一個大於$A[i]$的數字$A[j]$。 如果每一個數字的獨立的往右找,那麼可能會耗費$O(n^2)$的時間,例如$8,7,6,5,4,3,2,1,9$這個數列。 考慮以下做法。 我們從左往右,想像數字一個個進來。若容器$B$內裝著的是目前尚未找到Next greater的元素,現在進來的是$s$,那麼,依照定義,$B$中$<s$的元素的Next greater element就是$s$。再者,$B$中的元素都是尚未找到Next greater element的,所以他們從左到右必然是個遞減(非遞增)子序列,否則不會還沒找到。 所以,要找到$B$中所有小於$s$的元素只需**從後往前**一一比較就可以了:遇到小於$s$的,就設定他的next greater elemnet為$s$並從$B$中刪除,如果碰到$\geq s$的,就結束這一回合,因為$B$中剩下的也都$\geq s$。 在以上過程中,我們永遠只對容器$B$的最後一個元素進行比較或是刪除,結論就是,用一個stack來存放$B$就可以了。 我們看以下例題。 [(題目連結)496. Next Greater Element I (Easy)](https://leetcode.com/problems/next-greater-element-i/) 題目有一點變化。給兩個數列$nums1$是$nums2$的子集合,兩個數列中都沒有重複的數字。對於每一個$nums1[i]$要找這個數字在$nums2$中的next greater element。 因為$nums1$也可能等於$nums2$,所以就是要求出$nums2$所有數字的next greater element,我們用字典紀錄計算結果,$nums1$看成是query就好。 以下是範例程式。第3行初始一個stack,裡面放一個極大值當哨兵,用以減少空堆疊的檢查。第4行是一個字典,用來記錄next greater element計算結果,第5 ~ 8行就是前面介紹過維護堆疊的操作方法,本題中沒有相同數字,所以$<$與$<=$都一樣。第9行的while是因為殘留在stack中的元素是沒有next greater element的,依照題目規定給-1。時間複雜度$O(n)$。 ```python= class Solution: def nextGreaterElement(self, nums1: List[int], nums2: List[int]) -> List[int]: stk = [10001] # sentine d = {} for s in nums2: while stk[-1] < s: d[stk.pop()] = s stk.append(s) while len(stk)>1: # remaining d[stk.pop()] = -1 ans = [d[x] for x in nums1] return ans ``` ### 典型monotonic deque 與monotonic stack不同之處在於,在deque的題型中,前方的元素可能因為**過期而失效**,所以需要把它移除。這個狀況是不出現在前面stack題型中的。 以下是曾經在[Python-LeetCode 581 第四招 前綴和 Prefix Sum](/OK7eUkz3SAuStSuhJu_Auw)中拿出來舉例說明的題目,是**單調隊列基本型範例** [(題目連結)239. Sliding Window Maximum (hard)](https://leetcode.com/problems/sliding-window-maximum/) 題目說有一個長度為$k$的sliding window在一序列中一步一步移動,請計算出在每一個位置時的區間最大值。題目名稱有sliding window,但並非用該技巧。 其實要算的是$[max(nums[0:k])), max(nums[1:k+1]),\ldots ]$,也就是所有$n-k+1$個長度為$k$的區間的每一個區間最大值。當然,直接這麼寫的時間複雜度是$O(kn)$,太慢了。 單調隊列背後的道理可以以下面情境來打個比方。 「學生一屆一屆的進入學校,小明很喜歡程式比賽,當他進入學校時,如果有學長姐厲害到小明難以超越,那沒關係,小明在學校還是有出頭之日,因為學長姐會比他先畢業。但如果在小明之後新進來一位他無法超越的學弟妹,那完蛋了,小明在該校沒有什麼機會獨領風騷了,因為他會先畢業。」 我們的window在序列一步一步右移,可以看成一屆有一位學生進入,視窗長度固定為$k$,也就是學生進入後$k$屆就會畢業,同時在校的也恰有$k$位,我們要找出每一年最厲害的校內冠軍。 上面的例子告訴我們,對於某個$i$(小明),如果同時在視窗內有個$j$,$nums[j]>nums[i]$,如果$j<i$,則 $i$ 尚未絕望,將來還有可能有機會當冠軍;但如果$j>i$,則 $i$ 從此刻起永遠不會是校內冠軍了。 如果我們把視窗內這些不可能成為冠軍的去除不看,那麼視窗內的將是個單調下降的子序列(monotonically decreasing subsequene)。這就是**單調隊列**。 我們用一個容器存放目前的隊列,維護的迴圈不變性是: 1. 隊列是遞減的 2. 隊列中均為尚在範圍內的元素 請注意,**根據迴圈不變性,隊列最前方的就是區間最大值**。每次新元素進入時,我們做兩件事: 1. 新生刪除所有不比自己強的學長姐。根據迴圈不變性,隊列為遞減的,我們只要從後往前刪除比新元素小的成員,當碰到第一個大於他的元素,刪除動作即已完成,因為前面的更大。刪除完成後再將自己加入隊列尾端(新生永遠有希望)。 2. 檢查最前方,也就是最大的元素是否已經出界,若是,將其移除。 要使用什麼容器來存放隊列呢?因為要支援前方與後方的移除,典型的做法是用deque (double ended queue雙向佇列)。Python也有提供deque,我個人比較喜歡用list加上一個head變數記錄頭端的位置即可。因為list尾端的刪除與新增都是$O(1)$,所以很方便。 來看本題的範例程式。 ```python= class Solution: def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]: q = [] # (value,index) decreasing subsequence head = 0 ans = [] for i,x in enumerate(nums): while head<len(q) and q[-1] < (x,i): q.pop() q.append((x,i)) if q[head][1] <= i-k: # remove out of date head += 1 if i>=k-1: ans.append(q[head][0]) return ans ``` 因為需要偵測出界,這裡隊列的每個成員我們用一個tuple來存他的值與位置。當然也可以只存index然後用原陣列去抓值,也可以用兩個list。第6行逐一掃瞄新進成員,第7 ~ 8行執行尾端刪除,第9行將新進成員尾端加入,第10 ~ 11行檢查前端是否出界。 時間複雜度:$O(n)$,線性。 ## Monotonic Stack的例題 給一個整數陣列,要計算出每一個可能子陣列最小元素的總和。 [(題目連結) 907. Sum of Subarray Minimums (Medium)](https://leetcode.com/problems/sum-of-subarray-minimums/) 題目要求的是 ```python sum(min(arr[i:j]) for i in range(len(arr)) \ for j in range(i+1,len(arr)+1)) ``` 當然這麼寫會TLE,一個長度為$n$的陣列有$O(n^2)$個子陣列,每個子陣列用min求最小值,這樣寫的時間複雜度是$O(n^3)$,我們必須找更好的方法。 直覺的想法是求出每個子陣列的最小值,但我們也可以將角色交換,**找出每個元素是哪些子陣列的最小值**。如果$arr[i]$是$p[i]$個子陣列的最小值,那答案也可以計算為$\sum_{i}arr[i]*p[i]$。那麼,問題變成要如算計算$p[i]$。 首先$arr[i:i+1]$這個子陣列只含$arr[i]$,所以它的最小值當然就是。如果最小值要是$arr[i]$,當然這個子陣列必須包含$arr[i]$,所以它可能的左端必落在$[0,i]$區間,而他的右端必落在$[i,n-1]$。我們算出左端的可能數量與右端的可能數量,兩者乘起來就是答案。 我們從$i$往前走,當到達甚麼樣的值區間最小值會變成不是$arr[i:i+1]$呢?這樣一想就清楚了,左端的邊界是$i$前方比$i$小的數字,也就是previous smaller element,相似的,右端的邊界就是next smaller element。 這裡有一個細節,當相同元素時,定義哪一個當最小都可以,但是不要重複都算就行了,下面的範例程式是算在比較前面的元素。 前面我們已經看過用stack求出next greater element的方法,求previous/next與smaller/greater都是類似的,所以我們可以跑兩次。事實上,跑一次就可以了。 以下是範例程式。第9行開兩個list存previous/next smaller的index,用堆疊維護一個非遞減序列,初始時底部放一個最小值當哨兵。第11行開始掃描陣列,當堆疊頂端較小時,$i$就是他的next smaller;當較小值pop完畢後,堆疊頂端的就是$i$的previous smaller。 當掃描完畢時,堆疊內可能還有元素,這些元素沒有next smaller,我們一開始已預設為$n$,所以不必再做任何處理。第18行算答案,第19行輸出。時間複雜度$O(n)$,因為根據amortized analysis,所有元素進出堆疊僅一次。 如果還不清楚此處單調stack的操作與原理,最好再回去看前面的典型範例說明。 ```python= class Solution: # find the num of subarray [left,right] whose min is arr[i] # num of left = i - idx of previous smaller # num of right = idx of nxt smaller - i # maintain non-decreasing subsequence def sumSubarrayMins(self, arr: List[int]) -> int: n = len(arr) # inde of previous/next smaller pSmall, nSmall = [-1]*n, [n]*n stk = [(-1,-1)] # (val, idx) for i,p in enumerate(arr): while stk[-1] > (p,i): # i is next smaller nSmall[stk[-1][1]] = i stk.pop() pSmall[i] = stk[-1][1] # my previous smaller stk.append((p,i)) # result total = sum((i-pSmall[i])*(nSmall[i]-i)*p for i,p in enumerate(arr)) return total%(10**9+7) ``` 下一題直方圖的面積是很經典的題目。 [(題目連結) 84. Largest Rectangle in Histogram (Hard)](https://leetcode.com/problems/largest-rectangle-in-histogram/) 給一個直方圖(Histogram),也就是寬度均為1的一些底部並列在一條水平線的矩形,要求出落在直方圖內部的最大矩形的面積,請參見下圖。 ![](https://hackmd.io/_uploads/HkbF6gTka.jpg) 落在直方圖內的矩形也就是要選取一個連續區間$[i:j]$,其面積是$(j-i)\times min(h[i:j])$。 這麼一說,這題跟前面的子陣列最小值總和就十分相像。我們顛倒腳色,去算以$h[i]$為高度的最大矩形,也就是$h[i]$的previous smaller到next smaller之間的水平位置。對所有可能高度算出一個矩形,取最大值就是答案。 下面是一個範例程式,用stack維護一個increasing subsequence,previous/next smaller不必另外存,當遇到next smaller時,從stack pop出來,他的previous smaller此時就在stack的頂端。 時間複雜度$O(n)$。 ```python= class Solution: # for h[i] the max rectangle with h[i] as height is # from previous smaller to next smaller # maintain increasing subsequence # previous smaller is previous in stack # compute the area when next smaller apper def largestRectangleArea(self, heights: List[int]) -> int: heights.append(0) stk = [(-1,-1)] #stack # find the max area with height[i] as the right side max_area = 0 for i,h in enumerate(heights): while stk[-1][0]>=h: ih,_ = stk.pop() # i is next smaller of ih max_area = max(max_area, ih*(i-stk[-1][1]-1)) stk.append((h,i)) return max_area ``` 下面一題與前一題有點像,給一個0/1矩陣,找出1構成的最大矩形面積。 [(題目連結) 85. Maximal Rectangle (Hard)](https://leetcode.com/problems/maximal-rectangle/) ![](https://hackmd.io/_uploads/HklP-b6J6.png) 確實與直方圖面積非常像。想像第$i$ ~ $j$列的區間,水平看過去,對於每一欄,如果這些列都是1,就相當於直方圖中一個高度$j-i+1$的矩形bar,否則(不是全1),就將高度設為0。轉換後求直方圖面積即可。 對於$m\times n$的矩陣,要枚舉所有列區間,我們妥善安排順序,每一個區間的轉換只花$O(n)$的時間,直方圖求面積也只花$O(n)$的時間,整體時間複雜度就是$O(m^2 n)$。 ```python= class Solution: def maximalRectangle(self, matrix: List[List[str]]) -> int: m,n = len(matrix),len(matrix[0]) def Area(heights): heights.append(0) stk = [(-1,-1)] #stack # find the max area with height[i] as the right side max_area = 0 for i,h in enumerate(heights): while stk[-1][0]>=h: ih,_ = stk.pop() max_area = max(max_area, ih*(i-stk[-1][1]-1)) stk.append((h,i)) return max_area #main h = [0]*n ans = 0 for i in range(m): for j in range(n): if matrix[i][j]=="1": h[j] += 1 else: h[j] = 0 ans = max(ans, Area(h)) #end for return ans ``` [(題目連結) 1944. Number of Visible People in a Queue (Hard)](https://leetcode.com/problems/number-of-visible-people-in-a-queue/) 題目是說有$n$個人從左到右排成一列,給定每個人的高度,要計算每個人往右看過去可以看到幾個人,第$i$個人看得到第$j$個人的定義是兩人之間的人都比他們矮,$h[k]<min(h[i],h[j])$ for all $i<k<j$。 對於任何一個人$j$來說,如果他的左方出現一個$\geq h[j]$的人,他就會被**屏蔽**了,也就是不可能被更左方的人看到。所以我們如果從右往左掃過來,刪除沒有用的(被屏蔽而不會再被看到的),只需維護一個(左到右)遞增序列就可以。當我們掃到$i$時,他看到的人就是這個遞增序列中$<h[i]$的人以及第一個$\geq h[i]$的人。從前面所用的術語來說,事實上,就是以$i$為previous greater的人,以及$i$的next greater (相等納入), 所以程式跟前面的範例也很像。 ```python= class Solution: def canSeePersonsCount(self, heights: List[int]) -> List[int]: n = len(heights) ans = [0]*n stk = [100001] i = n-1 for h in heights[::-1]: cnt = 0 while stk[-1]<h: cnt += 1 stk.pop() ans[i] = cnt+int(len(stk)>1) stk.append(h) i -= 1 return ans ``` [(題目連結) 1776. Car Fleet II (Hard)](https://leetcode.com/problems/car-fleet-ii/) 有$n$部車在同一條路上同方向前進,第$i$部車的速度是$s_i$,編號越大的車的起始位置在越前面,後方車如果追上前方車,就會形成一個集團,集團以前方車的速度繼續往前。所有車在時間0時開始前進,請計算出每一台車追上前一台車的時間,如果不會追上則以-1表示。 每台車在追上前車之前是等速前進,追上前車後變成前車的速度繼續等速前進,所以每一台車全程的速度是一個**piecewised linear**函數,其斜率(速度)為遞減,如下圖。 ![](https://hackmd.io/_uploads/ByETOp1g6.png) 我們由前往後(編號由大到小)一一算出車子的速度函數,第$i$車的每一段速度就存在一個stack中,考慮第$i-1$台車時,從stack上方開始一一計算該轉折點是否追上前車,若否,則將該資料pop刪掉;如果追上時,就計算出追上的點,該點必位於此點與前一點之間,如下圖所示。如果始終都沒追上前車,會將前車的速度完全pop掉,此時在stack中加上本車的初始速度就可以了。 這樣我們不僅算出第$i-1$台車追上前車的時間,也將第$i$台車的速度(piecewised linear function)更新為第$i-1$的速度。 ![](https://hackmd.io/_uploads/Skj-jaJga.png) 在程式中,為方便表示,我們設一個無限大的距離。每一個速度轉折點存3個資料,時間、速度、以及距離,表示在此距離在此時間之前是以該速度前進,也就是前面圖中每一段的斜率。 第9行開始掃描每一台車,用while迴圈pop掉沒有追上的轉折點。第12行是stack全部pop到空的狀況,此時重新加上這一台車的速度;否則,我們要計算出追上的時間與位置(第16行)。最後的答案增加一個-1是因為第一台車(編號最大)是沒有前車可以追的。 ```python= class Solution: # piecewised linear in stack def getCollisionTimes(self, cars: List[List[int]]) -> List[float]: ans = [] oo = 10**13 cars = cars[::-1] # (time, speed before distance, distance) stk = [((oo-cars[0][0])/cars[0][1],cars[0][1],oo)] for p,s in cars[1:]: while stk and (stk[-1][2]-p)/s >= stk[-1][0]: stk.pop() if not stk: ans.append(-1) stk.append(((oo-p)/s,s,oo)) else: # t*s+p = pi-si*(ti-t), t = (stk[-1][2]-p-stk[-1][1]*stk[-1][0])/(s-stk[-1][1]) ans.append(t) stk.append((t,s,t*s+p)) ans = ans[::-1] + [-1] return ans ``` [(題目連結) 1856. Maximum Subarray Min-Product (Medium)](https://leetcode.com/problems/maximum-subarray-min-product/) 給一個正整數的陣列,要找出一個子陣列(連續區段),該子陣列的最小值與總和的乘積越大越好。 對於一個點$nums[i]=p$,我們往左找到第一個$nums[j]<p$,也就是previous smaller;往右找第一個$nums[k]<p$,也就是next smaller。那麼$[j+1,k-1]$這個區間就是以$nums[i]$為最小值的最大區間。對每一個$nums[i]$都找此最大區間的總和乘積,在求出其中最大的就是答案。 結論是這題也是要找previous/next smaller,為了計算區間總和,我們利用prefix sum。 以下為範例程式。psum是用來算prefix sum,我們一面走一面算,需要的值存在stack中。inc是我們的stack,用來存遞增子序列,每一個元素是(某點的值,某點的前綴和)。我們在堆疊底部放個無限小的-1,掃描陣列的尾端放個0,都是用來當哨兵簡化程式。 當$p$進來時,我們計算處理以$p$為next smaller的元素(等號包含)。它的previous smaller就在堆疊中,因此我們很容易計算以它為最小值的最大區間(第11行)。 時間複雜度$O(n)$。 ```python= class Solution: # using stack maintaining increasing subsequence def maxSumMinProduct(self, nums: List[int]) -> int: n = len(nums) psum = 0 # prefix sum inc = [(-1,0)] # stack (val,sum), sentinel imax = 0 for p in nums+[0]: #sentinel to clear remaining while inc[-1][0] >= p: # p as the right boundary imin = inc.pop()[0] curr = imin*(psum-inc[-1][1]) imax = max(imax, curr) psum += p inc.append((p,psum)) # return imax%1000000007 ``` ### Tree的題型 [(題目連結) 1130. Minimum Cost Tree From Leaf Values (Medium)](https://leetcode.com/problems/minimum-cost-tree-from-leaf-values/) 給一個正整數序列,要建構一個二元樹,其每個中間節點都恰有兩個孩子,而葉節點由左而右恰為所給的正整數序列,在滿足此要求的二元樹之中,希望找到中間節點的總和最小的,而中間節點的值定義為其左右子樹中最大的葉節點之值的乘積。 以下圖為例,輸入序列為$[6,2,4]$,可能的二元樹只有兩棵,圖右的中間節點總和較小,所以答案是8+24=32。 ![](https://hackmd.io/_uploads/SkIh6Akga.png) 上面的例子透漏了解題的可能方向。這題可以歸類在Greedy,但其做法是維護一個decreasing stack,所以歸在本單元也可以。 二元樹由底層往上,可以看成一個合併的過程。在上例中,關鍵在於2要先跟前面的6合併,還是先跟後面的4合併,而我們可以看到它所影響的就是2與其他節點合併的中間節點的值,所以它跟比較小的(4<6)合併會導致比較小的解。 因為中間節點的值是取左右的最大葉節點來相乘,所以一個數字一旦與一個比較大的數字合併後,再往上走它就沒有扮演腳色了,所以我們可以想像成**一個數字將會跟一個比較大的數字合併並消失**。那麼,它跟哪個數字合併最好呢?不難看出就是跟previous greater與next greater之中較小的合併最好,難道不會是其他的節點嗎?如果next greater先跟更後面的合併,那結果只會更大或一樣,所以跟更後面或更前面的合併不會比較好。 結論是**每個節點應該跟previous greater與next greater中較小的合併**。 和前面的範例類似,做法就是用一個stack維護遞減序列,堆疊內的前一個就是prevous greater,當遇到next greater的時候就可以決定一個元素要跟誰合併了。 以下是範例程式。相似的,我們在堆疊內放一個極大的值當作哨兵,掃描序列。第8行的while迴圈是進來的元素是next greater的狀況,我們將堆疊top元素pop出來,算它要跟前或後合併,處理完畢後,再將p塞入堆疊中維持一個遞減序列。 最後當掃描完畢後,堆疊內可能殘留一個遞減序列,我們要將每一個併入前一個(previous greater),直到剩下一個為止。 時間複雜度$O(n)$。 ```python= class Solution: # O(n) greedy; each element is removed and merged with the # smaller of its left/right greater def mctFromLeafValues(self, arr: List[int]) -> int: stk = [10**9] # a decreasing subsequence ans = 0 for p in arr: while stk[-1] <= p: q = stk.pop() ans += q*min(p,stk[-1]) stk.append(p) # while len(stk) > 2: q = stk.pop() ans += q*stk[-1] return ans ``` [(題目連結) 654. Maximum Binary Tree (Medium)](https://leetcode.com/problems/maximum-binary-tree/) 給一個沒有重複數字的數列,要用以下的方法建構出一個binary tree。 1. 找出最大值當作root 2. 最大值左邊遞迴建構出左子樹 3. 最大值右邊遞迴建構出右子樹 根據這個定義直接做的話,會太耗時,因為最大值的切割可能很不平均,一個極端的例子是變成一條鍊。 從定義觀察,$v$點的右孩子如果是$u$,那麼,$v$必然在$u$的左邊而且$v>u$。更進一步,$v$與$u$之間不可能有大於$v$或$u$的點,否則$u$不會是$v$右子樹中的最大。因此,$u$就是以$v$為previous greater的那些節點中最大的那一個,也就是最後一個。 類似的,$v$的左孩子就是以$v$為next greater那些節點中最大者,也就是最左邊的。 有了這樣的理解後,我們可以像求next greater的方式來決定每個點的左右孩子。以下是範例程式。 我們一樣用一個堆疊維護遞減序列,掃描到$v$時,堆疊中比它小的都是以它為next greater的點,我們不必管所謂的第一個,只要pop stack時把$v$的左孩子設為堆疊頂端的點,最後一個寫下去的就是所謂的第一個。相同的,堆疊pop完畢時,堆疊中的頂端($u$)就是$v$的previous greater,也就$u$的右孩子可能是$v$,一樣的,我們不需管$v$是否是最後一個以$u$為previous greater的點,我們只要寫下去,最後一個寫的就是最後一個,此處要注意,堆疊可能為空,也就是$v$沒有previous greater,所以要用if來檢查。 時間複雜度一樣是$O(n)$。另外要注意本題是規定用LeetCode提供的binary tree class,這一類的題目不少,只要看得懂,會初始節點,存取其中的欄位就可以了。 ```python= # Definition for a binary tree node. # class TreeNode: # def __init__(self, val=0, left=None, right=None): # self.val = val # self.left = left # self.right = right class Solution: # right child of v is the last vertex whose previous greater is v. # # left child is the first whose next greater is v. # stack keep decreasing subsequence def constructMaximumBinaryTree(self, nums: List[int]) -> Optional[TreeNode]: stk = [] # decreasing subsequence, right-most path of tree for x in nums: v = TreeNode(x) while stk and stk[-1].val< x: v.left = stk[-1] stk.pop() if stk: stk[-1].right = v stk.append(v) #end for return stk[0] ``` ## Monotonic Deque or Queue的例題 [(題目連結) 1499. Max Value of Equation (Hard)](https://leetcode.com/problems/max-value-of-equation/) 給一個平面整數點座標的陣列與一個整數$k$,保證X座標為嚴格遞增。要找兩點$(x_i,y_i)$與$(x_j,y_j)$,滿足$|x_i-x_j|\leq k$,求目標函數$y_i+y_j+|x_i-x_j|$的最大值。 用白話講就是,X差值在給定$k$的範圍內,兩點Y座標與X差值之和的最大值。 枚舉所有點對的時間複雜度$O(n^2)$不能滿足效率的要求,必須找優化的方式。對於每一個$(x_i,y_i)$,我們找出最大化目標函數的$j<i$,對所有$i$再取最大值就是答案。 對於指定的$i$且限定$j<i$之後,目標函數簡化為 $$ \begin{split} &\max_{j<i}\{y_i+y_j+|x_i-x_j|\} \\ &= \max_{j<i}\{y_j-x_j+(y_i+x_i)\} \\ &= \max_{j<i}\{y_j-x_j\} + (y_i+x_i) \end{split} $$ 因為對指定的$i$,$x_i+y_i$為常數,我們只要求$y_j-x_j$的最大值就好了,當然是在題目要求的$x_i-x_j\leq k$ 的限制之下。 做法已經浮現了。我們由左往右掃過去,紀錄目前最大的$y_j-x_j$,如果後面出現的值比較大,前面的就沒有用了(請回想本單元前面所揭示的經典題),但後面的值如果比較小,則仍需保留,因為前方的點會因為X範圍限制而出界。我們要保留的是一個$y_j-x_j$的遞減序列。 以下是範例程式,我們用一個list que搭配一個index head來做deque,每一個元素包含該點X與Y-X。第6行迴圈開始掃描歷遍,第7行檢查頭端元素是否已經過期出界,第9行若deque內有元素,取出頭端就是最大值。然後我們從deque的尾端檢查,刪除小於等於$y-x$元素(它們將不再有用),維護好遞減序列。 時間複雜度$O(n)$。 ```python= class Solution: # for each (x_i,y_i) find max{y_j-x_j: j<i and x_j>=x_i-k} def findMaxValueOfEquation(self, points: List[List[int]], k: int) -> int: imax = -10**9 que = []; head = 0 # (x_i, yi-xi) for x,y in points: while head<len(que) and que[head][0]<x-k: head += 1 # pop out of date if head<len(que): imax = max(imax, x+y+que[head][1]) t = y-x while head<len(que) and que[-1][1]<=t: que.pop() que.append((x,t)) return imax ``` [(題目連結) 1438. Longest Continuous Subarray With Absolute Diff Less Than or Equal to Limit (Medium)](https://leetcode.com/problems/longest-continuous-subarray-with-absolute-diff-less-than-or-equal-to-limit/) 給一個正整數的陣列,以及一個非負整數$limit$。要找出最長的一段子陣列,在此子陣列中任兩個數字的差值皆不超過$limit$。 任兩個數字的差值皆不超過$limit$的意思其實就是最大值與最小值的差值不超過$limit$。我們可以搞一個sliding window,每次將右端推一格,然後很直白的調整左端。 ```python= left = longest = 0 for right in range(n): # try each right while max(nums[left:right+1]) > min(nums[left:right+1])+limit: left += 1 longest = max(longest,right-left+1) ``` 這麼寫清楚是清楚,但時間複雜度很糟糕,對一個右端,可能嘗試各種左端,每次要花$O(right-left)$的時間算最大值與最小值,這樣$O(n^2)$少不了。 我們設法優化每次重新尋找區間最大值與最小值的部份,因為當右端移動一格時,區間的最大與最小的變化是有一些特性而非毫無脈絡的。 我們維護一個單調遞減序列qmax,可以找到最大值;再維護一個單調遞增序列qmin,可以找到最小值。對於每個右端$i$,我們先用while調整單調隊列,然後檢查是否調整左端,當最大值與最小值的差超過$limit$時,就要調整左端,調整時,看最大值與最小值哪一個出現在比較左邊,就先移除,直到區間內的差值在要求範圍內為止。 以下是範例程式,本題我們在單調隊列中放的是index,時間複雜度$O(n)$。 ```python= class Solution: def longestSubarray(self, nums: List[int], limit: int) -> int: qmax, qmin = [0],[0] # dec and inc subsequence (index) hmax,hmin = 0,0 # head of qmax and qmin longest = 1 left = 0 # current left for i,p in enumerate(nums[1:],start=1): while hmax<len(qmax) and p>=nums[qmax[-1]]: qmax.pop() # decreasing qmax.append(i) while hmin<len(qmin) and p<=nums[qmin[-1]]: qmin.pop() # increasing qmin.append(i) # move left if >limit while nums[qmax[hmax]] > nums[qmin[hmin]]+limit: # remove max or min, which is left if qmax[hmax] < qmin[hmin]: left = qmax[hmax]+1 hmax += 1 else: left = qmin[hmin]+1 hmin += 1 longest = max(longest,i-left+1) return longest ``` 這一題也有一個$O(n\log n)$的解。利用sliding window,我們可以在$O(n)$求出給定視窗長度$w$時,差值最小的視窗。因為視窗越長,最小差值必然單調非遞減,我們可以對答案進行二分搜。 ## DP+單調隊列 [(題目連結) 1425. Constrained Subsequence Sum (Hard)](https://leetcode.com/problems/constrained-subsequence-sum/) 給一個數列以及一個正整數$k$,要選取一個總和最大的子序列,限制條件是子序列中兩個相鄰元素在原始數列中的位置不得超過$k$。也可以這樣看,你可以從序列的任何一個位置開始挑選,也可以隨時終止挑選,但挑於一個之後,與下一個之間的距離不可超過$k$。 如果$k=1$,就是必須挑選連續的一段,這是一個著名經典題max subarray,所以本題是max subarray的一個延伸題。 考慮DP吧! 設$dp[i]$是最後一個挑$i$的最大總和。因為前一個間距不能超過$k$, $dp[i] = nums[i]+\max\{\max\{dp[j] \text{ for } i-k\leq j<i\},0\}$ 與0取max是因為如果範圍內的值是負的,則不如拋棄重新開始挑選。 有了DP遞迴式之後就要看如何做了,這樣一個1d1d的DP,如果照遞迴式直接做,或需要$O(kn)$的時間複雜度,因為有$O(n)$要求,每個需要在$k$個中挑最大值。 跟前面的題目一樣,我們只需要維護DP值的單調下降序列就可以了,因為後面出現更大的DP值時,前面的就沒用了,此外,因為有距離的限制,要考慮目前的最大值可能出界,所以用一個單調隊列就簡單了。 以下是範例程式,時間複雜度$O(n)$。 ```python= class Solution: # dp[i] = nums[i]+max(dp[j] for i-k<=j<i) def constrainedSubsetSum(self, nums: List[int], k: int) -> int: t = max(nums) if t<=0: return t imax = 0 # que = decreasing subsequence of dp que = [(0,-1)] # (dp, index) head = 0 for i,p in enumerate(nums): if que[head][1]<i-k: head += 1 # out of date t = max(que[head][0]+p,0) while head < len(que) and que[-1][0]<=t: que.pop() # pop useless que.append((t,i)) if t>imax: imax = t return imax ``` 下一題有些類似。 [(題目連結) 1696. Jump Game VI (Medium)](https://leetcode.com/problems/jump-game-vi/) 給一個整數陣列與一個正整數$k$,要從陣列位置0的地方開始跳到最後一格,每次只能往前跳距離不超過$k$,採到的格子就是獲得的分數,要求最大得分總和。 令$dp[i]$是最後踩在$i$的最大得分總和,動態規劃的遞迴式是 $dp[i] = \max_{i-k\leq j<i}\{dp[j]\} + nums[i]$。 相同的,我們要優化選取區間最大值的方法。使用一個單調下降的隊列就可以做到了。 下面是範例程式,我們用deque來維護dp值單調遞減序列的index。時間複雜度$O(n)$。 ```python= class Solution: # dp[i] = max(dp[i-k:i])+nums[i] # deque for decreasing subsequence def maxResult(self, nums: List[int], k: int) -> int: n = len(nums) d = deque([0]) # index of subsequence dp = [0]*n dp[0] = nums[0] for i in range(1,n): if d[0]<i-k: d.popleft() dp[i] = dp[d[0]]+nums[i] while d and dp[d[-1]] <= dp[i]: d.pop() d.append(i) return dp[-1] ``` ## 結語 Monotonic stack的題型大多是next greater element (NGE)的變化型,處理此問題時,我們掃描陣列,有些可以立刻決定NGE,但有些當下無法決定,但我們維護好一個遞減序列,在後續決策時省卻一些不必要的計算。我們可以再體會一下本單元開頭的那一段話,對算法的精神或許有進一步的體認。 單調隊列的DP優化是一個很經典的動態規劃題型,在需要區間極值的場合,也有另外的資料結構(Range maximum query, RMQ)可以協助處理,但會比較複雜,時間複雜度也可能差一些。單調隊列處理的是特殊的RMQ,它的區間是逐步滑動的一個特殊狀況,而非任意區間的查詢。 我們在後面的單元將會對DP有更多的介紹。