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

Cây cảnh

Cây cảnh
Nguồn: CLUE OJ – Chương 22: Quy hoạch động Độ khó: Trung bình Quy hoạch động Dãy con tăng dài nhất

Đề bài

Tèo có một hàng cây gồm $N$ cây cảnh được đánh số từ 1 đến $N$ từ đầu hàng đến cuối hàng. Lúc mới trồng, tất cả các cây cảnh có chiều cao bằng nhau. Một thời gian sau, do cây phát triển không đồng đều nên trong hàng có cây cao cây thấp. Để làm tăng vẻ đẹp của hàng cây, Tèo quyết định loại bỏ một số cây để hàng cây thỏa mãn một trong 2 điều kiện: hoặc là chiều cao của các cây nhìn từ đầu hàng phải tăng dần; hoặc là chiều cao của các cây nhìn từ đầu hàng phải giảm dần (cây đứng sau phải thấp hơn cây đứng trước).

Yêu cầu: hãy tính xem phải loại bỏ những cây cảnh trong hàng như thế nào để số cây bị loại bỏ là ít nhất.

Dữ liệu vào: từ tệp văn bản TREE.INP

  • Dòng 1: số nguyên dương $N$ $(N \le 10^5)$ là số cây cảnh có trong dãy.
  • Dòng 2: ghi $N$ số nguyên $a_1, a_2, \ldots, a_N$ $(1 \le a_i \le 10^9)$ là chiều cao của các cây.

Kết quả: ghi ra tệp văn bản TREE.OUT là số nguyên duy nhất là số cây cảnh bị phá bỏ thỏa mãn yêu cầu của đề bài.

Ví dụ – input
5
1 4 2 3 6
Ví dụ – output
1
Lưu ý khi nộp trên CLUE OJ
Dữ liệu đọc từ bàn phím và in ra màn hình (cin/cout), đề bài có ghi tên file TREE.INP/TREE.OUT thì các bạn cứ kệ nó nhé.

Giải thích ví dụ: hàng cây cao 1 4 2 3 6. Bỏ đi cây thứ 2 (cao 4) thì còn 1 2 3 6 tăng dần – chỉ phải bỏ 1 cây. Không thể bỏ 0 cây vì hàng ban đầu vừa có đoạn đi lên ($1 \to 4$) vừa có đoạn đi xuống ($4 \to 2$).

Lời mở đầu

Bỏ ít cây nhất cũng chính là giữ lại nhiều cây nhất. Các cây giữ lại vẫn đứng theo thứ tự cũ, tức là chúng tạo thành một dãy con của dãy chiều cao. Vậy bài toán quy về bài kinh điển của chương Quy hoạch động: dãy con tăng dài nhất (và giảm dài nhất). Mình sẽ đi từ cách vét cạn mọi cách giữ cây, sang QHĐ $O(N^2)$ quen thuộc, rồi tối ưu bằng chặt nhị phân để qua được $N = 10^5$.

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:

Bộ testBộ test chính thức có gì?

Test$N$Chiều cao $a_i$Có cây cao bằng nhauGiữ lại nhiều nhấtĐáp án lớn nhất
1–89 – 238 – 972.681.6043/8816
9–161.031 – 2.000101 – 999.948.1574/8861.917
17–2065.583 – 100.000100 – 999.999.5644/461999.383

Đề không ghi subtask nhưng bộ test chia rất rõ thành 3 mức theo $N$, mình tự đặt tên là các mức dưới đây. Để ý: có nhiều test có các cây cao bằng nhau (các test 9–12, 17–18 có $a_i \le 50000$ nên trùng rất nhiều) – vì đề yêu cầu tăng dần / giảm dần thực sự (cây sau phải thấp hơn hẳn cây trước) nên 2 cây bằng nhau không được đứng cạnh nhau trong hàng giữ lại. Đây là chỗ hay sai nhất của bài.

MứcĐiều kiệnTestCách 1
vét cạn bitmask
Cách 2
QHĐ $O(N^2)$
Cách 3
QHĐ + chặt nhị phân
1$N \le 23$1–8quaquaqua
2$N \le 2000$9–16không chạy đượcquaqua
3$N \le 10^5$17–20không chạy đượcquá thời gianqua

Ý tưởngGiữ lại nhiều cây nhất = dãy con tăng (giảm) dài nhất

Gọi $L_{tăng}$ là độ dài dãy con tăng ngặt dài nhất, $L_{giảm}$ là độ dài dãy con giảm ngặt dài nhất của dãy $a$. Tèo được chọn một trong hai kiểu nên số cây giữ lại nhiều nhất là $\max(L_{tăng}, L_{giảm})$ và

đáp án = N − max(Ltăng, Lgiảm)

Để tính $L_{tăng}$ bằng quy hoạch động, ta đặt trạng thái theo cây cuối cùng được giữ lại:

Công thức QHĐ
  • $f[i]$ = độ dài dãy con tăng dài nhất kết thúc tại cây $i$. Ta có $f[i] = 1 + \max\{ f[j] : j < i,\ a_j < a_i \}$ (nếu không có $j$ nào thì $f[i] = 1$ – chỉ giữ mình cây $i$).
  • $g[i]$ = độ dài dãy con giảm dài nhất kết thúc tại cây $i$: $g[i] = 1 + \max\{ g[j] : j < i,\ a_j > a_i \}$.
  • Kết quả: $\max(L_{tăng}, L_{giảm})$ $= \max_i \max(f[i], g[i])$.

Vì sao đúng? Dãy tăng kết thúc tại $i$ thì cây đứng ngay trước $i$ trong dãy đó là một cây $j < i$ thấp hơn hẳn $a_i$; phần trước đó là một dãy tăng kết thúc tại $j$, dài nhất là $f[j]$. Thử mọi $j$ rồi lấy cái tốt nhất. Chú ý dấu nhỏ hơn ngặt $a_j < a_i$: hai cây cao bằng nhau không được đứng liền nhau.

Em nhập chiều cao các cây rồi bấm Tiếp để xem bảng $f$, $g$ được điền dần (ô vàng là ô đang tính, ô xanh dương là các ô $j$ thỏa điều kiện mà nó được phép nối vào):

Ví dụ nhanh:

Cách 1Vét cạn mọi cách giữ cây (qua mức 1)

Cách "trâu" nhất: thử mọi tập cây được giữ lại. Mỗi số mask từ $1$ đến $2^N - 1$ ứng với một cách giữ: bit thứ $i$ bằng 1 nghĩa là giữ cây $i$. Với mỗi mask, đi qua các cây được giữ theo thứ tự và kiểm tra hàng đó có tăng ngặt hoặc giảm ngặt không.

C++▶ Chạy tay từng dòng codeCách 1: thử mọi mask (tập cây giữ lại), kiểm tra tăng ngặt hoặc giảm ngặt.
Dữ liệu mẫu:
Bước ở dòng if(truoc != -1) gộp luôn 2 phép so sánh bên trong cho mỗi cây được giữ.
  1. __builtin_popcount(mask) đếm số bit 1 = số cây giữ lại. Nếu con số này không vượt kỷ lục best thì khỏi kiểm tra – một mẹo cắt bớt nhỏ.
  2. Biến truoc nhớ chỉ số cây giữ lại ngay trước cây $i$. Chỉ cần một cặp liền nhau "không tăng" là tang = false, "không giảm" là giam = false.
Không gian$O(N)$
Thời gian$O(2^N \cdot N)$

Kết quả chạy thật: đúng 8/20 test (toàn bộ mức 1, chậm nhất 0,68 giây ở test $N = 23$). Từ test 9 trở đi $N \ge 1031$: mảng a[25] không đủ chỗ nên chương trình bị lỗi chạy – mà dù có khai báo mảng to thì $2^{1031}$ cách chọn cũng không bao giờ chạy xong. Bản Python của cách này còn chậm hơn nhiều nên mình không viết.

Cách 2Quy hoạch động $O(N^2)$ (qua mức 1, 2)

Cài đặt đúng công thức ở phần ý tưởng: với mỗi cây $i$, duyệt mọi cây $j$ đứng trước nó để tính cả $f[i]$ lẫn $g[i]$ trong cùng một vòng lặp.

C++▶ Chạy tay từng dòng codeCách 2: f[i], g[i] = dãy tăng / giảm dài nhất kết thúc tại cây i, O(N²).
Dữ liệu mẫu:
  1. f[i] = 1; g[i] = 1; – trường hợp tệ nhất là chỉ giữ một mình cây $i$.
  2. Hai điều kiện a[j] < a[i] và a[j] > a[i] đều là so sánh ngặt; khi a[j] == a[i] thì cây $j$ không được nối vào cả dãy tăng lẫn dãy giảm.
  3. best lấy max của mọi $f[i]$, $g[i]$ – dãy dài nhất có thể kết thúc ở bất kỳ cây nào chứ không nhất thiết ở cây $N$.
Không gian$O(N)$
Thời gian$O(N^2)$

Kết quả chạy thật: đúng 16/20 test, các test mức 1, 2 chạy dưới 0,01 giây. 4 test cuối có $N$ tới $10^5$: $\frac{N^2}{2} = 5 \cdot 10^9$ phép so sánh, chạy hơn 3,8 giây → quá thời gian. Bản Python cũng được 16/20.

Cách 3QHĐ + chặt nhị phân $O(N \log N)$ (full điểm)

Cách 2 chậm vì với mỗi $i$ phải dò lại toàn bộ các $j$ phía trước. Ta đổi cách lưu trạng thái:

Trạng thái mới
$d[k]$ = chiều cao cây cuối nhỏ nhất trong số các dãy con tăng có độ dài $k + 1$ đã gặp. Cây cuối càng thấp thì càng dễ nối thêm cây sau, nên với mỗi độ dài ta chỉ cần nhớ "đuôi" thấp nhất.

Mảng $d$ luôn tăng ngặt (dãy dài hơn thì đuôi phải cao hơn), nên khi xét cây mới cao $x$ ta dùng lower_bound tìm vị trí đầu tiên có $d[k] \ge x$:

  • Không có (mọi $d[k] < x$): $x$ nối được vào dãy dài nhất → d.push_back(x), độ dài tăng thêm 1.
  • Có: thay $d[k] = x$ – có dãy độ dài $k + 1$ kết thúc bằng $x$ thấp hơn đuôi cũ.

Cuối cùng d.size() chính là $L_{tăng}$. Dùng lower_bound (tìm $\ge x$) chứ không phải upper_bound để hai cây bằng nhau thay thế nhau thay vì nối dài – đúng với yêu cầu tăng ngặt. Còn $L_{giảm}$? Dãy giảm ngặt của $a$ chính là dãy tăng ngặt của $-a$, nên chỉ cần gọi lại hàm với dãy $b_i = -a_i$.

Em thử nhập một dãy rồi bấm Tiếp để xem mảng $d$ thay đổi:

Ví dụ nhanh:
C++▶ Chạy tay từng dòng codeCách 3: mảng d giữ đuôi nhỏ nhất của mỗi độ dài, cập nhật bằng lower_bound.
Dữ liệu mẫu:
  1. Hàm lis nhận vector theo tham chiếu const & để không phải sao chép $10^5$ phần tử.
  2. *it = x ghi đè đúng ô mà lower_bound trả về; mảng $d$ vẫn tăng ngặt sau khi ghi.
  3. Lưu ý: $d$ không phải là một dãy con thật của $a$ (thứ tự các phần tử có thể bị xáo), nhưng độ dài của nó luôn đúng bằng độ dài dãy con tăng dài nhất.
Không gian$O(N)$
Thời gian$O(N \log N)$

Kết quả chạy thật: đúng 20/20 test, chậm nhất 0,02 giây. Bản Python (dùng bisect_left) cũng 20/20, chậm nhất 0,11 giây.

Ghi chú của mình: bộ test 20 test chia 3 mức $N \le 23$, $N \le 2000$, $N \le 10^5$. Cách 1 (vét cạn bitmask) được 8/20, cách 2 (QHĐ $O(N^2)$) được 16/20, cách 3 (QHĐ + chặt nhị phân) được 20/20 với cả C++ (≤ 0,02 s) lẫn Python (≤ 0,11 s). Nhớ dùng so sánh ngặt vì bộ test có rất nhiều cây cao bằng nhau.
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