Bài tập duyệt đồ thị theo chiều sâu năm 2024

Một vấn đề rất quan trọng trong Lý thuyết đồ thị là bài toán duyệt tất cả các đỉnh có thể đến được từ một đỉnh xuất phát nào đó, không duyệt lặp lại cũng như không bỏ sót đỉnh nào cả. Vì vậy, ta phải xây dựng được những phép duyệt các đỉnh của đồ thị theo một hệ thống nhất định, những phép duyệt đó gọi là các thuật toán tìm kiếm trên đồ thị.

Có hai giải thuật tìm kiếm trên đồ thị cơ bản: Tìm kiếm theo chiều sâu [Depth First Search - DFS] và Tìm kiếm theo chiều rộng [Breadth First Search - BFS]. Hai giải thuật này có độ phức tạp thuật toán như nhau, nhưng sẽ có những ứng dụng khác nhau và cách cài đặt cũng khác nhau.

Xét bài toán sau: Cho đơn đồ thị vô hướng gồm nn đỉnh, mm cạnh, danh sách các cạnh được nhập vào theo dạng [u,v][u, v] với uu và vv là các đỉnh thuộc đồ thị [1≤u≠v≤n][1 \le u \ne v \le n]. Hãy đưa ra một đường đi từ đỉnh ss tới đỉnh ff cho trước.

Dưới đây ta sẽ cài đặt hai giải thuật DFS\text{DFS} và BFS\text{BFS} để giải quyết bài toán này. Các cài đặt đều sử dụng danh sách kề, nếu như đề bài thay đổi thành đa đồ thị hay đồ thị có hướng thì chỉ cần sửa đổi một chút trong quá trình nhập dữ liệu.

II. Giải thuật tìm kiếm theo chiều sâu [Depth First Search]

Tư tưởng thuật toán:

  • Nhận thấy rằng, mọi đỉnh kề với ss đều có thể đến được từ ss. Với mỗi đỉnh xx kề với ss, thì tất nhiên các đỉnh yy kề với xx cũng sẽ đến được từ ss,...Từ đây, ta có thể xây dựng một hàm tìm kiếm DFS[u]\text{DFS}[u] để duyệt các đỉnh kề với đỉnh uu, hàm này sẽ gọi tiếp tới hàm DFS[v]\text{DFS}[v] với vv là đỉnh kề với uu,...Quá trình tìm kiếm sẽ bắt đầu với lời gọi DFS[s]\text{DFS}[s].
  • Để không đỉnh nào bị thăm lại, khi duyệt tới một đỉnh vv nào đó, ta sẽ đánh dấu lại nó đã được thăm rồi, bằng cách sử dụng một mảng parvpar_v là đỉnh liền trước vv trong quá trình gọi đệ quy [hay còn gọi là đỉnh cha của đỉnh vv]. Khi đó, điều kiện để một đỉnh vv chưa thăm sẽ là parv=0par_v = 0, và khi duyệt tới một đỉnh vv kề với đỉnh uu, ta sẽ gán parv=upar_v=u, vừa để thể hiện đỉnh liền trước vv trên đường đi từ ss tới vv, vừa để đánh dấu đỉnh vv đã thăm rồi.
  • Kết thúc giải thuật, đường đi từ ss tới ff sẽ là:

f←p1=parf←p2=parp1←...←sf \leftarrow p_1=par_f \leftarrow p_2=par_{p_1} \leftarrow ... \leftarrow s

Cài đặt:

void dfs[int u] // Tìm kiếm theo chiều sâu từ u.
{
    for [int v: adj[u]]
    {
        if [v == par[u]] // Kiểm tra v không được trùng với cha của u.
            continue;
        par[v] = u; // Đặt cha của v là u.
        dfs[v]; // Tiếp tục tiến vào con của u.
    }
}
void trace[int s, int f]
{
    vector < int > path; // Lưu đường đi từ s tới f.
    while [f != 0]
    {
        path.push_back[f];
        f = trace[f];
    }
    for [int i = path.size[] - 1; i >= 0; --i] // In ngược lại để thu được thứ tự từ s -> f.
        cout > n >> m >> s >> f;
    for [int i = 1; i > u >> v;
        adj[u].push_back[v];
        adj[v].push_back[u];
    }
    dfs[s]; // Tìm kiếm theo chiều sâu bắt đầu từ s.
    if [trace[f] == 0] // Đỉnh f chưa được thăm thì thông báo không tìm được đường đi.
        cout  2 -> 3 -> 5

Hình vẽ dưới đây cho ta minh họa về quá trình duyệt DFS\text{DFS}:

Nhận xét:

  • Nhờ vào kĩ thuật đánh dấu đường đi, nên hàm DFS[u]\text{DFS}[u] chỉ được gọi không quá nn lần.
  • Có thể tồn tại nhiều đường đi từ ss tới f,f, nhưng giải thuật DFS\text{DFS} luôn luôn trả về đường đi có thứ tự từ điển nhỏ nhất.
  • Quá trình DFS\text{DFS} sẽ cho ta một cây DFS\text{DFS} gốc ss. Khi đó, tồn tại một quan hệ cha - con trên cây được định nghĩa là: Nếu từ đỉnh uu tới thăm đỉnh vv [DFS[u]\big[\text{DFS}[u] gọi DFS[v]]\text{DFS}[v]\big] thì uu là cha của vv.

III. Giải thuật tìm kiếm theo chiều rộng [Breadth First Search]

Tư tưởng thuật toán:

  • Dựa trên tư tưởng lập ra một thứ tự duyệt các đỉnh, sao cho các đỉnh gần ss hơn sẽ luôn luôn được duyệt xong trước các đỉnh xa ss hơn. Ví dụ, từ đỉnh ss ta thăm các đỉnh kề với nó là x1,x2,...,xkx_1,x_2,...,x_k. Khi thăm tới x1x_1, sẽ phát sinh việc thăm các đỉnh kề với x1x_1 là x1′,x2′,...,xk′′x'_1 , x'_2,...,x'_{k'}. Dĩ nhiên các đỉnh này đều xa ss hơn so với x1,x2,...,xkx_1, x_2,..., x_k, cho nên chúng sẽ chỉ được thăm sau khi toàn bộ các đỉnh x1,x2,...,xkx_1, x_2,..., x_k đã được thăm hết.
  • Quá trình thăm theo cách này sẽ tạo ra một cây BFS\text{BFS} rộng và hẹp, do đó gọi là tìm kiếm theo chiều rộng.

Thứ tự thăm các đỉnh bằng BFS

  • Để xây dựng một thứ tự như vậy, ta sẽ tạo ra một danh sách gồm các đỉnh đang được "chờ" thăm. Tại mỗi bước, thăm đỉnh ở đầu danh sách và thêm toàn bộ các đỉnh kề với đỉnh đó [mà chưa được thăm] vào cuối danh sách, như vậy danh sách sẽ được xây dựng thành các lớp liền nhau, mỗi lớp bao gồm các đỉnh cùng kề với một đỉnh nào đó. Cấu trúc dữ liệu phù hợp nhất để xây dựng danh sách này là hàng đợi [queue].
  • Việc truy vết cũng diễn ra tương tự như giải thuật DFS\text{DFS}, ta dùng một mảng tracevtrace_v để lưu lại đỉnh liền trước với đỉnh vv trong quá trình duyệt, và kiêm luôn chức năng đánh dấu đỉnh vv đã được thăm hay chưa.

Cài đặt:

void trace[int s, int f]
{
    vector < int > path; // Lưu đường đi từ s tới f.
    while [f != 0]
    {
        path.push_back[f];
        f = trace[f];
    }
    for [int i = path.size[] - 1; i >= 0; --i] // In ngược lại để thu được thứ tự từ s -> f.
        cout  vertexes;
    vertexes.push[s];
    while [!vertexes.empty[]]
    {
        int u = vertexes.front[];
        vertexes.pop[]; Thăm xong đỉnh ở đầu queue, pop nó ra.
        if [u == f] // Nếu đã tới được f thì truy vết và dừng luôn BFS.
        {
            trace[s, f];
            return;
        }
        for [int v: adj[u]] // Duyệt các đỉnh kề với u.
            if [trace[v] == 0] // Nếu đỉnh đó chưa thăm.
            {
                trace[v] = u; // Lưu vết đường đi tới v.
                vertexes.push[v]; // Đẩy vào queue.
            }
    }
    cout  adj[100001];
void dfs[int u]
{
    visited[u] = true;
    for [int v: par[u]]
        if [!visited[v]]
            dfs[v];
}
int count_ccp[]
{
    int ccp_amount = 0; // Số thành phần liên thông.
    // Duyệt từng đỉnh, đỉnh nào chưa thăm thì thăm DFS từ đỉnh đó.
    for [int u = 1; u > n >> m;
    // Xây dựng danh sách kề của đồ thị.
    for [int i = 1; i > u >> v;
        // Do đồ thị là vô hướng nên u thuộc danh sách kề của v và ngược lại.
        adj[u].push_back[v];
        adj[v].push_back[u];
    }
    cout 

Chủ Đề