Mục tiêu:
- Thành thạo các thao tác xử lý chuỗi cơ bản.
- Nắm vững và cài đặt được các thuật toán khớp chuỗi kinh điển: Hash chuỗi và KMP.
- Hiểu và vận dụng được các cấu trúc dữ liệu và thuật toán mạnh cho chuỗi: Trie, Z-function.
- Nhận diện và áp dụng đúng công cụ cho từng dạng bài toán xử lý chuỗi.
- Có cái nhìn tổng quan về các kỹ thuật nâng cao để định hướng học tập sâu hơn.
NỘI DUNG CHI TIẾT
Lời Mở Đầu
Xử lý chuỗi (String Processing) là một trong những lĩnh vực quan trọng và có mặt ở khắp mọi nơi trong khoa học máy tính. Từ những tác vụ đơn giản như tìm kiếm một từ trong văn bản, kiểm tra email hợp lệ, cho đến các ứng dụng phức tạp như phân tích dữ liệu gene trong sinh học tin học, xây dựng bộ máy tìm kiếm (search engine), hay xử lý ngôn ngữ tự nhiên, chuỗi ký tự luôn là một dạng dữ liệu cốt lõi.
Trong lập trình thi đấu, các bài toán về chuỗi xuất hiện với tần suất cao, trải dài từ các vòng loại cơ bản đến các kỳ thi quốc tế. Việc nắm vững các kỹ thuật xử lý chuỗi không chỉ giúp bạn giải quyết các bài toán đặc thù mà còn rèn luyện tư duy thuật toán sắc bén.
Chuyên đề này sẽ dẫn dắt bạn qua một lộ trình học tập có hệ thống:
- Làm chủ công cụ: Bắt đầu với việc sử dụng thành thạo thư viện
std::string
của C++, nền tảng cho mọi thao tác. - Thuật toán kinh điển: Đi sâu vào hai "vũ khí" mạnh mẽ và phổ biến nhất là Hash chuỗi (Rabin-Karp) và KMP, hai cách tiếp cận khác nhau cho bài toán tìm kiếm mẫu.
- Cấu trúc dữ liệu chuyên dụng: Khám phá Trie và Z-function, những công cụ hiệu quả cho các bài toán liên quan đến tập hợp chuỗi, tiền tố và hậu tố.
- Mở rộng tầm nhìn: Giới thiệu tổng quan về các kỹ thuật siêu nâng cao như Mảng Hậu tố (Suffix Array) và Tự động Hậu tố (Suffix Automaton) để bạn có định hướng nghiên cứu sâu hơn.
Hãy cùng bắt đầu hành trình chinh phục các thuật toán xử lý chuỗi!
Phần I: Xử lý chuỗi cơ bản với C++
Mục tiêu: Đảm bảo học sinh không mất điểm ở các thao tác đơn giản và sử dụng thư viện chuẩn một cách hiệu quả.
1. Sử dụng std::string
:
Thư viện <string>
của C++ cung cấp lớp std::string
vô cùng mạnh mẽ và tiện lợi.
-
Khai báo, nhập/xuất:
- Khai báo:
string s;
,string s = "hello";
- Nhập từ
cin
:cin >> s;
- Lưu ý: cách này chỉ đọc được một từ, dừng lại khi gặp khoảng trắng hoặc ký tự xuống dòng. - Nhập cả dòng với
getline
:getline(cin, s);
- Đọc toàn bộ dòng cho đến khi gặp ký tự xuống dòng. Đây là cách nhập chuỗi có khoảng trắng an toàn nhất. Để tránh lỗi trôi lệnh sau khi dùngcin
, bạn có thể cần dùngcin.ignore();
trướcgetline
.
- Khai báo:
-
Các thao tác cơ bản:
- Nối chuỗi: Dùng toán tử
+
hoặc+=
.string s1 = "hello"; string s2 = " world"; string s3 = s1 + s2; // s3 = "hello world" s1 += s2; // s1 bây giờ là "hello world"
- Truy cập ký tự: Dùng toán tử
[]
(tương tự mảng). Chỉ số bắt đầu từ 0.string s = "abc"; char c = s[1]; // c = 'b'
- Lấy độ dài: Dùng
.length()
hoặc.size()
. Cả hai đều trả về độ dài chuỗi và có độ phức tạp .string s = "vietnam"; int len = s.length(); // len = 7
- Nối chuỗi: Dùng toán tử
-
Các hàm hữu ích:
substr(pos, len)
: Lấy chuỗi con từ vị trípos
với độ dàilen
. Độ phức tạp là .string s = "abcdef"; string sub = s.substr(1, 3); // sub = "bcd"
find(pattern)
: Tìm vị trí xuất hiện đầu tiên của chuỗipattern
. Trả vềstring::npos
nếu không tìm thấy.string text = "this is a test"; string pattern = "is"; size_t pos = text.find(pattern); // pos = 2 if (text.find("xyz") == string::npos) { // không tìm thấy "xyz" }
push_back(char)
&pop_back()
: Thêm/xóa ký tự ở cuối chuỗi. Thao tác này thường có độ phức tạp trung bình là .string s = "cat"; s.push_back('s'); // s = "cats" s.pop_back(); // s = "cat"
-
Lưu ý hiệu năng: Việc nối chuỗi bằng toán tử
+
trong vòng lặp (s = s + T
) có thể rất chậm vì mỗi lần nối, một chuỗi mới được tạo ra và chuỗi cũ bị hủy. Sử dụng toán tử+=
(s += T
) sẽ hiệu quả hơn nhiều.
2. Code Mẫu:
#include <iostream>
#include <string>
#include <vector>
int main() {
// Khai báo và khởi tạo
std::string s1 = "Hello";
std::string s2;
// Nhập chuỗi (có khoảng trắng)
std::cout << "Nhap ten cua ban: ";
std::getline(std::cin, s2);
// Nối chuỗi
std::string greeting = s1 + ", " + s2 + "!";
std::cout << greeting << std::endl;
// Độ dài và truy cập ký tự
std::cout << "Ten cua ban co " << s2.length() << " ky tu." << std::endl;
if (!s2.empty()) {
std::cout << "Ky tu dau tien la: " << s2[0] << std::endl;
}
// Lấy chuỗi con
std::string sub = greeting.substr(0, 5);
std::cout << "Chuoi con 5 ky tu dau: " << sub << std::endl;
// Tìm kiếm
size_t pos = greeting.find(s2);
if (pos != std::string::npos) {
std::cout << "Ten cua ban bat dau tai vi tri: " << pos << std::endl;
}
return 0;
}
Phần II: Các Thuật Toán Khớp Chuỗi Kinh Điển
Mục tiêu: Nắm vững hai công cụ mạnh mẽ và phổ biến nhất để giải quyết bài toán tìm kiếm mẫu trong văn bản.
A. Hash Chuỗi (Rolling Hash) và Thuật toán Rabin-Karp
-
Ý tưởng cốt lõi: Thay vì so sánh hai chuỗi ký tự một cách trực tiếp, ta biến đổi mỗi chuỗi thành một con số duy nhất (hoặc gần như duy nhất) gọi là giá trị hash. Việc so sánh hai số nguyên lớn nhanh hơn rất nhiều so với việc so sánh từng ký tự của hai chuỗi.
-
Kỹ thuật Rolling Hash: Để áp dụng cho việc tìm kiếm, ta cần tính hash của mọi chuỗi con có cùng độ dài với mẫu. Kỹ thuật "lăn" (rolling) giúp thực hiện điều này một cách hiệu quả.
-
Công thức hash đa thức: Ta xem một chuỗi như một số ở hệ cơ số .
Trong đó:
- là giá trị số của ký tự thứ (ví dụ:
a
-> 1,b
-> 2, ...). - là một cơ số, thường chọn là một số nguyên tố lớn hơn kích thước bảng chữ cái (ví dụ, hoặc cho bảng chữ cái tiếng Anh thường).
- là một modulo lớn, thường là một số nguyên tố lớn (ví dụ, hoặc ) để giảm xác suất xung đột.
- là giá trị số của ký tự thứ (ví dụ:
-
Kỹ thuật "lăn": Giả sử ta đã có hash của chuỗi con . Để tính hash của chuỗi con tiếp theo trong , ta chỉ cần:
- Trừ đi phần của ký tự đầu tiên: .
- Chia toàn bộ cho .
- Cộng thêm phần của ký tự mới: .
Công thức đầy đủ:
(Lưu ý: là nghịch đảo modular của theo modulo ).
-
-
Xử lý xung đột (Collision):
- Hiện tượng: Xung đột xảy ra khi hai chuỗi khác nhau nhưng lại có cùng một giá trị hash. Điều này có thể xảy ra do phép toán modulo.
- Giải pháp:
- Kiểm tra lại: Khi tìm thấy hai giá trị hash bằng nhau, ta so sánh trực tiếp hai chuỗi gốc để đảm bảo chúng thực sự giống nhau.
- Hash kép (Double Hashing): Sử dụng hai cặp cơ số và modulo khác nhau và . Hai chuỗi được coi là bằng nhau chỉ khi cả hai cặp giá trị hash của chúng đều bằng nhau. Kỹ thuật này giảm xác suất xung đột xuống cực kỳ thấp, đến mức có thể bỏ qua bước kiểm tra lại trong các cuộc thi lập trình.
-
Ứng dụng:
- Tìm kiếm mẫu P trong văn bản T (Rabin-Karp): Tính hash của
P
. Sau đó dùng rolling hash để tính hash của mọi chuỗi con độ dài|P|
trongT
và so sánh. Độ phức tạp trung bình là . - Tìm chuỗi con chung dài nhất: Kết hợp hash và tìm kiếm nhị phân theo độ dài chuỗi con.
- Đếm số chuỗi con khác nhau: Tính hash của tất cả các chuỗi con và đếm số lượng hash khác nhau.
- Tìm chuỗi đối xứng (palindrome) dài nhất.
- Tìm kiếm mẫu P trong văn bản T (Rabin-Karp): Tính hash của
-
Code Template (Double Hashing):
#include <iostream> #include <string> #include <vector> using namespace std; struct RollingHash { long long p1 = 31, m1 = 1e9 + 7; long long p2 = 53, m2 = 1e9 + 9; string s; int n; vector<long long> hash1, hash2; vector<long long> p_pow1, p_pow2; vector<long long> inv_p1, inv_p2; long long power(long long base, long long exp, long long mod) { long long res = 1; base %= mod; while (exp > 0) { if (exp % 2 == 1) res = (res * base) % mod; base = (base * base) % mod; exp /= 2; } return res; } long long modInverse(long long n, long long mod) { return power(n, mod - 2, mod); } RollingHash(const string& str) { s = str; n = s.length(); p_pow1.resize(n + 1); p_pow2.resize(n + 1); inv_p1.resize(n + 1); inv_p2.resize(n + 1); hash1.resize(n + 1, 0); hash2.resize(n + 1, 0); p_pow1[0] = 1; p_pow2[0] = 1; inv_p1[0] = 1; inv_p2[0] = 1; long long p_inv1 = modInverse(p1, m1); long long p_inv2 = modInverse(p2, m2); for (int i = 1; i <= n; ++i) { p_pow1[i] = (p_pow1[i - 1] * p1) % m1; p_pow2[i] = (p_pow2[i - 1] * p2) % m2; inv_p1[i] = (inv_p1[i - 1] * p_inv1) % m1; inv_p2[i] = (inv_p2[i - 1] * p_inv2) % m2; } for (int i = 0; i < n; ++i) { hash1[i + 1] = (hash1[i] + (s[i] - 'a' + 1) * p_pow1[i]) % m1; hash2[i + 1] = (hash2[i] + (s[i] - 'a' + 1) * p_pow2[i]) % m2; } } // Lấy hash của chuỗi con từ vị trí i đến j (0-based) pair<long long, long long> get_hash(int i, int j) { long long h1 = (hash1[j + 1] - hash1[i] + m1) % m1; long long h2 = (hash2[j + 1] - hash2[i] + m2) % m2; h1 = (h1 * inv_p1[i]) % m1; h2 = (h2 * inv_p2[i]) % m2; return {h1, h2}; } };
B. Thuật toán KMP (Knuth-Morris-Pratt)
-
Ý tưởng cốt lõi: KMP là một thuật toán tìm kiếm chuỗi không bao giờ quay lại (re-scan) các ký tự trong văn bản. Khi xảy ra một sự không khớp, thay vì dịch chuyển mẫu đi 1 vị trí và so sánh lại từ đầu, KMP sử dụng thông tin từ chính các tiền tố của mẫu đã khớp để thực hiện một bước "trượt" thông minh, bỏ qua các vị trí chắc chắn không khớp.
-
Hàm tiền xử lý - Mảng LPS (Longest Proper Prefix which is also Suffix): Đây là trái tim của KMP. Mảng
lps
(hay còn gọi là hàm hoặc mảng failure) được xây dựng dựa trên chuỗi mẫupattern
.- Định nghĩa:
lps[i]
là độ dài của tiền tố riêng dài nhất của chuỗi conpattern[0...i]
mà cũng đồng thời là hậu tố của chuỗi con đó. (Tiền tố riêng là tiền tố không phải toàn bộ chuỗi). - Ví dụ minh họa: Với mẫu
P = ababaca
:P[0...0] = a
:lps[0] = 0
P[0...1] = ab
:lps[1] = 0
P[0...2] = aba
: Tiền tốa
cũng là hậu tố.lps[2] = 1
.P[0...3] = abab
: Tiền tốab
cũng là hậu tố.lps[3] = 2
.P[0...4] = ababa
: Tiền tốaba
cũng là hậu tố.lps[4] = 3
.P[0...5] = ababac
:lps[5] = 0
P[0...6] = ababaca
: Tiền tốa
cũng là hậu tố.lps[6] = 1
. => Mảnglps
là:[0, 0, 1, 2, 3, 0, 1]
- Thuật toán xây dựng
lps
: Có thể xây dựng mảng này trong thời gian tuyến tính bằng phương pháp quy hoạch động.
- Định nghĩa:
-
Thuật toán tìm kiếm: Ta dùng hai con trỏ:
i
cho văn bảntext
vàj
cho mẫupattern
.- Khi
text[i] == pattern[j]
, tăng cải
vàj
. - Nếu
j
đạt đến độ dài củapattern
, ta đã tìm thấy một vị trí khớp. Để tìm các vị trí khớp tiếp theo, ta "trượt"pattern
đi bằng cách gánj = lps[j-1]
. - Khi
text[i] != pattern[j]
:- Nếu
j > 0
, ta không cần quay lạii
. Ta chỉ cần lùij
vềlps[j-1]
. Điều này tương đương với việc trượt mẫu đi sao cho tiền tốpattern[0...lps[j-1]-1]
(đã biết là khớp với hậu tố của phần đã so sánh) thẳng hàng với văn bản. - Nếu
j == 0
, không có tiền tố nào để tận dụng, ta chỉ cần tăngi
lên.
- Nếu
- Độ phức tạp: Toàn bộ quá trình tìm kiếm và tiền xử lý có độ phức tạp là .
- Khi
-
Ưu và nhược điểm so với Hash:
- Ưu điểm:
- Chính xác 100%: Không có hiện tượng xung đột.
- Độ phức tạp worst-case được đảm bảo: Luôn là , trong khi Rabin-Karp có thể lên tới trong trường hợp xấu nhất (nhiều xung đột).
- Nhược điểm:
- Cài đặt phức tạp hơn: Khó hiểu và cài đặt hơn so với Rolling Hash.
- Kém linh hoạt: Hash chuỗi có thể dễ dàng mở rộng cho các bài toán so sánh hai chuỗi con bất kỳ, đếm chuỗi con khác nhau, v.v., trong khi KMP chủ yếu được thiết kế cho bài toán tìm kiếm mẫu.
- Ưu điểm:
-
Code Template:
#include <iostream> #include <string> #include <vector> // Hàm xây dựng mảng LPS cho KMP std::vector<int> computeLPS(const std::string& pattern) { int m = pattern.length(); std::vector<int> lps(m, 0); int length = 0; // Độ dài của tiền tố dài nhất trước đó cũng là hậu tố int i = 1; while (i < m) { if (pattern[i] == pattern[length]) { length++; lps[i] = length; i++; } else { if (length != 0) { length = lps[length - 1]; } else { lps[i] = 0; i++; } } } return lps; } // Thuật toán tìm kiếm KMP void KMPSearch(const std::string& text, const std::string& pattern) { int n = text.length(); int m = pattern.length(); std::vector<int> lps = computeLPS(pattern); int i = 0; // con trỏ cho text int j = 0; // con trỏ cho pattern while (i < n) { if (pattern[j] == text[i]) { i++; j++; } if (j == m) { std::cout << "Tim thay mau tai vi tri " << i - j << std::endl; j = lps[j - 1]; } else if (i < n && pattern[j] != text[i]) { if (j != 0) { j = lps[j - 1]; } else { i++; } } } }
Phần III: Các Cấu Trúc và Thuật Toán Nâng Cao
Mục tiêu: Trang bị các công cụ mạnh mẽ để giải quyết các bài toán liên quan đến tập hợp chuỗi và tiền tố/hậu tố.
A. Cây Tiền Tố (Trie / Prefix Tree)
-
Ý tưởng và cấu trúc:
- Trie là một cấu trúc dữ liệu dạng cây được sử dụng để lưu trữ một tập hợp các chuỗi.
- Mỗi cạnh của cây được gán một ký tự.
- Mỗi đường đi từ gốc xuống một nút bất kỳ đại diện cho một tiền tố.
- Các từ hoàn chỉnh được đánh dấu bằng một cờ đặc biệt tại nút cuối cùng của từ đó.
- Cấu trúc một nút:
- Một mảng (hoặc
std::map
) các con trỏ tới các nút con, mỗi con trỏ tương ứng với một ký tự trong bảng chữ cái. - Một biến boolean (hoặc số nguyên)
isEndOfWord
để đánh dấu đây có phải là ký tự cuối cùng của một từ trong tập hợp hay không.
- Một mảng (hoặc
-
Các thao tác cơ bản:
insert(string)
: Để chèn một chuỗi, ta duyệt từ gốc. Với mỗi ký tự, nếu chưa có đường đi (cạnh) tương ứng, ta tạo một nút mới. Cuối cùng, đánh dấuisEndOfWord
tại nút cuối cùng.search(string)
: Duyệt cây theo các ký tự của chuỗi. Nếu duyệt hết chuỗi và nút cuối cùng có cờisEndOfWord
làtrue
, từ đó tồn tại trong tập hợp.startsWith(prefix)
: Tương tựsearch
, nhưng chỉ cần duyệt hết chuỗi tiền tố. Nếu có thể duyệt hết, tức là có ít nhất một từ bắt đầu bằng tiền tố đó.
-
Phân tích độ phức tạp: Tất cả các thao tác trên (
insert
,search
,startsWith
) đều có độ phức tạp là , với là độ dài của chuỗi cần thao tác, độc lập với số lượng chuỗi đã có trong Trie. -
Ứng dụng:
- Hệ thống tự động điền (Autocomplete): Khi người dùng gõ một tiền tố, ta có thể tìm nút tương ứng với tiền tố đó trong Trie và duyệt tất cả các cây con từ đó để gợi ý các từ hoàn chỉnh.
- Kiểm tra chính tả.
- Đếm số chuỗi có tiền tố chung.
- Giải các bài toán XOR (Trie nhị phân): Biểu diễn các số nguyên dưới dạng chuỗi nhị phân và chèn vào Trie. Cấu trúc này giúp giải quyết các bài toán như tìm cặp có XOR lớn nhất/nhỏ nhất, tìm phần tử trong mảng có XOR lớn nhất với một số cho trước.
-
Code Template (Sử dụng mảng con trỏ):
#include <iostream> #include <string> #include <vector> const int ALPHABET_SIZE = 26; struct TrieNode { TrieNode* children[ALPHABET_SIZE]; bool isEndOfWord; TrieNode() { isEndOfWord = false; for (int i = 0; i < ALPHABET_SIZE; i++) { children[i] = nullptr; } } }; class Trie { private: TrieNode* root; public: Trie() { root = new TrieNode(); } void insert(const std::string& key) { TrieNode* pCrawl = root; for (char ch : key) { int index = ch - 'a'; if (!pCrawl->children[index]) { pCrawl->children[index] = new TrieNode(); } pCrawl = pCrawl->children[index]; } pCrawl->isEndOfWord = true; } bool search(const std::string& key) { TrieNode* pCrawl = root; for (char ch : key) { int index = ch - 'a'; if (!pCrawl->children[index]) { return false; } pCrawl = pCrawl->children[index]; } return (pCrawl != nullptr && pCrawl->isEndOfWord); } bool startsWith(const std::string& prefix) { TrieNode* pCrawl = root; for (char ch : prefix) { int index = ch - 'a'; if (!pCrawl->children[index]) { return false; } pCrawl = pCrawl->children[index]; } return true; } };
B. Hàm Z (Z-function)
-
Định nghĩa: Cho chuỗi có độ dài . Mảng Z của là một mảng
z
có độ dài , trong đóz[i]
là độ dài của chuỗi con chung dài nhất bắt đầu từ vị tríi
của chuỗi và tiền tố của chính chuỗi . Nói cách khác,z[i]
là độ dài lớn nhất của tiền tốS[0...k-1]
sao cho nó bằng với chuỗi con bắt đầu tạii
,S[i...i+k-1]
. Theo định nghĩa,z[0]
thường không xác định hoặc được gán bằng . -
Thuật toán tính Z-function trong thời gian tuyến tính : Thuật toán duyệt qua chuỗi từ trái sang phải để tính
z[i]
.- Ý tưởng cốt lõi: Duy trì một "đoạn khớp"
[l, r]
, là đoạnS[l...r]
khớp với tiền tốS[0...r-l]
cór
lớn nhất mà ta đã tìm thấy. - Khi tính
z[i]
, nếui
nằm ngoài[l, r]
, ta tínhz[i]
một cách "ngây thơ" bằng cách so sánhS[i...]
vớiS[0...]
từng ký tự. - Nếu
i
nằm trong[l, r]
, ta có thể tận dụng thông tin đã tính trước đó. Vị tríi
tương ứng với vị trík = i - l
trong tiền tố. Ta biếtz[k]
là độ dài khớp củaS[k...]
với tiền tố. Do đó,z[i]
ít nhất cũng sẽ bằngz[k]
, nhưng không được vượt quá phần còn lại của đoạn[l, r]
(tức làr - i + 1
). Ta có thể khởi tạoz[i]
bằngmin(z[k], r - i + 1)
rồi so sánh tiếp từ đó để mở rộng nếu có thể. - Trong quá trình tính toán, ta luôn cập nhật
[l, r]
nếu tìm được một đoạn khớp mới vươn xa hơn.
- Ý tưởng cốt lõi: Duy trì một "đoạn khớp"
-
Ứng dụng:
- Tìm kiếm mẫu
P
trongT
: Đây là ứng dụng kinh điển của Z-function, có thể thay thế KMP.- Xây dựng một chuỗi mới , trong đó
#
là một ký tự đặc biệt không có trongP
vàT
. - Tính mảng Z cho chuỗi .
- Duyệt qua mảng Z. Nếu tại một vị trí
i
nào đó (tương ứng với vị trí trongT
), ta cóz[i] == |P|
, điều đó có nghĩa là chuỗi con bắt đầu tạii
của khớp với tiền tố của (chính là ) với độ dài . Ta đã tìm thấy một vị trí khớp.
- Xây dựng một chuỗi mới , trong đó
- Tìm chu kỳ của chuỗi: Nếu chia hết cho thì là một độ dài chu kỳ.
- Nén chuỗi.
- Tìm kiếm mẫu
Phần IV: Giới thiệu các chuyên đề Siêu Nâng Cao (Nên biết)
Mục tiêu: Đưa ra cái nhìn tổng quan về các công cụ mạnh nhất trong xử lý chuỗi, định hướng cho các học sinh muốn đi sâu hơn.
-
Mảng Hậu Tố (Suffix Array):
- Là gì: Cho một chuỗi độ dài , Mảng Hậu Tố là một mảng chứa các chỉ số bắt đầu của tất cả các hậu tố của (từ đến ), được sắp xếp theo thứ tự từ điển của các hậu tố đó. Thường đi kèm với Mảng LCP (Longest Common Prefix) giữa hai hậu tố kề nhau trong Mảng Hậu Tố.
- Làm được gì: Là một cấu trúc dữ liệu cực kỳ mạnh, giải quyết hiệu quả rất nhiều bài toán về chuỗi con (substring):
- Tìm kiếm một mẫu trong văn bản (nhanh hơn KMP trong một số trường hợp).
- Tìm chuỗi con chung dài nhất (Longest Common Substring) của nhiều chuỗi.
- Đếm số chuỗi con khác nhau của một chuỗi.
- Tìm chuỗi đối xứng dài nhất.
- Đề cập: Có nhiều thuật toán xây dựng Mảng Hậu Tố. Các thuật toán phổ biến trong lập trình thi đấu có độ phức tạp hoặc . Cũng có thuật toán xây dựng trong nhưng rất phức tạp.
-
Tự Động Hậu Tố (Suffix Automaton):
- Là gì: Được coi là cấu trúc dữ liệu "tối thượng" về chuỗi con. Nó là một Máy trạng thái hữu hạn đơn định (DFA) nhỏ nhất nhận diện tất cả các chuỗi con của một chuỗi . Mỗi đường đi trong automaton tương ứng với một chuỗi con của .
- Làm được gì: Giải quyết được hầu hết các bài toán mà Mảng Hậu Tố làm được, và nhiều bài toán khác, thường với độ phức tạp tối ưu hơn. Ví dụ: tìm số lần xuất hiện của một mẫu, tìm chuỗi con khác nhau đầu tiên, ...
- Đề cập: Thuật toán xây dựng online của Suffix Automaton có độ phức tạp thời gian và không gian là . Tuy nhiên, nó nổi tiếng là một cấu trúc dữ liệu rất phức tạp để hiểu và cài đặt chính xác.
Phần V: Bài Tập Luyện Tập
Nguồn bài tập: VNOI, Codeforces, AtCoder là các nguồn bài tập chất lượng cao.
-
Nhóm 1: Bài tập về Hash
- VNOJ - SUBSTR (Tìm chuỗi) - Bài tập cơ bản về Rabin-Karp.
- VNOJ - PALINY (Chuỗi đối xứng) - Tìm chuỗi con đối xứng dài nhất, ứng dụng hash.
- Codeforces - 126B Password - Yêu cầu tìm một chuỗi vừa là tiền tố, vừa là hậu tố, vừa xuất hiện ở giữa. Hash là một cách tiếp cận.
- Codeforces - Good Substrings - Đếm số chuỗi con "tốt" khác nhau.
-
Nhóm 2: Bài tập về KMP
- VNOJ - KMP (Tìm kiếm KMP) - Bài tập cơ bản cài đặt KMP.
- VNOJ - PERIOD (Chu kỳ của chuỗi) - Ứng dụng mảng LPS để tìm chu kỳ.
- Codeforces - 471D MUH and Cube Walls - Bài toán có thể đưa về tìm kiếm mẫu bằng cách xét sự chênh lệch giữa các phần tử kề nhau.
- Codeforces - 1200E Compress Words - Nối các chuỗi lại với nhau, lợi dụng phần chồng lấp dài nhất giữa hậu tố chuỗi trước và tiền tố chuỗi sau (tính bằng KMP/LPS).
-
Nhóm 3: Bài tập về Trie
- VNOJ - VNTRIEU (Trie và các thao tác) - Bài tập cơ bản về cài đặt Trie.
- Codeforces - 706D Vasiliy's Multiset - Bài tập kinh điển về Trie nhị phân (XOR).
- AtCoder - ABC287 - F - Paste - Một bài toán có thể dùng Trie.
- LeetCode - 212. Word Search II - Tìm tất cả các từ trong một lưới ký tự, kết hợp Trie và DFS/Backtracking.
-
Nhóm 4: Bài tập về Z-function
- Codeforces - 126B Password - Bài này cũng có thể giải rất thanh lịch bằng Z-function.
- Codeforces - 432D Prefixes and Suffixes - Bài toán thuần túy về Z-function/KMP.
- VNOJ - ZFUNC (Z-function) - Bài tập cơ bản cài đặt Z-function.
-
Nhóm 5: Tổng hợp
- Các bài tập trong các kỳ thi lớn (USACO, Olympic...) thường yêu cầu kết hợp nhiều kỹ thuật.
- Ví dụ: một bài toán có thể yêu cầu dùng Hash kết hợp với Tìm kiếm nhị phân, hoặc Trie kết hợp với Quy hoạch động.
- Tham khảo các bài tập trong mục
String Suffix Structures
trên Codeforces hoặc các contest về chuỗi để thử thách bản thân.