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ọn nhóm học sinh (Tin học trẻ toàn quốc 2025 – Vòng loại quốc gia đợt 3 – Bảng C1)

Chọn nhóm học sinh
Nguồn: Tin học trẻ 2025 – Vòng loại QG đợt 3 – Bảng C1 Độ khó: Khó Bài tối ưu – chấm điểm từng phần Ủ mô phỏng + Tabu

Đề bài

Lớp học có $n$ học sinh đánh số $0..n-1$. Thầy chủ nhiệm muốn chọn ra một nhóm học sinh tham gia trò chơi. Mức độ phù hợp của nhóm được đánh giá qua nhiều tiêu chí (có nên chọn riêng học sinh $i$ không, có nên chọn hai học sinh $i$ và $j$ cùng nhau không, ...), nên thầy xây dựng một ma trận đối xứng $Q = (q_{ij})$ kích thước $n \times n$ để chấm điểm.

Mỗi cách chọn là một vector nhị phân $x = (x_0, x_1, \dots, x_{n-1})$, $x_i = 1$ nếu chọn học sinh $i$. Giá trị của cách chọn:

$$f(x) = \sum_{i=0}^{n-1} \sum_{j=0}^{n-1} q_{ij}\, x_i\, x_j.$$

Hãy chọn nhóm sao cho $f(x)$ càng lớn càng tốt.

Dữ liệu vào:

  • Dòng đầu chứa hai số nguyên $n, m$ $(n \le 500)$: kích thước ma trận và số phần tử khác 0 được liệt kê.
  • $m$ dòng tiếp theo, mỗi dòng 3 số nguyên $i, j, q_{ij}$ $(0 \le i, j \le n - 1;\ |q_{ij}| \le 10^9)$. Vì $Q$ đối xứng, nếu chỉ một trong hai phần tử $(i, j)$, $(j, i)$ được liệt kê thì phần tử kia có cùng giá trị. Các phần tử không được nhắc tới bằng 0.

Dữ liệu đảm bảo giá trị $f$ tối ưu luôn dương.

Kết quả: một dòng gồm $n$ số $x_0, x_1, \dots, x_{n-1}$ (mỗi số là 0 hoặc 1).

Cách tính điểm: gọi $GV$ là $f$ của đáp án ban giám khảo, $HS$ là $f$ của bạn, $S$ là điểm tối đa của test, $r = \dfrac{GV - HS}{GV}$.

  • $HS \ge GV$: được $S$ điểm.
  • $0 < r \le 1\%$: được $(1 - r) \cdot \dfrac{3S}{4}$ điểm.
  • $1\% < r < 10\%$: được $(1 - r) \cdot \dfrac{S}{3}$ điểm.
  • còn lại: 0 điểm.

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

Nguồn: Hội thi Tin học trẻ toàn quốc 2025 – Vòng loại quốc gia đợt 3 – Bảng C1. Đề được mình viết lại cho dễ đọc.

Ví dụ – input
2 2
0 0 5
1 1 -5
Ví dụ – output
1 0

Giải thích: chỉ chọn học sinh 0 cho $f = q_{00} = 5$; chọn thêm học sinh 1 thì $f = 5 + (-5) = 0$; không chọn ai thì $f = 0$. Vậy $5$ là lớn nhất.

Chi tiết dễ sai: phần tử đối xứng
Khi input chỉ liệt kê $(i, j)$ mà không có $(j, i)$, phải hiểu là $q_{ji} = q_{ij}$ – tức cặp $(i, j)$ đóng góp $2 q_{ij}$ vào $f$ khi chọn cả hai. Phiên bản đầu tiên của mình chỉ cộng đúng các phần tử được liệt kê nên tối ưu "nhầm hàm" và chỉ được 73,89 điểm trên tinhoctre.vn; sửa lại cách đọc này thì được 100 điểm.

Lời mở đầu

Đây là một bài tối ưu chấm điểm từng phần – dạng bài ít gặp ở các kỳ thi học sinh nhưng rất thú vị. Bài toán "tìm vector nhị phân cực đại một dạng toàn phương" có tên là QUBO (Quadratic Unconstrained Binary Optimization) và là bài toán NP-khó: không ai biết thuật toán chính xác nhanh cho $n = 500$. Vì vậy cách làm là: vét cạn khi $n$ nhỏ, còn $n$ lớn thì dùng các thuật toán tìm kiếm cục bộ (leo đồi, ủ mô phỏng, tìm kiếm Tabu) để tìm một lời giải đủ tốt trong thời gian cho phép. Mình sẽ đi từ vét cạn → leo đồi → ủ mô phỏng + Tabu, chạy thật và chấm điểm từng phần cho từng cách.

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 có gì?

Bộ test trên CLUE gồm 22 test do mình tự sinh, chấm bằng trình chấm điểm từng phần (testlib) đúng công thức trong đề. "Đáp án ban giám khảo" $GV$: với $n \le 22$ là tối ưu thật (vét cạn); với $n$ lớn là giá trị tốt nhất mình tìm được sau nhiều lần chạy dài (10 giây mỗi lần, nhiều hạt giống) – không chứng minh được là tối ưu. Test 21, 22 chỉ liệt kê nửa trên của ma trận ($i \le j$) để kiểm tra cách hiểu phần tử đối xứng. Lời giải cách 3 mình đã nộp trên bộ chấm chính thức tinhoctre.vn và được 100 điểm.

Bảng dưới là thống kê từng test và điểm thật (thang 1 mỗi test) của 3 cách bản C++ (ô xám: được một phần điểm):

Test$n$$m$Kiểu dữ liệuGV (đáp án mẫu)Cách 1Cách 2Cách 3
122ví dụ5111
211$n = 1$7111
31045ngẫu nhiên, giá trị nhỏ617111
420400dày đặc33.067.110.729111
522245mật độ 50%26.703.688.532111
6502.500dày đặc127.528.382.322011
71007.986mật độ 80%355.780.855.538011
82001.949thưa (5%)197.893.722.327011
930090.000dày đặc1.827.231.247.809011
10500250.000dày đặc3.438.802.006.742011
11500250.000dày đặc3.409.374.578.34600,751
12500250.000lệch âm, chéo dương56.794.493.520011
13500125.964mật độ 50%2.851.415.968.754011
145004.979rất thưa (2%)485.172.855.25100,751
15500227.089giá trị $\pm 5$, nhiều hoà20.502011
165005.536tập độc lập (đồ thị thưa)93.337.183.541000,74
17500250.000clique87.615.47000,321
18500250.000nhóm ẩn (2 lưu vực)5.527.006.031.888011
19500250.000hầu hết dương112.397.208.253.860011
20500250.000chéo gần âm3.767.737.436.751011
2140820chỉ cho nửa trên77.778.212.725011
22500125.250chỉ cho nửa trên3.721.793.600.38500,751
Tổng điểm (thang 100)22,7388,9298,84
  • Test 16 (tập độc lập trọng số lớn nhất trên đồ thị thưa: chọn 2 đỉnh kề nhau bị phạt rất nặng) là test khó nhất – ngay cả cách 3 cũng thường chỉ lệch dưới 1% (được khoảng 0,74 điểm).
  • Test 18 có "nhóm ẩn" với hai lưu vực xa nhau: một cực đại địa phương $2{,}03 \cdot 10^{12}$ và tối ưu $5{,}53 \cdot 10^{12}$ – thuật toán nào kẹt ở lưu vực sai là mất trắng.
  • Kết quả các cách 2, 3 phụ thuộc thời gian chạy (chạy tới 2,4 giây) và máy đang bận hay rảnh, nên chạy lại có thể chênh một chút.

Ý tưởngLợi ích khi lật một bit và cực trị địa phương

Đặt $W_{ij} = q_{ij} + q_{ji}$ (với $i \ne j$) là lượng $f$ được cộng thêm khi chọn cả $i$ và $j$. Viết lại

$$f(x) = \sum_i q_{ii} x_i + \sum_{i < j} W_{ij} x_i x_j.$$

Gọi $h_i = \sum_{j \ne i} W_{ij} x_j$. Khi lật bit $i$ (0 → 1 hoặc 1 → 0), $f$ thay đổi đúng

$$\Delta_i = (1 - 2x_i) \cdot (q_{ii} + h_i).$$

Duy trì mảng $h$: lật bit $i$ thì mỗi $h_j$ chỉ thay đổi $\pm W_{ij}$ – mất $O(\text{số phần tử khác 0 trên hàng } i)$. Đây là "viên gạch" của mọi thuật toán bên dưới:

  • Vét cạn mã Gray: đi qua cả $2^n$ vector, mỗi bước chỉ lật 1 bit.
  • Leo đồi: còn bit nào có $\Delta_i > 0$ thì lật bit có $\Delta$ lớn nhất; hết thì ta đang ở cực đại địa phương – nhưng chưa chắc là tối ưu.
  • Ủ mô phỏng / Tabu: cho phép đôi khi lật bit làm $f$ giảm để thoát khỏi cực đại địa phương.

Em chọn ma trận ngẫu nhiên (theo hạt giống) rồi bấm Tiếp để xem leo đồi: số nhỏ dưới mỗi ô là $\Delta_i$ (xanh = lật có lợi). Cuối cùng so với tối ưu thật (vét cạn) – nhiều khi leo đồi bị kẹt:

Ví dụ nhanh:

Cách 1Vét cạn theo mã Gray – O(2^n·n)

Thứ tự mã Gray đi qua cả $2^n$ vector nhị phân sao cho hai vector liên tiếp chỉ khác nhau đúng 1 bit: ở bước $g$ ta lật bit thứ __builtin_ctzll(g) (số chữ số 0 ở cuối của $g$). Nhờ công thức $\Delta_i$, mỗi bước chỉ tốn $O(n)$ để cập nhật $h$, thay vì $O(n^2)$ để tính lại $f$.

C++▶ Chạy tay từng dòng codeCách 1: vét cạn 2^n vector theo mã Gray, mỗi bước lật 1 bit.
Dữ liệu mẫu:
  1. Phần đọc dữ liệu bổ sung phần tử đối xứng còn thiếu: has[i][j] đánh dấu phần tử có trong input.
  2. best = 0 ứng với $x = 0$ (không chọn ai) – đề đảm bảo tối ưu dương nên thế nào cũng tìm được giá trị lớn hơn.
Không gian$O(n^2)$
Thời gian$O(2^n \cdot n)$

Kết quả chạy thật: bản C++ được 22,73/100 điểm (5/22 test trọn điểm, 0 điểm ở test 6–22). Đúng tuyệt đối với $n \le 22$ (test 1–5), còn $n \ge 40$ thì $2^n$ quá lớn. Bản Python được 18,18/100 điểm (4/22 test trọn điểm, 0 điểm ở test 5–22).

Cách 2Leo đồi + khởi động lại ngẫu nhiên

Xuất phát từ một vector $x$, lặp: tìm bit có $\Delta_i$ lớn nhất; nếu $\Delta_i > 0$ thì lật, ngược lại dừng (cực đại địa phương). Vì mỗi lần leo có thể kẹt ở "đỉnh đồi thấp", ta lặp lại nhiều lần từ các điểm xuất phát ngẫu nhiên cho tới hết 2,4 giây, giữ lời giải tốt nhất.

C++▶ Chạy tay từng dòng codeCách 2: leo đồi từ x = 0, sau đó khởi động lại ngẫu nhiên tới hết 2,4 giây.
Dữ liệu mẫu:
Các lần khởi động lại sau lần đầu được tua nhanh; khung chỉ nhận dữ liệu mà lần leo đầu đã tới tối ưu (kiểm chứng bằng vét cạn), khi đó các lần sau không thể đổi kết quả. Các dòng của hàm flip được hiện mỗi lần gọi.
  1. Lần đầu xuất phát từ $x = 0$, các lần sau mỗi bit được bật với xác suất $1/2$ (sinh số ngẫu nhiên bằng công thức đồng dư tuyến tính).
  2. Dùng thời gian thực (chrono::steady_clock) để dừng ở 2,4 giây, chừa thời gian in kết quả (giới hạn 3 giây).
  3. Ma trận thưa thì adj chỉ lưu phần tử khác 0, lật bit nhanh hơn nhiều.
Không gian$O(n^2)$
Thời gianchạy tới hết 2,4 giây

Kết quả chạy thật: bản C++ được 88,92/100 điểm (17/22 test trọn điểm, điểm một phần ở test 11, 14, 17, 22, 0 điểm ở test 16). Leo đồi tìm được tối ưu ở phần lớn test ngẫu nhiên, nhưng mất điểm ở các test có cấu trúc "bẫy" (test 16, 17 – tập độc lập, clique) và một số test dày lớn. Bản Python được 68,42/100 điểm (10/22 test trọn điểm, điểm một phần ở test 8–11, 13–15, 20, 22, 0 điểm ở test 12, 16–17) – Python leo được ít vòng hơn nhiều trong 2 giây.

Cách 3Ủ mô phỏng + tìm kiếm Tabu

Hai kỹ thuật kinh điển để thoát cực đại địa phương:

  • Ủ mô phỏng (simulated annealing): chọn ngẫu nhiên một bit; nếu lật có lợi thì lật, nếu thiệt ($\Delta < 0$) thì vẫn lật với xác suất $e^{\Delta / T}$. "Nhiệt độ" $T$ giảm dần theo cấp số nhân: lúc đầu nóng – nhảy lung tung khám phá, về cuối nguội – gần như chỉ leo lên. Nhiệt độ đầu được chuẩn hoá theo độ lớn trung bình của $|\Delta|$ để hợp với mọi thang giá trị.
  • Tìm kiếm Tabu: mỗi bước lật bit có $\Delta$ lớn nhất kể cả khi âm, nhưng bit vừa lật bị "cấm" (tabu) lật lại trong vài bước (trừ khi lật nó cho kỷ lục mới) – nhờ vậy không quay lại chỗ cũ mà đi được sang "đồi" khác.

Lời giải của mình: $n \le 24$ thì vét cạn mã Gray (chính xác); $n$ lớn thì nửa đầu thời gian lặp các vòng [ủ mô phỏng $1500n$ bước + Tabu tinh chỉnh] với điểm xuất phát luân phiên (toàn 0 / ngẫu nhiên / toàn 1 / lời giải tốt nhất), nửa sau chạy Tabu thuần, khi lâu không cải thiện thì quay về lời giải tốt nhất và lật ngẫu nhiên khoảng $n/10$ bit. Dừng ở 2,4 giây.

C++▶ Chạy tay từng dòng codeCách 3 với dữ liệu nhỏ (n ≤ 24): chương trình đi vào nhánh vét cạn mã Gray.
Dữ liệu mẫu:
Với n ≤ 24 lời giải vét cạn chính xác; phần ủ mô phỏng + Tabu (n lớn, dùng số ngẫu nhiên và đồng hồ) không chạy nên khung chạy tay không mô phỏng phần đó – xem widget ở phần Ý tưởng để hình dung leo đồi / kẹt cực trị địa phương.
  1. Hàm gain(i) và flip(i) chính là công thức $\Delta_i$ và cập nhật $h$ ở phần ý tưởng.
  2. Số bước ủ $1500n$ là con số mình chọn qua thử nghiệm: ủ quá lâu ($4000n$) lại hay kẹt ở lưu vực sai của test 18.
  3. Code dài nhưng mỗi phần đều là "leo đồi có thêm luật cho phép đi xuống".
Không gian$O(n^2)$
Thời gianchạy tới hết 2,4 giây

Kết quả chạy thật: bản C++ được 98,84/100 điểm (21/22 test trọn điểm, điểm một phần ở test 16). Lần chạy đầu (lúc máy đang chạy nhiều việc khác): được được 97,68/100 điểm (20/22 test trọn điểm, điểm một phần ở test 16–17). Trên bộ chấm chính thức tinhoctre.vn bản này được 100 điểm.

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

CáchNgôn ngữĐiểmTrọn điểmGhi chú
1. Vét cạn mã GrayC++22,735/22chính xác khi $n \le 22$
Python18,184/22$n = 20, 22$ quá giờ
2. Leo đồi + khởi động lạiC++88,9217/22chạy hết 2,4 giây
Python68,4210/22chạy 2 giây
3. Ủ mô phỏng + TabuC++98,8421/22lần chạy thứ 2
C++97,6820/22lần chạy thứ 1 (máy đang bận)
Ghi chú của mình: điểm tính theo trình chấm điểm từng phần (thang 100, mỗi test như nhau), thời gian chạy tối đa 2,4 giây/test. Với bài tối ưu NP-khó, "đúng" không còn là đúng/sai mà là tốt tới đâu: vét cạn chỉ ăn được test nhỏ; leo đồi đã được gần 90 điểm; ủ mô phỏng + Tabu gần như tuyệt đối. Kết quả các cách ngẫu nhiên phụ thuộc thời gian chạy và hạt giống, nên khi thi hãy tận dụng gần hết giới hạn thời gian.
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 😇