1. Giới thiệu về Dynamic Programming nén trạng thái

Dynamic Programming nén trạng thái: Được viết tắt là “State DP ”, là một phương pháp quy hoạch động được áp dụng trên mảng / chuỗi dữ liệu “quy mô nhỏ” và kết hợp với tính chất “nhị phân” để định nghĩa trạng thái và chuyển trạng thái.

Chúng ta đã học về “liệt kê nhị phân của tập con” trong phần “Kiến thức về toán tử nhị phân”. Hãy xem lại cách liệt kê nhị phân của tập con.

1.1 Liệt kê nhị phân của tập con

Đối với một tập hợp phần tử, mỗi vị trí trong tập hợp đều có hai trạng thái: chọn hoặc không chọn. Chúng ta có thể sử dụng số nhị phân có độ dài để biểu diễn tập hợp hoặc tập con của nó. Mỗi bit nhị phân tương ứng với trạng thái chọn hoặc không chọn một phần tử trong tập hợp.

Ví dụ, với tập hợp có độ dài , chúng ta có thể sử dụng một số nhị phân có độ dài để biểu diễn tập hợp hoặc một tập con của nó.

Ví dụ, số nhị phân biểu diễn việc chọn tất cả các phần tử trong tập hợp . Bảng dưới đây mô tả:

Vị trí phần tử trong tập hợp 54321
Giá trị nhị phân tương ứng11111
Trạng thái chọn tương ứngChọnChọnChọnChọnChọn

Ví dụ khác, số nhị phân biểu diễn việc chọn các phần tử ở vị trí , của tập hợp . Bảng dưới đây mô tả:

Vị trí phần tử trong tập hợp 54321
Giá trị nhị phân tương ứng10101
Trạng thái chọn tương ứngChọnKhông chọnChọnKhông chọnChọn

Số nhị phân biểu diễn việc chọn các phần tử ở vị trí của tập hợp . Bảng dưới đây mô tả:

Vị trí phần tử trong tập hợp 54321
Giá trị nhị phân tương ứng01001
Trạng thái chọn tương ứngKhông chọnChọnKhông chọnKhông chọnChọn

Từ các ví dụ trên, chúng ta có thể thấy rằng với tập hợp có độ dài , chúng ta chỉ cần liệt kê từ đến (tương ứng với các số từ đến ) một lần duy nhất để có được tất cả các tập con của tập hợp .

Chúng ta có thể mở rộng ví dụ trên cho tập hợp có độ dài . Tổng kết lại:

  • Đối với tập hợp có độ dài , chúng ta chỉ cần liệt kê từ đến (tổng cộng trạng thái) để có được tất cả các tập con của tập hợp .

1.2 Định nghĩa trạng thái và chuyển trạng thái

1.2.1 Định nghĩa trạng thái

Trong DP nén trạng thái, chúng ta thường sử dụng số nhị phân để biểu diễn trạng thái một chiều, tức là tình trạng lựa chọn của mỗi phần tử trong tập hợp.

Tương tự như thuật toán “liệt kê tất cả các tập con nhị phân”, chúng ta sử dụng một “số nhị phân có độ dài ” để biểu diễn “tất cả các trạng thái lựa chọn của phần tử trong tập hợp”.

Mỗi bit nhị phân trong số đó tương ứng với trạng thái lựa chọn của một phần tử trong tập hợp. Nếu bit thứ của số nhị phân đó là , có nghĩa là phần tử thứ trong tập hợp được chọn trong trạng thái đó. Ngược lại, nếu bit thứ của số nhị phân đó là , có nghĩa là phần tử thứ trong tập hợp không được chọn trong trạng thái đó.

1.2.2 Chuyển trạng thái

Thường thì, DP nén trạng thái có hai cách chuyển trạng thái:

  1. Liệt kê tất cả các tập con: Với một trạng thái, liệt kê tất cả các tập con của nó, hoặc liệt kê tất cả các vị trí của các phần tử, tìm ra tập con có số lượng phần tử ít hơn một phần tử so với trạng thái hiện tại. Sau đó, dựa trên giá trị của tập con và mối quan hệ giữa tập con và trạng thái, cập nhật giá trị của trạng thái hiện tại.
  2. Liệt kê tất cả các tập siêu: Với một trạng thái, liệt kê tất cả các tập siêu của nó. Sau đó, dựa trên giá trị của tập siêu và mối quan hệ giữa tập siêu và trạng thái, cập nhật giá trị của trạng thái hiện tại.

Trong đó, phương pháp “liệt kê tất cả các tập con” là phương pháp phổ biến nhất.

1.3 Điều kiện sử dụng DP nén trạng thái

Đối với tập hợp có số phần tử không vượt quá , tổng số trạng thái có thể có là . Vì số lượng trạng thái tăng theo cấp số mũ khi tăng lên, nên DP nén trạng thái chỉ phù hợp để giải quyết các vấn đề với quy mô dữ liệu nhỏ (thường là ). Khi quá lớn, việc sử dụng DP nén trạng thái có thể gây ra thời gian chạy quá lâu.

2. Các thao tác bit thường dùng trong DP nén trạng thái

Trong DP nén trạng thái, trạng thái một chiều là một tập hợp, và chúng ta thực hiện các hoạt động trên tập hợp hoặc chuyển đổi giữa các trạng thái.

Vì chúng ta sử dụng số nhị phân để định nghĩa trạng thái tập hợp, nên để thực hiện các hoạt động trên tập hợp, chúng ta sử dụng các phép toán bit.

Dưới đây là một số phép toán bit thường được sử dụng, trong đó là số phần tử trong tập hợp, là hai số nhị phân tương ứng với hai tập hợp, là vị trí của một phần tử.

  • Số lượng trạng thái: 1 << n

  • Thêm phần tử thứ vào tập hợp (đặt bit thứ của số nhị phân bằng ): A = A | (1 << i)

  • Xóa phần tử thứ khỏi tập hợp (đặt bit thứ của số nhị phân bằng ): A = A & ~(1 << i)

  • Kiểm tra xem tập hợp có chọn phần tử thứ hay không (kiểm tra bit thứ của số nhị phân có bằng không): if A & (1 << i): hoặc if (A >> i) & 1:

  • Đặt tập hợp thành tập rỗng: A = 0

  • Đặt tập hợp thành tập hợp đầy đủ: A = 1 << n

  • Tính tập bù của tập hợp : A = A ^ ((1 << n) - 1)

  • Tính tập hợp hợp của tập hợp và tập hợp : A | B

  • Tính tập hợp giao của tập hợp và tập hợp : A & B

  • Liệt kê tất cả các tập con của tập hợp (bao gồm ):

    subA = A						# Bắt đầu từ tập hợp A
    while subA > 0:					
        ...
        subA = (subB - 1) & A		# Lấy tập con tiếp theo
  • Liệt kê tất cả các tập con của tập hợp đầy đủ:

    for state in range(1 << n):		# state là tập con
        for i in range(n):			# Liệt kê phần tử thứ i
            if (state >> i) & i:	# Nếu bit thứ i của state bằng 1, thì tương ứng với việc chọn phần tử đó trong tập hợp
                ...

3.1 Ứng dụng của DP nén trạng thái

3.1.1 Liên kết đề bài

3.1.2 Ý tưởng

Mô tả: Cho hai mảng số nguyên , cả hai mảng đều có độ dài .

Yêu cầu: Sắp xếp lại các phần tử trong mảng sao cho tổng XOR của hai mảng là nhỏ nhất. Trả về tổng XOR sau khi sắp xếp lại.

Giải thích:

  • Tổng XOR của hai mảng: (chỉ số bắt đầu từ ).
  • Ví dụ, với , tổng XOR của hai mảng là .
  • .
  • .
  • .

Ví dụ:

  • Ví dụ 1:
Input: nums1 = [1,2], nums2 = [2,3]
Output: 2
Explanation: Sắp xếp lại nums2 thành [3,2].
Tổng XOR là (1 XOR 3) + (2 XOR 2) = 2 + 0 = 2.
  • Ví dụ 2:
Input: nums1 = [1,0,3], nums2 = [5,3,4]
Output: 8
Explanation: Sắp xếp lại nums2 thành [5,4,3].
Tổng XOR là (1 XOR 5) + (0 XOR 4) + (3 XOR 3) = 4 + 4 + 0 = 8.

3.1.3 Ý tưởng giải quyết

Ý tưởng 1: DP nén trạng thái

Vì mảng có thể được sắp xếp lại, chúng ta có thể giữ nguyên thứ tự các phần tử trong mảng và kết hợp phần tử thứ trong mảng với tất cả các phần tử chưa được chọn trong mảng để tìm ra tổng XOR nhỏ nhất.

Vì kích thước của hai mảng nằm trong khoảng , chúng ta có thể sử dụng “nén trạng thái” để biểu diễn trạng thái chọn phần tử trong mảng .

”Nén trạng thái” là việc sử dụng một số nhị phân có bit để biểu diễn trạng thái chọn phần tử trong một mảng.

Nếu bit thứ của số nhị phân là , điều đó có nghĩa là phần tử thứ trong mảng được chọn trong trạng thái đó. Ngược lại, nếu bit thứ , điều đó có nghĩa là phần tử thứ trong mảng không được chọn trong trạng thái đó.

Ví dụ:

  1. , đại diện cho việc chọn phần tử thứ và thứ , tức là .
  2. , đại diện cho việc chọn phần tử thứ , thứ và thứ , tức là , .

Như vậy, chúng ta có thể giải quyết bài toán này bằng cách sử dụng quy hoạch động.

1. Phân chia giai đoạn

Phân chia giai đoạn dựa trên trạng thái chọn phần tử trong mảng .

2. Định nghĩa trạng thái

Định nghĩa trạng thái hiện tại là , trong đó đại diện cho trạng thái chọn phần tử trong mảng là số lượng phần tử được chọn trong trạng thái đó.

Chúng ta có thể định nghĩa trạng thái như sau: là trạng thái chọn phần tử trong mảng và đã chọn phần tử đầu tiên trong mảng , tổng XOR nhỏ nhất có thể tạo ra.

3. Công thức chuyển tiếp trạng thái

Đối với trạng thái hiện tại , nó chắc chắn được tính từ trạng thái có ít hơn một phần tử được chọn. Chúng ta có thể liệt kê các trạng thái ít hơn một phần tử được chọn và tìm ra tổng XOR nhỏ nhất có thể tạo ra.

Ví dụ: , , đại diện cho việc chọn phần tử thứ và thứ , tức là . Khi đó, chỉ có thể chuyển từ , chúng ta chỉ cần duyệt qua hai trạng thái này và tìm tổng XOR nhỏ nhất.

Do đó, công thức chuyển tiếp trạng thái là: , trong đó bit thứ của chắc chắn là , là số lượng bit trong .

4. Điều kiện ban đầu
  • Vì chúng ta đang tìm kiếm giá trị nhỏ nhất, chúng ta có thể khởi tạo tất cả các trạng thái ban đầu là giá trị lớn nhất.
  • Khi chưa chọn bất kỳ phần tử nào, tổng XOR là , vì vậy chúng ta khởi tạo .
5. Kết quả cuối cùng

Dựa trên định nghĩa trạng thái, đại diện cho trạng thái chọn phần tử trong mảng và đã chọn phần tử đầu tiên trong mảng , tổng XOR nhỏ nhất có thể tạo ra. Vì vậy, kết quả cuối cùng là , trong đó .

Ý tưởng 1: Code
class Solution:
    def minimumXORSum(self, nums1: List[int], nums2: List[int]) -> int:
        ans = float('inf')
        size = len(nums1)
        states = 1 << size
 
        dp = [float('inf') for _ in range(states)]
        dp[0] = 0
        for state in range(states):
            one_cnt = bin(state).count('1')
            for i in range(size):
                if (state >> i) & 1:
                    dp[state] = min(dp[state], dp[state ^ (1 << i)] + (nums1[i] ^ nums2[one_cnt - 1]))
        
        return dp[states - 1]
Ý tưởng 1: Phân tích độ phức tạp
  • Độ phức tạp thời gian: , trong đó là độ dài của mảng .
  • Độ phức tạp không gian: .

3.2 Tổng AND lớn nhất của mảng

3.2.1 Liên kết đề bài

3.2.2 Ý tưởng

Mô tả: Cho một mảng số nguyên có độ dài và một số nguyên thỏa mãn . Có tổng cộng rổ, được đánh số từ đến .

Bây giờ, chúng ta cần phân chia tất cả số nguyên trong vào các rổ này, với mỗi rổ tối đa chứa số nguyên.

Yêu cầu: Trả về tổng AND lớn nhất có thể đạt được khi đặt tất cả các số trong vào rổ.

Giải thích:

  • Tổng AND: Tổng của phép AND giữa mỗi số và chỉ số rổ tương ứng.
    • Ví dụ, đặt các số vào rổ , vào rổ , tổng AND của phép AND giữa mỗi số và chỉ số rổ tương ứng là .
  • .
  • .
  • .
  • .

Ví dụ:

  • Ví dụ 1:
Input: nums = [1,2,3,4,5,6], numSlots = 3
Output: 9
Explanation: Một phân chia hợp lệ là đặt [1, 4] vào rổ 1, [2, 6] vào rổ 2, [3, 5] vào rổ 3.
Tổng AND là (1 AND 1) + (4 AND 1) + (2 AND 2) + (6 AND 2) + (3 AND 3) + (5 AND 3) = 1 + 0 + 2 + 2 + 3 + 1 = 9.
  • Ví dụ 2:
Input: nums = [1,3,10,4,7,1], numSlots = 9
Output: 24
Explanation: Một phân chia hợp lệ là đặt [1, 1] vào rổ 1, [3] vào rổ 3, [4] vào rổ 4, [7] vào rổ 7, [10] vào rổ 9.
Tổng AND là (1 AND 1) + (1 AND 1) + (3 AND 3) + (4 AND 4) + (7 AND 7) + (10 AND 9) = 1 + 1 + 3 + 4 + 7 + 8 = 24.
Lưu ý, rổ 2, 5, 68 là rỗng, và điều này là hợp lệ.

3.2.3 Ý tưởng giải quyết

Ý tưởng 1: Quy hoạch động nén trạng thái

Mỗi rổ tối đa chứa số nguyên, vì vậy chúng ta có thể chia một rổ thành hai rổ, và tổng số rổ là , mỗi rổ chứa tối đa số nguyên.

Do đó, chúng ta có thể sử dụng “trạng thái nén” để biểu diễn việc chọn số nguyên trong mỗi rổ.

”Trạng thái nén” là việc sử dụng một số nhị phân có bit để biểu diễn việc chọn số nguyên trong mỗi rổ. Nếu bit thứ của số nhị phân là , điều đó có nghĩa là số nguyên thứ trong mảng được chọn trong rổ tương ứng. Ngược lại, nếu bit thứ , điều đó có nghĩa là số nguyên thứ trong mảng không được chọn trong rổ tương ứng.

Chúng ta có thể giải quyết bài toán này bằng cách sử dụng quy hoạch động.

1. Phân chia giai đoạn

Phân chia giai đoạn dựa trên trạng thái chọn số nguyên trong mỗi rổ.

2. Định nghĩa trạng thái

Định nghĩa trạng thái hiện tại là , trong đó đại diện cho trạng thái chọn số nguyên trong mỗi rổ và là số lượng số nguyên được chọn trong trạng thái đó.

Chúng ta có thể định nghĩa trạng thái như sau: là trạng thái chọn số nguyên trong mỗi rổ và đã chọn số nguyên đầu tiên trong mảng , tổng AND lớn nhất có thể đạt được.

3. Công thức chuyển tiếp trạng thái

Đối với trạng thái hiện tại , nó chắc chắn được tính từ trạng thái có ít hơn một số nguyên được chọn. Chúng ta có thể liệt kê các trạng thái ít hơn một số nguyên được chọn và tìm tổng AND lớn nhất có thể đạt được.

Ví dụ: , , đại diện cho việc chọn số nguyên thứ và thứ , tức là . Khi đó, chỉ có thể chuyển từ , chúng ta chỉ cần duyệt qua hai trạng thái này và tìm tổng AND lớn nhất có thể đạt được.

Do đó, công thức chuyển tiếp trạng thái là: , trong đó:

  1. Bit thứ của chắc chắn là .
  2. là trạng thái có ít hơn một số nguyên được chọn.
  3. là số thứ tự của rổ.
  4. là số nguyên đang xét.
4. Điều kiện ban đầu
  • Ban đầu, khi chưa chọn bất kỳ số nguyên nào, tổng AND lớn nhất có thể đạt được là , nghĩa là .
5. Kết quả cuối cùng

Dựa trên định nghĩa trạng thái, đại diện cho trạng thái chọn số nguyên trong mỗi rổ và đã chọn số nguyên đầu tiên trong mảng , tổng AND lớn nhất có thể đạt được. Vì vậy, kết quả cuối cùng là .

Lưu ý: Khi , không thể tính bằng cách truy hồi, vì vậy cần bỏ qua.

Ý tưởng 1: Code
class Solution:
    def maximumANDSum(self, nums: List[int], numSlots: int) -> int:
        states = 1 << (numSlots * 2)
        dp = [0 for _ in range(states)]
 
        for state in range(states):
            one_cnt = bin(state).count('1')
            if one_cnt > len(nums):
                continue
            for i in range(numSlots * 2):
                if (state >> i) & 1:
                    dp[state] = max(dp[state], dp[state ^ (1 << i)] + ((i // 2 + 1) & nums[one_cnt - 1]))
        
        return max(dp)
Ý tưởng 1: Phân tích độ phức tạp
  • Độ phức tạp thời gian: , trong đó .
  • Độ phức tạp không gian: .