前言:
第一次接觸最大連續子數列和問題是在2008年的夏天,那是在Mark Allen Weiss的data structures and problem solving using C++(數據結構與問題求解(C++版))裏看到的。那時由於迷茫,在遇到問題的時候往往毫無頭緒,最後只好去網上看一些別人的算法,看了好像也不能理解這個算法爲什麼這麼好,更想知道他們到底是怎麼想出來的… 這麼多問題糾結在心頭,無法解決,也就是從那時開始,開始看一些算法、數據結構的書,漸漸地開始關注數學思想、科學哲學領域的書,也看了網絡上劉未鵬的blog,漸漸地開始理清思路…
最大連續子數列和問題就是這一段旅程的起點,就着這一個小問題,我第一次看清了一個問題是怎樣從最原始的解法去考慮,慢慢地優化,慢慢地磨,磨到一個遠優於原始解法的程度的。這裏我看到的是第一步的重要性,跟人生一樣,雖然知道第一步很可能而且在很多情況下不是最優的,但是還是要踏下去,踏下去才知道他到底是哪兒不好,應該往什麼方向努力,而不是停留在“這個算法可能時間性能不行”的定性的猜測上。在小的問題上多踏實地做做,多走走彎路,才能保證以後在大的問題的決策上你有足夠的經驗和數據去一步做到較優。
從這個小問題上,以及我後來一年中解決問題的經歷中,我都看到了分而治之作爲通用的方法論的巨大價值。無論是GOOGLE提出的map-reduce,亦或是所謂的分佈式計算、消息傳遞等等新名詞,其本質還是三個單詞divide and conquer。其實計算機世界遠比人類世界來得簡單,不是嗎?
好了,我們來看看這道題吧!
問題重述:
給定(可能是負的)整數A1、A2、…、AN,求出(並確定對應的序列) 的最大值。如果所有的整數都是負數,那麼最大連續子數列和就是0。
問題分析與求解:
這道題,據Udi Menber在Introduction to Algorithms: a creative approach中的說法,是來自於Bently的名著programming pearls(編程珠璣)。
在討論這個問題的算法之前,先搞清楚一個問題,那就是:爲什麼在所有輸入都是負數的情況下,最大連續子數列和是0,而不是返回輸入中的最大負整數(即絕對值最小的整數)。原因是由整數零組成的空的子數列也是一個子數列,並且其和就是0。該結果與空集是任意集合的子集類似。注意空的情況總是可能出現的,並且在許多例子中它根本不是特殊情況。
最大連續子數列和問題令人感興趣,主要是因爲有如此多的算法用於解決這個問題,並且這些算法的性能相差很大。
好的!廢話不多說,我們開始吧:
算法1.暴力搜索
首先想到的當然就是“刀耕火種”的暴力搜索(brute force)算法啦。我把所有的情況都遍歷一遍自然見分曉了。
暴力搜索在解決小規模問題應該還是可以的,但是一旦問題的規模變大,問題就來了,我們看到這個程序有兩重循環,也就是說它的複雜度是平方級別的,隨着問題規模的增長,算法的複雜度是平方增長的,這自然不是我們願意看到的。暴力搜索對解空間的全部元素進行了遍歷,我們可不可以想一些辦法,發覺這個問題的一些特徵,縮小解空間的遍歷範圍呢?(在解線性規劃的問題時,我們就曾經成功地利用了線性約束的凸集性質,把解的候選集縮小爲約束區域的邊界。)
所幸的是,我們找到了這個性質。
算法二 線性算法
我們先來看一下一個似乎顯而易見的結論。
結論一:
如果數列A的某個子列 的和,則肯定不是數列A的最大遞增子列。
這個結論是顯而易見的。說到“顯而易見”這個詞,想起一段軼事。我本科四年,在圖書館看書,總結出一個規律:“凡書中說某個結論顯而易見,大多數並不顯而易見,而且這個結論反而會讓人百思不得其解”,後來跟朋友一討論,發現竟有不少有相同的發現…爲了表明我們的結論確實顯而易見,用一個式子稍微說明一下:
這個結論告訴我們,i位置開始的子列,一旦遇到和爲0的子列,後面可以不要搜索了,直接從i+1位置開始的子列搜索吧。且慢!只是這樣嗎?且看下一個結論:
結論二:
如果是數列A以i起始的子列中第一個和的,則對任意,的和要麼小於最大連續子列和,要麼與現存的最大連續子列和相等。
這個結論說穿了,就是跳過i到j這一段不會影響結果。
下面來解釋一下這個結論:
從程序可以看出,這個算法是級別的。相對於暴力算法,提高的是一個數量級的速度。怎麼說?也就是暴力算法做一次的時間夠它做n次了,這裏n是問題的規模。
算法三 分治算法
這個問題到了算法二其實已經算是得到了比較圓滿的解決。但是,我們還可以再想想,我們在解決很多問題的時候如:排序、順序統計量的查找、FFT等平方級的算法時,都用了一個方法去解決,那就是“分治算法”。分治算法的神奇功效在於:把一個複雜的問題分解成一個個相似的簡單的問題,解決這些簡單的問題的適當組合來解決複雜的問題。這個算法,我覺得,在以後會越來越得到重視。因爲目前CPU的主頻在達到3.X GHz後就由於量子效應進展緩慢了,取而代之的是多核計算機的興起。放眼望去,剛出來的計算機很多都是多核的,多核的目的在於發揮羣體效應,期望達到人多勢衆的目的。這一目的的達到,需要實現算法的並行化,也就是儘量把解決問題的算法分解成幾個子問題,讓各個核分別同時去算,然後彙總結果。所謂並行即分治也!王能超在《算法演化論》中提到:“算法設計的基本理念是,通過簡單的重複生成複雜,或者說,將複雜劃歸爲簡單的重複。”結合現在的趨勢,我深以爲然。
那麼,我們現在嘗試看看把問題分解分解吧。
分解有好幾種,下面只分析我們用到的兩種:
1. 規模分解。把問題的操作數據集分爲兩個部分,對兩個子數據集分別操作,最後進行歸併。這是FFT的做法。
2. 遞推式分解。這方面的典型就是Fibonacci數列,其實它就是數學歸納法,假設我解決了規模爲n-1的問題,那麼我們需要再做點什麼就能解決規模爲n的問題呢?
下面,我們分別討論這兩種分解。
3.1規模分解
下面,同樣給出源代碼。(爲了在一頁內顯示,採用了緊縮排版)
template <class Comparable>
Comparable maxSubsequenceSum_dac1(const vector<Comparable> &a, int left, int right,
int &seqStart, int &seqEnd)
{
int n = a.size();
Comparable maxSum = 0;
Comparable maxLeftBorderSum = 0, maxRightBorderSum = 0;
Comparable leftBorderSum = 0, rightBorderSum = 0;
BoundError e;
Comparable maxVal = 0;
try{
if (n <= right || left < 0 )
{ throw(BoundError(n,left,right)); }
if (n == 0) {return 0;}
if (left == right){
seqStart = seqEnd = left;
return a[left] > 0 ? a[left] : 0; }
int center = ((left + right) >> 1);
int lseqStart,lseqEnd,rseqStart,rseqEnd,lStart,rEnd;
Comparable maxLeftSum = maxSubsequenceSum_dac1(a,left,center,lseqStart, lseqEnd);
Comparable maxRightSum = maxSubsequenceSum_dac1(a,center+1,right,rseqStart, rseqEnd);
for (int i = center; i >= left; i--){
leftBorderSum += a[i];
if (leftBorderSum >= maxLeftBorderSum)
{ maxLeftBorderSum = leftBorderSum;
lStart = i;
}}
for (int j = center + 1; j <= right; j++){
rightBorderSum += a[j];
if (rightBorderSum >= maxRightBorderSum)
{ maxRightBorderSum = rightBorderSum;
rEnd = j;}}
if (maxLeftSum > maxLeftBorderSum + maxRightBorderSum){
maxVal = maxLeftSum;
seqStart = lseqStart,seqEnd = lseqEnd;}
else{ maxVal = maxLeftBorderSum + maxRightBorderSum;
seqStart = lStart,seqEnd = rEnd;}
if (maxVal < maxRightSum){
maxVal = maxRightSum;
seqStart = rseqStart,seqEnd = rseqEnd;}
}
catch(BoundError e){e.dispErr();}
return maxVal;}
代碼提供了一個異常處理類,定義如下:
這個代碼代碼量看上去比上面大許多,時間複雜度也比不上線性算法,但是並不是每個問題都有那麼幸運能偶找到一個線性算法的,這時候的算法帶來的提升已經很可觀了,這就是爲什麼Turkey的FFT被評爲20世紀是十大算法的原因。另外在工程上,任何提升都是有代價的,這裏代碼量的增加就是平方時間複雜度的問題提升到對數時間複雜度所需要的代價。“沒有免費的午餐”是永恆的真理。
3.2遞推式分解
遞推式分解其實就是數學歸納法的生動體現,我們學過辯證法,知道歸納和推理是真理的兩大來源,而歸納由於其所需要的感性知識比推理遠來得多,顯得尤爲難得從而產生的成果也更爲可貴。
現在的問題是:如果現在我知道了規模爲n-1的數列的最大遞增子列的結果,現在在這個子列後面又添了一個元素,現在如何根據現有的結果更新新的數列的結果。
其實,可能有人已經看出來了,遞推分解就是前面規模分解的一種極限形式,把一個問題分解爲n-1和1兩個部分。同樣地,也有三種情形:
Case 1: 最大遞增子列全部在n-1的數列中;
Case 2: 就是最後一個數;
Case 3: 是n-1的數列的後綴和最後一個數的組合。
現在我們知道了,我們只要知道n-1的數列的最大遞增子列以及最大後綴子列就行了。
下面我們就可以着手實現了。我們看一下下面的代碼就知道,數學歸納是遞推,而規模分解是遞歸,雖然本質思想都是一樣的divide and conquer,但是正如我之前的Fibonacci數列的文章中分析的一樣,其實現代價和算法效率是不一樣的,顯然這個算法的效率是。
性能測試:
下面對上述四種算法進行了性能測試。
測試環境:
操作系統:windows xp
CPU : Intel(R) T2080 1.73GHz
內存 :1.73GHz,504MB
結果:
後記:
這篇文章的空文檔在我的電腦裏躺了一年,始終只有一個題目,電腦裏有很多這樣的文檔。今天花一天整理了一個問題,也終於開始了重新撿起地上丟下的棒子的旅程。這些問題都是一些小問題,但是卻都是“麻雀雖小,五臟俱全”。下一站—LZW算法。
參考文獻:
1. Mark Allen Weiss 數據結構與問題求解(C++版) 清華大學出版社
2. Udi Manber 算法引論-一種創造性方法 電子工業出版社
3. Walter Savitch 完美C++教程 清華大學出版社
附註:
由於本文的代碼使用了函數模板,因此如果是C++的初學者要使用本文的函數請注意:
許多編譯器不支持對模板的單獨編譯,因此你必須在代碼中使用模板的地方包含模板的定義。通常情況下,至少要保證模板函數的聲明要早於函數的模板的使用。
…
能保證在絕大多數的C++的編譯器上對模板程序編譯成功地佈局如下:將模板的定義與使用模板的函數放在一個文件中,並且保證模板的定義出現在所有使用模板的代碼之前。如果你想將函數模板的定義放在一個與你的應用程序不同的單獨的文件中,可以使用#includes命令在需要使用該模板的文件中將函數模板的定義包含進來。
———— 摘自《完美C++教程》
以上所有程序均編譯運行正常。如出現模板函數的相關問題,請請教高手,因爲我也是第一次使用模板函數,所以也不懂,呵呵。