1. 题目
2. 题干分析
- 输入分析: 输入的数组的下标是1到n。但是在大部分程序设计语言中,数组下标都是0到n-1,所以在程序实现的时候,注意下标+1.
- 输入分析: 序列的最大长度为100000 (10万), 序列中的值的取值范围是[−1000,1000]。
为什么会如此限制序列的范围呢?原因是要使得序列和满足如下不等式:
int32.MIN≤∑i=1nai≤int32.MAX
很明显,
100000∗(−1000)=−108≥int32.MIN
100000∗1000=108≤int32.MAX
所以,说在程序中使用 32位的有符号整型存储最大和值就足够了。 - 输出分析: 从Sample Output中,可以看出第二个输出结果还是比较奇怪的。输出是(7, 1, 6),意思是最大子序列和是7,起始下标是1,结束下标是6。就是下图中的数组下方指出的解。但是,(7, 6, 6)也是一种可能的解,如下图数组右上方所示。而且(我认为)二者之间并没有顺序关系。题目要求输出第一个解,但是不能区分开这两种解。所以在程序实现的时候,要注意这个细节。
- 题目没有明确说明的问题: 如果序列全为负数的时候,解是不是可以认为是0。也就是说,不选任何元素也看做是一种特殊的子序列。从输出的格式上看,因为要输出最优解的起始和结束下标,考虑到如果将全负数序列的最优解看做零,是无法输出的,所以,将全负数序列的最优解看做零的可能性不大。
3. 题目形式化描述
经过上面的分析,我们给出相关概念与题目的形式化描述,
3.1子序列
3.2 子序列和
3.3 题目的形式化描述
对于输入的整数序列:
求得maxSum,iMax,jMax 满足:
- maxSum满足:
- 对iMax,jMax,
我们先定义一个解集合(很明显,可能会有多各解)solutionSet,
如下:
solutionSet={(i,j)|∑k=ijak=maxSum}
那么, 根据题目中的"If there are more than one result, output the first one.",我们的解要满足:
对于 ∀(i,j)∈solutionSet,都有 iMax≤i
所以,对于题目中的第二个示例的解,我们就没那么困惑了。其实这个地方我们是定义了解之间的序关系,它是由起始下标决定的。
4. 解题思路与分析
比较常见的解题方法有三种,在王晓东编著的《计算机算法设计与分析》有一节最大子段和一节讲了这三个方法:全部遍历的常规方法、分治算法和动态规划。三者的时间复杂度分别是O(n2)、O(nlogn)和O(n)。这里,我当然要用最快的方法。下面就简单介绍如何用动态规划方法解决这个问题。
其实,在我看来这个题目并不是动态规划的典型例子。它只是部分过程用到了动态规划的技术。首先,我们将这个问题做一个转换,如下:
如果将
现在问题就转化为从 b1,b2,...,bn 中找出最大值,便是最终我们需要的解。如何计算bj呢?这个地方我们就可以从动态规划的角度去考虑了。众所周知,动态规划主要分四步:
- 看最优值是否能从子问题的最优值中得来
- 递归地定义最优值
- 根据递归的定义,以自底向上的方式计算出最优值
- 在计算最优值的过程中,记录下最优解
那么,我们就按照以上四步,来分析和计算bj。bj是不是可以从bj−1计算来呢?显然,bj和bj−1的最优解就差一个aj,而bj肯定要包含aj。那么,是不是bj−1的最优解再接上aj就是bj的最优解呢?这就要依情况而定,如果bj−1<0,bj对应的最优解是aj;否则,才是bj−1的最优解接上aj。那么,我们可以递归地定义最优值,如下:
为解决最终问题,我们要计算出b1,b2,...,bn。其实,由上式可知,计算bn的过程,就计算出了我需要的所有结果。为计算bn
, 我们写出如下递归方法:
public class Main{
public static int getBj(int[] a,int j){
if( j==0 ){
return a[0];
}
int bjminusone=getBj(a, j-1)
if( bjminusone < 0 ){
return a[j]
}else{
return bjminusone+a[j];
}
}
public static void main(final String args[]){
int[] a = ...;
int bn=getBj(a,a.length-1)
}
}
再将这个递归算法转换为自底向上的迭代算法。注意代码中用b[j]来表示bj:
//用b来存储b[j]的值
int b =-1;
for( int i=0; i < a.length; i++ ){
if( b < 0 ) {
//The b[i] is a[i]
b = a[i];
}else{
b = b+a[i];
}
}
//循环结束后,b中存储了b[n]。但是在每次迭代中,我们都计算了b[i]。
//每次迭代,我们也重用了上一次子问题的解。这也正是动态规划的特色。
很明显,我们离最终答案已经很近了。因为我们每一步已经计算出了bj。只要我们记录一下b1,b2,...,bn的最大值。我们就得到了最优值。然后,再想办法记录下起始和结束地址。就得到了最终的答案。完整且通过测试的答案参看我的解答一节。
5. 测试用例
这些测试用例,我还没有测过,但是在实现程序的时候,总要考虑这些或典型或特殊的情况。
输入序列 | 最大值 | 起始下标 | 结束下标 |
---|---|---|---|
-1 | -1 | 1 | 1 |
10 | 10 | 1 | 1 |
-1,1 | 1 | 2 | 2 |
-3,-1,-2 | -1 | 2 | 2 |
2,3,1 | 6 | 1 | 3 |
0,0,1 | 1 | 1 | 3 |
0,0,-1 | 0 | 1 | 1 |
-1,1,6,-1 | 6 | 1 | 3 |
6. 我的解答
import java.io.*;
import java.util.Scanner;
public class Main {
/**
* @param a 包含输入序列的Java整形数组
* @return 大小为3的数组,[0]为最优值,[1],[2]分别为最优值得
* 起始和结束下标
*/
public static int[] maxSubSequence(int[] a) {
int[] ret = new int[3];
int n = a.length;
//由题目可知,最大子段和一定大于-1001
//所以将初始的最大子段和设置为-1001
int maxSum = -1001;
//b为初始的记录b[j]的变量
//将它设置为-1,由递归式可知,第一次迭代就会被舍弃
int b = -1;
//maxStart和maxEnd记录最优解
//初始值设为-1,在程序出错时,更容易debug
int maxStart = -1;
int maxEnd = -1;
//start和end记录b[j]的开始和结束下标
//maxStart和maxEnd就是从这些start和end选出的。
int start = -1;
int end = -1;
//开始循环求解
for (int i = 0; i < n; i++) {
if (b < 0) {
//b[i-1]小于零,所以b[i]为a[i],
//那么,b[i]的起始和结束下标都为i,记录下来
b = a[i];
start = i;
end = i;
} else {
//否者, b[i]就为b[i-1]+a[i]
//起始下标保持不变,更新结束下标到i
b = b + a[i];
end = i;
}
//更新题目的最终的最优解
if (b > maxSum) {
maxSum = b;
maxStart = start;
maxEnd = end;
}
}
ret[0] = maxSum;
//因为题目的输入时1到n,所以注意+1
ret[1] = maxStart + 1;
ret[2] = maxEnd + 1;
return ret;
}
public static void main(final String[] args) {
Scanner in = new Scanner(new BufferedReader(new InputStreamReader(System.in)));
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
int caseNumber = 0;
if (in.hasNextInt()) {
caseNumber = in.nextInt();
}
for (int i = 0; i < caseNumber; i++) {
int arraySize = 0;
if (in.hasNextInt()) {
arraySize = in.nextInt();
}
int[] array = new int[arraySize];
for (int j = 0; j < arraySize; j++) {
array[j] = in.nextInt();
}
int[] result = maxSubSequence(array);
out.printf("Case %s:\r\n", i + 1);
if (i == caseNumber - 1) {
out.printf("%s %s %s\r\n", result[0], result[1], result[2]);
} else {
out.printf("%s %s %s\r\n\r\n", result[0], result[1], result[2]);
}
}
out.flush();
}
}
7. 正确性证明与分析
7.1 第一次循环能够正确执行
由于b<0,所以一次循环更新 b=a[0], start=0, end=0。
对应地,由于maxSum初始值为-1001一定小于a[0], 所以,会更新: maxSum=a[0],maxStart=0,maxEnd=0。
7.2 如果第i-1次循环能够正确完成,第i次循环也能够正确完成
此处分析略。基本上就是上面递归式的重复。
7.3 循环可以正确结束
循环结束是在i=n-1计算完成后。这个时候,我们整个循环就已经计算出了b1,b2,...,bn, 并选出了最优值和对应的起始和结束下标。
8. 复杂度分析
很显然,时间复杂度O(n),空间复杂度O(1)。
9. 运行结果与评价
10 典型测试用例的运行过程
为了加强对算法的理解,可以用纸和笔手工按照这个算法运行一下。
输入: -1,6,-3,8,-12,5 期待最优值:11
10.1 动态规划
用表格的方法实施动态规划的方法如下,
基本评价:
- 时间复杂度: O(n)
- 加法运算次数:3 次
- 空间复杂度: O(1)
- 程序复杂度: 简单
动态规划算法有点像是一种贪婪算法,又可以得到最优解。
10.2 枚举法(最简单直观的方法)
用表格的方法实施最简单的枚举法,如下:
基本评价:
- 时间复杂度: O(n^2),
- 加法预算次数: 15次
- 空间复杂度: O(1)
- 程序复杂度: 简单
- 分析: 没有避免计算无用的解,例如 (1,2)。
这个算法可以作为衡量正确性的标准或是生成测试用例。而且我们很容易想到的就是这种算法,我们在具体解决问题是可以先用这个算法,以加深对问题的理解,并分析该算法的优缺点,从而,可以发散出优化的思路。
10.3 分治法
分治法是递归实现的。具体方法可见王晓东编著的《计算机算法设计与分析》最大子段和一节。所以,我要画个树去实施该算法,如下:
基本评价:
- 时间复杂度:O(nlgn)
- 加法运算次数: 11 次
- 空间复杂度:O(lgn)
- 程序复杂度: 较复杂
就从树的结构来看,该算法就比其他两种算法要复杂的多。而且有些东西还需要额外的解释才能看得懂。上面的蓝色方框是分解子问题,黄色方框是开始求解,也就是递归调用的一层层返回。最下面的蓝色,就是递归调用完成得到的最优解。下面解释一下红色方框是什么意思。因为分治是分成的两个子问题,但是子问题中更好的最优解并不一定是父问题的最优解,还要看有没有横跨两个子问题序列的最优解更好。而红色方框计算的就是这个问题。红色方框旁边是加法次数。
11. 遇到的问题,分析与经验:
11.1 解题顺序
- 理解题目,写形式化表达和典型测试用例。
- 寻找解题方法,先尝试暴力的枚举,逐步优化;或者分析问题结构与特点
- 写伪代码,如果需要,划分一下模块。如果,问题比较大,可以先考虑划分模块
- 实现代码
- 代码走读、分析、证明和复查。注意边界条件
- 运行测试用例
11.2 分析出问题所在,而不是针对某个失败测试尝试修修补补。
对于题目中的第二例子(0 6 -1 1 -6 7 -5),我的程序开始的输出是(7,6,6)。对于这个失败的测试用例,我就加了如下一句:
if( iMax == jMax ){
iMax = 0;
}
然后,再去试一下能不能通过在线评判。这明显是修修补补而不是找到问题的所在,问题的症结是在于如下的判断条件:
if( b <= 0 ){
...
}else{
...
}
改为b<0才能真正修掉程序的问题。
12.参考
王晓东 《计算机算法设计与分析》