在学习图论的过程中,最短论问题是比较常见且又具有代表性的一类问题。最短路是给定两个定点,在以这两个点作为起点和终点的路径中,边的权值和最小的路径。在实际生活中,最常见的最短路问题,就是在地图导航上应用。比如我们把权值作为距离,那么我们就可以求得A到B的最短路径。如果时间作为权值,那么我们就可以得到A到B的最短时间。
1、Bellman-Ford算法
单源最短路问题就是将起点固定,求该起点到其他所有点的最短路问题。贝尔曼-福特算法(Bellman-Ford)是由理查德·贝尔曼(Richard Bellman) 和 莱斯特·福特(Lester Ford) 创立的,求解单源最短路径问题的一种算法。有时候这种算法也被称为 Moore-Bellman-Ford 算法,因为 Edward F. Moore 也为这个算法的发展做出了贡献。它的原理是对图进行|V|-1次松弛操作,得到所有可能的最短路径。其优于Dijkstra算法的方面是边的权值可以为负数、实现简单,缺点是时间复杂度过高,高达O(VE)。但算法可以进行若干种优化,提高了效率。
①算法描述
设dist[v]表示从源点s到v的最短路径长度。对于与v相邻的任意顶点u,dist[v]满足三角不等式:
dist[v] ≤ dist[u] + w(u, v), (其中w(u,v)为边(u, v)的权值)
我们设d[v]为s到v的最短路权值上界(可能为无穷大,即不连通),称为最短路估计。
如果 d[v] > d[u] + w(u, v),即说明d[v]还可以变得更小。于是我们就使 d[v] = d[u] + w(u, v),我们称这个操作为松弛操作。
显然每次通过松弛操作我们都可以使得d[v]减小,直到d[v]的值不在变化(即当d[v]等于dist[v])。
我们容易知道,在一个没有负权环的图中。每一个顶点至多与其它|V|-1个顶点进行松弛操作,若大于|V|-1,则必然存在负权环。
对于图G(V,E),下面给出Bellman-Ford的算法流程:
输入:图G和起点S
输出:s到每一个点的最短路径,以及图中是否存在负权环
具体流程:
1、初始化d数组,d[s] = 0, d[i] = ∞ (i ≠ s)
2、枚举每一条边,进行松弛操作
3、将操作2重复执行|V|-1次
4、枚举每一条边,看是否能够进行松弛操作,若能,这说明原图存在负权环
②时间复杂度
对于Bellman-Ford,由于每次操作需要枚举|E|条边,总共需要重复|V|-1次操作,则我们容易得出其时间复杂度为O(VE)。如果我们使用队列进行优化,则时间复杂度可下降为O(kE),k是个比较小的系数(并且在绝大多数的图中,k<=2,然而在一些精心构造的图中可能会上升到很高)。
③代码实现
<1>未优化版
#include <cstdio>
#include <cstring>
#define INF 0xfffffff
#define MAXN (100 + 10)
using namespace std;
struct edge{
int from, to;
edge(int f = 0, int t = 0)
: from(f), to(t){}
};
edge es[MAXN*MAXN];
int cost[MAXN];
bool graph[MAXN][MAXN];
int d[MAXN];
//判断图是否联通
void Floyd(int n){
for(int i = 1; i <= n; i++){
for(int k = 1; k <= n; k++){
for(int j = 1; j <= n; j++){
if(!graph[i][j])
graph[i][j] = graph[i][k] && graph[k][j];
}
}
}
}
bool bellman_ford(int s, int V, int E){
for(int i = 0; i <= V; i++)
d[i] = -INF;
d[s] = 100;
//重复对每一条边进行松弛操作
for(int k = 0; k < V-1; k++){
for(int i = 0; i < E; i++){
edge e = es[i];
//松弛操作
if(d[e.to] < d[e.from] + cost[e.to] && d[e.from] + cost[e.to] > 0){
d[e.to] = d[e.from] + cost[e.to];
}
}
}
//检查负权环
for(int i = 0; i < E; i++){
edge e = es[i];
if(d[e.to] < d[e.from] + cost[e.to] && graph[e.to][V] && d[e.from] + cost[e.to] > 0)
return true;
}
return d[V] > 0;
}
int main(){
int n, m, cnt, vex;
while(scanf("%d", &n), n != -1){
memset(graph, false, sizeof(graph));
cnt = 0;
for(int i = 1; i <= n; i++){
scanf("%d%d", &cost[i], &m);
for(int j = 0; j < m; j++){
scanf("%d", &vex);
es[cnt++] = edge(i, vex);
graph[i][vex] = true;
}
}
Floyd(n);
if(!graph[1][n] || !bellman_ford(1, n, cnt)){
printf("hopeless\n");
}
else{
printf("winnable\n");
}
}
return 0;
}
<2>队列优化SPFA
单源最短路的SPFA算法的全称是:Shortest Path Faster Algorithm。 SPFA算法是西南交通大学段凡丁于1994年发表的。松弛操作必定只会发生在最短路径前导节点松弛成功过的节点上,用一个队列记录松弛过的节点,可以避免了冗余计算。我们还是以hdu1317xyzzy为例,代码如下:
#include <cstdio>
#include <cstring>
#include <queue>
#define MAXN (100 + 10)
using namespace std;
//d表示s到各点的所经过路径的权值之和
//cost表示各点的权值
//cnt表示进入队列的次数
int d[MAXN], cost[MAXN], cnt[MAXN];
//reach表示两点之间是否联通,即可达
//graph记录两点之间是否有边
bool reach[MAXN][MAXN], graph[MAXN][MAXN];
void Init(){
memset(d, 0, sizeof(d));
memset(cnt, 0, sizeof(cnt));
memset(graph, false, sizeof(graph));
memset(reach, false, sizeof(reach));
}
//判断图是否联通
void Floyd(int n){
for(int i = 1; i <= n; i++){
for(int k = 1; k <= n; k++){
for(int j = 1; j <= n; j++){
if(!reach[i][j])
reach[i][j] = reach[i][k] && reach[k][j];
}
}
}
}
bool SPFA(int s, int n){
queue<int> Q;
d[s] = 100;
Q.push(s);
while(!Q.empty()){
int now = Q.front();
Q.pop();
cnt[now]++;
//如果不存在负权环(PS:在本题中为正权环),即每个点进入队列的次数至多为n-1
//若大于n-1,即表明必然存在负权环
if(cnt[now] >= n) return reach[now][n];
//依次枚举每条边
for(int next = 1; next <= n; next++){
if(graph[now][next] && d[now] + cost[next] > d[next] && d[now] + cost[next] > 0){
Q.push(next);
d[next] = d[now] + cost[next];
}
}
}
return d[n] > 0;
}
int main(){
int n, m, vex;
while(scanf("%d", &n), n != -1){
Init();
for(int i = 1; i <= n; i++){
scanf("%d%d", &cost[i], &m);
for(int j = 0; j < m; j++){
scanf("%d", &vex);
reach[i][vex] = true;
graph[i][vex] = true;
}
}
Floyd(n);
if(!reach[1][n] || !SPFA(1, n)){
printf("hopeless\n");
}
else{
printf("winnable\n");
}
}
return 0;
}
2、Dijkstra算法
我们容易发现,如果图中没有负边的情况。在Bellman-Ford算法中,如果d[u]还不是最短距离的话,那么即便我们进行了松弛操作,那么d[v]也不会变为最短距离。而且即便d[v]没有变化,那么他还是需要检查一次所有的边。显然这些操作很浪费时间,于是乎,我们就提出了以下改进:
(1)从最短距离已经确定的顶点出发更新与之相邻顶点的最短距离。
(2)对于最短距离已经确定的顶点,我们直接无视。
通过这样的修改我们就得到了Dijkstra算法。Dijkstra算法是用来解决只含非负权值边的图的单源最短路问题。换而言之,Dijkstra无法处理含有负权边的图。
①算法描述
对于图G(V,E),下面给出Dijkstra的算法流程:
输入:图G和起点S
输出:s到每一个点的最短路径
具体流程:
1、初始化d数组,d[s] = 0, d[i] = ∞ (i ≠ s)
2、设置所有点未访问过(即设置一个标记数组,并将其置空)
3、找出所有未访问过的点中距离值最小的点,将其标记为访问过
4、对3中找到的点的相邻边进行松弛操作
5、重复3和4直到所有点都访问过
下边给出一幅图来模拟Dijkstra的过程:
②时间复杂度
因为操作更新|V|次,每次操作需要找最小值,扫描一个点连接的所有边,如果我们使用堆来实现寻找和维护,则时间复杂度为O( (|E|+|V|) log|V| )。若只用普通的方法扫描,时间复杂度为O(|V|² + |E|)。
③代码实现
我们以hdu 1874畅通工程续 来说明Dijkstra算法:
<1>未优化版
#include <cstdio>
#include <vector>
#include <algorithm>
#define MAXN 200 + 10
#define INF 0xffffff
using namespace std;
struct Vex{
int v, weight;
Vex(int tv, int tw):v(tv), weight(tw){}
};
//graph用来记录图的信息
vector<Vex> graph[MAXN];
//判断是否已经找到最短路
bool inTree[MAXN];
//源点s到各顶点最短路的值
int mindist[MAXN];
//初始化
void Init(int n){
for(int i = 0; i < n; i++){
inTree[i] = false;
graph[i].clear();
mindist[i] = INF;
}
}
//s表示源点,t表示终点,n表示顶点数目
int Dijkstra(int s, int t, int n){
int tempMin, tempVex, addNode;
//初始化s
mindist[s] = 0;
//将源点s标记为访问过
inTree[s] = true;
//题目中可能有重边,我们去除重边
for(unsigned int i = 0; i < graph[s].size(); i++)
mindist[graph[s][i].v] = min(mindist[graph[s][i].v], graph[s][i].weight);
//从剩下的n-1个点逐个枚举
for(int nNode = 1; nNode <= n-1; nNode++){
tempMin = INF;
//寻找所有未访问过点中,有最小距离的点
for(int i = 0; i < n; i++){
if(!inTree[i] && mindist[i] < tempMin){
tempMin = mindist[i];
addNode = i;
}
}
//将该点标记为访问过
inTree[addNode] = true;
//将与该点相邻的点进行松弛操作
for(unsigned int i = 0; i < graph[addNode].size(); i++){
tempVex = graph[addNode][i].v;
if(!inTree[tempVex] && tempMin + graph[addNode][i].weight < mindist[tempVex]){
mindist[tempVex] = tempMin + graph[addNode][i].weight;
}
}
}
return mindist[t];
}
int main(){
int n, m;
int v1, v2, x, s, t;
while(scanf("%d%d", &n, &m) != EOF){
Init(n);
for(int i = 0; i < m; i++){
scanf("%d%d%d", &v1, &v2, &x);
graph[v1].push_back(Vex(v2, x));
graph[v2].push_back(Vex(v1, x));
}
scanf("%d%d", &s, &t);
int ans = Dijkstra(s, t, n);
if(ans == INF)
printf("-1\n");
else
printf("%d\n", ans);
}
return 0;
}
<2>堆优化版
#include <cstdio>
#include <vector>
#include <queue>
#include <algorithm>
#define MAXN 200 + 10
#define INF 0xffffff
using namespace std;
struct edge{
int to, cost;
};
typedef pair<int, int> P;
vector<edge> graph[MAXN];
int mindist[MAXN];
void Init(int n){
for(int i = 0; i < n; i++){
graph[i].clear();
mindist[i] = INF;
}
}
int Dijkstra_heap(int s, int t, int n){
//pair的first存放s->v的距离
//second存放顶点v
priority_queue<P, vector<P>, greater<P> > Q;
//初始化源点s的信息
mindist[s] = 0;
Q.push(P(0, s));
while(!Q.empty()){
//每次从堆中取出最小值
P p = Q.top(); Q.pop();
int v = p.second;
//当取出的值不是当前最短距离的话,就丢弃这个值
if(mindist[v] < p.first) continue;
//将与其相邻的点,进行松弛操作
for(unsigned int i = 0; i < graph[v].size(); i++){
edge e = graph[v][i];
if(mindist[e.to] > mindist[v] + e.cost){
mindist[e.to] = mindist[v] + e.cost;
//将满足条件的点重新加入堆中
Q.push(P(mindist[e.to], e.to));
}
}
}
return mindist[t];
}
int main()
{
int n, m;
int v1, v2, x, s, t;
while(scanf("%d%d", &n, &m) != EOF){
Init(n);
for(int i = 0; i < m; i++){
scanf("%d%d%d", &v1, &v2, &x);
graph[v1].push_back({v2, x});
graph[v2].push_back({v1, x});
}
scanf("%d%d", &s, &t);
int ans = Dijkstra_heap(s, t, n);
if(ans == INF)
printf("-1\n");
else
printf("%d\n", ans);
}
return 0;
}
PS:如果不懂优先队列的,请移步: 传送门
PPS:在附赠一个大礼包,图论500题