有向圖和最短路徑Dijkstra、Bellman-Ford、Floyd算法

本篇開始討論關於有向圖的算法,無向圖是特殊的有向圖。
內容概要:

  1. 有向圖的實現
  2. 最短路徑經典算法實現

有向圖的實現

在無向圖的基礎上,修改得到有向圖的類。
有向無權圖類

/*Ice_spring 2020/4/15*/
import java.io.File;
import java.io.IOException;
import java.util.Scanner;
import java.util.TreeSet;

// 支持有向圖和無向圖
public class Graph implements Cloneable{
    private int V; // 頂點數
    private int E; // 邊數
    private TreeSet<Integer>[] adj; // 鄰接矩陣
    boolean directed;

    public Graph(String filename, boolean directed){
        this.directed = directed;
        File file = new File(filename);
        try(Scanner scanner = new Scanner(file)){

            V = scanner.nextInt();
            if(V < 0) throw new IllegalArgumentException("V must be a non-neg number!");
            adj = new TreeSet[V];

            for(int i = 0; i < V; i ++)
                adj[i] = new TreeSet<>();
            E = scanner.nextInt();
            if(E < 0) throw new IllegalArgumentException("E must be a non-neg number!");
            for(int i=0; i < E; i ++){
                int a = scanner.nextInt();
                validateVertex(a);
                int b = scanner.nextInt();
                validateVertex(b);
                // 本代碼只處理簡單圖
                if(a == b) throw new IllegalArgumentException("檢測到self-loop邊!");
                if(adj[a].contains(b)) throw new IllegalArgumentException("Parallel Edges are detected!");
                adj[a].add(b);

                if(!directed)
                    adj[b].add(a);
            }
        }
        catch(IOException e){
            e.printStackTrace();//打印異常信息
        }
    }
    public Graph(String filename){
        // 默認構建無向圖
        this(filename, false);
    }
    public boolean isDirected(){
        return directed;
    }
    public void validateVertex(int v){
        // 判斷頂點v是否合法
        if(v < 0 ||v >= V)
            throw new IllegalArgumentException("vertex " + v + "is invalid!");
    }
    public int V(){ // 返回頂點數
        return V;
    }
    public int E(){
        return E;
    }
    public boolean hasEdge(int v, int w){
        // 頂點 v 到 w 是存在邊
        validateVertex(v);
        validateVertex(w);
        return adj[v].contains(w);
    }
    public Iterable<Integer> adj(int v){
        // 返回值可以是TreeSet,不過用 Iterable 接口更能體現面向對象
        // 返回和頂點 v 相鄰的所有頂點
        validateVertex(v);
        return adj[v];
    }

    public void removeEdge(int v, int w){
        // 刪除 v-w 邊
        validateVertex(v);
        validateVertex(w);
        if(adj[v].contains(w)) E --;
        adj[v].remove(w);
        if(!directed)
            adj[w].remove(v);
    }

    @Override
    public Object clone() {
        try {
            Graph cloned = (Graph) super.clone();
            cloned.adj = new TreeSet[V];
            for(int v = 0; v < V; v ++){
                cloned.adj[v] = new TreeSet<>();
                for(int w: this.adj[v])
                    cloned.adj[v].add(w);
            }
            return cloned;
        }catch (CloneNotSupportedException e){
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public String toString(){
        StringBuilder sb = new StringBuilder();
        sb.append(String.format("V = %d, E = %d, directed = %b\n",V, E, directed));
        for(int v = 0; v < V; v ++){
            // 編程好習慣 i,j,k 作索引, v,w 作頂點
            sb.append(String.format("%d : ", v));

            for(int w: adj[v])
                sb.append(String.format("%d ", w));

            sb.append('\n');
        }
        return sb.toString();
    }

    public static void main(String args[]){
        Graph g = new Graph("g.txt", false);
        System.out.println(g);
    }
}

有向帶權圖類

/*Ice_spring 2020/4/16*/
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.Scanner;
import java.util.TreeMap;

// 無向和有向有權圖
public class WeightedGraph implements Cloneable {
    private int V; // 頂點數
    private int E; // 邊數
    private boolean directed;
    private TreeMap<Integer, Integer>[] adj; // 鄰接集合,存放鄰接點和對應邊權值(可以是浮點型)

    public WeightedGraph(String filename, boolean directed){
        this.directed = directed;
        File file = new File(filename);
        try(Scanner scanner = new Scanner(file)){

            V = scanner.nextInt();
            if(V < 0) throw new IllegalArgumentException("V must be a non-neg number!");
            adj = new TreeMap[V];

            for(int i = 0; i < V; i ++)
                adj[i] = new TreeMap<>();
            E = scanner.nextInt();
            if(E < 0) throw new IllegalArgumentException("E must be a non-neg number!");

            for(int i=0; i < E; i ++){
                int a = scanner.nextInt();
                validateVertex(a);
                int b = scanner.nextInt();
                validateVertex(b);
                int weight = scanner.nextInt();
                // 本代碼只處理簡單圖
                if(a == b) throw new IllegalArgumentException("檢測到self-loop邊!");
                if(adj[a].containsKey(b)) throw new IllegalArgumentException("Parallel Edges are detected!");
                adj[a].put(b, weight);
                if(!directed)
                    adj[b].put(a, weight);
            }
        }
        catch(IOException e){
            e.printStackTrace();//打印異常信息
        }
    }
    public WeightedGraph(String filename){
        // 默認爲無向圖
        this(filename, false);
    }
    public void validateVertex(int v){
        // 判斷頂點v是否合法
        if(v < 0 ||v >= V)
            throw new IllegalArgumentException("vertex " + v + "is invalid!");
    }
    public int V(){ // 返回頂點數
        return V;
    }
    public int E(){
        return E;
    }
    public boolean hasEdge(int v, int w){
        // 頂點 v 到 w 是存在邊
        validateVertex(v);
        validateVertex(w);
        return adj[v].containsKey(w);
    }
    public Iterable<Integer> adj(int v){
        // 返回值可以是TreeSet,不過用 Iterable 接口更能體現面向對象
        // 返回和頂點 v 相鄰的所有頂點
        validateVertex(v);
        return adj[v].keySet();
    }
    public int getWeight(int v, int w){
        // v-w 邊的權值
        if(hasEdge(v, w))
            return adj[v].get(w);
        throw new IllegalArgumentException(String.format("No Edge %d-%d ", v, w));
    }

    public void removeEdge(int v, int w){
        // 刪除 v-w 邊
        validateVertex(v);
        validateVertex(w);
        if(adj[v].containsKey(w)) E --;
        adj[v].remove(w);
        if(!directed)
            adj[w].remove(v);
    }

    public boolean isDirected(){
        return directed;
    }
    @Override
    public Object clone() {
        try {
            WeightedGraph cloned = (WeightedGraph) super.clone();
            cloned.adj = new TreeMap[V];
            for(int v = 0; v < V; v ++){
                cloned.adj[v] = new TreeMap<>();
                for(Map.Entry<Integer, Integer> entry: adj[v].entrySet())// 遍歷Map的方式
                    cloned.adj[v].put(entry.getKey(), entry.getValue());
            }
            return cloned;
        }catch (CloneNotSupportedException e){
            e.printStackTrace();
        }
        return null;
    }
    @Override
    public String toString(){
        StringBuilder sb = new StringBuilder();
        sb.append(String.format("V = %d, E = %d, directed = %b\n",V, E, directed));
        for(int v = 0; v < V; v ++){
            // 編程好習慣 i,j,k 作索引, v,w 作頂點
            sb.append(String.format("%d : ", v));

            for(Map.Entry<Integer,Integer>entry: adj[v].entrySet())
                sb.append(String.format("(%d: %d)", entry.getKey(), entry.getValue()));

            sb.append('\n');
        }
        return sb.toString();
    }
    public static void main(String args[]){
        WeightedGraph g = new WeightedGraph("g.txt", true);
        System.out.println(g);
    }
}

Dijkstra算法

算法過程
Dijkstra算法基於貪心策略和動態規劃。
G=(V,E)是一個有向帶權圖,設置一個集合S記錄已求得最短路徑的頂點,具體過程如下:
(1)初始化把源點v放入S,若vV-S中頂點u有邊,則<u,v>有權值,若u不是v的出邊鄰接點,則<u,v>距離置爲無窮;
(2)從V-S中選取一個到中間頂點v距離最小的頂點k,把k加入S中;
(3)以k爲新的中間頂點,修改源點到V-S中各頂點的距離;若從源點v經過頂點k到頂點u的距離比不經過頂點k短,則更新源點到頂點u的距離值爲源點到頂點k的距離加上<k,u>邊上的權。
(4)重複步驟(2)和(3)直到所有頂點都包含在S中。
該算法無法處理帶負權邊的圖,如下圖,如果帶負權會有兩種情況一種是有負權環(環權值和爲負),那麼點對之間距離可以任意小;另一種是距離無法更新到正確的結果上。

算法實現

import java.util.Arrays;

public class Dijkstra {
    private WeightedGraph G;
    private int s; // 源點s
    private int dis[]; // 源點到各點的最短距離
    private boolean visited[];

    public Dijkstra(WeightedGraph G, int s){
        this.G = G;
        G.validateVertex(s);
        this.s = s;
        dis = new int[G.V()];
        Arrays.fill(dis, 0x3f3f3f3f);
        dis[s] = 0; // 初始狀態

        visited = new boolean[G.V()];
        while(true){
            int curdis = 0x3f3f3f3f, curv = -1;
            for(int v = 0; v < G.V(); v ++)
                if(!visited[v] && dis[v] < curdis){
                    curdis = dis[v];
                    curv = v;
                }
            if(curv == -1) break;

            visited[curv] = true;
            for(int w: G.adj(curv))
                if(!visited[w])
                    if(dis[curv] + G.getWeight(curv, w) < dis[w])
                        dis[w] = dis[curv] + G.getWeight(curv, w);
        }
    }

    public boolean isConnectedTo(int v){
        G.validateVertex(v);
        return visited[v];
    }

    public int distTo(int v){
        // 返回源點 s 到 v 的最短路徑
        G.validateVertex(v);
        return dis[v];
    }
    public static void main(String args[]){
        WeightedGraph g = new WeightedGraph("g.txt");
        Dijkstra d = new Dijkstra(g, 0);
        for(int v = 0; v < g.V(); v ++)
            System.out.print(d.distTo(v) + " ");
    }
}

時間複雜度
在上述實現中,每次確定到一個點的最短路徑,在確定到一個點的最短路徑時需要V次檢查以得到當前未訪問的dis值最小的節點,故時間複雜度爲O(V^2)
一個優化
不過如果對於尋找當前未訪問的dis值最小的節點使用優先隊列(最小堆),這樣就可以做到在優先隊列中動態更新和取得dis[v]的最小值,可以將時間複雜度優化到O(ElogE),實際應用中大部分情況都是稀疏圖所以這是很好的一個優化。

import java.util.*;

public class Dijkstra_pq {
    private WeightedGraph G;
    private int s; // 源點s
    private int dis[]; // 源點到各點的最短距離
    private boolean visited[];
    private int pre[];
    private class Node implements Comparable<Node>{
        public int v, dis;
        public Node(int v, int dis){
            this.v = v;
            this.dis = dis;
        }
        @Override
        public int compareTo(Node another){
            return this.dis - another.dis;
        }
    }
    public Dijkstra_pq(WeightedGraph G, int s){
        this.G = G;
        G.validateVertex(s);
        this.s = s;
        dis = new int[G.V()];
        Arrays.fill(dis, 0x3f3f3f3f);
        pre = new int[G.V()];
        Arrays.fill(pre, -1);

        dis[s] = 0; // 初始狀態
        pre[s] = s;

        visited = new boolean[G.V()];
        Queue<Node> pq = new PriorityQueue<>();
        pq.add(new Node(s, 0));

        while(!pq.isEmpty()){

            int curv = pq.remove().v;
            if(visited[curv]) continue;
            visited[curv] = true;
            for(int w: G.adj(curv))
                if(!visited[w])
                    if(dis[curv] + G.getWeight(curv, w) < dis[w]) {
                        dis[w] = dis[curv] + G.getWeight(curv, w);
                        pre[w] = curv;
                        pq.add(new Node(w, dis[w]));

                    }
        }
    }

    public boolean isConnectedTo(int v){
        G.validateVertex(v);
        return visited[v];
    }

    public int distTo(int v){
        // 返回源點 s 到 v 的最短路徑
        G.validateVertex(v);
        return dis[v];
    }

    public Iterable<Integer> path(int t){
        // 得到最短路徑具體是什麼
        ArrayList<Integer>res = new ArrayList<>();
        if(!isConnectedTo(t)) return res;
        int cur = t;
        while(cur !=s){
            res.add(cur);
            cur = pre[cur];
        }
        res.add(s);
        Collections.reverse(res);
        return res;
    }
    public static void main(String args[]){
        
        WeightedGraph g = new WeightedGraph("g.txt");
        Dijkstra_pq d = new Dijkstra_pq(g, 0);
        for(int v = 0; v < g.V(); v ++)
            System.out.print(d.distTo(v) + " ");
        System.out.println();
        System.out.println(d.path(3));
    }
}

多源最短路
如果要求任意兩個頂點之間的最短路徑,只需要對每個頂點v調用一次Dijkstra算法。另外,如果只關注某兩個頂點之間的最短路徑,可以將算法提前終止。

Bellman-Ford算法

Dijkstra算法雖然時間性能很優秀,但它有一個很大的侷限性就是無法處理帶負權環的圖。爲此來看Bellman-Ford算法,該算法使用動態規劃。
算法過程
G=(V,E)是一個有向帶權圖,Bellman-Ford算法具體過程如下:
(1)初始化dis[s]=0,其它dis值爲無窮;
(2)然後對所有邊進行一次鬆弛操作,這樣就求出了所有點,經過的邊數最多爲1的最短路;
(3)再進行1次鬆弛操作,則求出了所有點經過的邊數最多爲2的最短路;
(4)一般共進行鬆弛操作V-1次,重複到求出所有點經過的邊數最多爲V-1的最短路。
當存在負權環時,如果不停地兜圈子,那麼這個最短路徑是可以無限小的,這時對於圖就沒有最短路徑。另外對於可求最短路徑的圖,鬆弛操作可能比V-1小就可以了,V-1次可以保證求得最短路徑。由此,對於一般有向圖,如果再多進行一次鬆弛操作後dis數組發生了更新,說明圖中含有負權環。
時間複雜度
由於是V-1輪鬆弛操作,每輪對每條邊進行一次鬆弛,故時間複雜度爲O(V*E)
算法實現

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;

public class BellmanFord {
    private WeightedGraph G;
    private int s;
    private int dis[];
    int pre[];
    private boolean hasNegativeCycle;

    public BellmanFord(WeightedGraph G, int s){
        this.G = G;
        G.validateVertex(s);
        this.s = s;
        dis = new int[G.V()];
        Arrays.fill(dis, 0x3f3f3f3f);
        dis[s] = 0;

        pre = new int[G.V()];
        Arrays.fill(pre, -1);
        pre[s] = s;

        for(int i = 1; i < G.V(); i ++){// V - 1 輪鬆弛操作

            for(int v = 0; v < G.V(); v ++)
                for(int w: G.adj(v))// 避免無窮加無窮溢出
                    if(dis[v] != 0x3f3f3f3f && dis[v] + G.getWeight(v, w) < dis[w]) {
                        dis[w] = dis[v] + G.getWeight(v, w);
                        pre[w] = v;
                    }

        }
        // 再進行一次鬆弛操作,如果dis發生更新說明存在負權環
        for(int v = 0; v < G.V(); v ++)
            for(int w: G.adj(v))// 避免無窮加無窮溢出
                if(dis[v] != 0x3f3f3f3f && dis[v] + G.getWeight(v, w) < dis[w])
                    hasNegativeCycle = true;
    }
    public boolean hasNegCycle(){
        // 是否有負權環
        return hasNegativeCycle;
    }

    public boolean isConnectedTo(int v){
        G.validateVertex(v);
        return dis[v] != 0x3f3f3f3f;
    }

    public int disTo(int v){
        // 源點到 v 的距離
        G.validateVertex(v);
        if(hasNegativeCycle)
            throw new RuntimeException("exist negative cycle!");
        return dis[v];
    }

    public Iterable<Integer>path(int t){
        ArrayList<Integer>res = new ArrayList<>();
        if(!isConnectedTo(t)) return res;
        int cur = t;
        while(cur !=s){
            res.add(cur);
            cur = pre[cur];
        }
        res.add(s);
        Collections.reverse(res);
        return res;
    }
    public static void main(String args[]){
        WeightedGraph g = new WeightedGraph("g.txt");
        BellmanFord bf = new BellmanFord(g, 0);
        if(!bf.hasNegCycle())
            for(int v = 0; v < g.V(); v ++)
                System.out.print(bf.disTo(v) + " ");
        System.out.println();
        System.out.println(bf.path(3));
    }
}

一個優化
上述代碼在進行鬆弛操作時對每個dis都進行了檢查,實際上只有和當前考慮頂點相鄰的頂點dis值纔會被更新,爲此可以使用一個隊列記錄已經鬆弛過的節點,只關注每次鬆弛操作會影響的那些頂點的dis值。Bellman-Ford使用隊列優化後的算法稱作SPFA算法。

Floyd算法

Floyd算法解決的是任意兩點之間的最短路徑問題,基於動態規劃。在一些問題中求得任意兩點對間的最短路徑是非常有用的,比如求圖的直徑。Floyd算法同樣可以處理含有帶負權邊的圖,並檢測負權環。
算法過程
G=(V,E)是一個有向帶權圖,Floyd算法維護一個dis矩陣dis[v][w]表示頂點v到頂點w當前最短路徑。具體過程如下:
(1)初始時dis[v][v]=0,如果v-w有邊,則dis[v][w]=邊上的權,否則爲無窮;
(2)進行循環:

    for(int t = 0; t < V; t ++)
        for(int v = 0; v < V; v ++)
            for(int w = 0; w <V; w ++)
                if(dis[v][t] + dis[t][w] < dis[v][w])
                    dis[v][w] = dis[v][t] + dis[t][w];

關於算法正確性的說明:循環語義是從v到w經過[0...t]這些點的最短路徑,當t從0到V-1遍歷後,一定可以求得最短路徑。算法運行結束後如果存在dis[v][v]<0,說明存在負權環。
算法實現

import java.util.Arrays;

public class Floyd {
    private WeightedGraph G;
    private int[][] dis;
    private boolean hasNegativeCycle = false;
    public Floyd(WeightedGraph G){
        this.G = G;
        dis = new int[G.V()][G.V()];
        for(int i = 0; i < G.V(); i ++)
            Arrays.fill(dis[i], 0x3f3f3f3f);
        for(int v = 0; v < G.V(); v ++){
            dis[v][v] = 0;
            for(int w: G.adj(v)){
                dis[v][w] = G.getWeight(v, w);
            }
        }

        for(int t = 0; t < G.V(); t ++)
            for(int v = 0; v < G.V(); v ++)
                for(int w = 0; w < G.V(); w ++)
                    if(dis[v][t] != 0x3f3f3f3f && dis[t][w] != 0x3f3f3f3f
                            && dis[v][t] + dis[t][w] < dis[v][w])
                        dis[v][w] = dis[v][t] + dis[t][w];

        for(int v = 0; v < G.V(); v ++)
            if(dis[v][v] < 0)
                hasNegativeCycle = true;

    }
    public boolean hasNegCycle(){
        return hasNegativeCycle;
    }
    public boolean isConnectedTo(int v, int w){
        G.validateVertex(v);
        G.validateVertex(w);
        return dis[v][w] != 0x3f3f3f3f;
    }
    public int disTo(int v, int w){
        if(isConnectedTo(v, w))
            return dis[v][w];
        throw new RuntimeException("v-w is not connected!");
    }
    public static void main(String args[]){
        WeightedGraph g = new WeightedGraph("g.txt");
        Floyd f = new Floyd(g);
        if(!f.hasNegativeCycle){
            for(int v = 0; v < g.V(); v ++) {
                for (int w = 0; w < g.V(); w++)
                    System.out.print(f.disTo(v, w) + " ");
                System.out.println();
            }
        }

    }
}

時間複雜度
Floyd算法的時間複雜度是O(V^3),不過由於其代碼簡潔,且不包含其他複雜的數據結構,對於一般規模的數據還是可以的。

小結

Dijkstra算法解決單源最短路徑,時間複雜度O(ElogE),使用有線隊列優化後時間複雜度O(ElogV),不過Dijkstra算法不能處理含有負權邊的圖。
Bellman-Ford算法也是解決單源最短路徑,時間複雜度是O(VE),其基於隊列的優化後是SPFA算法,該算法最壞情況下時間複雜度也是O(VE),它們都可以處理含有負權邊的圖。
Floyd算法的時間複雜度是O(V^3),Floyd算法同樣可以處理含有負權邊的圖。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章