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

CrazyCatJack發表於2021-02-20


10.1 貪婪演算法

貪婪演算法分階段的工作,在每個階段,可以認為所做決定是最好的,而不考慮將來的後果。一般來說,這意味著選擇的是某個區域性的最優。當演算法終止時,我們希望區域性最優就是全域性最優。如果是這樣的話,那麼演算法就是正確的,否則,演算法得到的是一個次最優解。如果不要求絕對最佳答案,那麼有時用簡單的貪婪演算法生成近似答案,而不是使用一般來說產生準確答案所需要的複雜演算法。

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 多處理器

讓最短的作業先執行,按照作業執行時間從短到長的順序,依次輪流讓不同的處理器進行處理

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.png

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 許可協議。轉載請註明出處!

關注博主:如果您覺得該文章對您有幫助,可以點選文章右下角推薦一下,您的支援將成為我最大的動力!


相關文章