1. 程式人生 > 實用技巧 >演算法複習-貪心演算法

演算法複習-貪心演算法

基本思想

生活中有很多使用貪心思想的例子,比如找零錢,如果售貨員需要找給小朋友67美分的零錢,售貨員手中只有25美分、10美分、5美分和1美分的硬幣,她的做法是:先找不大於67的25美分2個,再找不大於17的10美分1個,再找不大於7的5美分1個,最後找2個1美分,最後,找給小朋友6枚硬幣。我們不難發現售貨員的目標是硬幣個數儘可能少。

再看一個經典的揹包問題,假設有\(n\)個物品,他們的體積相同,重量分別為\(w_1, w_2, ..., w_n\),揹包的最大載重量是\(c\),目標是往揹包裡裝入儘可能多的物品,一個自然的想法是“先裝重量最輕的物品”。如果用數學語言描述這個問題是這樣的:

\[\max \sum\limits_{i = 1}^n {{x_i}} ,(\sum\limits_{i = 1}^n {{w_i}{x_i} \le c,{x_i} = 0,1)} \]

其中\(x_i\)表示是否選擇第\(i\)件物品,1時選擇,0時不選擇。

從中我們不難發現貪心演算法的基本思想。貪心演算法,是在決策中總是做出在當前看來是最好的選擇。但是我們必須注意的是,區域性最優並不等同於全域性最優,所以貪心演算法並不一定總能得到正確的解。但是必須指出的是,有相當一部分問題是可以由貪心原則達到整體最優的。

揹包問題

本節中提到的揹包問題特指可分割的揹包問題。

已知容量為\(M\)的揹包和\(n\)件物品。第\(i\)件物品的重量為\(w_i\),價值是\(p_i\)。因而將物品\(i\)的一部分\(x_i\)放進揹包即獲得 \(p_ix_i\)的價值。問題是:怎樣裝包使所獲得的價值最大?即是如下的優化問題:

\[\max \sum\limits_{i = 1}^n {{p_i}{x_i}} ,(\sum\limits_{i = 1}^n {{w_i}{x_i} \le M,{x_i} = [0,1]} ) \]

因為物品可分割,所以我們優先裝單價最高的物品,這樣就可以得到這個優化問題的最優解。這裡必須指出的是,我們並沒有證明區域性最優就可以達到全域性最優,事實上,這個問題是可以通過嚴謹的數學語言證明的,由於篇幅所限,這裡不做介紹。

貪心演算法主要用於處理優化問題。每個優化問題都是由目標函式和約束條件組成。滿足約束條件的解稱為可行解,而那些使得目標函式取得最大(最小)值的可行解稱為最優解。如揹包問題是一個優化問題,\(\sum\limits_{i = 1}^n {{p_i}{x_i}}\)

是目標函式,而\(\sum\limits_{i = 1}^n {{w_i}{x_i} \le M,{x_i} = [0,1]}\)描述的要求是約束條件,這裡優化是使目標函式取最大值。

貪心演算法在每一步的決策中雖然沒有完全顧及到問題整體最優,但在區域性擇優中是朝著整體最優的方向發展的。為此,貪心演算法首先要確定一個度量準則(稱為貪心準則),每一步都是按這個準則選取優化方案。如(可分割)揹包問題的貪心準則是選取單位價值最大物品;找零錢問題所用的貪心準則是選取面值最大的硬幣。對於一個給定的問題,初看起來,往往有若干種貪心準則可選,但在實際上,其中的多數都不能使貪心演算法達到問題的最優解。

所以我們不難發現,貪心演算法的核心就在於設計一個產生最優解的貪心準則。

下面給出貪心演算法的虛擬碼:

Greedy(A)
{
    ans = {};
    for i to A.size()
    {
        x = Select(A);
        if Feasible(ans, x)
        {
            ans = Union(ans, x);
        }
    }
    return ans;
}
  1. Select(A):實現貪心準則,並按照貪心準則從輸入\(A\)中選擇當前的元素。
  2. Feasible(ans, x):判斷已知解的部分\(ans\)與新選取的\(x\)的結合是否是可行的。
  3. Union(ans, x):如果可行,在可行解中加入\(x\)

所以,貪心問題的求解就轉化成對這幾個函式的實現。

排程問題

活動安排問題

問題描述:已知\(n\)個活動\(E={1, 2, … ,n}\),要求使用同一資源,第\(k\)個活動要求的開始和結束時間為\(s_k, f_k\), 其中 \(s_k<f_k, k=1, 2, … , n\)。如果\(s_k > f_j\)或者\(s_j>f_k\),活動\(k\)與活動\(j\)稱為相容的。活動安排問題就是要在所給的活動集合中選出最大(活動個數最多)的相容活動子集。

這個問題的貪心準則是在未安排的活動中選擇結束時間最早的活動安排,這樣可以留出更多的時間安排後面的活動。同樣要注意的是,我們並沒有證明這個貪心準則獲得的可行解就是最優解,儘管這是對的。

通過以上的分析,不難寫出程式碼:

void quicksort(vector<int>& s, vector<int>& f, int start, int end)
{
    if(start > end) return;
    int i = start, j = end + 1;
    int pivot = f[start];
    while(true)
    {
        while(f[++i] < pivot && i < end);
        while(f[--j] > pivot && j > start);
        if(i < j)
        {
            swap(s[i], s[j]);
            swap(f[i], f[j]);
        }
        else break;
    }
    swap(s[j], s[start]);
    swap(f[j], f[start]);
    quicksort(s, f, start, j-1);
    quicksort(s, f, j + 1, end);
}
vector<vector<int>> greedyAction(vector<int>& s, vector<int>& f)
{
    int n = s.size();
    // 順便複習一下快排hhh
    quicksort(s, f, 0, n-1);
    int j = 0;
    vector<vector<int>> res = {{s[0], f[0]}};
    for(int i = 1 ; i < n ; ++i)
    {
        if(s[i] >= f[j])
        {
            res.push_back({s[i], f[i]});
            j = i;
        }
    }
    return res;
}

帶期限的單機排程問題

為使問題簡化,我們假定完成每項作業所用的時間都是一樣的,如都是 1。

問題陳述: 已知\(n\)項作業\(E = { 1 , 2 , … ,n}\),要求使用同臺機器完成(該臺機器在同一時刻至多進行一個作業),而且每項作業需要的時間都是1。第\(k\)項作業要求在時刻\(f_k\)之前完成,而且完成這項作業將獲得效益\(p_k, k=1, 2, … , n\)。如果其中的作業可以被安排由一臺機器完成,作業集\(E\)的子集稱為相容的。帶限期單機作業安排問題就是要在所給的作業集合中選出總效益值最大的相容子集。

首先考慮貪心準則,容易想到儘量選取效益值大的作業安排,所以演算法前提是任務按照價值由高到低的順序排列。之後考慮如何判斷是否是可行解,用兩個陣列\(f\)\(p\)分別存放作業的期限值和效益值,並使得陣列\(p\)中元素按照不增的順序排列。那麼根據相容性要求,可以得出:對於作業\(i\),只要目前可行解\(J\)中期限值不大於\(f_i\)的作業少於\(f_i\)個,作業\(i\)就可以加入可行解。看起來不錯,是嗎?但是這個判斷方法還不全面,看下面的例子:

作業的期限陣列\(f=[4,2,4,3,4,7,3]\)

顯然最優解是\(J=[0,1,2,3,5]\),但是如果應用上面的可行解分析,得出的結果是\(J'=[0,1,2,3,5,6]\),手推一下發現,判斷最後一個任務時,可行解中期限值小於等於3的任務有兩個,小於3,符合可行解條件,但是前4個時間片已經被任務\([0,1,2,3]\)佔滿。

那麼怎樣判斷可行解呢?很簡單,我們只要給所有任務分配時間片,只要當前任務期限值之前有空閒的時間片,那麼就可以擴充可行解。進一步,怎樣確定一個分配規則呢?

我們可以將\(J\)看做一個排程時間表,其中\(J[i]\)表示第\(i\)個時間片分配的活動編號,我們可以使用插入排序的思路,把任務\(i\)插入相應的位置,只需要\({f[i] < f[J[r]]} \&\& f[J[r]] != r\), for r from J.size() to 0 by -1即可找到插入位置\(r\),隨後把r之後的元素後移即可插入作業\(i\)

那麼這個貪心準則是否可以保證得到的可行解就是最優解呢?答案是肯定的,理論上我們需要證明,一個簡略的思路是使用反證法,假設得到的可行解\(J\)不是最優解,那麼一定存在一個解\(I\),使得效益最大。往證\(I=J\)。具體的證明不做詳細介紹。

根據以上的分析寫出程式碼(這裡時間片從1開始):

vector<int> greedyJob(vector<int>& f)
{
    vector<int> J{-1, 0};
    int cnt = 1;
    for(int i = 1 ; i < f.size() ; ++i)
    {
        int r = cnt;
        while(r > 0 && f[J[r]] > f[i] && f[J[r]] != r)
            --r;
        if(r == 0 || (f[J[r]] <= f[i] && f[i] > r))
        {
            J.push_back(-1);
            for(int j = cnt ; j > r ; --j)
                J[j+1] = J[j];
            J[r+1] = i;
            ++cnt;
        }
    }
    return J;
}

分配的步驟如下所示:

time: 1
activity: 0
threshold: 4

time: 1 2
activity: 1 0
threshold: 2 4

time: 1 2 3
activity: 1 0 2
threshold: 2 4 4

time: 1 2 3 4
activity: 1 3 0 2
threshold: 2 3 4 4

time: 1 2 3 4
activity: 1 3 0 2
threshold: 2 3 4 4

time: 1 2 3 4 5
activity: 1 3 0 2 5
threshold: 2 3 4 4 7

可見在最壞情況下,時間複雜度為\(O(n^2))\)。我們發現,很大一部分的開銷在頻繁的移動元素上,為了避免頻繁的移動資料,我們的分配規則可以改為儘可能的把某任務向接近其期限值的時間片上安排。根據以上分析可以修改為:

vector<int> greedyJob(vector<int>& f)
{
    int n = f.size();
    vector<int> J(n + 1, -1);
    J[1] = 0;
    for(int i = 1 ; i < f.size() ; ++i)
    {
        cout << 
        print(J, f);
        for(int j = min(n, f[i]) ; j > 0 ; --j)
        {
            if(J[j] == -1)
            {
                J[j] = i;
                break;
            }
        }
    }
    return J;
}

分配的步驟如下所示:

time: 1
activity: 0
threshold: 4

time: 1 2
activity: 0 1
threshold: 4 2

time: 1 2 4
activity: 0 1 2
threshold: 4 2 4

time: 1 2 3 4
activity: 0 1 3 2
threshold: 4 2 3 4

time: 1 2 3 4
activity: 0 1 3 2
threshold: 4 2 3 4

time: 1 2 3 4 7
activity: 0 1 3 2 5
threshold: 4 2 3 4 7

雖然減少了元素的移動,但是時間複雜度並沒有改變,最壞情況下的時間複雜度依舊是\(O(n^2)\)

理論上,我們可以通過並查集優化演算法的複雜度,不妨叫做快速帶期限單機排程演算法,可使時間複雜度達到\(O(m*\alpha(n))\),其中\(m\)表示執行\(find\)\(union\)的次數,\(\alpha(n)\)與修訂的\(Ackerman\)函式有關,\(\alpha(n) = min\{k \in N | A_k(1) \ge n \}\)。並查集的解答待續(可能鴿了hhh)。

多機排程問題

看到現在,好像給人一種“貪心準則可以達到最優解”的錯覺,而多機排程問題,就是一個貪心策略無法達到最優解的問題。

問題描述:設有\(n\)項獨立的作業\(\{1,2,…, n\}\),由\(m\)臺相同的機器加工處理。作業\(i\)所需要的處理時間為\(t_i\)。約定:任何一項作業可在任何一臺機器上處理,但未完工前不準中斷處理;任何作業不能拆分更小的子作業分段處理。多機排程問題要求給出一種排程方案,使所給的\(n\)個作業在儘可能短的時間內由\(m\)臺機器處理完。

這是一個\(NP\)完全問題,到目前為止還沒有一個有效的解法。利用貪心策略,有時可以設計出較好的近似解。可以採用貪心準則:需要長時間處理的作業優先處理。而排程策略則選擇:將需要時間最長的未被安排作業首先安排給能夠最早空閒下來的機器處理

上述的貪心演算法叫做\(LPT\)演算法,而這個演算法與最優解之間有一些非常漂亮的結論,有興趣的朋友可以閱讀下這篇文章[1]。

(20200727待續)

最優生成樹問題(Prim, Kruskal)

單源最短路徑問題(Dijkstra)

Huffman編碼

貪心演算法最優性理論

參考文獻

[1] Graham, R.. (1969). Bounds on Multiprocessing Timing Anomalies. Siam Journal on Applied Mathematics - SIAMAM. 17. 10.1137/0117039.