skill-12-图论
难度说明:🟢简单🟡中等🔴困难
学习资料
学习目标
掌握数据结构核心基础
借助数据结构完成常见题型
图论学习核心
① 图的表示:邻接矩阵、邻接表
- 在处理题型的时候,需要注意将给的边关系(
int[][] edges
:{{0,1},{1,2}}
)转化为邻接矩阵(int[][] graph
)、邻接表(List<List<Integer>> graph
)
- 在处理题型的时候,需要注意将给的边关系(
② 图的遍历(搜索方式):深度优先搜索(DFS)、广度优先搜索(BFS)
- 有向图的搜索(路径问题)
- 无向图的搜索(岛屿问题)
③ 常见算法核心(遍历搜索、环问题、最小生成树、最短路算法)
【1】并查集
DisJointSet
:- 核心方法:
init
(初始化构建father[]
)、findNode(int u)
(寻根:路径压缩优化)、join(int u,int v)
(构建边:v->u
)、isSame(int u,int v)
(判断两个点是否在同一个集合中) - 应用场景:寻根、将两个点加入集合、判断两个点是否在同一集合
- 核心方法:
【2】拓扑排序
- 核心思路:
- (1)根据边关系将图转化为邻接矩阵/邻接表,并记录每个点的入度
- (2)构建存储入度为0的队列,初始化将入度为0的点加入队列
- (3)遍历队列元素,并处理其关联的未被遍历的点的入度,处理完成后将入度为0的点入队,以此类推完成遍历操作
- (4)结果分析:如果经由队列取出的节点恰好遍历到了所有点(
queue.size()==n
)则说明当前图不存在环,可经由一定的处理顺序确保可以在满足依赖关系的前提下处理所有点
- 应用场景:判断图中是否存在环
- 核心思路:
【3】最小生成树问题(
prim
算法、kruskal
算法):无向图中连通所有节点的最小成本问题核心:以最小的成本(边的权值)将图中所有节点链接到一起
prim
算法:minDist[]
(基于点的选择方式)核心思路:维护
minDist[]
表示每个点距离最小生成树的最短距离,遍历每个节点,从未选中的节点中选出当前距离最小生成树最近的节点,将其加入最小生成树并更新minDist
,循环多次直到最小生成树构建完成(而最小成本即为最终的minDist
值)// 伪代码思路 int[] minDist; // 遍历每个节点(每次选中1个节点加入最小生成树) for(int i=0;i<n;i++){ // (1)从当前未选中节点(非生成树节点)中选出当前距离最小生成树最近的节点(可以理解为基于minDist数组从非生成树节点中选出一个最短距离对应的节点) // (2)将步骤(1)中选出的节点cur加入最小生成树 // (3)更新minDist数组(及更新当前选中节点cur与其他非生成树节点的最短距离,作为下一轮的选择参考依据) }
Kruskal
算法:排序 + 并查集(基于边的选择方式)核心思路:将边按照权值大小进行排序,遍历每一条边,借助并查集判断边对应的两个端点是否在同一集合(如果已在同一集合无需重复加入边(并累加边成本),如果不在则需将其加入并查集)
// 伪代码思路 Arrays.sort(...); // 根据边的权值对边数据进行排序 DisJointSet init; // 构建并查集并初始化 int pathSum = 0; // 遍历每个边(每次选择一条满足条件的最小边加入) for(int i=0;i<edges.length;i++){ boolean isSame = djs.isSame(edge[0],edge[1]); if(isSame){ // (1) u,v 在同一集合,无需加入该边 }else{ // (2)u,v 不在同一集合,将其加入并查集,该边的节点可以加入最小生成树(选中该边) djs.join(edge[0],edge[1]); djs.join(edge[1],edge[0]); pathSum += edge[2]; } }
【4】最短路径问题
(1)单源最短路径问题
Dijkstra
算法(基于点):在有权图(权值非负数)中求从起点到其他节点的最短路径算法(对比prim
算法理解记忆)minDist[]
存储源点到节点i
的最短路径处理核心:
int[] minDist = new int[n]; for(int i=0;i<n;i++){ minDist[i] = (i==source)?:INF; // 源点到自身的最短路径为0,源点到其他节点的最短路径为INF(最大值边界) } boolean[] visited = new boolean[n]; // 遍历n次,每次选出一个节点 for(int i=0;i<n;i++){ // (1) 从minDist中选出一个距离源点最近的点 int selected = getMin(...); // (2) 更新选中节点的状态 visited[selected] = true; // (3) 更新源点到其他节点的最短路径(基于selected节点的参考) for (int k = 1; k < minDist.length; k++) { if (!visited[k] && grid[cur][k] != INF) { minDist[k] = Math.min(minDist[k], minDist[cur] + grid[cur][k]); } } }
bellman_ford
算法(基于边):在有权图(权值存在负数)中求从起点到其他节点的最短路径算法minDist[]
存储源点到节点i
的最短路径处理核心:
int[] minDist = new int[n]; for(int i=0;i<n;i++){ minDist[i] = (i==source)?:INF; // 源点到自身的最短路径为0,源点到其他节点的最短路径为INF(最大值边界) } // 对每条边进行n-1次松弛 for(int i=0;i<n-1;i++){ for(int u = 0;u<n;u++){ for(List<Edge> edge : grid.get(u)){ int v = edge.v; int w = edge.w; if(minDist[u]!=INF){ minDist[v] = Math.min(minDist[v],minDist[u]+w); } } } }
SPFA
算法(基于边):基于bellman_ford
的优化版本(引入队列queue
存储上一次松弛操作后更新的节点,基于队列中的节点关联的边进行松弛操作)minDist[]
存储源点到节点i
的最短路径,引入queue
存储每一次松弛操作后更新的节点处理核心:
int[] minDist = new int[n]; for(int i=0;i<n;i++){ minDist[i] = (i==source)?:INF; // 源点到自身的最短路径为0,源点到其他节点的最短路径为INF(最大值边界) } Queue<Integer> queue = new LinkedList<>(); queue.offer(source); // 初始化源点入队 // 基于queue,对参与松弛操作更新后的节点关联的边进行n-1次松弛 while(!queue.isEmpty()){ int u = queue.poll(); for(int i=0;i<n-1;i++){ for(List<Edge> edge : grid.get(u)){ int v = edge.v; int w = edge.w; if(minDist[u]!=INF && minDist[u]+w<minDist[v]){ minDist[v] = minDist[u]+w; if(!queue.contains(v)){ // 如果节点v已经存在于队列则不重复加入 queue.offer(v); } } } } }
(2)多源最短路径问题
④ 常见题型:连通性问题、最短路径问题、环检测、拓扑排序
- 图的遍历:给定一个图,输出DFS、BFS的遍历结果
- 核心算法:DFS、BFS
- 常见题型:
- 797-所有可能路径(DFS)
- 岛屿问题系列(DFS、BFS)
- 连通性问题:
- 并查集:解决连通性问题
- 核心:
DisjointSet
(寻根find(int u)
、构建边join(int u,int v)
(将两个节点加入集合)、判断两个元素是否在同一集合isSame(int u,int v)
) - 1971-寻找图中是否存在路径:
- 思路1【并查集】:基于并查集模板,初始化并查集
init
,将边加入并查集join
,校验两个点是否在同一集合isSame
- 思路2【搜索:DFS、BFS】
- DFS:
boolean dfs(int[][] grid,boolean[] visited,int source,int dest)
- BFS:选择起点,然后向4个方向进行遍历,记录可达节点(通过队列辅助遍历)
- DFS:
- 思路1【并查集】:基于并查集模板,初始化并查集
- 核心:
- 环检测(判断有向图或无向图中是否存在环)
- 207-课程表:判断图中是否存在环(拓扑排序)
- 并查集:解决连通性问题
- 最短路径问题(BFS):最短路算法(
dijkstra
算法、bellman_ford
算法、SPFA
算法、floyd
算法、A *
(A star
算法))- 核心算法:有向图(正权,无回路)、有向图(负权,无回路)、有向图(负权,存在负权回路)
- dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法(对比
prim
算法理解记忆)- (1)定义
minDist[]
维护源点到各个点的最短距离,visited[]
标记已选中的节点 - (2)选择过程
- 遍历每个节点,从未被选中的节点列表中选择一个
min{minDist[x]}
作为当前轮次选中的节点 - 标记该节点为已被选中
visited[x]=true
- 基于上述择选操作更新
minDist
(minDist[dest]=minDist[x] + grid[x][dest]
)
- 遍历每个节点,从未被选中的节点列表中选择一个
- (3)缺点:无法计算含有负权的图的最短路径(因为会负权会带来更小的值,而最短路径希望路径越短越好,但是在处理的过程中有可能出现前面的节点已经选出(被标记为已处理),当后面的遍历过程中遇到更短的路径的时候却无法更新min,导致得到错误的结果)
- (1)定义
- bellman_ford 算法:在有权图(权值存在负数)中求从起点到其他节点的最短路径算法
- 核心:对所有边进行
n-1
次松弛操作minDist[i]
表示源点到节点的最短距离松弛
:遍历每一条边[u,v,w]
(表示u->v
的权值为w
),更新minDist[v]
(minDist[v] = minDist[u] + w
,只有minDist[u]
有效的情况下才需要更新,即minDist[u]
必须已经确定下来,否则更新无意义)- 为什么是
n-1
次?:因为起点到终点最多是n-1
条边相连,那么不管图是什么样的连接状态,对所有的边松弛n-1
次就一定可以得到起点到达终点的最短距离,这个过程中也相应得到了【起点】到【所有节点】的最短距离(因为对于所有节点而言,起点到这些节点连接的边数最多也是n-1
)
- 负权回路:即判断一个
circle
回路中所有边的权值之和是否为负数,如果为负数则称之为出现了负权回路- 切入点:基于bellman_ford的算法,假设没有限定松弛操作的情况下,如果一个有向图中出现了负权回路,那么就会就会一直选择走负权回路来获取更短的路径(或者说明更低的成本),如果不对松弛次数做限制,那么就会陷入死循环操作。而基于正常场景(无负权回路)的情况下,当对所有边进行n-1次松弛操作后是可以确定下来最短路径方案的(即后面的松弛都是无效松弛),如果说存在负权回路,那么再执行一次松弛操作则必然会出现更短的路径,基于此思路可以用于判断有向图是否存在负权回路
- 解决思路:对所有边进行n次松弛,在每次的**最后1次松弛(
i==n
)**进行校验是否出现了更短的路径,一旦出现则说明出现了父权回路
- 核心:对所有边进行
- SPFA 算法(bellman_ford 队列优化算法):在有权图(权值存在负数,且不存在任何负权回路)中求从起点到其他节点的最短路径算法
- 核心:基于bellman_ford 算法进行优化实际上就是引入
queue
维护需要上一次松弛更新的节点作为下一次检索的参考,因为如果没有变化的更新操作属于无效松弛(没有意义)queue
:存储上一次松弛更新的节点(和minDist[i]
的更新保持同步:即queue.push(startIdx)
)- 优化点:只对上一次松弛更新的节点关联的边进行n-1次松弛,如果已经存在于queue中的节点则不重复加入
- 核心:基于bellman_ford 算法进行优化实际上就是引入
- floyd 算法:多源最短距离算法
- A * 算法
- dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法(对比
- 743-网络延迟时间:给定一个加权图,求起点到终点的最短路径
- 核心算法:有向图(正权,无回路)、有向图(负权,无回路)、有向图(负权,存在负权回路)
- 拓扑排序
- 210-课程表II:判断是否可以完成所有课程,给出一个可行方案(如果不可行返回空)
- 最小生成树(
prim
算法、kruskal
算法)- 1135-最低成本联通所有城市:给定一个加权无向图,求其最小生成树
- 强连通分量
- 1192-查找集群内的关键连接:给定有向图,求其强连通分量
- 图的遍历:给定一个图,输出DFS、BFS的遍历结果
图论算法核心总结(todo)
- DFS
- BFS
- 拓扑排序
- 最小生成树
- 并查集......
skill-12-图论
理论基础
1.核心理论
图论常见应用:通信网络(拓扑排序、最短路算法),社交网络(深搜、广搜),路径优化(最短路算法),任务调度(拓扑排序),生物信息学(基因为节点,基因关系为边),游戏开发(A *
算法等)
针对图论的学习选择【ACM模式】,考察对代码细节的把控程度(图的构成、图的输出),如果没有掌握输入输出基础,相当于没有入门。因此在掌握图论核心算法的同时,还要掌握最基础的数据结构输入输出
🍚图的基本概念
什么是图?
在二维坐标中,两点可以连成线,多个点连成的线就构成了图。当然图也可以就一个节点,甚至没有节点(空图)
图的分类?
一般来说分为有向图和无向图:
- 有向图:图中的边是有方向的
- 加权有向图:图中的边是有权值且有方向的
- 无向图:图中的边是无方向的
- 加权无向图:图中的边是有权值但无方向的

度的概念
针对无向图而言,无向图中有几条边连接该节点,该节点就有几度;(以上述无向图为例,节点2
共有4
条边)
针对有向图而言,每个节点有出度和入度概念:(以上述无向图为例,节点2
出度为3
,入度为1
)
- 节点的出度:从该节点出发的边的条数
- 节点的入度:指向该节点(以该节点的为终点)的边的条数
连通性概念
连通性:在图中表示节点的联通情况,称为连通性
连通图、非连通图、强连通图:
连通图:在无向图中,任何两个节点都是可以到达的,则为连通图
非连通图:在无向图中,如果有节点不能到达其他节点,则为非连通图
强连通图:在有向图中,任何两个节点是可以相互到达的,称之为强连通图
- 连通分量:在无向图中的极大连通子图称之为该图的一个连通分量
- 节点1、2、5构成的子图为该无向图的一个联通分量,该子图中的所有节点都是相互可达到的
- 节点3、4、6构成的子图为该无向图的一个联通分量,该子图中的所有节点都是相互可达到的
- 节点3、4 并不是该无向图的联通分类,因为它并不是极大联通子图

- 强联通分量:在有向图中极大强连通子图称之为该图的强连通分量

🍚图的构造
图的构造一般使用邻接表、邻接矩阵或者类来表示
(1)邻接矩阵
邻接矩阵 使用 二维数组 来表示图结构。 邻接矩阵是从节点的角度来表示图,有多少节点就申请多大的二维数组。例如:
在一个节点数为n
的图中(假设n=8
),就需要申请8 × 8
的空间
- 【有向图】参考表示:
grid[2][5]
= 6,表示 节点 2 连接 节点5 为有向图,节点2 指向 节点5,边的权值为6 - 【无向图】参考表示:
grid[2][5]
= 6,grid[5][2]
= 6,表示节点2 与 节点5 相互连通,权值为6
邻接矩阵的特点:
- 优点:
- 表达方式简单,易于理解
- 检查任意两个定点间是否存在边的操作非常快(只需要看对应矩阵位置元素)
- 适合稠密图,在【边数接近定点数平方】的图中,邻接矩阵是一种空间效率较高的表示方法
- 缺点:
- 在稀疏图场景,即【节点多】、【边少】的情况下会导致申请过大的二维数组资源,造成空间浪费,且遍历边的时候需要遍历整个
n * n
矩阵造成时间浪费
- 在稀疏图场景,即【节点多】、【边少】的情况下会导致申请过大的二维数组资源,造成空间浪费,且遍历边的时候需要遍历整个
(2)邻接表
邻接表采用【数组 + 链表】的方式来表示,邻接表是从边的数量来表示图,有多少边才会申请对应大小的链表
- 用数组存储节点(有多少个节点参与连接就申请多大容量数组)
- 用链表存储边的连接情况,链表中存储的是当前数组元素指向的节点列表

邻接表的特点:
- 优点:
- 对于稀疏图的存储,只需要存储边的连接情况,空间利用率高
- 遍历节点连接情况相对容易
- 缺点:
- 检查任意两个节点是否存在边的效率比较低(需要O(V)时间,V表示某节点连接其他节点的数量)
- 实现相对复杂,不易理解
🍚图的遍历方式
图的遍历方式分类:(基本是两大类)
- 深度优先遍历(DFS)
- 广度优先遍历(BFS)
在二叉树章节中已经初步接触过这两种遍历方式:DFS(递归遍历:前序、中序、后序)、BFS(层序遍历),实际上DFS、BFS是一种搜索算法,可以在不同的数据结构上进行搜索,在二叉树的章节中是基于二叉树结构进行搜索分析,而在图论的章节中则是基于图(邻接表或邻接矩阵)进行搜索
==遍历搜索:==在图的遍历搜索中有一些需要记录已遍历节点的场景(以【岛屿问题】为例,其需要避免节点重复遍历,因此需要记录节点的遍历状态),往往有两种思路:
- ① 定义状态数组
boolean[][] visited
:用于记录对应位置的节点的遍历状态(false:未遍历;true:已遍历)好处在于完全独立于源数组,可以避免状态覆盖带来的一些隐藏的问题 - ② 更新节点状态:例如将
grid[x][y]
状态更新为一个新的状态用于标记当前节点已经遍历,其优点在于不用定义辅助的数组,但缺点在于需要考虑状态覆盖给算法带来的隐藏bug(如果有时候没有注意到这点,很容易踩坑)
DFS VS BFS
图论中的dfs、bfs主要区别体现如下:
- DFS:DFS可着一个方向搜索,不到终点不回头,直到遇到出口(搜索不下去了)再换方向(换方向的过程中涉及到回溯)
- BFS:BFS是将本节点所连接的所有节点都遍历一遍,走到下一个节点的时候再把连接节点的所有节点遍历一遍,搜索方向更像是广度、四面八方的搜索过程
(1)DFS
经典题型:797-所有可能路径(KMW098-所有可达路径)
案例:如图所示无向图,搜索【节点1】到【节点6】的所有路径
- 定向搜索:确定搜索方向,认准一个方向,直到碰壁之后再换方向
- 切换方向:撤销原路径,改为节点连接的下一个路径(回溯的过程)
此处并没有列举【节点1】到【节点6】的所有路径,主要理解DFS检索的核心:递归和回溯的过程分析。图示中撤销的过程实际上就是回溯,遇到终点(撞南墙了)需要回退,撤销上一个节点的选择,然后选择新的路径,以此类推,如果当前节点没有新路径可选了,则继续回退到上一步进行检索
代码框架
基于【有向图的路径搜索】,结合回溯代码框架理解,套用到图的DFS中同样使用
// 可以将一些参数定义为类属性,避免dfs方法中参数过多(让算法看起来思路更加清晰些)
List<List<Integer>> = res ; // 存储所有路径的结果集合
List<Integer> = path ; // 存储当前路径节点列表(此处为起点到终点的路径)
void dfs(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本节点所连接的其他节点) {
1.处理节点; // 处理节点
2.dfs(图,选择的节点); // 递归处理下一节点(可以在进入递归前先校验节点)
3.回溯,撤销处理结果 // 恢复现场
}
}
/**
* 🟡 797 所有可能的路径 - https://leetcode.cn/problems/all-paths-from-source-to-target/description/
*/
public class Solution797_01 {
List<List<Integer>> res = new ArrayList<>(); // 存储结果集
List<Integer> path = new ArrayList<>(); // 存储路径
/**
* 图的搜索(二维矩阵) - 有向无环图的搜索
*/
public List<List<Integer>> allPathsSourceTarget(int[][] graph) {
path.add(0);
dfs(graph, 0, graph.length - 1); // 节点区间[0,n-1]
return res;
}
// DFS检索(x(当前遍历节点)->n(终点))
private void dfs(int[][] graph, int x, int n) {
// 递归出口:遍历节点x走到节点n,说明遍历到终点,找到一条可达路径
if (x == n) {
res.add(new ArrayList<>(path));
return;
}
// 回溯处理
for (int i : graph[x]) {
// 遍历节点i连接的所有节点
path.add(i); // 加入节点
dfs(graph, i, n); // 递归
path.remove(path.size() - 1); // 恢复现场
}
}
}
(2)BFS
广搜的搜索方式就适合于解决两个点之间的最短路径问题。因为广搜是从起点出发,以起始点为中心一圈一圈进行搜索,一旦遇到终点,记录之前走过的节点就是一条最短路。
当然,也有一些问题是 广搜 和 深搜 都可以解决的,例如岛屿问题(这类问题的特征就是不涉及具体的遍历方式,只要能把相邻且相同属性的节点标记上就行)
用一个方格地图,假如每次搜索的方向为 上下左右(不包含斜上方),那么给出一个start
起始位置,那么BFS就是从上下左右四个方向走出第一步,如果加上end
终止位置,则使用BFS搜索的过程如下:结合图示分析,从start
起点开始,一圈圈向外搜索,直到找到终点位置。同样的,对于障碍问题(参考下述图示分析),如果要到达终点end
则需要第6步才能到达end终点(只要BFS只要搜到终点一定是一条最短路径)
代码框架
这一圈一圈的搜索过程是怎么做到的,是放在什么容器里才能这样去遍历。很多网上的资料都是直接说用队列来实现。其实,仅仅需要一个容器,能保存要遍历过的元素就可以,那么用队列,还是用栈,甚至用数组,都是可以的。
- 用队列的话,就是保证每一圈都是一个方向去转,例如统一顺时针或者逆时针。因为队列是先进先出,加入元素和弹出元素的顺序是没有改变的
- 如果用栈的话,就是第一圈顺时针遍历,第二圈逆时针遍历,第三圈又顺时针遍历。因为栈是先进后出,加入元素和弹出元素的顺序改变了。
那么对于广搜场景而言,其实转圈搜索的顺序其实并不那么重要,因此只需要选择要给容器来辅助遍历即可
# 广搜BFS模板
/**
* 图论:广度优先遍历代码模板(无向图)
*/
public class BFSTemplate {
// int[4][2]
int[][] dir = new int[][]{{0, 1}, {1, 0}, {-1, 0}, {0, -1}}; // 表示4个方向
/**
* @param graph 图(邻接矩阵)
* @param visited 用于标记已访问过的节点(不能重复访问)
* @param x,y 表示开始搜索的节点下标
*/
void bfs(int[][] graph, boolean[][] visited, int x, int y) {
// 定义队列
Queue<Pair> queue = new LinkedList<>();
queue.add(new Pair(x, y)); // 初始化
visited[x][y] = true; // 入队的同时记录为已遍历,避免重复访问
// 队列不为空,进行遍历
while (!queue.isEmpty()) {
Pair curPair = queue.poll();
int curX = curPair.x; // 横坐标
int curY = curPair.y; // 纵坐标
for (int i = 0; i < 4; i++) { // 从当前节点的4个方向左右上下去遍历
// 顺时针遍历新节点next,下面记录坐标
int nextX = curX + dir[i][0];
int nextY = curY + dir[i][1];
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue; // 去除越界部分(坐标越界则直接跳过)
}
// 如果节点没有被访问过,则添加该节点为下一轮要遍历的节点,并在入队时标记为已遍历
if (!visited[nextX][nextY] && graph[nextX][nextY] == 1) {
queue.add(new Pair(nextX, nextY));
visited[nextX][nextY] = true;// 逻辑同上
}
}
}
}
}
// 定义坐标类
class Pair {
public int x;
public int y;
public Pair() {
}
public Pair(int x, int y) {
this.x = x;
this.y = y;
}
}
2.技巧总结
图基础:
- 图的表示:图的两种表示方式和应用场景(邻接矩阵(稠密图)
int[][] grid
、邻接表(稀疏图)List<List<Edge>> graph
) - 图的遍历:
- 深搜(
DFS
):有两种实现模板(关键看是**处理(设置visited[]
)和校验(递归条件判断)**当前节点还是处理下一节点)- 实现模板
- 处理当前节点:设定
dfs
的递归出口,处理和校验都是针对当前节点。在for
循环中直接调用dfs
- 处理下一节点:在调用
dfs
方法前进行校验,只有满足条件的节点才调用dfs
,处理和校验针对的是下一节点
- 处理当前节点:设定
- 回溯的选择:需结合题型考虑什么时候需要回溯,什么时候不需要回溯(一般是计算路径的问题需要回溯,如果是染色问题(岛屿问题系列)不需要回溯)
- 105-有向图的完全可达性(深搜不需要回溯)
- 098-所有可达路径(深搜需要需要回溯)
- 实现模板
- 广搜(
BFS
):借助队列构建遍历列表,往多个方向进行遍历- 岛屿问题:基于每个可能的起点出发,向四周(4个方向)进行发散,寻找并校验下一个节点,如果节点有效且还没被遍历则加入队列并标记状态,以此类推
- 路径问题:基于限定的起点
source
出发,其发散方向只能是当前遍历节点连接的节点(有向图、无向图),如果其连接的下一个节点还没遍历则加入队列并标记状态,以此类推
- 深搜(
深搜(DFS)& 广搜(BFS)
可达路径:DFS(递归)
岛屿问题:DFS、BFS
- KMW099-岛屿数量:基础题型(遍历每个节点,其可能作为某个岛屿的起点(排除已遍历节点和非陆地),基于该起点进行深搜或者广搜,在搜索的过程中需要标记已遍历的节点,此时
main
主函数中进行岛屿数量统计)- 深搜:基于深搜模板进行设计,同步标记已经遍历过的区域
- 广搜:需注意只要加入队列就同步标记(而不是从队列中取出后再标记),否则可能导致写出的广搜算法超时(因为不同方向重复搜索了)
- KMW100-岛屿的最大面积:
- 可以理解为主函数中是以
岛屿
为维度进行遍历, 定义area
用于统计每个岛屿的面积,切换岛屿则重置area
计数,并更新maxArea
- 可以理解为主函数中是以
- KMW101-孤岛总面积
- 【思路1】孤岛标识:基于
099
、100
的思路,从孤岛概念切入,正常遍历每个岛屿面积,判断岛屿是否为孤岛则判断岛屿中的每个节点是否接触边缘(在遍历节点的时候即可校验),如果判定为孤岛才累加面积和- 以
DFS
为例,设定孤岛标识,以某个节点为起点遍历岛屿的时候校验其是否为孤岛(只要任意一个节点接触边界,该岛屿就不是孤岛),如果是则累加孤岛面积
- 以
- 【思路2】地图更新:基于地图更新的思路,遍历岛屿的时候,将岛屿接触边缘的关联陆地都变成海洋。基于此当地图更新完成之后,最终留存的岛屿就是孤岛,累加这些孤岛面积即可
- 以
BFS
为例,分别从左右边缘、上下边缘的陆地向中间进行搜索(因为以这些边缘陆地为起点关联的岛屿肯定是非孤岛),将遍历过程的节点都渲染成海洋(沉没孤岛调调),那么最终剩余的岛屿(陆地)就是孤岛。与【搜索岛屿】对比,此处相当于是缩圈搜索(将岛屿起点在边缘的节点关联的岛屿给沉没掉)
- 以
- 【思路1】孤岛标识:基于
- KMW102-沉没孤岛(和孤岛总面积相反,此处是要消除孤岛,留存非孤岛的矩阵)
- 地图更新:基于地图更新的思路,先将连接边缘的陆地全部置为
2
(中间态),更新地图后剩余的陆地即为孤岛,全部沉没(即将1
置为0),于此同时可以同步将2
重置为1
- 地图更新:基于地图更新的思路,先将连接边缘的陆地全部置为
- KMW103-水流问题
- 【思路1】暴力解法(水往低处流):从每个节点出发进行
dfs
或bfs
,用visited
标记已遍历的节点。随后分别判断基于当前节点的遍历路线是否可触达两组边界(遍历边界,判断边界节点是否被遍历),只要当前节点的搜索路线可以同时覆盖到两组边界,则当前节点满足要求可直接输出- ① 节点搜索:对每个节点进行
dfs
或bfs
,用visited
矩阵记录已遍历节点 - ② 边界校验:基于步骤①得到的
visited
判断两组边界是否均分别触达(第1组边界校验firstBorder
:上、左,第2组边界校验secondBorder
:右、下)- 符合条件的节点:
firstBorder && secondBorder
满足即可
- 符合条件的节点:
- ① 节点搜索:对每个节点进行
- 【思路2】边界搜索(汇合概念:逆流而上):分别从两组边界的节点出发进行
bfs
或dfs
(从边界节点出发就确保了触达边界这一条件),分别用矩阵firstBorder
、secondBorder
记录遍历过程中节点覆盖的情况,如果同时满足firstBorder[i][j] && secondBorder[i][j]
说明这从两组边界的节点出发的遍历覆盖范围存在公共交点(即汇合),那么这个汇合点即为所求(汇合点可以联通两组边界)- ① 边界节点搜索:从边界节点出发,对节点进行
dfs
或bfs
,分别用firstBorder
、secondBorder
矩阵记录分别从两组边界节点出发进行搜索的已遍历节点 - ② 求汇合点:基于步骤①可以得到分别从两组边界出发进行搜索可覆盖的节点,找到节点汇合处(公共节点),表示这个公共节点既能够到达第1组边界1、又能到达第2组边界,符合题意
- ① 边界节点搜索:从边界节点出发,对节点进行
- 【思路1】暴力解法(水往低处流):从每个节点出发进行
- KMW104-建造最大岛屿
- 【思路1】计算每个可能的改造方案下更新地图后的最大岛屿面积(时间复杂度:
(n*m)*(n*m)
)- 步骤 ①:根据当前地图获取最大岛屿面积
- 步骤 ②:遍历每个"海域",计算更新地图后的最大岛屿面积(根据步骤①提供的方法处理)
- 【思路2】标记岛屿,遍历每个"海域"(参与改造:累加当前区域邻接岛屿的面积,只需要根据当前遍历的海域坐标计算其四个方向邻接的区域坐标,然后根据岛屿标记获取关联岛屿面积)
- 步骤 ①:根据当前地图(源地图)标记岛屿并记录岛屿编号及其关联面积(
Map<岛屿编号,岛屿面积>
) - 步骤 ②:遍历每个"海域",计算其参与改造后可能构成的新岛屿面积(计算邻接坐标,根据坐标获取到对应的标记
graph[nextX][nextY]
及其岛屿面积,累加即可)- 实际上就是判断邻接区域是不是被标记为岛屿(在map中是否存在),如果是则累加岛屿面积(不需要重复计算)
- 步骤 ①:根据当前地图(源地图)标记岛屿并记录岛屿编号及其关联面积(
- 【思路1】计算每个可能的改造方案下更新地图后的最大岛屿面积(时间复杂度:
- KMW106-岛屿周长
- 【思路1】规律遍历法:根据计算每个陆地临接的区域情况进行周长统计(此处要跳出岛屿问题的
dfs
、bfs
思维惯性,用最原始的方法找出周长计算的规律,性价比更高)- 步骤 ①:定义方法
getCnt
根据遍历节点,判断其是否为陆地,如果为陆地则从4个方向判断其邻接节点是否为海域或者接触边界,统计周长 - 步骤 ②:遍历每个节点,调用步骤①中定义的方法,累加每个陆地周长统计的情况,得到最终岛屿的周长
- 步骤 ①:定义方法
- 【思路1】规律遍历法:根据计算每个陆地临接的区域情况进行周长统计(此处要跳出岛屿问题的
- KMW099-岛屿数量:基础题型(遍历每个节点,其可能作为某个岛屿的起点(排除已遍历节点和非陆地),基于该起点进行深搜或者广搜,在搜索的过程中需要标记已遍历的节点,此时
KMW110-字符串接龙
- 核心:无向图的最短路径求解思路,基于BFS进行搜索判断
最短路问题:
在搜索最短路的时候, 可以采取如下思路:
- 如果是无权图(边的权值都是1) 那就用广搜,代码简洁,时间效率和 dijkstra 差不多 (具体要取决于图的稠密)
- 可以基于
BFS
广搜版优化引入A *
算法
- 可以基于
- 如果是有权图(边有不同的权值),优先考虑 dijkstra
- 如果是无权图(边的权值都是1) 那就用广搜,代码简洁,时间效率和 dijkstra 差不多 (具体要取决于图的稠密)
并查集
- 并查集基础:为什么要引入并查集?可以解决什么问题?什么场景中会用到?
- 可以结合并查集的设计方法核心和原理理解其作用和应用场景
- 核心方法:
init
、find
(路径压缩、合并秩)、join
、isSame
- 并查集的应用主要体现在其三个方法:
- 寻根
find
:判断某个节点的根 - 加入集合
join
:将两个节点加入集合 - 校验是否同根
isSame
:校验两个节点是否同根(是否在同一个集合)
- 寻根
- 并查集基础:为什么要引入并查集?可以解决什么问题?什么场景中会用到?
最小生成树问题
prim
算法:针对节点处理,midDist[]
prim 三部曲
:- ① 找距离最小生成树最短路径节点
cur
(当前minDist[i]
中的最小值对应的节点) - ② 将找到的节点加入最小生成树
- ③ 更新非生成树节点到选中的节点
cur
的最短路径(更新minDist[]
)
- ① 找距离最小生成树最短路径节点
kruskal
算法:针对边处理,排序 + 并查集- ① 排序:按照边的权值大小(从小到大)进行排序
- ② 遍历:遍历排序后的边列表,依次选择边(如果边关联的两个节点已经存在在同一集合则跳过,否则加入最小生成树,此处引用并查集思路)
prim
VSkruskal
- 根据两者的算法特性,
prim
适用于稠密图(边连接差不多完全连接的情况)。kruskal
适用于稀疏图(边多、节点少等情况)
- 根据两者的算法特性,
拓扑排序
拓扑排序算法:基于辅助队列进行遍历(队列
queue
动态维护入度为0的节点列表,一一进行处理)- ① 选择一个入度为0的节点
- ② 将节点加入结果集,并从图中移除(处理指向节点列表的入度、更新
queue
)
拓扑排序常见应用场景:【排课依赖问题】、【文件/软件安装依赖问题】,转化为有向图BFS的拓扑排序思路处理
最短路问题
常见最短路算法
BFS
搜索【多方向扩张式搜索】:常用于解决棋盘问题(边权值为1的场景)dijkstra
之【最短路径(不含负权)】:朴素版、堆优化版bellman_ford
&SPFA
(bellman_ford
队列优化版本) 【版本1】之【单源最短路径(不含负权回路)】bellman_ford
&SPFA
【版本2】之【单源最短路径(判断负权回路)】bellman_ford
&SPFA
【版本3】之【单源有限最短路径(含负权回路,限定至多经过k个节点)】Floyd
之【多源最短路径】:给定x
个【起始->终点】计划,求解每个计划的最短路径A *
(A star
)启发式搜索:基于BFS
搜索改良版
最短路算法对比
最短路算法 适用图大小 边权(可否为负数) 检测负权回路 有限节点最短路径 源点数 时间复杂度 dijkstra
朴素版稠密图 no no no 单源 O(N2) N 节点数量 dijkstra
堆优化版稀疏图 no no no 单源 O(ElogE) E 边数量 bellman_ford
稠密图 yes yes(调整版本) yes(调整版本) 单源 O(N×E) SPFA
(bellman_ford
队列优化版)稀疏图 yes yes(调整版本) yes(调整版本) 单源 O(K×N)(K不定,取决于图的稠密度) Floyd
稠密图 yes yes no 多源 O(N3)
A *
属于启发式搜索,和上述最短路算法不算一类,理解其为BFS的改良版。对于A *
,由于其高效性,所以在实际工程应用中使用最为广泛 ,由于其 结果的不唯一性,也就是可能是次短路的特性,一般不适合作为算法题。游戏开发、地图导航、数据包路由等都广泛使用A *
算法
对于多源的计算,某些情况下思考是否可以调用多次单源的算法得到结果
常见题型
1.深搜(DFS)与广搜(BFS)
🟡KMW098-所有可达路径(DFS)(797-所有可能路径)
1.题目内容
本题和【LeetCode-797-所有可能路径】是同一道题目,输入【有向无环图】的所有可达路径
题目描述
给定一个有 n 个节点的有向无环图,节点编号从 1 到 n。请编写一个函数,找出并返回所有从节点 1 到节点 n 的路径。每条路径应以节点编号的列表形式表示。
输入描述
第一行包含两个整数 N,M,表示图中拥有 N 个节点,M 条边
后续 M 行,每行包含两个整数 s 和 t,表示图中的 s 节点与 t 节点中有一条路径
输出描述
输出所有的可达路径,路径中所有节点之间空格隔开,每条路径独占一行,存在多条路径,路径输出的顺序可任意。如果不存在任何一条路径,则输出 -1。
注意输出的序列中,最后一个节点后面没有空格! 例如正确的答案是 1 3 5
,而不是 1 3 5
, 5后面没有空格!
输入示例
5 5
1 3
3 5
1 2
2 4
4 5
输出示例
1 3 5
1 2 4 5

2.题解思路
👻方法1:LeetCode 模式
- 思路分析:基于回溯模板构建
/**
* 797 所有可能的路径(LeetCode模式)
*/
public class Solution1 {
List<List<Integer>> res = new ArrayList<>(); // 存储所有路径结果
List<Integer> path = new ArrayList<>(); // 存放当前路径
public List<List<Integer>> allPathsSourceTarget(int[][] graph) {
path.add(0); // 所有路径起点均为0
dfs(graph, 0, graph.length - 1); // 此处区间为[0,n-1]
return res; // 返回结果
}
/**
* 深度优先遍历
*
* @param graph 邻接矩阵
* @param x 当前遍历的节点
* @param n 终点
*/
public void dfs(int[][] graph, int x, int n) {
// 递归出口:当前遍历的节点x到达n,说明遍历到终点,找到一条可达路径
if (x == n) {
res.add(new ArrayList<>(path));
return;
}
// 回溯处理
for (int i : graph[x]) { // 遍历节点x链接的所有节点(graph[x]中存储的即为x链接的所有节点)
// 1.加入
path.add(i);
// 2.递归
dfs(graph, i, n);
// 3.回溯(恢复现场)
path.remove(path.size() - 1);
}
}
}
复杂度分析
- 时间复杂度:O(n × 2n)其中 n 为点的数量(最坏的情况是每个节点都可以去比它大的点,则路径数为O(2n),每条路径的长度为O(n),得到总的时间复杂度)
- 空间复杂度:O(n),其中 n 为点的数量。主要为
List
空间的开销。注意返回值不计入空间复杂度
👻方法2:ACM 模式(自定义图)
- 思路分析:自定义输入输出,确认节点个数、边数,自定义处理输入输出封装图(邻接表、邻接矩阵)
- 此处分别构建邻接矩阵、邻接表的图:
- 如果是临接矩阵:定义二维数组
graph[][]
,当节点与节点之间存在边则置为1- 递归判断是节点与节点否存在边进而确定节点可选择的连接列表,根据
graph[x][i]==1
进行校验
- 递归判断是节点与节点否存在边进而确定节点可选择的连接列表,根据
- 如果是临接表:定义
数组+链表
的组合形式,可以是List<Integer>[]
也可以是List<List<Integer>>
,如果节点与节点之间存在边,则加入指定节点关联的链表即可- 不需要判断节点与节点是否存在边,只需要拿到相应链表即可
- 如果是临接矩阵:定义二维数组
- 此处分别构建邻接矩阵、邻接表的图:
/**
* 797 所有可能的路径(ACM 模式:邻接表)
*/
public class Solution2 {
static List<List<Integer>> res = new ArrayList<>(); // 存储所有路径结果
static List<Integer> path = new ArrayList<>(); // 存放当前路径
/**
* 深度优先遍历
*
* @param graph 邻接表
* @param x 当前遍历的节点
* @param n 终点
*/
public static void dfs(List<List<Integer>> graph, int x, int n) {
// 递归出口:当前遍历的节点x到达n,说明遍历到终点,找到一条可达路径
if (x == n) {
res.add(new ArrayList<>(path));
return;
}
// 回溯处理
for (int i : graph.get(x)) { // 遍历节点x链接的所有节点
// 1.加入
path.add(i);
// 2.递归
dfs(graph, i, n);
// 3.回溯(恢复现场)
path.remove(path.size() - 1);
}
}
// 打印路径
public static void printRes() {
if (res.isEmpty()) {
System.out.println("-1");
return;
}
// 路径列表不为空,打印路径
for (int i = 0; i < res.size(); i++) {
List<Integer> path = res.get(i);
StringBuffer pathStr = new StringBuffer();
for (int j = 0; j < path.size() - 1; j++) {
pathStr.append(path.get(j)).append(" ");
}
pathStr.append(path.get(path.size() - 1)); // 补上最后一个元素
System.out.println(pathStr); // 打印路径
}
}
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
// 1.输入控制
System.out.println("分别输入节点个数、边数");
int nodeCnt = sc.nextInt();
int sideCnt = sc.nextInt();
System.out.println("输入各边连接情况");
// 2.定义邻接表
List<List<Integer>> graph = new ArrayList<>(nodeCnt + 1);
for (int i = 0; i <= nodeCnt; i++) {
graph.add(new ArrayList<>()); // 初始化链表
}
while (sideCnt-- > 0) {
// 分别接收节点,封装图(邻接表)
int node1 = sc.nextInt();
int node2 = sc.nextInt();
graph.get(node1).add(node2); // 将目标节点加入链表
}
// 3.调用dfs深度优先搜索获取所有可达路径
path.add(1); // 所有路径起点均为1
dfs(graph, 1, nodeCnt); // 区间从[1,n] graph.length-1
// 打印路径
printRes();
}
}
/**
* 797 所有可能的路径(ACM 模式:邻接矩阵)
*/
public class Solution3 {
static List<List<Integer>> res = new ArrayList<>(); // 存储所有路径结果
static List<Integer> path = new ArrayList<>(); // 存放当前路径
/**
* 深度优先遍历
*
* @param graph 邻接矩阵
* @param x 当前遍历的节点
* @param n 终点
*/
public static void dfs(int[][] graph, int x, int n) {
// 递归出口:当前遍历的节点x到达n,说明遍历到终点,找到一条可达路径
if (x == n) {
res.add(new ArrayList<>(path));
return;
}
// 回溯处理
for (int i = 1; i <= n; i++) { // 遍历节点x链接的所有节点
if (graph[x][i] == 1) { // 找到节点x链接的节点
// 1.加入
path.add(i);
// 2.递归
dfs(graph, i, n);
// 3.回溯(恢复现场)
path.remove(path.size() - 1);
}
}
}
// 打印路径
public static void printRes() {
if (res.isEmpty()) {
System.out.println("-1");
return;
}
// 路径列表不为空,打印路径
for (int i = 0; i < res.size(); i++) {
List<Integer> path = res.get(i);
StringBuffer pathStr = new StringBuffer();
for (int j = 0; j < path.size() - 1; j++) {
pathStr.append(path.get(j)).append(" ");
}
pathStr.append(path.get(path.size() - 1)); // 补上最后一个元素
System.out.println(pathStr); // 打印路径
}
}
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
// 1.输入控制
System.out.println("分别输入节点个数、边数");
int nodeCnt = sc.nextInt();
int sideCnt = sc.nextInt();
System.out.println("输入各边连接情况");
List<String> sideList = new ArrayList<>(); // 存储边连接情况
while (sideCnt > 0) {
String inputSide = sc.nextLine();
if (!inputSide.equals("")) { // 格式控制
sideList.add(inputSide);
sideCnt--;
}
}
// 2.定义二维数组(邻接矩阵)表示图
int[][] graph = new int[nodeCnt + 1][nodeCnt + 1]; // 节点取值为[1-n]
// 填充邻接矩阵
for (String side : sideList) {
String[] sideNode = side.split("\\s");
graph[Integer.valueOf(sideNode[0])][Integer.valueOf(sideNode[1])] = 1; // 如果节点间存在连接则对应矩阵位置元素置为1
}
// 3.调用dfs深度优先搜索获取所有可达路径
path.add(1); // 所有路径起点均为1
dfs(graph, 1, nodeCnt); // 区间从[1,n] graph.length-1
// 打印路径
printRes();
}
}
🟡KMW099-岛屿数量(200-岛屿数量)
1.题目内容
题目描述:
给定一个由 1(陆地)和 0(水)组成的矩阵,你需要计算岛屿的数量。岛屿由水平方向或垂直方向上相邻的陆地连接而成,并且四周都是水域。你可以假设矩阵外均被水包围。
输入描述:
第一行包含两个整数 N, M,表示矩阵的行数和列数。
后续 N 行,每行包含 M 个数字,数字为 1 或者 0。
输出描述:
输出一个整数,表示岛屿的数量。如果不存在岛屿,则输出 0。
输入示例:
4 5
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1
输出示例:3
题目中每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。也就是说斜角度的连接不算

2.题解思路
问题分析
结合题目给出示例,此处计算的是岛屿的数量,因此案例中共有3个岛屿。本题的解题思路在于,定义一个计数器,遍历每一个可能为起点的节点(计数器+1),基于这个节点进行dfs
操作(遍历的过程中记录已遍历的节点,遇到边界则退出;如果节点已经遍历过或者为非陆地则退出(跳过),可以理解为遍历以某个节点为起点所能到达的所有陆地并标记)
注意点:此处岛屿的标记是字符型,因此比较的是grid[i][j]=='1'
这种形式,如果在算法测试的过程中发现cnt
统计错误,则进一步排查是否因为比较错误导致计数异常
👻方法1:深搜版(DFS)
思路分析:以每一个可能的起点(没有被遍历过,且当前节点为陆地)进行递归搜索(递归的过程中记录已遍历过的节点)。深搜版本的遍历节点记录有两种方式:
【版本1】在
dfs
方法处理递归调用前就进行判断(可以理解为递归出口定义和标记已遍历节点,校验和处理的是当前节点)/** * 099 岛屿数量 */ public class Solution2 { static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上 /** * DFS * * @param graph 邻接矩阵 * @param visited 遍历标记(如果已遍历的元素则进行标记) * @param x 当前遍历坐标x * @param y 当前遍历坐标y */ public static void dfs(int[][] graph, boolean[][] visited, int x, int y) { // 递归出口 if (visited[x][y] || graph[x][y] == 0) { // 如果当前节点已经被遍历过,或者为非陆地则退出 return; } // 遍历当前节点 visited[x][y] = true; // 递归处理(往4个方向进行检索,此处选择列表为4个方向,遇到边界可跳过) for (int i = 0; i < 4; i++) { // 计算下一个要选择遍历的坐标 int nextX = x + dir[i][0]; int nextY = y + dir[i][1]; // 判断坐标是否越界(x∈[0,graph.length),y∈[0,graph[0].length)) if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) { continue; // 坐标越界,跳过当前选择 } // 判断条件放在了方法首部,此处直接调用dfs方法 dfs(graph, visited, nextX, nextY); } } // 输入控制,封装邻接矩阵 public static int[][] getGraph() { Scanner sc = new Scanner(System.in); System.out.println("输入整数N(矩阵行)、M(矩阵列)"); String[] nm = sc.nextLine().trim().split("\\s+"); int n = Integer.valueOf(nm[0]), m = Integer.valueOf(nm[1]); // 定义邻接矩阵 int[][] graph = new int[n][m]; System.out.println("输入N行,每行包含M个数字(数字为1或0)"); for (int i = 0; i < n; i++) { String[] input = sc.nextLine().trim().split("\\s+"); for (int j = 0; j < input.length; j++) { graph[i][j] = Integer.valueOf(input[j]); } } return graph; } public static void main(String[] args) { // 1.输入控制(邻接矩阵处理) int[][] graph = getGraph(); int n = graph.length, m = graph[0].length; // 定义visited数组,记录已遍历的节点 boolean[][] visited = new boolean[n][m]; // 初始化默认为false // 2.调用方法获取岛屿数量(遍历每一个可能的起点) int cnt = 0; // 岛屿数量 for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (!visited[i][j] && graph[i][j] == 1) { // 当前节点没有被遍历过且为陆地,则以该点为起点进行dfs cnt++; dfs(graph, visited, i, j); // 递归检索 } } } // 返回结果 System.out.println("岛屿数量:" + cnt); } }
【版本2】在处理调用递归方法(
for
选择时)时进行标记,处理的是nextX,nextY
- 对比上述递归方法处理在方法前面明确了递归出口(递归终止条件),此处的递归终止条件则是写在了调用
dfs
的地方,如果遇到不合法的方向不会调用dfs
(按照正常的递归模板思路参考上述代码,也要理解此处的模板思路中递归终止条件的设定)
/** * 099 岛屿数量 */ public class Solution1 { static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上 /** * DFS * * @param graph 邻接矩阵 * @param visited 遍历标记(如果已遍历的元素则进行标记) * @param x 当前遍历坐标x * @param y 当前遍历坐标y */ public static void dfs(int[][] graph, boolean[][] visited, int x, int y) { // 递归处理(往4个方向进行检索,此处选择列表为4个方向,遇到边界可跳过) for (int i = 0; i < 4; i++) { // 计算下一个要选择遍历的坐标 int nextX = x + dir[i][0]; int nextY = y + dir[i][1]; // 判断坐标是否越界(x∈[0,graph.length),y∈[0,graph[0].length)) if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) { continue; // 坐标越界,跳过当前选择 } // 递归处理(如果当前选择节点没有被遍历过,且为陆地,则将其置为true并递归检索下一个连接的陆地) if (!visited[nextX][nextY] && graph[nextX][nextY] == 1) { visited[nextX][nextY] = true; dfs(graph, visited, nextX, nextY); } } } // 输入控制,封装邻接矩阵 public static int[][] getGraph() { Scanner sc = new Scanner(System.in); System.out.println("输入整数N(矩阵行)、M(矩阵列)"); String[] nm = sc.nextLine().trim().split("\\s+"); int n = Integer.valueOf(nm[0]), m = Integer.valueOf(nm[1]); // 定义邻接矩阵 int[][] graph = new int[n][m]; System.out.println("输入N行,每行包含M个数字(数字为1或0)"); for (int i = 0; i < n; i++) { String[] input = sc.nextLine().trim().split("\\s+"); for (int j = 0; j < input.length; j++) { graph[i][j] = Integer.valueOf(input[j]); } } return graph; } public static void main(String[] args) { // 1.输入控制(邻接矩阵处理) int[][] graph = getGraph(); int n = graph.length, m = graph[0].length; // 定义visited数组,记录已遍历的节点 boolean[][] visited = new boolean[n][m]; // 初始化默认为false // 2.调用方法获取岛屿数量(遍历每一个可能的起点) int cnt = 0; // 岛屿数量 for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (!visited[i][j] && graph[i][j] == 1) { // 当前节点没有被遍历过且为陆地,则以该点为起点进行dfs visited[i][j] = true; // 将当前节点标记为已遍历 cnt++; dfs(graph, visited, i, j); // 递归检索 } } } // 返回结果 System.out.println("岛屿数量:" + cnt); } }
- 对比上述递归方法处理在方法前面明确了递归出口(递归终止条件),此处的递归终止条件则是写在了调用
两种版本的写法可以理解为递归出口的处理时机不同:
- 【版本1】中规中矩的递归处理模板,对于任意节点直接调用
dfs
方法,在dfs
方法中进行节点校验和递归终止条件的判断,如果节点不合法则直接return
- 【版本2】在调用
dfs
递归方法的时候先进行节点校验,确保传入dfs
的所有节点都是合法节点
所以有时候一些dfs
方法版本中有些会显式写递归出口,而有些方法版本连终止条件都没有,本质原因就是两种写法,前者是在递归的时候控制(直接调用dfs
,在dfs
方法内部进行递归校验),后者是在调用递归方法时进行控制(先进行节点校验,后调用dfs
)
(leetcode)深搜的简化版本实现(推荐🩵)(通过标记渲染:例如此处不用额外定义boolean进行标记,而是将陆地状态修改为
'2'
即可)
DFS 搜索核心分析:
① 递归出口:节点越界(
(x,y)
越界,遇到非陆地或者已经遍历过的陆地区域(区域状态为0
或者2
的土地))② 节点处理:将当前区域标记为已遍历,并进一步计算下个节点的坐标以递归搜索其下个节点
③ 递归搜索:
dfs(grid,nextX,nextY)
与前面的版本实际上是大同小异,此处由递归出口控制节点坐标校验和递归终止条件的判断,通过更改土地状态来标记标记已遍历的节点,版本会更加简化清晰
/**
* 🟡 200 岛屿数量
*/
public class Solution200_01 {
// 定义遍历的4个方向
int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}};
/**
* 思路分析:遍历每块未被访问过的陆地区域,基于该点作为起点对周边区域进行遍历渲染
* - ① 计数:通过寻找一个岛屿起点,然后对其连接的周边区域进行渲染,一个起点就是对应一个岛屿
* - ② 重复问题:因为在渲染的过程中只要找到1个起点会对已经遍历的区域进行比较,因此可以确保对于同一个岛屿不会被遍历多次
*/
public int numIslands(char[][] grid) {
int cnt = 0;
// 遍历矩阵的每个区域,以陆地作为起点进行搜索
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
// 如果当前遍历区域为陆地,则进一步进行深度搜索(将与该陆地相连)的区域进行渲染
if (grid[i][j] == '1') {
dfs(grid, i, j);
cnt++; // 岛屿面积+1
}
}
}
// 返回岛屿数量
return cnt;
}
// DFS检索
private void dfs(char[][] grid, int x, int y) {
int m = grid.length, n = grid[0].length;
// 如果节点越界、非陆地 或者当前区域已经被遍历过则退出
if (x < 0 || x >= m || y < 0 || y >= n || grid[x][y] == '0' || grid[x][y] == '2') {
return;
}
// 更新当前节点遍历状态(标记未2表示该陆地已经被遍历过)
if (grid[x][y] == '1') {
grid[x][y] = '2';
}
// 处理节点:获取nextX、nextY
for (int i = 0; i < 4; i++) { // 往4个方向进行检索
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 递归调用方法进行遍历
dfs(grid, nextX, nextY);
}
}
}
👻方法2:广搜版(BFS)
思路分析:
基于队列版本的BFS,此处需注意细节,对于已遍历节点的标记处理,此处应为加入队列的时候就进行标记,而不是从队列中取出的时候才进行标记。如果是从队列中取出才进行标记,那么在遍历四个方向的时候就会导致重复遍历的问题,参考下述图示(当遍历节点右下方的节点时又会继续向四周扩散,就会导致节点重复加入)
/**
* 099 岛屿数量
*/
public class Solution3 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
/**
* BFS
*
* @param graph 邻接矩阵
* @param visited 遍历标记(如果已遍历的元素则进行标记)
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void bfs(int[][] graph, boolean[][] visited, int x, int y) {
// 构建辅助队列
Queue<Pair> queue = new LinkedList<>();
queue.add(new Pair(x, y)); // 初始化队列
visited[x][y] = true; // 只要加入队列就立刻进行标记(避免重复遍历标记的情况)
// 队列不为空时进行遍历
while (!queue.isEmpty()) {
// 取出当前节点
Pair curPair = queue.poll();
int curX = curPair.x;
int curY = curPair.y;
// 往四个方向进行遍历
for (int i = 0; i < 4; i++) {
int nextX = curX + dir[i][0];
int nextY = curY + dir[i][1];
// 判断节点是否超出边界,如果超界则跳过
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue;
}
// 将节点加入队列(在加入队列的同时标记该节点的遍历状态)
if (!visited[nextX][nextY] && graph[nextX][nextY] == 1) {
queue.offer(new Pair(nextX, nextY));
visited[nextX][nextY] = true; // 只要加入队列就立刻进行标记(避免重复遍历标记的情况)
}
}
}
}
// 输入控制,封装邻接矩阵
public static int[][] getGraph() {
Scanner sc = new Scanner(System.in);
System.out.println("输入整数N(矩阵行)、M(矩阵列)");
String[] nm = sc.nextLine().trim().split("\\s+");
int n = Integer.valueOf(nm[0]), m = Integer.valueOf(nm[1]);
// 定义邻接矩阵
int[][] graph = new int[n][m];
System.out.println("输入N行,每行包含M个数字(数字为1或0)");
for (int i = 0; i < n; i++) {
String[] input = sc.nextLine().trim().split("\\s+");
for (int j = 0; j < input.length; j++) {
graph[i][j] = Integer.valueOf(input[j]);
}
}
return graph;
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
int[][] graph = getGraph();
int n = graph.length, m = graph[0].length;
// 定义visited数组,记录已遍历的节点
boolean[][] visited = new boolean[n][m]; // 初始化默认为false
// 2.调用方法获取岛屿数量(遍历每一个可能的起点)
int cnt = 0; // 岛屿数量
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (!visited[i][j] && graph[i][j] == 1) { // 当前节点没有被遍历过且为陆地,则以该点为起点进行dfs
cnt++;
bfs(graph, visited, i, j); // 广度检索:将与其连接的陆地都标记上true
}
}
}
// 返回结果
System.out.println("岛屿数量:" + cnt);
}
}
其他版本简化(标记法)
/**
* 🟡 200 岛屿数量
* - BFS 版本
*/
public class Solution200_02 {
// 自定义坐标类
static class Pair {
int x;
int y;
Pair(int x, int y) {
this.x = x;
this.y = y;
}
}
// 定义遍历的4个方向
int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}};
/**
* 思路分析:通过队列辅助遍历,遍历每个可能的岛屿起点,基于该起点搜索其关联的区域并标记
*/
public int numIslands(char[][] grid) {
int cnt = 0;
// 遍历每个可能的岛屿起点(没有被遍历过的陆地)
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
if (grid[i][j] == '1') {
bfs(grid, i, j); // 调用BFS进行搜索,渲染与该陆地相连的区域
cnt++; // 岛屿+1
}
}
}
// 返回岛屿数量
return cnt;
}
// BFS 检索(表示以(x,y)为起点进行广搜:只有没有被遍历过的陆地节点才需要进行广搜,避免重复搜索)
private void bfs(char[][] grid, int x, int y) {
int m = grid.length, n = grid[0].length;
// 定义Queue辅助图遍历
Queue<Pair> queue = new LinkedList<>();
queue.offer(new Pair(x, y)); // 初始化队列
grid[x][y] = '2'; // 只要加入队列就立刻进行标记
// 当队列不为空,遍历节点
while (!queue.isEmpty()) {
// 取出当前节点
Pair cur = queue.poll();
int curX = cur.x;
int curY = cur.y;
// 往当前节点的4个方向进行遍历
for (int i = 0; i < 4; i++) {
// 获取当前节点的下一个坐标
int nextX = curX + dir[i][0];
int nextY = curY + dir[i][1];
// 将节点加入队列(加入队列前进行节点校验和标记,避免不同方向的重复检索)
if (nextX < 0 || nextX >= m || nextY < 0 || nextY >= n) {
continue; // 当前节点越界,跳过
}
// 如果当前节点为没有被标记(遍历)过的陆地,则将其进行标记并加入队列
if (grid[nextX][nextY] == '1') { // '2'表示陆地被遍历过的标记
grid[nextX][nextY] = '2'; // 只要加入队列就立刻进行标记
queue.offer(new Pair(nextX, nextY));
}
}
}
}
}
🟡KMW100-岛屿的最大面积(695-岛屿的最大面积)
1.题目内容
题目描述
给定一个由 1(陆地)和 0(水)组成的矩阵,计算岛屿的最大面积。岛屿面积的计算方式为组成岛屿的陆地的总数。岛屿由水平方向或垂直方向上相邻的陆地连接而成,并且四周都是水域。你可以假设矩阵外均被水包围。
输入描述
第一行包含两个整数 N, M,表示矩阵的行数和列数。后续 N 行,每行包含 M 个数字,数字为 1 或者 0,表示岛屿的单元格。
输出描述
输出一个整数,表示岛屿的最大面积。如果不存在岛屿,则输出 0。
输入示例
4 5
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1
输出示例:4
2.题解思路
结合题意分析可以看到,回归到DFS、BFS的解题思路,实际就是在遍历的过程中看每个岛屿的大小,即以每个可能的起点关联的陆地有多少个,取每个岛屿的最大面积即可。可以直接在原有099-岛屿数量
的检索模板中直接进行改造,定义area
用于记录每个岛屿的面积
核心思路是先确定每个"岛屿",然后记录这个岛屿的面积,也就是说遇到新的岛屿就重置计数器area
,在内部遍历岛屿的方法(dfs
、bfs
方法的适当位置进行计数统计(当标记已遍历节点的时候,紧接着进行area
统计,跟着遍历节点标记走不易出错)),岛屿遍历完成则更新maxArea
值
参考示例:定义类变量static int area = 0; // 当前遍历岛屿面积
👻方法1:DFS版本
- 【版本1】改造
/**
* 100 岛屿的最大面积
*/
public class Solution1 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
static int area = 0; // 当前遍历岛屿面积
/**
* DFS
*
* @param graph 邻接矩阵
* @param visited 遍历标记(如果已遍历的元素则进行标记)
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void dfs(int[][] graph, boolean[][] visited, int x, int y) {
// 递归处理(往4个方向进行检索,此处选择列表为4个方向,遇到边界可跳过)
for (int i = 0; i < 4; i++) {
// 计算下一个要选择遍历的坐标
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 判断坐标是否越界(x∈[0,graph.length),y∈[0,graph[0].length))
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue; // 坐标越界,跳过当前选择
}
// 递归处理(如果当前选择节点没有被遍历过,且为陆地,则将其置为true并递归检索下一个连接的陆地)
if (!visited[nextX][nextY] && graph[nextX][nextY] == 1) {
visited[nextX][nextY] = true;
area++; // 匹配,当前岛屿面积+1
dfs(graph, visited, nextX, nextY);
}
}
}
// 输入控制,封装邻接矩阵
public static int[][] getGraph() {
Scanner sc = new Scanner(System.in);
System.out.println("输入整数N(矩阵行)、M(矩阵列)");
String[] nm = sc.nextLine().trim().split("\\s+");
int n = Integer.valueOf(nm[0]), m = Integer.valueOf(nm[1]);
// 定义邻接矩阵
int[][] graph = new int[n][m];
System.out.println("输入N行,每行包含M个数字(数字为1或0)");
for (int i = 0; i < n; i++) {
String[] input = sc.nextLine().trim().split("\\s+");
for (int j = 0; j < input.length; j++) {
graph[i][j] = Integer.valueOf(input[j]);
}
}
return graph;
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
int[][] graph = getGraph();
int n = graph.length, m = graph[0].length;
// 定义visited数组,记录已遍历的节点
boolean[][] visited = new boolean[n][m]; // 初始化默认为false
// 2.调用方法获取岛屿数量(遍历每一个可能的起点)
int maxArea = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
area = 0; // 每遍历一个可能起点,面积重置
if (!visited[i][j] && graph[i][j] == 1) { // 当前节点没有被遍历过且为陆地,则以该点为起点进行dfs
visited[i][j] = true; // 将当前节点标记为已遍历
area++;
dfs(graph, visited, i, j); // 递归检索
}
// 更新岛屿的最大面积
maxArea = Math.max(maxArea, area); // 可以输出area确认取值情况
}
}
// 返回结果
System.out.println("最大岛屿面积:" + maxArea);
}
}
- 【版本2】改造
/**
* 100 岛屿的最大面积
*/
public class Solution2 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
static int area = 0; // 当前遍历岛屿面积
/**
* DFS
*
* @param graph 邻接矩阵
* @param visited 遍历标记(如果已遍历的元素则进行标记)
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void dfs(int[][] graph, boolean[][] visited, int x, int y) {
// 递归出口
if (visited[x][y] || graph[x][y] == 0) {
// 如果当前节点已经被遍历过,或者为非陆地则退出
return;
}
// 遍历当前节点
visited[x][y] = true;
area ++; // 岛屿面积+1
// 递归处理(往4个方向进行检索,此处选择列表为4个方向,遇到边界可跳过)
for (int i = 0; i < 4; i++) {
// 计算下一个要选择遍历的坐标
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 判断坐标是否越界(x∈[0,graph.length),y∈[0,graph[0].length))
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue; // 坐标越界,跳过当前选择
}
// 判断条件放在了方法首部,此处直接调用dfs方法
dfs(graph, visited, nextX, nextY);
}
}
// 输入控制,封装邻接矩阵
public static int[][] getGraph() {
Scanner sc = new Scanner(System.in);
System.out.println("输入整数N(矩阵行)、M(矩阵列)");
String[] nm = sc.nextLine().trim().split("\\s+");
int n = Integer.valueOf(nm[0]), m = Integer.valueOf(nm[1]);
// 定义邻接矩阵
int[][] graph = new int[n][m];
System.out.println("输入N行,每行包含M个数字(数字为1或0)");
for (int i = 0; i < n; i++) {
String[] input = sc.nextLine().trim().split("\\s+");
for (int j = 0; j < input.length; j++) {
graph[i][j] = Integer.valueOf(input[j]);
}
}
return graph;
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
int[][] graph = getGraph();
int n = graph.length, m = graph[0].length;
// 定义visited数组,记录已遍历的节点
boolean[][] visited = new boolean[n][m]; // 初始化默认为false
// 2.调用方法获取岛屿数量(遍历每一个可能的起点)
int maxArea = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
area = 0; // 对于每一个可能的岛屿起点遍历,初始化area
if (!visited[i][j] && graph[i][j] == 1) { // 当前节点没有被遍历过且为陆地,则以该点为起点进行dfs
dfs(graph, visited, i, j); // 递归检索
}
// 更新岛屿的最大面积
maxArea = Math.max(maxArea, area); // 可以输出area确认取值情况
}
}
// 返回结果
System.out.println("最大岛屿面积:" + maxArea);
}
}
(leetcode)简化版本
- 思路分析:基于【岛屿的数量】的思路,找到每个岛屿的同时计算岛屿数量(注意计数器的重置)
/**
* 🟡 695 岛屿的最大面积 - https://leetcode.cn/problems/max-area-of-island/description/
* - DFS 版本
*/
public class Solution695_01 {
int curArea = 0; // 记录当前遍历岛屿的面积
// 方向定义
int[][] dir = new int[][]{{0, 1}, {1, 0}, {-1, 0}, {0, -1}};
public int maxAreaOfIsland(int[][] grid) {
int maxArea = 0;
// 遍历每个可能的岛屿起点
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
// 重置岛屿区间计数
curArea = 0;
// 如果当前区域为未被遍历过的陆地,则其可能作为岛屿区间
if (grid[i][j] == 1) {
// 递归搜索计算岛屿区域面积
dfs(grid, i, j);
// 更新最大岛屿面积
maxArea = Math.max(maxArea, curArea);
}
}
}
// 返回结果
return maxArea;
}
private void dfs(int[][] grid, int x, int y) {
int m = grid.length, n = grid[0].length;
// 递归出口(x、y越界 || 非陆地(标记为0) || 已经被遍历过的陆地(标记为2),退出检索)
if (x < 0 || x >= m || y < 0 || y >= n || grid[x][y] == 0 || grid[x][y] == 2) {
return;
}
// 处理节点,将当前陆地区域标记为已被遍历
if (grid[x][y] == 1) {
grid[x][y] = 2;
curArea++; // 当前岛屿面积+1
}
// 递归检索关联的区域
for (int i = 0; i < 4; i++) {
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 递归搜索下个节点
dfs(grid, nextX, nextY);
}
}
}
👻方法2:BFS版本
辅助操作方法定义:用于构建输入控制
/**
* 图:输入辅助操作方法
*/
public class GraphInputUtil {
// 输入控制,封装邻接矩阵
public static int[][] getMatrixGraph(int choose) {
Scanner sc = new Scanner(System.in);
// System.out.println("请选择是否需要手动构建(1),如果非手动则返回默认测试用例");
// int choose = sc.nextInt();
if (choose != 1) {
// n=4 m=5
int[][] graph = new int[][]{
{1, 1, 0, 0, 0}, {1, 1, 0, 0, 0}, {0, 0, 1, 0, 0}, {0, 0, 0, 1, 1}
};
return graph;
}
// 手动构建
System.out.println("输入整数N(矩阵行)、M(矩阵列)");
String[] nm = sc.nextLine().trim().split("\\s+");
int n = Integer.valueOf(nm[0]), m = Integer.valueOf(nm[1]);
// 定义邻接矩阵
int[][] graph = new int[n][m];
System.out.println("输入N行,每行包含M个数字(数字为1或0)");
for (int i = 0; i < n; i++) {
String[] input = sc.nextLine().trim().split("\\s+");
for (int j = 0; j < input.length; j++) {
graph[i][j] = Integer.valueOf(input[j]);
}
}
return graph;
}
}
/**
* 100 岛屿的最大面积
*/
public class Solution3 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
static int area = 0; // 当前遍历岛屿面积
/**
* BFS
*
* @param graph 邻接矩阵
* @param visited 遍历标记(如果已遍历的元素则进行标记)
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void bfs(int[][] graph, boolean[][] visited, int x, int y) {
// 构建辅助队列
Queue<Pair> queue = new LinkedList<>();
queue.add(new Pair(x, y)); // 初始化队列
visited[x][y] = true; // 只要加入队列就立刻进行标记(避免重复遍历标记的情况)
area++; // 岛屿面积+1
// 队列不为空时进行遍历
while (!queue.isEmpty()) {
// 取出当前节点
Pair curPair = queue.poll();
int curX = curPair.x;
int curY = curPair.y;
// 往四个方向进行遍历
for (int i = 0; i < 4; i++) {
int nextX = curX + dir[i][0];
int nextY = curY + dir[i][1];
// 判断节点是否超出边界,如果超界则跳过
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue;
}
// 将节点加入队列(在加入队列的同时标记该节点的遍历状态)
if (!visited[nextX][nextY] && graph[nextX][nextY] == 1) {
queue.offer(new Pair(nextX, nextY));
visited[nextX][nextY] = true; // 只要加入队列就立刻进行标记(避免重复遍历标记的情况)
area++; // 岛屿面积+1
}
}
}
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
int[][] graph = GraphInputUtil.getMatrixGraph(0);
int n = graph.length, m = graph[0].length;
// 定义visited数组,记录已遍历的节点
boolean[][] visited = new boolean[n][m]; // 初始化默认为false
// 2.调用方法获取岛屿数量(遍历每一个可能的起点)
int maxArea = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
area = 0; // 对于每一个可能的岛屿起点遍历,初始化area
if (!visited[i][j] && graph[i][j] == 1) { // 当前节点没有被遍历过且为陆地,则以该点为起点进行dfs
bfs(graph, visited, i, j); // 广度检索:将与其连接的陆地都标记上true
}
// 更新岛屿的最大面积
maxArea = Math.max(maxArea, area); // 可以输出area确认取值情况
}
}
// 返回结果
System.out.println("最大岛屿面积:" + maxArea);
}
}
(leetcode)标记渲染版本
注意点:算法测试的时候如果发现数据差异,除却排查思路,可以适当关注下idea的代码警告,可能是由于一些细节(copy、赋值等导致的小问题)
/**
* 🟡 695 岛屿的最大面积 - https://leetcode.cn/problems/max-area-of-island/description/
* - BFS 版本
*/
public class Solution695_02 {
int curArea = 0; // 记录当前遍历岛屿的面积
// 方向定义
int[][] dir = new int[][]{{0, 1}, {1, 0}, {-1, 0}, {0, -1}};
public int maxAreaOfIsland(int[][] grid) {
int maxArea = 0;
// 遍历每个可能的岛屿起点
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
// 重置岛屿区间计数
curArea = 0;
// 如果当前区域为未被遍历过的陆地,则其可能作为岛屿区间
if (grid[i][j] == 1) {
bfs(grid, i, j); // 递归搜索计算岛屿区域面积
maxArea = Math.max(maxArea, curArea); // 更新最大岛屿面积
}
}
}
// 返回结果
return maxArea;
}
private void bfs(int[][] grid, int x, int y) {
int m = grid.length, n = grid[0].length;
// 构建队列辅助遍历
Queue<Pair> queue = new LinkedList<>();
queue.offer(new Pair(x, y)); // 初始化队列
grid[x][y] = 2; // 只要加入队列就立刻进行标记
curArea++; // 岛屿陆地面积+1
// 队列不为空,遍历节点
while (!queue.isEmpty()) {
// 取出当前节点
Pair cur = queue.poll();
int curX = cur.x;
int curY = cur.y;
// 往4个方向进行BFS检索
for (int i = 0; i < 4; i++) {
// 计算下一个相邻的节点坐标
int nextX = curX + dir[i][0];
int nextY = curY + dir[i][1];
// 校验节点坐标是否有效(无效/越界则跳过)
if (nextX < 0 || nextX >= m || nextY < 0 || nextY >= n) {
continue; // 节点无效,跳过
}
// 判断下一个节点是否为未被遍历过的陆地,是则进行标记并加入队列
if (grid[nextX][nextY] == 1) {
grid[nextX][nextY] = 2; // 标记当前陆地为已遍历(只要加入队列就立刻进行标记)
curArea++; // 岛屿陆地面积+1
queue.offer(new Pair(nextX, nextY)); // 加入队列
}
}
}
}
// 自定义节点类
static class Pair {
int x;
int y;
Pair(int x, int y) {
this.x = x;
this.y = y;
}
}
}
🟡KMW101-孤岛的总面积
1.题目内容
题目描述
给定一个由 1(陆地)和 0(水)组成的矩阵,岛屿指的是由水平或垂直方向上相邻的陆地单元格组成的区域,且完全被水域单元格包围。孤岛是那些位于矩阵内部、所有单元格都不接触边缘的岛屿。
现在你需要计算所有孤岛的总面积,岛屿面积的计算方式为组成岛屿的陆地的总数。
输入描述
第一行包含两个整数 N, M,表示矩阵的行数和列数。之后 N 行,每行包含 M 个数字,数字为 1 或者 0。
输出描述
输出一个整数,表示所有孤岛的总面积,如果不存在孤岛,则输出 0。
输入示例
4 5
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1
输出示例:1
2.题解思路
思路分析
此处孤岛的核心在于找到不接触边缘的岛屿:
【思路1】可以先将接触边缘且相邻的陆地都变成海洋(相当于更新地图),然后剩下的岛屿即为孤岛,随后再遍历得到的新图得到孤岛总面积
【思路2】正常遍历岛屿:在每个岛屿的遍历过程中,判断当前岛屿遍历节点是否接触边缘,一旦接触则非孤岛,不计入面积统计
对于每个可能的岛屿起点,如果出现越界的情况则说明其为非孤岛,因此可以根据标识来判断当前岛屿是否为孤岛。即遍历岛屿时,判断当前岛屿关联的所有陆地是否位于边缘,只要关联的陆地有一个位于边缘则说明当前岛屿非孤岛。此处相当于在【100-岛屿的最大面积】的基础上微调,遍历的同时求出岛屿的面积,并且判断是否岛屿关联陆地是否接触边缘,对孤岛面积进行累加即可(和面积area
的定位类似,全局定义islandFlag
孤岛标识,每次遍历可能的岛屿起点的时候就重置,当前起点遍历完成就更新结果)
👻方法1:(标记处理)深搜版(DFS)
DFS 版本1
/**
* 101 孤岛总面积
*/
public class Solution1 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
static int area = 0; // 当前遍历岛屿面积
static boolean islandFlag = true; // 孤岛标识(true表示为孤岛)
/**
* DFS
*
* @param graph 邻接矩阵
* @param visited 遍历标记(如果已遍历的元素则进行标记)
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void dfs(int[][] graph, boolean[][] visited, int x, int y) {
int n = graph.length, m = graph[0].length;
// 递归处理(往4个方向进行检索,此处选择列表为4个方向,遇到边界可跳过)
for (int i = 0; i < 4; i++) {
// 计算下一个要选择遍历的坐标
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 判断坐标是否越界(x∈[0,graph.length),y∈[0,graph[0].length))
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue; // 坐标越界,跳过当前选择
}
// 递归处理(如果当前选择节点没有被遍历过,且为陆地,则将其置为true并递归检索下一个连接的陆地)
if (!visited[nextX][nextY] && graph[nextX][nextY] == 1) {
visited[nextX][nextY] = true;
area++; // 匹配,当前岛屿面积+1
// 设置孤岛标识(判断节点是否在边缘)
if (nextX == 0 || nextX == n - 1 || nextY == 0 || nextY == m - 1) {
islandFlag = false;
}
dfs(graph, visited, nextX, nextY);
}
}
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
int[][] graph = GraphInputUtil.getMatrixGraph(0);
// int[][] graph = new int[][]{
// {0,1,0,0,0,0,0,0},{1,1,1,0,0,0,1,1},{0,1,1,1,0,1,1,1},{0,0,0,0,1,0,0,0},
// {0,1,0,0,1,0,0,0},{0,0,1,0,0,0,0,0},{0,1,1,0,0,1,1,0}
// };
int n = graph.length, m = graph[0].length;
// 定义visited数组,记录已遍历的节点
boolean[][] visited = new boolean[n][m]; // 初始化默认为false
// 2.调用方法获取岛屿数量(遍历每一个可能的起点)
int res = 0; // 孤岛面积统计
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
area = 0; // 每遍历一个可能起点,面积重置
islandFlag = true; // 对于每一个可能的岛屿起点遍历,初始化islandFlag为true(默认为孤岛,如果遇到边界则说明非孤岛)
if (!visited[i][j] && graph[i][j] == 1) { // 当前节点没有被遍历过且为陆地,则以该点为起点进行dfs
visited[i][j] = true; // 将当前节点标记为已遍历
area++;
// 设置孤岛标识(判断节点是否在边缘)
if (i == 0 || i == n - 1 || j == 0 || j == m - 1) {
islandFlag = false;
}
dfs(graph, visited, i, j); // 递归检索
}
// 更新孤岛的面积和
res += (islandFlag) ? area : 0; // 如果是孤岛则累加孤岛面积
}
}
// 返回结果
System.out.println("孤岛岛屿面积:" + res);
}
}
DFS 版本2
/**
* 100 岛屿的最大面积
*/
public class Solution2 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
static int area = 0; // 当前遍历岛屿面积
static boolean islandFlag = true; // 孤岛标识(true表示为孤岛)
/**
* DFS
*
* @param graph 邻接矩阵
* @param visited 遍历标记(如果已遍历的元素则进行标记)
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void dfs(int[][] graph, boolean[][] visited, int x, int y) {
int n = graph.length, m = graph[0].length;
// 递归出口
if (visited[x][y] || graph[x][y] == 0) {
// 如果当前节点已经被遍历过,或者为非陆地则退出
return;
}
// 遍历当前节点
visited[x][y] = true;
area++; // 岛屿面积+1
// 设置孤岛标识(判断节点是否在边缘)
if (x == 0 || x == n - 1 || y == 0 || y == m - 1) {
islandFlag = false;
}
// 递归处理(往4个方向进行检索,此处选择列表为4个方向,遇到边界可跳过)
for (int i = 0; i < 4; i++) {
// 计算下一个要选择遍历的坐标
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 判断坐标是否越界(x∈[0,graph.length),y∈[0,graph[0].length))
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue; // 坐标越界,跳过当前选择
}
// 判断条件放在了方法首部,此处直接调用dfs方法
dfs(graph, visited, nextX, nextY);
}
}
// 输入控制,封装邻接矩阵
public static int[][] getGraph() {
Scanner sc = new Scanner(System.in);
System.out.println("输入整数N(矩阵行)、M(矩阵列)");
String[] nm = sc.nextLine().trim().split("\\s+");
int n = Integer.valueOf(nm[0]), m = Integer.valueOf(nm[1]);
// 定义邻接矩阵
int[][] graph = new int[n][m];
System.out.println("输入N行,每行包含M个数字(数字为1或0)");
for (int i = 0; i < n; i++) {
String[] input = sc.nextLine().trim().split("\\s+");
for (int j = 0; j < input.length; j++) {
graph[i][j] = Integer.valueOf(input[j]);
}
}
return graph;
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
int[][] graph = GraphInputUtil.getMatrixGraph(0);
// int[][] graph = new int[][]{
// {0, 1, 0, 0, 0, 0, 0, 0}, {1, 1, 1, 0, 0, 0, 1, 1}, {0, 1, 1, 1, 0, 1, 1, 1}, {0, 0, 0, 0, 1, 0, 0, 0},
// {0, 1, 0, 0, 1, 0, 0, 0}, {0, 0, 1, 0, 0, 0, 0, 0}, {0, 1, 1, 0, 0, 1, 1, 0}
// };
int n = graph.length, m = graph[0].length;
// 定义visited数组,记录已遍历的节点
boolean[][] visited = new boolean[n][m]; // 初始化默认为false
// 2.调用方法获取岛屿数量(遍历每一个可能的起点)
int res = 0; // 孤岛面积统计
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
area = 0; // 对于每一个可能的岛屿起点遍历,初始化area
islandFlag = true; // 对于每一个可能的岛屿起点遍历,初始化islandFlag为true(默认为孤岛,如果遇到边界则说明非孤岛)
if (!visited[i][j] && graph[i][j] == 1) { // 当前节点没有被遍历过且为陆地,则以该点为起点进行dfs
dfs(graph, visited, i, j); // 递归检索
}
// 更新孤岛的面积和
res += (islandFlag) ? area : 0; // 如果是孤岛则累加孤岛面积
}
}
// 返回结果
System.out.println("孤岛岛屿面积:" + res);
}
}
👻方法2:(标记处理)广搜版(BFS)
/**
* 101 孤岛总面积
*/
public class Solution3 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
static int area = 0; // 当前遍历岛屿面积
static boolean islandFlag = true; // 孤岛标识(true表示为孤岛)
/**
* BFS
*
* @param graph 邻接矩阵
* @param visited 遍历标记(如果已遍历的元素则进行标记)
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void bfs(int[][] graph, boolean[][] visited, int x, int y) {
int n = graph.length, m = graph[0].length;
// 构建辅助队列
Queue<Pair> queue = new LinkedList<>();
queue.add(new Pair(x, y)); // 初始化队列
visited[x][y] = true; // 只要加入队列就立刻进行标记(避免重复遍历标记的情况)
area++; // 岛屿面积+1
// 设置孤岛标识(判断节点是否在边缘)
if (x == 0 || x == n - 1 || y == 0 || y == m - 1) {
islandFlag = false;
}
// 队列不为空时进行遍历
while (!queue.isEmpty()) {
// 取出当前节点
Pair curPair = queue.poll();
int curX = curPair.x;
int curY = curPair.y;
// 往四个方向进行遍历
for (int i = 0; i < 4; i++) {
int nextX = curX + dir[i][0];
int nextY = curY + dir[i][1];
// 判断节点是否超出边界,如果超界则跳过
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue;
}
// 将节点加入队列(在加入队列的同时标记该节点的遍历状态)
if (!visited[nextX][nextY] && graph[nextX][nextY] == 1) {
queue.offer(new Pair(nextX, nextY));
visited[nextX][nextY] = true; // 只要加入队列就立刻进行标记(避免重复遍历标记的情况)
area++; // 岛屿面积+1
// 设置孤岛标识(判断节点是否在边缘)
if (nextX == 0 || nextX == n - 1 || nextY == 0 || nextY == m - 1) {
islandFlag = false;
}
}
}
}
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
// int[][] graph = GraphInputUtil.getMatrixGraph(0);
int[][] graph = new int[][]{
{0,1,0,0,0,0,0,0},{1,1,1,0,0,0,1,1},{0,1,1,1,0,1,1,1},{0,0,0,0,1,0,0,0},
{0,1,0,0,1,0,0,0},{0,0,1,0,0,0,0,0},{0,1,1,0,0,1,1,0}
};
int n = graph.length, m = graph[0].length;
// 定义visited数组,记录已遍历的节点
boolean[][] visited = new boolean[n][m]; // 初始化默认为false
// 2.调用方法获取岛屿数量(遍历每一个可能的起点)
int res = 0; // 孤岛面积统计
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
area = 0; // 对于每一个可能的岛屿起点遍历,初始化area
islandFlag = true; // 对于每一个可能的岛屿起点遍历,初始化islandFlag为true(默认为孤岛,如果遇到边界则说明非孤岛)
if (!visited[i][j] && graph[i][j] == 1) { // 当前节点没有被遍历过且为陆地,则以该点为起点进行dfs
bfs(graph, visited, i, j); // 广度检索:将与其连接的陆地都标记上true
}
// 更新孤岛的面积和
res += (islandFlag) ? area : 0; // 如果是孤岛则累加孤岛面积
}
}
// 返回结果
System.out.println("孤岛岛屿面积:" + res);
}
}
👻方法3:(更新地图)广搜版(BFS)
思路分析
基于更新地图的思路,从地图的周边向中间进行深度遍历,将地图边缘的陆地关联的陆地不断渲染更新为0(将关联陆地置为海洋),此处处理不同于"岛屿数量"相关的求解,对于岛屿数量的遍历过程中需要注意"重复遍历"的问题导致的死循环问题或者重复计算岛屿面积,因此需要借助额外的visited
矩阵来标记已遍历的节点。此处是在遍历的过程中从边缘向中间的方向遍历,边检索边更新地图,也就是说原来为1
的位置会被置为0
,在遍历的同时同步更新地图(相当于此处置0
操作平替了原来visited
的标记作用)
- 遍历顺序:
- 第1次搜索:从周边向中间检索(如果周边的陆地接触了边缘,则继续搜索将关联的陆地变成海洋)
- 第2次搜索:地图更新后进行第2次搜索,剩下的为不接触边缘的孤岛,直接进行岛屿面积累计
- 地图更新:从边缘出发,在广搜、深搜过程中如果发现存在陆地则将陆地变为海洋(相当于标记),遇到海洋则终止本次搜索
/**
* 101 孤岛总面积
*/
public class Solution4 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
static int area = 0; // 当前遍历岛屿面积
/**
* BFS
*
* @param graph 邻接矩阵
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void bfs(int[][] graph, int x, int y) {
// 构建辅助队列
Queue<Pair> queue = new LinkedList<>();
queue.add(new Pair(x, y)); // 初始化队列
graph[x][y] = 0; // 只要加入队列就立刻进行标记(更新地图,将陆地变为海洋)
area++; // 岛屿面积+1
// 队列不为空时进行遍历
while (!queue.isEmpty()) {
// 取出当前节点
Pair curPair = queue.poll();
int curX = curPair.x;
int curY = curPair.y;
// 往四个方向进行遍历
for (int i = 0; i < 4; i++) {
int nextX = curX + dir[i][0];
int nextY = curY + dir[i][1];
// 判断节点是否超出边界,如果超界则跳过
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue;
}
// 将节点加入队列(在加入队列的同时更新地图(将陆地变为海洋))
if (graph[nextX][nextY] == 1) {
queue.offer(new Pair(nextX, nextY));
graph[nextX][nextY] = 0; // 只要加入队列就立刻进行标记(更新地图,将陆地变为海洋)
area++; // 岛屿面积+1
}
}
}
// System.out.println("BFS操作渲染完成");
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
// int[][] graph = GraphInputUtil.getMatrixGraph(0);
int[][] graph = new int[][]{
{0, 1, 0, 0, 0, 0, 0, 0}, {1, 1, 1, 0, 0, 0, 1, 1}, {0, 1, 1, 1, 0, 1, 1, 1}, {0, 0, 0, 0, 1, 0, 0, 0},
{0, 1, 0, 0, 1, 0, 0, 0}, {0, 0, 1, 0, 0, 0, 0, 0}, {0, 1, 1, 0, 0, 1, 1, 0}
};
int n = graph.length, m = graph[0].length;
// 2.调用方法获取岛屿数量(遍历每一个可能的起点)
// 从四周边缘向中间进行广搜,将边缘周边的陆地连接的陆地全部置为海洋
for (int i = 0; i < n; i++) {
// 如果边缘关联陆地,则进行搜索将其关联陆地全部置为海洋
if (graph[i][0] == 1) { // 左侧边缘判断
bfs(graph, i, 0);
}
if (graph[i][m - 1] == 1) { // 右侧边缘判断
bfs(graph, i, m - 1);
}
}
System.out.println("----------左右侧向中间搜索将边缘连接陆地置为海洋----------");
PrintUtil.printGraphMatrix(graph); // 打印处理
for (int j = 0; j < m; j++) {
// 如果边缘关联陆地,则进行搜索将其关联陆地全部置为海洋
if (graph[0][j] == 1) { // 上方边缘判断
bfs(graph, 0, j);
}
if (graph[n - 1][j] == 1) { // 下方边缘判断
bfs(graph, n - 1, j);
}
}
System.out.println("----------上下两侧向中间搜索将边缘连接陆地置为海洋----------");
PrintUtil.printGraphMatrix(graph); // 打印处理
// 重置岛屿面积计数器(经过上述操作渲染,最终地图中留存的是剩下的孤岛,正常遍历计算岛屿面积)
area = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (graph[i][j] == 1) { // 当前节点没有被遍历过且为陆地,则以该点为起点进行dfs
bfs(graph, i, j); // 广度检索:将与其连接的陆地都标记上true
}
}
}
// 返回结果
System.out.println("孤岛岛屿面积:" + area);
}
}
👻方法4:(更新地图)深搜版(DFS)
同理,基于更新地图的思路,先分别从边缘向中间进行深搜,将所有接触边缘相关的陆地变为海洋,最终留下的就是孤岛,再基于以每个可能的起点遍历孤岛(陆地),进行面积累加,即可得到孤岛总面积(此处版本选择的是明示递归出口的版本)。此处graph[i][j]置0
的操作实际上就是将陆地变为海洋,等价于原有visited[][]
标记已遍历节点的作用且用于辅助本题求孤岛总面积的思路
/**
* 101 孤岛总面积(更新地图 DFS)
*/
public class Solution5 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
static int area = 0; // 当前遍历岛屿面积
/**
* DFS
*
* @param graph 邻接矩阵
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void dfs(int[][] graph, int x, int y) {
// 递归出口
if (graph[x][y] != 1) {
return;
}
graph[x][y] = 0; // 更新地图(将陆地变为海洋)
area++; // 匹配,当前岛屿面积+1
int n = graph.length, m = graph[0].length;
// 递归处理(往4个方向进行检索,此处选择列表为4个方向,遇到边界可跳过)
for (int i = 0; i < 4; i++) {
// 计算下一个要选择遍历的坐标
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 判断坐标是否越界(x∈[0,graph.length),y∈[0,graph[0].length))
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue; // 坐标越界,跳过当前选择
}
// 递归处理(如果当前选择节点没有被遍历过,且为陆地,则将其置为true并递归检索下一个连接的陆地) 此处在递归出口进行条件控制,直接调用递归方法
dfs(graph, nextX, nextY);
}
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
// int[][] graph = GraphInputUtil.getMatrixGraph(0);
int[][] graph = new int[][]{
{0, 1, 0, 0, 0, 0, 0, 0}, {1, 1, 1, 0, 0, 0, 1, 1}, {0, 1, 1, 1, 0, 1, 1, 1}, {0, 0, 0, 0, 1, 0, 0, 0},
{0, 1, 0, 0, 1, 0, 0, 0}, {0, 0, 1, 0, 0, 0, 0, 0}, {0, 1, 1, 0, 0, 1, 1, 0}
};
int n = graph.length, m = graph[0].length;
// 2.更新地图:分别从周边出发向中间进行搜索(将与边缘接触的陆地变为海洋)
// 分别从左侧、右侧向中间遍历,更新地图
for (int i = 0; i < n; i++) {
if (graph[i][0] == 1) {
dfs(graph, i, 0);
}
if (graph[i][m - 1] == 1) {
dfs(graph, i, m - 1);
}
}
// 分别从上侧、下侧向中间遍历,更新地图
for (int j = 0; j < m; j++) {
if (graph[0][j] == 1) {
dfs(graph, 0, j);
}
if (graph[n - 1][j] == 1) {
dfs(graph, n - 1, j);
}
}
// 3.检索孤岛(地图更新完成,剩余的即为孤岛)
int res = 0; // 孤岛面积统计
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
area = 0; // 每遍历一个可能起点,面积重置
dfs(graph, i, j); // 递归检索(直接调用递归,在递归方法中判断递归出口)
res += area; // 更新孤岛的面积和
}
}
// 返回结果
System.out.println("孤岛岛屿面积:" + res);
}
}
🟡KMW102-沉没孤岛
1.题目内容
题目描述:
给定一个由 1(陆地)和 0(水)组成的矩阵,岛屿指的是由水平或垂直方向上相邻的陆地单元格组成的区域,且完全被水域单元格包围。孤岛是那些位于矩阵内部、所有单元格都不接触边缘的岛屿。
现在你需要将所有孤岛“沉没”,即将孤岛中的所有陆地单元格(1)转变为水域单元格(0)。
输入描述:
第一行包含两个整数 N, M,表示矩阵的行数和列数。
之后 N 行,每行包含 M 个数字,数字为 1 或者 0,表示岛屿的单元格。
输出描述:
输出将孤岛“沉没”之后的岛屿矩阵。
输入示例:
4 5
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1
输出示例:
1 1 0 0 0
1 1 0 0 0
0 0 0 0 0
0 0 0 1 1
2.题解思路
思路分析
对于【KMW101-孤岛总面积】的解题思路,顺延原有求岛屿面积的版本,加上一个【岛屿是否为孤岛的判断】,进而求得孤岛的累加和。还提到了一种"更新地图"的思路,这个思路用于此处【沉没孤岛】的问题求解是更为合适的。
基于【更新地图】的求解思路,此处在不借助额外的空间visited[][]
标记已遍历节点的情况下,可以延续地图更新的思路去做:
- ① 标记周边:从地图边缘向中间进行深搜/广搜,将与边缘接触的陆地标识全部置为
2
(因为最终要还原边缘的陆地形态,因此此处先用2
作标记态坐中间过渡)2
- ② 沉没孤岛:基于普通深搜/广搜的思路,将剩余的孤岛全部沉没,即将剩余的孤岛的陆地标识全部置为
0
- ③ 还原周边:再次从从地图边缘向中间进行深搜/广搜,将原来的
2
表示的陆地切回1
👻方法1:深搜版(DFS)
/**
* 102 沉没孤岛(更新地图 DFS)
*/
public class Solution1 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
/**
* DFS
*
* @param graph 邻接矩阵
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void dfs(int[][] graph, int x, int y) {
// 递归出口
if (graph[x][y] != 1) {
return;
}
graph[x][y] = 2; // 更新地图(将陆地进行标记,标记为2)
int n = graph.length, m = graph[0].length;
// 递归处理(往4个方向进行检索,此处选择列表为4个方向,遇到边界可跳过)
for (int i = 0; i < 4; i++) {
// 计算下一个要选择遍历的坐标
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 判断坐标是否越界(x∈[0,graph.length),y∈[0,graph[0].length))
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue; // 坐标越界,跳过当前选择
}
// 递归处理: 此处在递归出口进行条件控制,直接调用递归方法
dfs(graph, nextX, nextY);
}
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
// int[][] graph = GraphInputUtil.getMatrixGraph(0);
int[][] graph = new int[][]{
{0, 1, 0, 0, 0, 0, 0, 0}, {1, 1, 1, 0, 0, 0, 1, 1}, {0, 1, 1, 1, 0, 1, 1, 1}, {0, 0, 0, 0, 1, 0, 0, 0},
{0, 1, 0, 0, 1, 0, 0, 0}, {0, 0, 1, 0, 0, 0, 0, 0}, {0, 1, 1, 0, 0, 1, 1, 0}
};
int n = graph.length, m = graph[0].length;
// 2.更新地图:分别从周边出发向中间进行搜索(将与边缘接触的陆地变为海洋) (步骤①)
// 分别从左侧、右侧向中间遍历,更新地图
for (int i = 0; i < n; i++) {
if (graph[i][0] == 1) {
dfs(graph, i, 0);
}
if (graph[i][n - 1] == 1) {
dfs(graph, i, n - 1);
}
}
// 分别从上侧、下侧向中间遍历,更新地图
for (int j = 0; j < m; j++) {
if (graph[0][j] == 1) {
dfs(graph, 0, j);
}
if (graph[n - 1][j] == 1) {
dfs(graph, n - 1, j);
}
}
// 3.根据标记重置地图
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (graph[i][j] == 1) {
graph[i][j] = 0; // 经过上述地图更新,此处剩余的陆地均为孤岛范畴,可以直接将孤岛沉没 (步骤②)
}
if (graph[i][j] == 2) {
graph[i][j] = 1; // 经过上述地图更新,被标记为2的地域是原来与边缘相接的陆地,将其重置为1(步骤③)
}
}
}
// 4.输出最终更新的地图(沉没孤岛)
PrintUtil.printGraphMatrix(graph);
}
}
👻方法2:广搜版(BFS)
/**
* 102 沉没孤岛(更新地图 BFS)
*/
public class Solution2 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义遍历的4个方向加成(往4个方向进行检索)右、下、左、上
/**
* BFS
*
* @param graph 邻接矩阵
* @param x 当前遍历坐标x
* @param y 当前遍历坐标y
*/
public static void bfs(int[][] graph, int x, int y) {
// 构建辅助队列
Queue<Pair> queue = new LinkedList<>();
queue.add(new Pair(x, y)); // 初始化队列
graph[x][y] = 2; // 更新地图(将陆地进行标记,标记为2)
// 队列不为空时进行遍历
while (!queue.isEmpty()) {
// 取出当前节点
Pair curPair = queue.poll();
int curX = curPair.x;
int curY = curPair.y;
// 往四个方向进行遍历
for (int i = 0; i < 4; i++) {
int nextX = curX + dir[i][0];
int nextY = curY + dir[i][1];
// 判断节点是否超出边界,如果超界则跳过
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
continue;
}
// 将节点加入队列(在加入队列的同时更新地图(将陆地变为海洋))
if (graph[nextX][nextY] == 1) {
queue.offer(new Pair(nextX, nextY));
graph[nextX][nextY] = 2; // 更新地图(将陆地进行标记,标记为2)
}
}
}
}
public static void main(String[] args) {
// 1.输入控制(邻接矩阵处理)
// int[][] graph = GraphInputUtil.getMatrixGraph(0);
int[][] graph = new int[][]{
{0, 1, 0, 0, 0, 0, 0, 0}, {1, 1, 1, 0, 0, 0, 1, 1}, {0, 1, 1, 1, 0, 1, 1, 1}, {0, 0, 0, 0, 1, 0, 0, 0},
{0, 1, 0, 0, 1, 0, 0, 0}, {0, 0, 1, 0, 0, 0, 0, 0}, {0, 1, 1, 0, 0, 1, 1, 0}
};
int n = graph.length, m = graph[0].length;
// 2.更新地图:分别从周边出发向中间进行搜索(将与边缘接触的陆地变为海洋) (步骤①)
// 分别从左侧、右侧向中间遍历,更新地图
for (int i = 0; i < n; i++) {
// 如果边缘关联陆地,则进行搜索将其关联陆地全部置为海洋
if (graph[i][0] == 1) { // 左侧边缘判断
bfs(graph, i, 0);
}
if (graph[i][m - 1] == 1) { // 右侧边缘判断
bfs(graph, i, m - 1);
}
}
System.out.println("----------左右侧向中间搜索将边缘连接陆地置为海洋----------");
PrintUtil.printGraphMatrix(graph); // 打印处理
// 分别从上侧、下侧向中间遍历,更新地图
for (int j = 0; j < m; j++) {
// 如果边缘关联陆地,则进行搜索将其关联陆地全部置为海洋
if (graph[0][j] == 1) { // 上方边缘判断
bfs(graph, 0, j);
}
if (graph[n - 1][j] == 1) { // 下方边缘判断
bfs(graph, n - 1, j);
}
}
System.out.println("----------上下两侧向中间搜索将边缘连接陆地置为海洋----------");
PrintUtil.printGraphMatrix(graph); // 打印处理
// 3.根据标记重置地图
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (graph[i][j] == 1) {
graph[i][j] = 0; // 经过上述地图更新,此处剩余的陆地均为孤岛范畴,可以直接将孤岛沉没 (步骤②)
}
if (graph[i][j] == 2) {
graph[i][j] = 1; // 经过上述地图更新,被标记为2的地域是原来与边缘相接的陆地,将其重置为1(步骤③)
}
}
}
// 4.输出最终更新的地图(沉没孤岛)
System.out.println("----------沉没孤岛后的更新地图----------");
PrintUtil.printGraphMatrix(graph);
}
}
🟡103-水流问题
1.题目内容
题目描述:
现有一个 N × M 的矩阵,每个单元格包含一个数值,这个数值代表该位置的相对高度。矩阵的左边界和上边界被认为是第一组边界,而矩阵的右边界和下边界被视为第二组边界。
矩阵模拟了一个地形,当雨水落在上面时,水会根据地形的倾斜向低处流动,但只能从较高或等高的地点流向较低或等高并且相邻(上下左右方向)的地点。我们的目标是确定那些单元格,从这些单元格出发的水可以达到第一组边界和第二组边界。
输入描述:
第一行包含两个整数 N 和 M,分别表示矩阵的行数和列数。
后续 N 行,每行包含 M 个整数,表示矩阵中的每个单元格的高度。
输出描述:
输出共有多行,每行输出两个整数,用一个空格隔开,表示可达第一组边界和第二组边界的单元格的坐标,输出顺序任意。

输入示例:
5 5
1 3 1 2 4
1 2 1 3 2
2 4 7 2 1
4 5 6 1 1
1 4 1 2 1
输出示例:
0 4
1 3
2 2
3 0
3 1
3 2
4 0
4 1
2.题解思路(可达边界的节点路径)
思路分析
路径检索:遍历每个节点(x,y)
,判断每个节点是否可以到达第1组边界、第二组边界。其核心思路可以拆分为两个步骤:
- ① 节点搜索(DFS、BFS):间遍历每个节点,确定每个节点检索的方向(4个方向尝试,判断是否满足水往低处流的条件),记录已经遍历的节点
- ② 检查边界/检查搜索结果(即检查第1、2组边界的节点是否被遍历过):判断当前已经遍历的节点路径是否可以触达两个边界,如果满足则说明这个节点满足,输出节点
👻方法1:节点路径检索(DFS | BFS)
DFS 版本
/**
* 103 水流问题
* 水只能流向更低的相邻节点
*/
public class Solution11 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}};// 四个方向
/**
* dfs 深搜
*
* @param graph 二维矩阵
* @param visited 已遍历节点
* @param x、y 当前遍历节点
*/
public static void dfs(int[][] graph, boolean[][] visited, int x, int y) {
if (visited[x][y]) {
return;
}
visited[x][y] = true; // 标记已遍历节点
// 分别从4个方向检索
int n = graph.length, m = graph[0].length;
for (int i = 0; i < 4; i++) {
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 判断是否越界
if (nextX < 0 || nextX >= n || nextY < 0 || nextY >= m) {
continue; // 越界则跳过
}
// 判断是否满足流向规则
if (graph[x][y] < graph[nextX][nextY]) {
continue; // 当前遍历节点比下一选择节点要小,不满足流向规则
}
// 递归调用
dfs(graph, visited, nextX, nextY);
}
}
/**
* 校验节点是否可以到达边界(第1组边界、第2组边界)
*/
public static boolean vaildPair(int[][] graph, int x, int y) {
int n = graph.length, m = graph[0].length;
boolean[][] visited = new boolean[n][m]; // 每次检索重置遍历矩阵
// 对当前节点进行dfs操作
dfs(graph, visited, x, y);
// 根据当前搜索的结果,判断当前已遍历的节点是否触达边界
boolean firstBorder = false, secondBorder = false;
// 按行判断左右边界是否触达
for (int i = 0; i < n; i++) {
if (visited[i][0]) {
firstBorder = true; // 第1组边界(左)
break;// 只要找到一个触达边界即可满足
}
}
for (int i = 0; i < n; i++) {
if (visited[i][m - 1]) {
secondBorder = true; // 第二组边界(右)
break;
}
}
// 按列判断上下边界是否触达
for (int j = 0; j < m; j++) {
if (visited[0][j]) {
firstBorder = true; // 第1组边界(上)
break;
}
}
for (int j = 0; j < m; j++) {
if (visited[n - 1][j]) {
secondBorder = true; // 第2组边界(下)
break;
}
}
// 如果两个边界均可触达,则当前路径有效
return firstBorder && secondBorder;
}
public static void main(String[] args) {
// 1.输入控制
int[][] graph = {
{1, 3, 1, 2, 4}, {1, 2, 1, 3, 2}, {2, 4, 7, 2, 1},
{4, 5, 6, 1, 1,}, {1, 4, 1, 2, 1}
};
int n = graph.length, m = graph[0].length;
// 2.判断每个节点是否可以同时到达第1组边界和第2组边界,如果可以则输出
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (vaildPair(graph, i, j)) {
System.out.println("i:" + i + " j:" + j);
}
}
}
}
}
BFS 版本
两种方式的实现方式在于基于每个起点的搜索方法不同,一个是基于DFS、一个是基于BFS,其他都是完全一致的
/**
* 103 水流问题
* 水只能流向更低的相邻节点
*/
public class Solution12 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}};// 四个方向
/**
* bfs 广搜
*
* @param graph 二维矩阵
* @param visited 已遍历节点
* @param x、y 当前遍历节点
*/
public static void bfs(int[][] graph, boolean[][] visited, int x, int y) {
int n = graph.length, m = graph[0].length;
// 构建辅助队列
Queue<Pair> queue = new LinkedList<>();
queue.offer(new Pair(x, y)); // 初始化
visited[x][y] = true; // 只要入队就立刻标记
// 遍历队列
while (!queue.isEmpty()) {
// 取出节点
Pair curPair = queue.poll();
int curX = curPair.x;
int curY = curPair.y;
// 分别向4个方向检索
for (int i = 0; i < 4; i++) {
int nextX = curX + dir[i][0];
int nextY = curY + dir[i][1];
// 判断是否越界
if (nextX < 0 || nextX >= n || nextY < 0 || nextY >= m) {
continue; // 越界则跳过
}
// 判断是否满足流向规则
if (graph[curX][curY] < graph[nextX][nextY]) {
continue; // 当前遍历节点比下一选择节点要小,不满足流向规则
}
// 如果节点没有被遍历过则加入队列并进行标记
if (!visited[nextX][nextY]) {
// 满足条件则加入队列并标记
queue.offer(new Pair(nextX, nextY));
visited[nextX][nextY] = true; // 只要入队就立刻标记
}
}
}
}
/**
* 校验节点是否可以到达边界(第1组边界、第2组边界)
*/
public static boolean vaildPair(int[][] graph, int x, int y) {
int n = graph.length, m = graph[0].length;
boolean[][] visited = new boolean[n][m]; // 每次检索重置遍历矩阵
// 对当前节点进行bfs操作
bfs(graph, visited, x, y);
// 根据当前搜索的结果,判断当前已遍历的节点是否触达边界
boolean firstBorder = false, secondBorder = false;
// 按行判断左右边界是否触达
for (int i = 0; i < n; i++) {
if (visited[i][0]) {
firstBorder = true; // 第1组边界(左)
break;// 只要找到一个触达边界即可满足
}
}
for (int i = 0; i < n; i++) {
if (visited[i][m - 1]) {
secondBorder = true; // 第二组边界(右)
break;
}
}
// 按列判断上下边界是否触达
for (int j = 0; j < m; j++) {
if (visited[0][j]) {
firstBorder = true; // 第1组边界(上)
break;
}
}
for (int j = 0; j < m; j++) {
if (visited[n - 1][j]) {
secondBorder = true; // 第2组边界(下)
break;
}
}
// 如果两个边界均可触达,则当前路径有效
return firstBorder && secondBorder;
}
public static void main(String[] args) {
// 1.输入控制
int[][] graph = {
{1, 3, 1, 2, 4}, {1, 2, 1, 3, 2}, {2, 4, 7, 2, 1},
{4, 5, 6, 1, 1,}, {1, 4, 1, 2, 1}
};
int n = graph.length, m = graph[0].length;
// 2.判断每个节点是否可以同时到达第1组边界和第2组边界,如果可以则输出
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (vaildPair(graph, i, j)) {
System.out.println("i:" + i + " j:" + j);
}
}
}
}
}
👻方法2:边界出发 + 汇合公共交点判断
上述的版本中,遍历每个节点,每个节点走过的路径都会标记一遍,并且判断其是否可以到达边界。分析其时间复杂度:遍历每个节点m*n
,每个节点都要做深搜m*n
,因此可以得到整体的时间复杂度为O(m*m*n*n)
,是一个四次方的时间复杂度,一旦矩阵较大,则这种检索方式肯定会超时
结合上述场景思考,如果不采用遍历每个节点
的方式,而是基于汇合
的思路,分别从两组边界出发,然后得到两组边界遍历结果的交集
。也就是说如果从第一组边界的节点出发走过的节点,和第二组边界节点出发走过的节点可以汇合,那么这个公共节点是可达两个边界的,核心思路整理说明如下:
- ① 边界节点搜索(DFS、BFS):遍历每个节点,确定每个节点检索的方向(4个方向尝试,注意此处搜索的水流方向是从低到高),记录已经遍历的节点
- ② 检查2个边界遍历节点交集(即检查经由两个边界节点出发,从低到高向中间遍历之后两个标记集合的交集):如果两个边界标记矩阵存在公共节点,说明这个公共节点既可以到达边界1,又可以到达边界2,因此公共节点就是一个汇合点
DFS 版本
时间复杂度初分析:递归搜索(O(n×m)),第1个for循环O(n×(n×m));第2个for循环O(m×(n×m)) =》 理论上总时间复杂度((m+n)×(n×m))
但实际上对于firstBorder、secondBorder对于相应边界节点为起点开始搜索是共用的,因此在边界节点遍历的时候如果节点已经被遍历过就不会再重复遍历,因此firstBorder、secondBorder用于分别限定从各自边界出发的节点搜索,已经遍历过的节点不会再重复遍历。也就是说例如以第1组边界的某个节点出发,搜索传入的是firstBorder记录已遍历节点,那么继续以第1组边界的下个节点出发,还是传入firstBorder,则针对已遍历节点不会重复遍历,所以实际上对于第1组边界节点的深搜其复杂度为O(n×m),同理对于第2组边界节点的深搜其复杂度为O(n×m) =》 总时间复杂度(2 ×(n×m))
/**
* 103 水流问题
* 水只能流向更低的相邻节点
*/
public class Solution21 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}};// 四个方向
/**
* dfs 深搜
*
* @param graph 二维矩阵
* @param visited 已遍历节点
* @param x、y 当前遍历节点
*/
public static void dfs(int[][] graph, boolean[][] visited, int x, int y) {
if (visited[x][y]) {
return;
}
visited[x][y] = true; // 标记已遍历节点
// 分别从4个方向检索
int n = graph.length, m = graph[0].length;
for (int i = 0; i < 4; i++) {
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 判断是否越界
if (nextX < 0 || nextX >= n || nextY < 0 || nextY >= m) {
continue; // 越界则跳过
}
// 判断是否满足流向规则
if (graph[x][y] > graph[nextX][nextY]) {
continue; // 此处校验的是从低到高流向
}
// 递归调用
dfs(graph, visited, nextX, nextY);
}
}
public static void main(String[] args) {
// 1.输入控制
int[][] graph = {
{1, 3, 1, 2, 4}, {1, 2, 1, 3, 2}, {2, 4, 7, 2, 1},
{4, 5, 6, 1, 1,}, {1, 4, 1, 2, 1}
};
int n = graph.length, m = graph[0].length;
// 2.分别从第1组边界、第2组边界出发,记录已遍历节点
boolean[][] firstBorder = new boolean[n][m];
boolean[][] secondBorder = new boolean[n][m];
// 遍历行:左(第1组边界)、右(第2组边界)
for (int i = 0; i < n; i++) {
dfs(graph, firstBorder, i, 0); // 左(第1组边界)
dfs(graph, secondBorder, i, m - 1); // 右(第2组边界)
}
// 遍历列:上(第1组边界)、下(第2组边界)
for (int j = 0; j < m; j++) {
dfs(graph, firstBorder, 0, j); // 上(第1组边界)
dfs(graph, secondBorder, n - 1, j); // 下(第2组边界)
}
// 3.判断这两个标记数组是否存在公共已遍历节点,如果存在则说明这个公共节点是既可达边界1又可达边界2的
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (firstBorder[i][j] && secondBorder[i][j]) {
System.out.println("i:" + i + " j:" + j);
}
}
}
}
}
DFS 的另一个写法
/**
* 103 水流问题
* 水只能流向更低的相邻节点
*/
public class Solution211 {
/**
* dfs 深搜
*
* @param graph 二维矩阵
* @param visited 已遍历节点
* @param x、y 当前遍历节点
* @param preH 上一个遍历节点
*/
public static void dfs(int[][] graph, boolean[][] visited, int x, int y, int preH) {
int n = graph.length, m = graph[0].length;
// 遇到边界或者已经访问过的节点(先校验边界避免越界),直接返回
if ((x < 0 || x >= n || y < 0 || y >= m) || visited[x][y]) {
return;
}
// 如果不满足水流搜索方向(此处为从低到高搜索)
if (graph[x][y] < preH) {
return; // 此处校验的是从低到高流向(当前节点值要大于上一节点值,不满足则直接返回)
}
// 标记已遍历节点
visited[x][y] = true;
// 分别从4个方向检索(等价于原来的dir判断),此处将递归条件判断放在前置判断中,因此此处可以直接进行dfs递归处理
dfs(graph, visited, x, y + 1, graph[x][y]);
dfs(graph, visited, x + 1, y, graph[x][y]);
dfs(graph, visited, x, y - 1, graph[x][y]);
dfs(graph, visited, x - 1, y, graph[x][y]);
}
public static void main(String[] args) {
// 1.输入控制
int[][] graph = {
{1, 3, 1, 2, 4}, {1, 2, 1, 3, 2}, {2, 4, 7, 2, 1},
{4, 5, 6, 1, 1,}, {1, 4, 1, 2, 1}
};
int n = graph.length, m = graph[0].length;
// 2.分别从第1组边界、第2组边界出发,记录已遍历节点
boolean[][] firstBorder = new boolean[n][m];
boolean[][] secondBorder = new boolean[n][m];
// 遍历行:左(第1组边界)、右(第2组边界)
for (int i = 0; i < n; i++) {
dfs(graph, firstBorder, i, 0, Integer.MIN_VALUE); // 左(第1组边界)
dfs(graph, secondBorder, i, m - 1, Integer.MIN_VALUE); // 右(第2组边界)
}
// 遍历列:上(第1组边界)、下(第2组边界)
for (int j = 0; j < m; j++) {
dfs(graph, firstBorder, 0, j, Integer.MIN_VALUE); // 上(第1组边界)
dfs(graph, secondBorder, n - 1, j, Integer.MIN_VALUE); // 下(第2组边界)
}
// 3.判断这两个标记数组是否存在公共已遍历节点,如果存在则说明这个公共节点是既可达边界1又可达边界2的
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (firstBorder[i][j] && secondBorder[i][j]) {
System.out.println("i:" + i + " j:" + j);
}
}
}
}
}
BFS 版本
/**
* 103 水流问题
* 水只能流向更低的相邻节点
*/
public class Solution22 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}};// 四个方向
/**
* bfs 广搜
*
* @param graph 二维矩阵
* @param visited 已遍历节点
* @param x、y 当前遍历节点
*/
public static void bfs(int[][] graph, boolean[][] visited, int x, int y) {
int n = graph.length, m = graph[0].length;
// 构建辅助队列
Queue<Pair> queue = new LinkedList<>();
queue.offer(new Pair(x, y)); // 初始化
visited[x][y] = true; // 只要入队就立刻标记
// 遍历队列
while (!queue.isEmpty()) {
// 取出节点
Pair curPair = queue.poll();
int curX = curPair.x;
int curY = curPair.y;
// 分别向4个方向检索
for (int i = 0; i < 4; i++) {
int nextX = curX + dir[i][0];
int nextY = curY + dir[i][1];
// 判断是否越界
if (nextX < 0 || nextX >= n || nextY < 0 || nextY >= m) {
continue; // 越界则跳过
}
// 判断是否满足流向规则
if (graph[curX][curY] > graph[nextX][nextY]) {
continue; // 此处校验的是从低到高流向
}
// 如果节点没有被遍历过则加入队列并进行标记
if (!visited[nextX][nextY]) {
// 满足条件则加入队列并标记
queue.offer(new Pair(nextX, nextY));
visited[nextX][nextY] = true; // 只要入队就立刻标记
}
}
}
}
public static void main(String[] args) {
// 1.输入控制
int[][] graph = {
{1, 3, 1, 2, 4}, {1, 2, 1, 3, 2}, {2, 4, 7, 2, 1},
{4, 5, 6, 1, 1,}, {1, 4, 1, 2, 1}
};
int n = graph.length, m = graph[0].length;
// 2.分别从第1组边界、第2组边界出发,记录已遍历节点
boolean[][] firstBorder = new boolean[n][m];
boolean[][] secondBorder = new boolean[n][m];
// 遍历行:左(第1组边界)、右(第2组边界)
for (int i = 0; i < n; i++) {
bfs(graph, firstBorder, i, 0); // 左(第1组边界)
bfs(graph, secondBorder, i, m - 1); // 右(第2组边界)
}
// 遍历列:上(第1组边界)、下(第2组边界)
for (int j = 0; j < m; j++) {
bfs(graph, firstBorder, 0, j); // 上(第1组边界)
bfs(graph, secondBorder, n - 1, j); // 下(第2组边界)
}
// 3.判断这两个标记数组是否存在公共已遍历节点,如果存在则说明这个公共节点是既可达边界1又可达边界2的
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (firstBorder[i][j] && secondBorder[i][j]) {
System.out.println("i:" + i + " j:" + j);
}
}
}
}
}
🟡KMW104-建造最大岛屿(🔴827-最大人工岛)
1.题目内容
题目描述:
给定一个由 1(陆地)和 0(水)组成的矩阵,你最多可以将矩阵中的一格水变为一块陆地,在执行了此操作之后,矩阵中最大的岛屿面积是多少。
岛屿面积的计算方式为组成岛屿的陆地的总数。岛屿是被水包围,并且通过水平方向或垂直方向上相邻的陆地连接而成的。你可以假设矩阵外均被水包围。
输入描述:
第一行包含两个整数 N, M,表示矩阵的行数和列数。之后 N 行,每行包含 M 个数字,数字为 1 或者 0,表示岛屿的单元格。
输出描述:
输出一个整数,表示最大的岛屿面积。
输入示例:
4 5
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1
输出示例:6
2.题解思路
👻方法1:暴力思路(0置1 + 搜索最大岛屿面积)
思路分析
最暴力的思路就是遍历每个区域,试图将海洋0
改造为陆地1
,然后计算对应改造方案相应的最大岛屿面积
。当所有的方案遍历完成,得到改造后的最大岛屿面积
此处需注意细节处理,是在地图更新后,需重新遍历地图的每个节点,得到岛屿的最大数量。而不是在遍历过程中边更新地图边计算面积(会漏掉连接的情况),核心步骤说明如下:
- ① 定义根据地图获取岛屿最大面积的方法
getMaxIslandArea
:根据当前给定的地图,基于dfs
或bfs
获取当前地图的最大岛屿面积 - ② 遍历源地图,如果遇到海域(试图将海域切为陆地并更新地图),并根据更新后的地图调用
getMaxIslandArea
得到改造后的最大岛屿面积,遍历所有的节点情况得到maxArea
时间复杂度分析:步骤①中深搜获取最大岛屿面积的时间复杂度为n*m
,而需要遍历每一个节点,对每一个海域改造后要重新以最新的地图根据步骤①计算最大岛屿面积,最坏的情况下所有遍历每个节点海域并计算最大岛屿面积,总的时间复杂度为(n*m)遍历海域节点 * (n*m)深搜计算最大岛屿面积
,总的时间复杂度为n^4
/**
* 104 建造最大人工岛
*/
public class Solution1 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义4个遍历方向(x、y坐标位移值)
static int area = 0; // 当前搜索岛屿面积值
/**
* 定义dfs检索
*
* @param graph 邻接矩阵
* @param visited 遍历标记
* @param x,y 当前遍历节点坐标
*/
public static void dfs(int[][] graph, boolean[][] visited, int x, int y) {
// 递归出口校验(如果已遍历过或非陆地则return)
if (visited[x][y] || graph[x][y] == 0) {
return;
}
visited[x][y] = true; // 标记当前节点为已遍历
area++; // 面积累加
int n = graph.length, m = graph[0].length;
// 从4个方向搜索,递归判断下一个可选择节点
for (int i = 0; i < 4; i++) {
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 校验节点是否越界,越界则跳过
if (nextX < 0 || nextX >= n || nextY < 0 || nextY >= m) {
continue; // 越界
}
// 递归处理(在递归方法前置处理递归出口,此处直接调用递归方法即可)
dfs(graph, visited, nextX, nextY);
}
}
// 获取当前地图的最大岛屿面积
public static int getMaxIslandArea(int[][] graph){
int n = graph.length, m = graph[0].length;
int maxArea = 0; // 记录最大岛屿值
boolean[][] visited = new boolean[n][m]; // 标记遍历节点,避免重复标记
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
area = 0; // 此处遍历每个可能的岛屿起点,已经遍历的元素不会重复遍历
// 递归搜索,获取当前岛屿面积
dfs(graph, visited, i, j);
maxArea = Math.max(maxArea, area); // 更新岛屿的最大面积
}
}
return maxArea;
}
public static void main(String[] args) {
// 1.输入控制
int[][] graph = GraphInputUtil.getMatrixGraph(0);
int n = graph.length, m = graph[0].length;
// 2.遍历每个节点,将"海洋"改造为"陆地",并计算当前地图更新后的最大岛屿值
int maxArea = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
// 最多可以改造一次,因此可以选择改造或者不改造,此处如果为海洋则进行改造
if (graph[i][j] == 0) {
graph[i][j] = 1; // 执行改造计划,更新地图
maxArea = Math.max(maxArea, getMaxIslandArea(graph)); // 获取地图更新后的最大岛屿面积
graph[i][j] = 0; // 恢复现场(回撤改造计划,继续遍历下一个节点)
}else{
// 获取不改造该节点时的最大岛屿面积
maxArea = Math.max(maxArea, getMaxIslandArea(graph)); // 获取地图更新后的最大岛屿面积
}
}
}
System.out.println("改造后的最大岛屿面积:" + maxArea);
}
}
可以打印语句确认每个改造计划的地图更新情况:
------------------------------start--------------------------------
执行改造计划:i:0 j:2 更新后的地图:
【0】1 1 1 0 0
【1】1 1 0 0 0
【2】0 0 1 0 0
【3】0 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:0 j:3 更新后的地图:
【0】1 1 0 1 0
【1】1 1 0 0 0
【2】0 0 1 0 0
【3】0 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:0 j:4 更新后的地图:
【0】1 1 0 0 1
【1】1 1 0 0 0
【2】0 0 1 0 0
【3】0 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:1 j:2 更新后的地图:
【0】1 1 0 0 0
【1】1 1 1 0 0
【2】0 0 1 0 0
【3】0 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:1 j:3 更新后的地图:
【0】1 1 0 0 0
【1】1 1 0 1 0
【2】0 0 1 0 0
【3】0 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:1 j:4 更新后的地图:
【0】1 1 0 0 0
【1】1 1 0 0 1
【2】0 0 1 0 0
【3】0 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:2 j:0 更新后的地图:
【0】1 1 0 0 0
【1】1 1 0 0 0
【2】1 0 1 0 0
【3】0 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:2 j:1 更新后的地图:
【0】1 1 0 0 0
【1】1 1 0 0 0
【2】0 1 1 0 0
【3】0 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:2 j:3 更新后的地图:
【0】1 1 0 0 0
【1】1 1 0 0 0
【2】0 0 1 1 0
【3】0 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:2 j:4 更新后的地图:
【0】1 1 0 0 0
【1】1 1 0 0 0
【2】0 0 1 0 1
【3】0 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:3 j:0 更新后的地图:
【0】1 1 0 0 0
【1】1 1 0 0 0
【2】0 0 1 0 0
【3】1 0 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:3 j:1 更新后的地图:
【0】1 1 0 0 0
【1】1 1 0 0 0
【2】0 0 1 0 0
【3】0 1 0 1 1
------------------------------end--------------------------------
------------------------------start--------------------------------
执行改造计划:i:3 j:2 更新后的地图:
【0】1 1 0 0 0
【1】1 1 0 0 0
【2】0 0 1 0 0
【3】0 0 1 1 1
------------------------------end--------------------------------
改造后的最大岛屿面积:6
易错点分析:需注意此处如果通过更新地图标记的方式处理已遍历的节点,有可能出现数据覆盖导致统计数据异常(部分用例通过、部分用例未覆盖)。例如在前面的案例解析中,通过设定grid[x][y]=2
达到标记节点的目的,但同时相当也也是在原数组上做了改动。因此在遍历获取每个海域位置改造的时候一定要将版本复原,即不能基于上一次遍历的结果来继续覆盖统计,而是要将数组复原成最初的grid
,可以通过改造前后打印矩阵信息观察。因此为了处理这个问题,避免原数组数据的直接覆盖,此处则采用额外visited[][]
矩阵来标记已经遍历的节点,而所谓复原只需要重置visited[][]
(重新初始化为全false)即可(否则就要重置grid
)
前面的案例中之所以没有体现出一些细节问题,是由于前面的案例只是基于一次搜索,没有涉及到多次检索、更新,所以可能没有体现数据覆盖导致的问题,而此处恰好设涉及多次对源图更新、搜索,就会受到更新影响。
/**
* 🔴 827 最大人工岛 - https://leetcode.cn/problems/making-a-large-island/
*/
public class Solution827_01 {
int[][] dir = new int[][]{{1, 0}, {0, 1}, {-1, 0}, {0, -1}};
int curArea = 0; // 当前遍历岛屿面积
// DFS
private void dfs(int[][] grid, int x, int y, boolean[][] visited) {
int m = grid.length, n = grid[0].length;
// 递归出口(节点越界或者非陆地、已遍历过的陆地)
if (x < 0 || x >= m || y < 0 || y >= n || grid[x][y] == 0 || visited[x][y]) {
return;
}
// 处理当前陆地
if (grid[x][y] == 1) {
curArea++; // 岛屿面积+1
visited[x][y] = true; // 标记节点为已遍历
}
// 递归处理其邻接节点
for (int i = 0; i < 4; i++) {
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
dfs(grid, nextX, nextY, visited);
}
}
// 获取当前地图的最大岛屿面积
public int getMaxIslandArea(int[][] grid) {
int m = grid.length, n = grid[0].length;
// 初始化visited
boolean[][] visited = new boolean[m][n];
for (boolean[] v : visited) {
Arrays.fill(v, false);
}
int maxArea = 0;
// 遍历每个节点
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
curArea = 0; // 重置岛屿面积计数器
if (grid[i][j] == 1) {
dfs(grid, i, j, visited); // 递归搜索 获取岛屿面积
maxArea = Math.max(maxArea, curArea); // 更新最大岛屿面积
}
}
}
return maxArea;
}
/**
* 思路分析:
* 1.地图更新:遍历每个海域,尝试将其改造成陆地,随后基于DFS获取更新后的地图每个岛屿的面积,获取最大值
* 2.岛屿标记:
* - 2.1 将每个岛屿的土地进行标记划分(例如岛屿1标记为1、岛屿2标记为2.....依次类推)
* - 2.2 再次遍历标记后的岛屿,尝试将海域改造成陆地,通过判断其是否邻接岛屿来计算改造更新后的岛屿面积
*/
public int largestIsland(int[][] grid) {
int m = grid.length, n = grid[0].length;
// int maxArea = 0; // 获取改造后可构成的最大岛屿面积
int maxArea = -1; // 如果没有海域可改造,那么原地图中的最大岛屿面积即所得
// 遍历每一个海域,将其改造成陆地,获取更新后的地图中的最大岛屿面积
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 0) {
System.out.print("(" + i + "," + j + ")" + "节点改造前:");
PrintUtil.printMatrix(grid);
grid[i][j] = 1; // 将海域改造成陆地
maxArea = Math.max(maxArea, getMaxIslandArea(grid));
System.out.print("(" + i + "," + j + ")" + "节点改造后:");
PrintUtil.printMatrix(grid);
grid[i][j] = 0; // 恢复现场
System.out.println("**********************************");
}
}
}
// 返回改造后的最大岛屿面积
return maxArea != -1 ? maxArea : m * n;
}
public static void main(String[] args) {
Solution827_01 s = new Solution827_01();
int[][] grid = new int[][]{{0, 0}, {0, 1}};
int res = s.largestIsland(grid);
System.out.println(res);
}
}
👻方法2:优化(标记源岛屿+遍历海域累加邻接岛屿面积)
基于方法1中每次深搜计算最大岛屿面积,实际上做了很多重复的工作(每次都要深搜重新计算岛屿面积)。此处可以采用岛屿标记的方式来介入,只需要记录一次深搜后的各个岛屿的面积,然后遍历每个海域改造后地图更新
此处visited
数组是用于记录已遍历区域的,此处mark
标记也可以替代其作用(已经遍历过的陆地其graph[i][j]
标记会被替换为相应的岛屿编号,是不为1
的),此处为了更好地体现每个变量的职责,还是单独拆分处理
/**
* 104 建造最大人工岛
*/
public class Solution2 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 定义4个遍历方向(x、y坐标位移值)
static int area = 0; // 当前搜索岛屿面积值
static int mark = 2; // 岛屿编号标记(从2开始计数)
/**
* 定义dfs检索
*
* @param graph 邻接矩阵
* @param visited 遍历标记
* @param x,y 当前遍历节点坐标
*/
public static void dfs(int[][] graph, boolean[][] visited, int x, int y) {
// 递归出口校验(如果已遍历过或非陆地则return)
if (visited[x][y] || graph[x][y] == 0) {
return;
}
visited[x][y] = true; // 标记当前节点为已遍历
area++; // 面积累加
graph[x][y] = mark; // 记录岛屿标记
int n = graph.length, m = graph[0].length;
// 从4个方向搜索,递归判断下一个可选择节点
for (int i = 0; i < 4; i++) {
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 校验节点是否越界,越界则跳过
if (nextX < 0 || nextX >= n || nextY < 0 || nextY >= m) {
continue; // 越界
}
// 递归处理(在递归方法前置处理递归出口,此处直接调用递归方法即可)
dfs(graph, visited, nextX, nextY);
}
}
// 获取当前地图的岛屿面积map(Map<岛屿编号,岛屿面积>)
public static Map<Integer, Integer> geIslandArea(int[][] graph) {
Map<Integer, Integer> map = new HashMap<>();
int n = graph.length, m = graph[0].length;
boolean[][] visited = new boolean[n][m]; // 标记遍历节点,避免重复标记
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (graph[i][j] == 1) { // 此处需限定为遍历未遍历节点,确保mark计数正确
area = 0; // 此处遍历每个可能的岛屿起点,已经遍历的元素不会重复遍历
// 递归搜索,获取当前岛屿面积
dfs(graph, visited, i, j);
// 记录岛屿编号和岛屿面积映射关系
map.put(mark, area);
mark++; // 更新下一个岛屿编号
}
}
}
return map;
}
public static void main(String[] args) {
// 1.输入控制
// int[][] graph = GraphInputUtil.getMatrixGraph(0);
int[][] graph = new int[][]{
{0, 1, 0, 0, 0, 0, 0, 0}, {1, 1, 1, 0, 0, 0, 1, 1}, {0, 1, 1, 1, 0, 1, 1, 1}, {0, 0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 0, 1, 0, 0, 0}, {0, 0, 1, 0, 0, 0, 0, 0}, {0, 1, 1, 0, 0, 1, 1, 0}
};
int n = graph.length, m = graph[0].length;
// 2.获取当前地图的每个岛屿的编号和面积
Map<Integer, Integer> map = geIslandArea(graph);
// 3.遍历每个"海域",判断其邻接的4个区域是否为归属岛屿(标记>=2表示被划分为某个岛屿),对当前海域节点进行改造则需累加周边岛屿面积
int maxArea = 0;
// 从当前岛屿列表中更新最大岛屿面积
for (int key : map.keySet()) {
maxArea = Math.max(maxArea, map.get(key));
}
// 遍历每个海域,确认改造后的最大岛屿面积
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
// 最多可以改造一次,因此可以选择改造或者不改造,此处如果为海洋则进行改造
if (graph[i][j] == 0) {
System.out.println("-------------------改造 start ------------------------");
System.out.println("当前改造海域节点:i-" + i + "\tj-" + j);
int curArea = 1;
// 判断当前海域节点邻接的4个区域是否归属岛屿,累加邻接岛屿面积
for (int k = 0; k < 4; k++) {
int nextX = i + dir[k][0];
int nextY = j + dir[k][1];
System.out.println("邻接节点:i-" + nextX + "\tj-" + nextY);
// 判断节点是否越界,越界则跳过
if (nextX < 0 || nextX >= n || nextY < 0 || nextY >= m) {
System.out.println("warning:当前方向邻接节点越界");
continue;
}
System.out.println("当前邻接节点:i-" + nextX + "\tj-" + nextY + "归属岛屿编号" + graph[nextX][nextY]);
// 判断邻接节点是否归属岛屿,累加
curArea += map.getOrDefault(graph[nextX][nextY], 0);
// 更新最大面积
maxArea = Math.max(maxArea, curArea);
}
System.out.println("-------------------改造 end ------------------------");
}
}
}
System.out.println("改造后的最大岛屿面积:" + maxArea);
}
}
时间复杂度分析,实际是n*m
+ n*m
的时间复杂度,一次是深搜获取每个岛屿编号和对应的岛屿面积并封装为map,一次是遍历每个海域
节点,判断其参与改造的话和其邻接节点所能构成的岛屿面积。且在整个遍历的过程中,对于已经遍历过的节点是不会重复去遍历的:
- 步骤① 深搜获取岛屿及其面积的关联关系时,对于已经遍历过的陆地不会重复遍历
- 步骤② 判断海域时只针对
graph[i][j]==0
的区域对其周边邻接的情况做判断(判断和累加基于Map操作,均为O(1)操作)
综合上述分析来看,整体总的时间复杂度是2*n*m
/**
* 🔴 827 最大人工岛 - https://leetcode.cn/problems/making-a-large-island/
*/
public class Solution827_02 {
int[][] dir = new int[][]{{1, 0}, {0, 1}, {-1, 0}, {0, -1}};
int curNum = 2; // 当前遍历岛屿编号(此处设定从2开始进行岛屿编号)
int curArea = 0; // 当前遍历岛屿面积
// DFS
private void dfs(int[][] grid, int x, int y, boolean[][] visited) {
int m = grid.length, n = grid[0].length;
// 递归出口(节点越界或者非陆地、已遍历过的陆地)
if (x < 0 || x >= m || y < 0 || y >= n || grid[x][y] == 0 || visited[x][y]) {
return;
}
// 处理当前陆地
if (grid[x][y] == 1) {
curArea++; // 岛屿面积+1
grid[x][y] = curNum;// 将当前陆地进行标记(标记其归属哪个岛屿)
visited[x][y] = true; // 标记节点为已遍历
}
// 递归处理其邻接节点
for (int i = 0; i < 4; i++) {
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
dfs(grid, nextX, nextY, visited);
}
}
// 封装岛屿编号和面积的映射关系
public Map<Integer, Integer> getMaxIslandArea(int[][] grid) {
int m = grid.length, n = grid[0].length;
// 封装岛屿编号和面积的映射关系
Map<Integer, Integer> map = new HashMap<>();
// 初始化visited
boolean[][] visited = new boolean[m][n];
for (boolean[] v : visited) {
Arrays.fill(v, false);
}
// 遍历每个节点
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
curArea = 0; // 重置岛屿面积计数器
if (grid[i][j] == 1) {
dfs(grid, i, j, visited); // 递归搜索 获取岛屿面积
// 当前岛屿搜索完成,封装岛屿编号和其面积
map.put(curNum, curArea);
curNum++; // 编号自增,为下一个岛屿的遍历搜索做准备
}
}
}
return map;
}
/**
* 思路分析:
* 1.地图更新:遍历每个海域,尝试将其改造成陆地,随后基于DFS获取更新后的地图每个岛屿的面积,获取最大值
* 2.岛屿标记:
* - 2.1 将每个岛屿的土地进行标记划分(例如岛屿1标记为1、岛屿2标记为2.....依次类推)
* - 2.2 再次遍历标记后的岛屿,尝试将海域改造成陆地,通过判断其是否邻接岛屿来计算改造更新后的岛屿面积
*/
public int largestIsland(int[][] grid) {
int m = grid.length, n = grid[0].length;
int maxArea = -1; // 如果没有海域可改造,那么原地图中的最大岛屿面积即所得
// ① 获取岛屿和其面积映射
System.out.println("标记前:");
PrintUtil.printMatrix(grid);
Map<Integer, Integer> map = getMaxIslandArea(grid);
// 打印标记后的岛屿信息
System.out.println("标记后:");
PrintUtil.printMatrix(grid);
// ② 遍历每一个海域,校验其是否邻接岛屿,如果临接岛屿则说明连成一片,追加岛屿面积
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 0) {
// 记录已经连接过的岛屿(即邻接的节点可能连通同一片岛屿,避免重复计算)
Set<Integer> set = new HashSet<>();
int curArea = 1; // 定义当前改造的面积
// 校验邻接节点是否为岛屿
for (int k = 0; k < 4; k++) {
int nextX = i + dir[k][0];
int nextY = j + dir[k][1];
// 如果邻接点越界,说明达到边界,跳过校验
if (nextX < 0 || nextX >= m || nextY < 0 || nextY >= n) {
continue;
}
// 如果邻接点为岛屿(在有效的岛屿编号范围内)
/*
if (grid[nextX][nextY] >= 2) {
// 追加邻接岛屿的面积
curArea += map.get(grid[nextX][nextY]);
}
*/
int islandNum = grid[nextX][nextY];
if (!set.contains(islandNum)) { // 校验该岛屿是否已经计算过
curArea += map.getOrDefault(grid[nextX][nextY], 0); // 追加岛屿面积
set.add(islandNum);
}
}
System.out.println("当前改造节点(" + i + "," + j + ")" + "改造后的面积为" + curArea);
// 更新改造后的最大面积
maxArea = Math.max(curArea, maxArea);
}
}
}
// 返回改造后的最大岛屿面积
return maxArea != -1 ? maxArea : m * n;
}
public static void main(String[] args) {
Solution827_02 s = new Solution827_02();
// int[][] grid = new int[][]{{1, 1}, {1, 0}};
int[][] grid = new int[][]{
{0, 0, 0, 0, 0, 0, 0},
{0, 1, 1, 1, 1, 0, 0},
{0, 1, 0, 0, 1, 0, 0},
{1, 0, 1, 0, 1, 0, 0},
{0, 1, 0, 0, 1, 0, 0},
{0, 1, 0, 0, 1, 0, 0},
{0, 1, 1, 1, 1, 0, 0}
};
int res = s.largestIsland(grid);
System.out.println(res);
}
}
🟡KMW110-字符串接龙
1.题目内容
题目描述
字典 strList 中从字符串 beginStr 和 endStr 的转换序列是一个按下述规格形成的序列:
- 序列中第一个字符串是 beginStr。
- 序列中最后一个字符串是 endStr。
- 每次转换只能改变一个字符。
- 转换过程中的中间字符串必须是字典 strList 中的字符串。
给你两个字符串 beginStr 和 endStr 和一个字典 strList,找到从 beginStr 到 endStr 的最短转换序列中的字符串数目。如果不存在这样的转换序列,返回 0。
输入描述
第一行包含一个整数 N,表示字典 strList 中的字符串数量。 第二行包含两个字符串,用空格隔开,分别代表 beginStr 和 endStr。 后续 N 行,每行一个字符串,代表 strList 中的字符串。
输出描述
输出一个整数,代表从 beginStr 转换到 endStr 需要的最短转换序列中的字符串数量。如果不存在这样的转换序列,则输出 0。
输入示例
6
abc def
efc
dbc
ebc
dec
dfc
yhn
输出示例:4
提示信息
从 startStr 到 endStr,在 strList 中最短的路径为 abc -> dbc -> dec -> def,所以输出结果为 4
2.题解思路
👻方法1:广搜(BFS)(无向图的最短路径)
- 思路分析:此处根据题意分析,求解的是无向图的最短路径,因此选用广搜法最为合适(因为广搜只要搜索到了终点,那么一定是最短的路径,因为广搜的算法就是以起点为中心向四周扩山的搜索,因此选用广搜)。如果选用深搜,则需要考虑在不同的深搜路径中选择一条最短的路径,而广搜遇到终点则一定是最短
- 无向图搜索:对于此处无向图的搜索,需要用到标记位,用于标记节点是否被遍历过,否则就会出现死循环
- set(快速判断元素是否存在):此处一个潜在的优化点就是判断构建的新字符串(新单词)是否在
wordList
中,可以选用Set
哈希表存储wordList
提升检索效率 - 广搜方向:即判断当前字符串要搜索的下一个字符串是什么,也就是说如果
beginStr
要变成endStr
的话,理想情况下是按部就班一步步将begin
的每个字符变成endStr
对应位置的字符,也就是说,当前搜索的方向对照的应该是每个位置上的字符变成26个字符序列中的任意一个构建成的新字符串(且这个新字符串需要在wordList
中)- 此处基于选用对每个位置的字符进行替换的思路,可以快速判断新构建的字符串是否在
wordList
中。而不需要每次都从wordList
筛选出前缀字符串相同的元素进行比较(重复比较处理较为繁琐,不如直接列举26种变化的情况)
- 此处基于选用对每个位置的字符进行替换的思路,可以快速判断新构建的字符串是否在
- Map<String,Integer>:map用于存储当前字符串遍历路径情况(此处
path
为路径大小)
/**
* KMW 110-字符串接龙
*/
public class Solution1 {
/**
* 从beginWord->endWord的转化,每次变化只能变化一个字符,且变化后的str需在wordList字符串序列中
*
* @param beginWord 源字符串
* @param endWord 目标字符串
* @param wordList 字符串序列
* @return
*/
public static int ladderLength(String beginWord, String endWord, List<String> wordList) {
return bfs(beginWord, endWord, wordList);
}
/**
* 广度优先遍历
*/
public static int bfs(String beginWord, String endWord, List<String> wordList) {
// 定义visitedSet存储已遍历元素
HashMap<String, Integer> visitedMap = new HashMap<>();
// 构建辅助队列
Queue<String> queue = new LinkedList<>();
queue.offer(beginWord); // 初始化入队
visitedMap.put(beginWord, 1); // 入队则进行标记(记录遍历状态)
// 遍历队列
while (!queue.isEmpty()) {
// 取出元素
String curStr = queue.poll();
int path = visitedMap.get(curStr);
// 对当前字符串的每个位置都尝试26个字母的替换
for (int i = 0; i < curStr.length(); i++) {
char[] curStrArr = curStr.toCharArray(); // 将字符串转为字符数组便于操作
for (char j = 'a'; j <= 'z'; j++) {
curStrArr[i] = j; // 替换对应位置字符
// 判断替换后生成的新字符串是否在wordList且没有被遍历过,如果是则加入搜索路径
String newStr = String.valueOf(curStrArr); // new String(curStrArr)
// 判断当前生成的新字符串是否为结束字符序列,是则说明找到了这个路径,直接返回
if (newStr.equals(endWord)) {
return path + 1; // 找到匹配路径,直接返回结果
}
// 继续下一个字符串搜索
if (wordList.contains(newStr) && !visitedMap.containsKey(newStr)) { // 构建的新字符串要在指定的字符串序列中,且该字符串没有被遍历过
queue.offer(newStr); // 加入搜索路径
visitedMap.put(newStr, path + 1); // 标记当前字符串已被遍历
}
}
}
}
// 队列遍历完成,没有找到匹配路径
return 0;
}
public static void main(String[] args) {
// 1.输入控制
/*
Scanner sc = new Scanner(System.in);
System.out.println("1.输入字符串序列个数");
int n = sc.nextInt();
System.out.println("2.输入源字符串和目标字符串,用空格间隔");
sc.nextLine(); // 此处冗余一层输入,分隔输入操作,避免输入处理错误
String[] inputStrArr = sc.nextLine().split("\\s+");
String beginStr = inputStrArr[0], endStr = inputStrArr[1];
System.out.println("3.按行输入字符串序列");
List<String> wordList = new ArrayList<>();
while (n-- > 0) {
wordList.add(sc.nextLine());
}
*/
String beginStr = "abc";
String endStr = "def";
List<String> wordList = new ArrayList<>();
wordList.add("efc");
wordList.add("dbc");
wordList.add("ebc");
wordList.add("dec");
wordList.add("dfc");
wordList.add("yhn");
// 2.bfs搜索
int res = Solution1.ladderLength(beginStr, endStr, wordList);
System.out.println("最短转换序列长度" + res);
}
}
🟡KMW105-有向图的完全可达性
1.题目内容
【题目描述】
给定一个有向图,包含 N 个节点,节点编号分别为 1,2,...,N。现从 1 号节点开始,如果可以从 1 号节点的边可以到达任何节点,则输出 1,否则输出 -1。
【输入描述】
第一行包含两个正整数,表示节点数量 N 和边的数量 K。 后续 K 行,每行两个正整数 s 和 t,表示从 s 节点有一条边单向连接到 t 节点。
【输出描述】
如果可以从 1 号节点的边可以到达任何节点,则输出 1,否则输出 -1。
【输入示例】
4 4
1 2
2 1
1 3
2 4
【输出示例】1
2.题解思路
👻方法1:有向图搜索全路径(DFS)
思路分析
对于无向图而言,此处可以联想到岛屿问题,对于同一个岛屿中的无向图而言,其所有节点都是可相互到达的。但是如果一个无向图中存在多个岛屿,那么多个独立的岛屿之间的节点不可达(例如A岛屿的节点不能到达B岛屿的节点)
本题是有向图的设定,所以本题的切入思路应为有向图搜索全路径,用深搜(DFS)、广搜(BFS)进行搜索
DFS 递归三部曲分析:
确定递归函数、参数
/** * graph 邻接表 * visted 已遍历元素 * key 当前遍历节点 */ void(List<List<Integer>> graph,boolean[] visited,int key){ }
确定递归终止条件
- 和KMW098-所有可达路径(DFS)版本类似,此处DFS有两种写法,主要取决于如何看待和处理要访问的节点(处理的不同体现在条件判断,本质上就是递归出口的不同写法)
- 如果处理的是当前访问的节点(明确递归出口):递归出口中明确退出条件,即在
dfs方法中
明确递归出口 - 处理下一个要访问的节点(在调用递归方法前确认下一个是没有访问过的节点):在
调用dfs方法前
就进行校验,确保调用dfs的节点都是可访问操作的节点
- 如果处理的是当前访问的节点(明确递归出口):递归出口中明确退出条件,即在
// dfs写法1:处理当前访问的节点(明确递归出口) void dfs(List<List<Integer>> graph,boolean[] visited,int key){ // 递归出口 if(visited[key]){ return; } visited[key] = true; // 标记当前节点已访问 for(int nextKey : graph.get(key)){ // 遍历当前节点关联的节点 // 直接进行dfs dfs(graph,nextKey,visited); } } // dfs写法2:处理下一个要访问的节点(在调用递归方法前确认下一个是没有访问过的节点) void dfs(List<List<Integer>> graph,boolean[] visited,int key){ for(int nextKey : graph.get(key)){ // 遍历当前节点关联的节点 if(visited[nextKey]){ continute; // 如果节点已访问则跳过 } visited[nextKey] = true; // 标记当前节点已访问 dfs(graph,cur,visited); } }
- 和KMW098-所有可达路径(DFS)版本类似,此处DFS有两种写法,主要取决于如何看待和处理要访问的节点(处理的不同体现在条件判断,本质上就是递归出口的不同写法)
递归处理
递归处理的核心思路就是标记当前节点已访问,然后递归调用下一节点,此处可以看到和【KMW098-所有可达路径】的dfs
写法中,此处少了回溯
的步骤。
因为【KMW098-所有可达路径】需要求的是所有的路径,因此需要回溯,才能正确得到所有可达路径,而本题的核心在于判断某个节点是否可以到达所有的节点,因此只要遍历过的节点就一律进行标记,将遍历过的节点一律标记上。当需要搜索一条可达路径的时候,此时则需要进行回溯
DFS 版本1:处理当前节点
此处节点的编号为1
开始,为了统一处理,此处在进行输入控制构建graph
数组的时候就将其构建为n+1
的输入(对于0号节点则直接空在那里不用即可),在遍历节点的时候则正常根据构建好的邻接表graph
进行处理
/**
* KMW105-有向图的可达路径
*/
public class Solution1 {
// dfs版本1:遍历当前节点
public static void dfs(List<List<Integer>> graph, boolean[] visited, int key) {
if (visited[key]) {
return; // 如果节点已经遍历过则return
}
visited[key] = true; // 标记节点访问状态
// 搜索节点
for (int nextKey : graph.get(key)) {
dfs(graph, visited, nextKey); // 遍历节点的邻接节点,检索路径
}
}
public static void main(String[] args) {
// 1.输入控制(输入邻接表)
List<List<Integer>> graph = GraphInputUtil.getTableGraph(0); // 1 表示手动输入控制
PrintUtil.printGraphTable(graph);
// 2.dfs 检索有向图
boolean[] visited = new boolean[graph.size()]; // 存储已遍历节点(graph存储处理范围为[0,n],即graph.size()为n个节点,此处正常处理即可)
dfs(graph, visited, 1); // 判断从1号节点出发是否可以到达其他所有节点
// 3.校验visited标记(判断节点1出发是否可以到达其他节点)
for (int i = 1; i < graph.size(); i++) { // 节点编号范围[1,graph.size()) (graph.size()的取值为n+1)
if (!visited[i]) {
System.out.println("-1" + "不可达");
return;
}
}
System.out.println("1" + "可达");
}
}
DFS 版本2:处理下一节点
在处理下一节点的时候,条件判断被放在调用dfs
方法前,也就是说如果不满足的节点会直接跳过,只有满足的未被遍历的节点才会被记录。因此需要注意在初始第一次调用dfs
方法中需要手动将起始节点状态置为已遍历(因为此处的处理是针对下一个节点,因此初始化状态要手动处理一下)
/**
* KMW105-有向图的可达路径
*/
public class Solution2 {
// dfs版本2:遍历下一节点
public static void dfs(List<List<Integer>> graph, boolean[] visited, int key) {
// 搜索节点
for (int nextKey : graph.get(key)) {
if (visited[nextKey]) {
continue; // 如果下一节点已经遍历过则跳过
}
visited[nextKey] = true; // 标记下一节点访问状态
dfs(graph, visited, nextKey); // 遍历节点的邻接节点,检索路径
}
}
public static void main(String[] args) {
// 1.输入控制(输入邻接表)
List<List<Integer>> graph = GraphInputUtil.getTableGraph(0); // 1 表示手动输入控制
PrintUtil.printGraphTable(graph);
// 2.dfs 检索有向图
boolean[] visited = new boolean[graph.size()]; // 存储已遍历节点(graph存储处理范围为[0,n],即graph.size()为n个节点,此处正常处理即可)
visited[1] = true; // 初始化先更新起始节点状态
dfs(graph, visited, 1); // 判断从1号节点出发是否可以到达其他所有节点
// 3.校验visited标记(判断节点1出发是否可以到达其他节点)
for (int i = 1; i < graph.size(); i++) { // 节点编号范围[1,graph.size()) (graph.size()的取值为n+1)
if (!visited[i]) {
System.out.println("-1" + "不可达");
return;
}
}
System.out.println("1" + "可达");
}
}
BFS 版本
/**
* KMW105-有向图的可达路径
*/
public class Solution3 {
// bfs 版本
public static void bfs(List<List<Integer>> graph, boolean[] visited, int key) {
Queue<Integer> queue = new LinkedList<>();
queue.offer(key); // 初始化队列,元素一入队就标记
visited[key] = true;
// 遍历队列,搜索节点
while (!queue.isEmpty()) {
// 取出元素
int cur = queue.poll();
// 继续搜索元素的邻接节点
for (int nextKey : graph.get(cur)) {
// 如果元素未被遍历,则进行标记
if (!visited[nextKey]) {
queue.offer(nextKey); // 节点入队
visited[nextKey] = true; // 元素一入队就标记
}
}
}
}
public static void main(String[] args) {
// 1.输入控制(输入邻接表)
List<List<Integer>> graph = GraphInputUtil.getTableGraph(0); // 1 表示手动输入控制
PrintUtil.printGraphTable(graph);
// 2.dfs 检索有向图
boolean[] visited = new boolean[graph.size()]; // 存储已遍历节点(graph存储处理范围为[0,n],即graph.size()为n个节点,此处正常处理即可)
bfs(graph, visited, 1); // 判断从1号节点出发是否可以到达其他所有节点
// 3.校验visited标记(判断节点1出发是否可以到达其他节点)
for (int i = 1; i < graph.size(); i++) { // 节点编号范围[1,graph.size()) (graph.size()的取值为n+1)
if (!visited[i]) {
System.out.println("-1" + "不可达");
return;
}
}
System.out.println("1" + "可达");
}
}
🟡KMW106-岛屿的周长(463-岛屿的周长)
1.题目内容
题目描述
给定一个由 1(陆地)和 0(水)组成的矩阵,岛屿是被水包围,并且通过水平方向或垂直方向上相邻的陆地连接而成的。
你可以假设矩阵外均被水包围。在矩阵中恰好拥有一个岛屿,假设组成岛屿的陆地边长都为 1,请计算岛屿的周长。岛屿内部没有水域。
输入描述
第一行包含两个整数 N, M,表示矩阵的行数和列数。之后 N 行,每行包含 M 个数字,数字为 1 或者 0,表示岛屿的单元格。
输出描述
输出一个整数,表示岛屿的周长。
输入示例
5 5
0 0 0 0 0
0 1 0 1 0
0 1 1 1 0
0 1 1 1 0
0 0 0 0 0
输出示例:14
2.题解思路
思路分析
岛屿问题可能最容易联想到dfs
、bfs
检索,但实际上针对本题可以不用通过搜索的方式来统计,而是观察岛屿的分布规律,结合每个区域的邻接情况来进行边长累加。可以理解为本题的设定是为了打破岛屿问题解题思路的惯性陷阱,用最原始的方法反而更易理解和掌握
👻方法1:规律遍历法(判断每个区域4个方向的周边邻接的情况)
- 思路:对于每个节点(区域)的周长,其本质上就是判断该节点和邻接节点的情况,如果其邻接节点
(nextX,nextY)
是超出边界或者为水域的情况,则周长需要计算该边。那么简单来想,如果不需要单独求出每个岛屿的周长,那么只需要循规蹈矩去遍历每个节点,然后累加有效的边长即可(除非是分别求出每个岛屿的周长,才需要考虑用BFS、DFS进行岛屿式的搜索)
/**
* KMW106 岛屿周长
*/
public class Solution1 {
static int[][] dir = new int[][]{{0, 1}, {1, 0}, {0, -1}, {-1, 0}};
/**
* getCnt 用于统计每个岛屿"陆地"的周长(邻接海域、邻接边界)
*
* @param graph 邻接矩阵
* @param x,y 当前遍历节点坐标
*/
public static int getCnt(int[][] graph, int x, int y) {
if (graph[x][y] != 1) {
return 0; // 如果为非陆地,不执行任何操作
}
int curCnt = 0;
// 分别从4个方向出发,校验当前节点邻接的4个方向的情况
for (int i = 0; i < 4; i++) {
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
// 如果邻接节点越界,则累加周长
if (nextX < 0 || nextX >= graph.length || nextY < 0 || nextY >= graph[0].length) {
curCnt++;
continue; // 跳过
}
// 如果邻接海域,则累加周长
if (graph[nextX][nextY] == 0) {
curCnt++;
}
}
// 返回当前陆地的边长计算情况
return curCnt;
}
public static void main(String[] args) {
// 1.输入控制
// int[][] graph = GraphInputUtil.getMatrixGraph(0);
// int[][] graph = new int[][]{
// {0,0,0,0,0},{0,1,0,1,0},{0,1,1,1,0},{0,1,1,1,0},{0,0,0,0,0} // 岛屿周长14
// };
int[][] graph = new int[][]{
{0, 0, 0, 0, 0}, {0, 0, 0, 0, 0}, {0, 0, 0, 0, 0}, {0, 0, 0, 1, 0}, {0, 0, 1, 1, 0} // 岛屿周长8
};
// 2.获取岛屿周长(遍历每个节点,计算陆地的累加周长)
int res = 0;
for (int i = 0; i < graph.length; i++) {
for (int j = 0; j < graph[0].length; j++) {
res += getCnt(graph, i, j); // 累加每个节点的校验结果(在方法内部进行统一处理)
}
}
// 返回结果
System.out.println("岛屿周长为:" + res);
}
}
复杂度分析
时间复杂度:O(n×m)遍历每个节点,对于每块"陆地"需要进一步校验其4个方向邻接的区域情况,因此总的时间复杂度为O(4mn)
空间复杂度:O(1)常数级别辅助空间(定义了一个
dir数组
用于控制搜索方向)
基于DFS 思路(可计算每个岛屿各自的周长)
对于分岛屿的周长计算来说,其本质上也是DFS
岛屿搜索,然后在遍历每个节点的过程中去校验每个节点的临接节点情况
/**
* 🟢 463 岛屿的周长 - https://leetcode.cn/problems/island-perimeter/description/
*/
public class Solution463_01 {
int curPerimeter = 0;
int[][] dir = new int[][]{{1, 0}, {0, 1}, {-1, 0}, {0, -1}};
public int islandPerimeter(int[][] grid) {
int totalPerimeter = 0;
// 遍历每个可能的岛屿起点
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
curPerimeter = 0; // 重置岛屿周长计数
if (grid[i][j] == 1) {
dfs(grid, i, j);
totalPerimeter += curPerimeter; // 累计各岛屿周长
}
}
}
// 返回岛屿总周长
return totalPerimeter;
}
// DFS 思路
private void dfs(int[][] grid, int x, int y) {
int m = grid.length, n = grid[0].length;
// 节点越界|非陆地|已遍历的陆地 则退出搜索
if (x < 0 || x >= m || y < 0 || y >= n || grid[x][y] != 1) { // grid[x][y] == 0 || grid[x][y] == 2
return;
}
// 标记节点
if (grid[x][y] == 1) {
// 处理当前陆地周长:判断其四周的接触情况(如果当前区域接触边缘或水域(即其邻接节点越界或者为水域)则需计算边长)
for (int i = 0; i < 4; i++) {
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
if (nextX < 0 || nextX >= m || nextY < 0 || nextY >= n) {
// 如果邻接节点越界(说明接触边缘),则需计算周长
curPerimeter++;
}else if (grid[nextX][nextY] == 0) {
// 如果邻接节点为水域,则需计算周长
curPerimeter++;
}
}
// 标记节点已处理
grid[x][y] = 2;
}
// 递归处理下一节点
for (int i = 0; i < 4; i++) {
int nextX = x + dir[i][0];
int nextY = y + dir[i][1];
dfs(grid, nextX, nextY);
}
}
}
2.并查集
🚀并查集理论基础
(1)【并查集】背景
首先要知道并查集可以解决什么问题呢?=》并查集常用来解决连通性问题
例如当需要判断两个元素是否在同一个集合里的时候,就要想到用并查集
并查集主要有下述功能:
寻找根节点:
find(int u)
构建边(将两个元素添加到一个集合中):
join(int u,int v)
(构建边v->u
)- 注意此处不同于二叉树根的概念,此处如果是
v->u
表示v节点指向u节点,则v节点的根为u节点
- 注意此处不同于二叉树根的概念,此处如果是
判断两个元素在不在同一个集合:
isSame(int u,int v)
并查集应用模板代码核心:init
(初始化)、find
(寻根)、join(u,v)
(构建边v->u
)、isSame(u,v)
(判断u
、v
是否在同一个集合),在并查集检索的时间效率优化上有两种思路:
- 【路径压缩】(推荐):
find
寻根过程中进行路径压缩father[u] = find(father[u])
- 【按秩合并】:
join
的过程中将rank
较小的树合并到rank
较大的树中
(2)原理分析
从代码层面理解,**如何将两个元素添加到同一个集合中?**正常情况下可以将其放入同一个容器(数组、集合(set、map))中以此表述两个元素在同一个集合。
【场景1】如果要对这些元素进行分门别类的话,可能涉及到不只一个集合,如果分类成百上千的情况下还要定义这么多数组或即可?基于此联想到二维数组的应用
【场景2】但如果要进一步判断两个元素是否在同一个集合中?,则需要遍历这个二维数据的所有元素;且当想要添加一个元素到指定分类时,也需要遍历二维数组才知道要放在哪个集合中
基于上述粗略的思路分析,如果沿着这个思路去实现代码,实际上是很复杂的(管理集合需要很多逻辑)。或许可以试着切换思路来看,换个方向思考
① 元素的连通性理解(判断元素是否在同一个集合中)
例如将元素A、B、C(以数字类型为例)放在一个集合中,实际上就是将这3个元素联通在一起,而这个连通性的表示可以用一个一维数组来表示:有向连通图:father[A]=B、father[B]=C
,代码如下
// 将v,u 这条边加入并查集
void join(int u, int v) {
u = find(u); // 寻找u的根
v = find(v); // 寻找v的根
if (u == v) return; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
father[v] = u;
}
对于father[A]=B
可以知道根据下标索引为A
的数值可以确定A
联通B
,那么如何知道B
联通A
呢?实际上此处的判断目的为判断这三个元素A
、B
、C
是否在同一个集合中,因此已知A
联通B
即可说明这两个元素在同一个集合中就已足够
② 寻根思路
所谓的寻根思路,实际上就是判断如果A
、B
、C
的根是同一个(在同一个根下)就是同一个集合。还是以father[A]=B、father[B]=C
分析
- 给出
A
元素:根据上述连通性公式,可以先通过A
找到B
(father[A]=B
),然后再通过B
找到C
(father[B]=C
)=> 得到根为C
- 给出
B
元素:同理,通过B
找到C
(father[B]=C
)=> 得到根为C
- 综上A、B的根最终跟踪到都是同一个
C
,因此说明A
、B
在同一个集合中
那么如何表示C
和A
、B
在同一个集合中呢?,即加上一个连通条件:fater[C]=C
,那么此时给出C
元素:元素C
的根即为自身(fater[C]=C
),则此时A
、B
、C
在同一个集合中
基于上述寻根思路分析,其在代码中的实现实际上就是通过数组下标找到数组元素,一层一层寻根的过程。在方法中递归寻根,而递归出口的条件就是father[x]=x
,即如果根为自身则退出递归,因此在初始化father[]
数组的时候要默认自己指向自己father[i]=i
,然后再构建连通关系
// 1.并查集初始化
void init() {
for (int i = 0; i < n; i++) {
father[i] = i;
}
}
// 2.并查集里寻根的过程
int find(int u) {
if (u == father[u]) return u; // 如果根就是自己,直接返回
else return find(father[u]); // 如果根不是自己,就根据数组下标一层一层向下找
}
// 3.判断u、v是否同一个集合中(即判断u、v是否指向同一个根)
boolean isSame(int u,int v){
int u = find(u);
int v = find(v);
return u = v;
}
(3)路径压缩(寻根过程中进行路径压缩,缩短查询根节点的时间)
在实现 find 函数的过程中,是通过递归的方式,不断获取father数组下标对应的数值,最终找到这个集合的根。搜索过程像是一个多叉树中从叶子到根节点的过程。但如果这个多叉树高度很深的话,每次find
函数去寻找根的过程就要递归很多次,但其实要知道这些节点是否在同一个集合,只需要知道这些节点是否在同一个根下,所以对于多叉树的构造可以进一步调整,将除了根节点之外的节点都直接挂载在对应根节点下,这样寻根只需要检索一步
要想实现这样的效果则需要路径压缩,将非根节点的所有节点都指向根节点,在递归的过程中让 father[u]
接住 递归函数 find(father[u])
的返回结果
// 并查集里寻根的过程
int find(int u) {
if (u == father[u]) return u;
else return father[u] = find(father[u]); // 路径压缩
}
// C++ 中简化后的三元表达式
return u == father[u]?u:father[u] = find(father[u]);

【路径压缩】复杂度分析:
- 空间复杂度:O(n) 需申请
father[n]
的数组 - 时间复杂度:路径压缩后的并查集时间复杂度在O(logn)与O(1)之间,且随着查询或者合并操作的增加,时间复杂度会越来越趋于O(1)。在第一次查询的时候,相当于是n叉树上从叶子节点到根节点的查询过程,时间复杂度是
logn
,但路径压缩后,后面的查询操作都是O(1),而 join 函数 和 isSame函数 里涉及的查询操作也是一样的过程
(4)代码模板
基于上述分析,可以得到并查集的代码模板,且据此可以知道并查集的三个主要功能:
- ① 寻找根节点,函数:
find(int u)
,也就是判断这个节点的祖先节点是哪个 - ② 将两个节点接入到同一个集合,函数:
join(int u, int v)
,将两个节点连在同一个根节点上 - ③ 判断两个节点是否在同一个集合,函数:
isSame(int u, int v)
,就是判断两个节点是不是同一个根节点
// 1.并查集初始化
void init() {
for (int i = 0; i < n; i++) {
father[i] = i;
}
}
// 2.并查集里寻根的过程
int find(int u) {
return u == father[u]?u:father[u] = find(father[u]); // 路径压缩
}
// 3.判断u、v是否同一个集合中(即判断u、v是否指向同一个根)
boolean isSame(int u,int v){
int u = find(u);
int v = find(v);
return u == v;
}
// 4.将v->u这条边加入并查集
void join(int u, int v) {
u = find(u); // 寻找u的根
v = find(v); // 寻找v的根
if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
father[v] == u;
}
代码模板和常见误区分析
从代码模板中可以看到,③和④方法中代码有重复,就会思考是不是可以将方法④中join
的实现简化一下?
# 代码版本(1)
// 3.判断u、v是否同一个集合中(即判断u、v是否指向同一个根)
boolean isSame(int u,int v){
int u = find(u);
int v = find(v);
return u = v;
}
// 4.将v->u这条边加入并查集
void join(int u, int v) {
if(isSame(u,v)){
return;
}
father[v] = u;
}
这样看上去好像没有什么问题,但实际上这种写法会导致错误的连通结果。而④方法的定义目的在于寻找到u
和v
的根,然后用根进行连线,而不是将u
和v
直接进行连线。实际上方法③中只是分别找到u
、v
的根,然后比较判断是否指向同一个根。为了让版本更加清晰,可以调整变量命名
# 代码版本(2)
// 3.判断u、v是否同一个集合中(即判断u、v是否指向同一个根)
boolean isSame(int u,int v){
int uRoot = find(u);
int vRoot = find(v);
return uRoot = vRoot;
}
// 4.将v->u这条边加入并查集
void join(int u, int v) {
int uRoot = find(u); // 寻找u的根
int vRoot = find(v); // 寻找v的根
if (uRoot == vRoot) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
father[vRoot] = uRoot;
}
以join(1,2);
、join(3,2)
为例,分别讨论上述两种情况代码设计的正确性
- 基于
代码版本(1)
:直接连接- ① 1、2 初始化本身不同根,join操作执行后2指向1
- ② 3、2初始化本身不同根,join操作执行后2指向3
- 基于
代码版本(2)
:找到根后,再用根进行连接- ① find(1)=1,find(2)=2,join操作执行后(father(2)=1)2指向1
- ② find(3)=3,find(2)=1,join操作执行后(father(1)=3)1指向3
JAVA 版本代码测试,查看
fater[]
的变化和连接情况
public class DisjointSetTemplate {
static int n = 10;
static int[] father = new int[n];
// 1.并查集初始化
static void init() {
for (int i = 0; i < n; i++) {
father[i] = i;
}
}
// 2.并查集里寻根的过程
static int find(int u) {
if (u == father[u]) {
return u; // 如果根就是自己,直接返回
}
// else return father[u] = find(father[u]); // 路径压缩
else {
father[u] = find(father[u]); // 路径压缩
return father[u];
}
}
// 3.判断u、v是否同一个集合中(即判断u、v是否指向同一个根)
static boolean isSame(int u, int v) {
int uRoot = find(u);
int vRoot = find(v);
return uRoot == vRoot;
}
// 4.将v->u这条边加入并查集
static void join(int u, int v) {
u = find(u); // 寻找u的根
v = find(v); // 寻找v的根
if (u == v) return; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
father[v] = u;
}
// 4.将v->u这条边加入并查集(版本测试)
static void joinErr(int u, int v) {
if (isSame(u, v)) {
return;
}
father[v] = u;
}
public static void main(String[] args) {
DisjointSetTemplate dst = new DisjointSetTemplate();
System.out.println("代码1版本测试:");
dst.init();
dst.joinErr(1, 2);
dst.joinErr(3, 2);
PrintUtil.print(dst.father);
System.out.println("节点1的根节点:" + dst.find(1));
System.out.println("节点3的根节点:" + dst.find(3));
System.out.println(dst.isSame(1, 3)); // false(不符合预期)
System.out.println("------------------------------------------------------------");
System.out.println("代码2版本测试:");
dst.init();
dst.join(1, 2);
dst.join(3, 2);
PrintUtil.print(dst.father);
System.out.println("节点1的根节点:" + dst.find(1));
System.out.println("节点3的根节点:" + dst.find(3));
System.out.println(dst.isSame(1, 3)); // true
}
}
// output
代码1版本测试:
[0]-[1]-[3]-[3]-[4]-[5]-[6]-[7]-[8]-[9]-
节点1的根节点:1
节点3的根节点:3
false
------------------------------------------------------------
代码2版本测试:
[0]-[3]-[1]-[3]-[4]-[5]-[6]-[7]-[8]-[9]-
节点1的根节点:3
节点3的根节点:3
true
(5)模拟过程(案例分析)
结合实际案例进行分析,拆解每个join
操作的图的连接变化(寻根 + 连接,寻根的过程中进行路径压缩,将根节点进行连接father[vRoot]=uRoot
)
join
连接顺序说明join(u,v)
- ①
join(1,8);
:find(1) = 1、find(8) = 8 =>father[8]=1
- ②
join(3,8);
:find(3) = 3、find(8) = 1 =>father[1]=3
- ③
join(1,7);
:find(1) = 3、find(7) = 7 =>father[7]=3
- ④
join(8,5);
:find(8) = 3、find(5) = 5 =>father[5]=3
,且在寻根过程中存在路径压缩,会最终更新father[8]=3
- 此处注意关键
寻根过程
中进行路径压缩
,在②中执行join后就可以看到8的根为3,但这一步还不会去更新,而是等到下一次寻8的根的时候进行路径压缩,更新father[8]=3
- 此处注意关键
- ⑤
join(2,9);
:find(2) = 2、find(9) = 9 =>father[9]=2
- ⑥
join(6,9);
:find(6) = 6、find(9) = 2 =>father[2]=6
- ①
基于上述图示⑥中构建的图,可以看到要判断两个节点是否在同一个集合,只需要判断其根是否为同一个:
- 例如8和7的根都是3,所以他们是在同一个集合 =》
isSame(8,7)
返回true - 例如7的根为3,2的根为6,所以他们不是在同一个集合 =》
isSame(7,2)
返回false
(6)rank (按秩(rank)合并)
在上述的分析过程中,可以通过【路径压缩】的方式来缩短根节点的时间,实际上还有另一种方式【按秩(rank)合并】的思路。
rank
表示树的高度,即树中节点层次的最大值。以下述图示为例rank1
表示树1
的高度、rank2
表示树2
的高度,合并指的是将树1
合并到树2
或者是将树2
合并到树1

结合上述图示可以看到,将【树1】合入【树2】后整棵树的高度和【树2】保持一致,而如果选择将【树2】合入【树1】后整棵树的高度变大。因此基于缩短检索时间的思路,因此在join中应该是要将rank
较小的树合并到rank
较大的树,以此确保合并后生成的树rank
最小,以降低树上查询的路径长度
按秩合并的代码分析梳理如下:注意此处find
过程中不做路径压缩(一旦进行路径压缩,则rank记录的高度就不准了,因此根据rank来判断合并就没有意义了),回归最基础的递归搜索。还有一种思路是在路径压缩的同时同步实时更新rank值,但是这样在代码实现成本上消耗不少且收益成效较低。因此在优化并查集检索效率的时候最优是参考【路径压缩】的思路(代码实现精简,效率也高),其次扩展【按秩合并】的思路(【按秩合并】的思路并没有将树形结构尽可能扁平化,因此整理效率并不如【路径压缩】)
int[] father = new int[n];
int[] rank = new int[n]
// 1.并查集初始化
void init() {
for (int i = 0; i < n; i++) {
father[i] = i; // 初始化父节点为自身
rank[i] = 1; // 初始化每棵树的高度都为1
}
}
// 2.并查集里寻根的过程
int find(int u) {
return u == father[u]?u: find(father[u]); // 递归寻根,此处不做路径压缩
}
// 3.判断u、v是否同一个集合中(即判断u、v是否指向同一个根)
boolean isSame(int u,int v){
int u = find(u);
int v = find(v);
return u == v;
}
// 4.将v->u这条边加入并查集
void join(int u, int v) {
u = find(u); // 寻找u的根
v = find(v); // 寻找v的根
if (rank[u] <= rank[v]){
father[u] = v; // rank小的树合入到rank大的树
}else{
father[v] = u;
}
if (rank[u] == rank[v] && u != v) rank[v]++; // 如果两棵树高度相同,则v的高度+1,因为上面 if (rank[u] <= rank[v]) father[u] = v; 注意是 <=
}
🟡107-寻找存在的路径(并查集基础题目)
1.题目内容
题目描述
给定一个包含 n 个节点的无向图中,节点编号从 1 到 n (含 1 和 n )。
你的任务是判断是否有一条从节点 source 出发到节点 destination 的路径存在。
输入描述
第一行包含两个正整数 N 和 M,N 代表节点的个数,M 代表边的个数。
后续 M 行,每行两个正整数 s 和 t,代表从节点 s 与节点 t 之间有一条边。
最后一行包含两个正整数,代表起始节点 source 和目标节点 destination。
输出描述
输出一个整数,代表是否存在从节点 source 到节点 destination 的路径。如果存在,输出 1;否则,输出 0。
输入示例
5 4
1 2
1 3
2 4
3 4
1 4
输出示例 1
2.题解思路
👻方法1:并查集
题目分析
本题实际上为并查集基础题目,可以从题意中拆解分析得到,题中的各个点是双向图连接,因此判断【一个顶点到另一个顶点中是否存在有效路径】实际上就是看【这两个顶点是否在同一个集合中】,因此可以基于【并查集】的思路,构建边关系(join
),然后最终判断isSame
两个顶点是否在同一个集合中(即根是否相同)即可
/**
* 并查集模板
*/
class DisJoint {
static int[] father;
// 1.init
static void init(int n) {
father = new int[n];
for (int i = 0; i < n; i++) {
father[i] = i;
}
}
// 2.find 寻根
static int find(int u) {
if (u == father[u]) {
return u; // 如果为自身则直接返回
} else {
// return find(father[u]); // 递归寻根
/**
* // 路径压缩:缩短检索时间
* father[u] = find(father[u]);
* return father[u];
*/
father[u] = find(father[u]); // 路径压缩:缩短检索时间
return father[u];
}
}
// 3. join 构建边(将两个节点加入集合)
static void join(int u, int v) {
int uRoot = find(u);
int vRoot = find(v);
if (uRoot == vRoot) {
return; // 如果同根则说明本来就在同一个集合中,不操作
}
father[vRoot] = uRoot; // v指向u表示:father[v] = u
}
// 4. isSame 判断两个节点是否在同一个集合(是否同根)
static boolean isSame(int u, int v) {
int uRoot = find(u);
int vRoot = find(v);
return uRoot == vRoot;
}
}
/**
* KMW107 寻找存在的路径
*/
public class Solution1 {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.println("1.输入n(节点个数)、m(边数)");
int n = sc.nextInt();
int m = sc.nextInt();
// 初始化并查集
DisJoint disJoint = new DisJoint();
disJoint.init(n + 1); // 编号有效范围为[1,n],此处加一位
System.out.println("2.输入m条边(x y)以空格间隔数字");
sc.nextLine();
while (m-- > 0) {
String[] inputStr = sc.nextLine().split("\\s+");
// 加入边
disJoint.join(Integer.valueOf(inputStr[0]), Integer.valueOf(inputStr[1]));
}
System.out.println("3.输入source destination");
String[] inputStr = sc.nextLine().split("\\s+");
System.out.println(disJoint.isSame(Integer.valueOf(inputStr[0]), Integer.valueOf(inputStr[1])));
}
}
leetcode 解析版本(对应1971-寻找图中是否存在路径)
基于并查集模板,此处只需要处理边将其加入并查集,然后传入参数校验两个点是否在同一个集合
public boolean validPath(int n, int[][] edges, int source, int destination) {
// 构建并查集
DisJointSet djs = new DisJointSet();
djs.init(n);
// 加入边
for (int[] edge : edges) {
djs.join(edge[0], edge[1]);
}
// 判断两个点是否在同一个集合
return djs.isSame(source, destination);
}
👻方法2:DFS
/**
* 🟢 1971 - 寻找图中是否存在路径
*/
public class Solution1971_02 {
/**
* 思路分析:DFS 搜索思路
*/
public boolean validPath(int n, int[][] edges, int source, int destination) {
// 处理边(构建图:邻接矩阵)
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], v = edge[1];
graph.get(u).add(v); // 处理u->v
graph.get(v).add(u); // 处理v->u
}
// 定义节点遍历标识
boolean[] visited = new boolean[n];
// 调用DFS进行搜索(确认从source出发的节点进行遍历,看是否可以到达dest)
return dfs(graph, visited, source,destination);
// return visited[destination];
}
// DFS
private boolean dfs(List<List<Integer>> graph, boolean[] visited, int source, int dest) {
if (source == dest) {
return true;
}
// 标记节点
visited[source] = true;
// 检索u关联的节点
for (int next : graph.get(source)) {
if (!visited[next] && dfs(graph, visited, next, dest)) {
return true;
}
}
return false;
}
}
👻方法3:BFS
从source
出发,遍历其关联的边的节点,如果节点还没被遍历过则将其加入队列,此处需要注意两个细节问题:
int[][] edges
:针对给出的边集合,区分有向图、无向图的处理,需要将给出的边集合转化为邻接矩阵/邻接表的表示形式,用于表示每个节点指向的边- 起点和终点:给定起点和终点,因此在BFS遍历的时候也是基于起点开始,最终校验基于起点覆盖了路径后
visited[dest]
是否为true - 节点的标记时机:在加入节点的时候同步标记该节点已遍历
/**
* 🟢 1971 - 寻找图中是否存在路径
*/
public class Solution1971_03 {
/**
* 思路分析:BFS 搜索思路
*/
public boolean validPath(int n, int[][] edges, int source, int destination) {
// 处理边(构建图:邻接矩阵)
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], v = edge[1];
graph.get(u).add(v); // 处理u->v
graph.get(v).add(u); // 处理v->u
}
// 定义节点遍历标识
boolean[] visited = new boolean[n];
// 构建辅助队列
Queue<Integer> queue = new LinkedList<>();
// 初始化队列
queue.offer(source); // 从source节点出发
visited[source] = true;
// 遍历队列
while (!queue.isEmpty()) {
// 取出节点
int u = queue.poll();
// 根据边关系确定可达路径(获取当前节点连接的下一个节点)
for (int next : graph.get(u)) {
// 将该节点加入队列(如果节点还没遍历)
if (!visited[next]) {
queue.offer(next);
visited[next] = true; // 处理节点(标记为已遍历) - 只要一加入队列就同步更新遍历状态
}
}
}
// 确认从source出发的节点进行遍历,看是否可以到达dest
return visited[destination];
}
}
🟡108-冗余连接(无向图)
1.题目内容
题目描述
有一个图,它是一棵树,他是拥有 n 个节点(节点编号1到n)和 n - 1 条边的连通无环无向图(其实就是一个线形图)。现在在这棵树上的基础上,添加一条边(依然是n个节点,但有n条边),使这个图变成了有环图。先请你找出冗余边,删除后,使该图可以重新变成一棵树。

输入描述
第一行包含一个整数 N,表示图的节点个数和边的个数。
后续 N 行,每行包含两个整数 s 和 t,表示图中 s 和 t 之间有一条边。
输出描述
输出一条可以删除的边。如果有多个答案,请删除标准输入中最后出现的那条边。
输入示例
3
1 2
2 3
1 3
输出示例 1 3

例如此处,输入1 2、2 3、1 3,按照【并查集】思路分析,顺序遍历:1 2(join)、2 3(join)、1 3(isSame为true)因此如果将1 3加入就会出现环,此时1 3即为冗余边,也是按照标准输入的最后出现的这条边(因为对于1 2 3集合来说,它可以有三条冗余边,而按照标准输入构建并查集的目的是为了让前面的节点先加入集合,那么后加入的边一旦可能导致出现环的话情况即为题中所求的最后一条冗余边的概念)
2.题解思路
👻方法1:并查集
思路分析
题目核心:对于更新构建的无向图,返回一条可以删去的边,使得结果图是一个有着N个节点的树(即:只有一个根节点)。如果有多个答案,则返回二维数组中最后出现的边。
基于题意分析可以从前向后遍历每一条边(因为优先让前面的边连上),边的两个节点如果不在同一个集合,就加入集合(即:同一个根节点)
例如:遍历到边
a,b
- 如果发现节点a、b不在同一个集合中,则加入这条边(将两个节点加入集合)
- 如果发现节点a、b已经在同一个集合中,说明当前这条边的两个节点已经连在一起了,如果再继续加入这条边就一定会出现环
因为树只会加上一条边使得其成环,所以正常按照顺序遍历边,找到这条冗余边输出即可(题目限定按照标准输入,因此是从前往后遍历,输出最后那条可能导致构成环的边即可)。此处还是基于【并查集】模板,然后按照这个思路输出结果
/**
* KMW108 冗余连接
*/
public class Solution2 {
public static void main(String[] args) {
// 定义输出边结果
int[] res = new int[2];
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入节点个数N、边数M:");
int n = sc.nextInt();
int m = sc.nextInt();
// 初始化并查集
DisJoint disJoint = new DisJoint();
disJoint.init(n+1); // 节点有效编号范围[1,n]
System.out.println("2.输入边:");
sc.nextLine();
while(m-->0){
String[] inputStr = sc.nextLine().split("\\s+");
int u = Integer.valueOf(inputStr[0]);
int v = Integer.valueOf(inputStr[1]);
// 判断u、v是否在同一个集合中
boolean isSame = disJoint.isSame(u,v);
if(isSame){
// 如果两个点已经在集合中,说明这条边为冗余边
res[0] = u;
res[1] = v;
System.out.println(res[0] + " " + res[1]);
return ;
}else{
// 如果两个点不在同一集合中,加入这条边
disJoint.join(u,v);
}
}
}
}
🟡109-冗余连接II(有向图)
1.题目内容
题目描述
有一种有向树,该树只有一个根节点,所有其他节点都是该根节点的后继。该树除了根节点之外的每一个节点都有且只有一个父节点,而根节点没有父节点。有向树拥有 n 个节点和 n - 1 条边。现在有一个有向图,有向图是在有向树中的两个没有直接链接的节点中间添加一条有向边。如图
输入一个有向图,该图由一个有着 n 个节点(节点编号 从 1 到 n),n 条边,请返回一条可以删除的边,使得删除该条边之后该有向图可以被当作一颗有向树。
输入描述
第一行输入一个整数 N,表示有向图中节点和边的个数。
后续 N 行,每行输入两个整数 s 和 t,代表这是 s 节点连接并指向 t 节点的单向边
输出描述
输出一条可以删除的边,若有多条边可以删除,请输出标准输入中最后出现的一条边。
输入示例
3 3
1 2
1 3
2 3
输出示例:2 3
2.题解思路
👻方法1:并查集
思路分析
此处不同于无向图的冗余边,不仅要考虑加入的边,还需要考虑每个节点的入度
本题的本质是 :有一个有向图,是由一颗有向树 + 一条有向边组成的 (所以此时这个图就不能称之为有向树),现在让我们找到那条边 把这条边删了,让这个图恢复为有向树。
还有“若有多条边可以删除,请输出标准输入中最后出现的一条边”,这说明在两条边都可以删除的情况下,要删顺序靠后的边!
有向树的性质,如果是有向树的话,只有根节点入度为0,其他节点入度都为1(因为该树除了根节点之外的每一个节点都有且只有一个父节点,而根节点没有父节点)。因此核心目的在于消除环,可以从以下几种情况切入
- 找到
入度为2
的节点,此处需要考虑几种入度为2
的处理情况- 删除一条指向该节点的边,按输入顺序选择靠后的
- 删除构成环的一边
- 没有
入度为2
的节点,说明图中存在有向环,因此要删除构成环的一边(选择靠后的顺序)
基于上述分析,需要在原有基础【并查集】模板上稍加改造,引入"节点的度"处理相关概念以辅助有向图的处理。因此构建两个最为关键的函数:
- ①
isTreeAfterRemoveEdge()
判断删一个边之后是不是有向树(如果删除一个边之后为有向树,则说明可以删除该边)- 将所有边的两端节点分别加入并查集,遇到要 要删除的边 则跳过,如果遇到即将加入并查集的边的两端节点 本来就在并查集了,说明构成了环。如果顺利将所有边的两端节点(除了要删除的边)加入了并查集,则说明 删除该条边 还是一个有向树
- ②
getRemoveEdge()
确定图中一定有了有向环,那么要找到需要删除的那条边:将所有边的两端节点分别加入并查集,如果遇到即将加入并查集的边的两端节点 本来就在并查集了,说明构成了环
/**
* KMW109 冗余连接II
*/
public class Solution1 {
public static int[] father;
// 1.init
public static void init(int n) {
father = new int[n];
for (int i = 0; i < n; i++) {
father[i] = i;
}
}
// 2.find 寻根
public static int find(int u) {
if (u == father[u]) {
return u; // 如果为自身则直接返回
} else {
// return find(father[u]); // 递归寻根
/**
* // 路径压缩:缩短检索时间
* father[u] = find(father[u]);
* return father[u];
*/
father[u] = find(father[u]); // 路径压缩:缩短检索时间
return father[u];
}
}
// 3. join 构建边(将两个节点加入集合)
public static void join(int u, int v) {
int uRoot = find(u);
int vRoot = find(v);
if (uRoot == vRoot) {
return; // 如果同根则说明本来就在同一个集合中,不操作
}
father[vRoot] = uRoot; // v指向u表示:father[v] = u
}
// 4. isSame 判断两个节点是否在同一个集合(是否同根)
public static boolean isSame(int u, int v) {
int uRoot = find(u);
int vRoot = find(v);
return uRoot == vRoot;
}
// 5.在有向图里找到删除的那条边,使其变成树
public static void getRemoveEdge(int n, List<int[]> edges) {
init(n); // 初始化并查集
for (int i = 0; i < edges.size(); i++) { // 遍历所有的边
if (isSame(edges.get(i)[0], edges.get(i)[1])) { // 如果构成有向环了,就是要删除的边
System.out.println(edges.get(i)[0] + " " + edges.get(i)[1]);
return;
} else {
join(edges.get(i)[0], edges.get(i)[1]);
}
}
}
// 6.删一条边之后判断是不是树
public static boolean isTreeAfterRemoveEdge(int n, List<int[]> edges, int deleteEdge) {
init(n); // 初始化并查集
for (int i = 0; i < edges.size(); i++) {
if (i == deleteEdge) continue; // 如果遇到要删除的边,则跳过
if (isSame(edges.get(i)[0], edges.get(i)[1])) { // 如果构成有向环了,一定不是树
return false;
}
join(edges.get(i)[0], edges.get(i)[1]);
}
return true;
}
public static void main(String[] args) {
Solution1 solution = new Solution1();
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入节点个数N、边数M:");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入边");
sc.nextLine();
List<int[]> edges = new ArrayList<>();
// Map<Integer, Integer> nodeEntryDegreeMap = new HashMap<>(); // 也可以选用数组处理
int[] nodeEntryDegree = new int[n + 1];
while (m-- > 0) {
// String[] inputStr = sc.nextLine().split("\\s+");
int u = sc.nextInt();
int v = sc.nextInt();
edges.add(new int[]{u, v}); // 记录边
// nodeEntryDegreeMap.put(u, nodeEntryDegreeMap.getOrDefault(u, 0) + 1); // 记录每个节点的入度
nodeEntryDegree[u]++; // 记录每个节点的入度
}
/**
* 分情况处理:判断是否存在入度为2的节点:
* 1.如果存在入度为2的节点,则尝试删除每一条边,判断删除后是否可以构成有向图
* 2.如果不存在入度为2的节点,则说明存在有向环,在有向图中删除构成有向环的那条边(这里的处理思路和【108冗余连接】一样)
*/
// 判断是否存在入度为2
List<Integer> vec = new ArrayList<>(); // 记录入度为2的边(如果有的话就两条边)
// 找入度为2的节点所对应的边,注意要倒序,因为优先删除最后出现的一条边
for (int i = n - 1; i >= 0; i--) {
if (nodeEntryDegree[edges.get(i)[1]] == 2) {
vec.add(i);
}
}
if (vec.size() > 0) {
// 处理情况① ② :vec里的边已经按照倒序放的,所以优先删 vec.get(0) 这条边
if (isTreeAfterRemoveEdge(n + 1, edges, vec.get(0))) {
System.out.println(edges.get(vec.get(0))[0] + " " + edges.get(vec.get(0))[1]);
} else {
System.out.println(edges.get(vec.get(1))[0] + " " + edges.get(vec.get(1))[1]);
}
return;
} else {
// 处理情况③:有向环的情况
solution.getRemoveEdge(n + 1, edges);
}
}
}
3.最小生成树
🚀最小生成树理论基础
最小生成树是所有节点的最小连通子图, 即:以最小的成本(边的权值)将图中所有节点链接到一起。
图中有n个节点,那么一定可以用 n - 1 条边将所有节点连接到一起。那么如何选择 这 n-1 条边 就是 最小生成树算法的任务所在。以图示案例为例进行分析
// 测试案例数据
7 11
1 2 1
1 3 1
1 5 2
2 6 1
2 4 2
2 3 2
3 4 1
4 5 1
5 6 2
5 7 1
6 7 1
⚽prim 算法 VS kruskal 算法
prim 算法
:维护的是节点的集合- 核心:
minDist[]
维护的是非最小生成树节点到最小生成树(选出的cur
(当前min[i]
的最小值,初始化选择任意一个节点开始))的最近距离 prim三部曲
:- ① 选择距离生成树最近的节点,即当前
minDist[i]
最小的节点cur
(出现相同可以任意选择某个) - ② 将步骤①中选出的节点
cur
加入最小生成树 - ③ 更新非生成树节点与
cur
的最近直接距离(刷新minDist[]
)
- ① 选择距离生成树最近的节点,即当前
- 核心:
Kruskal 算法
:维护的是边的集合- 核心:排序 + 并查集(
DisjointSet
) 校验 Kruskal 核心
:- ① 对边集合按照权值大小(从小到大)进行排序
- ② 遍历排序后的边集合,依次选择边(如果边端点都在同一集合中则跳过,如果不在同一集合中则加入并查集(即加入最最小生成树,并加入同一集合))
- 核心:排序 + 并查集(
Prim VS Kruskal
:- 两种算法最关键的核心在于
prim
关注的是端点的选择,而kruskal
关注的是边的选择,因此两者的适用场景可以从节点、边的数量来结合考虑- 节点数量固定的情况下,如果边数量较少(稀疏图)则考虑用
kruskal
算法 - 对于稠密图(边连接情况接近于完全图(所有节点皆相连:n×n),边的数量较多),则此时考虑用
prim
算法
- 节点数量固定的情况下,如果边数量较少(稀疏图)则考虑用
- 复杂度分析:
Prim
算法 时间复杂度为 O(n2),其中 n 为节点数量,它的运行效率和图中的边数量无关,适用稠密图Kruskal
算法 时间复杂度 为 O(nlogn),其中n 为边的数量,适用稀疏图
- 两种算法最关键的核心在于
🍚prim 算法(贪心思路:prim 三部曲)
- ① 选距离生成树未被访问的最近节点
node
- ② 将最近节点
node
加入生成树 - ③ 更新非生成树节点到生成树的距离(即
minDist[i]为i节点到node的最短距离
)
prim 算法中有一个特别重要的数组minDist
用于记录每一个非生成树节点距离最小生成树的最近距离
(图示节点编号从1开始,因此此处minDist
的有效编号范围也从1
开始使用)
(0)初始态
初始化minDist
数组(结合题意,节点距离设定不超过10000,因此可以将minDist
初始化为可能的最大值10001(或者是Integer.MAX_VALUE
),因为后续是要比较更新最近的距离,因此此处初始化为最大值)=》minDist{10001,10001,10001,10001,10001,10001,10001,10001}

(1)步骤①
① 选距离生成树最近节点
初始化最小生成树中还没有生成,因此可以随便选一个节点加入(因为每个节点一定会在最小生成树中,因此随便选一个加入即可),此处按照数组遍历的顺序,先选择【节点1】加入最小生成树
② 最近节点加入生成树
此时最近节点为【节点1】已经加入树,更新后最小生成树:{1}
③ 更新非生成树节点到生成树的距离
依次遍历其他节点,更新其他非生成树节点到当前【最近节点:节点1】的最短距离(更新范围是针对非生成树节点存在直接距离则取最小值进行更新)
- 【节点2】:
1<10001
=>minDist[2]=1
((1,2)
表示这两个节点对应的权值) - 【节点3】:
1<10001
=>minDist[3]=1
((1,3)
表示这两个节点对应的权值) - 【节点4】:无直接距离(无连接,不更新)
- 【节点5】:
2<10001
=>minDist[5]=1
((1,5)
表示这两个节点对应的权值) - 【节点6】:无直接距离
- 【节点7】:无直接距离
④ 更新后
(2)步骤②
① 选距离生成树最近节点
根据上一步骤更新的minDist
,此处选择距离生成树最近的非生成树节点,【节点2】、【节点3】举例生成树都是最近的(均为1
),因此此处选择【节点2】或者【节点3】均可,以节点2为参考,此处选择【节点2】
② 最近节点加入生成树
将【节点2】加入生成树,更新后最小生成树:{1,2}
③ 更新非生成树节点到生成树的距离
更新【节点2】直连的其他非生成树节点的最近距离,例如此处与【节点2】直连的节点有{1,3,4,6}
,而【节点1】已经加入生成树,因此不纳入更新范围,因此需要考虑更新的是【节点2】直连的其他非生成树节点{3,4,6}
的最近距离
- 【节点3】:
cur=(2,3)=2
,midDist[3]=min{prev,cur}=min{1,2}
距离并没有缩短,因此此处不更新 - 【节点4】:
cur=(2,4)=2
,midDist[4]=min{prev,cur}=min{10001,2}
距离缩短,此处更新minDist[4]=2
- 【节点6】:
cur=(2,6)=1
,midDist[6]=min{prev,cur}=min{10001,1}
距离缩短,此处更新minDist[4]=1
④ 更新后
(3)步骤③(以此类推,基于上述参考分析)
① 选距离生成树最近节点
此处选择【节点3】或者【节点6】均可,此处选择【节点2】为参考
② 最近节点加入生成树
将【节点3】加入生成树,更新后最小生成树:{1,2,3}
③ 更新非生成树节点到生成树的距离
更新【节点3】直连的其他非生成树节点的最近距离
- 【节点4】:
cur=(3,4)=1
,midDist[4]=min{prev,cur}=min{2,1}
=》更新minDist[4]=1
④ 更新后
(4)步骤④(同理)
① 选距离生成树最近节点
此处选择【节点4】或者【节点6】均可,此处选择【节点4】为参考
② 最近节点加入生成树
将【节点4】加入生成树
③ 更新非生成树节点到生成树的距离
更新【节点4】直连的其他非生成树节点的最近距离
- 【节点5】:
cur=(4,5)=1
,midDist[5]=min{prev,cur}=min{2,1}
=》更新minDist[5]=1
④ 更新后
- 最小生成树:
{1,2,3,4}
(5)步骤⑤(同理)
① 选距离生成树最近节点
此处选择【节点5】或者【节点6】均可,此处选择【节点5】为参考
② 最近节点加入生成树
将【节点5】加入生成树,更新后最小生成树:{1,2,3,4,5}
③ 更新非生成树节点到生成树的距离
更新【节点5】直连的其他非生成树节点的最近距离
- 【节点6】:
cur=(5,6)=2
,midDist[5]=min{prev,cur}=min{1,2}
=》不更新 - 【节点7】:
cur=(5,7)=1
,midDist[5]=min{prev,cur}=min{10001,1}
=》更新minDist[7]=1
④ 更新后
(6)步骤⑥(同理)
① 选距离生成树最近节点
此处选择【节点6】或者【节点7】均可,此处选择【节点6】为参考
② 最近节点加入生成树
将【节点6】加入生成树,更新后最小生成树:{1,2,3,4,5,6}
③ 更新非生成树节点到生成树的距离
更新【节点6】直连的其他非生成树节点的最近距离
- 【节点7】:
cur=(6,7)=1
,midDist[7]=min{prev,cur}=min{1,1}
=》不用更新
④ 更新后
(7)步骤⑦(同理)
① 选距离生成树最近节点
此处只有最后一个元素,选择【节点7】
② 最近节点加入生成树
将【节点7】加入生成树,更新后最小生成树:{1,2,3,4,5,6,7}
③ 更新非生成树节点到生成树的距离
更新【节点7】直连的其他非生成树节点的最近距离
- 无节点更新
④ 更新后
(-)步骤⑧(整理)
最终,基于上述minDist
构建的选择,将每次选择的节点加入最小生成树,将所有的节点链接到一起,保证权值和最小
🍚prim算法代码模板
基于上述prim 算法三部曲
进行构建,伪代码思路说明如下:
- 此处节点分类划分为生成树节点(表示已加入最小生成树的节点列表)、非生成节点(表示待处理的节点列表,即还没加入最小生成树的节点列表)
// 1.输入控制
int n,m ; // n节点个数([1,n]) m输入边数
Map<String,Integer> edges(存储每条边对应的val,对于无向图来说,此处两个端点需要构建两条边表示便于处理,或者构建邻接矩阵)
// 2.构建主循环(prim三部曲)
for(循环N次:每次选择一个节点加入生成树){
// 1.选择最近节点
从非生成树节点列表中选择一个距离最小的节点作为最近节点,设定选出的节点为cur
// 2.将选出的节点加入最小生成树
将cur加入最小生成树(可以构建一个List存储最小生成树,也可以基于数组设定boolean[n+1]来标记某个节点已经被加入到最小生成树)
// 3.更新midDist
从非生成树节点列表中更新遍历节点i与当前选出节点cur的最短距离(如果非最短则不需要更新,已加入生成树节点也不需要更新)
}
此处对于主循环的构建需要把握基调,将基本的框架先敲定下来,然后如何切入这三个步骤。
**主循环的设定:**主循环的设定没有特殊的限制,纯粹就是为了选节点,要选出n个节点就要执行n次这样的操作,要选出n-1条边就要执行n-1次这样的操作,概念和实现思路都是一样的,因此for
这个主循环的设定就是执行多次prim三部曲来选出最小生成树
需要注意的是此处【步骤1】中选择最近节点,有两种思路,一种是在【每次的步骤3】中更新的同时把下一步的选择敲定下来,一种是在【步骤1】中明确思路,从当前非生成树列表节点中筛选出minDist[i]
最小的节点(这种思路比较贴合流程,好理解不易混淆)。需要注意初始化状态下【步骤1】的处理,此处推荐写法是设定cur=-1、minVal=maxVal+1
然后遍历每个节点,去找到这个minDist[i]
最小的节点(此处为了让初始化可以选择到节点1
,需要将minVal的值设置得比maxVal
大一点,否则无法进入循环操作);但不管是基于什么写法,目的都是为了找到当前minDist
中属于非生成树节点的且minDist[i]
最小的节点,稍微注意下初始化的写法即可,不要瞻前顾后,用最基础的思路去做
public class PrimTemplate {
/**
* prim 算法:最小生成树
*/
public static int prim(int n, Map<String, Integer> edges) {
int maxVal = 10001; // Integer.MAX_VALUE
// 定义minDist[] 存储非生成树节点距离生成树的最小距离
int[] minDist = new int[n + 1]; // 节点有效范围取值为[1,n]
Arrays.fill(minDist, maxVal); // 初始化:初始化最近距离为最大(或者结合题目设定max:10001)
// 构建minDist
List<Integer> tree = new ArrayList<>(); // 存储最小生成树节点
/**
* prim 主循环函数
* 这个prim过程会执行n次,但[cnt<n || cnt<=n 这两个条件都不影响最终路径和],因为当更新到倒数第二个节点的时候实际上最后一个节点已经是明确的了(minDist更新完成)
* 所以cnt<n、cnt<=n这两个条件并不影响minDist中的路径总和累加,唯一不同的是tree这个最小生成树的标记
* - cnt<n 执行会选择n-1个节点加入到tree中(最后一步也已经是明确的)
* - cnt<=n 执行会将所有节点加入到tree中
*/
for (int cnt = 1; cnt <= n; cnt++) {
// 1.选出要加入最小生成树的节点
/*
int cur = 1; // 初始化
for (int i = 1; i <= n; i++) {
if (!tree.contains(i) && minDist[i] < minDist[cur]) {
cur = i;
}
}
*/
int cur = -1, curMin = maxVal + 1; // 推荐写法:初始化当前最近距离(处理初始minDist中的值都为maxVal的设定,确保可以选择一个节点)
// int cur = 1, curMin = minDist[cur]; // 初始化当前最近距离(也可以默认为1,在后面的循环中会更新cur的值)
for (int i = 1; i <= n; i++) {
if (!tree.contains(i) && minDist[i] < curMin) {
cur = i;
curMin = minDist[i];
}
}
// 2.将选出的节点加入最小生成树
tree.add(cur);
// 3.更新非生成树节点与choose的节点的最近距离
for (int i = 1; i <= n; i++) {
// 更新minDist(更新非生成树节点到choose节点的最近距离)
if (!tree.contains(i)) {
// 更新非生成树节点到最小生成树的最近距离
int edgeVal = edges.getOrDefault(cur + "," + i, Integer.MAX_VALUE);
minDist[i] = Math.min(minDist[i], edgeVal);
}
}
}
// 当所有的节点都选出,构成一条完整路径,此时minDist存储的为最小生成树的最近距离,累加总和得到最小生成树路径和
int sum = 0;
for (int i = 2; i <= n; i++) { // 从第2个节点开始计算路径
sum += minDist[i];
}
return sum;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("输入N个节点、M条边及其权值关系");
int n = sc.nextInt();
int m = sc.nextInt();
// Map<int[], Integer> edges = new HashMap<>(m); // (a,b) 权值v:Map<(a,b),val>
Map<String, Integer> edges = new HashMap<>(m); // (a,b) 权值v:Map<(a,b),val>
while (m-- > 0) {
int edge1 = sc.nextInt();
int edge2 = sc.nextInt();
int val = sc.nextInt();
// 构建无向图(双边)
edges.put(edge1 + "," + edge2, val);
edges.put(edge2 + "," + edge1, val);
}
/*
int n = 7;
Map<String, Integer> edges = new HashMap<>();
edges.put("1,2", 1);
edges.put("1,3", 1);
edges.put("1,5", 2);
edges.put("2,6", 1);
edges.put("2,4", 2);
edges.put("2,3", 2);
edges.put("3,4", 1);
edges.put("4,5", 1);
edges.put("5,6", 2);
edges.put("5,7", 1);
edges.put("6,7", 1);
edges.put("2,1", 1);
edges.put("3,1", 1);
edges.put("5,1", 2);
edges.put("6,2", 1);
edges.put("4,2", 2);
edges.put("3,2", 2);
edges.put("4,3", 1);
edges.put("5,4", 1);
edges.put("6,5", 2);
edges.put("7,5", 1);
edges.put("7,6", 1);
*/
// 调用prim算法
int res = PrimTemplate.prim(n, edges);
System.out.println("最小生成树路径和:" + res);
}
}
切换为二维矩阵处理
此处切换为二维矩阵处理概念实际也是类似的,无非就是对于边的处理不同。此处还补充对选择路径的输出,也就是引入minDistEdge[n+1]
数组,它与minDist[n+1]
数组同步更新,表示更新非生成树节点到当前选择节点的最近距离的边,当要筛选这条边的时候可以同步输出边的内容
实现思路和上述版本差不多,主要是边的处理以及引入minDistEdge[n+1]
数组用于输出对应的边的情况
/**
* 最小生成树算法模板2(基于邻接矩阵)
*/
public class PrimTemplate2 {
/**
* prim 算法:最小生成树
*/
public static int prim(int n, int[][] graph) {
int maxVal = 10001; // Integer.MAX_VALUE
// 定义minDist[] 存储非生成树节点距离生成树的最小距离
int[] minDist = new int[n + 1]; // 节点有效范围取值为[1,n]
Arrays.fill(minDist, maxVal); // 初始化:初始化最近距离为最大(或者结合题目设定max:10001)
String[] minDistEdge = new String[n + 1]; // 当前最小距离对应的边关系(例如`(a,b)`)
// 构建minDist
List<Integer> tree = new ArrayList<>(); // 存储最小生成树节点
/**
* prim 主循环函数
* 这个prim过程会执行n次,但[cnt<n || cnt<=n 这两个条件都不影响最终路径和],因为当更新到倒数第二个节点的时候实际上最后一个节点已经是明确的了(minDist更新完成)
* 所以cnt<n、cnt<=n这两个条件并不影响minDist中的路径总和累加,唯一不同的是tree这个最小生成树的标记
* - cnt<n 执行会选择n-1个节点加入到tree中(最后一步也已经是明确的)
* - cnt<=n 执行会将所有节点加入到tree中
*/
for (int cnt = 1; cnt <= n; cnt++) {
// 1.选出要加入最小生成树的节点
int cur = -1, curMin = maxVal + 1; // 推荐写法:初始化当前最近距离(处理初始minDist中的值都为maxVal的设定,确保可以选择一个节点)
// int cur = 1, curMin = minDist[cur]; // 初始化当前最近距离(也可以默认为1,在后面的循环中会更新cur的值)
for (int i = 1; i <= n; i++) {
if (!tree.contains(i) && minDist[i] < curMin) {
cur = i;
curMin = minDist[i];
}
}
// 2.将选出的节点加入最小生成树
tree.add(cur);
// 3.更新非生成树节点与choose的节点的最近距离
for (int i = 1; i <= n; i++) {
// 更新minDist(更新非生成树节点到choose节点的最近距离)
if (!tree.contains(i) && graph[cur][i] != 0 && graph[cur][i] < minDist[i]) { // 需注意此处需限定graph[cur][i]!=0表示只校验两个节点有直接路径的情况(无直接路径表示这两个节点不可达)
// 更新非生成树节点到最小生成树的最近距离 并 记录这个边关系
minDist[i] = graph[cur][i];
minDistEdge[i] = cur + "->" + i;
}
}
}
// 当所有的节点都选出,构成一条完整路径,此时minDist存储的为最小生成树的最近距离、minDistEdge存储对应的边关系,累加总和得到最小生成树路径和
int sum = 0;
for (int i = 2; i <= n; i++) { // 从第2个节点开始计算路径
sum += minDist[i];
System.out.println(minDistEdge[i]);
}
return sum;
}
public static void main(String[] args) {
// 输入控制
/*
Scanner sc = new Scanner(System.in);
System.out.println("输入N个节点、M条边及其权值关系");
int n = sc.nextInt();
int m = sc.nextInt();
// 构建邻接矩阵,元素值存储边的权值
int[][] graph = new int[n + 1][n + 1]; // 节点有效范围取值[1,n]
while (m-- > 0) {
int edge1 = sc.nextInt();
int edge2 = sc.nextInt();
int val = sc.nextInt();
// 构建无向图(双边)
graph[edge1][edge2] = val;
graph[edge2][edge1] = val;
}
*/
int n = 7;
int[][] graph = new int[][]{
{0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 1, 1, 0, 2, 0, 0}, {0, 1, 0, 2, 2, 0, 1, 0},
{0, 1, 2, 0, 1, 0, 0, 0}, {0, 0, 2, 1, 0, 1, 0, 0}, {0, 2, 0, 0, 1, 0, 2, 1},
{0, 0, 1, 0, 0, 2, 0, 1}, {0, 0, 0, 0, 0, 1, 1, 0}
};
// 调用prim算法
int res = PrimTemplate2.prim(n, graph);
System.out.println("最小生成树路径和:" + res);
}
}
// output
1->2
1->3
3->4
4->5
2->6
5->7
最小生成树路径和:6
🍚Kruskal 算法
Kruskal 核心思路
- 边的权值排序,因为要优先选最小的边加入到生成树里
- 遍历排序后的边,基于并查集思路处理边
- 如果边首尾的两个节点在同一个集合,说明如果连上这条边图中会出现环(跳过该边)
- 如果边首尾的两个节点不在同一个集合,加入到最小生成树,并把两个节点加入同一个集合(选中该边,并将
(u,v)
加入并查集)
算法案例分析
1.输入V顶点数量,E边数
7 11
2.输入边关系(a b c)表示a连接b,权值为c
1 2 1
1 3 1
1 5 2
2 6 1
2 4 2
2 3 2
3 4 1
4 5 1
5 6 2
5 7 1
6 7 1
还是基于上述案例进行分析,基于输入的边,按照权值从小到大进行排序:此处按照(a,b)[c]
这种方式表示a连接b且边权值为c
- 排序后:
(1,2)[1]
、(1,3)[1]
、(2,6)[1]
、(3,4)[1]
、(4,5)[1]
、(5,7)[1]
、(6,7)[1]
、(1,5)[2]
、(2,4)[2]
、(2,3)[2]
、(5,6)[2]
- 基于并查集思路分析每条边,如果发现两个端点已在同一个集合中则不重复加入,如果不在同一个集合则
join
入并查集(加入到最小生成树)(从贪心的角度分析,此处优先选择权值小的边加入到最小生成树中,此处按照边权值进行排序,无关边的输入顺序)
🍚Kruskal 算法代码模板
从上述Kruskal 算法分析,其本质上就是排序
+ 并查集
应用,因此可以基于此思路构建Kruskal
算法代码模板如下
- (1)排序:此处用
Map
存储边和权值关系,因此需要对其value进行排序(可以转化为List<Map.Entry<String,Integer>
处理,用List的sort
方法重写比较器来进行处理)- 需注意此处HashMap本身存储的是无序的元素,因此map存储的元素不一定是按照输入顺序存储,但此处边的顺序并没有那么重要,主要是关注边的权值(边的顺序并不影响算法的执行结果,只不过每次选的方案可能不一样,但殊途同归:可以参考分析案例和实际算法得出的路径结果图示进行比较分析)
- (2)并查集处理
- 此处的并查集即为要求的最小生成树,遍历排序后的边,如果端点已在集合中则不做处理,如果端点不在集合中则加入并查集(最小生成树)。此处实际上不需要处理两次(
u->v
、v->u
都是一样的,因此边不用存两次)
- 此处的并查集即为要求的最小生成树,遍历排序后的边,如果端点已在集合中则不做处理,如果端点不在集合中则加入并查集(最小生成树)。此处实际上不需要处理两次(
/**
* KMW053-寻宝(最小生成树基础题型)
*/
public class Solution2 {
/**
* Kruskal 算法
*
* @param n 顶点个数
* @param edges 边及对应权值关系
*/
public static int kruskal(int n, Map<String, Integer> edges) {
DisjointSet disjointSet = new DisjointSet();
disjointSet.init(n + 1); // 并查集初始化
// 1.边排序:对map的value进行排序,此处转化为List处理
List<Map.Entry<String, Integer>> list = new ArrayList<Map.Entry<String, Integer>>(edges.entrySet());
list.sort(new Comparator<Map.Entry<String, Integer>>() {
@Override
public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
// return o2.getValue().compareTo(o1.getValue());
return o1.getValue() - o2.getValue(); // 按照value从小到大
}
});
// 2.边遍历:遍历排序后的边
int sum = 0;
for (Map.Entry<String, Integer> entry : list) {
String[] edge = entry.getKey().split("->");
int u = Integer.valueOf(edge[0]), v = Integer.valueOf(edge[1]);
if (!disjointSet.isSame(u, v)) {
// 如果u、v不在一个集合,加入该边(加入并查集)
disjointSet.join(u, v);
System.out.println(u + "->" + v); // 输入加入的边
sum += entry.getValue(); // 累加边路径
}
}
// 返回结果
System.out.println("最小路径总和:" + sum);
return sum;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入V顶点数量,E边数");
int v = sc.nextInt();
int e = sc.nextInt();
System.out.println("2.输入边关系(a b c)表示a连接b,权值为c");
// 通过Map<edge,val>存储边的权值关系(key为`a->b`,value为`c`)
Map<String, Integer> edges = new HashMap<>();
while (e-- > 0) {
int node1 = sc.nextInt();
int node2 = sc.nextInt();
int val = sc.nextInt();
// 构建无向图的边关系
edges.put(node1 + "->" + node2, val);
// edges.put(node2 + "->" + node1, val); 此处只需要判断边,因此只需要校验一条边的两个端点即可,不需要存储两次
}
// 调用kruskal算法获取最小连通图的路径总和
Solution2.kruskal(v, edges);
}
}
/**
* 并查集处理
*/
class DisjointSet {
static int[] father;
// 1.初始化
public static void init(int n) {
father = new int[n];
for (int i = 0; i < n; i++) {
father[i] = i;
}
}
// 2.find 寻根
public static int find(int u) {
if (u == father[u]) {
return u;
}
father[u] = find(father[u]); // 路径压缩
return father[u];
}
// 3.join 将两个节点加入集合
public static void join(int u, int v) {
// 分别寻找u、v的根,构建根连接
int uRoot = find(u);
int vRoot = find(v);
// 如果同根则不执行操作
if (uRoot == vRoot) {
return;
}
// 如果不同根则构建根连接
father[vRoot] = uRoot; // v指向u
}
// 4.isSame 判断是否同根
public static boolean isSame(int u, int v) {
int uRoot = find(u);
int vRoot = find(v);
return uRoot == vRoot;
}
}
🟡KMW053-寻宝(最小生成树基础题)
1.题目内容
题目描述:
在世界的某个区域,有一些分散的神秘岛屿,每个岛屿上都有一种珍稀的资源或者宝藏。国王打算在这些岛屿上建公路,方便运输。
不同岛屿之间,路途距离不同,国王希望你可以规划建公路的方案,如何可以以最短的总公路距离将 所有岛屿联通起来。
给定一张地图,其中包括了所有的岛屿,以及它们之间的距离。以最小化公路建设长度,确保可以链接到所有岛屿。
输入描述:
第一行包含两个整数V 和 E,V代表顶点数,E代表边数 。顶点编号是从1到V。例如:V=2,一个有两个顶点,分别是1和2。
接下来共有 E 行,每行三个整数 v1,v2 和 val,v1 和 v2 为边的起点和终点,val代表边的权值。
输出描述:
输出联通所有岛屿的最小路径总距离
输入示例:
7 11
1 2 1
1 3 1
1 5 2
2 6 1
2 4 2
2 3 2
3 4 1
4 5 1
5 6 2
5 7 1
6 7 1
输出示例:6
2.题解思路
👻方法1:prim 算法
/**
* KMW053-寻宝(最小生成树基础题型)
*/
public class Solution1 {
/**
* prim算法
*
* @param n 顶点个数
* @param edges 边及对应权值关系
*/
public static int prim(int n, Map<String, Integer> edges) {
// 定义midDist[]存储非生成树节点到最小生成树的最近距离
int[] midDist = new int[n + 1]; // 编号有效范围从[1,n]
String[] midDistEdge = new String[n + 1]; // 编号有效范围从[1,n] 同步记录当前最近距离的边
Arrays.fill(midDist, 10001); // 初始化设定为maxVal(Integer.MAX_VALUE)
// 定义已加入最小生成树的节点(可以用List,也可以用数组同步表示)
boolean[] selectedNode = new boolean[n + 1];
Arrays.fill(selectedNode, false);
// 构建prim主循环算法(需要选择n次)
for (int cnt = 1; cnt <= n; cnt++) {
// 1.选择距离最小生成树最近的节点(即minDist[i]的最小值)
int cur = -1;
int curVal = 10002; // 初始化比maxVal大一点,确保初始节点可以被选上
for (int i = 1; i <= n; i++) {
// 从非生成树列表节点中筛选`minDist[i]`最小的值
if (!selectedNode[i]) {
if (midDist[i] < curVal) {
// 更新选择的节点
cur = i;
curVal = midDist[i];
}
}
}
// 2.将选出的最近的节点加入最小生成树
selectedNode[cur] = true;
// 3.更新midDist[i]:更新剩余的非生成树节点与`cur`节点的最近的距离
for (int i = 0; i <= n; i++) {
if (!selectedNode[i]) {
int edgeDist = edges.getOrDefault(cur + "->" + i, 0);
if (edgeDist != 0 && edgeDist < midDist[i]) { // 如果edgeDist为0说明两个节点不存在直达的关系,无需更新
midDist[i] = edgeDist;
midDistEdge[i] = cur + "->" + i; // 同步记录当前最近的距离关联的边关系
}
}
}
}
// 输出结果
int sum = 0;
for (int i = 2; i <= n; i++) {
sum += midDist[i];
System.out.println(midDistEdge[i]);
}
System.out.println("最小路径总距离:" + sum);
return sum;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入V顶点数量,E边数");
int v = sc.nextInt();
int e = sc.nextInt();
System.out.println("2.输入边关系(a b c)表示a连接b,权值为c");
// 通过Map<edge,val>存储边的权值关系(key为`a->b`,value为`c`)
Map<String, Integer> edges = new HashMap<>();
while (e-- > 0) {
int node1 = sc.nextInt();
int node2 = sc.nextInt();
int val = sc.nextInt();
// 构建无向图的边关系
edges.put(node1 + "->" + node2, val);
edges.put(node2 + "->" + node1, val);
}
// 调用prim算法获取最小连通图的路径总和
Solution1.prim(v, edges);
}
}
👻方法2:kruskal 算法
可以用Map<String,Integer>
存储边和权值关系,也可以自定义一个Edge
类来维护边的两个端点、权值的内容,便于处理
/**
* KMW053-寻宝(最小生成树基础题型)
*/
public class Solution3 {
/**
* Kruskal 算法
*
* @param n 顶点个数
* @param edges 边及对应权值关系
*/
public static int kruskal(int n, List<Edge> edges) {
DisJointSet disjointSet = new DisJointSet();
disjointSet.init(n + 1); // 并查集初始化
// 1.边排序:对map的value进行排序,此处转化为List处理
edges.sort(new Comparator<Edge>() {
@Override
public int compare(Edge o1, Edge o2) {
return o1.val - o2.val; // 根据边的权值排序
}
});
// 2.边遍历:遍历排序后的边
int sum = 0;
for (Edge edge : edges) {
int u = edge.u, v = edge.v;
if (!disjointSet.isSame(u, v)) {
// 如果u、v不在一个集合,加入该边(加入并查集)
disjointSet.join(u, v);
System.out.println(u + "->" + v); // 输入加入的边
sum += edge.val;
}
}
// 返回结果
System.out.println("最小路径总和:" + sum);
return sum;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入V顶点数量,E边数");
int v = sc.nextInt();
int e = sc.nextInt();
System.out.println("2.输入边关系(a b c)表示a连接b,权值为c");
// 通过Map<edge,val>存储边的权值关系(key为`a->b`,value为`c`)
List<Edge> edges = new ArrayList<>();
while (e-- > 0) {
int node1 = sc.nextInt();
int node2 = sc.nextInt();
int val = sc.nextInt();
// 构建无向图的边关系
edges.add(new Edge(node1, node2, val));
// edges.put(node2 + "->" + node1, val); 此处只需要判断边,因此只需要校验一条边的两个端点即可,不需要存储两次
}
// 调用kruskal算法获取最小连通图的路径总和
Solution3.kruskal(v, edges);
}
}
class Edge {
int u; // 边的端点1
int v; // 边的端点2
int val; // 边的权值
public Edge() {
}
public Edge(int u, int v, int val) {
this.u = u;
this.v = v;
this.val = val;
}
}
🟡1135-最低成本联通所有城市
1.题目内容
想象一下你是个城市基建规划者,地图上有 n
座城市,它们按以 1
到 n
的次序编号。
给你整数 n
和一个数组 conections
,其中 connections[i] = [xi, yi, costi]
表示将城市 xi
和城市 yi
连接所要的costi
(连接是双向的)。
返回连接所有城市的最低成本,每对城市之间至少有一条路径。如果无法连接所有 n
个城市,返回 -1
该 最小成本 应该是所用全部连接成本的总和。
此处的输入[[1,2,5],[1,3,6]]
表示无向图中各个边的连接关系以及权值,具体结合实际题型来分析,可以以根据给定的输入来转化为便于处理的数据结构
- 例如用
Map<String,Integer>
来记录每条边的边值映射,在遍历节点的时候只需要根据节点拼接为边的key
即可,例如Map<边,边值>
(<"1->2",5>
) - 也可以借助邻接矩阵来表示,以
(u,v,w)
(u
到v
存在边,其边的权值为w
)用邻接矩阵则可表示为grid[u,v]=w
2.题解思路
思路1:prim 算法(面向点)
思路分析:维护一个数组minDist
存储非生成树节点到最小生成树的最短距离,然后每次从非生成树节点中选择一个距离生成树节点最近的节点cur
,将其进行标记(加入生成树),并基于该节点更新minDist
数组(只更新剩余的非生成树节点到当前选中节点cur
的最短距离),当所有节点选择完成之后最终的minDist
数组存储的路径距离即为构成最小生成树的最短距离
/**
* 最小生成树:Prim 算法
*/
public class PrimTemplate {
/**
* prim 算法核心:
* ① 维护一个数组minDist存储每个点到最小生成树的最短距离
* ② 选择过程
* - 2.1 每次选中一个距离最小生成树最近的点(已经加入最小生成树的节点不重复处理)
* - 2.2 将其加入最小生成树
* - 2.3 更新minDist(作为下一轮选择的参考,及更新当前选中的节点与剩余的节点的最短距离)
*/
private int prim(int[][] grid, int n) {
int pathSum = 0;
// 定义数组:维护未选中节点到最小生成树的距离
int[] minDist = new int[n];
int maxVal = 10001; // Integer.MAX_VALUE
Arrays.fill(minDist, maxVal); // 初始化:初始化最近距离为最大(或者结合题目设定max:10001)
// 遍历每个节点,每次选中1个满足条件的节点加入最小生成树
// boolean[] selected = new boolean[n]; // 表示当前节点的选中状态(为true表示已加入最小生成树)
List<Integer> tree = new ArrayList<>();
for (int x = 0; x < n; x++) {
// ① 获取距离最小生成树的最近的未被选中的节点(即获取当前minDist中距离最小的那个节点)
int cur = -1, curMin = maxVal + 1; // 推荐写法:初始化当前最近距离(处理初始minDist中的值都为maxVal的设定,确保可以选择一个节点)
// int cur = 1, curMin = minDist[cur]; // 初始化当前最近距离(也可以默认为1,在后面的循环中会更新cur的值)
for (int i = 0; i < n; i++) {
if (!tree.contains(i) && minDist[i] < curMin) {
cur = i;
curMin = minDist[i];
}
}
// ② 将选中节点加入最小生成树
tree.add(cur);
// ③ 更新当前选中节点与非生成树节点(剩余节点)的最短距离
for (int i = 0; i < n; i++) {
/*
if (!tree.contains(i) && grid[cur][i] != 0 && grid[cur][i] < minDist[i]) { // 需注意此处需限定graph[cur][i]!=0表示只校验两个节点有直接路径的情况(无直接路径表示这两个节点不可达)
// 更新非生成树节点到最小生成树的最近距离 并 记录这个边关系
minDist[i] = grid[cur][i];
}
*/
if (!tree.contains(i) && grid[cur][i] != 0) { // 需注意此处需限定graph[cur][i]!=0表示只校验两个节点有直接路径的情况(无直接路径表示这两个节点不可达)
// 更新非生成树节点到最小生成树的最近距离 并 记录这个边关系
minDist[i] = Math.min(minDist[i], grid[cur][i]);
}
}
}
// 当所有的节点都选出,构成一条完整路径,此时minDist存储的为最小生成树的最近距离、minDistEdge存储对应的边关系,累加总和得到最小生成树路径和
for (int i = 0; i < n; i++) { // 从第2个节点开始计算路径
pathSum += minDist[i];
}
return pathSum;
}
public int minimumCost(int n, int[][] connections) {
int[][] grid = new int[n][n];
// ① 遍历连接关系,转化为邻接矩阵
for (int[] cn : connections) {
int u = cn[0];
int v = cn[1];
int w = cn[2];
// 构建无向图的邻接矩阵
grid[u][v] = w;
grid[v][u] = w;
}
// ② 调用算法,生成最小生成树
int res = prim(grid, n);
// 返回结果
return res;
}
}
思路2:kruskal 算法(面向边)
思路分析:基于边排序 + 并查集 的思路处理,将所有的边按照边值权重从小到大进行排序,然后依次遍历边,基于并查集处理边关系
- 如果边的2个端点已经存在于同一个集合,则无需处理
- 如果边的2个端点不在同一个集合,则选中该边加入最小生成树,并加其2个端点加入集合
public class KruskalTemplate {
public int kruskal(int[][] edges, int n) {
int pathSum = 0;
// ① 对边集合按照权值大小进行排序
Arrays.sort(edges, new Comparator<int[]>() {
@Override
public int compare(int[] o1, int[] o2) {
return o1[2] - o2[2]; // [u,v,w]
}
});
// ② 遍历所有边,基于并查集处理
DisjointSetTemplate djs = new DisjointSetTemplate();
djs.init(n);
for (int[] edge : edges) {
int u = edge[0];
int v = edge[1];
int w = edge[2];
// 判断u、v是否在同一集合
boolean isSame = djs.isSame(u, v);
if (!isSame) {
djs.join(u, v);
pathSum += w;
}
}
// 返回结果
return pathSum;
}
public static void main(String[] args) {
KruskalTemplate kt = new KruskalTemplate();
int[][] edges = new int[][]{{1, 2, 5}, {1, 3, 6}, {2, 3, 1}};
System.out.println(kt.kruskal(edges, 4)); // 此处编号节点是从1开始,因此n取大一点用于测试
}
}
4.拓扑排序
🚀拓扑排序应用场景
一聊到 拓扑排序 ,可能惯性思维会觉得这是排序算法,不会想到这是图论算法。其实拓扑排序是经典的图论问题,下述列举几个案例场景:
①【大学排课】场景:例如 先上A课,才能上B课,上了B课才能上C课,上了A课才能上D课,等等一系列这样的依赖顺序。 问给规划出一条完整的上课顺序。
②【文件处理】场景:拓扑排序在文件处理上也有应用,例如在做项目安装文件包的时候,经常发现 复杂的文件依赖关系, A依赖B,B依赖C,B依赖D,C依赖E 等等,如何给出一条线性的依赖顺序来下载这些文件呢?
对于简单的依赖关系可以通过穷举的方式一一列举,但如果依赖关系是一百对、一千对甚至上万个依赖关系,这些依赖关系中可能还有循环依赖,又该如何发现循环依赖呢,如何排出线性顺序呢?而拓扑排序就是专门解决这类问题的。概括来说,给出一个 有向图,把这个有向图转成线性的排序 就叫拓扑排序。
当然拓扑排序也要检测这个有向图 是否有环,即存在循环依赖的情况,因为这种情况是不能做线性排序的。
所以拓扑排序也是图论中判断有向无环图的常用方法
🚀拓扑排序原理分析
拓扑排序核心
拓扑排序的核心过程,主要拆解为两步:(可以采用BFS、DFS的思路分析)
- 步骤①:找到入度为0 的节点,加入结果集(入度为0说明没有前置依赖限制)
- 步骤②:将该节点从图中移除
循环以上两步,直到 所有节点都在图中被移除了。得到的结果集的顺序,就是合法的拓扑排序顺序 (结果集里顺序可能不唯一)
基于上述拓扑排序分析,可以得到【0 1 3 2 4】的合法序列,且从过程分析可知,如果这个合法序列可能存在多个,是因为在选择【入度为0】的节点时,可能会出现多个这样的节点,如果进一步没有限定选定的规则,那么选择不同的方案可能会触发不同的结果,进而产生多组合法序列
如何判断是否有环?
此处将【节点0】加入结果集后,从图中移除后发现,由节点【1】【2】【3】【4】构成了有向环,此时更新后的图中已经不存在入度为0的节点,遍历结束。可以看到此时结果集只有【节点0】,与节点总个数不匹配。也就是说按照上述思路执行操作,如果发现结果集的节点个数和总节点个数不匹配,则说明有向图中出现了环
拓扑排序代码分析
① 初始化:记录每个节点和其入度的依赖关系、记录每个节点指向的节点列表(用于将节点移出图时调整依赖关系)
inDegree[]
:节点与下标相对应,数组元素为对应节点的入度List<List<Integer>>
:记录每个下标(对应节点)指向的节点列表
② 定义队列动态存放入度为0的节点(因为每次寻找入度为0的节点不一定只有一个节点,可能很多个节点入度都为0,需要用队列存放这些节点,然后依次处理)
③ 依次遍历队列中入度为0的节点(加入结果集、移除节点)
- 加入结果集:遍历节点,将节点加入结果集(可以用数组或
List
存储) - 从图中移除节点:移除节点的过程涉及到边的处理(因为移出的这个节点可能是某个节点的前置依赖节点,需要相应处理节点的入度变化)以及更新队列(将更新后入度为0的节点加入队列)
- 此处的移除概念不是真的移除,而是相当于消除依赖关系,将一个节点移出图,即斩断这个节点和其关联指向的节点列表的联系,因此可以根据当前要移出的节点
cur
,找到其关联指向的节点列表,然后依次遍历处理这些节点的入度减1即可 - 节点入度更新后,需校验是否存在节点入度为0的元素,将其加入
queue
队列等待处理
- 此处的移除概念不是真的移除,而是相当于消除依赖关系,将一个节点移出图,即斩断这个节点和其关联指向的节点列表的联系,因此可以根据当前要移出的节点
针对辅助集合的选择:无非就是数组、List
、Map
、Set
、Queue
、Stack
这些数据结构,结合应用场景特性进行选择。对于构造映射关系,一般情况可能倾向使用Map
,但是像是此处节点和下标是一一对应的,实际上就可以将集合下标(例如List
、数组
)作为key,对应元素作为其value,进而构造节点和对应元素值的映射关系,处理也非常方便。
// 测试样例1
5 4
0 1
0 2
1 3
2 4
0->1->2->3->4->end
// 测试样例2
5 6
0 1
0 2
1 3
3 4
4 2
2 1
-1
/**
* 拓扑排序代码模板
*/
public class TopologicalSortTemplate {
// N 个文件([0,N-1])、M 条边(有向依赖关系)
public void topologicalSort(int n, List<Edge> edges) {
// 1.记录每个节点的入度、以及当前节点指向什么节点
/**
* 定义节点和其关联节点列表(指向的节点列表):
* 也可用Map<Integer,List<Integer>>集合形式构建映射关系
* 此处用List<List<Integer>>(下标表示节点,元素值表示节点指向的节点列表)
*/
List<List<Integer>> list = new ArrayList<>();
for (int i = 0; i < n; i++) { // 初始化list
list.add(new ArrayList<>());
}
// 构建每个节点及其入度的依赖关系(基于数组构建)
int[] inDegree = new int[n];
for (Edge edge : edges) {
int u = edge.u, v = edge.v; // u->v 关系校验
list.get(u).add(v); // 表示获取到u节点关联指向节点列表,然后追加相应的v节点(即处理u->v)
inDegree[v]++; // u->v,入度是针对v节点校验
}
// 2.定义队列存储入度为0的节点
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < inDegree.length; i++) {
if (inDegree[i] == 0) {
queue.offer(i); // 初始化将入度为0的节点加入队列
}
}
// 3.遍历处理(拓扑排序核心流程):选出入度为0的节点进行处理(将其加入结果集、移出图)
List<Integer> res = new ArrayList<>(); // int[] res = new int[n]; int idx = 0; // 结果集遍历指针
while (!queue.isEmpty()) {
// 按顺序取出[入度为0]的节点进行处理
int cur = queue.poll();
// 将该节点加入结果集
res.add(cur); // res[idx++] = cur;
// 将节点从图中移除(此处移除不是真的移除,而是处理边关系)
List<Integer> link = list.get(cur);
for (int node : link) {
inDegree[node]--; // cur 节点指向的节点的入度都减1
// 更新后发现当前节点入度为0则加入queue
if (inDegree[node] == 0) {
queue.add(node);
}
}
}
// 4.结果打印
if (res.size() == n) {
for (int node : res) {
System.out.print(node + "->");
}
System.out.println("end");
} else {
// 结果节点个数和实际节点个数不匹配,说明存在环,无法获取正常的拓扑序列
System.out.println("-1");
}
}
public static void main(String[] args) {
// 1.输入处理
// N 个文件([0,N-1])、M 条边(有向依赖关系)
System.out.println("1.输入N个节点、M条边");
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边(a,b)形式");
// 接收边关系
List<Edge> edges = new ArrayList<>();
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
edges.add(new Edge(u, v));
}
// 2.调用拓扑排序
TopologicalSortTemplate solution = new TopologicalSortTemplate();
solution.topologicalSort(n, edges);
}
}
🟡KMW117-软件构建(拓扑排序经典题型)
1.题目内容
题目描述:
某个大型软件项目的构建系统拥有 N 个文件,文件编号从 0 到 N - 1,在这些文件中,某些文件依赖于其他文件的内容,这意味着如果文件 A 依赖于文件 B,则必须在处理文件 A 之前处理文件 B (0 <= A, B <= N - 1)。请编写一个算法,用于确定文件处理的顺序。
输入描述:
第一行输入两个正整数 N, M。表示 N 个文件之间拥有 M 条依赖关系。
后续 M 行,每行两个正整数 S 和 T,表示 T 文件依赖于 S 文件。
输出描述:
输出共一行,如果能处理成功,则输出文件顺序,用空格隔开。
如果不能成功处理(相互依赖),则输出 -1。
输入示例:
5 4
0 1
0 2
1 3
2 4
输出示例:0 1 2 3 4
文件的依赖关系图示如下,所以文件除了上述示例的处理顺序外,还存在其他合法顺序【0 2 4 1 3】、【0 2 1 3 4】等
2.题解思路
👻方法1:拓扑排序(BFS思路)
/**
* KMW 117 软件构建
*/
public class Solution1 {
// N 个文件([0,N-1])、M 条边(有向依赖关系)
public void topologicalSort(int n, List<Edge> edges) {
// 1.记录每个节点的入度、以及当前节点指向什么节点
List<List<Integer>> list = new ArrayList<>();
for (int i = 0; i < n; i++) {
list.add(new ArrayList<>());
}
int[] inDegree = new int[n]; // 构建每个节点及其入度的依赖关系(基于数组构建)
for (Edge edge : edges) {
int u = edge.u, v = edge.v; // u->v 关系校验
list.get(u).add(v); // 表示获取到u节点的指向节点列表然后追加相应的v节点(即处理u->v)
inDegree[v]++; // u->v,入度是针对v节点校验
}
// 2.定义队列存储入度为0的节点
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < inDegree.length; i++) {
if (inDegree[i] == 0) {
queue.offer(i); // 初始化将入度为0的节点加入队列
}
}
// 3.遍历处理
List<Integer> res = new ArrayList<>();
while (!queue.isEmpty()) {
// 按顺序取出[入度为0]的节点进行处理
int cur = queue.poll();
// 将该节点加入结果集
res.add(cur);
// 将节点从图中移除(此处移除不是真的移除,而是处理边关系)
List<Integer> link = list.get(cur);
for (int node : link) {
inDegree[node]--; // cur 节点指向的节点的入度都减1
// 更新后发现当前节点入度为0则加入queue
if (inDegree[node] == 0) {
queue.add(node);
}
}
}
// 4.结果打印
if (res.size() == n) {
for (int node : res) {
System.out.print(node + "->");
}
System.out.println("end");
} else {
// 结果节点个数和实际节点个数不匹配,说明存在环,无法获取正常的拓扑序列
System.out.println("-1");
}
}
public static void main(String[] args) {
// 1.输入处理
// N 个文件([0,N-1])、M 条边(有向依赖关系)
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
// 接收文件依赖关系(实际对照的就是有向图的边关系)
List<Edge> links = new ArrayList<>();
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
links.add(new Edge(u, v));
}
// 2.调用拓扑排序
Solution1 solution = new Solution1();
solution.topologicalSort(n, links);
}
}
🟡 207-课程表
1.题目内容
你这个学期必须选修 numCourses
门课程,记为 0
到 numCourses - 1
。
在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites
给出,其中 prerequisites[i] = [ai, bi]
,表示如果要学习课程 ai
则 必须 先学习课程 bi
。
- 例如,先修课程对
[0, 1]
表示:想要学习课程0
,你需要先完成课程1
。
请你判断是否可能完成所有课程的学习?如果可以,返回 true
;否则,返回 false
。
示例 1:
输入:numCourses = 2, prerequisites = [[1,0]]
输出:true
解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。
示例 2:
输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的。
2.解题思路
👻方法1:拓扑排序(BFS思路)
此处需注意题目中给出的内容,确定好u->v
的位置关系,以及考虑好选用什么样的数据结构存储图能够更好地进行数据处理
/**
* 🟡 207 课程表 - https://leetcode.cn/problems/course-schedule/description/
*/
public class Solution207_01 {
/**
* 思路分析:
* ① 根据边关系(依赖关系)封装入度集合(数组、Map,存储当前节点对应的入度)
* ② 构建队列辅助遍历,每次存队列中取出[入度为0]的节点,然后更新其关联节点的入度信息
*/
public boolean canFinish(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<>());
}
// ① 根据边关系构建每个节点的入度(并构建邻接表)
int[] inDegrees = new int[numCourses];
for (int[] edge : prerequisites) {
// [u,v] 表示u->v
int u = edge[0], v = edge[1];
graph.get(u).add(v); // 构建邻接表
inDegrees[v]++; // 下标对应节点
}
// ② 构建队列辅助遍历
Queue<Integer> queue = new LinkedList<>();
// 初始化将入度为0的节点入队
for (int i = 0; i < inDegrees.length; i++) {
// 入度为0说明没有前置依赖,可以优先取出
if (inDegrees[i] == 0) {
queue.offer(i); // 下标对应节点
}
}
// 定义结果集
List<Integer> res = new ArrayList<>();
// 当队列不为空,遍历队列
while (!queue.isEmpty()) {
// 取出节点
int u = queue.poll();
res.add(u); // 加载取出的节点到结果集
// 遍历处理该节点关联的节点,更新入度信息
for (int v : graph.get(u)) {
inDegrees[v]--; // 取出u节点,则其指向v节点对应入度-1
// 每次处理完成,将入度为0的节点v入队
if (inDegrees[v] == 0) {
queue.offer(v);
}
}
}
// 结果处理(如果取出的节点个数和实际节点个数匹配,说明不存在环,可获取拓扑排序)
return res.size() == numCourses;
}
}
5.最短路算法
🚀最短路算法理论基础
图论经典问题之【求最短路】:即给出一个有向图,一个起点,一个终点,问起点到终点的最短路径
- dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法
- bellman_ford 算法:在有权图(权值存在负数)中求从起点到其他节点的最短路径算法
- SPFA 算法(bellman_ford 队列优化算法):在有权图(权值存在负数,且不存在任何负权回路)中求从起点到其他节点的最短路径算法
🍚dijkstra 算法
⚽ dijkstra 算法(迪杰斯特拉算法朴素版)
实际上结合定义可以看到,dijkstra
算法 和 最小生成树场景中的prim 算法
思路是非常接近的。因此此处给出dijkstra 算法三部曲
:
- 步骤 ① 选距源点最近节点:选出距离源点最近的且未被访问过的节点
node
- 步骤 ② 标记访问状态:将步骤①中选出的节点
node
标记为访问过 - 步骤 ③ 更新
minDist[]
:更新node
可达的未被访问的节点到源点的最近距离(更新minDist[]
数组)
和prim算法
类似,此处需要维护一个minDist[]
数组,用于记录每一个节点距离源点的最小距离
(0)初始态
// N M
7 9
// M条边
1 2 1
1 3 4
2 3 2
2 4 5
3 4 2
4 5 3
2 6 4
5 7 4
6 7 9
定义minDist[]
数组,此处还需相应维护一个visited
数组(用于标记已选节点)
设定源点为【1】,终点为【节点7】
(1)步骤①(每个步骤的分析基于上一步骤的更新结果进行)
- 步骤 ① 选距源点最近节点:初始状态均为max,源点自身距离自身为0最近(也可以选择在初始化的时候将源点的
minDist[source]
值设为0),选择【节点1】 - 步骤 ② 标记访问状态:
visited[1]=true
- 步骤 ③ 更新
minDist[]
:经过【节点1】可以到达【节点2】、【节点3】(判断【节点1】关联指向的边),因此此处要更新minDist[2]
、minDist[3]
minDist[2]=1
minDist[3]=4
- 更新后:
(2)步骤②
- 步骤 ① 选距源点最近节点:选择【节点2】
- 步骤 ② 标记访问状态:
visited[2]=true
- 步骤 ③ 更新
minDist[]
:经过【节点2】可以到达【节点3】、【节点4】、【节点6】(判断【节点2】关联指向的边),因此此处要更新minDist[3]
、minDist[4]
、minDist[6]
- 此处不需要硬磕每个路径的最短路径要怎么算或者纠结多条路径的选择,而是基于选择节点的角度出发,因为
minDist[i]
记录的是【源点】到【指定节点】的最短路径,那么既然选择了【节点2】那么说明【节点1(源点) =》节点2】这条路径的最短距离是明确下来的(即minDist[2]
),那么当选择了【节点2】扩展了路径,可以通过【节点2】到达其它还没访问的节点,只需要考虑扩展的这些路径中是否存在需要更新最短路径(原有的路径会在前面每一次的节点选择中更新,本次更新只考虑新增路径的影响)。以【节点3】为例,既然可以通过【节点2】到达【节点3】,源点 到 【节点3】的最短路径可以由minDist[2] + dist(2,3)
构成(即【源点】到【节点2】的最短路径 + 【节点2】到【节点3】的直接路径),只需要将这个新增的路径和与此前的minDist[3]
比较取最小值方案即可- 即设定选择节点下标为
cur
,则经由节点cur
到达其关联节点x
,更新minDist[x] = min{minDist[x],minDist[cur] + dist(cur,x)}
- 即设定选择节点下标为
minDist[3]= min{4,1+2}=3
minDist[4]= min{max,1+5}=6
minDist[6]= min{max,1+4}=5
- 此处不需要硬磕每个路径的最短路径要怎么算或者纠结多条路径的选择,而是基于选择节点的角度出发,因为
- 更新后:
(3)步骤③
- 步骤 ① 选距源点最近节点:选择【节点3】
- 步骤 ② 标记访问状态:
visited[3]=true
- 步骤 ③ 更新
minDist[]
:【节点3】指向未被遍历的节点列表【节点4】minDist[4]=min{6,3+2}=5
- 更新后:
(4)步骤④
- 步骤 ① 选距源点最近节点:选择【节点4】
- 步骤 ② 标记访问状态:
visited[4]=true
- 步骤 ③ 更新
minDist[]
:【节点4】指向未被遍历的节点列表【节点5】minDist[5]=min{max,5+3}=8
- 更新后:
(5)步骤⑤
- 步骤 ① 选距源点最近节点:选择【节点6】
- 步骤 ② 标记访问状态:
visited[6]=true
- 步骤 ③ 更新
minDist[]
:【节点6】指向未被遍历的节点列表【节点7】minDist[7]=min{max,5+9}=14
- 更新后:
(6)步骤⑥
- 步骤 ① 选距源点最近节点:选择【节点5】
- 步骤 ② 标记访问状态:
visited[5]=true
- 步骤 ③ 更新
minDist[]
:【节点5】指向未被遍历的节点列表【节点7】minDist[7]=min{14,8+4}=12
- 更新后:
(7)步骤⑦
- 步骤 ① 选距源点最近节点:选择【节点7】
- 步骤 ② 标记访问状态:
visited[7]=true
- 步骤 ③ 更新
minDist[]
:【节点7】指向未被遍历的节点列表【无】 - 更新后:
(-)步骤⑧(整理)
每次选择一个节点(实际上就是每次选择一条最短路径),上述操作遍历完成,所有节点都被选出,基于此得出结果
dijkstra 算法 模板(自定义Edge处理,邻接表处理方式)
- ① 初始化
minDist[]
、visited[]
minDist[]
:可以设定指定【源点】,因此对于【源点】到【源点】最短距离设定为0,其他均初始化为maxValvisited[]
:初始化未被访问(false
)- 因为在遍历的过程中需要根据选择节点可达的未被访问节点进行筛选,因此还需要构建辅助的集合来存储过程中的一些要素:
links
:记录每个节点关联的可达节点(例如u->v
,记录u
节点可以访问的节点列表)map
:记录节点(u,v)
的权值映射关系(例如(u,v)
表示u->v
的边权值,用Map存储为Map<"u->v",weight>
形式)
- ② dijkstra 三部曲
- 步骤 ① 选距源点最近节点:选出距离源点最近的且未被访问过的节点
node
- 步骤 ② 标记访问状态:将步骤①中选出的节点
node
标记为访问过 - 步骤 ③ 更新
minDist[]
:更新node
可达的未被访问的节点到源点的最近距离(更新minDist[]
数组)
- 步骤 ① 选距源点最近节点:选出距离源点最近的且未被访问过的节点
/**
* dijkstra 迪杰斯特拉算法(邻接表处理方式)
*/
public class DijkstraTemplate {
public static void dijkstra(int n, List<Edge> edges, int startIdx) {
int maxValue = Integer.MAX_VALUE;
// 构建minDist[]: 源点到指定节点的最短距离
int[] minDist = new int[n + 1]; // 此处节点有效范围选择[1,n]
for (int i = 0; i <= n; i++) {
// 初始化(此处根据源点校验,源点到源点自身的距离设置为0)
if (i == startIdx) {
minDist[i] = 0; // 源点到自身的距离为0
} else {
minDist[i] = maxValue; // 其他节点的最短距离默认设置为范围内的最大值
}
}
// 构建遍历标识
boolean[] selected = new boolean[n + 1]; // 节点有效范围选择[1,n]
Arrays.fill(selected, false); // 初始化设置为未被遍历过(未被选择)
// 构建每个节点关联的节点列表(节点i可到达的其他节点列表)
List<List<Integer>> links = new ArrayList<>();
for (int i = 0; i <= n; i++) { // 初始化(此处要将0位置也空出)
links.add(new ArrayList<>());
}
// 构建每个节点边路径和权值的映射关系
Map<String, Integer> map = new HashMap<>();
// 根据edges边关系构建
for (Edge edge : edges) {
int u = edge.u;
int v = edge.v;
links.get(u).add(v);
map.put(u + "->" + v, edge.val);
}
// 主循环(dijkstra算法核心:dijkstra三部曲)
for (int cnt = 1; cnt <= n; cnt++) {
// 1.选择距离源点的最近节点
int cur = -1; // 定义选择指针
int minVal = maxValue; // 如果minDist全初始化为maxValue,则此处取maxValue + 1确保循环正常进入处理
for (int i = 1; i <= n; i++) {
if (!selected[i] && minDist[i] < minVal) { // 从未遍历节点中选择
cur = i;
minVal = minDist[i];
}
}
// 2.将这个最近节点标记为已被选择(遍历)
selected[cur] = true;
// 3.更新经由当前选择节点扩展的新路径(cur可到达的其他节点)
List<Integer> relateNodes = links.get(cur);
for (int v : relateNodes) {
if (!selected[v]) {
minDist[v] = Math.min(minDist[v], minDist[cur] + map.get(cur + "->" + v));
}
}
}
// 处理结果:输出结果
for (int i = 1; i <= n; i++) {
System.out.println(minDist[i]);
}
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N(节点个数)M边数");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入边");
List<Edge> edges = new ArrayList<>();
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
edges.add(new Edge(u, v, weight));
}
// 调用算法
DijkstraTemplate dijkstraTemplate = new DijkstraTemplate();
dijkstraTemplate.dijkstra(n, edges, 1);
}
}
基于上述算法模板可以看到,这个过程中还需要构建一些辅助的集合来处理,但实际上对于图而言,还有一种邻接矩阵的表示方式,可以试着用邻接矩阵的方式简化一下代码实现(因为邻接矩阵的表示也可以非常方便的明确直到节点与节点之间的连接关系)
可以看到如果使用邻接矩阵的话,用一个特殊标识-1
表示u->v
不可达(或者设定一个最大值边界INF
表示不可达),如果可达则设置为大于等于0的weight
边权重。那么可以替代上述邻接表辅助集合的作用(两种方式的选择主要看图的稀疏度,采用邻接矩阵的方式代码实现比较简洁明了(适用于稠密图,边连接接近完全图的场景),邻接矩阵需要额外维护关联关系(适用于节点多边少的稀疏图,避免矩阵空间浪费))
基于【邻接表】的处理:实际上上述就是基于邻接表的处理,那么完全可以转化为邻接表的处理(List<List<Edge>> graph
),无需额外的辅助的集合构建(只要知道u
就能获取到其关联的(u,v,w)
列表),简化后的版本处理其实和邻接矩阵的处理思路无异
/**
* dijkstra 迪杰斯特拉算法(邻接表处理方式、堆优化版本)
*/
public class DijkstraTemplate4 {
/**
* @param n 节点个数
* @param graph 邻接表(图)
* @param startIdx 源点
*/
public static void dijkstra(int n, List<List<Edge>> graph, int startIdx) {
int maxValue = Integer.MAX_VALUE;
// 构建minDist[]: 源点到指定节点的最短距离
int[] minDist = new int[n + 1]; // 此处节点有效范围选择[1,n]
for (int i = 0; i <= n; i++) {
// 初始化(此处根据源点校验,源点到源点自身的距离设置为0)
if (i == startIdx) {
minDist[i] = 0; // 源点到自身的距离为0
} else {
minDist[i] = maxValue; // 其他节点的最短距离默认设置为范围内的最大值
}
}
// 构建遍历标识
boolean[] selected = new boolean[n + 1]; // 节点有效范围选择[1,n]
Arrays.fill(selected, false); // 初始化设置为未被遍历过(未被选择)
// 主循环(dijkstra算法核心:dijkstra三部曲)
for (int cnt = 1; cnt <= n; cnt++) {
// 1.选择距离源点的最近节点
int cur = -1; // 定义选择指针
int minVal = maxValue; // 如果minDist全初始化为maxValue,则此处取maxValue + 1确保循环正常进入处理
for (int i = 1; i <= n; i++) {
if (!selected[i] && minDist[i] < minVal) { // 从未遍历节点中选择
cur = i;
minVal = minDist[i];
}
}
// 2.将这个最近节点标记为已被选择(遍历)
selected[cur] = true;
// 3.更新经由当前选择节点扩展的新路径(cur可到达的其他节点)
List<Edge> relateEdges = graph.get(cur);
for (Edge edge : relateEdges) {
if (!selected[edge.v]) {
minDist[edge.v] = Math.min(minDist[edge.v], minDist[cur] + edge.val);
}
}
}
// 处理结果:输出结果
for (int i = 1; i <= n; i++) {
System.out.println(minDist[i]);
}
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N(节点个数)M边数");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入边");
// 定义邻接表(表示每个节点关联的边(可以从边定义中跟踪到(u,v,w)关系))
List<List<Edge>> graph = new ArrayList<>();
for (int i = 0; i <= n; i++) {
// 初始化邻接表:下标索引对应为节点u(取值范围:[1,n]),下标对应元素值为节点u关联的边关系
graph.add(new ArrayList<>());
}
// 处理边关系
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
graph.get(u).add(new Edge(u, v, weight));
}
// 调用算法
DijkstraTemplate4 dijkstraTemplate = new DijkstraTemplate4();
dijkstraTemplate.dijkstra(n, graph, 1);
}
}
/**
* dijkstra 迪杰斯特拉算法(邻接矩阵处理方式)
*/
public class DijkstraTemplate2 {
public static void dijkstra(int n, int[][] graph, int startIdx) {
int maxValue = Integer.MAX_VALUE;
// 构建minDist[]: 源点到指定节点的最短距离
int[] minDist = new int[n + 1]; // 此处节点有效范围选择[1,n]
for (int i = 0; i <= n; i++) {
// 初始化(此处根据源点校验,源点到源点自身的距离设置为0)
if (i == startIdx) {
minDist[i] = 0; // 源点到自身的距离为0
} else {
minDist[i] = maxValue; // 其他节点的最短距离默认设置为范围内的最大值
}
}
// 构建遍历标识
boolean[] selected = new boolean[n + 1]; // 节点有效范围选择[1,n]
Arrays.fill(selected, false); // 初始化设置为未被遍历过(未被选择)
// 主循环(dijkstra算法核心:dijkstra三部曲)
for (int cnt = 1; cnt <= n; cnt++) {
// 1.选择距离源点的最近节点
int cur = -1; // 定义选择指针
int minVal = maxValue; // 如果minDist全初始化为maxValue,则此处取maxValue + 1确保循环正常进入处理
for (int i = 1; i <= n; i++) {
if (!selected[i] && minDist[i] < minVal) { // 从未遍历节点中选择
cur = i;
minVal = minDist[i];
}
}
// 2.将这个最近节点标记为已被选择(遍历)
selected[cur] = true;
// 3.更新经由当前选择节点扩展的新路径(cur可到达的其他节点)
for (int v = 1; v <= n; v++) { // 遍历节点v
if (!selected[v] && graph[cur][v] != -1) { // 需校验cur->v直接是否可达,通过weight校验是否为负数(此处设置一个特殊标识-1标识u->v不可达)来进行排除
minDist[v] = Math.min(minDist[v], minDist[cur] + graph[cur][v]);
}
}
}
// 处理结果:输出结果
for (int i = 1; i <= n; i++) {
System.out.println(minDist[i]);
}
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N(节点个数)M边数");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入边");
// 构建邻接矩阵,初始化设置节点不可达标识为-1
int[][] graph = new int[n + 1][n + 1];
for (int i = 0; i < graph.length; i++) {
Arrays.fill(graph[i], -1);
}
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
graph[u][v] = weight;
}
// 调用算法
DijkstraTemplate2 dijkstraTemplate = new DijkstraTemplate2();
dijkstraTemplate.dijkstra(n, graph, 1);
}
}
补充 选择路径 跟踪
和prim
一样,构建一个selectedPath[n+1]
用于记录选择路径信息,在更新minDist[]
的时候同步更新selectedPath
,构成一条完整的路径。改造版本说明如下,实际就是更新最短路径的时候同步更新选择的路径即可
/**
* dijkstra 迪杰斯特拉算法(邻接矩阵处理方式)
* 补充选择的路径
*/
public class DijkstraTemplate3 {
public static void dijkstra(int n, int[][] graph, int startIdx) {
int maxValue = Integer.MAX_VALUE;
// 构建minDist[]: 源点到指定节点的最短距离
int[] minDist = new int[n + 1]; // 此处节点有效范围选择[1,n]
for (int i = 0; i <= n; i++) {
// 初始化(此处根据源点校验,源点到源点自身的距离设置为0)
if (i == startIdx) {
minDist[i] = 0; // 源点到自身的距离为0
} else {
minDist[i] = maxValue; // 其他节点的最短距离默认设置为范围内的最大值
}
}
// 构建遍历标识
boolean[] selected = new boolean[n + 1]; // 节点有效范围选择[1,n]
Arrays.fill(selected, false); // 初始化设置为未被遍历过(未被选择)
// 构建选择的路径("(u->v)"形式)
String[] selectedPath = new String[n + 1]; // 节点有效范围选择[1,n]
// 主循环(dijkstra算法核心:dijkstra三部曲)
for (int cnt = 1; cnt <= n; cnt++) {
// 1.选择距离源点的最近节点
int cur = -1; // 定义选择指针
int minVal = maxValue; // 如果minDist全初始化为maxValue,则此处取maxValue + 1确保循环正常进入处理
for (int i = 1; i <= n; i++) {
if (!selected[i] && minDist[i] < minVal) { // 从未遍历节点中选择
cur = i;
minVal = minDist[i];
}
}
// 2.将这个最近节点标记为已被选择(遍历)
selected[cur] = true;
// 3.更新经由当前选择节点扩展的新路径(cur可到达的其他节点)
for (int v = 1; v <= n; v++) { // 遍历节点v
if (!selected[v] && graph[cur][v] != -1) { // 需校验cur->v直接是否可达,通过weight校验是否为负数(此处设置一个特殊标识-1标识u->v不可达)来进行排除
// minDist[v] = Math.min(minDist[v], minDist[cur] + graph[cur][v]);
// 更新midDist[i]的同时更新路径
int curMin = minDist[cur] + graph[cur][v];
if (curMin < minDist[v]) {
minDist[v] = curMin; // 更新源点到节点v最短路径
selectedPath[v] = "(" + cur + "->" + v + ")"; // 更新当前的节点路径选择
}
}
}
}
// 处理结果:输出结果
for (int i = 1; i <= n; i++) {
System.out.println(selectedPath[i] + minDist[i]);
}
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N(节点个数)M边数");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入边");
// 构建邻接矩阵,初始化设置节点不可达标识为-1
int[][] graph = new int[n + 1][n + 1];
for (int i = 0; i < graph.length; i++) {
Arrays.fill(graph[i], -1);
}
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
graph[u][v] = weight;
}
// 调用算法
DijkstraTemplate3 dijkstraTemplate = new DijkstraTemplate3();
dijkstraTemplate.dijkstra(n, graph, 1);
}
}
⚽ dijkstra 算法 VS prim 算法
应用场景
- prim 算法 的应用场景是【求无向图的最小连通路径和(连通所有节点)】
- dijkstra 算法的应用场景是【求有向图的最短路径(源点到终点)】
三部曲实现分析
从代码实现分析上看,两者的主要区别体现在步骤③中
minDist
的处理,此处再次结合两者的三部曲剖析每个概念的含义prim 算法:
minDist[i] 表示节点i到生成树的最短距离
步骤 ① 选距离生成树最近未被访问的节点
node
步骤 ② 最近节点加入生成树(即标记为已访问)
步骤 ③ 更新非生成树节点到生成树的距离(当选择了
node
加入生成树,此处到生成树的最短距离即为节点i到node的最短距离
)
dijkstra 算法:
minDist[i] 表示节点i到源点的最短距离
步骤 ① 选距源点最近节点:选出距离源点最近的且未被访问过的节点
node
步骤 ② 标记访问状态:将步骤①中选出的节点
node
标记为访问过步骤 ③ 更新
minDist[]
:更新node
可达的未被访问的节点到源点的最近距离(更新minDist[]
数组)- 当选择了
node
节点,则需要从node
可达未被访问的节点列表进行更新,此处minDist[i]
的取值指的是i
到源点
的最短路径更新(此处与prim不同)。且经由分析,这个取值的公式为minDist[i] = min {minDist[i] , minDist[node] + dist(node,i)}
(即源点 => node 的最短路径
加上node => i 的路径
)
- 当选择了
minDist[]
初始化:prim 算法:因为初始化没有选定起始遍历点,所以在初始化处理的时候将
minDist[i]=maxVal
,在三部曲遍历的步骤①选择节点的时候要尤其小心注意让循环正常初始化处理其实可以在初始化
minDist[i]
的时候,可以设定一个起始点概念,设置对应minDist[start]=0
(或者比初始的maxVal小即可),则步骤1就可以正常选择节点操作(注意通用处理的代码细节即可)// 步骤1.选择距离源点的最近节点 int cur = -1; // 定义选择指针 int minVal = maxValue; // 如果minDist全初始化为maxValue,则此处取maxValue + 1确保循环正常进入处理 for (int i = 1; i <= n; i++) { if (!selected[i] && minDist[i] < minVal) { // 从未遍历节点中选择 cur = i; minVal = minDist[i]; } }
dijkstra 算法:因为需要选定源点source,所以可以在初始化的时候设置
minDist[source]=0
(表示源点到源点的最短路径为0),其他minDist[i]=maxVal
⚽ dijkstra 算法(迪杰斯特拉算法堆优化版)
在无向图的最小连通路径
中介绍了prim
和kruskal
两种算法实现,其切入点主要是分别根据节点处理
、边处理
两个维度切入,不同的算法选择实际上在不同的图状态下有着天差地别的效率差
- Prim 算法 时间复杂度为 O(n2),其中 n 为节点数量,它的运行效率和图中的边数量无关,适用稠密图(指的是边的数量很多(接近于n×n的全连接图))
- Kruskal算法 时间复杂度 为 nlogn,其中n 为边的数量,适用稀疏图
而上述稠密和稀疏的概念针对的是节点和边的数量关系,分析每个算法关联的时间复杂度因素,例如prim的实现复杂度是与节点有关而与边无关,那么prim算法就是用于节点数量一定的情况下边数量远大于节点数量的稠密图;同理,Kruskal算法处理是按照边的维度来出发,那么它的算法时间复杂度是和边数量挂钩的,因此其适用于边数量小于节点数量的稀疏图场景
同理,在图论的处理中,邻接矩阵和邻接表的表示方式的选择就是从节点、边的数量处理优化的角度出发,不同图的表示方式对算法的执行效率也有着相应的影响。参考上述dijkstra 算法(朴素版本)
的实现,有两种处理思路,主要是基于图的表示方式不同来进行处理。
因此此处可以基于稀疏图
、稠密图
的角度出发来调整算法的实现,基于邻接矩阵实现的 dijkstra 朴素版本适用于稠密图场景,但如果遇到稀疏图的话,就会造成二维矩阵的空间占用和遍历二维矩阵的时间浪费。所以此处基于稀疏图
的考虑,可以用邻接表来替代邻接矩阵的图表示,并在此基础上进一步引入堆优化
版本
堆优化版本实际就是对邻接表版本朴素版的改造,引入优先队列动态同步维护minDist[]
的有序性,优先队列中存储的元素实际上就是Node
(每个节点,源点到该节点的最短距离),它和minDist[]
的定义是对照的,即两者是同步更新的(可以理解为优先队列(小顶堆)
的引入就是为了给minDist[]
排序,确保每次从堆顶取出最短距离,降低时间复杂度)
/**
* dijkstra 迪杰斯特拉算法(邻接表处理方式,堆优化版本)
*/
public class DijkstraTemplate5 {
/**
* @param n 节点个数
* @param graph 邻接表(图)
* @param startIdx 源点
*/
public static void dijkstra(int n, List<List<Edge>> graph, int startIdx) {
int maxValue = Integer.MAX_VALUE;
// 构建minDist[]: 源点到指定节点的最短距离
int[] minDist = new int[n + 1]; // 此处节点有效范围选择[1,n]
for (int i = 0; i <= n; i++) {
// 初始化(此处根据源点校验,源点到源点自身的距离设置为0)
if (i == startIdx) {
minDist[i] = 0; // 源点到自身的距离为0
} else {
minDist[i] = maxValue; // 其他节点的最短距离默认设置为范围内的最大值
}
}
// 构建遍历标识
boolean[] selected = new boolean[n + 1]; // 节点有效范围选择[1,n]
Arrays.fill(selected, false); // 初始化设置为未被遍历过(未被选择)
// 定义小顶堆,维护`minDist[]`的有序性
PriorityQueue<Node> pq = new PriorityQueue<>(
new Comparator<Node>() {
@Override
public int compare(Node o1, Node o2) {
return o1.sourceToMinDist - o2.sourceToMinDist; // 根据【源点到当前节点的最短距离】从小到大进行排序
}
}
);
pq.offer(new Node(startIdx, 0)); // 初始化队列:开始节点为源点(startIdx,源点距源点最短距离为0)
// 主循环(dijkstra算法核心:dijkstra三部曲)
while (!pq.isEmpty()) {
// 1.选择距离源点的最近节点(从小顶堆中获取)
Node cur = pq.poll();
// 2.将这个最近节点标记为已被选择(遍历)
selected[cur.nodeIdx] = true;
// 3.更新经由当前选择节点扩展的新路径(cur可到达的其他节点)
List<Edge> relateEdges = graph.get(cur.nodeIdx);
for (Edge edge : relateEdges) {
if (!selected[edge.v]) {
minDist[edge.v] = Math.min(minDist[edge.v], minDist[cur.nodeIdx] + edge.val);
pq.offer(new Node(edge.v, edge.v)); // 更新midDist的同时同步更新优先队列
}
}
}
// 处理结果:输出结果
for (int i = 1; i <= n; i++) {
System.out.println(minDist[i]);
}
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N(节点个数)M边数");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入边");
// 定义邻接表(表示每个节点关联的边(可以从边定义中跟踪到(u,v,w)关系))
List<List<Edge>> graph = new ArrayList<>();
for (int i = 0; i <= n; i++) {
// 初始化邻接表:下标索引对应为节点u(取值范围:[1,n]),下标对应元素值为节点u关联的边关系
graph.add(new ArrayList<>());
}
// 处理边关系
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
graph.get(u).add(new Edge(u, v, weight));
}
// 调用算法
DijkstraTemplate5 dijkstraTemplate = new DijkstraTemplate5();
dijkstraTemplate.dijkstra(n, graph, 1);
}
}
堆优化的改造版本是基于邻接表的朴素版本迭代的,其引入了自定义的Node
(与minDist[]定义对照),构建核心主要还是基于三部曲的改造:
- 主循环(队列不为空)
- ① 获取距离源点最近的节点,直接从优先队列(最小堆)取堆顶元素(优化了每次循环检索min的时间复杂度)
- ② 将取出的节点放入结果集(不变)
- ③ 同步更新
cur
节点邻接的未被遍历的节点的minDist[]
,并动态维护优先队列pq
🍚Bellman_ford 算法(版本1)
(1)有向图权值出现负数的情况如何讨论
基于上述对dijkstra的介绍,图中的边权值的讨论都是整数,假设权值出现负数的情况,基于dijkstra算法分析其适用性是否满足?继续基于dijkstra三部曲来进行分析(求【节点1】到【节点5】的最短路径),理论上【节点1】到【节点5】的最短路径为【1->2->3->4->5】
基于dijkstra三部曲分析,得到下述结果,通过算法选择【1到5】的路径是:【1->3->4->5】,但实际上如果加上负权值的话能到到更小的路径和。通过模拟过程分析,在访问【节点2】的时候【节点3】已经访问过了(因此不会再更新minDist
),所以就算存在更小的负数路径也没有办法调整
基于此可能会思考,是不是可以通过调整dijkstra算法逻辑来进行适配?但实际上这种拆东墙补西墙的方式容易适得其反,而且无法兼顾算法的适配性,例如还要思考已经遍历的节点重复访问的情况会不会出现死循环等。如果要针对某个场景不断去修改调整dijkstra算法的代码逻辑来适配,其补充逻辑充其量也只是满足某种特定的场景,不具备支撑性。因此为了统一处理这种【有向图权值出现负数的情况】,可以使用Bellman-Ford 算法 来处理
(2)Bellman_ford 算法核心
Bellman_ford 测试案例数据
6 7
5 6 -2
1 2 1
5 3 1
2 5 2
2 4 -3
4 6 4
1 3 5
该算法是由 R.Bellman 和L.Ford 在20世纪50年代末期发明的算法,故称为Bellman_ford算法
Bellman_ford算法的核心思想是 对所有边进行松弛n-1次操作(n为节点数量),从而求得目标最短路
==什么是松弛?==以上述图示为例,【节点A】到【节点B】的边权值为value
、【节点B】到【节点C】的边权值为value2
minDist[B]
表示【源点】到达【节点B】的最小权值,则此时minDist[B]
可以由哪些状态推导出来?
- 状态1:
minDist[B] = minDist[A] + value
(【源点】到【节点A】的最短路径 +dist(A,B)
) - 状态2:
minDist[B]
本身的值(例如可能是前面几轮选择其他边选择链接到【节点B】,以至于记录了minDist[B]
,例如在A之前,先选了C,如果B还没被遍历过就会更新midDist[B]
的值) - 则对于
minDist[B]
的取舍很明显是从两个状态中选择最小(这个过程分析有点类似动态规划的分析过程)- 因此此处
minDist[B] = min{minDist[B],minDist[A] + value}
(且minDist[A]
必须是经由计算过的值(即此处minDist[A]!=maxVal
),否则没有意义)
- 因此此处
如何理解n-1
次松弛?可以理解为执行n-1
次上述对图中的每个边的松弛操作(结合案例模拟过程分析理解)
即 执行n-1
次计算【源点】到【节点i
】的最短距离。对所有边松弛一次,相当于计算 起点到达 与起点一条边相连的节点 的最短距离,这里 说的是 一条边相连的节点。
与起点(节点1)一条边相邻的节点,到达节点2 最短距离是 1,到达节点3 最短距离是5。而 节点1 -> 节点2 -> 节点5 -> 节点3 这条路线 是 与起点 三条边相连的路线了。
- 所以对所有边松弛一次 能得到 与起点 一条边相连的节点最短距离
- 对所有边松弛两次 可以得到与起点 两条边相连的节点的最短距离
- 对所有边松弛三次 可以得到与起点 三条边相连的节点的最短距离,此时就能得到到达节点3真正的最短距离,也就是 节点1 -> 节点2 -> 节点5 -> 节点3 这条路线
因为起点到终点最多是n-1
条边相连,那么不管图是什么样的连接状态,对所有的边松弛n-1
次就一定可以得到起点到达终点的最短距离,这个过程中也相应得到了【起点】到【所有节点】的最短距离(因为对于所有节点而言,起点到这些节点连接的边数最多也是n-1
)
结合上述图示分析:起点到达B
可能是经由A
或者经由C
确定,那么相当于起码要计算2次才能确认minDist[B]
。同理对于n个节点的图来说,每个节点都可能由其他n-1
个节点接入过来,因此起码要计算n-1
次才能确定minDist[end]
(可以理解为这个n-1
的设定是一个普适性的选择,适配所有图,实际一些图可能遍历到<n-1
次的时候就已经确定了所有的最短路径了)
(3)Bellman_ford 算法核心(案例模拟过程)
初始化 & 第①次松弛
初始化:初始化minDist[]
数组(minDist[i]
表示【源点】到【节点i】的最短路径和),初始化状态均为maxVal
,对于【源点】(【源点】到【源点】的最短路径为0)初始化minDist[startIdx]=0
第①次松弛:根据算法核心对每条边进行松弛操作,此处按照边的输入顺序依次进行遍历。松弛的核心条件分析如下:
- ①
minDist[u] != max
(防止从未被计算过的节点出发) (如果u
节点还没被计算过,那么此处的最短更新无意义,因此要避免从未被计算过的节点出发) - ②
minDist[u] + dist(u,v) < minDist[v]
(当出现了最短路径时,则选择更新) - ③ 当①②条件均满足的时候更新
minDist[v] = min{minDist[v], minDist[u] + weight}
松弛过程分析
- 遍历
(5,6,-2)
:此时【节点5】还没有计算过,因此minDist[6]=max
不变- 【max,0,max,max,max,max,max】
- 遍历
(1,2,1)
:此时【节点1】已经计算过,则判断minDist[1] + dist(1,2) = 0 + 1 = 1
(1<max
成立,说明出现了最短路径,需进行更新)- 【max,0,1,max,max,max,max】
- 遍历
(5,3,1)
:此时【节点5】还没有计算过,因此minDist[3]=max
不变- 【max,0,1,max,max,max,max】
- 遍历
(2,5,2)
:此时【节点2】已经计算过,则判断minDist[2] + dist(2,5) = 1 + 2 = 3
(3<max
成立,说明出现了最短路径,需进行更新)- 【max,0,1,max,max,3,max】
- 遍历
(2,4,-3)
:此时【节点2】已经计算过,则判断minDist[2] + dist(2,4) = 1 + (-3) = -2
(-2<max
成立,说明出现了最短路径,需进行更新)- 【max,0,1,max,-2,3,max】
- 遍历
(4,6,4)
:此时【节点4】已经计算过,则判断minDist[4] + dist(4,6) = (-2) + 4 = 2
(2<max
成立,说明出现了最短路径,需进行更新)- 【max,0,1,max,-2,3,2】
- 遍历
(1,3,5)
:此时【节点1】已经计算过,则判断minDist[1] + dist(1,3) = 0 + 5 = 5
(5<max
成立,说明出现了最短路径,需进行更新)- 【max,0,1,5,-2,3,2】
n-1
次松弛过程分析
同理,继续遍历每一条边,完成同样的松弛操作,过程分析如下(此处的过程分析,可以结合算法实现打印过程数据,看推导是否匹配)
(4)Bellman_ford 算法代码模板
⚽ bellmanFord算法:邻接表 方式处理
/**
* bellmanFord算法(处理带负权值的有向图的最短路径:起点到终点)
* 邻接表处理方式
*/
public class BellmanFordTemplateByGraphTable {
// bellmanFord算法(处理带负权值的有向图的最短路径:起点到终点)
public static void bellmanFord(int n, List<List<Edge>> graph, int startIdx) {
// 定义最大范围
int maxVal = Integer.MAX_VALUE;
// minDist[i] 源点到节点i的最短距离
int[] minDist = new int[n + 1]; // 有效节点编号范围:[1,n]
for (int i = 1; i <= n; i++) {
if (i == startIdx) {
minDist[i] = 0;
} else {
minDist[i] = maxVal;
}
}
// bellmanFord 算法核心:对所有边松弛n-1次
for (int i = 1; i < n; i++) {
// 执行bellmanFord核心,遍历所有边
for (int idx = 1; idx < graph.size(); idx++) {
for (Edge edge : graph.get(idx)) {
int u = edge.u; // 与idx对照
int v = edge.v;
int weight = edge.val;
if (minDist[u] != maxVal && minDist[u] + weight < minDist[v]) { // bellman_ford 核心公式
minDist[v] = minDist[u] + weight;
}
}
}
}
// 整理结果
for (int i = 1; i <= n; i++) {
System.out.print(minDist[i] + "->");
}
System.out.println("end");
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N个节点、M条边(u v weight)");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
List<List<Edge>> graph = new ArrayList<>(); // 构建邻接表(n个节点:关联节点的边)
for (int i = 0; i <= n; i++) {
graph.add(new ArrayList<>());
}
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
graph.get(u).add(new Edge(u, v, weight));
}
// 调用bellman算法
BellmanFordTemplateByGraphTable.bellmanFord(n, graph, 1);
}
}
bellmanFord算法(纯边处理)
基于上述分析可以看到bellmanFord算法实际上是对边的处理,对u
关联的v
列表这类关联关系的应用没有那么强的要求设定,算法的核心是对图的每一条边做n-1次松弛,那么此处可以不采用邻接矩阵或者邻接表的方式存储图,而是直接存储边关系即可(List<Edge>
:(u,v,weight)
分别为Edge
类的属性)
/**
* bellmanFord算法(版本1:处理不含【负权回路】的有向图的最短路径问题)
*/
public class BellmanFordForBase {
/**
* bellmanFord算法
*
* @param n 节点个数[1,n]
* @param edges 边列表集合
* @param startIdx 开始节点(源点)
*/
public static int[] bellmanFord(int n, List<Edge> edges, int startIdx) {
// 定义最大范围
int maxVal = Integer.MAX_VALUE;
// minDist[i] 源点到节点i的最短距离
int[] minDist = new int[n + 1]; // 有效节点编号范围:[1,n]
Arrays.fill(minDist, maxVal); // 初始化为maxVal
minDist[startIdx] = 0; // 设置源点到源点的最短路径为0
// bellmanFord 算法核心:对所有边松弛n-1次
for (int i = 1; i < n; i++) {
// 执行bellmanFord核心,遍历所有边
for (Edge edge : edges) {
int u = edge.u; // 与idx对照
int v = edge.v;
int weight = edge.val;
if (minDist[u] != maxVal && minDist[u] + weight < minDist[v]) { // bellman_ford 核心公式
minDist[v] = minDist[u] + weight;
}
}
System.out.println("第" + i + "次松弛");
PrintUtil.print(minDist); // 打印数组
}
// 返回minDist
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N个节点、M条边(u v weight)");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
List<Edge> edges = new ArrayList<>(); // 构建边集合
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
edges.add(new Edge(u, v, weight));
}
// 调用bellman算法
int[] minDist = BellmanFordForBase.bellmanFord(n, edges, 1);
// 校验起点1->终点n
if (minDist[n] == Integer.MAX_VALUE) {
System.out.println("[起点1]到[终点n]不可达");
} else {
System.out.println("[起点1]到[终点n]的最短路径:" + minDist[n]);
}
}
}
// output case01(不存在负权回路)
1.输入N个节点、M条边(u v weight)
6 7
2.输入M条边
5 6 -2
1 2 1
5 3 1
2 5 2
2 4 -3
4 6 4
1 3 5
第1次松弛
[2147483647]-[0]-[1]-[5]-[-2]-[3]-[2]-
第2次松弛
[2147483647]-[0]-[1]-[4]-[-2]-[3]-[1]-
第3次松弛
[2147483647]-[0]-[1]-[4]-[-2]-[3]-[1]-
第4次松弛
[2147483647]-[0]-[1]-[4]-[-2]-[3]-[1]-
第5次松弛
[2147483647]-[0]-[1]-[4]-[-2]-[3]-[1]-
[起点1]到[终点n]的最短路径:1
// output case02(不存在负权回路)
1.输入N个节点、M条边(u v weight)
4 4
2.输入M条边
1 2 -1
2 3 1
3 1 -1
3 4 1
第1次松弛
[2147483647]-[-1]-[-1]-[0]-[1]-
第2次松弛
[2147483647]-[-2]-[-2]-[-1]-[0]-
第3次松弛
[2147483647]-[-3]-[-3]-[-2]-[-1]-
[起点1]到[终点n]的最短路径:-1
🔔 Bellman_ford 队列优化 之 SPFA 算法
(1)SPFA 算法 核心(bellman_ford 队列优化算法)
Bellman_ford 队列优化算法 ,也叫SPFA算法(Shortest Path Faster Algorithm)
基于bellman_ford
算法分析可知,算法的核心是对每次操作都对所有边进行松弛操作,但真正有效的松弛是基于已经计算过的节点在做的松弛
还是基于上述bellman_ford
案例进行分析(以第①次对所有边松弛为例)
- ①遍历
(5,6,-2)
:此时**【节点5】还没有计算过**,因此minDist[6]=max
不变 (无效松弛)- 【max,0,max,max,max,max,max】
- ②遍历
(1,2,1)
:此时【节点1】已经计算过,则判断minDist[1] + dist(1,2) = 0 + 1 = 1
(1<max
成立,说明出现了最短路径,需进行更新)- 【max,0,1,max,max,max,max】
- ③遍历
(5,3,1)
:此时**【节点5】还没有计算过**,因此minDist[3]=max
不变 (无效松弛)- 【max,0,1,max,max,max,max】
- ④遍历
(2,5,2)
:此时【节点2】已经计算过,则判断minDist[2] + dist(2,5) = 1 + 2 = 3
(3<max
成立,说明出现了最短路径,需进行更新)- 【max,0,1,max,max,3,max】
- ⑤遍历
(2,4,-3)
:此时【节点2】已经计算过,则判断minDist[2] + dist(2,4) = 1 + (-3) = -2
(-2<max
成立,说明出现了最短路径,需进行更新)- 【max,0,1,max,-2,3,max】
- ⑥遍历
(4,6,4)
:此时【节点4】已经计算过,则判断minDist[4] + dist(4,6) = (-2) + 4 = 2
(2<max
成立,说明出现了最短路径,需进行更新)- 【max,0,1,max,-2,3,2】
- ⑦遍历
(1,3,5)
:此时【节点1】已经计算过,则判断minDist[1] + dist(1,3) = 0 + 5 = 5
(5<max
成立,说明出现了最短路径,需进行更新)- 【max,0,1,5,-2,3,2】
在bellman_ford
算法中实际上通过minDist[i]=MAX_VALUE
只是对还没计算过的节点(初始状态)关联的边进行过滤,但是从第②次对所有边松弛开始,实际上此时就会对所有边都进行松弛操作,但实际上是做了很多无用功,而此处只需要关注上一次松弛更新的节点(因为minDist[i]
仅仅受到【节点i
】的前置节点minDist[u]
的更新影响,这点可以由递推公式来观察可知:minDist[v] = {minDist[v],minDist[u] + dist(u,v)}
)
因此对于bellman_ford
的优化,实际上就是通过queue
队列存储上一次松弛更新的节点,通过遍历queue
来决定下一次要更新的节点,这才是有效的松弛。通过遍历队列queue
中的节点(u
),对以该节点出发的可达节点所连接的边进行松弛即可。此外,针对队列的更新,此处还有一个优化点就是针对已经存在于队列中的节点可以不需要重复添加
案例推演分析过程
- 初始化:
minDist
:存储【源点】到【节点】的最短距离,初始化均为maxVal,对于源点到自身的最短距离初始化为0(minDist[startIdx]=0
)queue
:存储上一次松弛更新的节点(和minDist[i]
的更新保持同步:即queue.push(startIdx)
)
- 依次取出队列中的节点,然后更新其关联的边的另一个端点
minDist[v]
(依次从队列中取出节点,然后对其关联的边进行松弛)- 取出【节点1】:
- ① 取出【节点1】,其关联节点为【节点2】【节点3】
- ② 判断是否需要更新
minDist[v]
同步更新minDist和queue,即判断minDist[u] + weight < minDist[v]
是否成立- 【节点2】:
需更新minDist[2]=1
; queue中没有【节点2】,将其入队 - 【节点3】:
需更新minDist[3]=5
;queue中没有【节点3】,将其入队
- 【节点2】:
- ③ 更新后的
minDist[]
、queue
minDist[]
:【max,0,1,5,max,max,max】queue
:【2,3】
- 继续取出【节点2】
- ① 取出【节点2】,其关联节点为【节点4】【节点5】
- ② 判断是否需要更新
minDist[v]
同步更新minDist和queue,即判断minDist[u] + weight < minDist[v]
是否成立- 【节点4】:
需更新minDist[4]=-2
; queue中没有【节点4】,将其入队 - 【节点5】:
需更新minDist[5]=3
;queue中没有【节点5】,将其入队
- 【节点4】:
- ③ 更新后的
minDist[]
、queue
minDist[]
:【max,0,1,5,-2,3,max】queue
:【3,4,5】
- 继续取出【节点3】
- ① 取出【节点3】,无关联节点,不需要执行更新操作
- ② 无
- ③ 更新后的
minDist[]
、queue
minDist[]
:【max,0,1,5,-2,3,max】queue
:【4,5】
- 继续取出【节点4】
- ① 取出【节点4】,其关联节点为【节点6】
- ② 判断是否需要更新
minDist[v]
同步更新minDist和queue,即判断minDist[u] + weight < minDist[v]
是否成立- 【节点6】:
需更新minDist[6]=2
; queue中没有【节点6】,将其入队
- 【节点6】:
- ③ 更新后的
minDist[]
、queue
minDist[]
:【max,0,1,5,-2,3,2】queue
:【5,6】
- 继续取出【节点5】
- ① 取出【节点5】,其关联节点为【节点3】【节点6】
- ② 判断是否需要更新
minDist[v]
同步更新minDist和queue,即判断minDist[u] + weight < minDist[v]
是否成立- 【节点3】:
需更新minDist[3]=4
; queue中没有【节点3】,将其入队 - 【节点6】:
需更新minDist[6]=1
;此时队列中已经存在【节点6】不需要重复入队(因为此时就算其不重复入队,其在下一次遍历也会校验到,因此此处优化队列处理,对于已经存在的节点不重复加入)
- 【节点3】:
- ③ 更新后的
minDist[]
、queue
minDist[]
:【max,0,1,4,-2,3,1】queue
:【6,3】
- 继续取出【节点6】
- ① 取出【节点6】,无关联节点,不需要执行更新操作
- ② 无
- ③ 更新后的
minDist[]
、queue
minDist[]
:【max,0,1,4,-2,3,1】queue
:【3】
- 继续取出【节点3】
- ① 取出【节点3】,无关联节点,不需要执行更新操作
- ② 无
- ③ 更新后的
minDist[]
、queue
minDist[]
:【max,0,1,4,-2,3,1】queue
:【】
- 队列为空,遍历结束,所有更新操作完成,最终生成结果【max,0,1,4,-2,3,1】
- 取出【节点1】:
(2)SPFA 算法代码模板(版本1)
SPFA 算法 实际 为 Bellman_ford 算法的优化版本,因此在原有版本进行优化改造。改造的核心点关注两个重点:
- ①
List<Edge>
=>List<List<Edge>>
- ,所以每次都是直接遍历所有边,而不关注节点与边的关联关系,所以可以用
List<Edge>
存储边集合,然后进行处理 - SPFA 是对上一次松弛更新后的节点关联的边进行松弛操作,因此需要记录
List<List<Edge>>
便于根据节点获取其关联连接的边信息,实际此处就是邻接表的存储
- ,所以每次都是直接遍历所有边,而不关注节点与边的关联关系,所以可以用
- ② 松弛条件
- Bellman_ford 是对所有边执行
n-1
次松弛 - SPFA 通过队列记录每次松弛操作更新了的节点(与
minDist[i]
的更新操作是同步的),通过遍历queue
来选择要进行松弛的节点,然后将满足更新条件的节点(实际执行更新操作的节点)加入队列等待下次处理- 此处针对队列有个优化点就是对于已经存在于
queue
队列中的节点无需重复加入,因为其肯定会在后面的遍历中进行处理,所以无需重复加入操作
- 此处针对队列有个优化点就是对于已经存在于
- Bellman_ford 是对所有边执行
/**
* SPFA算法(版本1):针对不含【负权回路】的有向图的最短距离问题
* (处理带负权值的有向图的最短路径:起点到终点) bellman_ford(版本1) 的队列优化算法版本
*/
public class SPFAForBase {
/**
* SPFA算法
*
* @param n 节点个数[1,n]
* @param graph 邻接表
* @param startIdx 开始节点(源点)
*/
public static int[] spfa(int n, List<List<Edge>> graph, int startIdx) {
// 定义最大范围
int maxVal = Integer.MAX_VALUE;
// minDist[i] 源点到节点i的最短距离
int[] minDist = new int[n + 1]; // 有效节点编号范围:[1,n]
Arrays.fill(minDist, maxVal); // 初始化为maxVal
minDist[startIdx] = 0; // 设置源点到源点的最短路径为0
// 定义queue记录每一次松弛更新的节点
Queue<Integer> queue = new LinkedList<>();
queue.offer(startIdx); // 初始化:源点开始(queue和minDist的更新是同步的)
// SPFA算法核心:只对上一次松弛的时候更新过的节点关联的边进行松弛操作
while (!queue.isEmpty()) {
// 取出节点
int cur = queue.poll();
// 获取cur节点关联的边,进行松弛操作
List<Edge> relateEdges = graph.get(cur);
for (Edge edge : relateEdges) {
int u = edge.u; // 与`cur`对照
int v = edge.v;
int weight = edge.val;
if (minDist[u] + weight < minDist[v]) {
minDist[v] = minDist[u] + weight; // 更新
// 队列同步更新(此处有一个针对队列的优化:就是如果已经存在于队列的元素不需要重复添加)
if (!queue.contains(v)) {
queue.offer(v); // 与minDist[i]同步更新,将本次更新的节点加入队列,用做下一个松弛的参考基础
}
}
}
}
// 返回minDist
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N个节点、M条边(u v weight)");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
List<List<Edge>> graph = new ArrayList<>(); // 构建邻接表
for (int i = 0; i <= n; i++) {
graph.add(new ArrayList<>());
}
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
graph.get(u).add(new Edge(u, v, weight));
}
// 调用算法
int[] minDist = SPFAForBase.spfa(n, graph, 1);
// 校验起点1->终点n
if (minDist[n] == Integer.MAX_VALUE) {
System.out.println("[起点1]到[终点n]不可达");
} else {
System.out.println("[起点1]到[终点n]的最短路径:" + minDist[n]);
}
}
}
(3)Bellman_ford VS SPFA 算法效率分析
队列优化版Bellman_ford(SPFA) 的时间复杂度 并不稳定,其效率高低依赖于图的结构。
例如 如果是一个双向图,且每一个节点和所有其他节点都相连的话,那么该算法的时间复杂度就接近于 Bellman_ford 的 O(N * E) N 为节点数量,E为边的数量。
在这种图中,每一个节点都会重复加入队列 n - 1次,因为 这种图中 每个节点 都有 n-1 条指向该节点的边,每条边指向该节点,就需要加入一次队列。当然这种图是比较极端的情况,也是最稠密的图。所以如果图越稠密,则 SPFA的效率越接近与 Bellman_ford,反之,图越稀疏,SPFA的效率就越高
一般来说,SPFA 的时间复杂度为 O(K * N) K 为不定值,因为 节点需要计入几次队列取决于 图的稠密度。如果图是一条线形图且单向的话,每个节点的入度为1,那么只需要加入一次队列,这样时间复杂度就是 O(N)。所以 SPFA 在最坏的情况下是 O(N * E),但 一般情况下 时间复杂度为 O(K * N)。
尽管如此,以上分析都是 理论上的时间复杂度分析。并没有计算 出队列 和 入队列的时间消耗。 因为这个在不同语言上 时间消耗也是不一定的。
因此SPFA算法在理论上时间复杂度是略胜一筹的,但是也要基于实际图的稠密程度,如果图很大且稠密的情况下,SPFA算法核心的时间复杂度不仅会趋向于bellman_ford,而且还有额外的队列处理消耗(出队、入队),则可能导致时间是SPFA的消耗更多
(4)死循环问题
此处根据!queue.isEmpty()
来作为循环条件,是否会造成死循环问题?(例如图中有环,会不会一直将元素添加到队列中)
实际上针对有环的情况还要进一步区分【正权回路】、【负权回路】的不同情况讨论
- 【正权回路】可以理解为有向图中存在环,环的总权值为正数
- 【负权回路】可以理解为有向图中存在环,环的总权值为负数
在有环且只有正权回路的情况下,即使元素重复加入队列,最后也会因为 所有边都松弛 后,节点数值(minDist
数组)不再发生变化了 而终止。而且有重复元素加入队列是正常的,多条路径到达同一个节点,节点需要选择一个最短的路径,而这个节点(例如有多个入度的节点)就会重复加入队列进行判断,选一个最短的。就算有重复的节点加入队列,最后也会因为minDist[]
不再发生变化(不发生变化就没有新的节点加入)而跳出循环,因为queue
的入队操作始终和minDist[]
的**有效松弛(满足条件才更新)**操作同步
但是当出现了负权回路的情况下,分析就不一样了(可以从成本的角度理解,如果存在负权回路的情况下,就会通过不断循环走这个【负权回路】来降低成本。结合下述图示分析,A->B->C->A
是一个负权回路,假设A为起点、C为终点,那么A->C的最短距离基于SPFA
算法的求解过程如下:
- 初始化,【A】入队
- 初始化:
minDist[]
【max,0,max,max】;queue
:【A】
- 初始化:
- 取出【A】
- 处理
minDist[B]=-1
,【B】入队 - 更新后:
minDist[]
【max,0,-1,max】;queue
:【B】
- 处理
- 取出【B】
- 处理
minDist[C]=-2
,【C】入队 - 更新后:
minDist[]
【max,0,-1,-2】;queue
:【C】
- 处理
- 取出【C】
- 处理
minDist[A]=-3
,【A】入队 - 更新后:
minDist[]
【max,-3,-1,-2】;queue
:【A】
- 处理
- 取出【A】
- 处理
minDist[B]=-4
,【B】入队 - 更新后:
minDist[]
【max,-3,-4,max】;queue
:【B】
- 处理
- ...... 以此类推,当出现【负权回路】,不断循环遍历,每走一圈【负权回路】都会导致
minDist[]
不断更新,也意味着queue
会不断添加、处理这些节点,因为可以通过【负权回路】去不断降低成本并更新,进而得到错误的结果。所以说对于现有的SPFA实现而言,其无法兼容存在负权回路的图
为了进一步作对比,此处列举【正权回路】的情况
初始化,【A】入队
- 初始化:
minDist[]
【max,0,max,max】;queue
:【A】
- 初始化:
取出【A】
- 处理
minDist[B]=-1
,【B】入队 - 更新后:
minDist[]
【max,0,-1,max】;queue
:【B】
- 处理
取出【B】
- 处理
minDist[C]=0
,【C】入队 - 更新后:
minDist[]
【max,0,-1,0】;queue
:【C】
- 处理
取出【C】
处理
minDist[A]
,此时会发现minDist[C]+dist(C,A)=0+1=1
,因此不满足更新条件(没有更短距离产生),因此不会更新minDist[A]
,因此【A】也不会入队更新后:
minDist[]
【max,0,-1,0】;queue
:【】
基于上述操作
queue
遍历为空,循环结束,因此也验证了【正权回路】的正常执行
对于SPFA算法中循环条件!queue.isEmpty()
的解读:
①在松弛的过程中可能不可避免会有节点重复加入队列的情况,这些节点的重复加入是因为其前置节点的最短距离发生了变化,所以联动更新,一旦minDist[u]
敲定下来,则同样不会有新的元素加入queue
,因此queue
的遍历可以正常退出
②假设出现"回路"的情况,那么就会出现节点循环加入的现象(本质是又可能重新遍历到【源点】开启新一轮循环),结合上述案例分析可以理解【正权回路】、【负权回路】的不同讨论:
- 【负权回路】在循环加入节点的过程中,
minDist[]
会不断累加负值而导致不断更新,进而陷入死循环 - 【正权回路】在循环加入节点的过程中,会发现当**再次处理到【源点A】**的时候会发现理论上的
minDist[C]+dist(C,A)=0+1=1
(这个值大于0
(原初始化【源点】到【源点】的最短路径))不满足更新条件,因此不会执行更新操作也不会再将【源点A】加入队列,因此可以顺利跳出循环- 基于此可以发现,其本质实际上就是【源点】被重复加入进而开始了新一轮的循环校验,而对于正权回路而言,初始化【源点】到【源点】的最短路径是0,如果是从【源点】出发走了一圈回到【源点】那么这个距离实际上就是这个环的路径和
X
,如果发现路径和X>0
则说明不是最短路径,自然不会更新,因此最多走一圈就会结束;如果发现路径和X<=0
的话会认为出现了最短路径,那么就会将【源点】再次加入然后再进行一轮循环校验,每走一圈X
就会越来越小,就会一直绕圈圈。那么此处的X
实际上就是【正权回路】、【负权回路】的分界标准,也就解释了SPFA算法为什么【正权回路】可行,而遇到【负权回路】就会陷入死循环
- 基于此可以发现,其本质实际上就是【源点】被重复加入进而开始了新一轮的循环校验,而对于正权回路而言,初始化【源点】到【源点】的最短路径是0,如果是从【源点】出发走了一圈回到【源点】那么这个距离实际上就是这个环的路径和
🍚Bellman_ford 算法 之 判断负权回路(版本2)
# 测试案例数据
4 4
1 2 -1
2 3 1
3 1 -1
3 4 1
(1)负权回路问题分析
基于上述对Bellman_ford 算法的分析可以知道,针对有向图(不含负权回路)的场景中,Bellman_ford 算法的核心是对所有边进行n-1
次松弛操作,当执行了n-1
次操作之后minDist[]
是必然已经确定下来的。换个角度思考,如果多执行1次松弛操作是不是就可以验证是否出现负权回路:
- 在没有负权回路的图中,松弛 n 次以上 ,结果不会有变化
- 如果图中存在负权回路,那么松弛 n 次以上 ,结果会不断发生变化(因为 只要有负权回路 就是可以无限最短路径(一直绕圈,就可以一直得到无限小的最短距离))
因此如果基于Bellman_ford算法改造,则是对每条边进行n
次松弛,并且在最后一次的松弛过程中校验minDist[i]
是否发生了变化(如果发生变化则说明存在负权回路)
/**
* bellmanFord算法(版本2:处理含【负权回路】的有向图的最短路径问题)(判断是否存在负权回路)
* bellmanFord算法(处理带负权值的有向图的最短路径:起点到终点)
* - 针对带有【负权回路】的处理
*/
public class BellmanFordForNegativeWeightCycle {
/**
* bellmanFord算法
*
* @param n 节点个数[1,n]
* @param edges 边列表集合
* @param startIdx 开始节点(源点)
*/
public static int[] bellmanFord(int n, List<Edge> edges, int startIdx) {
// 定义最大范围
int maxVal = Integer.MAX_VALUE;
// minDist[i] 源点到节点i的最短距离
int[] minDist = new int[n + 1]; // 有效节点编号范围:[1,n]
Arrays.fill(minDist, maxVal); // 初始化为maxVal
minDist[startIdx] = 0; // 设置源点到源点的最短路径为0
// bellmanFord 算法核心:对所有边松弛n次(n-1次完全松弛,第n次是为了校验是否存在负权回路)
boolean hasNegativeWeightCycle = false;
for (int i = 1; i <= n; i++) {
// 执行bellmanFord核心,遍历所有边
for (Edge edge : edges) {
int u = edge.u; // 与idx对照
int v = edge.v;
int weight = edge.val;
if (i < n) {
// 前n-1次是bellman_ford算法核心,对所有边执行n-1次松弛
if (minDist[u] != maxVal && minDist[u] + weight < minDist[v]) { // bellman_ford 核心公式
minDist[v] = minDist[u] + weight;
}
} else if (i == n) {
// 第n次松弛则是为了校验是否存在【负权回路】
if (minDist[u] != maxVal && minDist[u] + weight < minDist[v]) {
// 出现了更短的路径,说明存在【负权回路】
hasNegativeWeightCycle = true;
}
}
}
System.out.println("第" + i + "次松弛");
PrintUtil.print(minDist); // 打印数组
}
if (hasNegativeWeightCycle) {
System.out.println("[负权回路]出现标识:" + hasNegativeWeightCycle);
} else {
// 校验起点1->终点n
if (minDist[n] == Integer.MAX_VALUE) {
System.out.println("[起点1]到[终点n]不可达");
} else {
System.out.println("[起点1]到[终点n]的最短路径:" + minDist[n]);
}
}
// 返回minDist
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N个节点、M条边(u v weight)");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
List<Edge> edges = new ArrayList<>(); // 构建边集合
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
edges.add(new Edge(u, v, weight));
}
// 调用bellman算法
BellmanFordForNegativeWeightCycle.bellmanFord(n, edges, 1);
}
}
(2)SPFA 算法 之 判断负权回路(版本2)
同理,对于SPFA
算法而言,在极端情况下,即:所有节点都与其他节点相连,每个节点的入度为 n-1 (n为节点数量),所以每个节点最多加入 n-1 次队列。那么如果节点加入队列的次数 超过了 n-1次 ,那么该图就一定有负权回路
/**
* SPFA算法(版本2):处理含【负权回路】的有向图的最短路径问题
* (处理带负权值的有向图的最短路径:起点到终点) bellman_ford(版本2) 的队列优化算法版本
* - 针对带有【负权回路】的处理
*/
public class SPFAForNegativeWeightCycle {
/**
* SPFA算法(处理带负权值的有向图的最短路径:起点到终点) bellman_ford 的队列优化算法版本
*
* @param n 节点个数[1,n]
* @param graph 邻接表
* @param startIdx 开始节点(源点)
*/
public static int[] spfa(int n, List<List<Edge>> graph, int startIdx) {
// 定义最大范围
int maxVal = Integer.MAX_VALUE;
// minDist[i] 源点到节点i的最短距离
int[] minDist = new int[n + 1]; // 有效节点编号范围:[1,n]
Arrays.fill(minDist, maxVal); // 初始化为maxVal
minDist[startIdx] = 0; // 设置源点到源点的最短路径为0
// 定义queue记录每一次松弛更新的节点
Queue<Integer> queue = new LinkedList<>();
queue.offer(startIdx); // 初始化:源点开始(queue和minDist的更新是同步的)
// 定义数组统计每个节点加入队列的次数
int[] cnt = new int[n + 1]; // 下标对应节点,元素值对应节点被加入队列的次数统计
cnt[startIdx] = 1;
boolean hasNegativeWeightCycle = false;
// SPFA算法核心:只对上一次松弛的时候更新过的节点关联的边进行松弛操作
while (!queue.isEmpty()) {
// 取出节点
int cur = queue.poll();
// 获取cur节点关联的边,进行松弛操作
List<Edge> relateEdges = graph.get(cur);
for (Edge edge : relateEdges) {
int u = edge.u; // 与`cur`对照
int v = edge.v;
int weight = edge.val;
if (minDist[u] + weight < minDist[v]) {
minDist[v] = minDist[u] + weight; // 更新
// 队列同步更新(此处有一个针对队列的优化:就是如果已经存在于队列的元素不需要重复添加)
if (!queue.contains(v)) {
queue.offer(v); // 与minDist[i]同步更新,将本次更新的节点加入队列,用做下一个松弛的参考基础
cnt[v]++; // 节点加入队列次数更新
}
// 同步判断cnt[v]是否超出n-1,以控制循环正常跳出
if (cnt[v] > n - 1) {
hasNegativeWeightCycle = true;
// 此处主动将queue清空,用于跳出外层queue的遍历
queue.clear();
break;
}
}
}
}
if (hasNegativeWeightCycle) {
System.out.println("[负权回路]出现标识:" + hasNegativeWeightCycle);
} else {
// 校验起点1->终点n
if (minDist[n] == Integer.MAX_VALUE) {
System.out.println("[起点1]到[终点n]不可达");
} else {
System.out.println("[起点1]到[终点n]的最短路径:" + minDist[n]);
}
}
// 返回minDist
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N个节点、M条边(u v weight)");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
List<List<Edge>> graph = new ArrayList<>(); // 构建邻接表
for (int i = 0; i <= n; i++) {
graph.add(new ArrayList<>());
}
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
graph.get(u).add(new Edge(u, v, weight));
}
// 调用算法
SPFAForNegativeWeightCycle.spfa(n, graph, 1);
}
}
🍚 Bellman_ford 算法 之 单源有限最短路问题(版本3)
(1)单源有限最短路问题分析
参考题目:KMW096-城市间的货物运输III,此处限定了最多经过 k 个城市的条件下,而不是一定经过k个城市,也可以经过的城市数量比k小,但要最短的路径
在 Bellman_ford 算法中,对所有边松弛一次,相当于计算 起点到达 与起点一条边相连的节点 的最短距离。节点数量为n,起点到终点,最多是 n-1 条边相连。 那么对所有边松弛 n-1 次 就一定能得到 起点到达 终点的最短距离。结合此处题目分析,其是最多经过 k 个城市, 那么是 k + 1条边相连的节点,转化题意求的就是:起点最多经过k + 1 条边到达终点的最短距离。
代码实现思路:对所有边松弛一次,相当于计算 起点到达 与起点一条边相连的节点 的最短距离,那么对所有边松弛 k + 1次,就是求 起点到达 与起点k + 1条边相连的节点的 最短距离。最简单的实现就是基于Bellman_ford
改造,将n-1
次松弛改为k+1
次松弛(此处代码参考Bellman_ford
最基础的模板即可,将遍历次数改为k+1
次)
同理,针对【负权回路】的处理,在上述分析中说道,对于负权回路的判断,Bellman_ford
的基础做法是进行第n次松弛,然后校验数组是否发生变化,但放到此处并不是要判断是否存在负权回路,而是要正确获取到最多经k个城市条件下从source->dest 的最短距离
回忆Bellman_ford
的基础版本的案例分析演变过程,在一次松弛的过程中,实际上每个minDist[i]
都可能会因为前面的minDist[x]
变化联动松弛(最明显的体现就是:在第①次松弛的时候,直接更新了整个minDist
数组,因此比较都是基于当前的minDist
进行比较,而不是上一次松弛的结果
一次遍历中前面的节点松弛更新,则后面遍历的节点也可能会随之更新,进而导致并没有控制住k+1
次松弛这个条件)
而实际上,在每次计算 minDist
时候,要基于 对所有边上一次松弛的 minDist
数值才行,所以此处在每一次遍历之前要借助额外的数组(例如minDist_copy
)记录上一次松弛的minDist
作为本次松弛的基础,避免因为松弛过程中改变了数值而造成影响
案例分析参考
// n 个节点 m 条边
6 7
// m 条边(u v weight)
1 2 1
2 4 -3
2 5 2
1 3 5
3 5 1
4 6 4
5 6 -2
// src dst k
2 6 1
/**
* bellmanFord算法(版本3:处理限定至多途径k个节点的单源最短路径问题)
* 限定起点、终点、至多途径k个节点
*/
public class BellmanFordForSSSP {
/**
* bellmanFord算法
*
* @param n 节点个数[1,n]
* @param edges 边列表集合
* @param startIdx 开始节点(源点)
* @param k 至多途径节点个数
*/
public static int[] bellmanFord(int n, List<Edge> edges, int startIdx, int k) {
// 定义最大范围
int maxVal = Integer.MAX_VALUE;
// minDist[i] 源点到节点i的最短距离
int[] minDist = new int[n + 1]; // 有效节点编号范围:[1,n]
Arrays.fill(minDist, maxVal); // 初始化为maxVal
minDist[startIdx] = 0; // 设置源点到源点的最短路径为0
// bellmanFord 算法核心: 限定对所有边松弛`k+1`次
for (int i = 1; i <= k + 1; i++) { // 处理① 限定松弛k+1次
// 处理② 限定每次松弛都是基于上次松弛是更新的状态
int[] minDist_copy = Arrays.copyOfRange(minDist, 0, minDist.length);
// 执行bellmanFord核心,遍历所有边
for (Edge edge : edges) {
int u = edge.u; // 与idx对照
int v = edge.v;
int weight = edge.val;
if (minDist_copy[u] != maxVal && minDist_copy[u] + weight < minDist[v]) { // bellman_ford 核心公式
minDist[v] = minDist_copy[u] + weight;
}
}
System.out.println("第" + i + "次松弛");
PrintUtil.print(minDist); // 打印数组
}
// 返回minDist
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N个节点、M条边(u v weight)");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
List<Edge> edges = new ArrayList<>(); // 构建边集合
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
edges.add(new Edge(u, v, weight));
}
System.out.println("3.输入起点src、终点dst、途径城市数量限制 k");
int src = sc.nextInt();
int dst = sc.nextInt();
int k = sc.nextInt();
// 调用bellman算法
int[] minDist = BellmanFordForSSSP.bellmanFord(n, edges, src, k);
// 校验起点->终点
if (minDist[dst] == Integer.MAX_VALUE) {
System.out.println("unreachable");
} else {
System.out.println("最短路径:" + minDist[n]);
}
}
}
// output case01(不存在负权回路)
1.输入N个节点、M条边(u v weight)
6 7
2.输入M条边
1 2 1
2 4 -3
2 5 2
1 3 5
3 5 1
4 6 4
5 6 -2
3.输入起点src、终点dst、途径城市数量限制 k
2 6 1
第1次松弛
[2147483647]-[2147483647]-[0]-[2147483647]-[-3]-[2]-[2147483647]-
第2次松弛
[2147483647]-[2147483647]-[0]-[2147483647]-[-3]-[2]-[0]-
最短路径:0
// output case02(存在负权回路)
1.输入N个节点、M条边(u v weight)
4 4
2.输入M条边
1 2 -1
2 3 1
3 1 -1
3 4 1
3.输入起点src、终点dst、途径城市数量限制 k
1 4 3
第1次松弛
[2147483647]-[0]-[-1]-[2147483647]-[2147483647]-
第2次松弛
[2147483647]-[0]-[-1]-[0]-[2147483647]-
第3次松弛
[2147483647]-[-1]-[-1]-[0]-[1]-
第4次松弛
[2147483647]-[-1]-[-2]-[0]-[1]-
最短路径:1
(2)边顺序问题
边的顺序会影响每一次扩展的结果(可以结合【负权回路】的案例进行扩展分析)
使用没有引入
minDist_copy
概念的版本执行k+1次松弛- case1 结果错误
- case2 发现能拿到正确的结果
- 说明边的定义顺序不同,每次遍历松弛更新的节点处理顺序不同,阴差阳错得到正确的结果,但本质上还是要处理好
minDist_copy
的限定(确保每一次对所有边松弛的基础)
// case1
4 4
1 2 -1
2 3 1
3 1 -1
3 4 1
1 4 3
---------------------------
// case2(基于case1调整边的顺序) - 使用没有引入`minDist_copy`概念的版本执行k+1次松弛,发现能拿到正确的结果
4 4
3 1 -1
3 4 1
2 3 1
1 2 -1
1 4 3
(3)算法扩展思路本质
此处算法改造的本质要结合多个题型进行分析,因为不同题型限定的条件限制不同,所以算法执行体现可能并没有暴露出问题,但每一次的题型适配实际上就是对算法的兼容改造,主要还是要理解算法核心,找到算法调整的切入点。此处以最基础的Bellman_ford
版本分析,为什么在不同的题型场景下有时可用有时不可用,以及兼容改造的切入点是什么?
- KMW094-城市间货物运输I(带权有向图,不含负权回路):
- 目的:求【源点】到【目的点】的最短距离(最小成本),不含负权回路(起点【1】、终点【n】)
- 思路:
bellman_ford
:对所有边执行n-1
次松弛SPFA
:基于队列的bellman_ford
算法优化,对上一次松弛更新的点关联的边进行松弛(queue存储每次松弛后更新的节点)
- KMW095-城市间货物运输II(带权有向图,含负权回路):
- 目的:求【源点】到【目的点】的最短距离(最小成本),含负权回路(起点【1】、终点【n】)
- 思路:
bellman_ford
:再加1
次松弛(即对所有边执行n
次松弛,前n-1
次正常执行松弛,最后1
次松弛是校验是否出现了最短路径,如果出现说明出现负权回路)SPFA
:校验一个节点重复加入queue
的次数是否超出n-1
,如果超出n-1
则说明带有负权回路(如果带有负权回路则需手动clear
队列并break
内层循环,以控制循环正常跳出)
- KMW096-城市间货物运输III(带权有向图,可含负权回路,限定途径点不超过k):
- 目的:求【源点】到【目的点】的最短距离(最小成本),可含负权回路(起点【1】、终点【x】、限定途经点不超过
k
个) - 思路:
bellman_ford
:对所有边执行k+1
次松弛(至多途径k
个节点,【起点】到【终点】有k+1
条边),且为了确保节点松弛的准确性,每次松弛的基础是基于上次松弛的结果(每次对所有边进行松弛前先借助minDist_copy[]
存储当前状态的minDist[]
作为松弛比较的基础,避免重复松弛
- 目的:求【源点】到【目的点】的最短距离(最小成本),可含负权回路(起点【1】、终点【x】、限定途经点不超过
扩展理解:需要理解为什么多做松弛
的情况下,对于同一个bellman_ford
算法得到的结果完全不同?
- KMW094
KMW094的案例中不存在【负权回路】的情况,因此对所有边至多经过n-1
松弛即确定【起点】到【终点】的最短距离,且多做松弛也不会影响最终的结果。哪怕存在正权回路,多走几圈【正权回路】距离只会原来越大,所以【正权回路】也至多走一圈就终止。此外,基于上述算法分析可以看到,初始版本的松弛过程都是基于当前的minDist[]
,也就是说在一次对所有边进行松弛的过程中前面更新的节点对应的minDist[i]
会影响到后面的更新(最明显的体现就是上述版本案例中第一次松弛就将所有节点的最短距离更新了)
- KMW095
KMW095的案例中由于存在【负权回路】的情况,如果还是基于同一个算法版本,就会导致这个算法会再次回到源点选择走【负权回路】,因为只要走一圈负权回路这个距离就会越来越小,如果不加控制就会一直无限更新下去。为了解决【负权回路】的场景,此处是基于原有的设定进行验证,因为对于bellman_ford
算法来说,计算【节点1】到【节点n】的最短距离,只要对所有边执行n-1
次松弛,这个minDist[]
必然确定下来,如果存在【负权回路】的情况下,minDist[]
会继续更新,因此再加一次对所有边的松弛操作用于校验,如果发现第n
次的松弛操作过程中出现了更短的路径,那么说明存在负权回路
- KMW096
LMW096的案例中不仅存在【负权回路】对起点、终点、至多途经点数量都做了限制,一旦出现多做松弛的操作就会导致minDist[]
越来越小而脱离正确答案,其因素主要有两个:
- 存在【负权回路】,只要多做松弛,则结果必然变化
- 题中限定至多经过
k
个节点,对松弛次数是有限制的(每次松弛的基础应参考上一次松弛的更新的节点)
(4)SPFA(基于队列的优化版本)(版本3)
基于SPFA
的算法版本改造,本质在于如何控制松弛k次
此处可以回忆一下二叉树的BFS
遍历的分层统计,为了计算有多少层,会借助一个queue_size
记录每次加入的节点个数作为下次分层遍历的依据。类似的,此处在进行松弛的时候初始版本是直接根据queue.size()
来判断结束条件的,并没有次数概念,依次可以同样引入queue_size
记录每次松弛更新的节点个数作为下次松弛的依据,就能够对每次松弛的节点进行"分界",进而达到次数统计的目的
/**
* SPFA算法(版本3):处理含【负权回路】的有向图的最短路径问题
* bellman_ford(版本3) 的队列优化算法版本
* 限定起点、终点、至多途径k个节点
*/
public class SPFAForSSSP {
/**
* SPFA算法
*
* @param n 节点个数[1,n]
* @param graph 邻接表
* @param startIdx 开始节点(源点)
*/
public static int[] spfa(int n, List<List<Edge>> graph, int startIdx, int k) {
// 定义最大范围
int maxVal = Integer.MAX_VALUE;
// minDist[i] 源点到节点i的最短距离
int[] minDist = new int[n + 1]; // 有效节点编号范围:[1,n]
Arrays.fill(minDist, maxVal); // 初始化为maxVal
minDist[startIdx] = 0; // 设置源点到源点的最短路径为0
// 定义queue记录每一次松弛更新的节点
Queue<Integer> queue = new LinkedList<>();
queue.offer(startIdx); // 初始化:源点开始(queue和minDist的更新是同步的)
// SPFA算法核心:只对上一次松弛的时候更新过的节点关联的边进行松弛操作
while (k + 1 > 0 && !queue.isEmpty()) { // 限定松弛 k+1 次
int curSize = queue.size(); // 记录当前队列节点个数(上一次松弛更新的节点个数,用作分层统计)
while (curSize-- > 0) { //分层控制,限定本次松弛只针对上一次松弛更新的节点,不对新增的节点做处理
// 记录当前minDist状态,作为本次松弛的基础
int[] minDist_copy = Arrays.copyOfRange(minDist, 0, minDist.length);
// 取出节点
int cur = queue.poll();
// 获取cur节点关联的边,进行松弛操作
List<Edge> relateEdges = graph.get(cur);
for (Edge edge : relateEdges) {
int u = edge.u; // 与`cur`对照
int v = edge.v;
int weight = edge.val;
if (minDist_copy[u] + weight < minDist[v]) {
minDist[v] = minDist_copy[u] + weight; // 更新
// 队列同步更新(此处有一个针对队列的优化:就是如果已经存在于队列的元素不需要重复添加)
if (!queue.contains(v)) {
queue.offer(v); // 与minDist[i]同步更新,将本次更新的节点加入队列,用做下一个松弛的参考基础
}
}
}
}
// 当次松弛结束,次数-1
k--;
}
// 返回minDist
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N个节点、M条边(u v weight)");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
List<List<Edge>> graph = new ArrayList<>(); // 构建邻接表
for (int i = 0; i <= n; i++) {
graph.add(new ArrayList<>());
}
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
graph.get(u).add(new Edge(u, v, weight));
}
System.out.println("3.输入src dst k(起点、终点、至多途径k个点)");
int src = sc.nextInt();
int dst = sc.nextInt();
int k = sc.nextInt();
// 调用算法
int[] minDist = SPFAForSSSP.spfa(n, graph, src, k);
// 校验起点->终点
if (minDist[dst] == Integer.MAX_VALUE) {
System.out.println("unreachable");
} else {
System.out.println("最短路径:" + minDist[n]);
}
}
}
(5)能否用dijkstra?
dijkstra 是贪心的思路 每一次搜索都只会找距离源点最近的非访问过的节点。如果限制最多访问k个节点,那么 dijkstra 未必能在有限次就能到达终点,即使在经过k个节点确实可以到达终点的情况下。
可以基于【dijkstra 朴素版】进行分析看采用贪心的思路求解的过程是如何?(具体分析版本可以参考【dijkstra 朴素版】的详细过程分析),此处简单给出选节点的过程,可以看到如果基于【dijkstra 朴素版】的话,此时最多经过2个节点的搜索就结束了,而此时minDist[7]
还没有更新到,此时得到的结果就会是不可达,显然不符合题意。而这恰恰是因为贪心,导致错过了正确的路径
🍚 Floyd 算法 之 多源最短距离问题
(1)Floyd 算法分析
Floyd 算法原理分析
在上述最短路算法的案例场景中,dijkstra朴素版、dijkstra堆优化、Bellman算法、Bellman队列优化(SPFA) 面向的场景都是求解单源最短路,即只能有一个起点。而对于多源最短路,即 求多个起点到多个终点的多条最短路径,则需要新的算法支撑,也就是Floyd
算法
Floyd
算法对边的权值正负没有限制,都可以处理,其核心思想是基于动态规划思路
例如:用二维数组grid[i][j]
表示节点i
到节点j
的最短距离,则求节点1 到 节点9 的最短距离,用二维数组来表示即:grid[1][9]
,如果最短距离是10 ,那就是 grid[1][9] = 10
对于一条路径:1->3->5->9
,可以按照如下思路进行拆解:
- 【节点1】到【节点9】的最短路径可以看做是【节点1】到【节点5】的最短路径 加上 【节点5】到【节点9】的最短路径
- 即
grid[1][9]=grid[1][5] + grid[5][9]
- 即
- 以此类推,【节点1】到【节点5】的最短路径可以看做是【节点1】到【节点3】的最短路径加上【节点3】到【节点5】的最短路径
- 即
grid[1][5]=grid[1][3] + grid[3][5]
- 即
- 同理,针对其他路径也可以按照这个拆解思路,通过子问题推导出整体最优方案的递归关系,例如对于路径
1->7->9
也可以得到grid[1][9] = grid[1][7] + grid[7][9]
因此,从上述所有可能路径中找到一个最小的路径,进而求得最短路,基于此思路结合动态规划五部曲进行分析
【1】
dp
定义(dp
数组含义)(限定节点取值有效范围为[1,n]
)grid[i][j][k]
= m,表示 节点i
到 节点j
以[1...k]
集合中的节点为中间节点的最短距离为m- 节点
i
到节点j
的最短距离为m,而节点i
到节点j
会经过很多节点(这些节点集合范围在[1....k]
)- 例如
dp[i][j][1]
表示节点i
到节点j
可以经过【节点1】,因此在推导的过程中可以选择经过【节点1】或者不经过【节点1】这两种情况讨论选择min
,以此类推
- 例如
- 节点
【2】
dp
递推公式- ① 节点
i
到节点j
的最短距离经过节点k
:grid[i][j][k] = grid[i][k][k - 1] + grid[k][j][k - 1]
- 节点
i
到 节点k
的最短距离 是不经过节点k
,中间节点集合为[1...k-1],所以 表示为grid[i][k][k - 1]
- 节点
k
到 节点j
的最短距离 也是不经过节点k
,中间节点集合为[1...k-1],所以表示为grid[k][j][k - 1]
- 节点
- ② 节点
i
到节点j
的最短距离不经过节点k
:grid[i][j][k] = grid[i][j][k - 1]
- 如果节点
i
到 节点j
的最短距离 不经过节点k
,那么 中间节点集合[1...k-1],表示为grid[i][j][k - 1]
- 如果节点
- ③ 求最短路,则基于①②的推导取最小
grid[i][j][k] = min{grid[i][k][k - 1] + grid[k][j][k - 1],grid[i][j][k - 1]}
- ① 节点
【3】
dp
初始化grid[i][j][0] = grid[j][i][0] = dist(i,j)
- 可以从递推公式中寻找初始化的思路,如果要求
grid[i][j][1]
(节点i
到节点j
以[节点1]
为中间节点的最短距离)的话需要知道grid[i][j][k-1]
的内容,因此可以知道此处要初始化的是k=0
的情况,而针对双向图则需要i->j
、j->i
两条边。而此处节点编号的有效范围是[1,n]
,因此gird[i][j][0]
和gird[j][i][0]
实际上就是dist(i,j)
(节点i
和节点j
的直接距离,即两个端点的边权值weight
) - 其他元素:因为在递推过程中求的是最小距离,因此对于其他元素可以初始化为
MAX_VALUE
最大值
- 可以从递推公式中寻找初始化的思路,如果要求
【4】
dp
构建(确定遍历顺序)- 从递推公式分析,要构建这个三维数组,需要构建三个
for
循环,分别遍历i
j
k
三个维度,那么此处需思考要如何确定这3个for
循环的嵌套顺序 - 参考初始化思路,要计算
k=1
的情况,则需要先求出k=0
的情况,也就是说grid[i][j][1]
的计算基础是grid[i][j][0]
,所以需要先将k=0
的(i,j)
对应的数组进行初始化,才能基于此陆续将k=1
、k=2
.... 的情况处理 - 也可以从
三维图图形
的构建顺序进行分析,(i,j)
构成平面,而k
则是作为垂直于平面的坐标,因此这个三维立方图的构建是从底部向上处理的,也就是从先构建k=0
的平面,然后构建k=1
的平面,依次类推。而对于平面内部的坐标点构建,i,j
的遍历顺序并不重要(先i后j
或者先j后i
均可)
- 从递推公式分析,要构建这个三维数组,需要构建三个
【5】
dp
验证(举例推导dp
数组)- 可以一层一层打印出来,然后分析
Floyd 算法 (动态规划思路中遍历顺序的讨论)
在上述动态规划分析过程中可以看到,从dp
初始化和构建的角度理解遍历顺序的处理
dp
初始化是基于dp[i][j][1]
的递推讨论,因此要初始化dp[i][j][0]
,而dp[i][j][0]
的定义指的是节点i
到节点j
以[节点0]
为中间节点的的最短距离,在实际定义中这个【节点0】实际并不存在,因此dp[i][j][0]
表示的即为节点i
到节点j
的直接距离dist(i,j)
(即边权值)。同理,图为双向图的话则dp[j][i][0]=weight
从递推公式的角度来看,dp[i][j][k]
的构建与k-1
的dp
状态有关,而不是i-1
或j-1
,因此构建基础应以k
为基础
// 初始化伪代码
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
dp[i][j][0] = dp[j][i][0] = dist(i,j); // 即weight
dp[i][j][k] = maxVal; // 其余设置为maxVal
}
}
基于上述分析,将(i,j)
理解成一个平面,k 为垂直于平面的坐标,那么要构成这个三维立体题型的话,应该是依次从k=0
开始将平面逐渐构建起来
// 遍历顺序伪代码
for(int k=1;k<=n;k++){
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
dp[i][j][k] = dp[j][i][k] = min{grid[i][k][k - 1] + grid[k][j][k - 1],grid[i][j][k - 1]};
}
}
}
思考:如果基于惯性的思维,如果采用嵌套循环的顺序是i,j,k
的话可以吗?从遍历顺序分析看好像也是遍历封装每一个元素,但实际上这种情况就会导致(j,k)
构成一个平面,而i
为垂直于平面的坐标了,显然不符合题意。
// 遍历顺序(❌)
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
for(int k=1;k<=n;k++){
dp[i][j][k] = dp[j][i][k] = min{grid[i][k][k - 1] + grid[k][j][k - 1],grid[i][j][k - 1]};
}
}
}
此处结合测试用例分析:
public class FloydTemplate {
// public static int MAX_VAL = Integer.MAX_VALUE;
public static int MAX_VAL = 10005; // 边的最大距离是10^4(不选用Integer.MAX_VALUE是为了避免相加导致数值溢出)
public static int[][][] floyd(int n, int[][] grid) {
// 1.dp 定义: dp[i][j][k] 表示节点i到节点j 以[1...k-1]节点集合作为中间节点 的最短路径
int[][][] dp = new int[n + 1][n + 1][n + 1]; // 编号有效范围[1,n]
/**
* 2.dp 递推
* 选择经过K:dp[i][j][k] = dp[i][k][k-1] + dp[k][j][k-1]
* 选择不经过K:dp[i][j][k] = dp[i][j][k-1]
*/
// 3.dp 初始化
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
// int[] arr1 = dp[i][j]; Arrays.fill(arr1, MAX_VAL); // 需要拿到一维数组,才能通过fill正常封装
dp[i][j][0] = dp[j][i][0] = grid[i][j]; // k=0的处理
for (int k = 1; k <= n; k++) {
dp[i][j][k] = dp[j][i][k] = MAX_VAL;
}
}
}
// 4.dp 构建(确定遍历顺序:i j构成平面,k垂直于平面)(🔔)
/*
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
dp[i][j][k] = dp[j][i][k] = Math.min(dp[i][k][k - 1] + dp[k][j][k - 1], dp[i][j][k - 1]);
}
}
}
*/
// 遍历顺序:j、k构成平面,i垂直于平面(❌错误的遍历顺序)
/*
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
for (int k = 1; k <= n; k++) {
dp[i][j][k] = dp[j][i][k] = Math.min(dp[i][k][k - 1] + dp[k][j][k - 1], dp[i][j][k - 1]);
}
}
}
*/
// 遍历顺序:k、j构成平面(k循环放中间),i垂直于平面(❌错误的遍历顺序)
for (int i = 1; i <= n; i++) {
for (int k = 1; k <= n; k++) {
for (int j = 1; j <= n; j++) {
dp[i][j][k] = dp[j][i][k] = Math.min(dp[i][k][k - 1] + dp[k][j][k - 1], dp[i][j][k - 1]);
}
}
}
// 返回结果
return dp;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N M");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
int[][] grid = new int[n + 1][n + 1];
for (int i = 0; i <= n; i++) {
Arrays.fill(grid[i], MAX_VAL); // 初始化为最大值(如果不存在直接连接则设置为最大值)
}
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
grid[u][v] = grid[v][u] = weight;
}
System.out.println("3.输入[起点-终点]计划个数");
int x = sc.nextInt();
System.out.println("4.输入每个起点src 终点dst");
List<int[]> plans = new ArrayList<>(); // int[]{src,dst}
while (x-- > 0) {
int src = sc.nextInt();
int dst = sc.nextInt();
plans.add(new int[]{src, dst});
}
// 调用算法
int[][][] res = FloydTemplate.floyd(n, grid);
for (int i = 0; i < plans.size(); i++) {
int src = plans.get(i)[0], dst = plans.get(i)[1];
if (res[src][dst][n] == MAX_VAL) {
System.out.println("-1");
} else {
System.out.println(res[src][dst][n]);
}
}
}
}
通过上述代码,切换不同的遍历顺序,然后结合下述两个测试用例的结果理解遍历顺序的定义和选择
-- 测试用例1
// N M
5 4
// M 条边
1 2 10
1 3 1
3 4 1
4 2 1
// 计划个数
1
// 起点 终点
1 2
-- 测试用例2
// N M
5 2
// M 条边
1 2 1
2 3 10
// 计划个数
1
// 起点 终点
1 3
- 案例①:
- 嵌套顺序
(k,i,j)
:3
- 嵌套顺序
(i,j,k)
:10
- 嵌套顺序
- 案例②:
- 嵌套顺序
(k,i,j)
:11
- 嵌套顺序
(i,k,j)
:
- 嵌套顺序
(2)代码模板参考
上述给出的代码版本是中规中矩的输入控制、调用算法的形式,此处可以简化代码实现,边输入边处理,参考如下版本:
可以理解为dp[i][j][k]
实际就是无向图的邻接矩阵grid[i][j]
中维护的一个一维数组,这个一维数组(表示对应【起点】到【终点】以[1,k-1]
的某个节点为中间节点时的最短距离)中的值为m
public class FloydBase {
// public static int MAX_VAL = Integer.MAX_VALUE;
public static int MAX_VAL = 10005; // 边的最大距离是10^4(不选用Integer.MAX_VALUE是为了避免相加导致数值溢出)
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N M");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
// ① dp定义(grid[i][j][k] 节点i到节点j 可能经过节点K(k∈[1,n]))的最短路径
int[][][] grid = new int[n + 1][n + 1][n + 1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
for (int k = 0; k <= n; k++) {
grid[i][j][k] = grid[j][i][k] = MAX_VAL; // 其余设置为最大值
}
}
}
// ② dp 推导:grid[i][j][k] = min{grid[i][k][k-1] + grid[k][j][k-1], grid[i][j][k-1]}
// 处理边
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
grid[u][v][0] = grid[v][u][0] = weight; // 初始化(处理k=0的情况) ③ dp初始化
}
// ④ dp构建:floyd 推导
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
grid[i][j][k] = Math.min(grid[i][k][k - 1] + grid[k][j][k - 1], grid[i][j][k - 1]);
}
}
}
System.out.println("3.输入[起点-终点]计划个数");
int x = sc.nextInt();
System.out.println("4.输入每个起点src 终点dst");
while (x-- > 0) {
int src = sc.nextInt();
int dst = sc.nextInt();
// 根据floyd推导结果输出计划路径的最小距离
if (grid[src][dst][n] == MAX_VAL) {
System.out.println("-1");
} else {
System.out.println(grid[src][dst][n]);
}
}
}
}
🍚 A*
算法(A star算法)
(1)问题切入
【KMW127-骑士的攻击】问题切入,基于"马走日 象走田"的限定,给定n组数据(a1,a2,b1,b2)分别表示起点坐标(a1,a2) 终点坐标(b1,b2),求解起点到终点的最短距离
- 输入示例
// n
6
// 输入6组[起点,终点](a1,a2,b1,b2)
5 2 5 4
1 1 2 2
1 1 8 8
1 1 8 7
2 1 3 3
4 6 4 6
-- 输出示例
2
4
6
5
1
0
在讲解图论的广搜法的是否有介绍到,可以通过广搜法确定最短路径。因此此处针对棋盘问题的最短路径检索可以用广搜法基础模板来解决,不过此处的行进方向是要基于"马走日 象走田"的限定,所以每一次的新进方向有8个方向,当搜索到终点则结束
/**
* BFS 算法参考
* KMW127-骑士的攻击
*/
public class BFSSearch {
public static int limit = 10; // max限定为10001
/**
* @param startX,startY 起始坐标
* @param endX,endY 目标坐标
*/
public static int[][] bfs(int startX, int startY, int endX, int endY) {
// 定义行进方向(马走日、象走田)
int[][] dir = new int[][]{
{2, 1}, {2, -1}, {-2, 1}, {-2, -1},
{1, 2}, {1, -2}, {-1, 2}, {-1, -2}
};
// 记录到达指定节点路径
int[][] moved = new int[limit + 1][limit + 1];
// 构建辅助队列遍历搜索
Queue<Pair> queue = new LinkedList<>();
queue.offer(new Pair(startX, startY));
while (!queue.isEmpty()) {
// 取出坐标
Pair cur = queue.poll();
int curX = cur.x;
int curY = cur.y;
// 判断当前遍历节点是否为终点坐标(如果是终点坐标,搜索结束)
if (curX == endX && curY == endY) {
break;
}
// 往不同行进方向搜索
for (int i = 0; i < 8; i++) {
// 计算下一个位置
int nextX = cur.x + dir[i][0];
int nextY = cur.y + dir[i][1];
// 如果越界则跳过
if (nextX < 1 || nextX > limit || nextY < 1 || nextY > limit) { // x、y 属于[1,limit]
continue;
}
// 如果节点没有访问过,步数累加
if (moved[nextX][nextY] == 0) {
moved[nextX][nextY] = moved[curX][curY] + 1;
queue.offer(new Pair(nextX, nextY)); // 将坐标加入队列
}
}
}
// 打印矩阵
PrintUtil.printGraphMatrix(moved);
// 返回结果
return moved;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入n");
int n = sc.nextInt();
System.out.println("\n2.输入n行(起始位置->终点位置):(a1,a2)->(b1,b2)");
while (n-- > 0) {
int a1 = sc.nextInt();
int a2 = sc.nextInt();
int b1 = sc.nextInt();
int b2 = sc.nextInt();
int[][] moved = BFSSearch.bfs(a1, a2, b1, b2);
System.out.println(moved[b1][b2]);
}
}
}
(2)A star 算法
Astar 是一种 广搜的改良版,有的是 Astar是 dijkstra 的改良版。其实只是场景不同而已。
在搜索最短路的时候,一般可以采取如下思路:
- 如果是无权图(边的权值都是1) 考虑用广搜,代码简洁,时间效率和 dijkstra 差不多 (具体要取决于图的稠密)
- 如果是有权图(边有不同的权值),优先考虑 dijkstra(无向图最短路径(最小生成树问题))
而 Astar 关键在于 启发式函数, 也就是 影响 广搜 或者 dijkstra 从 容器(队列)里取元素的优先顺序。此处基于BFS的改良版进行介绍
在BFS中,如果要搜索,从起点到终点的最短路径,要一层一层去遍历(每次遍历都要选择不同的可行进方向去尝试,可以理解为寻找一条可达路径到终点,然后计算这条路径的长度),此处以设定矩阵(图)大小为11*11
,计算[1,1]-[8,8]
、[5,5]-[8,8]
的最短路径,打印上述BFS
版本算法执行后的结果
-- case01
1.输入n
1
2.输入n行(起始位置->终点位置):(a1,a2)->(b1,b2)
1 1 8 8
【0】0 0 0 0 0 0 0 0 0 0 0
【1】0 2 3 2 3 2 3 4 5 4 5
【2】0 3 4 1 2 3 4 3 4 5 6
【3】0 2 1 4 3 2 3 4 5 4 5
【4】0 3 2 3 2 3 4 3 4 5 6
【5】0 2 3 2 3 4 3 4 5 4 5
【6】0 3 4 3 4 3 4 5 4 5 6
【7】0 4 3 4 3 4 5 4 5 6 5
【8】0 5 4 5 4 5 4 5 6 5 6
【9】0 4 5 4 5 4 5 6 5 6 0
【10】0 5 6 5 6 5 6 5 6 7 6
-- 最短路径为:6
// case02
1.输入n
1
2.输入n行(起始位置->终点位置):(a1,a2)->(b1,b2)
5 5 8 8
【0】0 0 0 0 0 0 0 0 0 0 0
【1】0 0 0 2 0 2 0 2 0 0 0
【2】0 0 2 0 2 0 2 0 2 0 0
【3】0 2 0 0 1 2 1 0 3 2 0
【4】0 0 2 1 2 3 2 1 2 3 0
【5】0 2 0 2 0 2 0 2 0 2 0
【6】0 0 2 1 2 3 2 1 2 3 0
【7】0 2 0 0 1 2 1 0 3 2 0
【8】0 0 2 3 2 3 2 3 2 3 0
【9】0 0 0 2 0 2 0 2 0 0 0
【10】0 0 0 3 0 3 0 3 0 3 0
-- 最短路径为:2
从上述结果分析可知,要计算[1,1]
到[8,8]
的最短路径,从[1,1]
出发,每次都往8
个方向遍历,可以看到BFS
搜索每次向下层搜索都把附近的周边方向处理了个遍(此处通过限定已经遍历过的节点不重复检索),case01
相当于把所有节点都检索了,最终找到目标节点;同理,要计算[5,5]
到[8,8]
的最短路径,从[5,5]
出发,每次都往8
个方向遍历,直到搜索到目标节点
而基于A *
思路的搜索过程并不像BFS
那样没有目的性地去搜索,而是有方向性的搜索,以节省不必要的搜索步骤。结合上述两个案例分析A *
算法执行过程可以结合卡码网动画图示参考理解。
如何理解有方向的搜索?=》最简单的理解就是以[5,5]-[8,8]
为例,可以确定的是终点应该在起点的右上方,因此搜索方向应该是往右上方进行搜索,而不是像是BFS
那样每次都往多个方向盲目扩展
观察BFS
算法,指引搜索方向的关键代码在于每次从队列中取出一个节点Pair cur = queue.poll()
,然后基于这个节点扩展搜索方向。也就是说从队列取出什么元素,就从哪里开始搜索。那么这里可以有两个点可以介入提升搜索效率:
- 切入点①:介入
入队节点
的顺序,如果能确保靠近终点的节点先入队(或者先读取到靠近终点的节点),那么就能快速接近并到达终点 - 切入点②:介入
遍历方向
,如果可以根据判断当前遍历节点和终点的相对位置,然后快速接近终点。但是此处有一个弊端就是,接近终点的时候有可能会"越过"终点而迷失遍历方向
基于此,A *
算法采用的是思路①,能够保证快速接近终点,又可覆盖终点附近的周边方向的节点遍历,进而得到最短路径。其核心在于每次对入队的节点进行排序(根据不同的距离算法),确保每次拿到的遍历节点是从起点出发经过当前遍历节点并离终点最近的节点(因为要满足路径和最小),以此快速接近终点。
而要对节点进行排序,则需设定权值,此处的权值概念为:
F = G(dist(start,cur)) + H(dist(cur,end))
即权值为起点到达终点的距离,等于起点->当前遍历节点的距离 + 当前遍历节点的距离 -> 终点 的距离- 思考为什么权值要设置为【起点到终点的距离】? 因为题目的立意是求【起点到终点】的最短路径,而对于每个遍历节点
cur
来说,经过cur
这个节点不一定可以到达终点,也就是说如果此处权值设置为【cur到终点的距离】的话,如果经过当前节点的路径不可达终点的话,还是要继续检索下去(可以基于代码分析)
- 思考为什么权值要设置为【起点到终点的距离】? 因为题目的立意是求【起点到终点】的最短路径,而对于每个遍历节点
而不同的距离算法的选择,也会导致A *
算法的结果不同,对于无权网格状的图,计算两点距离通常有三种方式:
- ① 曼哈顿距离,计算方式:
d = abs(x1-x2)+abs(y1-y2)
- ② 欧氏距离(欧拉距离) ,计算方式:
d = sqrt( (x1-x2)^2 + (y1-y2)^2 )
- ③ 切比雪夫距离,计算方式:
d = max(abs(x1 - x2), abs(y1 - y2))
而针对本题中采用欧拉距离才能最大程度体现点与点之间的距离
基于上述分析,本题中A *
的处理思路就是基于BFS
算法的改良版,其优化核心在对对queue
的节点进行最短距离F(起点-cur-终点)
排序,确保每次优先取出F
最小的节点,然后快速接近终点
那么此处基于BFS版本的改造时机就是要给加入的遍历节点引入权值(这个权值为F=G+H
),需要注意的是,每次加入队列的节点为cur
遍历节点经由多个方向指向的下一个节点next
,因此此处构建的权值应该(start->next->end
),不要和前面的遍历概念混淆。那么要封装next
节点的权值就要理解F=G+H
应该怎么走可以达到最短路径,拆解路径分析可知是由cur
走日方向到达next
然后计算??(todo 待确认,还是说G应该和H的计算一样要用dist计算距离??概念有点混淆)
/**
* A Star算法 基于BFS版本改造
* KMW127-骑士的攻击
*/
class Knight {
public int x; // 节点坐标x
public int y; // 节点坐标y
// 权值处理
public int G; // 起点到该节点的路径消耗
public int H;// 该节点到终点的路径消耗
public int F; // F=G+H 权值( 表示 dist(start,cur)+dist(cur,end)} : 起点经由cur节点到终点的距离和)
public Knight() {
}
public Knight(int x, int y, int G, int H) {
this.x = x;
this.y = y;
this.G = G;
this.H = H;
this.F = G + H;
}
}
public class AStarByBFS {
public static int limit = 10; // max限定为10001
// 欧拉距离公式算法
public static int distance(int x1, int y1, int x2, int y2) {
// 此处设定不开根号,提高计算精度
return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);
}
/**
* @param startX,startY 起始坐标
* @param endX,endY 目标坐标
*/
public static int[][] astar(int startX, int startY, int endX, int endY) {
// 定义行进方向(马走日、象走田)
int[][] dir = new int[][]{
{2, 1}, {2, -1}, {-2, 1}, {-2, -1},
{1, 2}, {1, -2}, {-1, 2}, {-1, -2}
};
// int[][] dir={{-2,-1},{-2,1},{-1,2},{1,2},{2,1},{2,-1},{1,-2},{-1,-2}};
// 记录到达指定节点路径
int[][] moved = new int[limit + 1][limit + 1];
// 构建优先队列遍历搜索(按照PairDist的F排序,即排序的权值为F)
PriorityQueue<Knight> queue = new PriorityQueue<>(new Comparator<Knight>() {
@Override
public int compare(Knight o1, Knight o2) {
return o1.F - o2.F;
}
});
// 初始化起点
Knight startKnight = new Knight(startX, startY, 0, distance(startX, startY, endX, endY));
queue.offer(startKnight);
while (!queue.isEmpty()) {
// 取出坐标
Knight cur = queue.poll(); // 确保每次弹出的都是F最小的元素
int curX = cur.x;
int curY = cur.y;
// 判断当前遍历节点是否为终点坐标(如果是终点坐标,搜索结束)
if (curX == endX && curY == endY) {
break;
}
// 往四个方向搜索
for (int i = 0; i < 8; i++) {
// 计算下一个位置
int nextX = cur.x + dir[i][0];
int nextY = cur.y + dir[i][1];
// 如果越界则跳过
if (nextX < 1 || nextX > limit || nextY < 1 || nextY > limit) { // x、y 属于[1,limit]
continue;
}
// 如果节点没有访问过,步数累加
if (moved[nextX][nextY] == 0) {
moved[nextX][nextY] = moved[curX][curY] + 1;
// 计算欧拉距离(需注意,此处的入队的节点是cur指定的下一个节点next,那么其距离应该是[start->next->end])
int getG = cur.G + 5; // 马走日(1*1+2*2=5) (起点到cur,cur走日步到next可获得最短路径)
int getH = distance(nextX, nextY, endX, endY); // 指定的next节点到终点的距离
queue.offer(new Knight(nextX, nextY, getG, getH)); // 将下一个坐标加入队列
}
}
}
// 打印矩阵
PrintUtil.printGraphMatrix(moved);
// 返回结果
return moved;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入n");
int n = sc.nextInt();
System.out.println("\n2.输入n行(起始位置->终点位置):(a1,a2)->(b1,b2)");
while (n-- > 0) {
int a1 = sc.nextInt();
int a2 = sc.nextInt();
int b1 = sc.nextInt();
int b2 = sc.nextInt();
int[][] moved = AStarByBFS.astar(a1, a2, b1, b2);
System.out.println(moved[b1][b2]);
}
}
}
A * 算法的时间复杂度 其实是不好去量化的,因为他取决于 启发式函数怎么写
最坏情况下,A * 退化成广搜,算法的时间复杂度 是 O(n * 2),n 为节点数量
最佳情况,是从起点直接到终点,时间复杂度为 O(dlogd),d 为起点到终点的深度
因为在搜索的过程中也需要堆排序,所以是 O(dlogd)。实际上 A * 的时间复杂度是介于 最优 和最坏 情况之间, 可以 非常粗略的认为 A * 算法的时间复杂度是 O(nlogn) ,n 为节点数量。A * 算法的空间复杂度 O(b ^ d) ,d 为起点到终点的深度,b 是 图中节点间的连接数量,本题因为是无权网格图,所以 节点间连接数量为 4
不同距离算法的影响
如果本题使用 曼哈顿距离 或者 切比雪夫距离 计算的话,可以提交试一试,有的最短路结果是并不是最短的。
原因也是 曼哈顿 和 切比雪夫这两种计算方式在 本题的网格地图中,都没有体现出点到点的真正距离!一些题目中是可行,但一些场景中地图太小根本体现不出差异,没有复现本质问题
A * 算法 并不是一个明确的最短路算法,A * 算法搜的路径如何,完全取决于 启发式函数怎么写。
A * 算法并不能保证一定是最短路,因为在设计 启发式函数的时候,要考虑 时间效率与准确度之间的一个权衡
虽然本题中,A * 算法得到是最短路,也是因为本题 启发式函数 和 地图结构都是最简单的。
例如在游戏中,在地图很大、不同路径权值不同、有障碍 且多个游戏单位在地图中寻路的情况,如果要计算准确最短路,耗时很大,会给玩家一种卡顿的感觉。
而真实玩家在玩游戏的时候,并不要求一定是最短路,次短路也是可以的 (玩家不一定能感受出来,及时感受出来也不是很在意),只要奔着目标走过去 大体就可以接受。
所以 在游戏开发设计中,保证运行效率的情况下,A * 算法中的启发式函数 设计往往不是最短路,而是接近最短路的 次短路设计。
例如玩 LOL,或者 王者荣耀 可以回忆一下:如果 从很远的地方点击 让英雄直接跑过去 是 跑的路径是不靠谱的,所以玩家们才会在 距离英雄尽可能近的位置去点击 让英雄跑过去
A star 的缺点
基于上述分析,实际上A star算法还是往队列中添加了很多节点,但是队列中节点是根据F
进行排序,以确保可以快速到达终点。A * 在一次路径搜索中,大量不需要访问的节点都在队列里,也会造成空间的过度消耗,因此可以引入IDA *
算法解决
此外A *
无法解决一种场景问题:给出 多个可能的目标,然后在这多个目标中 选择最近的目标,这种 A *
就不擅长了, A *
只擅长给出明确的目标 然后找到最短路径
如果是多个目标找最近目标(特别是潜在目标数量很多的时候),可以考虑 Dijkstra
(无向图) ,BFS
或者Floyd
(多源)
🟡 743-网络延迟时间(单源最短路径基础题型)
1.题目内容
有 n
个网络节点,标记为 1
到 n
。
给你一个列表 times
,表示信号经过 有向 边的传递时间。 times[i] = (ui, vi, wi)
,其中 ui
是源节点,vi
是目标节点, wi
是一个信号从源节点传递到目标节点的时间。
现在,从某个节点 K
发出一个信号。需要多久才能使所有节点都收到信号?如果不能使所有节点收到信号,返回 -1
2.题解思路
👻方法1:dijkstra 算法
- 思路分析:
- ① 将边关系转化为邻接表、邻接矩阵的图示方式
- ② 调用dijkstra算法获取到【源点】到 其他各个点的最短距离
- (1)定义
minDist[]
维护源点到其他各个点的最短距离(初始化源点到源点的最短距离为0,而到达其他点初始化设置为不可达),定义visited[]
维护各点的选中情况(标记为已遍历) - (2)遍历每个节点,从未被选中的点中选择一个距源点最近的点(注意
cur
初始化状态的处理) - (3)将选中节点进行标记
visited[cur]=true
- (4)更新
minDist
,基于未被选中的节点(还没得到最短路径的节点),更新源点到其的最短距离,即为minDist[x] = minDist[cur] + minDist[cur][x]
(即源点到x
节点的最短距离表示为源点到cur
节点的最短距离 +cur
到x
的直接距离)(理论上为dist[source][x]=dist[source][cur] + dist[cur][x]
)
- (1)定义
- ③ 如果节点不能到达其他任意一个节点,则返回
-1
(或者设定的其他不可达标识INF
);如果节点可以到达任意一个节点,则"多久才能使所有节点都收到信号"应该表示的是最大的minDist
而不是到达每个节点的时间累加(因为到达某个节点的情况可能是会选择经过或者不经过其他某个节点,而信号传送是同时发送的)
/**
* 🟡 743 网络延迟时间 - https://leetcode.cn/problems/network-delay-time/description/
*/
public class Solution743_02 {
int INF = Integer.MAX_VALUE / 2; // 设置为最大值,或者比题目设定大大些均可(满足通过用例的前提下)
/**
* @param times [u,v,w] 有向边及边值关系
* @param n 顶点个数(节点标记是1-n)
* @param k 起点 K
* @return
*/
public int networkDelayTime(int[][] times, int n, int k) {
// ① 处理边数据,将其转化为邻接矩阵
int[][] grid = new int[n + 1][n + 1];
// 初始化所有矩阵边权值为(可以用 -1 表示不可达 || 或者设定一个最大值边界进行处理)
for (int i = 0; i < grid.length; i++) {
Arrays.fill(grid[i], INF);
}
// 处理边数据
for (int[] edge : times) {
int u = edge[0];
int v = edge[1];
int w = edge[2];
grid[u][v] = w;
}
// ② 调用dijkstra算法获取源点到任意点的最短距离
int[] minDist = dijkstra(grid, n, k);
// 此处获取所有节点接收到信号的时间应该是minDist的最大值(而不是到达某个节点的延迟时间累加)
int max = 0;
for (int i = 0; i < minDist.length; i++) {
if (minDist[i] == INF) {
return -1; // 源点不可达i
}
max = Math.max(max, minDist[i]);
}
// 返回结果
return max;
}
// dijkstra
private int[] dijkstra(int[][] grid, int n, int source) {
// 维护一个minDist表示源点到节点i的最短距离
int[] minDist = new int[n + 1];
for (int i = 1; i <= n; i++) {
// 初始化(此处根据源点校验,源点到源点自身的距离设置为0,他节点的最短距离默认设置为范围内的最大值)
minDist[i] = (i == source) ? 0 : INF;
}
boolean[] visited = new boolean[n + 1]; // 存储节点的访问状态
Arrays.fill(visited, false);
// 遍历所有节点
for (int i = 1; i <= n; i++) {
// ① 从当前minDist中选出一个距离源点最近的未被访问的节点
int cur = -1;
int curVal = INF;
for (int j = 1; j < minDist.length; j++) {
// 如果节点还没被访问过,则尝试更新min(即选出一个minDist[j]最小的)
if (!visited[j] && (cur == -1 || minDist[j] < curVal)) { // 如果是初始化状态或者出现更小的值则进行更新
cur = j;
curVal = minDist[j];
}
}
// ② 将当前选中节点标记为已遍历(表示将其选中)
visited[cur] = true;
// ③ 更新当前未被处理节点到源点的最短距离(即原source->i的距离,确认是否存在source->cur->i的更短的路径)
for (int k = 1; k < minDist.length; k++) {
if (!visited[k] && grid[cur][k] != -1) {
minDist[k] = Math.min(minDist[k], minDist[cur] + grid[cur][k]);
}
}
}
// 返回构建的数组
return minDist;
}
}
复杂度分析
时间复杂度:
空间复杂度:
👻方法2:bellman_ford 算法
- 思路分析:基于bellman_ford算法思路求解(对所有边进行n-1次松弛操作)
- 定义
minDist[]
维护源点到节点i
的最短路径,定义visited[]
存储确定的节点 - 择选思路:遍历每一条边,对其进行n-1次松弛
- 更新
minDist[]
:对于每一条边([u,v,w](u->v:w)
)minDist[v]=minDist[u]+w
(此处minDist[u]
必须有效,否则更新无意义,即无效松弛)
- 更新
- 定义
/**
* 🟡 743 网络延迟时间 - https://leetcode.cn/problems/network-delay-time/description/
*/
public class Solution743_02 {
int INF = Integer.MAX_VALUE / 2; // 设置为最大值,或者比题目设定大大些均可(满足通过用例的前提下)
static class Edge {
public int u;
public int v;
public int w;
Edge(int u, int v, int w) {
this.u = u;
this.v = v;
this.w = w;
}
}
/**
* @param times [u,v,w] 有向边及边值关系
* @param n 顶点个数(节点标记是1-n)
* @param k 起点 K
* @return
*/
public int networkDelayTime(int[][] times, int n, int k) {
// ① 处理边数据,将其转化为邻接表
List<List<Edge>> grid = new ArrayList<>(n + 1);
for (int i = 0; i < n + 1; i++) {
grid.add(new ArrayList<>());
}
// 处理边数据
for (int[] edge : times) {
int u = edge[0];
int v = edge[1];
int w = edge[2];
grid.get(u).add(new Edge(u, v, w));
}
// ② 调用bellman_ford算法获取源点到任意点的最短距离
int[] minDist = bellman_ford(grid, n, k);
// 此处获取所有节点接收到信号的时间应该是minDist的最大值(而不是到达某个节点的延迟时间累加)
int max = 0;
for (int i = 0; i < minDist.length; i++) {
if (minDist[i] == INF) {
return -1; // 源点不可达i
}
max = Math.max(max, minDist[i]);
}
// 返回结果
return max;
}
// bellman_ford 算法
private int[] bellman_ford(List<List<Edge>> grid, int n, int source) {
// 维护一个minDist表示源点到节点i的最短距离
int[] minDist = new int[n + 1];
for (int i = 1; i <= n; i++) {
// 初始化(此处根据源点校验,源点到源点自身的距离设置为0,他节点的最短距离默认设置为范围内的最大值)
minDist[i] = (i == source) ? 0 : INF;
}
// 遍历所有节点
for (int i = 1; i < n; i++) {// n-1次
// ① 对所有边进行松弛
for (int u = 1; u < grid.size(); u++) {
for (Edge next : grid.get(u)) {
int v = next.v;
int w = next.w;
if (minDist[u] != INF && minDist[u] + w < minDist[v]) { // bellman_ford 核心公式
minDist[v] = minDist[u] + w;
}
}
}
}
// 返回构建的数组
return minDist;
}
}
👻方法3:SPFA(bellman_ford 优化版本)算法
/**
* 🟡 743 网络延迟时间 - https://leetcode.cn/problems/network-delay-time/description/
*/
public class Solution743_03 {
int INF = Integer.MAX_VALUE / 2; // 设置为最大值,或者比题目设定大大些均可(满足通过用例的前提下)
static class Edge {
public int u;
public int v;
public int w;
Edge(int u, int v, int w) {
this.u = u;
this.v = v;
this.w = w;
}
}
/**
* @param times [u,v,w] 有向边及边值关系
* @param n 顶点个数(节点标记是1-n)
* @param k 起点 K
* @return
*/
public int networkDelayTime(int[][] times, int n, int k) {
// ① 处理边数据,将其转化为邻接表
List<List<Edge>> grid = new ArrayList<>(n + 1);
for (int i = 0; i < n + 1; i++) {
grid.add(new ArrayList<>());
}
// 处理边数据
for (int[] edge : times) {
int u = edge[0];
int v = edge[1];
int w = edge[2];
grid.get(u).add(new Edge(u, v, w));
}
// ② 调用spfa算法获取源点到任意点的最短距离
int[] minDist = spfa(grid, n, k);
// 此处获取所有节点接收到信号的时间应该是minDist的最大值(而不是到达某个节点的延迟时间累加)
int max = 0;
for (int i = 0; i < minDist.length; i++) {
if (minDist[i] == INF) {
return -1; // 源点不可达i
}
max = Math.max(max, minDist[i]);
}
// 返回结果
return max;
}
// bellman_ford 算法 优化版本 SPFA 算法
private int[] spfa(List<List<Edge>> grid, int n, int source) {
// 维护一个minDist表示源点到节点i的最短距离
int[] minDist = new int[n + 1];
for (int i = 1; i <= n; i++) {
// 初始化(此处根据源点校验,源点到源点自身的距离设置为0,他节点的最短距离默认设置为范围内的最大值)
minDist[i] = (i == source) ? 0 : INF;
}
// 定义队列维护上一次松弛操作更新的节点
Queue<Integer> queue = new LinkedList<>();
queue.offer(source); // 初始化源点入队
// 只对上一次松弛操作更新后的节点关联的边进行松弛操作(相当于过滤掉其他无效松弛)
while (!queue.isEmpty()) {
int u = queue.poll();
for (int i = 1; i < n; i++) {
for (Edge next : grid.get(u)) {
int v = next.v;
int w = next.w;
if (minDist[u] != INF && minDist[u] + w < minDist[v]) {
minDist[v] = minDist[u] + w;
if (!queue.contains(v)) {
queue.offer(v); // 将更新的节点加入队列(如果已经加入的节点不重复加入)
}
}
}
}
}
// 返回构建的数组
return minDist;
}
}
🟡KMW047-参加科学大会(dijkstra 基础题型)
1.题目内容
【题目描述】
小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。
小明的起点是第一个车站,终点是最后一个车站。然而,途中的各个车站之间的道路状况、交通拥堵程度以及可能的自然因素(如天气变化)等不同,这些因素都会影响每条路径的通行时间。
小明希望能选择一条花费时间最少的路线,以确保他能够尽快到达目的地。
【输入描述】
第一行包含两个正整数,第一个正整数 N 表示一共有 N 个公共汽车站,第二个正整数 M 表示有 M 条公路。
接下来为 M 行,每行包括三个整数,S、E 和 V,代表了从 S 车站可以单向直达 E 车站,并且需要花费 V 单位的时间。
【输出描述】
输出一个整数,代表小明从起点到终点所花费的最小时间。
输入示例
7 9
1 2 1
1 3 4
2 3 2
2 4 5
3 4 2
4 5 3
2 6 4
5 7 4
6 7 9
输出示例:12
【提示信息】
能够到达的情况:
如下图所示,起始车站为 1 号车站,终点车站为 7 号车站,绿色路线为最短的路线,路线总长度为 12,则输出 12。
数据范围:1 <= N <= 500; 1 <= M <= 5000
2.题解思路
👻方法1:dijkstra 算法(朴素版)
/**
* KMW047: 参加科学大会
*/
public class Solution1 {
// dijkstra:此处设定返回构建的`minDist[]`(源点到每个节点的最短路径)
public static int[] dijkstra(int n, int[][] graph, int startIdx) {
int maxValue = Integer.MAX_VALUE;
// 构建minDist[]: 源点到指定节点的最短距离
int[] minDist = new int[n + 1]; // 此处节点有效范围选择[1,n]
for (int i = 0; i <= n; i++) {
// 初始化(此处根据源点校验,源点到源点自身的距离设置为0)
if (i == startIdx) {
minDist[i] = 0; // 源点到自身的距离为0
} else {
minDist[i] = maxValue; // 其他节点的最短距离默认设置为范围内的最大值
}
}
// 构建遍历标识
boolean[] selected = new boolean[n + 1]; // 节点有效范围选择[1,n]
Arrays.fill(selected, false); // 初始化设置为未被遍历过(未被选择)
// 构建选择的路径("(u->v)"形式)
String[] selectedPath = new String[n + 1]; // 节点有效范围选择[1,n]
// 主循环(dijkstra算法核心:dijkstra三部曲)
for (int cnt = 1; cnt <= n; cnt++) {
// 1.选择距离源点的最近节点
int cur = -1; // 定义选择指针
int minVal = maxValue; // 如果minDist全初始化为maxValue,则此处取maxValue + 1确保循环正常进入处理
for (int i = 1; i <= n; i++) {
if (!selected[i] && minDist[i] < minVal) { // 从未遍历节点中选择
cur = i;
minVal = minDist[i];
}
}
// 2.将这个最近节点标记为已被选择(遍历)
selected[cur] = true;
// 3.更新经由当前选择节点扩展的新路径(cur可到达的其他节点)
for (int v = 1; v <= n; v++) { // 遍历节点v
if (!selected[v] && graph[cur][v] != -1) { // 需校验cur->v直接是否可达,通过weight校验是否为负数(此处设置一个特殊标识-1标识u->v不可达)来进行排除
// minDist[v] = Math.min(minDist[v], minDist[cur] + graph[cur][v]);
// 更新midDist[i]的同时更新路径
int curMin = minDist[cur] + graph[cur][v];
if (curMin < minDist[v]) {
minDist[v] = curMin; // 更新源点到节点v最短路径
selectedPath[v] = "(" + cur + "->" + v + ")"; // 更新当前的节点路径选择
}
}
}
}
// 处理结果:输出结果
for (int i = 1; i <= n; i++) {
System.out.println(selectedPath[i] + minDist[i]);
}
// 返回结果
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N(N个公共汽车站)M(M条公路:车站到车站之间的边)");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M行(S、E、V =》 S车站单向直达E车站,需要花费时间V单位)");
// 构建邻接矩阵,初始化设置节点不可达标识为-1
int[][] graph = new int[n + 1][n + 1];
for (int i = 0; i < graph.length; i++) {
Arrays.fill(graph[i], -1);
}
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
graph[u][v] = weight;
}
// 调用dijkstra算法
int[] minDist = Solution1.dijkstra(n, graph, 1); // 从车站1出发(起点为1)
// 终点为最后一个车站(n)
if(minDist[n]==Integer.MAX_VALUE){
System.out.println("起点到终点不可达");
}else{
System.out.println("起点到终点的最短路径为" + minDist[n]);
}
}
}
🟡KMW094-城市间货物运输 I (bellman_ford & SPFA 基础题型)
1.题目内容
【题目描述】
某国为促进城市间经济交流,决定对货物运输提供补贴。共有 n 个编号为 1 到 n 的城市,通过道路网络连接,网络中的道路仅允许从某个城市单向通行到另一个城市,不能反向通行。
网络中的道路都有各自的运输成本和政府补贴,道路的权值计算方式为:运输成本 - 政府补贴。
权值为正表示扣除了政府补贴后运输货物仍需支付的费用;权值为负则表示政府的补贴超过了支出的运输成本,实际表现为运输过程中还能赚取一定的收益。
请找出从城市 1 到城市 n 的所有可能路径中,综合政府补贴后的最低运输成本。
如果最低运输成本是一个负数,它表示在遵循最优路径的情况下,运输过程中反而能够实现盈利。
城市 1 到城市 n 之间可能会出现没有路径的情况,同时保证道路网络中不存在任何负权回路。
负权回路是指一系列道路的总权值为负,这样的回路使得通过反复经过回路中的道路,理论上可以无限地减少总成本或无限地增加总收益。
【输入描述】
第一行包含两个正整数,第一个正整数 n 表示该国一共有 n 个城市,第二个整数 m 表示这些城市中共有 m 条道路。
接下来为 m 行,每行包括三个整数,s、t 和 v,表示 s 号城市运输货物到达 t 号城市,道路权值为 v(单向图)。
【输出描述】
如果能够从城市 1 到连通到城市 n, 请输出一个整数,表示运输成本。如果该整数是负数,则表示实现了盈利。如果从城市 1 没有路径可达城市 n,请输出 "unconnected"。
【输入示例】:
6 7
5 6 -2
1 2 1
5 3 1
2 5 2
2 4 -3
4 6 4
1 3 5
2.题解思路
👻方法1:Bellman_ford 算法
/**
* KMW094-城市间货物运输 I
* https://kamacoder.com/problempage.php?pid=1152
*/
public class Solution1 {
public static int MAX_VAL = Integer.MAX_VALUE;
/**
* bellman_ford 算法: 处理带有负权值的有向图的最短路径问题(起点->终点)
*
* @param n 节点个数
* @param edges 边列表
* @param startIdx 起点
*/
public static int[] bellman_ford(int n, List<Edge> edges, int startIdx) {
// 定义minDist[i]:存储【源点】到【节点i】的最短距离(节点编号取值有效范围:[1,n])
int[] minDist = new int[n + 1];
Arrays.fill(minDist, MAX_VAL); // 初始化为最大值
minDist[startIdx] = 0; // 源点到自身的最短距离为0
// bellman_ford 算法核心:执行n-1次松弛
for (int i = 1; i < n; i++) {
// 对每条边执行松弛操作
for (Edge edge : edges) {
int from = edge.u;
int to = edge.v;
int weight = edge.val;
if (minDist[from] != MAX_VAL && minDist[from] + weight < minDist[to]) { // 设定minDist[from]!= MAX_VAL避免从未被计算过的节点出发(无意义)
minDist[to] = minDist[from] + weight; // 出现最短路径,更新
}
}
}
// 整理结果
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入n个城市编号,m条道路");
int n = sc.nextInt();
int m = sc.nextInt();
List<Edge> edges = new ArrayList<>(); // 收集边列表
System.out.println("2.输入m行(s t v)表示s城市到t城市的道路权值为v");
while (m-- > 0) {
int s = sc.nextInt();
int t = sc.nextInt();
int v = sc.nextInt();
edges.add(new Edge(s, t, v));
}
// 调用bell_ford算法:获取【源点】到其他各个节点的最短路径集合
int[] minDist = Solution1.bellman_ford(n, edges, 1);
// 判断起点1到城市n的运输成本
if (minDist[n] == MAX_VAL) {
System.out.println("unconnected");
} else {
System.out.println(minDist[n]);
}
}
}
复杂度分析
时间复杂度:对
m
条边执行n-1
次松弛,因此时间复杂度为O(m×(n-1))空间复杂度:不计算法输入输出的话,此算法定义
minDist[n+1]
数组辅助记录源点到各个节点的最短距离,因此空间复杂度为O(n)
👻方法2:SPFA 算法
题目中保证:城市 1 到城市 n 之间可能会出现没有路径的情况,同时保证道路网络中不存在任何负权回路。 因此可以选用bellman_ford
算法的队列优化版本(SPFA算法)来实现
/**
* KMW094-城市间货物运输 I
* https://kamacoder.com/problempage.php?pid=1152
*/
public class Solution2 {
public static int MAX_VAL = Integer.MAX_VALUE;
/**
* SPFA 算法: 处理带有负权值的有向图的最短路径问题(起点->终点) bellman_ford 队列优化版本
*
* @param n 节点个数
* @param graph 邻接表
* @param startIdx 起点
*/
public static int[] spfa(int n, List<List<Edge>> graph, int startIdx) {
// 定义minDist[i]:存储【源点】到【节点i】的最短距离(节点编号取值有效范围:[1,n])
int[] minDist = new int[n + 1];
Arrays.fill(minDist, MAX_VAL); // 初始化为最大值
minDist[startIdx] = 0; // 源点到自身的最短距离为0
// 定义Queue存储每次松弛操作更新后的节点
Queue<Integer> queue = new LinkedList<>();
queue.offer(startIdx); // queue的更新始终和minDist的更新保持同步,此处初始化加入源点
// SPFA 算法核心:对上一次松弛操作更新后的节点关联的边(出)进行松弛操作
while (!queue.isEmpty()) {
// 取出队列节点
int cur = queue.poll();
// 对该节点关联的边进行松弛操作
List<Edge> relateEdges = graph.get(cur);
for (Edge edge : relateEdges) {
int from = edge.u; // 与cur对照
int to = edge.v;
int weight = edge.val;
if (minDist[from] + weight < minDist[to]) {
minDist[to] = minDist[from] + weight; // 更新源点到节点的最短距离
if (!queue.contains(to)) { // 如果队列中存在v节点则不重复加入(队列优化)
queue.offer(to); // 将v节点加入队列
}
}
}
}
// 整理结果
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入n个城市编号,m条道路");
int n = sc.nextInt();
int m = sc.nextInt();
List<List<Edge>> graph = new ArrayList<>(); // 构建邻接表
for (int i = 0; i <= n; i++) {
graph.add(new ArrayList<>());
}
System.out.println("2.输入m行(s t v)表示s城市到t城市的道路权值为v");
while (m-- > 0) {
int s = sc.nextInt();
int t = sc.nextInt();
int v = sc.nextInt();
graph.get(s).add(new Edge(s, t, v));
}
// 调用bell_ford算法:获取【源点】到其他各个节点的最短路径集合
int[] minDist = Solution2.spfa(n, graph, 1);
// 判断起点1到城市n的运输成本
if (minDist[n] == MAX_VAL) {
System.out.println("unconnected");
} else {
System.out.println(minDist[n]);
}
}
}
🟡KMW095-城市间货物运输II(校验负权回路)
1.题目内容
【题目描述】
某国为促进城市间经济交流,决定对货物运输提供补贴。共有 n 个编号为 1 到 n 的城市,通过道路网络连接,网络中的道路仅允许从某个城市单向通行到另一个城市,不能反向通行。
网络中的道路都有各自的运输成本和政府补贴,道路的权值计算方式为:运输成本 - 政府补贴。权值为正表示扣除了政府补贴后运输货物仍需支付的费用;
权值为负则表示政府的补贴超过了支出的运输成本,实际表现为运输过程中还能赚取一定的收益。
然而,在评估从城市 1 到城市 n 的所有可能路径中综合政府补贴后的最低运输成本时,存在一种情况:图中可能出现负权回路。
负权回路是指一系列道路的总权值为负,这样的回路使得通过反复经过回路中的道路,理论上可以无限地减少总成本或无限地增加总收益。
为了避免货物运输商采用负权回路这种情况无限的赚取政府补贴,算法还需检测这种特殊情况。
请找出从城市 1 到城市 n 的所有可能路径中,综合政府补贴后的最低运输成本。同时能够检测并适当处理负权回路的存在。
城市 1 到城市 n 之间可能会出现没有路径的情况
【输入描述】
第一行包含两个正整数,第一个正整数 n 表示该国一共有 n 个城市,第二个整数 m 表示这些城市中共有 m 条道路。
接下来为 m 行,每行包括三个整数,s、t 和 v,表示 s 号城市运输货物到达 t 号城市,道路权值为 v。
【输出描述】
如果没有发现负权回路,则输出一个整数,表示从城市 1 到城市 n 的最低运输成本(包括政府补贴)。
如果该整数是负数,则表示实现了盈利。如果发现了负权回路的存在,则输出 "circle"。如果从城市 1 无法到达城市 n,则输出 "unconnected"。
【输入示例】
4 4
1 2 -1
2 3 1
3 1 -1
3 4 1
【输出示例】
circle
2.题解思路
👻方法1:Bellman_ford 之 负权回路 处理
核心:基于bellman_ford改造,引入第n次松弛,在第n次松弛的时候判断是否存在负权回路(即minDist[]
是否改变,是否出现了更短的路径)
/**
* KMW095-城市间货物运输 II
* https://kamacoder.com/problempage.php?pid=1153
* 针对带有【负权回路】的处理
*/
public class Solution1 {
public static int MAX_VAL = Integer.MAX_VALUE;
/**
* bellman_ford 算法: 处理带有负权值的有向图的最短路径问题(起点->终点)
*
* @param n 节点个数
* @param edges 边列表
* @param startIdx 起点
*/
public static int[] bellman_ford(int n, List<Edge> edges, int startIdx) {
// 定义minDist[i]:存储【源点】到【节点i】的最短距离(节点编号取值有效范围:[1,n])
int[] minDist = new int[n + 1];
Arrays.fill(minDist, MAX_VAL); // 初始化为最大值
minDist[startIdx] = 0; // 源点到自身的最短距离为0
// bellman_ford 算法核心:执行n次松弛(n-1次正常松弛,第n次松弛校验是否存在负权回路)
boolean hasNegCycle = false;
for (int i = 1; i <= n; i++) { // n 次松弛
// 对每条边执行松弛操作
for (Edge edge : edges) {
int from = edge.u;
int to = edge.v;
int weight = edge.val;
if (i < n) {
if (minDist[from] != MAX_VAL && minDist[from] + weight < minDist[to]) { // 设定minDist[from]!= MAX_VAL避免从未被计算过的节点出发(无意义)
minDist[to] = minDist[from] + weight; // 出现最短路径,更新
}
} else if (i == n) {
if (minDist[from] != MAX_VAL && minDist[from] + weight < minDist[to]) { // 设定minDist[from]!= MAX_VAL避免从未被计算过的节点出发(无意义)
// 出现最短路径,说明存在负权回路
hasNegCycle = true;
}
}
}
}
if (hasNegCycle) {
System.out.println("cycle");
} else {
// 判断起点1到城市n的运输成本
if (minDist[n] == MAX_VAL) {
System.out.println("unconnected");
} else {
System.out.println(minDist[n]);
}
}
// 整理结果
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入n个城市编号,m条道路");
int n = sc.nextInt();
int m = sc.nextInt();
List<Edge> edges = new ArrayList<>(); // 收集边列表
System.out.println("2.输入m行(s t v)表示s城市到t城市的道路权值为v");
while (m-- > 0) {
int s = sc.nextInt();
int t = sc.nextInt();
int v = sc.nextInt();
edges.add(new Edge(s, t, v));
}
// 调用bell_ford算法:获取【源点】到其他各个节点的最短路径集合
Solution1.bellman_ford(n, edges, 1);
}
}
👻方法2:SPFA 之 负权回路 处理
核心:基于SPFA
算法改造,核心在于判断节点被加入队列的次数是否超过n-1,如果超出则说明存在负权回路。在松弛的时候同步校验cnt[v]
以控制循环正常跳出,否则会出现死循环(queue始终不为空的话)
/**
* KMW095-城市间货物运输 II
* https://kamacoder.com/problempage.php?pid=1153
* 针对带有【负权回路】的处理
*/
public class Solution2 {
public static int MAX_VAL = Integer.MAX_VALUE;
/**
* SPFA 算法: 处理带有负权值的有向图的最短路径问题(起点->终点) bellman_ford 队列优化版本
*
* @param n 节点个数
* @param graph 邻接表
* @param startIdx 起点
*/
public static int[] spfa(int n, List<List<Edge>> graph, int startIdx) {
// 定义minDist[i]:存储【源点】到【节点i】的最短距离(节点编号取值有效范围:[1,n])
int[] minDist = new int[n + 1];
Arrays.fill(minDist, MAX_VAL); // 初始化为最大值
minDist[startIdx] = 0; // 源点到自身的最短距离为0
// 定义Queue存储每次松弛操作更新后的节点
Queue<Integer> queue = new LinkedList<>();
queue.offer(startIdx); // queue的更新始终和minDist的更新保持同步,此处初始化加入源点
int[] cnt = new int[n + 1];
cnt[startIdx] = 1; // 初始化
boolean hasNegCycle = false;
// SPFA 算法核心:对上一次松弛操作更新后的节点关联的边(出)进行松弛操作
while (!queue.isEmpty()) {
// 取出队列节点
int cur = queue.poll();
// 对该节点关联的边进行松弛操作
List<Edge> relateEdges = graph.get(cur);
for (Edge edge : relateEdges) {
int from = edge.u; // 与cur对照
int to = edge.v;
int weight = edge.val;
if (minDist[from] + weight < minDist[to]) {
minDist[to] = minDist[from] + weight; // 更新源点到节点的最短距离
if (!queue.contains(to)) { // 如果队列中存在v节点则不重复加入(队列优化)
queue.offer(to); // 将v节点加入队列
cnt[to]++; // 更新加入队列次数
}
// 同步校验cnt[v]是否超出n-1次,如果超出说明存在负权回路,需要跳出这个循环
if (cnt[to] > n - 1) {
hasNegCycle = true;
queue.clear(); // 清空queue节点,为了跳出queue的循环遍历
break; // 跳出当前循环
}
}
}
}
if (hasNegCycle) {
System.out.println("cycle");
} else {
// 判断起点1到城市n的运输成本
if (minDist[n] == MAX_VAL) {
System.out.println("unconnected");
} else {
System.out.println(minDist[n]);
}
}
// 整理结果
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入n个城市编号,m条道路");
int n = sc.nextInt();
int m = sc.nextInt();
List<List<Edge>> graph = new ArrayList<>(); // 构建邻接表
for (int i = 0; i <= n; i++) {
graph.add(new ArrayList<>());
}
System.out.println("2.输入m行(s t v)表示s城市到t城市的道路权值为v");
while (m-- > 0) {
int s = sc.nextInt();
int t = sc.nextInt();
int v = sc.nextInt();
graph.get(s).add(new Edge(s, t, v));
}
// 调用bell_ford算法:获取【源点】到其他各个节点的最短路径集合
Solution2.spfa(n, graph, 1);
}
}
🟡KMW096-城市间的货物运输III(限定至多经过K个节点)
1.题目内容
【题目描述】
某国为促进城市间经济交流,决定对货物运输提供补贴。共有 n 个编号为 1 到 n 的城市,通过道路网络连接,网络中的道路仅允许从某个城市单向通行到另一个城市,不能反向通行。
网络中的道路都有各自的运输成本和政府补贴,道路的权值计算方式为:运输成本 - 政府补贴。
权值为正表示扣除了政府补贴后运输货物仍需支付的费用;
权值为负则表示政府的补贴超过了支出的运输成本,实际表现为运输过程中还能赚取一定的收益。
请计算在最多经过 k 个城市的条件下,从城市 src 到城市 dst 的最低运输成本。
【输入描述】
第一行包含两个正整数,第一个正整数 n 表示该国一共有 n 个城市,第二个整数 m 表示这些城市中共有 m 条道路。
接下来为 m 行,每行包括三个整数,s、t 和 v,表示 s 号城市运输货物到达 t 号城市,道路权值为 v。
最后一行包含三个正整数,src、dst、和 k,src 和 dst 为城市编号,从 src 到 dst 经过的城市数量限制。
【输出描述】
输出一个整数,表示从城市 src 到城市 dst 的最低运输成本,如果无法在给定经过城市数量限制下找到从 src 到 dst 的路径,则输出 "unreachable",表示不存在符合条件的运输方案。
输入示例:
6 7
1 2 1
2 4 -3
2 5 2
1 3 5
3 5 1
4 6 4
5 6 -2
2 6 1
输出示例:
0
2.题解思路
👻方法1:bellman_ford 版本
/**
* bellmanFord算法 单源有限最短路径(对比`BellmanFordTemplate2`)
*/
public class Soluion1 {
/**
* bellmanFord算法(处理带负权值的有向图的最短路径:起点到终点)
*
* @param n 节点个数[1,n]
* @param edges 边列表集合
* @param startIdx 开始节点(源点)
*/
public static int[] bellmanFord(int n, List<Edge> edges, int startIdx, int k) {
// 定义最大范围
int maxVal = Integer.MAX_VALUE;
// minDist[i] 源点到节点i的最短距离
int[] minDist = new int[n + 1]; // 有效节点编号范围:[1,n]
Arrays.fill(minDist, maxVal); // 初始化为maxVal
minDist[startIdx] = 0; // 设置源点到源点的最短路径为0
int[] minDist_copy = new int[n + 1]; // 每次松弛前记录上一次松弛的结果,作为本次松弛的参考
// bellmanFord 算法核心:
for (int i = 1; i <= k + 1; i++) { // 限定松弛k+1次
// 记录上次松弛的结果
minDist_copy = Arrays.copyOfRange(minDist, 0, minDist.length);
// 执行bellmanFord核心,遍历所有边
for (Edge edge : edges) {
int u = edge.u; // 与idx对照
int v = edge.v;
int weight = edge.val;
if (minDist_copy[u] != maxVal && minDist_copy[u] + weight < minDist[v]) { // bellman_ford 核心公式
minDist[v] = minDist_copy[u] + weight;
}
}
System.out.println("第" + i + "次松弛");
PrintUtil.print(minDist); // 打印数组
}
// 返回minDist
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N个节点、M条边(u v weight)");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
List<Edge> edges = new ArrayList<>(); // 构建边集合
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
edges.add(new Edge(u, v, weight));
}
System.out.println("3.输入起点src、终点dst、途径城市数量限制 k");
int src = sc.nextInt();
int dst = sc.nextInt();
int k = sc.nextInt();
// 调用bellman算法
int[] minDist = Soluion1.bellmanFord(n, edges, src, k);
// 校验起点1->终点n
if (minDist[dst] == Integer.MAX_VALUE) {
System.out.println("unreachable"); // 指定起点到终点不可达
} else {
System.out.println("最短路径:" + minDist[n]);
}
}
}
// output case01(不存在负权回路)
1.输入N个节点、M条边(u v weight)
6 7
2.输入M条边
1 2 1
2 4 -3
2 5 2
1 3 5
3 5 1
4 6 4
5 6 -2
3.输入起点src、终点dst、途径城市数量限制 k
2 6 1
第1次松弛
[2147483647]-[2147483647]-[0]-[2147483647]-[-3]-[2]-[2147483647]-
第2次松弛
[2147483647]-[2147483647]-[0]-[2147483647]-[-3]-[2]-[0]-
最短路径:0
// output case02(存在负权回路)
1.输入N个节点、M条边(u v weight)
4 4
2.输入M条边
1 2 -1
2 3 1
3 1 -1
3 4 1
3.输入起点src、终点dst、途径城市数量限制 k
1 4 3
第1次松弛
[2147483647]-[0]-[-1]-[2147483647]-[2147483647]-
第2次松弛
[2147483647]-[0]-[-1]-[0]-[2147483647]-
第3次松弛
[2147483647]-[-1]-[-1]-[0]-[1]-
第4次松弛
[2147483647]-[-1]-[-2]-[0]-[1]-
最短路径:1
👻方法2:spfa 版本
/**
* SPFA算法 单源有限最短路径
*/
public class Soluion2 {
/**
* bellmanFord算法(处理带负权值的有向图的最短路径:起点到终点)
*
* @param n 节点个数[1,n]
* @param graph 邻接表
* @param startIdx 开始节点(源点)
*/
public static int[] spfa(int n, List<List<Edge>> graph, int startIdx, int k) {
// 定义最大范围
int maxVal = Integer.MAX_VALUE;
// minDist[i] 源点到节点i的最短距离
int[] minDist = new int[n + 1]; // 有效节点编号范围:[1,n]
Arrays.fill(minDist, maxVal); // 初始化为maxVal
minDist[startIdx] = 0; // 设置源点到源点的最短路径为0
int[] minDist_copy = new int[n + 1]; // 每次松弛前记录上一次松弛的结果,作为本次松弛的参考
// spfa 算法核心
Queue<Integer> queue = new LinkedList<>(); // 记录每一次松弛更新的节点
queue.offer(startIdx); // 初始化
while ((k + 1) > 0 && !queue.isEmpty()) {
// 每次松弛前记录当前数组状态,作为本次松弛操作的参考
minDist_copy = Arrays.copyOfRange(minDist, 0, minDist.length);
// 记录当次松弛要处理的节点个数
int curSize = queue.size();
while (curSize-- > 0) {
// 取出节点
int cur = queue.poll();
// 对节点连接的边进行松弛操作
List<Edge> relateEdges = graph.get(cur);
for (Edge edge : relateEdges) {
int from = edge.u; // 与cur节点对照
int to = edge.v;
int weight = edge.val;
// 松弛校验处理
if (minDist_copy[from] + weight < minDist[to]) {
minDist[to] = minDist_copy[from] + weight; // 更新节点
if (!queue.contains(to)) {
queue.offer(to); // 如果队列中已存在节点则不重复加入
}
}
}
}
// 当次松弛操作结束,计数减1
k--;
// System.out.println("第" + i + "次松弛");
PrintUtil.print(minDist); // 打印数组
}
// 返回minDist
return minDist;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N个节点、M条边(u v weight)");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
List<List<Edge>> graph = new ArrayList<>(); // 构建邻接表
for (int i = 0; i <= n; i++) {
graph.add(new ArrayList<>());
}
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
graph.get(u).add(new Edge(u, v, weight));
}
System.out.println("3.输入起点src、终点dst、途径城市数量限制 k");
int src = sc.nextInt();
int dst = sc.nextInt();
int k = sc.nextInt();
// 调用bellman算法
int[] minDist = Soluion2.spfa(n, graph, src, k);
// 校验起点1->终点n
if (minDist[dst] == Integer.MAX_VALUE) {
System.out.println("unreachable"); // 指定起点到终点不可达
} else {
System.out.println("最短路径:" + minDist[n]);
}
}
}
// output
1.输入N个节点、M条边(u v weight)
6 7
2.输入M条边
1 2 1
2 4 -3
2 5 2
1 3 5
3 5 1
4 6 4
5 6 -2
3.输入起点src、终点dst、途径城市数量限制 k
2 6 1
[2147483647]-[2147483647]-[0]-[2147483647]-[-3]-[2]-[2147483647]-
[2147483647]-[2147483647]-[0]-[2147483647]-[-3]-[2]-[0]-
最短路径:0
🟡KMW097-小明逛公园(Floyd 基础题型)
1.题目内容
【题目描述】
小明喜欢去公园散步,公园内布置了许多的景点,相互之间通过小路连接,小明希望在观看景点的同时,能够节省体力,走最短的路径。
给定一个公园景点图,图中有 N 个景点(编号为 1 到 N),以及 M 条双向道路连接着这些景点。每条道路上行走的距离都是已知的。
小明有 Q 个观景计划,每个计划都有一个起点 start 和一个终点 end,表示他想从景点 start 前往景点 end。由于小明希望节省体力,他想知道每个观景计划中从起点到终点的最短路径长度。 请你帮助小明计算出每个观景计划的最短路径长度。
【输入描述】
第一行包含两个整数 N, M, 分别表示景点的数量和道路的数量。
接下来的 M 行,每行包含三个整数 u, v, w,表示景点 u 和景点 v 之间有一条长度为 w 的双向道路。
接下里的一行包含一个整数 Q,表示观景计划的数量。
接下来的 Q 行,每行包含两个整数 start, end,表示一个观景计划的起点和终点。
【输出描述】
对于每个观景计划,输出一行表示从起点到终点的最短路径长度。如果两个景点之间不存在路径,则输出 -1。
【输入示例】
// N M
7 3
// M 条边
2 3 4
3 6 6
4 7 8
// 计划个数Q
2
// Q个计划【起点->终点】
2 3
3 4
【输出示例】
4
-1
【提示信息】
从 1 到 2 的路径长度为 4,2 到 3 之间并没有道路。
1 <= N, M, Q <= 1000
2.题解思路
👻方法1:Floyd 算法
/**
* KMW097-小明逛公园
*/
public class Solution1 {
// public static int MAX_VAL = Integer.MAX_VALUE;
public static int MAX_VAL = 10005; // 边的最大距离是10^4(不选用Integer.MAX_VALUE是为了避免相加导致数值溢出)
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入N M");
int n = sc.nextInt();
int m = sc.nextInt();
System.out.println("2.输入M条边");
// ① dp定义(grid[i][j][k] 节点i到节点j 可能经过节点K(k∈[1,n]))的最短路径
int[][][] grid = new int[n + 1][n + 1][n + 1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
for (int k = 0; k <= n; k++) {
grid[i][j][k] = grid[j][i][k] = MAX_VAL; // 其余设置为最大值
}
}
}
// ② dp 推导:grid[i][j][k] = min{grid[i][k][k-1] + grid[k][j][k-1], grid[i][j][k-1]}
// 处理边
while (m-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
int weight = sc.nextInt();
grid[u][v][0] = grid[v][u][0] = weight; // 初始化(处理k=0的情况) ③ dp初始化
}
// ④ dp构建:floyd 推导
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
grid[i][j][k] = Math.min(grid[i][k][k - 1] + grid[k][j][k - 1], grid[i][j][k - 1]);
}
}
}
System.out.println("3.输入[起点-终点]计划个数");
int x = sc.nextInt();
System.out.println("4.输入每个起点src 终点dst");
while (x-- > 0) {
int src = sc.nextInt();
int dst = sc.nextInt();
// 根据floyd推导结果输出计划路径的最小距离
if (grid[src][dst][n] == MAX_VAL) {
System.out.println("-1");
} else {
System.out.println(grid[src][dst][n]);
}
}
}
}
🟡KMW127-骑士的攻击
1.题目内容
【题目描述】
在象棋中,马和象的移动规则分别是“马走日”和“象走田”。现给定骑士的起始坐标和目标坐标,要求根据骑士的移动规则,计算从起点到达目标点所需的最短步数。
骑士移动规则如图,红色是起始位置,黄色是骑士可以走的地方
棋盘大小 1000 x 1000(棋盘的 x 和 y 坐标均在 [1, 1000] 区间内,包含边界)
【输入描述】
第一行包含一个整数 n,表示测试用例的数量。
接下来的 n 行,每行包含四个整数 a1, a2, b1, b2,分别表示骑士的起始位置 (a1, a2) 和目标位置 (b1, b2)。
【输出描述】
输出共 n 行,每行输出一个整数,表示骑士从起点到目标点的最短路径长度。
【输入示例】
6
5 2 5 4
1 1 2 2
1 1 8 8
1 1 8 7
2 1 3 3
4 6 4 6
【输出示例】
2
4
6
5
1
0
2.题解思路
👻方法1:广搜法
/**
* KMW127-骑士的攻击
*/
public class Solution1 {
public static int limit = 10001;
/**
* @param startX,startY 起始坐标
* @param endX,endY 目标坐标
*/
public static int[][] bfs(int startX, int startY, int endX, int endY) {
// 定义行进方向(马走日、象走田)
int[][] dir = new int[][]{
{2, 1}, {2, -1}, {-2, 1}, {-2, -1},
{1, 2}, {1, -2}, {-1, 2}, {-1, -2}
};
// 记录到达指定节点路径
int[][] moved = new int[limit + 1][limit + 1];
// 构建辅助队列遍历搜索
Queue<Pair> queue = new LinkedList<>();
queue.offer(new Pair(startX, startY));
while (!queue.isEmpty()) {
// 取出坐标
Pair cur = queue.poll();
int curX = cur.x;
int curY = cur.y;
// 判断当前遍历节点是否为终点坐标(如果是终点坐标,搜索结束)
if (curX == endX && curY == endY) {
break;
}
// 往四个方向搜索
for (int i = 0; i < 8; i++) {
// 计算下一个位置
int nextX = cur.x + dir[i][0];
int nextY = cur.y + dir[i][1];
// 如果越界则跳过
if (nextX < 1 || nextX > limit || nextY < 1 || nextY > limit) { // x、y 属于[1,limit]
continue;
}
// 如果节点没有访问过,步数累加
if (moved[nextX][nextY] == 0) {
moved[nextX][nextY] = moved[curX][curY] + 1;
queue.offer(new Pair(nextX, nextY)); // 将坐标加入队列
}
}
}
// 返回结果
return moved;
}
public static void main(String[] args) {
// 输入控制
Scanner sc = new Scanner(System.in);
System.out.println("1.输入n");
int n = sc.nextInt();
System.out.println("\n2.输入n行(起始位置->终点位置):(a1,a2)->(b1,b2)");
while (n-- > 0) {
int a1 = sc.nextInt();
int a2 = sc.nextInt();
int b1 = sc.nextInt();
int b2 = sc.nextInt();
int[][] moved = Solution1.bfs(a1, a2, b1, b2);
System.out.println(moved[b1][b2]);
}
}
}
👻方法2:A *
算法
扩展题型
🔴1192-查找集群内的关键连接
1.题目内容
力扣数据中心有 n
台服务器,分别按从 0
到 n-1
的方式进行了编号。它们之间以 服务器到服务器 的形式相互连接组成了一个内部集群,连接是无向的。用 connections
表示集群网络,connections[i] = [a, b]
表示服务器 a
和 b
之间形成连接。任何服务器都可以直接或者间接地通过网络到达任何其他服务器。
关键连接 是在该集群中的重要连接,假如我们将它移除,便会导致某些服务器无法访问其他服务器。
请你以任意顺序返回该集群内的所有 关键连接 。
2.题解思路
👻方法1:Tarjan
算法
将题目转化为在一个无向图中查找所有的桥(即关键连接)。桥是指在图中删除这条边后,图会被分割成两个或多个连通分量,其核心思路分析录下:
- ① 图的表示:使用邻接表表示图
- ② Tarjan算法:使用Tarjan算法来查找图中的桥
- Tarjan算法通过深度优先搜索(DFS)遍历图
- 记录每个节点的访问顺序(
disc
)、通过回边能够到达的最小访问顺序(low
)、每个节点的父节点(parent
)
- 记录每个节点的访问顺序(
- Tarjan算法通过深度优先搜索(DFS)遍历图
- ③ 桥的判断:在DFS过程中,更新每个节点的
low
值,并判断是否存在桥- 如果发现某个节点的
low
值大于其父节点的disc
值,则说明这条边是桥
- 如果发现某个节点的
public class Solution1192_01 {
private int time = 0;
/**
*
* @param n 节点个数
* @param connections 连接情况(边[0,1]表示节点0与节点1之间存在边)
*/
public List<List<Integer>> criticalConnections(int n, List<List<Integer>> connections) {
// ① 构建邻接表(List<List<Integer>> graph || List<Integer>[] graph)
List<Integer>[] graph = new ArrayList[n];
for (int i = 0; i < n; i++) {
graph[i] = new ArrayList<>();
}
for (List<Integer> connection : connections) {
int u = connection.get(0);
int v = connection.get(1);
graph[u].add(v);
graph[v].add(u);
}
// 初始化核心数组
int[] disc = new int[n]; // 每个节点的访问顺序
int[] low = new int[n]; // 通过回边能够到达的最小访问顺序
int[] parent = new int[n]; // 每个节点的父节点
Arrays.fill(disc, -1);
Arrays.fill(parent, -1);
// 定义结果集
List<List<Integer>> result = new ArrayList<>();
// DFS遍历
for (int i = 0; i < n; i++) {
if (disc[i] == -1) {
dfs(i, disc, low, parent, graph, result);
}
}
return result;
}
private void dfs(int u, int[] disc, int[] low, int[] parent, List<Integer>[] graph, List<List<Integer>> result) {
disc[u] = low[u] = ++time;
for (int v : graph[u]) {
if (disc[v] == -1) { // 如果v未被访问过
parent[v] = u;
dfs(v, disc, low, parent, graph, result);
// 更新low[u]
low[u] = Math.min(low[u], low[v]);
// 判断是否是桥
if (low[v] > disc[u]) {
result.add(Arrays.asList(u, v));
}
} else if (v != parent[u]) { // 如果v已被访问过且不是u的父节点
low[u] = Math.min(low[u], disc[v]);
}
}
}
}
复杂度分析
时间复杂度:
空间复杂度: