數據結構與算法--最小生成樹之Prim算法

數據結構與算法--最小生成樹之Prim算法

加權圖是一種為每條邊關聯一個權值或稱為成本的圖模型。所謂生成樹,是某圖的一棵含有全部n個頂點的無環連通子圖,它有n - 1條邊。最小生成樹(MST)是加權圖的一棵權值和(所有邊的權值相加之和)最小的生成樹。

要注意以下幾點:

  • 最小生成樹首先是一個生成樹,所以我們研究的是無環連通分量;
  • 邊的權值可能是0也可能是負數
  • 邊的權值不一定表示距離,還可以是費用等

加權無向圖的實現

之前圖的實現都沒有考慮權值,而權值存在于邊上,所以最好是將“邊”這個概念抽象出來,用一個Edge類來表示。如下

package Chap7;

public class Edge implements Comparable<Edge> {
    private int either;
    private int other;
    private double weight;

    public Edge(int either, int other, double weight) {
        this.either = either;
        this.other = other;
        this.weight = weight;

    }

    public double weight() {
        return weight;
    }

    public int either() {
        return either;
    }

    public int other(int v) {
        if (v == either) {
            return other;
        } else if (v == other) {
            return either;
        } else throw new RuntimeException("該邊無此頂點!");
    }

    @Override
    public int compareTo(Edge that) {
        return Double.compare(this.weight, that.weight);
    }

    @Override
    public String toString() {
        return "(" +
                either +
                "-" + other +
                " " + weight +
                ')';
    }
}

Edge類實現了Comparable<Edge>,使得Edge本身可以進行比較(就像Double類那樣)而比較的依據是邊上的權值。Edge類中的other(int v)方法,接收一個頂點,如果v在該邊中,返回該邊的另一個頂點,否則拋出異常。

接下來是加權無向圖的實現。

package Chap7;

import java.util.*;

public class EdgeWeightedGraph<Item> {

    private int vertexNum;
    private int edgeNum;
    // 鄰接表
    private List<List<Edge>> adj;
    // 頂點信息
    private List<Item> vertexInfo;

    public EdgeWeightedGraph(List<Item> vertexInfo) {
        this.vertexInfo = vertexInfo;
        this.vertexNum = vertexInfo.size();
        adj = new ArrayList<>();
        for (int i = 0; i < vertexNum; i++) {
            adj.add(new LinkedList<>());
        }
    }

    public EdgeWeightedGraph(List<Item> vertexInfo, int[][] edges, double[] weight) {
        this(vertexInfo);
        for (int i = 0; i < edges.length;i++) {
            Edge edge = new Edge(edges[i][0], edges[i][1], weight[i]);
            addEdge(edge);
        }
    }

    public EdgeWeightedGraph(int vertexNum) {
        this.vertexNum = vertexNum;
        adj = new ArrayList<>();
        for (int i = 0; i < vertexNum; i++) {
            adj.add(new LinkedList<>());
        }
    }

    public EdgeWeightedGraph(int vertexNum, int[][] edges, double[] weight) {
        this(vertexNum);
        for (int i = 0; i < edges.length;i++) {
            Edge edge = new Edge(edges[i][0], edges[i][1], weight[i]);
            addEdge(edge);
        }
    }

    public void addEdge(Edge edge) {
        int v = edge.either();
        int w = edge.other(v);
        adj.get(v).add(edge);
        adj.get(w).add(edge);
        edgeNum++;
    }
    // 返回與某個頂點依附的所有邊
    public Iterable<Edge> adj(int v) {
        return adj.get(v);
    }

    public List<Edge> edges() {
        List<Edge> edges = new LinkedList<>();
        for (int i = 0; i < vertexNum; i++) {
            for (Edge e: adj(i)) {
                // i肯定是邊e的一個頂點,我們只取other大于i的邊,避免添加重復的邊
                // 比如adj(1)中的1-3邊會被添加,但是adj(3)中的3-1就不會被添加
                if (e.other(i) > i) {
                    edges.add(e);
                }
            }
        }
        return edges;
    }

    public int vertexNum() {
        return vertexNum;
    }

    public int edgeNum() {
        return edgeNum;
    }

    public Item getVertexInfo(int i) {
        return vertexInfo.get(i);
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append(vertexNum).append("個頂點, ").append(edgeNum).append("條邊。\n");
        for (int i = 0; i < vertexNum; i++) {
            sb.append(i).append(": ").append(adj.get(i)).append("\n");
        }
        return sb.toString();
    }

    public static void main(String[] args) {
        List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4");
        int[][] edges = {{0, 1}, {0, 2}, {0, 3},
                {1, 3}, {1, 4},
                {2, 4}};
        double[] weight = {30.0, 40.0, 20.5, 10.0, 59.5, 20.0};

        EdgeWeightedGraph<String> graph = new EdgeWeightedGraph<>(vertexInfo, edges, weight);
        System.out.println("該圖的鄰接表為\n"+graph);
        System.out.println("該圖的所有邊:"+ graph.edges());

    }
}

edges()方法可以返回圖中的所有邊。下面的判斷比較關鍵

for (Edge e: adj(i)) {
    if (e.other(i) > i) {
        edges.add(e);
    }
}

i肯定是邊e的一個頂點,我們只取other大于i的邊,避免添加重復的邊。 比如adj(1)中的1-3邊會被添加,但是adj(3)中的3-1就不會被添加。

這份代碼只實現了部分方法,像獲取某個頂點的度,圖的平均度數,這些實現起來都很簡單,而且可以直接照搬無權圖的代碼,我們討論的重點是最小生成樹,所以就不貼那些代碼了。

最小生成樹有兩個經典的算法,一個是Prim算法,另外一個是Kruskal算法,接下來會依次介紹它們。

Prim算法

該算法的主要思想是:從生成樹鄰近的邊中,選出一條權值最小的,加入到樹中;如果選出的邊會導致成環,舍棄之,選擇下一條權值最小的邊。

首先我們需要一個優先隊列存放頂點的鄰接邊,一個布爾數組標記已經訪問過的頂點,一個隊列存儲最小生成樹的邊。

  • 從某一個頂點開始(不妨假設從頂點0開始),標記它,并將它鄰接表中的邊全部加入到隊列中;
  • 從隊列中選出并刪除權值最小的那條邊,先檢查這條邊的兩個頂點是否都已經被標記過,若是,加入這條邊會導致成環。跳過這條邊,選擇并刪除下一個權值最小的邊,直到某條邊的兩個頂點不是都被標記過,然后將其加入到MST中,將該邊的另一個頂點標記,并將所有與這個頂點相鄰且未被標記的頂點的邊加入隊列。
  • 重復上述步驟,直到列表中的元素都被刪除。
package Chap7;


import java.util.*;

public class LazyPrim {
    private boolean marked[];
    Queue<Edge> edges;
    private Queue<Edge> mst;

    public LazyPrim(EdgeWeightedGraph<?> graph) {
        marked = new boolean[graph.vertexNum()];
        edges = new PriorityQueue<>();
        mst = new LinkedList<>();
        // 從頂點0開始訪問
        visit(graph, 0);
        // 只要邊還沒被刪除完,就循環
        while (!edges.isEmpty()) {
            // 優先隊列,將權值最小的選出并刪除
            Edge edge = edges.poll();
            int v = edge.either();
            int w = edge.other(v);
            // 這樣的邊會導致成環,跳過
            if (marked[v] && marked[w]) {
                continue;
            }
            // 加入到MST中
            mst.offer(edge);
            // 因為edges中的邊肯定是有一個頂點已經visit過了,但是不知道是either還是other
            // 如果v沒被標記,那么訪問它;否則v被標記了,那么w肯定沒被標記(marked[v] && marked[w]的情況已經被跳過了)
            if (!marked[v]) {
                visit(graph, v);
            } else {
                visit(graph, w);
            }
        }
    }

    private void visit(EdgeWeightedGraph<?> graph, int v) {
        marked[v] = true;
        for (Edge e : graph.adj(v)) {
            // v的鄰接邊中,將另一個頂點未被標記的邊加入列表中。若另一個頂點標記了還加入,就會重復添加
            if (!marked[e.other(v)]) {
                edges.offer(e);
            }
        }
    }

    public Iterable<Edge> edges() {
        return mst;
    }

    public double weight() {
        return mst.stream().mapToDouble(Edge::weight).sum();
    }

    public static void main(String[] args) {
        List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4", " v5", "v6", "v7");
        int[][] edges = {{4, 5}, {4, 7}, {5, 7}, {0, 7},
                {1, 5}, {0, 4}, {2, 3}, {1, 7}, {0, 2}, {1, 2},
                {1, 3}, {2, 7}, {6, 2}, {3, 6}, {6, 0}, {6, 4}};

        double[] weight = {0.35, 0.37, 0.28, 0.16, 0.32, 0.38, 0.17, 0.19,
                0.26, 0.36, 0.29, 0.34, 0.40, 0.52, 0.58, 0.93};

        EdgeWeightedGraph<String> graph = new EdgeWeightedGraph<>(vertexInfo, edges, weight);
        LazyPrim prim = new LazyPrim(graph);
        System.out.println("MST的所有邊為:" + prim.edges());
        System.out.println("最小成本和為:" + prim.weight());
    }
}

/* Outputs

MST的所有邊為:[(0-7 0.16), (1-7 0.19), (0-2 0.26), (2-3 0.17), (5-7 0.28), (4-5 0.35), (6-2 0.4)]
最小成本和為:1.81
*/

上述代碼,mst是一個隊列,用來存放MST中的邊(按照加入的順序)。edges是一個優先隊列,每次訪問一個點,就將它的鄰接邊中,另一頂點未被標記的那些邊加入。每次要從edges里選出權值最小的邊刪除...解釋代碼始終讓人頭暈,還是結合圖來看下最小生成樹的那些邊是怎么選出來的。

  • 從頂點0開始,標記它,并將0-7, 0-2, 0-6, 0-4加入edges。這體現在一開始的visit(graph, 0)
  • 此時edges不為空,只要edges不為空,while循環就一直持續下去。選擇并刪除第一個元素(也就是權值最小的邊0-7),加入到MST中。
  • 接著訪問頂點7,將其所有鄰接邊加入到edges中,選出1-7這條權值最小的邊,加入到MST中。然后訪問頂點1,將除了1-7外的其他和1鄰接的邊都加入到edges中,從edges中選出權值最小的邊為0-2加入到MST,2的鄰接邊中2-7, 1-2由于會導致成環,所以不加入edges,將2-3, 2-6加入。
  • 選擇權值最小的2-3,加入MST。將3-6加入到edges。
  • 選出權值最小的5-7加入MST,4-5加入edges。
  • 接下來1-3,1-5,2-7由于兩個頂點都被標記過,所以被跳過。選擇4-5加入MST,同時6-4加入edges。
  • 1-2, 4-7,0-4連個頂點被標記,跳過。選擇6-2加入MST。至此n個頂點和n - 1條邊都被加入到MST中,最小生成樹完成了。
  • 后續工作,edges中剩余的邊,因為兩個頂點都標記過,所以一直跳過直到edges為空,程序結束。

可以看到,我們依次選出了(0-7 0.16), (1-7 0.19), (0-2 0.26), (2-3 0.17), (5-7 0.28), (4-5 0.35), (6-2 0.4)這些邊,他們的總和(最小權值和)為1.81。

Prim算法的優化

上述Prim算法是延時實現,因為它在列表中保留了無效的邊(會導致成環的邊),每次都要判斷并跳過,甚至最小生成樹完成后,還要歷經后續的檢查。究其原因,是因為每次訪問一個頂點幾乎將其所有鄰接邊都加入了edges列表里面。我們對此進行優化,優化的版本稱為Prim算法的即時實現。如下圖

0為起點,一開始,0-4、0-7、0-2、0-6會加入到edges,然后選出權值最小的0-7邊。關鍵來了,延時實現中,會將7-1, 7-2, 7-5, 7-4全加入edges。我們知道7-2和7-4最后因為是無效邊會被跳過。0和7都已經在MST中,那么7-4和0-4兩條邊不可能被同時選出作為MST的邊,否則成環;所以兩者只能有一條有可能成為MST的邊,自然選權值小的那條啊!所以到頂點4的邊應該選7-4,但是0-4已經被加入到edges中,我們要做的就是用7-4取代0-4;再看另一邊,同樣7-2和0-2也是只有一條有可能作為MST的邊,但是已經加入edges的0-2權值本來就比7-2要小,所以7-2不能加入到edges。優化后的Prim算法在這一步中只將7-1和7-5存入,加上一次將0-4修改為7-4的操作。

上面的意思其實就是說:MST以及一個MST外的頂點,我們總是選擇該頂點到MST各個頂點權值最小的那條邊。

基于此思想,我們來實現Prim算法的即時版本,首先要了解到,由于我們要時常更新存入的邊,所以改用一個edgeTo[]存放到某頂點權值最小的那條邊,distTo[]存放到該頂點的最小權值,也就是說distTo[w] = edgeTo[w].weight()。使用一個Map<Integer, Double> minDist代替原來的edges,用來存放頂點和到該頂點的最小權值,使用Map是因為頂點和到該頂點的最小權值存在一對一的映射關系,而且頂點作為鍵,本來就不存在重復的說法,可以放心用。

package Chap7;


import java.util.*;

public class Prim {
    private boolean marked[];
    private Edge[] edgeTo;
    private double[] distTo;
    private Map<Integer, Double> minDist;


    public Prim(EdgeWeightedGraph<?> graph) {
        marked = new boolean[graph.vertexNum()];
        edgeTo = new  Edge[graph.vertexNum()];
        distTo = new double[graph.vertexNum()];
        minDist = new HashMap<>();
        // 初始化distTo,distTo[0]不會被賦值,默認0.0正好符合我們的要求,使余下的每個值都為正無窮,
        for (int i = 1; i < graph.vertexNum(); i++) {
            distTo[i] = Double.POSITIVE_INFINITY; // 1.0 / 0.0為INFINITY
        }

        visit(graph, 0);
        while (!minDist.isEmpty()) {
            visit(graph, delMin());
        }
    }

    private int delMin() {
        Set<Map.Entry<Integer, Double>> entries = minDist.entrySet();
        Map.Entry<Integer, Double> min = entries.stream().min(Comparator.comparing(Map.Entry::getValue)).get();
        int key = min.getKey();
        minDist.remove(key);
        return key;
    }

    private void visit(EdgeWeightedGraph<?> graph, int v) {
        marked[v] = true;
        for (Edge e: graph.adj(v)) {
            int w = e.other(v);
            if (marked[w]) {
                continue;
            }

            if (e.weight() < distTo[w]) {
                distTo[w] = e.weight();
                edgeTo[w] = e;
                if (minDist.containsKey(w)) {
                    minDist.replace(w, distTo[w]);
                } else {
                    minDist.put(w, distTo[w]);
                }
            }
        }

    }

    public Iterable<Edge> edges() {
        List<Edge> edges = new ArrayList<>();
        edges.addAll(Arrays.asList(edgeTo).subList(1, edgeTo.length));
        return edges;
    }

    public double weight() {
        return Arrays.stream(distTo).reduce(0.0, Double::sum);
    }

    public static void main(String[] args) {
        List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4", " v5", "v6", "v7");
        int[][] edges = {{4, 5}, {4, 7}, {5, 7}, {0, 7},
                {1, 5}, {0, 4}, {2, 3}, {1, 7}, {0, 2}, {1, 2},
                {1, 3}, {2, 7}, {6, 2}, {3, 6}, {6, 0}, {6, 4}};

        double[] weight = {0.35, 0.37, 0.28, 0.16, 0.32, 0.38, 0.17, 0.19,
                0.26, 0.36, 0.29, 0.34, 0.40, 0.52, 0.58, 0.93};

        EdgeWeightedGraph<String> graph = new EdgeWeightedGraph<>(vertexInfo, edges, weight);
        Prim prim = new Prim(graph);
        System.out.println("MST的所有邊為:" + prim.edges());
        System.out.println("最小成本和為:" + prim.weight());
    }
}

/* Outputs

MST的所有邊為:[(1-7 0.19), (0-2 0.26), (2-3 0.17), (4-5 0.35), (5-7 0.28), (6-2 0.4), (0-7 0.16)]
最小成本和為:1.8099999999999998

*/

一開始將distTo[]初始化,除了distTo[0]因為永遠也訪問不到,其余都初始化為正無窮。然后開始訪問頂點0,visit方法基本和延時實現差不多,只是多了判斷,如果有到w權值更小的邊,就更新edgeTo數組和distTo數組,現在edgeTo數組存的是到頂點w權值最小的邊,除了edgeTo[0]永遠不會被訪問到,其值為null外,里面存的分別是到頂點1、2、3...n -1的權值最小的邊,共n - 1條。只要始終維護這個數組,保證到程序結束時到每個頂點的還是權值最小的那條邊,那么由這些邊組成的就是我們要求的最小生成樹。上面有提到,加了判斷后可避免一些遲早會成無效的邊加入。

再看delMin()方法,該方法選出字典中value(也就是權值)最小的那個鍵值對,獲得key(也就是頂點),刪除該鍵值對,緊接著訪問key頂點。和延時實現操作一樣。

最后字典為空時,edgeTo數組也隨之確定好了,不存在后續檢查步驟。輸出那么一長串,是double的鍋,精確值其實為1.81,使用BigDecimal可以得到精確值。

我們來詳細地走一遍。

  • 首先訪問頂點0,邊0-7, 0-2, 0-4, 0-6被加入Map中,因為這些邊是目前(唯一)MST外頂點與MST連接的最小權值邊,也就是說edgeTo[7] = 0-7、edgeTo[2] = 0-2、edgeTo[4] = 0-4、edgeTo[6] = 0-6。
  • 然后刪除權值最小的邊0-7,并開始訪問頂點7,將7-5, 7-1加入Map,7-4因為比0-4權值小(更靠近MST),所以edgeTo[4] 改為 7-4;7-2不加入Map因為0-2的權值本來就比7-2小。現在edgeTo[5] = 7-5, edgeTo[1] = 7-1;
  • 刪除邊7-1,并訪問頂點1。將1-3加入Map,1-5不加入因為7-5的權值本來就比它小。edgeTo[3] = 1-3
  • 刪除0-2,并訪問頂點2,2-3的權值比1-3小,所以更新edgeTo[3] = 2-3,2-6權值小于0-6,更新edgeTo[6] = 2-6
  • 刪除2-3,并訪問頂點3,3-6不加入Map因為2-6權值本來就比它小。
  • 刪除5-7,5-4權值比7-4小,更新edgeTo[4] = 5-4
  • 刪除4-5,訪問頂點4,4-6不加入Map,因為2-6的權值本來就比它小
  • 刪除6-2,至此所有頂點都已訪問過,且Map為空,最小生成樹完成。

Prim算法,就好比:從一株小樹苗開始,不斷從附近找到離它最近的一根樹枝,安在自己身上,小樹慢慢長大成大樹,最后找到n-1條樹枝后就成了最小生成樹。

上面說的太有畫面感了...由于篇幅原因,Kruskal算法在下一節中介紹。


by @sunhaiyu

2017.9.21

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,237評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,957評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,248評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,356評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,081評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,485評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,534評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,720評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,263評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,025評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,204評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,787評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,461評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,874評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,105評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,945評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,205評論 2 375

推薦閱讀更多精彩內容