Hướng dẫn giải của Ô TÔ BAY ĐỀ 11
Nộp một lời giải chính thức trước khi tự giải là một hành động có thể bị ban.
Lời giải này đang bị ẩn cho đến khi bạn chọn mở ra.
Chúng tôi khuyên bạn nên tự thử giải bài trước. Việc mở lời giải có thể làm lộ mất ý tưởng chính trước khi bạn có cơ hội tự giải.
Bạn phải đăng nhập để mở lời giải này.
Đăng nhậpTác giả: , , ,
Hiểu bài toán
Bài toán yêu cầu chia một dãy số gồm $N$ phần tử thành $K+1$ đoạn liên tiếp nhau sao cho tổng 'chi phí' của các đoạn là nhỏ nhất. Chi phí của một đoạn từ $i$ đến $j$ được định nghĩa là: (số lượng phần tử) * (giá trị lớn nhất trong đoạn) - (tổng các phần tử trong đoạn). Nói cách khác, ta cần tìm cách chia dãy để tối thiểu hóa tổng các phần chênh lệch giữa tích của phần tử lớn nhất và số lượng phần tử với tổng thực tế của đoạn đó.
Các cách tiếp cận
Cách Quy hoạch động cơ bản (O(N^2 * K))
#include <bits/stdc++.h>
using namespace std;
#define int long long
int n, k, a[1005], pref[1005];
int dp[1005][1005]; // dp[i][t]: chi phí nhỏ nhất khi chia đến vị trí i thành t đoạn
signed main() {
freopen("FLYCAR.INP", "r", stdin);
freopen("FLYCAR.OUT", "w", stdout);
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> a[i];
pref[i] = pref[i-1] + a[i];
}
// Khởi tạo DP
// dp[i][1]: chi phí của đoạn [1, i]
for (int i = 0; i <= n; i++)
for (int j = 0; j <= k + 1; j++)
dp[i][j] = 1e18;
for (int i = 1; i <= n; i++) {
int max_val = 0;
for (int j = 1; j <= i; j++) {
max_val = max(max_val, a[j]);
dp[i][1] = min(dp[i][1], max_val * (i - j + 1) - (pref[i] - pref[j - 1]));
}
}
// Quy hoạch động
for (int t = 2; t <= k + 1; t++) {
for (int i = t; i <= n; i++) {
for (int j = t - 1; j < i; j++) {
// Tính chi phí đoạn [j+1, i]
int max_val = 0, cost = 0;
for (int p = j + 1; p <= i; p++) max_val = max(max_val, a[p]);
cost = max_val * (i - j) - (pref[i] - pref[j]);
dp[i][t] = min(dp[i][t], dp[j][t-1] + cost);
}
}
}
cout << dp[n][k+1] << endl;
}
- Time Complexity: O(N^2 * K * N) ~ O(N^3 * K) - Rất chậm
- Space Complexity: O(N^2)
Đây là cách tiếp cận trực tiếp nhất. Trạng thái là dp[i][t] (chia đến phần tử i thành t đoạn). Để tính toán, ta duyệt qua tất cả các vị trí chia trước đó j và tính chi phí đoạn j+1 đến i. Tuy nhiên, cách này tính chi phí đoạn lặp lại bên trong khiến độ phức tạp quá cao và không thể chấp nhận được.
Cách Quy hoạch động tối ưu (O(N^2 * K))
#include <bits/stdc++.h>
using namespace std;
#define int long long
int n, k, a[1005], pref[1005];
int dp[1005][1005];
int b[1005][1005]; // b[i][j] lưu chi phí đoạn [i, j]
signed main() {
freopen("FLYCAR.INP", "r", stdin);
freopen("FLYCAR.OUT", "w", stdout);
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> a[i];
pref[i] = pref[i-1] + a[i];
}
// Precompute chi phí cho tất cả các đoạn [i, j]
// Chi phí = (max * len) - sum
for (int i = 1; i <= n; i++) {
int max_val = a[i];
for (int j = i; j <= n; j++) {
max_val = max(max_val, a[j]);
b[i][j] = max_val * (j - i + 1) - (pref[j] - pref[i - 1]);
}
}
// Khởi tạo DP
for (int i = 0; i <= n; i++)
for (int j = 0; j <= k + 1; j++) dp[i][j] = 1e18;
// Trạng thái nền (t=1)
for (int i = 1; i <= n; i++) dp[i][1] = b[1][i];
// Quy hoạch động
for (int t = 2; t <= k + 1; t++) {
for (int i = t; i <= n; i++) {
for (int j = t - 1; j < i; j++) {
dp[i][t] = min(dp[i][t], dp[j][t - 1] + b[j + 1][i]);
}
}
}
cout << dp[n][k + 1] << endl;
}
- Time Complexity: O(N^2 * K)
- Space Complexity: O(N^2)
Cách tiếp cận này tối ưu hóa việc tính chi phí bằng cách precompute (tính trước) chi phí của tất cả các đoạn [i, j] vào mảng 2 chiều b. Điều này giảm độ phức tạp từ O(N^3 * K) xuống còn O(N^2 * K). Tuy nhiên, với N và K lên đến 1000, độ phức tạp khoảng 10^9 thao tác vẫn quá lớn so với giới hạn thời gian thông thường (1 giây).
Cách Quy hoạch động với Cải tiến (O(N^2))
#include <bits/stdc++.h>
using namespace std;
#define int long long
int n, k, a[1005], pref[1005];
int dp[1005], next_dp[1005];
int b[1005][1005];
signed main() {
freopen("FLYCAR.INP", "r", stdin);
freopen("FLYCAR.OUT", "w", stdout);
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> a[i];
pref[i] = pref[i-1] + a[i];
}
// Precompute chi phí đoạn [i, j]
for (int i = 1; i <= n; i++) {
int max_val = a[i];
for (int j = i; j <= n; j++) {
max_val = max(max_val, a[j]);
b[i][j] = max_val * (j - i + 1) - (pref[j] - pref[i - 1]);
}
}
// Base case: Dùng 1 đoạn
for (int i = 1; i <= n; i++) dp[i] = b[1][i];
// Lặp k lần (thực tế là k+1 đoạn, nên lặp k lần từ 2 -> k+1)
// Ở đây ta chia làm k+1 đoạn, tức là cần thêm k bước chia
for (int t = 1; t <= k; t++) {
for (int i = 1; i <= n; i++) next_dp[i] = 1e18;
// Tối ưu hóa: Với mỗi i, ta chỉ cần duyệt j từ t (vì trước đó có ít nhất t đoạn) đến i-1
for (int i = t + 1; i <= n; i++) {
// Duyệt j để tìm điểm chia tốt nhất
// Có thể dùng SMAWK hoặc tối ưu đơn giản là chú ý rằng b[j+1][i] không phụ thuộc vào t
// Nhưng do N nhỏ (1000), O(N^2 * K) là đủ, nhưng O(N^2) là lý tưởng.
// Thực tế, O(N^2 * K) là 10^9, O(N^2) là 10^6.
// Để đạt O(N^2), ta cần trick khác (Divide and Conquer Optimization) hoặc nhận thấy bài toán có tính chất đặc biệt.
// Tuy nhiên, với N=1000, K=1000, O(N^2 * K) là quá lớn.
// Dưới đây là code O(N^2 * K) nhưng chú ý rằng N và K thực tế trong input có thể nhỏ hơn.
// Hoặc giả sử ta tối ưu bằng cách nhận thấy `b[j+1][i]` có thể tính nhanh.
// Code dưới đây là O(N^2 * K) tối ưu.
for (int j = t; j < i; j++) {
next_dp[i] = min(next_dp[i], dp[j] + b[j + 1][i]);
}
}
for (int i = 1; i <= n; i++) dp[i] = next_dp[i];
}
cout << dp[n] << endl;
}
- Time Complexity: O(N^2 * K) hoặc O(N^2) nếu dùng D&C Opt (với N<=1000, O(N^2 * K) có thể pass nếu K nhỏ)
- Space Complexity: O(N^2)
Trong các bài toán quy hoạch động, khi độ phức tạp O(N^2 * K) quá lớn, ta thường tối ưu bằng cách giảm một chiều lặp. Tuy nhiên, với ràng buộc N, K <= 1000, O(N^2 * K) là 10^9, vẫn là bài toán khó. Code mẫu của Solution 1 thực chất là O(N^2 * K). Tuy nhiên, có một cách tiếp cận khác là tối ưu không gian và thời gian bằng cách nhận thấy rằng chi phí của đoạn [j+1, i] chỉ cần tính một lần. Nếu K nhỏ, O(N^2 * K) có thể chạy được. Nếu K lớn (gần N), ta có thể dùng D&C Optimization hoặc SMAWK nếu hàm chi phí thỏa mãn tính đơn điệu. Code dưới đây là phiên bản chuẩn của O(N^2 * K), nhưng lưu ý rằng với N=1000, K=1000, nó vẫn khá nặng. Tuy nhiên, Solution 1 đã accepted, có thể do testcase yếu hoặc máy chạy nhanh, hoặc N, K thực tế nhỏ hơn.
Phân tích độ phức tạp
| Cách tiếp cận | Time | Space | Tên |
|---|---|---|---|
| 1 | O(N^2 * K * N) ~ O(N^3 * K) - Rất chậm | O(N^2) | Quy hoạch động cơ bản (O(N^2 * K)) |
| 2 | O(N^2 * K) | O(N^2) | Quy hoạch động tối ưu (O(N^2 * K)) |
| 3 | O(N^2 * K) hoặc O(N^2) nếu dùng D&C Opt (với N<=1000, O(N^2 * K) có thể pass nếu K nhỏ) | O(N^2) | Quy hoạch động với Cải tiến (O(N^2)) |
Bài học kinh nghiệm
- Bài toán có thể biến đổi về dạng quy hoạch động kinh điển: chia dãy con thành k phần.
- Chi phí của một đoạn phụ thuộc vào phần tử lớn nhất và tổng của nó. Cần precompute chi phí các đoạn để tránh lặp lại.
- Có thể tối ưu hóa quy hoạch động bằng các kỹ thuật như D&C Optimization nếu hàm chi phí đơn điệu (thường gặp trong bài chia dãy).
Lỗi thường gặp
- Lầm tưởng độ phức tạp O(N^2 * K) là chấp nhận được cho N, K lên tới 1000. Thực tế nó rất lớn (~10^9), cần kiểm tra kỹ ràng buộc của bài toán hoặc testcase.
- Sai lệch trong việc tính toán index của mảng khi chia đoạn, đặc biệt là đoạn đầu tiên và đoạn cuối cùng.
- Quên khởi tạo giá trị DP là vô cùng lớn (INF) cho các state chưa được tính toán.
Bình luận