总和最大区间问题

题目和解题思路来源于吴军著作《计算之魂》。本题目是例题1.3。

1 问题描述

总和最大区间问题:给定一个实数序列,设计一个最有效的算法,找到一个总和最大的区间。
例如给定序列:1.5,-12.3,3.2,-5.5,23.2,3.2,-1.4,-12.2,34.2,5.4,-7.8,1.1,-4.9
总和最大的区间是从第5个数(23.2)到第10个数(5.4)。
这个问题的另外一种表述是:寻找一只股票最长的有效增长期。研究股票投资的人都想了解一只股票最长的有效增长期是哪一个时间段,即从哪一天买进到哪一天卖出,收益最大。上面这一组数字可以认为是一只股票每天的涨跌幅度(扣除大盘影响后)。

2 解题思路

2.1 三重循环

public int[] findMaxSumRange(double[] values){
        if(values == null || values.length==0) return null;
        int start = 0;
        int end = 0;
        double maxSum = Integer.MIN_VALUE;
        int K = values.length;
        for(int p=0;p<K; p++){//枚举起始位置
            for(int q=p;q<K;q++){//枚举终点位置
                //计算子数组的和
                double sum = 0;
                for(int i = p; i<=q; i++){
                    sum += values[i];
                }
                if(maxSum < sum){
                    maxSum = sum;
                    start = p;
                    end = q;
                }
            }
        }

        return new int[]{start, end};
    }

枚举起点p,范围是从0到K-1,枚举终点q,范围是从p到K-1。这些数字的综合为S(p,q)。
区间一头一尾的组合有 O ( K 2 ) O(K^2) O(K2)种。计算S(p,q)平均要做 K / 4 K/4 K/4次加法。这又是一重循环。因此算法总复杂度为 O ( K 3 ) O(K^3) O(K3)
关于:计算S(p,q)平均要做 K / 4 K/4 K/4次加法这是书中的描述。我自己计算过应该不是。例如K=10,会有55种组合,因此要计算55次。计算加法的次数是220次。所以每次计算平均是 220 / 55 = 4 220/55=4 220/55=4,而 K / 4 = 10 / 4 = 2.5 K/4=10/4=2.5 K/4=10/4=2.5这是不一样的。但是从算法时间复杂度的角度是不受影响的。计算S(p,q)最少需要1次运算,最多需要K次。其平均值一定是一个关于K的一次函数。所以总体算法时间复杂度是 O ( K 3 ) O(K^3) O(K3)

算法做了很多无用功。例如如果已经计算了S(0,3),在计算S(0,4)的时候只需要S(0,3)+values[4]即可。

2.2 两重循环

public int[] findMaxSumRangeV2(double[] values){
        if(values == null || values.length==0) return null;
        int start = 0;
        int end = 0;
        double maxSum = Integer.MIN_VALUE;
        int K = values.length;
        for(int p=0;p<K; p++){//枚举起始位置
            double sum = values[p];
            if(maxSum < sum){
                maxSum = sum;
                start = p;
                end = p;
            }
            for(int q=p+1;q<K;q++){//枚举终点位置
                //计算子数组的和
                sum += values[q];
                if(maxSum < sum){
                    maxSum = sum;
                    start = p;
                    end = q;
                }
            }
        }
        return new int[]{start, end};
    }

实现方式和书中描述不完全一致。时间复杂度一致 O ( K 2 ) O(K^2) O(K2)

2.3 分治法

1 首先将序列一分为二,分成从1到K/2,以及K/2+1到K两个子序列(下标从1开始的描述方式)
2 对这两个子序列分别求总和最大区间。
3 归并步骤。
如果前后2个子序列的综合最大区间中间没有间隔,也就是说前一个的总和最大区间是[p,K/2],后一个的总和最大区间恰好是[K/2+1,q]。在这种情况下,如果两个结果的和都是正数,那么整个序列总和最大区间是[p,q]。否则就取两个子序列的总和最大区间中的大的一个。
如果前后2个子序列的总和最大区间中间有间隔,我们假定这两个子序列的总和最大区间分别为[p1,q1]和[p2,q2]。那么这时候整个序列的总和最大区间是下面这三个中的一个:[p1,q1],[p2,q2],[p1,q2]。这一步的时间复杂度为O(K)。

总体算法时间复杂度为 O ( K l o g K ) O(KlogK) O(KlogK)
到此,你已经具备成为四级工程师的条件。因为你已经掌握了计算机科学的一个精髓:分治法。

public int[] findMaxSumRangeV3(double[] values){
        if(values == null || values.length==0) return null;
        int K = values.length;

        double[] result = findMaxSumRange(values, 0 , values.length-1);
        return new int[]{(int)result[0], (int)result[1]};
    }

    private double[] findMaxSumRange(double[] values, int startIndex, int endIndex) {
        if(startIndex == endIndex){
            return new double[]{startIndex, startIndex, values[startIndex]};
        }
        int middle = (startIndex + endIndex)/2;
        double[] result1 = findMaxSumRange(values, startIndex, middle);
        double[] result2 = findMaxSumRange(values,middle+1, endIndex);
        if(result1[1] == result2[0] + 1){
            if(result1[2]>0 && result2[2]>0){
                return new double[]{result1[0], result2[1], result1[2] + result2[2]};
            }
            if(result1[2]>result2[2]){
                return result1;
            }
            return result2;
        }else{
            double sum = 0;
            for(int i=(int)result1[0]; i<= (int)result2[1];i++){
                sum += values[i];
            }
            double[] max = result2;
            if(result1[2] > result2[2]){
                max = result1;
            }
            if(sum > max[2]){
                max = new double[]{result1[0], result2[1], sum};
            }
            return max;
        }
    }

2.4 正反两遍扫描的方法

正向扫描得到最大区间的右边界,反向扫描得到最大区间的左边界。具体做法如下。
1 先在序列中扫描找到第一个大于0的数。
1.1 假设整个数组都是负数或者0,那找到最大的数,也就是所要找的区间。
1.2 否则,从头部序列开始删除直到遇到第一个大于0的数。到此我们认为数组第0个元素是一个正数。

2 把左边界固定在第一个数,然后q=2,3,…K,计算S(1,q),以及到目前为止和最大值Maxf,和达到最大值的右边界r。

3 对于所有的q,都有S(1,q)>=0,或者存在某个 q 0 q_0 q0,当 q > q 0 q>q_0 q>q0的时候,符合,都有S(1,q)>=0。在这种情况下,当扫描到最后,即q=K时,所保留的那个Maxf所对应的r就是我们要找的区间的右边界。
为什么?因为从第r+1个数开始,或者是负数,或者是0,无论再怎么加,也不可能让和更大。

我们推论一下。 假设整个数组的最大区间和是 S ( l , r 2 ) S(l,r_2) S(l,r2),并且 r 2 > r r_2>r r2>r.
我们现在已知 S ( 1 , r ) > S ( 1 , r 2 ) S(1,r)>S(1,r_2) S(1,r)>S(1,r2)
S ( 1 , r 2 ) = S ( 1 , r ) + S ( r + 1 , r 2 ) S(1,r_2)=S(1,r)+S(r+1,r_2) S(1,r2)=S(1,r)+S(r+1,r2) = > S ( r + 1 , r 2 ) < 0 =>S(r+1,r_2)<0 =>S(r+1,r2)<0
S ( l , r 2 ) = S ( l , r ) + S ( r + 1 , r 2 ) S(l,r_2)=S(l,r)+S(r+1,r_2) S(l,r2)=S(l,r)+S(r+1,r2),因为 S ( r + 1 , r 2 ) < 0 S(r+1,r_2)<0 S(r+1,r2)<0,所以 S ( l , r 2 ) < S ( l , r ) S(l,r_2)<S(l,r) S(l,r2)<S(l,r),这与假设 S ( l , r 2 ) S(l,r_2) S(l,r2)是最大区间和矛盾,所以我们推出整个数组的最大区间和是 S ( l , r ) S(l,r) S(l,r)。也就是说右边界确定是r。

可以看下表格中前向累计的结果。
在这里插入图片描述

从计算结果可以得知:Maxf=39.3,相应的r=10(下标从1开始)。

接下来只要把问题倒过来看。就可以知道左边界在哪里。我们从后往前计算累计之和。可以看出最大值Maxb=40.8,以及位置l=5。

问题的最终结果是:[5,10]。

public int[] findMaxSumRangeV4(double[] values){
        if(values == null || values.length==0) return null;
        double maxSum = Integer.MIN_VALUE;
        int K = values.length;
        int p = -1;
        for(int i=0;i<K;i++){
            if(values[i] > 0){
                p = i;
                break;
            }
        }
        if(p==-1){
            double max = Integer.MIN_VALUE;
            int maxOfIndex = -1;
            for(int i=0;i<K;i++){
                if(values[i] > max){
                   max = values[i];
                   maxOfIndex = i;
                }
            }
            return new int[]{maxOfIndex, maxOfIndex};
        }
        //从左到右
        double sum = values[p];
        double maxf = sum;
        int r = 0;
        for(int q=p + 1;q<K;q++){
            //计算子数组的和
            sum += values[q];
            if(maxf < sum){
                maxf = sum;
                r = q;
            }
        }
        //从右到左
        sum = values[K-1];
        maxf = sum;
        int l = K-1;
        for(int q = K -2; q >=0; q--){
            sum += values[q];
            if(maxf < sum){
                maxf = sum;
                l = q;
            }
        }
        return new int[]{l, r};
    }

2.5 再进一步,假设失效

2.4的解法的假设条件是:对于所有的q,都有S(1,q)>=0,或者存在某个 q 0 q_0 q0,当 q > q 0 q>q_0 q>q0的时候,符合,都有S(1,q)>=0。
如果在某个点之后S(1,q)都小于0会怎样?
把数组改为:1.5,-12.3,3.2,-5.5,23.2,3.2,-1.4,-62.2,44.2,5.4,-7.8,1.1,-4.9

在这里插入图片描述

如果还按照上面的算法,就会得出右边界r=6,左边界l=9。这是因为原本区间[9,10]是和最大区间。但是在累积了前面8个元素的之后后,和仍然小于0,我们就找不到它了。这样我们就需要改变一下。

我这里的疑问:在2.4中的推理过程应该没有用S(1,q)>=0这个条件。怎么就出错了呢?

1 我们先把左边界固定在第一个大于0的位置,例如p,然后让q=p,p+1,…K,计算S(p,q),以及目前为止最大的和Max和达到最大值的右边界r。如果我们计算到某一步q,发现S(p,q)<0,那么需要从q位置开始,反向计算Maxb,并且可以确定从p到q之间,最大区间和的区间,我们假定它为 [ l 1 , r 1 ] [l_1,r_1] [l1,r1],区间和为 M a x 1 Max_1 Max1

这里特别指出的是 l 1 = p l_1=p l1=p。为什么呢?也可以用反证法证明。 我们假设如果 l 1 ≠ p l_1 \ne p l1=p
根据我们对这种情况的假设: S ( p , l 1 ) > = 0 S(p,l_1)>=0 S(p,l1)>=0,于是就有 S ( p , r 1 ) = S ( p , l 1 − 1 ) + S ( l 1 , r 1 ) > = S ( l 1 , r 1 ) S(p,r_1)=S(p,l_1-1)+S(l_1,r_1)>=S(l_1,r_1) S(p,r1)=S(p,l11)+S(l1,r1)>=S(l1,r1),这就与 [ l 1 , r 1 ] [l_1,r_1] [l1,r1]是到q为止和最大的区间相矛盾了。

2 我们从q+1继续开始扫描,重复步骤1。先是找到第一个大于0的元素,从那里开始做累加操作,可能遇到某个 q ′ q' q,又出现了 S ( q + 1 , q ′ ) < 0 S(q+1,q')<0 S(q+1,q)<0的情况,这时候我们得到第二个局部和最大区间 [ l 2 , r 2 ] [l_2,r_2] [l2,r2]。以及相应区间和 M a x 2 Max_2 Max2

现在我们需要确定,从头开始到q’时的和最大的区间。我们只需要比较 M a x 1 Max_1 Max1 M a x 2 Max_2 Max2以及 M a x 1 + M a x 2 + S ( r 1 + 1 , l 2 − 1 ) Max_1+Max_2+S(r_1+1,l_2-1) Max1+Max2+S(r1+1,l21).
M a x 1 + M a x 2 + S ( r 1 + 1 , l 2 − 1 ) Max_1+Max_2+S(r_1+1,l_2-1) Max1+Max2+S(r1+1,l21)也就是 S ( l 1 , r 2 ) S(l_1,r_2) S(l1,r2)

在这里插入图片描述

我们可以先否定 S ( l 1 , r 2 ) S(l_1,r_2) S(l1,r2)的可能性。

由于 S ( q + 1 , r 2 ) = S ( q + 1 , l 2 − 1 ) + S ( l 2 , r 2 ) < S ( l 2 , r 2 ) S(q+1,r_2)=S(q+1,l_2-1)+S(l_2,r_2)<S(l_2,r_2) S(q+1,r2)=S(q+1,l21)+S(l2,r2)<S(l2,r2),所以 S ( q + 1 , l 2 − 1 ) < 0 S(q+1,l_2-1)<0 S(q+1,l21)<0,也就是说从第一次累加结束,到第二个局部和最大区间开始之间,所有的元素之和<0。

由于
S ( p , q ) < 0 S(p,q)<0 S(p,q)<0,那么 S ( l 1 , r 2 ) = S ( p , r 2 ) = S ( p , q ) + S ( q + 1 , l 2 + 1 ) + S ( l 2 , r 2 ) < S ( l 2 , r 2 ) = M a x 2 S(l_1,r_2)=S(p,r_2)=S(p,q)+S(q+1,l_2+1)+S(l_2,r_2)<S(l_2,r_2)=Max_2 S(l1,r2)=S(p,r2)=S(p,q)+S(q+1,l2+1)+S(l2,r2)<S(l2,r2)=Max2

这样一来,从序列头到q’时,和最大区间要么是 [ l 1 , r 1 ] [l_1,r_1] [l1,r1],要么是 [ l 2 , r 2 ] [l_2,r_2] [l2,r2]。那么只要取二者之中更大的那个保留为Max,[l,r]即可。

步骤3,采用步骤2的方法,继续向后扫描。得到一个个局部和最大的区间以及对应的部分和 M a x i Max_i Maxi,然后比较 M a x i Max_i Maxi和Max,做更新即可。

最后就得到了整个区间的总和最大和区间范围。
这应该就是线段树结构。待查证。

算法复杂度。无论是简单还是复杂情况,都只需要正向,反向各扫描一次数组。算法复杂度O(K)。

public int[] findMaxSumRangeV5(double[] values){
        if(values == null || values.length==0) return null;
        int K = values.length;
        //检查是否有大于0的元素
        int p = -1;
        for(int i=0;i<K;i++){
            if(values[i] > 0){
                p = i;
                break;
            }
        }
        if(p==-1){
            int maxOfIndex = argMax(values);
            return new int[]{maxOfIndex, maxOfIndex};
        }
        double maxSum = Integer.MIN_VALUE;//区间和最大值
        int l = -1, r = -1;
        double sum = 0;//某个区域内的累加和
        double maxF = Integer.MIN_VALUE;//某个区域内从左到右累加的和最大值
        int rF = p;//某个区域内和最大值的右边界
        int i = p;
        while(i < K){
            sum += values[i];
            if(sum<0){
                int q = i;
                //查找左边界
                double sumB = 0;
                double maxB = Integer.MIN_VALUE;
                int lF = q;
                for(int j=q;j>=p;j--){
                    sumB += values[j];
                    if(sumB > maxB){
                        maxB = sumB;
                        lF = j;
                    }
                }
                //计算区域内的和
                double sumRange = rangeSum(values, lF, rF);
                if(sumRange > maxSum){
                    maxSum = sumRange;
                    l = lF;
                    r = rF;
                }
                //查找下一个区段的起点
                while(q+1<K && values[q+1]<=0){
                    q++;
                }
                p = q + 1;
                i = q + 1;
            }else if(maxF < sum){
                maxF = sum;
                rF = i;
                i++;
            }
        }
        return new int[]{l, r};
    }
    private int argMax(double[] values){
        double max = Integer.MIN_VALUE;
        int maxOfIndex = -1;
        for(int i=0;i<values.length;i++){
            if(values[i] > max){
                max = values[i];
                maxOfIndex = i;
            }
        }
        return maxOfIndex;
    }

    private double rangeSum(double[] values, int start, int end){
        double sumRange = 0;
        for(int j=start;j<=end;j++){
            sumRange += values[j];
        }
        return sumRange;
    }

3 应用动态规划

在第2部分,对于第4 和5部分的思考还是很复杂的。使用动态规划思想。我们用dp[i]表示以第i个元素为结尾的子数组的最大和。那么我们的答案就是max(dp)。
对于dp[i]来说,要么第i个元素作为前面子数组的最后一个元素,追加上去;要么就是单独成一个子数组。dp[i]=max(dp[i-1]+values[i], values[i]).

具体实现过程中使用了空间优化,只利用pre和maxSum即可。

public int[] findMaxSumRangeV6(double[] values){
        double pre = values[0];
        double maxSum = values[0];
        int l = 0, r = 0;
        int lF = 0, rF = 0;
        for(int i=0;i<values.length;i++){
            if(pre + values[i] > values[i]){
                pre = pre + values[i];
                rF = i;
            }else{
                pre = values[i];
                lF = i;
                rF = i;
            }
            if(maxSum < pre){
                maxSum = pre;
                l = lF;
                r = rF;
            }
        }
        return new int[]{l, r};
    }
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值