5

四种重要的图模型:

  • 无向图(简单连接)

  • 有向图(连接有方向性)

  • 加权图(连接带有权值)

  • 加权有向图(连接既有方向性又带有权值)

无向图

定义:由一组顶点和一组能够将两个顶点相连的边组成。

特殊:自环(一条连接一个顶点和其自身的边);平行边(连接同一对顶点的两条边)

数学家将含有平行边的图称为多重图;将没有平行边或自环的图称为简单图。现实当中,两点就可以指代一条边。

术语表

  • 两个顶点通过一条边相连,称这两顶点相邻,并称该连接依附于这两个顶点

  • 某个顶点的度数即为依附于它的边的总数。

  • 子图是由一幅图的所有边的一个子集(以及它们所依附的所有顶点)组成的图。

  • 路径是由边顺序连接的一系列顶点。

  • 简单路径是一条没有重复顶点的路径。

  • 是一条至少含有一条边且起点和终点相同的路径。

  • 简单环是一条(除了起点和终点必须相同之外)不含有重复顶点和边的环。

  • 路径或者环的长度为其中所包含的边数。

    • 大多情况研究简单环和简单路径,并会省略简单二字。当允许重复的顶点时,指的都是一般的路径和环。

  • 当两个顶点之间存在一条连接双方的路径时,称一个顶点和另一个顶点是连通的。

    • U-V-W-X记为U到X的一条路径;U-V-W-X-U记为U到V到W到X再回到U的一条环。

  • 从任意一个顶点都存在一条路径到达另一个任意顶点,称这幅图是连通图

    • 一副非连通的图由若干连通的部分组成,它们都是其极大连通子图

    • 直观上:如果顶点是念珠,边是连接念珠的线,它们都是物理存在的对象,那么将任意顶点提起来,连通图都将是一个整体,而非连通图则会变成两个或多个部分。

    • 一般来说:要处理一张图就要一个个地处理它的连通分量(子图)。

  • 无环图:不包含环的图。

  • 树是一副无环连通图。互不相连的树组成的集合称为森林。连通图的生成树是它的一副子图,它含有图中的所有顶点且是一棵树。图的生成树森林是它的所有连通子图的生成树的集合。

树的定义非常通用,稍作改动就可以变成用来描述程序行为的(函数调用层次)模型和数据结构(二叉查找树、2-3树等)。

当且仅当一幅含有V个节点的图G满足下列5个条件之一时,它就是一棵树:

  • G有V-1条边且不含有环;

  • G有V-1条边且是连通的;

  • G是连通的,但删除任意一条边都会使之不再连通;

  • G是无环图,但添加任意一条边都会产生一条环;

  • G中的任意一对顶点之间仅存在一条简单路径。

图的密度是指已经连接的顶点对占所有可能被连接的的顶点对的比例。一般来说,如果一幅图中不同的边的数量只占顶点总数V的一小部分,那么就认为这幅图是稀疏的,否则是稠密的。

二分图是一种能够将所有节点分为两部分的图,其中图的每条边所连接的两个顶点都分别属于不同的部分。二分图会出现在许多场景中。

表示无向图的数据类型

图的基本操作的API:

两个构造,得到顶点数V( )和边数E( ),增加一条边addEdge( int v, int w )。本节所有算法都基于adj( )方法所抽象的基本操作。第二个构造函数接受的输入由2E+2个整数组成:首先是V, 然后是E, 在然后是 E 对 0到V-1之间的整数,每个整数对都表示一条边。

图的几种表示方法

要面对的下一个图处理问题就是用哪种数据结构来表示并实现这份API,这包含两个要求:

  • 必须为可能在应用中碰到的各种类型的图预留出足够的空间;

  • 实例方法的实现一定要快—它们是开发处理图的各种用例的基础。

要求比较模糊,但是仍然能帮我们在三种图的表示方法中进行选择。

  • 邻接矩阵。用V*V的布尔矩阵,当V和W有边时,定义V行W列元素为TRUE,否则为FALSE。该方法不符合第一个条件,上百万个顶点的图是很常见.V^2空间不满足

  • 边的数组。可以使用一个Edge类,含有两个int实例变量。表示方法简单但是不满足第二个条件—要实现adj( )需要检查所有边。

  • 邻接表数组。可以使用一个以顶点为索引的列表数组,其中每个元素都是和该顶点相邻的顶点列表。该结构同时满足两个条件。本章一直用它。

除了性能目标,还发现:允许存在平行边相当于排除了邻接矩阵,因为邻接矩阵无法表示它们。

邻接表的数据结构

非稠密图的标准表示称为邻接表的数据结构,它将每个顶点的所有相邻顶点都保存在该顶点对于的元素所指向的一张链表中。使用这个数组就是为了快速访问给定顶点的邻接顶点列表

使用Bag抽象数据类型(也可用Java中的<LinkedList>)来实现这个链表,这样就可以在常数时间内添加新的边或遍历任意顶点的所有相邻顶点。

这种Graph的实现的性能:

  • 使用的空间和V+E成正比;

  • 添加一条边所需要的时间为常数;

  • 遍历顶点V的所有相邻顶点所需要的时间和V的度数成正比。

对于这样的操作,这样的特性已经是最优,可以满足图处理应用的需要,并且支持平行边和自环。边的插入顺序决定了Graph得邻接表中顶点的出现顺序。使用构造函数从标准输入中读入一副图时,就意味着输入的格式和边的顺序决定了Graph的邻接表数组中顶点的出现顺序。

/**
 * 无向图
 */
public class Graph {
    private int vertexCount;            // 顶点数
    private int edgeCount;                // 边数
    private LinkedList<Integer>[] adj;    // 邻接表数组
    public Graph(int v){
        this.adj = new LinkedList[v];
        for(int i = 0; i<v; i++) adj[i] = new LinkedList<>();// 初始化邻接表数组
        this.vertexCount = v;
    }
    public Graph(In in) {
        this(in.readInt());
        int e = in.readInt();//得到边数
        // 读取每条边,进行图的初始化操作
        for(int i = 0; i<e;i++){
            int v = in.readInt();     // 起点
            int w = in.readInt();     // 终点
            addEdge(v, w);
        }
    }
    /*** 增加一条边*/
    public void addEdge(int start, int end) {
        adj[start].add(end);
        adj[end].add(start);
        this.edgeCount++;
    }
    public int getEdgeCount() { return edgeCount; }
    public int getVertexCount() { return vertexCount; }
    /** 返回顶点v的邻接表*/
    public LinkedList<Integer> adj(int v){return adj[v];}
    /** 把图转化成标准字符串形式*/
    public String toString(){
        String NEWLINE = System.getProperty("line.separator");
        StringBuilder sb = new StringBuilder();
        sb.append("vertex count: ").append(getVertexCount())
                .append(" edge count: ").append(getEdgeCount())
                .append(Config.NEWLINE);
        for (int v = 0; v < getVertexCount();v++){
            LinkedList<Integer> list = adj(v);
            sb.append(v).append(":\t").append("[");
            for (int i=0; i < list.size();i++){
                sb.append(list.get(i)).append(",");
            }
            sb.deleteCharAt(sb.length() - 1);
            sb.append("]").append(NEWLINE);
        }
        return sb.toString();
    }
    public static void main(String[] args) {
        String dir = Graph.class.getPackage().getName().replace(".", "/");
        String path = Graph.class.getClassLoader().getResource(dir+"/tinyG.txt").getPath();
        In in = new In(new File(path));
        Graph g = new Graph(in);
        System.out.println(g.toString());
    }
}

/**
 * 图的基本常用操作工具类
 */
public class GraphUtils {
    /** 计算顶点v的度数*/
    public static int degree(Graph graph, int v){return graph.adj(v).size();}
    /** 计算图中最大的度*/
    public static int maxDegree(Graph graph){
        int max = 0;
        for(int i = 0;i<graph.getVertexCount();i++){
            int currentDegree = degree(graph, i);
            max = currentDegree > max ? currentDegree : max;
        }
        return max;
    }
    /** 计算图的平均度数*/
    public static int avgDegree(Graph g){ return 2 * g.getEdgeCount() / g.getVertexCount(); }
    /** 计算自环的个数*/
    public static int numberOfSelfLoops(Graph g){
        int count = 0;
        for(int v = 0; v < g.getVertexCount(); v++)
            for(int w: g.adj(v))
                if(v == w)    count++;
        return count / 2; // 每条边计算了两次
    }
    public static void main(String[] args) {
        String dir = GraphUtils.class.getPackage().getName().replace(".", "/");
        String path = GraphUtils.class.getClassLoader().getResource(dir+"/tinyG.txt").getPath();
        In in = new In(new File(path));
        Graph g = new Graph(in);
        for (int i = 0; i < g.getVertexCount(); i++) {
            System.out.println(i+" degree : "+GraphUtils.degree(g, i));    
        }
        System.out.println("the max degree is : " + GraphUtils.maxDegree(g));
        System.out.println(g.toString());
        System.out.println("avg degree: "+GraphUtils.avgDegree(g));
        System.out.println("count of self loop: "+GraphUtils.numberOfSelfLoops(g));
    }
}
0 degree : 4
1 degree : 1
2 degree : 1
3 degree : 2
4 degree : 3
5 degree : 3
6 degree : 2
7 degree : 1
8 degree : 1
9 degree : 3
10 degree : 1
11 degree : 2
12 degree : 2
the max degree is : 4
vertex count: 13 edge count: 13
0:    [5,1,2,6]
1:    [0]
2:    [0]
3:    [4,5]
4:    [3,6,5]
5:    [0,4,3]
6:    [4,0]
7:    [8]
8:    [7]
9:    [12,10,11]
10:    [9]
11:    [12,9]
12:    [9,11]

avg degree: 2
count of self loop: 0

图的处理算法的设计模式

将图的表示和实现分离开。为每个任务创建一个相应的类,用例可以创建相应的对象来完成任务。

深度优先搜索

探索迷宫方法:tremaux搜索:

  • 选择一条没有标记过的通道,在走过的路上铺一条绳子;

  • 标记所有你第一次路过的路口和通道;

  • 当来到一个标记过的路口时(用绳子)回退到上一个路口;

  • 当回退到得路口已经没有可走的通道时继续回退。

绳子可保证总能找到一条出路,标记则能保证不会两次经过同一条通道或同一个路口。

看Java代码实现:

/**
 * 图的深度优先搜索算法
 */
public class DepthFirstSearch {
    private int count;
    private boolean[] marked; // 数组存储每个顶点是否被遍历过
    /**
     * 从顶点s开始对g进行深搜
     * @param g
     * @param s
     */
    public DepthFirstSearch(Graph g, int s) {
        marked = new boolean[g.getVertexCount()];
        dfs(g, s);
    }
    /** 深搜*/
    private void dfs(Graph g, int s) {
        marked[s] = true;                    // 1.标记顶点s
        count++;                            // 2.count数加一
        LinkedList<Integer> list = g.adj(s);// 3.获取s的邻接表
        for(int w: list)                    // 4.对邻接表进行遍历
            if(!isMarked(w))    dfs(g,w);    // 5.如果遍历到的顶点没有被标记过,对该顶点继续递归深搜
    }
    /** 顶点w是否和起点s相连通*/
    public boolean isMarked(int w){return marked[w];}
    
    /** 与起点s连通的顶点数量*/
    public int count(){return count;}
    
    public static void main(String[] args) {
        String dir = DepthFirstSearch.class.getPackage().getName().replace(".", "/");
        String path = DepthFirstSearch.class.getClassLoader().getResource(dir+"/tinyG.txt").getPath();
        In in = new In(new File(path));
        Graph g = new Graph(in);
        int start = 0;
        DepthFirstSearch search = new DepthFirstSearch(g, start);
        System.out.print("start vertex: "+ start+". ");
        StringBuilder sb = new StringBuilder();
        for(int i = 0; i< g.getVertexCount(); i++)
            if(search.isMarked(i)) sb.append(" "+ i);
        System.out.println("Connected " + sb.toString());
        // 如果和s连通的顶点数量和图的顶点数量相同,说明是连通图
        if(search.count() == g.getVertexCount())    System.out.println("g is a connected graph.");
        else System.out.println("g is not a connected graph.");
    }
}
start vertex: 0. Connected  0 1 2 3 4 5 6
g is not a connected graph.

“两个给定顶点是否连通?”等价于“两个给定的顶点之间是否存在一条路径”,也叫路径检测问题。

union-find算法的数据结构并不能解决找出这样一条路径问题,DFS是已经学习过的方法中第一个能够解决该问题的算法

能解决的另一问题:单点路径----给定一幅图和一个起点s,“从S到给定的顶点V是否存在一条路径,如果有,找出”

寻找路径

构造函数接受一个起点S作为参数,计算S到与S连通的每个顶点之间的路径。在为S创建了Paths对象后,用例可以调用pathTo()实例方法来遍历从S到任意和S连通的顶点的路径上的所有顶点。以后会实现只查找具有某些属性的路径。

Java实现

/**
 * 深搜寻找路径问题
 */
public class DepthFirstPaths {
    private boolean[] marked;        
    private int[] edgeTo;        // 路径
    private int start;            // 起点
    public DepthFirstPaths(Graph g, int s){
        marked = new boolean[g.getVertexCount()];
        edgeTo = new int[g.getVertexCount()];
        this.start = s;
        dfs(g, s);
    }
    private void dfs(Graph g, int s) {
        marked[s] = true;
        for(int w: g.adj(s)){
            if(!marked[w]){
                // 如果w没有被标记过,把路径数组中的w处置为s,意思:从s到达了w。此处记录了每一次深搜的路径节点
                edgeTo[w] = s; 
                dfs(g, w);
            }
        }
    }
    /** 从起点s到顶点v是否存在通路*/
    public boolean hasPathTo(int v){return marked[v];}
    public Stack<Integer> pathTo(int v){
        if(!hasPathTo(v))    return null;
        Stack<Integer> stack = new Stack<>();
        for(int x = v; x!=start; x=edgeTo[x]) // 从终点开始,倒着找起点,依次push入栈
            stack.push(x);
        stack.push(start);// for循环到起点处终止,所以在循环结束后要把起点入栈,至此 一条完整的路径依次入栈
        return stack;
    }
    public static void main(String[] args) {
        String dir = DepthFirstPaths.class.getPackage().getName().replace(".", "/");
        String path = DepthFirstPaths.class.getClassLoader().getResource(dir+"/tinyG.txt").getPath();
        In in = new In(new File(path));
        Graph g = new Graph(in);
        int start = 0;
        DepthFirstPaths pathSearch = new DepthFirstPaths(g, start);
        StringBuilder sb = new StringBuilder();
        for(int i = 0; i<g.getVertexCount(); i++){
            if(i == start) continue;
            if(!pathSearch.hasPathTo(i)){
                System.out.println(start+" to "+ i +" : not connected.");
                continue;
            }
            sb.setLength(0);
            sb.append(start).append(" to ").append(i).append(": ");
            Stack<Integer> p = pathSearch.pathTo(i);
            while(!p.isEmpty()) sb.append(p.pop()).append("->");
            sb.deleteCharAt(sb.length()-1);
            sb.deleteCharAt(sb.length()-1);
            System.out.println(sb.toString());
        }
    }
}
0 to 1: 0->1
0 to 2: 0->2
0 to 3: 0->5->4->3
0 to 4: 0->5->4
0 to 5: 0->5
0 to 6: 0->5->4->6
0 to 7 : not connected.
0 to 8 : not connected.
0 to 9 : not connected.
0 to 10 : not connected.
0 to 11 : not connected.
0 to 12 : not connected.

广度优先搜索BFS

深搜得到的路径不仅取决于图的结构,还取决于图的表示和递归调用的性质。我们自然对最短路径感兴趣:

单点最短路径。给定一幅图和一个起点S,从S到给定顶点V是否存在一条路径?如果有,请找出其中最短的那条(所含边数最少)。

  • DFS遍历图的顺序和找出最短路径的目标无关。

  • BFS为了这个目标而出现。要找到从S到V得最短路径,从S开始,在所有由一条边就可以到达的顶点中查找V, 如果找不到就继续在与S距离两条边的所有顶点中查找,如此一直执行。

  • DFS好像是一个人在走迷宫,BFS则像一组人在一起朝各个方向走这个迷宫,每个人都有自己的绳子,当出现新的叉路时,可以假设一个探索者可以分裂为更多的人来搜索。当来个那个探索者相遇的时候,合二为一,并继续使用先到达者的绳子。

    • 在程序中,搜索一幅图时遇到有多条边需要遍历的情况,我们会选择其中一条并将其他通道留到以后再继续搜索。在DFS中,用了一个可以下压的栈,以支持递归搜索。使用LIFO的规则来描述压栈和走迷宫时先探索相邻的通道类似。从有待搜索的通道中选择最晚遇到过的那条。

    • 在BFS中希望按照与起点的距离的顺序来遍历所有的顶点:使用FIFO先进先出队列来代替栈LIFO后进先出 即可。将从有待搜索的通道中选择最早遇到的那条。

实现:

算法4.2实现了BFS。使用队列保存所有已经被标记过但其邻接表还未被检查过的顶点。先将起点加入队列,然后重复下面步骤直到队列为空:

  • 取队列中的下一个顶点V并标记它;

  • 将与V相邻的所有未被标记过的顶点加入队列。

算法4.2中的方法不是递归的,不像递归中隐式使用的栈,而是显式地使用了一个队列。

  • 从队列中删除0,将相邻顶点2 1 5加入队列,标记它们并分别将它们在edgeTo[ ]中的值置为0;队列: 0 2 1 5

  • 从队列中删除2,并检查相邻顶点0 1 3 4, 0和1已经被标记,将3和4这两个没被标记的加入队列,标记它们,并分别将它们在edgeTo[ ] 中的值设为2;队列: 0 2 1 5 3 4

  • 删除1,检查相邻点0 2,发现都已经被标记;队列: 0 2 1 5 3 4

  • 删除5, 检查相邻点 0 3, 发现都已经被标记;队列: 0 2 1 5 3 4

  • 删除3, 检查相邻点 2 4 5, 发现都已经被标记;队列: 0 2 1 5 3 4

  • 删除4, 检查相邻点 2 3, 发现都已经被标记;队列: 0 2 1 5 3 4

/**
 * 广搜找到最短路径
 *         对于从s可达的任意顶点v,广搜都能找到一条从s到v的最短路径
 *         (没有其他从s到v的路径所含边比这条路径更少)
 * 广搜所需时间在最坏情况下和(v + e)成正比。
 */
public class BreadthFirstPaths {
    private boolean[] marked;
    private int[] edgeTo;
    private int start;
    public BreadthFirstPaths(Graph g, int s){
        this.start = s;
        marked = new boolean[g.getVertexCount()];
        edgeTo = new int[g.getVertexCount()];
        bfs(g, s);
    }
    private void bfs(Graph g, int s) {
        Queue<Integer> queue = new Queue<>();    
        marked[s] = true;     // 标记起点
        queue.enqueue(s);    // 起点入队
        while(!queue.isEmpty()){
            int head = queue.dequeue();    // 从队列中取出队首
            LinkedList<Integer> list = g.adj(head);    // 得到队首的邻接表
            for(int w: list){     //遍历邻接表
                if(!marked[w]){    // 若当前节点没有被标记过
                    edgeTo[w] = head;    // 1.存入路径
                    marked[w] = true;    // 2.进行标记
                    queue.enqueue(w);    // 3.节点入队
                }
            }
        }
    }
    /** 从起点s到顶点v是否存在通路*/
    public boolean hasPathTo(int v){return marked[v];}
    /** 返回从起点s到顶点v的一条最短路径*/
    public Stack<Integer> pathTo(int v){
        if(!hasPathTo(v))    return null; // 若不存在到v的路径,返回Null
        Stack<Integer> path = new Stack<>();
        for(int x = v; x!=start; x=edgeTo[x])
            path.push(x);
        path.push(start);
        return path;
    }
    public static void main(String[] args) {
        String dir = BreadthFirstPaths.class.getPackage().getName().replace(".", "/");
        String path = BreadthFirstPaths.class.getClassLoader().getResource(dir+"/tinyG.txt").getPath();
        In in = new In(new File(path));
        Graph g = new Graph(in);
        int start = 5;
        BreadthFirstPaths bfPath = new BreadthFirstPaths(g, start);
        for(int i = 0; i<g.getVertexCount();i++){
            if(i == start) continue;
            if(!bfPath.hasPathTo(i)){
                System.out.println(start + " to "+ i + " : not connected.");
                continue;
            }
            StringBuilder sb = new StringBuilder();
            sb.append(start + " to "+ i + " : ");
            Stack<Integer> p = bfPath.pathTo(i);
            while(!p.isEmpty()){
                sb.append(p.pop() + "->");
            }
            sb.deleteCharAt(sb.length() - 1);
            sb.deleteCharAt(sb.length() - 1);
            System.out.println(sb.toString());
        }
    }
}
5 to 0 : 5->0
5 to 1 : 5->0->1
5 to 2 : 5->0->2
5 to 3 : 5->3
5 to 4 : 5->4
5 to 6 : 5->0->6
5 to 7 : not connected.
5 to 8 : not connected.
5 to 9 : not connected.
5 to 10 : not connected.
5 to 11 : not connected.
5 to 12 : not connected.

对于这个例子,edgeTo[]数组在第二步之后就已经完成了。和深搜一样,一点所有顶点都已经被标记,余下的计算工作就只是在检查连接到各个已被标记的顶点的边而已。

命题:对于从S可达到的任意顶点V, 广搜都能找到一条从S到V的最短路径(没有其他从S到V得路径所含的边比这条路径更少)

续: 广搜所需的时间在最坏情况下和V+E成正比

DFS和BFS都会先将起点存入数据结构中,然后重复以下步骤知道数据结构被清空:

  • 取其中的下一个顶点并标记它;

  • 将V的所有相邻而又未被标记的顶点加入数据结构。

不同之处在于从数据结构中获取下一个顶点的规则:广搜是最早加入的顶点;深搜是最晚加入的顶点。这种差异得到了处理图的两种完全不同的视角,无论哪种,所有与起点连通的顶点和边都会被检查到。

连通分量

深搜下一个直接应用就是找出一幅图的所有连通分量。API:

CC的实现使用了marked[ ]数组来寻找一个顶点作为每个连通分量中深度优先搜索的起点。递归的深搜第一次调用的参数是顶点0,会标记所有与0连通的顶点。然后构造函数中的for循环会查找每个没有被标记的顶点并递归调用dfs来标记和它相邻的所有顶点。另外,它还使用了一个以顶点作为索引的数组id[ ],将同一个连通分量中的顶点和连通分量的标识符关联起来。这个数组使得connected( )方法的实现变得十分简单。

/**
 * 强连通分量
 */
public class CC {
    private boolean[] marked;
    private int[] id;
    private int count;
    public CC(Graph g){
        marked = new boolean[g.getVertexCount()];
        id = new int[g.getVertexCount()];
        for(int s = 0; s < g.getVertexCount(); s++){
            if(!marked[s]){
                dfs(g,s);
                count++;
            }
        }
    }
    private void dfs(Graph g, int v) {
        marked[v] = true;
        id[v] = count;
        for(int w: g.adj(v))
            if(!marked[w])
                dfs(g,w);
    }
    /** v和w连通吗*/
    public boolean connected(int v, int w)    { return id[v] == id[w]; }
    /** v所在的连通分量的标识符*/
    public int id(int v)    { return id[v]; }
    /** 连通分量数*/
    public int count()        {return count;}
    public static void main(String[] args) {
        String dir = CC.class.getPackage().getName().replace(".", "/");
        String path = CC.class.getClassLoader().getResource(dir+"/tinyG.txt").getPath();
        In in = new In(new File(path));
        Graph g = new Graph(in);
        CC cc = new CC(g);
        int m = cc.count();
        System.out.println("number of components: "+ m);
        LinkedList<Integer>[] components = new LinkedList[m];
        for(int i =0;i<m;i++)
            components[i] = new LinkedList<>();
        for(int v = 0; v< g.getVertexCount(); v++)
            components[cc.id(v)].add(v);
        for(int i=0;i<m;i++){
            for(int v: components[i])
                System.out.print(v + " ");
            System.out.println();
        }
    }
}
number of components: 3
0 1 2 3 4 5 6 
7 8 
9 10 11 12 

其实现基于一个由顶点索引的数组id[ ].若V属于第i个连通分量,则id[v]的值为i。构造函数会找出一个未被标记的顶点并调用递归函数dfs( )来标记并区分出所有和它连通的顶点,如此重复直到所有的顶点都被标记并区分。

命题C:深搜的预处理使用的时间和空间与V+E成正比且可以在常数时间内处理关于图的连通性查询。

  • 和union-find算法对比:理论上深搜比union-find快,因为能保证所需时间是常数,而union-find不行;但在实际中,该差异微不足道。union-find更快,因为它不需要完整的构造并表示一幅图。更重要的是:union-find算法是一种动态算法(在任何时候都能用接近常数的时间检查两个顶点是否连通,甚至是在添加一条边的时候),但深搜就必须对图进行预处理。

  • 因此,在完成只需要判断连通性或是需要完成有大量连通性查询和插入操作混合等类似的任务时,更倾向使用union-find,而深搜更适合实现图的抽象数据类型,因为能够更有效的利用已有数据结构。

DFS已经解决了几个基础问题。该方法很简单,递归实现使得我们能够进行复杂的运算并为一些图的处理问题给出简洁的解决方法。

下面对两个问题进行解答:

  • 检测环:给定的图是无环图吗?

  • 双色问题:能够用两种颜色将图的所有顶点着色,使得任意一条边上的两个端点的颜色都不同吗?这个问题等价于:这是一幅二分图吗?

检测环解题:


/**
 * 给定的图是无环图吗
 * 检测自环:假设没有自环,没有平行边
 */
public class Cycle {
    private boolean[] marked;
    private boolean hasCycle;
    public Cycle(Graph g){
        marked = new boolean[g.getVertexCount()];
        for(int i = 0;i<g.getVertexCount();i++)
            if(!marked[i])    dfs(g, i, i);
    }
    private void dfs(Graph g, int v, int u) {
        marked[v] = true;
        for(int w: g.adj(v))
            if(!marked[w])    dfs(g, w, v); // 若w没被标记过,那么从w继续递归深搜,把w的父节点作为第二参数
            else if(w != u) hasCycle = true; // 若w被标记过,那么若无环,w必然和父节点相同,否则就是有环
    }
    /** 是否含有环*/
    public boolean hasCycle(){return hasCycle;}
    public static void main(String[] args) {
        String dir = Cycle.class.getPackage().getName().replace(".", "/");
        String pathCycle = Cycle.class.getClassLoader().getResource(dir+"/tinyG.txt").getPath();
        String pathNoCycle = Cycle.class.getClassLoader().getResource(dir+"/cycle_test.txt").getPath();
        In in = new In(new File(pathCycle));
        Graph g = new Graph(in);
        Cycle c = new Cycle(g);
        System.out.println(c.hasCycle());
        In in2 = new In(new File(pathNoCycle));
        Graph g2 = new Graph(in2);
        Cycle c2 = new Cycle(g2);
        System.out.println(c2.hasCycle());
    }
}
true
false

双色问题解题

/**
 * 双色问题:能够用两种颜色将图的所有顶点着色,使得任意一条边上的两个端点的颜色都不同吗?
 * 等价于:判断是否是二分图的问题
 */
public class TwoColor {
    private boolean[] marked;
    private boolean[] color;
    private boolean isColorable;
    public TwoColor(Graph g){
        isColorable = true;
        marked = new boolean[g.getVertexCount()];
        color = new boolean[g.getVertexCount()];
        for(int i = 0; i<g.getVertexCount(); i++)//遍历所有顶点
            if(!marked[i])    dfs(g, i);//没有mark就进行深搜
    }
    private void dfs(Graph g, int v) {
        marked[v] = true;        // 标记
        for(int w: g.adj(v))    // 对邻接表进行遍历
            if(!marked[w]){        // 如果没有被标记
                color[w] = !color[v];    // 当前w节点颜色置为和父节点不同的颜色
                dfs(g, w);                // 对当前节点继续深搜
            }else if(color[w] == color[v]){    // 如果已经被标记,看是否颜色和父节点相同
                isColorable = false;         // 若相同则不是二分图
            }
    }
    /** 是否是二分图*/
    public boolean isBipartite(){return isColorable;}
    public static void main(String[] args) {
        String dir = TwoColor.class.getPackage().getName().replace(".", "/");
        String path = TwoColor.class.getClassLoader().getResource(dir+"/color_test.txt").getPath();
        String path2 = TwoColor.class.getClassLoader().getResource(dir+"/color_test2.txt").getPath();
        In in = new In(new File(path));
        Graph g = new Graph(in);
        TwoColor t = new TwoColor(g);
        System.out.println(t.isBipartite());
        
        In in2 = new In(new File(path2));
        Graph g2 = new Graph(in2);
        TwoColor t2 = new TwoColor(g2);
        System.out.println(t2.isBipartite());
    }
}
true
false

符号图

典型应用中,图都是通过文件或者网页定义的,使用的是字符串而非整数来表示和指代顶点。为了适应这样的应用,定义拥有以下性质的输入格式:

  • 顶点名为字符串

  • 用指定的分隔符来隔开顶点名(允许顶点名中含有空格)

  • 每一行都表示一组边的集合,每条边都连接着这一行的第一个名称表示的顶点和其他名称所表示的顶点

  • 顶点总数V和边的总数E都是隐式定义的。

例子:

API

定义了一个构造来读取并构造图,用name( )方法和index( )方法将输入流中的顶点名和图算法使用的顶点索引对应起来。

测试用例

例子:飞机场routes.txt--输入机场代码查找从该机场起飞到达的城市,但这些信息并不是直接从文件中能得到的。

例子:电影movies.txt--输入一部电影名字得到演员列表。这不过是在照搬文件中对应的行数据,

​ 但输入演员名字 查看其出演的电影列表,相当于反向索引。

​ 尽管数据库的构造是为了将电影名连接到演员,二分图模型同时也意味着将演员连接到电影名。

​ 二分图的性质自动完成了反向索引。这将成为处理更复杂的和图有关的问题的基础。

符号图的实现

SymbolGraph用到了3种数据结构:

  • 一个符号表st,键的类型为String(顶点名),值得类型为int(索引);

  • 一个数组keys[ ],用作反向索引,保存每个顶点索引对应的顶点名;

  • 一个Graph对象G,使用索引来引用图中的顶点。

SymbolGraph会遍历两遍数据结构来构造以上数据结构,主要是因为构造Graph对象需要顶点总数V。在典型的实际应用中,在定义图的文件中指明V和E可能会不方便,从而有了SymbolGraph,这样就可以方便地在routes.txt或者movies.txt中添加或者删除条目而不用但系需要维护边或者顶点的总数。

Java实现


/**
 * 符号图
 */
public class SymbolGraph {
    private HashMap<String, Integer> map;     // key:顶点名  value:索引
    private String[] keys;                    // 反向索引,保存每个顶点索引对应的顶点名
    private Graph g;                        // 使用索引来引用图中的顶点
    public SymbolGraph(String path, String sp){
        map = new HashMap<>();
        BufferedReader reader;
        String line;
        try {
            reader = new BufferedReader(new FileReader(new File(path)));
            while((line = reader.readLine()) != null){//第一遍,构造索引
                String [] vertexs = line.split(sp);
                for(String s : vertexs)
                    if(!map.containsKey(s))    map.put(s, map.size());
            }
            reader.close();
            keys = new String[map.size()]; 
            for(String name: map.keySet()){    // 遍历map的key,构造顶点名的反向索引
                keys[map.get(name)] = name; 
            }
            g = new Graph(map.size());
            line = "";
            reader = new BufferedReader(new FileReader(new File(path)));
            while((line = reader.readLine()) != null){ // 第二遍,构造图,将每一行的顶点和该行其他点相连
                String[] strs = line.split(sp);
                int start = map.get(strs[0]);//获取起点
                for(int i = 1; i< strs.length; i++)
                    g.addEdge(start, map.get(strs[i]));
            }
            reader.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    /** key是一个顶点吗*/
    public boolean  contains(String key){return map.containsKey(key);}
    /** key的索引*/
    public int index(String key){return map.get(key);}
    /** 索引v的顶点名*/
    public String name(int v){return keys[v];}
    /** 隐藏的Graph对象*/
    public Graph graph(){return g;}
    public static void main(String[] args) {
        String dir = Cycle.class.getPackage().getName().replace(".", "/");
        String path = Cycle.class.getClassLoader().getResource(dir+"/routes.txt").getPath();
        SymbolGraph sg = new SymbolGraph(path, " ");
        Graph g = sg.graph();
        HashMap<String, Integer> map = sg.map;
        for(Entry<String, Integer> s: map.entrySet()){
            System.out.println(s.getKey() + "-" +s.getValue());
        }
        System.out.println(g.toString());
        String start = "JFK";
        if(!sg.contains(start)){
            System.out.println("起点"+start + " 不在数据库.");
            return;
        }
        int s = sg.index(start);
        BreadthFirstPaths bfs = new BreadthFirstPaths(g, s);
        String end = "LAS";
        if(!sg.contains(end)){
            System.out.println("终点"+end + " 不在数据库.");
        }else{
            int t = sg.index(end);
            if(!bfs.hasPathTo(t)){
                System.out.println(start +" 和 " + end + " 没有路径相同.");
                return;
            }
            Stack<Integer> stack = bfs.pathTo(t);
            StringBuilder sb = new StringBuilder();
            while(!stack.isEmpty()){
                sb.append(sg.name(stack.pop())).append(" ");
            }
            System.out.println("起点"+start+"到终点"+end+"的路径为:");
            System.out.println(sb.toString());
        }
    }
}
LAS-9
LAX-8
DFW-5
ORD-2
JFK-0
HOU-4
ATL-7
DEN-3
PHX-6
MCO-1
vertex count: 10 edge count: 18
0:    [1,7,2]
1:    [0,7,4]
2:    [3,4,5,6,0,7]
3:    [2,6,9]
4:    [2,7,5,1]
5:    [6,2,4]
6:    [5,2,3,8,9]
7:    [0,4,2,1]
8:    [6,9]
9:    [3,8,6]

起点JFK到终点LAS的路径为:
JFK ORD DEN LAS 

同样可以把电影-演员作为例子输入:

这个Graph实现允许用例用字符串代替数字索引来表示图中的顶点。

它维护了

  • 实例变量st(符号表用来映射顶点名和索引)

  • keys(数组用来映射索引和顶点名)

  • g(使用索引表示顶点的图)

为了构造这些数据结构,代码会将图的定义处理两遍(定义的每一行都包含一个顶点以及它的相邻顶点列表,用分隔符sp隔开)

间隔的度数

图处理的一个经典问题就是,找到一个社交网络之中两个人间隔的度数。

演员K演过很多电影,为图中每个演员附一个K数:

  • K本人为0,

  • 所有和K演过同一部电影的人的值为1,

  • 所有(除K外)和K数为1的演员出演过同一部电影的人的值为2,

  • 以此类推。

可以看到K数必须为最短电影链的长度,因此不用计算机,很难知道。

用例DegreesOfSeparation所示,BreadthFirstPaths才是我们所需要的程序,通过最短路径来找出movies.txt中任意演员的K数。

总结

几个基本概念:

  • 图的术语;

  • 一种图的表示方法,能够处理大型而稀疏的图;

  • 和图处理相关的类的设计模式,其实现算法通过在相关的类的构造函数中对图进行预处理,构造所需的数据结构来高效支持用例对图的查询;

  • DFS&BFS

  • 支持使用符号作为图的顶点名的类。

上表总结了本节所有图算法的实现。适合作为图处理的入门学习。随后学习复杂类型图以及更加困难的问题时,会用到这些代码的变种。

考虑了边的方向以及权重之后,同样地问题会变得困难得多,但同样地算法仍然凑效并将成为解决更复杂问题的起点。



proheart
41 声望20 粉丝

Developer, Java & Android