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

1. 題目

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

王曉東 《計算機算法設計與分析》

發佈了27 篇原創文章 · 獲贊 9 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章