现在我们将要叙述四个算法来求解早先提出的最大子序列和问题。
第一个算法,它只是穷举式地尝试所有的可能。for循环中的循环变量反映了Java中数组从0开始而不是从1开始这样一个事实。还有,本算法并不计算实际的子序列;实际的计算还要添加一些额外的代码。
public static int maxSubSum1(int[] a) { int maxSum = 0; for(int i = 0;i<a.length;i++) for(int j = i;j<a.length;j++) { int thisSum = 0; for(int k = i;k<=j;k++) thisSum +=a[k]; if(thisSum > maxSum) maxSum = thisSum; } return maxSum; }
该算法肯定会正确运行(这用不着花太多时间去证明)。运行时间为O(N的3次方),这完全取决这两行代码:
for(int k = i;k<=j;k++) thisSum +=a[k];
它们由一个含于三种嵌套for循环中的O(1)语句组成。
下面这行代码循环大小为N
for(int i = 0;i<a.length;i++)
第二个循环大小为N-i,它可能要小,但也可能是N。我们必须假设最坏的情况,而这可能会使得最终的界有些大。第三个循环的大小为j-i+1我们也要假设它的大小为N。因此总数为
O(1.N.N.N)=O(N的3次方)。
而下面这行代码,开销只是O(1)
int maxSum = 0;
然而,下面这段代码也只不过总共开销O(N的2次方),因为它们只是两层循环内部的简单表达式
if(thisSum > maxSum) maxSum = thisSum;
第二个算法显然是O(N的2次方)
public static int maxSubSum2(int [] a) { int maxSum = 0; for(int i = 0;i<a.length;i++) { int thisSum = 0; for (int j = 0; j < a.length; j++) { thisSum += a[j]; if(thisSum > maxSum) maxSum = thisSum; } } return maxSum; }
对这个问题有一个递归和相对复杂的O(N logN)解法,我们现在就来描述它。要是真的没出现O(N)(线性的)解法,这个算法就会是体现递归威力的极好的范例。该方法采用一种对它们求解,这是“分”的部分。“治”阶段将两个子问题的解修补到一起并可能再做些少量的附加工作,最后得到整个问题的解。
在我们的例子中,最大子序列和可能在三处出现。或者整个出现在输入数据的左半部,或者整个出现在右半部,或者跨越输入数据的中部从而位于左右两半部分之中。前两种情况可以递归求解。第三种情况的最大和可以通过求出前半部分(包含前半部分最后一个元素)的最大和以及后半部分(包含后半部分第一个元素)的最大和而得到。此时将这两个和相加。作为一个例子,考虑下列输入,如图:
其中前半部分的最大子序列和为6(从元素A1到元素A3)而后半部分的最大子序列和为8(从元素A6到A7)。
前半部分包含其最后一个元素的最大和子序列和是4(从元素A1到元素A4),而后半部分 包含其第一个元素的最大和是7(从元素A5到A7)。因此,横跨这部分且通过中间的最大和为4+7=11(从元素A1到A7)。
我们看到,在形成本例中的最大和子序列的三种方式中,最好的方式是包含两部分的元素。于是,答案为11。
有必要对算法3的程序进行一些说明。递归过程调用的一般形式是传递输入的数组以及左边界和右边界,它们界定了数组要被处理的部分。单行驱动程序通过传递数组以及边界0和N-1而将该过程启动。
代码示例如下:
package cn.simple.example; public class AlgorithmTestExample { public static int maxSubSum1(int[] a) { int maxSum = 0; for(int i = 0;i<a.length;i++) for(int j = i;j<a.length;j++) { int thisSum = 0; for(int k = i;k<=j;k++) thisSum +=a[k]; if(thisSum > maxSum) maxSum = thisSum; } return maxSum; } public static int maxSubSum2(int [] a) { int maxSum = 0; for(int i = 0;i<a.length;i++) { int thisSum = 0; for (int j = 0; j < a.length; j++) { thisSum += a[j]; if(thisSum > maxSum) maxSum = thisSum; } } return maxSum; } private static int maxSumRec(int [] a,int left,int right) { if(left == right) if(a[left] > 0) return a[left]; else return 0; int center = (left + right)/2; int maxLeftSum = maxSumRec(a,left,center); int maxRightSum = maxSumRec(a,center+1,right); int maxLeftBorderSum = 0,leftBorderSum = 0; for(int i = center;i>=left;i--) { leftBorderSum +=a[i]; if(leftBorderSum > maxLeftBorderSum) maxLeftBorderSum = leftBorderSum; } int maxRightBorderSum = 0,rightBorderSum = 0; for(int i = center+1;i<=right;i++) { rightBorderSum += a[i]; if(rightBorderSum > maxRightBorderSum) maxRightBorderSum = rightBorderSum; } return max3(maxLeftSum,maxRightSum,maxLeftBorderSum + maxRightBorderSum); } private static int max3(int maxLeftSum, int maxRightSum, int i) { int max; if(maxLeftSum > maxRightSum) max = maxLeftSum; else max = maxRightSum; if(i > max) max = i; return max; } public static int maxSubSum3(int [] a) { return maxSumRec(a,0,a.length-1); } }
看这段代码,如下所示:
if(left == right) if(a[left] > 0) return a[left]; else return 0;
如果left==right,那么只有一个元素,并且当该元素非负时它就是最大子序列。left>right的情况是不可能出现的,除非N是负数(不过,程序中小的扰动有可能致使这种混乱产生)。
下面这两个递归调用,我们可以看到,递归调用总是对小于原问题的问题进行,不过程序小的扰动有可能破坏这个特性。
int maxLeftSum = maxSumRec(a,left,center); int maxRightSum = maxSumRec(a,center+1,right);
我们再看这下面两段代码:
代码1
int maxLeftBorderSum = 0,leftBorderSum = 0; for(int i = center;i>=left;i--) { leftBorderSum +=a[i]; if(leftBorderSum > maxLeftBorderSum) maxLeftBorderSum = leftBorderSum; }
代码2
int maxRightBorderSum = 0,rightBorderSum = 0; for(int i = center+1;i<=right;i++) { rightBorderSum += a[i]; if(rightBorderSum > maxRightBorderSum) maxRightBorderSum = rightBorderSum; }
这两段代码达到中间分界处的两个最大和的和数。这两个值的和为扩展到左右两部分的最大和。例程max3返回这三个可能的最大和的最大者。
显然,算法3需要比前面两种算法更多的编程努力。然而,程序短并不总意味着程序好。正如我们在前面显示算法运行时间的表中已经看到的(可以参考这篇文章:<数据结构与算法分析>读书笔记--要分析的问题),除最小的输入量外,该算法比前两个算法明显要快。
算法4:
public static int maxSubSum4(int [] a) { int maxSum = 0,thisSum = 0; for(int j = 0;j<a.length;j++) { thisSum +=a[j]; if(thisSum > maxSum) maxSum = thisSum; else if(thisSum < 0) thisSum = 0; } return maxSum; }
不难理解为什么时间的界是正确,但是要明白为什么算法是正确可行的却需要多加思考。为了分析原因,注意,像算法1和算法2一样,j代表当前序列的终点,而i代表当前序列的起点。碰巧的是,如果我们不需要知道具体最佳的子序列在哪里,那么i的使用可以从程序上被优化,因此在设计算法的时候假设i是需要的,而且我们想要改进算法2.一个结论是,如果a[i]是负的,那么它不可能代表最优序列的起点,因为任何包含a[i]的作为起点的子序列都可以通过用a[i+1)作起点而得到改进。类似地,任何负的子序列不可能是最优子序列的前缀。如果在内循环中检测到从a[i]到a[j]的子序列是负的,那么可以推进i。关键的结论是,我们不仅能够把i推进到i+1,而且实际上还可以把它一直推进到j+1。为了看清楚这一点,令p为i+1和j之间的任一下标。开始于下标p的任意子序列都不大于在下标i开始并包含从a[i]到a[p-1)的子序列的对应的子序列,因为后面这个子序列不是负的(j是使得从下标i开始其值成为负值的序列的第一个下标)。因此,把i推进到j+1是没有风险的:我们一个最优解也不会错过。
这个算法是许多聪明算法的典型:运行时间是明显的。但正确性则不那么容易看出来。对于这些算法,正式的正确性证明(比上面的分析更正式)几乎总是被需要的;然而,即使到那时,许多人仍然不信服。此外,许多这类算法需要更有技巧的编程,这导致更长的开发过程。不过当这些算法正常工作时,它们运行得很快,而我们将它们和一个低效的蛮力算法通过小规模的输入进行比较可以测试大部分的程序原理。
该算法的一个附带的优点是,它只对数据进行一次扫描,一旦a[i]被读入并被处理,它就不再需要被记忆。因此,如果数组在磁盘上或通过互联网传送,那么它就可以被按顺序读入,在主存中不必存储数组的任何部分。不仅如此,在任意时刻,算法都能对它已经读入的数据给出子序列问题的正确答案。具有这种特性的算法叫做联机算法。仅需要常量空间并以线性时间运行的联机算法几乎是完美的算法。
代码示例地址为:https://github.com/youcong1996/The-Data-structures-and-algorithms/tree/master/algorithm_analysis