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

Quay lui

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

  • Viết khung quay lui chuẩn ba bước
  • Cài bài toán tám hậu và đếm số lời giải
  • Thêm cắt tỉa để giảm không gian tìm kiếm
  • Cài trình giải sudoku

Quay lui thử mọi khả năng một cách có tổ chức: chọn, đi tiếp, và khi gặp ngõ cụt thì bỏ chọn rồi thử phương án khác. Sức mạnh thật của nó không nằm ở việc thử hết, mà ở việc không thử những nhánh chắc chắn hỏng.

#Khung ba bước

Quay lui
Duyệt cây các khả năng theo chiều sâu. Mỗi nút là một lời giải dở dang, mỗi cạnh là một lựa chọn. Khi một nhánh không dẫn tới đâu thì quay lại nút cha và thử cạnh khác.
void quay_lui(TrangThai *tt)
{
    if (da_xong(tt)) { ghi_nhan(tt); return; }

    for (moi lua chon hop le) {
        if (!an_toan(tt, lua_chon)) continue;   /* CAT TIA */

        chon(tt, lua_chon);                     /* 1. CHON */
        quay_lui(tt);                           /* 2. DE QUY */
        bo_chon(tt, lua_chon);                  /* 3. BO CHON */
    }
}
  1. Chọn

    Ghi lựa chọn vào trạng thái. Ví dụ đặt một quân hậu vào ô, điền một số vào ô sudoku.

  2. Đệ quy

    Giải phần còn lại với trạng thái đã cập nhật. Nếu tới đích thì ghi nhận lời giải.

  3. Bỏ chọn

    Khôi phục trạng thái về trước khi chọn, để vòng lặp thử được lựa chọn tiếp theo. Đây là bước hay bị quên nhất.

#Bài toán tám hậu

Đặt n quân hậu lên bàn cờ n nhân n sao cho không quân nào ăn được quân nào. Hậu ăn theo hàng, cột và hai đường chéo.

n-hau.c
#include <stdio.h>
#include <stdlib.h>

/* q[i] = cột của quân hậu ở hàng i.
   Biểu diễn này TỰ ĐỘNG bảo đảm mỗi hàng đúng một quân,
   nên chỉ cần kiểm cột và hai đường chéo. */
static int an_toan(const int *q, int hang, int cot)
{
    for (int i = 0; i < hang; ++i) {
        if (q[i] == cot) return 0;                     /* cùng cột */

        if (abs(q[i] - cot) == hang - i) return 0;     /* cùng đường chéo */
    }

    return 1;
}

/* Đếm số lời giải với các hàng từ 0 tới hang-1 đã đặt xong. */
int n_hau(int *q, int hang, int n)
{
    if (hang == n) return 1;      /* đặt đủ n quân, một lời giải */

    int dem = 0;

    for (int cot = 0; cot < n; ++cot)
        if (an_toan(q, hang, cot)) {
            q[hang] = cot;                 /* CHỌN */
            dem    += n_hau(q, hang + 1, n);/* ĐỆ QUY */
            /* q[hang] bị ghi đè ở vòng sau, không cần bỏ chọn */
        }

    return dem;
}

int main(void)
{
    int q[16];

    printf("%3s %12s\n", "n", "so loi giai");

    for (int n = 1; n <= 12; ++n)
        printf("%3d %12d\n", n, n_hau(q, 0, n));

    return 0;
}
terminal
gcc -std=c17 -O2 -Wall -Wextra n-hau.c -o t && ./t
  n  so loi giai
  1            1
  2            0
  3            0
  4            2
  5           10
  6            4
  7           40
  8           92
  9          352
 10          724
 11         2680
 12        14200

In ra bàn cờ

in-ban-co.c
static void in_ban_co(const int *q, int n)
{
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < n; ++j)
            printf("%c ", (q[i] == j) ? 'Q' : '.');

        printf("\n");
    }

    printf("\n");
}

/* Tìm lời giải ĐẦU TIÊN rồi dừng, thay vì đếm hết. */
int n_hau_mot(int *q, int hang, int n)
{
    if (hang == n) return 1;

    for (int cot = 0; cot < n; ++cot)
        if (an_toan(q, hang, cot)) {
            q[hang] = cot;

            if (n_hau_mot(q, hang + 1, n)) return 1;   /* dừng ngay */
        }

    return 0;
}
terminal
./n-hau-in 8
Q . . . . . . . 
. . . . Q . . . 
. . . . . . . Q 
. . . . . Q . . 
. . Q . . . . . 
. . . . . . Q . 
. Q . . . . . . 
. . . Q . . . . 

#Cắt tỉa

Cắt tỉa
Loại bỏ cả một nhánh của cây tìm kiếm khi biết chắc nó không chứa lời giải nào. Đây là thứ biến quay lui từ vét cạn vô vọng thành thuật toán dùng được.
cat-tia-bang-bit.c
/* Thay vong lap an_toan O(n) bang ba bit mask, kiem trong O(1).

   cot   : bit j bat neu cot j da bi chiem
   cheo1 : duong cheo chinh, chi so hang - cot + n - 1
   cheo2 : duong cheo phu,   chi so hang + cot                 */
static int dem_hau(int n, unsigned cot, unsigned cheo1, unsigned cheo2,
                   int hang)
{
    if (hang == n) return 1;

    int dem = 0;

    for (int c = 0; c < n; ++c) {
        unsigned bc  = 1u << c;
        unsigned b1  = 1u << (hang - c + n - 1);
        unsigned b2  = 1u << (hang + c);

        if ((cot & bc) || (cheo1 & b1) || (cheo2 & b2)) continue;

        dem += dem_hau(n, cot | bc, cheo1 | b1, cheo2 | b2, hang + 1);
    }

    return dem;
}

int n_hau_bit(int n) { return dem_hau(n, 0, 0, 0, 0); }
terminal
./do-cat-tia
     n   an_toan O(n)   bit mask O(1)   nhanh hon
     8       0.0004 s        0.0001 s     4.0 lan
    10       0.0120 s        0.0028 s     4.3 lan
    12       0.4120 s        0.0840 s     4.9 lan
    14      18.4000 s        3.2100 s     5.7 lan
nhanh-can.c
/* Ba lo 0/1 bang nhanh can. Voi n nho, no nhanh hon quy hoach dong
   khi W rat lon, vi no khong can bang O(n * W). */
static int tot_nhat;

/* Can tren: gia tri hien tai cong voi ba lo PHAN SO cua phan con lai.
   Ba lo phan so luon >= ba lo 0/1, nen day la can tren hop le. */
static double can_tren(const Mon *a, int n, int i, int con_lai, int gia_tri)
{
    double can = gia_tri;

    for (int k = i; k < n && con_lai > 0; ++k) {
        if (a[k].w <= con_lai) {
            can      += a[k].v;
            con_lai  -= a[k].w;
        } else {
            can += (double)a[k].v * con_lai / a[k].w;   /* cat mon */

            break;
        }
    }

    return can;
}

static void duyet(const Mon *a, int n, int i, int con_lai, int gia_tri)
{
    if (gia_tri > tot_nhat) tot_nhat = gia_tri;

    if (i == n) return;

    /* CAT TIA: nhanh nay khong the tot hon ket qua da co */
    if (can_tren(a, n, i, con_lai, gia_tri) <= tot_nhat) return;

    if (a[i].w <= con_lai)                                /* lay mon i */
        duyet(a, n, i + 1, con_lai - a[i].w, gia_tri + a[i].v);

    duyet(a, n, i + 1, con_lai, gia_tri);                 /* khong lay */
}
terminal
./do-nhanh-can
n = 40 mon, W = 1000000000:
  quy hoach dong : khong doi noi (can 4 GB)
  vet can 2^40   : khong doi noi (10^12 nut)
  nhanh can      : 0.012 s  (duyet 8412 nut)

n = 40 mon, W = 1000:
  quy hoach dong : 0.000 s
  nhanh can      : 0.001 s

#Trình giải sudoku

sudoku.c
#include <stdio.h>
#include <string.h>

/* bang[9][9], 0 nghĩa là ô trống. */
static int an_toan(const int bang[9][9], int r, int c, int v)
{
    for (int k = 0; k < 9; ++k) {
        if (bang[r][k] == v) return 0;      /* cùng hàng */
        if (bang[k][c] == v) return 0;      /* cùng cột */
    }

    int r0 = (r / 3) * 3, c0 = (c / 3) * 3;

    for (int i = 0; i < 3; ++i)
        for (int j = 0; j < 3; ++j)
            if (bang[r0 + i][c0 + j] == v) return 0;    /* cùng khối 3x3 */

    return 1;
}

/* Tìm ô trống có ÍT lựa chọn hợp lệ nhất. Đây là cắt tỉa mạnh nhất
   của bài này, gọi là heuristic giá trị còn lại nhỏ nhất. */
static int tim_o_kho_nhat(const int bang[9][9], int *ra_r, int *ra_c)
{
    int it_nhat = 10;

    for (int r = 0; r < 9; ++r)
        for (int c = 0; c < 9; ++c) {
            if (bang[r][c] != 0) continue;

            int dem = 0;

            for (int v = 1; v <= 9; ++v)
                if (an_toan(bang, r, c, v)) ++dem;

            if (dem == 0) return -1;      /* ô này không điền được gì, quay lui ngay */

            if (dem < it_nhat) {
                it_nhat = dem;
                *ra_r   = r;
                *ra_c   = c;
            }
        }

    return (it_nhat == 10) ? 0 : 1;      /* 0 nghĩa là đã điền hết */
}

int giai(int bang[9][9])
{
    int r, c;
    int tt = tim_o_kho_nhat(bang, &r, &c);

    if (tt == 0)  return 1;      /* xong */
    if (tt == -1) return 0;      /* bế tắc */

    for (int v = 1; v <= 9; ++v)
        if (an_toan(bang, r, c, v)) {
            bang[r][c] = v;              /* CHỌN */

            if (giai(bang)) return 1;    /* ĐỆ QUY */

            bang[r][c] = 0;              /* BỎ CHỌN */
        }

    return 0;
}
terminal
gcc -std=c17 -O2 -Wall -Wextra sudoku.c -o t && ./t de-kho.txt
de bai:
5 3 . | . 7 . | . . .
6 . . | 1 9 5 | . . .
. 9 8 | . . . | . 6 .
------+-------+------
8 . . | . 6 . | . . 3
4 . . | 8 . 3 | . . 1
7 . . | . 2 . | . . 6
------+-------+------
. 6 . | . . . | 2 8 .
. . . | 4 1 9 | . . 5
. . . | . 8 . | . 7 9

loi giai:
5 3 4 | 6 7 8 | 9 1 2
6 7 2 | 1 9 5 | 3 4 8
1 9 8 | 3 4 2 | 5 6 7
------+-------+------
8 5 9 | 7 6 1 | 4 2 3
4 2 6 | 8 5 3 | 7 9 1
7 1 3 | 9 2 4 | 8 5 6
------+-------+------
9 6 1 | 5 3 7 | 2 8 4
2 8 7 | 4 1 9 | 6 3 5
3 4 5 | 2 8 6 | 1 7 9

thoi gian: 0.0008 s, duyet 412 nut

#Sinh hoán vị và tổ hợp

sinh.c
#include <stdio.h>

/* Sinh moi hoan vi cua a[0..n). Doi cho tai cho, khong can bo dem phu. */
static void hoan_vi(int *a, int k, int n)
{
    if (k == n) {
        for (int i = 0; i < n; ++i) printf("%d ", a[i]);

        printf("\n");

        return;
    }

    for (int i = k; i < n; ++i) {
        int t = a[k]; a[k] = a[i]; a[i] = t;      /* CHON */

        hoan_vi(a, k + 1, n);

        t = a[k]; a[k] = a[i]; a[i] = t;          /* BO CHON */
    }
}

/* Sinh moi to hop k phan tu tu n phan tu. */
static void to_hop(int *chon, int bat_dau, int da_chon, int k, int n)
{
    if (da_chon == k) {
        for (int i = 0; i < k; ++i) printf("%d ", chon[i]);

        printf("\n");

        return;
    }

    /* CAT TIA: neu so phan tu con lai khong du thi bo nhanh */
    for (int i = bat_dau; i <= n - (k - da_chon); ++i) {
        chon[da_chon] = i;

        to_hop(chon, i + 1, da_chon + 1, k, n);
    }
}
terminal
./sinh hoan-vi 3
0 1 2 
0 2 1 
1 0 2 
1 2 0 
2 1 0 
2 0 1 
./sinh to-hop 5 3
0 1 2 
0 1 3 
0 1 4 
0 2 3 
0 2 4 
0 3 4 
1 2 3 
1 2 4 
1 3 4 
2 3 4 

#Giới hạn của quay lui

Bài toánKích thước xử lý đượcVì sao
N hậu, đếm hết lời giảin tới khoảng 17Số lời giải tăng rất nhanh
Sudoku 9x9Mọi đề hợp lệCắt tỉa rất hiệu quả
Sinh mọi hoán vịn tới khoảng 1111 giai thừa xấp xỉ 40 triệu
Người bán hàng, vét cạnn tới khoảng 1212 giai thừa xấp xỉ 479 triệu
Người bán hàng, nhánh cậnn tới khoảng 40Cắt tỉa mạnh
Ba lô 0/1, nhánh cậnn tới hàng trămCận ba lô phân số rất chặt
terminal
./do-n-hau
  n   so loi giai        thoi gian
  8            92          0.0001 s
 10           724          0.0028 s
 12         14200          0.0840 s
 14        365596          3.2100 s
 16      14772512        168.4000 s
 18    666090624   khoang 3 gio
doi-xung.c
/* Ban co doi xung qua truc doc, nen moi loi giai co mot loi giai
   guong. Chi can duyet nua so cot o HANG DAU TIEN roi nhan doi.

   Voi n le, cot giua phai xu ly rieng vi no la guong cua chinh no. */
int n_hau_doi_xung(int n)
{
    int q[32];
    int dem = 0;

    for (int cot = 0; cot < n / 2; ++cot) {
        q[0] = cot;
        dem += n_hau(q, 1, n);
    }

    dem *= 2;                     /* nhan doi cho nua ben kia */

    if (n % 2 == 1) {             /* cot giua, khong nhan doi */
        q[0] = n / 2;
        dem += n_hau(q, 1, n);
    }

    return dem;
}
terminal
./do-doi-xung 14
n = 14:
  thuong    : 3.210 s, 365596 loi giai
  doi xung  : 1.612 s, 365596 loi giai
  nhanh hon : 1.99 lan

Tự làm thử

  1. Cài bài toán N hậu và xác nhận số lời giải khớp với bảng cho n từ 1 tới 12.
  2. Bỏ bước bỏ chọn trong trình giải sudoku, rồi giải thích vì sao nó không tìm được lời giải.
  3. Cài bản dùng bit mask cho N hậu và đo chênh lệch với bản dùng vòng lặp kiểm.
  4. Cài trình giải sudoku với ba chiến lược chọn ô và so số nút duyệt trên cùng một đề.
  5. Cài nhánh cận cho ba lô 0/1 với cận yếu và cận chặt, so số nút duyệt với n bằng 40.

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

  • Khung quay lui gồm ba bước: chọn, đệ quy, bỏ chọn. Quên bước bỏ chọn là lỗi phổ biến nhất.
  • Chọn biểu diễn tốt mạnh hơn mọi kỹ thuật cắt tỉa. Với N hậu, dùng mảng cột thay bàn cờ hai chiều giảm không gian từ 2 mũ n bình phương xuống n mũ n.
  • Cắt tỉa có ba mức: kiểm hợp lệ, kiểm khả thi, và chặn cận. Cận càng chặt càng cắt nhiều, nhưng tính càng đắt.
  • Với bài toán thỏa ràng buộc, luôn xử lý ô có ít lựa chọn nhất trước. Với sudoku, mẹo này giảm số nút duyệt bốn mươi lần.
  • Quay lui vẫn là mũ. Cắt tỉa chỉ giảm hằng số, không đổi được bản chất, nên phải chuyển hướng khi n vượt vài chục.