Leetcode刷题之图

435 阅读5分钟

785. 判断二分图(Medium)

给定一个无向图graph,当这个图为二分图时返回true。

如果我们能将一个图的节点集合分割成两个独立的子集A和B,并使图中的每一条边的两个节点一个来自A集合,一个来自B集合,我们就将这个图称为二分图。

graph将会以邻接表方式给出,graph[i]表示图中与节点i相连的所有节点。每个节点都是一个在0到graph.length-1之间的整数。这图中没有自环和平行边: graph[i] 中不存在i,并且graph[i]中没有重复的值。

示例 1:

输入: [[1,3], [0,2], [1,3], [0,2]]
输出: true
解释: 
无向图如下:
0----1
|    |
|    |
3----2
我们可以将节点分成两组: {0, 2} 和 {1, 3}。

示例 2:

输入: [[1,2,3], [0,2], [0,1,3], [0,2]]
输出: false
解释: 
无向图如下:
0----1
| \  |
|  \ |
3----2
我们不能将节点分割成两个独立的子集。

注意:

  • graph 的长度范围为 [1, 100]。
  • graph[i] 中的元素的范围为 [0, graph.length - 1]。
  • graph[i] 不会包含 i 或者有重复的值。
  • 图是无向的: 如果j 在 graph[i]里边, 那么 i 也会在 graph[j]里边。
bfs染色法

从某个节点开始,将这个节点染成白色,然后遍历这个节点的所有相邻节点,如果是二分图,相邻节点的颜色都应该不同。

  • 如果相邻节点无色,则染成黑色
  • 如果相邻节点是黑色,那么该图不是二分图
  • 如果相邻节点为白色,不变 当遍历完所有节点的时候,所有相邻节点都是不同颜色,则该图为二分图
public boolean isBipartite(int[][] graph) {
    // 0表示未访问,1或者-1表示两种不同的颜色
    int[] visited = new int[graph.length];
    Queue<Integer> queue = new LinkedList<>();

    // 图中可能存在多个连通域,所以需要判断是否存在顶点未被访问,如果存在就进行一轮 BFS 染色
    for (int i = 0; i < graph.length; i++) {
        if (visited[i] != 0) {
            continue;
        }

        queue.offer(i);
        visited[i] = 1;
        // 出队一个节点,就将其所有相连节点染成相反的颜色,并入队
        while (!queue.isEmpty()) {
            Integer v = queue.poll();
            for (int w : graph[v]) {
                // 如果当前顶点的某个相连节点已染过色,且和顶点颜色相同,说明不是二分图
                if (visited[v] == visited[w]) {
                    return false;
                }
                if (visited[w] == 0) {
                    visited[w] = -visited[v];
                    queue.offer(w);
                }
            }
        }
    }
    return true;
}

207. 课程表(Medium)

你这个学期必须选修 numCourse 门课程,记为 0 到 numCourse-1

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

给定课程总量以及它们的先决条件,请你判断是否可能完成所有课程的学习?

示例 1:

输入: 2, [[1,0]] 
输出: true
解释: 总共有 2 门课程。学习课程 1 之前,你需要完成课程 0。所以这是可能的。

示例 2:

输入: 2, [[1,0],[0,1]]
输出: false
解释: 总共有 2 门课程。学习课程 1 之前,你需要先完成​课程 0;并且学习课程 0 之前,你还应先完成课程 1。这是不可能的。

提示:

  • 输入的先决条件是由 边缘列表 表示的图形,而不是 邻接矩阵 。详情请参见图的表示法。
  • 你可以假定输入的先决条件中没有重复的边。
  • 1 <= numCourses <= 10^5
深度优先遍历

当一个课程a是另外一个课程b的学习前提,课程b又是课程a的学习前提的时候,是不可能完成所有的课程学习的,所以关键是判断是否为有向无环图。具体思路如下:

  1. 构建一个数组flags记录课程的状态:
    1. 未被 DFS 访问:i == 0
    2. 已被其他节点启动的 DFS 访问:i == -1
    3. 已被当前节点启动的 DFS 访问:i == 1
  2. 对所有的课程进行 DFS,判断每个课程起点的 DFS 是否有环:
    1. 终止条件:
      • 当标志为 1 的时候,说明当前课程在本轮 DFS 被第二次访问,有环
      • 当标志为 -1,说明当前课程在其他轮 DFS 访问过,不存在环,直接返回 true
    2. 把当前课程的标志设为 1,标志被本轮 DFS 访问过
    3. 递归访问当前节点 i 的所有邻接节点 j,当发现环直接返回 false
    4. 当前节点的所有节点都被遍历,没有发现环,将当前节点标志设为 1 并返回 true
  3. 整个课程图没有环,返回 true
public boolean canFinish(int numCourses, int[][] prerequisites) {
    if (numCourses <= 0) {
        return false;
    }
    int plen = prerequisites.length;
    if (plen == 0) {
        return true;
    }

    // 初始化有向图
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < numCourses; i++) {
        graph.add(new ArrayList<>());
    }
    int[] flags = new int[numCourses];
    // 有向图的key前驱节点,value是后继节点的结合
    for (int[] cp : prerequisites) {
        graph.get(cp[1]).add(cp[0]);
    }

    for (int i = 0; i < numCourses; i++) {
        if (dfs(i, graph, flags)) {
            return false;
        }
    }
    return true;
}

/**
 *
 * @param i 当前访问的课程结点
 * @param graph
 * @param flags 如果 == 1 表示正在访问中,== -1表示已经访问过了
 * @return true表示有环
 */
private boolean dfs(int i, List<List<Integer>> graph, int[] flags) {
    if (flags[i] == 1) {
        return true;
    }

    if (flags[i] == -1) {
        return false;
    }

    // 表示正在访问中
    flags[i] = 1;
    // 寻找后继节点是否有环
    for (Integer j : graph.get(i)) {
        if (dfs(j, graph, flags)) {
            return false;
        }
    }
    // i 的所有后继结点都访问完了,都没有存在环,则这个结点就可以被标记为已经访问结束
    flags[i] = -1;
    return false;
}
拓扑排序

拓扑排序实际上应用的是贪心算法,每一步最优,则最后的结果就是最优的。

具体到拓扑排序就是每一步都删除入度为 0 的节点,依次得到的结点序列就是拓扑排序的结点序列。如果最后还剩下有节点,就说明不能完成全部课程的学习。

流程:

  1. 开始排序前,扫描数组,填充邻接表和入度数组,将入度为 0 的节点加入到队列(为什么使用队列)

如果不使用队列,要想得到当前入度为 00 的结点,就得遍历一遍入度数组。使用队列即用空间换时间。

  1. 只要队列非空,就从队列取出节点,将这个节点输出到结果集中,并将这个节点的所有相邻节点入度减 1,如果相邻节点减 1 之后入度为0,则加入队列
  2. 队列为空的时候,检查结果集的数量和节点个数是否相等
public boolean canFinish(int numCourses, int[][] prerequisites) {
    if (numCourses <= 0) {
        return false;
    }
    int plen = prerequisites.length;
    if (plen == 0) {
        return true;
    }

    int[] inDegree = new int[numCourses];
    HashSet<Integer>[] adj = new HashSet[numCourses];
    for (int i = 0; i < numCourses; i++) {
        adj[i] = new HashSet<>();
    }

    // 填充邻接表和入度数组
    for (int[] p : prerequisites) {
        inDegree[p[0]]++;
        adj[p[1]].add(p[0]);
    }

    Queue<Integer> queue = new LinkedList<>();

    // 首先加入入度为0的节点
    for (int i = 0; i < numCourses; i++) {
        if (inDegree[i] == 0) {
            queue.add(i);
        }
    }

    // 记录已经出队的数量
    int cnt = 0;
    while (!queue.isEmpty()) {
        Integer top = queue.poll();
        cnt += 1;
        // 遍历出队的所有后继节点
        for (int successor : adj[top]) {
            inDegree[successor]--;
            if (inDegree[successor] == 0) {
                queue.add(successor);
            }
        }
    }
    return cnt == numCourses;
}

210. 课程表 II(Medium)

现在你总共有 n 门课需要选,记为 0 到 n-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]

说明:

  • 输入的先决条件是由边缘列表表示的图形,而不是邻接矩阵。详情请参见图的表示法。
  • 你可以假定输入的先决条件中没有重复的边。
拓扑排序

根据拓扑排序的应用:一般用来“排序”具有依赖关系的任务可知,本题适合使用拓扑排序解决,拓扑排序的详细解法见上题的BFS拓扑排序

public int[] findOrder(int numCourses, int[][] prerequisites) {
    if (numCourses <= 0) {
        return new int[0];
    }

    // index: 节点; value:与节点相连的后继节点
    HashSet<Integer>[] adj = new HashSet[numCourses];
    for (int i = 0; i < numCourses; i++) {
        adj[i] = new HashSet<>();
    }

    // [1,0] 0->1
    // index:节点; value:入度
    int[] inDegree = new int[numCourses];
    for (int[] p : prerequisites) {
        adj[p[1]].add(p[0]);
        inDegree[p[0]]++;
    }

    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < numCourses; i++) {
        if (inDegree[i] == 0) {
            queue.offer(i);
        }
    }

    int[] res = new int[numCourses];
    // 当前结果集列表里的元素个数,正好可以作为下标
    int count = 0;

    while (!queue.isEmpty()) {
        Integer top = queue.poll();
        res[count++] = top;

        // 将所有相连的节点入度减1,减完之后的节点入度如果为0,加入队列
        Set<Integer> successors = adj[top];
        for (Integer nextCourse : successors) {
            inDegree[nextCourse]--;
            if (inDegree[nextCourse] == 0) {
                queue.offer(nextCourse);
            }
        }
    }

    if (count == numCourses) {
        return res;
    }
    return new int[0];
}

684. 冗余连接(Medium)

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

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

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

返回一条可以删去的边,使得结果图是一个有着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

注意:

  • 输入的二维数组大小在 3 到 1000。
  • 二维数组中的整数在1到N之间,其中N是输入数组的大小。
并查集

并查集的主要用于处理连接问题,判断两点之间有无连接。对于无环图来说,每合并一个点上去,都是不同的根在合并,而一旦出现合并的两个点具有同一个根时,说明他俩早已在同一条路径中,故而他两肯定形成环路。所以只需要遍历边,将所有的点合并,返回最后拥有相同根的两个点即可。

int[] result = new int[2];

public int[] findRedundantConnection(int[][] edges) {
    // index: 当前节点   vector:当前节点所在集合的代表节点
    int[] father = new int[edges.length + 1];
    // 初始化每个节点的代表节点为自身
    for (int i = 0; i < father.length; i++) {
        father[i] = i;
    }
    for (int[] edge : edges) {
        union(father, edge[0], edge[1]);
    }
    return result;
}

// 路径压缩,即找到 x 的根节点
private int findXFather(int[] father, int x) {
    while (father[x] != x) {
        father[x] = father[father[x]];
        x = father[x];
    }
    return x;
}

// 合并两个能连接上的点,father合并为最后确定的father
private void union(int[] father, int x, int y) {
    // 找到两节点的根节点
    int xFather = findXFather(father, x);
    int yFather = findXFather(father, y);
    // 两节点所在集合的代表节点相同表示出现了环,更新结果集
    if (xFather == yFather) {
        result[0] = x;
        result[1] = y;
    } else {
        father[xFather] = yFather;
    }
}