Studying-图论包含的算法总结

目录

1.DFS(深度优先搜索)

代码框架:

[2. BFS(广度优先搜索)](#2. BFS(广度优先搜索))

代码框架:

[3. 并查集](#3. 并查集)

4.最小生成树之Prim

5.最小生成树之Kruskal

6.拓扑排序

[7. 最短路径之-dijkstra(朴素版)](#7. 最短路径之-dijkstra(朴素版))

8.最短路径之dijkstra(堆优化版)

9.Bellman_ford算法精讲

[10.Bellman_ford 队列优化算法(又名SPFA)](#10.Bellman_ford 队列优化算法(又名SPFA))

bellman_ford之判断负权回路

bellman_ford之单源有限最短路

[11.Floyd 算法精讲](#11.Floyd 算法精讲)

[12.A * 算法精讲 (A star算法)](#12.A * 算法精讲 (A star算法))

资料来源:代码随想录-图论

1.DFS(深度优先搜索)

dfs的本质就是朝着一个方向去搜索,不到黄河不回头,直到遇到绝境了,搜不下去了,再换方向(换方向的过程就涉及到了回溯)

dfs搜索的关键就两点:

  • 搜索方向,是认准一个方向搜,直到碰壁之后再换方向
  • 换方向是撤销原路径,改为节点链接的下一个路径,是一个回溯的过程。

代码框架:

由于dfs搜索是可一个方向,并需要回溯,所以用递归的方式来实现是最方便的。因此我们可以借助回溯的模板来进行:

cpp 复制代码
void dfs(参数) {
    处理节点
    dfs(图,选择的节点); // 递归
    回溯,撤销处理结果
}

事实上,这与二叉树的递归法类似,二叉树的递归其实就是dfs,二叉树的层序遍历就是bfs。dfs,bfs其实是基础搜索算法,也广泛应用与其他数据结构与算法中

cpp 复制代码
void dfs(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本节点所连接的其他节点) {
        处理节点;
        dfs(图,选择的节点); // 递归
        回溯,撤销处理结果
    }
}

2. BFS(广度优先搜索)

广搜适合解决两个点之间的最短路径问题(没有权重)。广搜就是从起点出发,以起点为中心一圈一圈进行搜索,一旦遇到终点,记录之前走过的节点就是一条最短路。

代码框架:

广搜一般需要使用一个容器来保存我们要遍历的元素,例如使用队列,对于四方格进行遍历的过程就是:

cpp 复制代码
int dir[4][2] = {0, 1, 1, 0, -1, 0, 0, -1}; // 表示四个方向
// grid 是地图,也就是一个二维数组
// visited标记访问过的节点,不要重复访问
// x,y 表示开始搜索节点的下标
void bfs(vector<vector<char>>& grid, vector<vector<bool>>& visited, int x, int y) {
    queue<pair<int, int>> que; // 定义队列
    que.push({x, y}); // 起始节点加入队列
    visited[x][y] = true; // 只要加入队列,立刻标记为访问过的节点
    while(!que.empty()) { // 开始遍历队列里的元素
        pair<int ,int> cur = que.front(); que.pop(); // 从队列取元素
        int curx = cur.first;
        int cury = cur.second; // 当前节点坐标
        for (int i = 0; i < 4; i++) { // 开始想当前节点的四个方向左右上下去遍历
            int nextx = curx + dir[i][0];
            int nexty = cury + dir[i][1]; // 获取周边四个方向的坐标
            if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size()) continue;  // 坐标越界了,直接跳过
            if (!visited[nextx][nexty]) { // 如果节点没被访问过
                que.push({nextx, nexty});  // 队列添加该节点为下一轮要遍历的节点
                visited[nextx][nexty] = true; // 只要加入队列立刻标记,避免重复访问
            }
        }
    }
}

3. 并查集

并查集常用来解决连通性问题,也就是需要判断两个元素是否在同一个集合里的适合,我们一般会使用到并查集。

并查集的主要有两个功能:

  • 将两个元素添加到一个集合中
  • 判断两个元素在不在同一个集合

我们将三个元素 A,B,C (分别是数字)放在同一个集合,其实就是将三个元素连通在一起,如何连通呢。只需要用一个一维数组来表示,即:father[A] = B,father[B] = C 这样就表述 A 与 B 与 C连通了(有向连通图)。

并查集一般需要四个步骤:1.并查集初始化;2.寻根,找到元素对应的根;3.判断两个元素是否是一个根(是否是同一个集合)3.将两个元素加入到一个集合当中。

cpp 复制代码
int n = 1005; // n根据题目中节点数量而定,一般比节点数量大一点就好
vector<int> father = vector<int> (n, 0); // C++里的一种数组结构

// 并查集初始化
void init() {
    for (int i = 0; i < n; ++i) {
        father[i] = i;
    }
}
// 并查集里寻根的过程
int find(int u) {
    return u == father[u] ? u : father[u] = find(father[u]); // 路径压缩
}

// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {
    u = find(u);
    v = find(v);
    return u == v;
}

// 将v->u 这条边加入并查集
void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u;
}

4.最小生成树之Prim

最小生成树是所有节点的最小连通子图,即:以最小的成本(边的权值)将图中所有节点链接到一起。图中有n个节点,那么一定可以用n-1条边将所有节点连接到一起。

例如下图,如何选取n-1条边,使得图中所有节点连接到一起,并且边的权值和最小呢。

prim算法 是从节点的角度采用贪心的策略每次寻找距离最小生成树最近的节点 并加入到最小生成树中。

prim算法核心就是三步,我称为prim三部曲,大家一定要熟悉这三步,代码相对会好些很多:

  1. 第一步,选距离生成树最近节点
  2. 第二步,最近节点加入生成树
  3. 第三步,更新非生成树节点到生成树的距离(即更新minDist数组)

minDist数组用来记录每一个节点距离最小生成树的最近距离。理解这一点非常重要,这也是 prim算法最核心要点所在。

代码参考:

cpp 复制代码
#include<iostream>
#include<vector>
#include <climits>
using namespace std;
int main() {
    int v, e;
    int x, y, k;
    cin >> v >> e;
    // 填一个默认最大值,题目描述val最大为10000
    vector<vector<int>> grid(v + 1, vector<int>(v + 1, 10001));
    while (e--) {
        cin >> x >> y >> k;
        // 因为是双向图,所以两个方向都要填上
        grid[x][y] = k;
        grid[y][x] = k;

    }
    // 所有节点到最小生成树的最小距离
    vector<int> minDist(v + 1, 10001);

    // 这个节点是否在树里
    vector<bool> isInTree(v + 1, false);

    // 我们只需要循环 n-1次,建立 n - 1条边,就可以把n个节点的图连在一起
    for (int i = 1; i < v; i++) {

        // 1、prim三部曲,第一步:选距离生成树最近节点
        int cur = -1; // 选中哪个节点 加入最小生成树
        int minVal = INT_MAX;
        for (int j = 1; j <= v; j++) { // 1 - v,顶点编号,这里下标从1开始
            //  选取最小生成树节点的条件:
            //  (1)不在最小生成树里
            //  (2)距离最小生成树最近的节点
            if (!isInTree[j] &&  minDist[j] < minVal) {
                minVal = minDist[j];
                cur = j;
            }
        }
        // 2、prim三部曲,第二步:最近节点(cur)加入生成树
        isInTree[cur] = true;

        // 3、prim三部曲,第三步:更新非生成树节点到生成树的距离(即更新minDist数组)
        // cur节点加入之后, 最小生成树加入了新的节点,那么所有节点到 最小生成树的距离(即minDist数组)需要更新一下
        // 由于cur节点是新加入到最小生成树,那么只需要关心与 cur 相连的 非生成树节点 的距离 是否比 原来 非生成树节点到生成树节点的距离更小了呢
        for (int j = 1; j <= v; j++) {
            // 更新的条件:
            // (1)节点是 非生成树里的节点
            // (2)与cur相连的某节点的权值 比 该某节点距离最小生成树的距离小
            // 很多录友看到自己 就想不明白什么意思,其实就是 cur 是新加入 最小生成树的节点,那么 所有非生成树的节点距离生成树节点的最近距离 由于 cur的新加入,需要更新一下数据了
            if (!isInTree[j] && grid[cur][j] < minDist[j]) {
                minDist[j] = grid[cur][j];
            }
        }
    }
    // 统计结果
    int result = 0;
    for (int i = 2; i <= v; i++) { // 不计第一个顶点,因为统计的是边的权值,v个节点有 v-1条边
        result += minDist[i];
    }
    cout << result << endl;
}

5.最小生成树之Kruskal

与prim算法相同都是求解最小生成树的,但是不同的是prim 算法是维护节点的集合,而Kruskal 是维护边的集合。

kruscal的思路:

  • 边的权值排序,因为要优先选最小的边加入到生成树里
  • 遍历排序后的边
    • 如果边首尾的两个节点在同一个集合,说明如果连上这条边图中会出现环
    • 如果边首尾的两个节点不在同一个集合,加入到最小生成树,并把两个节点加入同一个集合

以下图为例:

将图中的边按照权值有小到大排序,这样从贪心的角度来说,优先选权值小的边加入到最小生成树中。排序后的边顺序为[(1,2) (4,5) (1,3) (2,6) (3,4) (6,7) (5,7) (1,5) (3,2) (2,4) (5,6)]。

根据边的顺序,我们会先选取边(1,2),并把节点1和节点2加入到一个集合当中,然后选取边(4,5)由于4,5同样不在一个集合中,因此我们仍然可以加入到一个集合中。

但在代码中,如果将两个节点加入同一个集合,又如何判断两个节点是否在同一个集合呢

这里就涉及到之前的并查集的内容,并查集主要就两个功能:

  • 将两个元素添加到一个集合中
  • 判断两个元素在不在同一个集合

代码参考:

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// l,r为 边两边的节点,val为边的数值
struct Edge {
    int l, r, val;
};

// 节点数量
int n = 10001;
// 并查集标记节点关系的数组
vector<int> father(n, -1); // 节点编号是从1开始的,n要大一些

// 并查集初始化
void init() {
    for (int i = 0; i < n; ++i) {
        father[i] = i;
    }
}

// 并查集的查找操作
int find(int u) {
    return u == father[u] ? u : father[u] = find(father[u]); // 路径压缩
}

// 并查集的加入集合
void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u;
}

int main() {

    int v, e;
    int v1, v2, val;
    vector<Edge> edges;
    int result_val = 0;
    cin >> v >> e;
    while (e--) {
        cin >> v1 >> v2 >> val;
        edges.push_back({v1, v2, val});
    }

    // 执行Kruskal算法
    // 按边的权值对边进行从小到大排序
    sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) {
            return a.val < b.val;
    });

    // 并查集初始化
    init();

    // 从头开始遍历边
    for (Edge edge : edges) {
        // 并查集,搜出两个节点的祖先
        int x = find(edge.l);
        int y = find(edge.r);

        // 如果祖先不同,则不在同一个集合
        if (x != y) {
            result_val += edge.val; // 这条边可以作为生成树的边
            join(x, y); // 两个节点加入到同一个集合
        }
    }
    cout << result_val << endl;
    return 0;
}

**小总结:**Kruskal算法更适合用于稀疏图,而Prim更适合用于稠密图。


6.拓扑排序

拓扑排序是用于解决事情存在依赖关系的情况下的合理进行顺序。用图论来说就是给出一个有向图,把这个有向图转成线性的排序就叫拓扑排序。

当然拓扑排序也要检测这个有向图是否有环,即存在循环依赖的情况,因为这种情况是不能做线性排序的。所以拓扑排序也是图论中判断有向无环图的常用方法。

拓扑排序的思路:

  1. 找到入度为0 的节点,加入结果集
  2. 将该节点从图中移除

循环以上步骤,直到所有节点都在图中被移除。

为了每次可以找到所有节点的入度信息,我们要在初始化的时候,就把每个节点的入度 和 每个节点的依赖关系做统计。

cpp 复制代码
cin >> n >> m;
vector<int> inDegree(n, 0); // 记录每个文件的入度
vector<int> result; // 记录结果
unordered_map<int, vector<int>> umap; // 记录文件依赖关系

while (m--) {
    // s->t,先有s才能有t
    cin >> s >> t;
    inDegree[t]++; // t的入度加一
    umap[s].push_back(t); // 记录s指向哪些文件
}

因为每次寻找入度为0的节点,不一定只有一个节点,可能很多节点入度都为0,所以要将这些入度为0的节点放到队列里,依次去处理。

cpp 复制代码
queue<int> que;
for (int i = 0; i < n; i++) {
    // 入度为0的节点,可以作为开头,先加入队列
    if (inDegree[i] == 0) que.push(i);
}

开始从队列里遍历入度为0 的节点,将其放入结果集。并把其从图中删除,把与其相关的边都给去掉。

cpp 复制代码
while (que.size()) {
    int  cur = que.front(); // 当前选中的节点
    que.pop();
    result.push_back(cur);
    // 将该节点从图中移除 
    vector<int> files = umap[cur]; //获取cur指向的节点
    if (files.size()) { // 如果cur有指向的节点
        for (int i = 0; i < files.size(); i++) { // 遍历cur指向的节点
            inDegree[files[i]] --; // cur指向的节点入度都做减一操作
            // 如果指向的节点减一之后,入度为0,说明是我们要选取的下一个节点,放入队列。
            if(inDegree[files[i]] == 0) que.push(files[i]); 
        }
    }

}

最终代码参考:

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
#include <unordered_map>
using namespace std;
int main() {
    int m, n, s, t;
    cin >> n >> m;
    vector<int> inDegree(n, 0); // 记录每个文件的入度

    unordered_map<int, vector<int>> umap;// 记录文件依赖关系
    vector<int> result; // 记录结果

    while (m--) {
        // s->t,先有s才能有t
        cin >> s >> t;
        inDegree[t]++; // t的入度加一
        umap[s].push_back(t); // 记录s指向哪些文件
    }
    queue<int> que;
    for (int i = 0; i < n; i++) {
        // 入度为0的文件,可以作为开头,先加入队列
        if (inDegree[i] == 0) que.push(i);
        //cout << inDegree[i] << endl;
    }
    // int count = 0;
    while (que.size()) {
        int  cur = que.front(); // 当前选中的文件
        que.pop();
        //count++;
        result.push_back(cur);
        vector<int> files = umap[cur]; //获取该文件指向的文件
        if (files.size()) { // cur有后续文件
            for (int i = 0; i < files.size(); i++) {
                inDegree[files[i]] --; // cur的指向的文件入度-1
                if(inDegree[files[i]] == 0) que.push(files[i]);
            }
        }
    }
    if (result.size() == n) {
        for (int i = 0; i < n - 1; i++) cout << result[i] << " ";
        cout << result[n - 1];
    } else cout << -1 << endl;
}

7. 最短路径之-dijkstra(朴素版)

最短路是图论中的经典问题即:给出一个有向图,一个起点,一个终点,问起点到终点的最短路径。

dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法。需要注意两点:

  • dijkstra算法可以同时求起点到所有节点的最短路径
  • 权值不能为负数

事实上dijkstra算法和我们之前讲解的prim算法思路非常接近,dijkstra算法同样是贪心的思路,不断寻找距离源点最近的没有访问过的节点。

这里我也给出 dijkstra三部曲

  1. 第一步,选源点到哪个节点近且该节点未被访问过
  2. 第二步,该最近节点被标记访问过
  3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)

在这里面 minDist数组用来记录每一个节点距离源点的最小距离 。(prim算法中minDist数组用来记录每一个节点距离最小生成树的最近距离)。

代码实现:

cpp 复制代码
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;

    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;
    }

    int start = 1;
    int end = n;

    // 存储从源点到每个节点的最短距离
    std::vector<int> minDist(n + 1, INT_MAX);

    // 记录顶点是否被访问过
    std::vector<bool> visited(n + 1, false);

    minDist[start] = 0;  // 起始点到自身的距离为0

    for (int i = 1; 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;
            }
        }

        visited[cur] = true;  // 2、标记该节点已被访问

        // 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; // 到达终点最短路径

}

如果要求路径的话,我们可以增加一个parent数组,把每个节点之前的父节点找出,这样就可以得到路径了。

cpp 复制代码
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;

    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;
    }

    int start = 1;
    int end = n;

    std::vector<int> minDist(n + 1, INT_MAX);

    std::vector<bool> visited(n + 1, false);

    minDist[start] = 0; 

    //加上初始化
    vector<int> parent(n + 1, -1);

    for (int i = 1; i <= n; i++) {

        int minVal = INT_MAX;
        int cur = 1;

        for (int v = 1; v <= n; ++v) {
            if (!visited[v] && minDist[v] < minVal) {
                minVal = minDist[v];
                cur = v;
            }
        }

        visited[cur] = true;

        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];
                parent[v] = cur; // 记录边
            }
        }

    }

    // 输出最短情况
    for (int i = 1; i <= n; i++) {
        cout << parent[i] << "->" << i << endl;
    }
}

打印结果:

cpp 复制代码
-1->1
1->2
2->3
3->4
4->5
2->6
5->7

注意:dijksra朴素版不能用于边的权值有负数的情况,直接原因在于是因为**访问过的节点不能再访问,导致错过真正的最短路。**以下图为例,我们在使用dijkstra算法的时候,第一个选取的点是1,第二个选取的点就会是3,而到3真正的最短路径是1->2->3。


8.最短路径之dijkstra(堆优化版)

朴素版本的时间复杂度 O(n^2),可以看出时间复杂度只和 n (节点数量)有关系。如果n很大的话,我们可以换一个角度来优化性能,例如我们在求解最小生成树的时候,有两个算法prim算法(从点的角度来求最小生成树)、Kruskal算法(从边的角度来求最小生成树)。因此我们也可以尝试从边的角度进行思考。

由于我们是站在点多边少,稀疏图的角度进行思考,因此我们可以采用邻接表的方式来存储图。在朴素版中,我们根据三部曲每一次都需要遍历节点,并找到未被访问的节点,这导致会出现内外双循环。但如果我们从边的角度出发,直接把边(带权值)加入到小顶堆中,利用堆来自动排序,那么每次我们从堆顶里取出的边自然就是距离源点最近的节点所在的边了。这样就不需要使用两层for循环来寻找最近的节点。

邻接表的存储方式一般是数组+链表的方式:

cpp 复制代码
vector<list<int>> grid(n + 1);

但在本题中,边存在权值,因此我们不能只使用int来来表示指向的点,需要一个键值对来保存两个数字,一个表示节点,一个表示指向该节点的这条边的权值。

cpp 复制代码
vector<list<pair<int,int>>> grid(n + 1);

但是在代码中,使用键值对,也很容易让我们搞混第一个int和第二个int,对于代码的可读性也比较差,因此可以使用一个类,来代替pair:

cpp 复制代码
struct Edge {
    int to;  // 邻接顶点
    int val; // 边的权重

    Edge(int t, int w): to(t), val(w) {}  // 构造函数
};

最后得到邻接表的保存方式:

cpp 复制代码
struct Edge {
    int to;  // 链接的节点
    int val; // 边的权重

    Edge(int t, int w): to(t), val(w) {}  // 构造函数
};

vector<list<Edge>> grid(n + 1); // 邻接表

堆优化本质上的思路依然是dijkstra三部曲:

  1. 第一步,选源点到哪个节点近且该节点未被访问过
  2. 第二步,该最近节点被标记访问过
  3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)

只不过之前是 通过遍历节点来遍历边,通过两层for循环来寻找距离源点最近节点。 这次我们直接遍历边,且通过堆来对边进行排序,达到直接选择距离源点最近节点。

那么三部曲的第一步,我们需要选择距离源点近的节点(即:该边的权值最小),所以我们需要一个小顶堆来帮我们对边的权值进行排序,每次从小顶堆堆顶取边就是权值最小的边。

C++定义小顶堆,可以用优先级队列实现,代码如下:

cpp 复制代码
// 小顶堆
class mycomparison {
public:
    bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
        return lhs.second > rhs.second;
    }
};
// 优先队列中存放 pair<节点编号,源点到该节点的权值> 
priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;

有了小顶堆自动对边的权值排序,那我们只需要直接从 堆里取堆顶元素(小顶堆中,最小的权值在上面),就可以取到离源点最近的节点了 (未访问过的节点,不会加到堆里进行排序)

所以三部曲中的第一步,我们不用for循环去遍历,直接取堆顶元素:

cpp 复制代码
// pair<节点编号,源点到该节点的权值>
pair<int, int> cur = pq.top(); pq.pop();

第二步(该最近节点被标记访问过) 这个就是将节点做访问标记,和朴素dijkstra 一样,代码如下:

cpp 复制代码
// 2. 第二步,该最近节点被标记访问过
visited[cur.first] = true;

第三步(更新非访问节点到源点的距离),这里的思路也是和朴素dijkstra一样的。

cpp 复制代码
// 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)
for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge
    // cur指向的节点edge.to,这条边的权值为 edge.val
    if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
        minDist[edge.to] = minDist[cur.first] + edge.val;
        pq.push(pair<int, int>(edge.to, minDist[edge.to]));
    }
}

最终的代码实现为:

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;    // 终点

    // 存储从源点到每个节点的最短距离
    std::vector<int> minDist(n + 1, INT_MAX);

    // 记录顶点是否被访问过
    std::vector<bool> visited(n + 1, false); 
    
    // 优先队列中存放 pair<节点,源点到该节点的权值>
    priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;


    // 初始化队列,源点到源点的距离为0,所以初始为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数组)
        for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge
            // cur指向的节点edge.to,这条边的权值为 edge.val
            if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
                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; // 到达终点最短路径
}

9.Bellman_ford算法精讲

该方法依然是解决单源最短路径的问题,只不过本题的方法允许边的权值存在负数。(一般负数可以理解为补贴或者是补助)。

Bellman_ford算法的核心思想是对所有边进行松弛n-1次操作(n为节点数量),从而求得目标最短路

如何理解"松弛",它的意思就是对边进行一次值的推断。以下图为例,节点A到节点B的权值为value:

minDist[B]表示到达B节点 最小权值,minDist[B]有哪些状态可以推出来?

状态一: minDist[A] + value 可以推出 minDist[B] 状态二: minDist[B]本身就有权值 (可能是其他边链接的节点B 例如节点C,以至于 minDist[B]记录了其他边到minDist[B]的权值)

minDist[B] 应为如何取舍。

本题我们要求最小权值,那么这两个状态我们就取最小的:

cpp 复制代码
if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value

也就是说,如果通过A到B这条边可以获得更短的到达B节点的路径,我们则更新minDist数组。这个过程就叫做"松弛"。

这段代码也就是Bellman_ford算法的核心操作。

以下面的例子为例,进行n-1次松弛

初始的时候,除起点的max设置为0以外,其他的都是max。因为我们要求最小距离,那么还没有计算过的节点默认是一个最大数,这样才能更新最小距离。

对所有边松弛一次,以示例给出的所有边为例:

cpp 复制代码
5 6 -2
1 2 1
5 3 1
2 5 2
2 4 -3
4 6 4
1 3 5

边:节点5 -> 节点6,权值为-2 ,minDist[5]还是默认数值max,所以不能基于节点5去更新节点6,如图:

边:节点1 -> 节点2,权值为1 ,minDist[2] > minDist[1] + 1 ,更新 minDist[2] = minDist[1] + 1 = 0 + 1 = 1 ,如图:

以此类推,对所有的边进行一次松弛。那么需要对所有边松弛几次才能得到起点(节点1)到终点(节点6)的最短距离呢?本质上来说对所有边松弛一次,相当于计算起点到达与起点一条边相连的节点的最短距离 。而对所有边松弛两次 可以得到与起点 两条边相连的节点的最短距离。对所有边松弛三次可以得到与起点三条边相连的节点的最短距离。因此节点数量为n,起点到终点最多是n-1条边相连,那么无论图是什么样的,边是什么样的顺序,我们对所有边松弛 n-1 次就一定能得到 起点到达终点的最短距离。那么Bellman_ford的解题解题过程其实就是对所有边松弛 n-1 次,然后得出得到终点的最短路径。

代码参考:

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});

    }
    int start = 1;  // 起点
    int end = n;    // 终点

    vector<int> minDist(n + 1 , INT_MAX);
    minDist[start] = 0;
    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]; // 边的权值
            // 松弛操作 
            // minDist[from] != INT_MAX 防止从未计算过的节点出发
            if (minDist[from] != INT_MAX && minDist[to] > minDist[from] + price) { 
                minDist[to] = minDist[from] + price;  
            }
        }
    }
    if (minDist[end] == INT_MAX) cout << "unconnected" << endl; // 不能到达终点
    else cout << minDist[end] << endl; // 到达终点最短路径

}

10.Bellman_ford 队列优化算法(又名SPFA)

在上一个算法中,我们其实可以发现,在首次松弛的时候,有一些边是不需要松弛的,或者说真正有效的松弛,是基于已经计算过的节点在做的松弛。

例如在上一题的例子中,第一次松弛,指针有效的松弛,只有松弛只有松弛边(节点1->节点2) 和边(节点1->节点3)。

所以可以对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;
        // 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; // 从队列里取出的时候,要取消标记,我们只保证已经在队列里的元素不用重复加入
        for (Edge edge : grid[node]) {
            int from = node;
            int to = edge.to;
            int value = edge.val;
            if (minDist[to] > minDist[from] + value) { // 开始松弛
                minDist[to] = minDist[from] + value; 
                if (isInQueue[to] == false) { // 已经在队列里的元素不用重复添加
                    que.push(to);
                    isInQueue[to] = true;
                }
            }
        }

    }
    if (minDist[end] == INT_MAX) cout << "unconnected" << endl; // 不能到达终点
    else cout << minDist[end] << endl; // 到达终点最短路径
}

bellman_ford之判断负权回路

我们知道,Bellman_ford算法能够用于带负值的路径,但是如果存在负权回路呢。 在这种情况下,意味着为经过一次1,2,3都能够使得值-1,绕的圈越多,总成本就会无限的减小。

那么如何判断是否有负权回路呢?

一般来说松弛 n-1 次所有的边就可以求得起点到任何节点的最短路径,松弛 n 次以上,minDist数组(记录起到到其他节点的最短距离)中的结果也不会有改变。

而本题有负权回路的情况下,一直都会有更短的最短路,所以松弛第n次,minDist数组也会发生改变。因此判断的核心思路就是,再多松弛一次,看minDist数组是否发生变化。

代码参考:

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});

    }
    int start = 1;  // 起点
    int end = n;    // 终点

    vector<int> minDist(n + 1 , INT_MAX);
    minDist[start] = 0;
    bool flag = false;
    for (int i = 1; i <= n; i++) { // 这里我们松弛n次,最后一次判断负权回路
        for (vector<int> &side : grid) {
            int from = side[0];
            int to = side[1];
            int price = side[2];
            if (i < n) {
                if (minDist[from] != INT_MAX && minDist[to] > minDist[from] + price) minDist[to] = minDist[from] + price;
            } else { // 多加一次松弛判断负权回路
                if (minDist[from] != INT_MAX && minDist[to] > minDist[from] + price) flag = true;

            }
        }

    }

    if (flag) cout << "circle" << endl;
    else if (minDist[end] == INT_MAX) {
        cout << "unconnected" << endl;
    } else {
        cout << minDist[end] << endl;
    }
}

bellman_ford之单源有限最短路

上面的题目,我们是计算从节点1到节点n的最短路径,但如果我们有要求,最多经过K个城市的条件下,最短路径是多少呢?

这个其实我们需要理解的是bellman_ford算法的本质,对所有边松弛一次,相当于计算起点到达起点一条边相连的节点的最短距离。

节点数量为n,起点到终点,最多是 n-1 条边相连。 那么对所有边松弛 n-1 次就一定能得到起点到达 终点的最短距离。

因此如果要求经过K个城市,那么就是K+1条边相连的节点。因此如果要求最多经过K个城市,那我们只需要对边进行K+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; // 到达终点最短路径

}

11.Floyd 算法精讲

该算法用于解决的是多源最短路问题。之前的算法都是单源最短路,即只能有一个起点。但是多源最短路,即求多个起点到多个终点的多条最短路径。

Floyd 算法对边的权值正负没有要求,都可以处理

Floyd算法的核心思想是动态规划。

例如我们再求节点1 到 节点9 的最短距离,用二维数组来表示即:grid[1][9],如果最短距离是10 ,那就是 grid[1][9] = 10。

同时节点1到节点9的最短距离,也可以由节点1到节点5的最短距离 + 节点5到节点9的最短距离组成。再进一步,节点1到节点5的最短距离,也可以有节点1到节点3的最短距离 + 节点3到节点5的最短距离组成。

以此类推,节点1到节点3的最短距离,可以由更小的区间组成。这样我们就找到了,子问题推导出整体最优方案的递归关系。

那么我们选择哪一个呢,显然,应该选择的是最小的,毕竟是求最短路径。这也就以及接近明确递归公式了。


12.A * 算法精讲 (A star算法)

A star算法,本身是一种广搜的改良版或者说dijkstra 的改良版。Astar关键在于启发式函数,也就是影响广搜或者dijkstra从容器(队列)里取元素的优先顺序。

一般来说,BFS 是没有目的性的 一圈一圈去搜索, 而 A * 是有方向性的去搜索。A*可以节省很多没有必要的遍历操作。

其关键在于启发函数,给出公式F = G + H

G:起点达到目前遍历节点的距离

H:目前遍历的节点到达终点的距离

由此F就是起点到达终点的距离。计算距离的方式有很多种,曼哈顿距离、欧式距离、切比雪夫距离等等。之后通过计算出来的F值,对点进行队列排序,每次取出的都是距离最小的值。

相关推荐
yuanbenshidiaos26 分钟前
C++----------函数的调用机制
java·c++·算法
唐叔在学习30 分钟前
【唐叔学算法】第21天:超越比较-计数排序、桶排序与基数排序的Java实践及性能剖析
数据结构·算法·排序算法
ALISHENGYA1 小时前
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之分支结构(switch语句)
数据结构·算法
chengooooooo1 小时前
代码随想录训练营第二十七天| 贪心理论基础 455.分发饼干 376. 摆动序列 53. 最大子序和
算法·leetcode·职场和发展
jackiendsc1 小时前
Java的垃圾回收机制介绍、工作原理、算法及分析调优
java·开发语言·算法
游是水里的游2 小时前
【算法day20】回溯:子集与全排列问题
算法
yoyobravery2 小时前
c语言大一期末复习
c语言·开发语言·算法
Jiude2 小时前
算法题题解记录——双变量问题的 “枚举右,维护左”
python·算法·面试
被AI抢饭碗的人2 小时前
算法题(13):异或变换
算法
nuyoah♂4 小时前
DAY36|动态规划Part04|LeetCode:1049. 最后一块石头的重量 II、494. 目标和、474.一和零
算法·leetcode·动态规划