动态规划之"最大连续子序列"

最大连续子序列问题

问题定义

给定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 ) 最大的连续子序列在该序列中的位置存在三种情况:

  1. 位于中间部分的左边;
  2. 位于中间部分的右边 ;
  3. 左边和右边都含有最大的连续子序列的一部分, 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} , 如果该子序列满足两个条件:

  1. 如果对x取 [i, j) 中的任意整数(包含i,不包含j) sum{ai, ..., ax} >0.
  2. 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] 和 和最大的一段数组的关系如下:

  1. 当0=i=j时,元素A[0]自己构成和最大的一段。
  2. 当0=i<j时,元素和最大的一段数组以A[0]开头A[j]结尾。
  3. 当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])式其和最大。

问题分解:

  1. 解没有穿过A[n-1]和A[0]连接
  2. 解穿过了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和扩展问题,都是引用《编程之美》上面的解法。

推荐阅读更多精彩内容