如果之前没有看过最大子数组和的解法思想,这一问题很能体现算法的设计能力。当然,算法是两面性的,越简单的效率越低,效率高的算法往往易错和更难理解。本文简单针对此例说说对算法设计的一些感悟
首先是问题定义:给定一个一维数组,其中包含一些负数,求其中任意连续子数组之和的最大值
首先是最基本最简单的思想:求和嘛,就是把所有的和都求出来,然后比较取其中的最大值就好了
maxsofar = 0
for i = [0,n)
for j = [i,n)
sum = 0
for k = [i,j]
sum += x[k]
maxsofar = max(maxsofar,sum)
跑完这个三重循环,maxsofar的值便是所求最大值。显然这一算法的效率非常低(
O(n³)),而且因为x[i...j+1]的和实际上是x[i...j]+x[j+1],而前者已经在上次计算中算得了,所以很容易想到
避免重复计算的优化,将算法的时间复杂度降低一阶,如下
maxsofar = 0
for i = [0,n)
sum = 0
for j = [i,n)
sum += x[j]
maxsofar = max(maxsofar,sum)
或者用另外一种方式,使用一个临时数组保存计算出的x[0...i]的和,这样任意的子数组x[i...j]的和都可以用x[0...j]-x[0...i-1]表示,依次比较即可(算法略),O(n²)
也许到这一步,很多人都已经满足了,或者说,想不出更好的优化方式了。其实不然,算法优化总是有无尽的神秘,总会有更优的方式(应用一些高级算法思想)
当规模比较大时,一般用到的就是分治法,即将问题分成近似相等的两部分,分开解决,最后合并解的结果
于是,我们可以把这个数组从中间分成两个部分,设为a和b,然后,分别计算a中的最大值和b中的最大值,最后结果再取一次最大值,是这样吗?
想想,如果这个最大数组c恰好在a,b之间,即左半部分在a右边界,右半部分在b左边界呢?
so,现在只要找到计算数组c的方法,以及定义边界取值情况(问题是基于左右下标的),算法就可以写出来了。计算数组c的思想在前一算法中已有体现,从中间元素开始,分别向左,向右找到最大的子序列和。然后求它们的和。因为这个子序列是“特殊的”一端固定,所以只需要O(n)时间计算得出
算法设计如下,使用递归方式:
float maxsum(l,r)
if(l>r) return 0
if(l=r) return max(0,x[l])
m = (l+r) /2
lmax = sum = 0
for(i=m;i>=l;i--)
sum += x[i]
lmax = max(lmax,sum)
rmax = sum = 0
for(i=m;i<=r;i++)
sum += x[i]
rmax = max(rmax,sum)
return max(lmax+rmax,maxsum(l,m),maxsum(m+1,r))
这样只要调用一次maxsum(0,n-1)就可以得到结果。也许有人会疑问这样的算法复杂度是不是反而增加了? T(n) = 2T(n/2) + O(n) = O(nlogn),一般来说,只要额外开销不大,分治法都可以达到这样的效率。 这种方式虽然好,但是在边界细节上不处理正确,程序就会跑错。
is that enough good?
其实算法设计也有一定的规律,也就是越通用的算法,应用于特定问题往往效率一般。而针对问题专门设计的算法,虽然丧失了通用性,却能得到很好的效果。来看下面的分析:
这个问题中定义子数组必然是连续的,那么我们用一个长度可变的“扫描器”,把这个数组从头到尾扫描一遍,是不是一定可以扫描到这个最大子数组呢?
显然是可以的。问题的关键就是“扫描器”的长度如何伸缩,是否舍弃了不必要的检测。 打个有趣的比方,有一只虫子,当它吃了正数就会变长,吃了负数就会缩短,于是我们可以让它爬过这个数组,同时保持纪录它成长过程中的最大值,当它爬到终点,不管最后实际长度是怎样的,我们总会纪录到这个最大值。
算法的核心在于负数的处理。试想,当它吃了一个小负数后又吃了一个较大的正数,总长度增加了,需要继续保持纪录。而一旦它遇到很大的负数,该怎么做?
虫子是不会变成负长度的,而我们也没必要留着这个负值,因为后面无论遇到什么样的数,都会“拖低”最大值的计算。所以,果断放弃,让虫子重生为0吧!
这样,我们就有了特有的线性算法:
maxsofar = 0
maxendinghere = 0
for i = [0,n)
maxendinghere = max(maxendinghere+x[i],0)
maxsofar = max(maxsofar,maxendinghere)
maxendinghere即所说虫子的长度,
舍弃负值为新的开始。最终只需要线性扫描一遍即完成算法
(关于算法复杂度所带来的影响,本文不涉及讨论)
结束语:
任何事物都有两面性,算法更是如此。简单易懂的算法容易修改维护,但是执行效率低。常规高级算法(姑且这样叫分治,回溯等思想把)有很多需注意的细节之处,需要多多实践运用才能驾轻就熟,高效运行。而特定算法往往需要非常透彻的分析+经验铺垫后的灵光一现,达到非常简单而意想不到的效果
有时候把算法和生活中的相关例子联系起来想,也是蛮有趣的^_^
(阅读编程珠玑column 8所感,代码均来自原书,思想自创)