← Cấu trúc dữ liệu & Giải thuật

Bài 7 · Nâng cao · 28 phút

Đồ thị & duyệt (BFS/DFS)

Biên soạn bởi Nguyễn Anh Tuấn

Mô hình hoá mạng lưới bằng đồ thị (graph): đỉnh, cạnh, danh sách kề; duyệt theo chiều rộng (BFS) tìm đường ngắn nhất và theo chiều sâu (DFS).

Tới giờ dữ liệu của mèo con là dãy (mảng, danh sách) hoặc cây. Nhưng rất nhiều thứ trong đời là một mạng lưới quan hệ: bạn bè trên mạng xã hội, các ga tàu nối nhau, những trang web trỏ link qua lại. Cấu trúc cho những thứ đó là đồ thị (graph).

Đồ thị gồm các đỉnh (vertex/node - như mỗi người, mỗi ga) và các cạnh (edge - mối nối giữa hai đỉnh). Cây ở bài trước thật ra chỉ là một đồ thị đặc biệt (có gốc, không chu trình); đồ thị thì tự do hơn nhiều - cạnh nối tuỳ ý, có thể có vòng.

  • Đồ thị = đỉnh (vertex) + cạnh (edge), mô hình hoá mạng lưới quan hệ.
  • Ví dụ: mạng xã hội, bản đồ giao thông, web, mạng máy tính.
  • Cây là đồ thị đặc biệt (có gốc, không chu trình); đồ thị tổng quát hơn.

Có hai cách quen thuộc, và chúng đánh đổi bộ nhớ khác nhau. Danh sách kề (adjacency list): mỗi đỉnh giữ một danh sách các đỉnh kề - đúng những viên gạch ở bài Mảng & danh sách liên kết. Cách này gọn cho đồ thị thưa (ít cạnh), tốn cỡ O(V + E) bộ nhớ.

Ma trận kề (adjacency matrix): một mảng 2 chiều n × n nằm liền kề trong RAM, ô [i][j] bằng 1 nếu có cạnh giữa đỉnh i và j. Hỏi “có cạnh không” thì tức thì O(1), nhưng tốn O(V²) bộ nhớ kể cả khi rất ít cạnh. (Cách một mảng nằm liền kề trong bộ nhớ ra sao là chuyện của bài Bộ nhớ & RAM ở khoá Máy tính hoạt động thế nào.)

  • Danh sách kề: mỗi đỉnh một danh sách hàng xóm; O(V + E) bộ nhớ, hợp đồ thị thưa.
  • Ma trận kề: mảng 2 chiều n×n; kiểm tra cạnh O(1) nhưng tốn O(V²) bộ nhớ.
  • Chọn cách lưu là chọn đánh đổi giữa bộ nhớ và tốc độ kiểm tra cạnh.

Để “đi thăm” mọi đỉnh từ một điểm xuất phát, có hai lối. BFS (Breadth-First Search - duyệt theo chiều rộng) dùng hàng đợi, lan ra theo từng lớp. DFS (Depth-First Search - duyệt theo chiều sâu) dùng ngăn xếp, đâm sâu một nhánh rồi quay lui. Cả hai đều mượn đúng hai công cụ ở bài Ngăn xếp & hàng đợi, cộng một tập đã thăm (chính là một bảng băm dạng set) để khỏi đi lại:

duyet.ts · đổi hàng đợi thành ngăn xếp là đổi BFS thành DFS

const dothi: Record<string, string[]> = {
  A: ['B', 'C'], B: ['A', 'D', 'E'], C: ['A', 'F'],
  D: ['B'], E: ['B', 'F'], F: ['C', 'E', 'G'], G: ['F'],
};

function bfs(g: Record<string, string[]>, start: string): string[] {
  const thuTu: string[] = [];
  const seen = new Set([start]);
  const queue = [start];               // HANG DOI (FIFO)
  while (queue.length) {
    const cur = queue.shift()!;        // lay o DAU
    thuTu.push(cur);
    for (const nb of g[cur]) if (!seen.has(nb)) { seen.add(nb); queue.push(nb); }
  }
  return thuTu;
}

function dfs(g: Record<string, string[]>, start: string): string[] {
  const thuTu: string[] = [];
  const seen = new Set([start]);
  const stack = [start];               // NGAN XEP (LIFO)
  while (stack.length) {
    const cur = stack.pop()!;          // lay o CUOI
    thuTu.push(cur);
    for (const nb of [...g[cur]].reverse()) if (!seen.has(nb)) { seen.add(nb); stack.push(nb); }
  }
  return thuTu;
}

console.log(bfs(dothi, 'A').join(' '));
console.log(dfs(dothi, 'A').join(' '));

Kết quả khi chạy

A B C D E F G
A B D E F G C

Hai hàm giống hệt nhau, chỉ khác một dòng: lấy phần tử ở đầu (hàng đợi) hay ở cuối (ngăn xếp). Vậy mà cho hai thứ tự đi rất khác nhau.

  • BFS = hàng đợi (lấy ở đầu) → lan theo lớp; DFS = ngăn xếp (lấy ở cuối) → đâm sâu.
  • Tập “đã thăm” (hash set) tránh đi lại đỉnh cũ, cần khi đồ thị có chu trình.
  • Đổi cấu trúc lưu “biên giới” là đổi luôn kiểu duyệt.

Chọn BFS hay DFS, bấm Tiếp → hoặc Tự chạy, và để ý hàng đợi/ngăn xếp ở dưới cùng đồ thị tô màu phía trên: cùng xuất phát từ A nhưng hai cách lan đi rất khác:

Đồ thị 6 đỉnh A-F với các cạnh; bên dưới ghi thứ tự thăm theo BFS và theo DFS khi xuất phát từ A.
Cùng xuất phát từ A: BFS lan theo lớp, DFS đi sâu trước - thứ tự thăm khác hẳn nhau.
|
ABCDEFG

Bắt đầu từ A, đưa vào hàng đợi

Hàng đợi: [A] ← lấy ở đầu

Đã thăm: (chưa có)

Cùng đồ thị, cùng điểm xuất phát A: BFS dùng hàng đợi nên lan ra theo từng lớp (gần A trước); DFS dùng ngăn xếp nên đâm sâu một nhánh tới cùng rồi mới quay lui. Khác nhau chỉ ở chỗ lấy phần tử từ ĐẦU hay từ CUỐI.

  • BFS thăm A, rồi mọi hàng xóm của A, rồi hàng xóm của họ - theo lớp.
  • DFS lao theo một nhánh tới tận cùng rồi mới quay lại nhánh khác.
  • Ô tô màu coral = đang thăm, xanh = đã xong, viền hổ phách = đang chờ trong biên giới.

Cả BFS lẫn DFS đều thăm mỗi đỉnh một lần và đi qua mỗi cạnh một lần, nên chi phí là O(V + E) (V đỉnh, E cạnh) - rất hiệu quả. Khác nhau là ở thứ tự, và thứ tự đó quyết định bài toán nào hợp cách nào.

Trung thực

BFS tìm đường ngắn nhất theo số cạnh, nhưng chỉ đúng khi các cạnh không có trọng số (mọi cạnh “dài” như nhau). Khi cạnh có trọng số (đường 5km so với đường 2km), tìm đường ngắn nhất cần thuật toán khác (Dijkstra, A*) - nằm ngoài phạm vi bài này, nhưng chúng cũng dựng trên chính đồ thị và hàng đợi (ưu tiên) mà ta vừa học.
  • Cả hai: O(V + E) - mỗi đỉnh và mỗi cạnh xử lý một lần.
  • BFS: đường ngắn nhất (theo số cạnh), lan đều - hợp “ít trạm nhất”.
  • DFS: dò sâu, phát hiện chu trình, sắp thứ tự phụ thuộc (topological sort).

Đồ thị khép lại bộ đồ nghề cấu trúc dữ liệu của mèo con. Đẹp nhất: BFS và DFS không phải phép màu mới - chúng chỉ là hàng đợi và ngăn xếp (bài 3) cộng một hash set (bài 6), áp lên một mạng lưới. Mọi mảnh ghép cả khoá nối vào nhau ở đây.

Bước tiếp theo

Giờ mèo con đã có đủ: Big-O để cân đo, mảng/danh sách/ngăn xếp/hàng đợi để lưu, sắp xếp & tìm kiếm, cây, bảng băm và đồ thị. Đã đến lúc ghép tất cả vào ba dự án chạy được ở bài Dự án cuối khoá: 3 bài toán DSA.

Câu hỏi thường gặp

Cây là một TRƯỜNG HỢP ĐẶC BIỆT của đồ thị: có gốc, không có chu trình, mỗi node (trừ gốc) có đúng một cha. Đồ thị tổng quát hơn - cạnh nối tự do, có thể có chu trình, có thể không có “gốc”. Vậy nên mọi cây đều là đồ thị, nhưng không phải đồ thị nào cũng là cây.

Vì hàng đợi khiến BFS lan ra theo từng lớp: thăm hết các đỉnh cách 1 cạnh, rồi mới tới các đỉnh cách 2 cạnh… Nên lần đầu chạm tới một đỉnh chính là qua đường ít cạnh nhất. Lưu ý: điều này đúng cho đồ thị KHÔNG TRỌNG SỐ; có trọng số (đường dài ngắn khác nhau) thì cần thuật toán khác như Dijkstra.

Sẽ lặp vô tận nếu đồ thị có chu trình: A sang B, B quay lại A, A lại sang B… Tập đã thăm (thường là một hash set, O(1) để hỏi “đã thăm chưa”) đảm bảo mỗi đỉnh chỉ xử lý một lần.

Cùng ý tưởng “đâm sâu rồi quay lui”. DFS đệ quy mượn luôn call stack của chương trình làm ngăn xếp; DFS vòng lặp thì tự quản một ngăn xếp tường minh. Đệ quy gọn hơn nhưng có thể tràn ngăn xếp (stack overflow) nếu đồ thị quá sâu.

Danh sách kề gọn cho đồ thị THƯA (ít cạnh), tốn bộ nhớ O(V + E), là lựa chọn mặc định. Ma trận kề tốn O(V²) bộ nhớ kể cả khi ít cạnh, nhưng hỏi “có cạnh giữa hai đỉnh không” cực nhanh O(1) - hợp đồ thị DÀY hoặc khi cần kiểm tra cạnh liên tục.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

BFS (duyệt theo chiều rộng) dùng cấu trúc nào để quản các đỉnh chờ thăm?

  1. 1

    Mô hình hoá bằng đồ thị

    Chọn một tình huống đời thực (mạng bạn bè, bản đồ tàu điện, các trang web nối nhau) và nói rõ: đỉnh là gì, cạnh là gì.

    Hoàn thành khi: Ví dụ hợp lý: mạng bạn bè → đỉnh là người, cạnh là quan hệ bạn bè; bản đồ tàu → đỉnh là ga, cạnh là đoạn đường nối hai ga.

  2. 2

    Viết danh sách kề

    Cho đồ thị vô hướng với cạnh: A-B, A-C, B-D, C-D. Viết danh sách kề của nó.

    Hoàn thành khi: A: [B, C], B: [A, D], C: [A, D], D: [B, C]. Mỗi cạnh xuất hiện ở cả hai đỉnh.

  3. 3

    Đoán thứ tự BFS

    Trên đồ thị bài tập 2, duyệt BFS từ A (xét hàng xóm theo thứ tự bảng chữ cái). Viết thứ tự thăm.

    Hoàn thành khi: Thứ tự: A, B, C, D. (A vào lớp 0; B, C lớp 1; D lớp 2.)

  4. 4

    Đoán thứ tự DFS

    Cũng đồ thị đó, duyệt DFS từ A. Viết thứ tự thăm và chỉ ra chỗ “quay lui”.

    Hoàn thành khi: Một thứ tự đúng: A, B, D, C - đi sâu A→B→D, D hết hàng xóm mới thì quay lui rồi sang C.

  5. 5

    Hàng đợi hay ngăn xếp

    Nếu lấy code BFS rồi đổi hàng đợi thành ngăn xếp (lấy phần tử ở CUỐI thay vì đầu), ta được thuật toán gì?

    Hoàn thành khi: Được DFS - chỉ khác cấu trúc lưu “biên giới”: lấy ở cuối (LIFO) thành đâm sâu, lấy ở đầu (FIFO) thành lan rộng.

  6. 6

    BFS hay DFS

    Chọn BFS hay DFS: (1) tìm đường ít trạm nhất giữa hai ga tàu; (2) kiểm tra xem có đường nào nối hai máy tính trong mạng không.

    Hoàn thành khi: (1) BFS (đường ngắn nhất theo số cạnh). (2) Cái nào cũng được (chỉ cần biết có tới được hay không); DFS thường gọn hơn.