I. Mở đầu
Trong những bài viết trước, các bạn đã được giới thiệu tuần tự những chiến lược giải thuật từ đơn giản tới nâng cao, như đệ quy, quay lui, nhánh cận, tham lam,...Những chiến lược nói trên thực ra sẽ không xuất hiện quá nhiều trong những cuộc thi lập trình, và cũng không phải là cách hay để giải quyết bài toán, vì nhiều lí do:
- Trong các cuộc thi lập trình, mỗi bài tập đều bị giới hạn thời gian rất chặt. Giải pháp quay lui hay nhánh cận mặc dù luôn luôn cho ra kết quả đúng, nhưng thời gian thực thi quá lớn, nên chỉ có thể lấy được số điểm rất ít.
- Giải pháp Tham lam mặc dù thời gian thực thi khá nhanh, nhưng kết quả lại không phải luôn luôn chính xác.
Còn một giải pháp nữa mà tôi cũng đã giới thiệu tới các bạn, đó là Chia để trị (Divide and Conquer). Tư tưởng của phương pháp này là chia nhỏ bài toán thành các bài toán con đơn giản hơn, tìm lời giải của chúng, cuối cùng kết hợp nghiệm của các bài toán con lại thành nghiệm của bài toán ban đầu. Giải thuật đệ quy cũng được thiết kế dựa trên nguyên lí này. Tuy nhiên, trong quá trình chia nhỏ bài toán lớn, sẽ có rất nhiều bài toán con bị lặp lại, gây ra các bước tính toán thừa thãi. Phương pháp Quy hoạch động ra đời chính là để giải quyết việc đó.
Mặc dù nghe có vẻ đơn giản, nhưng thực tế Quy hoạch động lại là phương pháp tốn rất nhiều giấy mực của các chuyên gia Tin học, cũng như khiến cho những người học lập trình cảm thấy rất đau đầu. Nhưng tỉ lệ thuận với sự phức tạp của nó, thì khả năng ứng dụng của phương pháp này trong các bài toán cũng vô cùng to lớn. Cụ thể ra sao, chúng ta hãy cùng tìm hiểu thông qua bài viết này.
II. Các khái niệm căn bản
1. Công thức truy hồi
Đây là một khái niệm khá quen thuộc với các bạn học sinh giỏi Toán. Đây là một chủ đề quan trọng của Lý thuyết tổ hợp, có nhiều ứng dụng trong các bài toán đếm, đồng thời là một công cụ vô cùng hữu hiệu để giải các bài toán có bản chất Đệ quy.
Lấy một ví dụ đơn giản, dãy số Fibonacci được định nghĩa đệ quy như sau:
Trong định nghĩa trên, ta có hai yếu tố cần chú ý:
- . Đây là các giá trị cung cấp sẵn của bài toán, hay còn gọi là bài toán cơ sở.
- . Đây là công thức để liên hệ giữa phần tử thứ của dãy số với các phần tử trước nó, hay còn gọi là công thức truy hồi.
Tóm lại, công thức truy hồi là một công thức dùng để liên hệ giữa kết quả của các bài toán con với kết quả của bài toán lớn hơn nó, từ đó tìm ra được lời giải cho bài toán ban đầu. Đây là đặc trưng chỉ xuất hiện trong những bài toán có tính chất đệ quy, nghĩa là lời giải cho bài toán lớn hơn được định nghĩa thông qua lời giải cho bài toán nhỏ hơn nó.
2. Bài toán tối ưu
Bài toán tối ưu là một bài toán có nhiều ứng dụng trong thực tế. Các bạn có lẽ đã nghe nhiều tới khái niệm này trong các phương pháp trước đó, nhưng chỉ là một cách đại thể. Bài toán tối ưu được định nghĩa chính xác như sau:
Xét bài toán có nghiệm là . Gọi là một hàm đánh giá độ "tốt" của nghiệm còn là các hàm điều kiện của bài toán. Nếu như bài toán yêu cầu tìm một nghiệm sao cho thỏa mãn tất cả các hàm điều kiện đồng thời là "tốt nhất", thì bài toán đó gọi là bài toán tối ưu.
III. Phương pháp Quy hoạch động
1. Bài toán con gối nhau và Cấu trúc con tối ưu
Bài toán con gối nhau
Như đã nói, những bài toán có bản chất đệ quy đều có thể giải được bằng phương pháp đệ quy. Hãy lấy luôn ví dụ là bài toán dãy Fibonacci ở trên. Nếu như sử dụng phương pháp đệ quy, ta sẽ cài đặt nó như sau:
Ngôn ngữ C++:
int fibo(int n)
{
if (n <= 1)
return n;
return fibo(n - 1) + fibo(n - 2);
}
Ngôn ngữ Python:
def fibo(n):
if n <= 1:
return n
return fibo(n - 1) + fibo(n - 2)
Hãy cùng nhìn vào sơ đồ thực thi của lời giải đệ quy trên, với :
Ta thấy để tính được bài toán chương trình phải tính lại lần lần lần lần và lần . Những bài toán trùng lặp đó gọi là bài toán con gối nhau, chúng chính là tác nhân chính khiến cho giải thuật đệ quy chạy rất chậm, vì việc tính toán phải thực hiện lại ngay cả khi bài toán đã có đáp số rồi!
Cấu trúc con tối ưu
Một bài toán tối ưu được gọi là thỏa mãn tính chất có "cấu trúc con tối ưu" khi và chỉ khi, lời giải tối ưu cho bài toán đó có thể thu được bằng cách sử dụng lời giải tối ưu của các bài toán con của nó.
Lấy một ví dụ đơn giản, bạn cần tìm một đường đi ngắn nhất từ nhà tới ga Hà Nội, và bạn biết rằng để tới được ga Hà Nội, thì chắc chắn phải đi qua phố Lê Duẩn. Vậy bạn có thể tìm một đường đi ngắn nhất từ nhà tới phố Lê Duẩn, rồi từ phố Lê Duẩn đi tới ga Hà Nội. Việc chia nhỏ các bài toán sẽ lại tiếp tục từ phố Lê Duẩn, cho tới khi tới được một địa điểm nào đó mà bạn đã biết chắc chắn đường đi ngắn nhất từ nhà mình tới địa điểm đó. Có thể nói, trong cuộc sống thường ngày chúng ta cũng hay tư duy theo cấu trúc con tối ưu, chứ không chỉ trong toán học!
2. Phương pháp Quy hoạch động
Quy hoạch động là phương pháp ra đời vào năm 1953, được sáng tạo bởi nhà Toán học người Mỹ Richard Bellman (1920 - 1984) để làm giảm thời gian chạy của các bài toán có tính chất của những bài toán con gối nhau và cấu trúc con tối ưu.
Richard Bellman
Hai dạng thường gặp nhất của các bài toán giải bằng quy hoạch động là:
- Bài toán đếm có bản chất đệ quy.
- Bài toán tối ưu có bản chất đệ quy.
Tư tưởng chủ đạo của phương pháp này vẫn là "chia để trị", tuy nhiên, sự khác biệt là ở phương pháp Quy hoạch động, những bài toán con gối nhau xuất hiện trong quá trình phân rã bài toán lớn sẽ được lưu lại hết lời giải trong một bảng phương án, thay vì tính toán lại nhiều lần. Sau đó, các lời giải này sẽ được kết hợp lại với nhau bằng công thức truy hồi để tạo thành kết quả của bài toán lớn.
Lấy ví dụ, bài toán Fibonacci thay vì giải bằng phương pháp đệ quy, thì ta có thể lưu lại kết quả của từng bài toán bằng một mảng rồi sử dụng kết quả đã tính để tạo ra số fibonacci tiếp theo.
Cài đặt C++:
int fibo(int n)
{
int f[n + 1];
f[0] = f[1] = 1;
for (int i = 2; i <= n; ++i)
f[i] = f[i - 1] + f[i - 2];
return f[n];
}
Cài đặt Python:
def fibo(n):
f = [0] * (n + 1)
f[0] = f[1] = 1
for i in range(2, n + 1):
f[i] = f[i - 1] + f[i - 2]
return f[n]
Với phương pháp này, mỗi giá trị Fibonacci được đảm bảo chỉ phải tính một lần duy nhất, khác hẳn với phương pháp đệ quy đã trình bày ở bên trên. Nhờ thế, thuật toán sẽ thực thi rất hiệu quả về mặt thời gian.
Bảng dưới đây sẽ so sánh những điểm khác biệt giữa hai phương pháp Đệ quy và Quy hoạch động:
Tựu chung lại, một bài toán Quy hoạch động sẽ được giải qua ba bước:
- Bước : Giải tất cả các bài toán cơ sở, lưu sẵn vào bảng phương án.
- Bước : Sử dụng công thức truy hồi, phối hợp lời giải của các bài toán con, từ đó tìm ra lời giải của bài toán lớn và tiếp tục lưu vào bảng phương án. Thực hiện như vậy tới khi tìm ra lời giải của bài toán ban đầu.
- Bước : Dựa vào bảng phương án, truy vết tìm ra nghiệm tối ưu của bài toán.
Ngoài ra, các bạn cũng cần ghi nhớ một số khái niệm khi học về phương pháp Quy hoạch động:
- Bài toán giải theo phương pháp Quy hoạch động gọi là Bài toán Quy hoạch động.
- Công thức phối hợp nghiệm của các bài toán con để có nghiệm của bài toán lớn gọi là Công thức truy hồi của Quy hoạch động.
- Tập các bài toán nhỏ nhất có ngay lời giải để từ đó tìm cách giải quyết các bài toán lớn hơn gọi là Cơ sở Quy hoạch động.
- Không gian lưu trữ lời giải các bài toán con để tìm cách phối hợp chúng gọi là Bảng phương án của Quy hoạch động.
Bây giờ, hãy cùng xét một số bài toán minh họa để hiểu rõ hơn về phương pháp này!
IV. Một số bài toán minh họa
1. Lát gạch
Đề bài
Một hành lang có kích thước ô gạch gần được lát gạch hoa. Chẳng hạn với hành lang được biểu diễn như hình vẽ dưới đây:
Yêu cầu: Hãy đếm số cách lát các viên gạch có kích thước sao cho lát kín được hành lang?
Input:
- Một dòng duy nhất chứa số nguyên dương - độ dài hành lang.
Ràng buộc:
- .
Output:
- Số nguyên duy nhất là số cách lát gạch thỏa mãn. In ra phần dư của kết quả sau khi chia cho .
Sample Input:
4
Sample Output:
5
Phân tích ý tưởng
Đầu tiên, ta nhận thấy hai trường hợp và có thể giải ngay lập tức:
- Nếu chỉ có một cách duy nhất để lát gạch là sử dụng một viên gạch đặt theo chiều dọc.
- Nếu có hai cách để lát gạch là đặt hai viên gạch theo chiều dọc hoặc chiều ngang.
Đặt là số cách lát gạch tính đến cột thứ thì ta đã biết trước và . Bây giờ, để lát kín cột thứ của hàng lang, ta có hai phương án như hình bên dưới:
Đối với cách lát gạch thứ nhất, thì tổng số cách lát tính tới cột thứ sẽ bằng tổng số cách lát tính tới cột thứ . Còn đối với cách lát gạch thứ hai, thì tổng số cách lát tính tới cột thứ sẽ bằng tổng số cách lát tính tới cột thứ .
Từ đây, ta rút ra công thức truy hồi:
Đây là một bài toán đếm có bản chất đệ quy. Nếu sử dụng phương pháp đệ quy, thì giống như bài toán dãy Fibonacci, sẽ có rất nhiều giá trị bị tính lại, dẫn đến thời gian thực hiện không thể trong giây được. Giải pháp là sử dụng một mảng để lưu lại kết quả sau mỗi lần tính, để đảm bảo mỗi giá trị chỉ phải tính một lần duy nhất.
Lưu ý đề bài yêu cầu in ra kết quả sau khi chia lấy dư cho . Đối với C++, cần thực hiện phép chia dư liên tục để tránh bị tràn số.
Cài đặt
Cài đặt C++:
#include <bits/stdc++.h>
using namespace std;
const long long mod = 1e9 + 7;
main()
{
int n;
cin >> n;
long long dp[n + 1];
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; ++i)
dp[i] = (dp[i - 1] + dp[i - 2]) % mod;
cout << dp[n];
return 0;
}
Cài đặt Python:
if __name__ == '__main__':
n = int(input())
dp = [0] * (n + 1)
dp[1] = 1
dp[2] = 2
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
mod = 10 ** 9
print(dp[n] % mod)
Đánh giá độ phức tạp
Mỗi giá trị chỉ được tính duy nhất một lần, do đó chương trình sẽ có độ phức tạp là .
2. Dãy con tăng dài nhất
Đề bài
Cho dãy số nguyên gồm phần tử . Một dãy con của dãy số là một cách chọn ra phần tử bất kỳ của dãy số, với . Dãy con đơn điệu tăng gồm phần tử là dãy con thỏa mãn .
Yêu cầu: Hãy tìm dãy con đơn điệu tăng dài nhất của dãy đã cho?
Input:
- Dòng đầu tiên chứa số nguyên dương - độ dài dãy số.
- Dòng thứ hai chứa số nguyên phân tách nhau bởi dấu cách - các phần tử của dãy ban đầu.
Ràng buộc:
- .
- .
Output:
- Dòng đầu tiên chứa một số nguyên là độ dài của dãy con tăng dài nhất tìm được.
- Dòng thứ hai chứa dãy con đó. Nếu tìm được nhiều dãy thì in ra một dãy bất kỳ.
Sample Input:
5
1 4 2 3 2
Sample Output:
3
1 2 3
Phân tích ý tưởng
Hãy gọi là độ dài của dãy con tăng dài nhất kết thúc tại phần tử .
Có một nhận xét rằng, trước khi tính nếu như ta đã biết được các thì ta hoàn toàn có thể tính được bằng cách lựa chọn một vị trí phía trước nó để ghép vào cuối dãy con đơn điệu tăng kết thúc tại . Điều quan trọng là lựa chọn dãy nào cho tốt nhất?
Nhận xét trên khiến ta rút ra được bài toán này là một bài toán thỏa mãn cấu trúc con tối ưu, và nó lại có bản chất đệ quy. Dĩ nhiên, muốn tạo được dãy con tăng kết thúc tại dài nhất, thì ta cần lựa chọn dãy con tăng dài nhất kết thúc tại một vị trí phía trước và phải nhỏ hơn (để đảm bảo tính tăng). Từ đây ta rút ra công thức:
Kết quả cuối cùng tất nhiên phải là .
Để truy vết tìm ra dãy con tăng dài nhất, ta sử dụng thêm một mảng để lưu lại vị trí của phần tử liền trước trong dãy con tăng dài nhất kết thúc tại . Mỗi khi ta tính được thì hãy lưu . Sau đó, quá trình truy vết diễn ra như sau:
- Gọi là vị trí có lớn nhất. Phần tử cuối cùng được chọn của dãy sẽ là .
- Phần tử áp chót trong dãy được chọn là .
- Phần tử liền trước phần tử áp chót trong dãy được chọn là .
Vậy ta có thể liên tục thực hiện gán rồi ghi nhận phần tử ở vị trí cho tới khi thì hoàn thành quá trình truy vết.
Cài đặt
Cài đặt C++:
#include <bits/stdc++.h>
using namespace std;
// Nhập dữ liệu.
void enter(int &n, vector < int > &a)
{
cin >> n;
a.resize(n + 1);
for (int i = 1; i <= n; ++i)
cin >> a[i];
}
// Truy vết tìm ra độ dài dãy con tăng dài nhất.
// và tìm ra một dãy con tăng dài nhất.
void trace_back(int n, vector < int > &a, vector < int > &dp, vector < int > &trace)
{
// Chọn ra vị trí best_pos có dp tại đó lớn nhất.
int best_pos = 0;
for (int i = 1; i <= n; ++i)
if (dp[i] > dp[best_pos])
best_pos = i;
cout << dp[best_pos] << endl;
vector < int > lis_elements;
while (best_pos)
{
lis_elements.push_back(a[best_pos]);
best_pos = trace[best_pos];
}
for (int i = lis_elements.size() - 1; i >= 0; --i)
cout << lis_elements[i] << ' ';
}
// Sử dụng công thức truy hồi để tính bảng phương án.
void solution(int n, vector < int > &a)
{
vector < int > dp(n + 1), trace(n + 1);
for (int i = 1; i <= n; ++i)
{
// Chọn vị trí jmax tốt nhất để nối a[i] vào sau.
int jmax = 0;
for (int j = 1; j < i; ++j)
if (dp[j] > dp[jmax] && a[j] < a[i])
jmax = j;
dp[i] = dp[jmax] + 1; // Cập nhật dp[i].
trace[i] = jmax; // Lưu vết phần tử đứng trước a[i] là a[jmax].
}
trace_back(n, a, dp, trace);
}
main()
{
int n;
vector < int > a;
enter(n, a);
solution(n, a);
return 0;
}
Cài đặt Python:
import sys
# Truy vết và in ra kết quả.
def trace_back(n, a, dp, trace):
# Chọn ra vị trí best_pos có dp tại đó lớn nhất.
best_pos = 1
for i in range(2, n + 1):
if dp[i] > dp[best_pos]:
best_pos = i
print(dp[best_pos])
# Tìm ra dãy con tăng dài nhất có phần tử kết thúc là a[best_pos].
lis_elements = []
while best_pos != 0:
lis_elements.append(a[best_pos])
best_pos = trace[best_pos]
lis_elements.reverse()
print(lis_elements)
# Tính bảng phương án bằng công thức truy hồi.
def solution(n, a):
trace, dp = [0] * (n + 1)
for i in range(1, n):
# Chọn vị trí jmax tốt nhất để nối a[i] vào sau.
jmax = 0
for j in range(1, i - 1):
if dp[j] > dp[jmax] and a[j] < a[i]:
jmax = j
dp[i] = dp[jmax] + 1 # Cập nhật dp[i].
trace[i] = jmax # Lưu vết phần tử liền trước a[i] là a[jmax].
trace_back(n, a, dp, trace)
if __name__ == '__main__':
n = int(input())
a = [0] * (n + 1)
for i in range(1, n + 1):
a[i] = int(input())
solution(n, a)
Đánh giá độ phức tạp
Với mỗi giá trị ta cần thực hiện lại một vòng lặp với thao tác so sánh và kiểm tra để chọn ra vị trí thỏa mãn lớn nhất và . Vì thế số lần lặp là tương ứng với độ phức tạp là .
V. Tổng kết
Như vậy, trong bài viết này, chúng ta đã cùng nhau tìm hiểu về những khái niệm cơ bản nhất của phương pháp Quy hoạch động. Hy vọng các bạn đã có thể hình dung được cách hoạt động của phương pháp sau khi đọc những bài toán ví dụ tôi đưa ra.
Ngoài ra, như đã nói ở trên, phương pháp Quy hoạch động thường dùng để giải hai dạng bài toán:
- Bài toán đếm có bản chất đệ quy.
- Bài toán tối ưu có bản chất đệ quy.
Vì vậy, nếu như các bạn gặp phải một bài toán yêu cầu đếm một đại lượng nào đó, hoặc yêu cầu tìm ra một kết quả lớn nhất hay nhỏ nhất, thì có nhiều khả năng đó là một bài toán giải bằng phương pháp Quy hoạch động. Điều cần quan tâm cuối cùng là bài toán đó có bản chất đệ quy hay không? Để kiểm tra điều này, các bạn có thể thử suy nghĩ giải bài toán đó bằng Đệ quy (với kích thước nhỏ thì bài toán Quy hoạch động hoàn toàn giải được bằng Đệ quy), rồi mới quyết định có sử dụng Quy hoạch động hay không.
Đôi khi những bài toán có yêu cầu tìm kết quả lớn nhất hay nhỏ nhất lại là một bài toán sử dụng Tìm kiếm nhị phân thay vì Quy hoạch động, vì thế các bạn cần phân biệt rõ điều kiện cần và đủ của hai phương pháp này, tránh gây ra nhầm lẫn khi giải các bài toán.
Cuối cùng, hãy luyện tập thật nhiều!
VI. Tài liệu tham khảo
- Competitive Programming 3 - Steven Halim & Felix Halim.
- https://www.geeksforgeeks.org/optimal-substructure-property-in-dynamic-programming-dp-2/.
- https://www.geeksforgeeks.org/overlapping-subproblems-property-in-dynamic-programming-dp-1/.
- Sách Giải thuật và Lập trình - thầy Lê Minh Hoàng.
- Tài liệu giáo khoa chuyên Tin quyển 1 - thầy Hồ Sĩ Đàm.