1. 程式人生 > >圖演算法:Bellman-Ford演算法和SPFA優化

圖演算法:Bellman-Ford演算法和SPFA優化

Bellman Ford 演算法介紹

Bellman Ford演算法解決的是一般情況下的單源最短路徑問題,不同於Dijkstra演算法,Bellman Ford演算法允許邊的權重為負數。給定帶權重的有向圖G =(V, E)和權重函式 w:E>R,Bellman Ford演算法返回一個布林值,以表明是否存在一個從源結點可以到達的權重為負值的環路,如果存在,演算法將告訴我們不存在解決方案,如果不存在,演算法將給出最短路徑和他們的權重。

1. 鬆弛操作

所謂鬆弛操作,就是對每一個結點 v,估計源結點 sv 的最短路徑。演算法在鬆弛操作過程,對每個非源結點 v 記錄它的前驅結點p

re[v]。當從結點s到結點u之間的最短路徑距離dis[u],加上結點u與結點v之間的邊權重edge(u,v).cost,與當前的結點s到結點v的最短路徑估計 dis[v] 比較,如果前者更小,那麼對 dis[v] 更新,如此,鬆弛操作將產生一棵“最短路徑樹”。因為對每一個非源結點v來說,它到源結點的最短路徑已經在這個過程被算出來了。

Question: 鬆弛為什麼需要執行|V|-1外迴圈?

假定圖G不包含從源結點s可以到達的權重的負值的環路,那麼在演算法的鬆弛操作執行 |V|1 次之後,對於所有從源結點s可以到的結點v,我們得到了v結點的最短路徑。考慮從s可到達的結點v,設 P=(v0,

v1,v2,v3,...,vk) 為任意一條最短路徑,P 最多包含 |V|1 條邊,因此 k<=|V|1 ,而鬆弛操作過程每次鬆弛了所有的 |E| 的邊,這樣重複 |V|1 就一定能夠找出符合條件的最短路徑 P

注意,每一次遍歷,都可以從前一次遍歷的基礎上,找到此次遍歷的部分點的單源最短路徑。如:這是第i次遍歷,那麼,通過數學歸納法,若前面單源最短路徑層次為1~(i-1)的點全部已經得到,而單源最短路徑層次為i的點,必定可由單源最短路徑層次為i-1的點集得到,從而在下一次遍歷中充當前一次的點集,如此往復迭代,[v]-1次後,若無負權迴路,則我們已經達到了所需的目的–得到每個點的單源最短路徑。

2. 簡單的C++實現

#include<iostream>

using namespace std;

#define MAX 0x3f3f3f3f
#define N 1010

int nodeNum, edgeNum, original; // 點, 邊, 起點

struct Edge {
    int u, v;
    int cost;
}; 

Edge edge[N];
int dis[N], pre[N];

bool Bellman_Ford() {
    /*
    * 初始化 
    */ 
    for(int i = 1; i <= nodeNum; ++i)
        dis[i] = (i == orginal)? 0 : MAX;

    /*
    * 鬆弛操作
    */ 
    for(int i = 1; i <= nodeNum - 1; ++i) {
        for(int j = 1; j <= edgeNum; ++j) {
            if(dis[edge[j].v] > dis[edge[j].u] + edge[j].cost) {
                dis[edge[j].v] = dis[edge[j].u] + edge[j].cost;
                pre[edge[j].v] = edge[j].u;
            }
        }
    }
    /*
    * 判斷是否有含負權的迴路
    */ 
    bool flag = true;
    for(int i = 1; i <= edgeNum; ++i) {
        if(dis[edge[i].v] > dis[edge[i].u] + edge[i].cost) {
            flag = false;
            break;
        }
    }
    return flag;
}
 /*
 * 打印出個各個點的最短路徑 
 */
void print_shortestPath(int root) {
    while(root != pre[root]) {
        cout << root <<"->";
        root = pre[root];
    }
    if(root == pre[root])
        cout << root;
}

int main() {
    cin << nodeNum << edgeNum << original;
    pre[original] = original;

    for(int i = 1; i<= edgeNum; ++i) {
        cin >> edge[i].u >> edge[i].v >> edge[i].cost;
    }
    if(Bellman_Ford()) {
        for(int i = 1; i <= nodeNum; ++i) {
            cout << dis[i] << endl;
            cout << "shortest path: ";
            print_shortestPath(i); 
        }
    } else {
        cout << "There must be some negative circles" << endl;
    }
    return 0;
}

3. Bellman Ford演算法的優化(SPFA,Shortest Path Faster Algorithm)

Shortest Path Faster Algorithm演算法:

The Shortest Path Faster Algorithm (SPFA) is an improvement of the Bellman–Ford algorithm which computes single-source shortest paths in a weighted directed graph. The algorithm is believed to work well on random sparse graphs and is particularly suitable for graphs that contain negative-weight edges. However, the worst-case complexity of SPFA is the same as that of Bellman–Ford, so for graphs with nonnegative edge weights Dijkstra’s algorithm is preferred.The SPFA algorithm was published in 1994 by Fanding Duan. —— from WIKIPEDIA

虛擬碼
input G,v
for each u ∈ V(G)
     let dist[u] = ∞
let dist[v] = 0
let Q be an initially empty queue
push(Q,v)
while not empty(Q)
     let u = pop(Q)
     for each (u,w) ∈ E(G)
          if dist[w] > dist[u]+wt(u,w)
               dist[w] = dist[u]+wt(u,w)
               if w is not in Q
                    push(Q,w)

演算法流程

演算法大致流程是用一個佇列來進行維護。 初始時將源加入佇列。 每次從佇列中取出一個元素,並對所有與他相鄰的點進行鬆弛,若某個相鄰的點鬆弛成功,則將其入隊。 直到佇列為空時演算法結束。
這個演算法,簡單的說就是佇列優化的bellman-ford,利用了每個點不會更新次數太多的特點發明的此演算法。

SPFA——Shortest Path Faster Algorithm,它可以在O(kE)的時間複雜度內求出源點到其他所有點的最短路徑,可以處理負邊。SPFA的實現甚至比Dijkstra或者Bellman_Ford還要簡單:設Dist代表S到I點的當前最短距離,Fa代表S到I的當前最短路徑中I點之前的一個點的編號。開始時Dist全部為+∞,只有Dist[S]=0,Fa全部為0。

維護一個佇列,裡面存放所有需要進行迭代的點。初始時佇列中只有一個點S。用一個布林陣列記錄每個點是否處在佇列中。

每次迭代,取出隊頭的點v,依次列舉從v出發的邊v->u,設邊的長度為len,判斷Dist[v]+len是否小於Dist[u],若小於則改進Dist[u],將Fa[u]記為v,並且由於S到u的最短距離變小了,有可能u可以改進其它的點,所以若u不在佇列中,就將它放入隊尾。這樣一直迭代下去直到佇列變空,也就是S到所有的最短距離都確定下來,結束演算法。若一個點入隊次數超過n,則有負權環。

SPFA 在形式上和寬度優先搜尋非常類似,不同的是寬度優先搜尋中一個點出了佇列就不可能重新進入佇列,但是SPFA中一個點可能在出佇列之後再次被放入佇列,也就是一個點改進過其它的點之後,過了一段時間可能本身被改進,於是再次用來改進其它的點,這樣反覆迭代下去。設一個點用來作為迭代點對其它點進行改進的平均次數為k,有辦法證明對於通常的情況,k在2左右。

SPFA演算法(Shortest Path Faster Algorithm),也是求解單源最短路徑問題的一種演算法,用來解決:給定一個加權有向圖G和源點s,對於圖G中的任意一點v,求從s到v的最短路徑。 SPFA演算法是Bellman-Ford演算法的一種佇列實現,減少了不必要的冗餘計算,他的基本演算法和Bellman-Ford一樣,並且用如下的方法改進: 1、第二步,不是列舉所有節點,而是通過佇列來進行優化 設立一個先進先出的佇列用來儲存待優化的結點,優化時每次取出隊首結點u,並且用u點當前的最短路徑估計值對離開u點所指向的結點v進行鬆弛操作,如果v點的最短路徑估計值有所調整,且v點不在當前的佇列中,就將v點放入隊尾。這樣不斷從佇列中取出結點來進行鬆弛操作,直至佇列空為止。 2、同時除了通過判斷佇列是否為空來結束迴圈,還可以通過下面的方法: 判斷有無負環:如果某個點進入佇列的次數超過V次則存在負環(SPFA無法處理帶負環的圖)。—— From nocow.

C++ 實現
/*
 * 單源最短路演算法SPFA,時間複雜度O(kE),k在一般情況下不大於2,對於每個頂點使用可以在O(VE)的時間內算出每對節點之間的最短路
 * 使用了佇列,對於任意在佇列中的點連著的點進行鬆弛,同時將不在佇列中的連著的點入隊,直到隊空則演算法結束,最短路求出
 * SPFA是Bellman-Ford的優化版,可以處理有負權邊的情況
 * 對於負環,我們可以證明每個點入隊次數不會超過V,所以我們可以記錄每個點的入隊次數,如果超過V則表示其出現負環,演算法結束
 * 由於要對點的每一條邊進行列舉,故採用鄰接表時時間複雜度為O(kE),採用矩陣時時間複雜度為O(kV^2)
 */
#include<cstdio>
#include<vector>
#include<queue>
#define MAXV 10000
#define INF 1000000000 //此處建議不要過大或過小,過大易導致運算時溢位,過小可能會被判定為真正的距離

using std::vector;
using std::queue;

struct Edge{
    int v; //邊權
    int to; //連線的點
};

vector<Edge> e[MAXV]; //由於一般情況下E<<V*V,故在此選用了vector動態陣列儲存,也可以使用連結串列儲存
int dist[MAXV]; //儲存到原點0的距離,可以開二維陣列儲存每對節點之間的距離
int cnt[MAXV]; //記錄入隊次數,超過V則退出
queue<int> buff; //佇列,用於儲存在SPFA演算法中的需要鬆弛的節點
bool done[MAXV]; //用於判斷該節點是否已經在佇列中
int V; //節點數
int E; //邊數

bool spfa(const int st){ //返回值:TRUE為找到最短路返回,FALSE表示出現負環退出
    for(int i=0;i<V;i++){ //初始化:將除了原點st的距離外的所有點到st的距離均賦上一個極大值
        if(i==st){
            dist[st]=0; //原點距離為0;
            continue;
        }
        dist[i]=INF; //非原點距離無窮大
    }
    buff.push(st); //原點入隊
    done[st]=1; //標記原點已經入隊
    cnt[st]=1; //修改入隊次數為1
    while(!buff.empty()){ //佇列非空,需要繼續鬆弛
        int tmp=buff.front(); //取出隊首元素
        for(int i=0;i<(int)e[tmp].size();i++){ //列舉該邊連線的每一條邊
            Edge *t=&e[tmp][i]; //由於vector的定址速度較慢,故在此進行一次優化
            if(dist[tmp]+(*t).v<dist[(*t).to]){ //更改後距離更短,進行鬆弛操作
                dist[(*t).to]=dist[tmp]+(*t).v; //更改邊權值
                if(!done[(*t).to]){ //沒有入隊,則將其入隊
                    buff.push((*t).to); //將節點壓入佇列
                    done[(*t).to]=1; //標記節點已經入隊
                    cnt[(*t).to]+=1; //節點入隊次數自增
                    if(cnt[(*t).to]>V){ //已經超過V次,出現負環
                        while(!buff.empty())buff.pop(); //清空佇列,釋放記憶體
                        return false; //返回FALSE
                    }
                }
            }
        }
        buff.pop();//彈出隊首節點
        done[tmp]=0;//將隊首節點標記為未入隊
    }
    return true; //返回TRUE
} //演算法結束

int main(){ //主函式
    scanf("%d%d",&V,&E); //讀入點數和邊數
    for(int i=0,x,y,l;i<E;i++){
        scanf("%d%d%d",&x,&y,&l); //讀入x,y,l表示從x->y有一條有向邊長度為l
        Edge tmp; //設定一個臨時變數,以便存入vector
        tmp.v=l; //設定邊權
        tmp.to=y; //設定連線節點
        e[x].push_back(tmp); //將這條邊壓入x的表中
    }
    if(!spfa(0)){ //出現負環
        printf("出現負環,最短路不存在\n");
    }else{ //存在最短路
        printf("節點0到節點%d的最短距離為%d",V-1,dist[V-1]);
    }
    return 0;
}