给定(可能有负的)整数
A1
,
A2
,…,
AN
,求
∑jk=iAk
的最大值(不要求子串具体的索引位置,为方便起见,若所有整数均为负数,则最大子序列和为0)。—-数据结构与算法分析Java语言描述
例如:对于输入-2,11,-4,13,-5,-2,答案为20(从
A2
到
A4
)。
我们将讨论求解该问题的四种算法,在某台计算机上的运行时间如下表所示。
输入大小 | 算法时间 | |||
---|---|---|---|---|
O(N3) | O(N2) | O(NlogN) | O(N) | |
N = 100 | 0.000159 | 0.000006 | 0.000005 | 0.000002 |
N = 1000 | 0.095857 | 0.000371 | 0.000060 | 0.000022 |
N = 10000 | 86.67 | 0.033322 | 0.000619 | 0.000222 |
N = 100000 | NA | 3.33 | 0.006700 | 0.002205 |
N = 1000000 | NA | NA | 0.074870 | 0.022711 |
从表中可以看出,对于小量的输入,这些算法都能在非常快的时间内完成,因此如果输入量很小,就没有必要设计聪明的算法了。对于大量的输入,显然算法4是最好的选择(当然算法3也是可用的)。
其次,表中所给出的时间不包括读入数据所需要的时间。对于算法4,仅仅从磁盘读入数据所用的时间很可能在数量级上比求解上述问题所需要的时间还要大,这也是许多有效算法的特点。
算法1:
算法1如以下代码所示,只是穷举式地尝试所有的可能,本算法不计算实际的子序列。
// Cubic maxium contiguous subsequence sum algorithm
public static int maxSubSum1(int[] a){
int maxSum = 0;
for(int i=0;i<a.length;i++)
for(int j=i;j<a.length;j++){
int thisSum = 0;
for(int k=i;k<=j;k++)
thisSum += a[k];
if(thisSum > maxSum)
maxSum = thisSum;
}
return maxSum;
}
该算法运行时间为
O(N3)
。事实上,考虑到这些循环的实际大小,更精确的分析指出答案是
Θ(N3)
,而我们的估计高6倍(不过这并没有什么影响,毕竟常数不影响数量级)。精确的分析由
∑N−1i=0∑N−1j=i∑jk=i1
得到,利用前N个整数求和以及前N个平方数求和的公式可以得到:
∑N−1i=0∑N−1j=i∑jk=i1=(N3+3N2+2N)/6
我们可以通过撤除一个for
循环来避免三次的运行时间。纠正这种低效率的改进算法可以通过观察
∑jk=iAk=Aj+∑j−1k=iAk
而看出,因此算法1中第三层循环的计算过分地耗费了。下列代码块给出了一种改进的算法。
算法2:
算法2显然是
O(N2)
。
// Quadratic maxium contiguous subsequence sum algorithm
public static int maxSubSum2(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;
}
算法3
对这个问题有一个递归和相对复杂的
O(NlogN)
解法,该方法采用一种“分治(divide-and-conquer)”策略,其想法是把问题分成两个大致相等的子问题,然后递归地对它们求解,这是“分”的部分。“治”阶段将两个子问题的解修补到一起并可能再做些少量的附加工作,最后得到整个问题的解。
在我们的例子中,最大子序列和可能在三处出现,或者整个出现在输入数据的左半部,或者整个出现在输入数据的右半部,或者跨越输入数据的中部从而位于左右两半部分之中。前两种情况可以递归求解,第三中情况的最大和可以通过求出前半部分(包含前半部分最后一个元素)的最大和以及后半部分(包含后半部分第一个元素)的最大和而得到,此时将两个和相加。
// Recursive maxium contiguous subsequence sum algorithm.
// Finds maxium sum in subarray spanning a[left..right].
// Does not attempt to maintain actual best sequence.
private static int maxSumRec(int[] a, int left, int right){
if(left == right) // Base case
if(a[left] > 0)
return a[left];
else
return 0;
int center = (left+right)/2;
int maxLeftSum = maxSumRec(a, left, center);
int maxRightSum = maxSumRec(a, center+1, right);
int maxLeftBorderSum = 0;
int leftBorderSum = 0;
for(int i=center;i>=left;i--){
leftBorderSum += a[i];
if(leftBorderSum > maxleftBorderSum)
maxLeftBorderSum = leftBorderSum;
}
int maxRightBorderSum = 0;
int rightBorderSum = 0;
for(int i=center+1;i<=right;i++){
rightBorderSum += a[i];
if(rightBorderSum > maxrightBorderSum)
maxrightBorderSum = rightBorderSum;
}
return max3(maxLeftSum, maxRightSum,
maxLeftBorderSum+maxRightBorderSum);
}
// Driver for divide-and-conquer maxium contiguous
// subsequence sum algorithm
public static int maxSunbSum3(int[] a){
return maxSumRec(a, 0, a.length-1);
}
根据分析的递归性质可知,实际上只有N是2的幂时结果才是合理的,否则我们最终要得到大小不是偶数的子问题,方程就是无效的;当N不是2的幂时,我们多少需要更加复杂一些的分析,但是大
O
<script type="math/tex" id="MathJax-Element-4403">O</script>的结果是不变的。
算法4
// Linear-time maxium contiguous subsequence sum algorithm
public static int maxSubSum4(int[] a){
int maxSum = 0;
int thisSum = 0;
for(int j=0;j<a.length;j++){
thisSum += a[j];
if(thisSum > maxSum)
maxSum = thisSum;
else if(thisSum < 0)
thisSum = 0;
}
return maxSum;
}
根算法1和算法2一样,j代表当前序列的终点,而i代表当前序列的起点。如题中所说,我们不需要知道最佳子序列的索引位置,于是i的还有那个可以从程序上被优化,因此在设计算法的时候假设i是需要的,而且我们想要改进算法2。
如果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
是没有风险的:我们一个最优解也不会错过。