Bài 18.4. Tìm đường đi trên đồ thị dùng BFS và DFS

Bài 18.4. Tìm đường đi trên đồ thị dùng BFS và DFS
Chào mừng trở lại với series Cấu trúc dữ liệu và Giải thuật của chúng ta! Sau khi đã làm quen với cách biểu diễn đồ thị và hai giải thuật duyệt đồ thị cơ bản là BFS và DFS, hôm nay chúng ta sẽ áp dụng ngay những kiến thức quý báu đó để giải quyết một bài toán rất thực tế và quan trọng: Tìm đường đi giữa hai đỉnh bất kỳ trên đồ thị.
Việc tìm đường đi là nền tảng cho rất nhiều ứng dụng, từ tìm đường trên bản đồ (Google Maps chẳng hạn), phân tích mạng xã hội, cho đến các bài toán về game (tìm đường đi cho nhân vật) hay mạng máy tính (tìm đường đi của gói tin). Chúng ta sẽ khám phá cách mà BFS và DFS, với những đặc điểm duyệt khác nhau, có thể được điều chỉnh để không chỉ duyệt qua các đỉnh mà còn ghi lại được con đường đã đi qua.
Hãy cùng nhau đi sâu vào chi tiết nhé!
1. Tìm đường đi bằng Breadth-First Search (BFS)
Như chúng ta đã biết, BFS duyệt đồ thị theo từng lớp (layer) hoặc từng mức (level). Nó bắt đầu từ đỉnh nguồn, thăm tất cả các đỉnh kề với đỉnh nguồn, sau đó thăm tất cả các đỉnh kề với các đỉnh đã thăm ở mức trước đó, và cứ thế lan rộng ra. Chính đặc điểm "lan rộng đều" này mang lại một ưu điểm cực kỳ quan trọng khi tìm đường đi trên đồ thị không có trọng số: BFS luôn tìm thấy đường đi ngắn nhất (theo số lượng cạnh).
Để sử dụng BFS để tìm đường đi, chúng ta cần làm thêm một bước trong quá trình duyệt: ghi lại đỉnh "cha" (parent) của mỗi đỉnh khi chúng ta lần đầu tiên thăm nó. Đỉnh cha chính là đỉnh mà từ đó chúng ta đi đến đỉnh hiện tại. Khi tìm thấy đỉnh đích, chúng ta có thể tái tạo lại đường đi bằng cách đi ngược lại từ đỉnh đích theo các đỉnh cha cho đến khi về tới đỉnh nguồn.
Các bước thực hiện:
- Khởi tạo một hàng đợi (queue) để lưu trữ các đỉnh cần thăm.
- Khởi tạo một mảng/map
visited
để đánh dấu các đỉnh đã thăm, ban đầu tất cả làfalse
. - Khởi tạo một mảng/map
parent
để lưu đỉnh cha của mỗi đỉnh. Ban đầu, có thể đặt giá trị đặc biệt (ví dụ: -1) cho tất cả. - Đưa đỉnh nguồn (
start_node
) vào hàng đợi và đánh dấuvisited[start_node] = true
. Đặtparent[start_node] = -1
(hoặc một giá trị chỉ ra đây là đỉnh gốc). - Trong khi hàng đợi chưa rỗng:
- Lấy một đỉnh
u
ra khỏi hàng đợi. - Nếu
u
là đỉnh đích (end_node
), chúng ta đã tìm thấy đường đi. Dừng quá trình duyệt. - Với mỗi đỉnh kề
v
củau
:- Nếu
v
chưa được thăm (visited[v] == false
):- Đánh dấu
visited[v] = true
. - Đặt
parent[v] = u
. - Đưa
v
vào hàng đợi.
- Đánh dấu
- Nếu
- Lấy một đỉnh
- Sau khi quá trình BFS dừng (hoặc hàng đợi rỗng), nếu đỉnh đích đã được thăm (
visited[end_node] == true
), chúng ta tái tạo lại đường đi:- Bắt đầu từ đỉnh đích
current = end_node
. - Thêm
current
vào danh sách đường đi. - Di chuyển đến đỉnh cha của
current
:current = parent[current]
. - Lặp lại cho đến khi
current
là đỉnh nguồn (hoặcparent[current]
là -1). - Danh sách đường đi lúc này đang theo chiều ngược lại (từ đích về nguồn), cần đảo ngược lại để có đường đi từ nguồn đến đích.
- Bắt đầu từ đỉnh đích
- Nếu đỉnh đích không bao giờ được thăm, có nghĩa là không có đường đi từ nguồn đến đích.
Ví dụ C++ minh họa cho BFS tìm đường đi:
Giả sử chúng ta có đồ thị vô hướng sau:
0 -- 1 -- 3
| \ |
| \ |
4 -- 2 -- 5
Chúng ta muốn tìm đường đi từ đỉnh 0 đến đỉnh 5.
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm> // For std::reverse
using namespace std;
// Hàm tìm đường đi bằng BFS
vector<int> find_path_bfs(const vector<vector<int>>& adj, int start_node, int end_node) {
int num_vertices = adj.size();
vector<int> parent(num_vertices, -1); // Lưu đỉnh cha
vector<bool> visited(num_vertices, false); // Đánh dấu đỉnh đã thăm
queue<int> q; // Hàng đợi cho BFS
// Bắt đầu từ đỉnh nguồn
q.push(start_node);
visited[start_node] = true;
// parent[start_node] đã là -1 mặc định
bool found = false;
while (!q.empty()) {
int u = q.front();
q.pop();
// Nếu tìm thấy đỉnh đích
if (u == end_node) {
found = true;
break; // Dừng BFS ngay khi tìm thấy đích
}
// Duyệt qua các đỉnh kề
for (int v : adj[u]) {
if (!visited[v]) {
visited[v] = true;
parent[v] = u; // Lưu lại đỉnh cha
q.push(v);
}
}
}
// Tái tạo lại đường đi nếu tìm thấy
vector<int> path;
if (found) {
int current = end_node;
while (current != -1) {
path.push_back(current);
current = parent[current];
}
reverse(path.begin(), path.end()); // Đảo ngược để có đường đi từ nguồn đến đích
}
return path; // Trả về đường đi (rỗng nếu không tìm thấy)
}
int main() {
// Biểu diễn đồ thị bằng danh sách kề (adjacency list)
// Đồ thị vô hướng với 6 đỉnh (0 đến 5)
vector<vector<int>> adj(6);
adj[0].push_back(1);
adj[0].push_back(2);
adj[0].push_back(4);
adj[1].push_back(0);
adj[1].push_back(2);
adj[1].push_back(3);
adj[2].push_back(0);
adj[2].push_back(1);
adj[2].push_back(4);
adj[2].push_back(5);
adj[3].push_back(1);
adj[4].push_back(0);
adj[4].push_back(2);
adj[5].push_back(2);
int start = 0;
int end = 5;
cout << "Tim duong di tu " << start << " den " << end << " bang BFS:" << endl;
vector<int> path = find_path_bfs(adj, start, end);
if (!path.empty()) {
cout << "Duong di tim thay: ";
for (size_t i = 0; i < path.size(); ++i) {
cout << path[i] << (i == path.size() - 1 ? "" : " -> ");
}
cout << endl;
} else {
cout << "Khong tim thay duong di." << endl;
}
// Ví dụ khác: tìm đường đi không tồn tại
int start2 = 3;
int end2 = 5; // Giả sử đỉnh 3 và 5 không có đường đi (trong đồ thị này thì có, nhưng ví dụ cho trường hợp không có)
// Để minh họa trường hợp không có đường đi, hãy thử tìm từ 3 đến một đỉnh không liên thông, ví dụ 6 nếu đồ thị lớn hơn
// Trong đồ thị hiện tại, mọi đỉnh đều liên thông. Giả sử start=0, end=10 trong đồ thị 6 đỉnh.
cout << "\nTim duong di tu 0 den 10 (khong ton tai) bang BFS:" << endl;
vector<vector<int>> adj_small(6); // Dùng lại đồ thị cũ
// ... (copy adjacency list setup from above)
adj_small[0].push_back(1); adj_small[0].push_back(2); adj_small[0].push_back(4);
adj_small[1].push_back(0); adj_small[1].push_back(2); adj_small[1].push_back(3);
adj_small[2].push_back(0); adj_small[2].push_back(1); adj_small[2].push_back(4); adj_small[2].push_back(5);
adj_small[3].push_back(1);
adj_small[4].push_back(0); adj_small[4].push_back(2);
adj_small[5].push_back(2);
int start_nonexistent = 0;
int end_nonexistent = 10; // Đỉnh không tồn tại
// Cần kiểm tra đỉnh đích có hợp lệ không trước khi gọi hàm
if (end_nonexistent >= 0 && end_nonexistent < adj_small.size()) {
vector<int> path_nonexistent = find_path_bfs(adj_small, start_nonexistent, end_nonexistent);
if (!path_nonexistent.empty()) {
// Code này sẽ không chạy vì end_nonexistent > adj_small.size() - 1
} else {
cout << "Khong tim thay duong di." << endl;
}
} else {
cout << "Dinh dich khong hop le." << endl;
}
return 0;
}
Giải thích Code BFS:
- Chúng ta sử dụng
std::vector<std::vector<int>> adj
để biểu diễn đồ thị bằng danh sách kề. Mỗi phần tửadj[i]
là một vector chứa các đỉnh kề với đỉnhi
. parent
là mộtstd::vector<int>
cùng kích thước với số đỉnh, lưu đỉnh cha của mỗi đỉnh.parent[v] = u
nghĩa là chúng ta đi từu
đếnv
. Giá trị -1 cho đỉnh nguồn hoặc các đỉnh chưa được thăm từ bất kỳ đâu.visited
làstd::vector<bool>
để tránh việc thăm lại các đỉnh đã xử lý, giúp đảm bảo mỗi đỉnh được thêm vào hàng đợi và xử lý duy nhất một lần.q
làstd::queue<int>
, cấu trúc dữ liệu trung tâm của BFS, giúp chúng ta duyệt theo từng lớp.- Chúng ta bắt đầu bằng cách đưa đỉnh nguồn vào hàng đợi và đánh dấu đã thăm.
- Vòng lặp
while (!q.empty())
là trái tim của BFS. Chúng ta lấy đỉnhu
ra khỏi hàng đợi, kiểm tra xem nó có phải là đích không. - Sau đó, chúng ta duyệt qua tất cả các đỉnh kề
v
củau
. Nếuv
chưa được thăm, chúng ta đánh dấu nó đã thăm, đặt đỉnh cha củav
làu
(parent[v] = u
), và đưav
vào hàng đợi. Bước đặtparent[v] = u
này là quan trọng nhất để tái tạo đường đi. - Nếu
u
là đỉnh đích, chúng ta đặt cờfound = true
và dùngbreak
để dừng sớm BFS. - Sau vòng lặp BFS, nếu
found
làtrue
, chúng ta tái tạo đường đi. Bắt đầu từend_node
, chúng ta thêm nó vào vectorpath
, sau đó nhảy lên đỉnh cha của nó (parent[end_node]
), rồi đỉnh cha của đỉnh đó, và cứ thế cho đến khi gặp đỉnh cóparent
là -1 (đỉnh nguồn). - Cuối cùng, chúng ta dùng
std::reverse
để đảo ngược vectorpath
vì nó đang chứa đường đi từ đích về nguồn. - Nếu
found
vẫn làfalse
sau khi hàng đợi rỗng, hoặc nếu đỉnh đích không hợp lệ (như trong ví dụ thứ hai), vectorpath
sẽ rỗng, chỉ ra rằng không có đường đi.
Ưu điểm của BFS cho bài toán tìm đường đi trên đồ thị không trọng số là nó luôn tìm ra đường đi có ít cạnh nhất. Điều này rất hữu ích khi bạn cần đường đi "thẳng" nhất hoặc "ngắn" nhất tính theo số bước nhảy.
2. Tìm đường đi bằng Depth-First Search (DFS)
DFS duyệt đồ thị theo chiều sâu. Nó đi sâu vào một nhánh càng xa càng tốt trước khi quay lui (backtrack) để khám phá các nhánh khác. Để sử dụng DFS để tìm một đường đi (không nhất thiết là đường đi ngắn nhất) từ đỉnh nguồn đến đỉnh đích, chúng ta cũng cần theo dõi đường đi hiện tại đang khám phá.
Phương pháp phổ biến để làm điều này với DFS là sử dụng đệ quy và truyền theo danh sách các đỉnh đã đi qua trên đường đi hiện tại, hoặc sử dụng một mảng parent
tương tự BFS nhưng cần quản lý cẩn thận hơn do cơ chế quay lui. Cách dùng đệ quy và lưu đường đi trong tham số là khá trực quan.
Các bước thực hiện (sử dụng đệ quy và lưu đường đi tạm thời):
- Khởi tạo một mảng/map
visited
để đánh dấu các đỉnh đã thăm trong quá trình tìm kiếm tổng thể, ban đầu tất cả làfalse
. Điều này giúp tránh lặp vô hạn trong đồ thị có chu trình. - Sử dụng một hàm đệ quy, ví dụ
find_path_dfs_recursive(current_node, target_node, visited, adj, current_path, result_path)
.current_node
: Đỉnh hiện tại đang xét.target_node
: Đỉnh đích cần tìm.visited
: Mảng/map đánh dấu các đỉnh đã thăm (toàn cục hoặc truyền theo tham chiếu).adj
: Biểu diễn đồ thị.current_path
: Vector lưu trữ đường đi từ nguồn đếncurrent_node
trong lần đệ quy hiện tại.result_path
: Vector để lưu đường đi cuối cùng nếu tìm thấy (truyền theo tham chiếu).
- Trong hàm đệ quy
find_path_dfs_recursive(u, target, visited, adj, current_path, result_path)
:- Đánh dấu đỉnh hiện tại
u
đã thăm:visited[u] = true
. - Thêm
u
vàocurrent_path
. - Trường hợp cơ sở: Nếu
u
là đỉnh đích (u == target
), chúng ta đã tìm thấy một đường đi. Sao chépcurrent_path
vàoresult_path
và trả vềtrue
. - Bước đệ quy: Với mỗi đỉnh kề
v
củau
:- Nếu
v
chưa được thăm (!visited[v]
):- Gọi đệ quy:
find_path_dfs_recursive(v, target, visited, adj, current_path, result_path)
. - Nếu lời gọi đệ quy này trả về
true
(nghĩa là tìm thấy đích từv
), thì đường đi đã hoàn thành. Trả vềtrue
ngay lập tức.
- Gọi đệ quy:
- Nếu
- Quay lui (Backtracking): Nếu sau khi thử tất cả các đỉnh kề mà không tìm thấy đích, điều này có nghĩa là đỉnh
u
hiện tại không nằm trên đường đi thành công. Chúng ta cần quay lui:- Xóa
u
khỏi cuốicurrent_path
(current_path.pop_back()
). - Đánh dấu
u
là chưa thăm lại (visited[u] = false
). Điều này quan trọng để cho phép các nhánh khác của DFS có thể đi qua đỉnhu
này như một phần của một con đường khác. (Lưu ý: Nếu bạn chỉ muốn tìm một đường đi bất kỳ, không cần đánh dấu lại là chưa thăm, chỉ cần xóa khỏicurrent_path
thôi). Tuy nhiên, cách đánh dấu lại cho phép tìm kiếm linh hoạt hơn hoặc tìm tất cả các đường đi (với biến đổi nhỏ). Ở đây, chúng ta tìm một đường đi nên việc đánh dấu lại là không bắt buộc nếu bạn chỉ cần dừng ở đường đi đầu tiên tìm thấy. Để đơn giản cho việc tìm một đường đi, chúng ta chỉ cầnvisited
toàn cục mà không cần unmark. Hãy điều chỉnh lại bước 1 và 3:visited
chỉ dùng để tránh lặp vô hạn trong chu trình, không cần unmark khi quay lui nếu chỉ tìm 1 path.
- Xóa
- Đánh dấu đỉnh hiện tại
Các bước thực hiện (làm lại, đơn giản hơn cho tìm một đường đi):
- Khởi tạo mảng
visited
toàn cục (hoặc truyền reference), ban đầufalse
. - Khởi tạo vector
path
toàn cục (hoặc truyền reference) để lưu đường đi tìm được. - Sử dụng hàm đệ quy
bool find_path_dfs_recursive(u, target, visited, adj, current_path)
:u
: Đỉnh hiện tại.target
: Đỉnh đích.visited
: Mảngvisited
(truyền reference).adj
: Danh sách kề (truyền const reference).current_path
: Vector để xây dựng đường đi hiện tại (truyền reference).- Thêm
u
vàocurrent_path
. - Đánh dấu
visited[u] = true
. - Trường hợp cơ sở: Nếu
u == target
, trả vềtrue
(đã tìm thấy đích). - Bước đệ quy: Với mỗi đỉnh kề
v
củau
:- Nếu
!visited[v]
:- Gọi đệ quy
find_path_dfs_recursive(v, target, visited, adj, current_path)
. - Nếu lời gọi này trả về
true
, có nghĩa là đã tìm thấy đường đi từv
đến đích. Vìu
đang ở trên đường đi đếnv
, nênu
cũng nằm trên đường đi đến đích. Trả vềtrue
ngay lập tức.
- Gọi đệ quy
- Nếu
- Quay lui (Backtracking): Nếu vòng lặp kết thúc mà không tìm thấy đích từ bất kỳ đỉnh kề nào, điều này có nghĩa là không có đường đi từ
u
đến đích thông qua nhánh này.u
không thuộc về đường đi thành công. Xóau
khỏi cuốicurrent_path
:current_path.pop_back()
. Đỉnhu
vẫn giữ trạng tháivisited = true
để tránh thăm lại nó trong các nhánh khác nếu ta chỉ muốn tìm một path. Nếu muốn tìm tất cả paths, ta cần unmarkvisited[u] = false
. Ở đây, chúng ta tìm một path, nên giữvisited[u] = true
là ok, nhưng việc xóau
khỏicurrent_path
là cần thiết để vectorcurrent_path
chỉ chứa các đỉnh trên đường đi thực sự dẫn đến đích. - Trả về
false
vì không tìm thấy đường đi từu
đến đích qua nhánh này.
- Hàm gọi ban đầu sẽ khởi tạo
visited
vàcurrent_path
rỗng, sau đó gọi đệ quy từ đỉnh nguồn. Nếu kết quả trả về làtrue
,current_path
sẽ chứa đường đi.
Ví dụ C++ minh họa cho DFS tìm đường đi:
Sử dụng lại đồ thị trên:
0 -- 1 -- 3
| \ |
| \ |
4 -- 2 -- 5
Tìm đường đi từ đỉnh 0 đến đỉnh 5.
#include <iostream>
#include <vector>
#include <algorithm> // Not needed for this DFS implementation, but good to include
#include <stack> // Could be used for iterative DFS, but we use recursion here
using namespace std;
// Hàm đệ quy tìm đường đi bằng DFS
// Trả về true nếu tìm thấy đường đi từ u đến target, false ngược lại
bool find_path_dfs_recursive(const vector<vector<int>>& adj, int u, int target,
vector<bool>& visited, vector<int>& current_path) {
visited[u] = true; // Đánh dấu đỉnh hiện tại đã thăm
current_path.push_back(u); // Thêm đỉnh hiện tại vào đường đi đang xét
// Trường hợp cơ sở: Đã đến đỉnh đích
if (u == target) {
return true; // Tìm thấy đường đi
}
// Duyệt qua các đỉnh kề
for (int v : adj[u]) {
if (!visited[v]) {
// Gọi đệ quy trên đỉnh kề chưa thăm
if (find_path_dfs_recursive(adj, v, target, visited, current_path)) {
return true; // Nếu tìm thấy đích từ v, trả về true ngay lập tức
}
}
}
// Nếu không tìm thấy đích từ bất kỳ đỉnh kề nào, đỉnh u không thuộc đường đi thành công
current_path.pop_back(); // Quay lui: Xóa đỉnh hiện tại khỏi đường đi
// Note: visited[u] vẫn giữ là true để tránh chu trình trong trường hợp tìm 1 path
return false; // Không tìm thấy đường đi từ u qua nhánh này
}
// Hàm wrapper để gọi DFS và lấy kết quả
vector<int> find_path_dfs(const vector<vector<int>>& adj, int start_node, int end_node) {
int num_vertices = adj.size();
vector<bool> visited(num_vertices, false); // Đánh dấu đỉnh đã thăm
vector<int> path; // Vector để lưu đường đi
// Kiểm tra các đỉnh có hợp lệ không
if (start_node < 0 || start_node >= num_vertices || end_node < 0 || end_node >= num_vertices) {
return path; // Trả về vector rỗng nếu không hợp lệ
}
// Nếu đỉnh bắt đầu và kết thúc giống nhau
if (start_node == end_node) {
path.push_back(start_node);
return path;
}
// Gọi hàm đệ quy chính
// visited được truyền reference để duy trì trạng thái giữa các lần gọi
// path được truyền reference để hàm đệ quy xây dựng đường đi trực tiếp vào đây
if (find_path_dfs_recursive(adj, start_node, end_node, visited, path)) {
return path; // Trả về đường đi nếu tìm thấy
} else {
return {}; // Trả về vector rỗng nếu không tìm thấy
}
}
int main() {
// Biểu diễn đồ thị bằng danh sách kề
vector<vector<int>> adj(6); // 6 đỉnh: 0 đến 5
adj[0].push_back(1);
adj[0].push_back(2);
adj[0].push_back(4);
adj[1].push_back(0);
adj[1].push_back(2);
adj[1].push_back(3);
adj[2].push_back(0);
adj[2].push_back(1);
adj[2].push_back(4);
adj[2].push_back(5);
adj[3].push_back(1);
adj[4].push_back(0);
adj[4].push_back(2);
adj[5].push_back(2);
int start = 0;
int end = 5;
cout << "Tim duong di tu " << start << " den " << end << " bang DFS:" << endl;
vector<int> path = find_path_dfs(adj, start, end);
if (!path.empty()) {
cout << "Duong di tim thay: ";
for (size_t i = 0; i < path.size(); ++i) {
cout << path[i] << (i == path.size() - 1 ? "" : " -> ");
}
cout << endl;
} else {
cout << "Khong tim thay duong di." << endl;
}
// Ví dụ khác: tìm đường đi không tồn tại
cout << "\nTim duong di tu 0 den 10 (khong ton tai) bang DFS:" << endl;
int start_nonexistent = 0;
int end_nonexistent = 10; // Đỉnh không tồn tại
if (end_nonexistent >= 0 && end_nonexistent < adj.size()) {
vector<int> path_nonexistent = find_path_dfs(adj, start_nonexistent, end_nonexistent);
if (!path_nonexistent.empty()) {
// Code này sẽ không chạy
} else {
cout << "Khong tim thay duong di." << endl;
}
} else {
cout << "Dinh dich khong hop le." << endl;
}
return 0;
}
Giải thích Code DFS:
adj
vẫn là danh sách kề biểu diễn đồ thị.visited
làstd::vector<bool>
được truyền bằng tham chiếu (vector<bool>& visited
) vào hàm đệ quy. Nó giúp chúng ta theo dõi những đỉnh nào đã được thăm tổng thể trong toàn bộ quá trình tìm kiếm. Điều này là cần thiết để tránh bị kẹt trong chu trình.current_path
làstd::vector<int>
cũng được truyền bằng tham chiếu (vector<int>& current_path
). Vector này đóng vai trò như một ngăn xếp (stack) ngầm định, lưu trữ các đỉnh trên đường đi từ đỉnh nguồn đến đỉnhu
hiện tại trong lần gọi đệ quy.- Trong
find_path_dfs_recursive(adj, u, target, visited, current_path)
:- Chúng ta đánh dấu
u
đã thăm và thêmu
vào cuốicurrent_path
. - Nếu
u
bằngtarget
, chúng ta đã tìm thấy đích. Hàm trả vềtrue
, và do cơ chế đệ quy, các lời gọi trước đó cũng sẽ trả vềtrue
, đồng thờicurrent_path
tại thời điểm này chính là đường đi từ nguồn đến đích. - Nếu
u
không phải là đích, chúng ta duyệt qua các đỉnh kềv
. - Nếu
v
chưa thăm, chúng ta gọi đệ quyfind_path_dfs_recursive(adj, v, target, visited, current_path)
. - Nếu bất kỳ lời gọi đệ quy nào trả về
true
(nghĩa là tìm thấy đích ở sâu hơn), chúng ta cũng trả vềtrue
ngay lập tức. Điều này đảm bảo rằng khi tìm thấy đích, chúng ta dừng việc khám phá các nhánh khác và giữ nguyên trạng tháicurrent_path
chứa đường đi thành công. - Nếu vòng lặp qua tất cả các đỉnh kề kết thúc mà không có lời gọi đệ quy nào trả về
true
(nghĩa là không tìm thấy đường đi từu
qua bất kỳ đỉnh kề chưa thăm nào), điều này có nghĩa làu
không nằm trên đường đi dẫn đến đích. Chúng ta cần quay lui: xóau
khỏi cuốicurrent_path
bằngcurrent_path.pop_back()
. Đỉnhu
vẫn được giữ làvisited = true
để tránh thăm lại nó (và tạo chu trình) khi khám phá các nhánh khác từ các đỉnh khác. - Nếu không tìm thấy đường đi, hàm cuối cùng trả về
false
.
- Chúng ta đánh dấu
- Hàm
find_path_dfs
là một hàm "bao bọc" (wrapper function) để khởi tạovisited
vàpath
, xử lý các trường hợp đặc biệt (nguồn = đích, đích không hợp lệ), sau đó gọi hàm đệ quy và trả về kết quả.
DFS tìm đường đi theo kiểu "thử và sai". Nó đi sâu vào một con đường, nếu con đường đó không dẫn đến đích, nó quay lui và thử con đường khác. Đường đi mà DFS tìm được không đảm bảo là ngắn nhất, nó chỉ là một trong những đường đi có thể có. Tuy nhiên, trên một số cấu trúc đồ thị hoặc khi bạn chỉ cần một đường đi bất kỳ và không quan tâm đến độ dài, DFS có thể là một lựa chọn tốt, đặc biệt là khi đồ thị rất sâu và hẹp (DFS có thể dùng ít bộ nhớ hơn BFS trong trường hợp này).
3. So sánh BFS và DFS trong bài toán tìm đường đi
Đặc điểm | BFS (Tìm đường đi) | DFS (Tìm đường đi) |
---|---|---|
Giải thuật nền | Duyệt theo chiều rộng, sử dụng hàng đợi. | Duyệt theo chiều sâu, sử dụng đệ quy (hoặc ngăn xếp). |
Đường đi tìm được | Luôn tìm đường đi ngắn nhất trên đồ thị không trọng số (theo số cạnh). | Tìm một đường đi bất kỳ. Không đảm bảo ngắn nhất. |
Cách lưu đường đi | Sử dụng mảng parent và tái tạo ngược từ đích về nguồn. |
Sử dụng vector/danh sách lưu đường đi đang duyệt, thêm vào khi đi sâu, xóa khi quay lui. |
Bộ nhớ | Trong trường hợp xấu nhất, cần lưu trữ tất cả các đỉnh ở cùng một "lớp" vào hàng đợi. Có thể tốn nhiều bộ nhớ cho đồ thị rộng. | Sử dụng bộ nhớ cho ngăn xếp đệ quy (hoặc ngăn xếp tường minh). Có thể tốn ít bộ nhớ hơn cho đồ thị sâu. |
Thời gian | O(V + E) trên đồ thị biểu diễn bằng danh sách kề. | O(V + E) trên đồ thị biểu diễn bằng danh sách kề. |
Ứng dụng phù hợp | Tìm đường đi ngắn nhất (không trọng số), tìm các đỉnh gần nguồn. | Tìm kiếm sự tồn tại của đường đi, các bài toán liên quan đến chu trình, liên thông mạnh (với biến thể), hoặc khi không quan tâm đến độ dài đường đi. |
Bài tập ví dụ:
[Graph].BFS trên đồ thị vô hướng.
Cho đồ thị vô hướng G = (V, E) được biểu diễn dưới dạng danh sách cạnh. Hãy thực hiện in ra danh sách các đỉnh được duyệt theo thuật toán BFS(s).
Input Format
Dòng đầu tiên là 2 số n và m và s, tương ứng với số lượng đỉnh, cạnh của đồ thị và đỉnh bắt đầu duyệt. Các đỉnh của đồ thị được đánh số từ 1 tới n. m dòng tiếp theo mỗi dòng chứa đỉnh u, v (u != v) tương ứng với một cạnh của đồ thị. (1<=s<=n<=1000; 1<=m<=n*(n-1)/2)
Constraints
.
Output Format
In ra các đỉnh được duyệt theo thuật toán BFS(s). Chú ý trong quá trình mở rộng các đỉnh của thuật toán BFS luôn lựa chọn duyệt các đỉnh có thứ tự nhỏ hơn trước.
Ví dụ:
Dữ liệu vào
5 9 4
1 2
1 3
1 4
2 3
2 4
2 5
3 4
3 5
4 5
Dữ liệu ra
4 1 2 3 5
Đây là hướng dẫn giải bài toán BFS trên đồ thị vô hướng theo yêu cầu:
Biểu diễn đồ thị: Sử dụng danh sách kề (adjacency list). Dùng
std::vector<std::vector<int>> adj
trong C++, kích thướcn+1
nếu đỉnh đánh số từ 1. Đọcm
cạnh, với mỗi cạnhu v
, thêmv
vào danh sách kề củau
vàu
vào danh sách kề củav
.Xử lý yêu cầu thứ tự duyệt: Sau khi xây dựng danh sách kề, duyệt qua từng danh sách
adj[i]
và sắp xếp nó theo thứ tự tăng dần. Điều này đảm bảo khi duyệt các đỉnh kề của một đỉnhu
, ta luôn xem xét các đỉnh có chỉ số nhỏ hơn trước.Thuật toán BFS:
- Cần một hàng đợi (
std::queue<int>
) để lưu các đỉnh sẽ được thăm. - Cần một mảng/vector boolean (
std::vector<bool> visited
) kích thướcn+1
để đánh dấu các đỉnh đã thăm, khởi tạo tất cả làfalse
. - Đưa đỉnh bắt đầu
s
vào hàng đợi và đánh dấuvisited[s] = true
. - Trong khi hàng đợi chưa rỗng:
- Lấy đỉnh
u
từ đầu hàng đợi và loại bỏ nó. - In đỉnh
u
. - Duyệt qua các đỉnh kề
v
củau
trong danh sáchadj[u
(lúc này đã được sắp xếp). - Nếu
v
chưa được thăm (!visited[v]
):- Đánh dấu
visited[v] = true
. - Đưa
v
vào cuối hàng đợi.
- Đánh dấu
- Lấy đỉnh
- Cần một hàng đợi (
Output: In các đỉnh
u
khi lấy ra từ hàng đợi, cách nhau bằng dấu cách.
Comments