Kỹ Thuật Tối Ưu Hóa Quy Hoạch Động Trong Bài Toán Xách Túi

Thiết Lập Ký Hiệu

Để đảm bảo tính thống nhất trong các phần phân tích dưới đây, chúng ta định nghĩa các biến như sau:

  • n: Tổng số nhóm hoặc loại vật phẩm.
  • C: Giới hạn dung tích của túi (sức chứa).
  • weight[i]: Khối lượng tiêu hao của vật phẩm loại i.
  • value[i]: Giá trị thu được khi lấy vật phẩm loại i.
  • quantity[i]: Số lượng vật phẩm loại i có thể sử dụng.
  • dp[k]: Max value (hoặc số cách) đạt được với dung tích k.

Bài Toán 0/1 Xách Túi

Trường hợp này giả định mỗi loại vật phẩm có sẵn chính xác một chiếc (quantity[i] == 1). Để tránh trường hợp một vật phẩm được tính vào nhiều lần trong cùng một vòng lặp cập nhật, chúng ta bắt buộc phải lặp qua các mức dung tích theo thứ tự giảm dần.

void processZeroOneKnapsack(int numItems, int bagCapacity, const vector& itemWeights, const vector& itemValues, vector& dpState) {
    for (int idx = 0; idx < numItems; ++idx) {
        int w = itemWeights[idx];
        int val = itemValues[idx];
        // Duyệt ngược từ sức chứa lớn nhất đến trọng lượng vật phẩm
        for (int cap = bagCapacity; cap >= w; --cap) {
            dpState[cap] = max(dpState[cap], dpState[cap - w] + val);
        }
    }
}

Bài Toán Xách Túi Hoàn Hảo

Khi số lượng mỗi loại vật phẩm là vô hạn (quantity[i] = ∞), ta có thể lựa chọn lại vật phẩm đã xét trong cùng một vòng lặp. Điều này cho phép lặp theo chiều tăng dần của dung tích.

void processCompleteKnapsack(int numItems, int bagCapacity, const vector& itemWeights, const vector& itemValues, vector& dpState) {
    for (int idx = 0; idx < numItems; ++idx) {
        int w = itemWeights[idx];
        int val = itemValues[idx];
        // Duyệt thuận, cho phép tái sử dụng vật phẩm cùng loại
        for (int cap = w; cap <= bagCapacity; ++cap) {
            dpState[cap] = max(dpState[cap], dpState[cap - w] + val);
        }
    }
}

Xử Lý Dạng Đếm Và Giới Hạn Số Lượng

Đối với bài toán khi số lượng vật phẩm bị giới hạn (quantity[i] = K), ta cần áp dụng các kỹ thuật tối ưu để tránh độ phức tạp O(n * C * K).

Tối Ưu Hàng Đợi Đơn Điệu (Monotonic Queue)

Hàm chuyển trạng thái có dạng dp[j] = max(dp[j - k*w] + k*v). Nếu xét các dung tích có cùng phần dư khi chia cho w, bài toán trở thành tìm giá trị cực đại trong một cửa sổ trượt có độ dài K. Chúng ta cần lưu trữ trạng thái mảng trước khi cập nhật để đảm bảo tính chính xác của việc chuyển trạng thái.

#include <deque>
#include <vector>
using namespace std;

void optimizeWithDeque(int totalItems, int maxCap, const vector& wt, const vector& vl, const vector& qty, vector& currentDp) {
    int capacity = maxCap + 1;
    vector previousDp = currentDp; // Sao chép trạng thái cũ
    
    for (int i = 0; i < totalItems; ++i) {
        int w = wt[i];
        int v = vl[i];
        int limit = qty[i];

        // Chia nhỏ theo phần dư modulo w
        for (int r = 0; r < w; ++r) {
            deque<int> window; // Lưu trữ chỉ số
            for (int j = r; j <= maxCap; j += w) {
                int k = (j - r) / w; // Vị trí trong dãy tương đương
                
                // Đảm bảo độ dài cửa sổ không vượt quá số lượng cho phép
                while (!window.empty() && k - window.front() > limit) {
                    window.pop_front();
                }

                // Cập nhật dp nếu có phần tử khả dụng trong hàng đợi
                if (!window.empty()) {
                    int bestIdx = window.front();
                    int prevVal = previousDp[bestIdx];
                    int diffK = k - (bestIdx - r) / w;
                    currentDp[j] = max(currentDp[j], prevVal + diffK * v);
                }

                // Duy trì tính đơn điệu giảm trong hàng đợi
                long long newVal = previousDp[j] - (long long)k * v;
                while (!window.empty()) {
                    int backIdx = window.back();
                    int backK = (backIdx - r) / w;
                    long long backVal = previousDp[backIdx] - (long long)backK * v;
                    if (backVal <= newVal) {
                        window.pop_back();
                    } else {
                        break;
                    }
                }
                window.push_back(j);
            }
        }
    }
}

Sử Dụng Cộng Dồn Tiền Tố (Prefix Sum)

Khi bài toán tập trung vào đếm số phương án thay vì tối đa hóa giá trị, chúng ta có thể dùng mảng tiền tố để tính nhanh tổng trong khoảng. Với mỗi vị trí j, tập các giá trị cha nó bao gồm những chỉ số có cùng phần dư modulo w nằm trong đoạn [j - K*w, j].

const int MOD = 1e9 + 7;

void solveCountingViaPrefix(int items, int bound, const vector& weights, vector& waysCount) {
    vector tempSum(bound + 1, 0);

    for (int i = 1; i <= items; ++i) {
        int w = weights[i];
        // Bước 1: Xây dựng mảng cộng dồn từ hàng trước
        for (int j = 0; j <= bound; ++j) {
            if (j >= w) {
                tempSum[j] = (tempSum[j - w] + waysCount[j]) % MOD;
            } else {
                tempSum[j] = waysCount[j];
            }
        }

        // Bước 2: Trừ đi phần ngoài khoảng cho phép
        for (int j = 0; j <= bound; ++j) {
            int removeEdge = j - (limit + 1) * w;
            if (removeEdge < 0) {
                waysCount[j] = tempSum[j];
            } else {
                waysCount[j] = (tempSum[j] - tempSum[removeEdge] + MOD) % MOD;
            }
        }
    }
}

Bài Toán Đặc Biệt Và Ứng Dụng Trên Cây

Các biến thể mở rộng thường gặp khi tham số đầu vào khác biệt hoàn toàn hoặc cấu trúc dữ liệu phức tạp.

Trọng Lượng Hoặc Giá Trị Cực Lớn

Nếu w[i] quá lớn so với sức chứa nhưng v[i] lại nhỏ, ta nên đổi trục bài toán: thay vì tính giá trị theo dung tích, hãy tính dung tích nhỏ nhất để đạt được giá trị X. Một trường hợp đặc biệt là w[i] = a * 2^b cho phép tối ưu hóa thông qua biểu diễn nhị phân của trọng lượng.

Quy Hoạch Động Trên Cây (Tree DP Optimization)

Xét bài toán mà nếu chọn nút con x, tất cả các nút tổ tiên của x cũng phải được chọn. Giải pháp duyệt cây thông thường có độ phức tạp O(n*m^2).

Sử dụng thứ tự DFS (Depth First Order - DFN), ta ánh xạ cây thành một mảng tuyến tính. Khoảng thời gian con của một nút trong thứ tự DFN là một đoạn liên tiếp.
Nếu quyết định không chọn nút gốc của một nhánh, toàn bộ nhánh con đó đều bị loại bỏ, cho phép ta nhảy trực tiếp từ vị trí hiện tại đến ngay sau cuối của nhánh con đó. Nếu chọn nút gốc, ta chỉ cần cộng thêm vào trạng thái tiếp theo dfn[x] + 1. Kỹ thuật này giúp đưa độ phức tạp xuống O(n*m) bằng cách thao tác tuyến tính trên mảng dFN thay vì gộp đệ quy lồng nhau.

Thẻ: algorithm dynamic-programming knapsack-problem monotonic-queue tree-traversal

Đăng vào ngày 21 tháng 6 lúc 18:47