Cách tôi tiếp cận bài toán Dynamic Programming
Dynamic Programming trở nên dễ kiểm soát hơn khi tôi không bắt đầu từ công thức, mà bắt đầu từ trạng thái, chuyển trạng thái và thứ tự tính toán. Bài ghi chú này tóm tắt checklist tôi thường dùng.
1. Bắt đầu từ câu hỏi mà trạng thái phải trả lời
Tôi luôn cố định nghĩa dp[i] hoặc dp[i][j] bằng ngôn ngữ tự nhiên trước khi viết
code. Nếu tôi chưa thể diễn đạt trạng thái thành một câu hoàn chỉnh, nghĩa là lời giải DP vẫn chưa rõ.
Một nguyên tắc hữu ích là: mỗi trạng thái nên trả lời đúng một câu hỏi cụ thể. Ví dụ, “giá trị tốt nhất khi
dùng i phần tử đầu tiên” rõ ràng hơn nhiều so với “một giá trị nào đó liên quan đến
i phần tử đầu”.
2. Truy ngược xem mỗi trạng thái đến từ đâu
Sau khi định nghĩa trạng thái, tôi liệt kê những trạng thái trước đó có thể chuyển sang nó. Đây thường là nơi diễn ra phần tư duy quan trọng nhất của DP. Nếu chuyển trạng thái cảm thấy gượng ép, có thể định nghĩa trạng thái đang sai.
- Bài toán con nhỏ hơn nào có thể dẫn tới trạng thái hiện tại?
- Ở bước này ta đang ra quyết định gì?
- Quyết định đó làm thay đổi đáp án như thế nào?
3. Xác định thứ tự tính
Khi đã có chuyển trạng thái, tôi quyết định nên dùng bottom-up hay top-down memoization. Trong môi trường thi đấu, tôi thường ưu tiên bottom-up nếu thứ tự tính đơn giản, vì dễ debug hơn.
4. Kiểm tra kỹ base case
Nhiều bài WA đến từ việc base case mơ hồ hoặc không khớp với định nghĩa trạng thái. Tôi luôn kiểm tra xem trạng thái nhỏ nhất có thực sự đúng với câu mô tả trạng thái hay không.
Mẫu tư duy đơn giản
Định nghĩa trạng thái:
dp[i] = đáp án cho i phần tử đầu tiên
Chuyển trạng thái:
dp[i] = tốt nhất giữa (dp[i - 1], dp[i - 2] + value[i])
Base case:
dp[0], dp[1]
Thứ tự tính:
for i từ 2 đến n
Kết luận
Bước tiến lớn nhất của tôi với Dynamic Programming đến từ việc đặt câu hỏi tốt hơn về trạng thái, chứ không phải chỉ ghi nhớ thêm mẫu. Mẫu giúp tăng tốc sau này, nhưng sự rõ ràng phải đến trước.