1 問題描述
這是從《編程珠璣(第 2 版)》的第 8 章“算法設計技術”中看到的一個問題。問題的描述是這樣的,
“問題的輸入是具有 n 個浮點數的向量 x,輸出是輸入向量的任何連續子向量中的最大和。例如,如果輸入向量包含下面 10個元素:(31,-41,59,26,-53,58,97,-93,-23,84) 那么該程序的輸出為x[2...6] 的總和,即 187。”
當所有的數都是正數時,問題很容易解決,此時最大的子向量就是輸入向量本身。但如果輸入向量中含有負數時就不好處理了。另外,為了使問題的定義更加完整,我們認為當所有的輸入都是負數時,總和最大的子向量為空向量,總和為 0。
2 問題分析
2.1 最簡單直接算法
看到問題,想到的最簡單直接的算法就是雙層嵌套循環遍歷所有的連續子向量,記錄下遇到過的最大和,並持續更新。該算法的偽代碼為,
1 maxSum = 0; 2 for i = 0 -> n-1 3 subVecSum = 0; 4 for j = i+1 -> n-1 5 subVecSum = subVecSum + x[j]; 6 if (subVecSum > maxSum) then 7 maxSum = subVecSum; 8 endif 9 endfor 10 endfor
這是一種簡單粗暴的算法,算法復雜度為 O(n2 ),效率太低了。
如何進行改進呢?在上述的雙層循環中,除了含有輸入向量首元素的連續子向量只被掃描 1 次之外,其他連續子向量都至少被掃描過 2 次。例如,子向量 (-41,59) 被掃描了兩次,、,在掃描子向量 (31,-41,59) 處理過一次(未特殊處理,也沒有記錄),在掃描子向量 (-41,59) 本身時又被處理了一次。實際上,連續子向量被處理的次數跟該子向量的首元素所在輸入向量中的位置有關,如果連續子向量首元素在輸入向量的第 m 位(從 1 開始),則該子向量被處理過 m 次,其中 m-1 次沒有進行記錄。通過上面分析,我們發現這種簡單算法的大部分效率消耗在了連續子向量的重復處理上。那么,我們進行算法優化的思路就是如何減少連續子向量的重復處理?
2.2 掃描算法
我們用 sum(i . . . j) 來表示連續子向量 x[i . . . j] 的總和。我們假設連續子向量 x[m . . . k] 的總和是最大,即
sum(m − 1 . . . k) ≤ sum(m . . . k) ≤ sum(m . . . k + 1)
也就是說,
sum(h . . . k) < sum(m . . . k) ∀ h ∈ [m, k) (1)
sum(l . . . k) ≤ sum(m . . . k) ∀ l ∈ [0, m) (2)
對於表達式 (1),我們得到下面的推導:
0 + sum(h . . . k) < sum(m . . . h) + sum(h . . . k)
表達式兩邊減去 sum(h . . . k),得到
0 < sum(m . . . h)
於是,我們得到結論 1:如果某個連續子向量的和大於零,則以該子向量為前綴的連續子向量的和可能會更大。
對於表達式 (2),我們得到下面的推導:
sum(l . . . m − 1) + sum(m . . . k) ≤ 0 + sum(m . . . k)
表達式兩邊減去 sum(m . . . k),得到
sum(l . . . m − 1) ≤ 0
於是,我們得到結論 2:如果某個連續子向量的和小於或等於零,則以該子向量為前綴的連續子向量的和不可能大於去掉該子向量前綴之后的子向量的和。
根據結論 1 和結論 2,我們就可以得到下面這個只需要掃描一遍輸入向量 x 的算法。
1 maxVecBegin = maxVecEnd = -1; 2 maxSum = 0; 3 cursorVecBegin = cursorVecEnd = 0; 4 cursorVecSum = 0; 5 while (cursorVecEnd < n) 6 do 7 cursorVecSum = cursorVecSum + x[cursorVecEnd]; 8 if (cursorVecSum > maxSum) then 9 maxSum = cursorVecSum; 10 maxVecBegin = cursorVecBegin; 11 maxVecEnd = cursorVecEnd; 12 else if (cursorVecSum <= 0) then 13 cursorVecSum = 0; 14 cursorVecBegin = cursorVecEnd + 1; 15 endif 16 cursorVecEnd = cursorVecEnd + 1; 17 endwhile
以 maxSum 記錄連續子向量的最大和,maxVecBegin 和 maxVecEnd 記錄和最大的連續子向量開始位置和結束位置。另外有個游標向量用於掃描輸入向量,游標向量的結束位置從輸入向量的首元素移動到末尾元素,而游標向量的開始位置則隨着掃描情況可能需要進行向后調整。
每將游標向量的結束位置 cursorVecEnd 向后移動一位時,做以下處理:
1. 計算游標向量的和 cursorVecSum;
2. 根據游標向量的和 cursorVecSum,做以下處理,
• 游標向量總和 cursorVecSum 大於最大和 maxVecSum,更新最大和 maxVecSum 為游標向量的和 cursorVecSum,並更新最大和連續子向量為游標向量(即 maxVecBegin=cursorVecBegin和 maxVecEnd=cursorVecEnd);
• 或者,游標向量的和小於或等於零,拋棄該游標向量,將游標向量開始位置調整當前掃描位置之后,即 cursorVecBegin=cursorVecEnd+1(根據結論 2);
• 否則保留當前游標向量,繼續將游標向量結束位置后移一位(根據結論 1)。
我們以問題描述中給的輸入向量用例
(31,-41,59,26,-53,58,97,-93,-23,84)
來 對 上 述 算 法 進 行 驗 證。 表 格 (1) 根 據 游 標 向 量 結 束 位 置 cursorVecEnd 來列表各輪循環中 cursorVecSum的值,以及該輪循環之后cursorVecBegin、cursorVecSum、maxVecBegin、maxVecEnd和 maxVecSum 的值。
循環中 | 循環后 | |||||
cursorVecEnd | cursorVecSum | cursorVecBegin | cursorVecSum | maxVecBegin | maxVecEnd | maxVecSum |
0 | 31 | 0 | 31 | 0 | 0 | 31 |
1 | -10 | 2 | 0 | 0 | 0 | 31 |
2 | 59 | 2 | 59 | 2 | 2 | 59 |
3 | 85 | 2 | 85 | 2 | 3 | 85 |
4 | 32 | 2 | 32 | 2 | 3 | 85 |
5 | 90 | 2 | 90 | 2 | 5 | 90 |
6 | 187 | 2 | 187 | 2 | 6 | 187 |
7 | 94 | 2 | 94 | 2 | 6 | 187 |
8 | 71 | 2 | 71 | 2 | 6 | 187 |
9 | 155 | 2 | 155 | 2 | 6 | 187 |
根據最后的結果得出,和最大的連續子向量即為 x[2 . . . 6],總和為187。
該算法只掃描了輸入向量一遍,則該算法的復雜度為 O(n)。這是一個線性算法,已經是最優的了。
3 問題來歷
該問題出現在布朗大學的 Ulf Grenander 所面對的一個模式識別問題中,問題的最初形式為下面的二維形式。
“給定 n × n 的實數數組,求出矩形子數組的最大總和。”
在二維形式的問題中,最大總和子數組是數字圖像中某種特定模式的最大似然估計量。因為二維問題的求解需要太多時間,所以 Grenander 將它簡化為一維問題,以深入了解其結構。
最近看到這個問題問題才想起來,兩年前畢業找工作的時候,就被一個面試官問到過這個二維問題。那時胡亂講了一通,最后面試當然就悲劇了。后面找個空閑時間再好好思考一下吧。