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
Đào tạo lập trình thuật toán thi đấu
Competitive Programming
Học sinh
  • 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
Sinh viên
  • Thi Olympic Tin học sinh viên / ICPC
Người đi làm / Sinh viên
  • Phỏng vấn về thuật toán
Thành tích học viênThủ khoa, giải tỉnh, chuyên Tin, đội tuyển quốc gia… Xem kết quả và cảm nhận của phụ huynh Khóa đào tạoLập trình Thuật toán thi đấu – 111 buổi, kèm cặp từ cấp 2 đến hết cấp 3. Xem nội dung, học phí và cách đăng ký
Nguyễn Việt Nam Sơn
Nguyễn Việt Nam Sơn
Tốt nghiệp ngành Công nghệ thông tin, Trường Đại học Khoa học Tự nhiên TP.HCM
12+năm kinh nghiệm dạy học
2.000+học viên đã theo học
438.000+thành viên group lập trình

Đồng sáng lập & quản trị group học lập trình với hơn 438.000 thành viên trên Facebook

Biến đổi xâu (Tin học trẻ toàn quốc 2022 – Vòng sơ khảo – Bảng C1, C2)

Biến đổi xâu (Tin học trẻ toàn quốc 2022 – Vòng sơ khảo – Bảng C1, C2)
Nguồn: Tin học trẻ 2022 Độ khó: Khá Xâu Chia căn (sqrt decomposition) Cây phân đoạn lazy Hợp ánh xạ

Đề bài

Nguồn đề: Hội thi Tin học trẻ toàn quốc 2022 – Vòng sơ khảo – Bảng C1, C2 (bài Biến đổi xâu). Đề 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).

Cho xâu $s$ độ dài $n$ chỉ gồm chữ cái in thường, các kí tự đánh số từ $0$ tới $n - 1$. Lần lượt thực hiện $m$ thao tác, mỗi thao tác có dạng $(i, j, c_1, c_2)$: mọi vị trí trong đoạn $[i, j]$ đang chứa chữ $c_1$ được đổi thành chữ $c_2$ (các vị trí khác giữ nguyên).

Yêu cầu: in xâu thu được sau khi thực hiện xong tất cả các thao tác theo đúng thứ tự.

Dữ liệu vào:

  • Dòng đầu: xâu $s$ ($1 \le n \le 10^6$), chỉ gồm chữ cái in thường;
  • Dòng thứ hai: số nguyên $m$ ($1 \le m \le 1{,}5 \cdot 10^5$);
  • $m$ dòng tiếp theo, mỗi dòng gồm $i, j, c_1, c_2$ ($0 \le i \le j < n$; $c_1 \ne c_2$ là chữ cái in thường), cách nhau bởi dấu cách.

Kết quả: xâu $s$ sau $m$ thao tác.

Subtask (mỗi subtask 20% số điểm):

  • Subtask 1: $n, m \le 2000$;
  • Subtask 2: $n, m \le 5 \cdot 10^4$;
  • Subtask 3: $n \le 3 \cdot 10^5$;
  • Subtask 4: các kí tự trong xâu và trong các thao tác chỉ là a hoặc b;
  • Subtask 5: 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
aaaabbbbcccc
3
0 2 a y
5 9 b c
1 3 a z
Ví dụ – output
yyyzbccccccc

Giải thích: sau thao tác 1 (đổi a thành y trong đoạn $[0, 2]$): yyyabbbbcccc; sau thao tác 2 (đổi b thành c trong $[5, 9]$): yyyabccccccc; sau thao tác 3 (đổi a thành z trong $[1, 3]$): yyyzbccccccc.

Lời mở đầu

Làm đúng như đề thì mỗi thao tác tốn $O(n)$, tổng $O(nm) = 1{,}5 \cdot 10^{11}$ – quá chậm. Khó khăn là thao tác không đồng nhất: trong cùng một đoạn, ô chứa $c_1$ thì đổi, ô khác thì không, nên không "gán đoạn" như bình thường được. Mấu chốt: hãy coi mỗi thao tác là một hàm số trên 26 chữ cái $g(x)$ = "$c_2$ nếu $x = c_1$, ngược lại $x$". Áp nhiều thao tác liên tiếp lên một ô chính là hợp các hàm đó – và hợp của các hàm trên 26 chữ cái vẫn chỉ là một bảng 26 ô. Từ đó ta có thể "ghi nợ" thao tác cho cả một khối (chia căn) hay một nút (cây phân đoạn lazy).

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 do mình tự sinh theo đúng giới hạn và tỉ lệ điểm subtask của đề: 20 test, mỗi subtask 4 test (test 1 là ví dụ của đề, test 2 có $n = 1$). Các thao tác có đoạn ngẫu nhiên, đoạn rất dài (gần cả xâu), đoạn rất ngắn (test 12: độ dài trung bình 3), bảng chữ cái nhỏ (2–6 chữ – thao tác đổi qua đổi lại liên tục) lẫn đủ 26 chữ.

TestSubtask$n$$m$Số chữ cái dùngĐộ dài đoạn trung bình
1112354
211121
312.0002.0004876
412.0002.00026716
5250.00050.0002616.617
6250.00050.000321.951
7250.00050.0002643.727
8249.99950.00068.301
93300.000150.0002699.960
103300.000150.0005161.910
113300.000150.0003133.197
123200.000150.000263
1341.000.000150.0002333.721
1441.000.000150.0002874.902
1541.000.000150.00024
164999.999149.9992521.243
1751.000.000150.00026332.596
1851.000.000150.00026696.494
1951.000.000150.0004360.259
2051.000.000150.00026361.373
Đã nộp thử trên bộ chấm chính thức
Lời giải đầy đủ của mình (quét vị trí + cây phân đoạn trên chỉ số thao tác – một biến thể khác của ý tưởng "hợp ánh xạ", mình nói ở cuối bài) đã 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 (AC); mọi đáp án của bộ test được đối chiếu với cây phân đoạn lazy ở cách 4.

Kết quả chạy thật các cách bên dưới theo từng subtask (ô xanh = đúng hết các test của subtask đó):

SubtaskĐiều kiệnTestCách 1
C++
Cách 1
Python
Cách 2
C++
Cách 2
Python
Cách 3
C++
Cách 4
C++
Cách 4
Python
Subtask 1 (20%)$n, m \le 2000$1–44/44/44/44/44/44/44/4
Subtask 2 (20%)$n, m \le 5 \cdot 10^4$5–82/40/44/40/44/44/40/4
Subtask 3 (20%)$n \le 3 \cdot 10^5$9–121/41/41/41/44/44/40/4
Subtask 4 (20%)chỉ có chữ a, b13–161/41/41/41/44/44/40/4
Subtask 5 (20%)không ràng buộc thêm17–200/40/40/40/44/44/40/4

Ý tưởngThao tác là một ánh xạ 26 chữ cái

Thao tác $(i, j, c_1, c_2)$ áp lên mỗi ô trong $[i, j]$ cùng một hàm $g$: $g(c_1) = c_2$, $g(x) = x$ với $x \ne c_1$. Nếu một nhóm ô cùng chịu các thao tác $g_1, g_2, \dots, g_t$ (theo thứ tự) thì ô nào ban đầu là chữ $x$ cuối cùng sẽ thành $g_t(\dots g_2(g_1(x)))$. Ta lưu hàm hợp $F = g_t \circ \dots \circ g_1$ dưới dạng bảng $F[x]$ với 26 ô, và cập nhật khi có thao tác mới phủ trọn nhóm: $F[x] \leftarrow g(F[x])$, tức là "ô nào đang (thực tế) là $c_1$ thì thành $c_2$": duyệt 26 chữ $x$, chỗ nào $F[x] = c_1$ thì đặt $F[x] = c_2$.

Chia căn: cắt xâu thành các khối dài $B$, mỗi khối giữ một bảng $F$. Thao tác trên $[i, j]$: các khối nằm trọn trong đoạn chỉ cần sửa bảng $F$ của khối; hai khối ở hai đầu (phủ một phần) thì "trả nợ" – áp $F$ xuống từng ô, đặt lại $F$ đồng nhất – rồi làm tay từng ô.

Em nhập xâu và các thao tác (mỗi thao tác "i j c1 c2", cách nhau bởi dấu ;), khối dài $B$, rồi bấm Tiếp:

Ví dụ nhanh:
Subtask 4 có mẹo riêng: khi chỉ có hai chữ a, b, thao tác "đổi $c_1$ thành $c_2$ trên $[i, j]$" làm mọi ô của đoạn thành $c_2$ (ô nào khác $c_1$ thì vốn đã là $c_2$). Đó là phép gán đoạn: chữ cuối cùng của ô $p$ là $c_2$ của thao tác cuối cùng phủ $p$. Duyệt thao tác từ cuối về đầu, tô các ô chưa tô (dùng DSU "nhảy tới ô chưa tô kế tiếp") là xong trong $O(n + m)$. Các cách tổng quát bên dưới đều tự nhiên ăn luôn subtask này.

Cách 1Làm đúng như đề (Subtask 1)

Mỗi thao tác duyệt các ô từ $i$ tới $j$ và đổi những ô đang là $c_1$.

C++▶ Chạy tay từng dòng codeCách 1: mỗi thao tác duyệt từng ô của đoạn [i, j].
Dữ liệu mẫu:
Không gian$O(n)$
Thời gian$O(n \cdot m)$

Kết quả chạy thật: Bản C++ đúng 8/20 test, quá thời gian test 6–7, 9–11, 13–14, 16–20 (các test đúng chạy lâu nhất 0.607s). Ăn trọn subtask 1, và qua được vài test khác có đoạn ngắn (test 8: độ dài đoạn trung bình khoảng 8000, test 12: 3, test 15: 4). Bản Python đúng 6/20 test, quá thời gian test 5–11, 13–14, 16–20 (các test đúng chạy lâu nhất 0.168s).

Cách 2Chia căn, mỗi khối một bảng ánh xạ (Subtask 1, 2)

Cài đúng ý tưởng chia căn với $B = \lfloor \sqrt{n} \rfloor$. Trong xâu s ta lưu chữ "gốc" của khối (chữ trước khi áp bảng $F$); chữ thật sự ở ô $p$ là f[b][s[p]].

C++▶ Chạy tay từng dòng codeCách 2: chia căn B = √n; khối nằm trọn chỉ sửa bảng ánh xạ 26 chữ, hai đầu thì trả nợ rồi làm tay.
Dữ liệu mẫu:
  • day(b): áp bảng của khối $b$ xuống từng ô, đặt lại bảng đồng nhất – $O(B + 26)$. Phải gọi trước khi làm tay trên khối, vì phần làm tay so sánh với chữ thật.
  • Khối nằm trọn: sửa 26 ô của bảng – $O(26)$, không đụng tới xâu.
  • Mỗi thao tác tốn khoảng $4B + 26 \cdot n / B$ phép.
Không gian$O(n + 26\sqrt{n})$
Thời gian$O(m(\sqrt{n} + 26\sqrt{n}))$

Kết quả chạy thật: Bản C++ đúng 10/20 test, quá thời gian test 9–11, 13–14, 16–20 (các test đúng chạy lâu nhất 0.266s). Với $n = 3 \cdot 10^5$, $m = 1{,}5 \cdot 10^5$ thì mỗi thao tác cỡ $2200 + 14000$ phép, tổng hơn $2 \cdot 10^9$ – quá thời gian; phần đắt nhất chính là 26 phép cho mỗi khối nằm trọn. Bản Python đúng 6/20 test, quá thời gian test 5–11, 13–14, 16–20 (các test đúng chạy lâu nhất 0.338s).

Cách 3Chia căn, gộp nhóm chữ bằng bitmask – O(1) mỗi khối (full)

Đổi góc nhìn: thay vì lưu "chữ gốc $x$ thành chữ gì" ($F[x]$), ta lưu ngược lại: nhom[b][c] = tập các chữ gốc hiện đang mang chữ $c$, dưới dạng bitmask 26 bit. Thao tác đổi $c_1 \to c_2$ trên khối nằm trọn chỉ là gộp nhóm: nhom[c2] |= nhom[c1]; nhom[c1] = 0; – $O(1)$!

C++▶ Chạy tay từng dòng codeCách 3: chia căn, mỗi khối lưu nhóm chữ gốc theo chữ thật (bitmask); khối nằm trọn chỉ gộp hai nhóm.
Dữ liệu mẫu:
  1. Ban đầu mỗi chữ một nhóm riêng: nhom[b][c] = 1 << c. Các nhóm luôn rời nhau và phủ đủ 26 chữ gốc.
  2. day(b) dựng lại bảng $F$ từ các nhóm (26 × 26 phép) rồi áp xuống $B$ ô; mảng ban đánh dấu khối nào có thay đổi để khỏi "trả nợ" vô ích.
  3. Mỗi thao tác giờ tốn khoảng $4B + n/B$ phép, nên chọn $B \approx \sqrt{n/4}$: với $n = 10^6$ là $B = 500$, khoảng $4000$ phép mỗi thao tác, tổng $6 \cdot 10^8$ phép rất đơn giản.
Không gian$O(n + 26\sqrt{n})$
Thời gian$O(m\sqrt{n} + 26^2\sqrt{n} + n)$

Kết quả chạy thật: Bản C++ đúng 20/20 test, chạy lâu nhất 0.799s. Full điểm – chỉ với chia căn, nhờ biến bước "sửa bảng 26 ô" thành "gộp hai bitmask".

Cách 4Cây phân đoạn với lazy là ánh xạ 26 chữ (full)

Cách "chuẩn" hơn: cây phân đoạn trên các vị trí, mỗi nút lưu lz[v] = ánh xạ đang chờ áp cho cả đoạn của nút. Cập nhật đoạn $[i, j]$: nút nằm trọn thì hợp $g$ vào lz (26 phép); nút phủ một phần thì đẩy lz xuống hai con trước rồi đệ quy. Cuối cùng đẩy hết xuống lá; lá $p$ cho chữ lz[lá][s[p]].

C++▶ Chạy tay từng dòng codeCách 4: cây phân đoạn trên vị trí, lazy là ánh xạ 26 chữ; cuối cùng đẩy hết xuống lá.
Dữ liệu mẫu:
  1. ap(v, g): lz[v][x] = g[lz[v][x]] – áp lz[v] cũ trước rồi tới $g$ (đúng thứ tự thời gian).
  2. Khi đẩy xuống, ánh xạ của cha xảy ra sau mọi ánh xạ đang chờ ở con, nên ap(con, lz[cha]).
  3. Vòng for (v = 1; v < sz; v++) day(v); đi theo thứ tự chỉ số tăng – cha luôn được đẩy trước con.
  4. Mỗi thao tác thăm $O(\log n)$ nút, mỗi nút 26 phép: tổng khoảng $1{,}5 \cdot 10^5 \cdot 40 \cdot 26 \approx 1{,}6 \cdot 10^8$.
Không gian$O(26n)$
Thời gian$O(26(m \log n + n))$

Kết quả chạy thật: Bản C++ đúng 20/20 test, chạy lâu nhất 0.828s. Full điểm. Bản Python đúng 4/20 test, quá thời gian test 5–20 (các test đúng chạy lâu nhất 0.055s) – bản Python dùng cây không đệ quy và bytes.translate để hợp ánh xạ cho nhanh, nhưng mỗi lần hợp ánh xạ vẫn là một thao tác trên chuỗi 256 byte, đi qua $2\log n$ nút cho mỗi thao tác – chỉ kịp ở subtask 1.

Một biến thể nữa (bản mình nộp chính thức): đổi vai trò – quét vị trí $p$ từ trái sang phải, dựng cây phân đoạn trên chỉ số thao tác; lá $t$ là $g_t$ nếu thao tác $t$ đang phủ $p$, ngược lại là hàm đồng nhất; gốc là hợp mọi thao tác phủ $p$ theo thứ tự thời gian. Thao tác $t$ được "bật" khi $p = i_t$ và "tắt" khi $p = j_t + 1$, nên tổng chỉ có $2m$ lần cập nhật điểm, $O(n + 26 m \log m)$.

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

CáchNgôn ngữĐúngLâu nhất
(test đúng)
Điểm theo subtask
1. Làm đúng như đềC++8/200.607s20%
Python6/200.168s20%
2. Chia căn, mỗi khối một ánh xạ 26 chữC++10/200.266s40%
Python6/200.338s20%
3. Chia căn, gộp nhóm chữ bằng bitmask – O(1) mỗi khốiC++20/200.799s100%
4. Cây phân đoạn, lazy là ánh xạ 26 chữC++20/200.828s100%
Python4/200.055s20%
Ghi chú của mình: thời gian là thời gian CPU đo trên máy mình (giới hạn 1 giây; test quá giờ bị dừng ở khoảng 4 giây). Bài học từ bài này: khi một thao tác trên đoạn "trông có vẻ không gộp được" vì nó phụ thuộc giá trị từng ô, hãy thử coi nó là một hàm trên miền giá trị nhỏ (ở đây 26 chữ cái) – hàm hợp của chúng vẫn nhỏ, nên vẫn ghi nợ (lazy) được. Và với chia căn, đổi cách biểu diễn (bảng ánh xạ ↔ các nhóm bitmask) có thể biến bước $O(26)$ thành $O(1)$.
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 😇