【题目】
给定k个整数的序列{N1,N2,…,Nk },其任意连续子序列可表示为{ Ni, Ni+1, …, Nj },其中 1 <= i <= j <= k。最大连续子序列是所有连续子序中元素和最大的一个,例如给定序列{ -2, 11, -4, 13, -5, -2 },其最大连续子序列为{11,-4,13},最大连续子序列和即为20。
【注】:为方便起见,如果所有整数均为负数,则最大子序列和为0。
解决这样一个问题是一个很有趣的过程,我们可以尝试着从复杂度比较高的算法一步一步地推出复杂度较低的算法。
【算法1】
时间复杂度:O(N^3)
int MaxSubSequence(const int A[], int N){
int ThisSum,MaxSum,i,j,k;
MaxSum = 0;
for(i=0;i<N;i++)
{
for(j=i;j<N;j++)
{
ThisSum = 0;
for(k=i;k<=j;k++)
{
ThisSum += A[k];
}
if(ThisSum > MaxSum)
MaxSum = ThisSum;
}
}
return MaxSum;
}
对于此种算法,其主要方法是穷举法,即求出该序列所有子序列的序列和,然后取最大值即可。
【算法2】
时间复杂度:O(N^2)
int MaxSubSequence(const int A[], int N){
int ThisSum,MaxSum,i,j;
MaxSum = 0;
for(i=0;i<N;i++)
{
ThisSum = 0;
for(j=i;j<N;j++)
{
ThisSum += A[j];
if(ThisSum > MaxSum)
MaxSum = ThisSum;
}
}
return MaxSum;
}
【分析】
对于这种方法,归根究底还是属于穷举法,其间接地求出了所有的连续子序列的和,然后取最大值即可。
那么,这里,我们需要对比一下前面两种算法,为什么同样都是穷举法,但算法一的时间复杂度远高于算法二的时间复杂度?
算法二相较于算法一,其优化主要体现在减少了很多重复的操作。
对于A-B-C-D这样一个序列,
算法一在计算连续子序列和的时候,其过程为:
A-B、A-C、A-D、B-C、B-D、C-D
而对于算法二,其过程为:
A-B、A-C、A-D、B-C、B-D、C-D
其过程貌似是一样的,但是算法一的复杂就在于没有充分利用前面已经求出的子序列和的值。
举个例子,算法一在求A-D连续子序列和的值时,其过程为A-D = A-B + B-C + C-D;
而对于算法二,A-D连续子序列和的求值过程为A-D = A-C+C-D;
这样,算法二充分利用了前面的计算值,这样就大大减少了计算子序列和的步骤。
【算法3:递归法(分治法)】
时间复杂度:O(NlogN)
易知,对于一数字序列,其最大连续子序列和对应的子序列可能出现在三个地方。或是整个出现在输入数据的前半部(左),或是整个出现在输入数据的后半部(右),或是跨越输入数据的中部从而占据左右两半部分。前两种情况可以通过递归求解,第三种情况可以通过求出前半部分的最大和(包含前半部分的最后一个元素)以及后半部分的最大和(包含后半部分的第一个元素)而得到,然后将这两个和加在一起即可。
int MaxSubSequence(const int A[],int N)
{
return MaxSubSum(A,0,N-1);
}
static int MaxSubSum(const int A[], int Left, int Right)
{
int MaxLeftSum,MaxRightSum;
int MaxLeftBorderSum,MaxRightBorderSum;
int LeftBorderSum,RightBorderSum;
int Center,i;
if(Left == Right)
{
if(A[Left] > 0)
return A[Left];
else
return 0;
}
Center = (Left + Right)/2;
MaxLeftSum = MaxSubSequence(A,Left,Center);
MaxRightSum = MaxSubSequence(A,Center+1,Right);
MaxLeftBorderSum = 0;
LeftBorderSum = 0;
for(i = Center;i >= Left;i--)
{
LeftBorderSum += A[i];
if(LeftBorderSum > MaxLeftBorderSum)
MaxLeftBorderSum = LeftBorderSum;
}
MaxRightBorderSum = 0;
RightBorderSum = 0;
for(i = Center+1;i <= Right;i++)
{
RightBorderSum += A[i];
if(RightBorderSum > MaxRightBorderSum)
MaxRightBorderSum = RightBorderSum;
}
return Max(MaxLeftSum,MaxRightSum,MaxLeftBorderSum + MaxRightBorderSum);
}
int Max(int a, int b, int c)
{
if(a>b&&a>c)
return a;
else if(b>a&&b>c)
return b;
else
return c;
}
现在对上面的代码进行相关说明:
Center变量所确定的值将处理序列分割为两部分,一部分为Center前半部,一部分为Center+1后半部。
在上文,我们提到,最大连续子序列的出现位置有三种情况。
对于前两种情况,我们根据递归特性,可以得到:
MaxLeftSum = MaxSubSequence(A,Left,Center);
MaxRightSum = MaxSubSequence(A,Center+1,Right);
而对于第三种情况,我们需要先求出前半部包含最后一个元素的最大子序列:
MaxLeftBorderSum = 0; LeftBorderSum = 0; for(i = Center;i >= Left;i--) { LeftBorderSum += A[i]; if(LeftBorderSum > MaxLeftBorderSum) MaxLeftBorderSum = LeftBorderSum; }
然后,再求出后半部包含第一个元素的最大子序列:
MaxRightBorderSum = 0; RightBorderSum = 0; for(i = Center+1;i <= Right;i++) { RightBorderSum += A[i]; if(RightBorderSum > MaxRightBorderSum) MaxRightBorderSum = RightBorderSum; }
最后,我们只需比较这三种情况所求出的最大连续子序列和,取最大的一个,即可得到需要求解的答案。
return Max(MaxLeftSum,MaxRightSum,MaxLeftBorderSum + MaxRightBorderSum);
我们在介绍这个算法的开始,就已经提到了其时间复杂度,现在做一个推导:
令T(N)是求解大小为N的最大连续子序列和问题所花费的时间。
当N==1时,T(1) = 1;
当N>1时,T(N) = T(N/2) + O(N);
有数学推导公式,我们可以得到:
T(N) = NlogN + N =O(NlogN)。
【
算法四:动态规划法】
时间复杂度:O(N)
终于到了动态规划的部分了,这么一步一步走来,感受到了算法的无穷魅力。那么如何用动态规划来处理这个问题?
首先,我们重温将一个问题用动态规划方法处理的准则:
“最优子结构”、“子问题重叠”、“边界”和“子问题独立”。
在本问题中,我们可以将子序列与其子子序列进行问题分割。
最后得到的状态转移方程为:
MaxSum[i] = Max{ MaxSum[i-1] + A[i], A[i]};
在这里,我们不必设置数组MaxSum[]。
代码实现:
int MaxSubSequence(const int A[], int N)
{
int ThisSum,MaxSum,j;
ThisSum = MaxSum =0;
for(j = 0;j < N;j++)
{
ThisSum += A[j];
if(ThisSum > MaxSum)
MaxSum = ThisSum;
else if(ThisSum < 0)
ThisSum = 0;
}
return MaxSum;
}
在本代码实现中,ThisSum持续更新,同时整个过程,只对数据进行了一次扫描,一旦A[i]被读入处理,它就不再需要被记忆。
小结:
整个过程是一个思想的选择问题,从最初的穷举法,到分治法,再到动态规划法。算法设计思想的灵活选择是处理一个实际问题的关键。