Xây Hàng Rào (HNOJ)
Đề bài
Marisa có $n$ tấm gỗ và sẽ sửa lại chúng để sử dụng, tấm gỗ thứ $i$ sẽ có độ đẹp là $A_i$ hoặc $B_i$ (tuỳ vào mục đích sử dụng mà Marisa sẽ sửa chúng khác nhau).
Marisa dự định chọn ra $k$ tấm gỗ để làm hàng rào bao quanh nhà. Trong đó, cô sẽ chọn ra $k - 1$ tấm để dựng hàng rào và 1 tấm để làm cửa. Khi đó, những tấm để làm hàng rào sẽ có độ đẹp là $A_i$ và tấm để làm cửa sẽ có độ đẹp là $B_i$.
Marisa đang cân nhắc số lượng tấm gỗ sử dụng để làm hàng rào, nên cô sẽ hỏi bạn $q$ câu hỏi, mỗi câu hỏi là một số nguyên $k$, hãy giúp cô tính độ đẹp lớn nhất có thể khi sử dụng $k$ tấm gỗ.
Dữ liệu vào từ tệp văn bản xayhangrao.inp:
- Dòng đầu tiên gồm hai số nguyên $n, q$.
- Dòng thứ hai gồm $n$ số nguyên $A_i$.
- Dòng thứ ba gồm $n$ số nguyên $B_i$.
- Dòng thứ tư gồm $q$ số nguyên $k$ theo thứ tự, mô tả các truy vấn.
Kết quả ghi ra tệp văn bản xayhangrao.out: In ra $q$ số nguyên, số nguyên thứ $i$ là đáp án truy vấn thứ $i$.
Giới hạn: Trong mọi test: $1 \le n, q \le 10^5$, $1 \le A_i, B_i \le 10^9$.
- Subtask 1 (25% số điểm): Marisa chỉ hỏi bạn một câu hỏi duy nhất với $k = n$.
- Subtask 2 (25% số điểm): $1 \le n, q \le 10$.
- Subtask 3 (25% số điểm): $1 \le n, q \le 1000$.
- Subtask 4 (25% số điểm): Không có giới hạn gì thêm.
Ví dụ: (khung trái là nội dung xayhangrao.inp, khung phải là nội dung xayhangrao.out)
3 1
3 5 4
5 8 5
3153 2
3 5 4
5 8 5
3 215 12if (fopen("xayhangrao.inp", "r")) { freopen(...); freopen(...); } (Python: if os.path.exists("xayhangrao.inp")): có file thì đọc/ghi file, không có thì đọc bàn phím và in ra màn hình. Nhờ vậy cùng 1 code nộp được cả lên hệ thống chấm đọc từ bàn phím lẫn khi thi chấm bằng file.Lời mở đầu
Bài này mình được một em học viên hỏi và mình đã hướng dẫn chi tiết cách thức giải quyết cho em ấy. Tin rằng sẽ có giá trị với nhiều bạn nên mình chia sẻ đề bài để các bạn thử sức làm, kèm thêm đó là toàn bộ những gì mình đã trao đổi với em ấy để bạn nào không giải quyết được thì có thể tham khảo nhen. Hi vọng mang đến được giá trị cho các bạn. Các bạn bấm vào link này để vào nộp bài nhé:
Link này chỉ học viên đã tham gia tổ chức mới vào được. Học viên nên ưu tiên nộp ở đây vì sẽ được chấm nhanh hơn.
Nộp bài trên CLUE OJBạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì dùng link này nhé. Chưa có tài khoản thì tạo tài khoản rồi đăng nhập nộp bài thử hen.
Nộp bài trên CodeforcesLộ trình lời giải, bấm vào từng phần để nhảy tới:
Hiểu đềMỗi tấm gỗ chỉ là 1 lựa chọn
Đầu tiên giải thích đề bài nhé, sợ em chưa hiểu rõ. Bài đó tóm lại là thế này: $(A_i, B_i)$ là 1 thằng nhé, thì hãy chọn ra 1 thằng ở mảng $B$ và $k - 1$ thằng ở mảng $A$ sao cho tổng tụi nó là lớn nhất và các chỉ số index phân biệt nhau.
Ví dụ test 1 đề cho thì giải thích như sau (tính index từ 1 cho dễ nhé): khi $k = 3$, chọn thằng $B_2$ và $A_1$, $A_3$. Nên là: $8 + 3 + 4 = 15$.
| 1 | 2 | 3 | |
|---|---|---|---|
| A (rào) | 3 | 5 | 4 |
| B (cửa) | 5 | 8 | 5 |
Có bạn lại thắc mắc là:
Trả lời: Nếu chọn thằng 5 ở mảng $A$ thì đó là index 2, trong khi ta đã chọn index 2 ở mảng $B$ rồi mà em, không được trùng. Số 8 đã chọn là index 2 ở mảng $B$ rồi. Em hiểu không? Nếu đã chọn $B_2$ thì sẽ không được chọn $A_2$ và ngược lại.
Trả lời: Em đọc kỹ đề bài khúc đầu tiên nó có nói câu này: "tấm gỗ thứ $i$ sẽ có độ đẹp là $A_i$ hoặc $B_i$". Có nghĩa là $(A_i, B_i)$ là 1 lựa chọn. Ví dụ nếu chọn index 1 thì nó chỉ có thể là $A_1$ hoặc $B_1$ chứ không thể cùng cả 2, vì tụi nó chỉ là 1 tấm gỗ thôi.
Tương tự ví dụ 2: với $k = 2$ thì chọn cửa $B_2 = 8$ và 1 tấm rào lớn nhất còn lại là $A_3 = 4$ ⇒ $12$.
Ý tưởngTham lam + vét cạn trước
Bài này dùng tư tưởng tham lam (Greedy) để giải quyết. Kể cả bạn nào không biết về tư tưởng tham lam thì thực ra đôi khi các bạn đang giải quyết vấn đề theo chính tư tưởng của tham lam mà các bạn không biết.
Anh nói em thế này: mọi bài nếu chưa nhìn ra thuật ăn full 100% của nó thì trước tiên hãy làm theo cách VÉT CẠN để ăn thử trước 1 số subtask và cũng để kiểm chứng ý tưởng đó có đúng không. Rồi khi thấy nó đúng rồi (tức là ăn hết được 1 số subtask nhỏ và bị TLE với các subtask lớn, tuyệt đối không có bị WA mà chỉ bị TLE) thì lúc này nhìn vào code vét cạn đó mà cải tiến lên để ăn full. Tất nhiên nói là vét cạn nhưng tư tưởng của nó cũng vẫn phải THAM LAM nha.
Gợi ý mấu chốt: chọn 1 phần tử ở mảng $B$ và $k - 1$ phần tử ở mảng $A$, thì $k$ có thể lớn lắm ($k \le n \le 10^5$), nhưng hãy nhìn lại mảng $B$ thì chỉ cần chọn 1 thằng thôi, chú ý kỹ nhé chỉ 1 thằng thôi. Đó là mấu chốt để nhìn ra ý tưởng vét cạn: mảng $B$ cần chọn 1 phần tử thì cứ giả sử chọn lần lượt từ $B_1$ đến $B_n$ thôi, rồi với mỗi lựa chọn đó sẽ chọn $k - 1$ phần tử lớn nhất còn lại trong mảng $A$ (khác index phần tử đã chọn của mảng $B$). Kết quả lớn nhất trong tất cả các cách chọn đó chính là đáp án.
Cách 1Sắp xếp + duyệt mỗi truy vấn – O(Q·N)
Đọc cặp số $(A_i, B_i)$ tương ứng là (first, second) em nhé, tức là ta có mảng pair hen. Sau đó sort tăng dần theo first. Ta sẽ bám theo yếu tố là ở second thì chỉ chọn duy nhất 1 tấm gỗ làm cửa, còn ở first thì chọn $k - 1$ tấm làm rào.
Tưởng tượng giờ nó đang tăng dần theo first, thì $k - 1$ thằng first cuối cùng sẽ có tổng lớn nhất tại vì tụi nó đang mang giá trị lớn nhất. Vậy:
- Trường hợp cửa nằm ngoài nhóm cuối: chọn $k - 1$ tấm
firstcuối cùng, thì để tham lam các thằngsecondtừ đầu đến vị trí còn lại sẽ chọn thằng lớn nhất. - Trường hợp cửa nằm trong nhóm cuối: lần lượt cho cửa là 1 trong các tấm cuối, khi đó tấm
firstở vị trí đó bị bỏ ra và ta lấy thêm tấmfirstngay phía trước nhóm.
Ví dụ nếu $n = 10$, $k = 3$ (số thứ tự tính từ 1 nhé), thì đầu tiên ta có: Max = a[10].first + a[9].first + max(a[1].second → a[8].second). Rồi giả sử chọn cửa là a[9].second thì để lớn nhất sẽ chọn a[10].first, a[8].first: Max = max(Max, a[9].second + a[10].first + a[8].first). Rồi chọn cửa là a[10].second thì chọn a[9].first, a[8].first: Max = max(Max, a[10].second + a[9].first + a[8].first). Lúc này đã xong, giá trị Max sau cùng là đáp án ứng với $k = 3$.
Anh liệt kê ra vài trường hợp $k = 2, 3, 4$ với $n = 10$ cho em xem, mỗi $k$ chọn Max của tất cả các dòng:
Thử tay cách chọn với từng k
Em nhập 2 dãy $A$, $B$ và số $k$ rồi bấm Tiếp: widget sẽ sắp xếp theo first, tô vàng các tấm làm rào, xanh tấm làm cửa (hoặc vùng lấy max second), rồi so sánh tất cả các trường hợp:
Với tư tưởng tham lam này dễ thấy với mỗi trường hợp $k$ ta sẽ xét qua hết $N$ phần tử là ra đáp án, và sẽ có $Q$ câu hỏi như vậy. Vậy cách làm vét cạn trước mắt sẽ có độ phức tạp $O(Q \cdot N)$, mà $Q, N$ tối đa $10^5$ nên $Q \cdot N = 10^{10}$, chắc chắn TLE vì đã vượt quá ngưỡng giới hạn 1 giây là $10^8$ rồi (vượt quá cả 100 lần). Nên chỉ ăn được 3 subtask đầu tiên, tức là 75% điểm:
- Subtask 1: $Q = 1$ ⇒ $O(Q \cdot N) = N$ mà $N$ tối đa $10^5$.
- Subtask 2: $N, Q \le 10$ ⇒ $10 \cdot 10 = 100$.
- Subtask 3: $N, Q \le 1000$ ⇒ $1000 \cdot 1000 = 10^6$.
Tất cả đều thỏa nhỏ hơn $10^8$ nên yên tâm cách làm có độ phức tạp $O(Q \cdot N)$ sẽ AC được 3 subtask đầu tiên. Đây là code theo cách này cho em tham khảo (trong code chỉ số tính từ 0, biến k được đổi thành n - k = vị trí tấm first "dự bị" ngay trước nhóm $k - 1$ tấm cuối):
#include <bits/stdc++.h>
using namespace std;
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
if (fopen("xayhangrao.inp", "r")) {
freopen("xayhangrao.inp", "r", stdin);
freopen("xayhangrao.out", "w", stdout);
}
long long n, q;
cin >> n >> q;
vector<pair<long long, long long>> a(n);
for(int i = 0; i < n; ++i){
cin >> a[i].first;
}
for(int i = 0; i < n; ++i){
cin >> a[i].second;
}
sort(a.begin(), a.end());
while(q--){
long long k;
cin >> k;
k = n - k;
long long sum = 0;
for(int i = k + 1; i < n; ++i){
sum += a[i].first;
}
long long Max = a[0].second;
for(int i = 1; i <= k; ++i){
Max = max(Max, a[i].second);
}
long long res = sum + Max;
sum += a[k].first;
for(int i = k + 1; i < n; ++i){
res = max(res, sum + a[i].second - a[i].first);
}
cout << res << " ";
}
return 0;
}
import sys, os
if os.path.exists("xayhangrao.inp"):
sys.stdin = open("xayhangrao.inp", "r")
sys.stdout = open("xayhangrao.out", "w")
n, q = map(int, input().split())
a = []
first = list(map(int, input().split()))
second = list(map(int, input().split()))
for i in range(n):
a.append((first[i], second[i]))
a.sort()
ks = list(map(int, input().split()))
for k in ks:
k = n - k
sum_ = 0
for i in range(k + 1, n):
sum_ += a[i][0]
Max = a[0][1]
for i in range(1, k + 1):
Max = max(Max, a[i][1])
res = sum_ + Max
sum_ += a[k][0]
for i in range(k + 1, n):
res = max(res, sum_ + a[i][1] - a[i][0])
print(res, end=' ')
Cách 2Tính sẵn đáp án mọi k – O(N log N + Q)
Còn để ăn được subtask cuối cùng thì em phải tìm cách tối ưu hơn. Nhận thấy rằng $N$ tối đa $10^5$ thì ta sẽ tính hết các trường hợp $k$ từ 1 đến $n$ trước. Vấn đề là chỗ này hãy nhìn kỹ nhé: không cần phải 2 vòng lặp lồng nhau vì nếu làm thế độ phức tạp sẽ là $O(N^2)$, mà chỗ này chỉ cần 1 vòng lặp duy nhất chạy từ 1 đến $n$ là tính ra được hết các đáp án. Để làm được điều đó em phải nhìn vào bản chất của các công thức. Anh viết lại công thức với $k = 2, 3, 4$ và $n = 10$ dưới dạng tổng sum, đánh dấu 3 màu: đỏ, cam, vàng:
sum(a[7].first → a[10].first) − a[9].first + a[9].second
Ở $k = 2$ thì chỉ có đỏ, cam, không có vàng; còn từ $k = 3$ trở lên là có đủ 3 màu. Em hãy nhìn những khối cùng màu trong các trường hợp $k$: ví dụ từ khối màu cam của $k = 2$ đi qua khối màu cam của $k = 3$ rồi đi qua khối màu cam của $k = 4$; từ khối màu vàng của $k = 3$ đi qua khối màu vàng của $k = 4$. Anh nghĩ em sẽ nhìn ra thứ gì đó 😉
Nhìn bằng hình: n = 10, k = 5
Khi $k = 5$ nghĩa là cần lấy 1 cái từ second và 4 cái từ first:
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | ||
|---|---|---|---|---|---|---|---|---|---|---|---|
| TH1 | first | ||||||||||
| second | max các second tô xanh | ||||||||||
| TH2 | first | ||||||||||
| second | |||||||||||
| TH3 | first | ||||||||||
| second | |||||||||||
| TH4 | first | ||||||||||
| second | |||||||||||
| TH5 | first | ||||||||||
| second | |||||||||||
- TH1: lấy 4 cái
firstto nhất (vì đang sắp tăng dần nên 4 thằng cuối cùng là 4 thằng to nhất), anh tô vàng đó; còn lại các thằngsecondđược tô xanh thì lấy thằng có giá trị lớn nhất trong đó. - TH2 → TH5:
secondlần lượt lấy vào các vị trí của 4 thằng tô vàng ở TH1. Khisecondlấy vào vị trí thằng tô vàng nào thì thằng tô vàng đó sẽ dịch xuống 1 vị trí: TH2secondlấy vị trí 7 thìfirstở vị trí 7 bỏ đi và ta lấyfirstở vị trí 6; TH3secondlấy vị trí 8 thì bỏfirstvị trí 8, lấy vị trí 6, 7…
Như thế em thấy từ TH2 trở đi, first luôn lấy tổng từ vị trí 6 đến 10, rồi trừ đi first ở vị trí cửa:
sum(first a[6..10]) + a[7].second − a[7].firstTH3:
sum(first a[6..10]) + a[8].second − a[8].firstTH4:
sum(first a[6..10]) + a[9].second − a[9].firstTH5:
sum(first a[6..10]) + a[10].second − a[10].firstHai mảng tính sẵn
- Mảng
MaxSecond:MaxSecond[i]= giá trịsecondlớn nhất củaa[1], a[2], …, a[i](lớn nhất từ đầu đến vị trí $i$) ⇒ chỉ cần $O(N)$ là tạo ra được. - Mảng
SumFirst:SumFirst[i]= tổng các giá trịfirsttừ vị trí $i$ đến cuối mảng:a[i] + a[i+1] + … + a[n]⇒ cũng chỉ $O(N)$.
Giờ nhìn lại "Hình 1":
- Màu đỏ: $k = 2$ là
MaxSecond[9] + SumFirst[10]; $k = 3$ làMaxSecond[8] + SumFirst[9]; $k = 4$ làMaxSecond[7] + SumFirst[8]. Em có thấy cứ mỗi $k$ tăng lên 1 thì index của 2 thằngMaxSecond,SumFirstbị giảm đi 1 so với trước đó không? ⇒ tính trực tiếp $O(1)$. - Màu cam: $k = 2$ là
SumFirst[9] − a[10].first + a[10].second; $k = 3$ làSumFirst[8] − a[10].first + a[10].second, tức là chính trường hợp $k = 2$ nhưng cộng thêma[8].first; $k = 4$ là trường hợp $k = 3$ cộng thêma[7].first. Vậy max của cả khối cam chỉ cần lấy max cũ + first mới. - Màu vàng: $k = 2$ không có, từ $k = 3$:
SumFirst[8] − a[9].first + a[9].second; qua $k = 4$ thì làSumFirst[7] − a[8].first + a[8].second: nó chỉ lùi lại 1 index. Và khối vàng của $k$ này sẽ nhập vào khối cam của $k + 1$.
Đó, em nhìn ra những điểm tương đồng thì ở mỗi $k$ em tính ra Max của màu đỏ (Max1) và Max của màu cam + vàng (Max2). Khi đi qua $k$ tiếp theo thì dựa trên các biến Max của trường hợp trước đó mà sinh ra thêm cái mới rồi so sánh cập nhật lại ⇒ 1 vòng for chạy từ 1 đến N là đủ:
Max1 = MaxSecond[n-k] + sumFirst[n-k+1]Max2 = max(Max2 + a[n-k].first, sumFirst[n-k] - a[n-k+1].first + a[n-k+1].second)ump[k] = max(Max1, Max2)Rồi thì lưu các đáp án ứng với $k$ từ 1 đến $n$ đó lại (lúc hướng dẫn anh gợi ý dùng map hay unordered_map, nhưng vì key chính là $k$ từ 1 đến $n$ nên dùng luôn mảng vector ump là gọn và nhanh nhất), mỗi truy vấn chỉ việc in ump[k] trong $O(1)$:
- Bước input: $O(N)$.
- Bước sort: $O(N \log N)$.
- Bước xử lý: $O(\max(N, Q))$.
⇒ Tổng độ phức tạp của bài: $O(N \log N + Q)$. Đây là code của anh theo đúng ý tưởng đã nói, đã nộp AC trên web:
#include <bits/stdc++.h>
using namespace std;
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
if (fopen("xayhangrao.inp", "r")) {
freopen("xayhangrao.inp", "r", stdin);
freopen("xayhangrao.out", "w", stdout);
}
long long n, q;
cin >> n >> q;
vector<pair<long long, long long>> a(n);
for(int i = 0; i < n; ++i){
cin >> a[i].first;
}
for(int i = 0; i < n; ++i){
cin >> a[i].second;
}
sort(a.begin(), a.end());
vector<long long> MaxSecond(n);
MaxSecond[0] = a[0].second;
for(int i = 1; i < n; ++i){
MaxSecond[i] = max(MaxSecond[i - 1], a[i].second);
}
vector<long long> sumFirst(n);
sumFirst[n - 1] = a[n - 1].first;
for(int i = n - 2; i >= 0; --i){
sumFirst[i] = sumFirst[i + 1] + a[i].first;
}
vector<long long> ump(n + 1);
ump[1] = MaxSecond[n - 1];
if(n >= 2){
long long Max1 = MaxSecond[n - 2] + sumFirst[n - 1];
long long Max2 = sumFirst[n - 2] - a[n - 1].first + a[n - 1].second;
ump[2] = max(Max1, Max2);
int j = 0;
for(int k = 3; k <= n; ++k){
j++;
Max1 = MaxSecond[n - 2 - j] + sumFirst[n - 1 - j];
Max2 = max(Max2 + a[n - k].first, sumFirst[n - k] - a[n - k + 1].first + a[n - k + 1].second);
ump[k] = max(Max1, Max2);
}
}
while(q--){
long long x;
cin >> x;
cout << ump[x] << " ";
}
return 0;
}
import sys, os
if os.path.exists("xayhangrao.inp"):
sys.stdin = open("xayhangrao.inp", "r")
sys.stdout = open("xayhangrao.out", "w")
n, q = map(int, input().split())
first = list(map(int, input().split()))
second = list(map(int, input().split()))
a = [(first[i], second[i]) for i in range(n)]
a.sort()
MaxSecond = [0] * n
MaxSecond[0] = a[0][1]
for i in range(1, n):
MaxSecond[i] = max(MaxSecond[i - 1], a[i][1])
sumFirst = [0] * n
sumFirst[-1] = a[-1][0]
for i in range(n - 2, -1, -1):
sumFirst[i] = sumFirst[i + 1] + a[i][0]
ump = [0] * (n + 1)
ump[1] = MaxSecond[n - 1]
if n >= 2:
Max1 = MaxSecond[n - 2] + sumFirst[n - 1]
Max2 = sumFirst[n - 2] - a[n - 1][0] + a[n - 1][1]
ump[2] = max(Max1, Max2)
j = 0
for k in range(3, n + 1):
j += 1
Max1 = MaxSecond[n - 2 - j] + sumFirst[n - 1 - j]
Max2 = max(Max2 + a[n - k][0], sumFirst[n - k] - a[n - k + 1][0] + a[n - k + 1][1])
ump[k] = max(Max1, Max2)
queries = list(map(int, input().split()))
for x in queries:
print(ump[x], end=' ')
fopen / os.path.exists để đọc/ghi file. Cả 4 bản đã được kiểm tra với 300 bộ test ngẫu nhiên ($n, q \le 8$, giá trị nhỏ nhiều số trùng lẫn giá trị tới $10^9$) so với code vét cạn thử mọi tấm làm cửa rồi lấy $k - 1$ tấm rào lớn nhất còn lại: khớp 100%. Với $n = q = 10^5$: cách 2 bản C++ chạy khoảng 0.06 giây, bản Python khoảng 0.9 giây; cách 1 bản C++ chạy hơn 10 giây ⇒ TLE subtask 4 đúng như phân tích (với $n = q = 1000$ chỉ mất vài mili giây).