数据结构高频面试题-图

时间:2022-07-22
本文章向大家介绍数据结构高频面试题-图,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

本篇是【数据结构高频算法题】专题的第2篇文章,主角是,说实话,图的相关算法不是很多,但是这些经典算法在面试中会经常出现,因为:图算法解起来相对复杂,看你计算机基础扎不扎实,写个图算法一目了然,大厂特别爱考哟~~

本文主要内容:

图的基础概念图的基础算法1. 图的遍历深度优先搜索遍历(DFS)广度优先搜索遍历(BFS)2. 单源最短路径问题(Dijkstra算法)3. 拓扑排序4. 最小生成树Kruskal算法(加边法)Prim算法(加点法)经典面试题1.克隆图2.课程表II3.网络延迟问题4.除法求值5.最小高度树6.重新安排行程7. 冗余连接

图的基础概念

  • 图(Graph):一种表示“多对多”关系的复杂数据结构。
  • 图的组成:图G由一个非空的有限顶点集合V(G)和一个有限边集合E(G)组成,定义为G=(V,E)
  • 无向图:若图的每条边都没有方向,则称该图为无向图
  • 有向图:若图的每条边都有方向,则称该图为有向图
  • 顶点的度
  • 对于无向图,顶点的度表示以该顶点作为一个端点的边的数目。
  • 对于有向图,顶点的度分为入度出度。入度是以该顶点为终点的入边数目,出度是以该顶点为起点的出边数目,该顶点的度等于其入度和出度之和。
  • 图的表示: 邻接矩阵和邻接表。(后面有图示)
  • 邻接矩阵:使用一个二维数组 G[N][N]存储图,如果顶点 Vi 和 顶点 Vj 之间有边,则 G[Vi][Vj] = 1 或 weight。邻接矩阵是对称的。
  • 邻接表:图的一种链式存储结构:对于图 G 中每个顶点 Vi,把所有邻接于 Vi 的顶点 Vj 链成一个单链表,这个单链表称为顶点 Vi 的邻接表。
  • 使用场景:邻接表占用空间少,适合存储稀疏图;邻接矩阵适合存储稠密图。如果需要直接判断任意两个结点之间是否有边连接,可能也要用邻接矩阵。
  • 路径:在图G中,存在一个顶点序列(Vp,Vi1,Vi2,Vi3…,Vin,Vq),使得(Vp,Vi1),(Vi1,Vi2),…,(Vim,Vq)均属于边集E(G),则称顶点Vp到Vq存在一条路径。
  • 路径长度:一条路径上经过的边的数量。
  • :某条路径包含相同的顶点两次或两次以上。
  • 有向无环图:没有环的有向图,简称DAG
  • 带权有向图的最短路径长度:源点Vm到终点Vn的所有路径中,权值和最小的路径是最短路径,其长度是最短路径长度。
  • 完全图:任意两个顶点都相连的图称为完全图,又分为无向完全图有向完全图
  • 连通图:在无向图中,若任意两个顶点vivi与vjvj都有路径相通,则称该无向图为连通图。
  • 强连通图:在有向图中,若任意两个顶点vivi与vjvj都有路径相通,则称该有向图为强连通图。
  • 连通网:带权值的连通图叫做连通网。
  • 生成树:将图中所有顶点以最少的边连通的子图。生成树包含全部n个顶点,有且仅有n-1条边,在添加边则必定成环。(因为每个结点(除根结点)都可以向上找到唯一的父节点,所有是树)。
  • 最小生成树:在所有生成树中,权值和最小的生成树就是最小生成树。
  • 树与图的关系:树的定义:有且只有一个结点的入度为0,其他节点的入度为1。树是一个无向连通图,其中任何两个顶点只通过一条路径连接。 换句话说,一个任何没有简单环路的连通图都是一棵树。

无向图的表示方法

有向图的表示方法

图的基础算法

1. 图的遍历

深度优先搜索遍历(DFS)

基本步骤:

  1. 从图中某个顶点v0出发,首先访问v0;
  2. 访问结点v0的第一个邻接点,以这个邻接点vt作为一个新节点,访问vt所有邻接点。直到以vt出发的所有节点都被访问到,回溯到v0的下一个未被访问过的邻接点,以这个邻结点为新节点,重复上述步骤。直到图中所有与v0相通的所有节点都被访问到。
  3. 若此时图中仍有未被访问的结点,则另选图中的一个未被访问的顶点作为起始点。重复深度优先搜索过程,直到图中的所有节点均被访问过。

深度优先搜索遍历(DFS)

广度优先搜索遍历(BFS)

基本步骤

  1. 从图中某个顶点v0出发,首先访问v0;
  2. 依次访问v0的各个未被访问的邻接点。
  3. 依次从上述邻接点出发,访问他们的各个未被访问的邻接点。始终保证一点:如果vivk之前被访问,则vi的邻接点应在vk的邻接点之前被访问。重复上述步骤,直到所有顶点都被访问到。
  4. 如果还有顶点未被访问到,则随机选择一个作为起始点,重复上述过程,直到图中所有顶点都被访问到。 提示:为了按照优先访问顶点的次序,访问其邻接点,所以需要建立一个优先队列(先进先出)。

广度优先搜索遍历(BFS)

面试题参考[第三部分]:图的克隆、除法求职、行程重排

2. 单源最短路径问题(Dijkstra算法)

单源最短路径问题:给定一个起点S(源),求出其与所有顶点的最短路径。最短指的是权值之和最小。

Dijkstra算法的雏形

  1. 找到所有已知顶点(起始是只有源点S)
  2. 将所有已知顶点指向的所有未知顶点罗列出来
  3. 计算源点S到这些未知顶点的distance,找到新distance最小的顶点X
  4. 只修改X的distance,并将X设为已知
  5. 回到第2步,若所有已知顶点的指向结点都已知,结束

Dijkstra算法思想简化:找到所有可确定distance的未知顶点中新distance最小的那个,修改它并将它设为已知。

优化思路:动态规划 广度优先搜索对应的最短路径:在执行广度优先搜索时,会自动查找从一个顶点到另一个相邻顶点的最短路径。 例如:要查找从顶点 A 到顶点 D 的最短路径,我们首先会查找从 AD 是否有任何一条单边路径,接着查找两条边的路径,以此类推,这正是广度优先搜索的搜索过程。

面试题参考[第三部分]:网络延迟问题

3. 拓扑排序

在图论中,拓扑排序(Topological Sorting)是一个有向无环图(DAG)的所有顶点的线性序列。 该序列必须满足下面两个条件:

  • 每个顶点出现且只出现一次
  • 若存在一条从顶点 A 到顶点 B 的路径,那么在序列中顶点 A 出现在顶点 B 的前面

注意:

  • 有向无环图(DAG)才有拓扑排序,非DAG图没有拓扑排序一说
  • 通常,一个有向无环图可以有一个或多个拓扑排序序列

拓扑排序通常用来“排序”具有依赖关系的任务,如选课时的先修课。它与广度优先搜索BFS类似。

算法思想

  • 从 DAG 图中选择一个 没有前驱(即入度为0)的顶点并输出。
  • 从图中删除该顶点和所有以它为起点的有向边。
  • 重复以上步骤,直到当前图中不存在无前驱的顶点。

面试题参考[第三部分]:课程表II

4. 最小生成树

图的生成树是指,包含图的所有节点且仅有n-1边的子图,最小生成树是所有边的代价之和最小的生成树。求最小生成树有以下两种算法。

1. Kruskal算法(加边法)

此算法初始最小生成树边数为0,每迭代一次就选择一条满足条件的最小代价边,加入到最小生成树的边集合里。

算法步骤:

  1. 把图中的所有边按代价从小到大排序;
  2. 把图中的n个顶点看成独立的n棵树组成的森林;
  3. 按权值从小到大选择边,所选的边连接的两个顶点ui,vi,ui,vi应属于两颗不同的树,则成为最小生成树的一条边,并将这两颗树合并作为一颗树。
  4. 重复步骤3,直到所有顶点都在一颗树内或者有n-1条边为止。

Kruskal算法

2. Prim算法(加点法)

此算法每次迭代选择代价最小的边对应的,加入到最小生成树中。算法从某一个顶点s开始,逐渐长大直至覆盖整个连通网的所有顶点。

算法步骤:

  1. 图的所有顶点集合为V;初始令集合u={s},v=V−u;
  2. 在两个集合u,v能够组成的边中,选择一条代价最小的边(u0,v0),加入到最小生成树中,并把v0并入到集合u中;
  3. 重复上述步骤,直到最小生成树有n-1条边或者n个顶点为止。

由于不断向集合u中加点,所以最小代价边必须同步更新;需要建立一个辅助数组closedge,用来维护集合v中每个顶点与集合u中最小代价边信息。

Prim算法

经典面试题

1.克隆图

题目描述(力扣133):

给定无向连通图中一个节点的引用,返回该图的深拷贝(克隆)。图中的每个节点都包含它的值 valInt) 和其邻居neighbors的列表(list[Node])。 提示:必须将给定节点的拷贝作为对克隆图的引用返回。

解题思路:

可以用dfs遍历每个节点; 遍历时,用map存储新图结点、旧图结点的映射关系; 之所以要存储映射关系,是因为:图中同一个结点只能出现一次,该结点的相关边都是对它的引用。因此,要用map保证结点的唯一性,同时也就能构建出各边的相互联系了。

代码实现

class Node(object):
    def __init__(self, val, neighbors):
        self.val = val
        self.neighbors = neighbors


class Solution(object):
    def cloneGraph(self, node: Node):
        """
        :type node: Node
        :rtype: Node
        """
        # 存储新旧结点的映射关系
        graph = {}
        visited = set()
        if node not in graph:
            graph[node] = Node(node.val, [])

        def dfs(node, visited, graph):
            if node in visited:
                return
            visited |= node

            for neighbor in node.neighbors:
                if neighbor not in graph:
                    graph[neighbor] = Node(neighbor.val, [])
                # 像新图的node结点添加一个映射后邻居结点
                graph[node].neighbors.append(graph[neighbor])
                dfs(neighbor, visited, graph)
            return graph[node]

        return dfs(node, visited, graph)

2.课程表II

题目描述(力扣210):

现在你总共有 n 门课需要选,记为 0n-1

在选修某些课程之前需要一些先修课程。例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]

给定课程总量以及它们的先决条件,返回你为了学完所有课程所安排的学习顺序。

可能会有多个正确的顺序,你只要返回一种就可以了。如果不可能完成所有课程,返回一个空数组。

示例 1:

输入: 2, [[1,0]] 
输出: [0,1]
解释: 总共有 2 门课程。要学习课程 1,你需要先完成课程 0。因此,正确的课程顺序为 [0,1] 。

示例 2:

输入: 4, [[1,0],[2,0],[3,1],[3,2]]
输出: [0,1,2,3] or [0,2,1,3]
解释: 总共有 4 门课程。要学习课程 3,你应该先完成课程 1 和课程 2。并且课程 1 和课程 2 都应该排在课程 0 之后。
因此,一个正确的课程顺序是 [0,1,2,3] 。另一个正确的排序是 [0,2,1,3] 。

解题思路:

拓扑排序

  • 从 DAG 图中找出所有入度为0的顶点,放入队列。
  • 每次从队列取出一个结点,从图中删除该顶点以及所有以它为起点的有向边。
  • 每删除一条有向边,该边的终结点的入度-1,如果入度为0,将终结点加入队列。
  • 重复以上步骤,直到当前图中不存在无前驱的顶点。

代码实现

import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;

/**
 * 使用拓扑排序来完成
 */
public class Solution {

    public int[] findOrder(int numCourses, int[][] prerequisites) {
        // 先处理极端情况
        if (numCourses <= 0) {
            return new int[0];
        }
        // 邻接表表示
        HashSet<Integer>[] graph = new HashSet[numCourses];
        for (int i = 0; i < numCourses; i++) {
            graph[i] = new HashSet<>();
        }
        // 入度表
        int[] inDegree = new int[numCourses];
        // 遍历 prerequisites 的时候,把 邻接表 和 入度表 都填上
        for (int[] p : prerequisites) {
            graph[p[1]].add(p[0]);
            inDegree[p[0]]++;
        }
        LinkedList<Integer> queue = new LinkedList<>();
        for (int i = 0; i < numCourses; i++) {
            if (inDegree[i] == 0) {
                queue.addLast(i);
            }
        }
        ArrayList<Integer> res = new ArrayList<>();
        while (!queue.isEmpty()) {
            // 当前入度为 0 的结点
            Integer inDegreeNode = queue.removeFirst();
            // 加入结果集中
            res.add(inDegreeNode);
            // 下面从图中删去
            // 得到所有的后继课程,接下来把它们的入度全部减去 1
            HashSet<Integer> nextCourses = graph[inDegreeNode];
            for (Integer nextCourse : nextCourses) {
                inDegree[nextCourse]--;
                // 马上检测该结点的入度是否为 0,如果为 0,马上加入队列
                if (inDegree[nextCourse] == 0) {
                    queue.addLast(nextCourse);
                }
            }
        }
        // 如果结果集中的数量不等于结点的数量,就不能完成课程任务,这一点是拓扑排序的结论
        int resLen = res.size();
        if (resLen == numCourses) {
            int[] ret = new int[numCourses];
            for (int i = 0; i < numCourses; i++) {
                ret[i] = res.get(i);
            }
            return ret;
        } else {
            return new int[0];
        }
    }
}

复杂度分析:

时间复杂度:O(E+V)。这里 E 表示邻边的条数,V表示结点的个数。初始化入度为 0 的集合需要遍历整张图,具体做法是检查每个结点和每条边,因此复杂度为 O(E+V),然后对该集合进行操作,又需要遍历整张图中的每个结点和每条边,复杂度也为 O(E+V); 空间复杂度:O(V):入度数组、邻接表的长度都是结点的个数 V,即使使用队列,队列最长的时候也不会超过 V,因此空间复杂度是 O(V)。

3.网络延迟问题

题目描述(力扣743):

N 个网络节点,标记为 1N。 给定一个列表 times,表示信号经过有向边的传递时间。 times[i] = (u, v, w),其中 u是源节点,v 是目标节点, w 是一个信号从源节点传递到目标节点的时间。 现在,我们向当前的节点 K 发送了一个信号。需要多久才能使所有节点都收到信号?如果不能使所有节点收到信号,返回 -1

解题思路:

单源最短路径(BFS-动态规划)

代码实现:

class Solution {
    public int networkDelayTime(int[][] times, int N, int K) {
        if (times == null || times.length == 0 || times[0].length == 0) {
            return -1;
        }

        // 使用邻接矩阵 Adjacency matrices 存储图结构
        int[][] adj = new int[N + 1][N + 1];
        for (int[] arr : adj) {
            Arrays.fill(arr, Integer.MAX_VALUE);
        }

        // 有权图
        for (int[] edge : times) {
            adj[edge[0]][edge[1]] = edge[2];
        }

        // res[i] 表示顶点 K 到顶点 i 的时间
        int[] res = new int[N + 1];
        Arrays.fill(res, Integer.MAX_VALUE);
        res[K] = 0;

        Queue<Integer> queue = new LinkedList<Integer>();
        queue.offer(K);

        while (!queue.isEmpty()) {
            Integer start = queue.poll();

            for (int i = 1; i <= N; i++) {
                int weight = adj[start][i];

                // DP 动态规划
                if (weight != Integer.MAX_VALUE && res[i] > res[start] + weight) {
                    res[i] = res[start] + weight;
                    queue.offer(i);
                }
            }
        }

        int count = 0;
        for (int i = 1; i <= N; i++) {
            if (res[i] == Integer.MAX_VALUE) {
                return -1;
            }

            count = Math.max(count, res[i]);
        }

        return count;
    }
}

复杂度分析:

时间复杂度:O(E+V)。 空间复杂度:O(N2)

4.除法求值

题目描述(力扣399):

给出方程式 A / B = k, 其中 AB 均为代表字符串的变量, k 是一个浮点型数字。根据已知方程式求解问题,并返回计算结果。如果结果不存在,则返回 -1.0

示例 : 给定 a / b = 2.0, b / c = 3.0 问题: a / c = ?, b / a = ?, a / e = ?, a / a = ?, x / x = ? 返回 [6.0, 0.5, -1.0, 1.0, -1.0 ]

输入为:

方程式 : vector<pair<string, string>> equations,

方程式结果 : vector<double> values,

问题方程式 : vector<pair<string, string>> queries, 其中 equations.size() == values.size(),程式与结果一一对应,并且结果值均为正数。

基于上述例子,输入如下:

equations(方程式) = [ ["a", "b"], ["b", "c"] ],
values(方程式结果) = [2.0, 3.0],
queries(问题方程式) = [ ["a", "c"], ["b", "a"], ["a", "e"], ["a", "a"], ["x", "x"] ]. 

假设:输入总是有效的,除法运算中不会出现除数为0的情况,且不存在任何矛盾的结果。

解题思路:

先构造图,使用dict实现,其天然的hash可以在in判断时做到O(1)复杂度。 对每个equation如"a/b=v"构造a到b的带权v的有向边和b到a的带权1/v的有向边, 之后对每个query,只需要进行dfs并将路径上的边权重叠乘就是结果了,如果路径不可达则结果为-1。

代码实现:

def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
        # 构造图,equations的第一项除以第二项等于value里的对应值,第二项除以第一项等于其倒数
        graph = {}
        for (x, y), v in zip(equations, values):
            if x in graph:
                graph[x][y] = v
            else:
                graph[x] = {y: v}
            if y in graph:
                graph[y][x] = 1/v
            else:
                graph[y] = {x: 1/v}

        # dfs找寻从s到t的路径并返回结果叠乘后的边权重即结果
        def dfs(s, t) -> int:
            if s not in graph:
                return -1
            if t == s:
                return 1
            for node in graph[s].keys():
                if node == t:
                    return graph[s][node]
                elif node not in visited:
                    visited.add(node)  # 添加到已访问避免重复遍历
                    v = dfs(node, t)
                    if v != -1:
                        return graph[s][node]*v
            return -1

        # 逐个计算query的值
        res = []
        for qs, qt in queries:
            visited = set()
            res.append(dfs(qs, qt))
        return res

5.最小高度树

题目描述(力扣310):

对于一个具有树特征的无向图,我们可选择任何一个节点作为根。图因此可以成为树,在所有可能的树中,具有最小高度的树被称为最小高度树。给出这样的一个图,写出一个函数找到所有的最小高度树并返回他们的根节点。

格式:

该图包含 n 个节点,标记为 0n - 1。给定数字 n 和一个无向边 edges 列表(每一个边都是一对标签)。

你可以假设没有重复的边会出现在 edges 中。由于所有的边都是无向边, [0, 1][1, 0]是相同的,因此不会同时出现在 edges 里。

示例 1:

输入: n = 4, edges = [[1, 0], [1, 2], [1, 3]]

     0
     |
     1
    / 
   2   3 

输出: [1]

示例 2:

输入: n = 6, edges = [[0, 3], [1, 3], [2, 3], [4, 3], [5, 4]]

  0  1  2
    | /
     3
     |
     4
     |
     5 

输出: [3, 4]

解题思路:

分析:在无向图中,最多只有两个根节点符合此题的要求,并且符合要求的节点必定不是叶节点。

  0  1  2
    | /
     3
     |
     4
     |
     5 

思路:设立一个点集,保存当前图中度为1的点,即树的叶子结点。然后将这些结点从图中删去,此时,有可能会生成一些新的叶子结点,那么再将这些新的叶子结点加入点集中。不断重复这个过程,直到图中的剩下的点不超过3个。为什么是3个呢?举个例子,假设一个图有两个点,用一条边连起来,那么返回的结果就是这两个点。但如果图中有三个点,用两条边连起来,那么返回的结果就是中间的那一个点。

总结:每次迭代将图中的叶子结点删掉,更新与该叶子结点相连的父节点的度,直到剩下叶子结点数<=2为止。

代码实现:

class Solution:
    def findMinHeightTrees(self, n: int, edges: List[List[int]]) -> List[int]:
        # 初始判断
        if not edges:
            return [] if n == 0 else [0]
        # 构建图
        graph = {}
        for v1, v2 in edges:
            graph[v1] = graph.get(v1, []) + [v2]
            graph[v2] = graph.get(v2, []) + [v1]
        # 逐层删除叶子结点
        while len(graph) > 2:
            leaf = [i for i in graph if len(graph[i])== 1]
            parent = [graph[i][0] for i in leaf]
            for i, j in zip(parent, leaf):
                graph[i].remove(j)
            for i in leaf:
                del graph[i]
        return list(graph.keys())

6.重新安排行程

题目描述(力扣332):

给定一个机票的字符串二维数组 [from, to],子数组中的两个成员分别表示飞机出发和降落的机场地点,对该行程进行重新规划排序。所有这些机票都属于一个从JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 出发。

说明:

  1. 如果存在多种有效的行程,你可以按字符自然排序返回最小的行程组合。例如,行程 ["JFK", "LGA"] 与 ["JFK", "LGB"] 相比就更小,排序更靠前
  2. 所有的机场都用三个大写字母表示(机场代码)。
  3. 假定所有机票至少存在一种合理的行程。

示例 1:

输入: [["MUC", "LHR"], ["JFK", "MUC"], ["SFO", "SJC"], ["LHR", "SFO"]]
输出: ["JFK", "MUC", "LHR", "SFO", "SJC"]

示例 2:

输入: [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]
输出: ["JFK","ATL","JFK","SFO","ATL","SFO"]
解释: 另一种有效的行程是 ["JFK","SFO","ATL","JFK","ATL","SFO"]。但是它自然排序更大更靠后。

解题思路:

DFS+后序遍历。 首先,这个题有两个问题要解决,第一个就是要把所有路径一次走完,第二个是走的过程我还要先走编码最小的。也就是第一个问题深度优先遍历,第二个排序。 深度优先遍历好理解,就一直去找下一节点,没有了就返回,排序则是用优先队列来解决。 首先我们要把二维字符串数组保存到一个map里,代表一个 from—— [to1,to2 …] , 在保存from对应的to地点的时候,我们把它保存到优先队列里,自然排序小的在前面,这样,我们在dfs的时候,就可以通过poll来取到最小的啦。 图构建好了之后就是去dfs,按照题目要求从"JFK"开始,找下一个地点, 当发现某个from没有在map里或者某个from对应的优先队列为空,这就代表它没有了下一个节点,放到最后结果的list集合里(就是后续遍历)。 当from对应的队列长度>0,那么就依次去dfs队列里面的地点, 全部dfs完之后记得list.add上from,因为它终要回到这里。 DFS+后续遍历算法演示-> 参考:https://blog.csdn.net/fuxuemingzhu/article/details/83551204

代码实现:

class Solution(object):
    def findItinerary(self, tickets):
        """
        :type tickets: List[List[str]]
        :rtype: List[str]
        """
        graph = collections.defaultdict(list)
        for frm, to in tickets:
            graph[frm].append(to)
        for frm, tos in graph.items():
            tos.sort()
        res = []
        self.dfs(graph, "JFK", res)
        return res[::-1]

    def dfs(self, graph, source, res):
        while graph[source]:
            v = graph[source].pop(0)
            self.dfs(graph, v, res)
        res.append(source)

7. 冗余连接

题目描述(力扣684):

在本问题中, 树指的是一个连通且无环的无向图。

输入一个图,该图由一个有着N个节点 (节点值不重复1, 2, …, N) 的树及一条附加的边构成。附加的边的两个顶点包含在1到N中间,这条附加的边不属于树中已存在的边。

结果图是一个以组成的二维数组。每一个的元素是一对[u, v] ,满足 u < v,表示连接顶点uv无向图的边。

返回一条可以删去的边,使得结果图是一个有着N个节点的树。如果有多个答案,则返回二维数组中最后出现的边。答案边 [u, v] 应满足相同的格式 u < v

示例 1:

输入: [[1,2], [1,3], [2,3]]
输出: [2,3]
解释: 给定的无向图为:
1
/ 
2 - 3

示例 2:

输入: [[1,2], [2,3], [3,4], [1,4], [1,5]]
输出: [1,4]
解释: 给定的无向图为:
5 - 1 - 2
 |   |
 4 - 3

解题思路:

本题可以理解为:对每个边进行遍历,如果构成该边的两个节点在图中已经连通,再添加边则必成环,所有这条边就是我们所要的结果。因此,问题转化为:判断无向图中的两个节点是否连通,不需要返回具体连通路径。

判断图中两节点是否连通的问题,一般我们会首选并查集算法

该算法将所有节点以整数表示,编号为0~N-1。在处理输入的edge<i,j>之前,每个节点必然都是孤立的,即他们分属于不同的组,可以使用数组来表示这一层关系,数组的index是节点的整数表示,而相应的值就是该节点的组号。

然后有两个操作函数:

find : 查找每个结点node的组号:

def find(node):
    # 不断向上层查找根结点,根结点特点:结点索引和索引值相同
    while node != parent[node]:
        node = parent[node]
    return node

union: 合并两个结点为一组:

def union(node1, node2):
    root1 = find(node1)
    root2 = find(node2)
    # 两个结点属于同一组,即已经连通
    if root1 == root2:
        return
    else:
        # 将第一组划入第二组中,只需改根结点的组号即可
        parent[root1] = root2

因此,本题的思路就很清晰了:遍历每个边,union连接边的两个结点,一旦发现两个结点属于一个组,即已连通,该边即为冗余边。

注意:并查集还是不懂的同学可以参考后文的参考链接,看了之后非常好懂hahaha~

代码实现

(1) java实现:

public int[] findRedundantConnection(int[][] edges) {
        int[] parent = new int[edges.length + 1];
        for (int i = 0; i < edges.length + 1; i++) {
            parent[i] = i;
        }
        int[] res = null;

        for (int[] is : edges) {
            int x = is[0];
            int y = is[1];

            while (x != parent[x]) {
                x = parent[x];
            }
            while (y != parent[y]) {
                y = parent[y];
            }

            if (x == y) {
                res = is;
            } else {
                parent[x] = y;
            }

        }

        return res;
    }

(2) python实现 - 查、并功能分离,并且添加路径压缩树平衡

class Solution:
    def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
        # 存储每个结点的父节点
        parent = list(range(len(edges) + 1))
        # 存储根结点所代表树的尺寸(结点个数)
        size = [1] * (len(edges) + 1)

        # 查找根结点
        def find(node):
            while node != parent[node]:
                # 路径压缩:parent[node]更新为其爷爷结点
                parent[node] = parent[parent[node]]
                node = parent[node]
            return node

        # 连接两个结点
        def union(node1, node2):
            root1 = find(node1)
            root2 = find(node2)
            if root1 == root2:
                return False
            else:
                # 尺寸小的树向尺寸大的树合并。树越平衡,find时复杂度越低。
                size1 = size[root1]
                size2 = size[root2]
                if size1 < size2:
                    parent[root1] = root2
                else:
                    parent[root2] = root1
                return True

        for edge in edges:
            if not union(edge[0], edge[1]):
                return edge

小结: (1) 给出两个节点,判断它们是否连通,如果连通,不需要给出具体的路径:并查集算法 (2) 给出两个节点,判断它们是否连通,如果连通,需要给出具体的路径:BFS或DFS算法


参考链接: 1. 并查集参考1:https://blog.csdn.net/qq_41593380/article/details/81146850 2. 并查集参考2:https://blog.csdn.net/dm_vincent/article/details/7655764 3. 图遍历:https://blog.csdn.net/luoshixian099/article/details/51897538 4. 最小生成树(Kruskal和Prim算法):https://blog.csdn.net/luoshixian099/article/details/51908175 5. 单源最短路径问题(Dijkstra算法):https://blog.csdn.net/luoshixian099/article/details/51918844