神奇的演算法 - Subarray Sum
Subarray
Definition
subarray 為一個 array 的連續子集合
subarray 不可為空,subarray sum 則為這個子陣列的和
Properties
subarray 有一個特性,給定長度為 n 的 array
subarray 的總數為 1 + 2 + 3 + ... + n
舉例來說
1
2
3
4
5
6
7
假設 array = 1234
subarray 會是
1234, 234, 34, 4 (index 3, value 4)
123, 23, 3 (index 2, value 3)
12, 2 (index 1, value 2)
1 (index 0, value 1)
可以看到數量是 1 + 2 + 3 + 4 = 10
這個特性剛好對到 array 的 index
也就是說,當我在 index 3(value 4) 的時候,我可以知道前面總共有 1 + 2 + 3 + 4 = 10 個 subarray
針對 index 3(value 4) 自己來說,他有 index + 1(也就是 4) 個 subarray
LeetCode 560. Subarray Sum Equals K
Brute Force Approach
最直觀的作法當然是每個 subarray 都檢查一遍,確認該 subarray 的總和是否為 k 即可
pseudo code 為以下
for i from 0 to length(array)
for j from i + 1 to length(array)
if sum(array[i] to array[j]) equals k
answer += 1
這樣的作法是使用 2 層 for-loop 下去針對每一個可能的 subarray 進行檢查
雖然非常的直覺,但他的時間複雜度可以看到是屬於 $O(n^2)$
對於非常大的陣列來說,它很容易就會 TLE(Time Limit Exceeded)
所以很明顯的,這個作法有待改進
Cumulative Table

上圖為身高的累進圖表,可以看到分別紀錄了各個身高所對應的 累進比例
比方說,從 140 cm 到 160 cm 的人數佔了所有人的 62.5 %
Cumulative Sum of Array
同樣的概念可以套用到 array 上面
假設有一個 array [5, 4, -1, 7, 8]
那麼他的 cumulative sum of array 就會是
| index | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| array | 5 | 4 | -1 | 7 | 8 |
| cumulative | 5 | 9 | 8 | 15 | 23 |
累進的規則是
1
2
3
4
cumulative[0] = array[0]
cumulative[1] = array[0] + array[1]
cumulative[2] = array[0] + array[1] + array[2]
cumulative[3] = array[0] + array[1] + array[2] + array[3]
依此類推
How to Get Subarray Sum from Cumulative Sum Array
假設我要找的 subarray sum 是 8
以肉眼觀察可以找到 2 組解答,分別為 [5, 4, -1] 以及 [8]
那要怎麼利用 cumulative sum array 來快速的找到呢?
根據上述的簡單累進推導規則,我們可以簡單的發現以下規則
1
2
3
array[1] = cumulative[1] - cumulative[0]
array[1] + array[2] = cumulative[2] - cumulative[0]
array[1] + array[2] + array[3] = cumulative[3] - cumulative[0]
也就是說,要取得 array[i] ~ array[j] 的總和
$array[i] + array[i + 1] + … + array[j] = cumulative[j] - cumulative[i - 1]$
回到原本的例子
| index | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| array | 5 | 4 | -1 | 7 | 8 |
| cumulative | 5 | 9 | 8 | 15 | 23 |
[5, 4, -1] 的 sum 用 cumulative 寫出來就會是 cumulative[2] - cumulative[-1]
我的習慣會是建立 cumulative sum array 的時候在前面多塞一個數值為 0 的(上面的 cumulative[-1] 就會等於 0)
How Cumulative Sum Array Helps Speedup?
看到這你不難發現,使用 cumulative sum array 可以取得 任意區間 的 subarray sum(透過兩個 cumulative 的數值相減即可得到區間和)
亦即只要把 cumulative sum array 建立起來,你就不需要用 2 層 for-loop 暴力的窮舉出所有可能了
Cumulative Sum Approach
| index | -1 | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|---|
| array | 0 | 5 | 4 | -1 | 7 | 8 |
| cumulative | 0 | 5 | 9 | 8 | 15 | 23 |
你可能會想,即使建立完 cumulative sum array,我不還是得用 2 層 for-loop 慢慢看區間和是否等於 k 嗎?
其實我們可以一邊建立一邊檢查區間和是否等於 k
前面提到,要取得 array[i] + ... + array[j] 可以使用 cumulative[j] - cumulative[i - 1] 取得
題目的要求是,區間和要等於 k
透過簡單的算式
1
2
3
4
5
因為
array[i] + ... + array[j] = k
array[i] + ... + array[j] = cumulative[j] - cumulative[i - 1]
所以
cumulative[j] - cumulative[i - 1] = k
可以得知,目標為 cumulative[j] - cumulative[i - 1] = k, where i < j
假設你建立 cumulative sum array 到一半,它應該會長成這樣
| index | -1 | 0 | 1 | 2 | x | x |
|---|---|---|---|---|---|---|
| array | 0 | 5 | 4 | -1 | x | x |
| cumulative | 0 | 5 | 9 | 8 | x | x |
有了這個半完成的 cumulative sum array
你有辦法算出所有區間內的和,包含 [0], [1], [2], [0,1], [0,1,2], [1,2] 每個的區間和
既然我們的目標是區間和要等於 k
把目標稍微改寫能得到 cumulative[j] - k = cumulative[i - 1]
k 已經有了,題目給的
在建立 cumulative[j] 的時候,cumulative[i - 1] 已經有了(因為 i < j)
所以! 我只要往前看,找看看有沒有誰的區間和等於 cumulative[j] - k 就可以了!
依照現在這個例子,cumulative sum array 建立到 index 2
我的目標 k 是 8,我只要找有 哪個先前 cumulative 的數值等於 cumulative[2] - k(也就是 8 - 8), 就代表該區間的和等於 k
為了使得尋找 cumulative[2] - k 有沒有存在於先前的區間和裡面,使用 hashmap 加速是一個可靠的選擇
講了那麼多,直接上 code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func subarraySum(nums []int, k int) int {
prefixSum := make([]int, 0)
prefixMap := make(map[int]int, 0)
prefixMap[0] = 1
previousPrefixSum := 0
answer := 0
for i, num := range nums {
prefixSum = append(prefixSum, previousPrefixSum + num)
if _, exists := prefixMap[prefixSum[i] - k]; exists {
answer += prefixMap[prefixSum[i] - k]
}
previousPrefixSum = prefixSum[i]
value, exists := prefixMap[prefixSum[i]]
if !exists {
prefixMap[prefixSum[i]] = 1
} else {
prefixMap[prefixSum[i]] = value + 1
}
}
return answer
}
Why do we Need to Store Occurrence of cumulative[i] in Map
前面提到,為了加速尋找 cumulative[j] - k 能夠跑得更快,因此實作當中使用了 map 的結構
但是為什麼要紀錄 cumulative[j] - k 出現了幾次呢?
只要紀錄他有出現過不就好了嗎?
再看另一個例子
| index | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|---|
| array | 0 | 3 | 4 | 7 | 2 | -3 | 1 | 4 | 2 | 1 |
| cumulative | 0 | 3 | 7 | 14 | 16 | 13 | 14 | 18 | 20 | 21 |
你可以看到 cumulative 陣列裡面出現了 2 次 14
假設你的條件剛好是 cumulative[j] - k = 14
如果你沒有紀錄出現的次數,在之後的答案當中你會少算了 1 次(以這個例子來說)
Do we Need to Consider -k Situation
我在嘗試理解這個算法的時候,一個問題油然而生
我到底需不需要考慮 -k 的情況?
還記得我們運算的陣列是 累進(累加) 的嗎?
意思就是說,如果考慮到 -k 的情況,你對回去原本的陣列看,他的 subarray sum 也會是 -k
所以只需要考慮 k 的情況就好了
LeetCode 53. Maximum Subarray
subarray 是由一個以上的連續元素所組成的,而 subarray sum 就是所有區間數值加總起來
要找到 maximum subarray sum 最直覺的方法就是窮舉出所有區間組合
但是這樣太複雜了,我們可以試著簡化問題
看一個實際例子比較快
如果之前的區間最大和(0 ~ n-1)是 10
num[n] = 2, 因為10 + 2 > 10, 所以目前 maximum subarray sum 就是10 + 2(num[0 ~ n])num[n] = -1, 因為10 + (-1) < 10,所以目前 maximum subarray sum 就是10 + (-1)(num[0 ~ n])num[n] = 20, 因為20 > 10, 所以目前 maximum subarray sum 就是20(num[n])
注意第二點,為什麼最大區間和不是 10 而是 9?
因為我們要考慮 “連續的情況”
如果你把它寫成 10, 那麼最大區間和中間就會有空格,就不符合 subarray 的定義了
我們可以把上述的情況匯總成以下規則
- 如果
num[n]小於num[0 ~ n], 那 maximum subarray sum 就是num[0 ~ n] - 如果
num[n]大於num[0 ~ n], 那 maximum subarray sum 就是num[n]
所以目前最大區間和,取決於 之前的最大區間和
這就是動態規劃(dynamic programming)
因為要我算出最大區間和實在是太困難了,當我知道之前的最大區間和(n-1),在加上目前的數字,我可以很輕易的判斷現在的區間最大和為多少(n)
所以實作就很簡單了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func maxSubArray(nums []int) int {
size := len(nums)
dp := make([]int, size)
maxSubSum := nums[0]
dp[0] = nums[0]
for i := 1; i < size; i++ {
dp[i] = max(nums[i], nums[i] + dp[i - 1])
maxSubSum = max(maxSubSum, dp[i])
}
return maxSubSum
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
另一種寫法一樣是使用累進的概念
定義一個 cumulative array
它負責的就是紀錄累進數字
區間和的寫法可以寫成 cumulative[j] - cumulative[i - 1]
所以換句話說,當前累進 - 最小的累進就是最大的區間和
只不過你還要跟 nums[i] 比較,因為有可能 nums[i] 比之前的區間和還大
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func maxSubArray(nums []int) int {
size := len(nums)
cumulative := make([]int, size + 1)
cumulative[0] = 0
result := nums[0]
cumulativeMin := cumulative[0]
for i := 1; i <= size; i++ {
cumulative[i] = cumulative[i - 1] + nums[i - 1]
result = max(nums[i - 1], max(result, cumulative[i] - cumulativeMin))
cumulativeMin = min(cumulativeMin, cumulative[i])
}
return result
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
LeetCode 3147. Taking Maximum Energy From the Mystic Dungeon
另一種變形是這題,給定一個 array 以及一個間距 k
你可以從任一 index 開始,每次都走 K 步直到最後,要求求出你能夠拿到的最大值
你可能會想這跟 LeetCode 53. Maximum Subarray 很像
但差別在於本題是沒辦法 pick/skip 的
所以針對 [-8,10,-10] 以及 K = 1 這個 test case 來說答案會是 0 而不是 10
因為你不能略過任何一個數值
所以 Kadane’s Algorithm 並不適用於本題
但也還好對吧? 就是稍微暴力的寫法而已
計算從每個 index 開始,走 K 步的最大值
不過效率有點低 而且會重複計算到
所以其實可以從最後面往前算
1
2
3
4
5
6
7
8
9
10
11
12
13
func maximumEnergy(energy []int, k int) int {
n := len(energy)
ans := -1 << 31
for i := n - k; i < n; i++ {
sum := 0
for j := i; j >= 0; j -= k {
sum += energy[j]
ans = max(ans, sum)
}
}
return ans
}
原本是從 0 算到 n - k
因為你只能移動 K 步,所以最多只有到 n - k
那反過來就是從 n - k 算到 n
inner loop 其實滿有意思的
為什麼每一次計算都可以拿來更新答案?
他是往前算得對吧,你要 i 把它當成是 終點
所以每一次的計算其實都是完整的區間(起點可以任意,但終點都是沒辦法走下去的那個點,而它是由 i 決定的)
所以全部遍歷之後就可以得到最大值了
Leave a comment