diff --git a/README.md b/README.md index 5389c89a..e94b4285 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,7 @@ * 编程语言 * [C++面试&C++学习指南知识点整理](https://github.com/youngyangyang04/TechCPP) - * [C++语言基础课](https://kamacoder.com/course.php?course_id=1) - * [Java语言基础课](https://kamacoder.com/course.php?course_id=2) + * [编程语言基础课](https://kamacoder.com/courseshop.php) * [23种设计模式](https://github.com/youngyangyang04/kama-DesignPattern) * 工具 @@ -91,12 +90,12 @@ * [BAT级别技术面试流程和注意事项都在这里了](./problems/前序/BAT级别技术面试流程和注意事项都在这里了.md) * 算法性能分析 - * [关于时间复杂度,你不知道的都在这里!](./problems/前序/关于时间复杂度,你不知道的都在这里!.md) - * [O(n)的算法居然超时了,此时的n究竟是多大?](./problems/前序/On的算法居然超时了,此时的n究竟是多大?.md) - * [通过一道面试题目,讲一讲递归算法的时间复杂度!](./problems/前序/通过一道面试题目,讲一讲递归算法的时间复杂度!.md) - * [关于空间复杂度,可能有几个疑问?](./problems/前序/关于空间复杂度,可能有几个疑问?.md) + * [关于时间复杂度,你不知道的都在这里!](./problems/前序/时间复杂度.md) + * [O(n)的算法居然超时了,此时的n究竟是多大?](./problems/前序/算法超时.md) + * [通过一道面试题目,讲一讲递归算法的时间复杂度!](./problems/前序/递归算法的时间复杂度.md) + * [关于空间复杂度,可能有几个疑问?](./problems/前序/空间复杂度.md) * [递归算法的时间与空间复杂度分析!](./problems/前序/递归算法的时间与空间复杂度分析.md) - * [刷了这么多题,你了解自己代码的内存消耗么?](./problems/前序/刷了这么多题,你了解自己代码的内存消耗么?.md) + * [刷了这么多题,你了解自己代码的内存消耗么?](./problems/前序/内存消耗.md) ## 数组 diff --git a/problems/0207.课程表.md b/problems/0207.课程表.md index 6a8eb23b..dff0b18e 100644 --- a/problems/0207.课程表.md +++ b/problems/0207.课程表.md @@ -12,10 +12,6 @@ 引用与任务调度,课程安排等等。 -为什么 - - ------ 「拓扑排序」是专门应用于有向图的算法; @@ -26,32 +22,42 @@ 这道题的做法同样适用于第 210 题。 -``` -vector inDegree(numCourses); -unordered_map> map; -for (int i = 0; i < prerequisites.size(); i++) { - inDegree[prerequisites[i][0]]++;//当前课程入度值+1 - map[prerequisites[i][1]].push_back(prerequisites[i][0]);//添加依赖他的后续课 -} -queue Qu; -for (int i = 0; i < numCourses; i++) { - if (inDegree[i] == 0) Qu.push(i);//所有入度为0的课入列 -} -int count = 0; -while (Qu.size()) { - int selected = Qu.front(); //当前选的课 - Qu.pop();//出列 - count++;//选课数+1 - vector toEnQueue = map[selected];//获取这门课对应的后续课 - if (toEnQueue.size()) { //确实有后续课 - for (int i = 0; i < toEnQueue.size(); i++) { - inDegree[toEnQueue[i]]--; //依赖它的后续课的入度-1 - if (inDegree[toEnQueue[i]] == 0) Qu.push(toEnQueue[i]); //如果因此减为0,入列 - } - } -} -if (count == numCourses) return true; -return false; +```CPP +class Solution { +public: + bool canFinish(int numCourses, vector>& prerequisites) { + vector inDegree(numCourses, 0); + unordered_map> umap; + for (int i = 0; i < prerequisites.size(); i++) { + + // prerequisites[i][0] 是 课程入度,prerequisites[i][1] 是课程出度 + // 即: 上课prerequisites[i][0] 之前,必须先上课prerequisites[i][1] + // prerequisites[i][1] -> prerequisites[i][0] + inDegree[prerequisites[i][0]]++;//当前课程入度值+1 + umap[prerequisites[i][1]].push_back(prerequisites[i][0]); // 添加 prerequisites[i][1] 指向的课程 + } + queue que; + for (int i = 0; i < numCourses; i++) { + if (inDegree[i] == 0) que.push(i); // 所有入度为0,即为 开头课程 加入队列 + } + int count = 0; + while (que.size()) { + int cur = que.front(); //当前选的课 + que.pop(); + count++; // 选课数+1 + vector courses = umap[cur]; //获取这门课指向的课程,也就是这么课的后续课 + if (courses.size()) { // 有后续课 + for (int i = 0; i < courses.size(); i++) { + inDegree[courses[i]]--; // 它的后续课的入度-1 + if (inDegree[courses[i]] == 0) que.push(courses[i]); // 如果入度为0,加入队列 + } + } + } + if (count == numCourses) return true; + return false; + + } +}; ```

diff --git a/problems/0210.课程表II.md b/problems/0210.课程表II.md new file mode 100644 index 00000000..2d2e2429 --- /dev/null +++ b/problems/0210.课程表II.md @@ -0,0 +1,39 @@ + +```CPP +class Solution { +public: + vector findOrder(int numCourses, vector>& prerequisites) { + vector inDegree(numCourses, 0); + vector result; + unordered_map> umap; + for (int i = 0; i < prerequisites.size(); i++) { + + // prerequisites[i][0] 是 课程入度,prerequisites[i][1] 是课程出度 + // 即: 上课prerequisites[i][0] 之前,必须先上课prerequisites[i][1] + // prerequisites[i][1] -> prerequisites[i][0] + inDegree[prerequisites[i][0]]++;//当前课程入度值+1 + umap[prerequisites[i][1]].push_back(prerequisites[i][0]); // 添加 prerequisites[i][1] 指向的课程 + } + queue que; + for (int i = 0; i < numCourses; i++) { + if (inDegree[i] == 0) que.push(i); // 所有入度为0,即为 开头课程 加入队列 + } + int count = 0; + while (que.size()) { + int cur = que.front(); //当前选的课 + que.pop(); + count++; // 选课数+1 + result.push_back(cur); + vector courses = umap[cur]; //获取这门课指向的课程,也就是这么课的后续课 + if (courses.size()) { // 有后续课 + for (int i = 0; i < courses.size(); i++) { + inDegree[courses[i]]--; // 它的后续课的入度-1 + if (inDegree[courses[i]] == 0) que.push(courses[i]); // 如果入度为0,加入队列 + } + } + } + if (count == numCourses) return result; + else return vector(); + } +}; +``` diff --git a/problems/kamacoder/00.软件构建.md b/problems/kamacoder/00.软件构建.md new file mode 100644 index 00000000..7229489b --- /dev/null +++ b/problems/kamacoder/00.软件构建.md @@ -0,0 +1,337 @@ + +# 拓扑排序精讲 + +[卡码网:软件构建](https://kamacoder.com/problempage.php?pid=1191) + +题目描述: + +某个大型软件项目的构建系统拥有 N 个文件,文件编号从 0 到 N - 1,在这些文件中,某些文件依赖于其他文件的内容,这意味着如果文件 A 依赖于文件 B,则必须在处理文件 A 之前处理文件 B (0 <= A, B <= N - 1)。请编写一个算法,用于确定文件处理的顺序。 + +输入描述: + +第一行输入两个正整数 M, N。表示 N 个文件之间拥有 M 条依赖关系。 + +后续 M 行,每行两个正整数 S 和 T,表示 T 文件依赖于 S 文件。 + +输出描述: + +输出共一行,如果能处理成功,则输出文件顺序,用空格隔开。 + +如果不能成功处理(相互依赖),则输出 -1。 + +输入示例: + +``` +5 4 +0 1 +0 2 +1 3 +2 4 +``` + +输出示例: + +0 1 2 3 4 + +提示信息: + +文件依赖关系如下: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510192157.png) + +所以,文件处理的顺序除了示例中的顺序,还存在 + +0 2 4 1 3 + +0 2 1 3 4 + +等等合法的顺序。 + +数据范围: + +* 0 <= N <= 10 ^ 5 +* 1 <= M <= 10 ^ 9 + + +## 拓扑排序的背景 + +本题是拓扑排序的经典题目。 + +一聊到 拓扑排序,一些录友可能会想这是排序,不会想到这是图论算法。 + +其实拓扑排序是经典的图论问题。 + +先说说 拓扑排序的应用场景。 + +大学排课,例如 先上A课,才能上B课,上了B课才能上C课,上了A课才能上D课,等等一系列这样的依赖顺序。 问给规划出一条 完整的上课顺序。 + +拓扑排序在文件处理上也有应用,我们在做项目安装文件包的时候,经常发现 复杂的文件依赖关系, A依赖B,B依赖C,B依赖D,C依赖E 等等。 + +如果给出一条线性的依赖顺序来下载这些文件呢? + +有录友想上面的例子都很简单啊,我一眼能给排序出来。 + +那如果上面的依赖关系是一百对呢,一千对甚至上万个依赖关系,这些依赖关系中可能还有循环依赖,你如何发现循环依赖呢,又如果排出线性顺序呢。 + +所以 拓扑排序就是专门解决这类问题的。 + +概括来说,**给出一个 有向图,把这个有向图转成线性的排序 就叫拓扑排序**。 + +当然拓扑排序也要检测这个有向图 是否有环,即存在循环依赖的情况,因为这种情况是不能做线性排序的。 + +所以**拓扑排序也是图论中判断有向无环图的常用方法**。 + +------------ + + +## 拓扑排序的思路 + +拓扑排序指的是一种 解决问题的大体思路, 而具体算法,可能是广搜也可能是深搜。 + +大家可能发现 各式各样的解法,纠结哪个是拓扑排序? + +其实只要能在把 有向无环图 进行线性排序 的算法 都可以叫做 拓扑排序。 + +实现拓扑排序的算法有两种:卡恩算法(BFS)和DFS + +> 卡恩1962年提出这种解决拓扑排序的思路 + +一般来说我们只需要掌握 BFS (广度优先搜索)就可以了,清晰易懂,如果还想多了解一些,可以再去学一下 DFS 的思路,但 DFS 不是本篇重点。 + +接下来我们来讲解BFS的实现思路。 + +以题目中示例为例如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510110836.png) + +做拓扑排序的话,如果肉眼去找开头的节点,一定能找到 节点0 吧,都知道要从节点0 开始。 + +但为什么我们能找到 节点0呢,因为我们肉眼看着 这个图就是从 节点0出发的。 + +作为出发节点,它有什么特征? + +你看节点0 的入度 为0 出度为2, 也就是 没有边指向它,而它有两条边是指出去的。 + +> 节点的入度表示 有多少条边指向它,节点的出度表示有多少条边 从该节点出发。 + +所以当我们做拓扑排序的时候,应该优先找 入度为 0 的节点,只有入度为0,它才是出发节点。 +**理解以上内容很重要**! + +接下来我给出 拓扑排序的过程,其实就两步: + +1. 找到入度为0 的节点,加入结果集 +2. 将该节点从图中移除 + +循环以上两步,直到 所有节点都在图中被移除了。 + +结果集的顺序,就是我们想要的拓扑排序顺序 (结果集里顺序可能不唯一) + +## 模拟过程 + +用本题的示例来模拟一下这一过程: + + +1、找到入度为0 的节点,加入结果集 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510113110.png) + +2、将该节点从图中移除 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510113142.png) + +---------------- + +1、找到入度为0 的节点,加入结果集 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510113345.png) + +这里大家会发现,节点1 和 节点2 入度都为0, 选哪个呢? + +选哪个都行,所以这也是为什么拓扑排序的结果是不唯一的。 + +2、将该节点从图中移除 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510113640.png) + +--------------- + +1、找到入度为0 的节点,加入结果集 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510113853.png) + +节点2 和 节点3 入度都为0,选哪个都行,这里选节点2 + +2、将该节点从图中移除 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510114004.png) + +-------------- + +后面的过程一样的,节点3 和 节点4,入度都为0,选哪个都行。 + +最后结果集为: 0 1 2 3 4 。当然结果不唯一的。 + +## 判断有环 + +如果有 有向环怎么办呢?例如这个图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510115115.png) + +这个图,我们只能将入度为0 的节点0 接入结果集。 + +之后,节点1、2、3、4 形成了环,找不到入度为0 的节点了,所以此时结果集里只有一个元素。 +那么如果我们发现结果集元素个数 不等于 图中节点个数,我们就可以认定图中一定有 有向环! +这也是拓扑排序判断有向环的方法。 + +通过以上过程的模拟大家会发现这个拓扑排序好像不难,还有点简单。 + +## 写代码 + +理解思想后,确实不难,但代码写起来也不容易。 + +为了每次可以找到所有节点的入度信息,我们要在初始话的时候,就把每个节点的入度 和 每个节点的依赖关系做统计。 + +代码如下: + +```CPP +cin >> n >> m; +vector inDegree(n, 0); // 记录每个文件的入度 +vector result; // 记录结果 +unordered_map> umap; // 记录文件依赖关系 + +while (m--) { + // s->t,先有s才能有t + cin >> s >> t; + inDegree[t]++; // t的入度加一 + umap[s].push_back(t); // 记录s指向哪些文件 +} + +``` + +找入度为0 的节点,我们需要用一个队列放存放。 + +因为每次寻找入度为0的节点,不一定只有一个节点,可能很多节点入度都为0,所以要将这些入度为0的节点放到队列里,依次去处理。 + +代码如下: + +```CPP + +queue que; +for (int i = 0; i < n; i++) { + // 入度为0的节点,可以作为开头,先加入队列 + if (inDegree[i] == 0) que.push(i); +} +``` + +开始从队列里遍历入度为0 的节点,将其放入结果集。 + +```CPP + +while (que.size()) { + int cur = que.front(); // 当前选中的节点 + que.pop(); + result.push_back(cur); + // 将该节点从图中移除 + +} +``` + +这里面还有一个很重要的过程,如何把这个入度为0的节点从图中移除呢? + +首先我们为什么要把节点从图中移除? + +为的是将 该节点作为出发点所连接的边删掉。 + +删掉的目的是什么呢? + +要把 该节点作为出发点所连接的节点的 入度 减一。 + +如果这里不理解,看上面的模拟过程第一步: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510113110.png) + +这事节点1 和 节点2 的入度为 1。 + +将节点0删除后,图为这样: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510113142.png) + +那么 节点0 作为出发点 所连接的节点的入度 就都做了 减一 的操作。 + +此时 节点1 和 节点 2 的入度都为0, 这样才能作为下一轮选取的节点。 + +所以,我们在代码实现的过程中,本质是要将 该节点作为出发点所连接的节点的 入度 减一 就可以了,这样好能根据入度找下一个节点,不用真在图里把这个节点删掉。 + +该过程代码如下: + + +```CPP + +while (que.size()) { + int cur = que.front(); // 当前选中的节点 + que.pop(); + result.push_back(cur); + // 将该节点从图中移除 + vector files = umap[cur]; //获取cur指向的节点 + if (files.size()) { // 如果cur有指向的节点 + for (int i = 0; i < files.size(); i++) { // 遍历cur指向的节点 + inDegree[files[i]] --; // cur指向的节点入度都做减一操作 + // 如果指向的节点减一之后,入度为0,说明是我们要选取的下一个节点,放入队列。 + if(inDegree[files[i]] == 0) que.push(files[i]); + } + } + +} +``` + +最后代码如下: + + +```CPP +#include +#include +#include +#include +using namespace std; +int main() { + int m, n, s, t; + cin >> n >> m; + vector inDegree(n, 0); // 记录每个节点的入度 + + unordered_map> umap;// 记录节点依赖关系 + vector result; // 记录结果 + + while (m--) { + // s->t,先有s才能有t + cin >> s >> t; + inDegree[t]++; // t的入度加一 + umap[s].push_back(t); // 记录s指向哪些节点 + } + queue que; + for (int i = 0; i < n; i++) { + // 入度为0的节点,可以作为开头,先加入队列 + if (inDegree[i] == 0) que.push(i); + //cout << inDegree[i] << endl; + } + // int count = 0; + while (que.size()) { + int cur = que.front(); // 当前选中的节点 + que.pop(); + //count++; + result.push_back(cur); + vector files = umap[cur]; //获取该节点指向的节点 + if (files.size()) { // cur有后续节点 + for (int i = 0; i < files.size(); i++) { + inDegree[files[i]] --; // cur的指向的节点入度-1 + if(inDegree[files[i]] == 0) que.push(files[i]); + } + } + } + // 判断是否有有向环 + if (result.size() == n) { + // 注意输出格式,最后一个元素后面没有空格 + for (int i = 0; i < n - 2; i++) cout << result[i] << " "; + cout << result[n - 1]; + } else cout << -1 << endl; +} +``` diff --git a/problems/kamacoder/0094.城市间货物运输I.md b/problems/kamacoder/0094.城市间货物运输I.md index 0ce00bbf..dc9b46f3 100644 --- a/problems/kamacoder/0094.城市间货物运输I.md +++ b/problems/kamacoder/0094.城市间货物运输I.md @@ -1,18 +1,21 @@ -# 94. 城市间货物运输 I +# Bellman_ford 算法精讲 -[卡码网: 94. 城市间货物运输 I](https://kamacoder.com/problempage.php?pid=1152) +[卡码网:94. 城市间货物运输 I](https://kamacoder.com/problempage.php?pid=1152) 题目描述 某国为促进城市间经济交流,决定对货物运输提供补贴。共有 n 个编号为 1 到 n 的城市,通过道路网络连接,网络中的道路仅允许从某个城市单向通行到另一个城市,不能反向通行。 -网络中的道路都有各自的运输成本和政府补贴,道路的权值计算方式为:运输成本 - 政府补贴。权值为正表示扣除了政府补贴后运输货物仍需支付的费用;权值为负则表示政府的补贴超过了支出的运输成本,实际表现为运输过程中还能赚取一定的收益。 +网络中的道路都有各自的运输成本和政府补贴,道路的权值计算方式为:运输成本 - 政府补贴。 + +权值为正表示扣除了政府补贴后运输货物仍需支付的费用;权值为负则表示政府的补贴超过了支出的运输成本,实际表现为运输过程中还能赚取一定的收益。 -请找出从城市 1 到城市 n 的所有可能路径中,综合政府补贴后的最低运输成本。如果最低运输成本是一个负数,它表示在遵循最优路径的情况下,运输过程中反而能够实现盈利。 +请找出从城市 1 到城市 n 的所有可能路径中,综合政府补贴后的最低运输成本。 +如果最低运输成本是一个负数,它表示在遵循最优路径的情况下,运输过程中反而能够实现盈利。 城市 1 到城市 n 之间可能会出现没有路径的情况,同时保证道路网络中不存在任何负权回路。 @@ -41,17 +44,19 @@ 1 3 5 ``` +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240509200224.png) + ## 思路 -本题依然是最短路问题,求 从 节点1 到节点n 的最小费用。 但本题不同之处在于 边的权值是有负数的。 +本题依然是单源最短路问题,求 从 节点1 到节点n 的最小费用。 **但本题不同之处在于 边的权值是有负数了**。 从 节点1 到节点n 的最小费用也可以是负数,费用如果是负数 则表示 运输的过程中 政府补贴大于运输成本。 在求单源最短路的方法中,使用dijkstra 的话,则要求图中边的权值都为正数。 -我们在 [kama47.参会dijkstra朴素](./kama47.参会dijkstra朴素.md) 中专门有讲解,为什么有边为负数 使用dijkstra就不行了。 +我们在 [dijkstra朴素版](./0047.参会dijkstra朴素.md) 中专门有讲解:为什么有边为负数 使用dijkstra就不行了。 -本题是经典的带负权值的单源最短路问题,此时就轮到Bellman_ford登场了,接下来我们来详细介绍Bellman_ford 算法 如何解决这类问题。 +**本题是经典的带负权值的单源最短路问题,此时就轮到Bellman_ford登场了**,接下来我们来详细介绍Bellman_ford 算法 如何解决这类问题。 > 该算法是由 R.Bellman 和L.Ford 在20世纪50年代末期发明的算法,故称为Bellman_ford算法。 @@ -67,7 +72,7 @@ 所以大家翻译过来,就是 “放松” 或者 “松弛” 。 -但《算法四》没有具体去讲这个 “放松” 究竟是个啥? 网上的题解也没有讲题解里的 “松弛这条边,松弛所有边”等等 里面的 “松弛” 究竟是什么意思? +但《算法四》没有具体去讲这个 “放松” 究竟是个啥? 网上很多题解也没有讲题解里的 “松弛这条边,松弛所有边”等等 里面的 “松弛” 究竟是什么意思? 这里我给大家举一个例子,每条边有起点、终点和边的权值。例如一条边,节点A 到 节点B 权值为value,如图: @@ -76,13 +81,13 @@ minDist[B] 表示 到达B节点 最小权值,minDist[B] 有哪些状态可以推出来? 状态一: minDist[A] + value 可以推出 minDist[B] -状态二: minDist[B]本身就有权值 (可能是其他边链接的节点B 例如节点C,以至于 dp[B]记录了其他边到dp[B]的权值) +状态二: minDist[B]本身就有权值 (可能是其他边链接的节点B 例如节点C,以至于 minDist[B]记录了其他边到minDist[B]的权值) -那么minDist[B] 应为如何取舍。 +minDist[B] 应为如何取舍。 本题我们要求最小权值,那么 这两个状态我们就取最小的 -``` +```CPP if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value ``` @@ -108,7 +113,7 @@ if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value **那么为什么是 n - 1次 松弛呢**? -这里要给大家模拟一遍 Bellman_ford 的算法才行,接下来我们来看看对所有边松弛 n -1 次的操作是什么样的。 +这里要给大家模拟一遍 Bellman_ford 的算法才行,接下来我们来看看对所有边松弛 n - 1 次的操作是什么样的。 我们依然使用**minDist数组来表达 起点到各个节点的最短距离**,例如minDist[3] = 5 表示起点到达节点3 的最小距离为5 @@ -204,19 +209,18 @@ if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value 那么无论图是什么样的,边是什么样的顺序,我们对所有边松弛 n-1 次 就一定能得到 起点到达 终点的最短距离。 -其实也同时计算出了,起点 到达 所有节点的最短距离,因为所有节点与起点连接的变数最多也就是 n-1条边。 +其实也同时计算出了,起点 到达 所有节点的最短距离,因为所有节点与起点连接的边数最多也就是 n-1 条边。 截止到这里,Bellman_ford 的核心算法思路,大家就了解的差不多了。 共有两个关键点。 -* “松弛”究竟是个啥 -* 为什么要对所有边松弛 n - 1 次 (n为节点个数) +* “松弛”究竟是个啥? +* 为什么要对所有边松弛 n - 1 次 (n为节点个数) ? 那么Bellman_ford的解题解题过程其实就是对所有边松弛 n-1 次,然后得出得到终点的最短路径。 - ### 代码 理解上面讲解的内容,代码就更容易写了,本题代码如下:(详细注释) @@ -271,7 +275,7 @@ int main() { grid数组是用来存图的,这是题目描述中必须要使用的空间,而不是我们算法所使用的空间。 -我们在讲空间复杂度的时候,一般都是说,我们这个算法的空间复杂度。 +我们在讲空间复杂度的时候,一般都是说,我们这个算法所用的空间复杂度。 ### 拓展 @@ -283,6 +287,7 @@ grid数组是用来存图的,这是题目描述中必须要使用的空间, 那么我们只要松弛 n - 1次 就一定能得到结果,没必要在松弛更多次了。 这里有疑惑的录友,可以加上打印 minDist数组 的日志,尝试一下,看看松弛 n 次会怎么样。 + 你会发现 松弛 大于 n - 1次,minDist数组 就不会变化了。 这里我给出打印日志的代码: @@ -336,9 +341,9 @@ int main() { ``` -通过打日志,大家发现,怎么对所有边进行第二次松弛以后结果就 不再变化了,那根本就不用松弛 n - 1啊? +通过打日志,大家发现,怎么对所有边进行第二次松弛以后结果就 不再变化了,那根本就不用松弛 n - 1 ? -这是本题的样例的特殊性, 松弛 n-1次 是保证对任何图 都能最后求得到终点的最小距离。 +这是本题的样例的特殊性, 松弛 n-1 次 是保证对任何图 都能最后求得到终点的最小距离。 如果还想不明白 我再举一个例子,用以下测试用例再跑一下。 @@ -367,11 +372,11 @@ int main() { 0 1 2 3 4 5 ``` -你会发现到 n-1 次 打印出最后的最短路结果。 +你会发现到 n-1 次 才打印出最后的最短路结果。 -关于上面的讲解,大家已经要多写代码去实验,验证自己的想法。 +关于上面的讲解,大家一定要多写代码去实验,验证自己的想法。 -至于 负权回路 ,我在下一篇会专门讲解这种情况,大家有个印象就好。 +**至于 负权回路 ,我在下一篇会专门讲解这种情况,大家有个印象就好**。 ## 总结 diff --git a/problems/kamacoder/0095.城市间货物运输II.md b/problems/kamacoder/0095.城市间货物运输II.md index 1dd14f58..3200efb3 100644 --- a/problems/kamacoder/0095.城市间货物运输II.md +++ b/problems/kamacoder/0095.城市间货物运输II.md @@ -1,7 +1,7 @@ -# 95. 城市间货物运输 II +# bellman_ford之判断负权回路 -[题目链接](https://kamacoder.com/problempage.php?pid=1153) +[卡码网:95. 城市间货物运输 II](https://kamacoder.com/problempage.php?pid=1153) 【题目描述】 diff --git a/problems/kamacoder/0096.城市间货物运输III.md b/problems/kamacoder/0096.城市间货物运输III.md index c9d97a9d..f7533e02 100644 --- a/problems/kamacoder/0096.城市间货物运输III.md +++ b/problems/kamacoder/0096.城市间货物运输III.md @@ -1,7 +1,7 @@ -# 96. 城市间货物运输 III +# bellman_ford之单源有限最短路 -[题目链接](https://kamacoder.com/problempage.php?pid=1154) +[卡码网:96. 城市间货物运输 III](https://kamacoder.com/problempage.php?pid=1154) 【题目描述】 @@ -560,26 +560,73 @@ int main() { 这又是为什么呢? -可以发现耗时主要是在 第8组数据上: - -![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240418114511.png) - -其实第八组数据是我特别制作的一个 稠密大图,该图有250个节点和10000条边, 在这种情况下, SPFA 的时间复杂度 是接近与 bellman_ford的。 +对于后台数据,我特别制作的一个稠密大图,该图有250个节点和10000条边, 在这种情况下, SPFA 的时间复杂度 是接近与 bellman_ford的。 但因为 SPFA 节点的进出队列操作,耗时很大,所以相同的时间复杂度的情况下,SPFA 实际上更耗时了。 这一点我在 [0094.城市间货物运输I-SPFA](./0094.城市间货物运输I-SPFA.md) 有分析,感兴趣的录友再回头去看看。 +## 拓展四(能否用dijkstra) + +本题能否使用 dijkstra 算法呢? + +dijkstra 是贪心的思路 每一次搜索都只会找距离源点最近的非访问过的节点。 + +如果限制最多访问k个节点,那么 dijkstra 未必能在有限次就能到达终点,即使在经过k个节点确实可以到达终点的情况下。 + +这么说大家会感觉有点抽象,我用 [dijkstra朴素版精讲](./0047.参会dijkstra朴素.md) 里的示例在举例说明: (如果没看过我讲的[dijkstra朴素版精讲](./0047.参会dijkstra朴素.md),建议去仔细看一下,否则下面讲解容易看不懂) + + +在以下这个图中,求节点1 到 节点7 最多经过2个节点 的最短路是多少呢? + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240508112249.png) + +最短路显然是: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240508112416.png) + +最多经过2个节点,也就是3条边相连的路线:节点1 -> 节点2 -> 节点6-> 节点7 + +如果是 dijkstra 求解的话,求解过程是这样的: (下面是dijkstra的模拟过程,我精简了很多,如果看不懂,一定要先看[dijkstra朴素版精讲](./0047.参会dijkstra朴素.md)) + +初始化如图所示: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130115306.png) + +找距离源点最近且没有被访问过的节点,先找节点1 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130115421.png) + + +距离源点最近且没有被访问过的节点,找节点2: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130121240.png) + +距离源点最近且没有被访问过的节点,找到节点3: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240130120434.png) + +距离源点最近且没有被访问过的节点,找到节点4: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240201105335.png) + +此时最多经过2个节点的搜索就完毕了,但结果中minDist[7] (即节点7的结果)并没有被更。 + +那么 dijkstra 会告诉我们 节点1 到 节点7 最多经过2个节点的情况下是不可到达的。 + +通过以上模拟过程,大家应该能感受到 dijkstra 贪心的过程,正是因为 贪心,所以 dijkstra 找不到 节点1 -> 节点2 -> 节点6-> 节点7 这条路径。 + ## 总结 本题是单源有限最短路问题,也是 bellman_ford的一个拓展问题,如果理解bellman_ford 其实思路比较容易理解,但有很多细节。 例如 为什么要用 minDist_copy 来记录上一轮 松弛的结果。 这也是本篇我为什么花了这么大篇幅讲解的关键所在。 -接下来,还给大家多了三个拓展: +接下来,还给大家做了四个拓展: * 边的顺序的影响 * 本题的本质 * SPFA的解法 +* 能否用dijkstra -学透了以上三个拓展,相信大家会对bellman_ford有更深入的理解。 +学透了以上四个拓展,相信大家会对bellman_ford有更深入的理解。 diff --git a/problems/kamacoder/0097.小明逛公园.md b/problems/kamacoder/0097.小明逛公园.md index b2afc482..7e699949 100644 --- a/problems/kamacoder/0097.小明逛公园.md +++ b/problems/kamacoder/0097.小明逛公园.md @@ -58,7 +58,7 @@ 通过本题,我们来系统讲解一个新的最短路算法-Floyd 算法。 -Floyd 算法对边的权值正负没有要求,都可以处理。 +**Floyd 算法对边的权值正负没有要求,都可以处理**。 Floyd算法核心思想是动态规划。 @@ -76,7 +76,7 @@ Floyd算法核心思想是动态规划。 那么这样我们是不是就找到了,子问题推导求出整体最优方案的递归关系呢。 -而节点1 到 节点9 的最短距离 可以由 节点1 到节点5的最短距离 + 节点5到节点9的最短距离组成, 也可以有 节点1 到节点7的最短距离 + 节点7 到节点9的最短距离的距离组成。 +节点1 到 节点9 的最短距离 可以由 节点1 到节点5的最短距离 + 节点5到节点9的最短距离组成, 也可以有 节点1 到节点7的最短距离 + 节点7 到节点9的最短距离的距离组成。 那么选哪个呢? @@ -100,11 +100,15 @@ Floyd算法核心思想是动态规划。 grid[i][j][k] = m,表示 节点i 到 节点j 以[1...k] 集合为中间节点的最短距离为m。 -可能有录友会想: 节点i 到 节点j 的最短距离为m,这句话可以理解,但 以[1...k]集合为中间节点 理解不辽。 +可能有录友会想,凭什么就这么定义呢? -节点i 到 节点j 的最短路径中 一定是经过很多节点,那么这个集合用[1...k] 来表示。 +节点i 到 节点j 的最短距离为m,这句话可以理解,但 以[1...k]集合为中间节点就理解不辽了。 -k不能单独指某个节点,因为谁说 节点i 到节点j的最短路径中 一定只有一个节点呢,所以k 一定要表示一个集合,即[1...k] ,表示节点1 到 节点k 一共k个节点的集合。 +节点i 到 节点j 的最短路径中 一定是经过很多节点,那么这个集合用[1...k] 来表示。 + +你可以反过来想,节点i 到 节点j 中间一定经过很多节点,那么你能用什么方式来表述中间这么多节点呢? + +所以 这里的k不能单独指某个节点,k 一定要表示一个集合,即[1...k] ,表示节点1 到 节点k 一共k个节点的集合。 2、确定递推公式 @@ -139,18 +143,20 @@ grid[i][j][k] = m,表示 节点i 到 节点j 以[1...k] 集合为中间节点 例如题目中只是输入边(节点2 -> 节点6,权值为3),那么grid[2][6][k] = 3,k需要填什么呢? -把k 填成1,那如何上来就知道 节点2 经过节点1 到达节点6的最短距离是3 呢。 +把k 填成1,那如何上来就知道 节点2 经过节点1 到达节点6的最短距离是多少 呢。 所以 只能 把k 赋值为 0,本题 节点0 是无意义的,节点是从1 到 n。 这样我们在下一轮计算的时候,就可以根据 grid[i][j][0] 来计算 grid[i][j][1],此时的 grid[i][j][1] 就是 节点i 经过节点1 到达 节点j 的最小距离了。 +grid数组是一个三维数组,那么我们初始化的数据在 i 与 j 构成的平层,如图: +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240425104247.png) -**初始化这里要画图,对后面的遍历顺序理解很重要** +红色的 底部一层是我们初始化好的数据,注意:从三维角度去看初始化的数据很重要,下面我们在聊遍历顺序的时候还会再讲。 -所以初始化: +所以初始化代码: ```CPP vector>> grid(n + 1, vector>(n + 1, vector(n + 1, 10005))); // C++定义了一个三位数组,10005是因为边的最大距离是10^4 @@ -167,7 +173,7 @@ grid数组中其他元素数值应该初始化多少呢? 本题求的是最小值,所以输入数据没有涉及到的节点的情况都应该初始为一个最大数。 -这样才不会影响,每次计算去最小值的时候,初始值对计算结果的影响。 +这样才不会影响,每次计算去最小值的时候 初始值对计算结果的影响。 所以grid数组的定义可以是: @@ -191,7 +197,7 @@ vector>> grid(n + 1, vector>(n + 1, vector(n 遍历的顺序是从底向上 一层一层去遍历。 -所以遍历k 的for循环一定是在最外面,这样才能 水平方向一层一层去遍历。如图: +所以遍历k 的for循环一定是在最外面,这样才能一层一层去遍历。如图: ![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240424120109.png) @@ -228,7 +234,7 @@ for (int i = 1; i <= n; i++) { ![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240424115827.png) -而我们初始化,是 k 为0,然后 i 和 j 形成的平面做初始化,如果以 k 和 j 形成的平面去遍历,就造成了 递推公式 用不上上一轮计算的结果,从而导致结果不对(初始化的结果只能用上一部分,因为初始化是 i 与j 形成的平面)。 +而我们初始化的数据 是 k 为0, i 和 j 形成的平面做初始化,如果以 k 和 j 形成的平面去一层一层遍历,就造成了 递推公式 用不上上一轮计算的结果,从而导致结果不对(初始化的部分是 i 与j 形成的平面,在初始部分有讲过)。 我再给大家举一个测试用例 @@ -246,23 +252,54 @@ for (int i = 1; i <= n; i++) { ![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240424120942.png) -就节点1 到 节点 2 的最短距离,运行结果是 10 ,但正确的结果很明显是3。 +求节点1 到 节点 2 的最短距离,运行结果是 10 ,但正确的结果很明显是3。 为什么呢? -因为 k 放在最里面,先就把 节点1 和 节点 2 的最短距离就确定了,后面再也不会计算节点 1 和 节点 2的距离,同时也不会基于 初始化或者之前计算过的结果来计算,即不会考虑 节点1 到 节点3, 节点3 到节点 4,节点4到节点2 的距离。 +因为 k 放在最里面,先就把 节点1 和 节点 2 的最短距离就确定了,后面再也不会计算节点 1 和 节点 2的距离,同时也不会基于 初始化或者之前计算过的结果来计算,即:不会考虑 节点1 到 节点3, 节点3 到节点 4,节点4到节点2 的距离。 + + +造成这一原因,是 在三维立体坐标中, 我们初始化的是 i 和 i 在k 为0 所构成的平面,但遍历的时候 是以 j 和 k构成的平面以 i 为垂直方向去层次遍历。 而遍历k 的for循环如果放在中间呢,同样是 j 与k 行程一个平面,i 是纵面,遍历的也是这样: ![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240424115827.png) - 同样不能完全用上初始化 和 上一层计算的结果。 -很多录友对于 floyd算法的遍历顺序搞不懂,其实 是没有从三维的角度去思考,同时我把三维立体图给大家画出来,遍历顺序标出来,大家就很容易想明白,为什么 k 放在最外层 才能用上 初始化和上一轮计算的结果了。 +根据这个情况再举一个例子: + +``` +5 2 +1 2 1 +2 3 10 +1 +1 3 +``` + +图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240425112636.png) + +求 节点1 到节点3 的最短距离,如果k循环放中间,程序的运行结果是 -1,也就是不能到达节点3。 + +在计算 grid[i][j][k] 的时候,需要基于 grid[i][k][k-1] 和 grid[k][j][k-1]的数值。 + +也就是 计算 grid[1][3][2] (表示节点1 到 节点3,经过节点2) 的时候,需要基于 grid[1][2][1] 和 grid[2][3][1]的数值,而 我们初始化,只初始化了 k为0 的那一层。 + +造成这一原因 依然是 在三维立体坐标中, 我们初始化的是 i 和 j 在k 为0 所构成的平面,但遍历的时候 是以 j 和 k构成的平面以 i 为垂直方向去层次遍历。 +很多录友对于 floyd算法的遍历顺序搞不懂,**其实 是没有从三维的角度去思考**,同时我把三维立体图给大家画出来,遍历顺序标出来,大家就很容易想明白,为什么 k 放在最外层 才能用上 初始化和上一轮计算的结果了。 + +5、举例推导dp数组 + +这里涉及到 三维矩阵,可以一层一层打印出来去分析,例如k=0 的这一层,k = 1的这一层,但一起把三维带数据的图画出来其实不太好画。 + +## 代码如下 + +以上分析完毕,最后代码如下: ```CPP @@ -300,42 +337,43 @@ int main() { } } +``` + +## 空间优化 + +这里 我们可以做一下 空间上的优化,从滚动数组的角度来看,我们定义一个 grid[n + 1][ n + 1][2] 这么大的数组就可以,因为k 只是依赖于 k-1的状态,并不需要记录k-2,k-3,k-4 等等这些状态。 + +那么我们只需要记录 grid[i][j][1] 和 grid[i][j][0] 就好,之后就是 grid[i][j][1] 和 grid[i][j][0] 交替滚动。 + +在进一步想,如果本层计算(本层计算即k相同,从三维角度来讲) gird[i][j] 用到了 本层中刚计算好的 grid[i][k] 会有什么问题吗? + +如果 本层刚计算好的 grid[i][k] 比上一层 (即k-1层)计算的 grid[i][k] 小,说明确实有 i 到 k 的更短路径,那么基于 更小的 grid[i][k] 去计算 gird[i][j] 没有问题。 + +如果 本层刚计算好的 grid[i][k] 比上一层 (即k-1层)计算的 grid[i][k] 大, 这不可能,因为这样也不会做更新 grid[i][k]的操作。 + +所以本层计算中,使用了本层计算过的 grid[i][k] 和 grid[k][j] 是没问题的。 + +那么就没必要区分,grid[i][k] 和 grid[k][j] 是 属于 k - 1 层的呢,还是 k 层的。 + +所以递归公式可以为: + +```CPP +grid[i][j] = min(grid[i][j], grid[i][k] + grid[k][j]); ``` +基于二维数组的本题代码为: -# 拓展 负权回路 - -本题可以有负数,但不能出现负权回路 - ---------- - -floyd n^3 - -同样多源汇最短路算法 Floyd 也是基于动态规划 - -Floyd 算法可以用来解决多源最短路径问题,它会计算图中每两个点之间的最短路径。 - - Floyd 算法对边权的正负没有限制要求(可处理正负权边的图),且能利用 Floyd 算法可能够对图中负环进行判定 - -LeetCode-1334. 阈值距离内邻居最少的城市 - -https://leetcode.cn/problems/find-the-city-with-the-smallest-number-of-neighbors-at-a-threshold-distance/description/ - ------------ - - -```CPP +```CPP #include #include -#include using namespace std; int main() { int n, m, p1, p2, val; cin >> n >> m; - vector> grid(n, vector(n, 10005)); // 因为边的最大距离是10^4 + vector> grid(n + 1, vector(n + 1, 10005)); // 因为边的最大距离是10^4 for(int i = 0; i < m; i++){ cin >> p1 >> p2 >> val; @@ -344,10 +382,10 @@ int main() { } // 开始 floyd - for (int p = 0; p < n; p++) { - for (int i = 0; i < n; i++) { - for (int j = 0; j < n; j++) { - grid[i][j] = min(grid[i][j], grid[i][p] + grid[p][j]); + for (int k = 1; k <= n; k++) { + for (int i = 1; i <= n; i++) { + for (int j = 1; j <= n; j++) { + grid[i][j] = min(grid[i][j], grid[i][k] + grid[k][j]); } } } @@ -355,13 +393,31 @@ int main() { int z, start, end; cin >> z; while (z--) { - cin >> start >> end; + cin >> start >> end; if (grid[start][end] == 10005) cout << -1 << endl; else cout << grid[start][end] << endl; } } - ``` +* 时间复杂度: O(n^3) +* 空间复杂度:O(n^2) + +## 总结 + +本期如果上来只用二维数组来讲的话,其实更容易,但遍历顺序那里用二维数组其实是讲不清楚的,所以我直接用三维数组来讲,目的是将遍历顺序这里讲清楚。 + +理解了遍历顺序才是floyd算法最精髓的地方。 + + +floyd算法的时间复杂度相对较高,适合 稠密图且源点较多的情况。 + +如果是稀疏图,floyd是从节点的角度去计算了,例如 图中节点数量是 1000,就一条边,那 floyd的时间复杂度依然是 O(n^3) 。 + +如果 源点少,其实可以 多次dijsktra 求源点到终点。 + + + + diff --git a/problems/kamacoder/0098.所有可达路径.md b/problems/kamacoder/0098.所有可达路径.md new file mode 100644 index 00000000..0aa12fb6 --- /dev/null +++ b/problems/kamacoder/0098.所有可达路径.md @@ -0,0 +1,471 @@ + +# 98. 所有可达路径 + +[卡码网题目链接(ACM模式)](https://kamacoder.com/problempage.php?pid=1170) + +【题目描述】 + +给定一个有 n 个节点的有向无环图,节点编号从 1 到 n。请编写一个函数,找出并返回所有从节点 1 到节点 n 的路径。每条路径应以节点编号的列表形式表示。 + +【输入描述】 + +第一行包含两个整数 N,M,表示图中拥有 N 个节点,M 条边 + +后续 M 行,每行包含两个整数 s 和 t,表示图中的 s 节点与 t 节点中有一条路径 + +【输出描述】 + +输出所有的可达路径,路径中所有节点的后面跟一个空格,每条路径独占一行,存在多条路径,路径输出的顺序可任意。 + +如果不存在任何一条路径,则输出 -1。 + +【输入示例】 + +``` +5 5 +1 3 +3 5 +1 2 +2 4 +4 5 +``` + +【输出示例】 + +``` +1 3 5 +1 2 4 5 +``` + +提示信息 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240514103953.png) + +用例解释: + +有五个节点,其中的从 1 到达 5 的路径有两个,分别是 1 -> 3 -> 5 和 1 -> 2 -> 4 -> 5。 + +因为拥有多条路径,所以输出结果为: + +``` +1 3 5 +1 2 4 5 +``` + +或 + +``` +1 2 4 5 +1 3 5 +``` + +都算正确。 + +数据范围: + +* 图中不存在自环 +* 图中不存在平行边 +* 1 <= N <= 100 +* 1 <= M <= 500 + + +## 插曲 + +这道题目是深度优先搜索,比较好的入门题。 + +如果对深度优先搜索还不够了解,可以先看这里:[深度优先搜索的理论基础](https://programmercarl.com/图论深搜理论基础.html) + +我依然总结了深搜三部曲,如果按照代码随想录刷题的录友,应该刷过 二叉树的递归三部曲,回溯三部曲。 + +**大家可能有疑惑,深搜 和 二叉树和回溯算法 有什么区别呢**? 什么时候用深搜 什么时候用回溯? + +我在讲解[二叉树理论基础](https://programmercarl.com/二叉树理论基础.html)的时候,提到过,**二叉树的前中后序遍历其实就是深搜在二叉树这种数据结构上的应用**。 + +那么回溯算法呢,**其实 回溯算法就是 深搜,只不过 我们给他一个更细分的定义,叫做回溯算法**。 + +那有的录友可能说:那我以后称回溯算法为深搜,是不是没毛病? + +理论上来说,没毛病,但 就像是 二叉树 你不叫它二叉树,叫它数据结构,有问题不? 也没问题对吧。 + +建议是 有细分的场景,还是称其细分场景的名称。 所以回溯算法可以独立出来,但回溯确实就是深搜。 + +## 图的存储 + +在[图论理论基础篇]() + + + +## 深度优先搜索 + +接下来我们使用深搜三部曲来分析题目: + +1. 确认递归函数,参数 + +首先我们dfs函数一定要存一个图,用来遍历的,还要存一个目前我们遍历的节点,定义为x + +至于 单一路径,和路径集合可以放在全局变量,那么代码是这样的: + +```CPP +vector> result; // 收集符合条件的路径 +vector path; // 0节点到终点的路径 +// x:目前遍历的节点 +// graph:存当前的图 +void dfs (vector>& graph, int x) +``` + +2. 确认终止条件 + +什么时候我们就找到一条路径了? + +当目前遍历的节点 为 最后一个节点的时候 就找到了一条 从出发点到终止点的路径。 + +----------- +当前遍历的节点,我们定义为x,最后一点节点 就是 graph.size() - 1(因为题目描述是找出所有从节点 0 到节点 n-1 的路径并输出)。 + +所以 但 x 等于 graph.size() - 1 的时候就找到一条有效路径。 代码如下: + +------- + +```CPP +// 要求从节点 0 到节点 n-1 的路径并输出,所以是 graph.size() - 1 +if (x == graph.size() - 1) { // 找到符合条件的一条路径 + result.push_back(path); // 收集有效路径 + return; +} +``` + +3. 处理目前搜索节点出发的路径 + +接下来是走 当前遍历节点x的下一个节点。 + +首先是要找到 x节点链接了哪些节点呢? 遍历方式是这样的: + +```c++ +for (int i = 0; i < graph[x].size(); i++) { // 遍历节点n链接的所有节点 +``` + +接下来就是将 选中的x所连接的节点,加入到 单一路径来。 + +```C++ +path.push_back(graph[x][i]); // 遍历到的节点加入到路径中来 + +``` + +一些录友可以疑惑这里如果找到x 链接的节点的,例如如果x目前是节点0,那么目前的过程就是这样的: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20221204111937.png) + +二维数组中,graph[x][i] 都是x链接的节点,当前遍历的节点就是 `graph[x][i]` 。 + +进入下一层递归 + +```CPP +dfs(graph, graph[x][i]); // 进入下一层递归 +``` + +最后就是回溯的过程,撤销本次添加节点的操作。 该过程整体代码: + +```CPP +for (int i = 0; i < graph[x].size(); i++) { // 遍历节点n链接的所有节点 + path.push_back(graph[x][i]); // 遍历到的节点加入到路径中来 + dfs(graph, graph[x][i]); // 进入下一层递归 + path.pop_back(); // 回溯,撤销本节点 +} +``` + +本题整体代码如下: + +```CPP +class Solution { +private: + vector> result; // 收集符合条件的路径 + vector path; // 0节点到终点的路径 + // x:目前遍历的节点 + // graph:存当前的图 + void dfs (vector>& graph, int x) { + // 要求从节点 0 到节点 n-1 的路径并输出,所以是 graph.size() - 1 + if (x == graph.size() - 1) { // 找到符合条件的一条路径 + result.push_back(path); + return; + } + for (int i = 0; i < graph[x].size(); i++) { // 遍历节点n链接的所有节点 + path.push_back(graph[x][i]); // 遍历到的节点加入到路径中来 + dfs(graph, graph[x][i]); // 进入下一层递归 + path.pop_back(); // 回溯,撤销本节点 + } + } +public: + vector> allPathsSourceTarget(vector>& graph) { + path.push_back(0); // 无论什么路径已经是从0节点出发 + dfs(graph, 0); // 开始遍历 + return result; + } +}; + +``` + +## 总结 + +本题是比较基础的深度优先搜索模板题,这种有向图路径问题,最合适使用深搜,当然本题也可以使用广搜,但广搜相对来说就麻烦了一些,需要记录一下路径。 + +而深搜和广搜都适合解决颜色类的问题,例如岛屿系列,其实都是 遍历+标记,所以使用哪种遍历都是可以的。 + +至于广搜理论基础,我们在下一篇在好好讲解,敬请期待! + +## 其他语言版本 + +### Java + +```Java +// 深度优先遍历 +class Solution { + List> ans; // 用来存放满足条件的路径 + List cnt; // 用来保存 dfs 过程中的节点值 + + public void dfs(int[][] graph, int node) { + if (node == graph.length - 1) { // 如果当前节点是 n - 1,那么就保存这条路径 + ans.add(new ArrayList<>(cnt)); + return; + } + for (int index = 0; index < graph[node].length; index++) { + int nextNode = graph[node][index]; + cnt.add(nextNode); + dfs(graph, nextNode); + cnt.remove(cnt.size() - 1); // 回溯 + } + } + + public List> allPathsSourceTarget(int[][] graph) { + ans = new ArrayList<>(); + cnt = new ArrayList<>(); + cnt.add(0); // 注意,0 号节点要加入 cnt 数组中 + dfs(graph, 0); + return ans; + } +} +``` + +### Python + +```python +class Solution: + def __init__(self): + self.result = [] + self.path = [0] + + def allPathsSourceTarget(self, graph: List[List[int]]) -> List[List[int]]: + if not graph: return [] + + self.dfs(graph, 0) + return self.result + + def dfs(self, graph, root: int): + if root == len(graph) - 1: # 成功找到一条路径时 + # ***Python的list是mutable类型*** + # ***回溯中必须使用Deep Copy*** + self.result.append(self.path[:]) + return + + for node in graph[root]: # 遍历节点n的所有后序节点 + self.path.append(node) + self.dfs(graph, node) + self.path.pop() # 回溯 +``` + + +### JavaScript +```javascript +var allPathsSourceTarget = function(graph) { + let res=[],path=[] + + function dfs(graph,start){ + if(start===graph.length-1){ + res.push([...path]) + return; + } + for(let i=0;i>) -> Vec> { + let (mut res, mut path) = (vec![], vec![0]); + Self::dfs(&graph, &mut path, &mut res, 0); + res + } + + pub fn dfs(graph: &Vec>, path: &mut Vec, res: &mut Vec>, node: usize) { + if node == graph.len() - 1 { + res.push(path.clone()); + return; + } + for &v in &graph[node] { + path.push(v); + Self::dfs(graph, path, res, v as usize); + path.pop(); + } + } +} +``` + +

+ + + + + +邻接矩阵 + +```CPP +#include +#include +using namespace std; +vector> result; // 收集符合条件的路径 +vector path; // 1节点到终点的路径 + +void dfs (const vector>& graph, int x, int n) { + + // 要求从节点 1 到节点 n 的路径并输出,所以是 graph.size() + if (x == n) { // 找到符合条件的一条路径 + result.push_back(path); + return; + } + + for (int i = 1; i <= n; i++) { // 遍历节点x链接的所有节点 + if (graph[x][i] == 1) { // 找到 x链接的节点 + path.push_back(i); // 遍历到的节点加入到路径中来 + dfs(graph, i, n); // 进入下一层递归 + path.pop_back(); // 回溯,撤销本节点 + } + } +} + +int main() { + + + int n, m, s, t; + + cin >> n >> m; + + // 节点编号从1到n,所以申请 n+1 这么大的数组 + vector> graph(n + 1, vector(n + 1, 0)); + + while (m--) { + cin >> s >> t; + // 使用临近矩阵 表示无线图,1 表示 s 与 t 是相连的 + graph[s][t] = 1; + } + path.push_back(1); // 无论什么路径已经是从0节点出发 + + dfs(graph, 1, n); // 开始遍历 + + // 输出结果 + if (result.size() == 0) cout << -1 << endl; + for (const vector &pa : result) { + for (int i = 0; i < pa.size(); i++) { + cout << pa[i] << " "; + } + cout << endl; + } +} +``` + + + +邻接表 + +```CPP +#include +#include +#include +using namespace std; + +vector> result; // 收集符合条件的路径 +vector path; // 1节点到终点的路径 + +void dfs (const vector>& graph, int x, int n) { + + // 要求从节点 1 到节点 n 的路径并输出,所以是 graph.size() + if (x == n) { // 找到符合条件的一条路径 + result.push_back(path); + return; + } + for (int i : graph[x]) { // 找到 x链接的节点 + path.push_back(i); // 遍历到的节点加入到路径中来 + dfs(graph, i, n); // 进入下一层递归 + path.pop_back(); // 回溯,撤销本节点 + } +} + +int main() { + + + int n, m, s, t; + + cin >> n >> m; + + // 节点编号从1到n,所以申请 n+1 这么大的数组 + vector> graph(n + 1); // 邻接表 + while (m--) { + cin >> s >> t; + // 使用邻接表 ,表示 s -> t 是相连的 + graph[s].push_back(t); + + } + + path.push_back(1); // 无论什么路径已经是从0节点出发 + + dfs(graph, 1, n); // 开始遍历 + + //输出结果 + if (result.size() == 0) cout << -1 << endl; + for (const vector &pa : result) { + for (int i = 0; i < pa.size(); i++) { + cout << pa[i] << " "; + } + cout << endl; + } +} + +``` diff --git a/problems/图论并查集理论基础.md b/problems/kamacoder/图论并查集理论基础.md similarity index 100% rename from problems/图论并查集理论基础.md rename to problems/kamacoder/图论并查集理论基础.md diff --git a/problems/图论广搜理论基础.md b/problems/kamacoder/图论广搜理论基础.md similarity index 100% rename from problems/图论广搜理论基础.md rename to problems/kamacoder/图论广搜理论基础.md diff --git a/problems/kamacoder/图论总结篇.md b/problems/kamacoder/图论总结篇.md new file mode 100644 index 00000000..bee1001d --- /dev/null +++ b/problems/kamacoder/图论总结篇.md @@ -0,0 +1,31 @@ + +# 图论总结篇 + +从深搜广搜 到并查集,从最小生成树到拓扑排序, 最后是最短路算法系列。 + +至此算上本篇,一共30篇文章,图论之旅就在此收官了。 + + +## 深搜与广搜 + +深搜与广搜是图论里基本的搜索方法,大家需要掌握三点: + +* 搜索方式:深搜是可一个方向搜,不到黄河不回头。 广搜是围绕这起点一圈一圈的去搜。 +* 代码模板:需要熟练掌握深搜和广搜的基本写法。 +* 应用场景:图论题目基本上可以即用深搜也可以广搜,无疑是用哪个方便而已 + +深搜注意事项 + +广搜注意事项 + +## 并查集 + +## 最小生成树 + +## 拓扑排序 + +## 最短路算法 + + + +算法4,只讲解了 Dijkstra,SPFA (Bellman-Ford算法基于队列) 和 拓扑排序, diff --git a/problems/图论深搜理论基础.md b/problems/kamacoder/图论深搜理论基础.md similarity index 100% rename from problems/图论深搜理论基础.md rename to problems/kamacoder/图论深搜理论基础.md diff --git a/problems/kamacoder/图论理论基础.md b/problems/kamacoder/图论理论基础.md new file mode 100644 index 00000000..be42a5fc --- /dev/null +++ b/problems/kamacoder/图论理论基础.md @@ -0,0 +1,222 @@ + +# 图论理论基础 + +这一篇我们正式开始图论! + +## 图的基本概念 + +二维坐标中,两点可以连成线,多个点连成的线就构成了图。 + +当然图也可以就一个节点,甚至没有节点(空图) + +### 图的种类 + +整体上一般分为 有向图 和 无向图。 + +有向图是指 图中边是有方向的: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510195737.png) + +无向图是指 图中边没有方向: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510195451.png) + +加权有向图,就是图中边是有权值的,例如: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240510195821.png) + +加权无向图也是同理。 + +### 度 + +无向图中有几条边连接该节点,该节点就有几度。 + +例如,该无向图中,节点4的度为5,节点6的度为3。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240511115029.png) + +在有向图中,每个节点有出度和入度。 + +出度:从该节点出发的边的个数。 + +入度:指向该节点边的个数。 + +例如,该有向图中,节点3的入度为2,出度为1,节点1的入度为0,出度为2。 + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240511115235.png) + + +## 连通性 + +在图中表示节点的连通情况,我们称之为连通性。 + +### 连通图 + +在无向图中,任何两个节点都是可以到达的,我们称之为连通图 ,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240511102351.png) + +如果有节点不能到达其他节点,则为非连通图,如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240511102449.png) + +节点1 不能到达节点4。 + +### 强连通图 + +在有向图中,任何两个节点是可以相互到达的,我们称之为 强连通图。 + +这里有录友可能想,这和无向图中的连通图有什么区别,不是一样的吗? + +我们来看这个有向图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240511104531.png) + +这个图是强连通图吗? + +初步一看,好像这节点都连着呢,但这不是强连通图,节点1 可以到节点5,但节点5 不能到 节点1 。 + +强连通图是在有向图中**任何两个节点是可以相互到达** + +下面这个有向图才是强连通图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240511113101.png) + + +### 连通分量 + +在无向图中的极大连通子图称之为该图的一个连通分量。 + +只看概念大家可能不理解,我来画个图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240511111559.png) + +该无向图中 节点1、节点2、节点5 构成的子图就是 该无向图中的一个连通分量,该子图所有节点都是相互可达到的。 + +同理,节点3、节点4、节点6 构成的子图 也是该无向图中的一个连通分量。 + +那么无向图中 节点3 、节点4 构成的子图 是该无向图的联通分量吗? + +不是! + +因为必须是极大联通子图才能是连通分量,所以 必须是节点3、节点4、节点6 构成的子图才是连通分量。 + +在图论中,连通分量是一个很重要的概念,例如岛屿问题(后面章节会有专门讲解)其实就是求连通分量。 + +### 强连通分量 + +在有向图中极大强连通子图称之为该图的强连通分量。 + +如图: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240511112951.png) + +节点1、节点2、节点3、节点4、节点5 构成的子图是强连通分量,因为这是强连通图,也是极大图。 + +节点6、节点7、节点8 构成的子图 不是强连通分量,因为这不是强连通图,节点8 不能达到节点6。 + +节点1、节点2、节点5 构成的子图 也不是 强连通分量,因为这不是极大图。 + + +## 图的构造 + +我们如何用代码来表示一个图呢? + +一般使用邻接表、邻接矩阵 或者用类来表示。 + +主流是 邻接表和邻接矩阵。 + +### 邻接矩阵 + +邻接矩阵 使用 二维数组来表示图结构。 邻接矩阵是从节点的角度来表示图,有多少节点就申请多大的二维数组。 + +例如: grid[2][5] = 6,表示 节点 2 连接 节点5 为有向图,节点2 指向 节点5,边的权值为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表示某节点连接其他节点的数量。 +* 实现相对复杂,不易理解 + + +**以上大家可能理解比较模糊,没关系**,因为大家还没做过图论的题目,对于图的表达没有概念。 + +这里我先不给出具体的实现代码,大家先有个初步印象,在后面算法题实战中,我还会讲到具体代码实现,等带大家做算法题,写了代码之后,自然就理解了。 + +## 图的遍历方式 + +图的遍历方式基本是两大类: + +* 深度优先搜索(dfs) +* 广度优先搜索(bfs) + +在讲解二叉树章节的时候,其实就已经讲过这两种遍历方式。 + +二叉树的递归遍历,是dfs 在二叉树上的遍历方式。 + +二叉树的层序遍历,是bfs 在二叉树上的遍历方式。 + +dfs 和 bfs 一种搜索算法,可以在不同的数据结构上进行搜索,在二叉树章节里是在二叉树这样的数据结构上搜索。 + +而在图论章节,则是在图(邻接表或邻接矩阵)上进行搜索。 + +## 总结 + +以上知识点 大家先有个印象,上面提到的每个知识点,其实都需要大篇幅才能讲明白的。 + +我这里先给大家做一个概括,后面章节会针对每个知识点都会有对应的算法题和针对性的讲解,大家再去深入学习。 + +图论是非常庞大的知识体系,上面的内容还不足以概括图论内容,仅仅是理论基础而已。 + +在图论章节我会带大家深入讲解 深度优先搜索(DFS)、广度优先搜索(BFS)、并查集、拓扑排序、最小生成树系列、最短路算法系列等等。 + +敬请期待! + + diff --git a/problems/kamacoder/最短路问题总结篇.md b/problems/kamacoder/最短路问题总结篇.md new file mode 100644 index 00000000..7f4ee6f8 --- /dev/null +++ b/problems/kamacoder/最短路问题总结篇.md @@ -0,0 +1,48 @@ + +# 最短路算法总结篇 + +至此已经讲解了四大最短路算法,分别是Dijkstra、Bellman_ford、SPFA 和 Floyd。 + +针对这四大最短路算法,我用了七篇长文才彻底讲清楚,分别是: + +* dijkstra朴素版 +* dijkstra堆优化版 +* Bellman_ford +* Bellman_ford 队列优化算法(又名SPFA) +* bellman_ford 算法判断负权回路 +* bellman_ford之单源有限最短路 +* Floyd 算法精讲 + + +最短路算法比较复杂,而且各自有各自的应用场景,我来用一张表把讲过的最短路算法的使用场景都展现出来: + +![](https://code-thinking-1253855093.file.myqcloud.com/pics/20240508121355.png) + + +可能有同学感觉:这个表太复杂了,我记也记不住。 + +其实记不住的原因还是对 这几个最短路算法没有深刻的理解。 + +这里我给大家一个大体使用场景的分析: + +如果遇到单源且边为正数,直接Dijkstra。 + +至于 使用朴素版还是 堆优化版 还是取决于图的稠密度, 多少节点多少边算是稠密图,多少算是稀疏图,这个没有量化,如果想量化只能写出两个版本然后做实验去测试,不同的判题机得出的结果还不太一样。 + +一般情况下,可以直接用堆优化版本。 + +如果遇到单源边可为负数,直接 Bellman-Ford,同样 SPFA 还是 Bellman-Ford 取决于图的稠密度。 + +一般情况下,直接用 SPFA。 + +如果有负权回路,优先 Bellman-Ford, 如果是有限节点最短路 也优先 Bellman-Ford,理由是写代码比较方便。 + +如果是遇到多源点求最短路,直接 Floyd。 + +除非 源点特别少,且边都是正数,那可以 多次 Dijkstra 求出最短路径,但这种情况很少,一般出现多个源点了,就是想让你用 Floyd 了。 + + + + + + diff --git a/problems/前序/什么是核心代码模式,什么又是ACM模式?.md b/problems/前序/ACM模式.md similarity index 100% rename from problems/前序/什么是核心代码模式,什么又是ACM模式?.md rename to problems/前序/ACM模式.md diff --git a/problems/前序/刷了这么多题,你了解自己代码的内存消耗么?.md b/problems/前序/内存消耗.md similarity index 100% rename from problems/前序/刷了这么多题,你了解自己代码的内存消耗么?.md rename to problems/前序/内存消耗.md diff --git a/problems/前序/力扣上的代码想在本地编译运行?.md b/problems/前序/力扣上的代码在本地编译运行.md similarity index 100% rename from problems/前序/力扣上的代码想在本地编译运行?.md rename to problems/前序/力扣上的代码在本地编译运行.md diff --git a/problems/前序/关于时间复杂度,你不知道的都在这里!.md b/problems/前序/时间复杂度.md similarity index 100% rename from problems/前序/关于时间复杂度,你不知道的都在这里!.md rename to problems/前序/时间复杂度.md diff --git a/problems/前序/关于空间复杂度,可能有几个疑问?.md b/problems/前序/空间复杂度.md similarity index 100% rename from problems/前序/关于空间复杂度,可能有几个疑问?.md rename to problems/前序/空间复杂度.md diff --git a/problems/前序/On的算法居然超时了,此时的n究竟是多大?.md b/problems/前序/算法超时.md similarity index 100% rename from problems/前序/On的算法居然超时了,此时的n究竟是多大?.md rename to problems/前序/算法超时.md diff --git a/problems/前序/通过一道面试题目,讲一讲递归算法的时间复杂度!.md b/problems/前序/递归算法的时间复杂度.md similarity index 100% rename from problems/前序/通过一道面试题目,讲一讲递归算法的时间复杂度!.md rename to problems/前序/递归算法的时间复杂度.md