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.參考
王曉東 《計算機算法設計與分析》