Xâu Đối Xứng (XDXUNG) (HSG12 Lâm Đồng 2025 – Bài 3)
Đề bài
Câu 3: Xâu đối xứng (4.0 điểm) – dữ liệu vào XDXUNG.INP, kết quả XDXUNG.OUT.
Cho một xâu $S$ (tối đa $10^6$ kí tự).
Xâu con của xâu $S$ là một dãy các kí tự liên tiếp nhau trong $S$.
Xâu đối xứng là xâu đọc từ trái qua phải hay đọc từ phải qua trái đều giống nhau, chẳng hạn như các xâu ‘a’, ‘abba’, ‘abcdcba’...
Yêu cầu: Tìm số nguyên $K$ là độ dài của xâu con đối xứng dài nhất trong xâu $S$.
Input: Trong file XDXUNG.INP
- Một xâu $S$.
Output: Ghi kết quả vào file XDXUNG.OUT
- Một số nguyên $K$: là độ dài của xâu con đối xứng dài nhất của xâu $S$.
(Code trong bài đọc/ghi chuẩn – bàn phím/màn hình; khi đi thi đọc ghi file thì các bạn thêm freopen nhé, không là 0 điểm đó.)
Abcbcadk %3abcccccbbbbdddkh7Lời mở đầu
Bài này là bài 3 trong đề thi học sinh giỏi Tin học lớp 12 tỉnh Lâm Đồng năm 2025. Đây là một bài toán khá cổ điển: tìm xâu con đối xứng (palindrome) dài nhất. Với xâu dài tới $10^6$ kí tự thì cách "loang từ tâm" quen thuộc sẽ không kịp, ta cần tới thuật toán Manacher. Mình viết lời giải + code chi tiết, hi vọng mang đến giá trị cho các bạn 😇.
Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì link bài đây nhé:
Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.
Nộp bài trên CLUE OJGiải thích ví dụ:
Abcbcadk %: xâu con đối xứng dài nhất làbcb(hoặccbc), dài $3$. Để ý xâu có cả dấu cách và kí tự%, nên phải đọc nguyên cả dòng (getline) chứ không đọc bằngcin >>được. Ngoài raAhoa vàathường là hai kí tự khác nhau.abcccccbbbbdddkh: xâu con dài nhất làbcccccb, dài $7$ (dài hơnccccchaybbbb).
Lộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Loang từ tâm – O(N²)
Mỗi xâu đối xứng đều có một tâm: xâu độ dài lẻ (như bcb) có tâm là 1 kí tự, xâu độ dài chẵn (như abba) có tâm nằm giữa 2 kí tự. Vậy ta thử từng tâm, rồi "loang" ra hai bên với hai con trỏ $l$, $r$: chừng nào $t_l = t_r$ thì còn đối xứng, $l$ lùi trái, $r$ tiến phải. Khi dừng, đoạn đối xứng là $(l, r)$ không kể hai đầu, dài $r - l - 1$.
Với mỗi vị trí $c$ ta loang 2 lần: tâm lẻ ($l = r = c$) và tâm chẵn ($l = c$, $r = c + 1$).
Source code để các bạn tham khảo nếu làm theo hướng này:
#include<bits/stdc++.h>
using namespace std;
int main(){
string t;
getline(cin, t);
if (!t.empty() && t.back() == '\r') t.pop_back();
int m = t.size(), res = 0;
for (int c = 0; c < m; c ++){
int l = c, r = c;
while (l >= 0 && r < m && t[l] == t[r]){ l --; r ++; }
res = max(res, r - l - 1);
l = c; r = c + 1;
while (l >= 0 && r < m && t[l] == t[r]){ l --; r ++; }
res = max(res, r - l - 1);
}
cout << res << endl;
}
import sys
t = sys.stdin.readline().rstrip('\n').rstrip('\r')
m = len(t)
res = 0
for c in range(m):
l, r = c, c
while l >= 0 and r < m and t[l] == t[r]:
l -= 1
r += 1
res = max(res, r - l - 1)
l, r = c, c + 1
while l >= 0 and r < m and t[l] == t[r]:
l -= 1
r += 1
res = max(res, r - l - 1)
print(res)
a dài $10^6$, tâm nào cũng loang tới tận mép xâu ⇒ cỡ $\frac{N^2}{2} = 5 \cdot 10^{11}$ phép so sánh. Mình chạy thử bản C++ hơn 20 giây vẫn chưa xong ⇒ chắc chắn TLE.Cách 2Thuật toán Manacher – O(N)
Bài này có thể dễ dàng được giải bằng Manacher. Tuy nhiên vì xét cả hai trường hợp tâm chẵn, tâm lẻ khá là mệt, nên ở giữa hai kí tự bất kỳ trong $S$, ta sẽ chèn thêm một kí tự ảo (mình dùng #). Nhận xét rằng trường hợp palin chẵn trong xâu ban đầu thì ở xâu mới (xâu sau khi chèn thêm kí tự ảo) sẽ có độ dài lẻ và tâm là kí tự ảo đó. Ví dụ abba → a#b#b#a: palin chẵn abba trở thành palin lẻ tâm là dấu # ở giữa. Như vậy ta chỉ cần xử lý palin lẻ trên xâu mới.
Nếu tính $cnt[i]$ bằng cách loang như cách 1 thì vẫn $O(N^2)$. Mẹo của Manacher là tận dụng đối xứng gương: luôn giữ đoạn palin $(l, r)$ (không tính hai đầu) có mép phải $r$ xa nhất đã tìm được. Khi xét tâm $i$ nằm trong đoạn đó, điểm đối xứng của $i$ qua tâm đoạn là $j = l + r - i$ (đã tính rồi). Vì đoạn $(l, r)$ đối xứng nên quanh $i$ "giống y" quanh $j$, ít nhất là trong phạm vi còn nằm trong đoạn:
Ta khởi tạo $cnt[i]$ bằng giá trị đó (hoặc $0$ nếu $i$ nằm ngoài đoạn), rồi mới loang tiếp. Mỗi lần loang thêm thành công thì mép phải $r$ được đẩy ra xa hơn, mà $r$ chỉ tăng chứ không giảm, nên tổng số lần loang của cả thuật toán chỉ $O(n)$. Hai kí tự canh biên . và ^ ở hai đầu giúp vòng while tự dừng mà không cần kiểm tra chỉ số.
Đổi bán kính ra độ dài thật. Ta có thể dễ dàng tính được độ dài của đoạn palin trên xâu mới và số kí tự ảo trong đó để trừ đi. Đoạn palin quanh tâm $i$ là $[i - a,\ i + a]$; trong xâu mới kí tự thật nằm ở vị trí lẻ, kí tự ảo ở vị trí chẵn, nên:
- Tâm $i$ là kí tự thật ($i$ lẻ): số kí tự thật trong đoạn là $2\lfloor a/2 \rfloor + 1$.
- Tâm $i$ là kí tự ảo ($i$ chẵn): số kí tự thật trong đoạn là $2\lceil a/2 \rceil = 2\lfloor (a+1)/2 \rfloor$.
s[i] == '#'.Đọc thêm về thuật toán này tại: wiki.vnoi.info/algo/string/manacher.
Minh hoạ Manacher trên xâu đã chèn kí tự ảo
Em nhập xâu (tối đa 16 kí tự) rồi bấm Tiếp. Mỗi tâm $i$ có 2 bước: lấy giá trị khởi tạo từ điểm gương $j$, rồi loang tiếp. Hàng cnt là kết quả đã tính.
Source code để các bạn tham khảo nếu làm theo hướng này:
#include<bits/stdc++.h>
using namespace std;
int n;
string s;
int cnt[2000005];
void cal(){
s = "." + s + "^";
int l = 1, r = 1;
for (int i = 1; i <= n; i ++){
cnt[i] = max(0, min(r - i, cnt[l + (r - i)]));
while(s[i - cnt[i]] == s[i + cnt[i]]) cnt[i] ++;
if (i + cnt[i] > r){
l = i - cnt[i];
r = i + cnt[i];
}
}
}
int main(){
string t;
getline(cin, t);
if (!t.empty() && t.back() == '\r') t.pop_back();
for (int i = 0; i < t.size(); i ++){
if (i) s += "#";
s += t[i];
}
n = s.size();
int res = 0;
cal();
for (int i = 1; i <= n; i ++){
int a = cnt[i] - 1;
if (i % 2 == 0) res = max(res, (a + 1) / 2 * 2);
else res = max(res, a / 2 * 2 + 1);
}
cout << res << endl;
}
import sys
def main():
t = sys.stdin.readline().rstrip('\n').rstrip('\r')
s = '#'.join(t)
n = len(s)
s = '.' + s + '^'
cnt = [0] * (n + 2)
l, r = 1, 1
for i in range(1, n + 1):
c = 0
if r - i > 0:
c = min(r - i, cnt[l + (r - i)])
while s[i - c] == s[i + c]:
c += 1
cnt[i] = c
if i + c > r:
l = i - c
r = i + c
res = 0
for i in range(1, n + 1):
a = cnt[i] - 1
if i % 2 == 0:
res = max(res, (a + 1) // 2 * 2)
else:
res = max(res, a // 2 * 2 + 1)
print(res)
main()
- Code gốc của mình chỉ chèn
#vào giữa các kí tự (không chèn ở hai đầu) nhưng lại quy đổi ra độ dài thật bằngcnt[i] - 2vàcnt[i] - 1. Công thức này chỉ đúng khi palin dừng lại ở một kí tự ảo; nếu palin chạm mép xâu thì bị hụt 1 (ví dụabara $2$,ara $0$). Hai ví dụ của đề không chạm mép nên vẫn ra đúng, nhưng mình đã sửa lại thành đếm số kí tự thật trong đoạn như công thức ở trên. - Ở vòng lặp đầu tiên, khởi tạo cũ
l = 0, r = -1làm chỉ sốcnt[l + (r - i)]bị âm (đọc ngoài mảng, dù kết quả sau đó bịmax(0, ...)che đi). Mình đổi thànhl = 1, r = 1cho an toàn.
'\r' cuối dòng (file tạo trên Windows) để khỏi bị tính nhầm vào xâu.Đánh giá độ phức tạp của cách làm này:
- Không gian: xâu mới và mảng
cntdài $2N + 1$ ⇒ $O(N)$. - Thời gian: mỗi lần vòng
whileloang thành công vượt qua mép phải $r$ thì $r$ tăng lên, mà $r$ không bao giờ giảm và không vượt quá $n + 1$, nên tổng số bước loang là $O(n)$ ⇒ cả thuật toán $O(N)$.
#, ., ^), so với code vét cạn $O(N^3)$ – khớp hết. Với xâu $10^6$ kí tự (toàn chữ a, xen kẽ abab..., ngẫu nhiên), Manacher chạy khoảng 0.05 giây (C++) và 1 giây (Python); cách 1 với xâu toàn chữ a chạy hơn 20 giây.- Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
- Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
- Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
- Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
- Dễ nản giữa chừng khi không có ai theo sát.
- Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
- Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
- Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
- Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.
Nhận xét
Đăng nhận xét