diff --git a/problems/0037.解数独.md b/problems/0037.解数独.md index d96e59df..b5f54b1f 100644 --- a/problems/0037.解数独.md +++ b/problems/0037.解数独.md @@ -87,7 +87,7 @@ bool backtracking(vector>& board) ![37.解数独](https://code-thinking-1253855093.file.myqcloud.com/pics/2020111720451790-20230310131822254.png) -在树形图中可以看出我们需要的是一个二维的递归(也就是两个for循环嵌套着递归) +在树形图中可以看出我们需要的是一个二维的递归 (一行一列) **一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性!** diff --git a/problems/0045.跳跃游戏II.md b/problems/0045.跳跃游戏II.md index e006caa2..01a9320d 100644 --- a/problems/0045.跳跃游戏II.md +++ b/problems/0045.跳跃游戏II.md @@ -4,7 +4,7 @@

参与本项目,贡献其他语言版本的代码,拥抱开源,让更多学习算法的小伙伴们收益!

-> 相对于[贪心算法:跳跃游戏](https://mp.weixin.qq.com/s/606_N9j8ACKCODoCbV1lSA)难了不少,做好心里准备! +> 相对于[贪心算法:跳跃游戏](https://mp.weixin.qq.com/s/606_N9j8ACKCODoCbV1lSA)难了不少,做好心理准备! # 45.跳跃游戏 II diff --git a/problems/0743.网络延迟时间.md b/problems/0743.网络延迟时间.md new file mode 100644 index 00000000..d9440609 --- /dev/null +++ b/problems/0743.网络延迟时间.md @@ -0,0 +1,1230 @@ + +# 743.网络延迟时间 + +https://leetcode.cn/problems/network-delay-time/description/ + + +有 n 个网络节点,标记为 1 到 n。 + +给你一个列表 times,表示信号经过 有向 边的传递时间。 times[i] = (ui, vi, wi),其中 ui 是源节点,vi 是目标节点, wi 是一个信号从源节点传递到目标节点的时间。 + +现在,从某个节点 K 发出一个信号。需要多久才能使所有节点都收到信号?如果不能使所有节点收到信号,返回 -1 。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240229104105.png) + +提示: + +* 1 <= k <= n <= 100 +* 1 <= times.length <= 6000 +* times[i].length == 3 +* 1 <= ui, vi <= n +* ui != vi +* 0 <= wi <= 100 +* 所有 (ui, vi) 对都 互不相同(即,不含重复边) + +# dijkstra 精讲 + +本题就是求最短路,最短路是图论中的经典问题即:给出一个有向图,一个起点,一个终点,问起点到终点的最短路径。 + +接下来,我们来详细讲解最短路算法中的 dijkstra 算法。 + +dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法。 + +需要注意两点: + +* dijkstra 算法可以同时求 起点到所有节点的最短路径 +* 权值不能为负数 + +(这两点后面我们会讲到) + +如本题示例中的图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240125162647.png) + +起点(节点1)到终点(节点7) 的最短路径是 图中 标记绿线的部分。 + +最短路径的权值为12。 + +其实 dijkstra 算法 和 我们之前讲解的prim算法思路非常接近,如果大家认真学过[prim算法](https://mp.weixin.qq.com/s/yX936hHC6Z10K36Vm1Wl9w),那么理解 Dijkstra 算法会相对容易很多。(这也是我要先讲prim再讲dijkstra的原因) + +dijkstra 算法 同样是贪心的思路,不断寻找距离 源点最近的没有访问过的节点。 + +这里我也给出 **dijkstra三部曲**: + +1. 第一步,选源点到哪个节点近且该节点未被访问过 +2. 第二步,该最近节点被标记访问过 +3. 第三步,更新非访问节点到源点的距离(即更新minDist数组) + +大家此时已经会发现,这和prim算法 怎么这么像呢。 + +我在[prim算法](https://mp.weixin.qq.com/s/yX936hHC6Z10K36Vm1Wl9w)讲解中也给出了三部曲。 prim 和 dijkstra 确实很像,思路也是类似的,这一点我在后面还会详细来讲。 + +在dijkstra算法中,同样有一个数组很重要,起名为:minDist。 + +**minDist数组 用来记录 每一个节点距离源点的最小距离**。 + +理解这一点很重要,也是理解 dijkstra 算法的核心所在。 + +大家现在看着可能有点懵,不知道什么意思。 + +没关系,先让大家有一个印象,对理解后面讲解有帮助。 + +我们先来画图看一下 dijkstra 的工作过程,以本题示例为例: (以下为朴素版dijkstra的思路) + +(**示例中节点编号是从1开始,所以为了让大家看的不晕,minDist数组下标我也从 1 开始计数,下标0 就不使用了,这样 下标和节点标号就可以对应上了,避免大家搞混**) + +## 朴素版dijkstra + +### 模拟过程 + +----------- + +0、初始化 + +minDist数组数值初始化为int最大值。 + +这里在强点一下 **minDist数组的含义:记录所有节点到源点的最短路径**,那么初始化的时候就应该初始为最大值,这样才能在后续出现最短路径的时候及时更新。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130115306.png) + +(图中,max 表示默认值,节点0 不做处理,统一从下标1 开始计算,这样下标和节点数值统一, 方便大家理解,避免搞混) + +源点(节点1) 到自己的距离为0,所以 minDist[1] = 0 + +此时所有节点都没有被访问过,所以 visited数组都为0 + +--------------- + +以下为dijkstra 三部曲 + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离源点最近,距离为0,且未被访问。 + +2、该最近节点被标记访问过 + +标记源点访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130115421.png) + + +更新 minDist数组,即:源点(节点1) 到 节点2 和 节点3的距离。 + +* 源点到节点2的最短距离为1,小于原minDist[2]的数值max,更新minDist[2] = 1 +* 源点到节点3的最短距离为4,小于原minDist[3]的数值max,更新minDist[4] = 4 + +可能有录友问:为啥和 minDist[2] 比较? + +再强调一下 minDist[2] 的含义,它表示源点到节点2的最短距离,那么目前我们得到了 源点到节点2的最短距离为1,小于默认值max,所以更新。 minDist[3]的更新同理 + + +------------- + +1、选源点到哪个节点近且该节点未被访问过 + +未访问过的节点中,源点到节点2距离最近,选节点2 + +2、该最近节点被标记访问过 + +节点2被标记访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130121240.png) + +更新 minDist数组,即:源点(节点1) 到 节点6 、 节点3 和 节点4的距离。 + +**为什么更新这些节点呢? 怎么不更新其他节点呢**? + +因为 源点(节点1)通过 已经计算过的节点(节点2) 可以链接到的节点 有 节点3,节点4和节点6. + + +更新 minDist数组: + +* 源点到节点6的最短距离为5,小于原minDist[6]的数值max,更新minDist[6] = 5 +* 源点到节点3的最短距离为3,小于原minDist[3]的数值4,更新minDist[3] = 3 +* 源点到节点4的最短距离为6,小于原minDist[4]的数值max,更新minDist[4] = 6 + + + +------------------- + +1、选源点到哪个节点近且该节点未被访问过 + +未访问过的节点中,源点距离哪些节点最近,怎么算的? + +其实就是看 minDist数组里的数值,minDist 记录了 源点到所有节点的最近距离,结合visited数组筛选出未访问的节点就好。 + +从 上面的图,或者 从minDist数组中,我们都能看出 未访问过的节点中,源点(节点1)到节点3距离最近。 + + +2、该最近节点被标记访问过 + +节点3被标记访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130120434.png) + +由于节点3的加入,那么源点可以有新的路径链接到节点4 所以更新minDist数组: + +更新 minDist数组: + +* 源点到节点4的最短距离为5,小于原minDist[4]的数值6,更新minDist[4] = 5 + +------------------ + +1、选源点到哪个节点近且该节点未被访问过 + +距离源点最近且没有被访问过的节点,有节点4 和 节点6,距离源点距离都是 5 (minDist[4] = 5,minDist[6] = 5) ,选哪个节点都可以。 + +2、该最近节点被标记访问过 + +节点4被标记访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201105335.png) + +由于节点4的加入,那么源点可以链接到节点5 所以更新minDist数组: + +* 源点到节点5的最短距离为8,小于原minDist[5]的数值max,更新minDist[5] = 8 + +-------------- + +1、选源点到哪个节点近且该节点未被访问过 + +距离源点最近且没有被访问过的节点,是节点6,距离源点距离是 5 (minDist[6] = 5) + + +2、该最近节点被标记访问过 + +节点6 被标记访问过 + + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201110250.png) + +由于节点6的加入,那么源点可以链接到节点7 所以 更新minDist数组: + +* 源点到节点7的最短距离为14,小于原minDist[7]的数值max,更新minDist[7] = 14 + + + +------------------- + +1、选源点到哪个节点近且该节点未被访问过 + +距离源点最近且没有被访问过的节点,是节点5,距离源点距离是 8 (minDist[5] = 8) + +2、该最近节点被标记访问过 + +节点5 被标记访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201110651.png) + +由于节点5的加入,那么源点有新的路径可以链接到节点7 所以 更新minDist数组: + +* 源点到节点7的最短距离为12,小于原minDist[7]的数值14,更新minDist[7] = 12 + +----------------- + +1、选源点到哪个节点近且该节点未被访问过 + +距离源点最近且没有被访问过的节点,是节点7(终点),距离源点距离是 12 (minDist[7] = 12) + +2、该最近节点被标记访问过 + +节点7 被标记访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201110920.png) + +节点7加入,但节点7到节点7的距离为0,所以 不用更新minDist数组 + +-------------------- + +最后我们要求起点(节点1) 到终点 (节点7)的距离。 + +再来回顾一下minDist数组的含义:记录 每一个节点距离源点的最小距离。 + +那么起到(节点1)到终点(节点7)的最短距离就是 minDist[7] ,按上面举例讲解来说,minDist[7] = 12,节点1 到节点7的最短路径为 12。 + +路径如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201111352.png) + +在上面的讲解中,每一步 我都是按照 dijkstra 三部曲来讲解的,理解了这三部曲,代码也就好懂的。 + +### 代码实现 + +本题代码如下,里面的 三部曲 我都做了注释,大家按照我上面的讲解 来看如下代码: + +```CPP +class Solution { +public: + int networkDelayTime(vector>& times, int n, int k) { + + // 注意题目中给的二维数组并不是领接矩阵 + // 需要邻接矩阵来存图 + // 因为本题处理方式是节点标号从1开始,所以数组的大小都是 n+1 + vector> grid(n + 1, vector(n + 1, INT_MAX)); + for(int i = 0; i < times.size(); i++){ + int p1 = times[i][0]; + int p2 = times[i][1]; + grid[p1][p2] = times[i][2]; + } + + // 存储从源点到每个节点的最短距离 + std::vector minDist(n + 1, INT_MAX); + + // 记录顶点是否被访问过 + std::vector visited(n + 1, false); + + minDist[k] = 0; // 起始点到自身的距离为0 + for (int i = 1; i <= n; i++) { + + int minVal = INT_MAX; + int cur = 1; + + // 遍历每个节点,选择未被访问的节点集合中哪个节点到源点的距离最小 + for (int v = 1; v <= n; ++v) { + if (!visited[v] && minDist[v] <= minVal) { + minVal = minDist[v]; + cur = v; + } + } + + visited[cur] = true; // 标记该顶点已被访问 + + for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + } + } + + + } + // 源点到最远的节点的时间,也就是寻找 源点到所有节点最短路径的最大值 + int result = 0; + for (int i = 1; i <= n; i++) { + if (minDist[i] == INT_MAX) return -1;// 没有路径 + result = max(minDist[i], result); + } + return result; + + } +}; +``` + +* 时间复杂度:O(n^2) +* 空间复杂度:O(n^2) + +### debug方法 + +写这种题目难免会有各种各样的问题,我们如何发现自己的代码是否有问题呢? + +最好的方式就是打日志,本题的话,就是将 minDist 数组打印出来,就可以很明显发现 哪里出问题了。 + +每次选择节点后,minDist数组的变化是否符合预期 ,是否和我上面讲的逻辑是对应的。 + +例如本题,如果想debug的话,打印日志可以这样写: + + +```CPP +class Solution { +public: + int networkDelayTime(vector>& times, int n, int k) { + + // 注意题目中给的二维数组并不是领接矩阵 + // 需要邻接矩阵来存图 + // 因为本题处理方式是节点标号从1开始,所以数组的大小都是 n+1 + vector> grid(n + 1, vector(n + 1, INT_MAX)); + for(int i = 0; i < times.size(); i++){ + int p1 = times[i][0]; + int p2 = times[i][1]; + grid[p1][p2] = times[i][2]; + } + + // 存储从源点到每个节点的最短距离 + std::vector minDist(n + 1, INT_MAX); + + // 记录顶点是否被访问过 + std::vector visited(n + 1, false); + + minDist[k] = 0; // 起始点到自身的距离为0 + for (int i = 1; i <= n; i++) { + + int minVal = INT_MAX; + int cur = 1; + + // 遍历每个节点,选择未被访问的节点集合中哪个节点到源点的距离最小 + for (int v = 1; v <= n; ++v) { + if (!visited[v] && minDist[v] <= minVal) { + minVal = minDist[v]; + cur = v; + } + } + + visited[cur] = true; // 标记该顶点已被访问 + + for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + } + } + // 打印日志: + cout << "select:" << cur << endl; + for (int v = 1; v <= n; v++) cout << v << ":" << minDist[v] << " "; + cout << endl << endl;; + + + + } + // 源点到最远的节点的时间,也就是寻找 源点到所有节点最短路径的最大值 + int result = 0; + for (int i = 1; i <= n; i++) { + if (minDist[i] == INT_MAX) return -1;// 没有路径 + result = max(minDist[i], result); + } + return result; + + } +}; + + +``` + +打印后的结果: + +``` +select:2 +1:1 2:0 3:1 4:2147483647 + +select:3 +1:1 2:0 3:1 4:2 + +select:1 +1:1 2:0 3:1 4:2 + +select:4 +1:1 2:0 3:1 4:2 +``` + +打印日志可以和上面我讲解的过程进行对比,每一步的结果是完全对应的。 + +所以如果大家如果代码有问题,打日志来debug是最好的方法 + +### 出现负数 + +如果图中边的权值为负数,dijkstra 还合适吗? + +看一下这个图: (有负权值) + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227104334.png) + +节点1 到 节点5 的最短路径 应该是 节点1 -> 节点2 -> 节点3 -> 节点4 -> 节点5 + +那我们来看dijkstra 求解的路径是什么样的,继续dijkstra 三部曲来模拟 :(dijkstra模拟过程上面已经详细讲过,以下只模拟重要过程,例如如何初始化就省略讲解了) + +----------- + +初始化: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227104801.png) + +--------------- + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离源点最近,距离为0,且未被访问。 + +2、该最近节点被标记访问过 + +标记源点访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227110217.png) + +更新 minDist数组,即:源点(节点1) 到 节点2 和 节点3的距离。 + +* 源点到节点2的最短距离为100,小于原minDist[2]的数值max,更新minDist[2] = 100 +* 源点到节点3的最短距离为1,小于原minDist[3]的数值max,更新minDist[4] = 1 + +------------------- + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离节点3最近,距离为1,且未被访问。 + +2、该最近节点被标记访问过 + +标记节点3访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227110330.png) + +由于节点3的加入,那么源点可以有新的路径链接到节点4 所以更新minDist数组: + +* 源点到节点4的最短距离为2,小于原minDist[4]的数值max,更新minDist[4] = 2 + +-------------- + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离节点4最近,距离为2,且未被访问。 + +2、该最近节点被标记访问过 + +标记节点4访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227110346.png) + +由于节点4的加入,那么源点可以有新的路径链接到节点5 所以更新minDist数组: + +* 源点到节点5的最短距离为3,小于原minDist[5]的数值max,更新minDist[5] = 5 + +------------ + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离节点5最近,距离为3,且未被访问。 + +2、该最近节点被标记访问过 + +标记节点5访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227110405.png) + +节点5的加入,而节点5 没有链接其他节点, 所以不用更新minDist数组,仅标记节点5被访问过了 + +------------ + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离节点2最近,距离为100,且未被访问。 + +2、该最近节点被标记访问过 + +标记节点2访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227110711.png) + +-------------- + +至此dijkstra的模拟过程就结束了,根据最后的minDist数组,我们求 节点1 到 节点5 的最短路径的权值总和为 3,路径: 节点1 -> 节点3 -> 节点4 -> 节点5 + +通过以上的过程模拟,我们可以发现 之所以 没有走有负权值的最短路径 是因为 在 访问 节点 2 的时候,节点 3 已经访问过了,就不会再更新了。 + +那有录友可能会想: 我可以改代码逻辑啊,访问过的节点,也让它继续访问不就好了? + +那么访问过的节点还能继续访问会不会有死循环的出现呢?控制逻辑不让其死循环?那特殊情况自己能都想清楚吗?(可以试试,实践出真知) + +对于负权值的出现,大家可以针对某一个场景 不断去修改 dijkstra 的代码,**但最终会发现只是 拆了东墙补西墙**,对dijkstra的补充逻辑只能满足某特定场景最短路求解。 + +对于求解带有负权值的最短路问题,可以使用 Floyd 算法 ,我在后序会详细讲解。 + +## dijkstra与prim算法的区别 + +> 这里再次提示,需要先看我的 [prim算法精讲](https://mp.weixin.qq.com/s/yX936hHC6Z10K36Vm1Wl9w) ,否则可能不知道我下面讲的是什么。 + +大家可以发现 dijkstra的代码看上去 怎么和 prim算法这么像呢。 + +其实代码大体不差,唯一区别在 三部曲中的 第三步: 更新minDist数组 + +因为**prim是求 非访问节点到最小生成树的最小距离,而 dijkstra是求 非访问节点到源点的最小距离**。 + +prim 更新 minDist数组的写法: + + +```CPP +for (int j = 1; j <= v; j++) { + if (!isInTree[j] && grid[cur][j] < minDist[j]) { + minDist[j] = grid[cur][j]; + } +} +``` + +因为 minDist表示 节点到最小生成树的最小距离,所以 新节点cur的加入,只需要 使用 grid[cur][j] ,grid[cur][j] 就表示 cur 加入生成树后,生成树到 节点j 的距离。 + +dijkstra 更新 minDist数组的写法: + +```CPP +for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + } +} +``` + +因为 minDist表示 节点到源点的最小距离,所以 新节点 cur 的加入,需要使用 源点到cur的距离 (minDist[cur]) + cur 到 节点 v 的距离 (grid[cur][v]),才是 源点到节点v的距离。 + +此时大家可能不禁要想 prim算法 可以有负权值吗? + +当然可以! + +录友们可以自己思考思考一下,这是为什么? + +这里我提示一下:prim算法只需要将节点以最小权值和链接在一起,不涉及到单一路径。 + + + +## 总结 + +本篇,我们深入讲解的dijkstra算法,详细模拟其工作的流程。 + +这里我给出了 **dijkstra 三部曲 来 帮助大家理解 该算法**,不至于 每次写 dijkstra 都是黑盒操作,没有框架没有章法。 + +在给出的代码中,我也按照三部曲的逻辑来给大家注释,只要理解这三部曲,即使 过段时间 对 dijkstra 算法有些遗忘,依然可以写出一个框架出来,然后再去调试细节。 + +对于图论算法,一般代码都比较长,很难写出代码直接可以提交通过,都需要一个debug的过程,所以 **学习如何debug 非常重要**! + +这也是我为什么 在本文中 单独用来讲解 debug方法。 + +本题求的是最短路径和是多少,**同时我们也要掌握 如何把最短路径打印出来**。 + +我还写了大篇幅来讲解 负权值的情况, 只有画图带大家一步一步去 看 出现负权值 dijkstra的求解过程,才能帮助大家理解,问题出在哪里。 + +如果我直接讲:是**因为访问过的节点 不能再访问,导致错过真正的最短路**,我相信大家都不知道我在说啥。 + +最后我还讲解了 dijkstra 和 prim 算法的 相同 与 不同之处, 我在图论的讲解安排中 先讲 prim算法 再讲 dijkstra 是有目的的, **理解这两个算法的相同与不同之处 有助于大家学习的更深入**。 + +而不是 学了 dijkstra 就只看 dijkstra, 算法之间 都是有联系的,多去思考 算法之间的相互联系,会帮助大家思考的更深入,掌握的更彻底。 + +本篇写了这么长,我也只讲解了 朴素版dijkstra,**关于 堆优化dijkstra,我会在下一篇再来给大家详细讲解**。 + + + +## 堆优化版本dijkstra + +> 本篇我们来讲解 堆优化版dijkstra,看本篇之前,一定要先看 我讲解的 朴素版dijkstra,否则本篇会有部分内容看不懂。 + +在上一篇中,我们讲解了朴素版的dijkstra,该解法的时间复杂度为 O(n^2),可以看出时间复杂度 只和 n (节点数量)有关系。 + +如果n很大的话,我们可以换一个角度来优先性能。 + +在 讲解 最小生成树的时候,我们 讲了两个算法,[prim算法](https://mp.weixin.qq.com/s/yX936hHC6Z10K36Vm1Wl9w)(从点的角度来求最小生成树)、[Kruskal算法](https://mp.weixin.qq.com/s/rUVaBjCES_4eSjngceT5bw)(从边的角度来求最小生成树) + +这么在n 很大的时候,也有另一个思考维度,即:从边的数量出发。 + +当 n 很大,边 的数量 也很多的时候(稠密图),那么 上述解法没问题。 + +但 n 很大,边 的数量 很小的时候(稀疏图),是不是可以换成从边的角度来求最短路呢? + +毕竟边的数量少。 + +有的录友可能会想,n (节点数量)很大,边不就多吗? 怎么会边的数量少呢? + +别忘了,谁也没有规定 节点之间一定要有边连接着,例如有一万个节点,只有一条边,这也是一张图。 + +了解背景之后,再来看 解法思路。 + +### 图的存储 + +首先是 图的存储。 + +关于图的存储 主流有两种方式: 邻接矩阵和邻接表 + +#### 邻接矩阵 + +邻接矩阵 使用 二维数组来表示图结构。 邻接矩阵是从节点的角度来表示图,有多少节点就申请多大的二维数组。 + +例如: grid[2][5] = 6,表示 节点 2 链接 节点5 为有向图,节点2 指向 节点5,边的权值为6 (套在题意里,可能是距离为6 或者 消耗为6 等等) + +如果想表示无向图,即:grid[2][5] = 6,grid[5][2] = 6,表示节点2 与 节点5 相互连通,权值为6。 + + +如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240222110025.png) + +在一个 n (节点数)为8 的图中,就需要申请 8 * 8 这么大的空间,有一条双向边,即:grid[2][5] = 6,grid[5][2] = 6 + +这种表达方式(邻接矩阵) 在 边少,节点多的情况下,会导致申请过大的二维数组,造成空间浪费。 + +而且在寻找节点链接情况的时候,需要遍历整个矩阵,即 n * n 的时间复杂度,同样造成时间浪费。 + +邻接矩阵的优点: + +* 表达方式简单,易于理解 +* 检查任意两个顶点间是否存在边的操作非常快 +* 适合稠密图,在边数接近顶点数平方的图中,邻接矩阵是一种空间效率较高的表示方法。 + +缺点: + +* 遇到稀疏图,会导致申请过大的二维数组造成空间浪费 且遍历 边 的时候需要遍历整个n * n矩阵,造成时间浪费 + +#### 邻接表 + +邻接表 使用 数组 + 链表的方式来表示。 邻接表是从边的数量来表示图,有多少边 才会申请对应大小的链表。 + +邻接表的构造如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240223103713.png) + +这里表达的图是: + +* 节点1 指向 节点3 和 节点5 +* 节点2 指向 节点4、节点3、节点5 +* 节点3 指向 节点4,节点4指向节点1。 + +有多少边 邻接表才会申请多少个对应的链表节点。 + +从图中可以直观看出 使用 数组 + 链表 来表达 边的链接情况 。 + +邻接表的优点: + +* 对于稀疏图的存储,只需要存储边,空间利用率高 +* 遍历节点链接情况相对容易 + +缺点: + +* 检查任意两个节点间是否存在边,效率相对低,需要 O(V)时间,V表示某节点链接其他节点的数量。 +* 实现相对复杂,不易理解 + +#### 本题图的存储 + +接下来我们继续按照稀疏图的角度来分析本题。 + +在第一个版本的实现思路中,我们提到了三部曲: + +1. 第一步,选源点到哪个节点近且该节点未被访问过 +2. 第二步,该最近节点被标记访问过 +3. 第三步,更新非访问节点到源点的距离(即更新minDist数组) + +在第一个版本的代码中,这三部曲是套在一个 for 循环里,为什么? + +因为我们是从节点的角度来解决问题。 + +三部曲中第一步(选源点到哪个节点近且该节点未被访问过),这个操作本身需要for循环遍历 minDist 来寻找最近的节点。 + +同时我们需要 遍历所有 未访问过的节点,所以 我们从 节点角度出发,代码会有两层for循环,代码是这样的: (注意代码中的注释,标记两层for循环的用处) + +```CPP + +for (int i = 1; i <= n; i++) { // 遍历所有节点,第一层for循环 + + int minVal = INT_MAX; + int cur = 1; + + // 1、选距离源点最近且未访问过的节点 , 第二层for循环 + for (int v = 1; v <= n; ++v) { + if (!visited[v] && minDist[v] < minVal) { + minVal = minDist[v]; + cur = v; + } + } + + visited[cur] = true; // 2、标记该节点已被访问 + + // 3、第三步,更新非访问节点到源点的距离(即更新minDist数组) + for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + } + } + +} +``` + +那么当从 边 的角度出发, 在处理 三部曲里的第一步(选源点到哪个节点近且该节点未被访问过)的时候 ,我们可以不用去遍历所有节点了。 + +而且 直接把 边(带权值)加入到 小顶堆(利用堆来自动排序),那么每次我们从 堆顶里 取出 边 自然就是 距离源点最近的节点所在的边。 + +这样我们就不需要两层for循环来寻找最近的节点了。 + +了解了大体思路,我们再来看代码实现。 + +首先是 如何使用 邻接表来表述图结构,这是摆在很多录友面前的第一个难题。 + +邻接表用 数组+链表 来表示,代码如下:(C++中 vector 为数组,list 为链表, 定义了 n+1 这么大的数组空间) + +```CPP +vector> grid(n + 1); +``` + +不少录友,不知道 如何定义的数据结构,怎么表示邻接表的,我来给大家画一个图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240223103713.png) + +图中邻接表表示: + +* 节点1 指向 节点3 和 节点5 +* 节点2 指向 节点4、节点3、节点5 +* 节点3 指向 节点4 +* 节点4 指向 节点1 + +大家发现图中的边没有权值,而本题中 我们的边是有权值的,权值怎么表示?在哪里表示? + +所以 在`vector> grid(n + 1);` 中 就不能使用int了,而是需要一个键值对 来存两个数字,一个数表示节点,一个数表示 指向该节点的这条边的权值。 + +那么 代码可以改成这样: (pair 为键值对,可以存放两个int) + +```CPP +vector>> grid(n + 1); +``` + +举例来给大家展示 该代码表达的数据 如下: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240223103904.png) + +* 节点1 指向 节点3 权值为 1 +* 节点1 指向 节点5 权值为 2 +* 节点2 指向 节点4 权值为 7 +* 节点2 指向 节点3 权值为 6 +* 节点2 指向 节点5 权值为 3 +* 节点3 指向 节点4 权值为 3 +* 节点5 指向 节点1 权值为 10 + +这样 我们就把图中权值表示出来了。 + +但是在代码中 使用 `pair` 很容易让我们搞混了,第一个int 表示什么,第二个int表示什么,导致代码可读性很差,或者说别人看你的代码看不懂。 + +那么 可以 定一个类 来取代 `pair` + +类(或者说是结构体)定义如下: + +```CPP +struct Edge { + int to; // 邻接顶点 + int val; // 边的权重 + + Edge(int t, int w): to(t), val(w) {} // 构造函数 +}; +``` + +这个类里有两个成员变量,有对应的命名,这样不容易搞混 两个int的含义。 + +所以 本题中邻接表的定义如下: + +```CPP +struct Edge { + int to; // 链接的节点 + int val; // 边的权重 + + Edge(int t, int w): to(t), val(w) {} // 构造函数 +}; + +vector> grid(n + 1); // 邻接表 + +``` + +(我们在下面的讲解中会直接使用这个邻接表的代码表示方式) + +### 堆优化细节 + +其实思路依然是 dijkstra 三部曲: + +1. 第一步,选源点到哪个节点近且该节点未被访问过 +2. 第二步,该最近节点被标记访问过 +3. 第三步,更新非访问节点到源点的距离(即更新minDist数组) + +只不过之前是 通过遍历节点来遍历边,通过两层for循环来寻找距离源点最近节点。 这次我们直接遍历边,且通过堆来对边进行排序,达到直接选择距离源点最近节点。 + +先来看一下针对这三部曲,如果用 堆来优化。 + +那么三部曲中的第一步(选源点到哪个节点近且该节点未被访问过),我们如何选? + +我们要选择距离源点近的节点(即:该边的权值最小),所以 我们需要一个 小顶堆 来帮我们对边的权值排序,每次从小顶堆堆顶 取边就是权值最小的边。 + +C++定义小顶堆,可以用优先级队列实现,代码如下: + +```CPP +// 小顶堆 +class mycomparison { +public: + bool operator()(const pair& lhs, const pair& rhs) { + return lhs.second > rhs.second; + } +}; +// 优先队列中存放 pair<节点编号,源点到该节点的权值> +priority_queue, vector>, mycomparison> pq; +``` + +(`pair`中 第二个int 为什么要存 源点到该节点的权值,因为 这个小顶堆需要按照权值来排序) + + +有了小顶堆自动对边的权值排序,那我们只需要直接从 堆里取堆顶元素(小顶堆中,最小的权值在上面),就可以取到离源点最近的节点了 (未访问过的节点,不会加到堆里进行排序) + +所以三部曲中的第一步,我们不用 for循环去遍历,直接取堆顶元素: + +```CPP +// pair<节点编号,源点到该节点的权值> +pair cur = pq.top(); pq.pop(); + +``` + +第二步(该最近节点被标记访问过) 这个就是将 节点做访问标记,和 朴素dijkstra 一样 ,代码如下: + +```CPP +// 2. 第二步,该最近节点被标记访问过 +visited[cur.first] = true; + +``` + +(`cur.first` 是指取 `pair` 里的第一个int,即节点编号 ) + +第三步(更新非访问节点到源点的距离),这里的思路 也是 和朴素dijkstra一样的。 + +但很多录友对这里是最懵的,主要是因为两点: + +* 没有理解透彻 dijkstra 的思路 +* 没有理解 邻接表的表达方式 + +我们来回顾一下 朴素dijkstra 在这一步的代码和思路(如果没看过我讲解的朴素版dijkstra,这里会看不懂) + +```CPP + +// 3、第三步,更新非访问节点到源点的距离(即更新minDist数组) +for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + } +} +``` + +其中 for循环是用来做什么的? 是为了 找到 节点cur 链接指向了哪些节点,因为使用邻接矩阵的表达方式 所以把所有节点遍历一遍。 + +而在邻接表中,我们可以以相对高效的方式知道一个节点链接指向哪些节点。 + +再回顾一下邻接表的构造(数组 + 链表): + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240223103713.png) + +假如 加入的cur 是节点 2, 那么 grid[2] 表示的就是图中第二行链表。 (grid数组的构造我们在 上面 「图的存储」中讲过) + +所以在邻接表中,我们要获取 节点cur 链接指向哪些节点,就是遍历 grid[cur节点编号] 这个链表。 + +这个遍历方式,C++代码如下: + +```CPP +for (Edge edge : grid[cur.first]) +``` + +(如果不知道 Edge 是什么,看上面「图的存储」中邻接表的讲解) + +`cur.first` 就是cur节点编号, 参考上面pair的定义: pair<节点编号,源点到该节点的权值> + +接下来就是更新 非访问节点到源点的距离,代码实现和 朴素dijkstra 是一样的,代码如下: + +```CPP +// 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组) +for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge + // cur指向的节点edge.to,这条边的权值为 edge.val + if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist + minDist[edge.to] = minDist[cur.first] + edge.val; + pq.push(pair(edge.to, minDist[edge.to])); + } +} +``` + +但为什么思路一样,有的录友能写出朴素dijkstra,但堆优化这里的逻辑就是写不出来呢? + +**主要就是因为对邻接表的表达方式不熟悉**! + +以上代码中,cur 链接指向的节点编号 为 edge.to, 这条边的权值为 edge.val ,如果对这里模糊的就再回顾一下 Edge的定义: + +```CPP +struct Edge { + int to; // 邻接顶点 + int val; // 边的权重 + + Edge(int t, int w): to(t), val(w) {} // 构造函数 +}; +``` + +确定该节点没有被访问过,`!visited[edge.to]` , 目前 源点到cur.first的最短距离(minDist) + cur.first 到 edge.to 的距离 (edge.val) 是否 小于 minDist已经记录的 源点到 edge.to 的距离 (minDist[edge.to]) + +如果是的话,就开始更新操作。 + +即: + +```CPP +if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist + minDist[edge.to] = minDist[cur.first] + edge.val; + pq.push(pair(edge.to, minDist[edge.to])); // 由于cur节点的加入,而新链接的边,加入到优先级队里中 +} + +``` + +同时,由于cur节点的加入,源点又有可以新链接到的边,将这些边加入到优先级队里中。 + + +以上代码思路 和 朴素版dijkstra 是一样一样的,主要区别是两点: + +* 邻接表的表示方式不同 +* 使用优先级队列(小顶堆)来对新链接的边排序 + +### 代码实现 + +堆优化dijkstra完整代码如下: + +```CPP +class Solution { +public: + // 小顶堆 + class mycomparison { + public: + bool operator()(const pair& lhs, const pair& rhs) { + return lhs.second > rhs.second; + } + }; + // 定义一个结构体来表示带权重的边 + struct Edge { + int to; // 邻接顶点 + int val; // 边的权重 + + Edge(int t, int w): to(t), val(w) {} // 构造函数 + }; + int networkDelayTime(vector>& times, int n, int k) { + + + std::vector> grid(n + 1); + for(int i = 0; i < times.size(); i++){ + int p1 = times[i][0]; + int p2 = times[i][1]; + // p1 指向 p2,权值为 times[i][2] + grid[p1].push_back(Edge(p2, times[i][2])); + } + + // 存储从源点到每个节点的最短距离 + std::vector minDist(n + 1, INT_MAX); + + // 记录顶点是否被访问过 + std::vector visited(n + 1, false); + + // 优先队列中存放 pair<节点,源点到该节点的距离> + priority_queue, vector>, mycomparison> pq; + + pq.push(pair(k, 0)); + minDist[k] = 0; // 这个不要忘了 + + while (!pq.empty()) { + // <节点, 源点到该节点的距离> + // 1. 第一步,选源点到哪个节点近且该节点未被访问过 (通过优先级队列来实现) + pair cur = pq.top(); pq.pop(); + + if (visited[cur.first]) continue; + + // 2. 第二步,该最近节点被标记访问过 + visited[cur.first] = true; + + + // 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组) + for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge + // cur指向的节点edge.to,这条边的权值为 edge.val + if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist + minDist[edge.to] = minDist[cur.first] + edge.val; + pq.push(pair(edge.to, minDist[edge.to])); + } + } + + } + + // 源点到最远的节点的时间,也就是寻找 源点到所有节点最短路径的最大值 + int result = 0; + for (int i = 1; i <= n; i++) { + if (minDist[i] == INT_MAX) return -1;// 没有路径 + result = max(minDist[i], result); + } + return result; + + } +}; + +``` + +* 时间复杂度:O(ElogE) E 为边的数量 +* 空间复杂度:O(N + E) N 为节点的数量 + +堆优化的时间复杂度 只和边的数量有关 和节点数无关,在 优先级队列中 放的也是边。 + +以上代码中,`while (!pq.empty())` 里套了 `for (Edge edge : grid[cur.first])` + +`for` 里 遍历的是 当前节点 cur 所连接边。 + +那 当前节点cur 所连接的边 也是不固定的, 这就让大家分不清,这时间复杂度究竟是多少? + +其实 `for (Edge edge : grid[cur.first])` 里最终的数据走向 是 给队列里添加边。 + +那么跳出局部代码,整个队列 一定是 所有边添加了一次,同时也弹出了一次。 + +所以边添加一次时间复杂度是 O(E), `while (!pq.empty())` 里每次都要弹出一个边来进行操作,在优先级队列(小顶堆)中 弹出一个元素的时间复杂度是 O(logE) ,这是堆排序的时间复杂度。 + +(当然小顶堆里 是 添加元素的时候 排序,还是 取数元素的时候排序,这个无所谓,时间复杂度都是O(E),总是是一定要排序的,而小顶堆里也不会滞留元素,有多少元素添加 一定就有多少元素弹出) + +所以 该算法整体时间复杂度为 O(ElogE) + +网上的不少分析 会把 n (节点的数量)算进来,这个分析是有问题的,举一个极端例子,在n 为 10000,且是有一条边的 图里,以上代码,大家感觉执行了多少次? + +`while (!pq.empty())` 中的 pq 存的是边,其实只执行了一次。 + +所以该算法时间复杂度 和 节点没有关系。 + +至于空间复杂度,邻接表是 数组 + 链表 数组的空间 是 N ,有E条边 就申请对应多少个链表节点,所以是 复杂度是 N + E + +## 拓展 + +当然也有录友可能想 堆优化dijkstra 中 我为什么一定要用邻接表呢,我就用邻接矩阵 行不行 ? + +也行的。 + +但 正是因为稀疏图,所以我们使用堆优化的思路, 如果我们还用 邻接矩阵 去表达这个图的话,就是 一个高效的算法 使用了低效的数据结构,那么 整体算法效率 依然是低的。 + +如果还不清楚为什么要使用 邻接表,可以再看看上面 我在 「图的存储」标题下的讲解。 + +这里我也给出 邻接矩阵版本的堆优化dijkstra代码: + +```CPP +class Solution { +public: + // 小顶堆(按照中的v 来从小到大排序) + class mycomparison { + public: + bool operator()(const pair& lhs, const pair& rhs) { + return lhs.second > rhs.second; + } + }; + int networkDelayTime(vector>& times, int n, int k) { + + // 注意题目中给的二维数组并不是邻接矩阵 + // 需要邻接矩阵来存图 + // 因为本题处理方式是节点标号从1开始,所以数组的大小都是 n+1 + vector> grid(n + 1, vector(n + 1, INT_MAX)); + for(int i = 0; i < times.size(); i++){ + int p1 = times[i][0]; + int p2 = times[i][1]; + grid[p1][p2] = times[i][2]; + } + + // 存储从源点到每个节点的最短距离 + std::vector minDist(n + 1, INT_MAX); + + // 记录顶点是否被访问过 + std::vector visited(n + 1, false); + + // 优先队列中存放 [节点,源点到该节点的距离] + priority_queue, vector>, mycomparison> pq; + + pq.push(pair(k, 0)); + minDist[k] = 0; // 这个不要忘了 + + while (!pq.empty()) { + // <节点, 源点到该节点的距离> + // 1、选距离源点最近且未访问过的节点 + pair cur = pq.top(); pq.pop(); + + if (visited[cur.first]) continue; + + // 2、标记该节点已被访问 + visited[cur.first] = true; + + // 3、第三步,更新非访问节点到源点的距离(即更新minDist数组) + // 遍历 cur 可以链接的节点,更新 minDist[j] + for (int j = 1; j <= n; j++) { + if (!visited[j] && grid[cur.first][j] != INT_MAX && (minDist[cur.first] + grid[cur.first][j] < minDist[j])) { + minDist[j] = minDist[cur.first] + grid[cur.first][j]; + pq.push(pair(j, minDist[j])); + } + } + } + + // 源点到最远的节点的时间,也就是寻找 源点到所有节点最短路径的最大值 + int result = 0; + for (int i = 1; i <= n; i++) { + if (minDist[i] == INT_MAX) return -1;// 没有路径 + result = max(minDist[i], result); + } + + + return result; + + } +}; + +``` + +* 时间复杂度:O(E * (N + logE)) E为边的数量,N为节点数量 +* 空间复杂度:O(log(N^2)) + +`while (!pq.empty())` 时间复杂度为 E ,while 里面 每次取元素 时间复杂度 为 logE,和 一个for循环 时间复杂度 为 N 。 + +所以整体是 E * (N + logE) + + +## 总结 + +在学习一种优化思路的时候,首先就要知道为什么要优化,遇到了什么问题。 + +正如我在开篇就给大家交代清楚 堆优化方式的背景。 + +堆优化的整体思路和 朴素版是大体一样的,区别是 堆优化从边的角度触发,且利用堆来排序。 + +很多录友别说写堆优化 就是看 堆优化的代码也看的很懵。 + +主要是因为两点: + +* 不熟悉邻接表的表达方式 +* 对dijkstra的实现思路还是不熟 + +这是我为什么 本篇花了大力气来讲解 图的存储,就是为了让大家彻底理解邻接表以及邻接表的代码写法。 + +至于 dijkstra的实现思路 ,朴素版 和 堆优化版本 都是 按照 dijkstra 三部曲来的。 + +理解了三部曲,dijkstra 的思路就是清晰的。 + +针对邻接表版本代码 我做了详细的 时间复杂度分析,也让录友们清楚,相对于 朴素版,时间都优化到哪了。 + +最后 我也给出了 邻接矩阵的版本代码,分析了这一版本的必要性以及时间复杂度。 + +至此通过 两篇dijkstra的文章,终于把 dijkstra 讲完了,如果大家对我讲解里所涉及的内容都吃透的话,详细对 dijkstra 算法也就理解到位了。 + + +# Bellman_ford + +```CPP +class Solution { +public: + + int networkDelayTime(vector>& times, int n, int k) { + vector minDist(n + 1 , INT_MAX/2); + minDist[k] = 0; + vector minDist_copy(n); // 用来记录每一次遍历的结果 + for (int i = 1; i <= n + 1; i++) { + minDist_copy = minDist; // 获取上一次计算的结果 + for (auto &f : times) { + int from = f[0]; + int to = f[1]; + int price = f[2]; + if (minDist[to] > minDist_copy[from] + price) minDist[to] = minDist_copy[from] + price; + } + + } + int result = 0; + for (int i = 1;i <= n; i++) { + if (minDist[i] == INT_MAX/2) return -1;// 没有路径 + result = max(minDist[i], result); + } + + return result; + + } +}; + +``` + diff --git a/problems/0787.K站中转内最便宜的航班.md b/problems/0787.K站中转内最便宜的航班.md new file mode 100644 index 00000000..c7b6236b --- /dev/null +++ b/problems/0787.K站中转内最便宜的航班.md @@ -0,0 +1,44 @@ + + +```CPP +class Solution { +public: + int findCheapestPrice(int n, vector>& flights, int src, int dst, int k) { + vector minDist(n , INT_MAX/2); + minDist[src] = 0; + vector minDist_copy(n); // 用来记录每一次遍历的结果 + for (int i = 1; i <= k + 1; i++) { + minDist_copy = minDist; // 获取上一次计算的结果 + for (auto &f : flights) { + int from = f[0]; + int to = f[1]; + int price = f[2]; + if (minDist[to] > minDist_copy[from] + price) minDist[to] = minDist_copy[from] + price; + } + + } + int result = minDist[dst] == INT_MAX/2 ? -1 : minDist[dst]; + return result; + } +}; +``` + +```CPP +class Solution { +public: + int findCheapestPrice(int n, vector>& flights, int src, int dst, int k) { + vector minDist(n , INT_MAX/2); + minDist[src] = 0; + for (int i = 1; i <= k + 1; i++) { + for (auto &f : flights) { + int from = f[0]; + int to = f[1]; + int price = f[2]; + if (minDist[to] > minDist[from] + price) minDist[to] = minDist[from] + price; + } + } + int result = minDist[dst] == INT_MAX/2 ? -1 : minDist[dst]; + return result; + } +}; +``` diff --git a/problems/kama47.参会dijkstra堆.md b/problems/kama47.参会dijkstra堆.md new file mode 100644 index 00000000..0cb59301 --- /dev/null +++ b/problems/kama47.参会dijkstra堆.md @@ -0,0 +1,651 @@ + +# dijkstra(堆优化版)精讲 + +[题目链接](https://kamacoder.com/problempage.php?pid=1047) + +【题目描述】 + +小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。 + +小明的起点是第一个车站,终点是最后一个车站。然而,途中的各个车站之间的道路状况、交通拥堵程度以及可能的自然因素(如天气变化)等不同,这些因素都会影响每条路径的通行时间。 + +小明希望能选择一条花费时间最少的路线,以确保他能够尽快到达目的地。 + +【输入描述】 + +第一行包含两个正整数,第一个正整数 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。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227101345.png) + +不能到达的情况: + +如下图所示,当从起始车站不能到达终点车站时,则输出 -1。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227101401.png) + +数据范围: + +1 <= N <= 500; +1 <= M <= 5000; + + +## 思路 + +> 本篇我们来讲解 堆优化版dijkstra,看本篇之前,一定要先看 我讲解的 朴素版dijkstra,否则本篇会有部分内容看不懂。 + +在上一篇中,我们讲解了朴素版的dijkstra,该解法的时间复杂度为 O(n^2),可以看出时间复杂度 只和 n (节点数量)有关系。 + +如果n很大的话,我们可以换一个角度来优先性能。 + +在 讲解 最小生成树的时候,我们 讲了两个算法,[prim算法](https://mp.weixin.qq.com/s/yX936hHC6Z10K36Vm1Wl9w)(从点的角度来求最小生成树)、[Kruskal算法](https://mp.weixin.qq.com/s/rUVaBjCES_4eSjngceT5bw)(从边的角度来求最小生成树) + +这么在n 很大的时候,也有另一个思考维度,即:从边的数量出发。 + +当 n 很大,边 的数量 也很多的时候(稠密图),那么 上述解法没问题。 + +但 n 很大,边 的数量 很小的时候(稀疏图),是不是可以换成从边的角度来求最短路呢? + +毕竟边的数量少。 + +有的录友可能会想,n (节点数量)很大,边不就多吗? 怎么会边的数量少呢? + +别忘了,谁也没有规定 节点之间一定要有边连接着,例如有一万个节点,只有一条边,这也是一张图。 + +了解背景之后,再来看 解法思路。 + +### 图的存储 + +首先是 图的存储。 + +关于图的存储 主流有两种方式: 邻接矩阵和邻接表 + +#### 邻接矩阵 + +邻接矩阵 使用 二维数组来表示图结构。 邻接矩阵是从节点的角度来表示图,有多少节点就申请多大的二维数组。 + +例如: grid[2][5] = 6,表示 节点 2 链接 节点5 为有向图,节点2 指向 节点5,边的权值为6 (套在题意里,可能是距离为6 或者 消耗为6 等等) + +如果想表示无向图,即:grid[2][5] = 6,grid[5][2] = 6,表示节点2 与 节点5 相互连通,权值为6。 + + +如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240222110025.png) + +在一个 n (节点数)为8 的图中,就需要申请 8 * 8 这么大的空间,有一条双向边,即:grid[2][5] = 6,grid[5][2] = 6 + +这种表达方式(邻接矩阵) 在 边少,节点多的情况下,会导致申请过大的二维数组,造成空间浪费。 + +而且在寻找节点链接情况的时候,需要遍历整个矩阵,即 n * n 的时间复杂度,同样造成时间浪费。 + +邻接矩阵的优点: + +* 表达方式简单,易于理解 +* 检查任意两个顶点间是否存在边的操作非常快 +* 适合稠密图,在边数接近顶点数平方的图中,邻接矩阵是一种空间效率较高的表示方法。 + +缺点: + +* 遇到稀疏图,会导致申请过大的二维数组造成空间浪费 且遍历 边 的时候需要遍历整个n * n矩阵,造成时间浪费 + +#### 邻接表 + +邻接表 使用 数组 + 链表的方式来表示。 邻接表是从边的数量来表示图,有多少边 才会申请对应大小的链表。 + +邻接表的构造如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240223103713.png) + +这里表达的图是: + +* 节点1 指向 节点3 和 节点5 +* 节点2 指向 节点4、节点3、节点5 +* 节点3 指向 节点4,节点4指向节点1。 + +有多少边 邻接表才会申请多少个对应的链表节点。 + +从图中可以直观看出 使用 数组 + 链表 来表达 边的链接情况 。 + +邻接表的优点: + +* 对于稀疏图的存储,只需要存储边,空间利用率高 +* 遍历节点链接情况相对容易 + +缺点: + +* 检查任意两个节点间是否存在边,效率相对低,需要 O(V)时间,V表示某节点链接其他节点的数量。 +* 实现相对复杂,不易理解 + +#### 本题图的存储 + +接下来我们继续按照稀疏图的角度来分析本题。 + +在第一个版本的实现思路中,我们提到了三部曲: + +1. 第一步,选源点到哪个节点近且该节点未被访问过 +2. 第二步,该最近节点被标记访问过 +3. 第三步,更新非访问节点到源点的距离(即更新minDist数组) + +在第一个版本的代码中,这三部曲是套在一个 for 循环里,为什么? + +因为我们是从节点的角度来解决问题。 + +三部曲中第一步(选源点到哪个节点近且该节点未被访问过),这个操作本身需要for循环遍历 minDist 来寻找最近的节点。 + +同时我们需要 遍历所有 未访问过的节点,所以 我们从 节点角度出发,代码会有两层for循环,代码是这样的: (注意代码中的注释,标记两层for循环的用处) + +```CPP + +for (int i = 1; i <= n; i++) { // 遍历所有节点,第一层for循环 + + int minVal = INT_MAX; + int cur = 1; + + // 1、选距离源点最近且未访问过的节点 , 第二层for循环 + for (int v = 1; v <= n; ++v) { + if (!visited[v] && minDist[v] < minVal) { + minVal = minDist[v]; + cur = v; + } + } + + visited[cur] = true; // 2、标记该节点已被访问 + + // 3、第三步,更新非访问节点到源点的距离(即更新minDist数组) + for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + } + } + +} +``` + +那么当从 边 的角度出发, 在处理 三部曲里的第一步(选源点到哪个节点近且该节点未被访问过)的时候 ,我们可以不用去遍历所有节点了。 + +而且 直接把 边(带权值)加入到 小顶堆(利用堆来自动排序),那么每次我们从 堆顶里 取出 边 自然就是 距离源点最近的节点所在的边。 + +这样我们就不需要两层for循环来寻找最近的节点了。 + +了解了大体思路,我们再来看代码实现。 + +首先是 如何使用 邻接表来表述图结构,这是摆在很多录友面前的第一个难题。 + +邻接表用 数组+链表 来表示,代码如下:(C++中 vector 为数组,list 为链表, 定义了 n+1 这么大的数组空间) + +```CPP +vector> grid(n + 1); +``` + +不少录友,不知道 如何定义的数据结构,怎么表示邻接表的,我来给大家画一个图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240223103713.png) + +图中邻接表表示: + +* 节点1 指向 节点3 和 节点5 +* 节点2 指向 节点4、节点3、节点5 +* 节点3 指向 节点4 +* 节点4 指向 节点1 + +大家发现图中的边没有权值,而本题中 我们的边是有权值的,权值怎么表示?在哪里表示? + +所以 在`vector> grid(n + 1);` 中 就不能使用int了,而是需要一个键值对 来存两个数字,一个数表示节点,一个数表示 指向该节点的这条边的权值。 + +那么 代码可以改成这样: (pair 为键值对,可以存放两个int) + +```CPP +vector>> grid(n + 1); +``` + +举例来给大家展示 该代码表达的数据 如下: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240223103904.png) + +* 节点1 指向 节点3 权值为 1 +* 节点1 指向 节点5 权值为 2 +* 节点2 指向 节点4 权值为 7 +* 节点2 指向 节点3 权值为 6 +* 节点2 指向 节点5 权值为 3 +* 节点3 指向 节点4 权值为 3 +* 节点5 指向 节点1 权值为 10 + +这样 我们就把图中权值表示出来了。 + +但是在代码中 使用 `pair` 很容易让我们搞混了,第一个int 表示什么,第二个int表示什么,导致代码可读性很差,或者说别人看你的代码看不懂。 + +那么 可以 定一个类 来取代 `pair` + +类(或者说是结构体)定义如下: + +```CPP +struct Edge { + int to; // 邻接顶点 + int val; // 边的权重 + + Edge(int t, int w): to(t), val(w) {} // 构造函数 +}; +``` + +这个类里有两个成员变量,有对应的命名,这样不容易搞混 两个int的含义。 + +所以 本题中邻接表的定义如下: + +```CPP +struct Edge { + int to; // 链接的节点 + int val; // 边的权重 + + Edge(int t, int w): to(t), val(w) {} // 构造函数 +}; + +vector> grid(n + 1); // 邻接表 + +``` + +(我们在下面的讲解中会直接使用这个邻接表的代码表示方式) + +### 堆优化细节 + +其实思路依然是 dijkstra 三部曲: + +1. 第一步,选源点到哪个节点近且该节点未被访问过 +2. 第二步,该最近节点被标记访问过 +3. 第三步,更新非访问节点到源点的距离(即更新minDist数组) + +只不过之前是 通过遍历节点来遍历边,通过两层for循环来寻找距离源点最近节点。 这次我们直接遍历边,且通过堆来对边进行排序,达到直接选择距离源点最近节点。 + +先来看一下针对这三部曲,如果用 堆来优化。 + +那么三部曲中的第一步(选源点到哪个节点近且该节点未被访问过),我们如何选? + +我们要选择距离源点近的节点(即:该边的权值最小),所以 我们需要一个 小顶堆 来帮我们对边的权值排序,每次从小顶堆堆顶 取边就是权值最小的边。 + +C++定义小顶堆,可以用优先级队列实现,代码如下: + +```CPP +// 小顶堆 +class mycomparison { +public: + bool operator()(const pair& lhs, const pair& rhs) { + return lhs.second > rhs.second; + } +}; +// 优先队列中存放 pair<节点编号,源点到该节点的权值> +priority_queue, vector>, mycomparison> pq; +``` + +(`pair`中 第二个int 为什么要存 源点到该节点的权值,因为 这个小顶堆需要按照权值来排序) + + +有了小顶堆自动对边的权值排序,那我们只需要直接从 堆里取堆顶元素(小顶堆中,最小的权值在上面),就可以取到离源点最近的节点了 (未访问过的节点,不会加到堆里进行排序) + +所以三部曲中的第一步,我们不用 for循环去遍历,直接取堆顶元素: + +```CPP +// pair<节点编号,源点到该节点的权值> +pair cur = pq.top(); pq.pop(); + +``` + +第二步(该最近节点被标记访问过) 这个就是将 节点做访问标记,和 朴素dijkstra 一样 ,代码如下: + +```CPP +// 2. 第二步,该最近节点被标记访问过 +visited[cur.first] = true; + +``` + +(`cur.first` 是指取 `pair` 里的第一个int,即节点编号 ) + +第三步(更新非访问节点到源点的距离),这里的思路 也是 和朴素dijkstra一样的。 + +但很多录友对这里是最懵的,主要是因为两点: + +* 没有理解透彻 dijkstra 的思路 +* 没有理解 邻接表的表达方式 + +我们来回顾一下 朴素dijkstra 在这一步的代码和思路(如果没看过我讲解的朴素版dijkstra,这里会看不懂) + +```CPP + +// 3、第三步,更新非访问节点到源点的距离(即更新minDist数组) +for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + } +} +``` + +其中 for循环是用来做什么的? 是为了 找到 节点cur 链接指向了哪些节点,因为使用邻接矩阵的表达方式 所以把所有节点遍历一遍。 + +而在邻接表中,我们可以以相对高效的方式知道一个节点链接指向哪些节点。 + +再回顾一下邻接表的构造(数组 + 链表): + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240223103713.png) + +假如 加入的cur 是节点 2, 那么 grid[2] 表示的就是图中第二行链表。 (grid数组的构造我们在 上面 「图的存储」中讲过) + +所以在邻接表中,我们要获取 节点cur 链接指向哪些节点,就是遍历 grid[cur节点编号] 这个链表。 + +这个遍历方式,C++代码如下: + +```CPP +for (Edge edge : grid[cur.first]) +``` + +(如果不知道 Edge 是什么,看上面「图的存储」中邻接表的讲解) + +`cur.first` 就是cur节点编号, 参考上面pair的定义: pair<节点编号,源点到该节点的权值> + +接下来就是更新 非访问节点到源点的距离,代码实现和 朴素dijkstra 是一样的,代码如下: + +```CPP +// 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组) +for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge + // cur指向的节点edge.to,这条边的权值为 edge.val + if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist + minDist[edge.to] = minDist[cur.first] + edge.val; + pq.push(pair(edge.to, minDist[edge.to])); + } +} +``` + +但为什么思路一样,有的录友能写出朴素dijkstra,但堆优化这里的逻辑就是写不出来呢? + +**主要就是因为对邻接表的表达方式不熟悉**! + +以上代码中,cur 链接指向的节点编号 为 edge.to, 这条边的权值为 edge.val ,如果对这里模糊的就再回顾一下 Edge的定义: + +```CPP +struct Edge { + int to; // 邻接顶点 + int val; // 边的权重 + + Edge(int t, int w): to(t), val(w) {} // 构造函数 +}; +``` + +确定该节点没有被访问过,`!visited[edge.to]` , 目前 源点到cur.first的最短距离(minDist) + cur.first 到 edge.to 的距离 (edge.val) 是否 小于 minDist已经记录的 源点到 edge.to 的距离 (minDist[edge.to]) + +如果是的话,就开始更新操作。 + +即: + +```CPP +if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist + minDist[edge.to] = minDist[cur.first] + edge.val; + pq.push(pair(edge.to, minDist[edge.to])); // 由于cur节点的加入,而新链接的边,加入到优先级队里中 +} + +``` + +同时,由于cur节点的加入,源点又有可以新链接到的边,将这些边加入到优先级队里中。 + + +以上代码思路 和 朴素版dijkstra 是一样一样的,主要区别是两点: + +* 邻接表的表示方式不同 +* 使用优先级队列(小顶堆)来对新链接的边排序 + +### 代码实现 + +堆优化dijkstra完整代码如下: + +```CPP +#include +#include +#include +#include +#include +using namespace std; +// 小顶堆 +class mycomparison { +public: + bool operator()(const pair& lhs, const pair& rhs) { + return lhs.second > rhs.second; + } +}; +// 定义一个结构体来表示带权重的边 +struct Edge { + int to; // 邻接顶点 + int val; // 边的权重 + + Edge(int t, int w): to(t), val(w) {} // 构造函数 +}; + +int main() { + int n, m, p1, p2, val; + cin >> n >> m; + + vector> grid(n + 1); + + for(int i = 0; i < m; i++){ + cin >> p1 >> p2 >> val; + // p1 指向 p2,权值为 val + grid[p1].push_back(Edge(p2, val)); + + } + + int start = 1; // 起点 + int end = n; // 终点 + + // 存储从源点到每个节点的最短距离 + std::vector minDist(n + 1, INT_MAX); + + // 记录顶点是否被访问过 + std::vector visited(n + 1, false); + + // 优先队列中存放 pair<节点,源点到该节点的权值> + priority_queue, vector>, mycomparison> pq; + + + // 初始化队列,源点到源点的距离为0,所以初始为0 + pq.push(pair(start, 0)); + + minDist[start] = 0; // 起始点到自身的距离为0 + + while (!pq.empty()) { + // 1. 第一步,选源点到哪个节点近且该节点未被访问过 (通过优先级队列来实现) + // <节点, 源点到该节点的距离> + pair cur = pq.top(); pq.pop(); + + if (visited[cur.first]) continue; + + // 2. 第二步,该最近节点被标记访问过 + visited[cur.first] = true; + + // 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组) + for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge + // cur指向的节点edge.to,这条边的权值为 edge.val + if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist + minDist[edge.to] = minDist[cur.first] + edge.val; + pq.push(pair(edge.to, minDist[edge.to])); + } + } + + } + + if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到达终点 + else cout << minDist[end] << endl; // 到达终点最短路径 +} + +``` + +* 时间复杂度:O(ElogE) E 为边的数量 +* 空间复杂度:O(N + E) N 为节点的数量 + +堆优化的时间复杂度 只和边的数量有关 和节点数无关,在 优先级队列中 放的也是边。 + +以上代码中,`while (!pq.empty())` 里套了 `for (Edge edge : grid[cur.first])` + +`for` 里 遍历的是 当前节点 cur 所连接边。 + +那 当前节点cur 所连接的边 也是不固定的, 这就让大家分不清,这时间复杂度究竟是多少? + +其实 `for (Edge edge : grid[cur.first])` 里最终的数据走向 是 给队列里添加边。 + +那么跳出局部代码,整个队列 一定是 所有边添加了一次,同时也弹出了一次。 + +所以边添加一次时间复杂度是 O(E), `while (!pq.empty())` 里每次都要弹出一个边来进行操作,在优先级队列(小顶堆)中 弹出一个元素的时间复杂度是 O(logE) ,这是堆排序的时间复杂度。 + +(当然小顶堆里 是 添加元素的时候 排序,还是 取数元素的时候排序,这个无所谓,时间复杂度都是O(E),总是是一定要排序的,而小顶堆里也不会滞留元素,有多少元素添加 一定就有多少元素弹出) + +所以 该算法整体时间复杂度为 O(ElogE) + +网上的不少分析 会把 n (节点的数量)算进来,这个分析是有问题的,举一个极端例子,在n 为 10000,且是有一条边的 图里,以上代码,大家感觉执行了多少次? + +`while (!pq.empty())` 中的 pq 存的是边,其实只执行了一次。 + +所以该算法时间复杂度 和 节点没有关系。 + +至于空间复杂度,邻接表是 数组 + 链表 数组的空间 是 N ,有E条边 就申请对应多少个链表节点,所以是 复杂度是 N + E + +## 拓展 + +当然也有录友可能想 堆优化dijkstra 中 我为什么一定要用邻接表呢,我就用邻接矩阵 行不行 ? + +也行的。 + +但 正是因为稀疏图,所以我们使用堆优化的思路, 如果我们还用 邻接矩阵 去表达这个图的话,就是 一个高效的算法 使用了低效的数据结构,那么 整体算法效率 依然是低的。 + +如果还不清楚为什么要使用 邻接表,可以再看看上面 我在 「图的存储」标题下的讲解。 + +这里我也给出 邻接矩阵版本的堆优化dijkstra代码: + +```CPP +#include +#include +#include +#include +using namespace std; +// 小顶堆 +class mycomparison { +public: + bool operator()(const pair& lhs, const pair& rhs) { + return lhs.second > rhs.second; + } +}; + +int main() { + int n, m, p1, p2, val; + cin >> n >> m; + + vector> grid(n + 1, vector(n + 1, INT_MAX)); + + for(int i = 0; i < m; i++){ + cin >> p1 >> p2 >> val; + // p1 指向 p2,权值为 val + grid[p1][p2] = val; + } + + int start = 1; // 起点 + int end = n; // 终点 + + // 存储从源点到每个节点的最短距离 + std::vector minDist(n + 1, INT_MAX); + + // 记录顶点是否被访问过 + std::vector visited(n + 1, false); + + // 优先队列中存放 pair<节点,源点到该节点的距离> + priority_queue, vector>, mycomparison> pq; + + + // 初始化队列,源点到源点的距离为0,所以初始为0 + pq.push(pair(start, 0)); + + minDist[start] = 0; // 起始点到自身的距离为0 + + while (!pq.empty()) { + // <节点, 源点到该节点的距离> + // 1、选距离源点最近且未访问过的节点 + pair cur = pq.top(); pq.pop(); + + if (visited[cur.first]) continue; + + visited[cur.first] = true; // 2、标记该节点已被访问 + + // 3、第三步,更新非访问节点到源点的距离(即更新minDist数组) + for (int j = 1; j <= n; j++) { + if (!visited[j] && grid[cur.first][j] != INT_MAX && (minDist[cur.first] + grid[cur.first][j] < minDist[j])) { + minDist[j] = minDist[cur.first] + grid[cur.first][j]; + pq.push(pair(j, minDist[j])); + } + } + } + + if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到达终点 + else cout << minDist[end] << endl; // 到达终点最短路径 + +} + +``` + +* 时间复杂度:O(E * (N + logE)) E为边的数量,N为节点数量 +* 空间复杂度:O(log(N^2)) + +`while (!pq.empty())` 时间复杂度为 E ,while 里面 每次取元素 时间复杂度 为 logE,和 一个for循环 时间复杂度 为 N 。 + +所以整体是 E * (N + logE) + + +## 总结 + +在学习一种优化思路的时候,首先就要知道为什么要优化,遇到了什么问题。 + +正如我在开篇就给大家交代清楚 堆优化方式的背景。 + +堆优化的整体思路和 朴素版是大体一样的,区别是 堆优化从边的角度触发,且利用堆来排序。 + +很多录友别说写堆优化 就是看 堆优化的代码也看的很懵。 + +主要是因为两点: + +* 不熟悉邻接表的表达方式 +* 对dijkstra的实现思路还是不熟 + +这是我为什么 本篇花了大力气来讲解 图的存储,就是为了让大家彻底理解邻接表以及邻接表的代码写法。 + +至于 dijkstra的实现思路 ,朴素版 和 堆优化版本 都是 按照 dijkstra 三部曲来的。 + +理解了三部曲,dijkstra 的思路就是清晰的。 + +针对邻接表版本代码 我做了详细的 时间复杂度分析,也让录友们清楚,相对于 朴素版,时间都优化到哪了。 + +最后 我也给出了 邻接矩阵的版本代码,分析了这一版本的必要性以及时间复杂度。 + +至此通过 两篇dijkstra的文章,终于把 dijkstra 讲完了,如果大家对我讲解里所涉及的内容都吃透的话,详细对 dijkstra 算法也就理解到位了。 + + + diff --git a/problems/kama47.参会dijkstra朴素.md b/problems/kama47.参会dijkstra朴素.md new file mode 100644 index 00000000..957d471a --- /dev/null +++ b/problems/kama47.参会dijkstra朴素.md @@ -0,0 +1,733 @@ + +# dijkstra(朴素版)精讲 + +[题目链接](https://kamacoder.com/problempage.php?pid=1047) + +【题目描述】 + +小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。 + +小明的起点是第一个车站,终点是最后一个车站。然而,途中的各个车站之间的道路状况、交通拥堵程度以及可能的自然因素(如天气变化)等不同,这些因素都会影响每条路径的通行时间。 + +小明希望能选择一条花费时间最少的路线,以确保他能够尽快到达目的地。 + +【输入描述】 + +第一行包含两个正整数,第一个正整数 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。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227101345.png) + +不能到达的情况: + +如下图所示,当从起始车站不能到达终点车站时,则输出 -1。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227101401.png) + +数据范围: + +1 <= N <= 500; +1 <= M <= 5000; + +## 思路 + +本题就是求最短路,最短路是图论中的经典问题即:给出一个有向图,一个起点,一个终点,问起点到终点的最短路径。 + +接下来,我们来详细讲解最短路算法中的 dijkstra 算法。 + +dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法。 + +需要注意两点: + +* dijkstra 算法可以同时求 起点到所有节点的最短路径 +* 权值不能为负数 + +(这两点后面我们会讲到) + +如本题示例中的图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240125162647.png) + +起点(节点1)到终点(节点7) 的最短路径是 图中 标记绿线的部分。 + +最短路径的权值为12。 + +其实 dijkstra 算法 和 我们之前讲解的prim算法思路非常接近,如果大家认真学过[prim算法](https://mp.weixin.qq.com/s/yX936hHC6Z10K36Vm1Wl9w),那么理解 Dijkstra 算法会相对容易很多。(这也是我要先讲prim再讲dijkstra的原因) + +dijkstra 算法 同样是贪心的思路,不断寻找距离 源点最近的没有访问过的节点。 + +这里我也给出 **dijkstra三部曲**: + +1. 第一步,选源点到哪个节点近且该节点未被访问过 +2. 第二步,该最近节点被标记访问过 +3. 第三步,更新非访问节点到源点的距离(即更新minDist数组) + +大家此时已经会发现,这和prim算法 怎么这么像呢。 + +我在[prim算法](https://mp.weixin.qq.com/s/yX936hHC6Z10K36Vm1Wl9w)讲解中也给出了三部曲。 prim 和 dijkstra 确实很像,思路也是类似的,这一点我在后面还会详细来讲。 + +在dijkstra算法中,同样有一个数组很重要,起名为:minDist。 + +**minDist数组 用来记录 每一个节点距离源点的最小距离**。 + +理解这一点很重要,也是理解 dijkstra 算法的核心所在。 + +大家现在看着可能有点懵,不知道什么意思。 + +没关系,先让大家有一个印象,对理解后面讲解有帮助。 + +我们先来画图看一下 dijkstra 的工作过程,以本题示例为例: (以下为朴素版dijkstra的思路) + +(**示例中节点编号是从1开始,所以为了让大家看的不晕,minDist数组下标我也从 1 开始计数,下标0 就不使用了,这样 下标和节点标号就可以对应上了,避免大家搞混**) + +## 朴素版dijkstra + +### 模拟过程 + +----------- + +0、初始化 + +minDist数组数值初始化为int最大值。 + +这里在强点一下 **minDist数组的含义:记录所有节点到源点的最短路径**,那么初始化的时候就应该初始为最大值,这样才能在后续出现最短路径的时候及时更新。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130115306.png) + +(图中,max 表示默认值,节点0 不做处理,统一从下标1 开始计算,这样下标和节点数值统一, 方便大家理解,避免搞混) + +源点(节点1) 到自己的距离为0,所以 minDist[1] = 0 + +此时所有节点都没有被访问过,所以 visited数组都为0 + +--------------- + +以下为dijkstra 三部曲 + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离源点最近,距离为0,且未被访问。 + +2、该最近节点被标记访问过 + +标记源点访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130115421.png) + + +更新 minDist数组,即:源点(节点1) 到 节点2 和 节点3的距离。 + +* 源点到节点2的最短距离为1,小于原minDist[2]的数值max,更新minDist[2] = 1 +* 源点到节点3的最短距离为4,小于原minDist[3]的数值max,更新minDist[4] = 4 + +可能有录友问:为啥和 minDist[2] 比较? + +再强调一下 minDist[2] 的含义,它表示源点到节点2的最短距离,那么目前我们得到了 源点到节点2的最短距离为1,小于默认值max,所以更新。 minDist[3]的更新同理 + + +------------- + +1、选源点到哪个节点近且该节点未被访问过 + +未访问过的节点中,源点到节点2距离最近,选节点2 + +2、该最近节点被标记访问过 + +节点2被标记访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130121240.png) + +更新 minDist数组,即:源点(节点1) 到 节点6 、 节点3 和 节点4的距离。 + +**为什么更新这些节点呢? 怎么不更新其他节点呢**? + +因为 源点(节点1)通过 已经计算过的节点(节点2) 可以链接到的节点 有 节点3,节点4和节点6. + + +更新 minDist数组: + +* 源点到节点6的最短距离为5,小于原minDist[6]的数值max,更新minDist[6] = 5 +* 源点到节点3的最短距离为3,小于原minDist[3]的数值4,更新minDist[3] = 3 +* 源点到节点4的最短距离为6,小于原minDist[4]的数值max,更新minDist[4] = 6 + + + +------------------- + +1、选源点到哪个节点近且该节点未被访问过 + +未访问过的节点中,源点距离哪些节点最近,怎么算的? + +其实就是看 minDist数组里的数值,minDist 记录了 源点到所有节点的最近距离,结合visited数组筛选出未访问的节点就好。 + +从 上面的图,或者 从minDist数组中,我们都能看出 未访问过的节点中,源点(节点1)到节点3距离最近。 + + +2、该最近节点被标记访问过 + +节点3被标记访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130120434.png) + +由于节点3的加入,那么源点可以有新的路径链接到节点4 所以更新minDist数组: + +更新 minDist数组: + +* 源点到节点4的最短距离为5,小于原minDist[4]的数值6,更新minDist[4] = 5 + +------------------ + +1、选源点到哪个节点近且该节点未被访问过 + +距离源点最近且没有被访问过的节点,有节点4 和 节点6,距离源点距离都是 5 (minDist[4] = 5,minDist[6] = 5) ,选哪个节点都可以。 + +2、该最近节点被标记访问过 + +节点4被标记访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201105335.png) + +由于节点4的加入,那么源点可以链接到节点5 所以更新minDist数组: + +* 源点到节点5的最短距离为8,小于原minDist[5]的数值max,更新minDist[5] = 8 + +-------------- + +1、选源点到哪个节点近且该节点未被访问过 + +距离源点最近且没有被访问过的节点,是节点6,距离源点距离是 5 (minDist[6] = 5) + + +2、该最近节点被标记访问过 + +节点6 被标记访问过 + + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201110250.png) + +由于节点6的加入,那么源点可以链接到节点7 所以 更新minDist数组: + +* 源点到节点7的最短距离为14,小于原minDist[7]的数值max,更新minDist[7] = 14 + + + +------------------- + +1、选源点到哪个节点近且该节点未被访问过 + +距离源点最近且没有被访问过的节点,是节点5,距离源点距离是 8 (minDist[5] = 8) + +2、该最近节点被标记访问过 + +节点5 被标记访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201110651.png) + +由于节点5的加入,那么源点有新的路径可以链接到节点7 所以 更新minDist数组: + +* 源点到节点7的最短距离为12,小于原minDist[7]的数值14,更新minDist[7] = 12 + +----------------- + +1、选源点到哪个节点近且该节点未被访问过 + +距离源点最近且没有被访问过的节点,是节点7(终点),距离源点距离是 12 (minDist[7] = 12) + +2、该最近节点被标记访问过 + +节点7 被标记访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201110920.png) + +节点7加入,但节点7到节点7的距离为0,所以 不用更新minDist数组 + +-------------------- + +最后我们要求起点(节点1) 到终点 (节点7)的距离。 + +再来回顾一下minDist数组的含义:记录 每一个节点距离源点的最小距离。 + +那么起到(节点1)到终点(节点7)的最短距离就是 minDist[7] ,按上面举例讲解来说,minDist[7] = 12,节点1 到节点7的最短路径为 12。 + +路径如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201111352.png) + +在上面的讲解中,每一步 我都是按照 dijkstra 三部曲来讲解的,理解了这三部曲,代码也就好懂的。 + +### 代码实现 + +本题代码如下,里面的 三部曲 我都做了注释,大家按照我上面的讲解 来看如下代码: + +```CPP +#include +#include +#include +using namespace std; +int main() { + int n, m, p1, p2, val; + cin >> n >> m; + + vector> grid(n + 1, vector(n + 1, INT_MAX)); + for(int i = 0; i < m; i++){ + cin >> p1 >> p2 >> val; + grid[p1][p2] = val; + } + + int start = 1; + int end = n; + + // 存储从源点到每个节点的最短距离 + std::vector minDist(n + 1, INT_MAX); + + // 记录顶点是否被访问过 + std::vector visited(n + 1, false); + + minDist[start] = 0; // 起始点到自身的距离为0 + + for (int i = 1; i <= n; i++) { // 遍历所有节点 + + int minVal = INT_MAX; + int cur = 1; + + // 1、选距离源点最近且未访问过的节点 + for (int v = 1; v <= n; ++v) { + if (!visited[v] && minDist[v] < minVal) { + minVal = minDist[v]; + cur = v; + } + } + + visited[cur] = true; // 2、标记该节点已被访问 + + // 3、第三步,更新非访问节点到源点的距离(即更新minDist数组) + for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + } + } + + } + + if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到达终点 + else cout << minDist[end] << endl; // 到达终点最短路径 + +} +``` + +* 时间复杂度:O(n^2) +* 空间复杂度:O(n^2) + +### debug方法 + +写这种题目难免会有各种各样的问题,我们如何发现自己的代码是否有问题呢? + +最好的方式就是打日志,本题的话,就是将 minDist 数组打印出来,就可以很明显发现 哪里出问题了。 + +每次选择节点后,minDist数组的变化是否符合预期 ,是否和我上面讲的逻辑是对应的。 + +例如本题,如果想debug的话,打印日志可以这样写: + + +```CPP +#include +#include +#include +using namespace std; +int main() { + int n, m, p1, p2, val; + cin >> n >> m; + + vector> grid(n + 1, vector(n + 1, INT_MAX)); + for(int i = 0; i < m; i++){ + cin >> p1 >> p2 >> val; + grid[p1][p2] = val; + } + + int start = 1; + int end = n; + + std::vector minDist(n + 1, INT_MAX); + + std::vector visited(n + 1, false); + + minDist[start] = 0; + for (int i = 1; i <= n; i++) { + + int minVal = INT_MAX; + int cur = 1; + + + for (int v = 1; v <= n; ++v) { + if (!visited[v] && minDist[v] < minVal) { + minVal = minDist[v]; + cur = v; + } + } + + visited[cur] = true; + + for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + } + } + + // 打印日志: + cout << "select:" << cur << endl; + for (int v = 1; v <= n; v++) cout << v << ":" << minDist[v] << " "; + cout << endl << endl;; + + } + if (minDist[end] == INT_MAX) cout << -1 << endl; + else cout << minDist[end] << endl; + +} + +``` + +打印后的结果: + +``` +select:1 +1:0 2:1 3:4 4:2147483647 5:2147483647 6:2147483647 7:2147483647 + +select:2 +1:0 2:1 3:3 4:6 5:2147483647 6:5 7:2147483647 + +select:3 +1:0 2:1 3:3 4:5 5:2147483647 6:5 7:2147483647 + +select:4 +1:0 2:1 3:3 4:5 5:8 6:5 7:2147483647 + +select:6 +1:0 2:1 3:3 4:5 5:8 6:5 7:14 + +select:5 +1:0 2:1 3:3 4:5 5:8 6:5 7:12 + +select:7 +1:0 2:1 3:3 4:5 5:8 6:5 7:12 +``` + +打印日志可以和上面我讲解的过程进行对比,每一步的结果是完全对应的。 + +所以如果大家如果代码有问题,打日志来debug是最好的方法 + +### 如何求路径 + +如果题目要求把最短路的路径打印出来,应该怎么办呢? + +这里还是有一些“坑”的,本题打印路径和 prim 打印路径是一样的,我在 [prim算法精讲](https://mp.weixin.qq.com/s/yX936hHC6Z10K36Vm1Wl9w) 【拓展】中 已经详细讲解了。 + +在这里就不再赘述。 + +打印路径只需要添加 几行代码, 打印路径的代码我都加上的日志,如下: + +```CPP +#include +#include +#include +using namespace std; +int main() { + int n, m, p1, p2, val; + cin >> n >> m; + + vector> grid(n + 1, vector(n + 1, INT_MAX)); + for(int i = 0; i < m; i++){ + cin >> p1 >> p2 >> val; + grid[p1][p2] = val; + } + + int start = 1; + int end = n; + + std::vector minDist(n + 1, INT_MAX); + + std::vector visited(n + 1, false); + + minDist[start] = 0; + + //加上初始化 + vector parent(n + 1, -1); + + for (int i = 1; i <= n; i++) { + + int minVal = INT_MAX; + int cur = 1; + + for (int v = 1; v <= n; ++v) { + if (!visited[v] && minDist[v] < minVal) { + minVal = minDist[v]; + cur = v; + } + } + + visited[cur] = true; + + for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + parent[v] = cur; // 记录边 + } + } + + } + + // 输出最短情况 + for (int i = 1; i <= n; i++) { + cout << parent[i] << "->" << i << endl; + } +} +``` + +打印结果: + +``` +-1->1 +1->2 +2->3 +3->4 +4->5 +2->6 +5->7 +``` + +对应如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201111352.png) + +### 出现负数 + +如果图中边的权值为负数,dijkstra 还合适吗? + +看一下这个图: (有负权值) + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227104334.png) + +节点1 到 节点5 的最短路径 应该是 节点1 -> 节点2 -> 节点3 -> 节点4 -> 节点5 + +那我们来看dijkstra 求解的路径是什么样的,继续dijkstra 三部曲来模拟 :(dijkstra模拟过程上面已经详细讲过,以下只模拟重要过程,例如如何初始化就省略讲解了) + +----------- + +初始化: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227104801.png) + +--------------- + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离源点最近,距离为0,且未被访问。 + +2、该最近节点被标记访问过 + +标记源点访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227110217.png) + +更新 minDist数组,即:源点(节点1) 到 节点2 和 节点3的距离。 + +* 源点到节点2的最短距离为100,小于原minDist[2]的数值max,更新minDist[2] = 100 +* 源点到节点3的最短距离为1,小于原minDist[3]的数值max,更新minDist[4] = 1 + +------------------- + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离节点3最近,距离为1,且未被访问。 + +2、该最近节点被标记访问过 + +标记节点3访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227110330.png) + +由于节点3的加入,那么源点可以有新的路径链接到节点4 所以更新minDist数组: + +* 源点到节点4的最短距离为2,小于原minDist[4]的数值max,更新minDist[4] = 2 + +-------------- + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离节点4最近,距离为2,且未被访问。 + +2、该最近节点被标记访问过 + +标记节点4访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227110346.png) + +由于节点4的加入,那么源点可以有新的路径链接到节点5 所以更新minDist数组: + +* 源点到节点5的最短距离为3,小于原minDist[5]的数值max,更新minDist[5] = 5 + +------------ + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离节点5最近,距离为3,且未被访问。 + +2、该最近节点被标记访问过 + +标记节点5访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227110405.png) + +节点5的加入,而节点5 没有链接其他节点, 所以不用更新minDist数组,仅标记节点5被访问过了 + +------------ + +1、选源点到哪个节点近且该节点未被访问过 + +源点距离节点2最近,距离为100,且未被访问。 + +2、该最近节点被标记访问过 + +标记节点2访问过 + +3、更新非访问节点到源点的距离(即更新minDist数组) ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240227110711.png) + +-------------- + +至此dijkstra的模拟过程就结束了,根据最后的minDist数组,我们求 节点1 到 节点5 的最短路径的权值总和为 3,路径: 节点1 -> 节点3 -> 节点4 -> 节点5 + +通过以上的过程模拟,我们可以发现 之所以 没有走有负权值的最短路径 是因为 在 访问 节点 2 的时候,节点 3 已经访问过了,就不会再更新了。 + +那有录友可能会想: 我可以改代码逻辑啊,访问过的节点,也让它继续访问不就好了? + +那么访问过的节点还能继续访问会不会有死循环的出现呢?控制逻辑不让其死循环?那特殊情况自己能都想清楚吗?(可以试试,实践出真知) + +对于负权值的出现,大家可以针对某一个场景 不断去修改 dijkstra 的代码,**但最终会发现只是 拆了东墙补西墙**,对dijkstra的补充逻辑只能满足某特定场景最短路求解。 + +对于求解带有负权值的最短路问题,可以使用 Bellman-Ford 算法 ,我在后序会详细讲解。 + +## dijkstra与prim算法的区别 + +> 这里再次提示,需要先看我的 [prim算法精讲](https://mp.weixin.qq.com/s/yX936hHC6Z10K36Vm1Wl9w) ,否则可能不知道我下面讲的是什么。 + +大家可以发现 dijkstra的代码看上去 怎么和 prim算法这么像呢。 + +其实代码大体不差,唯一区别在 三部曲中的 第三步: 更新minDist数组 + +因为**prim是求 非访问节点到最小生成树的最小距离,而 dijkstra是求 非访问节点到源点的最小距离**。 + +prim 更新 minDist数组的写法: + + +```CPP +for (int j = 1; j <= v; j++) { + if (!isInTree[j] && grid[cur][j] < minDist[j]) { + minDist[j] = grid[cur][j]; + } +} +``` + +因为 minDist表示 节点到最小生成树的最小距离,所以 新节点cur的加入,只需要 使用 grid[cur][j] ,grid[cur][j] 就表示 cur 加入生成树后,生成树到 节点j 的距离。 + +dijkstra 更新 minDist数组的写法: + +```CPP +for (int v = 1; v <= n; v++) { + if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) { + minDist[v] = minDist[cur] + grid[cur][v]; + } +} +``` + +因为 minDist表示 节点到源点的最小距离,所以 新节点 cur 的加入,需要使用 源点到cur的距离 (minDist[cur]) + cur 到 节点 v 的距离 (grid[cur][v]),才是 源点到节点v的距离。 + +此时大家可能不禁要想 prim算法 可以有负权值吗? + +当然可以! + +录友们可以自己思考思考一下,这是为什么? + +这里我提示一下:prim算法只需要将节点以最小权值和链接在一起,不涉及到单一路径。 + + + +## 总结 + +本篇,我们深入讲解的dijkstra算法,详细模拟其工作的流程。 + +这里我给出了 **dijkstra 三部曲 来 帮助大家理解 该算法**,不至于 每次写 dijkstra 都是黑盒操作,没有框架没有章法。 + +在给出的代码中,我也按照三部曲的逻辑来给大家注释,只要理解这三部曲,即使 过段时间 对 dijkstra 算法有些遗忘,依然可以写出一个框架出来,然后再去调试细节。 + +对于图论算法,一般代码都比较长,很难写出代码直接可以提交通过,都需要一个debug的过程,所以 **学习如何debug 非常重要**! + +这也是我为什么 在本文中 单独用来讲解 debug方法。 + +本题求的是最短路径和是多少,**同时我们也要掌握 如何把最短路径打印出来**。 + +我还写了大篇幅来讲解 负权值的情况, 只有画图带大家一步一步去 看 出现负权值 dijkstra的求解过程,才能帮助大家理解,问题出在哪里。 + +如果我直接讲:是**因为访问过的节点 不能再访问,导致错过真正的最短路**,我相信大家都不知道我在说啥。 + +最后我还讲解了 dijkstra 和 prim 算法的 相同 与 不同之处, 我在图论的讲解安排中 先讲 prim算法 再讲 dijkstra 是有目的的, **理解这两个算法的相同与不同之处 有助于大家学习的更深入**。 + +而不是 学了 dijkstra 就只看 dijkstra, 算法之间 都是有联系的,多去思考 算法之间的相互联系,会帮助大家思考的更深入,掌握的更彻底。 + +本篇写了这么长,我也只讲解了 朴素版dijkstra,**关于 堆优化dijkstra,我会在下一篇再来给大家详细讲解**。 + +加油 + + + diff --git a/problems/kama53.寻宝-Kruskal.md b/problems/kama53.寻宝-Kruskal.md new file mode 100644 index 00000000..1e6cbbb9 --- /dev/null +++ b/problems/kama53.寻宝-Kruskal.md @@ -0,0 +1,400 @@ + +# 寻宝 + +[卡码网:53. 寻宝](https://kamacoder.com/problempage.php?pid=1053) + +题目描述: + +在世界的某个区域,有一些分散的神秘岛屿,每个岛屿上都有一种珍稀的资源或者宝藏。国王打算在这些岛屿上建公路,方便运输。 + +不同岛屿之间,路途距离不同,国王希望你可以规划建公路的方案,如何可以以最短的总公路距离将 所有岛屿联通起来。 + +给定一张地图,其中包括了所有的岛屿,以及它们之间的距离。以最小化公路建设长度,确保可以链接到所有岛屿。 + +输入描述: + +第一行包含两个整数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 + +## 解题思路 + +在上一篇 我们讲解了 prim算法求解 最小生成树,本篇我们来讲解另一个算法:Kruskal,同样可以求最小生成树。 + +**prim 算法是维护节点的集合,而 Kruskal 是维护边的集合**。 + +上来就这么说,大家应该看不太懂,这里是先让大家有这么个印象,带着这个印象在看下文,理解的会更到位一些。 + +kruscal的思路: + +* 边的权值排序,因为要优先选最小的边加入到生成树里 +* 遍历排序后的边 + * 如果边首尾的两个节点在同一个集合,说明如果连上这条边图中会出现环 + * 如果边首尾的两个节点不在同一个集合,加入到最小生成树,并把两个节点加入同一个集合 + +下面我们画图举例说明kruscal的工作过程。 + +依然以示例中,如下这个图来举例。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240111113514.png) + +将图中的边按照权值有小到大排序,这样从贪心的角度来说,优先选 权值小的边加入到 最小生成树中。 + +排序后的边顺序为[(1,2) (4,5) (1,3) (2,6) (3,4) (6,7) (5,7) (1,5) (3,2) (2,4) (5,6)] + +> (1,2) 表示节点1 与 节点2 之间的边。权值相同的边,先后顺序无所谓。 + +**开始从头遍历排序后的边**。 + +-------- + +选边(1,2),节点1 和 节点2 不在同一个集合,所以生成树可以添加边(1,2),并将 节点1,节点2 放在同一个集合。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240111114204.png) + +-------- + +选边(4,5),节点4 和 节点 5 不在同一个集合,生成树可以添加边(4,5) ,并将节点4,节点5 放到同一个集合。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240111120458.png) + +**大家判断两个节点是否在同一个集合,就看图中两个节点是否有绿色的粗线连着就行** + +------ + +(这里在强调一下,以下选边是按照上面排序好的边的数组来选择的) + +选边(1,3),节点1 和 节点3 不在同一个集合,生成树添加边(1,3),并将节点1,节点3 放到同一个集合。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240112105834.png) + +--------- + +选边(2,6),节点2 和 节点6 不在同一个集合,生成树添加边(2,6),并将节点2,节点6 放到同一个集合。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240112110214.png) + +-------- + +选边(3,4),节点3 和 节点4 不在同一个集合,生成树添加边(3,4),并将节点3,节点4 放到同一个集合。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240112110450.png) + +---------- + +选边(6,7),节点6 和 节点7 不在同一个集合,生成树添加边(6,7),并将 节点6,节点7 放到同一个集合。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240112110637.png) + +----------- + +选边(5,7),节点5 和 节点7 在同一个集合,不做计算。 + +选边(1,5),两个节点在同一个集合,不做计算。 + +后面遍历 边(3,2),(2,4),(5,6) 同理,都因两个节点已经在同一集合,不做计算。 + + +------- + +此时 我们就已经生成了一个最小生成树,即: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240112110637.png) + +在上面的讲解中,看图的话 大家知道如何判断 两个节点 是否在同一个集合(是否有绿色的线连在一起),以及如何把两个节点加入集合(就在图中把两个节点连上) + +**但在代码中,如果将两个节点加入同一个集合,又如何判断两个节点是否在同一个集合呢**? + +这里就涉及到我们之前讲解的[并查集](https://www.programmercarl.com/%E5%9B%BE%E8%AE%BA%E5%B9%B6%E6%9F%A5%E9%9B%86%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%80.html)。 + +我们在并查集开篇的时候就讲了,并查集主要就两个功能: + +* 将两个元素添加到一个集合中 +* 判断两个元素在不在同一个集合 + +大家发现这正好符合 Kruskal算法的需求,这也是为什么 **我要先讲并查集,再讲 Kruskal**。 + +关于 并查集,我已经在[并查集精讲](https://www.programmercarl.com/%E5%9B%BE%E8%AE%BA%E5%B9%B6%E6%9F%A5%E9%9B%86%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%80.html) 详细讲解过了,所以这里不再赘述,我们直接用。 + +本题代码如下,已经详细注释: + +```CPP + +#include +#include +#include + +using namespace std; + +// l,r为 边两边的节点,val为边的数值 +struct Edge { + int l, r, val; +}; + +// 节点数量 +int n = 10001; +// 并查集标记节点关系的数组 +vector father(n, -1); // 节点编号是从1开始的,n要大一些 + +// 并查集初始化 +void init() { + for (int i = 0; i < n; ++i) { + father[i] = i; + } +} + +// 并查集的查找操作 +int find(int u) { + return u == father[u] ? u : father[u] = find(father[u]); // 路径压缩 +} + +// 并查集的加入集合 +void join(int u, int v) { + u = find(u); // 寻找u的根 + v = find(v); // 寻找v的根 + if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回 + father[v] = u; +} + +int main() { + + int v, e; + int v1, v2, val; + vector edges; + int result_val = 0; + cin >> v >> e; + while (e--) { + cin >> v1 >> v2 >> val; + edges.push_back({v1, v2, val}); + } + + // 执行Kruskal算法 + // 按边的权值对边进行从小到大排序 + sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) { + return a.val < b.val; + }); + + // 并查集初始化 + init(); + + // 从头开始遍历边 + for (Edge edge : edges) { + // 并查集,搜出两个节点的祖先 + int x = find(edge.l); + int y = find(edge.r); + + // 如果祖先不同,则不在同一个集合 + if (x != y) { + result_val += edge.val; // 这条边可以作为生成树的边 + join(x, y); // 两个节点加入到同一个集合 + } + } + cout << result_val << endl; + return 0; +} + +``` + +时间复杂度:nlogn (快排) + logn (并查集) ,所以最后依然是 nlogn 。n为边的数量。 + +关于并查集时间复杂度,可以看我在 [并查集理论基础](https://programmercarl.com/%E5%9B%BE%E8%AE%BA%E5%B9%B6%E6%9F%A5%E9%9B%86%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%80.html) 的讲解。 + +## 拓展一 + +如果题目要求将最小生成树的边输出的话,应该怎么办呢? + +Kruskal 算法 输出边的话,相对prim 要容易很多,因为 Kruskal 本来就是直接操作边,边的结构自然清晰,不用像 prim一样 需要再节点练成线输出边 (因为prim是对节点操作,而 Kruskal是对边操作,这是本质区别) + +本题中,边的结构为: + +```CPP +struct Edge { + int l, r, val; +}; +``` + +那么我们只需要找到 在哪里把生成树的边保存下来就可以了。 + +当判断两个节点不在同一个集合的时候,这两个节点的边就加入到最小生成树, 所以添加边的操作在这里: + +```CPP +vector result; // 存储最小生成树的边 +// 如果祖先不同,则不在同一个集合 +if (x != y) { + result.push_back(edge); // 记录最小生成树的边 + result_val += edge.val; // 这条边可以作为生成树的边 + join(x, y); // 两个节点加入到同一个集合 +} +``` + +整体代码如下,为了突出重点,我仅仅将 打印最小生成树的部分代码注释了,大家更容易看到哪些改动。 + +```CPP +#include +#include +#include + +using namespace std; + +struct Edge { + int l, r, val; +}; + + +int n = 10001; + +vector father(n, -1); + +void init() { + for (int i = 0; i < n; ++i) { + father[i] = i; + } +} + +int find(int u) { + return u == father[u] ? u : father[u] = find(father[u]); +} + +void join(int u, int v) { + u = find(u); + v = find(v); + if (u == v) return ; + father[v] = u; +} + +int main() { + + int v, e; + int v1, v2, val; + vector edges; + int result_val = 0; + cin >> v >> e; + while (e--) { + cin >> v1 >> v2 >> val; + edges.push_back({v1, v2, val}); + } + + sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) { + return a.val < b.val; + }); + + vector result; // 存储最小生成树的边 + + init(); + + for (Edge edge : edges) { + + int x = find(edge.l); + int y = find(edge.r); + + + if (x != y) { + result.push_back(edge); // 保存最小生成树的边 + result_val += edge.val; + join(x, y); + } + } + + // 打印最小生成树的边 + for (Edge edge : result) { + cout << edge.l << " - " << edge.r << " : " << edge.val << endl; + } + + return 0; +} + + +``` + +按照题目中的示例,打印边的输出为: + +``` +1 - 2 : 1 +1 - 3 : 1 +2 - 6 : 1 +3 - 4 : 1 +4 - 5 : 1 +5 - 7 : 1 +``` + +大家可能发现 怎么和我们 模拟画的图不一样,差别在于 代码生成的最小生成树中 节点5 和 节点7相连的。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240116163014.png) + + +其实造成这个差别 是对边排序的时候 权值相同的边先后顺序的问题导致的,无论相同权值边的顺序是什么样的,最后都能得出最小生成树。 + + +## 拓展二 + + +此时我们已经讲完了 Kruskal 和 prim 两个解法来求最小生成树。 + +什么情况用哪个算法更合适呢。 + +Kruskal 与 prim 的关键区别在于,prim维护的是节点的集合,而 Kruskal 维护的是边的集合。 +如果 一个图中,节点多,但边相对较少,那么使用Kruskal 更优。 + +有录友可能疑惑,一个图里怎么可能节点多,边却少呢? + +节点未必一定要连着边那, 例如 这个图,大家能明显感受到边没有那么多对吧,但节点数量 和 上述我们讲的例子是一样的。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240116152211.png) + +为什么边少的话,使用 Kruskal 更优呢? + +因为 Kruskal 是对边进行排序的后 进行操作是否加入到最小生成树。 + +边如果少,那么遍历操作的次数就少。 + +在节点数量固定的情况下,图中的边越少,Kruskal 需要遍历的边也就越少。 + +而 prim 算法是对节点进行操作的,节点数量越少,prim算法效率就越少。 + +所以在 稀疏图中,用Kruskal更优。 在稠密图中,用prim算法更优。 + +> 边数量较少为稀疏图,接近或等于完全图(所有节点皆相连)为稠密图 + + +Prim 算法 时间复杂度为 O(n^2),其中 n 为节点数量,它的运行效率和图中边树无关,适用稠密图。 + +Kruskal算法 时间复杂度 为 nlogn,其中n 为边的数量,适用稀疏图。 + +## 总结 + +如果学过了并查集,其实 kruskal 比 prim更好理解一些。 + +本篇,我们依然通过模拟 Kruskal 算法的过程,来带大家一步步了解其工作过程。 + +在 拓展一 中讲解了 如何输出最小生成树的边。 + +在拓展二 中讲解了 prim 和 Kruskal的区别。 + +录友们可以细细体会。 + + diff --git a/problems/kama53.寻宝.md b/problems/kama53.寻宝-prim.md similarity index 93% rename from problems/kama53.寻宝.md rename to problems/kama53.寻宝-prim.md index 3a4b8b27..9959954a 100644 --- a/problems/kama53.寻宝.md +++ b/problems/kama53.寻宝-prim.md @@ -5,9 +5,11 @@ 题目描述: -在世界的某个区域,有一些分散的神秘岛屿,每个岛屿上都有一种珍稀的资源或者宝藏。你是一名探险者,决定前往这些岛屿,但为了节省时间和资源,你希望规划一条最短的路径,以便在探索这些岛屿时尽量减少旅行的距离。 +在世界的某个区域,有一些分散的神秘岛屿,每个岛屿上都有一种珍稀的资源或者宝藏。国王打算在这些岛屿上建公路,方便运输。 -给定一张地图,其中包括了所有的岛屿,以及它们之间的距离。每个岛屿都需要被至少访问一次,你的目标是规划一条最短路径,以最小化探索路径的总距离,同时确保访问了所有岛屿。 +不同岛屿之间,路途距离不同,国王希望你可以规划建公路的方案,如何可以以最短的总公路距离将 所有岛屿联通起来。 + +给定一张地图,其中包括了所有的岛屿,以及它们之间的距离。以最小化公路建设长度,确保可以链接到所有岛屿。 输入描述: @@ -133,7 +135,7 @@ minDist 数组 里的数值初始化为 最大数,因为本题 节点距离不 1、prim三部曲,第一步:选距离生成树最近节点 -选取一个距离 最小生成树(节点1) 最近的非生成树里的节点,节点2,3,5 距离 最小生成树(节点1) 最近,选节点 2(其实选 节点3或者节点5都可以,距离一样的)加入最小生成树。 +选取一个距离 最小生成树(节点1) 最近的非生成树里的节点,节点2,3,5 距离 最小生成树(节点1) 最近,选节点 2(其实选 节点3或者节点2都可以,距离一样的)加入最小生成树。 2、prim三部曲,第二步:最近节点加入生成树 @@ -272,6 +274,8 @@ minDist数组已经更新了 所有非生成树的节点距离 最小生成树 ```CPP #include #include +#include + using namespace std; int main() { int v, e; @@ -296,28 +300,28 @@ int main() { for (int i = 1; i < v; i++) { // 1、prim三部曲,第一步:选距离生成树最近节点 - int cur = -1; // 选中哪个节点 加入最小生成树 + int cur = -1; // 选中哪个节点 加入最小生成树 + int minVal = INT_MAX; for (int j = 1; j <= v; j++) { // 1 - v,顶点编号,这里下标从1开始 - // 选取最小生成树节点的条件: + // 选取最小生成树节点的条件: // (1)不在最小生成树里 - // (2)距离最小生成树最近的节点 - // (3)只要不在最小生成树里,先默认选一个节点 ,在比较 哪一个是最小的 - // 理解条件3 很重要,才能理解这段代码:(cur == -1 || minDist[j] < minDist[cur]) - if (!isInTree[j] && (cur == -1 || minDist[j] < minDist[cur])) { + // (2)距离最小生成树最近的节点 + if (!isInTree[j] && minDist[j] < minVal) { + minVal = minDist[j]; cur = j; } } - // 2、prim三部曲,第二步:最近节点(cur)加入生成树 + // 2、prim三部曲,第二步:最近节点(cur)加入生成树 isInTree[cur] = true; // 3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组) // cur节点加入之后, 最小生成树加入了新的节点,那么所有节点到 最小生成树的距离(即minDist数组)需要更新一下 - // 由于cur节点是新加入到最小生成树,那么只需要关心与 cur 相连的 非生成树节点 的距离 是否比 原来 非生成树节点到生成树节点的距离更小了呢 - for (int j = 1; j <= v; j++) { - // 更新的条件: - // (1)节点是 非生成树里的节点 - // (2)与cur相连的某节点的权值 比 该某节点距离最小生成树的距离小 - // 很多录友看到自己 就想不明白什么意思,其实就是 cur 是新加入 最小生成树的节点,那么 所有非生成树的节点距离生成树节点的最近距离 由于 cur的新加入,需要更新一下数据了 + // 由于cur节点是新加入到最小生成树,那么只需要关心与 cur 相连的 非生成树节点 的距离 是否比 原来 非生成树节点到生成树节点的距离更小了呢 + for (int j = 1; j <= v; j++) { + // 更新的条件: + // (1)节点是 非生成树里的节点 + // (2)与cur相连的某节点的权值 比 该某节点距离最小生成树的距离小 + // 很多录友看到自己 就想不明白什么意思,其实就是 cur 是新加入 最小生成树的节点,那么 所有非生成树的节点距离生成树节点的最近距离 由于 cur的新加入,需要更新一下数据了 if (!isInTree[j] && grid[cur][j] < minDist[j]) { minDist[j] = grid[cur][j]; } @@ -325,14 +329,17 @@ int main() { } // 统计结果 int result = 0; - for (int i = 2; i <= v; i++) { // 不计第一个顶点,因为统计的是边的权值,v个节点有 v-1条边 + for (int i = 2; i <= v; i++) { // 不计第一个顶点,因为统计的是边的权值,v个节点有 v-1条边 result += minDist[i]; } cout << result << endl; } + ``` +时间复杂度为 O(n^2),其中 n 为节点数量。 + ## 拓展 上面讲解的是记录了最小生成树 所有边的权值,如果让打印出来 最小生成树的每条边呢? 或者说 要把这个最小生成树画出来呢? @@ -406,6 +413,8 @@ for (int j = 1; j <= v; j++) { ```CPP #include #include +#include + using namespace std; int main() { int v, e; @@ -426,11 +435,14 @@ int main() { for (int i = 1; i < v; i++) { int cur = -1; - for (int j = 1; j <= v; j++) { - if (!isInTree[j] && (cur == -1 || minDist[j] < minDist[cur])) { + int minVal = INT_MAX; + for (int j = 1; j <= v; j++) { + if (!isInTree[j] && minDist[j] < minVal) { + minVal = minDist[j]; cur = j; } } + isInTree[cur] = true; for (int j = 1; j <= v; j++) { if (!isInTree[j] && grid[cur][j] < minDist[j]) { @@ -440,11 +452,12 @@ int main() { } } } - // 输出 最小生成树边的链接情况 + // 输出 最小生成树边的链接情况 for (int i = 1; i <= v; i++) { - cout << i "->" parent[i] << endl; + cout << i << "->" << parent[i] << endl; } } + ``` 按照本题示例,代码输入如下: diff --git a/problems/qita/shejimoshi.md b/problems/qita/shejimoshi.md index cf980e45..d5342c61 100644 --- a/problems/qita/shejimoshi.md +++ b/problems/qita/shejimoshi.md @@ -1,4 +1,6 @@ +# 23种设计模式精讲 | 配套练习题 | 卡码网 + 关于设计模式的学习,大家应该还是看书或者看博客,但却没有一个边学边练的学习环境。 学完了一种设计模式 是不是应该去练一练?