Tìm kiếm theo chiều rộng¶
Khái quát về thuật toán BFS¶
BFS (Breadth-First Search) là thuật toán duyệt đồ thị để khám phá và tìm đường đi trên cấu trúc dữ liệu đồ thị hoặc cây.
Ý tưởng chính của thuật toán là xuất phát tại một đỉnh nào đó và duyệt các đỉnh khác theo hướng lan rộng ra. Nói cách khác, thuật toán này xét hết các đỉnh kề với đỉnh hiện hành, các đỉnh kề này đều cùng cấp, rồi mới xét tiếp các đỉnh khác ở cấp tiếp theo. Thuật toán này vì thế còn được gọi là thuật toán loang.
Ví dụ:
Xét cây gia phả bằng BFS, ta có trình tự xét như sau:
Ông cố →
Ông bác → Ông nội → Ông chú →
Các con của ông bác → Các con của ông nội (bác, ba, chú) → Các con của ông chú →
Các con của bác (anh họ 1, anh họ 2) → Các con của ba (anh, mình, em) → Các con của chú (em họ 1, em họ 2)
---
title: Hình 1. Một phần trình tự khi duyệt bằng BFS
---
graph LR
subgraph "Thế hệ 1"
direction LR
ong_noi([Ông nội])
end
subgraph "Thế hệ 2"
direction LR
bac([Bác]) --> ba([Ba]) --> chu([Chú])
end
subgraph "Thế hệ 3"
direction LR
anh([Anh]) --> minh([Mình]) --> em([Em])
end
subgraph "Thế hệ 4"
direction LR
con_1([Con 1]) --> con_2([Con 2])
end
ong_noi --> bac
chu --> anh
em --> con_1
Các bước của thuật toán¶
- Thuật toán BFS có thể thực hiện bằng hàng đợi như sau:
-
Bước 1:
Chọn một đỉnh làm đỉnh gốc để xuất phát, đặt là đỉnh S. Nạp đỉnh S vào hàng đợi. -
Bước 2:
Duyệt hàng đợi, trong khi hàng đợi vẫn còn phần tử để xét, thì lặp thao tác:
Lấy phần tử nằm ở đầu hàng đợi ra khỏi hàng đợi, đặt là đỉnh C.
Duyệt các đỉnh N kề với đỉnh C, lặp các thao tác:
Nếu đỉnh N chưa ghé thăm:
- Thực hiện các thao tác nào đó theo yêu cầu tại đỉnh N.
- Đánh dấu đỉnh N đã ghé thăm.
- Nạp đỉnh N vào hàng đợi.
Mã giả:
function bfs():
enqueue(đỉnh_S)
while queue is not empty:
đỉnh_C = dequeue(phần_tử_đầu_queue)
for đỉnh_N in các_đỉnh_kề_của_C:
if đỉnh_N not in visited:
do_something(đỉnh_N)
visited.add(đỉnh_N)
enqueue(đỉnh_N)
Bài toán ví dụ¶
Yêu cầu¶
Tìm một đường đi từ đỉnh xuất phát đến đỉnh đích. Trả về -1 nếu không có đường đi.1
Input¶
Output¶
Giải thích¶
Đồ thị có 10 đỉnh, 11 cạnh. Yêu cầu tìm đường đi từ đỉnh 1 đến đỉnh 5.
Đường đi tìm được: 1 → 3 → 5.
---
title: Hình 2. Đồ thị của bài toán ví dụ
---
graph LR
1([1]) --> 2([2]) & 3([3])
2 --> 3 & 4([4])
3 --> 5([5]) & 7([7]) & 8([8])
4 --> 6([6])
7 --> 8 --> 5
9([9]) --> 10([10])
Cách giải đề xuất¶
Khởi tạo¶
Trong bài này, ta muốn in ra đường đi theo trình tự từ đỉnh xuất phát đến đỉnh đích. Cho nên, ta không chỉ đánh dấu các đỉnh đã ghé thăm, mà còn phải lưu vết và truy vết. Để lưu và truy vết, ta sử dụng một mảng các số nguyên, đặt là trace
, trong đó trace[u] = v
với ý nghĩa liền trước đỉnh u là đỉnh v, hoặc nói cách khác, có đường đi v → u.
Trước hết, ta khởi tạo mảng trace
gồm toàn các phần tử 0, nghĩa là các đỉnh đều chưa có đỉnh liền trước. Riêng đỉnh xuất phát được gán -1.
Thực hiện BFS¶
-
Nạp đỉnh
start
vào queue. -
Dùng vòng lặp while để duyệt
queue
, trong khiqueue
vẫn còn đỉnh để xét, thì lặp thao tác:- Lấy ra đỉnh nằm ở đầu
queue
, đặt làcurrent
. - Dùng vòng lặp để duyệt các đỉnh kề với đỉnh
current
dựa trên danh sách kềa
, lặp các thao tác:
Giả sử
u
là một đỉnh kề đang xét. Dựa trên mảngtrace
để xét xemu
đã ghé thăm chưa. Nếuu
chưa ghé thăm,trace[u] == 0
, thì đánh dấuu
được ghé thăm từ đỉnhcurrent
:trace[u] = current
, rồi nap đỉnhu
vào hàng đợiqueue
để... đợi (!!!) tới lượt mình trở thànhcurrent
, nghĩa là đỉnhu
chuẩn bị trở thành một mắt xích tiếp theo cho tiến trình lây lan/loang/lan rộng. - Lấy ra đỉnh nằm ở đầu
Sau khi hàm Bfs()
hoàn tất, mảng trace
được điền đầy đủ như sau:
Đỉnh u | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
trace[u] | -1 | 1 | 1 | 2 | 3 | 4 | 3 | 3 | 0 | 0 |
Output¶
Nếu dựa vào mảng trace
để in ra đường đi thì trình tự sẽ bị ngược: 5 ← 3 ← 1. Vì vậy, ta giải quyết bằng cách: Nạp các đỉnh của đường đi vào stack
trước, rồi duyệt stack
để in ra, thì đường đi sẽ thuận chiều lại.
Cách nạp các đỉnh của đường đi vào stack
như sau:
-
Dùng vòng lặp while, cho một biến
tmp
xuất phát từ đỉnh đích (đỉnhfinish
) lùi dần về đỉnh xuất phát (đỉnhstart
) bằng mảngtrace
. Ứng với mỗi lần lùi, ta nạp đỉnh tương ứng (là biếntmp
) vàostack
.Lưu ý
Stack ở đây chỉ mang ý nghĩa lật ngược/đảo chiều trình tự hiển thị của đường đi, chứ không nhất thiết phải đúng kiểu dữ liệu
stack
. Ta có thể sử dụng bất kỳ kiểu dữ liệu nào miễn là phù hợp, tiện lợi, có hỗ trợ đảo chiều. Chẳng hạn, mặc dù C++ và Python đều có kiểustack
, ta vẫn có thể sử dụng kiểuvector
đối với C++ hoặclist
đối với Python, vì chúng đều có hàmreverse()
.Tương tự, queue trong hàm Bfs() mang ý nghĩa là cách thức duyệt và xử lý các đỉnh. Ta hoàn toàn có thể sử dụng những kiểu dữ liệu khác miễn là phù hợp, có hỗ trợ lấy ra phần tử nằm ở đầu và nạp phần tử vào từ đuôi.
Cũng liên quan điều trên, chương trình có thể được viết theo hướng khác tốt hơn. Chương trình trong bài này chỉ có tính đề xuất, có vẻ là một pha xử lý cồng kềnh, chủ yếu để người học luyện ngón.
-
Sau khi có stack, ta in ra đường đi bằng cách:
Dùng vòng lặp while để duyệt stack, lặp các thao tác:
- In ra đỉnh đầu của stack.
- Xóa bỏ đỉnh đầu này.
Toàn bộ chương trình¶
Code đầy đủ được đặt tại GitHub.
Nhận xét
Vì BFS duyệt các đỉnh theo từng cấp/từng thế hệ, hết cấp trên mới đến cấp dưới tiếp theo, nên hàng đợi của BFS chứa các đỉnh theo thứ tự cấp/thế hệ tăng dần, trong cùng cấp thì thứ tự các đỉnh cũng tăng dần. Bằng cách đó, hàng đợi trở thành trình tự duyệt của tất cả các đỉnh. Nói cách khác, một đỉnh muốn biết, từ đỉnh xuất phát, khi nào tới lượt mình, thì nhìn vào hàng đợi.
Cũng vì thế, kết quả của BFS chính là đường đi ngắn nhất từ đỉnh xuất phát đến một đỉnh nào đó. Và điều này đúng với đồ thị không có trọng số, sai đối với đồ thị có trọng số.
So sánh DFS và BFS¶
Cả hai thuật toán đều được dùng để duyệt đồ thị hoặc cây. Độ phức tạp đều là \(O(số đỉnh + số cạnh)\).
Những điểm khác nhau được thể hiện trong bảng sau:
DFS | BFS | |
---|---|---|
Chiến lược | Đi đến đỉnh xa nhất của riêng một nhánh trước khi quay lui. | Đi hết các đỉnh trong cùng cấp, rồi mới đến các đỉnh ở cấp khác. |
Ưu tiên duyệt đỉnh | Ưu tiên đi sâu hơn ưu tiên các đỉnh anh em. | Ưu tiên các đỉnh anh em cùng cấp hơn ưu tiên cấp tiếp theo. |
Cấu trúc dữ liệu sử dụng | Đệ quy, ngăn xếp | Hàng đợi |
Bộ nhớ | Trong trường hợp xấu nhất, đồ thị phức tạp, có thể dẫn đến tràn bộ nhớ do gọi đệ quy quá nhiều. | Thường sử dụng nhiều bộ nhớ hơn do phải lưu tất cả đỉnh trong cùng một cấp. |
Khả năng hoàn tất | Có thể không hoàn tất nếu gặp phải đồ thị có chu trình (đường đi tuần hoàn giữa các đỉnh). | Hoàn tất được, miễn là đồ thị có số đỉnh hữu hạn và các đỉnh đều liên thông. |
Thứ tự các đỉnh của đường đi kết quả | Có thứ tự từ điển nhỏ nhất (trong số các phương án). | Có thứ tự khoảng cách tăng dần tính từ đỉnh xuất phát. |
Đường đi ngắn nhất | Không đảm bảo tìm được đường đi ngắn nhất. | Đảm bảo tìm được đường đi ngắn nhất đối với đồ thị không trọng số. |
Ứng dụng | Tìm đường trong mê cung, phát hiện chu trình, khám phá các cây có kích thước lớn. | Tìm đường trong mê cung, tìm đường đi ngắn nhất, giải đố, phân tích mạng. |
-
Bài toán này được tham khảo từ:
Lê Minh Hoàng. Giải thuật & lập trình. Đại học Sư phạm Hà Nội, 1999-2002. ↩