1. 程式人生 > >貪心演算法和分治演算法及經典例子

貪心演算法和分治演算法及經典例子

貪心演算法

基本概念

所謂貪心演算法是指,在對問題求解時,總是做出在當前看來是最好的選擇。也就是說,不從整體最優上加以考慮,他所做出的僅是在某種意義上的區域性最優解。

貪心演算法沒有固定的演算法框架,演算法設計的關鍵是貪心策略的選擇。必須注意的是,貪心演算法不是對所有問題都能得到整體最優解,選擇的貪心策略必須具備無後效性,即某個狀態以後的過程不會影響以前的狀態,只與當前狀態有關。

 所以對所採用的貪心策略一定要仔細分析其是否滿足無後效性。  

貪心演算法的基本思路

  1. 建立數學模型來描述問題。

    2. 把求解的問題分成若干個子問題。

    3. 對每一子問題求解,得到子問題的區域性最優解。

    4. 把子問題的解區域性最優解合成原來解問題的一個解。

 

貪心演算法的 實現框架

貪心演算法適用的前提是:區域性最優策略能導致產生全域性最優解

實際上,貪心演算法適用的情況很少。一般,對一個問題分析是否適用於貪心演算法,可以先選擇該問題下的幾個實際資料進行分析,就可做出判斷。

 

貪心演算法的實現框架

    從問題的某一初始解出發;     while (能朝給定總目標前進一步)     {            利用可行的決策,求出可行解的一個解元素;     }     由所有解元素組合成問題的一個可行解;  

貪心策略的選擇

     因為用貪心演算法只能通過解區域性最優解的策略來達到全域性最優解,因此,一定要注意判斷問題是否適合採用貪心演算法策略,找到的解是否一定是問題的最優解。  

例題分析

    下面是一個可以試用貪心演算法解的題目,貪心解的確不錯,可惜不是最優解。       [揹包問題]有一個揹包,揹包容量是M=150。有7個物品,物品可以分割成任意大小。     要求儘可能讓裝入揹包中的物品總價值最大,但不能超過總容量。       物品  A     B    C    D     E     F    G     重量 35   30   60   50   40   10   25     價值 10   40   30   50   35   40   30     分析:     目標函式: ∑pi最大(價值總和最大)     約束條件是裝入的物品總重量不超過揹包容量:∑wi<=M( M=150)     (1)根據貪心的策略,每次挑選價值最大的物品裝入揹包,得到的結果是否最優?     (2)每次挑選所佔重量最小的物品裝入是否能得到最優解?     (3)每次選取單位重量價值最大的物品,成為解本題的策略。       值得注意的是,貪心演算法並不是完全不可以使用,貪心策略一旦經過證明成立後,它就是一種高效的演算法。  比如,求最小生成樹的Prim演算法和Kruskal演算法都是漂亮的貪心演算法。     貪心演算法還是很常見的演算法之一,這是由於它簡單易行,構造貪心策略不是很困難。     可惜的是,它需要證明後才能真正運用到題目的演算法中。     一般來說,貪心演算法的證明圍繞著:整個問題的最優解一定由在貪心策略中存在的子問題的最優解得來的。       對於例題中的3種貪心策略,都是無法成立(無法被證明)的,解釋如下:     (1)貪心策略:選取價值最大者。反例:     W=30     物品:A     B   C     重量:28  12  12     價值:30  20  20     根據策略,首先選取物品A,接下來就無法再選取了,可是,選取B、C則更好。     (2)貪心策略:選取重量最小。它的反例與第一種策略的反例差不多。     (3)貪心策略:選取單位重量價值最大的物品。反例:     W=30     物品: A   B    C     重量:28  20  10     價值:28  20  10     根據策略,三種物品單位重量價值一樣,程式無法依據現有策略作出判斷,如果選擇A,則答案錯誤。  

其實該情況是符合貪心策略的,因為該總情況不管先選哪兩個都會把揹包塞滿,因為該題物品可以分割成任意大小,所以,就算空下一下,也可以將最後一個物品分割,放進去,它們的單位重量的價值是一樣的,所以,最後揹包最後重量相同,重量相同那麼價值也相同。)

所以採用第三種策略,程式碼如下:

package cn.itcast.recursion;

import java.util.Arrays;

public class GreedyPackage {
    private int MAX_WEIGHT = 150;
    private int[] weights = new int[]{35, 30, 60, 50, 40, 10, 25};
    private int[] values = new int[]{10, 40, 30, 50, 35, 40, 30};

    private void packageGreedy(int capacity, int weights[], int[] values) {

        int n = weights.length;//物品的數量
        double[] r = new double[n];//價效比陣列
        int[] index = new int[n];//價效比排序物品的下標
        for (int i = 0; i < n; i++) {
            r[i] = (double) values[i] / weights[i];
            index[i] = i;//預設排序
        }
        double temp = 0;//對價效比進行排序
        for (int i = 0; i < n - 1; i++) {
            for (int j = i + 1; j < n; j++) {
                //降序,對價效比和對應下標進行排序
                if (r[i] < r[j]) {
                    temp = r[i];
                    r[i] = r[j];
                    r[j] = temp;
                    int x = index[i];
                    index[i] = index[j];
                    index[j] = x;
                }
            }
        }
        //排序好的重量和價值分別存到陣列
        int[] w1 = new int[n];
        int[] v1 = new int[n];
        //排序好的重量和價值分別存到陣列
        for (int i = 0; i < n; i++) {
            w1[i] = weights[index[i]];
            v1[i] = values[index[i]];
        }
        //用來裝物品的陣列
        int[] x = new int[n];
        //放入物品的最大價值
        int maxValue = 0;
        //放入物品的總重量
        int totalweights = 0;
        for (int i = 0; i < n; i++) {
            //物品重量比包的總容量小,表示還可以裝得下
            if (w1[i] < capacity) {
                x[i] = 1;//表示該物品被裝了
                maxValue += v1[i];
                System.out.println(w1[i] + "kg的物品被放進包包,價值:" + v1[i]);
                totalweights += w1[i];
                capacity = capacity - w1[i];
            }
        }
        System.out.println("總共放入的物品數量:" + Arrays.toString(x));
        System.out.println("總共放入的物品總重量" + totalweights);
        System.out.println("放入物品的最大價值:" + maxValue);
    }

    public static void main(String[] args) {
        GreedyPackage greedyPackage = new GreedyPackage();
        greedyPackage.packageGreedy(greedyPackage.MAX_WEIGHT, greedyPackage.weights, greedyPackage.values);
    }
}

 

 

分治演算法

定義

將原問題劃分成n個規模較小,並且結構與原問題相似的子問題,遞迴地解決這些子問題,然後再合併其結果,就得到原問題的解。

 

分治策略

  “分而治之”,大問題能夠拆成相似的小問題,記住這些小問題需要具有相似性。而後將小問題的每個解合成為大問題的解。所以說大問題如何拆,小問題如何合併才是這個演算法最主要的一個思想。實際上很多演算法如貪心演算法,動態規劃等等都是要求把大問題拆成小問題。而分治演算法的重要一點就是要適用於能夠重新把小問題的解合併為大問題的解。

 

使用分治演算法的前提條件

  • 原問題與分解成的小問題具有相同的模式;
  • 原問題分解成的子問題可以獨立求解,子問題之間沒有相關性,這一點是分治演算法跟動態規劃的明顯區別;
  • 具有分解終止條件,也就是說,當問題足夠小時,可以直接求解;
  • 可以將子問題合併成原問題,而這個合併操作的複雜度不能太高,否則就起不到減小演算法總體複雜度的效果了

 

每一次遞迴都會涉及三個操作

  • 分解:將原問題分解成一系列子問題;
  • 解決:遞迴地求解各個子問題,若子問題足夠小,則直接求解;
  • 合併:將子問題的結果合併成原問題;

分治法適用條件

 

  1、該問題的規模縮小到一定程度就可以很容易解決;

  2、該問題可以分解為若干個規模較小的相同問題,這裡注意是最優子結構性質;

  3、利用該問題分解出的子問題的解可以合併為該問題的解;

  4、該問題所分解出的各個子問題是相互獨立的,即子問題之間不包含公共子問題;

  對於很多演算法而言,第一條往往是必要的,因為資料量一旦大起來,問題往往復雜度上升的特別快。這裡就需要將這個大問題分解為小問題。小問題處理起來更加方便。第二、三條的才是分治思想的核心,因為很多時候我們會採用遞迴的方式進行解決,所以在大問題分解為小問題的時候需要保證小問題之間的相同性。單單分解為小問題之後還不能算完成,必須要能夠將小問題的解合併為這個問題的最終解才能算真正用到了分治的思想。最後一條也是最關鍵的,各個子問題之間必須要保證獨立性,即不互相影響。如果相互之間有影響,這時候我們採用的是動態規劃就更加好一點。

 

例題

  其實演算法的思想不用講太多,能夠化為幾句話是最好的,下面就舉幾個例子來看看分治演算法:

  例題一:二分查詢,給定一個按照升序排好的陣列array,要在這個陣列中找出一個特定的元素x;

  當我們遇到一個問題,完全可以在心裡問自己下面四個問題:

  1、當前問題能不能切分?

  答:能切分,因為陣列按照升序來排列。所以當x大於某個元素array[mid]時,x一定在array[mid]的右邊。以此再來切分。每次切一半

  2、分解出來的子問題相同嗎?

  答:相同,每個子問題的資料集都是父問題的1/2倍。並且每次只比較子問題的中間的資料

  3、子問題的解能合併為父問題的解嗎?

  答:不需要合併,子問題的解即為父問題的解。

  4、子問題之間相互獨立嗎?

  答:獨立,子問題只是判斷,不需要和父問題有很強的關聯性(這裡可以參考一下動態規劃演算法,就能理解子問題之間怎麼判斷是獨立的)

 

 例題二:歸併排序,給定一個無序陣列array[7]={49,38,65,97,76,13,27},使其變的有序

  同樣在自己心裡問問4個問題

  1、當前問題能切分嗎?

  答:能,最簡單的就是兩個數之間的比較,這個陣列可以看成多個兩個數來比較

  2、分解出來的子問題是否相同?

  答:相同,都是兩個數比較大小。

  3、子問題的解能夠合成父問題的解嗎?

   答:每兩個有序陣列再按照一定順序合起來就是最終的題解。這裡就是有個合併的過程

  4、子問題之間相互獨立嗎?

  答:獨立,分到最小的時候子問題之間互不影響。

  下面是歸併排序程式碼:

 

總結

  分治演算法只是一種思想,不是一個具體的套路,只能說在碰見具體問題時我們能夠從這個思路去思考,切分問題?合併問題?子問題之間影響關聯大不大?這些都是具體問題具體考慮。還有很多很多題目是用了分治演算法。也可以多刷刷題

問題:   

設有n=2^k個運動員,要進行網球迴圈賽。現在要設計一個滿足以下要求的比賽日程表

    (1)每個選手必須與其他n-1個選手各賽一場

    (2)每個選手一天只能賽一次

    (3)迴圈賽一共進行n-1天

將比賽日程表設計成n行n列,表中除了第一列,其他n-1列才是我們要的,陣列下標行列都從0開始,第i行j列代表第(i+1)位選手在第j天的對手:

 

 以8個選手為例子,下面是填表的步驟:

 

①我們先初始化第一行各個數為1~8(2~8為:第1天 — 第7天);

②因為是遞迴,那麼要填8x8的左下角和右下角,分別需要知道它的右上角和左上角

③而8x8的盒子它的左上角是一個4x4的盒子,要填4x4的左下角和右下角,也分別需要知道它的右上角和左上角

④現在遞迴到4x4的盒子的左上角,是一個2x2的盒子,它不需要遞迴了,直接沿對角線填左下角和右下角的數字,也就是上面的圖②

⑤可以看到,經過上面的②③步,我們左上角4x4的盒子,它的·右上角和左上角已經知道了,那就可以沿對角線填它的左下角和右下角了,所以出現了圖④

⑥其他的依次類推

 

通俗易懂地講,就是如果你想填一個大的,你得先得出它左上角和右上角兩個盒子 , 再沿對角線分別抄到右下角和左下角。 而為了得出它左上角和右上角,就需要遞迴了。

 

package cn.itcast.recursion;

public class SportsSchedule {
    public void scheduleTable(int[][] table, int n) {
        if (n == 1) {
            table[0][0] = 1;
        } else {
/*          填充左上區域矩陣
            n值的變化:8  4  2  1
            m值的變化:4  2  1  1  */
            int m = n / 2;
            scheduleTable(table, m);
            //填充右上區域矩陣
            for (int i = 0; i < m; i++) {
                for (int j = m; j < n; j++) {
                    table[i][j] = table[i][j - m] + m;
                }
            }
            //填充左下區域矩陣
            for (int i = m; i < n; i++) {
                for (int j = 0; j < m; j++) {
                    table[i][j] = table[i - m][j] + m;
                }
            }
            //填充右下區域矩陣
            for (int i = m; i < n; i++) {
                for (int j = m; j < n; j++) {
                    table[i][j] = table[i - m][j - m];
                }
            }
        }
    }

    public static void main(String[] args) {
        int[][] table = new int[8][8];
        int n = 8;
        SportsSchedule schedule = new SportsSchedule();
        schedule.scheduleTable(table, n);
        int c = 0;
        //列印二維陣列
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                System.out.print(table[i][j] + " ");
                c++;//每列印一個數,c++
                if (c % n == 0) {//說明列印一行了
                    System.out.println();//換行
                }
            }
        }
    }
}

 

&n