Hướng dẫn thuật toán

Trùm CUỐI 2025-12-02 1:17:06 2025-12-02 1:17:56

Link đề bài

Nhận xét

Đây là bài toán thuộc dạng Chia tập con có tổng bằng nhau (Partition Problem). Với giới hạn n \le 16 , phương pháp Đệ quy quay lui (Backtracking) kết hợp với Bitmask là cách tiếp cận hiệu quả.


1. Phân tích đề bài

  • Yêu cầu: Kiểm tra xem mảng a có thể chia thành k tập con không rỗng có tổng bằng nhau hay không.
  • Đầu vào: n (số phần tử), k (số tập con), và mảng a .
  • Điều kiện tiên quyết:
    • Tính tổng S của tất cả các phần tử trong mảng a .
    • Nếu S không chia hết cho k , chắc chắn không thể chia được, kết quả là false.
    • Tổng mục tiêu (Target Sum): Nếu chia được, tổng của mỗi tập con phải là T = S / k .
  • Mục tiêu thuật toán: Tìm cách chọn k tập con rời nhau, mỗi tập con có tổng bằng T , sao cho tất cả n phần tử đều được sử dụng.

2. Ý tưởng thuật toán: Đệ quy quay lui với Bitmask

Chúng ta sẽ sử dụng phương pháp đệ quy để cố gắng xây dựng từng tập con một.

  1. Trạng thái (State): Cần theo dõi những phần tử nào đã được sử dụng. Vì n \le 16 , ta dùng một số nguyên (Bitmask) để đại diện cho trạng thái:
    • Bit thứ i bằng 1: Phần tử a_i đã được sử dụng.
    • Bit thứ i bằng 0: Phần tử a_i chưa được sử dụng.
  2. Hàm đệ quy: solve(mask, current_sum, subsets_formed)
    • mask: Trạng thái các phần tử đã được sử dụng.
    • current_sum: Tổng hiện tại của tập con đang được xây dựng.
    • subsets_formed: Số lượng tập con đã hoàn thành (có tổng bằng T ).
  3. Quay lui: Tại mỗi bước, ta cố gắng thêm một phần tử chưa dùng vào tập con hiện tại, sau đó gọi đệ quy. Nếu không tìm được cách thêm, ta quay lui (bỏ phần tử đó ra) và thử phần tử khác.

3. Các bước cơ bản của thuật toán

Bước 1: Khởi tạo và Tiền xử lý

  1. Tính tổng S của mảng a .
  2. Nếu S \pmod k \ne 0 , trả về false.
  3. Tính tổng mục tiêu T = S / k .
  4. Sắp xếp mảng a theo thứ tự giảm dần (Đây là một tối ưu hóa quan trọng trong Backtracking: giúp nhanh chóng loại bỏ các nhánh không khả thi).

Bước 2: Định nghĩa hàm đệ quy solve(mask, current_sum, k_remaining)

  • k\_remaining : Số lượng tập con còn lại cần phải tạo.
  1. Điều kiện dừng (Thành công):

    • Nếu k_remaining == 0, nghĩa là ta đã tạo đủ k tập con, trả về true.
  2. Điều kiện chuyển trạng thái (Hoàn thành một tập con):

    • Nếu current_sum == T:
      • Ta đã hoàn thành một tập con. Bắt đầu xây dựng tập con tiếp theo.
      • Gọi đệ quy: solve(mask, 0, k_remaining - 1). Nếu kết quả là true, trả về true.
  3. Lựa chọn phần tử (Quay lui):

    • Duyệt qua tất cả các phần tử a_i chưa được sử dụng (bit thứ i trong mask bằng 0).
    • Kiểm tra điều kiện: Nếu current_sum + a_i <= T:
      • Thử: Thêm a_i vào tập con hiện tại.
        • Tạo new_mask = mask | (1 << i).
        • Gọi đệ quy: solve(new_mask, current_sum + a_i, k_remaining).
      • Nếu thành công: Nếu kết quả đệ quy là true, trả về true ngay lập tức.
      • Quay lui: Không cần làm gì vì ta không thay đổi biến toàn cục, chỉ cần tiếp tục vòng lặp để thử phần tử tiếp theo.
    • Nếu vòng lặp kết thúc mà không tìm thấy giải pháp, trả về false.

Bước 3: Gọi hàm chính

  • Gọi solve(0, 0, k).

4. Lưu ý khi triển khai

  • Tối ưu hóa (Memoization/DP): Vì trạng thái chỉ phụ thuộc vào maskcurrent_sum (hoặc chỉ mask nếu ta xử lý khéo léo), bạn có thể sử dụng Quy hoạch động trên Bitmask (Bitmask DP) để lưu lại kết quả của hàm solve(mask, ...) nhằm tránh tính toán lại các trạng thái đã gặp.
    • Ví dụ: Dùng một mảng memo[1 << n] để lưu kết quả. Tuy nhiên, việc này phức tạp hơn vì current_sum cũng là một phần của trạng thái.
    • Cách đơn giản hơn: Khi current_sum = 0 (bắt đầu một tập con mới), ta có thể sử dụng memoization chỉ dựa trên mask.
  • Tối ưu hóa chọn phần tử đầu tiên: Khi current_sum = 0, để giảm số lần gọi đệ quy, ta nên chọn phần tử chưa sử dụng có chỉ số nhỏ nhất để bắt đầu xây dựng tập con mới. Điều này đảm bảo rằng các tập con có cùng thành phần sẽ không bị thử lặp lại theo các thứ tự khác nhau.