图论(7):图的遍历 - 广度优先和深度优先算法

定义

从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这个访问的过程叫做图的遍历(Traversing Graph)。且图的遍历算法是一个比较基础的算法,前面我们介绍的有向无环图的依赖排序(拓扑排序)、关键路径等算法都需要基于该算法。
通常,有两条遍历图的路径:广度优先搜索深度优先搜索,且对无向图和有向图都适用。

另外,由于Guava中的Graph模块已对这两种图的遍历算法进行了实现,且其代码是我所见过最完美的实现了。因此,本篇文章不打算重新实现一个遍历算法,而是以Guava的实现为基础进行算法分析和验证。它的具体实现代码文件为:https://github.com/google/guava/blob/master/android/guava/src/com/google/common/graph/Traverser.java

广度优先遍历

也称为广度优先搜索(Breadth First Search),它类似于树的分层遍历算法(按树的深度来遍历,深度为1的节点、深度为2的节点...)。其定义如下:
假设从图中某个顶点v出发,在访问了v之后依次访问v的各个未曾访问过的邻接点,然后分别从这些邻接点出发并依次访问它们的邻接点,并使“先被访问的顶点邻接点”先于“后被访问的顶点的邻接点”被访问,直到图中所有所有已被访问的顶点的邻接点都被访问到。若此时图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点重复上述过程,直至图中所有顶点均被访问到为止。

换句话说,广度优先遍历的过程是以v为起始点,由近至远,依次访问和v有路径相通且路径长度为1,2,...的顶点的过程。

下面演示对示例图的广度优先遍历:假设从起始点v1开始遍历,首先访问v1和v1的邻接点v2和v3,然后依次访问v2的邻接点v4和v5,及v3的邻接点v6和v7,最后访问v4的邻接点v8。于是得到节点的线性遍历顺序为:v1 -> v2 -> v3 -> v4 -> v5 -> v6 -> v7 -> v8,即示例图中红色箭头线为其广度优先遍历顺序。

广度优先遍历(红色箭头线及序号为遍历顺序)

算法分析

从演示示例的推演中,我们可以发现其访问顺序恰好是一个队列的出队和入队的过程:出队的节点即为当前访问的节点,紧接着将其所有的邻接点入队,为下次迭代时将要访问的预备节点。

Guava的实现思路基本也是如此,下面我们看看它的实现方式:
首先,它定义了一个抽象的广度优先遍历接口,以便有多种实现方式(下面它还有一个树状图的遍历优化实现),更重要的是它将遍历的过程封装在一个迭代器(Iterable)中返回了,仅在调用方通过next()访问返回结果时才动态输出其遍历顺序,个人觉得这是它实现的一大亮点。这样做的好处是,实际的调用过程并不消耗cpu时间(仅仅是new了一个特定类型的Iterator),而是在调用完成后在使用其结果做其他运算时(比如输出或者将其作为其他功能的输入等)才耗费计算时间。一般,我们通常的做法是函数完成遍历运算然后返回运算结果(List<N>),如果后续需要利用该结果进行其他运算时再循环遍历结果集,亦或者直接在在遍历算法的迭代中直接处理其结果,而迭代器明显优于这两种做法。

广度优先遍历接口定义为:

/**
 * 
 * @param startNode: 遍历的起始节点
 * @return 返回一个Iterable作为节点的顺序
 */
public abstract Iterable<N> breadthFirst(N startNode);

由于接口返回的是一个Iterable,那么它的实现肯定就是创建了一个Iterable了,然后实现它的iterator()接口:

@Override
public Iterable<N> breadthFirst(final N startNode) {
    /**
     * 创建一个匿名的Iterable,并实现iterator()接口
     */
    return new Iterable<N>() {
        @Override
        public Iterator<N> iterator() {
            /**
             * 返回一个自定义的Iterator
             */
            return new BreadthFirstIterator(startNode);
        }
    };
}

该自定义迭代器BreadthFirstIterator中,其主体实现在迭代函数next()中,每迭代一次(调用next()函数一次)返回一个以广度优先遍历为顺序的一个节点:

/**
 * 声明为private私有, 可对外隐藏其内部实现,调用者仅需要
 * 知道是一个Iterator即可
 */
private final class BreadthFirstIterator extends Iterator<N> {
    //构建节点顺序的队列:执行入队和出队操作
    private final Queue<N> queue = new ArrayDeque<>();

    //用于记录节点是否访问标记,避免重复访问
    private final Set<N> visited = new HashSet<>();

    /**
     * 构造函数时,首先从起始点入队开始
     * @param root
     */
    BreadthFirstIterator(N root) {
        queue.add(root);
        visited.add(root);
    }

    @Override
    public boolean hasNext() {
        /**
         * 当队列为空时,则遍历完成,即没有后续节点了。
         */
        return !queue.isEmpty();
    }

    @Override
    public N next() {
        //队头节点出队,作为当前迭代的访问节点
        N current = queue.remove();
        /**
         * 依次将当前节点的后继节点入队,为下一次迭代做准备
         */
        for (N neighbor : graph.successors(current)) {
            //add()返回true时(表示第一次加入,即是第一次访问),则入队
            if (visited.add(neighbor)) {
                queue.add(neighbor);
            }
        }
        return current;
    }
}

上面给出了广度优先遍历算法的实现,下面写一个简单demo验证其实现结果:

//构建示例图所示的图结构(无向图)
MutableGraph<String> graph = GraphBuilder.undirected()
    .nodeOrder(ElementOrder.<String>natural()).build();

graph.putEdge(V1, V2);
graph.putEdge(V1, V3);
graph.putEdge(V2, V4);
graph.putEdge(V2, V5);
graph.putEdge(V3, V6);
graph.putEdge(V3, V7);
graph.putEdge(V4, V8);
graph.putEdge(V5, V8);
graph.putEdge(V6, V7);

//测试breadthFirst()接口,从V1开始遍历
Iterable<String> bfs = Traverser.forGraph(graph).breadthFirst(V1);

//输出遍历结果: for循环(forEach)默认实现iterator的遍历next()
for (String node : iterable) {
    print(node);
}

输出顺序为:

bfs graph: {V1,V3,V2,V6,V7,V5,V4,V8}

注:虽然该顺序与示例图的红色箭头线标识的顺序有所不同,但仍满足广度优先遍历的顺序,其差异主要是在选择当前节点的后继节点时 遍历后继节点的顺序不同。
而该顺序不同的主要原因是由于无向图邻接点(successors()返回的Set<T>结果)的存储结构UndirectedGraphConnections采用的是HashMap,然后通过HashMapKeySet()(对应的是HashSet<T>)返回的节点集,但由于HashSet是不是有序的,所以导致最终的结果并没有按预先的顺序,但结果整体上满足遍历顺序的要求。

关于HashMap的顺序问题,我做了如下测试:

//返回V1的后继节点集
Set<String> successors = graph.successors(V1);
print(success);

//测试HashMap的节点顺序
Map<String, Integer> testMap = new HashMap<>();
testMap.put(V1, 0);
testMap.put(V2, 0);
testMap.put(V3, 0);
print(testMap.keySet());

测试结果如下,刚好重现了遍历顺序的差异:

successor: {V3,V2}
Map KeySet() order: {V3,V2,V1}

下面我继续来看深度优先遍历:

深度优先遍历

也称为深度优先搜索(Depth First Search),它类似于树的先根遍历(先访问树的根节点)。其定义如下:
假设从图中的某个顶点v出发,访问此节点后,然后依次从v的未被访问的邻接点出发深度优先遍历图,直到图中所有和v有路径相通的顶点都被访问到;若此时图中尚有顶点未被访问,则选另选一个未曾访问的顶点作为起始点重复上述过程,直至图中的所有节点都被访问到为止。

下面演示对示例图的深度优先遍历:假设从起始点v1开始遍历,在访问了v1后,选择其邻接点v2。因为v2未曾访问过,则从v2出发进行深度优先遍历。依次类推,接着从v4、v8、v5出发进行遍历。在访问了v5后,由于v5的邻接点都已被访问,则遍历回退到v8。同样的理由,继续回退到v4、v2直至v1,此时v1的另一个邻接点v3未被访问,则遍历又从v1到v3,再继续进行下去。于是得到节点的线性顺序为:v1 -> v2 -> v4 -> v8 -> v5 -> v3 -> v6 -> v7,即示例图中红色箭头线为其深度优先遍历顺序。

深度优先遍历-PreOrder顺序(红色箭头线及序号为遍历顺序)

算法分析

从上述的描述可以看出,深度优先遍历的定义就是一个递归的过程,每次都以当前节点的第一个未曾访问过的邻接点进行深度优先遍历的过程。因此使用递归函数实现该算法是最直观的实现方式,但由于递归的过程是函数栈累积的过程,如果节点数较多,容易造成函数栈的溢出而导致程序崩溃,因此正常生产环境一般会使用一个栈结构(Stack)来存放遍历的节点以模拟函数栈的调用情况,以此避免递归的缺陷。

Guava的大体实现思路也是如此,使用栈结构来替代函数的递归实现。另外,Guava对深度优先遍历还进行了扩展:区分节点的访问顺序是第一次访问到节点的顺序(PreOrder),还是访问到底后回退时的节点顺序(PostOrder)。

附上Guava的原文注释:
"Pre-order" implies that nodes appear in the {@code Iterable} in the order in which they are first visited.
"Post-order" implies that nodes appear in the {@code Iterable} in the order in which they are visited for the last time.

因此,对于深度优先遍历,Guava根据这两个场景定义了下面两个接口:

//第一次访问到节点的顺序(Pre-order)
public abstract Iterable<N> depthFirstPreOrder(N startNode);

//访问到最后,然后回退访问节点的顺序(Post-order)
public abstract Iterable<N> depthFirstPostOrder(N startNode);

它的实现与上述描述相同,也是创建了一个Iterable,然后实现它的iterator()接口,由于这两个接口相近,下面仅举例说明其中一个(depthFirstPostOrder):

@Override
public Iterable<N> depthFirstPostOrder(final N startNode) {
    /**
     * 创建一个匿名的Iterable,并实现iterator()接口
     */
    return new Iterable<N>() {
        @Override
        public Iterator<N> iterator() {
            /**
             * 返回一个自定义的Iterator
             */
            return new DepthFirstIterator(startNode, Order.POSTORDER);

            /**
             * depthFirstPreOrder()遍历时也是创建了DepthFirstIterator(),只是参数换成了Order.PREORDER
             */
            //return new DepthFirstIterator(startNode, Order.PREORDER);
        }
    };
}

该自定义迭代器DepthFirstIterator中,其主体实现在迭代函数next()中,每迭代一次(调用next()函数一次)返回一个以深度优先遍历为顺序的一个节点:


/**
 * 自定义深度优先遍历的结果迭代器(AbstractIterator继承自Iterator)
 */
private final class DepthFirstIterator extends AbstractIterator<N> {

    /**
     * 模拟函数递归遍历节点的堆栈,每次入栈的数据是:节点-节点的邻节点集
     */
    private final Deque<NodeAndSuccessors> stack = new ArrayDeque<>();

    /**
     * 标识节点是否已访问过
     */
    private final Set<N> visited = new HashSet<>();

    /**
     * 指定深度优先遍历的顺序: PREORDER, POSTORDER
     */
    private final Order order;

    /**
     * 构造函数,首先将指定的起始节点-及邻接点入栈
     * @param root:起始节点
     * @param order:指定遍历的顺序
     */
    DepthFirstIterator(N root, Order order) {
        stack.push(withSuccessors(root)); //节点-及邻接点入栈
        this.order = order;
    }

    /**
     * 基类封装的next()函数
     * @return
     */
    @Override
    protected N computeNext() {
        while (true) { //直到找到合适遍历顺序的节点
            /**
             * 堆栈为空时,遍历结束,返回null
             */
            if (stack.isEmpty()) {
                return endOfData();
            }

            /**
             * 每次拿到栈顶的数据参与运算,且由于withSuccessors()
             * 对应的邻接点也是Iterator,因此需要注意运算中是否所有
             * 的邻接点都有遍历完毕。(算法核心)
             */
            NodeAndSuccessors node = stack.getFirst();

            /**
             * Set<>.add()操作返回true时,表示为第一次加入到Set集合中
             * 对应到遍历顺序 -- PREORDER。因此firstVisit标识PREORDER顺序的访问
             */
            boolean firstVisit = visited.add(node.node);

            /**
             * 当successorIterator都遍历完毕时(!node.successorIterator.hasNext()),
             * 表示该路径已经遍历完毕了,现在需要开始回退,则该节点是回退时的节点
             * 对应到遍历顺序 -- POSTORDER。因此lastVisit标识POSTORDER顺序的访问
             *
             */
            boolean lastVisit = !node.successorIterator.hasNext();

            /**
             * 当前步骤是否产生了一个可以返回的迭代节点(firstVisit | lastVisit)
             */
            boolean produceNode =
                (firstVisit && order == Order.PREORDER) || (lastVisit && order == Order.POSTORDER);

            /**
             * 如果当前节点的邻接点集都已遍历完毕时,则将该节点出栈。
             * 以致下一次stack.getFirst()时获得的数据变成未访问的新数据
             */
            if (lastVisit) {
                stack.pop();
            } else {
                /**
                 * 如果邻接点还没遍历完毕,则每次将一个邻接点及邻接点的邻接点集合压入栈中
                 * 需注意该邻接点是否已有访问过
                 */
                N successor = node.successorIterator.next();
                if (!visited.contains(successor)) {
                    stack.push(withSuccessors(successor));
                }
            }
            /**
             * 若当前节点满足遍历顺序(firstVisit | lastVisit),则返回该节点。
             * 否则,将继续上述栈中数据的操作,直到找到满足produceNode的节点或者栈中数据遍历完毕
             */
            if (produceNode) {
                return node.node;
            }
        }
    }

    /**
     * 构建堆栈数据结构:节点-及邻接点
     * @param node
     * @return
     */
    NodeAndSuccessors withSuccessors(N node) {
        return new NodeAndSuccessors(node, graph.successors(node));
    }
}

针对上面给出的深度度优先遍历算法实现,下面还是写一个简单demo验证其实现结果:

//构建示例图所示的图结构(无向图)
MutableGraph<String> graph = GraphBuilder.undirected()
    .nodeOrder(ElementOrder.<String>natural()).build();

graph.putEdge(V1, V2);
graph.putEdge(V1, V3);
graph.putEdge(V2, V4);
graph.putEdge(V2, V5);
graph.putEdge(V3, V6);
graph.putEdge(V3, V7);
graph.putEdge(V4, V8);
graph.putEdge(V5, V8);
graph.putEdge(V6, V7);

//测试depthFirstPreOrder()接口,从V1开始遍历
Iterable<String> dfsPre = Traverser.forGraph(graph).depthFirstPreOrder(V1);
for (String node : iterable) {
    print(node);
}


//测试depthFirstPostOrder()接口,从V1开始遍历
Iterable<String> dfsPost = Traverser.forGraph(graph).depthFirstPostOrder(V1);
for (String node : iterable) {
    print(node);
}

输出顺序为:

dfs pre-order graph: {V1,V3,V6,V7,V2,V5,V8,V4}
dfs post-order graph: {V7,V6,V3,V4,V8,V5,V2,V1}

注:该顺序与示例图的红色箭头线标识的顺序有所不同,但仍满足深度优先遍历的顺序,其差异原因前面已经有说明过。

另外,由于图节点连接属性的复杂性(任何两个节点都可能存在连接),所以在遍历过程中需要另设一个Set<N>来记录该节点的访问标记(因为图中如果存在回路时会存在重复访问的问题);为此,Guava对于明确不存在回路的树形图(即有向无环图)提供了另一种更优的访问方法forTree(),不过它也是在前面介绍的三种遍历方法的基础上进行优化实现的,具体可以参考原文的前面的链接进行查看。

最后,文中示例代码的详细实现参见:https://github.com/Jarrywell/GH-Demo/blob/master/app/src/main/java/com/android/test/demo/graph/TestTraverser.java

参考文档

《数据结构》-- 严蔚敏

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

推荐阅读更多精彩内容