Trung Bình Cộng Lớn Nhất (CSLOJ 1404) (Bồi dưỡng HSGQG Hải Phòng 2020)
Đề bài
Nguồn: Bài tập Bồi dưỡng HSGQG, Hải Phòng, T11/2020, Thái BK.
Cho dãy số nguyên $a_1, a_2, \ldots, a_n$ và một số nguyên dương $k$. Hãy tìm một đoạn con của $a$, có ít nhất $k$ phần tử và có trung bình cộng lớn nhất có thể.
Dữ liệu vào:
- Dòng đầu chứa hai số nguyên dương $n, k$;
- Dòng tiếp theo chứa dãy $a$.
Dữ liệu ra:
- Ghi trung bình cộng lớn nhất tìm được, quy tròn đến số thập phân thứ năm.
Giới hạn:
- $1 \le k \le n \le 10^5$, $-10^5 \le a_i \le 10^5$;
- Có $50\%$ số test với $n \le 5000$.
7 3
2 4 5 1 3 4 13.66667Lời mở đầu
Bữa giờ LeetCode hoài sợ anh em ngán 😅. Nay mình đi comment dạo trong một nhóm lập trình, thấy có bạn hỏi bài này, mình thấy bài cũng hay nên đăng để anh em thử sức nhé. Bài này là một ví dụ rất "kinh điển" cho kỹ thuật chặt nhị phân đáp án kết hợp mảng cộng dồn, anh em nào chưa biết kỹ thuật này thì đây là dịp tốt để học luôn 😇.
Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì link bài đây nhé:
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 OJGiải thích ví dụ cho bạn nào chưa hiểu đề: với $k = 3$, ta chỉ được chọn các đoạn liên tiếp có từ 3 phần tử trở lên. Thử vài đoạn:
- $[2, 4, 5]$ có trung bình $\dfrac{11}{3} \approx 3.66667$;
- $[4, 5, 1]$ có trung bình $\dfrac{10}{3} \approx 3.33333$; $[2, 4, 5, 1]$ có trung bình $\dfrac{12}{4} = 3$;
- $[4, 5, 1, 3, 4]$ có trung bình $\dfrac{17}{5} = 3.4$; cả dãy có trung bình $\dfrac{20}{7} \approx 2.85714$...
Không đoạn nào vượt quá $\dfrac{11}{3}$, nên đáp án là 3.66667.
Lộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Duyệt mọi đoạn với mảng cộng dồn – O(n²)
Cách tự nhiên nhất: thử hết tất cả các đoạn $[j+1, i]$ có độ dài $i - j \ge k$, tính trung bình cộng rồi lấy lớn nhất. Để tính tổng một đoạn trong $O(1)$ thì ta dùng mảng cộng dồn:
Vậy chỉ cần 2 vòng lặp: $i$ chạy từ $k$ đến $n$ (điểm cuối đoạn), $j$ chạy từ $0$ đến $i - k$ (đoạn bắt đầu ở $j + 1$, đảm bảo đủ ít nhất $k$ phần tử).
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;
long long S[100005];
int main(){
cin >> n >> k;
for(int i = 1; i <= n; i++){
long long x;
cin >> x;
S[i] = S[i - 1] + x;
}
double best = -1e18;
for(int i = k; i <= n; i++){
for(int j = 0; j <= i - k; j++){
double tb = (double)(S[i] - S[j]) / (i - j);
best = max(best, tb);
}
}
printf("%.5f", best);
return 0;
}
import sys
data = sys.stdin.read().split()
n, k = int(data[0]), int(data[1])
S = [0] * (n + 1)
for i in range(1, n + 1):
S[i] = S[i - 1] + int(data[i + 1])
best = -1e18
for i in range(k, n + 1):
for j in range(0, i - k + 1):
tb = (S[i] - S[j]) / (i - j)
if tb > best:
best = tb
print("%.5f" % best)
Đánh giá độ phức tạp của cách làm này:
- Không gian: mảng cộng dồn $S$ có $n + 1$ phần tử ⇒ $O(n)$.
- Thời gian: số cặp $(j, i)$ cỡ $\dfrac{n^2}{2}$ ⇒ $O(n^2)$. Với $n \le 5000$ thì khoảng $1.25 \cdot 10^7$ phép tính, C++ chạy tức thì; nhưng với $n = 10^5$ thì lên tới $5 \cdot 10^9$ ⇒ TLE.
Cách 2Chặt nhị phân đáp án – O(n · log)
Thay vì hỏi "trung bình lớn nhất là bao nhiêu?", ta đổi sang câu hỏi dễ hơn: "có đoạn nào (ít nhất $k$ phần tử) có trung bình cộng $\ge x$ hay không?"
Biến đổi trung bình thành tổng
Đoạn $a_{j+1}, \ldots, a_i$ (độ dài $L = i - j$) có trung bình $\ge x$ khi và chỉ khi:
Tức là: trừ $x$ vào mọi phần tử, rồi hỏi có đoạn nào dài ít nhất $k$ mà tổng $\ge 0$ không.
Gọi $P_i = (a_1 - x) + \ldots + (a_i - x)$ là mảng cộng dồn của dãy đã trừ $x$. Đoạn kết thúc tại $i$ có tổng $P_i - P_j$ với $j \le i - k$. Muốn tổng này lớn nhất thì $P_j$ phải nhỏ nhất ⇒ ta chỉ cần giữ $mn = \min(P_0, P_1, \ldots, P_{i-k})$, cập nhật dần khi $i$ tăng. Một vòng lặp $O(n)$ là trả lời xong câu hỏi.
Vì sao chặt nhị phân được?
Nếu có đoạn trung bình $\ge x$ thì đoạn đó cũng có trung bình $\ge x'$ với mọi $x' \lt x$. Nghĩa là câu trả lời "có/không" theo $x$ có dạng: có, có, ..., có, không, không, ... ⇒ đúng điều kiện để chặt nhị phân. Đáp án luôn nằm giữa $\min(a)$ và $\max(a)$, nên ta khởi tạo $lo = \min(a)$ (chắc chắn "có"), $hi = \max(a)$ và chặt:
- $mid = \dfrac{lo + hi}{2}$; nếu
check(mid)đúng thì đáp án $\ge mid$ ⇒ $lo = mid$; - ngược lại đáp án $\lt mid$ ⇒ $hi = mid$;
- dừng khi $hi - lo \le 10^{-7}$ (đủ chính xác để làm tròn 5 chữ số thập phân), in $lo$.
Minh hoạ chặt nhị phân
Em nhập $k$ và dãy $a$ rồi bấm Tiếp. Mỗi bước là một lần thử $mid$: dòng $a - mid$ là dãy sau khi trừ, dòng $P$ là mảng cộng dồn, ô xanh là đoạn có tổng $\ge 0$ mà check tìm được (nếu có).
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;
double a[100005], P[100005];
bool check(double x){
P[0] = 0;
double mn = 1e18;
for(int i = 1; i <= n; i++){
P[i] = P[i - 1] + (a[i] - x);
if(i >= k){
mn = min(mn, P[i - k]);
if(P[i] - mn >= 0) return true;
}
}
return false;
}
int main(){
cin >> n >> k;
double lo = 1e9, hi = -1e9;
for(int i = 1; i <= n; i++){
cin >> a[i];
lo = min(lo, a[i]);
hi = max(hi, a[i]);
}
while(hi - lo > 1e-7){
double mid = (lo + hi) / 2;
if(check(mid)) lo = mid;
else hi = mid;
}
if(fabs(lo) < 5e-6) lo = 0; // tránh in ra "-0.00000"
printf("%.5f", lo);
return 0;
}
import sys
from itertools import accumulate
from operator import sub
data = sys.stdin.read().split()
n, k = int(data[0]), int(data[1])
a = [int(x) for x in data[2:2 + n]]
def check(x):
# P[i] = tổng (a[j] - x) của i phần tử đầu
P = list(accumulate((v - x for v in a), initial=0))
# M[t] = min(P[0..t]) – tiền tố nhỏ nhất có thể "cắt bỏ"
M = list(accumulate(P[:n - k + 1], min))
# đoạn kết thúc tại i (i >= k): P[i] - min(P[0..i-k])
return max(map(sub, P[k:], M)) >= 0
lo, hi = min(a), max(a)
while hi - lo > 1e-7:
mid = (lo + hi) / 2
if check(mid):
lo = mid
else:
hi = mid
if abs(lo) < 5e-6: # tránh in ra "-0.00000"
lo = 0.0
print("%.5f" % lo)
itertools.accumulate để tính mảng cộng dồn và min tiền tố, ý tưởng y hệt hàm check của C++ nhưng chạy nhanh hơn nhiều so với vòng for thuần (mỗi lần check đi qua $10^5$ phần tử, mà phải gọi vài chục lần).Đánh giá độ phức tạp của cách làm này:
- Không gian: mảng $a$ và $P$ ⇒ $O(n)$.
- Thời gian: mỗi lần
checklà $O(n)$; số lần chặt là $\log_2 \dfrac{\max(a) - \min(a)}{10^{-7}} \le \log_2 \dfrac{2 \cdot 10^5}{10^{-7}} \approx 41$ lần ⇒ tổng cỡ $41 \cdot n \approx 4 \cdot 10^6$ phép tính, rất nhanh.
- Đáp án là số thực nên không chặt nhị phân kiểu
lo + 1như số nguyên được, mà chặt tới khi $hi - lo$ đủ nhỏ ($10^{-7}$). Đừng để sai số quá lớn (như $10^{-3}$) vì sẽ làm tròn sai chữ số thứ 5. - Khi đáp án đúng bằng $0$, $lo$ có thể là một số âm rất nhỏ như $-0.00000003$ và
printf("%.5f")sẽ in ra-0.00000. Vì thế code có thêm dòng gán $lo = 0$ khi $|lo| \lt 5 \cdot 10^{-6}$ (đáp án khác 0 luôn có trị tuyệt đối $\ge 10^{-5}$ vì mẫu số $\le 10^5$, nên dòng này không làm sai trường hợp nào).
- 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