經典算法問題 - 最大連續子數列和

文章來自http://conw.net/archives/9/算法

(不是抄襲,那是我本身的博客,源地址查看代碼有高亮數組

最大連續子數列和一道很經典的算法問題,給定一個數列,其中可能有正數也可能有負數,咱們的任務是找出其中連續的一個子數列(不容許空序列),使它們的和儘量大。咱們一塊兒用多種方式,逐步優化解決這個問題。測試

爲了更清晰的理解問題,首先咱們先看一組數據:
8
-2 6 -1 5 4 -7 2 3
第一行的8是說序列的長度是8,而後第二行有8個數字,即待計算的序列。
對於這個序列,咱們的答案應該是14,所選的數列是從第2個數到第5個數,這4個數的和是全部子數列中最大的。優化

最暴力的作法,複雜度O(N^3)

暴力求解也是容易理解的作法,簡單來講,咱們只要用兩層循環枚舉起點和終點,這樣就嘗試了全部的子序列,而後計算每一個子序列的和,而後找到其中最大的便可,C語言代碼以下:spa

#include <stdio.h>

//N是數組長度,num是待計算的數組,放在全局區是由於能夠開很大的數組
int N, num[1024];

int main()
{
    //輸入數據
    scanf("%d", &N);
    for(int i = 1; i <= N; i++)
        scanf("%d", &num[i]);
    
    int ans = num[1]; //ans保存最大子序列和,初始化爲num[1]能保證最終結果正確
    //i和j分別是枚舉的子序列的起點和終點,k所在循環計算每一個子序列的和
    for(int i = 1; i <= N; i++) {
        for(int j = i; j <= N; j++) {
            int s = 0;
            for(int k = i; k <= j; k++) {
                s += num[k];
            }
            if(s > ans) ans = s;
        }
    }
    printf("%d\n", ans);

    return 0;
}

 

這個算法的時間複雜度是O(N^3),複雜度的計算方法可參考《算法導論》第一章,若是咱們的計算機能夠每秒計算一億次的話,這個算法在一秒內只能計算出500左右長度序列的答案。.net

一個簡單的優化

若是你讀懂了剛纔的程序,咱們能夠來看一個簡單的優化。
若是咱們有這樣一個數組sum,sum[i]表示第1個到第i個數的和。那麼咱們如何快速計算第i個到第j個這個序列的和?對,只要用sum[j] - sum[i-1]就能夠了!這樣的話,咱們就能夠省掉最內層的循環,讓咱們的程序效率更高!C語言代碼以下:code

#include <stdio.h>

//N是數組長度,num是待計算的數組,sum是數組前綴和,放在全局區是由於能夠開很大的數組
int N, num[16384], sum[16384];

int main()
{
    //輸入數據
    scanf("%d", &N);
    for(int i = 1; i <= N; i++)
        scanf("%d", &num[i]);
    
    //計算數組前綴和
    sum[0] = 0;
    for(int i = 1; i <= N; i++) {
        sum[i] = num[i] + sum[i - 1];
    }

    int ans = num[1]; //ans保存最大子序列和,初始化爲num[1]能保證最終結果正確
    //i和j分別是枚舉的子序列的起點和終點
    for(int i = 1; i <= N; i++) {
        for(int j = i; j <= N; j++) {
            int s = sum[j] - sum[i - 1];
            if(s > ans) ans = s;
        }
    }
    printf("%d\n", ans);

    return 0;
}

 

這個算法的時間複雜度是O(N^2)。若是咱們的計算機能夠每秒計算一億次的話,這個算法在一秒內能計算出10000左右長度序列的答案,比以前的程序已經有了很大的提高!此外,咱們在這個程序中建立了一個sum數組,事實上,這也是沒必要要的,咱們我就也能夠把數組前綴和直接計算在num數組中,這樣能夠節約一些內存。blog

換個思路,繼續優化

你應該據說過度治法,正是:分而治之。咱們有一個很複雜的大問題,很難直接解決它,可是咱們發現能夠把問題劃分紅子問題,若是子問題規模仍是太大,而且它還能夠繼續劃分,那就繼續劃分下去。直到這些子問題的規模已經很容易解決了,那麼就把全部的子問題都解決,最後把全部的子問題合併,咱們就獲得複雜大問題的答案了。可能提及來簡單,可是仍不知道怎麼作,接下來分析這個問題:
首先,咱們能夠把整個序列平均分紅左右兩部分,答案則會在如下三種狀況中:
一、所求序列徹底包含在左半部分的序列中。
二、所求序列徹底包含在右半部分的序列中。
三、所求序列恰好橫跨分割點,即左右序列各佔一部分。
前兩種狀況和大問題同樣,只是規模小了些,若是三個子問題都能解決,那麼答案就是三個結果的最大值。咱們主要研究一下第三種狀況如何解決:
排序

咱們只要計算出:以分割點爲起點向左的最大連續序列和、以分割點爲起點向右的最大連續序列和,這兩個結果的和就是第三種狀況的答案。由於已知起點,因此這兩個結果都能在O(N)的時間複雜度能算出來。遞歸

遞歸不斷減少問題的規模,直到序列長度爲1的時候,那答案就是序列中那個數字。
綜上所述,C語言代碼以下,遞歸實現:

#include <stdio.h>

//N是數組長度,num是待計算的數組,放在全局區是由於能夠開很大的數組
int N, num[16777216];

int solve(int left, int right)
{
    //序列長度爲1時
    if(left == right)
        return num[left];
    
    //劃分爲兩個規模更小的問題
    int mid = left + right >> 1;
    int lans = solve(left, mid);
    int rans = solve(mid + 1, right);
    
    //橫跨分割點的狀況
    int sum = 0, lmax = num[mid], rmax = num[mid + 1];
    for(int i = mid; i >= left; i--) {
        sum += num[i];
        if(sum > lmax) lmax = sum;
    }
    sum = 0;
    for(int i = mid + 1; i <= right; i++) {
        sum += num[i];
        if(sum > rmax) rmax = sum;
    }

    //答案是三種狀況的最大值
    int ans = lmax + rmax;
    if(lans > ans) ans = lans;
    if(rans > ans) ans = rans;

    return ans;
}

int main()
{
    //輸入數據
    scanf("%d", &N);
    for(int i = 1; i <= N; i++)
        scanf("%d", &num[i]);

    printf("%d\n", solve(1, N));

    return 0;
}

 

不難看出,這個算法的時間複雜度是O(N*logN)的(想一想歸併排序)。它能夠在一秒內處理百萬級別的數據,甚至千萬級別也不會顯得很慢!這正是算法的優美之處。對遞歸不太熟悉的話可能會對這個算法有所疑惑,那可就要仔細琢磨一下了。

動態規劃的魅力,O(N)解決!

不少動態規劃算法很是像數學中的遞推。咱們若是能找到一個合適的遞推公式,就能很容易的解決問題。
咱們用dp[n]表示以第n個數結尾的最大連續子序列的和,因而存在如下遞推公式:
dp[n] = max(0, dp[n-1]) + num[n]
仔細思考後不難發現這個遞推公式是正確的,則整個問題的答案是max(dp[m]) | m∈[1, N]。C語言代碼以下:

#include <stdio.h>

//N是數組長度,num是待計算的數組,放在全局區是由於能夠開很大的數組
int N, num[134217728];

int main()
{
    //輸入數據
    scanf("%d", &N);
    for(int i = 1; i <= N; i++)
        scanf("%d", &num[i]);
    
    num[0] = 0;
    int ans = num[1];
    for(int i = 1; i <= N; i++) {
        if(num[i - 1] > 0) num[i] += num[i - 1];
        else num[i] += 0;
        if(num[i] > ans) ans = num[i];
    }

    printf("%d\n", ans);

    return 0;
}

 

這裏咱們沒有建立dp數組,根據遞歸公式的依賴關係,單獨一個num數組就足以解決問題,建立一個一億長度的數組要佔用幾百MB的內存!這個算法的時間複雜度是O(N)的,因此它計算一億長度的序列也不在話下!不過你若是真的用一個這麼大規模的數據來測試這個程序會很慢,由於大量的時間都耗費在程序讀取數據上了!

另闢蹊徑,又一個O(N)的算法

考慮咱們以前O(N^2)的算法,即一個簡單的優化一節,咱們還有沒有辦法優化這個算法呢?答案是確定的!
咱們已知一個sum數組,sum[i]表示第1個數到第i個數的和,因而sum[j] - sum[i-1]表示第i個數到第j個數的和。
那麼,以第n個數爲結尾的最大子序列和有什麼特色?假設這個子序列的起點是m,因而結果爲sum[n] - sum[m-1]。而且,sum[m]必然是sum[1],sum[2]...sum[n-1]中的最小值!這樣,咱們若是在維護計算sum數組的時候,同時維護以前的最小值, 那麼答案也就出來了!爲了節省內存,咱們仍是隻用一個num數組。C語言代碼以下:

#include <stdio.h>

//N是數組長度,num是待計算的數組,放在全局區是由於能夠開很大的數組
int N, num[134217728];

int main()
{
    //輸入數據
    scanf("%d", &N);
    for(int i = 1; i <= N; i++)
        scanf("%d", &num[i]);
    
    //計算數組前綴和,並在此過程當中獲得答案
    num[0] = 0;
    int ans = num[1], lmin = 0;
    for(int i = 1; i <= N; i++) {
        num[i] += num[i - 1];
        if(num[i] - lmin > ans)
            ans = num[i] - lmin;
        if(num[i] < lmin)
            lmin = num[i];
    }

    printf("%d\n", ans);

    return 0;
}

 

看起來咱們已經把最大連續子序列和的問題解決得很完美了,時間複雜度和空間複雜度都是O(N),不過,咱們確實還能夠繼續!

大道至簡,最大連續子序列和問題的完美解決

很顯然,解決此問題的算法的時間複雜度不可能低於O(N),由於咱們至少要算出整個序列的和,不過若是空間複雜度也達到了O(N),就有點說不過去了,讓咱們把num數組也去掉吧!

#include <stdio.h>

int main()
{
    int N, n, s, ans, m = 0;

    scanf("%d%d", &N, &n); //讀取數組長度和序列中的第一個數
    ans = s = n; //把ans初始化爲序列中的的第一個數
    for(int i = 1; i < N; i++) {
        if(s < m) m = s;
        scanf("%d", &n);
        s += n;
        if(s - m > ans)
            ans = s - m;
    }
    printf("%d\n", ans);

    return 0;
}

 

這個程序的原理和另闢蹊徑,又一個O(N)的算法中介紹的同樣,在計算前綴和的過程當中維護以前獲得的最小值。它的時間複雜度是O(N),空間複雜度是O(1),這達到了理論下限!惟一比較麻煩的是ans的初始化值,不能直接初始化爲0,由於數列可能全爲負數!

至此,最大連續子序列和的問題已經被咱們完美解決!然而以上介紹的算法都只是直接求出問題的結果,而不能求出具體是哪個子序列,其實搞定這個問題並不複雜,具體怎麼作留待讀者思考吧!

相關文章
相關標籤/搜索