Frog 2 (AtCoder Educational DP Contest B)
Đề bài
Atcoder Educational DP Contest B – Frog 2
Có $N$ hòn đá, được đánh số từ $1$ đến $N$. Với mỗi chỉ số $i$ $(1 \le i \le N)$, độ cao của hòn đá thứ $i$ là $h_i$.
Ban đầu, có một chú ếch đang ngồi ở hòn đá thứ nhất và chú sẽ thực hiện liên tục một loạt các hành động sau:
- Nếu chú đang ngồi ở hòn đá $i$ chú có thể nhảy đến các hòn đá thứ $i + 1, i + 2, \ldots, i + K$. Chú sẽ mất phí khi nhảy là $|h_i - h_j|$ với $j$ là hòn đá mà chú ếch nhảy đến.
Bạn hãy giúp chú ếch tìm chi phí tối thiểu để nhảy từ hòn đá thứ nhất đến hòn đá thứ $N$ nhé.
Input:
- Dòng đầu tiên của dữ liệu vào chứa hai số nguyên dương $N$ và $K$ $(2 \le N \le 10^5,\ 1 \le K \le 100)$, lần lượt là số lượng hòn đá và giới hạn nhảy của ếch.
- Dòng thứ hai gồm $N$ số nguyên $h_i$ $(1 \le i \le N,\ 1 \le h_i \le 10^4)$, là độ cao của hòn đá thứ $i$.
Output: Gồm một số nguyên, là chi phí ít nhất để nhảy từ hòn đá thứ nhất đến hòn đá thứ $N$.
5 3
10 30 40 50 20303 1
10 20 10202 100
10 10010 4
40 10 20 70 80 10 20 70 80 6040Lời mở đầu
Bài này mình được một bạn học viên hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇. Đây là bài B trong bộ Educational DP Contest nổi tiếng của AtCoder – bản "nâng cấp" của bài Frog 1: thay vì chỉ nhảy 1 hoặc 2 bước, giờ chú ếch được nhảy tối đa $K$ bước.
Mình giải thích ví dụ mẫu 1 để bạn nào chưa hiểu đề sẽ hiểu rõ. Có $N = 5$ hòn đá cao lần lượt $10, 30, 40, 50, 20$, mỗi lần ếch nhảy được $1$, $2$ hoặc $3$ hòn ($K = 3$). Một vài cách đi:
- $1 \to 2 \to 3 \to 4 \to 5$: $20 + 10 + 10 + 30 = 70$.
- $1 \to 4 \to 5$: $|10 - 50| + |50 - 20| = 40 + 30 = 70$.
- $1 \to 3 \to 5$: $|10 - 40| + |40 - 20| = 30 + 20 = 50$.
- $1 \to 2 \to 5$: $|10 - 30| + |30 - 20| = 20 + 10 = 30$ – đây là ít nhất, đáp án là 30.
Lưu ý từ hòn $1$ không nhảy thẳng tới hòn $5$ được vì $5 - 1 = 4 \gt K = 3$.
Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.
Nộp bài trên CLUE OJLộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Đệ quy thử mọi đường đi – hàm mũ
Trước khi vào lời giải chính, mình thử cách "làm theo đề" để các bạn thấy vì sao cần quy hoạch động. Gọi solve(i) là chi phí ít nhất để đi từ hòn $i$ tới hòn $N$. Đứng ở hòn $i$ thì ếch thử nhảy tới từng hòn $j = i+1, \ldots, \min(N, i+K)$, mất phí $|h_i - h_j|$ rồi đi tiếp từ $j$: solve(i) = min(|h[i] - h[j]| + solve(j)), và solve(N) = 0. Đáp án là solve(1).
Cách này đúng, nhưng có một vấn đề rất lớn: solve(j) của cùng một $j$ bị gọi đi gọi lại rất nhiều lần (ví dụ solve(4) được gọi từ cả solve(1), solve(2), solve(3)…), mỗi lần lại tính lại từ đầu. Số lời gọi tăng theo hàm mũ của $N$.
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
using namespace std;
int n, k;
int a[100005];
int solve(int i){
if(i == n){
return 0;
}
int best = INT_MAX;
for(int j = i + 1; j <= min(n, i + k); ++j){
best = min(best, abs(a[i] - a[j]) + solve(j));
}
return best;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
cin >> n >> k;
for(int i = 1; i <= n; ++i){
cin >> a[i];
}
cout << solve(1);
return 0;
}
import sys
sys.setrecursionlimit(10000)
data = sys.stdin.read().split()
n, k = int(data[0]), int(data[1])
a = [0] + [int(v) for v in data[2:2 + n]]
def solve(i):
if i == n:
return 0
best = float('inf')
for j in range(i + 1, min(n, i + k) + 1):
best = min(best, abs(a[i] - a[j]) + solve(j))
return best
print(solve(1))
Các bạn bấm nút Gọi lặp lại nhiều rồi bấm ⏭ để xem: chỉ với $n = 6$, $k = 3$ mà solve(6) đã bị gọi tới 13 lần.
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(N)$ cho mảng độ cao và độ sâu đệ quy.
- Thời gian: số lời gọi tăng theo hàm mũ của $N$, không thể chạy với $N = 10^5$.
Cách 2Quy hoạch động 4 bước – O(N·K)
Bài này áp dụng phương pháp Quy Hoạch Động (Dynamic Programming) để giải quyết nhé các bạn. Cho bạn nào đã biết cơ bản qua về quy hoạch động rồi thì 4 bước giải một bài toán quy hoạch động như sau.
(Thầy gọi hòn đá là "cột" và chi phí là "mức năng lượng" cho dễ hình dung nha các bạn.)
Bước 1: Xác định bài toán con
Gọi $F[i]$ là mức năng lượng nhỏ nhất của con ếch khi đi từ cột $1$ đến cột $i$.
Bước 2: Xác định bài toán cơ sở
$F[1] = 0$, bởi vì ban đầu con ếch đã ở sẵn cột $1$ rồi nên nó không cần mất năng lượng gì khi di chuyển từ cột $1$ đến cột $1$ cả.
Bước 3: Xác định đáp án bài toán
Quá rõ ràng đó chính là $F[N]$, vì đề bài đang yêu cầu tìm mức năng lượng nhỏ nhất để con ếch đi từ cột $1$ đến cột $N$.
Bước 4: Xác định công thức truy hồi
Nếu muốn đi đến vị trí $i$ thì trước đó con ếch phải ở một trong các vị trí $i - 1$, $i - 2$, $i - 3$, …, $i - K$: xa nhất là $i - K$ để với một bước nhảy $1$ hay $2$ hay … hay $K$ ô thì con ếch đến được vị trí $i$.
Để năng lượng là nhỏ nhất, ta thử từng vị trí đứng trước đó: năng lượng khi đứng ở vị trí $j$ (cũng đã là nhỏ nhất tính từ đầu đến $j$, tức $F[j]$), cộng thêm năng lượng để từ $j$ nhảy tiếp đến $i$ là $|h_i - h_j|$. Trong tối đa $K$ vị trí đó, vị trí nào cho tổng nhỏ nhất thì $F[i]$ lấy theo:
$F[i] = \min\big(F[i-1] + |h_i - h_{i-1}|,\ F[i-2] + |h_i - h_{i-2}|,\ \ldots,\ F[i-K] + |h_i - h_{i-K}|\big)$
max(1, i - k).Với sample 1 ($K = 3$, $h = 10, 30, 40, 50, 20$): $F[1] = 0$; $F[2] = 0 + 20 = 20$; $F[3] = \min(20 + 10,\ 0 + 30) = 30$; $F[4] = \min(30 + 10,\ 20 + 20,\ 0 + 40) = 40$; $F[5] = \min(40 + 30,\ 30 + 20,\ 20 + 10) = 30$ ✅ (đi từ hòn 2).
Minh hoạ: ếch tới hòn i từ đâu là rẻ nhất?
Các bạn nhập $K$ và dãy độ cao rồi bấm Tiếp. Với mỗi hòn $i$, các mũi tên là những cú nhảy có thể tới $i$ (từ $K$ hòn trước), mũi tên xanh là cú nhảy cho $F[i]$ nhỏ nhất. Bước cuối sẽ tô lại đường đi tối ưu:
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
using namespace std;
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
int n, k;
cin >> n >> k;
int a[n + 1];
for(int i = 1; i <= n; ++i){
cin >> a[i];
}
int f[n + 1];
f[1] = 0;
for(int i = 2; i <= n; ++i){
f[i] = f[i - 1] + abs(a[i - 1] - a[i]);
int finish = max(1, i - k);
for(int j = i - 2; j >= finish; --j){
f[i] = min(f[i], f[j] + abs(a[j] - a[i]));
}
}
cout << f[n];
return 0;
}
import sys
data = sys.stdin.read().split()
n, k = int(data[0]), int(data[1])
a = [0] * (n + 1)
for i in range(1, n + 1):
a[i] = int(data[1 + i])
f = [0] * (n + 1)
f[1] = 0
for i in range(2, n + 1):
f[i] = f[i - 1] + abs(a[i - 1] - a[i])
finish = max(1, i - k)
for j in range(i - 2, finish - 1, -1):
f[i] = min(f[i], f[j] + abs(a[j] - a[i]))
print(f[n])
Giải thích 1 số chỗ trong cách làm ở trên:
- 2 dòng đầu của hàm main (
ios::sync_with_stdio(0); cin.tie(0);) giúp đẩy nhanh tốc độ nhập xuất nếu các bạn code C++ (để hiểu vì sao thì cứ gõ mấy từ này lên Google là ra rất nhiều bài viết giải thích nhé). Bài này $N$ có thể tới $10^5$, tức là cả 100,000 phần tử cần nhập, nên hãy thêm 2 dòng này vào cho chạy nhanh hơn. - Do bài này vị trí tính từ $1$, để thống nhất tránh rắc rối không cần thiết ta khởi tạo mảng có $n + 1$ phần tử để chỉ số chạy từ $0$ đến $n$, coi như bỏ qua không xài chỉ số $0$, chỉ xài từ $1$ đến $n$.
- Khởi tạo
f[1] = 0chính là bước 2 (bài toán cơ sở). Rồi xét $i$ từ $2$ đến $n$. Vì tối thiểu $K$ cũng là $1$ nênf[i]gán luôn mặc định làf[i - 1] + abs(a[i - 1] - a[i]): năng lượng nhỏ nhất để đến cột $i - 1$, cộng năng lượng để nhảy từ cột $i - 1$ sang cột $i$ – tức là năng lượng nhỏ nhất đến cột $i$ nếu vị trí đứng trước đó là $i - 1$. Sau đó ta xét tiếp các vị trí đứng trước hợp lệ còn lại (từ $i - 2$ lùi vềfinish), so sánh và cập nhật nếu tìm ra giá trị nhỏ hơn, để sau cùngf[i]mang giá trị nhỏ nhất. Lặp đủ $i$ đến $n$ là cóf[n]– đáp án cần tìm. - Kiểu
intlà đủ: mỗi bước nhảy tốn tối đa $10^4 - 1$, đi qua tối đa $10^5 - 1$ bước nên $F[N] \lt 10^9$, chưa chạm giới hạn củaint.
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(2 \cdot N)$ cho 2 mảng $a$, $f$, tức là $O(N)$, với $N$ là số lượng cột.
- Thời gian: $O(N)$ của bước đọc dữ liệu cộng $O(N \cdot K)$ của bước xử lý ($N$ là số cột, $K$ là số bước tối đa con ếch có thể nhảy). Theo quy tắc cộng Big-O thì lấy theo thành phần lớn nhất, nên tổng quát là $O(N \cdot K)$.
Trường hợp xấu nhất mọi dữ liệu đều chạm ngưỡng tối đa: $N = 10^5$, $K = 100$ thì số lần lặp là $10^5 + 10^7 = 10{,}100{,}000$ (10 triệu 100 ngàn). Với giới hạn 2 giây thì số lần lặp được phép khoảng $10^8$ (100 triệu), cao lắm là $2 \cdot 10^8$, nên cách này ít hơn cả 10 đến 20 lần, chắc chắn không sợ TLE. Thậm chí nếu đề chỉ cho 1 giây (ngưỡng khoảng $(3 \text{ đến } 5) \cdot 10^7$, cao lắm $10^8$) thì cách làm này vẫn ít hơn ngưỡng từ 3 đến 10 lần.
min([...]) trên list comprehension thì cũng chỉ còn khoảng 9 giây. Bài này các bạn nên nộp C++ nha (hoặc chọn PyPy nếu hệ thống chấm có hỗ trợ).- Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
- Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
- Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
- Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
- Dễ nản giữa chừng khi không có ai theo sát.
- Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
- Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
- Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
- Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.
Nhận xét
Đăng nhận xét