《數據結構與算法分析》學習筆記-第十章-算法設計技巧


10.1 貪婪算法

貪婪算法分階段的工做,在每一個階段,能夠認爲所作決定是最好的,而不考慮未來的後果。通常來講,這意味着選擇的是某個局部的最優。當算法終止時,咱們但願局部最優就是全局最優。若是是這樣的話,那麼算法就是正確的,不然,算法獲得的是一個次最優解。若是不要求絕對最佳答案,那麼有時用簡單的貪婪算法生成近似答案,而不是使用通常來講產生準確答案所須要的複雜算法。html

10.1.1 調度問題

10.1.1.1 單處理器

設有做業j1,j2,j3,j4,其對應的運行時間分別爲t1,t2,t3,t4.而處理器只有一個。爲了把做業的平均完成的時間最小化,調度這些做業最優的順序是什麼。若是按照順序調度,那麼調度做業的平均時間爲:算法

t1
t1+t2
t1+t2+t3
t1+t2+t3+t4
求和:4t1+3t2+2t3+t4
求平均調度時間:4t1+3t2+2t3+t4/4

顯而易見,若是但願平均調度時間最優,那麼就要優先作耗時較短的工做,所以操做系統調度程序通常把優先權賦予那些更短的做業。數據結構

10.1.1.2 多處理器

讓最短的做業先運行,按照做業運行時間從短到長的順序,依次輪流讓不一樣的處理器進行處理ide

CPU1 j1 j4 j7
CPU2 j2 j5 j8
CPU3 j3 j6 j9

以前的操做都是將平均調度時間最小化,若是想將最後完成時間最小化就不是很容易,即讓整個序列完成的時間更早。性能

10.1.2 Huffman編碼

文件壓縮中常見。ASCII碼中有100個左右可打印字符。那麼能夠用log100個bit來表示。對於壓縮文件中只使用了某些字符,那麼能夠經過更少的Bit來表示。例如圖中向左分支是0,向右分支是1,那麼a爲000,c爲001,以此類推。
rICzsH.pngui

rIPlF0.png

因爲newline沒有右兄弟,所以上移,將樹變成滿樹編碼

rIPdT1.png

滿樹:全部的節點,要麼是樹葉,要麼有兩個兒子。一種最優的編碼將總具備這個性質,不然就像上面,具備一個兒子的節點能夠向上移動一層。若是字符都只放在樹葉上,那麼任何比特序列總可以被毫無歧義的譯碼。而且,這些字符代碼的長度是否不一樣並沒關係,只要沒有字符代碼是別的字符代碼的前綴便可。這種編碼叫作前綴碼。反之,若是一個字符放在非樹葉節點上,那就不可以保證譯碼沒有二義性。能夠想見,若是想要以最小的空間表示最多的字符,那麼就要將出現頻率高的字符,放到儘量淺的深度,而出現頻率低的字符,能夠放到深的深度操作系統

rIijDH.png

哈夫曼算法

假設字符的個數爲C,哈夫曼算法能夠描述以下:算法對一個由樹組成的森林進行。一棵樹的權等於它的樹葉的頻率的和。任意選取最小權的兩棵樹T1和T2,並任意造成以T1和T2爲子樹的新樹,將這樣的過程進行C-1次。在算法的開始,存在C棵單節點數。每一個字符一棵。在算法結束時獲得一棵樹,這棵樹就是最優哈弗曼編碼樹。翻譯

ro3tEt.png

ro3TbR.png

  1. 初始階段,每一個元素當作一棵單節點樹。每一個節點有本身的value和權重。
  2. 將當前森林中,權值最低的兩棵樹進行合併,合併後新樹的權值是老樹權值的和
  3. 繼續進行第二步,不斷將當前森林中權值最低的兩棵樹進行合併,合併時,左右分支任意,能夠互換
  4. 能夠看出,權值(出現頻率)越低,其深度越深;權值越高,其深度越淺。這樣就能保證總開銷最小。

該算法是貪婪算法的緣由在於,在每一階段咱們都進行一次合併而沒有進行全局的考慮,咱們只是選擇兩棵權值最小的樹進行合併。咱們能夠依權排序將這些樹保存在一個優先隊列中。那麼對於元素個數不超過C的優先隊列將進行一次BuildHeap, 2C-2次DeleteMin和C-2次Insert,所以運行時間偉O(ClogC)。若是不使用優先隊列,而是鏈表的話,將給出一個O(C^2)的算法。優先隊列實現方法的選擇取決於C有多大。設計

10.1.3 近似裝箱問題

  1. 聯機:必須解決當前問題,流程才能繼續
  2. 脫機:必須瞭解完全部的問題,流程才能開始

10.1.3.1 聯機算法

對於聯機裝箱問題不存在最優算法。聯機算法從不知道輸入什麼時候會結束,所以它提供的性能保證必須在整個算法的每一時刻成立。

  • 定理:存在使得任意聯機裝箱算法至少使用4/3最優箱子數的輸入
1. 下項適合算法

當處理任何一項物品時,咱們檢查看它是否能裝進剛剛裝進物品的同一個箱子中去。若是可以裝進去,那麼就把它放入該箱中。不然就開闢一個新箱子。該算法可以以線性時間運行。

  • 令M是將一列物品I裝箱所需的最優裝箱數,則下項適合算法所用箱數毫不超過2M個箱子。存在一些順序使得下項適合算法用箱2M-2個

roab9I.png

2. 首次適合算法

雖然下項適合算法有一個合理的性能保證。可是它的實踐效果卻不好,由於在不須要開闢新箱子的時候,它卻開闢了新的箱子。首次適合算法的策略是依序掃描這些箱子,但把新的一項物品放入足夠盛下它的第一個箱子中。所以,只有當先前放置物品的結果已經沒有再容下當前物品餘地的時候,咱們纔開闢一個新的箱子。首次適合算法保證其解最多包含最優裝箱數的二倍。當首次適合算法對大量其大小均勻分佈在0和1之間的物品進行運算時,經驗結果指出,首次適合算法用到大約比最優裝箱方法多2%的箱子,這是徹底能夠接受的

roBU54.png

3. 最佳適合算法

該法不是把一項新物品放入所發現的第一個能容納它的箱子,而是放到全部箱子中能容納它的最滿的箱子中。最佳適合算法比起最優算法,毫不會壞過1.7倍左右

ror64e.png

10.1.3.2 脫機算法

若是可以觀察所有物品以後再算出答案,那麼應該會作的更好。全部聯機算法的主要問題在於將大項物品裝箱困難,特別是當他們在輸入的晚期出現的時候。所以解決該問題的方法時將各項物品排序,將最大的物品放在最早。此時能夠應用首次適合算法或最佳適合算法,分別獲得首次適合遞減算法和最佳適合遞減算法。最佳適合遞減算法和首次適合遞減算法的效果差很少。

  • 令N項物品的輸入大小(以遞減順序排序)分別爲s1, s2, ... , sN。並設最優裝箱方法使用M個箱子。那麼,首次適合遞減算法放到外加的箱子中的全部物品的大小最多爲1/3
  • 放入外加的箱子中的物品的個數最可能是M-1
  • 令M時物品集I裝箱所需的最優箱子數,則首次適合遞減算法所用箱子數毫不超過(4M+1)/3
  • 令M是將物品集I裝箱所須要的最優箱子數,則首次適合遞減算法所用箱子數毫不超過11/9 * M + 4。存在使得首次適合遞減算法用到11/9 * M個箱子的序列

10.2 分治算法

  • 分:遞歸解決較小的問題(基本狀況除外)
  • 治:從子問題的解,構建原問題的解

10.2.1 分治算法的運行時間

全部有效的分治算法都是把問題分紅一些子問題,每一個子問題都是原問題的一部分。而後進行某些附加的工做以算出最後的答案。

方程T(N)=aT(N/b)+Θ(N^k)的解爲
T(N)=
O(N^(log(b)a)), 若a>b^k
O(N^k * logN), 若a=b^k
O(N^k), 若a<b^k
其中a>=1, b>1

方程T(N)=aT(N/b)+Θ(N^k * (logN)^p)的解爲
T(N)=
O(N^(log(b)a)), 若a>b^k
O(N^k * (logN)^(p+1)), 若a=b^k
O(N^k * (logN)^p), 若a<b^k
a >=1, b> 1 且p >=0

rH3mJx.png

10.2.2 最近點問題

平面上有點列P,若是p1=(x1,y1), p2=(x2,y2),那麼p1和p2間歐幾里得距離爲[(x1-x2)^2 + (y1-y2)2](1/2)。咱們須要找出一對距離最近的點。將這些點按照x的座標排序,畫一條垂線,將點集分爲兩半:PL和PR,最近的一對點或者都在PL中,或者都在PR中,或者一個在PL而另外一個在PR中。這三個距離分別叫作dL、dR和dC

rHSEan.png

  1. 蠻力計算
for(i = 0; i<NumPointsInStrip; i++)
    for(j=i+1; j<NumPointsInStrip; j++)
        if(Distance(Pi, Pj) < x)
            x = Distance(Pi,Pj);
  1. 精煉計算
for(i = 0; i<NumPointsInStrip; i++)
    for(j=i+1; j<NumPointsInStrip; j++)
        if (Pi和Pj的y座標相差大於x)
            break;
        else
            if(Distance(Pi, Pj) < x)
                x = Distance(Pi,Pj);

10.2.3 選擇問題

要求找出含N個元素的表S中的第k個小的元素。基本的算法是簡單遞歸策略。設N大於截止點,在截止點後元素將進行簡單的排序。v是選出的一個元素,叫作樞紐元。其他的元素被放在兩個集合S1和S2中。S1含有那些不大於v的元素,而S2則包含那些不小於v的元素。若是k <= |S1|,那麼S中的第k個最小的元素,能夠經過遞歸的計算S1中第k個最小的元素而找到。若是k=|S1|+1,則樞紐元就是第k個最小的元素。不然,在S中第k個最小的元素是S2中的第(k-|S1|-1)個最小元素。這個算法和快速排序之間的主要區別在於,這裏要求解的只有一個子問題而不是兩個子問題。爲了保證快速的選擇出好樞紐元,關鍵想法是再用一個間接層。咱們不是從隨即元素的樣本中找出中項,而是從中項的樣本中找出中項。

  1. 把N個元素分紅[N/5]組,5個元素一組,忽略(最多4個)剩餘的元素
  2. 找出每組的中項,獲得[N/5]箇中項的表M
  3. 求出M的中項,將其做爲樞紐元V返回

使用五分化中項的中項的快速選擇算法的運行時間爲O(N)。分治算法還能夠用來下降選擇算法預計所須要的比較次數

10.2.4 一些運算問題的理論改進

10.2.4.1 整數相乘

假設想要將兩個N位數X和Y相乘。若是X和Y剛好有一個是負的,那麼結果就是負的,不然結果爲正數。所以能夠進行這種檢查而後假設X, Y >= 0。設X=61438521,Y=94736407。咱們將X和Y拆成兩半。分別由最高几位和最低幾位數字組成。XL=6143,XR=8521,YL=9473,YR=6407.咱們還有X=XL104+XR和Y=YL104+YR。由此獲得:XY=XLYL108+(XLYR+XRYL)104+XRYR。該方程由四次乘法組成。即XLYL、XLYR、XRYL、XRYR。它們每個都是原問題大小的通常(N/2數字)。用108和104作乘法實際就是添加一些0,這及其後的幾回加法只是添加了O(N)附加的工做。若是咱們遞歸地使用該算法進行這四項乘法,在一個適當的基本情形下中止,咱們獲得遞歸:T(N)=4T(N/2)+O(N)。根據定理,能夠看到T(N)=O(N^2)。爲了獲得一個亞二次的算法,咱們必須使用少於四次的遞歸調用.關鍵在於XLYR+XRYL=(XL-XR)(YR-YL)+XLYL+XRYR。這樣經過三次遞歸調用便可得出結果。

rH3z0H.png

如今的遞歸方程知足:T(N)=3T(N/2)+O(N),根據定理,獲得T(N)=O(N^(log(2)3)) = O(N^1.59)。未完成這個算法,咱們必需要有一個基準狀況,該狀況能夠無需遞歸而解決。當兩個數都是一位數字時,能夠經過查表進行乘法,如有一個乘數爲0,則咱們返回0.假如咱們在實踐中要用這種算法,咱們將選擇對機器最方便的狀況做爲基本狀況。

10.2.4.2 矩陣乘法

rOshHH.png

  1. 當矩陣A的列數(column)等於矩陣B的行數(row)時,A與B能夠相乘。
  2. 矩陣C的行數等於矩陣A的行數,C的列數等於B的列數。
  3. 乘積C的第m行第n列的元素等於矩陣A的第m行的元素與矩陣B的第n列對應元素乘積之和。

簡單的O(N^3)矩陣乘法

void
MatrixMultiply(Matrix A, Matrix B, Matrix C, int N)
{
    int i, j, k;
    
    for (i = 0; i < N; i++)
        for (j = 0; j < N; j++)
            C[i][j] = 0;
    
    for (i = 0; i < N; i++)
        for (j = 0; j < N; j++)
            for (k = 0; k < N; k++)
                C[i][j] += A[i][k] * B[k][j];
}

10.3 動態規劃

一個能夠被數學上遞歸表示的問題也能夠表示成一個遞歸算法,在許多情形下對樸素的窮舉搜索獲得顯著的性能改進。任何數學遞歸公式均可以直接翻譯成遞歸算法,可是基本現實是編譯器經常不能正確對待遞歸算法,結果致使低效的算法。當咱們懷疑極可能是這種狀況時,必須再給編譯器提供一些幫助,將遞歸算法從新寫成非遞歸算法,讓編譯器把那些子問題的答案系統的記錄在一個表內,利用這種方法的一種技巧叫作動態規劃。

10.3.1 用一個表代替遞歸

  1. 斐波那契數的低效算法
int
Fib(int N)
{
    if (N <= 1)
        return 1;
    else
        return Fib(N - 1) + Fib(N - 2);
}

該算法慢的緣由在於冗餘計算,且榮譽計算的增加是爆炸性的,若是編譯器的遞歸模擬算法要是可以保留一個預先算出的值的表而對已經解過的子問題再也不進行遞歸調用。那麼這種指數式的爆炸增加就能夠避免。

  1. 斐波那契數的線性算法
int
Fibonacci(int N)
{
    int i, Last, NextToLast, Answer;
    
    if (N <= 1)
        return 1;
    
    Last = NextToLast = 1;
    for (i = 2; i <= N; i++)
    {
        Answer = Last + NextToLast;
        NextToLast = Last;
        Last = Answer;
    }
    
    return Answer;
}

10.3.2 矩陣乘法的順序安排

設有四個矩陣ABC和D。不一樣的相乘順序致使計算次數徹底不一樣,致使效率徹底不一樣。最好的排列順序方法大約只用了最壞的排列順序方法的九分之一的懲罰次數。咱們定義T(N)是順序的個數,此時T(1)=T(2)=1, T(3)=2,而T(4)=5.

spHHxI.png

spHqMt.jpg

設mLeft, Right是進行矩陣乘法ALeftALeft+1 ... ARight-1ARight所須要的乘法次數,爲方便起見,mLeft,Left=0.設最後的乘法是(ALeft...Ai)(Ai+1...ARight),其中Left<=i<Right。此時所用的乘法次數爲mLeft,i+mi+1,Right+cLeft-1cicRight。這三項分別表明計算(Aleft...Ai)、(Ai+1...ARight)以及它們的乘積所須要的乘法。若是咱們定義MLeft,Right爲在最優排列順序下所須要的乘法次數,那麼,若Left<Right,則:
s9FUfI.png

這個方程意味着,若是咱們有乘法ALeft...ARight的最優的乘法排列順序,那麼子問題ALeft...Ai和Ai+1...ARight就不能次最優的執行。不然咱們能夠經過用最優的計算代替次最優計算而改進整個結果。
這個公式能夠直接翻譯成遞歸程序,這樣的程序將是明顯低效的,因爲大約只有MLeft,Right的N^2/2個值須要計算,所以顯然能夠用一個表來存放這些值。進一步的考察代表,若是Right-Left=k,那麼只有在MLeft,Right的計算中所須要的那些值Mx,y知足y-x<k。這告訴咱們計算這個表所須要使用的順序。若是除最後答案M1,N外咱們還想要顯示實際的乘法順序,那麼咱們可使用第九章中的最短路徑算法的思路,不管什麼時候改變MLeft,Right,咱們都要記錄i的值,這個值是重要的。

找出矩陣乘法最優順序的程序
void
OptMatrix(const long C[], int N, TwoDimArray M, TwoDimArray LastChange)
{
    int i, k, Left, Right;
    long ThisM;
    
    for (Left = 1; Left <=N; Left++)
        M[Left][Left] = 0;
    for (k = 1; k < N; k++)
        for (Left = 1; Left <= N-k; Left++)
        {
            /* for each position */
            Right = Left + k;
            M[Left][Right] = Infinity;
            for (i = Left; i < Right; i++)
            {
                ThisM = M[Left][i] + M[i+1][Right] + C[Left - 1]*C[i]*C[Right];
                if (ThisM < M[Left][Right])
                {
                    M[Left][Right] = ThisM;
                    LastChange[Left][Right] = i;
                }
            }
        }
}

10.3.3 最優二叉查找樹

給定一列單詞w1, w2, ... wN和他們出現的固定的機率p1, p2, ... pN。問題是要以一種方法在一棵二叉查找樹中安放這些單詞使得總的指望存取時間最小。在一棵二叉查找樹中,訪問深度d處的一個元素所須要的比較次數是d+1,所以若是wi被放在深度di上,那麼咱們就要將
s9mBPe.png

假設樣本輸入以下:

s9KzRg.png

第一棵樹是是用貪婪方法造成的,存取機率最高的單詞被放在根節點處。而後左右子樹遞歸造成。第二棵樹是理想平衡查找樹。這兩棵樹都不是最優的,由第三棵樹的存在能夠證明。

s9MSzQ.png

最優二叉樹的構造:

s9MUQH.png

若是Left > Right,那麼樹的開銷是0,這就是NULL情形,對於二叉查找樹咱們總有這種情形,不然,根花費pi,左子樹的代價相對於它的根爲Cleft,i-1,右子樹相對於它的根的代價爲Ci+1,Right,這兩棵樹的每一個節點從wi開始都比從它們對應的根開始深一層。所以咱們必須加

s9MWOs.png

由此獲得公式:

s9M4wq.png

10.3.4 全部點對最短路徑

計算有向圖G=(V,E)中每一點時間賦權最短路徑的一個算法。在第九章咱們看到單發點最短路徑問題的一個算法,該算法找出從任意一點s到全部其餘頂點的最短路徑。該算法(Dijkstra)對稠密的圖以O(|V|2)時間運行,實際上對稀疏的圖更快。這裏將給出一個較小的算法解決對稠密圖的全部點對的問題,該算法的運行時間爲O(|V|3),他不是對Dijkstra算法|V|次迭代的一種漸進改進,但對很是稠密的圖可能更快,緣由是它的循環更緊湊。若是存在一些負的邊值但沒有負值圈,那麼這個算法也能正確運行,而Dijkstra算法此時是失敗的。Dijkstra算法在頂點s開始並分階段工做。圖中的每一個頂點最終都要被選做中間結點。若是當前所選的頂點是v,那麼對於每一個w屬於V,置dw=min(dw, dv+cv,w),這個公式是說,從s到w的最佳舉例或者是從前面知道的從s到w的舉例,或者是從s(最優的)到v而後在直接從v到w的結果。Dijkstra算法提供了動態規劃算法的想法。咱們依序選擇這些頂點。咱們將Dk,i,j定義爲從vi到vj只使用v1,v2,...vk做爲中間頂點的最短路徑的權。根據這個定義,D0,i,j=ci,j。其中若(vi, vj)不是該圖的邊則ci,j是無窮。再有,根據定義,D|V|,i,j是圖中從vi到vj的最短路徑。當k>0時,咱們能夠給Dk,i,j寫出一個簡單公式。從vi到vj只使用v1,v2,...vk做爲中間頂點的最短路徑或者根本不使用vk做爲中間頂點的最短路徑,或者是由兩條路景vi->vk和vk->vj合併而成的最短路徑。其中每條路徑只使用前k-1個頂點做爲中間頂點。得出公式:Dk,i,j=min{Dk-1,i,j, Dk-1,i,k+Dk-1,k,j}。實踐需求仍是O(|V|^3),跟前面的兩個動態規劃例子不一樣,這個時間界實際上還沒有用另外的方法下降。由於第k階段只依賴於第k-1階段,因此看來只有兩個|V|*|V|矩陣須要保存,然而,在用k開始或結束的路徑上以k做爲中間頂點對結果沒有改進,除非存在一個負的圈。所以只有一個矩陣是必須的,由於Dk-1,i,k=Dk,i,k和Dk-1,k,j=Dk,k,j。這意味着右邊的項都不改變值且都不須要存儲。這個觀察結果致使圖中的簡單程序。在一個徹底圖中,每一對頂點(兩個方向上)都是聯通的,該算法幾乎確定要比Dijkstra算法的|V|次迭代快,由於這裏的循環很是緊湊並適合並行計算。

void
AllPairs(TwoDimArray A, TwoDimArrayD, TwoDimArray Path, int N)
{
    int i, j, k;
    
    /* Initialize D and Path */
    for (i = 0; i < N; i++)
        for (j = 0; j < N; j++)
        {
            D[i][j] = A[i][j];
            Path[i][j] = NotAVertex;
        }
    
    for (k = 0; k < N; k++)
        /* Consider each vertex as an intermediate */
        for (i = 0; i < N; i++)
            for (j = 0; j < N; j++)
                if (D[i][k] + D[k][j] < D[i][j])
                {
                    /* Update shortest path */
                    D[i][j] = D[i][k] + D[k][j];
                    Path[i][k] = k;
                }
}

動態規劃是強大的算法設計技巧,它給解提供一個起點,它基本上是首先求解一些更簡單問題的分治算法的範例,重要的區別在於這些更簡單的問題不是原問題的明確的分割。由於子問題反覆被求解,因此重要的是將它們的解記錄在一個表中而不是從新計算它們。在某些狀況下,解能夠被改進(這確實不老是明顯鵝,並且經常是困難的)。在另外一些狀況下,動態規劃方法則是所知道的最好的處理方法。在某種意義上,若是你看出一個動態規劃問題,那麼你就看出全部的問題

10.4 隨機化算法

在算法期間,隨機數至少有一次用於決策。該算法的運行時間不僅依賴於特定的輸入,並且依賴於所發生的隨機數。一個隨機化算法的最壞運行時間幾乎老是和非隨機化算法的最壞情形運行時間相同,區別在於,好的隨機化算法沒有很差的輸入,而只有壞的隨機數(相對於特定的輸入)。例如快速排序中樞紐元的選擇,方法A選第一個元素,方法B隨機選出一個元素。兩種最壞情形之間的區別在於,存在特定的輸入總可以出如今A中併產生很差的運行時間。當每一次給定已排序數據時,方法A老是會以最壞運行時間運行(O(N^2))。若是方法B以相同的輸入運行兩次,它將有兩個不一樣的運行時間。在運行時間的計算中,咱們假設全部的輸入都是等可能的,實際上這並不成立。例如排序的輸入經常要比統計上指望的出現的多得多。這會產生一些問題,特別是對於快速排序和二叉查找樹。經過使用隨機化算法特定的輸入再也不是重要的重要的是隨機數,咱們能夠獲得一個指望的運行時間,此時咱們是對全部可能的隨機數取平均而不是對全部可能的輸入取平均。使用隨機樞紐元的快速排序算法是一個O(NlogN)指望時間算法,這就是說,對任意的輸入,包括已經排序的輸入,運行時間的指望值爲O(NlogN)。指望運行時間界要多少強於平均時間界,比對應的最壞情形界弱。獲得最壞情形時間界的那些解決方案經常不如他們的平均情形那樣在實際中常見、可是隨機化算法卻一般是一致的。

參考文獻

  1. Mark Allen Weiss.數據結構與算法分析[M].America, 2007

本文做者: CrazyCatJack

本文連接: https://www.cnblogs.com/CrazyCatJack/p/14408191.html

版權聲明:本博客全部文章除特別聲明外,均採用 BY-NC-SA 許可協議。轉載請註明出處!

關注博主:若是您以爲該文章對您有幫助,能夠點擊文章右下角推薦一下,您的支持將成爲我最大的動力!

相關文章
相關標籤/搜索