手撕-图论-并查集与邻接表

29 篇文章 0 订阅
本文介绍了如何使用并查集和拓扑排序解决图相关问题,包括寻找冗余连接、判断图中环路以及课程表的安排。文章通过Java代码示例展示了并查集的实现,如并查集的初始化、查找和合并操作,并探讨了邻接表的构建和使用,如ArrayList和HashMap的组合应用。此外,还讲解了如何利用DFS和宽搜判断图的连通性和腐烂橘子的传播问题。
摘要由CSDN通过智能技术生成

大厂手撕题目不乏图相关的问题,如何对图有一个很好的认识,以及很好的表示方法是很关键的。

今天正好做了几道题,分享一下代码模板和求解思路,方便回顾与发散思维。

这些题目的主要求解方式为【拓扑排序,并查集,邻接表结合dfs】,其他内容主要为数据结构中的图结构的表示。


冗余连接

并查集求解:

class Solution {
    int[] parent;
    public int[] findRedundantConnection(int[][] edges) {
         // 并查集问题
        int n = edges.length;

        parent = new int[1005 + 1];
        for(int i=0;i<1005;i++){
            parent[i] = i; // 初始化并查集
        }

        for(int i=0;i<edges.length;i++){
            int a = edges[i][0];
            int b = edges[i][1];

            // 判断这两个点是否已经被加入一个并查集(一棵树)
            if(find(a) == find(b))return edges[i];
            else insert(a, b);
        }

        return null;
    }
    public int find(int a){
        if(a == parent[a])return a;
        parent[a] = find(parent[a]);
        return parent[a];
    }
    public void insert(int a, int b){
        if(find(a) == find(b))return;
        parent[find(b)] = a;
    }
}

对于并查集的学习,可以看acwing的并查集专项AcWing 836. 合并集合 - AcWing ,我个人觉得很明白。

不过并查集有很多扩展,基础的并查集来说主要包含几个结构

int[] parent; // 父节点数组
int find(int u);  // 查找u的根是谁,集合归属判别依据
void insert(int u, int v);  // 合并方法

加边法结合宽搜:

我个人是更倾向于参考资料里的【加边法】判别

// 加边法 + bfs
class Solution {
    public int[] findRedundantConnection(int[][] edges) {
        int n = edges.length;
        boolean[] visited = new boolean[n];
        List<List<Integer>> graph = new ArrayList<>();
        for(int i = 0; i < n; ++i) graph.add(new ArrayList<>());
        for(int[] edge : edges){
            int u = edge[0] - 1, v = edge[1] - 1;
            if(bfs(u, v, visited, graph)) { // 通过 bfs 判断 u, v 是否连通
                return new int[]{u + 1, v + 1};
            }
            graph.get(u).add(v); // 加边 (u, v)
            graph.get(v).add(u); // 加边 (v, u)
            Arrays.fill(visited, false);
        }
        return null; // 本题保证必有答案
    }
    public boolean bfs(int u, int v, boolean[] visited, List<List<Integer>> graph){
        Queue<Integer> q = new ArrayDeque<>();
        q.add(u);
        while(!q.isEmpty()){
            u = q.remove();
            visited[u] = true;
            for(int w : graph.get(u)){
                if(w == v) return true; // 判明 u, v 连通立即返回 true
                if(!visited[w]) q.add(w);
            }
        }
        return false;
    }
}
/**
作者:yukiyama
链接:https://leetcode.cn/circle/discuss/FyPTTM/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
**/

因为这种思路最直观,最易于理解:

构造邻接表,每添加一条边,我们就判断当前建成的图是否有环,某一次判断要插入的边已经存在,则可找到目标解.

省份数量

并查集求解:

很多题目都是并查集思路求解,但是中间过程需要做一些适配,比如只要遇到两个城市是相连的,我们就约定以靠前的城市为根,将后者的根像靠前的根看齐。

class Solution {
    int[] parent;
    public int findCircleNum(int[][] isConnected) {
        //一道并查集的经典问题
        // 我们尝试运用并查集的思想去求解问题
        if(isConnected == null ||isConnected[0] == null || isConnected.length < 1)return 0; //没有城市是相连的

        int n = isConnected.length;
        // 总共有n个城市
        parent = new int[n];
        
        // 初始化并查集
        for(int i=0;i<n;i++){
            parent[i] = i;
        }
        Set<Integer> set = new HashSet<>();

        // 我们最后看parent数组有几个不同的数即可。
        //开始遍历图,以合并城市的连接性
        for(int i=0;i<n;i++){
            for(int j =0;j<n;j++){
                if(isConnected[i][j] == 1){
                    // 确认i和j两个身份的连接关系
                    insert(i, j);
                }
            }
        }
        for(int i=0;i<n;i++){
            // System.out.println("i: " + i + " parent[i]: "+parent[i]);
            set.add(find(i));
        }
        return set.size();
    }

    public void insert(int u, int v){
        if(find(u)==find(v))return;

        parent[find(v)] = find(u);
    }

    public int find(int u){
        if(u == parent[u])return u;
        parent[u] = find(parent[u]);
        return parent[u];
    }
}

课程表

逆邻接表结合DFS求解:

class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // 我们构建一个逆连接表,判断每门课的一个前置课程是否有自己即可。

        int len = prerequisites.length;

        if(len == 0)return true; //肯定能完成修读

        int[] vis = new int[numCourses];

        // 我们需要构建一个逆邻接表,谁需要先上,我们就让他先上
        Map<Integer, List<Integer>> map = new HashMap<>();

        for(int[] pre:prerequisites){
            if(!map.containsKey(pre[1])){
                List<Integer> ints = new ArrayList<>();
                ints.add(pre[0]);
                map.put(pre[1], ints);
            }else{
                List<Integer> lst = map.get(pre[1]);
                lst.add(pre[0]);
                map.put(pre[1], new ArrayList<Integer>(lst));
            }
        }

        // 遍历这个逆邻接表
        for(int i=0;i<numCourses; i++){
            // 如果遇到环路
            if(dfs(i, map, vis))return false; 
        }

        // 没有遇到环路,成功到达终点
        return true;
    }

    public boolean dfs(int i, Map<Integer, List<Integer>> map, int[] vis){
        // 判断这个点是否有环路
        // 我们可以定义vis的属性
        // vis = 2 代表正在访问
        // vis = 1 表示已经可以上课
        // vis = 0表示没访问过
        if(vis[i] == 2)return true; //本来已经正在访问了,又访问一边

        if(vis[i] == 1)return false; // 没环路依赖,可以安全访问

        // 开始访问
        vis[i] = 2;
        
        // 看i课有没有后续课程
        if(map.containsKey(i)){
            List<Integer> lst = map.get(i);
            // 我们开始上i的后置课程
            for(int j=0;j<lst.size(); j++){
                if(dfs(lst.get(j), map, vis))return true;
            }
        }
        // 访问完毕
        vis[i] = 1;
        return false;
    }
}

对于Java语言邻接表构建的方案,很灵活。介绍一些参考:

在Java中实现邻接表有多种数据结构可供选择,以下是其中几种常用的数据结构及其优势:

        1. Map + List

这种实现方式使用Map存储节点与其对应的边列表,其中边列表可以使用List、Set等数据结构存储。该实现方式的优势在于方便快速地查找某个节点所对应的边列表,同时也支持添加和删除节点以及更新节点属性。

        2. ArrayList + LinkedList

这种实现方式使用ArrayList存储节点数组,每个节点通过一个LinkedList来表示它的邻居节点。该实现方式的优势在于可以快速地遍历整个邻接表,同时也支持添加和删除节点以及更新节点属性。        

        3. HashMap + HashSet

这种实现方式使用HashMap存储节点与其对应的邻居节点集合,其中邻居节点集合可以使用HashSet、TreeSet等数据结构存储。该实现方式的优势在于能够快速地定位某个节点,并且可以快速地进行邻居节点的添加和删除操作。

        4. 自定义数据结构

拓扑排序求解:

拓扑排序的核心即找入度为0的结点,以及在算法执行过程中把结点的入度不断-1以及判断。

class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
            // 拓扑排序
            int len = prerequisites.length;
            if(len == 0)return true;

            int[] in_degree = new int[numCourses];

            Map<Integer, List<Integer>> map = new HashMap<>(); //构建邻接表
            // 邻接表存放的是后继节点的集合。

            for(int[] pre : prerequisites){
                in_degree[pre[0]] ++;

                if(!map.containsKey(pre[1])){
                    List<Integer> lst = new ArrayList<>();
                    lst.add(pre[0]);
                    map.put(pre[1], lst);
                }else{
                    // 邻接表项已经存在
                    List<Integer> lst = map.get(pre[1]);
                    lst.add(pre[0]);
                    map.put(pre[1], lst);
                }
            }

            // 首先遍历一遍节点,入度为0的加入队列

            Queue<Integer> q = new LinkedList<>();
            for(int i=0;i<numCourses;i++){
                if(in_degree[i]==0)q.offer(i);
            }
            // 我们记录入度能清为0,或者本来就为0的课程
            int counter = 0;

            while(!q.isEmpty()){
                int top = q.poll();

                counter++;

                if(map.containsKey(top)){
                    List<Integer> lst = map.get(top);
                    // 把后续课程的入度--
                    for(int i=0;i<lst.size();i++){
                        in_degree[lst.get(i)]--;
                        if(in_degree[lst.get(i)] == 0){
                            q.offer(lst.get(i));
                        }
                    }
                }
            }

            return counter == numCourses;
    }
}

腐烂的橘子 

宽搜标准解法,中间变量来进行进度跟进:

class Solution {
    int round = 0; //回合数
    int[] dx = new int[]{-1, 0, 1, 0};
    int[] dy = new int[]{0, 1, 0, -1};
    public int orangesRotting(int[][] grid) {
        // 每一汇合我们都以腐烂的橘子为起点向外扩散。
        if(grid == null || grid[0] == null)return -1;  //没有橘子,不会腐烂
        // 我们引入队列,队列中的元素为腐烂元素的下标,腐烂橘子传递过一次之后不能再被使用。
        // 我们定义,使用过的腐烂橘子将变为3, 而0就保留为空白
        Queue<int[]> q = new LinkedList<>();

        int m = grid.length;
        int n = grid[0].length;
        int org_num = 0; // 所有橘子综述
        int bad_num = 0;
        // 每个节点只需要操作一次即可。
        int[][] vis = new int[m][n];
        // 我们还需要计算一个联通分量, 或者可以限制轮次
        for(int i=0;i<m;i++){
            for(int j=0;j<n;j++){
                // 遍历一次数组需要完成几个任务
                // 将腐烂橘子的坐标加入队列
                // 判断每个是1橘子的地方是否和其他橘子联通, 只要有一个1橘子不是联通的,则不肯满足题意
                // 统计总橘子的个数
                if(grid[i][j] == 0)continue;
                else if(grid[i][j] == 2){
                    vis[i][j] = 1;//首次访问

                    q.offer(new int[]{i,j});
                    org_num ++;
                    bad_num ++;
                }else{
                    // System.out.println("we meet a grid == 1");
                    // System.out.println("i: " + i +" j:" + j);
                    org_num ++;
                    int has_org = 0;

                    // 这一个剪枝不是最完美的,比如这个样例 
                    // [[2],[2],[1],[0],[1],[1]]
                    // 个人理解如果想规避这种样例,需要引入类似岛屿数量的解题过程。
                    for(int k = 0;k<4;k++){
                        int x = i + dx[k];
                        int y = j + dy[k];
                        // System.out.println("x: " + x +" y:" + y);

                        if(x >=0 && x < m && y >= 0 && y < n){
                            // i,j对应的位置时一个正常的橘子
                            // x,y 对应的是其四个正方形的位置
                            // 如果x,y都没有橘子,那就不行
                            // System.out.println(grid[x][y]);
                            if(grid[x][y]>0){
                                has_org++;   
                            }
                        }
                    }
                    if(has_org == 0)return -1; 

                    // System.out.println("it's not alone.");
                }
            }
        }
        // 所有的腐烂橘子都在队列里,和总橘子数目相等
        // System.out.println("here");
        // 全是新鲜橘子,或者一个橘子也没有
        if(org_num == 0 || q.size() == org_num)return 0; 
        if(q.size() == 0 )return -1;// 没有腐烂的



        // 迭代正式开始,我们每一次操作队列都要清空当前代的元素,可以类比二叉树的层次遍历
        while(!q.isEmpty()){
            // 先把当前代需要处理的结点提取出来。
            int size = q.size(); 
            ++round;
            // 这一轮新污染的橘子
            int bad_new = 0;
            for(int i=0;i<size;i++){
                int[] tmp = q.poll();
                int x = tmp[0];
                int y = tmp[1];

                grid[x][y] = 3; //腐烂,且已经用过
                // 腐烂橘子向四周进行扩展
                for(int k = 0;k<4;k++){
                    int nx = x + dx[k];
                    int ny = y + dy[k];

                    if(nx>=0 && nx < m && ny >=0 && ny < n){
                        if(vis[nx][ny] == 0 && grid[nx][ny] == 1){
                            vis[nx][ny] = 1;
                            q.offer(new int[]{nx, ny});
                            bad_new++;
                            // bad_num ++;
                        }
                    }
                }
            }
            bad_num += bad_new;

            // 这一轮腐败扩散已经结束,我们先判别是否全部已经腐烂
            if(bad_num == org_num)return round;

        }

        // 每一回合结束,我们需要判断每个不为0的位置是否为2,如果已经为2,则终止判断,输出已经走过的回合数
        // 如果没有提前返回,到此说明 bad_num != org_num, 有橘子没有腐烂
        return -1;
    }
}

参考资料:

图论算法从入门到放下 - 力扣(LeetCode)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值