今天也是为了cc,努力奋斗的一天ヾ(≧▽≦*)o
1. Bellman-Ford算法
引言
前面在学习Dijkstra算法时提到,Dijkstra算法不适用于边的权值为负数的情况。为了解决这个负边权这个问题,就需要使用Bellman-Ford算法(简称BF算法)。和Dijkstra算法一样,Bellman-Ford算法可解决单源最短路径问题,而且在此基础上也能处理负边权的情况。
基本思想
与Dijkstra算法相同,Bellman-Ford算法设置一个数组d,用来存放从源点到达各个顶点的最短距离。同时Bellman-Ford算法返回一个bool值:如果其中存在从源点可达的负环,那么函数将返回false;否则,函数将返回true,此时数组d中存放的值就是从源点到达各顶点的最短距离。
伪代码
需要对图中的边进行
V
−
1
V-1
V−1轮操作,每轮都遍历图中的所有边:对每条边u -> v,如果以u为中介点可以使d[v]最小,即d[u]+length[u -> v] < d[v]
成立时,就用d[u] + length[u -> v]更新d[v]。同时也可以看出,Bellman-Ford算法的时间复杂度是O(VE),其中V是顶点的个数,E是边数。
for(i = 0;i < n - 1;i++){ //执行n-1轮操作,其中n为顶点数
for(each edge u->v){ //每轮操作都遍历所有边
if(d[u] + length[u -> v] < d[v]){ //以u为中介点可以使d[v]更小
d[v] = d[u] + length[u -> v]; //松弛操作
}
}
}
此时,如果图中没有从源点可达的负环,那么数组d中的所有值都应当已经达到最优。因此,如下面的伪代码所示,只需再对所有边进行一轮操作,判断是否有某条边u -> v仍然满足d[u] + length[u->v] < d[v],如果有,则说明图中有从源点可达的负环,返回false;否则,说明数组d中的所有值都已经达到最优,返回true。
for(each edge u -> v){ //对每条边进行判断
if(d[u] + length[u -> v] < d[v]){ //如果仍可以被松弛
return false; //说明图中有从源点可达的负环
}
return true;
}
邻接表实现
由于Bellman-Ford算法需要遍历所有的边,显然使用邻接表会比较方便;如果使用邻接矩阵,则时间复杂度会上升到 O ( V 3 ) O(V^3) O(V3)。因此,下面的代码将使用邻接表作为举例:
struct Node{
int v,dis; //v为邻接边的目标顶点,dis为邻接边的边权
};
vector<Node> Adj[MAXV]; //图G的邻接表
int n; //n为顶点数,MAXV为最大顶点数
int d[MAXV]; //起点到达各点的最短路径长度
bool Bellman(int s){ //s为源点
fill(d,d+MAXV,INF); //fill函数将整个d数组赋值为INF(慎用memset)
d[s] = 0; //起点s到达自身的距离为0
//以下是求解数组d的部分
for(int i=0;i<n-1;i++){ //执行n-1轮操作,n为顶点数
for(int u=0;u<n;u++){ //每轮操作都遍历所有边
for(int j = 0;j < Adj[u].size();j++){
int v = Adj[u][j].v; //邻接边的顶点
int dis = Adj[u][j].dis; //邻接边的边权
if(d[u] + dis < d[v]){ //以u为中介点可以使d[v]更小
d[v] = d[u] + dis; //松弛操作
}
}
}
}
//以下为判断负环的代码
for(int u=0;u < n;u++){ //对每条边进行判断
for(int j=0;j<Adj[u].size();j++){
int v = Adj[u][j].v; //邻接边的顶点
int dis = Adj[u][j].dis; //邻接边的边权
if(d[u] + dis < d[v]){ //如果仍可以被松弛
return false; //说明图中有从源点可达的负环
}
}
}
return true; //数组d的所有值都可以达到最优
}
注意,外层循环进行了 V − 1 V-1 V−1次
非常重要的一点
统计最短路径条数的做法:由于Bellman-Ford算法期间会多次访问曾经访问过的顶点,如果单纯按照Dijkstra算法中的num数组的写法,会反复累计已经计算过的顶点。为了解决这个问题,需要设置记录前驱的数组set<int> pre[MAXV]
,当遇到一条和已有最短路径长度相同的路径时,必须重新计算最短路径条数。
2. SPFA
引言
虽然Bellman-Ford算法的思路很简洁,但是O(VE)
的时间复杂度确实很高,在很多情况下并不尽人意。仔细思考后发现,Bellman-Ford算法的每轮操作都需要操作所有的边,显然这其中有大量无意义的操作,严重影响了算法的性能。
基本思想
于是,注意到,只有当某个顶点u的d[u]值改变时,从它出发的边的邻接点v的d[v]值才有可能被改变。由此可以进行一个优化:建立一个队列,每次将队首顶点u取出,然后对从u出发的所有边u -> v进行松弛操作,也就是判断d[u] + length[u -> v]< d[v]
是否成立,如果成立,则用d[u] + length[u -> v]
覆盖d[v],于是d[v]获得了更优的值,此时如果v不在队列中,就把v加入到队列中。这样操作直到队列为空(说明图中没有源点可达的负环),或是某个顶点的入队次数超过V-1
(说明图中存在从源点可达的负环)。
伪代码
下面是伪代码:
queue<int> Q;
源点s入队;
while(队列非空){
取出队首元素u;
for(u的所有邻接边 u-> v){
if(d[u] + dis < d[v]){
d[v] = d[u] + dis;
if(v当前不在队列){
v入队;
if(v入队次数大于n-1){
说明有可达负环,return;
}
}
}
}
}
分析
-
这种优化后的算法被称为SPFA(Shortest Path Faster Algorithm)。
-
它的期望时间复杂度是
O(KE)
,其中E是图的边数,k是一个常数,在很多情况下k不超过2,可见这个算法在大部分数据时异常高效,并且经常性地优于堆优化的Dijkstra算法。但是如果图中有从源点可达的负环时,传统SPFA的时间复杂度会退化成O(VE)
。 -
理解SPFA的关键是理解它是如何从Bellman-Ford算法中优化得到的。
邻接表实现
vector<Node> Adj[MAXV]; //图G的邻接表
int n,d[MAXV],num[MAXV]; //num数组记录顶点的入队次数
bool inq[MAXV]; //顶点是否在队列中
bool SPFA(int s){
//初始化部分
memset(inq,false,sizeof(inq));
memset(num,0,sizeof(num));
//源点入队部分
queue<int> Q;
Q.push(s); //源点入队
inq[s] = true; //源点已入队
num[s]++; //源点入队次数加1
d[s] = 0; //源点的d值为0
//主体部分
while(!Q.empty()){
int u = Q.front(); //队首顶点编号为u
Q.pop(); //出队
inq[u] = false; //设置u为不在队列中
//遍历u的所有邻接边v
for(int j=0;j<Adj[u].size();j++){
int v = Adj[u][j].v;
int dis = Adj[u][j].dis;
//松弛操作
if(d[u] + dis < d[v]){
d[v] = d[u] + dis;
if(!inq[v]){ //如果v不在队列中
Q.push(v); //v入队
inq[v] = true; //设置v为在队列中
num[v++]; //v的入队次数加一
if(num[v] >= n){
return false; //有可达负环
}
}
}
}
}
}