最大連續子序列問題
問題定義:
給定K個整數的序列{ N1, N2, ..., Nk },其任意連續子序列可表示為{ Ni, Ni+1, ..., Nj },其中 1 <= i <= j <= K。最大連續子序列是所有連續子序列中元素和最大的一個, 例如給定序列{ -2, 11, -4, 13, -5, -2 },其最大連續子序列為{ 11, -4, 13 },最大和為20
解法1:樸素解法, 時間復雜度 O(K^2)
//假設給定序列:a1,a2,...,aK
maxsum=0; // 最大的連續子序列的和
for(int i=0; i<K; i++){
tmpSum=0;
for(int j=i; j<K; j++){
tmpSum += a[j]
if(tmpSum > maxsum){
maxsum = tmpSum;
}
}
}
解法2:分治算法, 時間復雜度:O(nlogn)
對于任意一個序列{a1, a2, ...,am,.... an}, ( m=(n+1)/2 ) 最大的連續子序列在該序列中的位置存在三種情況:
- 位于中間部分的左邊;
- 位于中間部分的右邊 ;
- 左邊和右邊都含有最大的連續子序列的一部分, e.g. ai, ..., am, ...., aj.
對于情況1,2, 使用遞歸算法可以輕松計算出;對于情況3, 則通過求出前半部分的最大和(包含前半部分的最后一個元素)以及后半部分的最大和(包含后半部分的第一個元素)而得到,然后將這兩個和加在一起, 最后,三種情況中最大的結果就是要求的結果。
int MaxSubSum(const int A[], int Left, int Right)
{
int MaxLeftSum,MaxRightSum;
int MaxLeftBorderSum,MaxRightBorderSum;
int LeftBorderSum,RightBorderSum;
int mid,i;
if(Left == Right) // 處理只有一個元素的子序列
{
if(A[Left] > 0)
return A[Left];
else // 對于小于等于0的元素,
return 0;
}
mid= (Left + Right)/2;
// 情況1
MaxLeftSum = MaxSubSum(A,Left,mid);
// 情況2
MaxRightSum = MaxSubSum(A,mid+1,Right);
// 情況3
MaxLeftBorderSum = 0;
LeftBorderSum = 0;
for(i = mid;i >= Left;i--)// 求解最大序列的左邊部分
{
LeftBorderSum += A[i];
if(LeftBorderSum > MaxLeftBorderSum)
MaxLeftBorderSum = LeftBorderSum;
}
MaxRightBorderSum = 0;
RightBorderSum = 0;
for(i = mid+1;i <= Right;i++)// 求解最大序列的右邊部分
{
RightBorderSum += A[i];
if(RightBorderSum > MaxRightBorderSum)
MaxRightBorderSum = RightBorderSum;
}
return Max(MaxLeftSum, MaxRightSum, MaxLeftBorderSum + MaxRightBorderSum); // 返回三種情況中最大的結果
}
解法3: 動態規劃 , 時間復雜度O(n)
引理1: 以負數開頭的子序列不會是最大子序列。
證明:令子序列為{ai, ..., aj}, 其中開頭的元素 ai < 0, 則 ai + ... + aj < ai+1+...+aj 顯然成立。
引理2:對子序列 {ai, ..., aj} , 如果該子序列滿足兩個條件:
- 如果對x取 [i, j) 中的任意整數(包含i,不包含j) sum{ai, ..., ax} >0.
- sum{ai, ..., aj}<0.
則以該子序列中的任何元素ap開頭的以aj為終結的任意子序列的和必定小于0。
證明:從兩個條件中易推斷出:aj<0, 且由引理1知 以負數開頭的連續子序列不可能是最大連續子序列,則: ai > 0.
顯然有 0 >= sum{ai, ..., aj} >= sum{ai-1, ..., aj} >= sum{ap, ..., aj}, 其中 p 是[i, j)之間的整數。
反證法:假設sum{ap, ..., aj}>0, p取 [i, j) 之間的整數, 由引理2條件 sum{ai, ..., aj}<0 得出sum{ai, ..., ap-1}<0,該結論違反了引理2中的條件:如果對x取[i, j)中的任意整數(包含i,不包含j) sum{ai, ..., ax} >0. 得證。
由引理1可知,若a[i]<0, 則應跳到a[i+1]作為子序列的開頭元素(如果a[i+1]>0);
由引理2可知, 若a[i]+...+a[j]<=0且滿足引理2的第一個條件,則應以a[j+1]作為最大連續子序列的開頭元素(如果a[j+1]>0). 實質上,引理1是引理2的特例。
引理1和2可歸結為該狀態方程: maxsum(i)= max( maxsum(i-1)+ary(i), ary(i) ); (也可以由動態規劃方法處理的準則:最優子結構”、“子問題重疊”、“邊界”和“子問題獨立”得到)
通過對給定序列順序地反復運用引理1和引理2,最終可求得該序列的最大連續子序列。
代碼如下:
int maxSubSeq(int[] ary){
int maxsum=0;
int localSum=0;
for (int i=0; i<ary.length; ++i){
localSum += ary[i];
if(localSum > maxsum){
maxsum= localSum;
}else if (localSum < 0){
localSum=0; // 不考慮 ai~aj中的元素作為子序列的開頭, 其中ai>0, aj<0
} //else ==> localSum >0, 就是引理2中的條件1
}
return maxsum;
}
注意:解法2對于數組中全部是負數的數組返回0,而不是數組中的最大值。
解法4:動態規劃(可以處理數組中全部是負數的情況,該方法會返回數組中的最大值)
從解法2的分治思想得到提示,可以考慮數組的第一個元素A[0], 以及和最大的一段數組(A[i], .., A[j]), A[0] 和 和最大的一段數組的關系如下:
- 當0=i=j時,元素A[0]自己構成和最大的一段。
- 當0=i<j時,元素和最大的一段數組以A[0]開頭A[j]結尾。
- 當0 < i時,元素和和最大的一段數組沒有關系。
因此,我們將一個大問題(具有N個元素的數組)轉換成較小的問題(具有N-1個元素的數組)
記 all[1] 為 A[1],...,A[N-1]中 和最大的一段數組之和
記 start[1] 為 A[1], ..., A[N-1]中 以A[1]開頭的和最大的一段數組之和
不難發現,(A[0], A[1], ..., A[N-1]) 中和最大的一段數組的和 是 三種情況的最大值 max(A[0], A[0]+start[1], all[1])
可以看出該問題無后效性,可以使用動態規劃的方案解決。
因此我們可以得到初始的算法:
public static int maxSum1(int[] A){
int[] start = new int[A.length];
int[] all = new int[A.length];
all[A.length-1] = A[A.length-1];
start[A.length-1] = A[A.length - 1];
for(int i = A.length-2; i>=0; --i){
start[i] = Math.max(A[i], A[i] + start[i+1]);
all[i] = Math.max(start[i], all[i+1]);
}
return all[0];
}
算法優化
可以看到,計算start[i] 時,和 start[i+1]有關,計算all[i] 時,和all[i+1]有關
因此,我們可以使用兩個變量進行優化。
public static int maxSum1(int[] A){
int nStart = A[A.length-1];
int nAll = A[A.length-1];
for(int i = A.length-2; i>=0; --i){
nStart = Math.max(A[i], A[i] + nStart);
nAll = Math.max(nStart , nAll);
}
return nAll;
}
從上述優化算法可以看出:當nStart < 0時,nStart被賦值為A[i].
因此我們可以將算法改寫為更清晰的寫法:
public static int maxSum(int[] A){
int nStart = A[A.length-1];
int nAll = A[A.length - 1];
for(int i = A.length-2; i>=0; --i){
if(nStart < 0){
nStart = 0;
}
nStart += A[i];
if(nStart > nAll){ /// 即使數組中全部是負數,我們也會選出具有最大值的數。
nAll = nStart;
}
}
return nAll;
}
擴展問題
問題1
如果數組(A[0], ..., A[n-1])首尾相連,即我們被允許找到一段數字(A[i],..., A[n-1], A[0], ..., A[j])式其和最大。
問題分解:
- 解沒有穿過A[n-1]和A[0]連接
- 解穿過了A[n-1]和A[0]連接
2.1. 解包含A[0], ..., A[n-1]
2.2. 解包含兩部分:(1)從A[0]開始的一段 (A[0], ..., A[j]) (0<=j <n); (2) 從A[i]開始的一段(A[i], .., A[n-1]) (j<i<n)
尋找2.2.的解 相當于 從A數組中刪除一塊子數組(A[j+1],....,A[i-1])且刪除的子數組的和是負數且其絕對值最大。這相當于將問題轉為子問題1。
問題的解:取兩種情況的最大值。
時間復雜度 :求解子問題2只需遍歷數組一次,子問題1可以使用前面介紹的方法求解時間復雜度O(N). 所以時間復雜度共O(N)
代碼:(該代碼尚未驗證其正確性,請讀者自行驗證,如有錯誤請留言評論)
/**
* The correctness should be validated in the future!!!
* @param A
* @return
*/
public static int maxSumCycle(int[] A){
int s1 = maxSum(A);
int s2 = 0;
int nAll = A[A.length-2];
int nStart = A[A.length-2];
for(int i=A.length-1; i>=0; --i){
s2 += A[i];
// Find maximum abs value from range 1~A.length-2
if(i>=1 && i<=A.length-3){
nStart = Math.min(nStart, A[i] + nStart);
nAll = Math.min(nStart, nAll);
}
}
if(nAll>0) nAll = 0;
return Math.max(s1, Math.max(s2, s2 + nAll));
}
問題2
如果要求通知返回最大子數組的位置,應該如何修改算法,使保持O(N)的復雜度?
public static int maxSum(int[] A){
int s=A.length-1, e=A.length-1; // [s, e]
int p =0;
int nStart = A[A.length-1];
int nAll = A[A.length - 1];
for(int i = A.length-2; i>=0; --i){
if(nStart < 0){ // 以 A[i+1] 開頭的子數組的和,不可能是最優解,新的最優解的終點應該是 A[i]
nStart = 0;
p = i;
}
nStart += A[i];
if(nStart > nAll){
if(nStart==A[i]) e = p; // 表明以p為終點的最優解 開始計算。
nAll = nStart;
s = i; // 如果 nStart > nAll, 說明以當前 A[i] 開始一段數組,具有目前最優的解。
}
}
System.out.printf("sidx=%d, eidx=%d\n", s, e);
return nAll;
}
//測試實例,讀者可自行實驗,推導
// int[] ary = {1, -2, 3, 10, -4, 7, 2, -5};
// int[] ary = {0, -2, 3, 5, -1, 2};
// int[] ary = {-9, -2, -3, -5, -3};
// int[] ary = {1, -2, 3, 5, -3, 2};
注:解法4和擴展問題,都是引用《編程之美》上面的解法。