文章目录
- [题型:最短路径算法 - Dijkstra算法](#题型:最短路径算法 - Dijkstra算法)
-
- [1. 核心思路](#1. 核心思路)
-
- [1.1 基本概念](#1.1 基本概念)
- [1.2 核心思想](#1.2 核心思想)
- [1.3 与Prim算法的区别](#1.3 与Prim算法的区别)
- [2. 基础版本(邻接矩阵)](#2. 基础版本(邻接矩阵))
-
- [2.1 算法模板](#2.1 算法模板)
- [2.2 时间复杂度分析](#2.2 时间复杂度分析)
- [3. 堆优化版本(邻接表)](#3. 堆优化版本(邻接表))
-
- [3.1 核心思想](#3.1 核心思想)
- [3.2 算法模板](#3.2 算法模板)
- [3.3 时间复杂度分析](#3.3 时间复杂度分析)
- [4. 常见变形](#4. 常见变形)
-
- [4.1 求源点到所有节点的最短路径](#4.1 求源点到所有节点的最短路径)
- [4.2 记录最短路径(路径还原)](#4.2 记录最短路径(路径还原))
- [4.3 多条最短路径](#4.3 多条最短路径)
- [4.4 限制边数的最短路径](#4.4 限制边数的最短路径)
- [5. 典型应用场景](#5. 典型应用场景)
- [6. 算法对比总结](#6. 算法对比总结)
- [7. 注意事项](#7. 注意事项)
- [题型:最短路径算法 - Bellman-Ford算法](#题型:最短路径算法 - Bellman-Ford算法)
-
- [1. 核心思路](#1. 核心思路)
-
- [2. 基础版本(边列表)](#2. 基础版本(边列表))
- [3. SPFA算法(队列优化的Bellman-Ford)](#3. SPFA算法(队列优化的Bellman-Ford))
- [4. 时间复杂度分析](#4. 时间复杂度分析)
- [5. 常见变形](#5. 常见变形)
-
- [5.1 检测负权回路](#5.1 检测负权回路)
-
- [5.1.1 Bellman-Ford检测负权回路](#5.1.1 Bellman-Ford检测负权回路)
- [5.1.2 SPFA检测负权回路](#5.1.2 SPFA检测负权回路)
- [5.2 限制边数的最短路径](#5.2 限制边数的最短路径)
- [6. 典型应用场景](#6. 典型应用场景)
- [7. 算法对比总结](#7. 算法对比总结)
- [8. 注意事项](#8. 注意事项)
- [题型:多源最短路 - Floyd算法](#题型:多源最短路 - Floyd算法)
-
- [1. 核心思路](#1. 核心思路)
-
- [1.1 基本概念](#1.1 基本概念)
- [1.2 核心思想](#1.2 核心思想)
- [2. 动态规划五部曲](#2. 动态规划五部曲)
-
- [2.1 确定dp数组及下标含义](#2.1 确定dp数组及下标含义)
- [2.2 确定递推公式](#2.2 确定递推公式)
- [2.3 dp数组初始化](#2.3 dp数组初始化)
- [2.4 确定遍历顺序](#2.4 确定遍历顺序)
- [2.5 举例推导数组](#2.5 举例推导数组)
- [3. 算法模板](#3. 算法模板)
-
- [3.1 三维版本(便于理解)](#3.1 三维版本(便于理解))
- [3.2 空间优化版本(推荐)](#3.2 空间优化版本(推荐))
- [3.3 时间复杂度分析](#3.3 时间复杂度分析)
- [4. 常见变形](#4. 常见变形)
-
- [4.1 记录最短路径](#4.1 记录最短路径)
- [4.2 检测负权回路](#4.2 检测负权回路)
- [4.3 传递闭包](#4.3 传递闭包)
- [5. 典型应用场景](#5. 典型应用场景)
- [6. 算法对比总结](#6. 算法对比总结)
- [7. 注意事项](#7. 注意事项)
- 题型:A*算法(A-Star)
-
- [1. 核心思路](#1. 核心思路)
-
- [1.1 基本概念](#1.1 基本概念)
- [1.2 评估函数](#1.2 评估函数)
- [1.3 算法特点](#1.3 算法特点)
- [2. 算法模板](#2. 算法模板)
-
- [2.1 基础版本(网格地图)](#2.1 基础版本(网格地图))
- [2.2 通用版本(图结构)](#2.2 通用版本(图结构))
- [2.3 时间复杂度分析](#2.3 时间复杂度分析)
- [3. 常见变形](#3. 常见变形)
-
- [3.1 不同的启发式函数](#3.1 不同的启发式函数)
-
- [3.1.1 曼哈顿距离(Manhattan Distance)](#3.1.1 曼哈顿距离(Manhattan Distance))
- [3.1.2 欧氏距离(Euclidean Distance)](#3.1.2 欧氏距离(Euclidean Distance))
- [3.1.3 切比雪夫距离(Chebyshev Distance)](#3.1.3 切比雪夫距离(Chebyshev Distance))
- [3.2 路径还原](#3.2 路径还原)
- [3.3 加权A*(Weighted A*)](#3.3 加权A*(Weighted A*))
- [4. 典型应用场景](#4. 典型应用场景)
- [5. 算法对比总结](#5. 算法对比总结)
- [6. 注意事项](#6. 注意事项)
题型:最短路径算法 - Dijkstra算法
1. 核心思路
Dijkstra算法用于在**有权图(权值非负)**中求从起点到其他所有节点的最短路径。
1.1 基本概念
- 单源最短路径:从一个源点出发,到图中所有其他节点的最短路径
- 权值限制 :权值必须非负(因为访问过的节点不能再访问,负权值会导致错过真正的最短路)
- 应用场景 :
- 网络路由:找到数据包传输的最短路径
- 地图导航:找到两点间的最短距离
- 资源分配:找到最优分配路径
示例:
有向图: 从A到各点的最短路径:
A--3-->B A → A: 0
| | A → B: 3
1 2 A → C: 1
| | A → D: 5 (A→C→D)
C--4-->D A → E: 6 (A→B→E)
|
5
|
E
1.2 核心思想
采用贪心策略,每次选择距离源点最近且未访问过的节点,更新其邻居节点的最短距离。
算法流程(三部曲):
- 选节点:从所有未访问节点中,选择距离源点最近的节点
- 标记访问:将该节点标记为已访问
- 更新距离:更新该节点的所有邻居节点到源点的最短距离(通过minDist数组)
关键数据结构:
minDist[i]:记录源点到节点i的最短距离visited[i]:标记节点i是否已被访问
1.3 与Prim算法的区别
| 比较项 | Prim算法 | Dijkstra算法 |
|---|---|---|
| 目标 | 最小生成树(所有节点连通) | 最短路径(源点到各点) |
| 更新规则 | minDist[j] = min(minDist[j], grid[cur][j]) |
minDist[v] = min(minDist[v], minDist[cur] + grid[cur][v]) |
| 含义 | 节点j到生成树的最小距离 | 源点到节点v的最短距离 |
| 计算方式 | 直接使用边的权值 | 源点到cur的距离 + cur到v的距离 |
核心区别:
- Prim :
grid[cur][j]表示 cur 加入生成树后,生成树到节点j的距离 - Dijkstra :
minDist[cur] + grid[cur][v]表示源点到cur的距离 + cur到v的距离 = 源点到v的距离
2. 基础版本(邻接矩阵)
2.1 算法模板
cpp
#include<iostream>
#include<vector>
#include<climits>
using namespace std;
int main(){
int n, m, p1, p2, val; // n是节点数,m是边数
cin >> n >> m;
// 邻接矩阵(适合稠密图)
// 标记数组序号和节点一致,都是1到n,不用0
// 初始化为最大值,表示不连通
vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
// 读入边
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2] = val; // 有向图
// 如果是无向图,需要添加:grid[p2][p1] = val;
}
int start = 1; // 起点
int end = n; // 终点
// 存储从源节点到每个节点的最短距离
vector<int> minDist(n + 1, INT_MAX);
// 记录顶点是否被访问过
vector<bool> visited(n + 1, false);
minDist[start] = 0; // 起点到自身的距离为0
// 遍历所有节点
for(int i = 0; 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;
}
}
// 如果没有找到未访问的节点,说明图不连通
if(cur == -1) break;
// 2. 标记该节点已经访问
visited[cur] = true;
// 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;
}
return 0;
}
2.2 时间复杂度分析
- 时间复杂度 :O(V²)
- 外层循环:V次
- 内层循环:每次遍历V个节点找最小值,再遍历V个节点更新距离
- 空间复杂度:O(V²)(邻接矩阵)
- 适用场景:稠密图(边数接近V²)
3. 堆优化版本(邻接表)
3.1 核心思想
类似Prim和Kruskal的思想:
- 稠密图:用邻接矩阵方便
- 稀疏图:用邻接表较为方便
优化思路:
- 基础版本用两层循环遍历,一层是遍历所有节点找最近节点,第二层是更新minDist
- 堆优化版本从边的角度 出发,利用小顶堆对边的权值进行排序,这样取出来的就是离源节点最近的节点
3.2 算法模板
cpp
#include<iostream>
#include<vector>
#include<list>
#include<queue>
#include<climits>
using namespace std;
// 小顶堆比较器
class mycomparison{
public:
bool operator()(const pair<int, int>& lhs, const pair<int, int>& 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<list<Edge>> 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; // 终点
// 存储从源节点到每个节点的最短距离
vector<int> minDist(n + 1, INT_MAX);
// 记录顶点是否被访问过
vector<bool> visited(n + 1, false);
// 优先级队列存放 pair<节点,源点到该节点的距离>
//priority_queue<元素类型, 存储容器, 比较器>
priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;
// 初始化队列,源点到源点的距离是0
pq.push(pair<int, int>(start, 0));
minDist[start] = 0; // 起始点到自身的距离为0
while(!pq.empty()){
// 1. 通过优先级队列,选源节点到哪个节点近且该节点未被访问过
pair<int, int> cur = pq.top();
pq.pop();
// 如果该节点已经访问过,跳过
if(visited[cur.first]) continue;
// 2. 标记被访问过
visited[cur.first] = true;
// 3. 更新非访问节点到源点的距离(minDist数组)
// 遍历cur指向的节点
for(Edge edge : grid[cur.first]){
// cur指向节点edge.to,这条边的权值为edge.val
if(!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]){
minDist[edge.to] = minDist[cur.first] + edge.val;
pq.push(pair<int, int>(edge.to, minDist[edge.to]));
}
}
}
// 输出结果
if(minDist[end] == INT_MAX) {
cout << -1 << endl;
} else {
cout << minDist[end] << endl;
}
return 0;
}
3.3 时间复杂度分析
- 时间复杂度 :O((V + E)logV)
- 每个节点最多入队一次:O(V)
- 每条边最多被访问一次:O(E)
- 堆操作:O(logV)
- 空间复杂度:O(V + E)(邻接表 + 堆)
- 适用场景:稀疏图(边数远小于V²)
4. 常见变形
4.1 求源点到所有节点的最短路径
cpp
// 在算法结束后,minDist数组存储的就是源点到所有节点的最短距离
// 遍历minDist数组即可得到所有最短路径
for(int i = 1; i <= n; i++){
if(minDist[i] == INT_MAX) {
cout << "节点" << i << "不可达" << endl;
} else {
cout << "源点到节点" << i << "的最短距离: " << minDist[i] << endl;
}
}
4.2 记录最短路径(路径还原)
cpp
// 添加parent数组记录路径
vector<int> parent(n + 1, -1);
// 在更新距离时记录父节点
if(minDist[cur] + grid[cur][v] < minDist[v]){
minDist[v] = minDist[cur] + grid[cur][v];
parent[v] = cur; // 记录父节点
}
// 路径还原
vector<int> path;
int cur = end;
while(cur != -1){
path.push_back(cur);
cur = parent[cur];
}
reverse(path.begin(), path.end());
4.3 多条最短路径
cpp
// 使用vector存储多条路径
vector<vector<int>> paths;
vector<int> path;
// 使用DFS回溯找到所有最短路径
void dfs(int node, int target, vector<int>& path,
vector<vector<int>>& graph, vector<int>& minDist){
if(node == target){
paths.push_back(path);
return;
}
for(int next : graph[node]){
if(minDist[next] == minDist[node] + 1){ // 假设边权为1
path.push_back(next);
dfs(next, target, path, graph, minDist);
path.pop_back();
}
}
}
4.4 限制边数的最短路径
cpp
// 使用动态规划:dp[k][v] 表示经过k条边到达v的最短距离
vector<vector<int>> dp(k + 1, vector<int>(n + 1, INT_MAX));
dp[0][start] = 0;
for(int step = 1; step <= k; step++){
for(int u = 1; u <= n; u++){
if(dp[step - 1][u] != INT_MAX){
for(Edge edge : graph[u]){
dp[step][edge.to] = min(dp[step][edge.to],
dp[step - 1][u] + edge.val);
}
}
}
}
5. 典型应用场景
-
网络路由问题
- LeetCode 743. 网络延迟时间
- 找到数据包传输的最短路径
-
地图导航问题
- 找到两点间的最短距离
- 计算最优路线
-
资源分配问题
- 找到最优分配路径
- 最小成本路径
-
社交网络问题
- 找到两个人之间的最短关系链
- 六度分隔理论
6. 算法对比总结
| 特性 | 基础版本 | 堆优化版本 |
|---|---|---|
| 数据结构 | 邻接矩阵 | 邻接表 + 优先队列 |
| 时间复杂度 | O(V²) | O((V+E)logV) |
| 空间复杂度 | O(V²) | O(V+E) |
| 适用图类型 | 稠密图 | 稀疏图 |
| 实现难度 | 简单 | 中等 |
| 推荐 | 稠密图使用 | 稀疏图使用 |
选择建议:
- 稠密图 (E ≈ V²)→ 使用 基础版本(邻接矩阵)
- 稀疏图 (E << V²)→ 使用 堆优化版本(邻接表)
- 一般情况 → 堆优化版本更通用,推荐使用
7. 注意事项
-
权值必须非负
- 如果图中有负权边,Dijkstra算法无法得到正确结果
- 需要使用Bellman-Ford或SPFA算法
-
有向图 vs 无向图
- 有向图:只添加一条边
grid[p1][p2] = val - 无向图:需要添加两条边
grid[p1][p2] = val; grid[p2][p1] = val;
- 有向图:只添加一条边
-
不可达节点
- 如果
minDist[i] == INT_MAX,说明源点无法到达节点i - 需要在输出时进行判断
- 如果
-
堆优化版本的重复入队
- 同一个节点可能多次入队(因为距离可能被多次更新)
- 使用
visited数组避免重复处理
-
初始化问题
- 起点距离必须初始化为0:
minDist[start] = 0 - 其他节点初始化为最大值:
minDist[i] = INT_MAX
- 起点距离必须初始化为0:
题型:最短路径算法 - Bellman-Ford算法
1. 核心思路
Bellman-Ford算法用于在**有权图(可以包含负权边)**中求从起点到其他所有节点的最短路径。
关键特性:
- 可以处理负权边:这是与Dijkstra算法的主要区别
- 可以检测负权回路:如果图中存在负权回路,算法可以检测出来
- 单源最短路径:从一个源点出发,到图中所有其他节点的最短路径
核心思想 :
对所有边进行松弛操作 (minDist[B] = min(minDist[A] + value, minDist[B]))n-1次(n为节点数量),从而求得最短路。
松弛操作的含义:
- 松弛1次:可以得到起点到达与起点一条边相连的节点的最短距离
- 松弛2次:可以得到起点到达与起点两条边相连的节点的最短距离
- ...
- 松弛n-1次:可以得到起点到达所有节点的最短距离(因为n个节点最多需要n-1条边连接)
为什么需要n-1次松弛:
- 节点数量为n,起点到终点最多是n-1条边相连接
- 对所有边松弛n-1次就一定能得到起点到达终点的最短距离
2. 基础版本(边列表)
算法模板:
cpp
#include<iostream>
#include<vector>
#include<list>
#include<climits>
using namespace std;
int main(){
int n,m,p1,p2,val;
cin>>n>>m;
vector<vector<int>>grid;
//将所有边保存起来
for(int i=0;i<m;i++){
cin>>p1>>p2>>val;
//p1指向p2,权值为val
grid.push_back({p1,p2,val});//行列表
//grid[p1][p2]=val;//地图,注意不能混用
}
int start=1;
int end=n;
vector<int> minDist(n+1,INT_MAX);
minDist[start]=0;
//因为 Bellman-Ford 依赖"重复松弛"来逐渐传播最短路径信息,所以不需要visited数组
for(int i=1;i<n;i++){//对所有边松弛n-1次
for(vector<int> &side:grid){//每一次松弛都是对所有边进行松弛
int from=side[0];//边的出发点
int to=side[1];//边的到达点
int price=side[2];//边的权值
//松弛
//防止从未计算的节点出发
if(minDist[from]!=INT_MAX&&minDist[to]>minDist[from]+price){
minDist[to]=minDist[from]+price;
}
}
}
if(minDist[end]==INT_MAX)cout<<-1<<endl;
else cout<<minDist[end]<<endl;
}
3. SPFA算法(队列优化的Bellman-Ford)
核心思想 :
初始的Bellman-Ford算法对每条边都做了松弛操作,但实际上没有必要。只需要对上一次松弛时更新过的节点(用队列记录)作为出发节点所链接的边进行松弛操作即可。
优化原理:
- 使用队列记录需要松弛的节点
- 只有距离被更新的节点才可能影响其他节点
- 避免了大量无效的松弛操作
算法模板:
cpp
#include<iostream>
#include<vector>
#include<queue>
#include<list>
#include<climits>
using namespace std;
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<list<Edge>>grid(n+1);
vector<bool> isInQueue(n+1);//优化一下,已经在队列里面的元素不用重复添加
//记录输入
for(int i=0;i<m;i++){
cin>>p1>>p2>>val;
grid[p1].push_back(Edge(p2,val));
}
int start=1;
int end=n;
vector<int> minDist(n+1,INT_MAX);
minDist[start]=0;
queue<int>que;
que.push(start);
while(!que.empty()){
int node=que.front();que.pop();
isInQueue[node]=false;//从队列取出来时候要取消标记,i因为只是保证已经在队列里面的元素不用重复加入
for(Edge edge:grid[node]){
int from=node;
int to=edge.to;
int val=edge.val;
if(minDist[to]>minDist[from]+val){
//开始松弛
minDist[to]=minDist[from]+val;
if(isInQueue[to]==false){//已在队列中的元素不用重复添加
que.push(to);
isInQueue[to]=true;
}
}
}
}
if(minDist[end]==INT_MAX) cout<<"unconnected"<<endl;
else cout<<minDist[end]<<endl;
}
为什么不会死循环:
- 如果没有负权回路(负权回路是指一个环,整个环上的权值和是负的),队列不会造成死循环
- 即使有多个路径值一样也没有关系,不会死循环
- 节点再加入队列需要有松弛的行为,但是每个节点已经计算出起点到该节点的最短路径后,就不会进入if判断,即不会有新的节点加入队列
4. 时间复杂度分析
- Bellman-Ford基础版本 :
- 时间复杂度:O(V × E)
- 空间复杂度:O(V)
- SPFA算法 :
- 时间复杂度:平均O(E),最坏O(V × E)(存在负权回路时)
- 空间复杂度:O(V + E)
5. 常见变形
5.1 检测负权回路
负权回路:一个环,整个环上的权值和是负的。如果存在负权回路,最短路径可能不存在(可以在这个环中一直转,minDist会一直变小)。
5.1.1 Bellman-Ford检测负权回路
在松弛n-1次已经得到结果后,可以进行第n次松弛,如果结果有变化,就代表存在负权回路:
cpp
// 在Bellman-Ford算法后添加检测
bool hasNegativeCycle = false;
for(vector<int> &side : grid){
int from = side[0];
int to = side[1];
int price = side[2];
// 如果还能继续松弛,说明存在负权回路
if(minDist[from] != INT_MAX && minDist[to] > minDist[from] + price){
hasNegativeCycle = true;
break;
}
}
5.1.2 SPFA检测负权回路
在极端情况下,所有节点都与其他节点相连,每个点都入度为n-1,所以每个节点最多加入n-1次队列。如果某个节点加入队列次数超过了n-1次,那么该图一定有负权回路:
cpp
vector<int> count(n + 1, 0); // 记录每个节点入队次数
while(!que.empty()){
int node = que.front();
que.pop();
isInQueue[node] = false;
for(Edge edge : grid[node]){
if(minDist[edge.to] > minDist[node] + edge.val){
minDist[edge.to] = minDist[node] + edge.val;
if(!isInQueue[edge.to]){
count[edge.to]++;
if(count[edge.to] > n - 1){
// 存在负权回路
return true;
}
que.push(edge.to);
isInQueue[edge.to] = true;
}
}
}
}
5.2 限制边数的最短路径
问题描述 :从起点到终点,最多经过k个中间城市(不是一定经过k个城市,可以经过的城市数量比k少),求最短路径。
核心思想:
- 标准的Bellman-Ford算法是松弛n-1次
- 限制边数版本:最多经过k个中间城市,算上起点和终点就是k+1条边,所以只需要松弛k+1次
关键点 :需要使用上一轮的minDist(minDist_copy)来计算,保证每轮的更新是严格+1条边。
为什么需要复制 :
如果不复制,在同一轮松弛中,可能会使用本轮已经更新过的值,导致一条边被计算了多次。
示例说明:
图:1 -> 2 权重 1
2 -> 3 权重 1
要求:最多经过 1 条边(k=1)
正确答案:1 -> 2 OK,1 -> 3 不行(需要两条边)
如果不复制会怎么样?
第一轮(使用 minDist):
1 -> 2 得到距离 1
再用更新后的 minDist[2] = 1 继续松弛
→ 2 -> 3 得到距离 2 (错误!)
这等于是让"第一轮"用了两条边,违反了"最多1条边"的要求。
正确做法(复制 minDist 到 minDist_copy):
第一轮:
minDist_copy = {dist using 0 条边}
用 minDist_copy 松弛
只能更新 1 个边:1 -> 2
不会更新 3
这样保证每轮的更新是严格 +1 条边。
算法模板:
cpp
#include <iostream>
#include <vector>
#include <list>
#include <climits>
using namespace std;
int main() {
int src, dst,k ,p1, p2, val ,m , n;
cin >> n >> m;
vector<vector<int>> grid;
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid.push_back({p1, p2, val});
}
cin >> src >> dst >> k;
vector<int> minDist(n + 1 , INT_MAX);
minDist[src] = 0;
vector<int> minDist_copy(n + 1); // 用来记录上一次遍历的结果
for (int i = 1; i <= k + 1; i++) {
minDist_copy = minDist; // 获取上一次计算的结果
for (vector<int> &side : grid) {
int from = side[0];
int to = side[1];
int price = side[2];
// 注意使用 minDist_copy 来计算 minDist
if (minDist_copy[from] != INT_MAX && minDist[to] > minDist_copy[from] + price) {
minDist[to] = minDist_copy[from] + price;
}
}
}
if (minDist[dst] == INT_MAX) cout << "unreachable" << endl; // 不能到达终点
else cout << minDist[dst] << endl; // 到达终点最短路径
}
6. 典型应用场景
-
包含负权边的最短路径问题
- 金融系统中的套利检测
- 网络延迟可能为负的情况
-
负权回路检测
- 检测图中是否存在负权回路
- 套利机会检测
-
限制边数的最短路径
- 航班中转次数限制
- 网络跳数限制
-
动态图最短路径
- 边权值可能变化的情况
- 实时路径规划
7. 算法对比总结
| 特性 | Dijkstra | Bellman-Ford | SPFA |
|---|---|---|---|
| 权值限制 | 非负权 | 可负权 | 可负权 |
| 负权回路检测 | 不支持 | 支持 | 支持 |
| 时间复杂度 | O((V+E)logV) | O(V×E) | 平均O(E),最坏O(V×E) |
| 空间复杂度 | O(V+E) | O(V) | O(V+E) |
| 适用场景 | 非负权图 | 负权图、检测负权回路 | 负权图(优化版) |
| 推荐 | 非负权图使用 | 需要检测负权回路 | 负权图优化版本 |
选择建议:
- 非负权图 → 使用 Dijkstra算法
- 负权图,需要检测负权回路 → 使用 Bellman-Ford算法
- 负权图,不需要检测负权回路 → 使用 SPFA算法(更高效)
8. 注意事项
-
Bellman-Ford不需要visited数组
- 因为依赖"重复松弛"来逐渐传播最短路径信息
- 同一个节点可能被多次访问
-
边列表 vs 邻接矩阵
- Bellman-Ford使用边列表存储所有边
- 不能混用邻接矩阵和边列表
-
防止从未计算的节点出发
- 松弛时需要判断
minDist[from] != INT_MAX - 避免从未访问的节点出发进行松弛
- 松弛时需要判断
-
SPFA的isInQueue数组
- 从队列取出时要取消标记:
isInQueue[node] = false - 只是保证已经在队列中的元素不用重复加入
- 同一个节点可能多次入队和出队
- 从队列取出时要取消标记:
-
限制边数时需要使用minDist_copy
- 必须使用上一轮的结果进行计算
- 保证每轮的更新是严格+1条边
- 避免在同一轮中使用本轮已更新的值
题型:多源最短路 - Floyd算法
1. 核心思路
Floyd算法用于求解所有节点对之间的最短路径(多源最短路),而Dijkstra和Bellman-Ford算法都是单源最短路(只能有一个起点)。
1.1 基本概念
- 多源最短路:一次性求出所有节点对之间的最短路径
- 权值限制 :对边权值没有要求,可以处理正权、负权、零权
- 核心算法 :基于动态规划思想
- 应用场景 :
- 任意两点间最短距离
- 网络中心节点选择
- 图的传递闭包
示例:
图: Floyd结果(所有节点对的最短距离):
A--3--B A→A: 0, A→B: 3, A→C: 1, A→D: 5
| | B→A: 3, B→B: 0, B→C: 4, B→D: 2
1 2 C→A: 1, C→B: 4, C→C: 0, C→D: 4
| | D→A: 5, D→B: 2, D→C: 4, D→D: 0
C--4--D
1.2 核心思想
动态规划思想:逐步允许使用更多节点作为中间节点,更新所有节点对之间的最短距离。
状态定义:
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] = min(grid[i][k][k-1] + grid[k][j][k-1], grid[i][j][k-1])
空间优化:
- 由于只依赖k-1层,可以使用二维数组:
grid[i][j] - 状态转移:
grid[i][j] = min(grid[i][j], grid[i][k] + grid[k][j])
2. 动态规划五部曲
2.1 确定dp数组及下标含义
三维版本(便于理解):
grid[i][j][k]:节点i到节点j,允许使用节点1到k作为中间节点的最短距离
二维版本(空间优化):
grid[i][j]:节点i到节点j的最短距离(逐步更新)
2.2 确定递推公式
核心递推式:
cpp
grid[i][j][k] = min(
grid[i][j][k-1], // 不经过节点k
grid[i][k][k-1] + grid[k][j][k-1] // 经过节点k
)
空间优化版本:
cpp
grid[i][j] = min(grid[i][j], grid[i][k] + grid[k][j])
2.3 dp数组初始化
初始化规则:
grid[i][i][0] = 0:节点到自身的距离为0grid[i][j][0] = edge[i][j]:直接相连的边,权值为边的权值grid[i][j][0] = INF:不直接相连的节点,初始化为最大值
形象理解:
- 可以想象成初始化一个长方体的底层(k=0层)
- 其他元素初始化为最大值
2.4 确定遍历顺序
关键 :k必须是最外层循环,i和j的顺序可以任意。
原因:
- k表示允许使用的中间节点集合,需要从小到大逐步扩展
- i和j形成的平面初始值都是初始化好的
- 如果k不是最外层,会导致使用未计算的值
遍历顺序:
cpp
for(int k = 1; k <= n; k++) { // k必须最外层
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++) {
// 状态转移
}
}
}
2.5 举例推导数组
(可以添加一个具体例子来说明)
3. 算法模板
3.1 三维版本(便于理解)
cpp
#include<iostream>
#include<vector>
#include<climits>
using namespace std;
int main(){
int n, m, p1, p2, val;
cin >> n >> m;
// 三维版本:grid[i][j][k] 表示节点i到j,允许使用节点1到k作为中间节点
vector<vector<vector<int>>> grid(n + 1,
vector<vector<int>>(n + 1,
vector<int>(n + 1, INT_MAX)));
// 初始化:k=0层(不使用任何中间节点)
for(int i = 1; i <= n; i++){
grid[i][i][0] = 0; // 节点到自身距离为0
}
// 读入边
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2][0] = val;
grid[p2][p1][0] = val; // 无向图
}
// Floyd算法:k必须是最外层
for(int k = 1; k <= n; k++){
for(int i = 1; i <= n; i++){
for(int j = 1; j <= n; j++){
// 状态转移:取经过k和不经过k的最小值
grid[i][j][k] = min(
grid[i][j][k-1], // 不经过节点k
grid[i][k][k-1] + grid[k][j][k-1] // 经过节点k
);
}
}
}
// 输出:查询所有节点对的最短距离
int z, start, end;
cin >> z;
while(z--){
cin >> start >> end;
if(grid[start][end][n] == INT_MAX) {
cout << -1 << endl; // 不可达
} else {
cout << grid[start][end][n] << endl;
}
}
return 0;
}
3.2 空间优化版本(推荐)
由于只依赖k-1层,可以使用二维数组进行空间优化:
cpp
#include<iostream>
#include<vector>
#include<climits>
using namespace std;
int main(){
int n, m, p1, p2, val;
cin >> n >> m;
// 二维版本:grid[i][j] 表示节点i到j的最短距离
vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
// 初始化
for(int i = 1; i <= n; i++){
grid[i][i] = 0; // 节点到自身距离为0
}
// 读入边
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2] = val;
grid[p2][p1] = val; // 无向图
}
// Floyd算法:k必须是最外层
for(int k = 1; k <= n; k++){
for(int i = 1; i <= n; i++){
for(int j = 1; j <= n; j++){
// 防止溢出:需要判断是否可达
if(grid[i][k] != INT_MAX && grid[k][j] != INT_MAX){
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] == INT_MAX) {
cout << -1 << endl;
} else {
cout << grid[start][end] << endl;
}
}
return 0;
}
3.3 时间复杂度分析
- 时间复杂度 :O(V³)
- 三层循环,每层都是V次
- 空间复杂度 :
- 三维版本:O(V³)
- 二维版本:O(V²)(推荐)
- 适用场景:适合稠密图,节点数较少的情况
4. 常见变形
4.1 记录最短路径
使用parent数组记录路径:
cpp
vector<vector<int>> parent(n + 1, vector<int>(n + 1, -1));
// 初始化
for(int i = 1; i <= n; i++){
for(int j = 1; j <= n; j++){
if(grid[i][j] != INT_MAX && i != j){
parent[i][j] = i; // j的前驱是i
}
}
}
// Floyd算法中更新
if(grid[i][j] > grid[i][k] + grid[k][j]){
grid[i][j] = grid[i][k] + grid[k][j];
parent[i][j] = parent[k][j]; // 更新路径
}
// 路径还原
void printPath(int i, int j){
if(parent[i][j] == -1) return;
printPath(i, parent[i][j]);
cout << j << " ";
}
4.2 检测负权回路
如果存在负权回路,grid[i][i] < 0(节点到自身的距离为负):
cpp
bool hasNegativeCycle(vector<vector<int>>& grid, int n){
for(int i = 1; i <= n; i++){
if(grid[i][i] < 0){
return true; // 存在负权回路
}
}
return false;
}
4.3 传递闭包
判断节点i是否能到达节点j(无权图):
cpp
vector<vector<bool>> reachable(n + 1, vector<bool>(n + 1, false));
// 初始化
for(int i = 1; i <= n; i++){
reachable[i][i] = true; // 节点到自身可达
}
// 读入边
for(int i = 0; i < m; i++){
cin >> p1 >> p2;
reachable[p1][p2] = true;
}
// Floyd传递闭包
for(int k = 1; k <= n; k++){
for(int i = 1; i <= n; i++){
for(int j = 1; j <= n; j++){
reachable[i][j] = reachable[i][j] ||
(reachable[i][k] && reachable[k][j]);
}
}
}
5. 典型应用场景
-
任意两点间最短距离
- 地图导航:查询任意两个地点间的最短路径
- 网络路由:计算所有节点对之间的最短路径
-
图的传递闭包
- 判断节点间的可达性
- 关系传递问题
-
最小环问题
- 找到图中权值最小的环
- 检测负权回路
-
网络中心节点选择
- 找到到所有其他节点距离和最小的节点
- 网络优化问题
6. 算法对比总结
| 特性 | Dijkstra | Bellman-Ford | Floyd |
|---|---|---|---|
| 类型 | 单源最短路 | 单源最短路 | 多源最短路 |
| 权值限制 | 非负权 | 可负权 | 可负权 |
| 时间复杂度 | O((V+E)logV) | O(V×E) | O(V³) |
| 空间复杂度 | O(V+E) | O(V) | O(V²) |
| 适用场景 | 单源,非负权 | 单源,负权 | 多源,任意权值 |
| 推荐 | 单源非负权图 | 单源负权图 | 多源或节点数少 |
选择建议:
- 单源最短路,非负权 → 使用 Dijkstra算法
- 单源最短路,负权 → 使用 Bellman-Ford或SPFA算法
- 多源最短路 → 使用 Floyd算法
- 节点数较少(V < 200) → Floyd算法简单高效
7. 注意事项
-
遍历顺序是Floyd的精髓
- k必须是最外层循环
- i和j的顺序可以任意
- 如果k不是最外层,会导致使用未计算的值
-
防止整数溢出
- 在更新时判断
grid[i][k] != INT_MAX && grid[k][j] != INT_MAX - 避免两个INT_MAX相加导致溢出
- 在更新时判断
-
初始化问题
- 节点到自身距离必须初始化为0:
grid[i][i] = 0 - 不直接相连的节点初始化为最大值:
grid[i][j] = INT_MAX
- 节点到自身距离必须初始化为0:
-
有向图 vs 无向图
- 有向图:只添加一条边
grid[p1][p2] = val - 无向图:需要添加两条边
grid[p1][p2] = val; grid[p2][p1] = val;
- 有向图:只添加一条边
-
Floyd的特点
- 从节点的角度计算,时间复杂度较高
- 适合稠密图或节点数较少的情况
- 可以一次性求出所有节点对的最短路径
-
空间优化
- 推荐使用二维数组版本(空间优化)
- 三维版本便于理解,但空间开销大
题型:A*算法(A-Star)
1. 核心思路
A*算法是一种启发式搜索算法,是广度优先搜索(BFS)的改良版本。
1.1 基本概念
- 启发式搜索:使用启发式函数来指导搜索方向,提高搜索效率
- 与BFS的区别 :
- BFS:没有目的性,一圈圈去搜索所有可能的路径
- A*:有方向性的搜索,优先探索更可能到达目标的路径(关键在于启发式函数影响队列中元素的排序)
核心思想 :
对队列中节点的排序权值进行定义,利用权值进行排序,优先处理更可能到达目标的节点。
1.2 评估函数
F = G + H
- G(实际代价):从起点到当前节点的实际距离(已走过的路径)
- H(启发式函数):从当前节点到终点的预估距离(启发式估计)
- F(总评估值):节点的优先级,F值越小优先级越高
距离定义方式:
- 曼哈顿距离 :
|x1-x2| + |y1-y2|(只能上下左右移动) - 欧氏距离 :
√((x1-x2)² + (y1-y2)²)(可以斜向移动) - 切比雪夫距离 :
max(|x1-x2|, |y1-y2|)(可以八个方向移动)
1.3 算法特点
优势:
- 比BFS更高效,能够快速找到目标
- 在启发式函数设计合理的情况下,能找到最优解
局限性:
- A*搜索的路径和启发式函数有关
- 如果启发式函数不满足可采纳性(admissible),A*不能保证一定是最短路
- 在保证运行效率的情况下,可能找到次短路而非最短路
可采纳性(Admissible):
- 如果启发式函数H(n)永远不会高估从节点n到目标的实际距离,则A*保证找到最优解
- 即:
H(n) ≤ 实际最短距离
2. 算法模板
2.1 基础版本(网格地图)
cpp
#include<iostream>
#include<queue>
#include<cstring>
#include<climits>
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}; // 马走日,8个方向
int b1, b2; // 目标点坐标
// F = G + H
// G:从起点到当前节点的实际路径消耗
// H:从当前节点到终点的预估消耗(启发式函数)
struct Knight{
int x, y; // 当前位置
int g, h, f; // G值、H值、F值
bool operator < (const Knight& k) const{
// 重载运算符,F值小的优先级高(小顶堆)
return f > k.f; // 注意:priority_queue默认是大顶堆,所以用>实现小顶堆
}
};
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& start){
Knight cur, next;
que.push(start);
while(!que.empty()){
cur = que.top();
que.pop();
// 到达目标点
if(cur.x == b1 && cur.y == b2) break;
// 遍历8个方向(马走日)
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; // a1,a2是起始点,b1,b2是目标点,n是测试数量
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;
}
2.2 通用版本(图结构)
cpp
#include<iostream>
#include<vector>
#include<queue>
#include<climits>
using namespace std;
struct Node{
int id;
int g, h, f; // G值、H值、F值
bool operator < (const Node& n) const{
return f > n.f; // 小顶堆
}
};
// 启发式函数:估算从当前节点到目标节点的距离
int heuristic(int current, int target, vector<vector<int>>& graph){
// 这里可以使用曼哈顿距离、欧氏距离等
// 示例:使用简单的直线距离估算
return abs(current - target);
}
void astar(int start, int target, vector<vector<pair<int, int>>>& graph){
priority_queue<Node> pq;
vector<int> dist(graph.size(), INT_MAX);
vector<bool> visited(graph.size(), false);
Node startNode;
startNode.id = start;
startNode.g = 0;
startNode.h = heuristic(start, target, graph);
startNode.f = startNode.g + startNode.h;
pq.push(startNode);
dist[start] = 0;
while(!pq.empty()){
Node cur = pq.top();
pq.pop();
if(visited[cur.id]) continue;
visited[cur.id] = true;
// 到达目标
if(cur.id == target) break;
// 遍历邻居节点
for(auto& edge : graph[cur.id]){
int next = edge.first;
int weight = edge.second;
if(!visited[next]){
int newG = cur.g + weight;
if(newG < dist[next]){
dist[next] = newG;
Node nextNode;
nextNode.id = next;
nextNode.g = newG;
nextNode.h = heuristic(next, target, graph);
nextNode.f = nextNode.g + nextNode.h;
pq.push(nextNode);
}
}
}
}
cout << dist[target] << endl;
}
2.3 时间复杂度分析
- 时间复杂度 :O(b^d),其中b是分支因子,d是解的深度
- 最优情况下:O(b^d)(如果启发式函数完美)
- 最坏情况下:退化为BFS,O(b^d)
- 空间复杂度:O(b^d)(需要存储所有探索的节点)
- 实际性能:通常比BFS快得多,因为启发式函数指导搜索方向
3. 常见变形
3.1 不同的启发式函数
3.1.1 曼哈顿距离(Manhattan Distance)
适用于只能上下左右移动的网格:
cpp
int manhattan(int x1, int y1, int x2, int y2){
return abs(x1 - x2) + abs(y1 - y2);
}
3.1.2 欧氏距离(Euclidean Distance)
适用于可以斜向移动的网格:
cpp
int euclidean(int x1, int y1, int x2, int y2){
return (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2); // 不开根号
// 或 return sqrt((x1-x2)*(x1-x2) + (y1-y2)*(y1-y2));
}
3.1.3 切比雪夫距离(Chebyshev Distance)
适用于可以八个方向移动的网格:
cpp
int chebyshev(int x1, int y1, int x2, int y2){
return max(abs(x1 - x2), abs(y1 - y2));
}
3.2 路径还原
记录父节点,还原完整路径:
cpp
struct Node{
int x, y;
int g, h, f;
int parentX, parentY; // 父节点坐标
};
vector<pair<int, int>> reconstructPath(Node target){
vector<pair<int, int>> path;
Node cur = target;
while(cur.parentX != -1){
path.push_back({cur.x, cur.y});
// 根据parentX和parentY找到父节点
// ...
}
reverse(path.begin(), path.end());
return path;
}
3.3 加权A*(Weighted A*)
在启发式函数前加权重,平衡搜索速度和最优性:
cpp
// F = G + w * H,其中w是权重
next.f = next.g + w * next.h;
// w > 1:更偏向速度,可能不是最优解
// w = 1:标准A*
// w < 1:更偏向最优性,搜索更慢
4. 典型应用场景
-
路径规划问题
- 游戏中的NPC寻路
- 机器人路径规划
- 地图导航系统
-
拼图问题
- 8数码问题
- 15数码问题
- 滑块拼图
-
网格地图搜索
- 迷宫求解
- 最短路径查找
- 障碍物避让
-
游戏AI
- 策略游戏中的单位移动
- 实时策略游戏的路径查找
- 塔防游戏的敌人路径
5. 算法对比总结
| 特性 | BFS | Dijkstra | A* |
|---|---|---|---|
| 搜索策略 | 无方向性,逐层扩展 | 贪心,选择最短距离 | 启发式,优先探索有希望的方向 |
| 数据结构 | 队列 | 优先队列 | 优先队列(按F值) |
| 时间复杂度 | O(V+E) | O((V+E)logV) | O(b^d) |
| 最优性 | 保证最优(无权图) | 保证最优(非负权) | 取决于启发式函数 |
| 适用场景 | 无权图,最短路径 | 加权图,单源最短路 | 有目标点的路径搜索 |
| 优势 | 简单,保证最优 | 处理加权图 | 高效,有方向性 |
| 劣势 | 效率低,无方向性 | 无方向性 | 需要好的启发式函数 |
选择建议:
- 无权图,需要最短路径 → 使用 BFS
- 加权图,单源最短路 → 使用 Dijkstra算法
- 有明确目标点,需要高效搜索 → 使用 A*算法
- 需要保证最优解 → 确保启发式函数满足可采纳性
6. 注意事项
-
启发式函数的选择
- 启发式函数必须可采纳(admissible)才能保证最优解
- 即:
H(n) ≤ 实际最短距离 - 如果启发式函数高估了距离,可能找不到最优解
-
优先级队列的实现
- 使用小顶堆,F值小的优先级高
- C++中
priority_queue默认是大顶堆,需要重载<运算符或使用greater
-
已访问节点的处理
- 可以使用
visited数组避免重复访问 - 或者允许节点多次入队(如果找到更优路径)
- 可以使用
-
G值和H值的计算
- G值:从起点到当前节点的实际距离,需要累加
- H值:从当前节点到目标的预估距离,使用启发式函数计算
- F值:G + H,用于优先级排序
-
A*不能保证最优解的情况
- 如果启发式函数不满足可采纳性
- 在保证运行效率的情况下,可能找到次优解
- 需要根据实际需求权衡最优性和效率
-
适用场景
- 适合有明确起点和终点的路径搜索问题
- 不适合需要遍历所有节点的问题
- 在网格地图、游戏寻路等场景中表现优异