HDUOJ_1003: 最大子段和解法深入解读

来源:互联网 发布:温州淘宝打包招聘 编辑:程序博客网 时间:2024/05/17 22:10

1. 题目

  • Max Sum 最大子段和 (Click Me)

2. 题干分析

  • 输入分析: 输入的数组的下标是1到n。但是在大部分程序设计语言中,数组下标都是0到n-1,所以在程序实现的时候,注意下标+1.
  • 输入分析: 序列的最大长度为100000 (10万), 序列中的值的取值范围是[1000,1000]。 为什么会如此限制序列的范围呢?原因是要使得序列和满足如下不等式:
    int32.MINi=1naiint32.MAX

    很明显,
    100000(1000)=108int32.MIN

    1000001000=108int32.MAX

    所以,说在程序中使用 32位的有符号整型存储最大和值就足够了。
  • 输出分析: 从Sample Output中,可以看出第二个输出结果还是比较奇怪的。输出是(7, 1, 6),意思是最大子序列和是7,起始下标是1,结束下标是6。就是下图中的数组下方指出的解。但是,(7, 6, 6)也是一种可能的解,如下图数组右上方所示。而且(我认为)二者之间并没有顺序关系。题目要求输出第一个解,但是不能区分开这两种解。所以在程序实现的时候,要注意这个细节。
    子序列和的多解
  • 题目没有明确说明的问题: 如果序列全为负数的时候,解是不是可以认为是0。也就是说,不选任何元素也看做是一种特殊的子序列。从输出的格式上看,因为要输出最优解的起始和结束下标,考虑到如果将全负数序列的最优解看做零,是无法输出的,所以,将全负数序列的最优解看做零的可能性不大。

3. 题目形式化描述

经过上面的分析,我们给出相关概念与题目的形式化描述,

3.1子序列

ai,...,aj  1ijn

3.2 子序列和

k=ijak  1ijn

3.3 题目的形式化描述

对于输入的整数序列:

a1,a2,a3,...,an  1n105,103ai103

求得maxSum,iMax,jMax 满足:

  1. maxSum满足:

maxSum=max1ijnk=ijak

  • iMax,jMax, 我们先定义一个解集合(很明显,可能会有多各解)solutionSet, 如下:
    solutionSet={(i,j)|k=ijak=maxSum}

    那么, 根据题目中的"If there are more than one result, output the first one.",我们的解要满足:
     (i,j)solutionSet, iMaxi

    所以,对于题目中的第二个示例的解,我们就没那么困惑了。其实这个地方我们是定义了解之间的序关系,它是由起始下标决定的。

4. 解题思路与分析

比较常见的解题方法有三种,在王晓东编著的《计算机算法设计与分析》有一节最大子段和一节讲了这三个方法:全部遍历的常规方法、分治算法和动态规划。三者的时间复杂度分别是O(n2)O(nlogn)O(n)。这里,我当然要用最快的方法。下面就简单介绍如何用动态规划方法解决这个问题。
其实,在我看来这个题目并不是动态规划的典型例子。它只是部分过程用到了动态规划的技术。首先,我们将这个问题做一个转换,如下:

maxSum=max1ijnk=ijak=max1jnmax1ijk=ijak

如果将
max1ijk=ijak
记为bj, 我们可以得到
maxSum=max1jnbj

现在问题就转化为从 b1,b2,...,bn 中找出最大值,便是最终我们需要的解。如何计算bj呢?这个地方我们就可以从动态规划的角度去考虑了。众所周知,动态规划主要分四步:
- 看最优值是否能从子问题的最优值中得来
- 递归地定义最优值
- 根据递归的定义,以自底向上的方式计算出最优值
- 在计算最优值的过程中,记录下最优解
那么,我们就按照以上四步,来分析和计算bjbj是不是可以从bj1计算来呢?显然,bjbj1的最优解就差一个aj,而bj肯定要包含aj。那么,是不是bj1的最优解再接上aj就是bj的最优解呢?这就要依情况而定,如果bj1<0,bj对应的最优解是aj;否则,才是bj1的最优解接上aj。那么,我们可以递归地定义最优值,如下:
bj=max{bj1+aj,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-111101011-1,1122-3,-1,-2-1222,3,16130,0,11130,0,-1011-1,1,6,-1613

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.参考

王晓东 《计算机算法设计与分析》

0 0