圖論--最大流


最大流

最大流在貨物運輸,設施選址問題中可能被用到.

流網絡G=(V,E)是一個連通的有向圖,滿足以下限制:

  • 容量限制:每條邊(u,v)有容量限制c,且不存在反向平行的邊(v,u).
  • 流量守恆:流入一個點的等於流出改點的流量.

最大流問題:給定一個流網絡,一個源結點,一個匯點,找出從源結點可以流出的最大的流.

實際運用中一張圖可能未嚴格滿足流網絡的要求,但是可以通過較小的轉換來轉成流網絡,如:

  • 多源多匯:只需添加一個超級源點和超級匯點,與原始的源點和匯點分別連接,將這些邊的流量限制按實際問題設置,如設為無窮或消費需求.
  • 反平行邊:雙向網絡即存在反平行邊,在相互反平行的其中一條邊上添加一個中間節點即可打破平行.

雖然理論上要按照上述的做法轉成流網絡,但在編程實現中可不必這么做,如超級源點有時不必添加,分別對每一個原始的源點計算即可.

Ford-Folkerson方法

殘存網絡:對流網絡分配流量,對剩余容量修改后的網絡.

增廣路徑:殘存網絡中從源s到匯t的可行的簡單路徑.

方法:

Ford-Folkerson(G,s,t)
初始化零流f;
while(存在增廣路徑p){
  min = p上瓶頸邊的容量;
  沿着p路徑對流f增加min;
}

return f;

算法的執行效率取決於尋找增廣路的方法,如果使用廣度優先搜索,則此方法又稱為Edmonds-Karp(EK)算法,其時間復雜度為\(O(VE^2)\).

最小費用最大流

此問題指的是滿足最大流的前提下使得費用最小化.在Ford-Folkerson方法中找增廣路徑那里每次找當前的最短路即可使得費用最小.

由於流網絡是單源點,所以需要用單源最短路徑算法,可選的有Dijikstra,SPFA,Bellman-Ford.其中隊列實現的SPFA像是Dijikstra與Bellman-Ford的變異體,能運用的場景(是否存在負權,負環等)和時間復雜度各有不同.Dijikstra不能用於負權(更不能用於負環),而Bellman-Ford可以用於負權,不能用於負環.Dijikstra時間復雜度比Bellman-Ford低.

Dijikstra

Dijikstra(G,w,s)
    初始化單源點s到每個點的距離(s到自己的距離為0);
	S=NULL;//s到集合S中的每個點的最短路已找到
    Q=G.V;//所有節點的最小優先隊列
	while(Q is not NULL){
      u=Q.min;//V-S中與s距離最短的點
      S.add(u);//將u加入到S中,在Q中不再考慮
      for(v:G.Adj[u])
        Relax(u,v,w);//通過u來更新v的距離,即更改標號數組dist[]
	}

基礎版的最小優先隊列實現的Dijikstra總運行時間為\(O(V^2+E)\),使用二叉堆實現優先隊列之后的復雜度為\(O((V+E)lgV)\),斐波那契堆實現的更低,為\(O(VlgV+E)\).

二叉堆實現的Dijikstra算法代碼:

/**
 * Created by fyk on 17-4-3.
 * 使用優先級隊列(二叉堆),求單源最短路
 */
public class DijkstraPro {
    Graph g;
    //存放源點到每個點的最短距離
    int[] ShortDis=new int[g.V];

    public DijkstraPro(Graph g) {
        this.g=g;
        Arrays.fill(ShortDis, Integer.MAX_VALUE);
    }

    public void computShortestPath(PNode source) {
        PriorityQueue<PNode> que = new PriorityQueue<PNode>();
//        FibonacciHeap<PNode> que = new FibonacciHeap<PNode>();
        source.length = 0;
        que.add(source);
//        que.enqueue(source,source.length);
        while (!que.isEmpty()) {
            PNode u = que.remove();
//            PNode u = que.dequeueMin().getValue();
            for (int j = 0; j < g.V; j++) {
                //反向邊(服務點->消費點)// The neighbor of vertex u
                if(g.edges[j][u.id]!=null && g.edges[j][u.id].band_remain != 0//剩余流量
                        && (u.length + g.edges[u.id][j].weight) < ShortDis[j]){
                    ShortDis[j] =  u.length + g.edges[u.id][j].weight;
                    PNode node = new PNode(j, ShortDis[j], u);
                    que.add(node);// 新節點入堆
//                    que.enqueue(node,node.length);
                }
            }

        }
    }
}

上述代碼中的PriorityQueue可以替換成FibonacciHeap,可方便使用的一個斐波那契堆的Java實現網址:FibonacciHeap.java.

SPFA

代碼:

	/**
     * 這里使用Bellman Ford改進版SPFA算法,尋找最短路
     * 參考https://en.wikipedia.org/wiki/Shortest_Path_Faster_Algorithm
     * http://www.nocow.cn/index.php/SPFA算法
     * SPFA算法有兩個優化算法 SLF 和 LLL: SLF:Small Label First 策略,設要加入的節點是j,隊首元素為i,若dist(j)<dist(i),則將j插入隊首,否則插入隊尾。 LLL:Large Label Last 策略,設隊首元素為i,隊列中所有dist值的平均值為x,若dist(i)>x則將i插入到隊尾,查找下一元素,直到找到某一i使得dist(i)<=x,則將i出對進行松弛操作。 SLF 可使速度提高 15 ~ 20%;SLF + LLL 可提高約 50%。 在實際的應用中SPFA的算法時間效率不是很穩定,為了避免最壞情況的出現,通常使用效率更加穩定的Dijkstra算法。
     * 對於負環,我們可以證明每個點入隊次數不會超過V,所以我們可以記錄每個點的入隊次數,如果超過V則表示其出現負環,算法結束
     * @return 是否有從s到t的增廣路
     */
boolean SPFA(int s,int t){
        Arrays.fill(dist, g.INF);//輸出,s到所有點的距離
        Arrays.fill(edge_to, null);//輸出,最短路上的前驅節點鏈表
        int[] enQcnt=new int[V]; //記錄入隊次數,超過V則退出,說明存在負環

        q.clear();
        for(int s:g.videoNodes){
            q.offer(s);//入隊尾
            dist[s] = 0;//到自己距離為0
        }

        while(!q.isEmpty()){
            int u=q.poll();//removeFirst();
            LinkedList<DirectedEdge> edges = g.adj[u];
            if(edges == null) continue;
            for(DirectedEdge edge:edges){
                if(edge.remain() == 0)
                    continue;// 如果剩余容量為0,這條邊就不存在
                int price = edge.price;
                int v = edge.to.id;
                // 松弛操作
                int newDist = dist[u] + price;
                if( dist[v] > newDist ){
                    dist[v]    = newDist;
                    edge_to[v] = edge; // 更新前趨邊
                    if( ! q.contains(v)){//避免出現環路
                        enQcnt[v]+=1; //節點入隊次數自增
                        if(enQcnt[v]>V){ //已經超過V次,出現負環,清空隊列,釋放內存
                            return false; //返回FALSE
                        }
                        //SLF優化,比較要加入的節點和隊首元素的dist
                        if(!q.isEmpty()){
                            if(dist[v]<dist[q.peekFirst()])
                                q.offerFirst(v);//則將j插入隊首
                            else
                                q.offerLast(v);//否則插入隊尾
                        }else{
                            q.offerLast(v);
                        }
                        //LLL優化:Large Label Last 然而可能更費時間了
                        /*int sum = 0;
                        for(int id:q){
                            sum+=dist[id];
                        }
                        double x = ((double)sum)/q.size();//均值
                        while (dist[q.peekFirst()] > x){
                            int tmp=q.pollFirst();
                            q.offerLast(tmp);
                        }*/
                    }
                }
            }
        }
        if( dist[t] != g.INF)
            return true;
        else
            return false;
    }

Bellman-Ford

與Dijikstra不同的是每次外層循環都考慮所有的邊,而Dijikstra每次維護最小隊列時的時間不相同(不斷縮小).

Bellman-Ford(G,w,s)
    初始化單源點s到每個點的距離(s到自己的距離為0);
	for(i=1;i<|G.V|;i++){//|G.V|-1次更新
      for(edge(u,v): G.E)
        Relax(u,v,w);
	}
	//檢查負環
	for(edge(u,v): G.E)
      if(v.d > u.d + w(u,v))
        return FALSE;//存在負環,上邊計算的最短距離是錯誤的
	return TRUE;

時間復雜度\(O(VE)\).

用於稀疏圖的Johnson算法

用於所有節點對之間的最短路計算,不能用於負環.在稀疏圖情況下通常比Floyd-Warshall表現要好,用二叉最小堆dijkstra實現的時間為\(O(VElgV)\),斐波那契堆則為\(O(V^2lgV+VE)\).

Johnson(G,w)
  在圖G中添加虛擬源點s,並在s與所有節點之間添加0權重的邊,構成G2;
  if(Bellman-Ford(G2,w,s)==FALSE) //對G2運行Bellman-Ford
    return NULL;//存在負環,退出
  for(v:G2.V)
    h(v)=dist-of-Bellman-Ford(s,v);//Bellman-Ford計算的最短距離用於下面的重賦權
  for(edge(u,v): G2.E)
    w(u,v)=w(u,v) + h(u)-h(v);
  D={d(u,v)};//nxn矩陣,存儲結果
  for(u:G.V){//原圖的每個結點計算最短路
    Dijikstra(G,w,u);
    for(v:G.V)//還原真實距離
      d(u,v)=dist-of-Dijikstra(u,v) + h(v)-h(u);
  }
  return D;

如果圖中沒有負環可以通過對負權邊重新賦權變為正數后應用Dijikstra來提高Ford-Folkerson方法的后續查找最短路的效率.

最小費用流具體實現

基於SPFA的Ford-Folkerson方法求最小費用最大流代碼:

	/**
     * 貪心選擇:每次尋找費用最少的增廣路
     * 如果圖中沒有負環可以通過Johnson多源最短路方法中的重賦權方法對負權邊重新賦權變為正數后應用Dijikstra來提高Ford-Folkerson方法的后續查找最短路的效率.如果沒有負權,那么直接用Dijikstra吧.
     * param: 單源點s,匯點t
     * @return 最小費用
     */
    public int maxFlowFordFulkerson(int s,int t) {
        	int totalCost = 0;
            while (SPFA(s,t)) {//有增廣路則求出
                int minflow = INF;  // 瓶頸邊容量
                // 尋找瓶頸邊
                int x = t;
                while (edge_to[x] != null) {
                    minflow = Math.min(minflow, edge_to[x].remain());
                    x = edge_to[x].from.id;
                }
                // 增廣
                x = t;
                while (edge_to[x] != null) {
                    DirectedEdge e = edge_to[x];
                    totalCost += minflow * e.price;// 更新總費用
                    e.flow += minflow;
                    e.reverse_edge.flow -= minflow;
                    x = edge_to[x].from.id;
                }
            }

        return totalCost;
    }

流量路徑分配,雖然最小費用是求出來了,但是能不能輸出從源點到匯點的路徑流量分配情況呢?因為圖上最終保存了每條邊上分配的流量,所以可以據此來給每條s->t的可行路分配流量.你可能想到直接把最小費用計算過程中的增廣路作為輸出的路徑,而流量分配是最終該條路徑上所有邊的最小流量值.然而Ford-Folkerson方法用的回流的思想,所以增廣路s->t方向上某些邊可能是負流量.所以保險的方法是深度優先遍歷.

/**
 * Created by fyk on 17-3-28.
 * 用於最大流計算之后輸出流量分配路徑,給每一條深度遍歷的路徑分配流量后比較粗暴地將該路徑的所有邊減去分配的流量.
 */
public class PathSearcher {
    private static int path_index;
    /**
     * 輸出所有 paths from 's' to 'd'
     * s,d必須是超級源和匯集節點,輸出不包含這兩個
     */
    public static ArrayList<String> getAllPaths(LinkGraph g)
    {
        boolean[] visited = new boolean[g.Vt];
        int[] path = new int[g.Vt];
        path_index = 0;
        ArrayList<String> result=new ArrayList<>();
        Arrays.fill(visited,false);
        int d=g.superDst.id;

        Stack<DirectedEdge> stack=new Stack<>();
        for(int s:g.sources){//多個源點,這里忽略了超級源點
            // 遞歸
            getAllPathsUtil(stack,result,g,s, d, visited, path);
        }

        return result;
    }

    /**
     *  遞歸打印u到d的所有路徑
     *  visited[] 記錄當前路徑中的節點.
     *  path[] 存儲實際的節點,
     *  path_index是 path[] 中的當前索引
     */
    private static void getAllPathsUtil(Stack<DirectedEdge> stack,ArrayList<String> result,LinkGraph g,int u, int d, boolean[] visited,
             int path[])
    {
        // 標記當前節點
        visited[u] = true;
        path[path_index] = u;
        path_index++;
        if (u == d)
        {
            int curminflow=Integer.MAX_VALUE;
            for(DirectedEdge e:stack){
                curminflow = e.flow <curminflow?e.flow:curminflow;
            }

            if(curminflow>0){
                StringBuilder sb=new StringBuilder();
                int n=path_index-1;//忽略超級匯集節點
                for (int i = 0; i<n; i++) {//忽略首尾節點
                    sb.append(path[i]).append(' ');
                }
                sb.append(curminflow);
                for(DirectedEdge e:stack){
                    e.flow -= curminflow;
                }
                result.add(sb.toString());
            }
        }
        else
        {
            // 相鄰節點遞歸
            LinkedList<DirectedEdge> edges=g.adj[u];
            Iterator<DirectedEdge> iter=edges.iterator();
            while ( iter.hasNext()){
                DirectedEdge e=iter.next();
                if( e.flow>0 && !visited[e.to.id]){
                    stack.push(e);
                    getAllPathsUtil(stack,result,g,e.to.id, d, visited, path);
                    stack.pop();
                }
            }

        }

        // 刪除當前節點,標記
        path_index--;
        visited[u] = false;
    }
}

To be continued...

消圈算法(通常較慢), zkw 費用流算法(對於最終流量較大, 而費用取值范圍不大的圖, 或者是增廣路徑比較短的圖 (如二分圖), zkw 算法都會比較快)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM