最大连续子序列问题
问题定义:
给定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和扩展问题,都是引用《编程之美》上面的解法。