【算法日积月累】14-图论基础

图论基础(Graph Theory)

图是由结点(Vertex)和边(Edge)组成的抽象的数据结构。结点和结点之间靠边连接起来。

图的分类 1

1、无向图(Undirected Graph),是我们主要研究的部分,我们可以把无向图看成一种特殊的有向图;

2、有向图(Directed Graph),有向图具有不对称的特性。

无向图和有向图

图的分类 2

1、无权图(Unweighted Graph)

2、有权图(Weighted Graph)

无权图和有权图

理解图的连通性。

图的连通性

上面这张图,它的连通分量为 。

简单图(Simple Graph):无自环边(self-loop),并且无平行边的图(parallel-edges)。

自环边和平行边

我们的学习将从简单图开始。

图的表示

本章仅介绍无权图。图这种数据结构可以使用“邻接矩阵”和“邻接表”来表示。

邻接矩阵

无向图的邻接矩阵表示是一个对称矩阵。

邻接矩阵

邻接表

邻接表

稀疏图与稠密图

邻接表适合表示稀疏图(Sparse Graph),边较少。

邻接矩阵适合表示稠密图(Dense Graph),边较多。

邻接表和邻接矩阵适合表示

我们的图是实现了如下接口的 Java 类。

Java 代码:

// 图的接口,统一表示稀疏图与稠密图
public interface Graph {

    // 返回顶点的个数
    int V();

    // 返回边的条数
    int E();

    // 在顶点 v 和 w 之间添加一条表
    void addEdge(int v, int w);

    // 顶点 v 与 顶点 w 是否有边连接
    boolean hasEdge(int v, int w);

    // 显示这个图中的元素
    void show();

    // 得到顶点 v 的所有邻居顶点
    Iterable adj(int v);
}

编写相邻结点迭代器

为了方便遍历一个点的所有邻居结点,我们需要实现一个迭代器。

稀疏图(邻接表)的相邻点迭代器

由稀疏图(邻接表)的表示决定了返回一个结点的所有相邻结点是十分容易的。

Java 代码:

public Iterable adj(int v) {
    assert v >= 0 && v < n;
    return g[v];
}

稠密图(邻接矩阵)的相邻点迭代器

由稠密图(邻接矩阵)得到一个结点的所有相邻结点只须遍历邻接矩阵中的一行,把值为 True 的所有顶点拿到即可。

Java 代码:

public Iterable adj(int v){
    assert v >= 0 && v < n;
    Vector adjV = new Vector<>();
    for (int i = 0; i < n; i++) {
        if(g[v][i]){
            adjV.add(i);
        }
    }
    return adjV;
}

我们将图的数据以一定格式保存在一个文件中,因此我们要编写一个工具类读这个图文件。

从一个文件读出一个图

我们图的信息存在一个文件中,然后通过读取这个文件的内容得到图的顶点和边的信息,为此我们编写了如下代码。

编写一个读图的工具类

例1:文件 testG1.txt 的内容如下,其中第 1 行的第 1 个数表示这张图有多少个顶点,第 2 个数表示这张图有多少条边。
第 2 行直至最后一行表示的是所有的边,以两个顶点来表示一条边。

13,13
0,5
4,3
0,1
9,12
6,4
5,4
0,2
11,12
9,10
0,6
7,8
9,11
5,3

如果它表示一个无向图,那么画出来就是:

例 1

用邻接表表示是这样的:

例 1 邻接表表示

用邻接矩阵表示是这样的:

例 1 邻接矩阵表示

读图的工具类(Java)

深度优先遍历与图的连通分量

和二分搜索树的遍历一样,图的遍历也可以分为深度优先遍历和广度优先遍历。

图的深度优先遍历

  • 从图中任意一个结点开始,遍历它的相邻结点,只要这个相邻的结点还有相邻的结点就继续遍历,遍历完成以后才返回。深度优先遍历可以使用递归来完成。

  • 递归到底的情形在编码的时候不容易发现,其实就是在所有的结点都被遍历完成的时候,就是递归到底、方法应该返回的时候。

  • 实现深度优先遍历有两个关键点:

  1. 从一个顶点开始不停地向下试,直到试不下去了为止;

  2. 图和树不一样的,树到了叶子结点就走不下去了,在图中要记录每一个结点是否被遍历过

图的深度优先遍历的应用

应用1:计算图的连通分量;

Java 代码:经过一次深度优先遍历得到连通分量

public class Component {

    private Graph graph;

    private boolean[] visited;

    // 连通分量的个数,同时可以在遍历的过程中,为各个连通分量所在的集合进行标记
    private int ccount;

    private void dfs(int i) {
        // 记录是否被访问过,这个非常关键,深度遍历一开始就要记录这些结点被访问过
        visited[i] = true;
        Iterable adj = graph.adj(i);
        for(Integer v:adj){
            if(!visited[v]){
                dfs(v);
            }
        }
    }

    // 构造方法,通过传入一个图,经过了深度优先遍历,计算无权图的连通分量
    public Component(Graph graph) {
        this.graph = graph;
        int vCount = graph.V(); // 图中顶点的个数
        // 可以不用显式赋值,因为默认就是 false
        visited = new boolean[vCount];
        for (int i = 0; i < vCount; i++) {
            if (!visited[i]){// 如果没有遍历过,就进行一次深度优先遍历
                // depth first search 深度优先遍历
                // 注意:深度优先遍历,不是遍历这个结点的所有邻居
                // 而是把所有与该点向量的所有结点都遍历一遍
                dfs(i);
                ccount++; // 经过了一次深度优先遍历以后,连通分量 +1
            }
        }
    }

    // 返回这个图的连通分量的个数
    public int getCcount() {
        return ccount;
    }
}
  • 应用2:通过计算图的连通分量,还可以很快地判断出两个结点是否相连。

在计算连通分量的同时,给每一个顶点编号,编号的规则是:属于一个连通分量的应该编号一样。在上面的代码中,在每一次记录顶点 i 被访问过以后 visited[i] = true;,马上给这个顶点编号,编号就用到此时得到的连通分量的最大值。通过给每个顶点标记,我们就能很轻松地实现查询顶点是否相连,只需要查询它们的连通分量的组别就行了。

Java 代码:

public class Component {

    private Graph graph;
    private boolean[] visited;
    private int[] id;
    private int ccount;

    private void dfs(int i) {
        visited[i] = true;
        // 在计算连通分量的同时,给每一个顶点编号,编号的规则是:属于一个连通分量的应该编号一样。
        id[i] = ccount;
        Iterable adj = graph.adj(i);
        for (Integer v : adj) {
            if (!visited[v]) {
                dfs(v);
            }
        }
    }

    public Component(Graph graph) {
        this.graph = graph;
        int vCount = graph.V();
        visited = new boolean[vCount];
        id = new int[vCount];

        for (int i = 0; i < vCount; i++) {
            visited[i] = false;
            id[i] = -1;
        }

        for (int i = 0; i < vCount; i++) {
            if (!visited[i]){
                dfs(i);
                ccount++;
            }
        }
    }

    public int getCcount() {
        return ccount;
    }

    public boolean isConnected(int v, int w) {
        assert v >= 0 && v < graph.V();
        assert w >= 0 && w < graph.V();
        return id[v] == id[w];
    }
}

寻路算法

在进行深度优先遍历的过程中形成了一条路径,我们这一节的目标是得到这条路径,但我们要知道,使用深度优先遍历并不能保证这条路径是最短的。

寻路算法是一个常见的算法,实现起来也很简单:在每一次遍历的时候,我们都记录一下,这一次遍历从何而来。

使用深度优先遍历获得两点之间的一条路径

Java 代码:

import java.util.Stack;
import java.util.Vector;

// 寻路算法,只能得到无权图的一条路径,该路径并不是最短路径
// 该算法对有向图依然有效
public class Path {
    private Graph graph;
    private boolean[] visited;
    private int[] from;

    private void dfs(Integer v) {
        visited[v] = true;
        Iterable adj = graph.adj(v);
        for (Integer i : adj) {
            if (!visited[i]) {
                from[i] = v;
                dfs(i);
            }
        }
    }

    public Path(Graph graph, int s) {
        assert s >= 0 && s < graph.V();
        this.graph = graph;
        int vCount = graph.V();
        visited = new boolean[vCount];
        from = new int[vCount];
        for (int i = 0; i < vCount; i++) {
            visited[i] = false;
            from[i] = -1;
        }
        // 从 source 开始,做一次深度优先遍历
        dfs(s);
    }

    // 查询从 s 点到 w 点是否有路径
    public boolean hasPath(int w) {
        assert w >= 0 && w < graph.V();
        return visited[w];
    }

    // 得到从 s 到 w 的一条路径
    public void path(int w, Vector vec) {
        assert hasPath(w);
        // 我们要倒着把路径得到,所以应该借助栈来完成该工作
        Stack stack = new Stack<>();
        // 这一段逻辑比较绕,但不难
        int p = w;
        while (p != -1) {
            stack.add(p);
            p = from[p];
        }
        while (!stack.isEmpty()) {
            vec.add(stack.pop());
        }
    }

    // 显示从 s 到 w 的一条路径
    public void showPath(int w) {
        Vector vec = new Vector<>();
        path(w, vec);
        for (int i = 0; i < vec.size(); i++) {
            System.out.print(vec.get(i));
            if (i == vec.size() - 1) {
                System.out.println();
            } else {
                System.out.print(" -> ");
            }
        }
    }
}
  • [ ] 练习 1 :检查有向图中是否有环。
  • [ ] 练习 2 :检查有向图是否是二分图。

10.7 图的广度优先遍历和无权图的最短路径

  • 和树的广度优先遍历是一致,使用队列作为辅助的数据结构;
  • visited 数组应该是对加入到队列中的元素进行处理,因为它加入到队列了,早晚都会被处理;
  • 先遍历到的结点比后遍历到结点距离起始结点更近,所以可以解决最短路径的问题;
  • 针对无向图和有向图都有效。

10.7.1 图的广度优先遍历可以回答的问题

图的广度优先遍历 API

Java 代码:

import java.util.*;

public class ShortestPath {

    private Graph graph;
    private int s; // source
    private boolean[] visited;
    private int[] from; // 记录了从哪个结点访问而来
    private int[] ord;     // 记录了层序

    public ShortestPath(Graph graph, int s) {
        this.graph = graph;
        assert s >= 0 && s < graph.V();

        visited = new boolean[graph.V()];
        from = new int[graph.V()];
        ord = new int[graph.V()];

        for (int i = 0; i < graph.V(); i++) {
            visited[i] = false;
            from[i] = -1;
            ord[i] = -1;
        }
        this.s = s;

        // 特别要注意:使用链表作为队列的时候,出队和入队不能混淆
        // 如果入队的方向是从右到左,出队也是从右到左
        // 如果入队的方向是从左到右,出队也是从左到右
        LinkedList queue = new LinkedList<>();
        queue.push(s);
        visited[s] = true;
        ord[s] = 0;
        while (!queue.isEmpty()) {
            // peek 的意思是:我只是瞅了一眼,并没有真的把它拿出来
            int v = queue.pollLast();
            for (Integer i : graph.adj(v)) {
                if (!visited[i]) {
                    // 注意:LinkedList 有 push 和 add 两种方法
                    // 这一点区别是我在使用测试用例的过程中发现了错误,使用 IDEA 的 debug 调试代码的过程中发现的
                    // push 的源代码点开,你会看到 addFirst
                    // add 的源代码点开,你会看到 linkLast
                    queue.push(i);
                    visited[i] = true;
                    from[i] = v;
                    ord[i] = ord[v] + 1;
                }
            }
        }
    }

    // 图的广度优先遍历可以回答的问题 1 : 判断到结点 w 是否有路径
    public boolean hasPath(int w) {
        assert w >= 0 && w < graph.V();
        return visited[w];
    }

    // 图的广度优先遍历可以回答的问题 2 :打印从 s 到 w 的最短路径
    public void showPath(int w) {
        assert hasPath(w);
        List paths = path(w);
        for (int i = 0; i < paths.size(); i++) {
            if (i == paths.size() - 1) {
                System.out.println(paths.get(i));
            } else {
                System.out.print(paths.get(i) + " -> ");
            }
        }
    }

    // 图的广度优先遍历可以回答的问题 3 :计算从 s 到 w 的最短路径
    public List path(int w) {
        Stack stack = new Stack<>();
        int cur = w;
        while (cur != -1) {
            stack.push(cur);
            cur = from[cur];
        }
        List ret = new ArrayList<>();
        while (!stack.isEmpty()) {
            ret.add(stack.pop());
        }
        return ret;
    }

    // 图的广度优先遍历可以回答的问题 4 :查看从 s 点到 w 点的最短路径长度,若从 s 到 w 不可达,返回 -1
    public int length(int w) {
        assert w >= 0 && w < graph.V();
        return ord[w];
    }
}

关于无权图算法的测试:

// 关于无权图算法的测试
public class Main {

    public static void main(String[] args) {
        // 稠密图
        // DenseGraph graph = new DenseGraph(13, false);
        // new ReadGraphUtil(graph, "testG1.txt");
        // graph.show();

        // 稀疏图
        SparseGraph graph = new SparseGraph(13, false);
        new ReadGraphUtil(graph, "testG1.txt");
        // graph.show();

        Component component = new Component(graph);
        System.out.println("图的连通分量:" + component.getCcount());

        // 通过深度优先遍历,得到一个路径
        Path path = new Path(graph, 0);
        // 此时的路径不是最短路径
        path.showPath(6);

        // 广度优先遍历
        ShortestPath shortestPath = new ShortestPath(graph, 0);
        shortestPath.showPath(3);
    }
}

本文源代码

Python:代码文件夹,Java:代码文件夹。

(本节完)

你可能感兴趣的:(【算法日积月累】14-图论基础)