Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học

Dự Trữ Nước

Dự Trữ Nước
Nguồn: Codeforces Group Độ khó: Dễ Tham lam Sắp xếp

Đề bài

Ở miền Trung thường năm nào cũng có những đợt hạn hán nên ông Nam có những thùng dự trữ nước. Do mua làm nhiều đợt nên $N$ $(1 \le N \le 10^6)$ thùng chứa nước của ông Nam có kích thước khác nhau, mỗi thùng có sức chứa $C_i$ $(1 \le C_i \le 10^7,\ 1 \le i \le N)$.

Dự đoán rằng năm nay sẽ có đợt hạn hán lớn nên ông Nam muốn đổ đầy nước hết các thùng để dự trữ. Sau khi kiểm tra ông Nam thấy rằng có một số thùng vẫn còn đầy, một số khác thì vơi đi một phần, còn một số thì đã hết. Ông quyết định các thùng nào chưa đầy thì sẽ chở đi để đổ đầy nước. Nhưng do nơi lấy nước rất xa, và mỗi lần chỉ chở đi được 1 thùng, và mỗi thùng chỉ được chở đi một lần nên ông quyết định sẽ san nước giữa các thùng với nhau để số thùng phải chở đi là ít nhất.

Yêu cầu: Cho dung lượng nước hiện có của thùng thứ $i$ là $B_i$ $(0 \le B_i \le C_i,\ 1 \le i \le N)$, hãy giúp ông Nam xác định số lượng thùng ít nhất phải mang đi.

Input:

  • Dòng thứ nhất ghi một số tự nhiên $N$ là số lượng các thùng nước.
  • Dòng thứ $i$ trong $N$ dòng tiếp theo mỗi dòng có 2 số nguyên $B_i$ và $C_i$ $(0 \le B_i \le C_i)$ mô tả thông tin thùng thứ $i$, với $B_i$ là nước còn trong thùng và $C_i$ là sức chứa của thùng, các số cách nhau ít nhất một khoảng trắng.

Output:

  • Số duy nhất là số lượng ít nhất các thùng nước phải chở đi.
Ví dụ – input
4
0 1
4 5
0 2
1 2
Ví dụ – output
1

Giải thích: Sau khi san nước giữa các thùng thì ông Nam chỉ cần chở đi thùng số 2 để đổ nước mang về.

Thùng 10/1Thùng 24/5Thùng 30/2Thùng 41/2Ban đầu: tổng nước = 5Thùng 11/1Thùng 32/2Thùng 42/2Thùng 20/5Sau khi san: 1 + 2 + 2 = 5

Đổ hết nước vào 3 thùng nhỏ nhất (sức chứa 1, 2, 2) là vừa khít 5 đơn vị, chỉ còn thùng số 2 (sức chứa 5) là chưa đầy.

Lờ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 😇.

Dành cho học viên · Ưu tiênNộp bài trên hệ thống CLUE OJ

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 OJ
Dành cho tất cả mọi ngườiNộp bài trên Codeforces

Bạ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 Codeforces

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Ý tưởngTư tưởng tham lam (Greedy)

Bài này dùng tư tưởng tham lam để giải quyết. Kể cả bạn nào có không biết về tư tưởng tham lam này 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 đó. Cho bạn nào không biết thì mình có thể nói nhanh như thế này:

Tham lam là gì?
Tham lam ở đây nghĩa là ta sẽ cố gắng chọn ra cách giải quyết tốt nhất trong tất cả các cách ở hiện tại rồi đi theo cách giải quyết tốt nhất đó. Nếu lại đứng trước nhiều lựa chọn khác nhau nữa thì ta lại cố gắng chọn cách tốt nhất trong số đó, tóm lại cứ tốt nhất mà triển, vì thế tên gọi nó là tham lam. Quy luật nó là như vậy thôi, chứ không có công thức từng bước cụ thể như các thuật toán (lưu ý: tham lam chỉ là tư tưởng/kỹ thuật chứ không phải giải thuật/thuật toán nhé). Ta có thể giải quyết theo cách bất kỳ, miễn tư tưởng giải quyết luôn là chọn theo hướng tốt nhất trong các lựa chọn thì đó chính là tham lam.

Thì tại sao bài này lại có thể nhìn ra tư tưởng của nó là tham lam? Đọc kỹ đề ta sẽ thấy yêu cầu là hãy cố gắng san lượng nước hiện tại đang có vào các thùng, những thùng nào chưa đầy thì sẽ được chở đi, và làm sao để số lượng thùng phải chở đi là ít nhất.

Ta sẽ hiểu rằng để số thùng chở đi là ít nhất thì phải cố gắng lấp đầy lượng nước hiện tại vào được nhiều thùng nhất có thể, thì số lượng thùng chưa đầy cần chở đi lúc này sẽ là ít nhất. Chính từ chỗ cố gắng lấp đầy được càng nhiều thùng càng tốt thì đây chính là tư tưởng của tham lam dễ dàng để nhìn ra. Thực ra đây cũng là tư tưởng từ thực tế thôi, nếu xét theo thực tế thì cũng phải làm như vậy.

Điểm mấu chốt
Tổng lượng nước hiện có trong tất cả các thùng phải cố gắng làm sao lấp đầy được nhiều thùng nhất có thể. Chỉ cần nhìn ra được điều này là đã giải quyết được 50% bài này rồi, còn lại 50% là ở cách thức triển khai ra được tư tưởng đã nhìn ra.

Triển khaiĐổ vào thùng nhỏ nhất trước

Muốn lấp đầy được nhiều thùng nhất thì dĩ nhiên ta phải ưu tiên lấp những thùng có sức chứa nhỏ trước (tốn ít nước nhất). Cách thức triển khai như sau:

  1. Có một biến tổng sum: trong quá trình đọc dữ liệu đầu vào, lượng nước hiện có $B_i$ của mỗi thùng ta cộng dồn vào biến tổng, còn sức chứa $C_i$ của từng thùng thì đưa vào mảng 1 chiều a để lưu trữ.
  2. Sắp xếp mảng sức chứa a tăng dần.
  3. Xét từ đầu mảng (thùng có sức chứa bé nhất trở lên, $i = 0, 1, 2\dots$): nếu sum >= a[i] thì thùng $i$ được lấp đầy, biến tổng bị trừ đi a[i] rồi xét tiếp thùng sau.
  4. Đến thời điểm nào mà sum < a[i] thì thùng thứ $i$ không lấp đầy được, vậy từ thùng thứ $i$ đến cuối chính là các thùng phải chở đi, số lượng là $N - i$. In ra kết quả và kết thúc chương trình luôn.
  5. Cần lưu ý tình huống vòng lặp chạy đến tận cùng mà vẫn không xuất ra kết quả (khi toàn bộ các thùng đều được lấp đầy), thì sau cùng nhớ xuất ra 0 (không có thùng nào phải chở đi).
ia[i] (đã sắp)sum ≥ a[i]?Xử lý
015 ≥ 1Lấp đầy, sum = 5 − 1 = 4
124 ≥ 2Lấp đầy, sum = 4 − 2 = 2
222 ≥ 2Lấp đầy, sum = 2 − 2 = 0
350 < 5Dừng! In N − i = 4 − 3 = 1

Chạy với ví dụ đề: tổng nước $0 + 4 + 0 + 1 = 5$, sức chứa sau khi sắp tăng dần là $1, 2, 2, 5$.

Mình bổ sung thêm vì sao tham lam này luôn đúng: giả sử có cách san nước lấp đầy được $k$ thùng nào đó thì tổng sức chứa $k$ thùng đó phải $\le$ sum. Mà tổng sức chứa của $k$ thùng nhỏ nhất luôn $\le$ tổng sức chứa của $k$ thùng bất kỳ, nên $k$ thùng nhỏ nhất cũng lấp đầy được. Vậy chỉ cần lấp lần lượt các thùng nhỏ nhất là đạt được số thùng đầy nhiều nhất. (Phần nước dư còn lại thì đổ vào thùng tiếp theo, đằng nào thùng đó cũng phải chở đi.)

Em nhập lượng nước $B$ và sức chứa $C$ của các thùng rồi bấm Tiếp để xem từng bước san nước:

Ví dụ nhanh:

100%Code O(N log N)

Lưu ý 1: sắp xếp phải O(N log N)
Vì số lượng thùng nước tối đa có thể lên đến $10^6$ mà đề bài chỉ cho chạy tối đa 1 giây, nên bước sắp xếp các thùng tăng dần theo sức chứa ta phải lựa chọn những thuật toán sắp xếp có độ phức tạp tối ưu $O(N \log N)$ (ví dụ: Quick Sort, Merge Sort, Heap Sort), chứ nếu dùng những cách sắp xếp không tối ưu với độ phức tạp $O(N^2)$ (ví dụ: Selection Sort, Interchange Sort, Bubble Sort…) thì chắc chắn sẽ bị Time Limit Exceeded (TLE: chạy quá giới hạn thời gian cho phép). Thường thì mình sẽ dùng ngay hàm sort trong thư viện algorithm, nó đã được code tối ưu luôn đảm bảo $O(N \log N)$ và gọi ra nhanh gọn để xài.
Lưu ý 2: biến tổng phải là long long
Biến tổng lưu tổng lượng nước đang có trong các thùng nhớ phải dùng kiểu long long (64 bit), bởi có tối đa $10^6$ thùng, mỗi thùng tối đa $10^7$. Trường hợp lớn nhất biến tổng phải lưu đến $10^7 \cdot 10^6 = 10^{13}$ (10 nghìn tỷ), mà kiểu 32 bit chỉ lưu được cỡ $2 \cdot 10^9$ (thực ra hơn 2 tỷ xíu) thì không nổi, phải dùng kiểu 64 bit mới lưu nổi nhé.
Lưu ý 3: đọc dữ liệu nhanh
Số lượng input cần đọc rất nhiều, có thể lên đến $10^6$ dòng, mỗi dòng 2 số, nghĩa là đến 2 triệu lần đọc. Vì thế ta nên dùng scanf của C để đọc sẽ tốt hơn cin của C++. Hoặc bạn nào quen dùng cin thì tham khảo 2 dòng đầu tiên trong hàm main của mình dưới đây.

Source code cho anh em tham khảo với tư tưởng xử lý mình trình bày ở trên:

#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e6;
int a[maxn + 5];
int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    long long sum = 0;
    for(int i = 0; i < n; ++i){
        int b;
        cin >> b >> a[i];
        sum += b;
    }
    sort(a, a + n);
    for(int i = 0; i < n; ++i){
        if(sum >= a[i]){
            sum -= a[i];
        }
        else{
            cout << n - i;
            return 0;
        }
    }
    cout << 0;
    return 0;
}
import sys
data = sys.stdin.buffer.read().split()
n = int(data[0])
total = sum(map(int, data[1:2 * n + 1:2]))  # tổng lượng nước B[i]
a = sorted(map(int, data[2:2 * n + 2:2]))   # sức chứa C[i] tăng dần
ans = 0
for i in range(n):
    if total >= a[i]:
        total -= a[i]
    else:
        ans = n - i
        break
print(ans)
C++▶ Chạy tay từng dòng codeTham lam: gom tổng nước, sort sức chứa tăng dần, lấp đầy thùng nhỏ trước – O(N log N).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3const int maxn = 1e6;
4int a[maxn + 5];
5int main() {
6 ios::sync_with_stdio(0);
7 cin.tie(0);
8 int n;
9 cin >> n;
10 long long sum = 0;
11 for(int i = 0; i < n; ++i){
12 int b;
13 cin >> b >> a[i];
14 sum += b;
15 }
16 sort(a, a + n);
17 for(int i = 0; i < n; ++i){
18 if(sum >= a[i]){
19 sum -= a[i];
20 }
21 else{
22 cout << n - i;
23 return 0;
24 }
25 }
26 cout << 0;
27 return 0;
28}

Giải thích một số chỗ trong code

  1. ios::sync_with_stdio(0); cin.tie(0); 2 dòng đầu tiên của hàm main sẽ giúp đẩy nhanh tốc độ nhập xuất dữ liệu nếu các bạn đang code theo ngôn ngữ C++ (để hiểu vì sao thì các bạn cứ gõ mấy từ này lên Google là sẽ ra rất nhiều bài viết giải thích nhé), nên kinh nghiệm trong các bài cần nhập xuất nhiều, như bài này có tới $2 \cdot 10^6$ số cần đọc vào, ta hãy nên để 2 dòng này vào để chương trình chạy nhanh hơn. Tất nhiên nếu các bạn code theo cú pháp của C thì chẳng cần mấy dòng đó làm gì, tuy nhiên thường khi code các bài lập trình thi đấu thế này ta dùng C++ vì cú pháp ngắn gọn cũng như nhiều hỗ trợ của nó, vậy nên bạn nào code C++ thì nhớ thêm 2 dòng này vào đầu hàm main nhé.
  2. Mảng a khai báo toàn cục với kích thước maxn + 5 $= 10^6 + 5$: mảng lớn như vậy nếu khai báo trong hàm main có thể tràn bộ nhớ stack, để ra ngoài làm biến toàn cục thì an toàn.
  3. Lượng nước b của mỗi thùng không cần lưu lại, chỉ cộng dồn vào sum là đủ, vì sau khi san nước thì nước ở thùng nào cũng như nhau.
  4. Khi gặp thùng đầu tiên không lấp đầy được, in n - i rồi return 0 kết thúc luôn; nếu vòng lặp chạy hết thì in 0.
Không gian$O(N)$
Thời gian$O(N \log N)$

Thời gian gồm $O(N)$ của bước đọc dữ liệu + $O(N \log N)$ của bước sắp xếp mảng tăng dần + $O(N)$ của bước duyệt mảng trừ dần biến tổng. Theo quy tắc cộng của BigO ta lấy thành phần lớn nhất, nên độ phức tạp tổng quát là $O(N \log N)$.

Với $N$ lớn nhất là $10^6$ thì yên tâm sẽ AC được, vì với giới hạn 1 giây thì số lần lặp tối đa được phép ở ngưỡng $(3 \text{ đến } 5) \cdot 10^7$. Tính chi tiết cả thảy sẽ là $10^6 + 10^6 \cdot \log_2(10^6) + 10^6$ $\approx 22 \cdot 10^6$ $\approx 3 \cdot 10^7$, vẫn ở ngưỡng tối đa được phép. Không gian là $O(N)$ do cần mảng lưu sức chứa của $N$ thùng.

Ghi chú kiểm tra của mình: code C++ là của mình trong bài viết gốc, bản Python là mình viết thêm. Cả hai đã được kiểm tra với 300 bộ test ngẫu nhiên ($N \le 10$, có nhiều thùng đầy, thùng rỗng, sức chứa trùng nhau) so với chương trình vét cạn thử mọi tập con các thùng được lấp đầy. Với $N = 10^6$ (sức chứa tới $10^7$), bản C++ chạy khoảng 0.23 giây, bản Python khoảng 0.85 giây; trường hợp tất cả thùng đều đầy $10^7$ cho tổng $10^{13}$, xác nhận phải dùng long long.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇

Nhận xét