目录
前言
本篇对于数据结构里的算法,结合实例进行了较为详细的解说。
最大子列和问题
上一篇提到了有关算法分析的小技巧,下面让我们看看算法分析的应用实例,叫做最大子列和问题。
所谓的最大子列和问题就是:
函数f(i,j)即求从Ai到Aj连续子列的和的最大值。(i,j为任意的小于N的整数,不包括0)
对N个整数来说,有很多这样的连续的子列,我们要求的是所以连续子列和里面的最大的一个。如果这个和是一个负数的话,就返回0作为结束。要解决这个问题有很多不同的算法。
算法1
将所有的连续子列和全部都算出来,然后从中找最大的那一个。(最直接)
int MaxSubseqSuml( int A[],int N) //输入整数序列以及整数序列的个数
{
int ThisSum,MaxSum = 0;
int i,j,k;
for(i=0;i<N;i++){ //i是子列的左端
for(j=i;j<N;j++){ //j是子列的右端,j总是大于等于i
ThisSum = 0; //ThisSum是从A[i]到A[j]的子列和
for(k=i;k<=j;k++)
ThisSum+=A[k];
if(ThisSum>MaxSum) //如果刚得到的这个子列和更大
MaxSum=ThisSum; //则更新结果
} //j循环结束
} //i循环结束
return MaxSum;
}
这个算法的复杂度:
很直观的理由:有三个嵌套的for循环。如果每一层for循环都是从0到N的,那么很显然三个乘在一起就是N的立方。
这个没有那么明显,因为只有第一重循环是从0到N的,但是无论如何用一下我们中学的证明技巧,仍可以证明整个的运算量是N的立方乘一个常数。
这个算法十分的不聪明。当i=0时,每一次计算子列都要从第一个数开始相加;当i=1时,每一次计算子列都要从第二个数开始相加。
当我们知道一个子列的和,计算下一个子列的时候,只需上一个子列的和加到下一个j,即为本次子列的和,不需要每次都从头开始相加。k循环完全是多余的。
算法2
int MaxSubseqSum2(int A[],int N)
{
int ThisSum=0;
int MaxSum=0;
int i,j
for(i=0;i<N;i++){ //i是子列的左端位置
ThisSum=0: //ThisSum是从A[i]到A[j]的子列和
for(j=i;j<N;j++){ //j是子列的右端位置
ThisSum+=A[j]: //对于相同的i,不同的j,只要在j-1的基础上累加1项即可
if(ThisSum>MaxSum) //如果刚得到的这个子列和更大
MaxSum=ThisSum; //则更新结果
} //j循环结束
} //i循环结束
return MaxSum;
}
很显然这个算法有两个循环的嵌套,所以这个算法的复杂度:
算法2要比算法1聪明,因为当N=10000时,这两个算法的效率就相差了10000倍。但是它不是最好的。
一个专业的程序员设计了一个算法,但他发现这个算法是O(N^2)时,下意识的本能就应该想:有没有可能把它改变为N或log N呢?这样就要快很多。
算法3(分而治之)
算法3的名字叫做分而治之,可以用来解决很多的应用题。它的大概思路就是:把一个问题切分成小的块,然后分头去解决它们,最后把结果合并起来。
首先把数组二等分,然后递归的去解决左右两边的问题。递归的去解决左边的问题,会得到左边的最大子列和;递归的去解决右边的问题,会得到右边的最大子列和。但是这样我们就可以说最大子列和是在它们俩之间吗?不一定。还有一种情况,就是跨越边界的最大子列和。找到这三种结果之后,最后的结果一定就是这三个中间最大的那一个。 计算这三种情况就叫做“分”,最后总结就叫做“治”。
例如: | || | ||| | || |
4 | -3 | 5 | -2 | -1 | 2 | 6 | -2 |
4 | 5 | 2 | 6 | ||||
6 | 8 | ||||||
11 |
先从左边的分析。左边的4个数被两次二等分后,先看4与-3,比较后的最大子列和为4,5与-2比较后,最大的子列和为5。跨越边界的最大子列和为6,即为4,-3,5相加的结果。右边的分析亦是如此。最后计算整个的跨越边界的最大为11。
看上去不是很复杂,但是程序比较复杂,这里就不展示程序了。
难点在于怎么分析它的算法复杂度。分析这种递归的算法略微有点难度。
它的想法是:想象当我们解决整个问题有N个数字时,如果把它的复杂度记作T(N),那么当我们将数组二等分后,得到左边的复杂度为T(N/2),因为我们的规模减半了;同样道理,右边的递归结果的时间复杂度为T(N/2)。而怎么得到中间跨边界的最大子列和呢?做法就是从中间开始,分别向左右两边扫描元素,每一个元素都被扫描了一次,所以这个复杂度应该是N的常数倍。由此可以得到关于T(N)的递推公式:
T(N) = 2T(N/2) + c N (左边、右边、中间复杂度之和)
T(1)为一个常数,T(1) = O(1)。怎么递推呢?可以把N换成 N/2
T(N/2) = 2T(N/4) + c N/2
然后将T(N/2)带入T(N)中,即可得到 T(N) = 2【2T(N/2^2) + c N/2】 + c N = 2^2T(N/2^2) + 2 cN
我们会一直展开直到T里面的数为1时为止。过了k步以后,T(N) = 2^k *O(1) + c kN ,其中 N/2^k = 1
所以在分析复杂度时,就有了两项,一项是N乘一个常数,另一项是以2为底的logN乘N乘一个常数。
上一篇中提到过当两个复杂度相加时,取复杂度较大的一项。因此我们就得到整个算法的复杂度为
T(N) = O(N*logN)
算法4:在线处理
N*logN已经是很快的算法了,但是在解决这个问题来说,还不是最快的,更快的算法叫做 在线处理 算法。
int MaxSubseqSum4(int A[].int N)
{
int ThisSum = 0;
int MaxSum = 0;
int i;
for(i=0;i<N;i++){
ThisSum += A[i]; //向右累加
if(ThisSum > MaxSum)
MaxSum = ThisSum; //发现更大和则更新当前结果
else if(ThisSum < 0) //如果当前子列和为负
ThisSum = 0; //则不可能使后面的部分和增大,抛弃之
}
return MaxSum;
}
这个算法的时间复杂度:T(N) = O(N)
算法4中只有一个for循环,for循环里面所有的if,else这些东西都是常数数量级的复杂度,所以很显然这个算法的复杂度是线性的。
这个是我们可以想象的最快的算法,当然一个算法的效率这么高,总会有副作用的。副作用是它的正确性不是那么的明显,即别人理解这个算法是怎么工作的,略微有点困难。下面来看一个具体的例子:
-1 | 3 | -2 | 4 | -6 | 1 | 6 | -1 |
当我们首次for循环时,i=0,ThisSum = -1;if被跳过去,执行else if循环,因为子列和为0,所以ThisSum=0。因为我们要求连续的子列和,下一步我们要顺着往后去求和的,如果现在的和是负数的话,不管加什么数,都只能让后面的数字越来越小,不可能越来越大,所以可以抛弃它。
第二轮我们开始读下一个数字,传进来的是3,发现3比MaxSum大,于是MaxSum更新为3。
再进行一次for循环,加下一个数,于是当前和为正1,1比MaxSum要小,所以MaxSum的值不变,ThisSum的值为1,不会被抛弃。于是我们继续进行循环。
为什么这个算法叫做“在线处理”呢?因为整个过程是一个数字一个数字的读进来,而输入如果停住,后面的输入不读了,现在的程序应返回3,作为当前的最大子列和。对于前三个数的最大子列和,这个结果是正确的。
当进入第五轮循环时,ThisSum=-1,于是ThisSum的值归0,MaxSum的值为5。第六轮循环,ThisSum=1,MaxSum=5;第六轮,ThisSum=7,MaxSum的值更新为7。
为什么会快,因为它利用了这一点:一旦发现当前子列和为负,没有用了,就直接被抛弃,扫描后面的数字。
“在线”的意思是指每输入一个数据就进行即时处理,在任何一个地方中止输入,算法都能正确给出当前的解。
有兴趣的话,可以跑一下,看看4个算法的运行时间怎么样。
下面来进行运行时间比较:
运行时间比较(秒) | |||||
算法 | 1 | 2 | 3 | 4 | |
时间复杂度 | O(N^3) | O(N^2) | O(NlogN) | O(N) | |
输入规模 | N=10 | 0.00103 | 0.00045 | 0.00066 | 0.00034 |
N=100 | 0.47015 | 0.01112 | 0.00486 | 0.00063 | |
N=1000 | 448.77 | 1.01233 | 0.05843 | 0.00333 | |
N=10000 | NA | 111013 | 0.68631 | 0.03042 | |
N=100000 | NA | NA | 8.0113 | 0.29832 |
注:NA即为Not Available
NA即算不下去了。