1. 程式人生 > >學習圖論(四)——最短路問題

學習圖論(四)——最短路問題

一、DFS或BFS搜尋(單源最短路徑)
思想:遍歷所有從起點到終點的路徑,選取一條權值最短的路徑。
下面程式碼是參考部落格中的程式碼,加上本人一些註釋

void DFS(int u,int dist) //u 為當前節點; dist 為當前點到起點的距離 
{
    //min 表示目前起點到終點的最短距離,初始化為 無窮(用一個很大的數去表示) 
    if(dist>min) //當前距離已經大過目前起點終點的最短距離,沒必要往下 
        return ;
    if(u==dest)  //到達目的地 
        if(min>dist)
        {
            min
=dist; return ; } for(int i=1;i<=n;++i) //遍歷每一條邊,標記點 { //對於同一個點,edge[i][i] = 0,下面條件中讓 edge[u][i]大於0,是為了防止陷入無限迴圈 //即老是搜尋同一個點。 if(edge[u][i]!=INF&&edge[u][i]&&!vis[i]) { vis[i]=1; DFS(i,dist+edge[u][i]); //更新當前距離
vis[i]=0; } } return ; }

二、弗洛伊德演算法(多源最短路徑)
思想:通過比較得出圖中的任意兩個點,依次通過0個,1個,2個…中介點所形成的路徑中最短的那一條。
一開始如果兩點(U和V)之間的路徑(路徑①)上沒有其他點,那麼這條路徑必定是這兩點過上的最短路徑。現在如果兩點之間有另外一條路徑(路徑②),該路徑上存在第三個點(T),若這條路徑比原來的路徑①短,則將該路徑②更新為U和V之前的最短路徑,而這條路徑②,是由U-T,T-U兩條直接相連的路徑構成。依次往下,當存在一條路徑連線U、T、V且上面有第四個點時,在這條路徑上U到V的距離是否更小?…

void Floyd(int all) //all 共有多少節點 
{
    //三層迴圈表示的是:從 j 到 k 經過點 i 後 j和k之間的距離有沒有縮小 
    for(int i=1;i<=all;++i)
        for(int j=1;j<=all;++j)
            for(int k=1;k<=all;++k)
                //最好加入 egde[j][i] 和 egde[i][k] 都小於inf的條件,防止資料爆掉 
                if(egde[j][k]>egde[j][i]+egde[i][k]) //判斷藉助中轉點後,路程是否縮小 
                    egde[j][k]=egde[j][i]+egde[i][k];  //每個 egde[i][j]都表示當前時刻下 i 到 j 的最短距離 
} 

使用Floyd演算法後,二維陣列edge表示的邊都是兩點之間的最短路,所以可以輸出任意兩點之間最短的路徑。

三、迪傑斯特拉演算法(單源最短路徑)
思想: 通過 n-1 層迴圈,每一次,尋找距離起點最近的點,做標記(不再訪問),然後用該點作為其他點到起點路徑上的中介,更新其他點到起點之間的距離。即,假設起點為U,中介點為T,圖上任意沒有被標記過的點為V,判斷U-V的距離是否大於U-T-V距離,是的話更新U-V的距離為U-T-V的距離。(用一個一維陣列存放在圖上其他點到起點的距離),這種對兩點直接距離的更新稱為鬆弛操作(記住一些專業名詞對以後的閱讀有好處)。然後再次從未標記的點中尋找和起點最近的點,做同樣的操作,知道遍歷所有的點。最終得到的 dist陣列中存放的是圖上任意點到起點的最短距離

為什麼可以實現找到最短路?
因為這個演算法先是找到和起點直接相連的距離最短的點,在更新其他點到起點的距離的時候,都是利用已計算過和起點的最短距離的點作為中介點去比較,所以每次得到的必然都是最短距離。

void Dijkstra(int u)
{
    int min;//表示某一點到起點的最短距離
    int k;// k 記錄為訪問的點中距離起點最近的點
    for(int i=1; i<=n; ++i)
    {
        flag[i]=0;// flag 用於標記節點是否已經訪問過;
        //dist 陣列,儲存圖上各點到起點的距離
        dist[i]= edge[u][i]; // edge 存放的是兩個節點之間的距離,若兩個點之間沒有邊,則賦值為 INF(極大的數)
        pre[i] = 0; //pre 陣列用來儲存 最短路徑中節點 i 的上一個節點是哪一個
    }
    flag[u]=1;//標記起點,沒必要訪問
    dist[u]=0;
    //開始Dijkstra演算法,遍歷 n-1次,即遍歷除起點外其他所有的點
    for(int i=1; i<n; ++i)
    {
        //尋找離起點最近的點
        min = INF;
        for(int j=1; j<=n; ++j)
            if(!flag[j]&&min>dist[j])
            {
                min=dist[j];
                k = j;
            }
        //相應的點後,更新圖上其他點到起點的路徑距離
        flag[k]=1;
        for(int j=1; j<=n; ++j)
            if(edge[k][j]!=INF)// k 和 j 之間存在邊時
            {
                int temp = edge[k][j]+dis[k];  //i 到 j 的新路徑
                if(!flag[j]&&temp<dis[j])  //如果該路徑距離小於原本 j到i 的距離,重新整理替換
                {
                    dis[j]=temp;
                    pre[j]=k;
                }
            }
    }
}

此處補充四個小概念:(N表示節點數,M表示邊數)
①稀疏圖:M遠比N²小的圖稱為稀疏圖
②稠密圖:M相對較大的圖稱為稠密圖
③大頂堆:堆頂元素最大的堆
④小頂堆:堆頂元素最小的堆

紫書上對該演算法進行了優化並且封裝,其中涉及優先佇列的知識,基本思想一致,用優先佇列只是進行了優化,使得即使是稠密圖,使用該優化演算法的速度也比用鄰接矩陣的演算法快。因為執行 push存在前提。
(用優先佇列,是因為演算法中d[i]越小,越應該先出隊(每次先取和起點距離最近的點))
下面給出紫書把此演算法封裝成結構體的程式碼
連結所指大佬提供了迪傑斯特拉演算法堆優化的另一種思路:不需要定義結構體過載 <,而是直接對距離取相反數


//把整個演算法以及所用到的資料結構封裝到一個結構體中
struct Dijkstra
{
    struct Edge
    {
        int from,to,dist;
        Edge(int u,int v,int d):from(u),to(v),dist(d) {}
    }; //存放邊的結構體
    int n,m;
    vector<Edge> edges;
    vector<int> G[MAXN];
    bool done[MAXN];// 標記是否訪問過
    int d[MAXN];//起點到各個點的距離
    int p[MAXN];// 記錄最短路徑

    void inti(int n)
    {
        this->n=n; //賦值節點數
        //清空操作,是為了可以重複使用此結構體
        for(int i=1; i<=n; ++i)
            G[i].clear();
        edges.clear();
    }

    void AddEdge(int f,int t,int d)
    {
        edges.push_back(Edge(f,t,d));
        m = edges.size(); //每次新增邊後都把邊數賦值給 m 最後得到總邊數
        G[f].push_back(m-1);  //m-1是儲存了 邊在edges中的編號(下標),可以通過此編號訪問邊
    }
    //定義一個結構體作為優先佇列中的元素型別 

    struct HeapNode{
        int d,u; //d 是距離, u 是節點
        //表示優先順序為從小到大 
        bool operator<(const HeapNode& a)const
        {
            return d>a.d; //小堆頂 
            //個人理解,佇列的每次push操作都要根據優先順序判斷再放置隊頂元素,
            //即是否 隊頂<新入隊元素的操作(STL預設操作是大根堆)
            //過載<運算子後使得比較結果顛倒,優先順序變成從小到大
        }
    };
    //主演算法 
    void dijkstra(int u)
    {
        priority_queue<HeapNode> q; //建立一個優先佇列 
        for(int i=1;i<=n;++i)
            d[i] = INF;
        d[u] = 0;//起點到起點的距離當然為0 
        memset(done,0,sizeof(done));
        HeapNode v(0,u); //起點 
        q.push(v);
        while(!q.empty())
        {
            v=q.top();
            q.pop();
            if(done[v.u]) //已經訪問過的點不再訪問 
                continue;
            done[v.u] = true;

            //可以省去done陣列,改為 if(v.d!=d[v.u]) 防止節點重複擴充套件 

            //思路同,只是實現方式改變 
            for(int i=0;i<G[v.u].size();++i)  //這種情況下每條邊只被訪問了一次 
            {
                Edge& e = edges[G[v.u][i]];
                if(d[e.to]>d[v.u]+e.dist)
                {
                    d[e.to]=d[v.u]+e.dist;
                    p[e.to]=v.u;
                    q.push((HeapNode){d[e.to],e.to});
                }
            }
        }
    }
};

注意:迪傑斯特拉演算法不可以求含有負權邊的最短路!
具體原因等我搞明白了再寫一篇部落格。

四、Bellman-Ford(貝爾曼 - 福特)演算法(單源最短路徑,可以求解含負權邊的圖)
思想:(節點數N,邊數M)進行**至多**N-1次迴圈,每一次都更新圖上任意點到起點的距離(鬆弛操作)。其實個人覺得類似Dijkstra演算法,對任意點到起點的距離進行更新,只是每一次都遍歷所有邊去更新。至多N-1次,是考慮了最壞的情況,即每一次遍歷所有邊,只有和起點相連的邊得到更新 。

核心程式碼:

// u[i]和v[i] 分別存放第 i 條邊的前後頂點,w[i]存放權值  
bool BellmanFord()
{
    for(int i=1;i<=n;++i)
        d[i]=(i==1?0:INF);
    //至多遍歷  n-1 次
    //考慮最壞的情況,即每一次遍歷所有邊,只有和起點相連的邊得到更新 
    for(int i=1;i<n;++i)
        for(int j=1;j<=m;++j)
        {
            int x=u[i],y=v[i];
            if(d[x]<INF)
                d[y]=min(d[y],d[x]+w[i]);
        }
    //判斷是否存在 含有負權的環 
    for(int i=1;i<=m;++i)
        if(d[v[i]]>d[u[i]]+w[i])
            return false;
    return true;
}

此演算法的缺點是效率不高。因為要進行N-1次迴圈,但可能在第一次就已經得到答案了,亦即是做了很多無用的工作。
下面是對該演算法的改進,一般稱為SPFA演算法。
思想:在Bellman-Ford演算法的基礎上,使用佇列儲存待鬆弛的點,對每個出隊的點,遍歷跟它相鄰的點,如果這兩點間的邊能夠鬆弛且相鄰點不在佇列中,則把相鄰點存入佇列。(因為鬆弛操作可能對該相鄰點的相鄰點產生影響)

核心程式碼:

void SPFA()
{

    vector<int> d(n+1,INF); //距離
    vector<int> cnt(n+1,0); //記錄每個點遍歷的次數
    vector<int> inq(n+1,0); //記錄節點是否在佇列內
    d[s]=0;
    cnt[s]=inq[s]=1;
    deque<int> q(1,s);
    while(!q.empty())
    {
        int u=q.front(),i=0,to,dist;
        inq[u] = 0; //出隊
        q.pop_front();
        for(; i<g[u].size(); ++i)
        {
            to=g[u][i].to;//g[u][i].to表示u-i邊箭頭端(末端)的點
            dist=g[u][i].dist;
            if(d[to]>d[u]+dist)
            {
                d[to]=d[u]+dist; 
                if(!inq[to])  //如果點不在佇列內再入隊 
                {
                    //若有負權環,則會無限迴圈下去,所以肯定有一個點遍歷的次數大於 n 
                    if(n==++cnt[to])return 0;  //判斷是否為負權環
                    inq[to]=1;
                    //SLF優化:減少重複擴充套件的次數。
                    if(!q.empty()&&d[to]<q.front()) //確保佇列非空才能訪問q.front() 
                        q.push_front(to);
                    else
                        q.push_back(to);
                }
            }
        }
    }

其中運用了SLF優化(關於此優化,好像還沒有確切的證明,但實際運用上確實優化了不少,如果有讀者知曉麻煩評論告知一下,多謝)
SLF優化:判斷將要入隊的節點到源點的距離是否比隊首元素小,是的話插入隊首,否則插入隊尾。所以上面程式碼用了雙向佇列。這樣做可能會減少原隊首的某些鄰接點或者一些本來沒有SLF優化時需要入隊的點的入隊次數,即減少了擴充套件次數。但是存在反例使得使用了SLF優化反而使程式變慢。所以一般可以用,但不是絕對都能用。(我對此的理解還很淺薄)

學習、學好圖論的路,還很長…