算法分析與設計:貪心算法

1、貪心算法

貪心算法,是在每一次選擇中,總是做出當前看來最好的選擇,而不從整體的最優考慮,選擇只是某種意義上局部的最優解。生活中很多問題需要對資源優化分配,達到資源利用率最大化。貪心算法雖然不能對所有的問題都求得整體最優解,但是對大部分的問題都能求得最優近似解,對部分問題也能得到最優解,例如單源最短路徑最小生成樹等。

● 語言描述與基本思想

貪心算法的語言描述爲:貪心算法一步步進行,每次都對當前的局部最優解判斷:若添加入部分解後仍是可行解就加入;否則捨棄該局部解,尋找下一個局部最優解,直到將所有數據全部枚舉完成或可以確定達到目標
基本思想可以概括爲:從問題的某一個初始解出發,向給定的目標推進,每次都做出當前最佳的貪心選擇,不斷將問題實例歸納爲更小的相似子問題。

● 基本性質

貪心算法常用於解決最大值最小值的優化問題。貪心算法能求得最優解的問題一般具有兩個重要性質

  1. 貪心選擇性質:問題的整體最優解可以通過一系列局部最優的選擇達到。這是貪心算法可行的第一個基本要素,也是貪心算法問題動態規劃問題主要區別。貪心算法通常自頂向下,以迭代的方式做出相繼的貪心選擇。
  2. 最優子結構性質:問題的整體最優解包含子問題的最優解

● 貪心算法與動態規劃對比

貪心算法較爲簡便,效率更高,但求出的解不一定是整體最優解;動態規劃通過求子問題的解構造原問題的解,相對更爲複雜,但通過若干局部解的比較,去掉了次優解,得到的必定是整體最優解。
貪心算法與動態規劃對比

● 基本步驟

貪心算法主要分3個步驟:

  1. 建立數學模型分析問題
  2. 選定合適的貪心選擇標準
  3. 根據貪心選擇標準完成算法

2、經典問題

● 找零問題

  1. 問題描述
    假設有面值爲50元、20元、10元、5元、1元的貨幣,現需要找給顧客x元現金,求最少給出的貨幣總數。
  2. 問題分析
    在現實的合理貨幣標準下,找零問題是最簡單的貪心算法例題之一。若貨幣標準由題目給定,可能需要動態規劃求解。
    設y∈{50,20,10,5,1}使得找x-y元現金成爲原問題的一個子問題,當wx爲原問題的最優解時,wx-y=wx-1必然是原問題的最優解;否則原問題將有wx’=wx-y+1<wx的更優解。由上反證法可證明找零問題具有最優子結構性質
    在題目所給條件下,該問題也具有貪心選擇性質
  3. 算法實現
//找零問題
//貪心算法求最小貨幣數,cash爲貨幣數組,x爲找零總數,res爲各貨幣使用總數
int getMinCash(int x,int *cash,int *res)     
{
	sort(cash);
    int ans = 0;
    int i = 0;
    while(x > 0){
        if(x >= cash[i]){
            x -= cash[i];
            ++res[i];
            ++ans;
        }
        else
            ++i;
    }
    return ans;
}

● 活動安排問題

  1. 問題描述
    設n個活動的集合E={1,2,…,n},每個活動需要使用統一資源,而同一時間只能有一個活動使用這一資源。每個活動i都有一個開始時間si和一個結束時間fi,且必有si<fi。任意一個活動將在半開區間[si,fi)內佔用資源。對於兩個活動i和j,若區間[si,fi)和[sj,fj)不相交,稱i和j是相容的;否則稱i和j是不相容的或衝突的。求在一定時間內所能安排活動的最大數,也即求集合E的最大相容子集。
  2. 問題分析
    活動安排問題是貪心算法有效求解的例題之一。我們選擇結束時間作爲貪心選擇的標準,每次都選擇當前可容的結束時間最早的活動。這樣做的目的是爲剩餘的未安排活動留下儘可能多的時間。該問題的最優子結構性質可由數學歸納法證明;貪心選擇性質可由反證法證明。
  3. 算法實現
//活動安排問題
//獲取最大相容活動個數和子集,s爲開始時間集合,f爲結束時間集合,n爲活動總數,res爲選擇的活動
int getMaxActivity(int *s,int *f,bool* res,int n)  
{
    int lastF = 0;
    int ans = 0;
    for(int i = 1;i <= n;i++){
        if(s[i] >= lastF){
            ++ans;
            res[i] = true;
            lastF = f[i];
        }
    }
    return ans;
}

時間複雜度分析:對於已經給定非降序序列,主要時間消耗在遍歷序列上,故時間複雜度爲O(n);若給定序列無序,還需要進行排序,則時間複雜度由排序的時間複雜度決定,通常爲O(nlog n)。

● 刪數問題

  1. 問題描述
    對給定的數字串x1x2…xn,刪除其中的k個數字,使得剩餘數字按原次序組成的新數字最大。
  2. 問題分析
    對於一個長整數,其高位數字越大,數字也就越大。若存在xi與xi+1滿足xi<xi+1,則刪除xi必然能使數字變大;當序列已經呈非增序時,刪除末尾的元素。由於越高位的數字對數字的大小影響也越大,由此可以從左端開始遍歷,每次遇到第一個刪除的數字就是當前的局部最優解。而每次貪心選擇後,問題都化爲n-1的子問題,因此該問題具有貪心選擇性質
    設An爲n問題的最優解,而An-1是n-1問題的最優解。若An-1不是n-1問題的最優解,設該最優解爲Bn-1,滿足Bn-1>An-1,那麼補上刪除的xi後,必有An = An-1 + xi · 10n-i < Bn-1 + xi · 10n-i = Bn,即存在Bn>An滿足Bn爲更優解。由上反證法可得,該問題具有最優子結構性質
  3. 算法實現
//刪數問題
//x爲數字序列,k爲需要刪除的數字個數,n爲序列長度
void getMaxAfterDelK(int* x,int k,int n)
{
    int i;
    int t = 0;
    while(t < k){
        for(i = 1;i <= n - 1;i++){
            if(x[i] < x[i+1]){
                int tmp = x[i];
                for(int j = i;j <= n - t - 1;j++)	//被刪元素後的元素前移
                    x[j] = x[j+1];
                x[n-t] = tmp;
                ++t;
                break;
            }
        }
        if(i == n)  break;
    }
}

時間複雜度分析:刪數問題的貪心算法主要時間消耗在遍歷數組和移動數組上,共循環k次,所以時間複雜度爲O(kn)。

● 揹包問題

  1. 問題描述
    給定n種物品和一個容量爲C的揹包,物品i的重量爲wi,價值爲vi,如何選擇裝入揹包的物品,使得揹包中物品總價值最大?
  2. 問題分析
    在前面學習動態規劃時提到過,揹包問題分爲兩種:0-1揹包問題可拆分揹包問題(簡稱揹包問題)。對於0-1揹包問題,由於揹包剩餘空間可能降低物品的單位重量價值,因此不適用貪心算法,而適用動態規劃。而這裏將討論物品物品可拆分的揹包問題
    由於揹包的容量是有限的,因此物品的重量和價值都會對結果產生影響。因此我們以物品的單位重量價值作爲貪心選擇的標準。每次選擇單位重量價值最高的物品加入揹包。
    每次貪心選擇放入R%的物品i後,我們的問題就變成C - R% · wi的揹包容量與剩餘物品集合的揹包問題了。設原問題的最優解爲A,子問題的最優解爲A’。設子問題有更優解滿足B‘ > A’,那麼加上揹包內的價值R% · vi後,可以得到A = A’ + R% · vi < B’ + R% · vi = B,即B爲原問題的更優解。如上反證法可得該問題具有最優子結構性質
    設k爲原問題或原問題的一個子問題。對於k的第一個選擇,若該選擇爲貪心選擇,則滿足貪心選擇性質;若該選擇不爲貪心選擇,可以將其替換爲貪心選擇,而不影響之後的選擇,使得得到的新解優於原解。因此,該問題具有貪心選擇性質
  3. 算法實現
//揹包問題
//物品結構體
struct Object{
    double w;
    double v;
    double v_w;
    int i;
};

//objects爲物品集合,c爲揹包容量,n爲集合長度,res儲存物品的選取重量
double getMaxBagValue(Object* objects,double c,int n,double* res)    //貪心算法求揹包能放的最高價值
{
	sort(objects+1,objects+n+1,[](Object a,Object b)->bool{return a.v_w > b.v_w;});	//對物品按單位重量排序
    int i = 1;
    double value = 0;
    while(c >= objects[i].w){		//能完全裝入的完全裝入
        c -= objects[i].w;
        value += objects[i].v;
        res[objects[i].i] += objects[i].w;
        ++i;
    }
    if(c > 0){						//不能完全裝入的,將剩餘空間填滿
        double r = c / objects[i].w;
        value += r * objects[i].v;
        res[objects[i].i] += c;
    }
    return value;
}

時間複雜度分析:揹包問題的貪心算法時間主要消耗在對物品序列排序,其時間複雜度通常爲O(nlog n);若給出的序列以滿足按單位重量價值非增序,則時間消耗主要在遍歷物品序列上,其時間複雜度爲O(n)。

● 單源最短路徑

  1. 問題描述
    給定一個帶權有向圖G={V,E},每條邊的權值爲非負實數。從圖上的一點v0()出發,求到達任意另一點vi最短路徑
  2. 問題分析
    最短路徑問題顯然具有最優子結構性質:最短路徑必然能分成多個子問題的最短路徑,否則該路徑可以以最短路徑替代,以得到更優的路徑。
    最短路徑問題的貪心選擇性質證明如下:
    我們取當前的最短路徑爲貪心選擇標準,若最短路徑不具有貪心選擇性質,則必然存在點vx位於貪心路徑之外。那麼v0到vi的最短路徑d(0,i)可以表示爲:
    d(0,i) = d(0,x) + d(x,i)
    設v0到vi的貪心選擇路徑爲dist(0,i),則一定有:
    d(0,i) < dist(0,i)
    由於各邊的權值均爲非負,那麼應有:
    d(0,x) ≤ d(0,i)
    再由上面的第二個式子可以得到:
    d(0,x) < dist(0,i)
    這個式子表示頂點v0到vx的距離小於v0到vi的貪心選擇距離。根據貪心選擇的定義,vx應當在貪心選擇路徑中。
    由上反證法可證明最短路徑問題的貪心選擇性質

Dijkstra算法是典型的最短路徑算法。它將頂點分爲兩個集合:已得到最短路徑的頂點集合S未確定最短路徑的頂點集合V-S,並設一個數組D保存v0到V-S內頂點的當前最短路徑
算法步驟如下:
● 初始狀態下,S中只有v0,D中記錄v0到其它各頂點出弧的權值;若不鄰接,則記爲無窮大;
● 從D中選擇一條最短、且鄰接點不在S中的路徑,並將鄰接點加入S中,該最短路徑就是v0到該點的最短路徑
● 遍歷該鄰接點的所有出弧,計算出弧的權值源到鄰接點最短路徑的和,並與D中記錄的源到弧頭的最短路徑比較。如果和小於原記錄,則更新數組D。
● 重複步驟2和3,直到所有的頂點都加入S,或目標頂點vi 加入S。

  1. 算法實現
//Dijkstra算法求最短路徑
//圖的結構體
struct Graph{
	int **martix;	//鄰接矩陣
	int v;			//頂點個數
};

//G爲圖,dist爲最短路徑數組,prev爲前置頂點集合,v0爲出發點
void dijkstra(Graph G,int* dist,int *prev,int v0)
{
    bool visited[G.v] = {false};
    visited[v0] = true;
    //初始化
    for(int i = 0;i < G.v;i++){
        if(G.martix[v0][i] > 0 && i != v0){
            dist[i] = G.martix[v0][i];
            prev[i] = v0;
        }
        else{
            dist[i] = 0x7fffffff;
            prev[i] = -1;
        }
    }
    dist[v0] = 0;
    prev[v0] = v0;
    //循環n-1次
    for(int i = 0;i < G.v;i++){
        if(i == v0)     continue;
        int min_num = 0x7fffffff;
        int v;                  //下一個加入的點
        for(int j = 0;j < G.v;j++){     //遍歷查詢最短的路徑
            if(visited[j] == false && dist[j] < min_num){
                min_num = dist[j];
                v = j;
            }
        }
        visited[v] = true;
        for(int j = 0;j < G.v;j++){     //更新dist
            if(visited[j] == false && G.martix[v][j] > 0 && min_num + G.martix[v][j] < dist[j]){
                dist[j] = min_num + G.martix[v][j];
                prev[j] = v;
            }
        }
    }
}

//構造最優路徑
void showPath(int* prev,int v)
{
    if(v == prev[v])
        cout << 'v' << v;
    else{
        cout << 'v' << v << "<-";
        showPath(prev,prev[v]);
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章