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

Bản đồ Hapmap (Tin học trẻ toàn quốc 2023 – Vòng khu vực miền Trung – Bảng C1)

Bản đồ Hapmap (Tin học trẻ toàn quốc 2023 – Vòng khu vực miền Trung – Bảng C1)
Nguồn: Tin học trẻ 2023 Độ khó: Khó Bài tối ưu Heuristic Tham lam Mô phỏng tôi luyện

Đề bài

Nguồn đề: Hội thi Tin học trẻ toàn quốc 2023 – Vòng khu vực miền Trung – Bảng C1 (bài Bản đồ Hapmap). Năm 2023 vòng khu vực có hai bộ đề: miền Bắc và miền Nam dùng chung một đề, miền Trung dùng một đề riêng – bài này thuộc đề miền Trung. Đề 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).

Bản đồ Hapmap của con người giúp chẩn đoán bệnh và tìm thuốc chữa trị mới. Bài toán dùng hai khái niệm sinh học được đơn giản hóa như sau:

  1. Một Haplotype $H = (h_1, \dots, h_n)$ là xâu nhị phân độ dài $n$ (mỗi $h_i$ là 0 hoặc 1).
  2. Một Genotype $G = (g_1, \dots, g_n)$ là xâu độ dài $n$ tạo ra từ hai Haplotype $H$ và $H'$ theo quy tắc: $g_i = 0$ nếu $h_i = h'_i = 0$; $g_i = 1$ nếu $h_i = h'_i = 1$; $g_i = 2$ nếu $h_i \ne h'_i$.

Mỗi cặp $(H, H')$ tạo ra đúng một Genotype, nhưng một Genotype có thể được tạo từ nhiều cặp khác nhau. Các nhà khoa học muốn "giải mã" ngược: với một tập Genotype, tìm một tập ít Haplotype nhất sao cho mỗi Genotype đều tạo được từ hai Haplotype (có thể trùng nhau) trong tập.

Yêu cầu: cho $k$ Genotype $G_1, \dots, G_k$, hãy tìm $k$ cặp $(H_t, H'_t)$ với $(H_t, H'_t)$ tạo ra $G_t$, sao cho tập $\{H_1, H'_1, \dots, H_k, H'_k\}$ có ít phần tử nhất.

Dữ liệu vào: dòng đầu hai số nguyên dương $k, n$ ($k \le 100$, $n \le 200$); $k$ dòng sau, dòng thứ $t$ là xâu độ dài $n$ gồm các ký tự 0, 1, 2 – Genotype $G_t$.

Kết quả: dòng đầu số nguyên dương $p$ – số Haplotype của tập tìm được; $p$ dòng sau, mỗi dòng một xâu nhị phân độ dài $n$. Mỗi Genotype phải tạo được từ hai Haplotype trong tập em in ra. Có nhiều đáp án thì in đáp án bất kỳ.

Cách tính điểm: mỗi test chấm riêng. Gọi $p$ là số Haplotype em tìm được, $r$ là kết quả của Ban giám khảo. Nếu kết quả không hợp lệ hoặc $p > 2k$ thì được 0 điểm; ngược lại được tỉ lệ điểm của test là

$\min\left\{1, \left(\dfrac{r}{p}\right)^3\right\}$.

Subtask: Subtask 1 (50%): $k \le 10$. 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
2 4
1212
1110
Ví dụ – output
2
1110
1011

Giải thích: $G_1 = 1212$ tạo từ cặp $(1110, 1011)$: hai vị trí đầu và thứ ba giống nhau (1, 1), vị trí 2 và 4 khác nhau → ra ký tự 2. $G_2 = 1110$ tạo từ cặp $(1110, 1110)$. Tập Haplotype chỉ có 2 phần tử.

Lưu ý về cách chấm: đây là bài tối ưu (bài toán gốc tên là Pure Parsimony Haplotyping, đã được chứng minh là NP-khó) – không có "đáp án đúng duy nhất". Đề gốc có 10 test, mỗi test 10 điểm. Trên CLUE mình làm 20 test, trình chấm (checker) kiểm tra từng Genotype có tạo được từ hai Haplotype trong tập không, rồi cho điểm theo công thức $\min\{1, (r/p)^3\}$, với $r$ là số dòng đầu trong file đáp án của test (do lời giải cách 3 của mình tạo ra). Mũ 3 làm điểm giảm rất nhanh: kém $r$ 10% thì chỉ còn khoảng 75% điểm, gấp đôi $r$ chỉ còn 12,5%.

Lời mở đầu

Đây là bài "nặng đô" nhất của đề miền Trung bảng C1: một bài tối ưu – điểm phụ thuộc vào việc tập Haplotype của em nhỏ tới đâu so với của Ban giám khảo. Không có thuật toán nhanh nào cho đáp án tốt nhất, nên lời giải là: biến đổi bài toán cho gọn, nghĩ một cách tham lam hợp lý, rồi dùng hết thời gian để cải thiện dần bằng mô phỏng tôi luyện. Mình đi 3 mức: một cặp riêng cho mỗi Genotype (luôn hợp lệ) → tham lam dùng lại Haplotype → tham lam + mô phỏng tôi luyện (lời giải mình đã nộp tinhoctre.vn được 100 điểm).

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 20 test (test 1 là ví dụ của đề): test 1–10 có $k \le 10$ (subtask 1), test 11–20 có $k = 100$. Phần lớn test được sinh "có gieo": tạo trước một số Haplotype ẩn rồi ghép ngẫu nhiên từng cặp thành Genotype – đúng như câu chuyện của đề; một số test có Genotype hoàn toàn ngẫu nhiên (khó dùng chung), test có $n = 200$ toàn ký tự 2, test có Genotype trùng nhau.

Test$k$$n$Genotype khác nhauSố ký tự 2 nhiều nhất$r$ (file đáp án)
124222
211112
344303
41020061004
510508286
61081067
710200106920
810610511
9101208165
101020072006
111002007511720
121002009412249
1310020010011785
1410030942340
15100121001057
161002009512440
17100150855430
1810020010056200
19100101007101
201002008011365

Cột $r$ là số dòng đầu của file đáp án – kết quả của lời giải cách 3 (chạy một lần, mình đã thử chạy lâu hơn với nhiều hạt giống ngẫu nhiên nhưng không tốt hơn ở test nào). Với các test "có gieo", $r$ không vượt số Haplotype ẩn thật sự được dùng.

Đã nộp thử trên bộ chấm chính thức
Lời giải cách 3 mình đã nộp lên hệ thống chấm chính thức của Hội thi (tinhoctre.vn) và được 100/100 điểm. Nộp trên CLUE OJ (bộ 20 test của mình) cũng đạt 20/20 test trọn điểm. Vì lời giải dừng theo đồng hồ, chạy lại nhiều lần có thể lệch nhẹ (bảng bên dưới có một lần được 99,85 do một test thiếu 1 Haplotype).

Điểm (chấm bằng checker, quy về thang 100) của các cách bên dưới theo từng subtask:

SubtaskĐiều kiệnTestCách 1Cách 2Cách 3
(TB 3 lần)
Subtask 1 (50%)$k \le 10$1–1019,7235,9050,00
Subtask 2 (50%)$k \le 100$11–207,4517,1249,95
Tổng1–2027,1753,0299,95

Ý tưởngChỉ cần chọn một Haplotype cho mỗi Genotype

Nhận xét 1. Nhìn một Genotype $G$: ở vị trí $g_i = 0$ hoặc $1$, cả $H$ và $H'$ bắt buộc bằng $g_i$. Chỉ ở vị trí $g_i = 2$ mới có lựa chọn, và ở đó $H'$ luôn là đảo bit của $H$. Vậy:

  • $H$ phải khớp $G$: ở mọi vị trí không phải 2, $h_i = g_i$;
  • khi đã chọn $H$ thì $H'$ được xác định luôn: $H' = H \oplus m$, với $m$ là mặt nạ các vị trí 2 (đảo các bit đó). Mình gọi $H'$ là "bạn đôi" của $H$ đối với $G$.

Như vậy bài toán trở thành: với mỗi Genotype $G_t$ chọn một Haplotype $h_t$ khớp nó; tập kết quả là hợp của các cặp $\{h_t, h_t \oplus m_t\}$; cần ít phần tử phân biệt nhất. Genotype có $c$ ký tự 2 có $2^c$ lựa chọn (mỗi cặp được đếm 2 lần), nên vét cạn chỉ làm nổi với dữ liệu tí hon.

Nhận xét 2 (tham lam). Một Genotype "rẻ" nhất khi cả $h_t$ và bạn đôi của nó đã có sẵn trong tập (thêm 0 Haplotype mới); kế đến là $h_t$ đã có, chỉ thêm bạn đôi (thêm 1). Xử lý Genotype ít ký tự 2 trước (chúng gần như bị ép buộc, như $1110$ chỉ có một cách), rồi các Genotype sau cố "dùng lại" Haplotype đã có.

Em nhập các Genotype rồi bấm Tiếp để xem cách tham lam dùng lại Haplotype (ô vàng là các vị trí 2, xanh lá = Haplotype dùng lại, xanh dương = Haplotype thêm mới):

Ví dụ nhanh:

Cách 1Mỗi Genotype một cặp riêng (luôn hợp lệ)

Cách dễ nhất: với mỗi Genotype, $H$ lấy 0 ở mọi vị trí 2, $H'$ lấy 1 ở mọi vị trí 2. Cho tất cả vào một set để bỏ trùng. Kết quả luôn hợp lệ và $p \le 2k$ nên chắc chắn có điểm, nhưng thường gấp đôi $r$.

C++▶ Chạy tay từng dòng codeCách 1: mỗi Genotype một cặp (vị trí 2 lấy 0 / lấy 1), set tự bỏ trùng.
Dữ liệu mẫu:
Không gian$O(k \cdot n)$
Thời gian$O(k \cdot n \log k)$

Kết quả chạy thật (chấm bằng checker): cả hai bản đều hợp lệ ở 20/20 test, tổng điểm 27,17/100. Dữ liệu được sinh từ ít Haplotype ẩn nên "cặp 0/1 cố định" gần như không bao giờ trùng nhau giữa các Genotype.

Cách 2Tham lam: dùng lại Haplotype đã có

Làm đúng như nhận xét 2: bỏ Genotype trùng, sắp theo số ký tự 2 tăng dần. Với mỗi Genotype, duyệt các Haplotype $h$ đang có trong tập: nếu $h$ khớp thì "giá" là 0 khi bạn đôi của $h$ cũng có sẵn, ngược lại là 1. Chọn $h$ có giá nhỏ nhất; nếu không có $h$ nào khớp thì tự tạo (vị trí 2 lấy 0) và thêm cả cặp.

C++▶ Chạy tay từng dòng codeCách 2: xử lý Genotype ít ký tự 2 trước, ưu tiên Haplotype đã có mà bạn đôi cũng đã có.
Dữ liệu mẫu:
  • khop(g, h): kiểm tra mọi vị trí không phải 2. banDoi(g, h): đảo bit của $h$ ở các vị trí 2.
  • tot = số Haplotype phải thêm mới (0, 1, hoặc 3 = chưa tìm được). Gặp giá 0 thì dừng duyệt luôn.
  • stable_sort với hàm so sánh đếm số ký tự 2: Genotype bị ép buộc nhiều được xử lý trước.
Không gian$O(k \cdot n)$
Thời gian$O(k^2 \cdot n)$ (tập có tối đa $2k$ phần tử)

Kết quả chạy thật: hợp lệ 20/20 test, tổng 53,02/100 (bản Python cho đúng y kết quả). Ăn trọn những test nhỏ, nhưng ở test $k = 100$ tham lam "chọn ngay" thường kẹt: một quyết định sớm (chọn $h$ tình cờ khớp) làm các Genotype sau không dùng chung được nữa.

Cách 3Tham lam + mô phỏng tôi luyện (100 điểm)

Cách 2 cho một lời giải ban đầu khá ổn; ta sửa dần nó bằng mô phỏng tôi luyện (simulated annealing): lặp liên tục, mỗi lần thử thay đổi lựa chọn $h_t$ của một Genotype ngẫu nhiên, tính lại số Haplotype phân biệt; nếu tốt hơn hoặc bằng thì nhận, nếu tệ hơn $d$ thì vẫn nhận với xác suất $e^{-d / temp}$ (nhiệt độ $temp$ giảm dần từ 0,6 xuống 0,02 theo thời gian) để thoát khỏi "cực tiểu địa phương". Ba kiểu thay đổi:

  1. Chia sẻ: lấy một Haplotype của Genotype khác, "chiếu" nó lên $G_t$ (giữ bit ở vị trí 2 của $G_t$, các vị trí còn lại lấy theo $G_t$) làm $h_t$ mới.
  2. Gộp: chọn $G_s$ tương thích với $G_t$ (không có vị trí cố định nào mâu thuẫn), ép hai Genotype dùng chung một Haplotype.
  3. Lật bit: đảo một bit ngẫu nhiên ở vị trí 2 của $h_t$.

Để mỗi bước thật nhanh: Haplotype lưu thành 4 số unsigned long long (256 bit ≥ 200), "khớp" và "bạn đôi" chỉ là vài phép AND/OR/XOR; số Haplotype phân biệt đếm bằng unordered_map (mỗi Haplotype đang được bao nhiêu Genotype dùng) nên thay đổi một Genotype chỉ tốn $O(1)$ thao tác trên bảng băm. Trong 0,75 giây chạy được vài triệu bước; luôn lưu lại trạng thái tốt nhất từng gặp.

C++▶ Chạy tay từng dòng codeCách 3: đọc, lập mặt nạ bit, khởi tạo tham lam (chi tiết), rồi tóm tắt bước mô phỏng tôi luyện.
Dữ liệu mẫu:
  • proj(t, u) = $(u \;\&\; m_t) \;|\; v_t$: "chiếu" $u$ lên $G_t$. compatible(t, u): $u$ khớp $G_t$ (các vị trí cố định của $u$ đúng bằng $G_t$).
  • setChoice(t, h): gỡ cặp cũ của $G_t$ khỏi bảng đếm, thêm cặp mới. distinctCnt luôn là số Haplotype phân biệt hiện tại.
  • Bước khởi tạo giống cách 2 nhưng khi không dùng lại được, các bit ở vị trí 2 được lấy theo một Genotype tương thích chưa xử lý (để sau này Genotype đó dễ dùng lại).
  • Thời gian đo bằng clock() (thời gian CPU), dừng ở 0,75 giây để an toàn với giới hạn 1 giây.
Không gian$O(k^2 + k \cdot n)$
Thời giankhởi tạo $O(k^2 \cdot n)$, mỗi bước tôi luyện $O(n / 64)$, chạy đến 0,75 giây

Kết quả chạy thật (3 lần): hợp lệ 20/20 test, tổng điểm 100,00 / 100,00 / 99,85 /100, chạy lâu nhất 0,76 giây. Chi tiết từng test:

Test$r$Cách 1
$p$
điểmCách 2
$p$
điểmCách 3 (3 lần chạy)
$p$
điểm TB
1230,29621,0002 / 2 / 21,000
2221,00021,0002 / 2 / 21,000
3331,00031,0003 / 3 / 31,000
44100,06441,0004 / 4 / 41,000
56160,053160,0536 / 6 / 61,000
67170,070110,2587 / 7 / 71,000
720201,000201,00020 / 20 / 201,000
811160,325120,77011 / 11 / 111,000
95130,05751,0005 / 5 / 51,000
106140,079130,0986 / 6 / 61,000
11201440,003240,57920 / 20 / 201,000
12491870,0181610,02849 / 49 / 491,000
13851990,0781910,08885 / 85 / 851,000
14401880,0101680,01340 / 40 / 401,000
15571660,0401110,13557 / 57 / 571,000
16401880,0101030,05940 / 40 / 401,000
17301660,006570,14630 / 30 / 301,000
182002001,0002001,000200 / 200 / 2001,000
191011610,2471240,540101 / 101 / 1020,990
20651510,080690,83665 / 65 / 651,000

Khung chạy tay bên dưới mô phỏng phần đọc dữ liệu + khởi tạo tham lam (phần mô phỏng tôi luyện chạy hàng triệu bước ngẫu nhiên nên chỉ được tóm tắt trong một bước). Mình chọn các dữ liệu mẫu mà khởi tạo đã đạt tối ưu, nên kết quả in ra đúng y chương trình thật.

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

CáchNgôn ngữĐiểm trên bộ test (/100)Lâu nhấtGhi chú
1. Mỗi Genotype một cặp riêngC++27,170.005sLuôn hợp lệ, nhưng gần như không dùng chung được gì
1. Mỗi Genotype một cặp riêngPython27,170.028sNhư bản C++
2. Tham lam: dùng lại Haplotype đã cóC++53,020.004sTất định, rất nhanh
2. Tham lam: dùng lại Haplotype đã cóPython53,020.026sNhư bản C++
3. Tham lam + mô phỏng tôi luyệnC++100,00 / 100,00 / 99,850.758s3 lần chạy; dừng theo đồng hồ nên kết quả có thể lệch nhẹ giữa các lần
Ghi chú của mình: điểm ở đây là điểm theo công thức $\min\{1, (r/p)^3\}$ chấm bằng checker trên bộ test của mình, quy về thang 100; thời gian là thời gian CPU. Với bài tối ưu, hãy nhớ trình tự: (1) in ra một đáp án hợp lệ trước (đã có điểm), (2) biến đổi bài toán để mỗi "phương án" được mô tả gọn (ở đây: mỗi Genotype chỉ cần chọn một Haplotype), (3) tham lam cho lời giải ban đầu, (4) dùng phần thời gian còn lại để cải thiện bằng tìm kiếm cục bộ / mô phỏng tôi luyện, luôn giữ lại trạng thái tốt nhất.
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 😇