演算法基礎第三章搜尋與圖論

紅葉~發表於2021-12-26

圖演算法(陣列版)

1.1最短路徑Dijkstra演算法:

  • 假設頂點是\(V_0到V_5\) 六個點,開始時候是沒有連線的,但是已知能互相到達的頂點之間的邊權。
  • 步驟是每次從頂點0開始查詢,找出距離頂點最短的點,然後標記該點為true,再查詢該點能直達的其他點加上邊權會不會比原先記錄的距離值小--->即更新最短距離;遍歷完了所有從該點能到的點後再次回到起點。
#include<iostream>
#include<algorithm>
using namespace std;
const int MAXV=1000;
const int INF = 0x3f3f3f3f;//很大的數

int n,m,s,G[MAXV][MAXV];//n為頂點數量,m為邊數,s為起點
int d[MAXV];//起點到各點的最短路徑長度
bool vis[MAXV]={false}; //標記訪問陣列 false為沒有訪問,true 為訪問過
/*本題輸入:
6 8 0 //6個頂點  8條邊  起點為0號
0 1 1 從0點到1點距離為1
0 3 4
0 4 4
1 3 2
2 5 1
3 2 2
3 4 3
4 5 3
*/
void Dijkstra(int s){
    fill(d,d+MAXV,INF); 
    d[s]=0; //初始化操作
    for(int i=0;i<n;i++){//每次更新完都要回到起點,迴圈n次
        int u=-1,MIN=INF; //比較下面,u使得d[u]最小,MIN存放該最小的d[u]
        for(int j=0;j<n-1;j++){
            if(vis[j]==false && d[j]<MIN){
                u = j;
                MIN = d[j];
            }
        }
        if(u == -1) return;//剩下的頂點和起點s不通
        vis[u]= true;//找出距離起點最短的點 u
        for(int v=0;v<n;v++){//從 u 開始走,更新最短距離
            if(vis[v]==false && G[u][v]!=INF && d[u]+G[u][v]<d[v]){//G[u][v]是從u到v頂點的直通距離
                d[v]=d[u]+G[u][v];
            }
        }
    }
}
int main(){
    int u,v,w;
    cin>>n>>m>>s;
    fill(G[0],G[0]+MAXV*MAXV,INF);
    for(int i=0;i<m;i++){
        cin>>u>>v>>w;
        G[u][v]=w;
    }
    Dijkstra(s); // s
    for(int i=0;i<n;i++){
        cout<<d[i];//輸出結果最短路徑
    }
    return 0;
}

1.2基本模板

//初始化
for(迴圈n次){
    u = 使得d[u]最小且還未被訪問的頂點的標號;
    記u已被訪問;
    for(從u出發能到達的所有頂點v){
        if(v未被訪問 && 以u為中介點使s到頂點v 的最短距離d[v]更優){
            優化d[v];
       }

2.1圖的儲存

樹與圖的儲存

樹是一種特殊的圖,與圖的儲存方式相同。
對於無向圖中的邊ab,儲存兩條有向邊a->b, b->a。
因此我們可以只考慮有向圖的儲存。

(1) 鄰接矩陣:g[a][b] 儲存邊a->b

(2) 鄰接表:

// 對於每個點k,開一個單連結串列,儲存k所有可以走到的點。h[k]儲存這個單連結串列的頭結點

int h[N], e[N], ne[N], idx;
// 對於每個點k,開一個單連結串列,儲存k所有可以走到的點。h[k]儲存這個單連結串列的頭結點

// 新增一條邊a->b
void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}

// 初始化
idx = 0;
memset(h, -1, sizeof h);

當這個圖是n個點 m條邊的無向圖時,那麼m可能和 n的2倍差不多:

這時, 不妨設M = 2 * N

h[N] e[M]、ne[M]

2.2樹與圖的遍歷

時間複雜度O(n+m),n表示點數,m表示邊數

(1)深度優先遍歷

int dfs(int u)
{
    st[u] = true;
    for(int i = h[u];i!= - 1;i = ne[i])
    {
        int j = e[i];
        if(!st[j])	dfs(j);
    }
}

(2)寬度優先遍歷

queue<int> q;
st[1] = true;
q.push(1);
while(q.size())
{
    int t = q.front();
    q.pop();
    for(int i = h[t];h!=-1;i = ne[i])
    {
        int j = e[i];
        if(!st[j])
        {
            st[j] = true;
            q.push(j);
        }
    }
}

最短路演算法

image-20211212160526024

3.dijkstra演算法

  • 樸素版dijkstra演算法適合稠密圖,用鄰接矩陣儲存
  • 堆優化版適合稀疏圖,用鄰接表儲存 點的範圍較大--稀疏圖

3.1樸素\(dijkstra\)演算法

時間複雜是 \(O(n^2+m)\), n 表示點數,m 表示邊數

1、當到一個時間點時,圖上部分的點的最短距離已確定,部分點的最短距離未確定。

2、選一個所有未確定點中離源點最近的點,把他認為成最短距離。

3、再把這個點所有出邊遍歷一邊,更新所有的點。

演算法設計:貪心

int g[N][N];  // 儲存每條邊
int dist[N];  // 儲存1號點到每個點的最短距離
bool st[N];   // 儲存每個點的最短路是否已經確定

// 求1號點到n號點的最短路,如果不存在則返回-1
// 點的座標從1~n
int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0; // 求得是1號點到其他點的距離,就標記dist[1] = 0

    // 遍歷其他點
    for (int i = 0; i < n - 1; i ++ ) { // 只是迴圈n-1次沒有其他意義
        int t = -1;     
        // 在還未用來更新最短路的點中,尋找距離最小的點
        for (int j = 1; j <= n; j ++ )
            if (!st[j] && (t == -1 || dist[t] > dist[j])) // t==-1用來確定剩餘未用來更新的第一個點
                t = j;

        // 用t更新其他點的距離
        for (int j = 1; j <= n; j ++ )
            // if(!st[j]) 可寫可不寫
            dist[j] = min(dist[j], dist[t] + g[t][j]);

        st[t] = true;
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

st[]更確切的含義是某個點是否已經更新過其他點st最短路確定而不是它的最短距離是否已經確定。所以更新其他點的距離時,前面更新過的點也要更新 其實也可以加上if(!st[j])

3.2堆優化dijkstra演算法

思路:

集合S中的點表示已經找到最短路徑

堆優化版的dijkstra是對樸素版dijkstra進行了優化,在樸素版dijkstra中時間複雜度最高的尋找距離最短的點O(n^2)可以使用最小堆優化。
1. 一號點的距離初始化為零,其他點初始化成無窮大。
2. 將一號點放入堆中。
3. 不斷迴圈,直到堆空。每一次迴圈中執行的操作為:
    彈出堆頂(與樸素版diijkstra找到S外距離最短的點相同,並標記該點的最短路徑已經確定)。
    用該點更新臨界點的距離,若更新成功就加入到堆中。
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
typedef pair<int,int> PII;
const int N = 150010;

// 稀疏圖用鄰接矩陣來儲存
int h[N], e[N], ne[N], idx;
int w[N], dist[N];
bool st[N];
int n, m;

void add(int a, int b, int c) {
    e[idx] = b;
    ne[idx] = h[a];
    w[idx] = c; // 邊的權重,
    // 有重邊也不要緊,假設1->2有權重為2和3的邊,再遍歷到點1的時候2號點的距離會更新兩次放入堆中
    h[a] = idx++;
}

int dijkstra() {
    memset(dist, 0x3f, sizeof dist);
    priority_queue<PII, vector<PII>, greater<PII>> heap; 
    dist[1] = 0;
    heap.push({0,1}); // 把初始值放入小根堆裡 距離--點
    while(heap.size()) {
        PII k = heap.top(); // 取不在集合S中距離最短的點,集合S表示已經確定最短路的點的集合
        heap.pop();
        int v = k.second, distance = k.first;
        
        if(st[v])   continue;
        st[v] = true;
        
        // 以 v 為出點,遍歷v的鄰接點
        for(int i = h[v]; i != -1; i = ne[i]) {
            int j = e[i];
            if(dist[j] > distance + w[i]) {
                dist[j] = distance + w[i];
                heap.push({dist[j], j}); 
            }
        }
    }
    if(dist[n] == 0x3f3f3f3f)   return -1;
    return dist[n];
}

int main() {
    memset(h, -1, sizeof h);
    scanf("%d%d", &n,&m);
    while(m--) {
        int x,y,c;
        scanf("%d%d%d", &x,&y,&c);
        add(x,y,c);
    }
    cout << dijkstra() << endl;
    return 0;
}

一些問題:

  • 在更新dist時,有可能兩個點重複進堆,有的點它既是a的鄰接點又是b的鄰接點,這樣的點可能會在更新a的鄰接點時加進一次,右在更新b的鄰接點的時再進入一次,這樣佇列中就有兩個一樣的點,雖然距離不同,所以用st 陣列對已經找出最短路徑的點進行標記,避免重複計算

4.\(Bellman-Ford\)演算法

題目:有邊數限制的最短路a

單源最短路徑演算法

對於帶權有向圖 G = (V, E),Dijkstra 演算法要求圖 G 中邊的權值均為非負,而 Bellman-Ford 演算法能適應一般的情況(即存在負權邊的情況)。一個實現的很好的 Dijkstra 演算法比 Bellman-Ford 演算法的執行時間要低。

設計:動態規劃

時間複雜度:\(O(V*E)\) 頂點數 邊數, \(n頂點數,m邊數\)

理解:對所有邊進行\(n-1\)次鬆弛操作,因為在一個含有n個頂點的圖中,任意兩點之間的最短路徑最多包含n-1條邊

換句話說,第1輪在所有邊進行鬆弛後,得到的是源點最多經過1條邊到達其他頂點的最短距離;

第2輪在對所有的邊進行鬆弛後,得到的是源點最多經過2條邊到達其他頂點的最短距離;

演算法描述:

1、\(dist[N]\)陣列表示源頂點到所有頂點的距離,初始化為\(infinte\),\(dist[1][1]=0\),

2、計算最短路徑,執行\(V-1\)次遍歷

對於圖中的每條邊:如果起點到u的距離d加上權值w小於到終點v的距離,更新終點v的距離值d

\(if(dist[b]>dist[a]+w) dist[b]=dist[a]+w\)

例如以下加上一個拷貝陣列就可以求最多經過k條邊的最短距離

int n, m;       // n表示點數,m表示邊數
int dist[N];        // dist[x]儲存1到x的最短路距離
int backup[N]; // 拷貝陣列,這樣就保證輪數與邊數一致
struct Edge     // 邊,a表示出點,b表示入點,w表示邊的權重
{
    int a, b, w;
}edges[M];

// 求1到n的最短路距離,如果無法從1走到n,則返回-1。
int bellman_ford()
{
    memset(dist, 0x3f, sizeof dist);
    // 初始時,1號點到其他點的距離為inf
    dist[1] = 0;
	
    // 如果第n次迭代仍然會鬆弛三角不等式,就說明存在一條長度是n+1的最短路徑,由抽屜原理,路徑中至少存在兩個相同的點,說明圖中存在負權迴路。
    for (int i = 0; i < n; i ++ )
    {
        memcpy(backup,dist,sizeof dist); // 拷貝陣列,因為更新其他點時候會影響其他點的更新資訊
        for (int j = 0; j < m; j ++ ) // 遍歷每條邊
        {
            int a = edges[j].a, b = edges[j].b, w = edges[j].w;
            if (dist[b] > backup[a] + w)
                dist[b] = backup[a] + w;
        }
    }

    if (dist[n] > 0x3f3f3f3f / 2) return -1;
    return dist[n];
}

判斷是否有負權環,再對邊進行一次額外的遍歷,如果還能更新說明仍然存在一條邊使得兩點距離更短,事實上再更新多次還是有更新的情況。

image-20211128150729768

注意:

  • 如果不限制邊數,直接求最短路,不需要拷貝陣列

  • 如果限制邊數,則需要拷貝陣列

  • 為什麼是> 0x3f3f3f3f / 2 (主要還是因為每條邊都遍歷了,遍歷了很多無用的邊)

  • image-20211212160728504

5、\(SPFA\)演算法

題目: spafa判斷負環

spfa求最短路

https://blog.csdn.net/qq_35644234/article/details/61614581 這篇部落格給出了過程

https://www.cnblogs.com/acioi/p/11694294.html spfa求負環的解釋

時間複雜度 平均情況下 \(O(m)\),最壞情況下 \(O(nm)\), n 表示點數,m 表示邊數

\(SPFA演算法\)是對上面的\(bellmanford\)演算法的佇列優化

演算法描述:首先建立一個佇列,初始佇列裡只有起始點,建立一個表格記錄起始點到所有點的最短路徑(初始值賦為極大值),然後進行鬆弛操作,依次用佇列中的點去重新整理起始點到所有點的最短路,如果重新整理成功且被重新整理點不在佇列中則把其加入到佇列中。

求負環:如果某個點進入佇列的次數超過N次則存在負環(N為圖的頂點數)

最優解法:用一個cnt[i] 陣列記錄當前到 到 i 點的最短路徑上經過的點的數量,如果 出現cnt[i] > n說明出現了負環。也可統計邊數,當邊數 >= n時也是出現了負環。

st陣列的作用只是記錄當前有哪些點在佇列中

int n; // 總點數
int h[N],w[N],e[N],ne[N],idx; // 鄰接表儲存所有邊
int dist[N];
bool st[N];// 儲存每個點是否在佇列中
// 求1號點到n號點的最短路距離,如果從1號點無法走到n號點則返回-1
int spfa()
{
    memset(dist,0x3f,sizeof dist);
    dist[1] = 0;
    queue<int> q;
    q.push(1);
    st[1] = true;
    // 取出佇列中的一個元素來更新距離
    while(q.size())
    {
        auto t = q.front();
        q.pop();
        
        st[t] = false; // 先彈出佇列標記為false,因為後面可能還會有更新
        for(int i = h[t];i != -1;i = ne[i])
        {
            int j = e[i];
            if(dist[j] > dist[t]+w[i])
            {
                 // 先更新最短距離 
                dist[j] = dist[t] + w[i];
                // 如果被更新的點不在佇列中,就要加入,因為後面需要用到其最短值
                if(!st[j])
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }
     if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

5、\(Floyd\)演算法

\(Floyd\)演算法屬於暴力求解,時間複雜度\(O(n^3)\),\(n\)表示點數

// 初始化
	for(int i = 1;i <= n;i++)
        for(int j = 1;j <= n;j++)
        {
            d[i][j] = (i == j ? 0 : INF);
        }

// 演算法結束後,d[a][b]表示a到b的最短距離
void floyd()
{
    for (int k = 1; k <= n; k ++ ) // z
        for (int i = 1; i <= n; i ++ )
            for (int j = 1; j <= n; j ++ )
                d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
if(g[l][r] > inf / 2)   cout << "impossible" << endl;
        else cout << g[l][r] << endl; 
  • 判斷無最短路徑的方法是> inf / 2, 原因:加入求1~6個點的距離,6是終點,

d[1][5] = 0x3f,1到5不可達,此時 d[5][6] = -4, d[1][6] = d[1][5] + d[5][6] !=0x3f但是大於0x3f/2。此時1到6是不可達的。

6、有向無環圖的拓撲序列

題目有向無環圖的拓撲序列

在圖論中,拓撲排序是一個有向無環圖的所有頂點的線性序列:

1、每個頂點出現一次

2、若存在一條從A到B的路徑,那麼在序列中頂點A在B的前面。

一個有向無環圖一定至少存在一個入度為0的點

如何求拓撲序列?

  • 拓撲序列中,所有的邊都是從前往後的,因此入度為0的點都可以作為起點,將所有入度為0的點入隊,因為前面沒有點指向它,它只能指向後面的點
  • 入隊之後,將它指向的終點的入度減去1

入度為0的點入隊

queue<int> q;
for(int i = 1;i <= n;i++) {
    if(!d[i])	q.push(i);
}

遍歷t的所有出邊

for(int i = h[t]; i!=-1;i = ne[i]) {
    int j = e[i];
}

完整模板

bool f() {
    int q[N], hh = 0, tt = -1;
    for(int i = 1; i < n;i++) {
        if(!d[i])  q[++tt] = i; // 入隊
    }
    while(hh <= tt) {
        int t = q[hh++];
        // 遍歷t的終點
        for(int i = h[t]; i != -1; i = ne[i]) {
            int j = e[i];
            d[j]--;
            if(!d[j])   q[++tt] = j;
        }
    }
    return tt == n - 1; // 是否所有點都入隊了,否則表示圖中有環
}

相關文章