文章目录
【47.参加科学大会|djikstra堆优化版】
思路
解决带权无负权图 单源最短路径问题(从起点 1 到终点 n),用邻接表存图 + 小顶堆优先队列优化。
-
初始化:
minDist[]全部设无穷大INT_MAX,起点距离设 0;小顶堆放入起点 (节点号,距离)。 -
循环取出堆顶:当前离源点最近的节点。
-
如果该节点已经访问过,直接跳过(堆里存了旧的、更长距离的过期记录)。
-
标记节点已访问(Dijkstra 性质:一旦弹出堆,最短路径已经确定,不再更新)。
-
遍历该节点所有邻边,做松弛操作:
如果
起点→cur + cur→next的距离 < 原来记录的起点→next 距离,就更新最短距离,把新的距离压入小顶堆。
-
堆空结束。最后判断终点距离:无穷大输出
-1(不可达),否则输出最短距离。
重点考点
- 适用范围:图不能有负权边!有负权 Dijkstra 失效,要用 Bellman-Ford/SPFA。
- 小顶堆仿函数 :
return lhs.second > rhs.second;class要加public:,否则operator()私有编译报错。pair<节点编号,距离>,按距离比较。 - visited 数组作用:节点一旦从小顶堆弹出,代表已经找到它的最短路径,后续堆里这个节点的旧记录直接跳过。
- 邻接表 :
vector<list<Edge>>,Edge 存邻接节点 + 边权重;每次建单向边。无向图需要正反两次加边。 - 松弛操作:核心,尝试更新邻居的最短距离,更新成功就入堆。堆里允许存在同一个节点多条记录,不用去重。
- 坑点
minDist[cur]是INT_MAX时,minDist[cur]+edge.val会整数溢出(刷题可加判断)- 不要混淆 pair 的 first /second:first 节点编号,second 是距离
- 复杂度:(O(m\log m)),m 是边数量;稀疏图远快于朴素 Dijkstra (O(n^2))
一句话记忆:每次选最近点,标记固定最短距离,遍历邻边松弛,新距离入小顶堆。
cpp
#include <iostream>
#include <vector>
#include <climits>
#include <queue>
#include <list>
using namespace std;
// 小顶堆
class mycomparision{
public:
bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs){
return lhs.second > rhs.second;
}
};
// 定义一个结构体表示带权重的边
struct Edge{
int node; // 表示邻接节点
int val; // 表示节点值
Edge(int x, int y):node(x), val(y){} // 构造函数
};
int main(){
int n, m, p1, p2, val;
cin >> n >> m;
vector<list<Edge>> grid(n + 1); // 构建邻接表存放路线和权值
for(int i = 1; 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<pair<int, int>, vector<pair<int, int>>, mycomparision> pq;
pq.push(pair<int, int>(start, 0)); //初始化pq,原点到原点的距离是0
minDist[start] = 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]){
if(!visited[edge.node] && minDist[cur.first] + edge.val < minDist[edge.node]){ // 更新minDist
minDist[edge.node] = minDist[cur.first] + edge.val;
pq.push(pair<int, int>(edge.node, minDist[edge.node]));
}
}
}
if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到达终点
else cout << minDist[end] << endl; // 到达终点最短路径
}
- 时间复杂度:O(ElogE) E 为边的数量
- 空间复杂度:O(N + E) N 为节点的数量
堆优化的时间复杂度 只和边的数量有关 和节点数无关,在 优先级队列中 放的也是边。
以上代码中,while (!pq.empty()) 里套了 for (Edge edge : grid[cur.first])
for 里 遍历的是 当前节点 cur 所连接边。
那 当前节点cur 所连接的边 也是不固定的, 这就让大家分不清,这时间复杂度究竟是多少?
其实 for (Edge edge : grid[cur.first]) 里最终的数据走向 是 给队列里添加边。
那么跳出局部代码,整个队列 一定是 所有边添加了一次,同时也弹出了一次。
所以边添加一次时间复杂度是 O(E), while (!pq.empty()) 里每次都要弹出一个边来进行操作,在优先级队列(小顶堆)中 弹出一个元素的时间复杂度是 O(logE) ,这是堆排序的时间复杂度。
(当然小顶堆里 是 添加元素的时候 排序,还是 取数元素的时候排序,这个无所谓,时间复杂度都是O(E),总之是一定要排序的,而小顶堆里也不会滞留元素,有多少元素添加 一定就有多少元素弹出)
所以 该算法整体时间复杂度为 O(ElogE)
while (!pq.empty()) 中的 pq 存的是边,其实只执行了一次。
所以该算法时间复杂度 和 节点没有关系。
至于空间复杂度,邻接表是 数组 + 链表 数组的空间 是 N ,有E条边 就申请对应多少个链表节点,所以是 复杂度是 N + E
【94.城市间货物运输I|Bellman_ford算法】
思路:
本题依然是单源最短路问题,求 从 节点1 到节点n 的最小费用。 但本题不同之处在于 边的权值是有负数了。
从 节点1 到节点n 的最小费用也可以是负数,费用如果是负数 则表示 运输的过程中 政府补贴大于运输成本。
在求单源最短路的方法中,使用dijkstra 的话,则要求图中边的权值都为正数。
本题是经典的带负权值的单源最短路问题,此时就轮到Bellman_ford登场了,接下来我们来详细介绍Bellman_ford 算法 如何解决这类问题。
该算法是由 R.Bellman 和L.Ford 在20世纪50年代末期发明的算法,故称为Bellman_ford算法。
Bellman_ford算法的核心思想是 对所有边进行松弛n-1次操作(n为节点数量),从而求得目标最短路。
什么叫做松弛
看到这里,估计大家都比较晕了,为什么是 n-1 次,那"松弛"这两个字究竟是个啥意思?
我们先来说什么是 "松弛"。
这里我给大家举一个例子,每条边有起点、终点和边的权值。例如一条边,节点A 到 节点B 权值为value,如图:

minDistB 表示 到达B节点 最小权值,minDistB 有哪些状态可以推出来?
状态一: minDistA + value 可以推出 minDistB 状态二: minDistB本身就有权值 (可能是其他边链接的节点B 例如节点C,以至于 minDistB记录了其他边到minDistB的权值)
minDistB 应为如何取舍。
本题我们要求最小权值,那么 这两个状态我们就取最小的
cpp
if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value
也就是说,如果 通过 A 到 B 这条边可以获得更短的到达B节点的路径,即如果 minDist[B] > minDist[A] + value,那么我们就更新 minDist[B] = minDist[A] + value ,这个过程就叫做 "松弛" 。
以上讲了这么多,其实都是围绕以下这句代码展开:
text
if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value
这句代码就是 Bellman_ford算法的核心操作。
以上代码也可以这么写:minDist[B] = min(minDist[A] + value, minDist[B])
刷题到现在,会发现 无论是背包问题还是子序列问题,这段代码(递推公式)出现频率非常高的。
其实 Bellman_ford算法 也是采用了动态规划的思想,即:将一个问题分解成多个决策阶段,通过状态之间的递归关系最后计算出全局最优解。
(如果理解不了动态规划的思想也无所谓,理解我上面讲的松弛操作就好)
那么为什么是 n - 1次 松弛呢?
这里要给大家模拟一遍 Bellman_ford 的算法才行,接下来我们来看看对所有边松弛 n - 1 次的操作是什么样的。
我们依然使用minDist数组来表达 起点到各个节点的最短距离,例如minDist3 = 5 表示起点到达节点3 的最小距离为5
模拟过程
初始化过程。
起点为节点1, 起点到起点的距离为0,所以 minDist1 初始化为0
如图:

其他节点对应的minDist初始化为max,因为我们要求最小距离,那么还没有计算过的节点 默认是一个最大数,这样才能更新最小距离。
对所有边 进行第一次松弛: (什么是松弛,在上面我已经详细讲过)
以示例给出的所有边为例:
text
5 6 -2
1 2 1
5 3 1
2 5 2
2 4 -3
4 6 4
1 3 5
接下来我们来松弛一遍所有的边。
边:节点5 -> 节点6,权值为-2 ,minDist5 还是默认数值max,所以不能基于 节点5 去更新节点6,如图:

(再复习一下,minDist5 表示起点到节点5的最短距离)
边:节点1 -> 节点2,权值为1 ,minDist2 > minDist1 + 1 ,更新 minDist2 = minDist1 + 1 = 0 + 1 = 1 ,如图:

边:节点5 -> 节点3,权值为1 ,minDist5 还是默认数值max,所以不能基于节点5去更新节点3 如图:

边:节点2 -> 节点5,权值为2 ,minDist5 > minDist2 + 2 (经过上面的计算minDist2已经不是默认值,而是 1),更新 minDist5 = minDist2 + 2 = 1 + 2 = 3 ,如图:

边:节点2 -> 节点4,权值为-3 ,minDist4 > minDist2 + (-3),更新 minDist4 = minDist2 + (-3) = 1 + (-3) = -2 ,如图:

边:节点4 -> 节点6,权值为4 ,minDist6 > minDist4 + 4,更新 minDist6 = minDist4 + 4 = -2 + 4 = 2

边:节点1 -> 节点3,权值为5 ,minDist3 > minDist1 + 5,更新 minDist3 = minDist1 + 5 = 0 + 5 = 5 ,如图:

以上是对所有边进行一次松弛之后的结果。
那么需要对所有边松弛几次才能得到 起点(节点1) 到终点(节点6)的最短距离呢?
对所有边松弛一次,相当于计算 起点到达 与起点一条边相连的节点 的最短距离。
上面的距离中,我们得到里 起点达到 与起点一条边相邻的节点2 和 节点3 的最短距离,分别是 minDist2 和 minDist3
疑惑:minDist3 = 5,分明不是 起点到达 节点3 的最短距离,节点1 -> 节点2 -> 节点5 -> 节点3 这条路线 距离才是4。
注意我上面讲的是 对所有边松弛一次,相当于计算 起点 到达 与起点一条边相连的节点 的最短距离,这里 说的是 一条边相连的节点。
与起点(节点1)一条边相邻的节点,到达节点2 最短距离是 1,到达节点3 最短距离是5。
而 节点1 -> 节点2 -> 节点5 -> 节点3 这条路线 是 与起点 三条边相连的路线了。
所以对所有边松弛一次 能得到 与起点 一条边相连的节点最短距离。
那对所有边松弛两次 可以得到与起点 两条边相连的节点的最短距离。
那对所有边松弛三次 可以得到与起点 三条边相连的节点的最短距离,这个时候,我们就能得到到达节点3真正的最短距离,也就是 节点1 -> 节点2 -> 节点5 -> 节点3 这条路线。
那么再回归刚刚的问题,需要对所有边松弛几次才能得到 起点(节点1) 到终点(节点6)的最短距离呢?
节点数量为n,那么起点到终点,最多是 n-1 条边相连。
那么无论图是什么样的,边是什么样的顺序,我们对所有边松弛 n-1 次 就一定能得到 起点到达 终点的最短距离。
其实也同时计算出了,起点 到达 所有节点的最短距离,因为所有节点与起点连接的边数最多也就是 n-1 条边。
截止到这里,Bellman_ford 的核心算法思路,大家就了解的差不多了。
共有两个关键点。
- "松弛"究竟是个啥?
- 为什么要对所有边松弛 n - 1 次 (n为节点个数) ?
那么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;
}
- 时间复杂度: O(N * E) , N为节点数量,E为图中边的数量
- 空间复杂度: O(N) ,即 minDist 数组所开辟的空间
关于空间复杂度,代码中数组grid不也开辟空间了吗? 为什么只算minDist数组的空间呢?
grid数组是用来存图的,这是题目描述中必须要使用的空间,而不是我们算法所使用的空间。
我们在讲空间复杂度的时候,一般都是说,我们这个算法所用的空间复杂度。