图论--最大流

最大流

最大流在货物运输,设施选址问题中可能被用到.

流网络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 算法都会比较快)

posted @ 2017-04-10 11:09  康行天下  阅读(2155)  评论(0编辑  收藏  举报