day52 代码随想录算法训练营 图论专题6

1 今日打卡

多余的边 108. 多余的边

多余的边Ⅱ 109. 多余的边II

2 多余的边

2.1 思路

读取边的总数n;

初始化并查集(容量n+1,适配顶点编号从 1 开始的场景);

逐个读取每条边的两个顶点:

若顶点已连通 → 这条边是「环的关键边」,输出并退出;

若未连通 → 合并两个顶点的集合,继续处理下一条边。

2.2 实现代码

java 复制代码
import java.util.*;

/**
 * 主类:利用并查集(Disjoint Set Union)查找无向图中首次出现的环边
 * 核心逻辑:遍历每条边,若边的两个顶点已连通(属于同一集合),则该边会形成环,输出并终止程序
 */
public class Main {

    /**
     * 并查集(Disjoint Set)实现类
     * 功能:管理元素的分组,支持查找根节点、合并集合、判断是否同属一个集合
     */
    static class disJoint {
        // 父节点数组:father[i] 表示节点i的父节点,用于维护集合关系
        private int[] father;

        /**
         * 并查集初始化
         * @param n 节点总数(数组容量)
         */
        public disJoint(int n) {
            father = new int[n];
            // 初始状态:每个节点的父节点是自己,即每个节点自成一个独立集合
            for (int i = 0; i < n; i++) {
                father[i] = i;
            }
        }

        /**
         * 查找节点a的根节点(带路径压缩优化)
         * 路径压缩:将查找路径上的所有节点直接指向根节点,降低后续查找的时间复杂度
         * @param a 要查找的节点
         * @return 节点a的根节点
         */
        public int find(int a) {
            // 递归终止条件:节点a的父节点是自己(a就是根节点)
            if (a == father[a]) {
                return a;
            }
            // 路径压缩:将a的父节点更新为根节点
            father[a] = find(father[a]);
            // 返回根节点
            return father[a];
        }

        /**
         * 合并两个节点a和b所在的集合
         * @param a 节点a
         * @param b 节点b
         */
        public void join(int a, int b) {
            // 先找到a和b的根节点(只有根节点能代表集合)
            int rootA = find(a);
            int rootB = find(b);
            // 如果根节点相同,说明已在同一集合,无需合并
            if (rootA == rootB) {
                return;
            }
            // 合并:将b集合的根节点挂到a集合的根节点下(也可反过来,不影响核心逻辑)
            father[rootB] = rootA;
        }

        /**
         * 判断两个节点是否属于同一个集合(是否连通)
         * @param a 节点a
         * @param b 节点b
         * @return true=同集合/连通,false=不同集合/不连通
         */
        public boolean isSame(int a, int b) {
            // 根节点相同则属于同一集合
            return find(a) == find(b);
        }
    }

    /**
     * 主方法:程序入口,处理输入、调用并查集、检测环边
     * @param args 命令行参数(无实际使用)
     */
    public static void main(String[] args) {
        // 创建Scanner对象,用于读取控制台输入
        Scanner sc = new Scanner(System.in);
        // 读取边的总数n(后续会遍历n条边)
        int n = sc.nextInt();
        // 初始化并查集:节点编号通常从1开始,所以容量设为n+1(避免下标越界)
        disJoint dj = new disJoint(n + 1);

        // 遍历每一条边,逐个检测是否形成环
        for (int i = 0; i < n; i++) {
            // 读取当前边的两个顶点:from(起点)、des(终点)
            int from = sc.nextInt();
            int des = sc.nextInt();
            // 检测:两个顶点是否已连通(属于同一集合)
            if (dj.isSame(from, des)) {
                // 已连通 → 这条边会形成环,输出该边并终止程序
                System.out.println(from + " " + des);
                return;
            } else {
                // 未连通 → 合并两个顶点所在的集合,继续处理下一条边
                dj.join(from, des);
            }
        }

        // 关闭Scanner,释放资源
        sc.close();
    }
}

3 多余的边Ⅱ

3.1 思路

核心问题是:给定一个有 n 个节点、n 条边的有向图(本应是一棵有根树 + 1 条冗余边),找出这条冗余边,删除后图能恢复为合法的有根树。

关键背景:有根树的合法条件

每个节点入度为 1(除根节点入度为 0);

无环;

所有节点连通。

冗余边的两种核心场景(可能叠加)

场景 1:存在入度为 2 的节点 → 冗余边一定是指向该节点的两条边之一;

场景 2:所有节点入度为 1,但图成环 → 冗余边是环中的任意一条(和无向图冗余连接逻辑一致)。

解题核心步骤

遍历所有边,统计每个节点的入度,找到入度为 2 的节点(记为 doubleIn);

若存在入度为 2 的节点:

收集指向该节点的两条候选边;

先尝试删除后出现的那条候选边,检查剩余边是否能构成合法树(无环 + 连通);

若删除后合法,这条边就是答案;否则删除另一条候选边;

若不存在入度为 2 的节点:

直接用并查集找环,环中最后出现的那条边就是答案(和无向图冗余连接逻辑一致)。

3.2 实现代码

java 复制代码
import java.util.*;

/**
 * 冗余连接II:解决有向图中冗余边问题(入度为2 或 成环)
 * 核心逻辑:
 * 1. 先找入度为2的节点,处理其两条候选边
 * 2. 若无入度为2的节点,直接找环中的边
 */
public class Main {

    /**
     * 并查集模板:用于判断连通性、检测环
     */
    static class Disjoint {
        // 父节点数组:father[i]表示节点i的父节点
        private final int[] father;

        // 初始化并查集:每个节点的父节点是自己
        public Disjoint(int n) {
            father = new int[n];
            for (int i = 0; i < n; i++) {
                father[i] = i;
            }
        }

        // 合并两个节点所在的集合
        public void join(int n, int m) {
            // 先找到两个节点的根节点
            int rootN = find(n);
            int rootM = find(m);
            // 根节点相同,无需合并
            if (rootN == rootM) return;
            // 合并:将n的根节点挂到m的根节点下(方向不影响核心逻辑)
            father[rootN] = rootM;
        }

        // 查找节点的根节点(带路径压缩,优化效率)
        public int find(int n) {
            // 递归终止:节点的父节点是自己(根节点)
            // 路径压缩:将当前节点直接指向根节点
            return father[n] == n ? n : (father[n] = find(father[n]));
        }

        // 判断两个节点是否属于同一集合(是否连通)
        public boolean isSame(int n, int m) {
            return find(n) == find(m);
        }
    }

    /**
     * 边的封装类:存储边的起点(s)和终点(t)
     */
    static class Edge {
        int s; // 起点
        int t; // 终点

        public Edge(int s, int t) {
            this.s = s;
            this.t = t;
        }
    }

    /**
     * 节点的封装类:存储节点id、入度(in)、出度(out)(出度本题未用到)
     */
    static class Node {
        int id;   // 节点编号
        int in;   // 入度
        int out;  // 出度
    }

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        // 输入边的数量(节点数 = 边数 = n)
        int n = scanner.nextInt();
        // 存储所有边
        List<Edge> edges = new ArrayList<>();
        // 节点映射数组:索引为节点编号(1~n),存储节点的入度/出度信息
        Node[] nodeMap = new Node[n + 1];
        // 初始化每个节点(节点编号从1开始)
        for (int i = 1; i <= n; i++) {
            nodeMap[i] = new Node();
            nodeMap[i].id = i; // 初始化节点id
        }

        // 记录入度为2的节点编号(初始为null,表示无)
        Integer doubleIn = null;
        // 遍历所有边,统计入度,找入度为2的节点
        for (int i = 0; i < n; i++) {
            int s = scanner.nextInt(); // 边的起点
            int t = scanner.nextInt(); // 边的终点
            // 终点t的入度+1
            nodeMap[t].in++;
            // 若t的入度≥2,标记为入度为2的节点
            if (nodeMap[t].in >= 2) {
                doubleIn = t;
            }
            // 将当前边加入边列表
            edges.add(new Edge(s, t));
        }

        Edge result = null; // 最终要删除的冗余边

        // 场景1:存在入度为2的节点
        if (doubleIn != null) {
            // 收集指向doubleIn的两条候选边
            List<Edge> doubleInEdges = new ArrayList<>();
            for (Edge edge : edges) {
                if (edge.t == doubleIn) {
                    doubleInEdges.add(edge);
                    // 找到两条后停止遍历
                    if (doubleInEdges.size() == 2) break;
                }
            }
            // 候选边1:先出现的边;候选边2:后出现的边
            Edge edge1 = doubleInEdges.get(0);
            Edge edge2 = doubleInEdges.get(1);
            // 先尝试删除后出现的边edge2,检查剩余边是否能构成合法树
            if (isTreeWithExclude(edges, edge2, nodeMap)) {
                result = edge2; // 删除edge2后合法,edge2是冗余边
            } else {
                result = edge1; // 删除edge2后仍有环,edge1是冗余边
            }
        } else {
            // 场景2:无入度为2的节点,直接找环中的冗余边
            result = getRemoveEdge(edges, nodeMap);
        }

        // 输出冗余边的起点和终点
        System.out.println(result.s + " " + result.t);
        scanner.close();
    }

    /**
     * 验证:删除指定边后,剩余边是否能构成合法树(无环 + 连通)
     * @param edges 所有边列表
     * @param excludeEdge 要排除(删除)的边
     * @param nodeMap 节点映射数组
     * @return true=合法树,false=仍有环/不连通
     */
    public static boolean isTreeWithExclude(List<Edge> edges, Edge excludeEdge, Node[] nodeMap) {
        // 初始化并查集(容量=节点数+1,适配节点编号1~n)
        Disjoint disjoint = new Disjoint(nodeMap.length + 1);
        for (Edge edge : edges) {
            // 跳过要排除的边
            if (edge == excludeEdge) continue;
            // 若当前边的起点和终点已连通,说明剩余边仍有环→不是合法树
            if (disjoint.isSame(edge.s, edge.t)) {
                return false;
            }
            // 合并起点和终点的集合
            disjoint.join(edge.s, edge.t);
        }
        // 遍历完所有边无环,说明是合法树
        return true;
    }

    /**
     * 无入度为2的节点时,找环中的冗余边(和无向图冗余连接逻辑一致)
     * @param edges 所有边列表
     * @param nodeMap 节点映射数组
     * @return 环中最后出现的那条边
     */
    public static Edge getRemoveEdge(List<Edge> edges, Node[] nodeMap) {
        int length = nodeMap.length;
        // 初始化并查集
        Disjoint disjoint = new Disjoint(length);
        // 遍历所有边,找第一个导致环的边(最后出现的环边)
        for (Edge edge : edges) {
            // 若起点和终点已连通,当前边就是环的冗余边
            if (disjoint.isSame(edge.s, edge.t)) {
                return edge;
            }
            // 合并起点和终点的集合
            disjoint.join(edge.s, edge.t);
        }
        // 理论上不会走到这里(题目保证有冗余边)
        return null;
    }

}
相关推荐
lxl13072 小时前
C++算法(11)字符串
开发语言·c++·算法
2601_953465612 小时前
m3u8live.cn深度解析:一款专为开发者打造的 M3U8 调试工具
java·前端·django·音视频·开发工具
passxgx2 小时前
12.3 多维高斯分布与加权最小二乘法
线性代数·算法·最小二乘法
少许极端2 小时前
算法奇妙屋(三十)-递归、回溯与剪枝的综合问题 3
算法·深度优先·剪枝·数独·n皇后
稻草猫.2 小时前
SpringBoot日志全解析:从调试到持久化
java·开发语言·spring boot·java-ee·idea
zopple2 小时前
Knife4j文档请求异常(基于SpringBoot3,查找原因并解决)
java·服务器·数据库
WBluuue2 小时前
数据结构与算法:01分数规划
c++·算法
小鸡吃米…2 小时前
Python线程同步
开发语言·数据结构·python
清水白石0082 小时前
Python 弱引用深度解析——让缓存不再成为内存泄漏的温床
java·python·缓存