问题的引入算法
给定(可能有负数)整数序列A1, A2, A3..., An, 求这个序列中子序列和的最大值。(为方便起见,若是全部整数均为负数,则最大子序列和为0)。例如:输入整数序列: -2, 11, 8, -4, -1, 16, 5, 0,则输出答案为35,即从A2~A6。编程
这个问题之因此有吸引力,主要是由于存在求解它的不少算法,而这些算法的性能差别又很大。这些算法,对于少许的输入差异都不大,几个算法都能在瞬间完成,这时若花费大量的努力去设计聪明的算法恐怕就不太值得了;可是若是对于大量的输入,想要更快的获取处理结果,那么设计精良的算法显得颇有必要。数组
切入正题网络
下面先提供一个设计最不耗时间的算法,此算法很容易设计,也很容易理解,但对于大量的输入而言,效率过低:性能
算法一:测试
public static int maxSubsequenceSum(int[] a) { int maxSum = 0; for(int i=0; i<a.length; i++) { //i为子序列的左边界 for(int j=i; j<a.length; j++) { //j为子序列的右边界 int thisSum = 0; for(int k=0; k<=j; k++) //迭代子序列中的每个元素,求和 thisSum += a[k]; if(thisSum > maxSum) maxSum = thisSum; } } return maxSum; }
上述设计很容易理解,它只是穷举各类可能的结果,最后得出最大的子序列和。毫无疑问,这个算法可以正确的得出和,可是若是还要得出是哪一个子序列,那么这个算法还须要添加一些额外的代码。优化
如今来分析如下这个算法的时间复杂度。运行时间的多少,彻底取决于第六、7行,它们由一个含有三重嵌套for循环中的O(1)语句组成:第3行上的循环大小为N,第4行循环大小为N-i,它可能很小,但也多是N。咱们在判断时间复杂度的时候必须取最坏的状况。第6行循环大小为j-i+1,咱们也要假设它的大小为N。所以总数为O(1*N*N*N)=O(N3)。第2行的总开销为O(1),第八、9行的总开销为O(N2),由于它们只是两层循环内部的简单表达式。this
咱们能够经过拆除一个for循环来避免3次的运行时间。不过这不老是可能的,在这种状况下,算法中出现大量没必要要的计算。纠正这种低效率的改进算法能够经过观察Sum(Ai~Aj) = Aj + Sum(Ai~A[j-1])而看出,所以算法1中第六、7行上的计算过度的耗费了。下面是在算法一的基础上改进的一种算法:spa
算法二:设计
public static int maxSubsequenceSum(int[] a) { int maxSum = 0; for(int i=0; i<a.length; i++) { int thisSum = 0; for(int j=i; j<a.length; j++) { thisSum += a[j]; if(thisSum > maxSum) maxSum = thisSum; } } return maxSum; }
对于此算法,时间复杂度显然是O(N2),对它的分析甚至比前面的分析还要简单,就是直接使用穷举法把序列中i后面的每一个值相加,若是发现有比maxSum大的,则更新maxSum的值。
对于这个问题,有一个递归和相对复杂的O(NlogN)解法,咱们如今就来描述它。要是真的没有出现O(N)(线性的)解法,这个算法就会是体现递归为例的极好的范例了。该方法采用一种“分治”策略。其想法就是吧问题分红两个大体相等的子问题,而后递归地对它们求解,这是“分”的阶段。“治”阶段就是将两个子问题的解修补到一块儿并可能再作些少许的附加工做,最后获得整个问题的解。
在咱们的例子中,最大子序列的和只可能出如今3个地方:
出如今输入数据的左半部分
出如今输入数据的右半部分
跨越输入数据的中部而位于左右两个部分
前两种状况能够递归求解,第三种状况的最大和能够经过求出前半部分(包含前半部分的最后一个元素)的最大和以及后半部分(包括后半部分的第一个元素)的最大和,再将两者相加获得。做为例子,考虑如下输入:
----------------------------------------- 前半部分 后半部分 ----------------------------------------- -2, 11, 8, -4, -1, 16, 5, 0 -----------------------------------------
其中,前半部分的最大子序列和为19(A2~A3),然后半部分的最大子序列和为21(A6~A7)。前半部分包含其最后一个元素的最大和是15(A2~A4),后半部分包含第一个元素的最大和是20(A5~A7)。所以,跨越这两部分的这个子序列才是拥有最大和的子序列,和为15+20=35(A2~A7)。因而出现了下面这种算法:
算法三:
public static int maxSubsequenceSum(int[] a, int left, int right) { if(left == right) { //Base case if(a[left] > 0) { return a[left]; } else { return 0; //保证最小值为0 } } int center = (left+right)/2; int maxLeftSum = maxSubsequenceSum(a, left, center); //递归调用,求左部分的最大和 int maxRightSum = maxSubsequenceSum(a, center+1, right);//递归调用,求右部分的最大和 int leftBorderSum = 0, maxLeftBorderSum = 0;//定义左边界子序列的和 for(int i=center; i>=left; i--) {//求左边界的最大和(从右边开始往左求和) leftBorderSum += a[i]; if(leftBorderSum > maxLeftBorderSum) { maxLeftBorderSum = leftBorderSum; } } int rightBorderSum = 0, maxRightBorderSum = 0;//定义右边界子序列的和 for(int i=center+1; i<=right; i++) {//求右边界的最大和(从左边开始往右求和) rightBorderSum += a[i]; if(rightBorderSum > maxRightBorderSum) { maxRightBorderSum = rightBorderSum; } } //选出这三者中的最大值并返回(max3(int a, int b, int c)的实现没有给出) return max3(maxLeftSum, maxRightSum, maxLeftBorderSum + maxRightBorderSum); }
有必要对算法三的程序进行一些说明。递归过程调用的通常形式是传递输入的数组和左右边界,它们界定了数组要被处理的部分。第2~8行处理基准状况,让递归调用有退出的机会。若是left==right,那么只有一个元素,而且当该元素非负时,它就是最大子序列。第十一、12行执行两个递归调用。咱们能够看到,递归调用老是对小于原问题的问题进行,不过程序中的小扰动有可能破坏这个特性。14~20行,22~28行分界处左右两边的最大子序列和,这两个值的和就有多是整个序列中的最大子序列和。第31行调用max3方法,求出这三种状况下的最大值,该值即为整个序列的最大子序列和。
显然,算法三须要比设计前两种算法付出更多的编程努力,看上去前面两种算法的代码量要比算法三少量多,然而,程序短并不意味着程序好。测试代表,除较小的输入量外,算法三比前两个算法明显要快。如今来分析如下算法三的时间复杂度。
令T(N)是求解大小为N的最大子序列和问题所花费的时间。若是N=1,则算法3执行程序第2~8行花费某个常数时间,咱们称之为一个时间单位。因而T(1)=1,不然,程序必须执行两个递归调用,即在14~28行之间的两个for循环以及几个小的簿记量,如十、14行。这两个for循环总共接触到A1~An中的每一个元素,而在循环内部的工做量是常量,因而14~28行花费的时间为O(N)。从2~十、1四、22和31行上的程序的工做量是常量,从而与O(N)相比能够忽略。其他就剩下11~12行上运行的工做。这两行求解大小为N/2的子序列问题(假设N为偶数)。所以,这两行每行花费T(N/2)个时间单元,共花费2T(N/2)个时间单元。所以,算法三花费的总时间为2T(N/2)+O(N)。因而咱们获得方程组:
T(1) = 1 T(N) = 2T(N/2) + O(N)
为了简化计算,咱们能够用N代替上面方程中的O(N)项;因为T(N)最终仍是要用大O表示,所以这么作并不影响答案。如今,若是T(N) = 2T(N/2) + N,且T(1) = 1,那么T(2) = 4 = 2*2;T(4) = 12 = 4*3;T(8) = 32 = 8*4;T(16) = 80 = 16*5。用数学概括法能够证实若N=2^k,那么T(N) = 2^k * (k+1) = N * (k+1) = N(logN + 1) = NlogN + N = O(NlogN)。即算法三的时间复杂度为O(NlogN),这明显小于算法二的复杂度O(N2),所以算法三会更快的得出结果。
这个分析假设N是偶数,不然N/2就不肯定了。经过该分析的递归性质可知,实际上只有当N是2的幂时结果才是合理的,不然咱们最终要获得大小不是偶数的子问题,方程就是无效的了。当N不是2的幂时,咱们多少须要更加复杂一些的分析,可是大O的结果仍是不变的。
更优秀的算法
虽然算法三已经足够优秀,将时间复杂度由O(N2)下降为O(NlogN),可是,这并非最优秀的,下面介绍针对这个问题更优秀的解法。
算法四:
public static int maxSubsequenceSum(int[] a) { int maxSum = 0, thisSum = 0;; for(int i=0; i<a.length; i++) { thisSum += a[i]; if(thisSum > maxSum) maxSum = thisSum; else if(thisSum < 0) thisSum = 0; } return maxSum; }
很显然,此算法的时间复杂度为O(N),这小于算法三中的时间复杂度O(NlogN),所以,此算法比算法三更快!方法当然已给出,可是要明白为何此方法能用,还需多加思考。
在算法一和算法二中,i表明子序列的起点,j表明子序列的终点。碰巧的是,咱们不须要知道具体最佳的子序列在哪里,那么i的使用能够从程序上被优化,所以在设计算法的时候假设i是必需的,并且咱们想改进算法二。一个结论是:若是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]被读入并处理,它就再也不须要被记忆。所以,若是数组在磁盘上活经过网络传送,那么它就能够被按顺序读入,在主存中没必要存储改数组的任何部分。不只如此,在任意时刻,算法都能对它已经读入的数据给出子序列问题的正确答案(其余算法不具有这个特性)。具备这种特性的算法叫作“联机算法”。仅须要常量空间并以线性时间运行的联机算法几乎是完美的算法。