Nhập môn

Thuật toán quay lui (Backtracking): khuôn mẫu 3 bước, code C++ và Python

Thuật toán quay lui (backtracking) là kỹ thuật tìm kiếm lời giải bằng cách xây dựng cấu hình từng bước một theo nhánh đệ quy; mỗi khi phát hiện nhánh đang đi không thể dẫn tới kết quả hợp lệ hoặc đã liệt kê xong một nghiệm, thuật toán sẽ hoàn tác bước vừa chọn để lùi về ngã rẽ trước đó và thử lựa chọn khác.

Ý chính
  • Mọi bài quay lui đều viết theo cùng một khuôn mẫu 3 bước bên trong vòng lặp: (1) Ghi nhận lựa chọn → (2) Gọi đệ quy sang bước kế → (3) Hoàn tác lựa chọn (bỏ đánh dấu).
  • Khác với vét cạn mù quáng ở cuối cùng, quay lui kiểm tra điều kiện ngay ở từng bước (cắt nhánh — pruning) để loại bỏ sớm cả một nhánh cây khổng lồ nếu bước đầu đã vi phạm.
  • Vì số cấu hình tăng theo hàm mũ O(2ⁿ) hoặc giai thừa O(n!), quay lui thường áp dụng khi giới hạn n nhỏ (n ≤ 20 với sinh tập con, n ≤ 10 với sinh hoán vị, n ≤ 14 với N quân hậu).

Hình dung thuật toán quay lui qua việc đi tìm lối ra trong mê cung

Hãy tưởng tượng bạn đang đi trong một mê cung có nhiều ngã rẽ:

  • Tại ngã rẽ thứ nhất, bạn đánh dấu phấn xuống đất rồi rẽ trái.
  • Đi thêm một đoạn lại gặp ngã rẽ thứ hai, bạn tiếp tục rẽ trái nhưng đụng phải ngõ cụt.
  • Bạn không cần phải quay về tận cổng xuất phát! Bạn chỉ cần lùi lại đúng ngã rẽ thứ hai vừa rồi, xóa vết phấn rẽ trái và thử sang lối rẽ phải.
  • Nếu cả hai lối ở ngã rẽ thứ hai đều cụt, bạn mới lùi tiếp về ngã rẽ thứ nhất.

Đó chính là quay lui (backtracking) được cài đặt bằng đệ quy: mỗi tầng đệ quy phụ trách quyết định tại một ngã rẽ; khi lời gọi đệ quy kết thúc và trả quyền điều khiển về tầng trước, ta khôi phục trạng thái cũ để thử phương án tiếp theo trong vòng lặp.

Khuôn mẫu 3 bước dùng chung cho mọi bài quay lui

Gần như 100% các bài toán liệt kê cấu hình (sinh dãy nhị phân, sinh tập con, sinh tổ hợp, sinh hoán vị, giải Sudoku, N quân hậu) đều có chung bộ khung sau:

  1. Kiểm tra điều kiện hoàn thành: Nếu cấu hình hiện tại cur đã đủ độ dài n, ta ghi nhận hoặc in ra nghiệm rồi return.
  2. Duyệt qua các ứng viên có thể chọn cho bước hiện tại: Với mỗi ứng viên v chưa bị cấm (thỏa điều kiện cắt nhánh):
    • Bước 1 (Chọn): Thêm v vào cấu hình cur và bật mảng đánh dấu daDung[v] = true.
    • Bước 2 (Đi tiếp): Gọi đệ quy để điền vị trí tiếp theo.
    • Bước 3 (Hoàn tác — Backtrack): Ngay sau dòng gọi đệ quy, rút v ra khỏi cur (cur.pop_back()) và trả lại daDung[v] = false để trạng thái sạch như cũ cho lần lặp kế tiếp.

Ví dụ 1: Liệt kê mọi hoán vị của {1, 2, ..., n}

Cần xếp n = 3 số {1, 2, 3} vào 3 vị trí sao cho mỗi số xuất hiện đúng 1 lần. Hãy nhìn nhánh đầu tiên của cây quyết định khi vị trí đầu chọn số 1:

Cây quyết định (nhánh bắt đầu bằng 1) [ 1 ] chọn 2 chọn 3 [ 1, 2 ] [ 1, 3 ] [ 1, 2, 3 ] [ 1, 3, 2 ] In xong [1, 2, 3] → bỏ 3, bỏ 2 (quay lui về [1]) → thử sang nhánh chọn 3 Nghiệm 4 quân hậu (N = 4) ♛ ♛ ♛ ♛ Cột đặt theo hàng: [1, 3, 0, 2]
Nhánh bắt đầu bằng 1 của hoán vị {1, 2, 3} [ 1 ] [ 1, 2 ] [ 1, 3 ] [ 1, 2, 3 ] [ 1, 3, 2 ] Xong [1, 2, 3] → bỏ 3, bỏ 2 → thử sang [1, 3]
Hình 1. Trái: Nhánh bắt đầu bằng số 1 trong cây quyết định sinh hoán vị {1, 2, 3}. Phải: Một nghiệm hợp lệ của bài toán 4 quân hậu.
  • Ở gốc [], chọn 1 → đánh dấu daDung[1] = true, cấu hình thành [1].
  • Tại [1], số 1 đã dùng nên vòng lặp chọn tiếp 2 → cấu hình thành [1, 2].
  • Tại [1, 2], chỉ còn 3 chưa dùng → cấu hình thành [1, 2, 3] (đủ 3 số, in ra 123).
  • Quay lui: rút 3 ra, rồi rút 2 ra (daDung[2] = false), cấu hình quay về [1]. Bây giờ vòng lặp tại [1] thử sang ứng viên tiếp theo là 3 → đi tới [1, 3] rồi [1, 3, 2] (in ra 132).
#include <iostream>
#include <vector>
using namespace std;

void quayLuiHoanVi(int n, vector<int>& cur, vector<bool>& daDung) {
    if ((int)cur.size() == n) {  // đã xếp đủ n vị trí -> in cấu hình
        for (int x : cur) cout << x;
        cout << " ";
        return;
    }
    for (int v = 1; v <= n; ++v) {
        if (daDung[v]) continue;
        // 1. Ghi nhận lựa chọn
        daDung[v] = true;
        cur.push_back(v);

        // 2. Đi tiếp sang vị trí kế tiếp
        quayLuiHoanVi(n, cur, daDung);

        // 3. Hoàn tác lựa chọn (quay lui) để thử giá trị v khác
        cur.pop_back();
        daDung[v] = false;
    }
}

int main() {
    int n = 3;
    vector<int> cur;
    vector<bool> daDung(n + 1, false);
    quayLuiHoanVi(n, cur, daDung);
    cout << "\n";  // 123 132 213 231 312 321
    return 0;
}
Học cùng giảng viên

Chủ đề này nằm trong Lecture 03: Backtracking (Quay lui) của Khóa Orange. Xem đề cương

Ví dụ 2: Bài toán N quân hậu (N-Queens) và kỹ thuật Cắt nhánh

Đề bài: Đặt N quân hậu lên bàn cờ vua kích thước N × N sao cho không có hai quân hậu nào ăn nhau (tức không có hai quân hậu nào nằm cùng hàng, cùng cột, hoặc cùng đường chéo). Đếm số cách đặt hợp lệ.

  • Nếu vét cạn đặt thử mọi cách trên bàn cờ 8 × 8 rồi cuối cùng mới kiểm tra, số cách chọn 8 ô trong 64 ô là hơn 4,4 tỷ cách.
  • Nếu nhận xét mỗi hàng bắt buộc phải có đúng 1 quân hậu và đặt lần lượt từ hàng 0 đến hàng N - 1, ngay khi định đặt quân hậu ở ô (r, c) mà thấy cột c hoặc hai đường chéo đi qua (r, c) đã có hậu ở các hàng trên, ta cắt bỏ ngay nhánh đó không xét các hàng dưới nữa!

Mẹo đánh dấu đường chéo trong O(1) cực kỳ gọn:

  • Mọi ô (r, c) nằm trên cùng một đường chéo chính (hướng \ ) đều có hiệu r - c bằng nhau (cộng thêm n thành r - c + n để chỉ số không bị âm).
  • Mọi ô (r, c) nằm trên cùng một đường chéo phụ (hướng / ) đều có tổng r + c bằng nhau.
#include <iostream>
#include <vector>
using namespace std;

int demNQuanHau(int n) {
    vector<bool> cot(n, false), cheoChinh(2 * n, false), cheoPhu(2 * n, false);
    int soCach = 0;

    auto datHau = [&](auto& self, int r) -> void {
        if (r == n) {  // đã đặt xong cả n hàng từ 0 đến n - 1
            soCach++;
            return;
        }
        for (int c = 0; c < n; ++c) {
            if (cot[c] || cheoChinh[r - c + n] || cheoPhu[r + c]) continue;  // cắt nhánh bị ăn
            cot[c] = cheoChinh[r - c + n] = cheoPhu[r + c] = true;
            self(self, r + 1);
            cot[c] = cheoChinh[r - c + n] = cheoPhu[r + c] = false;         // hoàn tác
        }
    };

    datHau(datHau, 0);
    return soCach;
}

int main() {
    cout << demNQuanHau(4) << " " << demNQuanHau(8) << "\n";  // 2 92
    return 0;
}

Nhờ cắt nhánh sớm ngay từ hàng đầu, với N = 8, trong số 8⁸ = 16.777.216 cấu hình đặt 8 hàng, hàm quay lui chỉ phải ghé thăm đúng 2.057 nút để tìm ra đầy đủ 92 nghiệm!

Độ phức tạp của thuật toán quay lui

Độ phức tạp thời gian của quay lui phụ thuộc vào số nút trên cây quyết định không bị cắt bỏ:

Dạng bài toán quay lui Số cấu hình lá tối đa Độ phức tạp thời gian Giới hạn n chạy kịp 1 giây
Sinh mọi dãy nhị phân độ dài n / mọi tập con 2n O(n · 2n) n ≤ 20 .. 22
Sinh mọi tổ hợp chập k của n phần tử C(n, k) O(k · C(n, k)) n ≤ 25 .. 30 (tùy k)
Sinh mọi hoán vị của n phần tử n! O(n · n!) n ≤ 10 .. 11
Bài toán N quân hậu (có cắt nhánh) Nhỏ hơn n! rất nhiều Khoảng vài chục nghìn bước ở n = 12 n ≤ 13 .. 14

Độ phức tạp bộ nhớ phụ của các bài trên chỉ là O(n) (chiều cao của cây đệ quy và vài mảng đánh dấu độ dài n).

Lỗi hay gặp

Cẩn thận
  • Quên bước hoàn tác (bỏ chọn) sau khi gọi đệ quy. Nếu bạn thêm cur.push_back(v) và daDung[v] = true trước khi gọi đệ quy nhưng quên cur.pop_back() và daDung[v] = false ngay phía sau, mảng cur sẽ phình dài mãi và các số bị khóa cứng không bao giờ dùng được cho nhánh khác.
  • Lưu trực tiếp tham chiếu danh sách cur vào mảng kết quả trong Python. Trong Python, nếu ở điểm dừng bạn viết ket_qua.append(cur) thì bạn chỉ lưu tham chiếu tới danh sách cur. Khi thuật toán quay lui và cur.pop() rút hết phần tử ra, mọi danh sách trong ket_qua cũng biến thành rỗng []! Hãy luôn chép bản sao: ket_qua.append(list(cur)) hoặc cur.copy().
  • Sinh trùng lặp hoán vị khi các phần tử đầu vào giống nhau. Ví dụ sinh hoán vị của [1, 1, 2]: để không sinh lặp hai lần [1, 1, 2], bạn hãy sắp xếp mảng đầu vào trước và thêm điều kiện bỏ qua nếu a[i] == a[i - 1] và phần tử a[i - 1] chưa được dùng ở tầng trên.

Luyện ngay trên Big-O Coding

Thực hành ngay tư duy quay lui và duyệt cấu hình qua các bài tập dưới đây trên hệ thống Big-O Coding:

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

Quay lui (Backtracking) khác gì với Vét cạn (Brute Force)?

Vét cạn thuần túy thường tạo ra đầy đủ cả cấu hình độ dài n rồi mới kiểm tra xem cấu hình đó có hợp lệ không. Quay lui thông minh hơn: nó xây dựng từng bước từ 1 đến n và kiểm tra tính hợp lệ ngay ở mỗi bước; hễ thấy bước thứ k đã vi phạm quy tắc (ví dụ 2 quân hậu ăn nhau ngay ở hàng thứ 2) thì lập tức cắt bỏ toàn bộ nhánh con phía dưới mà không cần đi tiếp tới hàng thứ n.

Quay lui khác gì với Duyệt theo chiều sâu (DFS) trên đồ thị?

Về mặt cơ chế, quay lui chính là DFS trên một cây trạng thái ẩn (implicit state tree). Khác biệt nhỏ là trên đồ thị thông thường, mỗi đỉnh thường chỉ thăm 1 lần nên ta giữ nguyên visited[u] = true; còn trong quay lui tìm mọi đường đi hoặc liệt kê cấu hình, khi lùi khỏi một nhánh ta phải trả lại daDung[v] = false để nhánh khác vẫn có thể sử dụng phần tử v đó.

Nhánh cận (Branch and Bound) là gì?

Nhánh cận là thuật toán quay lui áp dụng cho bài toán tìm giá trị tối ưu (nhỏ nhất hoặc lớn nhất). Bên cạnh việc cắt các nhánh không hợp lệ, ta còn giữ một biến best lưu kỷ lục tốt nhất đã tìm thấy; tại một nhánh đang đi dở, nếu ước lượng thấy dù đi tiếp thuận lợi nhất cũng không thể vượt qua được kỷ lục best hiện có thì ta cắt bỏ nhánh đó ngay lập tức.

Muốn học bài bản cùng giảng viên?

Khóa Orange: làm chủ thuật toán nâng cao như Graph, DP tối ưu, Segment Tree, HLD để giải bài Hard. 16 buổi online, C++/Java/Python, học thử buổi 1 miễn phí.

Xem Khóa Orange

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483