第62天,完结撒花*★,°*:.☆( ̄▽ ̄)/$:*.°★* ,最后的两个算法学习,编程语言C++
目录
[Floyd 算法精讲](#Floyd 算法精讲)
[A*算法精讲(A star算法)](#A*算法精讲(A star算法))
Floyd 算法精讲
文档讲解:代码随想录Floyd算法
本题是经典的多源最短路问题。此前我们采用的dijkstra朴素版、dijkstra堆优化、Bellman算法、Bellman队列优化(SPFA)等算法都是单源最短路,即只能有一个起点。
而本题是多源最短路,即求多个起点到多个终点的多条最短路径。通过本题我们来学习一个新的最短路径算法Floyd算法。Floyd算法对边的权值正负没有要求,都可以处理。
Floyd算法核心思想是动态规划:
例如我们求节点1到节点9距离的时候,是不是可以由节点1到节点5的最短距离 + 节点5到节点9的最短距离组成。而节点1到节点5的距离,又是不是可以由节点1到节点3的最短距离 + 节点3到节点5的最短距离组成。
而节点1到节点9的距离,也有可能由节点1到节点7的距离 + 节点7到节点9的距离组成。那取哪一个距离,显然应该取的是最短距离。
由此我们就接近了动态规划的一个过程,下面我们依据动态五部曲来进行Floyd算法的学习。首先回忆动归五部曲:
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
1.确定dp数组(dp table)以及下标的含义:
我们使用grid数组来保存图,因此我们可以尝试将grid数组定义为dp数组。这里我们定义一个三维数组。grid[i][j][k] = m,表示节点i到节点j,以[1....k]集合为中间节点的最短距离为m。
节点i到节点j很好理解,起点和终点,那集合[1...k]是什么意思呢,它就有点像是我们在动态规划中写过的背包问题,集合[1..k]就是我们遍历的物品也就是遍历的点。节点i到节点j的最短路径中一定时经过很多节点的,那么这个集合用[1...k]来表示。这里要明确k表示的不是一个点,而是一个集合,一个[1...k]的集合。
2.确定递推公式:
我们根据上述描述差不多了解了一个递推的关系,接着我们将情况分为两种:①节点i 到节点j 的最短路径经过节点k;②节点i 到节点j 的最短路径不经过节点k。
对于第一种情况,grid[i][j][k] = grid[i][k][k - 1] + grid[k][j][k - 1];节点i 到节点k 的最短距离是不经过节点k,中间节点集合为[1...k-1],所以表示为grid[i][k][k - 1],节点k 到节点j 的最短距离也是不经过节点k,中间节点集合为[1...k - 1],所以表示为grid[i][j][k - 1]。
对于第二种情况,grid[i][j][k] = grid[i][j][k - 1];如果节点i到节点j的最短距离不经过节点k,那么中间节点集合[1...k - 1],表示为grid[i][j][k - 1]。
因为我们是求最短路,因此我们需要对这两种情况取最小值:grid[i][j][k] = min(grid[i][k][k - 1] + grid[k][j][k - 1], grid[i][j][k - 1])
这个地方可能还不好理解,可以继续往后看。
3.dp数组如何初始化:
grid[i][j][k] = m,表示节点i 到节点j 以[1...k]集合为中间节点的最短距离为m。刚开始初始化k是不确定的,例如如果输入边有节点2->节点6,权值为3,那么grid[2][6][k] = 3,k只能填0。因此本题初始化的时候,我们把k赋值为0,本题中节点0是无意义的,节点是从1到n。
这样初始化后,我们进入下一轮计算的时候,可以根据grid[i][j][0] 来计算 grid[i][j][1],此时的grid[i][j][1] 就是 节点i经过节点1到达节点j的最小距离。
grid是一个三维数组,我们初始化的时候k为0,是在底层,之后随着每一轮计算逐步抬高,如图:
红色底部一层使我们初始化的数据,初始化的代码为:
cpp
vector<vector<vector<int>>> grid(n + 1, vector<vector<int>>(n + 1, vector<int>(n + 1, 10001))); // C定义了一个三维dp数组,10001是因为边的最大权值是10^4,这里也可以定义为INT_MAX
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2][0] = val;
grid[p2][p1][0] = val; // 注意这里是双向图
}
本题求的是最小值,因此输入数据没有涉及到的节点,都应该初始化为一个最大数,这次才不会影响我们计算最小值。
4.确定遍历顺序:
从递推公式: grid[i][j][k] = min(grid[i][k][k - 1] + grid[k][j][k - 1], grid[i][j][k - 1]),可以看出我们需要三个for循环,分别遍历i, j 和 k。而k依赖于k - 1,i和j不依赖于i - 1和j-1。
挤着我们需要思考这三个循环的嵌套关系,初始化的时候,我们是把k = 0的对应的i和j的数值都初始化了,以便我们计算k = 1的时候i 和 j对应的数值。因此我们遍历的时候,也应该是从底层一层一层往上去遍历,所以k的for循环一定是在最外面,至于i和j的循环,则先后顺序都可以。
5.举例推导dp数组
这个可以由我们将数值一层一层打印出来进行分析。
代码:最终代码
cpp
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, m;
cin >> n >> m;
//1.确定dp数组以及下标的含义:
//保存图,同时也是dp数组,10001是因为边的权值最多为10000
vector<vector<vector<int>>> grid(n + 1, vector<vector<int>>(n + 1, vector<int>(n + 1, 10001)));
//2.确定递推公式
//grid[i][j][k] = min(grid[i][j][k-1], grid[i][k][k-1] + grid[k][j][k-1]);
int s, t, val;
while(m--) {
cin >> s >> t >> val;
//3.初始化dp数组,要注意这里是双向图
grid[s][t][0] = val;
grid[t][s][0] = val;
}
//4.确定遍历顺序
for(int k = 1; k <= n; k++) {
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++) {
grid[i][j][k] = min(grid[i][j][k-1], grid[i][k][k-1] + grid[k][j][k-1]);
}
}
}
//输出结果
int q;
cin >> q;
int start, end;
while(q--) {
cin >> start >> end;
if(grid[start][end][n] == 10001) {
cout << -1 << endl;
}
else {
cout << grid[start][end][n] << endl;
}
}
return 0;
}
其实知道了递推公式,代码也就比较简单了。在这里我们还可以堆该算法的空间进行优化,采用滚动数组的方式,因为显然k只依赖于k - 1的状态,并不需要记录k - 2,k - 3, k - 4等等这些状态。因此我们只需要记录两层的内容就可以了,定义一个grid[n + 1][ n + 1][2] 这么大的数组就可以。
这样能够解出答案,但是我们还可以再进一步,如果本层计算出的结果grid[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]的操作。
所以甚至我们不需要区分是k - 1层还是k层的,这样只需要一个二维数组就可以了,递推公式改为:
cpp
grid[i][j] = min(grid[i][j], grid[i][k] + grid[k][j]);
代码:dp数组为二维数组
cpp
//时间复杂度O(n^3)
//空间复杂度O(n^2)
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, m, p1, p2, val;
cin >> n >> m;
vector<vector<int>> grid(n + 1, vector<int>(n + 1, 10005)); // 因为边的最大距离是10^4
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2] = val;
grid[p2][p1] = val; // 注意这里是双向图
}
// 开始 floyd
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
grid[i][j] = min(grid[i][j], grid[i][k] + grid[k][j]);
}
}
}
// 输出结果
int z, start, end;
cin >> z;
while (z--) {
cin >> start >> end;
if (grid[start][end] == 10005) cout << -1 << endl;
else cout << grid[start][end] << endl;
}
return 0;
}
A*算法精讲(A star算法)
文档讲解:代码随想录A*算法精讲
其实能走的位置属于是"马走日",对一个点而言有八个可以移动的方向。因此我们可以采用广搜的方式,从一个点出发,遍历其八个方向,直到找到终点。
cpp
#include<iostream>
#include<queue>
#include<string.h> //包含memset函数
using namespace std;
int moves[1001][1001]; //棋盘大小
int dir[8][2]={-2,-1,-2,1,-1,2,1,2,2,1,2,-1,1,-2,-1,-2}; //八个方向
void bfs(int a1,int a2, int b1, int b2)
{
//初始化
queue<int> q;
q.push(a1);
q.push(a2);
while(!q.empty())
{
int m=q.front(); q.pop();
int n=q.front(); q.pop();
if(m == b1 && n == b2)
break;
for(int i=0;i<8;i++)
{
int mm=m + dir[i][0];
int nn=n + dir[i][1];
if(mm < 1 || mm > 1000 || nn < 1 || nn > 1000) { //越界
continue;
}
if(!moves[mm][nn]) //如果这个位置已经有值了,说明走回头路了
{
moves[mm][nn]=moves[m][n]+1; //移动一次
q.push(mm);
q.push(nn);
}
}
}
}
int main()
{
int n, a1, a2, b1, b2;
cin >> n;
while (n--) {
cin >> a1 >> a2 >> b1 >> b2;
memset(moves,0,sizeof(moves)); //此函数的意思是将数组moves里面的元素,都改为0,相当于每一次都需要重置moves数组一次。
bfs(a1, a2, b1, b2);
cout << moves[b1][b2] << endl;
}
return 0;
}
但显然广搜的时间复杂度很高,进行了很多无用的查询。
A*算法
Astar算法是一种广搜的改良版算法,也可以说是dijkstra的改良版算法。其实我们在进行搜索最短路的时候,如果边的权值都是1,那么我们一般使用广搜来进行,代码简洁时间效率和dijkstra相同,但是,如果是带权图,那么优先考虑dijkstra算法。
而Astar算法的关键就在于启发式函数,也就是影响广搜或者dijkstra从队列里取出元素的顺序。
首先我们知道在BFS中,我们搜索起点到终点的最短距离,是一层一层的去遍历的。
但如果使用Astar算法话,其搜索过程其实是这样的:
换句话说BF是没有目的性的一圈一圈去搜索,而Astar算法是有方向性的去搜索。因此能够节省很多不必要的步骤。
因此我们现在所需要知道的关键就是,Astar算法,是如何确定出方向的。关键就在于启发函数。 由于我们每次递归,都是从队列中选出两个点进行搜索,因此**启发函数的作用就是影响队列里面的顺序!**让朝着目标方向的点先出队列进行搜索。
因此启发函数其实就是计算遍历点到终点距离的一个公式。为了影响节点在队列中的顺序,我们可以给每个节点一个权值F,F = G + H。G:起点达到目前遍历节点的距离;H:目前遍历的节点到达终点的距离,F就表示起点到目标节点的距离,显然两点相连直线最短,这样就保证了,朝着相连直线的方向前进。
本题的图是无权网格状,在计算两点距离通常有如下三种计算方式:
- 曼哈顿距离,计算方式: d = abs(x1-x2)+abs(y1-y2)
- 欧氏距离(欧拉距离) ,计算方式:d = sqrt( (x1-x2)^2 + (y1-y2)^2 )
- 切比雪夫距离,计算方式:d = max(abs(x1 - x2), abs(y1 - y2))
x1, x2 为起点坐标,y1, y2 为终点坐标 ,abs 为求绝对值,sqrt 为求开根号。采用不同的距离公式,也会使得Astar算法的结果不同,本题我们采用欧拉距离公式,更大程度体现点与点之间的距离。
接着解题思路就是,计算出每个点的F,然后按照F的大小,来选取出队列中的节点,这个过程可以使用优先级队列来帮忙进行排序,每次出队列,就是F最小的节点。
代码:
cpp
#include<iostream>
#include<queue>
#include<string.h>
using namespace std;
int moves[1001][1001]; //棋盘大小
int dir[8][2]={-2,-1,-2,1,-1,2,1,2,2,1,2,-1,1,-2,-1,-2}; //八个方向
int b1, b2; //记录终点,设为全局变量方便计算欧拉距离使用
struct Knight{ //创建一个结构体其实,来存储坐标信息,和权值
int x,y;
int g,h,f;
bool operator < (const Knight & k) const{ // 重载运算符, 从小到大排序
return k.f < f;
}
};
priority_queue<Knight> que; //优先级队列
int Heuristic(const Knight& k) { // 欧拉距离
return (k.x - b1) * (k.x - b1) + (k.y - b2) * (k.y - b2); // 统一不开根号,这样可以提高精度
}
void astar(const Knight& k)
{
Knight cur, next;
que.push(k);
while(!que.empty())
{
cur = que.top();
que.pop();
if(cur.x == b1 && cur.y == b2) { //找到了目标终点
break;
}
for(int i = 0; i < 8; i++) //遍历八个方向
{
next.x = cur.x + dir[i][0];
next.y = cur.y + dir[i][1];
if(next.x < 1 || next.x > 1000 || next.y < 1 || next.y > 1000) { //越界
continue;
}
if(!moves[next.x][next.y]) //如果这个位置已经有值了,说明走回头路了
{
moves[next.x][next.y] = moves[cur.x][cur.y] + 1;
// 计算F
next.g = cur.g + 5; // 统一不开根号,提高精度,骑士属于马走日,每次距离都会是5
next.h = Heuristic(next);
next.f = next.g + next.h;
que.push(next); //优先级队列中,会自动把f小的放前面
}
}
}
}
int main()
{
int n, a1, a2;
cin >> n;
while (n--) {
cin >> a1 >> a2 >> b1 >> b2;
memset(moves,0,sizeof(moves)); //每一次将棋盘重置
Knight start;
start.x = a1;
start.y = a2;
start.g = 0;
start.h = Heuristic(start);
start.f = start.g + start.h;
astar(start); //算法开始
while(!que.empty()) que.pop(); // 队列清空,进入下一次循环
cout << moves[b1][b2] << endl;
}
return 0;
}
复杂度分析
Astar算法的复杂度,主要取决于启发式函数怎么写。最坏情况下,A * 退化成广搜,算法的时间复杂度是 O(n * 2),n 为节点数量。最佳情况,是从起点直接到终点,时间复杂度为 O(dlogd),d 为起点到终点的深度,这个时间复杂度也是堆排序的时间复杂度,也就是使用优先级队列的时间复杂度。实际上 A * 的时间复杂度是介于最优和最坏情况之间, 可以非常粗略的认为 A * 算法的时间复杂度是 O(nlogn) ,n 为节点数量。
A * 算法的空间复杂度 O(b ^ d) ,d 为起点到终点的深度,b是图中节点间的连接数量,本题因为是无权网格图,所以节点间连接数量为 4,四个方向。
A*算法的缺点
显然我们在运行A*算法的时候,向队列中添加了很多节点,但是实际取出来的仅仅是靠启发函数判断的距离中终点最近的接待你。因此可以说相较于BFS广度搜索算法,我们只是取出了最近的节点而已。这样就会导致大量不需要访问的节点都在队列里,会造成空间的过度消耗。
IDA * 算法对这一空间增长问题进行了优化,关于 IDA * 算法,还需要后续进行学习。
另外如果是给出多个可能的目标,然后在这多个目标中选择最近的目标,这样的场景A *是解决不了的。
最短路算法总结篇
最短路径算法可总结为四种算法:Dijkstra、Bellman_ford、SPFA 和 Floyd。(A*算作是广度搜索算法的优化)总计又包含:
- dijkstra朴素版
- dijkstra堆优化版
- Bellman_ford
- Bellman_ford 队列优化算法(又名SPFA)
- bellman_ford 算法判断负权回路
- bellman_ford之单源有限最短路
- Floyd 算法精讲
- 启发式搜索:A * 算法
每个算法,都有各自的应用场景,用一个表格表示为:
图论总结
图论正式完结了!!!图论包含了:
深搜和广搜
深搜与广搜是图论里基本的搜索方法,大家需要掌握三点:
- 搜索方式:深搜是可一个方向搜,不到黄河不回头。 广搜是围绕这起点一圈一圈的去搜。
- 代码模板:需要熟练掌握深搜和广搜的基本写法。
- 应用场景:图论题目基本上可以即用深搜也可用广搜,无疑是用哪个方便而已
并查集
并查集重点是要理解以下几个部分:
- 为什么要用并查集,怎么不用个二维数据,或者set、map之类的。(时间复杂度高,不方便)
- 并查集能解决那些问题,哪些场景会用到并查集(两个节点是否在一个集合,将两个节点加入到一个集合)
- 并查集原理以及代码实现
- 并查集写法的常见误区
- 带大家去模拟一遍并查集的过程
- 路径压缩的过程
- 时间复杂度分析
最小生成树
最小生成树算法,有prim 和 kruskal。
prim 算法是维护节点的集合,而 Kruskal 是维护边的集合。
在 稀疏图中,用Kruskal更优。 在稠密图中,用prim算法更优。
关于 prim算法,我自创了三部曲,来帮助大家理解:
- 第一步,选距离生成树最近节点
- 第二步,最近节点加入生成树
- 第三步,更新非生成树节点到生成树的距离(即更新minDist数组)
minDist数组 是prim算法的灵魂,它帮助 prim算法完成最重要的一步,就是如何找到距离最小生成树最近的点。
kruscal的主要思路:
- 边的权值排序,因为要优先选最小的边加入到生成树里
- 遍历排序后的边:如果边首尾的两个节点在同一个集合,说明如果连上这条边图中会出现环;如果边首尾的两个节点不在同一个集合,加入到最小生成树,并把两个节点加入同一个集合
而判断节点是否在一个集合以及将两个节点放入同一个集合,正是并查集的擅长所在。所以 Kruskal 是需要用到并查集的。
拓扑排序
拓扑排序是在图上的一种排序。给出一个有向图,把这个有向图转成线性的排序就叫拓扑排序。同样,拓扑排序也可以检测这个有向图是否有环,即存在循环依赖的情况。
只要记住如下两步拓扑排序的过程,代码就容易写了:
- 找到入度为0 的节点,加入结果集
- 将该节点从图中移除
最短路算法
最短路算法是图论中,比较复杂的算法,而且不同的最短路算法都有不同的应用场景。
可总结为四种算法:Dijkstra、Bellman_ford、SPFA 和 Floyd。(A*算作是广度搜索算法的优化)总计又包含:
- dijkstra朴素版
- dijkstra堆优化版
- Bellman_ford
- Bellman_ford 队列优化算法(又名SPFA)
- bellman_ford 算法判断负权回路
- bellman_ford之单源有限最短路
- Floyd 算法精讲
- 启发式搜索:A * 算法
总结
代码随想录60天的训练营!!!完结撒花,谢谢卡哥,谢谢代码随想录🎇🎇,后续还会有二刷,三刷!!!。