絕妙的算法——最大子序列和問題

問題的引入算法

    給定(可能有負數)整數序列A1, A2, A3..., An, 求這個序列中子序列和的最大值。(爲方便起見,若是全部整數均爲負數,則最大子序列和爲0)。例如:輸入整數序列: -2, 11, 8, -4, -1, 16, 5, 0,則輸出答案爲35,即從A2~A6。編程

    這個問題之因此有吸引力,主要是由於存在求解它的不少算法,而這些算法的性能差別又很大。這些算法,對於少許的輸入差異都不大,幾個算法都能在瞬間完成,這時若花費大量的努力去設計聰明的算法恐怕就不太值得了;可是若是對於大量的輸入,想要更快的獲取處理結果,那麼設計精良的算法顯得頗有必要。數組

切入正題網絡

    下面先提供一個設計最不耗時間的算法,此算法很容易設計,也很容易理解,但對於大量的輸入而言,效率過低:性能

    算法一:測試

public static int maxSubsequenceSum(int[] a) {
    int maxSum = 0;
    for(int i=0; i<a.length; i++) {        //i爲子序列的左邊界
        for(int j=i; j<a.length; j++) {    //j爲子序列的右邊界
            int thisSum = 0;
            for(int k=0; k<=j; k++)        //迭代子序列中的每個元素,求和
                thisSum += a[k];
            if(thisSum > maxSum)
                maxSum = thisSum;
        }
    }
    return maxSum;
}

    上述設計很容易理解,它只是窮舉各類可能的結果,最後得出最大的子序列和。毫無疑問,這個算法可以正確的得出和,可是若是還要得出是哪一個子序列,那麼這個算法還須要添加一些額外的代碼。優化

    如今來分析如下這個算法的時間複雜度。運行時間的多少,徹底取決於第六、7行,它們由一個含有三重嵌套for循環中的O(1)語句組成:第3行上的循環大小爲N,第4行循環大小爲N-i,它可能很小,但也多是N。咱們在判斷時間複雜度的時候必須取最壞的狀況。第6行循環大小爲j-i+1,咱們也要假設它的大小爲N。所以總數爲O(1*N*N*N)=O(N3)。第2行的總開銷爲O(1),第八、9行的總開銷爲O(N2),由於它們只是兩層循環內部的簡單表達式。this

    咱們能夠經過拆除一個for循環來避免3次的運行時間。不過這不老是可能的,在這種狀況下,算法中出現大量沒必要要的計算。糾正這種低效率的改進算法能夠經過觀察Sum(Ai~Aj) = Aj + Sum(Ai~A[j-1])而看出,所以算法1中第六、7行上的計算過度的耗費了。下面是在算法一的基礎上改進的一種算法:spa

    算法二:設計

public static int maxSubsequenceSum(int[] a) {
    int maxSum = 0;
    for(int i=0; i<a.length; i++) {
        int thisSum = 0;
        for(int j=i; j<a.length; j++) {
            thisSum += a[j];
            if(thisSum > maxSum)
                maxSum = thisSum;
        }
    }
    return maxSum;
}

    對於此算法,時間複雜度顯然是O(N2),對它的分析甚至比前面的分析還要簡單,就是直接使用窮舉法把序列中i後面的每一個值相加,若是發現有比maxSum大的,則更新maxSum的值。

    對於這個問題,有一個遞歸和相對複雜的O(NlogN)解法,咱們如今就來描述它。要是真的沒有出現O(N)(線性的)解法,這個算法就會是體現遞歸爲例的極好的範例了。該方法採用一種「分治」策略。其想法就是吧問題分紅兩個大體相等的子問題,而後遞歸地對它們求解,這是「分」的階段。「治」階段就是將兩個子問題的解修補到一塊兒並可能再作些少許的附加工做,最後獲得整個問題的解。

    在咱們的例子中,最大子序列的和只可能出如今3個地方:

  1. 出如今輸入數據的左半部分

  2. 出如今輸入數據的右半部分

  3. 跨越輸入數據的中部而位於左右兩個部分

    前兩種狀況能夠遞歸求解,第三種狀況的最大和能夠經過求出前半部分(包含前半部分的最後一個元素)的最大和以及後半部分(包括後半部分的第一個元素)的最大和,再將兩者相加獲得。做爲例子,考慮如下輸入:

-----------------------------------------
    前半部分           後半部分  
-----------------------------------------
-2, 11, 8, -4,    -1, 16, 5, 0   
-----------------------------------------

    其中,前半部分的最大子序列和爲19(A2~A3),然後半部分的最大子序列和爲21(A6~A7)。前半部分包含其最後一個元素的最大和是15(A2~A4),後半部分包含第一個元素的最大和是20(A5~A7)。所以,跨越這兩部分的這個子序列纔是擁有最大和的子序列,和爲15+20=35(A2~A7)。因而出現了下面這種算法:

    算法三:

public static int maxSubsequenceSum(int[] a, int left, int right) {
    if(left == right) { //Base case
        if(a[left] > 0) {
            return a[left];
        } else {
            return 0; //保證最小值爲0
        }
    }
    
    int center = (left+right)/2;
    int maxLeftSum = maxSubsequenceSum(a, left, center); //遞歸調用,求左部分的最大和
    int maxRightSum = maxSubsequenceSum(a, center+1, right);//遞歸調用,求右部分的最大和
    
    int leftBorderSum = 0, maxLeftBorderSum = 0;//定義左邊界子序列的和
    for(int i=center; i>=left; i--) {//求左邊界的最大和(從右邊開始往左求和)
        leftBorderSum += a[i];
        if(leftBorderSum > maxLeftBorderSum) {
            maxLeftBorderSum = leftBorderSum;
        }
    }
    
    int rightBorderSum = 0, maxRightBorderSum = 0;//定義右邊界子序列的和
    for(int i=center+1; i<=right; i++) {//求右邊界的最大和(從左邊開始往右求和)
        rightBorderSum += a[i];
        if(rightBorderSum > maxRightBorderSum) {
            maxRightBorderSum = rightBorderSum;
        }
    }
    
    //選出這三者中的最大值並返回(max3(int a, int b, int c)的實現沒有給出)
    return max3(maxLeftSum, maxRightSum, maxLeftBorderSum + maxRightBorderSum);
}

    有必要對算法三的程序進行一些說明。遞歸過程調用的通常形式是傳遞輸入的數組和左右邊界,它們界定了數組要被處理的部分。第2~8行處理基準狀況,讓遞歸調用有退出的機會。若是left==right,那麼只有一個元素,而且當該元素非負時,它就是最大子序列。第十一、12行執行兩個遞歸調用。咱們能夠看到,遞歸調用老是對小於原問題的問題進行,不過程序中的小擾動有可能破壞這個特性。14~20行,22~28行分界處左右兩邊的最大子序列和,這兩個值的和就有多是整個序列中的最大子序列和。第31行調用max3方法,求出這三種狀況下的最大值,該值即爲整個序列的最大子序列和。

    顯然,算法三須要比設計前兩種算法付出更多的編程努力,看上去前面兩種算法的代碼量要比算法三少量多,然而,程序短並不意味着程序好。測試代表,除較小的輸入量外,算法三比前兩個算法明顯要快。如今來分析如下算法三的時間複雜度。

    令T(N)是求解大小爲N的最大子序列和問題所花費的時間。若是N=1,則算法3執行程序第2~8行花費某個常數時間,咱們稱之爲一個時間單位。因而T(1)=1,不然,程序必須執行兩個遞歸調用,即在14~28行之間的兩個for循環以及幾個小的簿記量,如十、14行。這兩個for循環總共接觸到A1~An中的每一個元素,而在循環內部的工做量是常量,因而14~28行花費的時間爲O(N)。從2~十、1四、22和31行上的程序的工做量是常量,從而與O(N)相比能夠忽略。其他就剩下11~12行上運行的工做。這兩行求解大小爲N/2的子序列問題(假設N爲偶數)。所以,這兩行每行花費T(N/2)個時間單元,共花費2T(N/2)個時間單元。所以,算法三花費的總時間爲2T(N/2)+O(N)。因而咱們獲得方程組:

T(1) = 1
T(N) = 2T(N/2) + O(N)

    爲了簡化計算,咱們能夠用N代替上面方程中的O(N)項;因爲T(N)最終仍是要用大O表示,所以這麼作並不影響答案。如今,若是T(N) = 2T(N/2) + N,且T(1) = 1,那麼T(2) = 4 = 2*2;T(4) = 12 = 4*3;T(8) = 32 = 8*4;T(16) = 80 = 16*5。用數學概括法能夠證實若N=2^k,那麼T(N) = 2^k * (k+1) = N * (k+1) = N(logN + 1) = NlogN + N = O(NlogN)。即算法三的時間複雜度爲O(NlogN),這明顯小於算法二的複雜度O(N2),所以算法三會更快的得出結果。

    這個分析假設N是偶數,不然N/2就不肯定了。經過該分析的遞歸性質可知,實際上只有當N是2的冪時結果纔是合理的,不然咱們最終要獲得大小不是偶數的子問題,方程就是無效的了。當N不是2的冪時,咱們多少須要更加複雜一些的分析,可是大O的結果仍是不變的。

更優秀的算法

    雖然算法三已經足夠優秀,將時間複雜度由O(N2)下降爲O(NlogN),可是,這並非最優秀的,下面介紹針對這個問題更優秀的解法。

    算法四:

public static int maxSubsequenceSum(int[] a) {
    int maxSum = 0, thisSum = 0;;
    for(int i=0; i<a.length; i++) {
        thisSum += a[i];
        if(thisSum > maxSum)
            maxSum = thisSum;
        else if(thisSum < 0)
            thisSum = 0;
    }
    return maxSum;
}

    很顯然,此算法的時間複雜度爲O(N),這小於算法三中的時間複雜度O(NlogN),所以,此算法比算法三更快!方法當然已給出,可是要明白爲何此方法能用,還需多加思考。

    在算法一和算法二中,i表明子序列的起點,j表明子序列的終點。碰巧的是,咱們不須要知道具體最佳的子序列在哪裏,那麼i的使用能夠從程序上被優化,所以在設計算法的時候假設i是必需的,並且咱們想改進算法二。一個結論是:若是a[i]是負數,那麼它不可能表明最優序列的起點,由於任何包含a[i]的做爲起點的子序列均可以經過使用a[i+1]做爲起點獲得改進。相似的,任何負的子序列也不多是最優子序列的前綴(原理相同)。若是在內循環中檢測到從a[i]到a[j]的子序列的和是負數,那麼能夠向後推動i。關鍵的結論是:咱們不只可以把i推動到 i+1,並且實際上咱們還能夠把它一直推動到 j+1。爲了看清楚這一點,咱們令 p 爲 i+1 和 j 之間的任意一下標。開始於下標 p 的任意子序列都不大於在下標i開始幷包含從 a[i] 到 a[p-1] 的子序列的對應的子序列,由於後面這個子序列不是負的(即j是使得從下標 i 開始,其值成爲負值的序列的第一個下標)。所以,把 i 推動到 j+1 是沒有風險的:咱們一個最優解也不會錯過。

    這個算法是許多聰明算法的典型:運行時間是明顯的,但正確性卻不那麼容易就能看出來。對於這些算法,正式的正確性證實(比上面分析更加正式)幾乎老是須要的;然而,及時到那時,許多人仍然仍是不信服。此外,許多這類算法須要更有技巧的編程,這致使更長的開發過程。不過,當這些算法正常工做時,它們運行得很快!而咱們將它們和一個低效但容易實現的蠻力算法經過小規模的輸入進行比較能夠測試到大部分的程序原理。

    該算法的一個附帶優勢是:它值對數據進行一次掃描,一旦a[i]被讀入並處理,它就再也不須要被記憶。所以,若是數組在磁盤上活經過網絡傳送,那麼它就能夠被按順序讀入,在主存中沒必要存儲改數組的任何部分。不只如此,在任意時刻,算法都能對它已經讀入的數據給出子序列問題的正確答案(其餘算法不具有這個特性)。具備這種特性的算法叫作「聯機算法」。僅須要常量空間並以線性時間運行的聯機算法幾乎是完美的算法。

相關文章
相關標籤/搜索