数据结构与算法--拓补排序及无环加权有向图的最短路径

数据结构与算法--拓补排序及无环加权有向图的最短路径

现实生活中一些项目工程、生产开发,都有一个所谓的流程。一个流程分为若干个活动或者说步骤,这些活动具有一个优先级,很显然我们得按照优先级顺序去执行它们(优先级相同的活动可以不强调先后),即某些活动完成之后才能执行下一活动。不能把后期的任务放到前面来做是吧。这可以抽象成有向图,且要求该有向图不能成环。比如你参加校园话剧表演,你总得先写剧本 -> 不断排练 -> 登台正式表演。成有向环就是说,正式表演 -> 写剧本,哦?你是啥准备都没就即兴表演了,剧本都是表演后才写,这明显不符合逻辑,所以流程中不允许出现环。如果某个工程中出现了有向环,无疑是设计失误了。

拓补序列

上面表达的意思用图(Graph)来说就是:有向图G中,从顶点v 到w有一条路径,则在顶点序列中v必须在w之前,这样的顶点序列称为拓补序列。而拓补排序就是构造拓补序列的过程。

当且仅当一幅有向图是无环图时它才能进行拓补排序。

拓补排序的核心思想:入度为0的顶点需优先被放入序列中,待所有顶点都被放入,拓补序列就完成了。如果将顶点想像成活动,入度为0就是说任何其他活动执行完毕后也不会轮到该活动了(因为流程图中没有一个箭头指向这个活动),该活动就被漏下、被遗忘了,工程中少了一个步骤那怎么行?!所以对于入度为0的活动,一定要先做;当然如果有多个这样的活动存在,任意选出一个先执行都可以。

基于DFS的顶点排序

基于上述思想,可以使用深度优先搜索实现拓补排序。原理是利用了递归产生的由系统维护的栈。方法调用表示进栈,方法结束表示出栈。进栈的顺序表示了访问顶点的顺序。比如先调用dfs(s),然后递归调用dfs(w),再调用dfs(v),这就表示了一条s -> w -> v的路径。使用DFS保存顶点有三种顺序:

  • 前序:在递归调用刚开始将顶点存入队列;
  • 后序:在递归调用即将结束时将顶点存入队列;
  • 逆后序:在递归调用即将结束时将顶点存入压入栈。

先给出结论:一幅有向无环图的拓补顺序即为所有顶点的逆后序排列。

现在来证明,我们知道拓补序列中对于图中任意边v -> w,v必须在排在w之前。现对于任意v -> w边,当调用dfs(v)时候,无非以下几种情况

  • dfs(w)还没被调用。这说明刚进入dfs(v)方法,还没有(即将)进入dfs(w)。那么dfs(w)会在dfs(v)之前返回。
  • dfs(w)被调用过了,且已经返回。这说明说明是先dfs(v),然后dfs(w),然后dfs(w)执行完毕,当然是返回到dfs(v)方法体了。此时dfs(w)依然是先于dfs(v)返回。
  • dfs(w)被调用,但是没有返回。这说明dfs(w)先被调用,然后才调用的dfs(v),只有这样才会出现dfs(w)调用了却没有返回。方法的调用顺序说明说明之前访问的路径为w -> v,而现在是v -> w的边刚好形成环。这说明在无环有向图中,这最后一种情况不存在。

前两种都是dfs(w)比dfs(v)先返回,所以当处理无环有向图时一定是先返回w再返回v,所以我们利用dfs返回顺序的不变性,在它即将返回之际,用栈存入就一定能保证v -> w的正确顶点排列顺序。既然对于任意边都能保证正确的顶点排列顺序,那么对整个图的所有顶点,排列顺序也是正确的

开始实现,首先不能有环,写个类判断。

package Chap7;

import java.util.LinkedList;

public class DiCycle {
    private boolean[] marked;
    private int[] edgeTo;
    private boolean[] onStack;
    private LinkedList<Integer> cycle;

    public DiCycle(DiGraph<?> graph) {
        marked = new boolean[graph.vertexNum()];
        edgeTo = new int[graph.vertexNum()];
        onStack = new boolean[graph.vertexNum()];
        // 有向图可能不是强连通的,所以需要从每个顶点出发,寻找有向环
        for (int i = 0; i < graph.vertexNum(); i++) {
            dfs(graph, i);
        }
    }

    public DiCycle(EdgeWeightedDiGraph<?> graph) {
        marked = new boolean[graph.vertexNum()];
        edgeTo = new int[graph.vertexNum()];
        onStack = new boolean[graph.vertexNum()];
        // 有向图可能不是强连通的,所以需要从每个顶点出发,寻找有向环
        for (int i = 0; i < graph.vertexNum(); i++) {
            dfs(graph, i);
        }
    }

    private void dfs(DiGraph<?> graph, int v) {
        // 模拟系统递归使用的栈,方法开始进栈;方法结束出栈
        onStack[v] = true;
        marked[v] = true;
        for (int w : graph.adj(v)) {
            // 如果已经存在环,终止递归方法
            if (hasCycle()) {
                return;
            }
            if (!marked[w]) {
                edgeTo[w] = v;
                dfs(graph, w);
                // v -> w的路径,且w在栈中,说明形成有向环
            } else if (onStack[w]) {
                cycle = new LinkedList<>();
                for (int x = v; x != w; x = edgeTo[x]) {
                    cycle.push(x);
                }
                // 导致成环的连个顶点入栈
                cycle.push(w);
                cycle.push(v);
            }
        }
        onStack[v] = false;
    }

    private void dfs(EdgeWeightedDiGraph<?> graph, int v) {
        // 模拟系统递归使用的栈,方法开始进栈;方法结束出栈
        onStack[v] = true;
        marked[v] = true;
        for (DiEdge edge : graph.adj(v)) {
            // 如果已经存在环,终止递归方法
            if (hasCycle()) {
                return;
            }
            int w = edge.to();
            if (!marked[w]) {
                edgeTo[w] = v;
                dfs(graph, w);
                // v -> w的路径,且w在栈中,说明形成有向环
            } else if (onStack[w]) {
                cycle = new LinkedList<>();
                for (int x = v; x != w; x = edgeTo[x]) {
                    cycle.push(x);
                }
                // 导致成环的连个顶点入栈
                cycle.push(w);
                cycle.push(v);
            }
        }
        onStack[v] = false;
    }

    public boolean hasCycle() {
        return cycle != null;
    }

    public Iterable<Integer> cycle() {
        return cycle;
    }
}

此方法也是基于DFS实现,原理也是递归产生的由系统维护的栈。onStack[]其实就是一条路径,onStack[w] = true说明顶点w位于从起点s可达的onStack[]这条路径中。当找到一条v -> w,而此时w正好在onStack[]中(曾访问过且还在路径中),说明在这条路径上成环了。

然后是深度优先搜索的顶点排序,只是在dfs代码的基础上加了几行添加数据而已。针对拓补排序,我们只需要逆后序排列reversePost

package Chap7;

import java.util.LinkedList;
import java.util.Queue;

public class DFSorder {
    private boolean[] marked;

    private Queue<Integer> pre; // 前序
    private Queue<Integer> post; // 后序
    private LinkedList<Integer> reversePost; // 逆后序

    public DFSorder(DiGraph<?> graph) {
        pre = new LinkedList<>();
        post = new LinkedList<>();
        reversePost = new LinkedList<>();
        marked = new boolean[graph.vertexNum()];
        // 有向图可能不是强连通的,所以需要从每个顶点出发,寻找有向环
        for (int v = 0; v < graph.vertexNum(); v++) {
            if (!marked[v]) {
                dfs(graph, v);
            }
        }
    }

    public DFSorder(EdgeWeightedDiGraph<?> graph) {
        pre = new LinkedList<>();
        post = new LinkedList<>();
        reversePost = new LinkedList<>();
        marked = new boolean[graph.vertexNum()];
        // 有向图可能不是强连通的,所以需要从每个顶点出发,寻找有向环
        for (int v = 0; v < graph.vertexNum(); v++) {
            if (!marked[v]) {
                dfs(graph, v);
            }
        }
    }

    private void dfs(EdgeWeightedDiGraph<?> graph, int v) {
        pre.offer(v);
        marked[v] = true;

        for (DiEdge edge : graph.adj(v)) {
            int w = edge.to();
            if (!marked[w]) {
                dfs(graph, w);
            }
        }

        post.offer(v);
        reversePost.push(v);
    }

    private void dfs(DiGraph<?> graph, int v) {
        pre.offer(v);
        marked[v] = true;

        for (int w:graph.adj(v)) {
            if (!marked[w]) {
                dfs(graph, w);
            }
        }

        post.offer(v);
        reversePost.push(v);
    }

    public Iterable<Integer> pre() {
        return pre;
    }

    public Iterable<Integer> post() {
        return post;
    }

    public Iterable<Integer> reversePost() {
        return reversePost;
    }
}

有了这些,实现拓补排序就轻松的。

package Chap7;

public class TopoSort {
    private Iterable<Integer> order;

    public TopoSort(DiGraph<?> graph) {
        DiCycle cycleFinder = new DiCycle(graph);
        if (!cycleFinder.hasCycle()) {
            DFSorder dfs = new DFSorder(graph);
            order = dfs.reversePost();
        }
    }

    public TopoSort(EdgeWeightedDiGraph<?> graph) {
        DiCycle cycleFinder = new DiCycle(graph);
        if (!cycleFinder.hasCycle()) {
            DFSorder dfs = new DFSorder(graph);
            order = dfs.reversePost();
        }
    }

    public boolean isDAG() {
        return order != null;
    }

    public Iterable<Integer> order() {
        return order;
    }

    public static void main(String[] args) {
        int[][] edges = {{0, 1}, {0, 5}, {0, 6}, {2, 0}, {2, 3}, {3, 5},{5, 4},{6, 4}, {6, 9}, {7, 6},
                {8, 7}, {9, 10}, {9, 11}, {9, 12}, {11, 12}};
        DiGraph<?> graph = new DiGraph<>(13, edges);
        TopoSort topo = new TopoSort(graph);

        if (topo.isDAG()) {
            System.out.println(topo.order());
        }
    }
}

/* Output
[8, 7, 2, 3, 0, 6, 9, 11, 12, 10, 5, 4, 1]
*/

先使用DiCycle类判断无环才进入后续操作,然后直接返回了DFS的逆后序排列,就是拓补序列了。isDAG()判断该图是否是一幅无环有向图,如果是,用order方法可以将拓补序列返回。以上几个类我们都重载了相应的方法,使得它们也可以处理加权有向图。

更为流行的实现

如果认为上面的实现不太好懂,将要介绍的这个思路更加直观容易理解,而且更为流行。我们把这句入度为0的顶点必须先被放入序列中,翻译成代码就OK了。

为此我们需要两条队列,一条zeroInQueue专门存放那些入度为0的顶点,另一条队列order表示拓补序列,我们要做的就是每次将前者中的顶点出列,然后入列到后者中。还需要一个in[]数组,存放下标对应顶点的入度。当将一个入度为0的顶点v删除并加入序列中,就表明此活动v已经执行完毕了,从v可达的所有邻接点w,它们入度都要减小1,表示活动w的前期活动已经完成一项(前期活动都完成后才轮到w执行)。若某个顶点的入度在自减过程中变为0,说明它可以被执行了,所以加入到序列中。

好了,给出实现。

package Chap7;

import java.util.LinkedList;
import java.util.Queue;

public class Topological {
    private int[] in;
    private Queue<Integer> zeroInQueue; // 入度为0的队列
    private Queue<Integer> order; // 保存拓补排序的队列

    public Topological(DiGraph<?> graph) {
        zeroInQueue = new LinkedList<>();
        order = new LinkedList<>();
        in = new int[graph.vertexNum()];
        // 原图的逆向图,求逆向图的出度得到的就是原图的入度
        DiGraph<?> R = graph.reverse();
        for (int i = 0; i < graph.vertexNum(); i++) {
            int inCount = 0;
            // 逆向图的出度就是原图的入度
            for (int ignored : R.adj(i)) {
                inCount++;
            }
            // 得到各个顶点的入度数组
            in[i] = inCount;
        }
        // 以上是初始化

        // 先将入度为0的所有顶点入列
        for (int i = 0; i < in.length; i++) {
            if (in[i] == 0) {
                zeroInQueue.offer(i);
            }
        }
        // 不断取出队列中的顶点,将该顶点的邻接点的入度减去1
        while (!zeroInQueue.isEmpty()) {
            int v = zeroInQueue.poll();
            order.offer(v); // 加入到拓补序列中
            // 可以看作是移除该顶点,于是和该顶点相邻的边也删掉。从度的角度来看就是邻接点入度减小1
            for (int w : graph.adj(v)) {
                if (--in[w] == 0) {
                    zeroInQueue.offer(w);
                }
            }
        }
    }

    public Iterable<Integer> order() {
        // 是拓补序列才输出
        if (isDAG()) {
            return order;
        }
        return null;
    }

    public boolean isDAG() {
        // in.length就是图的顶点数,只要不是全部顶点都进入到了拓补排序队列中,说明遇到了有向环
        return order.size() == in.length;
    }

    public static void main(String[] args) {
        int[][] edges = {{0, 1}, {0, 5}, {0, 6}, {2, 0}, {2, 3}, {3, 5},{5, 4},{6, 4}, {6, 9}, {7, 6}, {8, 7}, {9, 10}, {9, 11}, {9, 12}, {11, 12}};
        DiGraph<?> graph = new DiGraph<>(13, edges);
        Topological topo = new Topological(graph);
        System.out.println(topo.order());
    }
}
/*
[2, 8, 0, 3, 7, 1, 5, 6, 4, 9, 10, 11, 12]
*/

由于我实现的有向图的邻接表表示的出度,我很懒..也不想再去实现可以表示入度的邻接表...幸而有向图中我实现了一个可以返回原图的逆向图的方法,而逆向图的出度就是原图的入度。逆向图的生成,代码如下

public DiGraph<Item> reverse() {
    DiGraph<Item> R = new DiGraph<>(vertexNum);
    for (int v = 0; v < vertexNum; v++) {
        for (int w: adj(v)) {
        R.addEdge(w, v);
        }
    }
    return R;
}

其实就是把原来v -> w的边变成了w -> v,每条边都反向了,图也变成了逆向图。

DiGraph<?> R = graph.reverse();
for (int i = 0; i < graph.vertexNum(); i++) {
    int inCount = 0;
    // 逆向图的出度就是原图的入度
    for (int ignored : R.adj(i)) {
        inCount++;
    }
    // 得到各个顶点的入度数组
    in[i] = inCount;
}

逆向图每个顶点的邻接表长度,表示逆向图中该顶点的出度,也就是原图中该顶点的入度了。经过上面几行in[i]现在表示顶点i的入度。

首先将入度本来就为0的那些顶点入列,之后不断出列,不断将那些入度变为0的顶点入列....如此直到队列为空。

然后是判断一个图是否是无环有向图,如果一个图存在有向环,这个环上的所有顶点因为其入度都不为0,导致这些顶点无法加入到队列中,因此序列order中也不会有它们。所以,图中若有顶点没能被存入order,说明存在有向环;如果图中所有顶点都存入order,说明这是幅无环有向图,拓补序列构造成功。

来看几幅图加深对该算法的理解。

首先了解到,图中使用了栈来存放那些入度为0的顶点,而在我们的实现中使用的是队列。这没什么影响,只是最后得到的不是同一个拓补序列。可见,一幅无环有向图可以对应对个拓补序列。因为优先级相同的活动,随便先执行哪个都是一样的,所以会有多个拓补序列。

好,接上图开始,v0、v1、v3是本身入度就为0的顶点,先入栈。接着v3出栈(如果使用队列就是v0出列),从v3可达的v2和v13入度都要减小1,从图中看出来就是从v3引出的所有边都删除了。

然后v1出列,从v1可达的v4、v5、v11入度都减小1。由v1引出的所有边都删除。此时出现了新的入度为0的顶点,即顶点2,压入栈。

之后v2出栈....如此直到栈空。

无环加权有向图的最短路径

使用拓补排序可以很简单地求得无环加权有向图的最短路径,该算法比Dijkstra算法还要快,不过要求有向图一定得是无环的。它的特点有:

  • 能在线性时间内解决单点最短路径问题;
  • 可以处理负权值的边;
  • 还能解决最长路径的问题。

先看拓补排序在最短路径上的应用,只需按照拓补序列依次放松每个顶点,就能解决无环加权有向图的单点最短路径问题。

首先每条边v -> w都只会被放松一次,当v被放松时候,总有distTo[w] <= distTo[v] + e.weight(),该不等式在算法结束之前都成立。因此distTo[w]只会变小。而distTo[v]是不会变化了,因为按照拓补顺序放松顶点,在v被放松后没有任何指向v的边了。正因为如此,实现中也无需marked[]了,因为放松v后没有边指向它,意味着不可能再次访问到这个顶点。

package Chap7;

import java.util.LinkedList;

public class AcycliSP {
    private DiEdge[] edgeTo;
    private double[] distTo;

    public AcycliSP(EdgeWeightedDiGraph<?> graph, int s) {
        edgeTo = new DiEdge[graph.vertexNum()];
        distTo = new double[graph.vertexNum()];

        for (int i = 0; i < graph.vertexNum(); i++) {
            distTo[i] = Double.POSITIVE_INFINITY; // 1.0 / 0.0为INFINITY
        }

        distTo[s] = 0.0;
        // 以上是初始化
        TopoSort topo = new TopoSort(graph);

        if (!topo.isDAG()) {
            throw new RuntimeException("该图存在有向环,本算法无法处理!");
        }
        // 按照拓补顺序依次放松每个顶点,每条边都会被放松一次
        for (int v : topo.order()) {
            relax(graph, v);
        }
    }

    private void relax(EdgeWeightedDiGraph<?> graph, int v) {
        for (DiEdge edge : graph.adj(v)) {
            int w = edge.to();
            if (distTo[v] + edge.weight() < distTo[w]) {
                distTo[w] = distTo[v] + edge.weight();
                edgeTo[w] = edge;
            }
        }
    }

    public double distTo(int v) {
        return distTo[v];
    }

    public boolean hasPathTo(int v) {
        return distTo[v] != Double.POSITIVE_INFINITY;
    }

    public Iterable<DiEdge> pathTo(int v) {
        if (hasPathTo(v)) {
            LinkedList<DiEdge> path = new LinkedList<>();
            for (DiEdge edge = edgeTo[v]; edge != null; edge = edgeTo[edge.from()]) {
                path.push(edge);
            }
            return path;
        }
        return null;
    }

    public static void main(String[] args) {
        int[][] edges = {{5, 4}, {4, 7}, {5, 7}, {5, 1}, {4, 0}, {0, 2},
                {3, 7}, {1, 3}, {7, 2}, {6, 2}, {3, 6}, {6, 0}, {6, 4}};

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

        EdgeWeightedDiGraph<String> graph = new EdgeWeightedDiGraph<>(8, edges, weight);
        AcycliSP acycliSP = new AcycliSP(graph, 5);
            for (int i = 0; i < graph.vertexNum(); i++) {
                System.out.print(5 + " to " + i + ": ");
                System.out.print("(" + acycliSP.distTo(i) + ") ");
                System.out.println(acycliSP.pathTo(i));
            }
            System.out.println();

    }
}

/*
5 to 0: (0.73) [(5->4 0.35), (4->0 0.38)]
5 to 1: (0.32) [(5->1 0.32)]
5 to 2: (0.6200000000000001) [(5->7 0.28), (7->2 0.34)]
5 to 3: (0.61) [(5->1 0.32), (1->3 0.29)]
5 to 4: (0.35) [(5->4 0.35)]
5 to 5: (0.0) []
5 to 6: (1.13) [(5->1 0.32), (1->3 0.29), (3->6 0.52)]
5 to 7: (0.28) [(5->7 0.28)]
*/

by @sunhaiyu

2017.9.27

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容