题目和解题思路来源于吴军著作《计算之魂》。本题目是例题1.3。
文章目录
- 1 问题描述
- 2 解题思路
- 2.1 三重循环
- 2.2 两重循环
- 2.3 分治法
- 2.4 正反两遍扫描的方法
- 2.5 再进一步,假设失效
- 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(K2)O(K^2)O(K2)种。计算S(p,q)平均要做K/4K/4K/4次加法。这又是一重循环。因此算法总复杂度为O(K3)O(K^3)O(K3)。
关于:计算S(p,q)平均要做K/4K/4K/4次加法这是书中的描述。我自己计算过应该不是。例如K=10,会有55种组合,因此要计算55次。计算加法的次数是220次。所以每次计算平均是220/55=4220/55=4220/55=4,而K/4=10/4=2.5K/4=10/4=2.5K/4=10/4=2.5这是不一样的。但是从算法时间复杂度的角度是不受影响的。计算S(p,q)最少需要1次运算,最多需要K次。其平均值一定是一个关于K的一次函数。所以总体算法时间复杂度是O(K3)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(K2)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(KlogK)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,或者存在某个q0q_0q0,当q>q0q>q_0q>q0的时候,符合,都有S(1,q)>=0。在这种情况下,当扫描到最后,即q=K时,所保留的那个Maxf所对应的r就是我们要找的区间的右边界。
为什么?因为从第r+1个数开始,或者是负数,或者是0,无论再怎么加,也不可能让和更大。
我们推论一下。 假设整个数组的最大区间和是S(l,r2)S(l,r_2)S(l,r2),并且r2>rr_2>rr2>r.
我们现在已知S(1,r)>S(1,r2)S(1,r)>S(1,r_2)S(1,r)>S(1,r2),
S(1,r2)=S(1,r)+S(r+1,r2)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,r2)<0=>S(r+1,r_2)<0=>S(r+1,r2)<0
S(l,r2)=S(l,r)+S(r+1,r2)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,r2)<0S(r+1,r_2)<0S(r+1,r2)<0,所以S(l,r2)<S(l,r)S(l,r_2)<S(l,r)S(l,r2)<S(l,r),这与假设S(l,r2)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,或者存在某个q0q_0q0,当q>q0q>q_0q>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之间,最大区间和的区间,我们假定它为[l1,r1][l_1,r_1][l1,r1],区间和为Max1Max_1Max1。
这里特别指出的是l1=pl_1=pl1=p。为什么呢?也可以用反证法证明。 我们假设如果l1≠pl_1 \ne pl1=p
根据我们对这种情况的假设:S(p,l1)>=0S(p,l_1)>=0S(p,l1)>=0,于是就有S(p,r1)=S(p,l1−1)+S(l1,r1)>=S(l1,r1)S(p,r_1)=S(p,l_1-1)+S(l_1,r_1)>=S(l_1,r_1)S(p,r1)=S(p,l1−1)+S(l1,r1)>=S(l1,r1),这就与[l1,r1][l_1,r_1][l1,r1]是到q为止和最大的区间相矛盾了。
2 我们从q+1继续开始扫描,重复步骤1。先是找到第一个大于0的元素,从那里开始做累加操作,可能遇到某个q′q'q′,又出现了S(q+1,q′)<0S(q+1,q')<0S(q+1,q′)<0的情况,这时候我们得到第二个局部和最大区间[l2,r2][l_2,r_2][l2,r2]。以及相应区间和Max2Max_2Max2。
现在我们需要确定,从头开始到q’时的和最大的区间。我们只需要比较Max1Max_1Max1,Max2Max_2Max2以及Max1+Max2+S(r1+1,l2−1)Max_1+Max_2+S(r_1+1,l_2-1)Max1+Max2+S(r1+1,l2−1).
Max1+Max2+S(r1+1,l2−1)Max_1+Max_2+S(r_1+1,l_2-1)Max1+Max2+S(r1+1,l2−1)也就是S(l1,r2)S(l_1,r_2)S(l1,r2)
我们可以先否定S(l1,r2)S(l_1,r_2)S(l1,r2)的可能性。
由于S(q+1,r2)=S(q+1,l2−1)+S(l2,r2)<S(l2,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,r2)=S(q+1,l2−1)+S(l2,r2)<S(l2,r2),所以S(q+1,l2−1)<0S(q+1,l_2-1)<0S(q+1,l2−1)<0,也就是说从第一次累加结束,到第二个局部和最大区间开始之间,所有的元素之和<0。
由于
S(p,q)<0S(p,q)<0S(p,q)<0,那么S(l1,r2)=S(p,r2)=S(p,q)+S(q+1,l2+1)+S(l2,r2)<S(l2,r2)=Max2S(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_2S(l1,r2)=S(p,r2)=S(p,q)+S(q+1,l2+1)+S(l2,r2)<S(l2,r2)=Max2
这样一来,从序列头到q’时,和最大区间要么是[l1,r1][l_1,r_1][l1,r1],要么是[l2,r2][l_2,r_2][l2,r2]。那么只要取二者之中更大的那个保留为Max,[l,r]即可。
步骤3,采用步骤2的方法,继续向后扫描。得到一个个局部和最大的区间以及对应的部分和MaxiMax_iMaxi,然后比较MaxiMax_iMaxi和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};}