1. Giới thiệu về Quy hoạch động

1.1 Định nghĩa của Quy hoạch động

Quy hoạch động (Dynamic Programming): viết tắt là DP, là một phương pháp giải quyết các vấn đề tối ưu hóa trong quá trình ra quyết định nhiều giai đoạn. Trong quy hoạch động, chúng ta chia bài toán gốc thành các bài toán con tương đối đơn giản hơn, giải quyết trước các bài toán con, sau đó sử dụng kết quả của các bài toán con để tìm kết quả của bài toán gốc.

Quy hoạch động ban đầu được Richard Bellman giới thiệu vào năm 1957 trong cuốn sách “Quy hoạch động (Dynamic Programming)“. Tại đây, từ “Programming” không có nghĩa là lập trình, mà chỉ một phương pháp “xử lý bảng”, tức là lưu trữ kết quả tính toán của mỗi bước vào bảng để sử dụng cho các tính toán sau.

1.2 Ý tưởng cốt lõi của Quy hoạch động

Ý tưởng cốt lõi của Quy hoạch động:

  1. Chia bài toán gốc thành “các bài toán con chồng chéo nhau”, mỗi bài toán con được tính toán trong một “giai đoạn”. Sau khi hoàn thành tính toán của một giai đoạn, phương pháp quy hoạch động mới thực hiện tính toán giai đoạn tiếp theo.
  2. Trong quá trình giải quyết bài toán con, có 2 phương pháp để tiếp cận là “tính toán từ trên xuống (top-down)” hoặc “tính toán từ dưới lên (bottom-up)” để tìm ra “kết quả của bài toán con”, lưu trữ kết quả này vào bảng. Khi cần tính toán lại bài toán con này, chỉ cần truy vấn kết quả của bài toán con từ bảng, từ đó tránh được việc tính toán lặp lại nhiều lần.

Điều này giống với phương pháp chia để trị, nhưng điểm khác biệt giữa Quy hoạch động và phương pháp chia để trị là:

  1. Các vấn đề được giải quyết bằng Quy hoạch động thường có các bài toán con tương quan với nhau, có thể có nhiều bài toán con chồng chéo.
  2. Sử dụng phương pháp Quy hoạch động sẽ lưu trữ kết quả của các bài toán con chồng chéo vào bảng, để sử dụng cho các tính toán sau này, từ đó tránh được việc tính toán lặp lại nhiều lần.

1.3 Ví dụ đơn giản về Quy hoạch động

Bây giờ chúng ta hãy sử dụng một ví dụ đơn giản để giới thiệu về Quy hoạch động và sau đó sẽ giải thích các thuật ngữ trong Quy hoạch động.

Dãy Fibonacci: Dãy số Fibonacci bắt đầu từ , các số tiếp theo là tổng của hai số trước đó. Tức là:

Dựa trên công thức , chúng ta có thể đệ quy chia bài toán gốc thành hai bài toán con . Quá trình đệ quy tương ứng được mô tả trong hình dưới đây:

Từ hình trên, ta có thể thấy: nếu tính toán bằng cách sử dụng thuật toán đệ quy truyền thống, ta cần tính toán trước, và khi tính toán ta cần tính toán nữa, điều này dẫn đến việc tính toán lặp lại của . Tương tự, , , đều được tính toán nhiều lần, dẫn đến việc tính toán lặp lại.

Để tránh tính toán lặp lại, chúng ta có thể sử dụng “phương pháp bảng xử lý” trong Quy hoạch động để giải quyết.

Ở đây, chúng ta sử dụng phương pháp “tính toán từ dưới lên” để tìm ra kết quả của các bài toán con , sau đó lưu trữ kết quả này vào bảng, để sử dụng cho các tính toán sau này. Quá trình cụ thể như sau:

  1. Định nghĩa một mảng , được sử dụng để lưu trữ các giá trị trong dãy Fibonacci.
  2. Khởi tạo .
  3. Dựa trên công thức đệ quy của dãy Fibonacci , bắt đầu tính toán từ để tính toán các số Fibonacci, cho đến khi tính toán được .
  4. Cuối cùng, trả về để nhận được số Fibonacci thứ .

Code cụ thể như sau:

class Solution:
    def fib(self, n: int) -> int:
        if n == 0:
            return 0
        if n == 1:
            return 1
 
        dp = [0 for _ in range(n + 1)]
        dp[0] = 0
        dp[1] = 1
 
        for i in range(2, n + 1):
            dp[i] = dp[i - 2] + dp[i - 1]
 
        return dp[n]

Phương pháp này sử dụng bộ nhớ đệm (bảng băm, tập hợp hoặc mảng) để lưu trữ kết quả tính toán, từ đó tránh được tính toán lặp lại của các bài toán con, đây chính là “phương pháp Quy hoạch động”.

2. Đặc điểm của quy hoạch động

Vậy vấn đề nào có thể được giải quyết bằng thuật toán quy hoạch động?

Đầu tiên, vấn đề có thể được giải quyết bằng phương pháp quy hoạch động phải đáp ứng ba đặc điểm sau:

  1. Tính chất con tối ưu
  2. Tính chất con trùng lặp
  3. Tính chất không có tác động sau này

2.1 Tính chất con tối ưu

Tính chất con tối ưu: Đề cập đến việc giải pháp tối ưu của một vấn đề chứa trong nó giải pháp tối ưu của các vấn đề con.

Ví dụ, như hình dưới đây, vấn đề gốc , sau bước chúng ta chọn một giải pháp tối ưu hiện tại, vấn đề trở thành việc giải quyết vấn đề con . Nếu giải pháp tối ưu của vấn đề gốc có thể được tạo thành từ “giải pháp tối ưu của bước ” và “giải pháp tối ưu của ”, thì vấn đề đó đáp ứng tính chất con tối ưu.

Nghĩa là, nếu giải pháp tối ưu của vấn đề gốc chứa giải pháp tối ưu của vấn đề con, thì vấn đề đó đáp ứng tính chất con tối ưu.

2.2 Tính chất con trùng lặp

Tính chất con trùng lặp: Đề cập đến việc trong quá trình giải quyết vấn đề con, có rất nhiều vấn đề con bị lặp lại, một vấn đề con có thể được sử dụng nhiều lần trong quá trình ra quyết định của giai đoạn tiếp theo. Nếu có nhiều vấn đề con trùng lặp, chỉ cần giải quyết một lần và lưu kết quả vào bảng, sau đó có thể truy vấn trực tiếp khi cần, không cần giải quyết lại.

Trong ví dụ “dãy Fibonacci” đã đề cập trước đó, , , , đã được tính toán lại nhiều lần. Thuật toán quy hoạch động sử dụng tính chất con trùng lặp, lưu trữ kết quả của chúng vào bảng trong lần tính toán đầu tiên, và khi sử dụng lại, chỉ cần truy vấn trực tiếp, không cần tính toán lại, từ đó tăng hiệu suất.

2.3 Tính chất không có tác động sau này

Tính chất không có tác động sau này: Đề cập đến việc giải pháp của vấn đề con (giá trị trạng thái) chỉ phụ thuộc vào giai đoạn trước đó và không bị ảnh hưởng bởi quyết định của giai đoạn sau này.

Nghĩa là, một khi giải pháp của một vấn đề con được xác định, nó sẽ không bị thay đổi nữa.

Ví dụ, trong hình dưới đây là một đồ thị có hướng không chu trình với trọng số, khi giải quyết vấn đề tìm đường đi ngắn nhất từ điểm đến điểm , giả sử chúng ta đã biết đường đi ngắn nhất từ điểm đến điểm (độ dài là ). Sau đó, bất kể lựa chọn đường đi sau này như thế nào, độ dài đường đi ngắn nhất từ điểm đến điểm không bị ảnh hưởng. Đây chính là tính chất “không có tác động sau này”.

Nếu một vấn đề có “tác động sau này”, có thể cần chuyển đổi hoặc giải quyết theo hướng ngược lại để loại bỏ tác động sau này, sau đó mới có thể sử dụng thuật toán quy hoạch động.

202303072158574.png

4. Ứng dụng của quy hoạch động

Các vấn đề liên quan đến quy hoạch động thường linh hoạt và phức tạp, không có một cách tiếp cận cụ thể và thường xuyên xuất hiện trong các cuộc thi lập trình và phỏng vấn.

Điểm quan trọng của các vấn đề quy hoạch động là “làm thế nào để thiết kế trạng thái” và “rút ra điều kiện chuyển trạng thái”, cùng với các “phương pháp tối ưu” khác nhau. Cần luyện tập và tổng kết nhiều vấn đề loại này, chỉ khi tiếp xúc với nhiều loại câu hỏi, chúng ta mới có thể thành thạo ý tưởng của quy hoạch động.

Dưới đây là một số bài tập cơ bản về quy hoạch động.

4.1 Số Fibonacci

4.1.1 Đường dẫn đến câu hỏi

4.1.2 Tóm tắt đề bài

Mô tả: Cho một số nguyên .

Yêu cầu: Tính số Fibonacci thứ .

Giải thích:

  • Dãy Fibonacci được định nghĩa như sau:
    • .
    • , với .
  • .

Ví dụ:

  • Ví dụ 1:
Input: n = 2
Output: 1
Explanation: F(2) = F(1) + F(0) = 1 + 0 = 1
  • Ví dụ 2:
Input: n = 3
Output: 2
Explanation: F(3) = F(2) + F(1) = 1 + 1 = 2

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

1. Phân chia giai đoạn

Chúng ta có thể phân chia giai đoạn theo thứ tự số nguyên từ đến .

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

Định nghĩa trạng thái là: số Fibonacci thứ .

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

Dựa trên định nghĩa dãy Fibonacci trong câu hỏi , ta có công thức chuyển trạng thái .

4. Điều kiện ban đầu

Dựa trên điều kiện ban đầu trong câu hỏi , ta có điều kiện ban đầu của quy hoạch động là .

5. Kết quả cuối cùng

Dựa trên định nghĩa trạng thái, kết quả cuối cùng là , tức là số Fibonacci thứ .

4.1.4 Code

class Solution:
    def fib(self, n: int) -> int:
        if n <= 1:
            return n
 
        dp = [0] * (n + 1)
        dp[0] = 0
        dp[1] = 1
        for i in range(2, n + 1):
            dp[i] = dp[i - 2] + dp[i - 1]
 
        return dp[n]

4.1.5 Phân tích độ phức tạp

  • Độ phức tạp thời gian: . Vòng lặp đơn duy nhất có độ phức tạp thời gian là .
  • Độ phức tạp không gian: . Sử dụng một mảng một chiều để lưu trạng thái, vì vậy độ phức tạp không gian tổng thể là .

4.2 Leo cầu thang

4.2.1 Liên kết đến câu hỏi

4.2.2 Tóm tắt đề bài

Mô tả: Giả sử bạn đang leo cầu thang. Bạn cần bước để leo lên đầu cầu thang. Mỗi lần bạn có thể leo hoặc bước. Cho một số nguyên .

Yêu cầu: Tính số cách khác nhau để leo lên đầu cầu thang.

Giải thích:

  • .

Ví dụ:

  • Ví dụ 1:
Input: n = 2
Output: 2
Explanation: Có hai cách để leo lên đầu cầu thang.
1. 1 bước + 1 bước
2. 2 bước
  • Ví dụ 2:
Input: n = 3
Output: 3
Explanation: Có ba cách để leo lên đầu cầu thang.
1. 1 bước + 1 bước + 1 bước
2. 1 bước + 2 bước
3. 2 bước + 1 bước

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

1. Phân chia giai đoạn

Chúng ta có thể phân chia giai đoạn theo số bước của cầu thang, từ đến .

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

Định nghĩa trạng thái là: số cách để leo lên bước thứ của cầu thang.

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

Dựa trên yêu cầu đề bài, mỗi lần chỉ có thể leo hoặc bước. Vì vậy, bước thứ của cầu thang chỉ có thể leo lên từ bước hoặc . Do đó, ta có công thức chuyển trạng thái .

4. Điều kiện ban đầu
  • Số cách để leo lên bước của cầu thang: có thể coi là cách (leo từ bước lên bước ), tức là .
  • Số cách để leo lên bước của cầu thang: cách (leo từ bước lên bước ), tức là .
  • Số cách để leo lên bước của cầu thang: cách (leo từ bước lên bước , hoặc leo từ bước lên bước ).
5. Kết quả cuối cùng

Dựa trên định nghĩa trạng thái, kết quả cuối cùng là , tức là số cách để leo lên bước thứ của cầu thang.

Mặc dù công thức chuyển trạng thái của hai bài toán này đều là , nhưng cách tiếp cận của hai bài toán này không giống nhau, điều này cho thấy tính linh hoạt và đa dạng của các bài toán liên quan đến quy hoạch động.

4.2.4 Code

class Solution:
    def climbStairs(self, n: int) -> int:
        dp = [0 for _ in range(n + 1)]
        dp[0] = 1
        dp[1] = 1
        for i in range(2, n + 1):
            dp[i] = dp[i - 1] + dp[i - 2]
        
        return dp[n]

4.2.5 Phân tích độ phức tạp

  • Độ phức tạp thời gian: . Vòng lặp đơn duy nhất có độ phức tạp thời gian là .
  • Độ phức tạp không gian: . Sử dụng một mảng một chiều để lưu trạng thái, vì vậy độ phức tạp không gian tổng thể là . Vì trạng thái chỉ phụ thuộc vào , nên có thể sử dụng biến để lưu trạng thái tương ứng với , , , từ đó tối ưu không gian đến .

4.3 Đường đi duy nhất

4.3.1 Liên kết đến câu hỏi

4.3.2 Tóm tắt đề bài

Mô tả: Cho hai số nguyên , đại diện cho một bảng cỡ , một con robot đang ở vị trí góc trên bên trái của bảng. Mỗi lần robot chỉ có thể di chuyển sang phải hoặc xuống dưới một bước.

Yêu cầu: Tính số lượng đường đi khác nhau để robot đi từ góc trên bên trái đến góc dưới bên phải của bảng.

Giải thích:

  • .
  • Đáp án được bảo đảm nhỏ hơn hoặc bằng .

Ví dụ:

  • Ví dụ 1:
Input: m = 3, n = 7
Output: 28
  • Ví dụ 2:
Input: m = 3, n = 2
Output: 3
Explanation:
3 đường đi để robot đi từ góc trên bên trái đến góc dưới bên phải.
1. Sang phải -> Xuống dưới -> Xuống dưới
2. Xuống dưới -> Xuống dưới -> Sang phải
3. Xuống dưới -> Sang phải -> Xuống dưới

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

1. Phân chia giai đoạn

Chúng ta có thể phân chia giai đoạn dựa trên vị trí kết thúc của đường đi (tạo thành một cặp tọa độ hai chiều gồm hàng và cột).

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

Định nghĩa trạng thái là: số lượng đường đi từ góc trên bên trái đến vị trí trên bảng.

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

Vì chúng ta chỉ có thể di chuyển sang phải hoặc xuống dưới một bước, nên để đến vị trí , chúng ta chỉ có thể di chuyển từ vị trí xuống dưới một bước; hoặc từ vị trí sang phải một bước. Do đó, chúng ta có công thức chuyển trạng thái: với .

4. Điều kiện ban đầu
  • Để đến vị trí , chỉ có một cách, tức là .
  • Các phần tử trong hàng đầu tiên chỉ có một đường đi (chỉ có thể di chuyển từ phần tử trước đó sang phải), do đó .
  • Tương tự, các phần tử trong cột đầu tiên chỉ có một đường đi (chỉ có thể di chuyển từ phần tử trên đó xuống dưới), do đó .
5. Kết quả cuối cùng

Dựa trên định nghĩa trạng thái, kết quả cuối cùng là , tức là số lượng đường đi từ góc trên bên trái đến vị trí trên bảng.

4.3.4 Code

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        dp = [[0 for _ in range(n)] for _ in range(m)]
        
        for j in range(n):
            dp[0][j] = 1
        for i in range(m):
            dp[i][0] = 1
 
        for i in range(1, m):
            for j in range(1, n):
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
        
        return dp[m - 1][n - 1]

4.3.5 Phân tích độ phức tạp

  • Độ phức tạp thời gian: . Vòng lặp đôi duy nhất có độ phức tạp thời gian là .
  • Độ phức tạp không gian: . Sử dụng một mảng hai chiều để lưu trạng thái, vì vậy độ phức tạp không gian tổng thể là . Tuy nhiên, vì trạng thái chỉ phụ thuộc vào , và chúng ta duyệt qua các phần tử theo thứ tự từ trên xuống dưới, từ trái sang phải, nên có thể sử dụng một mảng một chiều có độ dài để lưu trạng thái, từ đó tối ưu không gian đến .