Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 28.530 phút đọc

Đồ thị, BFS và DFS

Sau bài này bạn sẽ làm được

  • Chọn giữa ma trận kề và danh sách kề
  • Cài BFS bằng hàng đợi và DFS bằng đệ quy
  • Tìm thành phần liên thông và phát hiện chu trình
  • Cài sắp xếp tô-pô

Đồ thị là cấu trúc tổng quát nhất trong khóa học: cây là đồ thị không có chu trình, danh sách liên kết là đồ thị mà mỗi đỉnh có một cạnh ra. Hai cách duyệt cơ bản, BFS và DFS, là nền của gần như mọi thuật toán đồ thị.

#Hai cách biểu diễn

Cùng một đồ thị năm đỉnh, hai cách biểu diễn. Ma trận kề tra cạnh trong O(1), danh sách kề tiết kiệm bộ nhớ.
Tiêu chíMa trận kềDanh sách kề
Bộ nhớO(V bình phương)O(V + E)
Kiểm tra cạnh u v có tồn tạiO(1)O(bậc của u)
Duyệt mọi láng giềng của uO(V)O(bậc của u)
Thêm cạnhO(1)O(1)
Xóa cạnhO(1)O(bậc của u)
Hợp với đồ thịDày, E gần V bình phươngThưa, E gần V
Thân thiện bộ nhớ đệmTốtKém nếu dùng con trỏ
do-thi.h
#ifndef DO_THI_H
#define DO_THI_H

#include <stddef.h>

/* Cách 1: ma trận kề, dùng mảng phẳng để cấp phát một lần. */
typedef struct {
    int   *m;        /* m[u * V + v] khác 0 nếu có cạnh u tới v */
    size_t V;
} MaTran;

/* Cách 2: danh sách kề dạng CSR, tức mảng phẳng thay vì con trỏ.
   dinh[u] tới dinh[u+1] là khoảng chỉ số trong ke chứa láng giềng của u.
   Cách này tiết kiệm và thân thiện bộ nhớ đệm hơn danh sách liên kết. */
typedef struct {
    size_t *dinh;    /* V + 1 phần tử */
    int    *ke;      /* 2E phần tử với đồ thị vô hướng */
    int    *trong_so;
    size_t  V, E;
} DoThi;

#endif
dung-csr.c
#include <stdlib.h>
#include <string.h>

/* Dựng CSR từ danh sách cạnh. Hai lượt: đếm bậc, rồi điền.
   O(V + E) thời gian, không cấp phát tạm nào ngoài mảng bậc. */
int dt_dung(DoThi *g, size_t V, const int *u, const int *v, size_t E)
{
    g->dinh = calloc(V + 1, sizeof *g->dinh);
    g->ke   = malloc(2 * E * sizeof *g->ke);

    if (g->dinh == NULL || g->ke == NULL) {
        free(g->dinh); free(g->ke);

        return -1;
    }

    g->V = V;
    g->E = E;

    /* Lượt 1: đếm bậc của từng đỉnh, ghi vào dinh[i+1] */
    for (size_t i = 0; i < E; ++i) {
        ++g->dinh[(size_t)u[i] + 1];
        ++g->dinh[(size_t)v[i] + 1];
    }

    /* Tổng tiền tố: dinh[i] thành chỉ số bắt đầu của đỉnh i */
    for (size_t i = 0; i < V; ++i)
        g->dinh[i + 1] += g->dinh[i];

    /* Lượt 2: điền, dùng một mảng con trỏ ghi tạm */
    size_t *vi_tri = malloc(V * sizeof *vi_tri);

    if (vi_tri == NULL) { free(g->dinh); free(g->ke); return -1; }

    memcpy(vi_tri, g->dinh, V * sizeof *vi_tri);

    for (size_t i = 0; i < E; ++i) {
        g->ke[vi_tri[u[i]]++] = v[i];
        g->ke[vi_tri[v[i]]++] = u[i];
    }

    free(vi_tri);

    return 0;
}

#BFS: duyệt theo tầng

Cùng đồ thị, cùng đỉnh xuất phát, hai thứ tự thăm khác nhau. Khác biệt duy nhất là hàng đợi hay ngăn xếp.
bfs.c
#include <stdlib.h>

/* BFS từ đỉnh nguon. Ghi khoảng cách theo số cạnh vào kc,
   và đỉnh cha trên đường đi ngắn nhất vào cha.
   Trả về 0 nếu ổn, -1 nếu hết bộ nhớ. O(V + E). */
int bfs(const DoThi *g, int nguon, int *kc, int *cha)
{
    size_t *hang_doi = malloc(g->V * sizeof *hang_doi);

    if (hang_doi == NULL) return -1;

    for (size_t i = 0; i < g->V; ++i) { kc[i] = -1; cha[i] = -1; }

    size_t dau = 0, cuoi = 0;

    kc[nguon]        = 0;
    hang_doi[cuoi++] = (size_t)nguon;

    while (dau < cuoi) {
        size_t u = hang_doi[dau++];

        for (size_t i = g->dinh[u]; i < g->dinh[u + 1]; ++i) {
            int v = g->ke[i];

            if (kc[v] != -1) continue;      /* đã thăm */

            kc[v]            = kc[u] + 1;
            cha[v]           = (int)u;
            hang_doi[cuoi++] = (size_t)v;
        }
    }

    free(hang_doi);

    return 0;
}
Ứng dụng của BFSVì sao BFS phù hợp
Đường đi ngắn nhất trên đồ thị không trọng sốThăm theo tầng nên tầng k là mọi đỉnh cách nguồn đúng k cạnh
Tìm đường trong mê cungMỗi ô là một đỉnh, mọi bước có chi phí như nhau
Số bước ít nhất trong trò chơi ghép hìnhMỗi trạng thái là một đỉnh, mỗi nước đi là một cạnh
Kiểm tra đồ thị hai phíaTô màu xen kẽ theo tầng, gặp mâu thuẫn thì không phải hai phía
Tìm mọi đỉnh trong bán kính kDừng khi kc vượt quá k

#DFS: duyệt theo chiều sâu

dfs.c
/* Bản đệ quy, ngắn và tự nhiên. */
static void dfs_de_quy(const DoThi *g, size_t u, int *da_tham)
{
    da_tham[u] = 1;

    printf("%zu ", u);

    for (size_t i = g->dinh[u]; i < g->dinh[u + 1]; ++i) {
        int v = g->ke[i];

        if (!da_tham[v])
            dfs_de_quy(g, (size_t)v, da_tham);
    }
}

/* Bản lặp, dùng ngăn xếp tường minh. Không sợ tràn ngăn xếp. */
int dfs_lap(const DoThi *g, int nguon, int *da_tham)
{
    size_t *nx = malloc(g->V * sizeof *nx);

    if (nx == NULL) return -1;

    size_t n = 0;

    nx[n++] = (size_t)nguon;

    while (n > 0) {
        size_t u = nx[--n];

        if (da_tham[u]) continue;      /* có thể vào ngăn xếp nhiều lần */

        da_tham[u] = 1;
        printf("%zu ", u);

        /* Đẩy ngược để lấy ra theo đúng thứ tự láng giềng */
        for (size_t i = g->dinh[u + 1]; i-- > g->dinh[u]; ) {
            int v = g->ke[i];

            if (!da_tham[v]) nx[n++] = (size_t)v;
        }
    }

    free(nx);

    return 0;
}
BFSDFS
Cấu trúc dữ liệuHàng đợiNgăn xếp hoặc đệ quy
Bộ nhớ phụO(V), tệ nhất cả một tầngO(chiều sâu)
Đường đi ngắn nhất không trọng sốCóKhông
Phát hiện chu trìnhĐược, nhưng lúng túngTự nhiên
Sắp xếp tô-pôĐược, dùng bậc vàoTự nhiên, hậu thứ tự
Thành phần liên thông mạnhKhôngCó, Tarjan hoặc Kosaraju
Với đồ thị rất sâuỔnCó thể tràn ngăn xếp nếu đệ quy

#Thành phần liên thông

lien-thong.c
/* Gán mỗi đỉnh một số hiệu thành phần. Trả về số thành phần.
   O(V + E). */
int thanh_phan(const DoThi *g, int *tp)
{
    for (size_t i = 0; i < g->V; ++i) tp[i] = -1;

    int so_tp = 0;

    for (size_t s = 0; s < g->V; ++s) {
        if (tp[s] != -1) continue;      /* đã thuộc một thành phần */

        /* BFS từ s, gán mọi đỉnh tới được số hiệu so_tp */
        size_t *hd  = malloc(g->V * sizeof *hd);

        if (hd == NULL) return -1;

        size_t dau = 0, cuoi = 0;

        tp[s]      = so_tp;
        hd[cuoi++] = s;

        while (dau < cuoi) {
            size_t u = hd[dau++];

            for (size_t i = g->dinh[u]; i < g->dinh[u + 1]; ++i) {
                int v = g->ke[i];

                if (tp[v] == -1) { tp[v] = so_tp; hd[cuoi++] = (size_t)v; }
            }
        }

        free(hd);
        ++so_tp;
    }

    return so_tp;
}
terminal
./lien-thong
do thi 8 dinh, canh: 0-1 1-2 3-4 5-6 6-7 5-7

so thanh phan: 3
  thanh phan 0: 0 1 2
  thanh phan 1: 3 4
  thanh phan 2: 5 6 7

dinh co lap: khong co

Ứng dụng: tô màu ảnh

to-mau.c
/* Mỗi điểm ảnh là một đỉnh, hai điểm kề nhau và cùng màu thì có cạnh.
   Tìm thành phần liên thông chính là tìm các vùng màu.
   Đây là thuật toán tô loang trong mọi phần mềm vẽ. */
static void to_loang(int *anh, int W, int H, int x, int y,
                     int mau_cu, int mau_moi)
{
    if (mau_cu == mau_moi) return;      /* nếu không thì lặp vô hạn */

    /* Bản lặp, tránh tràn ngăn xếp với vùng lớn */
    int   *nx = malloc((size_t)W * H * sizeof *nx);
    size_t n  = 0;

    if (nx == NULL) return;

    nx[n++] = y * W + x;

    while (n > 0) {
        int p = nx[--n];
        int px = p % W, py = p / W;

        if (px < 0 || px >= W || py < 0 || py >= H) continue;
        if (anh[p] != mau_cu) continue;

        anh[p] = mau_moi;

        if (px > 0)     nx[n++] = p - 1;
        if (px < W - 1) nx[n++] = p + 1;
        if (py > 0)     nx[n++] = p - W;
        if (py < H - 1) nx[n++] = p + W;
    }

    free(nx);
}

#Phát hiện chu trình

chu-trinh.c
/* Đồ thị VÔ HƯỚNG: có chu trình nếu gặp một đỉnh đã thăm
   mà không phải đỉnh cha trực tiếp. */
static int co_chu_trinh_vo_huong(const DoThi *g, size_t u, int cha,
                                 int *da_tham)
{
    da_tham[u] = 1;

    for (size_t i = g->dinh[u]; i < g->dinh[u + 1]; ++i) {
        int v = g->ke[i];

        if (!da_tham[v]) {
            if (co_chu_trinh_vo_huong(g, (size_t)v, (int)u, da_tham))
                return 1;
        } else if (v != cha) {
            return 1;      /* cạnh ngược, tức có chu trình */
        }
    }

    return 0;
}

/* Đồ thị CÓ HƯỚNG: cần ba trạng thái, không phải hai.
     0 = chưa thăm
     1 = đang trong ngăn xếp đệ quy
     2 = đã xong

   Gặp một đỉnh ở trạng thái 1 nghĩa là quay lại chính nhánh đang đi,
   tức có chu trình. Gặp trạng thái 2 thì không sao. */
static int co_chu_trinh_co_huong(const DoThi *g, size_t u, int *trang_thai)
{
    trang_thai[u] = 1;

    for (size_t i = g->dinh[u]; i < g->dinh[u + 1]; ++i) {
        int v = g->ke[i];

        if (trang_thai[v] == 1) return 1;              /* chu trình */

        if (trang_thai[v] == 0 &&
            co_chu_trinh_co_huong(g, (size_t)v, trang_thai))
            return 1;
    }

    trang_thai[u] = 2;      /* xong nhánh này */

    return 0;
}

#Sắp xếp tô-pô

Sắp xếp tô-pô
Xếp các đỉnh của một đồ thị có hướng không chu trình thành một dãy sao cho mọi cạnh đều đi từ trái sang phải. Tồn tại khi và chỉ khi đồ thị không có chu trình.
to-po.c
/* Cách 1: Kahn, dùng bậc vào và một hàng đợi.
   Trả về số đỉnh đã xếp. Nhỏ hơn V nghĩa là có chu trình. */
size_t to_po_kahn(const DoThi *g, int *ra)
{
    size_t *bac_vao = calloc(g->V, sizeof *bac_vao);
    size_t *hd      = malloc(g->V * sizeof *hd);

    if (bac_vao == NULL || hd == NULL) { free(bac_vao); free(hd); return 0; }

    for (size_t u = 0; u < g->V; ++u)
        for (size_t i = g->dinh[u]; i < g->dinh[u + 1]; ++i)
            ++bac_vao[g->ke[i]];

    size_t dau = 0, cuoi = 0;

    for (size_t u = 0; u < g->V; ++u)
        if (bac_vao[u] == 0) hd[cuoi++] = u;

    size_t k = 0;

    while (dau < cuoi) {
        size_t u = hd[dau++];

        ra[k++] = (int)u;

        for (size_t i = g->dinh[u]; i < g->dinh[u + 1]; ++i)
            if (--bac_vao[g->ke[i]] == 0)
                hd[cuoi++] = (size_t)g->ke[i];
    }

    free(bac_vao);
    free(hd);

    return k;      /* k < V nghĩa là có chu trình */
}

/* Cách 2: DFS, ghi đỉnh vào kết quả theo HẬU thứ tự rồi đảo ngược. */
static void to_po_dfs(const DoThi *g, size_t u, int *trang_thai,
                      int *ra, size_t *k)
{
    trang_thai[u] = 1;

    for (size_t i = g->dinh[u]; i < g->dinh[u + 1]; ++i)
        if (trang_thai[g->ke[i]] == 0)
            to_po_dfs(g, (size_t)g->ke[i], trang_thai, ra, k);

    trang_thai[u] = 2;
    ra[(*k)--]    = (int)u;      /* điền từ CUỐI mảng về đầu */
}
terminal
./to-po
phu thuoc bien dich:
  main.c   -> can  util.h  list.h
  util.c   -> can  util.h
  list.c   -> can  list.h  util.h
  util.h   -> can  (khong)
  list.h   -> can  util.h

thu tu bien dich hop le:
  util.h  list.h  util.c  list.c  main.c

kiem tra: moi canh deu di tu trai sang phai   dung
# Với đồ thị có chu trình
./to-po vong.txt
a.h -> b.h -> c.h -> a.h

chi xep duoc 0 trong 3 dinh
CO CHU TRINH, khong sap xep to-po duoc
KahnDFS
Cấu trúc dữ liệuHàng đợi và mảng bậc vàoNgăn xếp đệ quy
Phát hiện chu trìnhTự nhiên, đếm số đỉnh xếp đượcCần ba trạng thái
Cho ra thứ tự nàoƯu tiên đỉnh sẵn sàng sớmƯu tiên đi sâu
Chọn được thứ tự cụ thểCó, đổi hàng đợi thành heapKhó
Đệ quyKhôngCó, có thể tràn

Tự làm thử

  1. Cài đồ thị dạng CSR và dựng nó từ danh sách cạnh, kiểm với đồ thị năm đỉnh trong bài.
  2. Cài BFS và xác nhận thứ tự thăm là 0 1 2 3 4, rồi cài DFS và xác nhận thứ tự là 0 1 3 2 4.
  3. Viết bản BFS đánh dấu khi lấy ra, chạy dưới -fsanitize=address và đọc báo cáo tràn.
  4. Cài phát hiện chu trình có hướng với hai trạng thái, rồi tìm đồ thị ba đỉnh làm nó báo sai.
  5. Cài sắp xếp tô-pô bằng cả Kahn và DFS, dùng nó tính thứ tự biên dịch cho một dự án C thật.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Ma trận kề tốn O(V bình phương) bộ nhớ nhưng tra cạnh O(1). Danh sách kề dạng CSR tốn O(V + E) và thân thiện bộ nhớ đệm.
  • BFS dùng hàng đợi và cho đường đi ngắn nhất theo số cạnh. DFS dùng ngăn xếp và tự nhiên cho chu trình, tô-pô, thành phần liên thông mạnh.
  • BFS phải đánh dấu đã thăm khi đẩy vào hàng đợi, nếu không một đỉnh vào nhiều lần và mảng bị tràn.
  • Phát hiện chu trình trên đồ thị có hướng cần ba trạng thái. Hai trạng thái báo nhầm với đồ thị hoàn toàn không có chu trình.
  • Sắp xếp tô-pô bằng Kahn phát hiện chu trình miễn phí: số đỉnh xếp được nhỏ hơn V thì có chu trình.