在一些情况下,算法的设计对程序的性能起着很大的作用,因此在有些时候,我们不得不多花一些时间在算法的设计上。
当然,算法的设计不是一言两语能讲清楚的,本章节作者通过对一个小问题进行研究,提出了4种不同的算法,为我们展示了在算法设计中的一些技术。
input:一个具有n个浮点数字的序列x;
output:最大连续子序列和;
看到这个问题,我想最最简单的思路就是遍历了!
maxsofar = 0
for i = [0, n)
for j = [i, n)
sum = 0
//计算子序列x[i...j]的和
for k = [i, j]
sum += x[k]
maxsofar = max(maxsofar, sum)
这个算法的时间复杂度为O(n^3)。
方案1可谓是不假思索得到的,那么只要我们稍加思索一下,就会发现,其中有着大量的重复计算!事实上,x[i…j]与x[i…j-1]密切相关,我们可以通过这一点来加速我们的计算。
maxsofar = 0
for i = [0, n)
sum = 0
for j = [i, n)
sum += x[j]
//此处,本次循环的sum仍可用在下一次循环上,通过累加减少运算
maxsofar = max(maxsofar, sum)
这个算法的时间复杂度为O(n^2),比方案1好了一些。
在编程的时候,我曾经想过一个方案:
对于i = 0,…,n-1,计算出x[0…i]的总和,我们记为presum[i],于是对于x[i…j]的总和,可以通过presum[j]-presum[i-1]来计算。
于是有以下算法:
presum[-1] = 0
for i =[0,n)
presum[i] = presum[i-1] + x[i]
maxsofar = 0
for i =[0,n)
for j =[i,n)
sum = presum[j] - presum[i-1]
maxsofar = max(maxsofar, sum)
这种思路是挺好的,不过遗憾的是,在本问题中,他的复杂度为O(n^2),并没有实际性的改善。
作者在文中提出了第三种方案:分治算法。
将序列划分为两个子序列,假设第一部分的最大连续子序列和为m1,第二部分的最大连续子序列和为m2,那么答案很有可能就是m1和m2两者之一。不过,还有一种可能:答案跨越了两个部分,于是我们假设跨越了两部分的最大值为m3,答案必定在m1,m2,m3之中了。其中,对于m1,m2的计算,我们可以递归地进行。对于m3的计算,它必定包含从边界开始往左边累加得到的最大连续序列,也包含从边界开始往右边累加得到的最大连续序列。
这个方案的时间复杂度是O(n log n)。
作者所说的“扫描算法”,其实就是我们常用的动态规划。
定义b[j]为数组中包含x[j]的最大连续子序列和。
注意,b[j] 并不是1-j中最大的连续子序列的和,而是包含x[j]的最大子序列的和。
而我们所要求的是求出b[j]中最大的值。
状态方程为: b[j] = max(b[j-1] + x[j] , x[j])
//由于对于b数组,b[j-1]只在计算b[j]时用过一次而已,所以我们可以只用一个变量maxendinghere来表示!
maxsofar = 0
maxendinghere = 0
for i = [0,n)
manendinghere = max(maxendinghere + x[i], x[i])
mansofar = max(maxsofar, maxendinghere)
至此,算法已经优化到O(n)了!
相信本章中提到的这个问题,大家并不陌生,看到这个的时候,我是觉得特别亲切的!刚接触编程的时候,用的就是方案1,后来由于OJ上题目的时间限定,问题规模扩大之后便超时了,于是也经历了方案2!而对于方案3,由于跨越分界的特殊情况,所以一时没想到~而方案4,这是在学习动态规划的时候碰到的,当时一开始还不理解,现在回头看,原来如此简单!当然,这种简单,事实上并不简单~它常常需要我们在动手编程前深思熟虑。
本例子虽小,但是却可以看到我们常用的一些算法设计技术:
记忆化搜索:算法2和4对状态进行了保存,避免了重复计算。
预处理:算法2b。
分治:算法3。
动态规划:我如何将x[0…i-1]的解决方案扩展到x[0…i],或者换句话说,假设x[0…i-1]已经解决了,如何得到x[0…i]的解决方案。
累积:算法2b。