Bài 12.5. Bài tập cơ bản về quay lui và nhánh cận

Chào mừng bạn quay trở lại với loạt bài blog về Cấu trúc dữ liệu và Giải thuật! Hôm nay, chúng ta sẽ cùng khám phá hai kỹ thuật giải thuật mạnh mẽ và linh hoạt, thường được dùng để giải quyết các bài toán tìm kiếm hoặc tối ưu hóa trên không gian trạng thái lớn: Quay lui (Backtracking)Nhánh cận (Branch and Bound).

Hãy tưởng tượng bạn đang đứng trước một mê cung phức tạp và cần tìm đường ra. Bạn thử một con đường, đi sâu vào. Nếu nó dẫn đến ngõ cụt, bạn phải quay ngược lại điểm giao nhau cuối cùng và thử một con đường khác. Quá trình thử - thất bại - quay lại - thử lại chính là ý tưởng cơ bản đằng sau Quay lui.

Còn nếu bài toán không chỉ là tìm đường ra, mà là tìm con đường ngắn nhất? Lúc này, bạn không chỉ thử các đường, mà còn phải ghi nhớ con đường ngắn nhất đã đi qua. Nếu tại một ngã rẽ, bạn nhận ra rằng con đường tiềm năng từ đây chắc chắn sẽ dài hơn con đường ngắn nhất đã biết, bạn sẽ ngừng ngay lập tức việc khám phá nhánh đó. Đó là ý tưởng của Nhánh cận, một sự mở rộng của Quay lui dành cho bài toán tối ưu hóa.

Trong bài viết này, chúng ta sẽ đi sâu vào cơ chế hoạt động của từng kỹ thuật thông qua các ví dụ C++ kinh điển, giúp bạn nắm vững cách áp dụng chúng vào giải quyết các bài toán thực tế.

1. Quay lui (Backtracking): Tìm kiếm có hệ thống

Quay lui là một kỹ thuật giải thuật dùng để tìm kiếm giải pháp cho các bài toán bằng cách xây dựng từng phần giải pháp và thử nghiệm xem liệu phần giải pháp hiện tại có thể dẫn đến một giải pháp hoàn chỉnh và hợp lệ hay không. Nếu tại bất kỳ bước nào, phần giải pháp đang xây dựng vi phạm các ràng buộc của bài toán, hoặc rõ ràng không thể dẫn đến giải pháp, giải thuật sẽ quay lui (backtrack) về bước trước đó để thử một lựa chọn khác.

Có thể hình dung quá trình quay lui như việc duyệt một cây tìm kiếm theo chiều sâu (DFS). Mỗi nút trên cây biểu diễn một phần giải pháp, và các cạnh là các lựa chọn để mở rộng phần giải pháp đó. Quay lui khám phá từng nhánh của cây. Nếu một nhánh không khả thi, nó sẽ "cắt" nhánh đó (gọi là pruning - tỉa cành) và quay lại nút cha để khám phá nhánh tiếp theo.

Quay lui thường được áp dụng cho các bài toán:

  • Tìm kiếm tất cả (hoặc một) cấu hình thỏa mãn ràng buộc (ví dụ: xếp quân Hậu).
  • Bài toán liên quan đến hoán vị, tổ hợp, phân hoạch (ví dụ: liệt kê tập con).
  • Các bài toán tô màu đồ thị, giải Sudoku...

Để cài đặt Quay lui, ta thường sử dụng một hàm đệ quy. Hàm này sẽ:

  1. Kiểm tra điều kiện cơ sở: Nếu đã xây dựng xong một giải pháp hoàn chỉnh, xử lý giải pháp đó (lưu, in...).
  2. Duyệt qua tất cả các lựa chọn có thể để mở rộng giải pháp hiện tại.
  3. Với mỗi lựa chọn:
    • Áp dụng lựa chọn đó vào giải pháp hiện tại.
    • Kiểm tra xem phần giải pháp sau khi áp dụng lựa chọn có còn hợp lệ theo các ràng buộc của bài toán không.
    • Nếu hợp lệ, gọi đệ quy cho bước tiếp theo.
    • Sau khi lời gọi đệ quy kết thúc (dù có tìm được giải pháp từ nhánh đó hay không), quay lui bằng cách hoàn tác lại lựa chọn đã áp dụng ở bước này, để các lựa chọn khác ở cùng mức có thể được thử.
Ví dụ 1: Bài toán N-Queens

Bài toán kinh điển này yêu cầu đặt N quân Hậu lên bàn cờ N x N sao cho không có hai quân Hậu nào tấn công lẫn nhau (tức là không cùng hàng, cùng cột hoặc cùng đường chéo).

Chúng ta sẽ xây dựng giải pháp bằng cách đặt từng quân Hậu vào từng cột, từ cột 0 đến cột N-1. Tại mỗi cột, ta thử đặt Hậu vào từng hàng và kiểm tra xem vị trí đó có an toàn không.

#include <iostream>
#include <vector>
#include <string>

// Hàm kiểm tra xem có an toàn khi đặt Hậu tại board[row][col] không
bool isSafe(const std::vector<std::vector<int>>& board, int row, int col, int N) {
    // Kiểm tra hàng bên trái: Nếu có quân Hậu nào ở cùng hàng về phía bên trái cột hiện tại
    for (int i = 0; i < col; ++i) {
        if (board[row][i]) return false;
    }

    // Kiểm tra đường chéo trên bên trái: Nếu có quân Hậu nào trên đường chéo này
    for (int i = row, j = col; i >= 0 && j >= 0; --i, --j) {
        if (board[i][j]) return false;
    }

    // Kiểm tra đường chéo dưới bên trái: Nếu có quân Hậu nào trên đường chéo này
    for (int i = row, j = col; i < N && j >= 0; ++i, --j) {
        if (board[i][j]) return false;
    }

    // Nếu không vi phạm ràng buộc nào, vị trí này an toàn
    return true;
}

// Hàm đệ quy giải bài toán N-Queens
// col: cột hiện tại chúng ta đang cố gắng đặt quân Hậu vào
// board: ma trận biểu diễn bàn cờ
// N: kích thước bàn cờ
// Hàm trả về true nếu tìm thấy một giải pháp, false nếu không có giải pháp từ trạng thái hiện tại
bool solveNQueensUtil(std::vector<std::vector<int>>& board, int col, int N) {
    // Điều kiện cơ sở: Nếu tất cả các quân Hậu đã được đặt thành công (đã xét xong N cột)
    if (col >= N) {
        // Tìm thấy một giải pháp. Trong ví dụ này, chúng ta chỉ cần in ra một giải pháp
        // và có thể dừng lại. Để tìm tất cả các giải pháp, cần thay đổi logic ở đây.
        return true;
    }

    // Duyệt qua từng hàng trong cột hiện tại (từ 0 đến N-1) để thử đặt quân Hậu
    for (int i = 0; i < N; ++i) {
        // Kiểm tra xem việc đặt quân Hậu tại board[i][col] có an toàn không
        if (isSafe(board, i, col, N)) {
            // Nếu an toàn, "thử" đặt quân Hậu vào vị trí này
            board[i][col] = 1;

            // Gọi đệ quy để giải quyết cho cột tiếp theo (col + 1)
            if (solveNQueensUtil(board, col + 1, N)) {
                 // Nếu lời gọi đệ quy trả về true (nghĩa là tìm thấy giải pháp từ đây),
                 // thì chúng ta cũng trả về true (nếu chỉ cần tìm 1 giải pháp).
                 return true;
            }

            // Nếu lời gọi đệ quy cho cột col + 1 trả về false (nghĩa là không có cách đặt Hậu
            // từ trạng thái hiện tại để có giải pháp), chúng ta phải "quay lui" (backtrack).
            // Bỏ quân Hậu vừa đặt ở board[i][col] để thử hàng khác trong cùng cột.
            board[i][col] = 0; // Đây là bước QUAY LUI
        }
    }

    // Nếu đã thử hết tất cả các hàng trong cột hiện tại mà không tìm thấy vị trí an toàn
    // hoặc không tìm thấy giải pháp từ các vị trí đó, trả về false.
    return false;
}

// Hàm chính để bắt đầu giải bài toán N-Queens
void solveNQueens(int N) {
    // Khởi tạo bàn cờ N x N với tất cả ô đều trống (giá trị 0)
    std::vector<std::vector<int>> board(N, std::vector<int>(N, 0));

    // Bắt đầu tìm kiếm từ cột đầu tiên (cột 0)
    if (solveNQueensUtil(board, 0, N) == false) {
        std::cout << "Khong tim thay giai phap cho N = " << N << std::endl;
        // Lưu ý: Để tìm *tất cả* các giải pháp, hàm solveNQueensUtil cần được thay đổi.
        // Khi col >= N, thay vì return true, ta in ra bảng và sau đó tiếp tục
        // quá trình quay lui bằng cách luôn luôn return false (hoặc không return gì cả)
        // để khám phá các nhánh khác.
    } else {
        // Nếu solveNQueensUtil trả về true, nghĩa là đã tìm thấy ít nhất một giải pháp.
        // In ra bàn cờ biểu diễn giải pháp đó.
        std::cout << "Mot giai phap cho N = " << N << ":" << std::endl;
         for (int i = 0; i < N; ++i) {
            for (int j = 0; j < N; ++j) {
                std::cout << (board[i][j] ? "Q " : ". "); // In 'Q' nếu có Hậu, '.' nếu trống
            }
            std::cout << std::endl;
        }
    }
}

int main() {
    int N = 4; // Thử giải bài toán 4 Quân Hậu
    std::cout << "---------- Bai toan N-Queens (N=" << N << ") ----------" << std::endl;
    solveNQueens(N);

    std::cout << std::endl;

    N = 8; // Có thể thử với N lớn hơn, nhưng thời gian chạy sẽ tăng đáng kể
    // std::cout << "---------- Bai toan N-Queens (N=" << N << ") ----------" << std::endl;
    // solveNQueens(N); // Uncomment để chạy thử với N=8

    return 0;
}

Giải thích code:

  • board: Ma trận N x N biểu diễn bàn cờ, 1 nghĩa là có Hậu, 0 nghĩa là trống.
  • isSafe(board, row, col, N): Hàm kiểm tra xem có thể đặt Hậu ở vị trí (row, col) hay không. Nó duyệt các ô về phía bên trái của vị trí đang xét trên cùng hàng, đường chéo lên và đường chéo xuống để xem có quân Hậu nào khác không.
  • solveNQueensUtil(board, col, N): Đây là hàm đệ quy cốt lõi.
    • Base Case: Khi col >= N, nghĩa là chúng ta đã đặt thành công quân Hậu vào tất cả N cột mà không vi phạm ràng buộc nào, tức là đã tìm thấy một giải pháp.
    • Recursive Step: Với cột hiện tại col, chúng ta duyệt qua từng hàng i từ 0 đến N-1. Nếu isSafe(board, i, col, N) trả về true, nghĩa là vị trí (i, col) là an toàn để đặt Hậu tạm thời. Ta "thử" đặt Hậu ở đây (board[i][col] = 1) và gọi đệ quy cho cột tiếp theo (solveNQueensUtil(board, col + 1, N)).
    • Backtracking: Nếu lời gọi đệ quy cho col + 1 trả về false, điều đó có nghĩa là với cách đặt Hậu ở vị trí (i, col) hiện tại, không có cách nào để hoàn thành việc đặt Hậu ở các cột còn lại. Do đó, chúng ta cần "quay lui": bỏ quân Hậu vừa đặt ở board[i][col] (board[i][col] = 0) và tiếp tục vòng lặp để thử hàng khác i+1 trong cùng cột col.
Ví dụ 2: Bài toán Subset Sum (Tìm tập con có tổng bằng K)

Cho một tập hợp các số nguyên và một giá trị tổng K cho trước, tìm tất cả các tập con (liên tiếp hoặc không liên tiếp, tùy định nghĩa bài toán, ở đây ta xét tập con không liên tiếp) của tập hợp ban đầu có tổng bằng K.

Chúng ta sẽ sử dụng Quay lui bằng cách xét từng phần tử trong tập hợp ban đầu và tại mỗi phần tử, ta có hai lựa chọn: bao gồm nó vào tập con hiện tại hoặc không bao gồm.

#include <iostream>
#include <vector>
#include <numeric> // Không cần thiết cho logic quay lui, nhưng có thể hữu ích cho các biến thể

// Hàm đệ quy giải bài toán Subset Sum
// index: chỉ mục của phần tử đang xét trong tập hợp ban đầu
// currentSum: tổng của các phần tử đã chọn vào tập con hiện tại
// targetSum: tổng mục tiêu cần đạt
// subset: vector lưu trữ các phần tử của tập con đang xây dựng
// set: tập hợp ban đầu chứa các số nguyên
void findSubsetsUtil(size_t index, int currentSum, int targetSum,
                     std::vector<int>& subset, const std::vector<int>& set) {

    // Điều kiện cơ sở 1: Nếu tổng hiện tại đã bằng tổng mục tiêu, chúng ta đã tìm thấy một tập con hợp lệ
    if (currentSum == targetSum) {
        std::cout << "Tim thay tap con: ";
        for (size_t i = 0; i < subset.size(); ++i) {
            std::cout << subset[i] << (i == subset.size() - 1 ? "" : ", ");
        }
        std::cout << std::endl;
        // Quan trọng: Không return ở đây nếu bạn muốn tìm TẤT CẢ các tập con
        // mà chỉ tìm một tập con. Để tìm tất cả, cứ in ra và tiếp tục quá trình quay lui.
        // Ví dụ này tìm TẤT CẢ các tập con.
    }

    // Điều kiện cơ sở 2: Nếu đã duyệt hết tất cả các phần tử trong tập hợp ban đầu
    if (index == set.size()) {
        return; // Dừng nhánh tìm kiếm này
    }

    // Pruning đơn giản: Nếu tổng hiện tại cộng với các phần tử còn lại (tối đa)
    // vẫn không đủ đạt targetSum, hoặc đã vượt quá targetSum, có thể dừng sớm.
    // Tuy nhiên, trong cài đặt đơn giản này, ta chỉ kiểm tra sau khi thêm hoặc không thêm.
    // Một cách pruning phổ biến hơn cho Subset Sum là kiểm tra currentSum > targetSum.
     if (currentSum > targetSum) {
         return;
     }


    // Lựa chọn 1: Bao gồm phần tử set[index] vào tập con hiện tại
    subset.push_back(set[index]);
    // Gọi đệ quy với index tiếp theo, tổng hiện tại tăng thêm giá trị của phần tử vừa thêm
    findSubsetsUtil(index + 1, currentSum + set[index], targetSum, subset, set);
    // **QUAY LUI**: Sau khi khám phá nhánh bao gồm set[index], chúng ta loại bỏ nó
    // khỏi tập con để chuẩn bị cho lựa chọn thứ 2 (không bao gồm nó).
    subset.pop_back();


    // Lựa chọn 2: Không bao gồm phần tử set[index] vào tập con hiện tại
    // Gọi đệ quy với index tiếp theo, tổng hiện tại giữ nguyên
    findSubsetsUtil(index + 1, currentSum, targetSum, subset, set);
}

// Hàm chính để bắt đầu tìm kiếm các tập con
void findSubsets(const std::vector<int>& set, int targetSum) {
    std::vector<int> subset; // Vector để lưu trữ tập con đang được xây dựng
    // Bắt đầu tìm kiếm từ phần tử đầu tiên (index 0), tổng ban đầu là 0
    findSubsetsUtil(0, 0, targetSum, subset, set);
}

int main() {
    std::vector<int> set = {10, 7, 5, 18, 12, 20, 15};
    int targetSum = 35;

    std::cout << "---------- Bai toan Subset Sum ----------" << std::endl;
    std::cout << "Tim cac tap con co tong bang " << targetSum << " tu tap { ";
     for (size_t i = 0; i < set.size(); ++i) {
        std::cout << set[i] << (i == set.size() - 1 ? "" : ", ");
    }
    std::cout << " }:" << std::endl;

    findSubsets(set, targetSum);

    return 0;
}

Giải thích code:

  • findSubsetsUtil: Hàm đệ quy chính nhận vào chỉ mục phần tử đang xét (index), tổng hiện tại của tập con đang xây dựng (currentSum), tổng mục tiêu (targetSum), tập con đang được xây dựng (subset), và tập hợp ban đầu (set).
  • Base Cases:
    • Nếu currentSum == targetSum, chúng ta đã tìm thấy một tập con có tổng bằng K. In tập con đó ra.
    • Nếu index == set.size(), nghĩa là đã xét hết tất cả các phần tử, không có gì để làm thêm trong nhánh này. Dừng lại.
    • Một pruning cơ bản khác là if (currentSum > targetSum) return;. Nếu tổng hiện tại đã vượt quá tổng mục tiêu, không cần tiếp tục theo nhánh này nữa.
  • Recursive Steps: Tại mỗi bước, với phần tử set[index] hiện tại, có hai nhánh tìm kiếm được khám phá:
    • Bao gồm phần tử: Thêm set[index] vào vector subset, cập nhật currentSum, và gọi đệ quy cho index + 1.
    • Quay lui: Sau khi lời gọi đệ quy cho trường hợp "bao gồm" kết thúc, chúng ta cần hoàn tác thay đổi bằng cách loại bỏ phần tử vừa thêm vào subset (subset.pop_back()) để nhánh thứ hai (không bao gồm phần tử này) có thể được khám phá với trạng thái subsetcurrentSum như trước khi xem xét set[index].
    • Không bao gồm phần tử: Không thêm set[index] vào subset, giữ nguyên currentSum, và gọi đệ quy cho index + 1.

Kỹ thuật subset.push_back()subset.pop_back() minh họa rõ ràng hành động "thử" và "quay lui" trong việc xây dựng tập con.

2. Nhánh cận (Branch and Bound): Tối ưu hóa thông minh hơn

Trong khi Quay lui thường dùng để tìm các giải pháp thỏa mãn một điều kiện nào đó, Nhánh cận (Branch and Bound) là một kỹ thuật được thiết kế đặc biệt để giải các bài toán tối ưu hóa, tức là tìm giải pháp tốt nhất (tối thiểu hoặc tối đa) trong không gian tìm kiếm. Nó là một sự mở rộng của Quay lui, nhưng có thêm một yếu tố quan trọng: việc sử dụng cận (bound) để loại bỏ các nhánh không tiềm năng.

Ý tưởng chính của Nhánh cận:

  1. Nhánh (Branching): Giống như Quay lui, phân chia không gian tìm kiếm thành các tập con (nhánh), thường bằng cách đưa ra quyết định cho từng phần tử của giải pháp (ví dụ: có chọn món đồ này không?).
  2. Cận (Bounding): Đối với mỗi nhánh (hoặc một tập con của không gian tìm kiếm), tính toán một cận trên (đối với bài toán Maximize) hoặc cận dưới (đối với bài toán Minimize) về giá trị tốt nhất có thể đạt được từ nhánh đó trở đi. Cận này phải tính nhanh và phải đảm bảo rằng giá trị thực tế không bao giờ tốt hơn (lớn hơn với Maximize, nhỏ hơn với Minimize) cận này.
  3. Tỉa cành (Pruning): Duy trì giá trị của giải pháp tốt nhất đã tìm thấy cho đến thời điểm hiện tại (best_solution_value). Nếu cận của một nhánh cho thấy rằng giá trị tốt nhất có thể đạt được từ nhánh đó không thể tốt hơn best_solution_value, thì toàn bộ nhánh đó sẽ bị loại bỏ (tỉa cành) mà không cần khám phá sâu hơn. Điều này giúp giảm đáng kể không gian tìm kiếm.

Nhánh cận đặc biệt hữu ích cho các bài toán NP-hard như Bài toán người du lịch (Traveling Salesperson Problem), Bài toán cái túi 0/1 (0/1 Knapsack Problem),... nơi việc tìm kiếm vét cạn là không khả thi. Việc tính toán cận hiệu quả là chìa khóa thành công của Branch and Bound.

Ví dụ 3: Bài toán 0/1 Knapsack (phiên bản tối ưu hóa)

Cho N món đồ, mỗi món có trọng lượng w_i và giá trị v_i. Cần chọn một tập con các món đồ đưa vào một cái túi có sức chứa tối đa W sao cho tổng giá trị của các món đồ được chọn là lớn nhất. Mỗi món đồ chỉ được chọn toàn bộ hoặc không chọn (0/1).

Đây là bài toán tối ưu hóa, rất phù hợp với Nhánh cận. Chúng ta sẽ xây dựng cây tìm kiếm bằng cách xét từng món đồ và quyết định có cho vào túi hay không. Để tính cận, chúng ta sẽ sử dụng ý tưởng của bài toán Fractional Knapsack (có thể chọn một phần của món đồ), giải bài toán này một cách tham lam trên các món đồ còn lại theo tỉ lệ giá trị/trọng lượng. Giá trị tối đa từ Fractional Knapsack sẽ là cận trên cho bài toán 0/1 Knapsack trên cùng tập món đồ, bởi vì việc chỉ được chọn toàn bộ món đồ (0/1) không thể cho giá trị cao hơn việc được chọn cả phần lẻ.

#include <iostream>
#include <vector>
#include <algorithm> // For std::sort

struct Item {
    int weight;
    int value;
    double ratio; // value/weight ratio, used for bounding
};

// Hàm so sánh cho việc sắp xếp (giảm dần theo ratio)
bool compareItems(const Item& a, const Item& b) {
    return a.ratio > b.ratio;
}

// Hàm tính cận trên (Upper Bound) cho một nhánh
// currentWeight: trọng lượng hiện tại trong túi của nhánh đang xét
// currentValue: giá trị hiện tại trong túi của nhánh đang xét
// index: chỉ mục của món đồ BẮT ĐẦU tính cận (các món đồ từ index trở đi)
// W: sức chứa tối đa của túi
// items: danh sách các món đồ (đã sắp xếp theo ratio giảm dần)
double calculateBound(int currentWeight, int currentValue, int index, int W, const std::vector<Item>& items) {
    double bound = currentValue; // Giá trị khởi đầu của cận là giá trị đã có
    int totalWeight = currentWeight; // Trọng lượng đã có

    // Duyệt qua các món đồ còn lại (từ index trở đi) để tính cận theo kiểu Fractional Knapsack
    for (size_t i = index; i < items.size(); ++i) {
        // Nếu có thể thêm toàn bộ món đồ items[i]
        if (totalWeight + items[i].weight <= W) {
            totalWeight += items[i].weight;
            bound += items[i].value;
        } else {
            // Nếu không đủ chỗ cho toàn bộ món đồ, thêm một phần của nó
            // (fractional) để lấp đầy trọng lượng còn lại và tính giá trị tương ứng.
            // Đây chính là điểm mấu chốt của việc tính cận bằng Fractional Knapsack.
            int remainingWeight = W - totalWeight;
            bound += remainingWeight * items[i].ratio;
            break; // Túi đã "đầy" (với fractional item cuối cùng)
        }
    }
    return bound;
}

// Hàm đệ quy giải bài toán Knapsack bằng Nhánh cận
// index: chỉ mục của món đồ đang xét quyết định (có cho vào túi hay không)
// currentWeight: trọng lượng hiện tại trong túi
// currentValue: giá trị hiện tại trong túi
// W: sức chứa tối đa của túi
// items: danh sách các món đồ (đã sắp xếp theo ratio)
// max_value: biến tham chiếu để lưu trữ giá trị tối ưu tìm được cho đến nay
void knapsackUtil(size_t index, int currentWeight, int currentValue, int W,
                  const std::vector<Item>& items, int& max_value) {

    // Cập nhật giá trị tối đa tìm được nếu giải pháp hiện tại tốt hơn
    // (Giải pháp được xem xét khi đã xét hết các món đồ hoặc tại bất kỳ nút nào)
    if (currentValue > max_value) {
        max_value = currentValue;
    }

    // Điều kiện dừng: Đã xét hết tất cả các món đồ
    if (index == items.size()) {
        return;
    }

    // ------ Lựa chọn 1: Bao gồm món đồ items[index] vào túi (nếu có đủ chỗ) ------
    // Chỉ xét nếu trọng lượng hiện tại cộng trọng lượng món đồ không vượt quá sức chứa
    if (currentWeight + items[index].weight <= W) {
        int nextWeight = currentWeight + items[index].weight;
        int nextValue = currentValue + items[index].value;

        // Tính cận trên cho nhánh CÓ bao gồm items[index]
        // Cận này dựa trên trạng thái sau khi thêm items[index] và các món đồ còn lại từ index+1
        double bound_include = calculateBound(nextWeight, nextValue, index + 1, W, items);

        // **TỈA CÀNH (PRUNING)**: Nếu cận trên của nhánh này còn THẤP HƠN hoặc bằng giá trị tối đa
        // đã tìm được cho đến nay, thì không cần khám phá sâu hơn nhánh này.
        // Sử dụng epsilon nhỏ khi so sánh double với int để tránh sai số dấu phẩy động
        if (bound_include > max_value - 1e-9) { // bound_include > max_value (cho bài Maximize)
             // Nếu tiềm năng (cận) của nhánh này tốt hơn giải pháp tốt nhất đã biết, thì đi tiếp
            knapsackUtil(index + 1, nextWeight, nextValue, W, items, max_value);
        }
    }


    // ------ Lựa chọn 2: Không bao gồm món đồ items[index] vào túi ------
    // Tính cận trên cho nhánh KHÔNG bao gồm items[index]
    // Cận này dựa trên trạng thái hiện tại và các món đồ còn lại từ index+1
    double bound_exclude = calculateBound(currentWeight, currentValue, index + 1, W, items);

    // **TỈA CÀNH (PRUNING)**: Nếu cận trên của nhánh này còn THẤP HƠN hoặc bằng giá trị tối đa
    // đã tìm được cho đến nay, thì không cần khám phá sâu hơn nhánh này.
    if (bound_exclude > max_value - 1e-9) { // bound_exclude > max_value
        // Nếu tiềm năng (cận) của nhánh này tốt hơn giải pháp tốt nhất đã biết, thì đi tiếp
       knapsackUtil(index + 1, currentWeight, currentValue, W, items, max_value);
    }

    // Lưu ý: Không cần "backtrack" trạng thái (như pop_back trong Subset Sum)
    // vì currentWeight và currentValue được truyền theo giá trị vào lời gọi đệ quy mới.
    // Việc "không đi vào" một nhánh nào đó chính là hành động pruning dựa trên cận.
}


// Hàm chính giải bài toán Knapsack bằng Branch and Bound
int solveKnapsack(int W, std::vector<Item>& items) {
    // Bước 1: Sắp xếp các món đồ theo tỉ lệ giá trị/trọng lượng giảm dần
    // Việc này giúp calculateBound tính cận "chặt" hơn và pruning hiệu quả hơn.
    for(size_t i = 0; i < items.size(); ++i) {
        items[i].ratio = static_cast<double>(items[i].value) / items[i].weight;
    }
    std::sort(items.begin(), items.end(), compareItems);

    int max_value = 0; // Lưu trữ giá trị tối đa tìm được (khởi tạo bằng 0)

    // Bắt đầu quá trình tìm kiếm từ món đồ đầu tiên (index 0),
    // túi rỗng (currentWeight=0, currentValue=0)
    knapsackUtil(0, 0, 0, W, items, max_value);

    return max_value;
}

int main() {
    std::vector<Item> items = {
        {10, 60, 0}, // Trọng lượng, Giá trị, (Ratio sẽ tính sau)
        {20, 100, 0},
        {30, 120, 0}
    };
    int W = 50; // Sức chứa tối đa của túi

    std::cout << "---------- Bai toan 0/1 Knapsack (Optimization) ----------" << std::endl;
    std::cout << "Cac mon do: { (10kg, 60$), (20kg, 100$), (30kg, 120$) }" << std::endl;
    std::cout << "Suc chua tui: " << W << " kg" << std::endl;

    int max_val = solveKnapsack(W, items);

    std::cout << "Gia tri toi da co the mang duoc: " << max_val << "$" << std::endl; // Kết quả nên là 220$ (chọn item 10kg và 20kg)

    return 0;
}

Giải thích code:

  • Item struct: Lưu trữ trọng lượng, giá trị và tỉ lệ giá trị/trọng lượng của mỗi món đồ. Tỉ lệ được tính và sắp xếp trước để hàm calculateBound hiệu quả hơn.
  • compareItems: Hàm so sánh để sắp xếp các món đồ theo tỉ lệ giá trị/trọng lượng giảm dần.
  • calculateBound: Đây là trái tim của phần "Bounding". Hàm này tính toán cận trên cho giá trị có thể đạt được từ trạng thái hiện tại (currentWeight, currentValue) và các món đồ còn lại từ index trở đi. Nó mô phỏng việc giải bài toán Fractional Knapsack trên các món đồ còn lại: thêm toàn bộ các món đồ theo thứ tự tỉ lệ giảm dần cho đến khi túi đầy, món đồ cuối cùng có thể chỉ được thêm một phần. Giá trị thu được là cận trên.
  • knapsackUtil: Hàm đệ quy chính thực hiện quá trình "Branch and Bound".
    • max_value: Biến tham chiếu quan trọng, luôn lưu giữ giá trị tốt nhất của một giải pháp hoàn chỉnh đã tìm được cho đến thời điểm hiện tại.
    • Mỗi bước đệ quy xét món đồ tại index và tạo ra hai nhánh: bao gồm món đồ đó hoặc không bao gồm.
    • Đối với mỗi nhánh tiềm năng, trước khi đi sâu vào (gọi đệ quy), ta tính toán cận trên cho nhánh đó bằng hàm calculateBound.
    • Pruning: So sánh cận tính được (bound_include hoặc bound_exclude) với max_value. Nếu cận này nhỏ hơn hoặc bằng max_value, điều đó có nghĩa là dù có cố gắng tối đa trong nhánh này (bằng cách thêm các món đồ còn lại một cách phân số), giá trị thu được cũng không thể tốt hơn giải pháp 0/1 tốt nhất đã tìm thấy. Do đó, chúng ta "tỉa cành" (prune) bằng cách không thực hiện lời gọi đệ quy cho nhánh đó.
    • max_value được cập nhật bất cứ khi nào chúng ta thấy một currentValue (tổng giá trị của một tập con hợp lệ) lớn hơn max_value hiện tại.

Nhánh cận làm giảm đáng kể số lượng trạng thái cần khám phá so với Quay lui thuần túy bằng cách loại bỏ sớm những nhánh tìm kiếm không có tiềm năng. Hiệu quả của nó phụ thuộc nhiều vào chất lượng của hàm tính cận.

3. So sánh ngắn gọn: Quay lui và Nhánh cận

  • Mục đích:
    • Quay lui: Tìm các giải pháp (một hoặc tất cả) thỏa mãn ràng buộc.
    • Nhánh cận: Tìm giải pháp tối ưu (lớn nhất hoặc nhỏ nhất) cho bài toán tối ưu hóa.
  • Cách tỉa cành (Pruning):
    • Quay lui: Tỉa cành khi phần giải pháp hiện tại vi phạm ràng buộc hoặc rõ ràng không thể dẫn đến giải pháp hợp lệ.
    • Nhánh cận: Tỉa cành khi cận của nhánh cho thấy rằng giải pháp tốt nhất có thể đạt được từ nhánh đó không thể tốt hơn giải pháp tối ưu đã tìm thấy cho đến nay.
  • Ứng dụng:
    • Quay lui: Bài toán cấu hình/tìm kiếm (N-Queens, Sudoku solver, Graph Coloring, generating permutations/combinations).
    • Nhánh cận: Bài toán tối ưu hóa (Knapsack, Traveling Salesperson, Job Scheduling).

Bài tập ví dụ:

[DSA-QuayLui-NhanhCan].Số nguyên tố.

Cho ba số N, P, S. Trong đó, P là một số nguyên tố. Nhiệm vụ của bạn là đưa ra tất cả N số nguyên tố tính từ P + 1 có tổng bằng S. Ví dụ với S = 28, P=7, N =2 ta có kết quả 11 + 17 = 28. Với N = 3, P = 2, S = 23 ta có kết quả : ,

Input Format

Dòng đầu tiên đưa vào số lượng bộ test T. Những dòng kế tiếp đưa vào các bộ test. Mỗi bộ test là bộ ba số S, P, N được viết trên một dòng. S, P, N thỏa mãn ràng buộc: 1≤T ≤100; 1 ≤ N ≤ 10; 2≤S, P≤200.

Constraints

.

Output Format

Với mỗi test, dòng đầu tiên in ra số lượng đáp án tìm được. Mỗi dòng tiếp theo in ra kết quả tìm được theo thứ tự từ điển.

Ví dụ:

Dữ liệu vào
2
2 7 28
3 2 23
Dữ liệu ra
1
11 17
2
3 7 13
5 7 11
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>

// Hàm kiểm tra số nguyên tố
bool isPrime(int n) {
    if (n <= 1) return false;
    for (int i = 2; i * i <= n; ++i) {
        if (n % i == 0) return false;
    }
    return true;
}

// Hướng dẫn giải bằng C++ sử dụng Quay lui (Backtracking) kết hợp Nhánh cận
// Không cung cấp code hoàn chỉnh, chỉ mô tả logic.

void solve() {
    int N, P, S;
    std::cin >> N >> P >> S;

    // 1. Chuẩn bị danh sách các số nguyên tố có thể sử dụng:
    //    - Cần các số nguyên tố lớn hơn P.
    //    - Tổng S không quá lớn (200), nên các số nguyên tố cũng không vượt quá S.
    //    - Ta có thể sàng các số nguyên tố lên đến S hoặc lớn hơn một chút (ví dụ 200).
    //    - Tạo một vector (ví dụ `availablePrimes`) chứa các số nguyên tố p' sao cho P < p' <= S.
    std::vector<int> availablePrimes;
    // Code để sàng và điền vào availablePrimes...
    // Vòng lặp từ P + 1 đến S (hoặc 200), kiểm tra isPrime, nếu là prime thì thêm vào availablePrimes.
    // Ví dụ: for (int i = P + 1; i <= 200; ++i) { if (isPrime(i)) availablePrimes.push_back(i); }
    // Lưu ý: Giới hạn S=200, nên sàng đến 200 là đủ.

    // 2. Khởi tạo cấu trúc lưu kết quả:
    //    - Một vector các vector số nguyên (ví dụ `results`) để lưu các tổ hợp tìm được.
    //    - Một vector số nguyên (ví dụ `currentCombination`) để lưu tổ hợp đang xây dựng.
    std::vector<std::vector<int>> results;
    std::vector<int> currentCombination;

    // 3. Xây dựng hàm quay lui (backtracking function):
    //    - Hàm này sẽ thử thêm các số nguyên tố từ `availablePrimes` vào `currentCombination`.
    //    - Các tham số cần thiết:
    //      - `index`: Chỉ số trong `availablePrimes` mà ta đang xét (để tránh trùng lặp và đảm bảo thứ tự tăng dần).
    //      - `currentSum`: Tổng hiện tại của các số trong `currentCombination`.
    //      - `countChosen`: Số lượng số đã chọn trong `currentCombination`.
    //      - Các tham chiếu đến `availablePrimes`, `results`, `currentCombination`, N, S.

    // void backtrack(int index, int currentSum, int countChosen,
    //                  const std::vector<int>& availablePrimes,
    //                  std::vector<std::vector<int>>& results,
    //                  std::vector<int>& currentCombination,
    //                  int N, int S)
    // {
        // 4. Điều kiện dừng (Base cases):
        //    - Nếu `currentSum == S` và `countChosen == N`:
        //      - Ta đã tìm được một tổ hợp hợp lệ.
        //      - Thêm `currentCombination` vào `results`.
        //      - Kết thúc nhánh này (`return`).
        //    - Nếu `currentSum > S`:
        //      - Tổng đã vượt quá S, nhánh này không hợp lệ.
        //      - Kết thúc nhánh này (`return`).
        //    - Nếu `countChosen > N`:
        //      - Đã chọn quá N số, nhánh này không hợp lệ.
        //      - Kết thúc nhánh này (`return`).

        // 5. Nhánh cận (Pruning):
        //    - Nếu số lượng số còn lại trong `availablePrimes` từ `index` trở đi cộng với số đã chọn (`countChosen`)
        //      ít hơn N, thì không thể chọn đủ N số nữa. Dừng vòng lặp hoặc kết thúc nhánh.
        //    - If (availablePrimes.size() - index + countChosen < N) { return; } // Hoặc break trong vòng lặp

        // 6. Bước đệ quy (Recursive step):
        //    - Duyệt qua các số nguyên tố trong `availablePrimes` từ vị trí `index` trở đi (ví dụ dùng vòng for từ `i = index` đến cuối `availablePrimes`).
        //    - Với mỗi số nguyên tố `p = availablePrimes[i]`:
        //      - Kiểm tra nhánh cận: Nếu `currentSum + p > S`, thì các số tiếp theo trong `availablePrimes` (lớn hơn p) cũng sẽ làm tổng vượt S. Do đó, ta có thể dừng vòng lặp tại đây (`break`).
        //      - Thêm `p` vào `currentCombination`.
        //      - Gọi đệ quy: `backtrack(i + 1, currentSum + p, countChosen + 1, availablePrimes, results, currentCombination, N, S)`.
        //        - `i + 1` đảm bảo số tiếp theo được chọn lớn hơn `p`, tạo ra các tổ hợp khác nhau và đảm bảo thứ tự từ điển.
        //      - Quay lui: Xóa `p` khỏi `currentCombination` (để thử các số khác ở vị trí hiện tại).
        //        - `currentCombination.pop_back();`

    // } // Kết thúc định nghĩa hàm backtrack

    // 7. Gọi hàm quay lui ban đầu:
    //    - Bắt đầu từ chỉ số 0 trong `availablePrimes`, tổng 0, và số lượng đã chọn 0.
    // backtrack(0, 0, 0, availablePrimes, results, currentCombination, N, S);

    // 8. In kết quả:
    //    - In ra số lượng kết quả tìm được (`results.size()`).
    //    - Duyệt qua vector `results` và in từng tổ hợp trên một dòng, cách nhau bởi dấu cách.
    //    - Các kết quả đã được sắp xếp theo thứ tự từ điển do cách duyệt và chọn số nguyên tố tăng dần.
    // Code để in results...

}

// Hàm main chỉ cần đọc T và gọi hàm solve() T lần.
int main() {
    // Tối ưu nhập xuất
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);
    int T;
    std::cin >> T;
    while (T--) {
        solve();
    }
    return 0;
}

Làm thêm nhiều bài tập miễn phí tại đây

Comments

There are no comments at the moment.