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;
}
}