# Python-LeetCode 581 第四招 前綴和 Prefix Sum
「前綴和應該怎麼做呀?我煩到好像要死掉了!」
「錢墜河跟鑰匙掉了都一樣,撿起來就好了呀!」
![](https://hackmd.io/_uploads/B1Fhb6th2.jpg)
(這是前綴和一個有趣的梗圖)
## 基本原理
Prefix前綴,也翻譯為字首,就是字串或序列從頭開始到某個位置的連續一段,相對於prefix,suffix就是後綴或字尾。顧名思義,前綴和就是數列前面一段的總和。若 $nums[\;:n]$ 是一個整數序列,$prefix[i]$就是$sum(nums[\; :i+1])$,即前$i$項的總和。下面是一個例子。
| index | 0 | 1 | 2 | 3 | 4 | 5 |
| - | - | - | - | - | - |-|
| nums | 2 | -5 | 3 | 2 | 1 | -2 |
| prefix-sum | 2 | -3 | 0 | 2 |3 | 1 |
我們這裡談的序列是離散函數,若類比到連續函數,$prefix$是$nums$積分,而$nums$是$prefix$的微分。前綴應用的題目頗多,這個單元會先談一些最直接的應用。最直接的應用就是區間和。
一個長度為$n$的數列,有$O(n^2)$個可能的區間$[i,j]$ for $0\leq i \leq j<n$。但只有$n$個prefix sum,若$psum[i]$為前$i$項的前綴和,則$[i,j]$區間和可以藉由前$j$項的總和減去前$i-1$項的總和來求出,即$psum[j]-psum[i-1]$,當然這裡$i>0$。若$i=0$,則$[i,j]$區間和就是$psum[j]$.
所以我們只要先計算並存好$n$個前綴和,就可以隨時得到$O(n^2)$個區間和的任何一個。而且,我們很容易在$O(n)$時間內算好所有的前綴和。
### Python計算儲存前綴和
以下是根據定義直接計算前綴和。
```python
psum = [0]*n
for i in range(n): psum[i] = sum(nums[:i+1])
```
很遺憾,這樣寫複雜度是$O(n^2)$。從DP(動態規劃)的觀點,前$i$項的總和可以由前$i-1$項的總和再加上第$i$項來得到。於是得到以下$O(n)$的方法
```python
psum = [0]*n
psum[0] = nums[0]
for i in range(1,n): psum[i] = psum[i-1]+nums[i]
```
也可以這樣寫
```python
psum = [nums[0]]
for i in range(1,n):
psum.append(psum[-1]+nums[i])
```
$psum[-1]$就是$psum$的最後一項。
要計算$[i,j]$區間和時
```python
rsum = psum[j]-psum[i-1] if i>0 else psum[j]
```
如果原序列nums不必保留,那可以更簡單的在原地計算前綴和
```python
for i in range(1,n): nums[i] += nums[i-1]
```
夠清楚夠乾淨的程式了,但這個 if 有點討厭,邊界檢查總是讓人心煩,有好的方式簡化嗎?
#### 哨兵 sentinel
簡化邊界檢查常用放哨兵的技巧,上個單元中,我們在Grid的邊界上放哨兵來省略邊界檢查,這裡我們也可以類似的做法。
如果我們在$psum$的最前方放一個0,index的定義移到區間$[1,n]$,那麼就不必檢查邊界了。常看到這樣的寫法:
```python
psum = [0]
for p in nums: psum.append(psum[-1]+p)
# find range sum [i,j]
rsum = psum[j+1]-psum[i] # index shift +1
```
吹毛求疵,這個方法還是有個缺點,index位移有時候忘記了就會犯錯。其實我們可以利用Python index -1 表示結尾的特性,把前方的哨兵0寫在後方。以下程式假設在原陣列中計算。
```python
for i in range(1,n): nums[i] += nums[i-1]
nums.append(0) # sentinel for [-1]
# find range sum [i,j]
rsum = nums[j]-nums[i-1] # index no shift
```
你可以發現$i=0$時,$nums[i-1]=nums[-1]=0$。
## 基本範例
LeetCode中有全裸的前綴和題目,直接要求實作online的區間和查詢。以下說明這兩個範例,一個是一維,一個是二維。
[(題目連結)303. Range Sum Query - Immutable (Easy)](https://leetcode.com/problems/range-sum-query-immutable/description/)
這個題目直接要求實作一個class來回覆區間和的查詢。解法就是上面說明的前綴和基本寫法,就不必多解釋了。
```python=
class NumArray:
def __init__(self, nums: List[int]):
self.prefix = [nums[0]]
for p in nums[1:]:
self.prefix.append(self.prefix[-1]+p)
self.prefix.append(0) #sentinel
def sumRange(self, left: int, right: int) -> int:
return self.prefix[right]-self.prefix[left-1]
# Your NumArray object will be instantiated and called as such:
# obj = NumArray(nums)
# param_1 = obj.sumRange(left,right)
```
### 二維前綴和
前綴和可以擴展到二維矩陣。每一個前綴和$psum[i][j]$定義是從左上角(0,0)到右下角(i,j)這個矩形區域的總和。當要計算左上角(r1,c1)到右下角(r2,c2)區域內總和時,可以根據簡單的排容原理來計算,請看下圖。
![](https://hackmd.io/_uploads/BJ12YRt2h.png)
要計算紅色區塊的總和,我們可以用最大的那個矩形減去左邊綠色的,減去上方黑色的,再加回左上角淺藍色那一小塊(因為它被減去兩次)。下面請看LeetCode上二維前綴和的裸題。
[(題目連結)304. Range Sum Query 2D - Immutable (Medium)](https://leetcode.com/problems/range-sum-query-2d-immutable/description/)
題目是給一個矩陣後,每次要查詢子矩陣的總和。
計算二維前綴和有幾個類似的方式,這裡採用的是與計算區間和一樣的原理,把上面那個圖形中右下小角的紅色區塊看成只有一個元素時,大的矩形就是要求的前綴和,我們可以用上面的加上左邊的減去左上角的,最後加上紅色這個元素。因此,從上到下,由左而右掃描矩陣來計算即可。請看範例程式。
```python=
class NumMatrix:
def __init__(self, matrix: List[List[int]]):
m,n = len(matrix),len(matrix[0])
self.p = [row[:]+[0] for row in matrix]
self.p.append([0]*n)
for i in range(m):
for j in range(n): # row prefix sum
self.p[i][j] += self.p[i-1][j] + self.p[i][j-1] \
- self.p[i-1][j-1]
def sumRegion(self, row1: int, col1: int, row2: int, col2: int) -> int:
return self.p[row2][col2] - self.p[row1-1][col2] \
-self.p[row2][col1-1] + self.p[row1-1][col1-1]
# Your NumMatrix object will be instantiated and called as such:
# obj = NumMatrix(matrix)
# param_1 = obj.sumRegion(row1,col1,row2,col2)
```
## 前綴與後綴
接著我們來看一個用到前綴與後綴的題目。後綴和與前綴和的道理是一樣的。
[(題目連結)2017. Grid Game (Medium)](https://leetcode.com/problems/grid-game/description/)
這個題目是說有一個$2\times n$的矩陣,有兩個人玩遊戲,要從左上角走到右下角,每一步只能往右或往下,第一個人先走,走過的地方分數就會變成0,目標是讓第二個人的分數越小越好。
請注意矩陣只有兩列。
其實第一個走的路徑只有$n$種,也就是只要看他在哪個column由第一列走到第2列,第二個人可以得到的分數就是第一列與第二列剩下較多的。
![](https://hackmd.io/_uploads/HJzZllc2n.png)
如上圖,假設箭頭是第一個人的路徑,第二個人的分數就是紅色與橙色區域比較大的,所以我們只要min(max(紅色,橙色))就是答案。
我們先計算出第一列所有的後綴和,以及第二列的所有前綴和,然後對所有的$i$,找兩者較大的最小值,要注意邊界。
以下是範例程式,時間複雜度$O(n)$。
```python=
class Solution:
# going down at column i, score =
# #max(suffix[i+1] of row 0, prefix[i-1] row 1)
def gridGame(self, grid: List[List[int]]) -> int:
n = len(grid[0])
if n==1: return 0
for i in range(n-2,0,-1):
grid[0][i] += grid[0][i+1] # suffix sum
for i in range(1,n):
grid[1][i] += grid[1][i-1] # prefix sum
imin = min(grid[0][1], grid[1][n-2]) # col 0 and col n-1
for i in range(1,n-1):
imin = min(imin, max(grid[0][i+1],grid[1][i-1]))
return imin
```
## 前綴+搜尋
前綴和搭配搜尋的題目很多。典型的題目是由前掃到後,一路走一路計算前綴和,並在之前的前綴和中搜尋。
[(題目連結)560. Subarray Sum Equals K (Medium)](https://leetcode.com/problems/subarray-sum-equals-k/description/)
題目是給一個整數數列以及一個整數$k$,要求出有多少subarray其和恰為$k$。
subarray是指陣列中連續的一段區間,所以就是要找區間和,如果前$i$項的和是$psum[i]$,那麼就是要找有多少個$j$,滿足$j<i$且$psum[i]-psum[j-1]==k$。
如果這題陣列元素都是正的,對固定一端的區間和將會是單調上升(或下降),那麼可以用二分搜來找是否有對應區間和為$k$,這種情形用滑動視窗也可以做。這題的陣列數值有正有負,沒有單調性,不過他要找的和是恰為$k$,搜尋除了有順序的二分搜之外,還可以運用字典搜尋。
將$i$由左至右逐步滑行,之前的所有前綴和放在一個字典中,我們在字典中搜尋$psum[i]-k$有幾個,就是以$i$為右端有幾個區間和為$k$。要記錄成員的個數,我們的字典以數值當key,對應欄位給個數。以下的範例程式我們用Counter(),用一般字典也可以。
```python=
class Solution:
def subarraySum(self, nums: List[int], k: int) -> int:
d = Counter() # d[p] = number of prefix sum with value p
d[0] = 1
psum = total = 0
for v in nums:
psum += v # prefix sum
total += d[psum-k]
d[psum] += 1
return total
```
前綴和是逐步算出,且不必另外儲存,第7行計算前綴和,第8行將字典中$psum-k$的個數加進total中,第9行再將自己的前綴和放入字典中,結束這一回合。
小心要先查詢後記錄,因為題目要求子陣列必須非空,若$k=0$時不能搜到自己。
再看一題類似的題目。
[(題目連結)523. Continuous Subarray Sum (Medium)](https://leetcode.com/problems/continuous-subarray-sum/)
這題也是給一個陣列以及一個整數$k$,問是否存在一個長度至少為2的子陣列其和是$k$的倍數。也就是說,是否存在區間$[i,j]$,滿足$(prefix[j]-prefix[i-1])%k==0$,等價於$prefix[j]$與$prefix[i-1]$除以$k$的餘數相同。
因此,我們可以一路掃過去,把之前的前綴和除以$k$的餘數都放入集合中,每次去集合中搜尋是否存在相同的餘數即可。
如果去看一下題目的通過率,會發現相對較低,我的猜想是這題有個陷阱,就是長度必須至少為2,沒注意會誤判。處理的方式不只一種,這裡採取的方式很簡單,就是第一單元中提到的「避免踩到自己」的原則,也就是那個「羊往前走,屎往後拉才不會踩到」的小故事。
下面範例中,我們用原陣列的位置計算前綴和,在第$i$步結束時,我們才將$i-1$的前綴和加入,自然搜尋到的長度至少為2。
```python=
class Solution:
# find same remainder of prefix sum
# avoid searching singleton, starting from nums[1] and insert i-1 at i
def checkSubarraySum(self, nums: List[int], k: int) -> bool:
nums[0] %= k
d = {0} # 0 for null prefix
for i in range(1,len(nums)):
nums[i] = (nums[i]+nums[i-1])%k # prefix sum
if nums[i] in d: return True
d.add(nums[i-1])
return False
```
#### 二維的例子
接下來看一個二維陣列的題目。
[(題目連結)1074. Number of Submatrices That Sum to Target (Hard)](https://leetcode.com/problems/number-of-submatrices-that-sum-to-target/)
本題中,輸入一個$m\times n$矩陣以及一個整數$target$,要找出有多少個子矩陣其和恰為$target$。跟前一題很像,只是一維陣列改成了二維陣列。
既然一維陣列會做,那麼我們可以用類似的方法來解二維陣列的問題。我們嘗試枚舉所有列的區間,對於一組列區間,將這些列合併加總變成一列,然後再用一維陣列的方法來解。在枚舉所有列區間的時候,我們適當的安排順序,可以使得每一個列區間的合併只耗費$O(n)$的時間就可以從前一個列區間得到,而前述一維的方法也是線性的時間複雜度。因為所有列區間的數量是$O(m^2)$,整個時間複雜度就是$O(m^2n)$。
為了應付$m>>n$的情形,下面的範例程式中,我們在$m>n$時先將矩陣轉置(Transpose)。
```python=
class Solution:
#O(m^2*n) m<=n; enumerate i<=j, sum up matrix[i:j] to one row
def numSubmatrixSumTarget(self, matrix: List[List[int]], target: int) -> int:
m,n = len(matrix),len(matrix[0])
if m>n: #transpose
t = [[row[j] for row in matrix] for j in range(n)]
t,matrix = matrix,t
m,n = n,m
total = 0
for i in range(m):
s = [0]*n # the combined row from i to some j
for row in matrix[i:]: # all matrix[i:j]
cnt = Counter({0:1}) # dict for previous prefix sum
prefix = 0 # current prefix sum
for k in range(n): # sweep column by column
s[k] += row[k]
prefix += s[k]
total += cnt[prefix - target]
cnt[prefix] += 1
#end for-i
return total
```
## 組合技範例:單調隊列+前綴和
題目如果搭配了多個技巧,往往就變得比較難。這裡我們示範其中一題,這一題是前綴和搭配了「單調隊列」。考慮到有些人尚未了解單調隊列,我們先拿一題單調隊列的題目來做說明。未來會有另外一個單元專門講單調隊列。
### 單調隊列基本型範例
下面這一題是典型的單調隊列。
[(題目連結)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)$,線性。
### 單調隊列+前綴和
回到本單元主題前綴和,以下這題是搭配前綴和與單調隊列的難題。
[(題目連結)862. Shortest Subarray with Sum at Least K (Hard)](https://leetcode.com/problems/shortest-subarray-with-sum-at-least-k/)
題意很簡單,給一個數列以及一個整數$k$,找出區間和不小於 $k$ 的最短非空區間長度。參數範圍:
* $1 \leq nums.length \leq 10^5$
* $-10^5 \leq nums[i] \leq 10^5$
* $1 \leq k \leq 10^9$
如果陣列數值均為正,我們可以用sliding window去推。但本題陣列數值有正有負,區間和沒有單調性。從區間和與前綴和的關係下手,對於 $i$ 結尾的區間來說,我們要找的是最大的$j<i$,滿足$prefix[i]-prefix[j]\geq k$,也就是說,在掃描到 $i$ 之前的前綴和之中,越小的越好,越晚出現的越好,這就是單調隊列了。
我們逐步掃描陣列,計算前綴和,將單調隊列維護好,因為越小越好,這裡的單調隊列是遞增的。前端何時出界呢?一個左端如果已經找到一組解,就不必再用它了,因為右端是遞增,再用它的解不會更好只會更差(更長)。以下是範例程式。
```python=
class Solution:
# monotonic queue for prefix sum
# previous prefix sum: smaller is better, later is better
def shortestSubarray(self, nums: List[int], k: int) -> int:
n = len(nums)
que = [(0,-1)]; head=0 #[val,idx] monotonic deque
psum = 0 # prefix sum
ans = n+1
for i,s in enumerate(nums):
psum += s
# pop out useless
while head<len(que) and que[-1][0] >= psum:
que.pop()
que.append((psum,i))
# check answer from head
while psum-que[head][0] >= k: # k>=1, nerver empty
ans = min(ans, i-que[head][1])
head += 1 # left become useless after finding partner
#end for
if ans==n+1: return -1
return ans
```
稍微留意,在第14行我們將 $i$ 的前綴和由尾端加入,因為這題的$k\geq 1$,我們不必擔心搜尋到自己,同樣的,第16行的while不必檢查佇列是否已經全空。
## 結語
前綴和是簡單而又很有用的技巧,最常出現的是尋找區間和。常見的有一維與二維的前綴和,事實上更高維也是一樣的道理,只是更煩而已。
前綴和搭配一路掃描一路搜尋是很常見的題型,這時常用到字典,集合或表格來搜尋,也可能搭配二分搜的情形。
前綴和還有些延伸本單元沒談到,例如前綴的不是和,而是prefix maximum/minimum。計算動態prefix sum常用的進階資料結構Binary Indexed Tree,甚至線段樹,也很值得介紹,所謂動態是指陣列資料可能改變的情形。另外有些題目可以運用prefix sum與差分是反運算的觀念來做,這些將來應該都會有另外的單元來討論。