昨天晚上在宿舍看Mark Allen Weiss老爺子的《數據結構與算法分析Java語言描述》的這本書,看到第二章的時候舉了個例子來討論,就是關於最大子序列和的算法分析。一共提了四個算法,首先當你看見第一個算法的時候覺得這個算法不錯,可以實現,再接着當你看到后面連着的三個例子的時候這才明白算法一步步的優化對於整個性能的提升,有時候我們不是想不出來好的算法,只是當第一種算法出來得時候我們的思維已經被局限化了,覺得是對的就以為是唯一解了,實感唏噓! 早上來到實驗室特地找了一些資料,貼在這里,以后多學習多看看好的算法!
Maximum Continuous Subsequence Sum
最大連續子序列求和詳解
1. 問題描述
輸入一個整數序列(浮點數序列也適合本處講的算法),求出其中連續子序列求和的最大值。
2. 算法分析
2.1. 算法一
2.1.1. 算法描述
遍歷所有子序列並求和,比較得出其中的最大值。
2.1.2. 代碼描述
1 public static int maxSubSumCubic(int[] array) { 2 int maxSum = 0; //最大子序列求和 3 //start表示要求和的子序列的開始索引,end表示結束索引 4 for(int start = 0; start < array.length; start++) { 5 for(int end = start; end < array.length; end++) { 6 int thisSum = 0; //當前子序列求和 7 //求出array[start]~array[end]子序列的和 8 for(int index = start; index <= end; index++) { 9 thisSum += array[index]; 10 } 11 //判斷是否大於之前得到的最大子序列求和 12 if(thisSum > maxSum) { 13 maxSum = thisSum; 14 } 15 } 16 } 17 return maxSum; 18 }
2.1.3. 算法分析
設輸入序列長度為N,算法一有三個循環嵌套,第4行的循環長度為N。第5行的循環長度為N-start+1,因為我們考慮的是最差性能,所以可取為最大的N。第8行的循環長度是end-start+1,同理可取為N。所以可得算法的運行時間是O(N*N*N)=O(N^3 ),即算法的運行時間是以輸入長度的立方增長的。可想而知,一旦輸入長度變大,算法的運行效率將慢得無法接受,這也從反面說明了算法設計的重要性。
2.2. 算法二
2.2.1. 算法描述
算法設計的一個重要原則就是“不要重復做事”。在算法一中,對array[start]~array[end]子序列求和,可以由上一次求和array[start]~array[end-1]的結果加上array[end]得到,而不用從頭開始計算。
2.2.2. 代碼描述
1 public static int maxSubSumQuadratic(int[] array) { 2 int maxSum = 0; //最大子序列求和 3 //start表示要求和的子序列的開始索引,end表示結束索引 4 for(int start = 0; start < array.length; start++) { 5 int thisSum = 0; //當前子序列求和 6 for(int end = start; end < array.length; end++) { 7 //已求得的array[start]~array[end-1]子序列的和加上array[end] 8 //得到array[start]~array[end]子序列的和 9 thisSum += array[end]; 10 //判斷是否大於之前得到的最大子序列求和 11 if(thisSum > maxSum) { 12 maxSum = thisSum; 13 } 14 } 15 } 16 return maxSum; 17 }
2.2.3. 算法分析
算法二比算法一少了一個循環,同之前的分析一樣,容易得到該算法的運行時間為O(N^2),算法的運行時間是以輸入長度的立方增長的。
2.3. 算法三
2.3.1. 算法描述
考慮把輸入序列從中間分成兩半,那么最大和子序列的位置存在三種情況:1、最大和子序列完全在輸入序列的左半部分;2、最大和子序列完全在輸入序列的右半部分;3、最大和子序列跨越左右兩部分。
所以,為了得到輸入序列的最大子序列和,我們可以分別求出左半部分的最大子序列和、右半部分的最大子序列和、以及跨越左右兩部分的最大子序列和,比較三者得出最大者就是要求的。
求左半部分的最大子序列和,可把左半部分作為新的輸入序列通過該算法遞歸求出。右半部分的最大子序列和也同理。
接下來就是求解跨越左右兩部分的最大子序列和,也就是求出左半部分中包含最右邊元素(如圖中的12)的子序列的最大和,和右半部分中包含最左邊(如圖中的6)的子序列的最大和,將兩者相加即為跨越左右兩個部分的最大子序列和。
另外還有一個需要說明的就是,對於有奇數個元素的數組,那么左右兩半部分並不是平分的,但這其實不是問題。上面的算法並不要求是兩半部分,分成任意兩部分都可以。
2.3.2. 代碼描述
1 public static int maxSubSumRec(int[] array, int left, int right) { 2 //遞歸的基准情況:待處理序列只有一個元素 3 if(left == right) { 4 //空集也算是子序列,空集和為0,所以最大子序列和最小為0 5 if(array[left] > 0) 6 return array[left]; 7 else 8 return 0; 9 } 10 11 //遞歸求出左半部分和右半部分的最大子序列和 12 int center = (left + right) / 2; 13 int maxLeftSum = maxSubSumRec(array, left, center); 14 int maxRightSum = maxSubSumRec(array, center + 1, right); 15 16 //求出左半部分中包含最右邊元素的子序列的最大和 17 int maxLeftBorderSum = 0, leftBorderSum = 0; 18 for(int i = center; i >= left; i--) { 19 leftBorderSum += array[i]; 20 if(leftBorderSum > maxLeftBorderSum) { 21 maxLeftBorderSum = leftBorderSum; 22 } 23 } 24 25 //求出右半部分中包含最左邊元素的子序列的最大和 26 int maxRightBorderSum = 0, rightBorderSum = 0; 27 for(int i = center + 1; i <= right; i++) { 28 rightBorderSum += array[i]; 29 if(rightBorderSum > maxRightBorderSum) { 30 maxRightBorderSum = rightBorderSum; 31 } 32 } 33 34 //跨越兩個部分的最大子序列和 35 int maxLeftRightSum = maxLeftBorderSum + maxRightBorderSum; 36 37 //maxLeftSum、maxRightSum、maxLeftRightSum中的最大值即為最大子序列和 38 int maxSubSum = 0; 39 maxSubSum = maxLeftSum > maxRightSum ? maxLeftSum: maxRightSum; 40 maxSubSum = maxSubSum > maxLeftRightSum ? maxSubSum: maxLeftRightSum; 41 42 return maxSubSum; 43 }
2.3.3. 算法分析
設T(N)表示輸入序列長度為N時的運行時間,若N=1,即只有一個元素,那么left==right,所以有T(1)=1。對於N>1,程序需運行兩個遞歸調用和兩個for循環。其中每個遞歸調用的運行時間相當於輸入長度為N/2時算法的運行時間T(N/2),兩個遞歸則為2T(N/2)。每個for循環的運行次數為N/2,循環體中的語句運行時間是常數,所以兩個for循環的運行時間為O(N/2*2)=O(N)。所以對於N>1,有T(N)=2T(N/2)+O(N),為了方便,可用N代替O(N),數量級不變,所以T(N)=2T(N/2)+N。在Weiss的《數據結構與算法分析Java語言描述(第二版)》中,作者通過觀察的方式得出T(N)=N*(K+1),其中N=2^k ,所以有T(N)=N*(k+1)=NlogN +N=O(N )。
這里筆者自己嘗試推導出這個結果,如有疏誤或更好的方法,請不吝指教。同樣,假設N為2的K次方,如果不是2的K次方,直接推導可能會比較復雜。當然我們可以這么理解,如果N不是2的K次方,可以通過在輸入序列開頭加入n個0,使N=N+n變成2的K次方。因為增加了輸入的長度,所以運行時間比原本的要長,因為算法分析是最差時的性能,所以能用變長了的運行時間來代替原來的運行時間。所以問題同樣轉化成N為2的K次方時,T(N)的表示式怎么求。好了,廢話不多說,直接推導。
由T(N)=2T(N/2)+N兩邊同除以N,得
(T(N))/N=(T(N/2))/(N/2)+1 => (T(N))/N-(T(N/2))/(N/2)=1
令F(N)=(T(N))/N ,則有
F(N)-F(N/2)=1
往下遞推有
F(N/2)-F(N/4)=1
...
F(2) - F(1)=1
以上K(=logN )個式子相加可得
F(N)-F(1)=1+...+1=logN
即
T(N)/N-T(1)/1=logN
可得
T(N)=N logN+N=O(N logN)
由以上推理過程可得該算法的運行時間為
T(N)=O(N logN)
2.4. 算法四
2.4.1. 算法描述
設輸入序列為A,長度為N,從a0開始求和並記錄最大值,如a(0),a(0)+a(1),a(0)+a(1)+a(2)…,直到開始出現求和小於0則停止。設加到a(i)時開始小於0,即有a(0),a(0)+a(1),…,a(0)+…+a(p-1)都大於0,而a(0)+a(1)+…+a(p)<0。此時,可從a(p+1)重新開始求和並記錄最大值。為什么可以這么做呢?我們把從a(1)到a(p)之間開始的子序列分為兩種情形(設子序列的開始索引為start,結束索引為end):
1、end<=p,a(start)+…+a(end)=a(0)+…+a(start)+…a(end) –[a(0)+…+a(start-1)]。由前面知,對於start-1<p,有a(0)+…+a(start-1)>0,所以可得到a(0)+…+a(start)+…a(end)> a(start)+…+a(end)。又由於a(0)+…+a(start)+…a(end)已經考慮過了,所以比其小的子序列無需考慮。
2、end>p,因為1<=start<=p,有a(0)+…+a(start-1)>0而a(0)+…+a(start)+…a(p)<0,所以有a(start)+…a(p)= a(0)+…+a(start)+…a(p)-[ a(0)+…+a(start-1)]<0。對於end>p,有a(start)+…+a(p)+…+a(end)<a(p+1)+…a(end)。
綜上所述,只需要從a(p+1)開始重新求和,重復以上步驟即可得到最大子序列求和。
2.4.2. 代碼描述
1 public static int maxSubSumLinear(int[] array) { 2 int maxSum = 0, thisSum = 0; 3 for(int j = 0; j < array.length; j++) { 4 thisSum += array[j]; 5 if (thisSum < 0) { 6 thisSum = 0; 7 } 8 else if(thisSum > maxSum) { 9 maxSum = thisSum; 10 } 11 } 12 return maxSum; 13 }