Thuật toán Dijkstra

“What is the shortest way to travel from Rotterdam to Groningen. It is the algorithm for the shortest path, which I designed in about 20 minutes. One morning I was shopping in Amsterdam with my young fiancée, and tired, we sat down on the café terrace to drink a cup of coffee and I was just thinking about whether I could do this, and I then designed the algorithm for the shortest path. As I said, it was a 20-minute invention. In fact, it was published in 1959, three years later. The publication is still quite nice. One of the reasons that it is so nice was that I designed it without pencil and paper. […] Eventually, that algorithm became to my great amazement, one of the cornerstones of my fame.”

- Thomas J. Misa, An interview with Edsger W. Dijkstra

Comment: Mê cái cách ông ấy nhấn mạnh việc thiết kế một thuật toán chỉ trong vòng \(20\) phút. Tận 2 LẦN!!!

Thuật toán Dijkstra là thuật toán tham lam tìm đường đi ngắn nhất từ một đỉnh trên đồ thị.

Thuật toán

Ban đầu, thuật toán sẽ gán khoảng cách đến đỉnh bắt đầu \(s\) là \(0\), và \(\infty\) cho khoảng cách đến các đỉnh còn lại. Ở mỗi bước, thuật toán sẽ tìm đỉnh có khoảng cách ngắn nhất chưa được xét đến trên đồ thị, và cập nhật khoảng cách của các đỉnh kề cạnh với nó.

Ta sẽ chạy thuật toán Dijkstra trên đồ thị, với đỉnh bắt đầu \(s = 1\):

Đồ thị

Ta thấy, đỉnh \(1\) là đỉnh chưa được xét đến có khoảng cách nhỏ nhất là \(0\). Từ đỉnh \(1\), ta sẽ cập nhật khoảng cách của các đỉnh \(2\), \(4\) kề với nó.

Lần cập nhật đầu tiên

Tiếp theo, đỉnh \(2\) là đỉnh chưa được xét đến có khoảng cách nhỏ nhất là \(2\). Từ đỉnh \(2\), ta sẽ cập nhật khoảng cách của các đỉnh \(3\), \(4\) kề với nó.

Lần cập nhật thứ hai

Ta tiếp tục cho tới khi tất cả các đỉnh đã được xét.

Kết thúc thuật toán

Kết thúc thuật toán, ta tính được khoảng cách ngắn nhất từ đỉnh \(1\) đến các đỉnh còn lại.

Đỉnh\(1\)\(2\)\(3\)\(4\)\(5\)\(6\)
Khoảng cách\(0\)\(2\)\(8\)\(4\)\(7\)\(9\)

Để cài đặt thuật toán, ta sử dụng set trong việc tìm đỉnh chưa xét có khoảng cách ngắn nhất.

const int INF = 1e9; // vô cực
vector<pair<int, int>> adj[N]; // danh sách cạnh lưu đồ thị có trọng số 
int dist[N], p[N];
bitset<N> vst;
int n; 

void dijkstra(int s){
	for(int i = 1; i <= n; ++i)	dist[i] = INF;
	dist[s] = 0;
	set<pair<int, int>> pq;
	for(int i = 1; i <= n; ++i){
		pq.insert({dist[i], i});
	}
	int v, w;

	while(pq.size()){
		int u = (*pq.begin()).second;
		pq.erase(pq.begin());
		vst[u] = 1;

		for(auto it : adj[u]){
			tie(v, w) = it;
			if(vst[v]) continue;

			if(dist[u] + w < dist[v]){			
				pq.erase({dist[v], v});
				dist[v] = dist[u] + w;
				pq.insert({dist[v], v});
				p[v] = u;
			}
		}
	}
}

Độ phức tạp thuật toán: \(O((|V| + |E|) \log {|V|})\). Nếu đồ thị liên thông, độ phức tạp có thể viết gọn thành \(O(|E|\log{|V|})\).

Dijkstra + priority_queue

Một cách cài đặt Dijkstra phổ biến khác sử dụng priority_queue để tìm đỉnh chưa xét có khoảng cách ngắn nhất. priority_queue sẽ xét thứ tự ưu tiên cho các cặp giá trị \(\{u_{dist}, u\}\) theo \(u_{dist}\) giảm dần, và nếu các \(u_{dist}\) bằng nhau thì theo \(u\) giảm dần.

Không giống set, ta không thể xóa một phần tử bất kì trong priority_queue. Vì thế, ta sẽ áp dụng phương pháp “xóa lười”. Giả sử ta thành công cập nhật \(v_{dist}\), ta sẽ thêm vào pq một cặp \(\{v_{dist}, v\}\) mới, có \(v_{dist}\) nhỏ hơn so với cặp \(\{v_{dist}, v\}\) cũ ở trong pq. Giả sử khi ta xét đến cặp \(\{v_{dist}, v\}\) cũ trong pq, ta có thể bỏ qua cặp giá trị này.

void dijkstra(int s){
	for(int i = 1; i <= n; ++i)	dist[i] = INF;
	dist[s] = 0;
	priority_queue<pair<int, int>, 
				   vector<pair<int, int>>, 
				   greater<pair<int, int>>> pq;

	pq.push({0, s});
	int u, d;
	int v, w;
	while(pq.size()){
		tie(d, u) = pq.top(); pq.pop();
		if(d > dist[u]) continue; // xóa lười
		if(vst[u]) continue;

		for(auto it : adj[u]){
			tie(v, w) = it;
			if(vst[v]) continue;
			if(dist[u] + w < dist[v]){
				dist[v] = dist[u] + w;
				pq.push({dist[v], v});
				p[v] = u;
			}
		}
	}
}

Độ phức tạp thuật toán tương tư cách cài đặt sử dụng set: \(O((|V| + |E|) \log {|E|})\). Nếu đồ thị liên thông, độ phức tạp có thể viết gọn thành \(O(|E|\log{|E|})\).

Dijkstra trên đồ thị có trọng số âm

Thuật toán Dijkstra không thể thực hiện việc tìm kiếm đường đi ngắn nhất trên đồ thị có trọng số âm.

Ta ví dụ với đồ thị sau:

Dijkstra Chạy Sai

Dijkstra sẽ tính sai giá trị đường đi ngắn nhất từ đỉnh \(1\) đến hai đỉnh \(4\) và \(5\).

Ta có thể khắc phục vấn đề này bằng cách vẫn cho phép các đỉnh được ghé thăm cập nhật khoảng cách ngắn nhất của nó, kể cả khi đỉnh đã được xét. Nếu một đỉnh được xét được cập nhật đường đi ngắn nhất, ta sẽ xem nó như là một đỉnh chưa xét. Điều này sẽ giúp thuật toán của ta chạy được với cả các đồ thị có trọng số âm.

Dijkstra Chạy Đúng
// vst đã được loại bỏ
// 
// Có thể làm điều tương tự với đoạn code sử dụng set

void dijkstra(int s){
	for(int i = 1; i <= n; ++i)	dist[i] = INF;
	dist[s] = 0;
	priority_queue<pair<int, int>, 
				   vector<pair<int, int>>, 
				   greater<pair<int, int>>> pq;

	pq.push({0, s});
	int u, d;
	int v, w;
	while(pq.size()){
		tie(d, u) = pq.top(); pq.pop();
		if(d > dist[u]) continue; 

		for(auto it : adj[u]){
			tie(v, w) = it;
			if(dist[u] + w < dist[v]){
				dist[v] = dist[u] + w;
				pq.push({dist[v], v});
				p[v] = u;
			}
		}
	}
}

Độ phức tạp thuật toán của ta sẽ giữ nguyên bằng \(O((|V| + |E|) \log {|E|})\) nếu đồ thị không có trọng số âm.

Độ phức tạp thuật toán của ta sẽ lớn hơn \(O((|V| + |E|) \log {|E|})\) nếu đồ thị có trọng số âm do sẽ có nhiều đỉnh phải cập nhật lại giá trị.

Dijkstra trên đồ thị có chu trình âm

Nếu đồ thị có chu trình âm, tức là có một chu trình với khoảng cách âm, thì thuật toán Dijkstra được sửa đổi để có thể chạy trên đồ thị trọng số âm sẽ chạy trong một vòng lặp vô hạn khi thuật toán cố gắng xây dựng đường đi ngắn nhất bằng cách đi trên chu trình âm ấy vô hạn lần để cho ra kết quả nhỏ nhất.

Dijkstra Chu Trình Âm

Ở ví dụ trên, thuật toán vẫn chưa xét đỉnh \(5\) vì nó vẫn mải mê cập nhật đường đi ngắn nhất của các đỉnh còn lại.

Tìm con đường ngắn nhất

Nếu ta để ý thì sẽ thấy một mảng p bí ẩn ở trong các đoạn code. Mảng p này mang ý nghĩa: Để tìm được đường đi ngắn nhất từ \(s\) đến \(u\), ta cần tìm đường đi ngắn nhất từ \(s\) đến \(p[u]\), cộng thêm trọng số của cạnh \({u, p[u]}\).

Sử dụng thông tin này ta có thể tìm được các đỉnh của (một) đường đi ngắn nhất từ đỉnh \(s\) đên một đỉnh bất kì.

Hàm printpath dưới đây sẽ tìm một con đường ngắn nhất từ đỉnh \(s\) đến đỉnh \(u\) bằng cách gọi printpath(s, u).

void printpath(int s, int u){
	if(s != u) printpath(s, p[u]);
	cout << u << ' ';
}

BFS \(0/1\)

Ta biết được rằng BFS có thể giải được đường đi ngắn nhất trên đồ thị không trọng số. Nếu đồ thị có trọng số, ta cần phải sử dụng các thuật toán khác như Dijkstra. Thế nhưng nếu giá trị của các trọng số chỉ là \(0\) hoặc \(1\), thì đấy sẽ là một câu chuyện khác.

Giả sử ta chạy thuật toán Dijkstra trên một đồ thị có trọng số \(0/1\).

BFS 0/1

Thuật toán của ta sẽ có độ phức tạp thuật toán bằng \(O((|V| + |E|) \log {|V|})\).

Hãy nhìn vào các đỉnh trong pq trong quá trình chạy thuật toán:

\[pq = \underbrace{u, \dots, u}_{u_{dist}}, \underbrace{v, \dots, v}_{u_{dist} + 1}\]

Từ đây, ta có thể rút gọn việc thêm các cặp giá trị \(\{v_{dist}, v\}\) vào pq sau mỗi lần cập nhật như sau:

  • Nếu \(v_{dist} = u_{dist} + 1\), hay cạnh \(uv\) có trọng số là \(1\), thêm đỉnh \(v\) vào cuối pq.
  • Nếu \(v_{dist} = u_{dist} + 0\), hay cạnh \(uv\) có trọng số là \(0\), thêm đỉnh \(v\) vào đầu pq.

Ta có thể bỏ giá trị \(dist\) và chỉ lưu các đỉnh vào trong pq.

Vì cách thêm các đỉnh được thực hiện một cách đơn giản nên ta không cần một priority_queue hay set để lưu các đỉnh mà chỉ cần một CTDL đơn giản như deque.

Ta gọi thuật toán này là BFS \(0/1\).

void bfs01(int s){
	for(int i = 1; i <= n; ++i)	dist[i] = INF;
	dist[s] = 0;
	deque<int> dq;
	dq.push_back(s);

	int v, w;

	while(pq.size()){
		int u = dq.front(); dq.pop_front();
		for(auto it : adj[u]){
			tie(v, w) = it;
			if(dist[u] + w < dist[v]){
				dist[v] = dist[u] + 1;
				p[v] = u;
				if(w == 0) dq.push_front(v);
				else dq.push_back(v); 
			}
		}
	}
}

Độ phức tạp thuật toán của BFS \(0/1\) bằng với BFS thông thường: \(O(|V| + |E|)\) - loại bỏ được \(\log\) so với Dijkstra.