文章目录
- 图的概念
- 图的存储方式
- DFS
- BFS
- 有向无环图的拓扑序列
- 最短路径问题
-
- 单源最短路径问题
- [朴素 Dijkstra 算法 O ( n 2 + m ) O(n^2+m) O(n2+m)](#朴素 Dijkstra 算法 O ( n 2 + m ) O(n^2+m) O(n2+m))
- [堆优化 Dijkstra 算法 O ( ( n + m ) l o g n ) O((n+m)logn) O((n+m)logn)](#堆优化 Dijkstra 算法 O ( ( n + m ) l o g n ) O((n+m)logn) O((n+m)logn))
- [Bellman-Ford 算法 O ( n m ) O(nm) O(nm)](#Bellman-Ford 算法 O ( n m ) O(nm) O(nm))
- [SPFA O ( k m ) / O ( n m ) O(km)/O(nm) O(km)/O(nm)](#SPFA O ( k m ) / O ( n m ) O(km)/O(nm) O(km)/O(nm))
- 相关题目
- 最小生成树
-
- [朴素 Prim 算法 O ( n 2 + m ) O(n^2 + m) O(n2+m)](#朴素 Prim 算法 O ( n 2 + m ) O(n^2 + m) O(n2+m))
- [堆优化 Prim 算法 O ( ( n + m ) l o g n ) O((n+m)logn) O((n+m)logn)](#堆优化 Prim 算法 O ( ( n + m ) l o g n ) O((n+m)logn) O((n+m)logn))
- [Kruskal 算法 O ( m l o g m ) O(mlogm) O(mlogm)](#Kruskal 算法 O ( m l o g m ) O(mlogm) O(mlogm))
- 相关题目
- 二分图
阅读前导
本文默认读者有数据结构和图论基础,本文是对图论的几个代表性算法的入门,虽然题目的解法比较朴素,但是比较好理解。
图的概念
首先简单复习一下离散数学中图论的相关概念。
图的概念
图是由顶点和边组成,顶点一般表示对象,边一般表示对象之间的关系。
在图论中,多个顶点或边组成的集合叫做顶点集(Vertices Set)或边集(Edges Set)。例如,图 G 可以写成 G= (V, E),其中 V 是图 G 的顶点集,E 是图 G 的边集。
树是一种特殊的图。
图的分类
有向图和无向图
根据边是否有方向,可以将图分为有向图和无向图。
无向图:

有向图:

通常情况下,只对有向图进行讨论,因为无向图的每一条无向边相当于两条方向相反的有向边组成的。
连通性
- 无向图的连通性:如果无向图中任意两个顶点之间都有一条无向路径,则称该图为连通图。
- 有向图的连通性:如果有向图中任意两个顶点之间都有一条有向路径,则称该图为强连通图。 如果将有向图的所有边替换成无向边后得到一个连通图,则称该有向图为弱连通图。
连通块
连通块是指无向图中的一个子图,它满足以下两个条件:
- 子图中的任意两个顶点都能通过路径相连,即可以沿着图中的边互相可达。
- 子图中的所有顶点都不和原图中的其他顶点连通,即子图是原图的一个独立部分。
当左边灰色区域中最右边的节点被移除时,这个图就变得不连通了:

当虚线边被移除时,这个图就会不连通:

有顶点 0,这个图就是非连通的。该图的其余部分是连通的:

重边和自环
重边是两条或多条与同一对顶点相连接的边。例如:

自环是一条顶点与自身连接的边。例如顶点 1:

稠密图和稀疏图
若一张图的边数远小于其点数的平方,那么它是一张稀疏图 (sparse graph)。
若一张图的边数接近其点数的平方,那么它是一张稠密图 (dense graph)。
区分稠密图和稀疏图的主要依据是看题目给的数据是否呈以上两种关系之一,这么做的原因是算法在稠密图和稀疏图中的效率不同。
参考资料
图的存储方式
在计算机中,图的存储就是用数据结构来表示图的顶点集和边集的方法。根据图的稀疏或稠密,主要分为邻接表或邻接矩阵。
邻接表
用一个一维数组和一个链表来存储图中顶点和边的信息,一维数组中的每个元素对应一个顶点,每个元素指向一个链表,链表中存储与该顶点相邻的顶点或者边的权重(很像哈希桶)。邻接表适合表示稀疏图,即边数较少的图,空间复杂度为 O ( N + E ) O (N+E) O(N+E),其中 N N N为顶点数, E E E为边数。
可以用 链式前向星 来存储邻接表。
无向图,链表记录的是顶点的邻居结点:
有向图,链表记录的是顶点的出度:
由于我们解决的问题主要是关于"路径长度"的问题,枚举每一条边,就是枚举每个顶点的出边。因此研究的单位应该是边,一条边需要两个点和一个边长来表示,分别用三个数组来存储:
head[]
:存储每个顶点ver[]
:head[i] 这个点指向的终点edge[]
:head[i] 点指向 ver[i] 这条边的长度
除此之外,邻接表本身是一个链表,而链表的实现有几种,在算法题目中通常用占用内存较小的数组模拟逻辑上的链表。
next[]
:记录边集数组的下标
以上面这个有向图为例,四个数组的关系是这样的:
在分析时,应该注意每个数组的含义,例如从 head[i] 这个顶点出发,到 ver[i] 这个边,边长为 edge[i],下一个结点的位置是 next[i]。
代码
cpp
const N = 100010, M = N * 2; // 无向图需要两条有向边
int head[N], ver[M], edge[M], Next[M], idx;
// 插入一条从 x 到 y 长度为 z 的有向边
void add(int x, int y, int z)
{
idx++;
ver[idx] = y;
edge[idx] = z;
// 头插
Next[idx] = head[x];
head[x] = idx;
}
// 读入一条有向边
add(x, y, z);
// 读入一条无向边(一对有向边==一条无向边)
add(x, y, z);
add(y, x, z);
// 枚举从 x 顶点出发的所有边
for (int i = head[x]; i != 0; i = Next[i])
{
// 能提供循环条件,则说明还有边
int y = ver[i];
int z = edge[i];
// 后续操作
}
// 清零只需要处理链表和计数器
memset(head, 0, sizeof(head));
idx = 0;
邻接矩阵
用一个二维数组来存储图中顶点之间的关系,数组的行和列分别对应图中的顶点,数组的元素表示两个顶点之间是否有边或者边的权重。邻接矩阵适合表示稠密图,即边数较多的图,但是空间复杂度较高,为 O ( N 2 ) O (N^2) O(N2) ,其中 N N N为顶点数。
在这个矩阵中,不论是有向图还是无向图,顶点到它本身的距离为 0。如果两个顶点不是直接连通的,那么规定距离为无穷 ∞ ∞ ∞。
无向图:矩阵记录每个顶点到它邻居结点的距离,关于对角线对称。
有向图:矩阵记录每个顶点的出度结点的距离。
二维矩阵的存储,只需要用一个二维数组a[i][j]
表示从 i 指向 j 的一条边,这个二维下标对应的数组元素则为边的权重。
DFS
深度优先搜索(Depth-first search ,DFS)是一种图算法,它的基本思想是从一个顶点开始,沿着一条路径不断向前探索,直到不能再继续为止,然后回溯到上一个顶点,再从另一条路径继续探索,直到遍历完所有的顶点和边。
DFS 中的"深度"体现在它的搜索策略上,即优先选择未访问过的相邻顶点进行探索,形成一条尽可能长的路径(所谓"一条路走到黑,不撞南墙不回头")。DFS 的思想天然地与递归契合,每次递归调用相当于向深处探索一层,每次返回相当于回溯一层。

对于 DFS,最重要的是"顺序",即用何种顺序把所有 情况遍历一次。由于 DFS 的特点,每一个 DFS 路径都对应着一颗搜索树,什么意思呢?就是说 DFS 在走到不能走的时候,就说明此时已经找到了一个结果(具体这个结果正确与否,取决于问题对这个结果的限制)。
全排列问题
题目描述
按照字典序输出自然数 1 1 1 到 n n n 所有不重复的排列,即 n n n 的全排列,要求所产生的任一数字序列中不允许出现重复的数字。
输入格式
一个整数 n n n。
输出格式
由 1 ∼ n 1 \sim n 1∼n 组成的所有不重复的数字序列,每行一个序列。
每个数字保留 5 5 5 个场宽。
样例输入
3
样例输出
1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1
提示
1 ≤ n ≤ 9 1 \leq n \leq 9 1≤n≤9。
思路
如果只问你 1/2/3 这三个数字最多能有多少种排列方式,相信你会很快得到答案:3*2*1=6
,这个算法的本质是枚举每一个位置上,能够取哪些数字。例如个位数能取到 1/2/3,那么十位数只能取到其中两个数字,百位数只能取一个数字。
这和朴素的递归思想是类似的,因为递归执行的是同一种操作,只是它的规模在不断缩小,当规模缩小到不能再小时,就"撞到南墙"了,也就得到一个结果了。
首先用一个递归树来演示 DFS 的过程:
初始状态是三个空位置,第一位有 3 种填法,第二位有 2 种填法,因为不能和第一位相同,第三位只有一种填法。
视角:在每一层中,递归应该看的是下一层还能填什么数字,如果没得填了,就说明走到最后了。
回溯
当得到一个结果时,相当于这条分支已经被使用过了,但是从这递归树来看,它的父结点的另一个孩子可能还未使用(即当前结点的兄弟结点),所以要回溯到上一层,以还原"现场"。
因为要回溯,所以我们需要用栈来保存当前结点的父结点在递归树中的位置,不过递归天然地使用了系统中函数栈帧,所以递归调用的返回就是一次回溯。
标记
为了保证能够一次性枚举图中的所有元素,当得到结果的同时为这个叶子结点打上标记。这应该在回溯之前完成。
剪枝
在 DFS 中,不一定所有结果都是符合题目要求的,例如在递归的过程中,第二个数字和第一个数字相同,那么此时就可以直接返回,此路径作废,以此提高效率,这就是剪枝。
代码
数据结构:
path[]
存储递归树中,从根结点到叶子结点的路径,也就是保存一个结果,以供打印。(如果只问结果的个数,可以不需要它)visited[]
存储 path 这条路径中,已经访问过的结点。
注意,visited[i] 这个标记当回溯时也要被还原,因为回溯的前提是上一次递归返回了。结合递归树理解,为什么要标记呢?因为递归的下一层仍然是一个类似的递归树。递归从 x 结点进入下一层时,x 结点对于本次递归就算是访问过了,当跳出此次递归后,还得访问另一边的子树,所以恢复 x 结点的状态,以通过进入递归的判断条件。
递归终止条件:当计数器和数字的长度相等时,即得结果,打印路径。
cpp
#include <iostream>
using namespace std;
const int N = 100010;
int path[N];
bool visited[N];
int n;
void dfs(int x)
{
// 当填满时,打印
if (x == n)
{
for (int i = 0; i < n; i++) printf(" %d", path[i]);
printf("\n");
return;
}
for (int i = 1; i <= n; i++)
{
// 如果这个结点还没有被访问过
if (visited[i] != true)
{
path[x] = i; // 记录到路径中
visited[i] = true; // 标记它被使用过
dfs(x + 1); // 递归下一层
visited[i] = false; // 递归返回后才能走到这一步,回溯还原现场
}
}
}
int main()
{
while (cin >> n)
{
dfs(0); // 注意从第 0 个元素开始
}
return 0;
}
递归必须从第 0 个格子开始,其次是这个例子的剪枝体现的不明显,在下面的例子中会有比较深刻的体会。
时间复杂度
这个 DFS 的思路的时间复杂度是 O ( n ! ) O(n!) O(n!),因为它要枚举每一行的每一列,然后检查是否满足条件。如果满足条件,就继续递归下一行。如果不满足条件,就回溯到上一行。这样的过程相当于在 n n n 个数中选出 n n n 个数的全排列。
N 皇后问题
题目描述
一个如下的 6 × 6 6 \times 6 6×6 的跳棋棋盘,有六个棋子被放置在棋盘上,使得每行、每列有且只有一个,每条对角线(包括两条主对角线的所有平行线)上至多有一个棋子。

上面的布局可以用序列 2 4 6 1 3 5 2\ 4\ 6\ 1\ 3\ 5 2 4 6 1 3 5 来描述,第 i i i 个数字表示在第 i i i 行的相应位置有一个棋子,如下:
行号 1 2 3 4 5 6 1\ 2\ 3\ 4\ 5\ 6 1 2 3 4 5 6
列号 2 4 6 1 3 5 2\ 4\ 6\ 1\ 3\ 5 2 4 6 1 3 5
这只是棋子放置的一个解。请编一个程序找出所有棋子放置的解。
并把它们以上面的序列方法输出,解按字典顺序排列。
请输出前 3 3 3 个解。最后一行是解的总个数。
输入格式
一行一个正整数 n n n,表示棋盘是 n × n n \times n n×n 大小的。
输出格式
前三行为前三个解,每个解的两个数字之间用一个空格隔开。第四行只有一个数字,表示解的总数。
样例
样例输入
6
样例输出
2 4 6 1 3 5
3 6 2 5 1 4
4 1 5 2 6 3
4
对于 100 % 100\% 100% 的数据, 6 ≤ n ≤ 13 6 \le n \le 13 6≤n≤13。
全排列思路 O ( n ! ) O(n!) O(n!)
八皇后在每行每列,还有两条对角线中只允许一个皇后棋子存在,那么我们可以枚举每一列皇后的位置,只要满足条件就可以进入递归。
增加的逻辑是,八皇后不仅限制同行同列,还限制两条对角线。由于枚举的是每行的情况,那么就用一个数组col[]
记录列格子的状态,用dg[[]
和antidg[]
来保存对角线(Diagonal)和反对角线(Antidiagonal)格子的状态。
代码
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 20;
int n;
bool col[N], dg[N], antidg[N];
int path[N];
vector<vector<int>> ans; // 用一个二维数组来保存所有的解
// 枚举每一行,x 表示一列中第 x 格
void dfs(int x)
{
if (x == n)
{
ans.push_back(vector<int>(path, path + n)); // 把当前解放入数组中
return;
}
// 枚举 x 格所在行的这一列
for (int i = 0; i < n; i++)
{
// 如果 x 格在它所在列/对角线/反对角线都没有被访问过
if (col[i] != true && dg[x + i] != true && antidg[n - x + i] != true)
{
path[x] = i; // 记录到路径中
col[i] = dg[x + i] = antidg[n - x + i] = true; // 标记
dfs(x + 1); // 进入递归
col[i] = dg[x + i] = antidg[n - x + i] = false; // 回溯
}
}
}
int main()
{
cin >> n;
dfs(0);
sort(ans.begin(), ans.end()); // 对所有的解进行排序
for (int i = 0; i < min(3, (int)ans.size()); i++) // 输出前三个解,或者所有的解(如果小于三个)
{
for (int j = 0; j < n; j++)
{
printf("%d ", ans[i][j] + 1);
}
cout << endl;
}
cout << ans.size() << endl;
return 0;
}
枚举思路 O ( n ! ) O(n!) O(n!)
上面的思路是枚举每一行中的每个列的格子,下面的思路是直接枚举每一个格子,是比较朴素的思路。
对于每一个格子,有两种选择:选或不选。那么每一个格子都会分为两个分支,形成一棵递归树。
对于每个格子:
- 不放皇后:直接递归到下一个格子
- 放皇后:
- 这个格子所在的行和列以及两个对角线不能有皇后存在
- 更新状态:记录此行此列和两个对角线上已经有皇后了
- 递归到下一个格子
- 跳出递归,回溯恢复现场
注意,在枚举每个格子时,需要注意数组越界的问题,也就是当每一行走完后,就必须让它走到下一行的第一个位置了。
终止条件:这个朴素的思路是枚举每个格子,那么终止条件就是当找到所有符合条件的皇后时即得到一个结果。
代码
cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 20;
int n;
bool row[N], col[N], dg[N], antidg[N];
int path[N][N];
vector<vector<int>> ans;
// 将当前放置的皇后位置保存到结果中
void saveResult()
{
vector<int> queenPos;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
if (path[i][j] == 1)
{
queenPos.push_back(j);
break;
}
}
}
ans.push_back(queenPos);
}
// 枚举每一个格子,坐标是 (x, y), count 表示已经放下的皇后数量
void dfs(int x, int y, int count)
{
if (y == n)
{ // 当此行走到最后一个格子
y = 0; // 让 y 回到最左
x++; // 让 x 走到下一行
}
if (x == n)
{ // 当走到最后一行
if (count == n) // 所有皇后都被放下
saveResult(); // 将结果保存到 ans 中
return;
}
// 不放皇后
dfs(x, y + 1, count);
// 放皇后
if (row[x] != true && col[y] != true && dg[x + y] != true && antidg[x - y + n] != true)
{
path[x][y] = 1; // 记录到路径中
row[x] = col[y] = dg[x + y] = antidg[x - y + n] = true; // 标记
dfs(x, y + 1, count + 1); // 进入递归
path[x][y] = 0; // 回溯,撤销放置的皇后
row[x] = col[y] = dg[x + y] = antidg[x - y + n] = false; // 回溯,撤销标记
}
}
int main()
{
cin >> n;
memset(path, 0, sizeof(path));
dfs(0, 0, 0);
sort(ans.begin(), ans.end());
for (int i = 0; i < min(3, (int)ans.size()); i++)
{
for (int j = 0; j < n; j++)
{
printf("%d ", ans[i][j] + 1);
}
cout << endl;
}
cout << ans.size() << endl;
return 0;
}
注\] 这段代码是最原始的版本,是比较好理解的,但是在处理大规模数据时(OJ)可能会超时,这是因为代码中存在一些不必要的操作。例如,在每次递归调用时都会检查所有的行和列,这实际上是不必要的,因为已经知道这一格在哪一行和哪一列。
此外,在保存结果时,遍历了整个棋盘来找到皇后的位置,这也增加了额外的计算量。实际上,可以在放置皇后时就记录下皇后的位置,这样在保存结果时就不需要再次遍历棋盘。
下面是优化后的版本:
```cpp
const int N = 20;
int n;
bool col[N], dg[2 * N], udg[2 * N];
int path[N];
vector
关于树的重心的一些结论:
- 如果以某个节点为整棵树(n 个节点)的重心,它的每棵子树的大小都小于等于 n/2。
- 重心到其他节点的距离和最小,如果有两个重心,那么距离和相同。
- 一棵树添加或删除一个节点,树的重心最多只移动一条边的位置。
- 把两棵树通过某个点相连,那么新树的重心必定存在于这条相连的路径上。
参考资料
相关题目
- Luogu:P1157 组合的输出
- 「JY/DFS - Part I」|Luogu 题单
- DFS 题单|LeetCode
- DFS--基本入门模板 和 例题 |CSDN
- 【DFS 入门题小集】
- 深度优先搜索-DFS|OJ
BFS
广度优先搜索(Breadth-First Search,BFS)和 DFS 一样,也是一种图搜索算法。它的思想是从一个顶点开始,访问它的所有相邻顶点,然后再依次访问这些相邻顶点的相邻顶点,直到访问完所有的顶点。
BFS 可以用来寻找图中的最短路径、连通分量、拓扑排序等问题。它使用一个队列来存储待访问的顶点,每次从队列中取出一个顶点,访问它,并将它的未访问过的相邻顶点入队,直到队列为空。
这和 DFS 不同,DFS 使用的是系统维护的函数栈帧,通过递归建立;而 BFS 需要自己维护一个队列。

对于一棵二叉树而言,BFS 就是层序遍历,下一次搜索的范围就是在原有的基础上扩大一个单位的长度。
二叉树的层序遍历
给你二叉树的根节点 root
,返回其节点值的层序遍历。 (即逐层地,从左到右访问所有节点)。
示例 1:
输入:root = [3,9,20,null,null,15,7]
输出:[[3],[9,20],[15,7]]
示例 2:
输入:root = [1]
输出:[[1]]
示例 3:
输入:root = []
输出:[]
思路 O ( n ) O(n) O(n)
由于 BFS 每次只会对与当前节点距离为 1 的节点进行扩展,所以 BFS 遍历树的结果,也就是树的层序遍历。
但是需要注意的是,BFS 的队列中在某一刻得到的序列,并不一定都在同一层,假如第二层的最后一个节点 X 还没有出队列,下一层的节点就已经进队列了,所以原生的 BFS 会有「元素分层」的现象。
树的层序遍历,使得我们需要增加一些限制,使得队列中如果有元素,那么它们在树中一定是同一层的。办法是:在遍历当前层的元素时,先把这一层元素的数量(即队列大小)保存下来,因为 BFS 每访问一个元素时,都会将它出队列,那么队列的大小是在不断变化的。
代码
cpp
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> res;
if (root == nullptr) return res;
queue<TreeNode*> q;
q.push(root);
while (!q.empty())
{
vector<int> curLevel; // 记录当前层的元素值
int n = q.size(); // 注意队列的大小必须在操作它之前保存,才能完整地遍历下一层的所有结点
for (int i = 0; i < n; i++)
{
TreeNode* node = q.front();
q.pop();
curLevel.push_back(node->val);
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
res.push_back(curLevel);
}
return res;
}
};
这个思路的时间复杂度是 O ( n ) O(n) O(n),其中 n n n是树节点的个数。
参考资料
以上图片源自此题的题解:BFS 的使用场景总结:层序遍历、最短路径问题
BFS 的入门,同时也是此题解的视频解析:【111 广搜 宽搜 (BFS) 算法】
走迷宫
给定一个 n ∗ m n*m n∗m的二维整数数组,用来表示一个迷宫,数组中只包含 0 或 1,其中 0 表示可以走的路,1 表示不可通过的墙壁。
最初,有一个人位于左上角 (1, 1) 处,已知该人每次可以向上、下、左、右任意一个方向移动一个位置。
请问,该人从左上角移动至右下角 (n, m) 处,至少需要移动多少次。
数据保证 (1, 1) 处和 (n, m) 处的数字为 0,且一定至少存在一条通路。
输入格式
第一行包含两个整数 n 和 m。
接下来 n 行,每行包含 m 个整数(0 或 1),表示完整的二维数组迷宫。
输出格式
输出一个整数,表示从左上角移动至右下角的最少移动次数。
数据范围
1 ≤ n , m ≤ 100 1≤n,m≤100 1≤n,m≤100
样例
输入样例:
5 5
0 1 0 0 0
0 1 0 1 0
0 0 0 0 0
0 1 1 1 0
0 0 0 1 0
输出样例:
8
思路 O ( n m ) O(nm) O(nm)
- 初始化队列:们需要一个队列来存储待处理的节点,将起始点放入队列中。
- 处理队列中的节点:处理队列中的节点。对于队列中的每一个节点,都要检查它的四个方向(左、上、右、下)。如果某个方向上的节点是可达的(即值为 1),并且没有被访问过,那么就将其加入到队列中,并标记该节点为已访问。
- 记录路径:为了能够找到从起始点到终点的路径,需要在每个节点中记录从起始点到当前节点的路径。使用一个二维数组 path 来存储路径信息,其中 path[i][j] 表示从起始点到点 (i, j) 的路径。
- 找到终点:当从队列中取出终点时,就表示已经找到了一条从起始点到终点的路径,可以直接从 path 数组中获取并输出这条路径。
- 处理所有路径:由于题目要求输出所有可能的路径,所以不能在找到第一条路径后就停止搜索。而是需要继续处理队列中的其他节点,直到队列为空。
- 无法到达终点:如果队列为空,但此时还没有找到终点,说明从起始点无法到达终点,输出 -1。
下面是得到这个样例输出的过程(图中省略了部分扩展方向,例如只有左和下,实际上需要有四个方向,荧光绿表示它是未被使用并且可以走的,但是此次查找不走它):

在搜索的过程中,要用队列维护元素的状态:
- 入队:表示排队等待扩展
- 出队:扩展出队元素的邻居结点
数据结构:
- 用一个队列保存将要扩展的结点,这个结点应该是由上一个结点扩展决定的,默认是起点
- 用数组
gra[][]
来读取地图 - 用数组
dis[x][y]
来表示 (x, y) 这个点距离原点的距离
通过对 (x, y) 坐标的加减操作,实现对这个点周围的四个点的访问,可以用两个数组分别保存对 x 和 y 坐标的变换距离:x[4] = {-1, 0, 1, 0}
和 y[4] = {0, 1, 0, -1}
,注意它们是组合使用的,例如要访问 (x, y) 点的左边那个点,那么就需要这么做:matrix[i + x[2] ][j + y[2] ]
== matrix[i + 1][y + 0]
。
如果要输出路径,可以用Prev[][]
来保存路径(小写的 prev 可能会和头文件中的变量冲突),它保存的是当前节点的上一个节点。
代码
cpp
#include <iostream>
#include <cstring>
#include <queue>
using namespace std;
const int N = 110;
queue<pair<int, int>> q;
int n, m;
int gra[N][N];
int dis[N][N]; // 记录某点到原点的距离
pair<int, int> Prev[N][N]; // 记录当前元素的上一个结点
void printPath()
{
int x = n - 1;
int y = m - 1;
while (x != 0 || y != 0)
{
printf ("%d %d\n", x, y);
auto t = Prev[x][y];
x = t.first;
y = t.second;
}
}
int bfs()
{
int dx[4] = {-1, 0, 1, 0};
int dy[4] = {0, 1, 0, -1};
memset(dis, -1, sizeof(dis));
dis[0][0] = 0; // 初始化距离
q.push({0, 0}); // 将起点入队
while (!q.empty())
{
auto t = q.front(); // 取出队头元素 t
q.pop(); // 出队
for (int i = 0; i < 4; i++) // 访问 t 的 4 个邻居
{
int x = t.first + dx[i], y = t.second + dy[i];
// 如果坐标合法且不是墙,并且没有被访问过,则入队
if ((x >= 0 && x < n && y >= 0 && y < m) && gra[x][y] == 0 && dis[x][y] == -1)
{
// path[x][y] = t; // 记录路径
dis[x][y] = dis[t.first][t.second] + 1;
Prev[x][y] = t; // 记录上一个合法的元素
q.push({x, y});
}
}
}
// 打印
printPath();
return dis[n - 1][m - 1];
}
int main()
{
cin >> n >> m;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
scanf("%d", &gra[i][j]);
cout << bfs() << endl;
return 0;
}
- 空间复杂度:这段代码需要存储迷宫本身,距离数组,前驱数组,和队列。迷宫本身,距离数组,和前驱数组都占用 O ( n m ) O(nm) O(nm) 的空间,队列的最大长度为 O ( n m ) O(nm) O(nm)(最坏情况下,所有的点都入队一次)。
- 时间复杂度为 O ( n m ) O(nm) O(nm):这段代码需要遍历迷宫中的所有点,每个点最多被访问一次,每次访问需要 O ( 1 ) O(1) O(1) 的时间。另外,每个点最多有四个邻居,每次访问邻居需要 O ( 1 ) O(1) O(1) 的时间。
相关题目
有向无环图的拓扑序列
有向无环图
在图论中,如果一个有向图从任意顶点出发无法经过若干条边回到该点,则这个图是一个有向无环图(DAG,Directed Acyclic Graph)。----有向无环图|维基百科
图片来源----图的拓扑排序|掘金
在一个有向图中,一个顶点的**「出度」指的是由该顶点指出的边的总数;一个顶点的「入度」**为指向该顶点的边的总数。
拓扑序列
拓扑排序是一种对有向无环图(DAG)中的所有顶点进行线性排序的方法,使得对于任意一条有向边 (u,v),顶点 u 都排在顶点 v 的前面。拓扑排序可以用来表示一些有依赖关系的任务的执行顺序,例如课程的选修顺序。
以一个生活中的例子理解拓扑排序,在大学中的第一年我们学习的课程都是通识课,例如高等数学,概率论和线性代数等。只有学了这些前导课程,才有可能学习后续的专业课。也就是说,我们在不同阶段学习的课程是有先后顺序、依赖关系的。这也是有向图才会有拓扑序列的原因。
例如学习本科算法的前导课程(不考虑 C 语言)是高等数学->概率论->数据结构,那么当学习高等数学时,它是没有前导课程的,所以我们可以直接学习它;当学习完高等数学以后,对于概率论而言,我们已经学习了它的前导课程,那么我们也可以直接开始学习概率论。... 因此,在一个合法的拓扑序列中,对于每一个当前元素而言,它的所有依赖元素我们都已经访问过,也就是它的入度为 0,即它无需依赖任何元素。如果遍历了图中每个元素都符合这一规则,那么这就是这个 DAG 的一种合法的拓扑序列。
BFS 思路 O ( n + m ) O(n+m) O(n+m)
Kahn 算法是一种基于广度优先搜索(BFS)的拓扑排序算法,它的切入点是拓扑序列的定义,即一个元素的入度,表示了它的依赖关系。BFS 的思想是枚举所有可能,其实也是一种不断假设并尝试的过程:BFS 能够一次遍历图中所有元素,同时能够假设当前元素在何种条件下是复合某种规则的。那么元素的出度拿来做什么呢?----出度是对于当前元素而言的,它用来寻找这个元素后面的元素。
数据结构:
- 二维数组
ver[x][y]
:存储 x 元素的下一个邻居,即(x, y)/(x->y)这条有向边。 - 数组
in[x]
:存储 x 元素的入度 - 数组
topo[]
:存储当前合法的拓扑序列 -
算法核心\] 队列`q`:维护一个入度为 0 的元素的集合
- 枚举图中每个顶点,把所有入度为 0 的顶点添加到队列
q
中。 - 当队列
q
不为空时:- 在队列
q
中任取一个顶点 x(一般为了方便,取队头),将 x 添加到到数组topo[]
中 - 将顶点 x 的所有出边(x->y)删除,即删除边 (x, y),那么顶点 y 的入度就为 0,将 y 入队
q
- 在队列
- 循环结束,队列为空
- 如果数组
topo[]
的有效元素个数为 n,那么说明所有元素都执行了上述步骤,也就是说每个顶点都可以作为入度为 0 的点压入队列q
中,所以这是一个合法的拓扑序列;否则说明图中存在环。
以一个简单的例子演示算法流程:
假如图中有环:
注意,拓扑序列是不唯一的,这取决于每次从队列取出元素的顺序。
代码
cpp
#include <iostream>
#include <queue>
using namespace std;
const int N = 100010;
int n, m;
int in[N];
vector<int> ver[N], topo;
bool TopoSort()
{
queue<int> q;
// 将所有入度为 0 的顶点入队
for (int i = 1; i <= n; i++)
if (in[i] == 0) q.push(i);
while (!q.empty())
{
// 取出队头元素 x(理论上可以取队列中任意元素)
int x = q.front();
q.pop();
topo.push_back(x);
// 将 x 顶点的出边 (x, y) 全部删除
// 当 y 顶点的入度为 0, 则入队
for (auto &y : ver[x])
if (--in[y] == 0) q.push(y);
}
return topo.size() == n;
}
int main()
{
// 读入顶点数 n,m 行数据
cin >> n >> m;
for (int i = 0; i < m; i++)
{
int a, b;
cin >> a >> b;
// 读取 (a, b) 有向边,b 的入度则+1
ver[a].push_back(b);
in[b]++;
}
if (!TopoSort()) puts("-1");
else for (auto &e : topo) printf("%d ", e);
return 0;
}
注意:
- ver 是一个二维数组,它存储的是 x->y 有向边,当读入这条边时,y 顶点的入度要加 1。
- 拓扑序列不是唯一的,在一些 OJ 为了答案的一致性,要求取出队列中编号较小的那一个,这就需要用一个优先队列或栈来维护这个队列中的最大或最小值了。
- 如果图中存在孤立点,那么说明它是没有依赖某个顶点的,所以它可以出现在拓扑序列的任意位置。在这个写法中,算法的第一步就将所有入度为零的顶点入队,包括孤立点。
这个算法的时间复杂度是 O ( n + m ) O(n+m) O(n+m),其中 n n n 是顶点数, m m m 是边数。:
- 遍历所有顶点,计算每个顶点的入度,并将入度为 0 的顶点入队。这一步的时间复杂度是 O ( n + m ) O(n + m) O(n+m),
- 不断从队列中取出一个顶点,将其加入拓扑序列,并将其所有出边删除,即将其相邻顶点的入度减 1,并将入度变为 0 的顶点入队。这一步的时间复杂度是 O ( n + m ) O(n + m) O(n+m),因为每个顶点和每条边都只被访问一次。
参考资料
相关题目
最短路径问题
最短路径问题是对于含有边权的图而言的,主要分为以下几种,分类的根据是已知的起点和终点的数量:
-
确定起点的最短路径问题,也叫单源最短路问题,即已知一个起点,求到其他所有点的最短路径。
- 边权为正:
- 朴素 Dijkstra 算法, O ( n 2 ) O(n^2) O(n2)
- 堆优化 Dijkstra 算法, O ( m l o g n ) O(mlogn) O(mlogn)
- 存在负权边:
- Bellman-Ford 算法, O ( n m ) O(nm) O(nm)
- SPFA 算法,一般 O ( m ) O(m) O(m),最坏 O ( n m ) O(nm) O(nm)
它们的基本思想是动态规划/贪心思想,即利用已知的最短路径信息更新其他点的最短路径。
- 边权为正:
-
全局最短路径问题,也叫多源最短路问题,即求图中任意两点之间的最短路径。
- Floyd-Warshall 算法等方法解决。
它的基本思想是逐步扩展中间点的集合,更新两点之间的最短路径。
值得注意的是,在学习图论时经常会使用到贪心思想和动态规划,问题在于证明它们的正确性(尤其是贪心)不是一件容易的事,所以希望读者在初学过程中能够通过一定数量的经典案例来体会这两种思想适用于何种问题。
贪心和动态规划这两种思想,在某些问题中往往难以明确地划分它们的区别,但是它们的着眼点有所不同:贪心关注问题的局部最优,每一步都是最优的,那么结果也是最优的(如果贪心是正确的话);动态规划虽然操作的是局部,但是关心的是整体,它不会漏掉任何一种情况,而贪心可能会因为局部选择最优而漏掉一些情况。
单源最短路径问题
在单源最短路径问题(Single Source Shortest Path)中,给定一张有向图 G = ( V , E ) G=(V, E) G=(V,E)。 V V V是点集, E E E是边集, ∣ V ∣ = n |V|=n ∣V∣=n, ∣ E ∣ = m |E|=m ∣E∣=m,节点以 [ 1 , n ] [1, n] [1,n]之间的连续整数编号, ( x , y , z ) (x, y, z) (x,y,z)描述一条从 x x x出发,到达 y y y,权值/长度为 z z z的有向边。设 1 1 1号点位起点,求长度为 n n n的数组 d i s t [ ] dist[ ] dist[],其中 d i s t [ i ] dist[i] dist[i]表示从起点 1 1 1到节点 i i i的最短路径长度。----算法竞赛进阶指南
题目描述
给定一个 n n n 个点, m m m 条有向边的带非负权图,请你计算从 s s s 出发,到每个点的距离。
数据保证你能从 s s s 出发到任意点。
输入格式
第一行为三个正整数 n , m , s n, m, s n,m,s。
第二行起 m m m 行,每行三个非负整数 u i , v i , w i u_i, v_i, w_i ui,vi,wi,表示从 u i u_i ui 到 v i v_i vi 有一条权值为 w i w_i wi 的有向边。
输出格式
输出一行 n n n 个空格分隔的非负整数,表示 s s s 到每个点的距离。
样例输入
4 6 1
1 2 2
2 3 2
2 4 1
1 3 5
3 4 3
1 4 4
样例输出
0 2 4 3
其中:
1 ≤ n ≤ 1 0 5 1 \leq n \leq 10^5 1≤n≤105;
1 ≤ m ≤ 2 × 1 0 5 1 \leq m \leq 2\times 10^5 1≤m≤2×105;
s = 1 s = 1 s=1;
朴素 Dijkstra 算法 O ( n 2 + m ) O(n^2+m) O(n2+m)
它的基本思想是从源点开始,每次选择一个距离源点最近的未访问过的点,然后用它来更新其他点的距离,直到所有的点都被访问过或者找到了目标点。
数据结构:
- 数组
dist[]
:保存当前已经确定的最短路径的点,下标从 s 开始,下标 0 用作哨兵位。 - 数组
visited[]
:保存已经访问过的点。
具体步骤如下:
- 初始化数组
dist[]
:初始时,源点到自己的距离为 0,即dist[1]=0
,源点到其他点的距离为无穷大,用一个很大且不容易溢出的正整数表示,如1e9
或0x3f3f3f3f
。 - 初始化数组
visited[]
:所有的点都未被访问过。 - 重复以下操作,直到所有的点都被访问过或者找到了目标点:
- 从未访问过的点中(也就是不在
visited[]
中),选择一个距离源点最近的点,记为 x。 - **[松弛操作]**将 x 标记为已访问,并用 x 来更新其他未访问过的点 y 的距离(即遍历),即如果通过 x 到达某个点 y 的距离比原来的距离更短,就更新距离数组中 x 的值为源点到 y 的距离加上 x 到 y 的距离。
- 从未访问过的点中(也就是不在
注意,x 就是当前最短路径的最新的那个点,也是当前离原点最远的点,不断地这样找下一个最近的点,就能找到整张图中离原点的最短路径。
每次用 x 找最近的下一个点,就好像在一个以 x 为起点的子图中找最短路径,那么递归地从倒数第二个点往前看,每一个子图连上一个最近的点,就是更大的那个子图的最短路径。
松弛操作:
松弛操作:是最短路径算法中的一种基本步骤,用于更新顶点之间的最短距离估计值。松弛操作的原理是,如果从源点 s s s到顶点 u u u的最短距离加上从顶点 u u u到顶点 v v v的边的权重小于从源点 s s s到顶点 v v v的最短距离,那么就可以用前者替换后者,从而缩短从 s s s到 v v v的路径。
对边 ( u , v ) (u, v) (u,v),用 d i s t ( u ) dist(u) dist(u)和 l ( u , v ) l(u, v) l(u,v)的和尝试 更新 d i s t ( v ) dist(v) dist(v),即:
d i s t ( v ) = m i n ( d i s t ( v ) , d i s t ( u ) + l ( u , v ) ) dist(v)=min(dist(v),dist(u) + l(u, v)) dist(v)=min(dist(v),dist(u)+l(u,v))
例如下面就是一次成功的松弛操作:
松弛操作的名称来源于一个类比,把最短距离估计值看作是一根弹簧的长度,初始时弹簧是被拉伸的,随着最短路径的发现,弹簧的长度会缩短,也就是松弛。
松弛操作也可以理解为减少对变量的约束,使得满足三角不等式(在下面会提到它)的条件更加宽松。松弛操作是很多最短路径算法的核心,比如 Dijkstra 算法和 Bellman-Ford 算法,它们都是通过不同的方式来确定边的松弛顺序,从而求解最短路径问题。
用一个例子理解算法流程:
注意,在每轮更新时,都是找 dist 数组中值最小的那个对应的顶点的出边来更新其它点的,而不是按 dist 数组的顺序。
这个"其他点"指的是最小值对应的顶点的邻居顶点。在这步中,B 的 dist 值被松弛更新为 3,在目前对于 B 而言,这是一条道起点的最短路径。
那么现在已经有两个点,S 和 A 点已经被访问过了,它们将会作为最短路径的顶点之一,以后更新最短路时,无需再访问它们。用蓝色路线标记。
这样, 便找到了最短路径:S->A->B->D->C->E。
算法的核心步骤是在 dist 中未访问过的顶点中用距离最小的那个,来更新它自己的邻居顶点。
代码
cpp
#include <iostream>
#include <cstring>
using namespace std;
int n, m, s;
const int N = 10010, INF = 1e9, M = 2 * N;
int gra[N][N], dist[M];
bool visited[N];
void dijkstra(int s)
{
// 初始化
memset(dist, 0x3f, sizeof(dist));
memset(visited, false, sizeof(visited));
dist[s] = 0;
// 重复操作 n 次,每次选择一个最近的点
for (int i = 0; i < n; i++)
{
// 在未被访问过的点中选择一个最近的点 x
// min_dist 记录最小距离
int x, min_dist = INF;
for (int j = 1; j <= n; j++)
{
// 没有被访问过,且距离更小则更新
if (!visited[j] && dist[j] < min_dist)
{
x = j;
min_dist = dist[j];
}
}
visited[x] = true; // 标记 x 已被访问
// 用 x 来更新其他未访问过的点的距离
for (int y = 1; y <= n; y++) // 松弛操作
dist[y] = min(dist[y], dist[x] + gra[x][y]);
}
if (dist[n] == 0x3f3f3f3f) puts("-1");
else for (int i = 1; i <= n; i++) cout << dist[i] << " ";
}
int main()
{
cin >> n >> m >> s; // 读入点数/边数/起点
memset(gra, 0x3f, sizeof(gra));
// 如果图中可能存在重边或自环,那么只读取那个较小的
for (int i = 0; i < m; i++) // 注意是读入边,所以是 m
{
int x, y, z;
cin >> x >> y >> z;
gra[x][y] = min(gra[x][y], z);
}
for (int i = 1; i <= n; i++) gra[i][i] = 0;
dijkstra(s);
return 0;
}
注意:这段代码无法通过 OJ,原因是朴素的 Dijkstra 算法时间复杂度很高,OJ 限制了内存。Dijkstra 算法的时间复杂度取决于实现方式,如果使用邻接矩阵(即二维数组)来存储图,那么时间复杂度为 O(n\^2),其中 n n n 是图中的点数。
OJ 题的限制是一回事,使用邻接矩阵来存储图的原因是这个图是稠密图,也就是边数 ∣ E ∣ |E| ∣E∣接近 ∣ V ∣ |V| ∣V∣,稀疏图反之。主要是因为使用了二维数组来存储图的邻接矩阵,这样会占用很多空间,尤其是当图的边数远小于点数的平方时。可以使用邻接表来优化代码,这样只需要存储每个点的相邻点和边权,可以节省很多空间。
堆优化 Dijkstra 算法 O ( ( n + m ) l o g n ) O((n+m)logn) O((n+m)logn)
堆优化 Dijkstra 算法解决了:
- 二维数组占用过多内存
- 遍历顶点效率低
思路和朴素的 Dijkstra 算法是一样的,只不过是把二维数组中的数据交给堆来维护,遍历的操作通过堆来实现。这么做就不能用二维数组来存储图了,需要用链式前向星来存储图的邻接表(在本文的「图的存储方式」中有介绍)。
数据结构:
- 数组
dist[]
和数组visited[]
:保存当前已经确定的最短路径的点和是不是第一次第一次出队(这和朴素 Dijkstra 中的 visited 数组的含义是不同的)。 - 堆
priority_queue<pair<int, int>> heap
:存储没有被访问过的点,堆自动会将最小值放在堆顶。first 存储距离,second 存储节点本身的编号(不是数组的下标)。 - 链式前向星:存储邻接表。
具体步骤如下:
- 初始化数组
dist[]
和数组visited[]
。 - 重复以下操作,直到堆
heap
为空:- 从堆
heap
中取出堆顶元素 x,即距离当前路径最短的顶点,取出后弹出它。 - 判断 x 是否已经被访问过,如果是,则跳过这个点,因为它可能是一个重复的点,或者是一个已经确定最短距离的点。
- 如果 x 没有被访问过,就将其标记为已访问,并遍历 x 的所有出边,即从邻接表中找到所有与 x 相连的点 y 和边权 z。
- **[松弛操作]**将 x 标记为已访问,并用 x 来更新其他未访问过的点 y 的距离,也就是要遍历堆中每个元素,符合条件则更新点 y 的距离
dist[y]
,再将更新后的点 y 压入堆heap
中。
- 从堆
代码
cpp
#include <iostream>
#include <queue>
#include <cstring>
using namespace std;
int n, m, s;
const int N = 100010, M = N * 2;
int head[N], ver[M], edge[M], Next[M], idx;
int dist[N];
bool visited[N];
// pair<-dist[x], x>
priority_queue<pair<int, int>> heap;
// 加边
void add(int x, int y, int z)
{
idx++;
ver[idx] = y;
edge[idx] = z;
Next[idx] = head[x];
head[x] = idx;
}
void dijkstra(int s)
{
// 初始化
memset(dist, 0x3f, sizeof(dist));
memset(visited, false, sizeof(visited));
dist[s] = 0;
heap.push(make_pair(0, s));
while (!heap.empty())
{
int x = heap.top().second;
heap.pop();
if (visited[x]) continue;
visited[x] = true;
for (int i = head[x]; i != 0; i = Next[i])
{
int y = ver[i], z = edge[i];
if (dist[y] > dist[x] + z)
{
dist[y] = dist[x] + z;
heap.push(make_pair(-dist[y], y));
}
}
}
if (dist[n] == 0x3f3f3f3f) puts("-1");
else for (int i = 1; i <= n; i++) cout << dist[i] << " ";
}
int main()
{
cin >> n >> m >> s; // 读入点数/边数/起点
for (int i = 0; i < m; i++)
{
int x, y, z;
cin >> x >> y >> z;
add(x, y, z);
}
dijkstra(s);
return 0;
}
注意:
-
堆的元素的 first 值是负数,是因为优先队列默认按照大根堆的方式排序,也就是每次输出的是最大的元素。但是最短路径问题需要取最小值,所以把正值取反,这样就可以利用大根堆的性质实现小根堆的效果。
如果想修改优先队列以小根堆排序:在定义优先队列的时候,指定第三个模板参数:
cpppriority_queue<int, int, greater<int>> heap;
cpp
heap.pop();
if (visited[x]) continue;
visited[x] = true;
第一句和第三句表示:x 顶点第一次出队时,就给它打上「已访问」标记;第二句表示:除了第一次以外,再出队就直接跳过 x。
这是因为堆顶维护的是当前两个集合相连边的最小权值,第一次出队一定是当前堆中的最小值;如果是第二次出队, 说明在它之前还有更小的值,那就不能再选 x 了。也就是说如果一个顶点被访问了多次,那么则意味着有比之前找到的更短的路径到达该节点,这与算法的保证相矛盾。这就保证了从起点到每个节点的最短路径只会被访问一次。即一张含有 n n n个顶点的图中,最短路径经过的最多顶点数是 n − 1 n-1 n−1。
即使节点
x
已经被访问过,也需要将它从优先队列(堆)中弹出吗?
需要,这是因为 Dijkstra 算法使用优先队列来存储待访问的节点,而优先队列中的节点是根据到起点的距离排序的。如果一个已经被访问过的节点仍然留在优先队列中,则会影响算法的效率。
注\] 实际上,删除堆顶元素会破坏堆的结构,这可能会降低效率,一种做法是将它置为无效值,使它不会成为堆顶,但是会增加代码的复杂度,好在建堆的时间复杂度是$ O(log n)$,所以还是直接删除。
**时间复杂度**:
朴素的 Dijkstra 算法中的松弛操作需要遍历二维数组的所有点来找到最小距离的点,这样的时间复杂度是 O ( n 2 ) O(n\^2) O(n2)(其中 n n n是点的个数)。如果用堆来优化,就可以用一个优先队列来存储未确定最短距离的点,每次从队列中取出距离最小的点,这样的时间复杂度是 O ( l o g n ) O(logn) O(logn),然后再用 O ( l o g n ) O(logn) O(logn)的时间来更新其他点的距离,总的时间复杂度是 O ( ( n + m ) l o g n ) O((n+m)logn) O((n+m)logn)(其中 m m m是边的个数)。这样可以提高效率,尤其是当图比较稀疏的时候。
#### 补充
**"无穷"的表示**:
在使用 Dijkstra 算法解决「最短路径问题」时,使用到了数学上"无穷"的概念,计算机的内存有限,只能用一个绝对值很大的数字(通常是整数)来表示。
算法题目在设计时,数据的数量级的上限一般取 1 0 9 10\^9 109(不超过),即`1e9`。"无穷"的取值也可以是`0x3f3f3f3f`或`0x7f7f7f7f`或`1<<30`,它们的绝对值是 1061109567 1061109567 1061109567和 2139062143 2139062143 2139062143和 1073741824 1073741824 1073741824,这么做的原因是有时候会对这个"无穷大"的数字做运算,例如"无穷大的无穷大",那么它们的两倍不会让 int-32bit( 4294967295 4294967295 4294967295)溢出。在代码中,这个很大的整数通常用`INF`来表示,意为"无穷"。
`memset`函数按字节初始化空间,只要数组中每个字节都是`3f`或者`7f`,那么数组的所有元素都是"无穷",就无需使用循环来初始化数组了。
另外,在全局的变量是有默认值的,布尔类型的 visited 数组默认值是 false,在代码中为了对应思路,仍然显式地初始化了,可以省略。
**Dijkstra 算法的局限性:**
Dijkstra 算法的一个重要条件是图中的边的权重必须为正,否则算法可能会得到错误的结果。这是因为算法的贪心策略是基于假设每次选择最近的点都不会导致之后的路径变长。如果存在负权重的边,那么可能会出现通过更远的点反而使得路径变短的情况,从而违反了算法的贪心策略。
#### 参考资料
* [Dijkstra 求最短路算法\|CSDN](https://blog.csdn.net/weixin_45629285/article/details/109406455)
* [301 最短路 Dijkstra 算法\|哔哩哔哩](https://www.bilibili.com/video/BV1Ya411L7gb?vd_source=8c26a06cbe4ef9ac4beefc637a03af25)
* [Dijkstra 算法+堆优化\|哔哩哔哩](https://www.bilibili.com/video/BV1164y1U71q?vd_source=8c26a06cbe4ef9ac4beefc637a03af25)
### Bellman-Ford 算法 O ( n m ) O(nm) O(nm)
题目:[P3385 【模板】负环](https://www.luogu.com.cn/problem/P3385)
Bellman-Ford 算法可以解决 Dijkstra 算法不能处理负权边的情况,和 Dijkstra 算法不同的是,它是基于「迭代」的思想:这一次不行,那就算下一次。它的核心思路是对所有的边进行 n − 1 n-1 n−1轮松弛操作,这样可以保证每个点的最短距离是正确的。因为在一个含有 n n n个顶点的图中,任意两点之间的最短路径最多包含 n − 1 n-1 n−1边(一条链)。下一次迭代的结果,是在本次的基础上进行的。
换句话说,第 1 1 1轮在对所有的边进行松弛后,得到的是源点最多经过一条边到达其他顶点的最短距离;第 2 2 2轮在对所有的边进行松弛后,得到的是源点最多经过两条边到达其他顶点的最短距离;依此类推,直到第 n − 1 n-1 n−1轮,得到的是源点到其他所有顶点的最短距离。如果在第 n n n轮时(也可能是之后),还有可以松弛的边,那么说明存在负权回路。
如果没有负权回路,那么所有点的最短距离在 n − 1 n-1 n−1轮之后就不会再变化了;反之沿着这个负权回路走一圈,就可以使得某些点的最短距离变得更小,理论上能到数学意义上的无穷小,这样就会导致松弛操作无法收敛到一个确定的值。

**算法流程:**
1. 初始化数组`dist[]`。
2. 执行多轮迭代,每次迭代都对图上所有边尝试一次松弛操作。
3. 当某一次迭代松弛操作失败,即某一次迭代中所有顶点的`dist[x]`都没有发生变化,算法终止。
下面用一个例子来理解算法流程:
第一轮迭代:

枚举每条边,也就是遍历每个顶点,然后枚举它们的所有出边,理论上这个顺序可以是任意的,通常按照编号来枚举边,也就是例子中 S-\>E 这个顺序。

注意当枚举 B 的出边之前,B 到起点的距离仍然是无穷的,所以在这个基础上再扩展一次也没有任何意义,所以先跳过它。如果 B 点是其他点的下一个点,那么可能后面的点或者下一轮迭代可以用其他点来更新 B 点的 dist,这样就能枚举 B 的出边了。

事实证明这么做是可行的,最后的 E 点的出边指向了 D 点,那么它可以更新 D 点的 dist。那么下一轮迭代就可以枚举 D 点的出边了。
第二轮迭代:


可见,随着迭代的继续,有许多顶点都不能再更新它的出边的 dist 值了,这说明算法接近尾声,最短路逐渐确定。
为了演示的方便,第三次迭代中只显示松弛操作成功的顶点,不成功的顶点编号不会被染色。

那么在最短路存在的情况下,一次迭代会使最短路的边数至少+1,而起点到每个顶点的最短路经过的边数最多为 n − 1 n-1 n−1,因此这个算法最多会进行 n − 1 n-1 n−1轮迭代(例如一条链)。每轮迭代最坏可能要枚举所有边,每轮迭代时间复杂度为 O ( m ) O(m) O(m),整体时间复杂度为 O ( n m ) O(nm) O(nm)。
**判断图中是否存在负环:**
通过上面这个例子我们可以知道,这个算法最多进行 n − 1 n-1 n−1轮迭代,而且迭代这么多次以后就不会有顶点的 dist 值发生变化了。在『扩展最短路径』的意义下,如果图中存在负环,那么最短路径的长度理论上是无穷小,这意味着循环会迭代无穷次。所以要找到负环,只要在 n − 1 n-1 n−1的基础上再循环一次,如果这一次循环中某个顶点的 dist 值发生了变化,则说明有环。(这在代码中体现了)
#### 代码
```cpp
#include