1. 程式人生 > >最大子段和的DP算法設計與其單元測試

最大子段和的DP算法設計與其單元測試

his 最大子數組 利用 來看 中一 tco public art 容器

技術分享圖片

表情包形象取自番劇《貓咪日常


那我也整一個

曾幾何時,筆者是個對算法這個概念漠不關心的人,由衷地感覺它就是一種和奧數一樣華而不實的存在,即便不使用任何算法的思想我一樣能寫出能跑的程序

直到一年前幫同學做了個手機遊戲demo才發現了一個嚴峻的問題

**為啥*一樣的畫面能跑出ppt的質感?**

雖然發現當時的問題主要出現在使用了一個有bug的API,它導致了低性能的循環調用,但是從那時便開始就重新審視算法了,僅僅一個函數就能大幅地改變程序帶給用戶的體驗這個觀念根植心底

後來多多少少也學習了一些算法的知識,這回一看到這次要解決的問題有點來頭,叫 最大子段和 ,多虧筆者知識面狹隘,未曾了解過前人的解決方案,於是就萌生了“整一個算法解決吧”的想法,可設計的算法直到寫這篇blog為止,結果還不甚理想,可後來筆者發現了原因居然是MStest中一個微妙的缺陷

,這種經驗值得分享,就想著把這次的設計經過也寫進來吧,於是便催生了本文

本文原意在於分享一次算法設計的經歷,如果有概念上的錯誤和紕漏勞請各位指正,筆者在敘述中會盡可能的避免涉及到算法的一些名詞概念,盡量保證所有讀者都能無障礙的閱讀,篇幅較長,但是希望讀者能從中得到一些感悟,本文最終實現了一種比較優雅的算法

整個項目的代碼都在 騰訊雲 中,可以自行下載,註意:筆者使用語言為C#


那沒得說了,開幹

技術分享圖片

有想法就要去做,首先,我們得分析最大子段和這個問題的

數組

這樣是不是就好理解了?最大子段問題如果不進行推廣,狹義地定義在數組中就是本文要解決的問題了,或者應該說這次的問題就是如何得到最大子數組和

連續

這其實是隱含的條件,實際上沒必要單拎出來說,只是為了確保思路的連續性,而在得知了這兩項要求的情況下,我們很容易找到這個問題的第一個解決方案:

  1. 找到當前數組的所有子數組
  2. 找出其中數組和最大的

這就是最容易理解的窮舉的方法,在後來上網查閱相關代碼時,該算法的實現也是被列在第一個,可既然要設計算法,就自然要盡量尋找它的更優解,算法出現的本意也是找到更高效的方式解決同一問題,可即便如此,理解這種方法也對我們接下來理解筆者蹩腳的設計大有幫助,因為看到最後大家會察覺到這種算法設計的思路其實是一種更高效的窮舉,或者說是一種窮舉搜索模式


兩個簡單的設計

我們理解了要找出數組和最大的子數組是問題的最終解,而解域則是原本數組的冪集,這顯然不是我們樂於見到的問題規模,由此,筆者斷定解決問題的關鍵在壓縮原問題的“尺寸”

壓縮

對於這個問題,我們要看到它的本質,無論什麽樣的子數組都是由連續的原數組內元素組成的,而元素可以分成三類:

  1. 正數
  2. 負數
  3. 0

這三類元素對子數組的影響也能分成三類:

  1. 增大
  2. 減小
  3. 不變

而分散在數組裏的各種元素的分布只有兩種狀態:

  1. 連續
  2. 穿插

連續的任一元素造成的影響都不會改變,比如連續的正數依舊會增大子數組,於是筆者就寫出了該算法的第一個函數Compress()

public ArrayList Compress(ArrayList arr)
{
    int previousSum = 0;
    if (arr.Count >= 2)              //在獲取值之前判斷該容器是否需要判定
    {
        previousSum = (int)arr[0];
    }
    for(int i=1;i<arr.Count;i++)    //合法數組長度大於等於2
    {
        if(previousSum>=0)
        {
            if((int)arr[i]>=0)
            {
                previousSum += (int)arr[i];
                arr.RemoveAt(i);
                i--;
                arr[i] = previousSum;
            }
            else
            {
                previousSum = (int)arr[i];
            }
        }
        else
        {
            if ((int)arr[i] < 0)
            {
                previousSum += (int)arr[i];
                arr.RemoveAt(i);
                i--;
                arr[i] = previousSum;
            }
            else
            {
                previousSum = (int)arr[i];
            }
        }
    }
    for (int i = 0;i< arr.Count;i++)
    {
        if((int)arr[i]>=0)
        {
            break;
        }
        else
        {
            arr.RemoveAt(i);
            i--;
        }
    }
    return arr;
}

雖然用到了C#中的ArrayList(),但是它在C++11標準後有Array這種類似實現方式,在別的語言中相當於一個容器類的實現

這個函數的功能是整合目前數組裏連續的正數和負數,具體的邏輯操作是根據判斷當前的連續和與下一個元素的符號是否相同,最後加入了一個簡單的循環剔除掉了數組前方可能出現的負數,這個操作出現的原因在之後筆者會做進一步解釋

找到那個最大的家夥

一個原數組經過剛剛的處理就變成了正負數交叉出現的精簡數組,接下來看看這個被我們壓縮打包的數組,如果有一個最大的子數組出現,那麽它的首尾就一定不會是負數,因此我們可以將這個數組進一步壓縮,將首尾出現的負數砍掉,而這就是我們在壓縮函數的最後幹的事情
什麽?我沒有將尾巴的負數砍掉嗎?不要急,這都會有解釋的

算法設計的重點:

我們無法確定現在究竟有多少個元素在我們的手裏,但我們可以處理至少有一個元素的情況,我們的算法最後也會處理至少一個元素以上的情況

而當我們手裏只有一個元素時,這根本不需要判斷,因為經過前面的處理,我們手裏如果只有一個元素,那它必然是最大的子數組的值了

之前我們沒有將尾巴的負數去掉,出現兩個元素的情況必然是第二個元素是一個負數項,這裏就開始揭曉伏筆了

兩個元素中,如果第二個元素是負數,我們絕對不會考慮把它和第一個元素加和

這個原則就是該算法設計的核心思路,思維敏捷的讀者應該能從這些敘述中感覺到一些眉目了,我們就繼續往下處理三個元素的情況

三個元素或許只是增加了一個元素,但不免讓人想的更加復雜長遠,比如考慮這些元素的值的正負,是否會出現特殊情況等,但是我們先不去這麽想,因為我們雖然不太清楚三個元素的處理方式,但我們已經很了解兩個元素要如何處理了,那麽先處理後兩個元素,這個操作我們剛剛做過

兩個元素中,如果第二個元素是負數,我們絕對不會考慮把它和第一個元素加和
沒錯吧?

當我們把這後面的兩個元素處理完就會得到它們組成的“數組”的最大子數組,把這個最大子數組作為一個新的元素,和我們的第一個元素放到一起進行同樣的操作,最終我們會的到一個新的最大子數組

而四個、五個、六個...k個元素的處理方式依此類推,於是乎我們就將這些所有元素所能組成的所有最大子數組都找到了,這是不是很熟悉?

對的,我們在前文中提到了窮舉的方法,這其實也是一種窮舉,只是將窮舉每一個數組變成了窮舉每一個最大子數組,這就是提高了效率的原因,也是 動態規劃法 也被普遍視作窮舉算法的優化形式的原因

動態規劃(Dynamic Programming,DP),這裏是行文至此第一個對初學算法或者不太了解算法的讀者而言比較陌生的詞匯,但是多少都有聽說過,它有個近親叫分治(Divide and Conquer,D&C),而兩者的區別用三言兩語很難說清,給感興趣的讀者的建議是去看對應的問題的實現代碼和編寫者的解釋,很容易找到區分二者的感覺

暫時不做過多的展開,我們的算法進行到這一步已經做完了大部分的工作,而我們的問題也快要得到解決了,現在來重新看看我們的問題是———

找到那個最大的家夥

技術分享圖片

吼吼,對了,我們最終要找到那個最大的家夥

現在我們已經找到了所有的最大子數組,找到它們中最大的家夥很簡單,筆者選擇的方法是在每次獲得一個新的最大子數組時把它和當前的最大子數組比較,將目前的最大子數組賦為兩者中最大的那一個值就行了,當然,當前最大子數組的初始值賦為0就可以了,到此為止,我們的算法就完成了!

這裏給出第二個函數SearchForLargest()的代碼

public int largest=0;
public int SearchForLargest(ArrayList arr,int start)
{
    int largestSum = 0;
    int i = start;
    if (i < arr.Count)
    {
        largestSum = Math.Max((int)arr[i], (int)arr[i] + SearchForLargest(arr, start + 1));
    }
    if (largest < largestSum) 
    {
        largest = largestSum;
    }
    return largestSum;
}

筆者的思路在編寫這一段遞歸的時候曾出現過問題,額外加入了一些對正負數的判斷,這就是前面提到過的問題規模的增大容易讓人想的更復雜長遠,一方面也是體現了思路的不清晰和不簡潔,具體錯誤筆者註釋在了騰訊雲 的源碼裏,感興趣的大家可以去下載,好像也可以在線看,這裏是 鏈接

其實還有一點,筆者就算不提,很多人也看出來了,沒錯,該算法的代碼依舊有優化的空間,比如第一個函數Compress()完全可以不要,可筆者以為的算法的要聯系到應用,Compress()函數可以用作在初始化階段壓縮數組的手段,在需要時進行最大子段和的計算,因此沒有刪除Compress()函數以及有關它的敘述


那是真的牛啤這也太酷了吧

技術分享圖片

“這難道就是最大子段和的最優算法了嗎?”

你好,不是的,這裏給出比較酷的,O(N)級別算法的代碼

static public int EasyGetLargestSub(ArrayList arr)
{
    int largest = 0;
    int largestThisSub = 0;
    foreach(int i in arr)
    {
        largestThisSub = Math.Max(largestThisSub + i, 0);
        largest = Math.Max(largestThisSub, largest);
    }
    return largest;
}

是不是看到如此簡單的代碼實現感覺自己像是被潑了一盆冷水?剛剛看了筆者一頓分析猛如虎到頭來就這幾行代碼就能得到更優化的算法?

當然了,剛剛上文提到過筆者設計的DP方案也可以精簡到類似的厚度,雖然時間復雜度可能不如這種算法低,可本文的主要意義也不在於比較算法的優劣,而是為了分享設計的思路,筆者的初衷是讓任何一個用心看完本文的人都能在面對類似簡單的DP算法時能露出會心一笑,進而能夠開始理性地分析算法的用意

雖然理論上講,任誰花幾個小時去設計算法最後發現還不如一個單循環心態都不會很好,可筆者親眼看到這種實現的時候卻前所未有的釋然,因為確實學習到了它抽象的理念,也多虧如此才會繼續完善自己的算法,才會有本文的出現,這種體驗也是筆者想傳達出去的,希望大家可以去嘗試設計算法並能有所收獲

順帶一提,這種算法師出有名,叫Kadane算法,運用的是數學歸納的思想,筆者僅僅是用微不足道的思考和應用一些設計理念去做該算法的設計的,數學應用一直是筆者的弱項,畢竟筆者的一項知名記錄是高數(上)連掛三年


相關算法的一些後話

這裏再簡單地講一下動態規劃(DP)和分治法(D&C)兩者的異同,可以選擇性閱讀

兩者的共同點都在用把大問題劃分為小問題的思想解決復雜的難題,眾所周知,量變會引起質變,解域的擴張也是這樣,所以只要收縮問題的規模,解決問題的效率就會幾何級的提升,這就是兩者優化窮舉的核心理念

網上有總結二者的區別是動態規劃法在各個子問題間存在聯系,而分治法是子問題相互獨立,其實這要看對聯系的定義了,筆者覺得不甚明了,所以這裏給出兩個例子:

快速排序 —— DP
0-1背包問題 —— D&C

這兩個例子是筆者在讀《算法圖解》的時候書中介紹的典型例子,這本書是很優秀的入門書,學有余力的朋友更建議配合算法實例進行閱讀,同時也能鞏固對算法的理解。


使用MStest進行單元測試

好的,那麽剛剛的算法設計的篇章告一段落了,整理一下心情,我們開始進入另一個環節,利用MStest進行單元測試,上一篇blog中有提到如何使用MStest,想要了解的讀者可以點擊這裏

那麽題外話到此為止,這裏測試的代碼我們直接選擇了上文提到過的O(N)級別(Kadane)算法的代碼,因為不得不承認,它的代碼邏輯很好整理...

技術分享圖片

tadang~筆者應該不用解釋為什麽這裏會有一張流程圖吧,既然要進行白盒測試幾乎必需一個程序流程圖,好讓我們直觀的明白在各種情況下程序會到達一個什麽樣的狀態,但我們仍需要手工記錄自己用例能覆蓋的條件和路徑,這裏強力推薦黑科技——PS,可以用不同的圖層表示這是第幾個用例,或是用例中的第幾個狀態,使用得當你會得到類似下面的效果

技術分享圖片 技術分享圖片

這裏的初值選擇了1,然後逐漸改變數組的值,到達上圖狀態是覆蓋的極限,那麽由此分析可知該段代碼有不可達的一條路徑,原本兩個串連條件判斷可以得到四個路徑,因此只剩下了三種不同路徑,如圖所示其實一個用例就可以完成這個測試了,但是還有一條退出路徑,我們也盡可能地向路徑覆蓋的級別去做這個簡單的測試,那麽就要編寫三個測試用例使得每一個路徑最終都能接上一次該退出路徑

測試用例最後的選擇需要我們將剛才選擇的條件值整形化,最後得出三個測試用例分別為:

  1. {-2}
  2. {-2,2}
  3. {-2,2,-4,1}

技術分享圖片


技術分享圖片 技術分享圖片 技術分享圖片


告一段落?

怎麽可能這麽輕易地放過測試自己算法的一次機會?自己的孩子再孬也得讓他試試!

測試樣例,全部運行!

這裏引入了一些白盒測試的用例,但是默認輸入合法(輸入一個合法的數組),因此引入的用例均為邊界測試,主要是防止自己的程序出現無法處理的合法內容,其中包括:

  1. 全正值輸入 TestAllPositiveArrayInput()
  2. 全負值輸入 TestAllNegativeArrayInput()
  3. 全0輸入 TestAllZeroArrayInput()
  4. 空數組輸入 TestEmptyArrayInput()

接下來就是主角們...

  1. 測試壓縮尋找最大子段的算法(本文dp算法) TestCompressAndLargest()
  2. 測試Kadane算法 TestLargestEasy()

測試用例不出所料的都通過了,可...

技術分享圖片

???

技術分享圖片

這執行效率的差距也太過分了!!!
究其原因,這原來是個...

大撲棱蛾子

技術分享圖片

筆者這裏分析過函數本身的問題,比如傳值使用引用傳遞,或者壓縮函數有缺陷,或是用例選擇特殊,同時使用Stopwatch(C#的運行時間計時器)來進行監控,但都不是,最終得到的結論是:

這是目前MStest的一個bug

它的第一個測試用例永遠會是一個超長的時間,最差的一次甚至達到了40ms,只要將你一個後面的測試用例復制粘貼到第一個用例的前面就可以得到正常的結果了,最好粘貼兩個以上才能保證後續用例基本正確,這是後來進一步測試的結果

技術分享圖片

筆者不太了解其它的測試框架,所以不敢隨意猜測是否是框架的特點,目前只能認為它在測試框架啟動時第一個用例的運行時間計時器就開始計時了,而第一個用例此時還沒有運行,從而產生了錯誤計時


總的來說

筆者這次收獲很多,在寫blog的過程中也在不停地理順著思路,優化著代碼,這是一個很勞費心神卻又很快樂的過程,希望各位同樣勞神讀到現在的朋友一樣有所收獲,感謝您的閱讀。

最大子段和的DP算法設計與其單元測試