5.图论.基础2

拓扑排序

拓扑排序指的是一种 解决问题的大体思路, 而具体算法,可能是广搜也可能是深搜。其实只要能在把 有向无环图 进行线性排序 的算法 都可以叫做 拓扑排序。
实现拓扑排序的算法有两种:**卡恩算法(BFS)**和DFS;
在这里插入图片描述

实现拓扑排序的的的话,1)首先需要找到根节点(其特征是入度为0),然后加入结果集;2)然后将该节点从图上移除。循环以上两步,直到所有节点都在图中被移除。结果集的顺序就是我们想要的拓扑排列顺序(结果顺序可能不唯一)

意外情况:当我们招不到入度=0的节点了,发现结果集元素个数 不等于 图中节点个数,我们就可以认定图中一定有 有向环!

代码框架:

// que是定义的队列,先进先处理
// umap是ordered_map<int, vector<int>>,记录文件的依赖关系
// inDegree是vector<int>,记录对应文件的入度

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

Dijkstra算法

其实 dijkstra 算法 和 我们之前讲解的prim算法思路非常接近。
在有权图(权值非负数)中求从起点到其他节点的最短路径算法;

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

dijkstra 算法 同样是贪心的思路,不断寻找距离 源点最近的没有访问过的节点:
第一步:选源点到哪个节点近且该节点未被访问过
第二步:该最近节点被标记访问过
第三步:更新非访问节点

到源点的距离(即更新minDist数组),在更新阶段,由于当前还未遍历节点会收到graph[cur]的影响,节点到达源点的距离会相应发生改变(也就是剪枝处理)。

普通版

#include "iostream"
#include "vector"
#include "queue"
#include "string"
#include "climits"

using namespace std;


int main(){
    int n ,m, p1, p2, val;
    cin>> n>> m; // 接收graph的邻接矩阵的元素个数
    vector<vector<int>> graph(n+1, vector<int>(n+1, INT_MAX));
    for(int i=0; i<m; i++){
        cin>> p1>> p2>> val; // 接收graph的邻接矩阵的元素
        graph[p1][p2] = val; // 有向图
    }

    vector<int> mindist(n+1, INT_MAX);
    vector<bool> visited(n+1, false);
    mindist[1] = 0; // 起点为1
    vector<int> parent(n+1, -1); // 记录路径

    for(int i=1; i<=n; i++){
        int mindist_val = INT_MAX;
        int cur = 1;
        // 该循环用于找到未访问节点中距离最近的节点
        for(int j=1; j<=n; j++){
            if(!visited[j] && mindist[j] < mindist_val){
                mindist_val = mindist[j];
                cur = j;
            }
        }
        visited[cur] = true; // 标记该节点已访问
        // 该循环用于更新未访问节点的距离
        for(int j=1; j<=n; j++){
            if(!visited[j]&&graph[cur][j]!= INT_MAX && mindist[cur]+graph[cur][j]<mindist[j]){
                mindist[j] = mindist[cur]+graph[cur][j];
                parent[j] = cur;
            }
        }
    }
    // 输出路径
//    cout<< "parent:"<< endl;
//    for(int i=1; i<=n; i++){
//        cout<< parent[i]<< "->"<< i<< endl;
//    }
    if (mindist[n]==INT_MAX) cout<< -1<< endl;
    else cout<< mindist[n]<< endl;

    return 0;
}

时间复杂度:O(n^2); 空间复杂度:O(n^2)

异常情况

注意当边的权值是负值时,dijkstra算法会发生异常;
在这里插入图片描述
对于负权值的出现,大家可以针对某一个场景 不断去修改 dijkstra 的代码,但最终会发现只是 拆了东墙补西墙,对dijkstra的补充逻辑只能满足某特定场景最短路求解;对于求解带有负权值的最短路问题,可以使用 Bellman-Ford 算法

与prim算法的区别

两者代码大体不差,唯一区别在 三部曲中的 第三步: 更新minDist数组
因为prim是求 非访问节点到最小生成树的最小距离,而 dijkstra是求 非访问节点到源点的最小距离。

// prim 更新 minDist数组的写法:
for (int j = 1; j <= v; j++) {
    if (!isInTree[j] && grid[cur][j] < minDist[j]) {
        minDist[j] = grid[cur][j];
    }
}

// dijkstra 更新 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];
    }
}
  • 前者中:minDist表示 节点到最小生成树的最小距离,所以 新节点cur的加入,只需要 使用 grid[cur][j]grid[cur][j] 就表示 cur 加入生成树后,生成树到 节点j 的距离。(思考prim算法适合于负权值的最短路径搜索,当然可以,prim算法只需要将节点以最小权值和链接在一起,不涉及到单一路径
  • 后者中:minDist表示 节点到源点的最小距离,所以 新节点 cur 的加入,需要使用 源点到cur的距离 (minDist[cur]) + cur 到 节点 v 的距离 (grid[cur][v]),才是 源点到节点v的距离。

优化版

当 n 很大,边 的数量 也很多的时候(稠密图),那么 上述解法没问题;但 n 很大,边 的数量 很小的时候(稀疏图),是不是可以换成从边的角度求最短路。

图的存储方式

  • 邻接矩阵:优点——表达方式简单,易于理解;检查任意两个顶点间是否存在边的操作非常快;适合稠密图,在边数接近顶点数平方的图中,邻接矩阵是一种空间效率较高的表示方法。缺点——遇到稀疏图,会导致申请过大的二维数组造成空间浪费 且==遍历 边 ==的时候需要遍历整个n * n矩阵,造成时间浪费
  • 邻接表:优点——对于稀疏图的存储,只需要存储边,空间利用率高;遍历节点链接情况相对容易。缺点——检查任意两个节点间是否存在边,效率相对低,需要 O(V)时间,V表示某节点链接其他节点的数量;实现相对复杂,不易理解

在普通版的dijkstra算法中,这三部曲是套在一个 for 循环里,这是因为我们从节点的角度来解决该问题(从节点的角度出发,需要更新n次cur(才能遍历完所有节点的情况)->mindist数组才能得到)。

而当我们从边的角度出发,处理 三部曲里的第一步(选源点到哪个节点近且该节点未被访问过)的时候 ,我们可以直接把 边(带权值)加入到 小顶堆(利用堆来自动排序),那么每次我们从 堆顶里 取出 边 自然就是 距离源点最近的节点所在的边。这样我们就可以不需要使用两层for循环来寻找最近的节点了。

邻接表用 数组+链表 来表示:

// 基础
vector<list<int>> grid(n + 1);
// 考虑边权值
vector<list<pair<int,int>>> grid(n + 1);

// 可以 定一个类 来取代 pair<int, int>,以增强代码可读性
struct Edge {
    int to;  // 链接的节点
    int val; // 边的权重

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

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

堆优化细节
dijkstra三部曲依然是原本的思路,但与之前通过遍历节点来遍历边,通过两层for循环来寻找距离源点最近节不同, 这次直接遍历边,且通过堆来对边进行排序,达到直接选择距离源点最近节点。

C++定义小顶堆

// 小顶堆
struct 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;

第一步,选择源点到哪个节点近且该节点未被访问过,直接在小顶堆top取

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

第二步,标记所选择的最近节点为visited

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

第三步,(更新非访问节点到源点的距离),这里的思路 也是 和朴素dijkstra一样的:在使用邻接表的情况下,我们可以根据cur节点,找到cur可以到达的节点,根据新得到的边更mindist数组,以及小顶堆

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

最后在顶层循环中,我们不必使用一个顶层for循环,而是使用一个 while (!pq.empty())作为循环结束的标志。
时间复杂度:O(ElogE) E 为边的数量;空间复杂度:O(N + E) N 为节点的数量

另外 因为稀疏图,所以我们使用堆优化的思路, 如果我们还用 邻接矩阵 去表达这个图的话,就是 一个高效的算法 使用了低效的数据结构,那么 整体算法效率 依然是低的。
时间复杂度:O(E * (N + logE)) E为边的数量,N为节点数量;空间复杂度:O(log(N^2))


Bellman_ford算法

解决经典的带负权值的单源最短路问题,此时就轮到Bellman_ford登场了。
共有两个关键点。

  1. “松弛”究竟是个啥?
  2. 为什么要对所有边松弛 n - 1 次 (n为节点个数) ?

Bellman_ford算法的核心思想是 对所有边进行松弛n-1次操作(n为节点数量),从而求得目标最短路。
在这里插入图片描述
B节点的mindist值可以由A,C节点值推导出来,状态一: minDist[A] + value 可以推出 minDist[B] 状态二: minDist[B]本身就有权值 (可能是其他边链接的节点B 例如节点C,以至于 minDist[B]记录了其他边到minDist[B]的权值)
如果 通过 A 到 B 这条边可以获得更短的到达B节点的路径,这就是松弛的概念;而在路径搜索中这成为剪枝操作;也是采用了动态规划的思想,即:将一个问题分解成多个决策阶段,通过状态之间的递归关系最后计算出全局最优解。

if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value

对所有边松弛一次,相当于计算 起点到达 与起点一条边相连的节点 的最短距离
节点数量为n,那么起点到终点,最多是 n-1 条边相连。那么无论图是什么样的,边是什么样的顺序,我们对所有边松弛 n-1 次 就一定能得到 起点到达 终点的最短距离。

队列优化

Bellman_ford 队列优化算法(Queue improved Bellman-Ford) ,也叫SPFA算法(Shortest Path Faster Algorithm)
大家可以发现 Bellman_ford 算法每次松弛 都是对所有边进行松弛。但真正有效的松弛,是基于已经计算过的节点在做的松弛
基于以上思路,==如何记录上次松弛的时候更新过的节点呢?==用队列来记录。(其实用栈也行,对元素顺序没有要求)
基于队列优化的算法,要比bellman_ford 算法 减少很多无用的松弛情况,特别是对于边数众多的大图 优化效果明显;

    while (!que.empty()) {

        int node = que.front(); que.pop();

        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;
                que.push(to);
            }
        }

    }

Bellman_ford队列优化版 的时间复杂度 并不稳定,效率高低依赖于图的结构;
例如 如果是一个双向图,且每一个节点和所有其他节点都相连的话,那么该算法的时间复杂度就接近于 Bellman_ford 的 O(N * E) N 为节点数量,E为边的数量。
所以如果图越稠密,则 SPFA的效率越接近与 Bellman_ford。反之,图越稀疏,SPFA的效率就越高

判断负权回路

在评估从城市 1 到城市 n 的所有可能路径中综合政府补贴后的最低运输成本时,存在一种情况:图中可能出现负权回路。负权回路是指一系列道路的总权值为负,这样的回路使得通过反复经过回路中的道路,理论上可以无限地减少总成本或无限地增加总收益。为了避免在使用Bellman_ford算法遇到该情况时,出现这种异常情况,需要插入一个判断环节,返回这种异常情况。
在这里插入图片描述
在 bellman_ford 算法中,松弛 n-1 次所有的边 就可以求得 起点到任何节点的最短路径,松弛 n 次以上,minDist数组(记录起到到其他节点的最短距离)中的结果也不会有改变;而在有负权回路的情况下,一直都会有更短的最短路,所以 松弛 第n次,minDist数组也会发生改变。

那么很自然,我们在基础版的还是队列优化版的bellman_ford算法松弛n-1次的基础上,再额外松弛一次,判断mindist数组中是否发生改变,若有发生改变,则图中必定存在负权回路。

  • 基础版bellman_ford:n个节点,最多n-1条边即可保证起点1至n连通,所以bellman_ford松弛n-1次即可得到到达n节点的距离最小路径。。在顶层for循环中增加一次for循环,在最后的循环中检查mindist数组是否满足松弛条件
  • 队列优化版bellman_ford:在极端情况下,即:所有节点都与其他节点相连,每个节点的入度为 n-1 (n为节点数量),所以每个节点最多加入 n-1 次队列。在while循环下,检查松弛条件生效时,全局计数器数组对应节点下标加1,如果存在某节点加入队列次数超过 n-1次 就说明该图与负权回路。

有限最短路径

解决经典的带负权值的单源最短路问题,考虑最多经过 k 个城市的条件下,而不是一定经过k个城市,也可以经过的城市数量比k小,但要最短的路径。此时在考虑带负权回路的图,且在途径节点数量有限的情况下,使用普通版还是队列优化版的Bellman_ford算法都会遇到问题,因为之前的代码没有考虑带有负权值回路的情况,只要多做松弛,结果是会变的。因此每次根据一个节点松弛操作必须是基于上一次计算的mindist数组,不然就会进行多次松弛操作。

  • 普通版Bellman_ford算法:在每次计算 minDist 时候,要基于 对所有边上一次松弛的 minDist 数值才行,所以我们要记录上一次松弛的minDist(直观的想法是每次顶层遍历时先拷贝上一次计算的结果)
  • 队列优化版Bellman_ford算法:对松弛次数是有限制的。使用技巧,可以用一个变量 que_size 记录每一轮松弛入队列的所有节点数量;下一轮松弛的时候,就把队列里 que_size 个节点都弹出来,就是上一轮松弛入队列的节点。

Floyd算法

面对经典的多源最短路问题。与之前的单源最短路径,之能有一个起点不同。这题是求多个起点到多个终点的多条最短路径。通过该题学习Floyd算法,Floyd 算法对边的权值正负没有要求,都可以处理。
Floyd算法核心思想是动态规划。也有动态规划五部曲。
1.dp数组定义,下标含义
2.确定递推表达式
3.dp数组初始化
4.递推方向
5.打印dp数组(模拟过程)


#A*算法

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值