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

Xử lí chuỗi sinh học (Tin học trẻ toàn quốc 2025 – Vòng khu vực miền Bắc & miền Trung – Bảng C1)

Xử lí chuỗi sinh học
Nguồn: Hội thi Tin học trẻ toàn quốc 2025 – Vòng khu vực miền Bắc & miền Trung – Bảng C1 Độ khó: Khó Quy hoạch động đoạn Trie Xâu

Đề bài

Alice có một xâu $S$ mô tả một đoạn ADN (chỉ gồm chữ cái thường a–z, các kí tự đánh số từ 0) và một tập $n$ mẫu $w_1, w_2, \dots, w_n$; mẫu $w_i$ có trọng số $p_i > 0$.

Mỗi khi một mẫu $w_i$ xuất hiện như một đoạn con liên tiếp trong xâu hiện tại, Alice được phép trích xuất nó: xoá đoạn đó khỏi xâu, nối phần bên trái và bên phải lại với nhau, và nhận được lợi ích $p_i$. Cứ thế lặp lại cho tới khi trong xâu không còn mẫu nào.

Yêu cầu: đưa ra một chiến lược trích xuất có tổng lợi ích lớn nhất.

Dữ liệu vào:

  • Dòng 1: xâu $S$ ($|S| \le 1000$).
  • Dòng 2: số nguyên $n$ ($n \le 3000$).
  • $n$ dòng sau: dòng thứ $i$ gồm xâu $w_i$ (dài không quá 30, chỉ gồm a–z) và số nguyên $p_i$ ($0 < p_i \le 10^4$).

Kết quả: dòng đầu là số thao tác $k$; $k$ dòng sau, mỗi dòng hai số $L\ R$: thao tác đó xoá các kí tự từ vị trí $L$ tới $R$ của xâu hiện tại (đánh số từ 0), đoạn này phải trùng với một mẫu.

Chấm điểm: gọi $s$ là tổng lợi ích của em, $d$ là kết quả của Ban giám khảo. Nếu $d - s \le 100$ thì em được $0{,}95^{\max(0,\ d - s)}$ số điểm của test, ngược lại được 0 điểm.

Subtask: Subtask 1 (40%): $|S| \le 100$. Subtask 2 (60%): $|S| \le 1000$.

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
abbaa
3
bb 5
aaa 1
ba 4
Ví dụ – output
2
2 3
1 2

Giải thích: xoá đoạn $[2, 3]$ = ba của abbaa được 4, xâu còn aba; xoá tiếp đoạn $[1, 2]$ = ba được thêm 4, xâu còn a. Tổng lợi ích 8. Nếu tham lam xoá bb (5 điểm) trước thì xâu còn aaa, chỉ được thêm 1 – tổng 6.

Lời mở đầu

Đây là bài khó nhất của vòng khu vực miền Bắc & miền Trung bảng C1. Ví dụ đã cho thấy tham lam không ăn thua: xoá một mẫu có thể làm hai mảnh xa nhau "dính" lại thành mẫu mới. Ta cần một quy hoạch động trên đoạn của xâu gốc, kết hợp với trie để duyệt nhiều mẫu cùng lúc. Mình sẽ đi từ vét cạn, tới QHĐ đoạn cơ bản, rồi tới bản có tỉa nhánh chạy kịp $|S| = 1000$, $n = 3000$. Mình cũng kể thật về kết quả nộp trên trang chấm chính thức: Cách 3 được 60 điểm, Cách 4 được 90 đ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 của mình có 20 test (giống bộ trên CLUE): test 1–8 thuộc subtask 1 ($|S| \le 100$), test 9–20 thuộc subtask 2 ($|S|$ = 1000). Có nhiều chiến lược tối ưu khác nhau nên bài chấm bằng trình chấm: mô phỏng lại các thao tác của em (đoạn $[L, R]$ phải trùng một mẫu), tính $s$ rồi cho điểm theo công thức $0{,}95^{d-s}$. Các mẫu trùng nhau thì trình chấm lấy trọng số lớn nhất.

Test$|S|$Số loại
kí tự
$n$ mẫu
(khác nhau)
Độ dài mẫuTrọng sốLợi ích tối ưuSố thao tác
1523 (3)2 – 31 – 582
2112 (2)1 – 23 – 700
310023000 (2148)1 – 301 – 9.999989.856100
4100450 (46)2 – 6606 – 1.96832.76843
51003200 (113)2 – 51 – 1042950
6100263000 (2253)1 – 302 – 9.999781.43196
797240 (29)2 – 8216 – 9.530406.75548
81004300 (233)1 – 10300 – 3.32033.25148
91000263000 (2862)1 – 301 – 10.0007.627.481933
10100043000 (2721)1 – 302 – 9.9989.585.1881000
11100043000 (2793)2 – 304 – 9.9974.767.068500
12100023000 (2458)2 – 302 – 9.9964.903.202500
131000263000 (2985)2 – 305 – 9.9931.281.327204
14100031500 (1043)1 – 121 – 1010.0001000
15100023000 (2391)1 – 30300 – 9.990333.000475
16100043000 (998)1 – 302 – 9.9989.866.6001000
17100043000 (1196)2 – 3012 – 9.9984.747.887500
181000261500 (1453)3 – 3016 – 9.998946.710133
19100043000 (2811)2 – 301 – 10.0004.893.298500
20100021 (1)1 – 16.748 – 6.7483.427.984508
  • Có test với bảng chữ cái rất nhỏ (2–4 kí tự, kiểu ADN acgt) và 3000 mẫu dài tới 30: các mẫu chồng chéo nhau dày đặc – đây là các test "nặng" nhất.
  • Có test nhiều mẫu trùng nhau (cột số mẫu khác nhau nhỏ hơn $n$), test chỉ có 1 mẫu, test mẫu toàn là đoạn con của $S$ (26 chữ cái).

Kết quả chạy thật của 3 cách bên dưới theo từng subtask (đáp án được trình chấm kiểm tra, "qua" nghĩa là đạt đúng lợi ích tối ưu):

SubtaskĐiều kiệnTestCách 1
vét cạn
Cách 2
QHĐ đoạn cơ bản
Cách 3
QHĐ đoạn + tỉa
Cách 4
+ cận, dự phòng
Subtask 1 (40%)$|S| \le 100$1–82/8 test7/8 testquaqua
Subtask 2 (60%)$|S| \le 1000$9–200/12 test1/12 testquaqua
Kết quả trên trang chấm chính thức tinhoctre.vn
  • Cách 3 (bài nộp 3039) chỉ được 60 điểm: quá thời gian các test 19–24, 26–30 và sai test 25. Trên bộ test của mình nó chạy dưới nửa giây, nhưng máy chấm chính thức chậm hơn máy mình khoảng 1,5–2 lần và bộ test chính thức có những test nặng hơn bộ của mình.
  • Cách 4 (cùng thuật toán, thêm tỉa bằng cận trên/cận dưới, tối ưu hằng số và dự phòng thời gian khi sắp hết giờ) được 90 điểm: sai 3 test 19, 24, 25. Mình đã thử thêm 5 biến thể theo các cách hiểu khác nhau của đề – mẫu trùng nhau lấy trọng số lần đầu / lần cuối / cộng dồn, dùng số 64 bit phòng tràn, tắt hẳn phần dự phòng thời gian – tất cả đều đúng 90 điểm, sai đúng 3 test đó.
Lời giải khớp với chương trình vét cạn trên hàng nghìn test ngẫu nhiên nhỏ và đạt tối ưu trên toàn bộ bộ test của mình. Vì không xem được 3 test 19, 24, 25 nên mình chưa biết chắc nguyên nhân – có thể là test quá nặng khiến phần dự phòng phải dùng lời giải gần đúng, một cách hiểu đề khác của Ban giám khảo, hoặc đáp án của Ban giám khảo ở các test đó chưa tối ưu (khi đó trình chấm có thể báo sai lúc thí sinh tốt hơn đáp án). Các bạn tìm ra lý do thì nhắn mình với nhé!

Ý tưởngNhìn các thao tác trên xâu gốc

Đánh dấu mỗi kí tự bằng vị trí của nó trong xâu gốc $S$. Một thao tác xoá một đoạn liên tiếp của xâu hiện tại – nhưng trên xâu gốc, đó là các vị trí $i_1 < i_2 < \dots < i_m$ mà mọi kí tự nằm giữa chúng đã bị xoá trước đó. Ở ví dụ: thao tác 1 xoá vị trí gốc $\{2, 3\}$, thao tác 2 xoá vị trí gốc $\{1, 4\}$ – "khe" $[2, 3]$ ở giữa đã được dọn sạch từ trước.

Từ đó định nghĩa hai bảng trên các đoạn $[l, r]$ của xâu gốc:

  • $full[l][r]$ = lợi ích lớn nhất để xoá sạch đoạn $S[l..r]$ chỉ bằng các thao tác nằm gọn trong đoạn (bằng $-\infty$ nếu không xoá sạch được).
  • $prim[l][r]$ = như trên, nhưng thao tác cuối cùng xoá một mẫu chứa cả $S[l]$ lẫn $S[r]$: mẫu $w$ khớp ở các vị trí $l = i_1 < \dots < i_m = r$ ($S[i_j] = w_j$), mỗi khe giữa hai vị trí liên tiếp được xoá sạch trước: $$prim[l][r] = \max_{w,\ i_1..i_m}\Big(p(w) + \sum_{\text{khe}} full[\text{khe}]\Big).$$

Xét thao tác xoá kí tự $S[l]$ trong một cách xoá sạch $[l, r]$; mẫu của nó kết thúc ở vị trí gốc $m$. Phần $[l, m]$ được xoá hết bởi thao tác đó cùng các khe, phần $[m+1, r]$ phải được xoá sạch độc lập (một thao tác không thể "vắt" qua $m$ vì vị trí $m$ chỉ biến mất ở thao tác cuối của $[l, m]$). Vậy: $$full[l][r] = \max\Big(prim[l][r],\ \max_{l \le m < r} prim[l][m] + full[m+1][r]\Big).$$

Cuối cùng, các kí tự không bị xoá chia xâu thành các đoạn được xoá sạch rời nhau, nên đáp án là QHĐ trên tiền tố: $best[i]$ $= \max(best[i-1],\ \max_l best[l] + full[l][i-1])$. Vì mọi trọng số dương nên xâu còn lại của lời giải tối ưu chắc chắn không còn mẫu nào (còn thì xoá thêm sẽ được lợi hơn). Truy vết từ các bảng sẽ ra dãy thao tác: khe nào cũng xoá trước, mẫu "bao ngoài" xoá sau.

Em nhập xâu và các mẫu (dạng mẫu:trọng_số) rồi bấm Tiếp để xem bảng $full$ được điền từ dưới lên (hàng $l$ giảm dần), sau đó là dãy thao tác truy vết được:

Ví dụ nhanh:

Cách 1Vét cạn, nhớ theo xâu hiện tại

Hàm $f(s)$ = lợi ích lớn nhất thu được từ xâu hiện tại $s$: thử mọi mẫu, mọi vị trí xuất hiện, xoá rồi gọi đệ quy; lưu kết quả vào map theo chính xâu $s$ để không tính lại. Truy vết: từ $S$, mỗi lần chọn một thao tác $t$ có $p + f(t) = f(s)$.

C++▶ Chạy tay từng dòng codeCách 1: đệ quy có nhớ theo xâu hiện tại, rồi truy vết.
Dữ liệu mẫu:
Không giansố xâu con sinh ra (mũ)
Thời gianmũ theo $|S|$

Kết quả chạy thật: bản C++ đúng 2/20 test, quá thời gian test 3–20; các test đúng chạy lâu nhất 0,003 giây. Bản Python đúng 2/20 test, quá thời gian test 3–20; các test đúng chạy lâu nhất 0,019 giây.

Số xâu con có thể sinh ra tăng theo cấp số mũ, nên chỉ test 1, 2 (xâu dài 5 và 1) chạy được. Lưu ý quá thời gian là 0 điểm – công thức $0{,}95^{d-s}$ chỉ cứu được khi em in ra kịp một chiến lược gần tối ưu.

Cách 2QHĐ đoạn + duyệt trie từ mỗi vị trí l

Tính $prim[l][\ast]$ cho mọi $r$ cùng lúc: đi từ $S[l]$ trên trie của các mẫu. Trạng thái $(u, p, g)$: đã khớp tiền tố mẫu ứng với nút $u$, kí tự khớp sau cùng ở vị trí $p$, lợi ích các khe đã xoá là $g$. Từ đó khớp kí tự tiếp theo ở vị trí $q > p$ nếu khe $[p+1, q-1]$ rỗng hoặc xoá sạch được ($full[p+1][q-1] \ge 0$ – đã biết vì $p + 1 > l$) và trie có cạnh $S[q]$ từ $u$. Gặp nút là cuối một mẫu thì cập nhật $prim[l][p]$.

  • Duyệt $l$ giảm dần, nên mọi $full[x][\ast]$ với $x > l$ đã có.
  • Các trạng thái được xử lí theo "tầng" (độ dài tiền tố); trạng thái trùng $(u, q)$ chỉ giữ lợi ích lớn nhất (cùng nút cha để truy vết).
  • okEnd[x] lưu các $y$ mà $S[x..y]$ xoá sạch được – chỉ duyệt các khe hợp lệ thay vì mọi $q$.
C++▶ Chạy tay từng dòng codeCách 2: QHĐ đoạn, mỗi hàng l tính bằng một lượt đi trên trie; truy vết bằng delFull / delPrim.
Dữ liệu mẫu:
Mỗi lượt pass(l) được gộp thành một bước (khung "Ghi chú" liệt kê vài trạng thái đầu); các vòng lặp nhỏ khi tính bảng cũng được gộp.
Không gian$O(|S|^2)$ cho hai bảng
Thời gian$O\left(\sum_l \text{số trạng thái} \times \text{số khe}\right)$ – xấu nhất rất lớn

Kết quả chạy thật: bản C++ đúng 8/20 test, quá thời gian test 3, 9–19; các test đúng chạy lâu nhất 0,091 giây.

Cách này đúng (mình đã đối chiếu với vét cạn trên 1500 test ngẫu nhiên nhỏ) nhưng số trạng thái bùng nổ khi bảng chữ cái nhỏ và mẫu dày: test 3 ($|S| = 100$, chỉ 2 loại kí tự, 3000 mẫu) đã quá thời gian, và hầu hết subtask 2 cũng vậy.

Cách 3Thêm tỉa nhánh: mẫu bị trội và trạng thái bị trội

Hai nhận xét giúp bỏ đi phần lớn công việc mà không làm mất đáp án tối ưu:

  1. Mẫu bị trội. Nếu bản thân xâu $w$ xoá sạch được bằng các mẫu ngắn hơn với tổng lợi ích $\ge p(w)$ thì mỗi lần xoá $w$ ta thay bằng dãy thao tác đó, không thiệt gì → bỏ hẳn $w$. Kiểm tra bằng chính bộ giải trên xâu $w$ (dài $\le 30$), xét mẫu theo độ dài tăng dần. Với bảng chữ cái nhỏ, hầu hết mẫu dài bị loại ở bước này.
  2. Trạng thái bị trội. Gom các trạng thái cùng nút $u$ theo vị trí tăng dần. Gọi $R[q]$ = lợi ích tốt nhất để "đứng ở nút $u$, sắp khớp $S[q]$" (tức $\max g + full[\text{khe}]$). Trạng thái $(u, p, g)$ với $x = p + 1$ mà đã có $R[x] \ge g$ thì vô dụng: mọi thứ nó tạo ra, trạng thái trước tạo được với lợi ích không nhỏ hơn (vì hai đoạn xoá sạch nối nhau vẫn xoá sạch, lợi ích cộng lại).

Thêm vài kỹ thuật cài đặt: mảng R tính theo hàng liền nhau (máy vectơ hoá được nhờ #pragma GCC optimize("O3")), chỉ quét các vị trí có kí tự là con của $u$ (mảng nxt), dùng danh sách khe theo kí tự kế tiếp (okBy) khi số khe hợp lệ ít, và lưu lại trạng thái của từng $l$ để truy vết khỏi phải chạy lại.

C++▶ Chạy tay từng dòng codeCách 3: bỏ mẫu bị trội, gom trạng thái theo nút trie và bỏ trạng thái bị trội; truy vết trên các trạng thái đã lưu.
Dữ liệu mẫu:
Mỗi mẫu được kiểm tra "bị trội" bằng chính bộ giải trên xâu của mẫu – gộp thành một bước. Mỗi lượt pass(l) cũng gộp thành một bước.
Không gian$O(|S|^2 + \text{số trạng thái})$
Thời giannhư Cách 2 nhưng ít trạng thái hơn nhiều; thực tế $\le 0{,}5$ giây

Kết quả chạy thật: bản C++ đúng 20/20 test; các test đúng chạy lâu nhất 0,461 giây. Bản Python đúng 11/20 test, quá thời gian test 9, 11–13, 15–19; các test đúng chạy lâu nhất 0,644 giây.

Bản C++ đạt lợi ích tối ưu ở cả 20 test, chậm nhất chưa tới nửa giây. Mình cũng đối chiếu Cách 3 với vét cạn (1500 test nhỏ) và với Cách 2 (400 test $|S| \le 70$): khớp hết. Bản Python cùng thuật toán chỉ kịp subtask 1 và vài test lớn "dễ".

Nhưng trên trang chấm chính thức, bản C++ này (bài nộp 3039) chỉ được 60 điểm: quá thời gian 11 test (19–24, 26–30) và sai test 25. Bộ test chính thức nặng hơn bộ của mình, máy chấm lại chậm hơn – nên cần thêm tỉa và một "lưới an toàn" về thời gian, đó là Cách 4.

Cách 4Thêm cận trên/cận dưới, tối ưu hằng số và dự phòng thời gian

Đây là bản mình nộp được 90 điểm (điểm cao nhất của mình ở bài này). Thuật toán vẫn là QHĐ đoạn của Cách 3, thêm:

  1. Tỉa bằng cận (tỉa 3). $B[x][r]$ = lợi ích lớn nhất của các thao tác nằm gọn trong $[x, r-1]$ (không bắt buộc xoá sạch) – một cận trên. $LB[r]$ = một cận dưới của $full[l][r]$, lấy từ các lượt duyệt nhanh chỉ cho phép khe dài $\le 2, 12, 48$ (lượt sau được tỉa bằng cận của lượt trước). Trạng thái $(u, x, g)$ bị bỏ nếu với mọi $r$: $g + ext{maxBelow}(u)$ $+ B[x][r] < LB[r]$ – mọi $prim$ sinh ra từ nó đều nhỏ hơn hẳn $full$ nên không bao giờ được dùng.
  2. Tối ưu hằng số: nén bảng chữ cái về các kí tự có trong $S$, trie dạng mảng phẳng, chọn giữa duyệt thưa (danh sách khe theo kí tự kế tiếp) và duyệt dày (cập nhật cả hàng – dùng lệnh AVX2 nếu CPU hỗ trợ, kiểm tra lúc chạy).
  3. Dự phòng thời gian: đếm giờ bằng clock(); nếu tới 0,85 giây vẫn chưa xong thì bỏ, chạy lại với khe dài tối đa 8 (gần đúng, nhanh hơn nhiều); tới 0,92 giây vẫn chưa xong thì chạy với khe 0 (chỉ xoá các mẫu đứng liền). Như vậy luôn in kịp một chiến lược hợp lệ, và nhờ công thức $0{,}95^{d-s}$ vẫn có điểm nếu kết quả gần tối ưu.
  4. Đọc dữ liệu "chịu lỗi" (cả dòng, bỏ \r, kí tự lạ), cuối cùng xoá tham lam mọi mẫu còn sót để xâu cuối chắc chắn không còn mẫu nào.
C++▶ Chạy tay từng dòng codeCách 4 (bản được 90 điểm): như Cách 3 + nén bảng chữ cái, tỉa bằng cận trên/cận dưới, dự phòng thời gian.
Dữ liệu mẫu:
Các lượt bounds(l)/pass(l) và việc kiểm tra mẫu bị trội được gộp thành một bước mỗi lần; khung chạy tay tính bảng F giống hệt Cách 3 (các kỹ thuật tỉa không làm đổi giá trị). Với dữ liệu nhỏ phần dự phòng thời gian không bao giờ được dùng.
Không gian$O(|S|^2)$
Thời giannhư Cách 3, ít trạng thái hơn; luôn dừng trước 1 giây

Kết quả chạy thật: bản C++ đúng 20/20 test; các test đúng chạy lâu nhất 0,409 giây. Trên bộ test của mình phần dự phòng không bao giờ phải dùng tới (mọi test đều đạt đúng tối ưu). Trên tinhoctre.vn: 90 điểm (sai test 19, 24, 25 – xem hộp ở mục Bộ test).

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. Vét cạn, nhớ theo xâu hiện tạiC++2/200,003sQuá thời gian test 3–20
Python2/200,019sQuá thời gian test 3–20
2. QHĐ đoạn + duyệt trie (cơ bản)C++8/200,091sQuá thời gian test 3, 9–19
3. QHĐ đoạn + tỉa mẫu/trạng thái bị trộiC++20/200,461sFull điểm
Python11/200,644sQuá thời gian test 9, 11–13, 15–19
4. Cách 3 + cận trên/dưới, tối ưu hằng số, dự phòng thời gianC++20/200,409sFull điểm
Ghi chú của mình: thời gian là thời gian CPU trên máy mình (giới hạn 1 giây), điểm tính bằng trình chấm theo công thức của đề – mọi test "qua" đều đạt đúng lợi ích tối ưu ($s = d$) – nhưng như đã thấy, bộ test của mình nhẹ hơn bộ chính thức: Cách 3 đúng 20/20 ở đây mà chỉ 60 điểm ở đó. Kỹ thuật cốt lõi của bài – nhìn các thao tác xoá trên xâu gốc và QHĐ đoạn theo "thao tác cuối cùng" – là một dạng kinh điển (giống các bài xoá dãy ngoặc, Zuma...). Còn về 90 điểm chính thức: mình đã kể ở mục Bộ test, nếu bạn nào biết 3 test 19, 24, 25 "khó" ở chỗ nào thì chia sẻ giúp mình 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 😇