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

Chặt Cây Xây Nhà

Chặt Cây Xây Nhà
Nguồn: Codeforces Group Độ khó: Trung bình Tìm kiếm nhị phân Chặt nhị phân kết quả

Đề bài

Bob là 1 thợ mộc tài hoa. Hiện tại anh đang có kế hoạch xây dựng một căn nhà toàn bộ bằng gỗ. Biệt thự được xây bởi các khúc gỗ khác nhau và tổng độ dài của các thanh gỗ là $L$.

Để lấy các khúc gỗ đó, anh ấy cần vào rừng và chặt cây. Khu rừng gần chỗ anh có $N$ cây với độ cao khác nhau. Bob sẽ dùng một cái máy cưa đặc biệt, nó có thể cắt một lượt qua tất cả các cây.

Đầu tiên, anh ấy sẽ chọn một độ cao cố định là $H$, sau đó dùng máy cưa đặc biệt để cắt một đường theo độ cao $H$ này trên các cây có độ cao lớn hơn $H$.

Ví dụ: các cây có độ cao lần lượt là 10, 16, 17, 15. Chọn $H = 15$. Do đó, cây thứ 2, 3 sẽ được cắt và tổng độ dài các khúc gỗ thu được là $1 + 2 = 3$.

Cho độ cao của các cây trong rừng và giá trị $L$ (tổng độ dài của các khúc gỗ cần). Hãy giúp Bob tìm giá trị lớn nhất của $H$ thoả mãn rằng Bob chỉ cần dùng máy cắt đúng một lượt để thu được số các khúc gỗ cần thiết.

Chú ý: tổng độ dài các khúc gỗ thoả mãn có thể lớn hơn $L$ (hay nói cách khác $L \le$ tổng độ dài).

Input: dòng đầu tiên chứa một số nguyên mô tả số lượng cây $N$ $(1 \le N \le 10^6)$ và $L$ là tổng độ dài của các khúc gỗ cần dùng $(1 \le L \le 2 \cdot 10^9)$. Dòng tiếp theo là độ cao tương ứng của các cây $h[i]$ $(h[i] < 10^9)$. Tổng độ cao của các cây lớn hơn hoặc bằng $L$.

Output: dòng duy nhất gồm 1 số nguyên duy nhất: giá trị độ cao lớn nhất mà tại đó Bob cắt đúng một đường để thu được các khúc gỗ thoả mãn.

Giới hạn

  • 50% số test đầu tiên có $h[i] \le 10^3$, $N \le 10^3$.
  • 50% số test còn lại không có giới hạn gì thêm.
Ví dụ 1 – input
3 6
1 2 3
Ví dụ 1 – output
0
Ví dụ 2 – input
4 10
10 15 12 13
Ví dụ 2 – output
10
Ví dụ 3 – input
4 1
5 5 5 5
Ví dụ 3 – output
4

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:

Hiểu đềTóm tắt đề bài

Trước tiên mình sẽ tóm tắt lại đề bài một cách đơn giản nhất để bạn nào chưa hiểu như sau: cho $N$ cây gỗ, $L$ là tổng độ dài các khúc gỗ cần dùng, mảng $h$ gồm $N$ phần tử với $h[i]$ là độ cao của cây thứ $i$. Ta có chiếc máy cưa đặc biệt: khi chọn 1 độ cao cố định $H$, nó lướt qua tất cả các cây, và những cây có độ cao $> H$ sẽ cho $h[i] - H$ khúc gỗ.

Yêu cầu
Tìm giá trị $H$ lớn nhất có thể nhưng vẫn thu được lượng gỗ $\ge L$ (thu thừa gỗ cũng không sao). Nói cách khác: cố gắng thu được lượng gỗ ít nhất có thể sao cho lượng gỗ đó vẫn $\ge L$.

Ví dụ: $N = 4$, $L = 10$, $h = \{10, 15, 12, 13\}$. Thì $H$ lớn nhất ta tìm được sẽ bằng 10: cây thứ nhất có $h[1] \le H$ nên không thu được gỗ, cây thứ 2 thu được $15 - 10 = 5$, cây thứ 3 thu được $12 - 10 = 2$, cây thứ 4 thu được $13 - 10 = 3$. Tổng cộng $5 + 2 + 3 = 10$ khúc gỗ, thoả mãn $\ge L$.

Em tự tay kéo thanh trượt $H$ dưới đây để thấy máy cưa "lướt" qua các cây như thế nào nhé. Phần màu cam phía trên đường cưa chính là gỗ thu được:

Tối đa 12 cây, mỗi cây cao từ 1 đến 60 để hình vẽ dễ nhìn.
H

50%Cách vét cạn: thử H từ trên xuống

Giờ cách làm đơn giản nhất của bài này là vét cạn thôi. Ta chắc chắn rằng $H$ sẽ nằm trong khoảng từ 0 đến độ cao cây gỗ cao nhất trừ 1 (ta gọi là hMax - 1 nha). Nhiều bạn sẽ nghĩ tới việc cho $H$ chạy từ 0 lên hMax - 1, nhưng bởi vì ta đang cần tìm $H$ lớn nhất, tại sao ta không thử chạy ngược lại, từ hMax - 1 về 0? Khi đó, lần đầu tiên ta gặp một giá trị thoả mãn số gỗ $\ge L$ là ta kết luận luôn đó là $H$ lớn nhất rồi.

Ví dụ $N = 4$, $L = 10$, $h = \{10, 15, 12, 13\}$, $hMax = 15$, ta cho $H$ chạy từ 14 về 0:

HSố gỗ thu đượcSo với L = 10
141 = 1✘ nhỏ hơn L
132 = 2✘ nhỏ hơn L
123 + 1 = 4✘ nhỏ hơn L
114 + 1 + 2 = 7✘ nhỏ hơn L
105 + 2 + 3 = 10✔ ≥ L, chọn!

Em nhập $L$ và độ cao các cây rồi bấm Tiếp để xem cách vét cạn hạ dần đường cưa $H$ từ cây cao nhất xuống, tới lần đầu tiên thu đủ gỗ thì dừng:

Ví dụ nhanh:

Mặc dù về độ phức tạp thời gian thì chạy từ trên xuống hay từ dưới lên là như nhau, nhưng ta chọn chạy từ trên xuống vì chỉ cần gặp trường hợp đầu tiên thoả mãn thì kết luận được ngay.

Đây là source code cho bạn nào muốn tham khảo cách làm trên của mình nha:

Xem code vét cạn (50% điểm)
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;

int main() {
    ios_base::sync_with_stdio(0);
    cin.tie(0);
    ll N, L;
    cin >> N >> L;
    vector<ll> h(N);
    ll hMax = 0;
    for(int i = 0; i < N; ++i) {
        cin >> h[i];
        hMax = max(hMax, h[i]);
    }
    for(int H = hMax; H >= 0; --H) {
        ll sum = 0;
        for(int i = 0; i < N; ++i) {
            if(h[i] > H) {
                 sum += h[i] - H;
            }
        }
        if(sum >= L) {
            cout << H;
            break;
        }
    }
    return 0;
}
N, L = map(int, input().split())
h = list(map(int, input().split()))
hMax = max(h)
for H in range(hMax, -1, -1):
    s = 0
    for x in h:
        if x > H:
            s += x - H
    if s >= L:
        print(H)
        break
C++▶ Chạy tay từng dòng codeVét cạn: thử H từ hMax xuống, gặp H đầu tiên thu đủ gỗ thì in ra.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5
6int main() {
7 ios_base::sync_with_stdio(0);
8 cin.tie(0);
9 ll N, L;
10 cin >> N >> L;
11 vector<ll> h(N);
12 ll hMax = 0;
13 for(int i = 0; i < N; ++i) {
14 cin >> h[i];
15 hMax = max(hMax, h[i]);
16 }
17 for(int H = hMax; H >= 0; --H) {
18 ll sum = 0;
19 for(int i = 0; i < N; ++i) {
20 if(h[i] > H) {
21 sum += h[i] - H;
22 }
23 }
24 if(sum >= L) {
25 cout << H;
26 break;
27 }
28 }
29 return 0;
30}

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 mà cần nhập xuất nhiều, ví dụ như bài này dữ liệu đầu vào có thể lên đến $10^6$ số, thì ta hãy nên để 2 dòng này vào để 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. vector<ll> h(N): ta khai báo vector $h$ có $N$ phần tử để tiện việc đọc, chỉ việc cin >> h[i].
Không gian$O(N)$
Thời gian$O(hMax \cdot N)$

Ta dễ dàng nhận thấy cách trên không thể nào AC được. Trong trường hợp xấu nhất $hMax = 10^9$, $N = 10^6$, và muốn chặt hết toàn bộ các cây ta cần chặt tận gốc, $H = 0$ chẳng hạn, thì sẽ chạy tận $10^9 \times 10^6 = 10^{15}$ phép tính. Thời gian cần lên tới $10^{15} / 10^8 = 10^7$ giây, 10 triệu giây là tầm hơn 100 ngày đó 😱.

Tuy nhiên hãy vẫn nên cài đặt nó để sau này lấy làm công cụ kiểm tra tính chính xác cho cách làm tối ưu. Bạn nào chưa biết cách lấy nó làm công cụ kiểm tra thì xem bài viết Hạn chế lỗi Wrong Answer của mình nha.

Ghi chú viết tắt: AC (Accepted – được chấp thuận), TLE (Time Limit Exceeded – quá thời gian), WA (Wrong Answer – sai kết quả).

100%Cách tối ưu: chặt nhị phân kết quả

Để AC bài này ta dùng giải thuật tìm kiếm nhị phân trên kết quả (còn gọi là chặt nhị phân kết quả, hay gọi tắt là chặt nhị phân). Để áp dụng được, ta cần thoả mãn 3 điều sau:

1Tập kết quả là gì?Quá rõ ràng rồi: $0 \le H \le hMax - 1$.
2Biết H thì kiểm tra được không?Có: chạy 1 vòng for qua mảng $h$, đếm số gỗ thu được rồi so với $L$.
3Biết 1 giá trị H đúng hoặc sai thì cả một phía cũng đúng hoặc sai theoĐiều này nghe hơi khó hiểu, xem ví dụ ngay dưới nhé.

Ví dụ $N = 4$, $L = 13$, $h = \{10, 15, 12, 13\}$. Bảng dưới là số gỗ thu được với từng giá trị $H$:

Hgỗ
050
146
242
338
434
530
626
722
818
914
1010
117
124
132
141
✔ gỗ ≥ 13 (thoả mãn)✘ gỗ < 13 (không thoả mãn)đáp án: ô ✔ cuối cùng
  1. Thử $H = 7$: số gỗ $= 22 \ge L$, thoả mãn. Mọi giá trị $H \le 7$ chắc chắn cũng thoả mãn, vì H càng xuống thì gỗ thu được càng nhiều. Còn $H > 7$ thì chưa biết, có thể đáp án là 7, cũng có thể đáp án nằm ở phía $H > 7$.
  2. Thử $H = 11$: số gỗ $= 7 < L$, không thoả mãn. Mọi giá trị $H \ge 11$ chắc chắn cũng không thoả mãn, vì H càng cao thì gỗ thu được càng giảm.
  3. Vậy $H$ lớn nhất có khả năng nằm trong $8 \le H \le 10$, nếu không thì sẽ là 7. Thử $H = 9$: số gỗ $= 14$, thoả mãn. Đáp án không phải 7 nữa, mà có thể là 9 hoặc 10.
  4. Thử $H = 10$: số gỗ $= 10$, không thoả mãn. Vậy $H$ lớn nhất bằng 9.
Điểm mấu chốt
Dãy kết quả luôn có dạng ✔✔✔…✔✘✘…✘: một đoạn thoả mãn rồi tới một đoạn không thoả mãn. Mỗi lần thử ở giữa, ta loại được một nửa số giá trị còn lại. Đáp án là ô ✔ cuối cùng.

Các bước thực hiện

  1. Ba biến left, right, res: đoạn $[left, right]$ là khoảng mà $H$ lớn nhất có khả năng nằm trong đó, còn res lưu kết quả tốt nhất đã biết. Lúc đầu res = 0 (chắc chắn thoả mãn vì tổng độ cao các cây $\ge L$), left = 1, right = hMax - 1.
  2. Hàm check(H): trả về true nếu với độ cao $H$ ta thu được lượng gỗ $\ge L$.
  3. Biến mid = (left + right) / 2: nếu check(mid) sai thì mọi $H \ge mid$ đều sai, cập nhật right = mid - 1. Nếu đúng thì mọi $H \le mid$ đều đúng, cập nhật res = mid và left = mid + 1. Lặp lại đến khi left > right thì dừng và in ra res.

Bạn nhìn lại ví dụ ở trên đi, mình chọn các giá trị thử dựa đúng theo mid = (left + right) / 2 đó:

[left, right]midgỗ khi H = midcheck(mid)Cập nhậtres
[1, 14]722trueres = 7, left = 87
[8, 14]117falseright = 107
[8, 10]914trueres = 9, left = 109
[10, 10]1010falseright = 99

Giờ em tự tay chạy từng bước nhé. Có thể sửa $L$ và độ cao các cây để thử với dữ liệu của mình:

Tối đa 12 cây, mỗi cây cao từ 1 đến 60 để hình vẽ dễ nhìn.
■ chắc chắn thoả mãn■ chắc chắn không thoả mãn■ đoạn [left, right] còn đang xét□ mid: giá trị đang thử

Đây là source code cho bạn nào muốn tham khảo cách làm trên của mình:

#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;

ll N, L;
vector<ll> h;

bool check(ll H) {
    ll sum = 0;
    for(int i = 0; i < N; ++i) {
        if(h[i] > H) {
            sum += h[i] - H;
        }
    }
    return sum >= L;
}

int main() {
    ios_base::sync_with_stdio(0);
    cin.tie(0);
    cin >> N >> L;
    ll hMax = 0;
    for(int i = 0; i < N; ++i) {
        ll x;
        cin >> x;
        hMax = max(hMax, x);
        h.push_back(x);
    }
    ll res = 0, left = 1, right = hMax - 1;
    while(left <= right) {
        ll mid = (left + right) / 2;
        if(check(mid)) {
            res = mid;
            left = mid + 1;
        }
        else {
            right = mid - 1;
        }
    }
    cout << res;
    return 0;
}
import sys
from bisect import bisect_right

def main():
    data = sys.stdin.buffer.read().split()
    N, L = int(data[0]), int(data[1])
    h = sorted(map(int, data[2:2 + N]))
    # suffix[i] = tổng các h[j] với j >= i (mảng đã sắp xếp tăng dần)
    suffix = [0] * (N + 1)
    for i in range(N - 1, -1, -1):
        suffix[i] = suffix[i + 1] + h[i]

    def check(H):
        i = bisect_right(h, H)          # các cây từ vị trí i trở đi cao hơn H
        return suffix[i] - H * (N - i) >= L

    res, left, right = 0, 1, h[-1] - 1
    while left <= right:
        mid = (left + right) // 2
        if check(mid):
            res = mid
            left = mid + 1
        else:
            right = mid - 1
    print(res)

main()
C++▶ Chạy tay từng dòng codeChặt nhị phân kết quả: mỗi lần thử mid bằng check(mid), loại một nửa đoạn [left, right].
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5
6ll N, L;
7vector<ll> h;
8
9bool check(ll H) {
10 ll sum = 0;
11 for(int i = 0; i < N; ++i) {
12 if(h[i] > H) {
13 sum += h[i] - H;
14 }
15 }
16 return sum >= L;
17}
18
19int main() {
20 ios_base::sync_with_stdio(0);
21 cin.tie(0);
22 cin >> N >> L;
23 ll hMax = 0;
24 for(int i = 0; i < N; ++i) {
25 ll x;
26 cin >> x;
27 hMax = max(hMax, x);
28 h.push_back(x);
29 }
30 ll res = 0, left = 1, right = hMax - 1;
31 while(left <= right) {
32 ll mid = (left + right) / 2;
33 if(check(mid)) {
34 res = mid;
35 left = mid + 1;
36 }
37 else {
38 right = mid - 1;
39 }
40 }
41 cout << res;
42 return 0;
43}

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

return sum >= L;: nếu sum >= L thì hàm trả về true, ngược lại là false thôi nha.

Không gian$O(N)$
Thời gian$O(N \cdot \log_2 hMax)$

Hàm check mất $O(N)$ phép tính, còn vòng lặp while chỉ chạy $\log_2(hMax)$ lần. Trường hợp xấu nhất chỉ phải thực hiện $10^6 \times \log_2(10^9) \approx 3 \cdot 10^7$ phép tính, tầm 0.3 giây, nhanh hơn cách vét cạn rất nhiều rồi đúng không các bạn?

Ghi chú của mình: bản Python là mình viết thêm. Vì Python chạy chậm hơn C++ nhiều, bản này sắp xếp mảng độ cao trước và tính tổng hậu tố, để mỗi lần check(H) chỉ cần tìm nhị phân vị trí cây đầu tiên cao hơn $H$ (bisect) thay vì duyệt cả mảng. Ý tưởng chặt nhị phân trên $H$ vẫn giữ nguyên. Cả 4 bản code đã được kiểm tra với 400 bộ test ngẫu nhiên so với cách vét cạn; với $N = 10^6$, bản C++ chạy khoảng 0.15 giây, bản Python khoảng 1 giây.
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