图论理论基础(5) - 教程

文章目录

题型:最短路径算法 - 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 核心思想

采用贪心策略,每次选择距离源点最近且未访问过的节点,更新其邻居节点的最短距离。

算法流程(三部曲):

  1. 选节点:从所有未访问节点中,选择距离源点最近的节点
  2. 标记访问:将该节点标记为已访问
  3. 更新距离:更新该节点的所有邻居节点到源点的最短距离(通过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的距离

核心区别

  • Primgrid[cur][j] 表示 cur 加入生成树后,生成树到节点j的距离
  • DijkstraminDist[cur] + grid[cur][v] 表示源点到cur的距离 + cur到v的距离 = 源点到v的距离

2. 基础版本(邻接矩阵)

2.1 算法模板

#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 算法模板

#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 求源点到所有节点的最短路径

// 在算法结束后,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 记录最短路径(路径还原)

// 添加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 多条最短路径

// 使用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 限制边数的最短路径

// 使用动态规划: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. 典型应用场景

  1. 网络路由问题

    • LeetCode 743. 网络延迟时间
    • 找到数据包传输的最短路径
  2. 地图导航问题

    • 找到两点间的最短距离
    • 计算最优路线
  3. 资源分配问题

    • 找到最优分配路径
    • 最小成本路径
  4. 社交网络问题

    • 找到两个人之间的最短关系链
    • 六度分隔理论

6. 算法对比总结

特性基础版本堆优化版本
数据结构邻接矩阵邻接表 + 优先队列
时间复杂度O(V²)O((V+E)logV)
空间复杂度O(V²)O(V+E)
适用图类型稠密图稀疏图
实现难度简单中等
推荐稠密图使用稀疏图使用

选择建议

  • 稠密图(E ≈ V²)→ 使用 基础版本(邻接矩阵)
  • 稀疏图(E << V²)→ 使用 堆优化版本(邻接表)
  • 一般情况堆优化版本更通用,推荐使用

7. 注意事项

  1. 权值必须非负

    • 如果图中有负权边,Dijkstra算法无法得到正确结果
    • 需要使用Bellman-Ford或SPFA算法
  2. 有向图 vs 无向图

    • 有向图:只添加一条边 grid[p1][p2] = val
    • 无向图:需要添加两条边 grid[p1][p2] = val; grid[p2][p1] = val;
  3. 不可达节点

    • 如果 minDist[i] == INT_MAX,说明源点无法到达节点i
    • 需要在输出时进行判断
  4. 堆优化版本的重复入队

    • 同一个节点可能多次入队(因为距离可能被多次更新)
    • 使用 visited 数组避免重复处理
  5. 初始化问题

    • 起点距离必须初始化为0:minDist[start] = 0
    • 其他节点初始化为最大值:minDist[i] = INT_MAX

题型:最短路径算法 - 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. 基础版本(边列表)

算法模板

#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算法对每条边都做了松弛操作,但实际上没有必要。只需要对上一次松弛时更新过的节点(用队列记录)作为出发节点所链接的边进行松弛操作即可。

优化原理

  • 使用队列记录需要松弛的节点
  • 只有距离被更新的节点才可能影响其他节点
  • 避免了大量无效的松弛操作

算法模板

#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次松弛,如果结果有变化,就代表存在负权回路:

// 在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次,那么该图一定有负权回路:

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 条边。

算法模板

#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. 典型应用场景

  1. 包含负权边的最短路径问题

    • 金融系统中的套利检测
    • 网络延迟可能为负的情况
  2. 负权回路检测

    • 检测图中是否存在负权回路
    • 套利机会检测
  3. 限制边数的最短路径

    • 航班中转次数限制
    • 网络跳数限制
  4. 动态图最短路径

    • 边权值可能变化的情况
    • 实时路径规划

7. 算法对比总结

特性DijkstraBellman-FordSPFA
权值限制非负权可负权可负权
负权回路检测不支持支持支持
时间复杂度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. 注意事项

  1. Bellman-Ford不需要visited数组

    • 因为依赖"重复松弛"来逐渐传播最短路径信息
    • 同一个节点可能被多次访问
  2. 边列表 vs 邻接矩阵

    • Bellman-Ford使用边列表存储所有边
    • 不能混用邻接矩阵和边列表
  3. 防止从未计算的节点出发

    • 松弛时需要判断 minDist[from] != INT_MAX
    • 避免从未访问的节点出发进行松弛
  4. SPFA的isInQueue数组

    • 从队列取出时要取消标记:isInQueue[node] = false
    • 只是保证已经在队列中的元素不用重复加入
    • 同一个节点可能多次入队和出队
  5. 限制边数时需要使用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作为中间节点的最短距离

状态转移

  • 如果最短路径经过节点kgrid[i][j][k] = grid[i][k][k-1] + grid[k][j][k-1]
  • 如果最短路径不经过节点kgrid[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 确定递推公式

核心递推式

grid[i][j][k] = min(
grid[i][j][k-1],                    // 不经过节点k
grid[i][k][k-1] + grid[k][j][k-1]   // 经过节点k
)

空间优化版本

grid[i][j] = min(grid[i][j], grid[i][k] + grid[k][j])

2.3 dp数组初始化

初始化规则

  • grid[i][i][0] = 0:节点到自身的距离为0
  • grid[i][j][0] = edge[i][j]:直接相连的边,权值为边的权值
  • grid[i][j][0] = INF:不直接相连的节点,初始化为最大值

形象理解

  • 可以想象成初始化一个长方体的底层(k=0层)
  • 其他元素初始化为最大值

2.4 确定遍历顺序

关键k必须是最外层循环,i和j的顺序可以任意。

原因

  • k表示允许使用的中间节点集合,需要从小到大逐步扩展
  • i和j形成的平面初始值都是初始化好的
  • 如果k不是最外层,会导致使用未计算的值

遍历顺序

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 三维版本(便于理解)

#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层,可以使用二维数组进行空间优化:

#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数组记录路径:

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(节点到自身的距离为负):

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(无权图):

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. 典型应用场景

  1. 任意两点间最短距离

    • 地图导航:查询任意两个地点间的最短路径
    • 网络路由:计算所有节点对之间的最短路径
  2. 图的传递闭包

    • 判断节点间的可达性
    • 关系传递问题
  3. 最小环问题

    • 找到图中权值最小的环
    • 检测负权回路
  4. 网络中心节点选择

    • 找到到所有其他节点距离和最小的节点
    • 网络优化问题

6. 算法对比总结

特性DijkstraBellman-FordFloyd
类型单源最短路单源最短路多源最短路
权值限制非负权可负权可负权
时间复杂度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. 注意事项

  1. 遍历顺序是Floyd的精髓

    • k必须是最外层循环
    • i和j的顺序可以任意
    • 如果k不是最外层,会导致使用未计算的值
  2. 防止整数溢出

    • 在更新时判断 grid[i][k] != INT_MAX && grid[k][j] != INT_MAX
    • 避免两个INT_MAX相加导致溢出
  3. 初始化问题

    • 节点到自身距离必须初始化为0:grid[i][i] = 0
    • 不直接相连的节点初始化为最大值:grid[i][j] = INT_MAX
  4. 有向图 vs 无向图

    • 有向图:只添加一条边 grid[p1][p2] = val
    • 无向图:需要添加两条边 grid[p1][p2] = val; grid[p2][p1] = val;
  5. Floyd的特点

    • 从节点的角度计算,时间复杂度较高
    • 适合稠密图或节点数较少的情况
    • 可以一次性求出所有节点对的最短路径
  6. 空间优化

    • 推荐使用二维数组版本(空间优化)
    • 三维版本便于理解,但空间开销大

题型: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 基础版本(网格地图)

#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 通用版本(图结构)

#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)

适用于只能上下左右移动的网格:

int manhattan(int x1, int y1, int x2, int y2){
return abs(x1 - x2) + abs(y1 - y2);
}
3.1.2 欧氏距离(Euclidean Distance)

适用于可以斜向移动的网格:

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)

适用于可以八个方向移动的网格:

int chebyshev(int x1, int y1, int x2, int y2){
return max(abs(x1 - x2), abs(y1 - y2));
}

3.2 路径还原

记录父节点,还原完整路径:

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*)

在启发式函数前加权重,平衡搜索速度和最优性:

// F = G + w * H,其中w是权重
next.f = next.g + w * next.h;
// w > 1:更偏向速度,可能不是最优解
// w = 1:标准A*
// w < 1:更偏向最优性,搜索更慢

4. 典型应用场景

  1. 路径规划问题

    • 游戏中的NPC寻路
    • 机器人路径规划
    • 地图导航系统
  2. 拼图问题

    • 8数码问题
    • 15数码问题
    • 滑块拼图
  3. 网格地图搜索

    • 迷宫求解
    • 最短路径查找
    • 障碍物避让
  4. 游戏AI

    • 策略游戏中的单位移动
    • 实时策略游戏的路径查找
    • 塔防游戏的敌人路径

5. 算法对比总结

特性BFSDijkstraA*
搜索策略无方向性,逐层扩展贪心,选择最短距离启发式,优先探索有希望的方向
数据结构队列优先队列优先队列(按F值)
时间复杂度O(V+E)O((V+E)logV)O(b^d)
最优性保证最优(无权图)保证最优(非负权)取决于启发式函数
适用场景无权图,最短路径加权图,单源最短路有目标点的路径搜索
优势简单,保证最优处理加权图高效,有方向性
劣势效率低,无方向性无方向性需要好的启发式函数

选择建议

  • 无权图,需要最短路径 → 使用 BFS
  • 加权图,单源最短路 → 使用 Dijkstra算法
  • 有明确目标点,需要高效搜索 → 使用 A*算法
  • 需要保证最优解 → 确保启发式函数满足可采纳性

6. 注意事项

  1. 启发式函数的选择

    • 启发式函数必须可采纳(admissible)才能保证最优解
    • 即:H(n) ≤ 实际最短距离
    • 如果启发式函数高估了距离,可能找不到最优解
  2. 优先级队列的实现

    • 使用小顶堆,F值小的优先级高
    • C++中priority_queue默认是大顶堆,需要重载<运算符或使用greater
  3. 已访问节点的处理

    • 可以使用visited数组避免重复访问
    • 或者允许节点多次入队(如果找到更优路径)
  4. G值和H值的计算

    • G值:从起点到当前节点的实际距离,需要累加
    • H值:从当前节点到目标的预估距离,使用启发式函数计算
    • F值:G + H,用于优先级排序
  5. A*不能保证最优解的情况

    • 如果启发式函数不满足可采纳性
    • 在保证运行效率的情况下,可能找到次优解
    • 需要根据实际需求权衡最优性和效率
  6. 适用场景

    • 适合有明确起点和终点的路径搜索问题
    • 不适合需要遍历所有节点的问题
    • 在网格地图、游戏寻路等场景中表现优异
posted @ 2026-01-04 14:20  clnchanpin  阅读(4)  评论(0)    收藏  举报