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

Đóng gói (Tin học trẻ toàn quốc 2025 – Vòng khu vực miền Nam – Bảng C1)

Đóng gói
Nguồn: Tin học trẻ 2025 Độ khó: Khó Bài tối ưu (chấm điểm từng phần) Tham lam Tìm kiếm cục bộ

Đề bài

Nguồn đề: Hội thi Tin học trẻ toàn quốc 2025 – Vòng khu vực miền Nam – Bảng C1 (bài Đóng gói). Đề bên dưới do mình viết lại bằng lời của mình cho dễ hiểu (giữ đúng nghĩa, đúng số liệu và ví dụ của đề gốc).

Alice cần đóng gói $n$ món quà, món thứ $i$ có khối lượng $w_i$. Alice có $b$ thùng. Một thùng nếu được dùng thì tổng khối lượng các món trong thùng phải không vượt quá $m$ và không nhỏ hơn $\left\lceil \dfrac{9m}{10} \right\rceil$ (tức là thùng phải "gần đầy"). Thùng không chứa món nào thì không bị ràng buộc gì.

Yêu cầu: giúp Alice đóng gói được càng nhiều món quà càng tốt vào $b$ thùng.

Dữ liệu vào:

  • Dòng 1: ba số nguyên dương $n, m, b$ ($b \le n \le 1000$; $m \le 10^9$).
  • Dòng 2: $n$ số nguyên dương $w_1, w_2, \dots, w_n$ ($w_i \le 10^9$).

Kết quả: một dòng gồm $n$ số nguyên $r_1, r_2, \dots, r_n$ ($0 \le r_i \le b$): $r_i = 0$ nghĩa là món $i$ không được đóng gói, $r_i > 0$ nghĩa là món $i$ được cho vào thùng số $r_i$.

Chấm điểm: với mỗi test, gọi $s$ là số món trong phương án của thí sinh (phương án phải hợp lệ) và $d$ là số món trong phương án của Ban giám khảo. Nếu $d - s \le 10$ thì thí sinh được $0{,}9^{\max(0,\ d - s)}$ số điểm của test, ngược lại được 0 điểm.

Subtask: Subtask 1 (50%): $m \le 1000$; Subtask 2 (50%): không có ràng buộc gì thêm.

Giới hạn thời gian: 1 giây. Dữ liệu nhập từ bàn phím, in ra màn hình.

Ví dụ – input
8 10 3
1 2 3 4 5 6 8 3
Ví dụ – output
1 2 1 3 1 3 2 0

Giải thích: mỗi thùng được dùng phải có tổng trong đoạn $[9, 10]$. Thùng 1 chứa các món 1, 3, 5 (tổng $1 + 3 + 5 = 9$), thùng 2 chứa các món 2, 7 ($2 + 8 = 10$), thùng 3 chứa các món 4, 6 ($4 + 6 = 10$). Có 7 món được đóng gói. Bài có thể có nhiều phương án đúng (output của em khác ví dụ vẫn được chấm đúng nếu hợp lệ và đủ nhiều món).

Lời mở đầu

Đây là một bài tối ưu hóa thuộc họ "xếp thùng" (bin packing) – một bài toán NP-khó: không ai biết thuật toán nào vừa luôn cho đáp án tốt nhất vừa chạy nhanh với $n = 1000$. Vì vậy đề chấm theo kiểu điểm từng phần: càng gần đáp án của Ban giám khảo càng nhiều điểm. Với dạng bài này, mục tiêu không phải là "đúng" mà là "đủ tốt" – và kỹ năng cần rèn là: tìm cận trên để biết mình còn cách tối ưu bao xa, xây một phương án tham lam làm "lưới an toàn", rồi cải thiện bằng tìm kiếm cục bộ.

Mình cũng sẽ kể thật về kết quả trên bộ chấm chính thức – vì bài này có một chuyện khá lạ.

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

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

Bộ testBộ test và cách chấm trên CLUE

Bộ test trên CLUE do mình tự sinh theo đúng giới hạn và tỉ lệ subtask của đề: 25 test (test 1 là ví dụ), test 1–13 có $m \le 1000$, test 14–25 có $m$ tới $10^9$. Vì không có đáp án của Ban giám khảo, "đáp án tham chiếu" $d$ của mỗi test là kết quả của lời giải mạnh nhất của mình (bản đã nộp chính thức, mô tả ở cuối bài). Trình chấm (checker) trên CLUE làm đúng công thức của đề: kiểm tra phương án hợp lệ, rồi cho $0{,}9^{\max(0, d - s)}$ điểm nếu $d - s \le 10$.

Cột UB là một cận trên dễ tính (giải thích ở phần ý tưởng): không phương án nào gói được nhiều hơn UB món. Ô xanh ở cột "đáp án tham chiếu" là test mà đáp án tham chiếu đạt đúng UB – tức là chắc chắn tối ưu – có 15/25 test như vậy.

Test$n$$m$$b$Kiểu khối lượngCận trên
UB
Đáp án
tham chiếu
Cách 1
số món (điểm)
Cách 2
số món (điểm)
18103ví dụ776 (0,90)7 (1,00)
210001.00050rất nhỏ (≤ m/20)10001000993 (0,48)1000 (1,00)
310001.0001000rất nhỏ10001000983 (0,00)1000 (1,00)
410001.000300nhỏ (≤ m/5)10001000997 (0,73)1000 (1,00)
51000997400m/5 – m/310001000622 (0,00)1000 (1,00)
610001.000500m/4 – m/210001000618 (0,00)999 (0,90)
710001.0001000lớn 0,8m – 1,1m668314314 (1,00)314 (1,00)
81000101000bất kì ≤ m10001000776 (0,00)1000 (1,00)
910001600có món > m317317317 (1,00)317 (1,00)
1010001.000200trộn: nhiều món tí hon + vài món lớn1000968914 (0,00)976 (1,00)
111000999100lệch: đa số nhỏ994994933 (0,00)994 (1,00)
1210001.000333sát m/3 (khó ghép 3 món)1000999999 (1,00)999 (1,00)
1371003bất kì, n = 7766 (1,00)6 (1,00)
1410001.000.000.00050rất nhỏ10001000990 (0,35)1000 (1,00)
1510001.000.000.0001000nhỏ10001000997 (0,73)1000 (1,00)
161000999.999.999300m/5 – m/310001000633 (0,00)1000 (1,00)
1710001.000.000.000500m/4 – m/21000991570 (0,00)999 (1,00)
1810001.000.000.0001000lớn 0,8m – 1,1m1000687687 (1,00)687 (1,00)
1910001.000.000.000700có món > m879875199 (0,00)872 (0,73)
201000987.654.321250trộn1000982920 (0,00)980 (0,81)
2110001.000.000.000120lệch993993919 (0,00)993 (1,00)
2210001.000.000.000400sát m/31000999999 (1,00)999 (1,00)
231000123.456.7891000bất kì ≤ m10001000674 (0,00)1000 (1,00)
2410001.000.000.00010nhỏ, b = 10292291289 (0,81)292 (1,00)
2510001.000.000.0001000rất nhỏ10001000987 (0,00)1000 (1,00)

Điểm theo subtask (trung bình điểm các test trong subtask):

SubtaskĐiều kiệnTestCách 1
tham lam
Cách 2
tìm kiếm cục bộ
Bản đã nộp
chính thức
Subtask 1 (50%)$m \le 1000$1–1346,98%99,23%100,00%
Subtask 2 (50%)không ràng buộc thêm14–2532,40%96,16%100,00%
Kết quả trên bộ chấm chính thức – nói thật với các bạn

Lời giải mạnh nhất của mình (cột "bản đã nộp chính thức") khi nộp lên hệ thống chấm của Hội thi (tinhoctre.vn) chỉ được 2,185/100 điểm.

Theo công thức của đề, thí sinh gói được ít nhất bằng Ban giám khảo thì được trọn điểm test đó, và chỉ bị 0 điểm khi kém BGK hơn 10 món. Mà trên bộ test của mình, lời giải này đạt đúng cận trên lý thuyết ở 15/25 test, các test còn lại cũng không thể kém tối ưu nhiều đến thế ở các dạng dữ liệu "dễ". Mình không biết bộ test chính thức, nhưng 2,185 điểm nghĩa là gần như mọi test đều bị 0 điểm – điều này rất khó xảy ra nếu bộ chấm làm đúng công thức. Đây là dấu hiệu bộ chấm chính thức không chấm đúng công thức trong đề (ví dụ: bắt khớp đúng phương án của BGK, hoặc phạt cả khi thí sinh gói nhiều hơn BGK). Mình không kiểm chứng được nguyên nhân nên chỉ ghi nhận như vậy; điểm chính thức của bài này không phản ánh chất lượng lời giải.

Ý tưởngCận trên, và vì sao nên chọn món nhỏ

Đặt $L = \lceil 9m/10 \rceil$. Món nào có $w_i > m$ thì chắc chắn không gói được – loại ngay. Muốn gói nhiều món thì tự nhiên nên ưu tiên món nhẹ. Từ đó có một cận trên đơn giản:

Cận trên UB
Sắp xếp các món tăng dần. $UB$ = số món nhỏ nhất lớn nhất có tổng khối lượng $\le b \cdot m$. Gói $k$ món bất kì thì tổng của chúng $\ge$ tổng $k$ món nhỏ nhất, mà tổng này không thể vượt quá sức chứa $b \cdot m$ của mọi thùng – nên không thể gói quá UB món.

Nhưng chọn toàn món nhỏ chưa chắc đã được: mỗi thùng phải đạt ít nhất $L$ (gần đầy). Ví dụ $m = 10$, các món $1, 1, 9$: hai món nhỏ $1 + 1 = 2 < 9$ không lấp nổi thùng nào, còn món $9$ một mình thì được. Cái khó của bài nằm ở cửa sổ hẹp $[L, m]$ (chỉ rộng 10% sức chứa).

Chiến lược của mình: (1) tham lam ra một phương án hợp lệ; (2) với một số món $k$ cho trước, cố "nhét" $k$ món (bắt đầu từ $k$ món nhỏ nhất) vào $u$ thùng sao cho mọi thùng nằm trong cửa sổ, bằng tìm kiếm cục bộ; (3) dò $k$ từ UB đi xuống.

Em nhập dữ liệu rồi bấm Tiếp để xem cách 2 chạy trên dữ liệu nhỏ: mỗi cột là một thùng, dải xanh là cửa sổ $[L, m]$, thùng đỏ là thùng đang vi phạm; mỗi bước tìm kiếm thử chuyển/đổi món và chỉ giữ lại nếu tổng vi phạm $V$ không tăng:

Ví dụ nhanh:

Cách 1Tham lam lấp từng thùng

Lần lượt với từng thùng $j = 1, 2, \dots$:

  1. Cho vào các món nhỏ nhất còn lại cho tới khi món tiếp theo làm tràn $m$.
  2. Nếu tổng vẫn $< L$ (thùng chưa đủ đầy): duyệt các món còn lại từ lớn xuống, món $y$ nào thay được một món nhỏ hơn trong thùng mà không tràn thì thay vào (tổng tăng lên).
  3. Vẫn không đạt $L$ thì dừng (các thùng sau còn ít món hơn, cũng khó đạt); đạt thì chốt thùng $j$.
C++▶ Chạy tay từng dòng codeCách 1: lấp từng thùng bằng món nhỏ nhất, thiếu thì thay bằng món lớn hơn.
Dữ liệu mẫu:
  1. Phương án luôn hợp lệ (mỗi thùng được chốt đều có tổng trong $[L, m]$), nên không bao giờ bị "sai" – chỉ là có thể ít món.
  2. Cách này hay hụt ở chỗ: lấy quá nhiều món nhỏ vào các thùng đầu, tới khi chỉ còn món to thì không ghép nổi; hoặc dừng sớm khi một thùng không lấp đủ.
Không gian$O(n)$
Thời gian$O(b \cdot n^2)$ (thực tế rất nhanh)

Kết quả chạy thật (chấm bằng checker): bản C++ được 39,69 điểm, bản Python cũng 39,69 điểm (cùng thuật toán, chạy lâu nhất 0.075s). Đúng trọn điểm ở các test dễ (ví dụ món lớn chỉ cần mỗi thùng một món), nhưng nhiều test kém đáp án tham chiếu hơn 10 món → 0 điểm.

Cách 2Chọn số món k + tìm kiếm cục bộ

Đổi góc nhìn: cố định số món $k$ muốn gói, rồi hỏi "có xếp được không?". Hàm tryKU(k, r, u):

  • Tập món ban đầu: $k - r$ món nhỏ nhất và $r$ món lớn nhất. Thường $r = 0$; nhưng nếu trong $k$ món nhỏ nhất có các món $> m/2$ (mỗi món như vậy phải nằm riêng một thùng) thì thử thêm phương án lấy $r$ món lớn nhất thay cho chúng – vì một món đã chiếm riêng một thùng thì càng to càng dễ lấp đầy thùng đó.
  • Số thùng $u$: ít nhất $\lceil T/m \rceil$ ($T$ = tổng khối lượng) và ít nhất bằng số món $> m/2$; thử vài giá trị: ước lượng $2T/(L + m)$ (mỗi thùng ở giữa cửa sổ), cận dưới, $T/L$, và $\min(b, k)$.
  • Chia ban đầu: món lớn trước, mỗi món vào thùng đang nhẹ nhất (hàng đợi ưu tiên).
  • Hàm phạt $V$ = tổng độ vi phạm: thùng có tổng $s < L$ phạt $L - s$, $s > m$ phạt $s - m$. Mục tiêu: đưa $V$ về 0.
  • Tìm kiếm cục bộ: lặp lại: chọn ngẫu nhiên một món $x$ (ưu tiên món đang ở thùng vi phạm) và một trong 3 phép: chuyển $x$ sang thùng khác, đổi $x$ với một món ở thùng khác, đổi $x$ với một món chưa gói (thay đổi tập món!). Nếu $V$ tăng thì hoàn tác. Lâu không giảm được $V$ (20 000 bước) thì bỏ cuộc.

Hàm main: lấy phương án của cách 1 làm "lưới an toàn", rồi thử $k = UB,$ $UB - 1,$ $UB - 2,$ $UB - 4,$ $\dots$ cho tới khi thành công, sau đó tìm nhị phân giữa $k$ thành công và $k$ thất bại. Tổng số bước tìm kiếm giới hạn $6 \cdot 10^6$ để chắc chắn kịp 1 giây; bộ sinh số ngẫu nhiên xorshift với hạt giống cố định nên chạy lại luôn ra cùng kết quả.

C++▶ Chạy tay từng dòng codeCách 2: phương án tham lam làm nền, rồi thử gói k món (k từ UB giảm dần, tìm nhị phân) bằng tìm kiếm cục bộ.
Dữ liệu mẫu:
Khi tìm kiếm cục bộ chạy quá nhiều bước, khung chạy tay chỉ hiện chi tiết 60 bước đầu, các bước sau được tua nhanh.
  1. Hàm moveItem là "trái tim": chuyển một món và cập nhật $V$ trong $O(1)$ (chỉ 2 thùng thay đổi). "Thùng" số 0 là phần bên ngoài, không tính phạt.
  2. Thử một phép rồi hoàn tác nếu tệ hơn – nhờ moveItem nhanh nên mỗi bước chỉ vài phép tính, 6 triệu bước chạy khoảng 0,3 giây.
  3. Nhận cả bước "không đổi $V$" (đi ngang) giúp thoát khỏi các vùng phẳng. Mình đã thử thêm "mô phỏng luyện kim" (nhận cả bước tệ hơn với xác suất nhỏ) nhưng trên bộ test này không tốt hơn nên bỏ cho gọn.
  4. Subtask 1 ($m \le 1000$) gợi ý có thể quy hoạch động theo tổng, nhưng với cách tìm kiếm này thì $m$ lớn hay nhỏ không khác gì nhau.
Không gian$O(n + b)$
Thời gian$O(n \log n + \text{số bước tìm kiếm})$, tối đa $6 \cdot 10^6$ bước

Kết quả chạy thật (chấm bằng checker): được 97,69 điểm so với đáp án tham chiếu, chạy lâu nhất 0.324s. Ở test 17 và 24, cách 2 còn gói nhiều hơn đáp án tham chiếu. Chỉ còn hụt vài món ở các test khó (món sát $m/3$, dạng "trộn"). Bản Python của cách này mình không viết – với vài triệu bước tìm kiếm thì Python quá chậm.

Bản đã nộp chính thức (khoảng 340 dòng, dùng để sinh đáp án tham chiếu) cùng khung với cách 2 nhưng thêm: mô phỏng luyện kim, phép "đổi một món lấy một nhóm món nhỏ ở thùng khác", và một pha cuối "thêm từng món một rồi sửa". Nó chạy lâu nhất 0.463s trên bộ test của mình. Với 1500 test ngẫu nhiên $n \le 8$, kết quả của nó trùng với vét cạn (tối ưu) ở 100% test.

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

CáchNgôn ngữĐiểm trên bộ test CLUELâu nhất
1. Tham lam lấp từng thùngC++39,690.005s
Python39,690.075s
2. Tham lam + chọn k + tìm kiếm cục bộC++97,690.324s
Bản đã nộp chính thức (~340 dòng, sinh đáp án tham chiếu)C++100,000.463s
Ghi chú của mình: điểm ở đây là điểm theo checker của CLUE (đúng công thức đề, so với đáp án tham chiếu của mình); thời gian là thời gian CPU trên máy mình. Với bài tối ưu, hãy luôn: (1) in ra một phương án hợp lệ càng sớm càng tốt (tham lam); (2) tính một cận trên để biết còn bao xa; (3) dùng tìm kiếm cục bộ với hàm phạt đo "độ sai" của phương án; (4) giới hạn số bước để không quá thời gian. Và đừng buồn nếu điểm chính thức thấp bất thường như bài này – hãy kiểm tra lại lời giải bằng checker của chính mì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 😇