最短路演算法之:Dijkstra 演算法

Weekoder發表於2024-06-09

最短路系列:Dijkstra 演算法

大家好,我是Weekoder!

最短路系列的第二期:Dijkstra 他來啦!

那麼廢話不多說,讓我們直奔主題吧。

Dijkstra 演算法的用處

與 floyd 演算法不同的,Dijkstra 演算法用於求解單源最短路徑。顧名思義,單源最短路徑就是起點唯一,終點有多個的最短路演算法。

Dijkstra 的思想是貪心,而這也將在演算法的實現步驟中體現出來。

DIjkstra 演算法的思想

Dijkstra 演算法是以點為研究物件的最短路演算法。我們把圖中的點分為兩種:黑點和白點。黑點是已經找到最短路的點,白點則相反。一開始所有點都是白點,我們需要將這些點全部染成黑點。用一個 \(vis\) 陣列標記,\(vis_i=\text{True}\) 代表 \(i\) 點是黑點,反之則是白點。

我們有一個 \(dis\) 陣列,\(dis_i\) 代表從源點到點 \(i\) 的最短路。此時顯然有:

\[dis_s=0 \]

\(s\) 為起點。

而其餘的則設為 INF(\(\infty\))。又有:

\[dis_i=+\infty(i\ne s) \]

我們每次在 \(dis\) 中選取一個最小值並染成黑色,此時這個點的最短路就是確定的了。既然這個點的最短路是確定的,我們就可以利用這個點來鬆弛其他點。即對於一條邊 \(cur\to nxt\),有:

\[dis_{nxt}=\min(dis_{nxt},dis_{cur}+w),(cur,nxt)\in E \]

如此往復,直到所有點染成黑色。

Dijkstra 演算法的正確性

剛才說到 Dijkstra 是將所有點染成黑色,每次找 \(dis\) 中最小的值,這就是一個貪心。那這個貪心是正確的嗎?為什麼最小的 \(dis\) 的最短路就是確定了的呢?因為如果 \(dis_i\) 是最小的,那麼就不可能有更小的方案從其他地方過來。比如現在有 \(dis_1,dis_2,dis_3\),其中 \(dis_1<dis_2<dis_3\),即 \(dis_1\) 最小,就必然有 \(dis_2+dis_3>dis_1\)。這個時候,\(dis_1\) 是怎麼也無法更新的。但如果權值為負,這個貪心就不成立了。比如有兩條負邊權 \(-2\)
\(-3\),哪怕對於一個最小的 \(-4\),也可能有 \((-2)+(-3)\le-5\)。還有,Dijkstra 無法跑最長路,因為最長路無法用 Dijkstra 的邏輯實現。

Dijkstra 的實現

根據我們上面的邏輯寫即可。這份程式碼僅能透過P3371

\(\text{Code:}\)

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int N = 1e4 + 5;

struct Edge {
	int to, dis;
};

ll n, m, s, dis[N];
bool vis[N];

vector<Edge> nbr[N];

void dijkstra() {
	memset(dis, 0x3f, sizeof dis);
	dis[s] = 0;
	for (int i = 1; i <= n; i++) {
		ll minx = 1e18, x;
		for (int j = 1; j <= n; j++) 
			if (!vis[j] && dis[j] < minx)
				minx = dis[j], x = j;
		vis[x] = 1;
		for (Edge nxt : nbr[x]) {
			ll to = nxt.to, w = nxt.dis;
			dis[to] = min(dis[to], dis[x] + w);
		}
	}	
}

int main() {
	cin >> n >> m >> s;
	for (int i = 1; i <= m; i++) {
		int u, v, w;
		cin >> u >> v >> w;
		nbr[u].emplace_back((Edge){v, w});
	}
	dijkstra();
	for (int i = 1; i <= n; i++)
		cout << (dis[i] == 0x3f3f3f3f3f3f3f3f ? (ll)pow(2, 31) - 1 : dis[i]) << " ";
	return 0;
}

容易發現,Dijkstra 演算法的時間複雜度是 \(\Theta(n\cdot(n+m))\)。在 \(m\) 達到上限 \(n^2\) 的時候,演算法的時間複雜度約為 \(\Theta(n^3)\),還不如 floyd。當然,在絕大多數情況下,樸素 Dijkstra 演算法的時間複雜度都大約為 \(\Theta(n^2)\)

Dijkstra 演算法的最佳化

考慮最佳化 Dijkstra 的時間複雜度。對於第一層大迴圈的 \(\Theta(n)\),是一個個給點染色,無法最佳化。而對於鬆弛邊的 \(\Theta(m)\),也是必要的,依然無法最佳化。那麼就只能從尋找 \(dis\) 陣列的最小值 \(\Theta(n)\) 的複雜度入手。學過二叉堆的都知道有個東西叫優先佇列,可以以 \(\Theta(\log n)\) 的複雜度快速維護序列的最值。那我們也可以藉助優先佇列維護 \(dis\) 的最小值,於是程式碼的時間複雜度降為 \(\Theta(n\cdot(\log n+m))\approx\Theta(n\log n)\)

\(\text{Optimal Code:}\)

#include <bits/stdc++.h>
using namespace std;

const int N = 1e5 + 5;

struct Edge {
    int to, dis;
};
struct node {
    int y, w;
    bool operator<(const node &x)const {
        return w > x.w;
    }
};

int n, m, dis[N];
bool vis[N];

vector<Edge> nbr[N];
priority_queue<node> q;

void dijkstra(int s) {
    memset(vis, 0, sizeof vis);
    memset(dis, 0x3f, sizeof dis);
    dis[s] = 0;
    q.push((node){s, 0});
    while (!q.empty()) {
        node now = q.top();
        q.pop();
        int cur = now.y;
        if (vis[cur]) continue;
        vis[cur] = 1;
        for (auto nxt : nbr[cur]) {
            int to = nxt.to, w = nxt.dis;
            if (dis[to] > dis[cur] + w) {
                dis[to] = dis[cur] + w;
                q.push((node){to, dis[to]});
            }
        }
    }
}

int main() {
    int s;
    cin >> n >> m >> s;
    for (int i = 1; i <= m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        nbr[u].emplace_back((Edge){v, w});
    }
    dijkstra(s);
    for (int i = 1; i <= n; i++)
        cout << dis[i] << " ";
    return 0;
}

\(\text{Optimal}\) 意為最佳化)

這份最佳化後的程式碼可以透過P4779

小結

關於單源最短路徑 Dijkstra 演算法的介紹就到這裡。Dijkstra 演算法的時間複雜度其實相當優秀,是最短路演算法的首選。在無權圖中,優先選擇 BFS,而在正權圖中,優先選擇 Dijkstra。而遇到負權圖,我們又該怎麼辦呢?讓我們一起期待下一次的最短路演算法!

再見!

相關文章