提示:DDU,供自己复习使用。欢迎大家前来讨论~
文章目录
- 图论part11
-
- [Floyd 算法精讲](#Floyd 算法精讲)
- [题目:97. 小明逛公园](#题目:97. 小明逛公园)
- [A \* 算法精讲 (A star算法)](#A * 算法精讲 (A star算法))
图论part11
Floyd 算法精讲
可以有效地解决多源最短路径问题,即计算图中所有节点对之间的最短路径。
题目:97. 小明逛公园
解题思路:
Floyd算法是一种用于解决多源最短路径问题的算法。
- 问题背景:传统的最短路径算法如Dijkstra和Bellman-Ford算法都是单源最短路径算法,而Floyd算法可以解决多源最短路径问题。
- 算法特点:Floyd算法可以处理边权值为正或负的情况。
- 核心思想:Floyd算法基于动态规划,通过逐步增加中间节点集合来迭代计算最短路径。
- dp数组定义 :使用一个三维数组
grid[i][j][k]
来表示从节点i到节点j,以[1...k]集合为中间节点的最短距离。 - 递推公式 :
- 如果路径经过节点k,则
grid[i][j][k] = grid[i][k][k - 1] + grid[k][j][k - 1]
。 - 如果路径不经过节点k,则
grid[i][j][k] = grid[i][j][k - 1]
。 - 最终取两者的最小值作为
grid[i][j][k]
的值。
- 如果路径经过节点k,则
- 初始化 :
grid[i][j][0]
初始化为直接连接节点i和j的边的权值,如果没有直接连接,则为无穷大。 - 遍历顺序 :按照k的值从小到大遍历,每一步都更新
grid
数组。 - 算法步骤 :
- 初始化
grid
数组。 - 遍历k,对于每个k,更新
grid
数组。 - 对于每个i和j,更新
grid[i][j][k]
的值。
- 初始化
- 算法优势:Floyd算法的时间复杂度为O(n^3),适合于节点数量不是特别大的图。
- 应用场景:Floyd算法适用于需要计算图中所有节点对之间最短路径的情况。
grid数组是一个三维数组,那么我们初始化的数据在 i 与 j 构成的平层,如图:
红色的 底部一层是我们初始化好的数据,注意:从三维角度去看初始化的数据很重要
解题思路总结:
-
初始化 :创建一个三维数组
grid
,用于存储任意两个节点间的最短路径。数组的每个元素grid[i][j][k]
表示从节点i到节点j经过节点集合[1...k]的最短路径长度。初始时,如果节点i和节点j之间有直接的边,则grid[i][j][0]
设为边的权重;如果没有直接的边,则设为一个足够大的数(如10005),表示无穷大。 -
递推公式 :通过动态规划的方式,逐步更新
grid
数组。对于每个节点k,更新所有节点i和j之间的最短路径,考虑是否经过节点k。递推公式为:[ grid[i][j][k] = \min(grid[i][j][k-1], grid[i][k][k-1] + grid[k][j][k-1]) ]
-
遍历顺序:遍历顺序至关重要,k的循环应该在外层,因为k的更新依赖于k-1的结果。i和j的循环可以任意顺序,因为它们不依赖于彼此的更新。
-
遍历实现:通过三层循环遍历所有节点,对于每一层k,更新i到j的最短路径。外层循环遍历k,中层循环遍历i,内层循环遍历j。
-
举例推导 :可以通过打印每一层k的
grid
数组来理解算法的执行过程,观察如何逐步更新最短路径。 -
结果输出 :最终,
grid[i][j][n]
将包含节点i到节点j的最短路径长度。 -
注意事项:确保初始化正确,以及遍历顺序正确,否则算法可能无法得到正确的结果。
以上分析完毕,最后代码如下:
cpp
#include <iostream>
#include <vector>
#include <list>
using namespace std;
int main() {
int n, m, p1, p2, val;
cin >> n >> m;
vector<vector<vector<int>>> grid(n + 1, vector<vector<int>>(n + 1, vector<int>(n + 1, 10005))); // 因为边的最大距离是10^4
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2][0] = val;
grid[p2][p1][0] = 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][k] = min(grid[i][j][k-1], grid[i][k][k-1] + grid[k][j][k-1]);
}
}
}
// 输出结果
int z, start, end;
cin >> z;
while (z--) {
cin >> start >> end;
if (grid[start][end][n] == 10005) cout << -1 << endl;
else cout << grid[start][end][n] << endl;
}
}
A * 算法精讲 (A star算法)
题目:127. 骑士的攻击
问题描述
- 题目要求在一个1000x1000的地图上,找到从起点(a1, a2)到终点(b1, b2)的最短路径。
- 地图上可能有障碍物,障碍物的位置由
moves
数组标记,如果moves[mm][nn]
为非零,则表示该位置有障碍物。
算法选择
- 广度优先搜索(BFS)是解决此类最短路径问题的经典算法。
解题思路
C++代码实现
cpp
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, 2, 1, 1, 2, 2, -1, 1, -2, -1, -2};
void bfs(int a1, int a2, int b1, int b2) {
queue<pair<int, int>> q;
q.push({a1, a2});
while (!q.empty()) {
int m = q.front().first; q.pop();
int n = q.front().second; 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, nn});
}
}
}
}
int main() {
int n, a1, a2, b1, b2;
cin >> n;
while (n--) {
cin >> a1 >> a2 >> b1 >> b2;
memset(moves, 0, sizeof(moves));
bfs(a1, a2, b1, b2);
cout << moves[b1][b2] << endl;
}
return 0;
}
- 代码在处理大规模数据时超时,因为地图很大,且查询次数可能很多。
解决方案
- 需要优化算法以减少计算时间。
- 可以考虑使用启发式搜索(如A*搜索算法)来减少搜索空间。
- 可以使用记忆化搜索(Memoization)来避免重复计算。
- 可以考虑使用并查集等数据结构来优化地图的障碍物处理。
总结
- BFS是解决最短路径问题的有效方法,但在大规模数据下可能会超时。
- 需要考虑算法优化和数据结构的选择来提高效率。
Astar
Astar 是一种 广搜的改良版。 有的是 Astar是 dijkstra 的改良版。
其实只是场景不同而已 我们在搜索最短路的时候, 如果是无权图(边的权值都是1) 那就用广搜,代码简洁,时间效率和 dijkstra 差不多 (具体要取决于图的稠密)
如果是有权图(边有不同的权值),优先考虑 dijkstra。
而 Astar 关键在于 启发式函数, 也就是 影响 广搜或者 dijkstra 从 容器(队列)里取元素的优先顺序。
以下,我用BFS版本的A * 来进行讲解。
在BFS中,我们想搜索,从起点到终点的最短路径,要一层一层去遍历。
如果 使用A * 的话,其搜索过程是这样的,如图,图中着色的都是我们要遍历的点。
(上面两图中 最短路长度都是8,只是走的方式不同而已)
大家可以发现 BFS 是没有目的性的 一圈一圈去搜索, 而 A * 是有方向性的去搜索。
看出 A * 可以节省很多没有必要的遍历步骤。
为了让大家可以明显看到区别,我将 BFS 和 A * 制作成可视化动图,大家可以自己看看动图,效果更好。
地址:https://kamacoder.com/tools/knight.html
那么 A * 为什么可以有方向性的去搜索,它的如何知道方向呢?
其关键在于 启发式函数。
那么启发式函数落实到代码处,如果指引搜索的方向?
在本篇开篇中给出了BFS代码,指引 搜索的方向的关键代码在这里:
cpp
int m=q.front();q.pop();
int n=q.front();q.pop();
从队列里取出什么元素,接下来就是从哪里开始搜索。
所以 启发式函数 要影响的就是队列里元素的排序!
这是影响BFS搜索方向的关键。
对队列里节点进行排序,就需要给每一个节点权值,如何计算权值呢?
每个节点的权值为F,给出公式为:F = G + H
G:起点达到目前遍历节点的距离
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 为求开根号,
选择哪一种距离计算方式 也会导致 A * 算法的结果不同。
本题,采用欧拉距离才能最大程度体现 点与点之间的距离。
所以 使用欧拉距离计算 和 广搜搜出来的最短路的节点数是一样的。 (路径可能不同,但路径上的节点数是相同的)
我在制作动画演示的过程中,分别给出了曼哈顿、欧拉以及契比雪夫 三种计算方式下,A * 算法的寻路过程,大家可以自己看看看其区别。
动画地址:https://kamacoder.com/tools/knight.html
计算出来 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;
// F = G + H
// G = 从起点到该节点路径消耗
// H = 该节点到终点的预估消耗
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; // 统一不开根号,这样可以提高精度,马走日,1 * 1 + 2 * 2 = 5
next.h = Heuristic(next);
next.f = next.g + next.h;
que.push(next);
}
}
}
}
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;
}
A算法是一种启发式搜索算法,它结合了最佳优先搜索和Dijkstra算法的优点,用于寻找两点间的最短路径。以下是对A算法复杂度分析的拓展:
时间复杂度
- 最坏情况 :A算法的时间复杂度为(O(b^d)),其中(b)是每个节点的邻居节点数(在网格图中通常是4或8),(d)是从起点到终点的路径长度。在最坏情况下,A算法需要探索所有可能的路径才能找到最短路径。
- 最佳情况:如果启发式函数能够完美地预测剩余距离,那么A*算法的时间复杂度可以降低到(O(d)),即路径长度。
- 平均情况:通常认为A*算法的时间复杂度为(O(n\log n)),这里(n)是节点数量。这个估计是基于启发式函数通常能够减少搜索空间的大小,并且搜索过程中的堆排序操作导致的。
空间复杂度
- A*算法的空间复杂度为(O(b^d)),其中(b)是每个节点的邻居节点数,(d)是从起点到终点的路径长度。这是因为算法需要存储所有已经探索过的节点。
启发式函数
- 启发式函数的选择对A*算法的性能有重大影响。一个好的启发式函数可以减少搜索空间,从而减少计算时间。
- 常用的启发式函数包括曼哈顿距离(Manhattan distance)和欧几里得距离(Euclidean distance),它们分别适用于不同规则的网格图。
优化
- 使用优先队列:A*算法通常使用优先队列来存储待探索的节点,这样可以快速找到具有最低(f(n) = g(n) + h(n))值的节点,其中(g(n))是从起点到当前节点的实际代价,(h(n))是启发式估计的从当前节点到终点的代价。
- 内存优化:在实际应用中,可以通过实现迭代加深或使用内存池等技术来减少A*算法的空间复杂度。
小结
A算法是一种非常有效的路径搜索算法,其性能依赖于启发式函数的设计。在最坏情况下,它可能退化为广度优先搜索,但在最佳情况下,它可以非常高效地找到最短路径。实际应用中,A算法通常介于这两种极端情况之间,其性能通常优于Dijkstra算法和广度优先搜索。
最短路算法总结篇
- dijkstra朴素版
- dijkstra堆优化版
- Bellman_ford
- Bellman_ford 队列优化算法(又名SPFA)
- bellman_ford 算法判断负权回路
- bellman_ford之单源有限最短路
- Floyd 算法精讲
- 启发式搜索:A * 算法
这段文字提供了一个关于最短路径算法选择的指导,以及它们各自的使用场景。以下是对这段文字的整理:
最短路径算法使用场景分析
-
Dijkstra算法
- 适用场景:单源最短路径问题,所有边的权重必须为正数。
- 选择版本:图的稠密度高时,使用堆优化版;稠密度低时,朴素版或堆优化版均可。
- 一般推荐:直接使用堆优化版。
-
Bellman-Ford算法
- 适用场景:单源最短路径问题,边的权重可以为负数,但不能有负权回路。
- 选择版本:图的稠密度高时,使用SPFA;稠密度低时,Bellman-Ford或SPFA均可。
- 一般推荐:直接使用SPFA。
-
Floyd算法
- 适用场景:多源最短路径问题,可以处理边权重为正或负的情况。
- 一般推荐:直接使用Floyd算法。
-
A*算法
- 适用场景:启发式搜索,适用于路径规划问题,如游戏开发、地图导航、数据包路由等。
- 特点:高效,但结果可能不是最短路径,而是近似解。
算法特性总结
- Dijkstra算法:适用于边权重全为正的情况。
- Bellman-Ford算法:适用于边权重可以为负的情况,可以检测负权回路。
- SPFA算法:Bellman-Ford算法的优化版本,适用于稠密图。
- Floyd算法:适用于多源点最短路径问题,可以处理边权重为正或负的情况。
算法选择建议
- 单源正权:使用Dijkstra算法。
- 单源负权:使用Bellman-Ford算法或SPFA算法。
- 多源点:使用Floyd算法。
- 路径规划:使用A*算法。
注意事项
- 图的稠密度:没有明确的量化标准,可以通过实验测试来确定使用哪种版本。
- 负权回路:如果存在负权回路,优先使用Bellman-Ford算法。
- 有限节点最短路:如果节点数量有限,优先使用Bellman-Ford算法,因为代码实现简单。
- 算法题:A*算法由于结果的不唯一性,一般不适合作为算法题。
图论总结
图论算法和数据结构
-
图的存储方式
- 邻接表和邻接矩阵是图的两种基本存储方式。
-
深度优先搜索(DFS)和广度优先搜索(BFS)
- 搜索方式:DFS深入搜索,BFS逐层扩展。
- 代码模板:掌握DFS和BFS的基本代码实现。
- 应用场景:根据问题选择合适的搜索方法。
-
DFS和BFS的注意事项
- DFS可能需要回溯,特别是在需要计算路径的问题中。
- BFS需要在加入队列时标记节点,避免重复访问。
-
并查集
- 用途:用于处理一些不交集的合并及查询问题。
- 原理:通过父指针和路径压缩来优化查询和合并操作。
- 应用:例如判断图是否为树,解决冗余连接问题。
-
最小生成树
- Prim算法:适用于稠密图,维护节点集合。
- Kruskal算法:适用于稀疏图,维护边集合。
- 选择依据:根据图的稠密度选择算法。
-
拓扑排序
- 定义:对有向图的节点进行线性排序,使得所有有向边都从排序前的节点指向排序后的节点。
- 应用场景:如大学排课、文件下载依赖等。
- 过程:找到入度为0的节点,加入结果集并从图中移除。
-
最短路径算法
- 包括Dijkstra算法、Bellman-Ford算法、Floyd算法等,各有其适用场景。
总结
- 图论是算法中的重要部分,涵盖了多种算法和数据结构。
- 理解各种算法的原理和适用场景对于解决实际问题至关重要。
- 掌握图的存储方式、DFS/BFS、并查集、最小生成树、拓扑排序和最短路径算法是图论学习的关键。
- 通过不断回顾和实践,可以更深入地理解图论算法。