数据结构与算法——图

为什么要有图?

前面学过的 线性表

  • 线性表:局限于一个 直接前驱 和 一个 直接后继 的关系
  • 树:只能有一个直接前驱(父节点)

当我们需要表示 多对多 的关系时,就需要用到

图的举例说明

比如:城市交通图。他就是一个图,对应程序中的图如下所示

图是一种 数据结构,其中节点可以具有 零个或多个相邻元素,两个节点之间的链接称为 ,节点页可以称为 顶点。

图的常用概念

  • 顶点(vertex)

  • 边(edge)

  • 路径:路径就是一个节点到达另一个节点所经过的节点连线

    D -> C 的路径就有两条(针对无向图来说):

    • D → B → C
    • D → A → B → C
  • 无向图顶点之间的连接没有方向

    比如上图它是一个 无向图;比如 A-B,可以是 A->B 也可以是 B <- A

  • 有向图:顶点之间的连接是有方向的。如下图

    那么 A-B,就只能是 A → B,而不能是 B → A

  • 带权图:边有权值时,则叫做带权图,同时也叫

    比如上图中,北京 到 上海这一条边上有一个数值 1463,这个可能是他的距离,这种就叫做 边带权值

图的表示方式

有两种:

  • 二维数组表示:邻接矩阵
  • 链表表示:邻接表

邻接矩阵

邻接矩阵是表示图形中 顶点之间相邻关系 的矩阵,对于 n 个顶点的图而言,矩阵的 row 和 col 表示的是 1....n 个点

上图是一个无向图,它用矩阵表示如右图:

  • 左侧的 0~5 表示顶点(也就是列,竖看)
  • 横着的 0 -5 表示,左侧的顶点,与其他顶点的关系

比如:0,0 的值为 0,则表示 不能直连0-1 的值为 1,表示可用直连(注意是直连)。

邻接表

由于邻接矩阵有一个缺点:它需要为每个顶点都分配 n 个边的空间,其实有很多边都是不存在的(比如上面的 0,0 不链接,也需要表示出来),这 会造成空间的一定损失

邻接表 的实现只关心 存在的边,因此没有空间浪费,由 数组 + 链表组成

如上图:

  • 左侧(竖向)表示了有 n 个点,用数组存放。

  • 右侧每一个点,都有一条链表,表示:顶点与链表中的点都可以直连

    注意:它并不是表示可以从 1 到 2 到 3 的 路径,只表示与链表中的点可以直连

图的快速入门案例

要求用代码实现如下图结构

思路分析:

  • 每一个顶点需要用一个容器来装,这里使用简单的 String 类型来表示 A、B ... 等节点
  • 这些所有的顶点,我们用一个 List 来存储
  • 它对应的矩阵使用一个二维数组来表示,节点之间的关系

代码实现:

/**
 * 邻接矩阵 图
 */
public class GraphTest {
    @Test
    public void graphTest() {
        int n = 5;
        String vertexValue[] = {"A", "B", "C", "D", "E"};
        Grap grap = new Grap(n);
        for (String value : vertexValue) {
            grap.insertVertex(value);
        }
        // 设置顶点关系

        /*
        // A 与 C、B 直连
        grap.insertEdge(0, 1, 1);
        grap.insertEdge(0, 2, 1);

        // B 与 C、A、E、D 直连
        grap.insertEdge(1, 0, 1);
        grap.insertEdge(1, 2, 1);
        grap.insertEdge(1, 3, 1);
        grap.insertEdge(1, 4, 1);

        // C 与 B、A 直连
        grap.insertEdge(2, 0, 1);
        grap.insertEdge(2, 1, 1);

        // D 与 B 直连
        grap.insertEdge(3, 1, 1);

        // E 与 B 直连
        grap.insertEdge(4, 1, 1);
         */
        // 上面这种写法是双向的,由于内部已经处理过双向边了,所以只需要设置 5 条单向的即可
        // a,b  a,c  b,c  b,d  b,e
        grap.insertEdge(0, 1, 1);
        grap.insertEdge(0, 2, 1);
        grap.insertEdge(1, 2, 1);
        grap.insertEdge(1, 3, 1);
        grap.insertEdge(1, 4, 1);

        grap.showGraph();
        System.out.println("边:" + grap.getNumOfEdges());
        System.out.println("下标 1:" + grap.getValueByIndex(1));
    }

    class Grap {
        /**
         * 存放所有的顶点
         */
        private List<String> vertexs;
        /**
         * 矩阵:存放边的关系(顶点之间的关系)
         */
        private int[][] edges;
        /**
         * 存放有多少条边
         */
        private int numOfEdges = 0;

        /**
         * @param n 有几个顶点
         */
        public Grap(int n) {
            //初始化
            vertexs = new ArrayList<>(n);
            edges = new int[n][n];
        }
        /*
         *=============
         * 有两个核心方法:插入顶点,设置边的关系
         */

        /**
         * 插入顶点
         *
         * @param vertex
         */
        public void insertVertex(String vertex) {
            vertexs.add(vertex);
        }

        /**
         * 添加边的关系
         *
         * @param v1     第一个顶点对应的矩阵下标
         * @param v2     第二个顶点对应的矩阵下标
         * @param weight 他们之间的关系:0|不直连,1|直连
         */
        public void insertEdge(int v1, int v2, int weight) {
            edges[v1][v2] = weight;
            // 由于是无向图,反向也可以连通
            edges[v2][v1] = weight;
            numOfEdges++;  // 边增加 1
        }

        /*
         *=============
         * 下面写几个图的常用方法
         */

        /**
         * 获取顶点的数量
         */
        public int getNumOfVertex() {
            return vertexs.size();
        }

        /**
         * 获取边的数量
         *
         * @return
         */
        public int getNumOfEdges() {
            return numOfEdges;
        }

        /**
         * 根据下标获得顶点的值
         *
         * @param i
         * @return
         */
        public String getValueByIndex(int i) {
            return vertexs.get(i);
        }

        /**
         * 显示图的矩阵
         */
        public void showGraph() {
            System.out.printf("  ");
            for (String vertex : vertexs) {
                System.out.printf(vertex + " ");
            }
            System.out.println();
            for (int i = 0; i < edges.length; i++) {
                System.out.printf(vertexs.get(i) + " ");
                for (int j = 0; j < edges.length; j++) {
                    System.out.printf(edges[i][j] + " ");
                }
                System.out.println();
            }
        }
    }
}

首先编写了两个核心方法:插入顶点、设置边关系,其次编写了几个辅助获取信息的方法。

测试输出如下

  A B C D E 
A 0 1 1 0 0 
B 1 0 1 1 1 
C 1 1 0 0 0 
D 0 1 0 0 0 
E 0 1 0 0 0 
边:5

图的深度优先遍历

所谓图的遍历,则是 对节点的访问。一个图有很多个节点,如何遍历这些节点,需要特定策略,一般有两种访问策略:

  1. 深度优先遍历(DFS,Depth First Search)
  2. 广度优先遍历(BFS,Broad First Search)

深度优先遍历基本思想

图的深度优先搜索(Depth First Search),简称 DFS

从初始访问节点出发,初始访问节点可能有多个 邻接节点(直连),深度优先遍历的策略为:

  • 首先访问第一个邻接节点
  • 然后以这个 被访问的邻接节点又作为初始节点
  • 然后循环这个操作。

可以这样理解:每次都在访问完 当前节点 后,首先 访问当前节点的第一个邻接节点

可以看到:这样的访问策略是 优先往纵向 挖掘深入,而 不是 对一个节点的所有邻接节点进行 横向访问

那么显然深度优先搜索是一个 递归过程

深度优先遍历算法步骤

基本思想看完,可能还是不清楚是如何遍历的,看看他的遍历步骤:

  1. 访问初始节点 v,并标记节点 v 为已访问
  2. 查找节点 v 的第一个邻接(直连)节点 w
  3. 如果节点 w 不存在,则回到第 1 步,然后从 v 的下一个节点继续
  4. 如果节点 w 存在:
    1. 未被访问过,则对 w 进行深度优先遍历递归(即把 w 当做另一个 v,执行步骤 123)
    2. 如果已经被访问过:查找节点 v 的 w 邻接节点的下一个邻接节点,转到步骤 3

以上图作为例子:添加节点的顺序为 A、B、C、D、E。

  1. 添加节点的顺序为 A、B、C、D、E。那么第一个初始节点就是 A

  2. 访问 A,输出 A,并标记为已访问

  3. 查找 A 的下一个邻接节点:

    0,0 开始找,直到找到 0,1 = 1 即 B,

    如果 B 没有被访问过,则以 B 为基础递归 B。

  4. 递归:访问 B,输出 B,并标记为已访问

  5. 查找 B 的下一个邻接节点:

    1,0 开始找,直到找到 1,2 = 1 即 C,

    如果 C 没有被访问过,则以 C 为基础递归 C

  6. 递归:访问 C ,输出 C,并标记为已访问

  7. 查找 C 的下一个邻接节点:

    2,0 开始找,找到 2,4 都没有找到有直连的;这里会退出递归 C,从而回到递归 B

  8. 由于循环并未结束:会判断找到的 C,已经被访问过。则从 B 为基础查找下一个:

    也就是从 1,2+11,3 = 1,即 B,D B 直连 D

  9. 递归访问 D,输出 D,并标记为已访问

    3,0 开始找,找到 3,4 都没有找到与 D 直连的下一个,则退出 D 递归

  10. 回到了递归 B,由于循环未结束,会判断 D ,已经被访问过,则从 B 为基础查找下一个

    也就是从 1,3+11,4 = 1 ,即 B,E B 直连 E

  11. 递归访问 E,输出 E,并标记为已访问

  12. 查找 E 的下一个节点,由于 E 是最后一个,退出递归 E,回到了递归 B

  13. 回到了递归 B,由于循环未结束,会判断 E,已经被访问过,则从 B 为基础查找下一个(这里已经是找完了),也未找到(已经找完了)

  14. 回到了递归 A,由于循环未结束,会判断 B,已经被访问过,则从 A 为基础查找下一个

    查找到 C,A 与 C 直连,由于 C 已经被访问过,则继续以 A 为基础查找下一个,把 A 可能链接的点查找完成,没有,则退出递归 A

  15. 这时,已经跳出了第一次初始点 A 的深度优先查找 ,按照插入顶点的顺序,下一个节点为 B,从 B 开始深度优先查找

    这里先判定:是否已经访问过,肯定已经访问过了,直接跳过 深度优先查找

  16. 由于 B 已经被访问过,那么直接下一个 C,发现 C 也被访问过

  17. 以此类推,后面的都被访问过了,则直接完成。

思路小节:

  1. 先从一个初始节点开发深度优先查找
  2. 然后找到该节点的第一个邻接节点,找到则继续深度优先
  3. 如果找不到,则会 回溯:那么尝试该节点的其他路径是否可以连通。
  4. 直到回溯到最顶层,然后退出该次 深度优先查找函数。挑选下一个初始节点如果没有访问过,则调用深度优先函数

到这里都应该明白它的工作原理了,如果还是不明白就请看下面的代码应该就可以懂了。

代码实现

在上面原先的代码基础上进行功能添加。

        /**
         * 存放顶点是否已经访问过,下标对应顶点插入列表的下标
         */
        private boolean isVisiteds[];
       /**
         * 深度遍历
         */
        public void dfs() {
            for (int i = 0; i < vertexs.size(); i++) {
                // 如果已经访问过,则跳过
                if (isVisiteds[i]) {
                    continue;
                }
                // 没有访问过,则以此节点为基础进行深度遍历
                dfs(i);
            }
        }

        /**
         * 深度优先遍历
         *
         * @param i 当前是以,顶点插入列表中的哪一个顶点进行深度优先查找
         */
        public void dfs(int i) {
            // 输出自己,并标记为已访问过
            System.out.print(vertexs.get(i) + " -> ");
            isVisiteds[i] = true;

            // 查找此节点的第一个邻接节点
            int w = getFirstNeighbor(i);
            // 如果找到了 w ,则对 w 进行深度优先遍历
            while (w != -1) {
                // 已经访问过,
                if (isVisiteds[w]) {
                    w = getNextNeighbor(i, w);
                } else {
                    dfs(w);
                }
            }
        }

        /**
         * 查找第一个邻接节点
         *
         * @param i
         * @return 如果找到,则返回具体的下标
         */
        private int getFirstNeighbor(int i) {
            for (int j = i; j < vertexs.size(); j++) {
                if (edges[i][j] == 1) {
                    return j;
                }
            }
            return -1;
        }

        /**
         * 如果 w 节点被访问过,则查找 i 节点的下一个 邻接节点(就不是第一个节点了)
         * 联系代码就可以知道其的功能定位了
         * @param i
         * @param w
         * @return
         */
        private int getNextNeighbor(int i, int w) {
            for (int j = w + 1; j < vertexs.size(); j++) {
                if (edges[i][j] == 1) {
                    return j;
                }
            }
            return -1;
        }

测试代码

    @Test
    public void dfsTest() {
        int n = 5;
        String vertexValue[] = {"A", "B", "C", "D", "E"};
        Grap grap = new Grap(n);
        for (String value : vertexValue) {
            grap.insertVertex(value);
        }
        // a,b  a,c  b,c  b,d  b,e
        grap.insertEdge(0, 1, 1);
        grap.insertEdge(0, 2, 1);
        grap.insertEdge(1, 2, 1);
        grap.insertEdge(1, 3, 1);
        grap.insertEdge(1, 4, 1);
        grap.showGraph();

        System.out.println();
        grap.dfs();
    }

测试输出

  A B C D E 
A 0 1 1 0 0 
B 1 0 1 1 1 
C 1 1 0 0 0 
D 0 1 0 0 0 
E 0 1 0 0 0 

A -> B -> C -> D -> E ->

这里的难点,一定不要以为直接按照添加的顶点顺序输出就行,虽然这里结果看上去是添加的顶点顺序,实际上它是有查找第一个邻接节点,不存在则回溯到上一层,直到回溯到初始节点。 这里有一个回溯的流程。如果是一个比较复杂的图,输出的结果就不一定是更添加顶点的顺序了。

简单总结如下:

  1. 每次只找第一个邻接节点(纵向)
  2. 找不到,则返回到上一层。然后开始 横向找非第一个邻接节点
  3. 然后不断的找第一个,然后回溯(横向找下一个)的流程

通过上面的例子,你可能会发现:在循环的时候,把 A 作为参数调用 深度优先搜索,整个图就遍历完成了,那为什么还需要外面一层循环呢?

这个问题你想象一下:你看一个地铁图的时候,假设有 2 个地铁站 G、H,没有和其他节点连接,只有 G→H 相连了,那么上面的列子,最外层的循环就起作用了。简单说就是:当一个点,不能间接的到达某一个点,那么就需要外层的这个循环来工作。

图的广度优先遍历

图的广度优先搜索(DFS,Broad First Search),类似于一个 分层搜索的过程,广度优先遍历需要使用一个队列以保持访问过的节点的顺序,以便按这个顺序来访问这些节点的邻接节点。

算法步骤

  1. 访问初始节点 v ,并标记节点 v 为已访问
  2. 节点 v 入队列
  3. 当队列非空时,继续执行,否则算法结束(仅对 v 的节点算法结束)。
  4. 出队列,取得队头的节点 u
  5. 查找节点 u 的第一个邻接节点 w
  6. 若节点 u 的邻接节点 w 不存在,则跳转到步骤 3;否则执行以下三个步骤:
    1. 若节点 w 尚未被访问,则访问节点 w 并标记为已访问
    2. 节点 w 入队列
    3. 查找节点 u 的继 w 邻接节点后的下一个邻接节点 w,转到步骤 6

这里可以看到,与深度优先不同的是:

  • 广度优先:找到第一个邻接节点,访问之后,会继续寻找这个节点的下一个邻接节点(非第一个)
  • 深度优先:每次只找第一个邻接节点,然后以找到的节点作为基础找它的第一个邻接节点,如果找不到才回溯到上一层,寻找找它的下一个邻接节点(非第一个)

就如同上图:

  • 左侧的是广度优先,先把 A 能直连的都访问完,再换下一个节点
  • 右图是深度优先,每次都只访问第一个直连的节点,然后换节点继续,访问不到,则回退到上一层,找下一个直连节点

代码实现

记住这个步骤:

  1. 访问初始节点 v ,并标记节点 v 为已访问
  2. 节点 v 入队列
  3. 当队列非空时,继续执行,否则算法结束(仅对 v 的节点算法结束)。
  4. 出队列,取得队头的节点 u
  5. 查找节点 u 的第一个邻接节点 w
  6. 若节点 u 的邻接节点 w 不存在,则跳转到步骤 3;否则执行以下三个步骤:
    1. 若节点 w 尚未被访问,则访问节点 w 并标记为已访问
    2. 节点 w 入队列
    3. 查找节点 u 的继 w 邻接节点后的下一个邻接节点 w,转到步骤 6

下面的代码实现也是这个步骤来实现的

在原先的代码基础上增加功能。

        /**
         * 对整个节点进行 广度优先 遍历
         */
        public void bsf() {
            for (int i = 0; i < vertexs.size(); i++) {
                // 如果已经访问过,则跳过
                if (isVisiteds[i]) {
                    continue;
                }
                System.out.println("新的节点广度优先");  // 换行 1
                // 没有访问过,则以此节点为基础进行深度遍历
                bsf(i);
            }
        }

        /**
         * 对单个节点为初始节点,进行广度优先遍历
         *
         * @param i
         */
        private void bsf(int i) {
            // 访问该节点,并标记已经访问
            System.out.print(getValueByIndex(i) + " → ");
            isVisiteds[i] = true;

            // 将访问过的添加到队列中
            LinkedList<Integer> queue = new LinkedList<>();
            queue.addLast(i); // 添加到末尾

            int u; // 队列头的节点
            int w; // u 的下一个邻接节点
            // 当队列不为空的时候,查找节点 u 的第一个邻接节点 w
            while (!queue.isEmpty()) {
//                System.out.println();  // 换行 2
                u = queue.removeFirst();
                w = getFirstNeighbor(u);
                // w 存在的话
//                while (w != -1) {
//                    // 如果 w 已经被访问过
//                    if (isVisiteds[w]) {
//                        // 则:以 u 为初始节点,查找 w 的下一个邻接节点
//                        w = getNextNeighbor(u, w);
//                    }
//                    // 如果 w 没有被访问过,则访问它,并标记已经访问
//                    else {
//                        System.out.print(getValueByIndex(w) + " → ");
//                        isVisiteds[w] = true;
//                        queue.addLast(w); // 访问过的一定要入队列
//                    }
//                }
                // 上面这样写,容易阅读,但是会存在多一次循环的问题,改写成下面这样
                while (w != -1) {
                    // 如果没有被访问过,则访问,并标记为已经访问过
                    if (!isVisiteds[w]) {
                        System.out.print(getValueByIndex(w) + " → ");
                        isVisiteds[w] = true;
                        queue.addLast(w); // 访问过的一定要入队列
                    }
                    // 上面访问之后,就需要获取该节点的下一个节点
                    // 否则,下一次还会判断一次 w,然后去获取下一个节点,只获取,但是没有进行访问相关操作
                    // 相当于每个节点都会循环两次,这里减少到一次
                    w = getNextNeighbor(u, w);
                }
            }
        }
    }

        /**
         * 查找第一个邻接节点
         *
         * @param i
         * @return 如果找到,则返回具体的下标
         */
        private int getFirstNeighbor(int i) {
            for (int j = i; j < vertexs.size(); j++) {
                if (edges[i][j] == 1) {
                    return j;
                }
            }
            return -1;
        }

        /**
         * 如果 w 节点被访问过,则查找 i 节点的下一个 邻接节点(就不是第一个节点了)
         * 联系代码就可以知道其的功能定位了
         * @param i
         * @param w
         * @return
         */
        private int getNextNeighbor(int i, int w) {
            for (int j = w + 1; j < vertexs.size(); j++) {
                if (edges[i][j] == 1) {
                    return j;
                }
            }
            return -1;
        }

测试代码

    /**
     * 图的广度优先遍历
     */
    @Test
    public void bfsTest() {
        int n = 5;
        String vertexValue[] = {"A", "B", "C", "D", "E"};
        Grap grap = new Grap(n);
        for (String value : vertexValue) {
            grap.insertVertex(value);
        }
        // a,b  a,c  b,c  b,d  b,e
        grap.insertEdge(0, 1, 1);
        grap.insertEdge(0, 2, 1);
        grap.insertEdge(1, 2, 1);
        grap.insertEdge(1, 3, 1);
        grap.insertEdge(1, 4, 1);
        grap.showGraph();

        System.out.println();
        grap.bsf();
    }

测试输出

  A B C D E 
A 0 1 1 0 0 
B 1 0 1 1 1 
C 1 1 0 0 0 
D 0 1 0 0 0 
E 0 1 0 0 0 

A → B → C → D → E →

当只打开 换行 1 的时候,输出如下信息

System.out.println("新的节点广度优先");  // 换行 1
A → B → C → D → E →  

然后同时打开 换行 1、2,输出信息如下

新的节点广度优先
A → 
B → C → 
D → E → 

可以看到:

  • 先输出了 A:因为 A 是初始节点
  • 然后以 A 为基础,找与 A 直连的,B、C,由于后面没有了,则会退出一个小循环
  • 然后从队列中取出头:也就是 B,因为前面记录了访问顺序,找与 B 直连的,进入小循环
  • 输出了与 B 直连的:D、E

通过这个过程可以看到:广度优先,他是一层一层的查找的。

图的深度优先 VS 广度优先

由于前面讲解的点较少,恰好输出顺序一致,现在来对比下多一点的点。如下图

 /**
     * 构建数量较多的点的 图
     *
     * @return
     */
    private Grap buildGrap2() {
        String vertexValue[] = {"1", "2", "3", "4", "5", "6", "7", "8"};
        Grap grap = new Grap(vertexValue.length);
        for (String value : vertexValue) {
            grap.insertVertex(value);
        }
        // a,b  a,c  b,c  b,d  b,e
        grap.insertEdge(0, 1, 1);
        grap.insertEdge(0, 2, 1);
        grap.insertEdge(1, 3, 1);
        grap.insertEdge(1, 4, 1);
        grap.insertEdge(3, 7, 1);
        grap.insertEdge(4, 7, 1);
        grap.insertEdge(2, 5, 1);
        grap.insertEdge(2, 6, 1);
        grap.insertEdge(5, 6, 1);
        return grap;
    }

    /**
     * 图的深度优先遍历:点数量较多的测试
     */
    @Test
    public void dfsTest2() {
        Grap grap = buildGrap2();
        grap.showGraph();

        System.out.println();
        grap.dfs();
    }

    /**
     * 图的广度优先遍历:点数量较多的测试
     */
    @Test
    public void bfsTest2() {
        Grap grap = buildGrap2();
        grap.showGraph();

        System.out.println();
        grap.bsf();
    }

测试输出:深度优先

  1 2 3 4 5 6 7 8 
1 0 1 1 0 0 0 0 0 
2 1 0 0 1 1 0 0 0 
3 1 0 0 0 0 1 1 0 
4 0 1 0 0 0 0 0 1 
5 0 1 0 0 0 0 0 1 
6 0 0 1 0 0 0 1 0 
7 0 0 1 0 0 1 0 0 
8 0 0 0 1 1 0 0 0 

1 -> 2 -> 4 -> 8 -> 5 -> 3 -> 6 -> 7 -> 

测试输出:广度优先

  1 2 3 4 5 6 7 8 
1 0 1 1 0 0 0 0 0 
2 1 0 0 1 1 0 0 0 
3 1 0 0 0 0 1 1 0 
4 0 1 0 0 0 0 0 1 
5 0 1 0 0 0 0 0 1 
6 0 0 1 0 0 0 1 0 
7 0 0 1 0 0 1 0 0 
8 0 0 0 1 1 0 0 0 

1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 

可以看到输出的遍历结果明显不同。

posted @ 2021-09-20 15:45  海绵寳寳  阅读(112)  评论(0编辑  收藏  举报