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

Thành sĩ gái

Thành sĩ gái
Nguồn: CLUE OJ – Map / Set Độ khó: Trung bình Tham lam multiset · lower_bound

Đề bài

Nhân dịp chào đón Giáng sinh, Thành quyết định lấy hết tiền học bổng của mình để mua toàn bộ $N$ món quà lưu niệm tặng các bạn nữ trong lớp, các món quà được đánh số từ $1$ đến $N$, món thứ $i$ có giá là $a_i$ $(i = 1 \div N)$. Cảm động trước hành động của Thành, chủ cửa hàng đã có chương trình khuyến mãi đặc biệt dành cho em. Thành được phép chia $N$ món quà thành một hay nhiều đơn hàng với điều kiện hai món quà bất kì trong một đơn phải có giá chênh lệch nhau không bé hơn $K$. Với mỗi đơn hàng như vậy, Thành chỉ phải thanh toán số tiền là giá của món quà đắt nhất trong đơn đó mà thôi.

Yêu cầu: Giúp Thành chia $N$ món quà thành các đơn hàng sao cho tổng số tiền phải trả cho tất cả đơn hàng là nhỏ nhất.

Dữ liệu: vào gồm 2 dòng:

  • Dòng thứ nhất chứa hai số nguyên dương $N$ và $K$ $(N \le 10^5,\ K \le 10^9)$.
  • Dòng thứ hai gồm $N$ số nguyên dương $a_1, a_2, \ldots, a_N$ $(a_i \le 10^9$ với mọi $i = 1 \div N)$.

Kết quả: ghi ra một số nguyên duy nhất là tổng số tiền phải trả.

Ràng buộc:

  • Có 50% số test ứng với 50% số điểm có $N \le 10^4$;
  • Có 50% số test còn lại ứng với 50% số điểm không có ràng buộc gì thêm.
Ví dụ – input
3 2
1 3 3
Ví dụ – output
6

Giải thích ví dụ: chia thành 2 đơn $\{3, 1\}$ (chênh lệch $2 \ge K$, trả $3$) và $\{3\}$ (trả $3$), tổng $6$. Hai món giá $3$ không thể chung một đơn vì chênh lệch $0 < 2$.

Lời mở đầu

Một bài tham lam rất đẹp: đọc đề tưởng phải thử mọi cách chia đơn (số cách chia là khổng lồ), nhưng chỉ cần xét các món từ đắt đến rẻ là mọi thứ trở nên rõ ràng. Phần cài đặt là chỗ để luyện multiset và hàm lower_bound – đúng chủ đề Map/Set. Cuối bài mình còn chỉ ra một nhận xét giúp bỏ luôn multiset, chỉ cần hai con trỏ.

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ì?

TestNKGiá lớn nhấtĐáp án lớn nhất
1 – 104 … 101 … 101057
11 – 5010.0003 … 935.507.608999.979.820692.919.719.871
51 – 100100.0001 … 393.464.216999.999.8554.575.304.827.212
  • Bộ test có 100 test: 10 test đầu rất nhỏ ($N \le 10$, dùng để kiểm tra tính đúng), 40 test $N = 10^4$ và 50 test $N = 10^5$.
  • $K$ trải rộng từ $1$ tới hàng trăm triệu – $K$ càng lớn thì càng khó ghép, số đơn hàng càng nhiều (đây chính là điểm yếu của Cách 1).
  • Đáp án lớn tới 4.575.304.827.212, có 58 test vượt quá kiểu int → tổng tiền bắt buộc dùng long long.
  • Giá các món có rất nhiều giá trị trùng nhau (ví dụ test $N = 10^5$ chỉ có khoảng 62 nghìn giá khác nhau) → phải dùng multiset chứ không phải set.

Hai subtask khớp đúng với bộ test như sau (ô ghi số test đúng của từng cách – chạy thật):

SubtaskĐiều kiệnCác testCách 1
O(N·số đơn)
Cách 2
multiset
Cách 3
hai con trỏ
1 (50%)$N \le 10^4$1 – 5050/5050/5050/50
2 (50%)$N \le 10^5$51 – 10036/5050/5050/50

Ý tưởngXét từ món đắt nhất, ghép được thì ghép

Nhận xét 1. Nếu xếp các món trong một đơn theo giá giảm dần $x_1 \ge x_2 \ge \ldots \ge x_m$, thì điều kiện "hai món bất kì chênh lệch $\ge K$" tương đương với hai món liền nhau chênh lệch $\ge K$ (vì $x_1 - x_3$ $= (x_1 - x_2) + (x_2 - x_3)$ $\ge 2K$ $\ge K$...). Vậy mỗi đơn là một "chuỗi" đi xuống, mỗi bước giảm ít nhất $K$, và Thành trả tiền món đầu chuỗi.

Nhận xét 2. Sắp xếp các món giảm dần rồi xét lần lượt. Khi tới món $x$, mọi món đã xét đều $\ge x$, nên $x$ chỉ có hai lựa chọn:

  • Ghép vào cuối một đơn cũ có món rẻ nhất (gọi là đuôi) $\ge x + K$ → miễn phí, đuôi của đơn đó trở thành $x$.
  • Mở đơn mới → $x$ là món đắt nhất của đơn mới (các món sau đều rẻ hơn), phải trả $x$.
Thuật toán tham lam
Sắp xếp giảm dần. Với mỗi món $x$: nếu có đơn nào có đuôi $\ge x + K$ thì ghép vào (đuôi đơn đó thành $x$), ngược lại mở đơn mới và cộng $x$ vào đáp án.

Vì sao "ghép được thì ghép" không bao giờ thiệt? So sánh hai lựa chọn khi có đơn với đuôi $v \ge x + K$: ghép thì tập đuôi là "bỏ $v$, thêm $x$", không mất tiền; mở mới thì tập đuôi là "thêm $x$" nhưng mất $x$ đồng. Lựa chọn mở mới chỉ "lời" ở chỗ còn giữ đuôi $v$ – nhưng đuôi đó chỉ giúp ghép miễn phí một món $y$ sau này, mà $y \le x$, tức là tiết kiệm được tối đa $y \le x$, không bù nổi $x$ đồng đã trả. Vậy ghép luôn có lợi hơn hoặc bằng.

Ghép vào đơn nào? Đơn nào có đuôi $\ge x + K$ cũng được – vì các món $y$ xét sau đều $\le x$, nên mọi đuôi $\ge x + K$ cũng đều $\ge y + K$, "tốt như nhau" với các món sau. Cách 2 dùng lower_bound lấy đuôi nhỏ nhất thoả mãn cho tự nhiên; Cách 3 khai thác chính nhận xét này để bỏ hẳn multiset.

Em nhập $K$ và giá các món rồi bấm Tiếp để xem từng món được ghép vào đơn nào (ô vàng là món phải trả tiền – đầu mỗi đơn, ô viền cam là đuôi của đơn):

Ví dụ nhanh:

Cách 1Duyệt từng đơn để tìm chỗ ghép – O(N · số đơn)

Cài đặt thẳng ý tưởng: lưu đuôi của các đơn trong một vector cuoi. Với mỗi món, duyệt lần lượt các đơn, gặp đơn đầu tiên có đuôi $\ge a_i + K$ thì ghép vào.

C++▶ Chạy tay từng dòng codeCách 1: với mỗi món, duyệt lần lượt các đơn để tìm đơn có đuôi ≥ a[i] + k.
Dữ liệu mẫu:
  1. sort(a.rbegin(), a.rend()) sắp xếp giảm dần (duyệt mảng theo chiều ngược rồi sắp tăng).
  2. chon = -1 nghĩa là "chưa tìm được đơn nào"; tìm được thì break ngay.
  3. Như đã nói ở phần ý tưởng, chọn đơn đầu tiên thoả mãn vẫn đúng, không cần chọn đơn "đẹp nhất".
Không gian$O(N)$
Thời gian$O(N \log N + N \cdot D)$, $D$ = số đơn hàng (tệ nhất $O(N^2)$)

Kết quả chạy thật: bản C++ đúng 86/100 test (quá 1 giây ở các test 53, 56, 61, 64–65, 67–69, 73, 77–78, 80, 84, 96), test đúng chạy lâu nhất 0.922s; bản Python đúng 28/100 test (quá thời gian ở các test 12–15, 18–20, 25, 29–31, 33–34, 37–38, 41–46, 48, 50–54, 56–100), lâu nhất 0.85s.

Điều thú vị: Cách 1 không chỉ qua subtask 1 mà còn qua kha khá test của subtask 2. Lý do là thời gian phụ thuộc vào số đơn hàng: $K$ nhỏ thì hầu như món nào cũng ghép được ngay vào đơn đầu tiên, vòng for j chạy rất ít. Các test bị quá thời gian đều có $K$ lớn (từ vài trăm nghìn trở lên) – khi đó có hàng chục nghìn đơn và mỗi món phải duyệt qua chừng ấy đơn.

Cách 2multiset + lower_bound – O(N log N)

Thay vì duyệt từng đơn, ta cất các đuôi vào một multiset (tự sắp tăng dần, cho phép trùng). Câu hỏi "có đuôi nào $\ge a_i + K$ không" chính là lower_bound(a[i] + k): trả về đuôi nhỏ nhất mà $\ge a_i + K$, hoặc end() nếu không có.

C++▶ Chạy tay từng dòng codeCách 2: multiset chứa đuôi các đơn, lower_bound tìm đuôi nhỏ nhất ≥ a[i] + k.
Dữ liệu mẫu:
  1. Dù ghép hay mở đơn mới thì $a_i$ đều trở thành đuôi của một đơn, nên cuoi.insert(a[i]) nằm ngoài if. Ghép thì xoá đuôi cũ cuoi.erase(it) trước.
  2. erase(it) xoá đúng một phần tử tại vị trí it. Nếu viết cuoi.erase(*it) (xoá theo giá trị) thì multiset xoá tất cả các phần tử bằng giá trị đó – sai!
  3. Bản Python không có multiset nên mình dùng heapq lấy đuôi lớn nhất: nếu đuôi lớn nhất cũng không đủ $\ge x + K$ thì không đơn nào nhận được. Theo nhận xét ở phần ý tưởng, ghép vào đuôi lớn nhất cũng đúng.
Không gian$O(N)$
Thời gian$O(N \log N)$

Kết quả chạy thật: bản C++ đúng 100/100 test, test đúng chạy lâu nhất 0.040s; bản Python đúng 100/100 test, lâu nhất 0.13s.

Cách 3Bỏ luôn multiset: hai con trỏ – O(N log N) cho sắp xếp

Vì ghép vào đuôi nào cũng được, ta chọn đuôi lớn nhất. Hãy để ý: các đuôi được thêm vào theo thứ tự giảm dần (vì các món được xét giảm dần) → đuôi lớn nhất luôn là đuôi được thêm vào sớm nhất còn lại. Tập đuôi hoạt động đúng như một hàng đợi: thêm ở cuối, lấy ra ở đầu!

Món nào cũng thành đuôi đúng một lần, nên hàng đợi chính là mảng a đã sắp xếp, đầu hàng đợi là một con trỏ j: các món $a_j, a_{j+1}, \ldots, a_{i-1}$ là các đuôi hiện có, lớn nhất là $a_j$.

C++▶ Chạy tay từng dòng codeCách 3: các đuôi tạo thành hàng đợi a[j..i-1], chỉ cần so a[i] với a[j].
Dữ liệu mẫu:
  1. Nếu $a_j \ge a_i + K$: ghép $a_i$ sau $a_j$ → $a_j$ không còn là đuôi nữa (j++), còn $a_i$ thành đuôi mới (tự động vì nó nằm ở cuối "hàng đợi").
  2. Không cần kiểm tra j < i: khi j == i thì điều kiện thành $a_i \ge a_i + K$, sai vì $K \ge 1$.
Không gian$O(N)$
Thời gian$O(N \log N)$ (sắp xếp), phần tham lam chỉ $O(N)$

Kết quả chạy thật: bản C++ đúng 100/100 test, test đúng chạy lâu nhất 0.022s; bản Python đúng 100/100 test, lâu nhất 0.09s.

Tổng kếtKết quả chạy thật trên bộ test

CáchNgôn ngữĐúngLâu nhất (test đúng)Ghi chú
1. Duyệt từng đơnC++86/1000.922sQuá thời gian ở test 53, 56, 61, 64–65, 67–69, 73, 77–78, 80, 84, 96
Python28/1000.847sQuá thời gian ở test 12–15, 18–20, 25, 29–31, 33–34, 37–38, 41–46, 48, 50–54, 56–100
2. multisetC++100/1000.040sFull điểm
Python (heapq)100/1000.134sFull điểm
3. Hai con trỏC++100/1000.022sFull điểm
Python100/1000.094sFull điểm
Ghi chú của mình: thời gian trong bảng là thời gian CPU đo trên máy của mình (giới hạn 1 giây/test). Cách 2 và Cách 3 đều full điểm và rất nhanh (dưới 0.05 giây), phần lớn thời gian là đọc dữ liệu và sắp xếp. Đáp án vượt $2^{31}$ ở 58 test, nên nhớ dùng long long cho tổng tiền. Bài học rút ra: khi gặp bài "chia nhóm" với chi phí là phần tử lớn nhất, hãy thử sắp xếp rồi xét từ lớn đến nhỏ.
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