Mảng hậu tố là một cấu trúc dữ liệu trong việc xử lý các bài toán về xâu. Nó hỗ trợ các thuật toán tìm kiếm xâu, thành lập từ điển, tìm xâu con chung một cách nhanh chóng và hiệu quả. Trong khuôn khổ thời gian của một cuộc thi lập trình, việc biết rõ về mảng hậu tố để giải quyết những bài toán là rất cần thiết, đặc biệt là tại kỳ thi ACMICPC, IOI, ...
Trang 1SỞ GIÁO DỤC VÀ ĐÀO TẠO THÀNH PHỐ CẦN THƠ
TRƯỜNG THPT CHUYÊN LÝ TỰ TRỌNG
TỔ TIN HỌC
CHUYÊN ĐỀ
CÂY HẬU TỐ, MẢNG HẬU TỐ VÀ
ỨNG DỤNG
Học sinh thực hiện: Hoàng Văn Thiên
Lớp: 11A2-P
Năm học: 2015 – 2016
Trang 3MỤC LỤC
I ĐẶT VẤN ĐỀ 4
II NỘI DUNG 5
1 Định nghĩa 5
a Hậu tố (suffix) 5
b Trie hậu tố (suffix trie) 5
c Cây hậu tố (suffix tree) 5
d Mảng hậu tố (suffix array) 7
2 Thuật toán và ứng dụng 7
a Thuật toán nhân đôi tiền tố 7
b Thuật toán tìm kiếm xâu trong ( ) 11
c Thuật toán tìm tiền tố chung dài nhất trong ( ) 12
d Bài toán tìm chuỗi con dài nhất được lặp lại (Longest Substring Repeated) 13
e Bài toán tìm chuỗi con chung dài nhất (Longest Common Substring) 13
3 Bài tập 14
4 Tài liệu tham khảo 15
III KẾT LUẬN 15
Trang 4I ĐẶT VẤN ĐỀ
Xử lý chuỗi từ lâu đã trở thành một vấn đề quen thuộc, đặc biệt là trên những xâu có độ dài rất lớn, chẳng hạn như xử lý chuỗi DNA, đoạn văn bản, từ điển, v.v… Vì vậy, việc thành lập các cấu trúc dữ liệu riêng cho xâu và các thuật toán để thao tác trên xâu là vô cùng cần thiết Trong chuyên đề này, em xin giới thiệu một vài cấu trúc dữ liệu xâu tiêu biểu và hiệu quả đó là cây hậu tố, cùng với một dạng mô phỏng khác của cây là mảng hậu tố
Sức mạnh của cây hậu tố nằm ở chỗ chúng có thể biểu diễn tất cả các hậu tố của xâu và cung cấp nhiều phép toán làm nền tảng cho những thuật toán hiệu quả Bên cạnh đó, việc xây dựng cây hậu tố khá phức tạp và tốn kém bộ nhớ Trong một số trường hợp, người ta lại chuộng sử dụng mảng hậu tố hơn bởi tính ngắn gọn khi xây dựng, ít tốn kém Trong các kỳ thi ACM – ICPC, IOI, …
do tính đặc thù về khống chế thời gian nên dùng mảng hậu tố thay thế cho cây hậu tố là điều cần thiết
Theo thời gian, các nhà khoa học đã có nhiều đóng góp để tiến hóa cấu trúc
dữ liệu hậu tố này Ban đầu là Trie hậu tố, sau đó rút gọn lại thành cây hậu tố
và cuối cùng là mảng hậu tố Tuy nhiên, đã có nhiều thuật toán để hình thành trực tiếp mảng hậu tố mà không cần thông qua cây và còn dùng mảng để
“dựng ngược lại” về cấu trúc cây
Trong chuyên đề hôm nay, em sẽ giới thiệu sơ qua những định nghĩa về Trie hậu tố, Cây hậu tố và Mảng hậu tố Kèm theo đó, em sẽ nói thêm về giải thuật hình thành Mảng hậu tố trực tiếp và các dạng bài tập cơ bản để ứng dụng
Trang 5II NỘI DUNG
1 Định nghĩa
a Hậu tố (suffix)
Hậu tố i của một xâu là xâu con tính từ vị trí thứ i đến vị trí cuối cùng
của xâu đó
Ví dụ, hậu tố 4 của xâu ‘BALALAIKA’ là ‘LAIKA’ (vị trí đầu tiên của chuỗi được đánh số thứ tự là 0)
b Trie hậu tố (suffix trie)
Trie được lấy từ chữ ‘Information Retrieval’ (truy hồi thông tin)
Cho S là một tập hợp các xâu Suffix Trie của S là một cây gồm tất cả các
hậu tố viết ra được từ S Cây này tuân thủ một vài nguyên tắc như sau:
- Mỗi cạnh được gán nhãn bằng một ký tự
- Mỗi đỉnh được gán nhãn bằng một xâu được hình thành bằng cách
ghép tất cả các nhãn của các cạnh trên đường đi từ đỉnh gốc đến đỉnh đang xét
- Mỗi đỉnh liên kết với tối đa 26 đỉnh (giả thiết chỉ xét chuỗi gồm các
ký tự tiếng Anh in hoa)
- Mỗi đỉnh có thêm hai nhãn boolean để đánh dấu xâu trên đỉnh đó
có phải là một hậu tố hay không, và có phải là một phần tử trong S hay không
Ví dụ, S = {‘DOG’, ‘JOG’, ‘JOB’}
thì các hậu tố sẽ bao gồm
{‘DOG’, ‘JOG’, ‘JOB’, ‘OG’, ‘OB’,
‘G’, ‘B’} Hình bên mô tả một
Suffix Trie của tập S Các đỉnh
tô màu trắng mang xâu là một
hậu tố trong S Các đỉnh được
viền đen mang xâu là một
phần tử trong S Hiển nhiên vì
một xâu là hậu tố của chính
nó nên nếu đỉnh nào được
viền đen thì sẽ tô màu trắng
c Cây hậu tố (suffix tree)
Bây giờ chúng ta hãy thử chuyển sang thao tác trên chuỗi dài hơn để thấy rõ sự thiếu hiệu quả trong phân vùng dữ liệu của Trie
Xét xâu S = ‘BALALAIKA$’ Ký tự cuối ‘$’ mang ý nghĩa kết thúc một xâu Trong bảng mã ASCII, ký tự này đứng trước ký tự ‘A’, nói cách khác,
Root
DO
DOG
JO
JOG
O
G
O
G JOB B OG
G OB B
Trang 6đứng trước tất cả các ký tự trong xâu S Ký tự này đảm bảo rằng mọi hậu tố của S đều nằm ở nút lá trên cây Nút lá này sẽ được gán nhãn
bởi một số i, mô tả rằng hậu tố khi đi từ root đến nút lá này trùng với hậu tố thứ i của S
Nếu biểu diễn trong Suffix Trie, có thể thấy sự thiếu hiệu quả nằm ở chỗ: Xâu S càng dài
thì càng nhiều đỉnh bị
lặp lại
Suffix Tree được hình thành bằng cách “gộp” các đỉnh chỉ có một nút con lại với nhau Như vậy Suffix Tree cho xâu S nói trên được biểu diễn gọn gàng như hình bên
Khi đó, nhãn của mỗi cạnh không chỉ là một ký tự mà
là một chuỗi Tương tự như ở Trie, nhãn của một nút là chuỗi ghép của các chuỗi nằm trên các cạnh khi đi từ gốc xuống nút đó
Trong suffix tree của một chuỗi độ dài n, số nút (kí hiệu là V) sẽ không
các nút trên cây Mà:
- Nút gốc có ít nhất 1 con
root
K
L
A
L
A
L
A
I
K
A
A
I
K
A L
1
$
I
K
A
3
$
I
5
K A
A
I
K A L
2
$
I
K
A
4
$
K
A
6
$
A
7
$
8
$
root
9
0
6 7
$ A
BALALAIKA$
LA IKA$
KA$
LA
1
LAIKA$
3
IKA$
5 IKA$
2
LAIKA$
4 IKA$
8
$
Trang 7- Nút nhánh ngoại trừ nút gốc có ít nhất 2 con
- Nút lá không có con
d Mảng hậu tố (suffix array)
Trong quyển Algorithmica của Esko Ukkonen có trình bày cách xây
dựng cây hậu tố trong ( ), tuy nhiên rất phức tạp và khó cài đặt trong các kỳ thi lập trình Một sự thay thế gần như hoàn hảo đó là mảng hậu
tố, được thiết kế bởi Udi Manber và Gene Meyers, và đơn giản hơn rất nhiều so với cây hậu tố
Gọi A là tập hợp các hậu tố của chuỗi S, sắp xếp mảng A theo thứ tự từ
điển Với mỗi A[i] có thể xác định nó là hậu tố thứ SA[i] của chuỗi S Như vậy mảng SA được hình thành như thế gọi là mảng hậu tố Ví
dụ, S=’BALALAIKA$’ thì mảng hậu tố sẽ là SA = {9, 8, 5, 3, 1, 0, 4, 2, 6, 7} Xem bảng dưới đây:
Cách hình thành trên đơn thuần chỉ định nghĩa mảng hậu tố là gì Nếu
dựa vào thuật toán trên, sẽ tốn ( ) cho mỗi lần so sánh, và tốn ( log ) cho công việc sắp xếp Như vậy độ phức tạp cuối cùng là
Trong phần tiếp theo của chuyên đề, em xin được trình bày thuật toán thành lập mảng hậu tố hiệu quả, cùng với các bài toán cơ bản đi kèm
2 Thuật toán và ứng dụng
a Thuật toán nhân đôi tiền tố
Thuật toán này dùng để lập mảng hậu tố trong thời gian ( log )
root
9
0
6 7
$ A
BALALAIKA$
LA
IKA$
KA$
LA
1
LAIKA$
3
IKA$
5 IKA$
2 LAIKA$
4 IKA$
8
$
Trang 8Trong giới hạn của chuyên đề này, khi nói đến “sắp xếp”, độc giả hãy ngầm hiểu rằng em đang nói đến “sắp xếp tăng dần” theo giá trị (đối với số) hoặc theo thứ tự từ điển (đối với chuỗi)
Ý tưởng của thuật toán như sau:
- Gán mỗi hậu tố một hạng (rank): hai hậu tố có ký tự đầu bằng nhau
có hạng bằng nhau, hậu tố nào có ký tự đầu nhỏ hơn có hạng nhỏ hơn Việc gán hạng có thể tiến hành trong ( ) Hạng đại diện cho
ký tự đầu của hậu tố Các hậu tố nếu được sắp xếp theo hạng cũng đồng nghĩa với việc được sắp xếp theo ký tự đầu tiên
- Giả sử chúng ta đã có dãy các hậu tố sắp xếp theo k ký tự đầu tiên, thuật toán sẽ xây dựng dãy các hậu tố sắp xếp theo 2k ký tự đầu tiên
nếu ta đã xếp được k ký tự đầu, thì tại hậu tố thứ i cần xem hậu
tố thứ i+k như là một tiêu chí tiếp theo để sắp xếp Vậy mỗi hậu
tố thứ i cần có thêm một hạng thứ cấp, chính bằng hạng sơ cấp
của hậu tố thứ i+k Trường hợp chuỗi S không có hậu tố i+k (tức
là i+k ≥ |S|) thì xem như hạng thứ cấp của hậu tố i bằng 0
sắp xếp, vị trí của các hậu tố thay đổi theo cặp số này Hay nói cách khác, chúng ta sắp xếp hậu tố một cách gián tiếp, thông qua việc sắp xếp cặp hạng này
tố có cặp hạng cũ bằng nhau thì hạng mới bằng nhau, hậu tố nào
có cặp hạng cũ nhỏ hơn thì hạng mới nhỏ hơn
tiên Thuật toán có thể được ngưng lại khi hạng sơ cấp của các hậu tố đôi một khác nhau (tức là việc sắp xếp hậu tố đã hoàn toàn xác định)
Có tổng cộng tối đa log bước lặp, mỗi bước lặp lại có thao tác gán hạng tốn và thao tác sắp xếp ( log ) Vậy cơ bản thì thuật toán nhân đôi tiền tố tốn ( (log ) ) Tuy nhiên thuật toán sắp xếp cho số nhỏ
có thể dùng đếm phân phối (counting sort) và sắp xếp cơ số (radix sort)
để giảm độ phức tạp còn ( )
Mô phỏng qua ví dụ S = ‘BALALAIKA$’ như sau:
(Để thuận tiện cho việc trình bày, em xin dùng ký tự ‘_’ để mô tả ký tự
“rỗng” trong chuỗi.)
Trang 9- Xếp hạng các ký tự đầu tiên Ví dụ, các ký tự đầu tiên của các hậu tố gồm {$, A, B, I, K, L} tương ứng với các hạng {1, 2, 3, 4, 5, 6} Hậu tố bắt đầu bởi ký tự nào thì được gán hạng sơ cấp là một số nguyên dương tương ứng được mô tả ở trên
- Sắp xếp lại cặp số (RA[SA[i]], RA[SA[i]+1])
- Đặt lại hạng sơ cấp dựa theo cặp (<hạng sơ cấp>, <hạng thứ cấp>) Hai hậu tố có cặp hạng cũ bằng nhau thì hạng mới bằng nhau Hậu
tố nào có cặp hạng cũ nhỏ hơn thì hạng mới nhỏ hơn Ví dụ hậu tố 1
và 3 bảng trên có cùng cặp hạng (2, 5) nên ở bảng dưới chúng có cùng hạng sơ cấp (hạng 4)
Trang 10- Sắp xếp bảng trên theo cặp số (<hạng sơ cấp>, <hạng thứ cấp>) và đặt hạng sơ cấp mới, ta có bảng dưới đây:
- Từ trên xuống dưới, 10 hạng đã điền đủ cho 10 hậu tố nên đây là kết quả sắp xếp cuối cùng Mảng SA = {9, 8, 5, 3, 1, 0, 6, 7, 4, 2} là mảng hậu tố, mô tả các hậu tố tương ứng sắp xếp theo thứ tự từ điển
Pseudo Code sau sử dụng SA là mảng hậu tố, RA[SA[i]] là hạng sơ cấp của hậu tố SA[i], tempSA[.] và tempRA[.] là hai mảng tạm thời để lưu
vị trí mới của SA và RA khi thực hiện counting sort và radix sort
Ghi chú: mảng hạng đang xét có thể là mảng hạng sơ cấp hoặc thứ cấp
int c[N], RA[N], tempRA[N], SA[N], tempSA[N];
void countingSort(int k) { // sắp xếp SA theo RA[i+k]
fill c with zero;
∀i∈[0 n-1]: if (i+k >= n) c[0]++; else c[RA[i+k]]++;
// lúc này, c[x] = số lần xuất hiện của x trong mảng hạng đang xét;
∀i∈[0 max(300,n)): {
int t = c[i]; c[i] = sum; sum += t;
}
// lúc này, c[x] = số phần tử trong mảng hạng đang xét có giá trị nhỏ hơn x
// thứ tự xếp của RA[i+k] cũng là của i
∀i∈[0 n-1]:
if (i+k >= n) tempSA[c[0]++] = i;
else tempSA[c[RA[i+k]]++] = i;
// cập nhật SA
∀i∈[0 n-1]: SA[i] = tempSA[i];
}
void radixSort(int k) {
countingSort(k);
countingSort(0);
}
void construct(int k) {
Trang 11∀i∈[0 n-1]: SA[i] = i;
∀i∈[0 n-1]: RA[i] = S[i]; // tận dụng mã ASCII trở thành hạng của ký tự đầu tiên
int k = 1, r;
while (k <= n) {
radixSort(k);
// SA đã được sắp xếp theo 2k ký tự đầu
tempRA[SA[0]] = r = 1;
∀i∈[1 n-1]:
if (hạng sơ cấp và thứ cấp của hậu tố SA[i] và SA[i-1] đều bằng nhau)
tempRA[SA[i]] = r; // chúng vẫn giữ hạng cũ
else tempRA[SA[i]] = ++r; // ngược lại, có hạng mới
∀i∈[0 n-1]: RA[i] = tempRA[i]; // cập nhật
if (RA[SA[n-1]] == n-1) ngắt vòng lặp;
}
}
b Thuật toán tìm kiếm xâu trong ( )
Cho xâu S độ dài n và mảng hậu tố SA đã được xây dựng từ S Dùng kỹ
thuật tìm kiếm nhị phân trên một mảng hậu tố đã sắp xếp, chúng ta dễ tìm ra được sự tồn tại của xâu P độ dài m nào đó trên xâu S trong tối
đa (log ) phép so sánh Mỗi phép so sánh xâu có độ phức tạp ( ) Như vậy thuật toán hoạt động hiệu quả với ( log )
Do xâu P có thể xuất hiện nhiều lần trong S, tức là nó sẽ là tiền tố của một dãy các hậu tố liên tiếp trong mảng SA đã xếp theo thứ tự từ điển Nếu tìm cận dưới (lower bound) và cận trên (upper bound) của P trong
mảng các hậu tố đã sắp xếp thì ta dễ dàng đưa ra tất cả các lần xuất hiện của P trong S chứ không đơn thuần là kiểm tra sự xuất hiện nữa
II stringMatching() {
int l = 0, r = n-1, mid;
while (l <= r) {
mid = (l+r)/2;
if (strncmp(S+SA[mid], P, m) >= 0) r = mid; else l = mid+1; }
if (strncmp(S+SA[l], P, m) < 0) return mp(-1, -1);
II ans = mp(l, -1);
l = 0, r = n-1;
while (l <= r) {
mid = (l+r)/2;
if (strncmp(S+SA[mid], P, m) > 0) r = mid; else l = mid+1; }
if (strncmp(S+SA[r], P, m) != 0) –-r;
ans.S = r;
return ans;
}
Trang 12Hàm trên trả về một pair, mô tả rằng tất cả các hậu tố từ SA[pair.first] đến SA[pair.second] đều có một tiền tố trùng với P
c Thuật toán tìm tiền tố chung dài nhất trong ( )
Khái niệm:
- Cho hai xâu x[0 m] và xâu y[0 n] Tiền tố chung dài nhất (Longest
Common Prefix) của hai xâu x và y là số L lớn nhất sao cho x[0 L]
== y[0 L] Ký hiệu:
L = pcp(x, y) Nhận xét: Hậu tố i có tiền tố chung dài nhất với một hậu tố j của chuỗi S thì i và j nằm kề nhau trong mảng hậu tố, nói cách khác, hai hậu tố i và j nằm liền kề nhau trong thứ tự từ điển
- Cho xâu S[0 n] có mảng hậu tố SA[0 n], mảng LCP[1 n] được hình thành theo nguyên tắc:
LCP[j] = lcp(S[SA[j] n], S[SA[j-1] n]) (LCP[j] = tiền tố chung dài nhất của hậu tố SA[j] và hậu tố SA[j-1])
- Mảng LCP được sắp xếp lại (Permuted LCP Array) – PLCP[0 n-1] được hình thành theo nguyên tắc:
PLCP[SA[j]] = LCP[j]
- Mảng Phi, Phi[i] mô tả tiền tố đứng trước tiền tố i trong mảng SA
Dựa vào định nghĩa, có thể tính được mảng LCP:
void computeLCP() {
LCP[0] = 0;
for (int i = 1; i < n; ++i) {
int L = 0;
while (T[SA[i]+L] == T[SA[i-1]+L]) ++L;
LCP[i] = L;
}
}
với mục đích dựng mảng hậu tố Chúng ta sẽ có bước tiếp cận khác
Ta thừa nhận định lý sau:
Định lý trên đã được chứng minh bởi Kesai trong tài liệu [2]
Dựa vào định lý này, thay vì đặt L = 0, có thể đặt L = PLCP[i – 1] + 1 Khi
đó, câu lệnh while chỉ được thực hiện tối đa n lần
Trang 13void computeLCP_PLCP() {
Phi[SA[0]] = -1;
for (i=1; i<n; ++i) Phi[SA[i]] = SA[i-1];
for (i=0; i<n; ++i) {
if (Phi[i] == -1) PLCP[i] = 0;
while (S[i+L] == S[Phi[i]+L]) ++L;
PLCP[i] = L;
L = max(L-1, 0);
}
for (i=0; i<n; ++i) LCP[i] = PLCP[SA[i]];
}
d Bài toán tìm chuỗi con dài nhất được lặp lại (Longest Substring Repeated)
Cho một chuỗi S, tìm một chuỗi con xuất hiện ít nhất 2 lần trong S sao cho chuỗi này là dài nhất có thể Hai lần xuất hiện được xem là khác nhau nếu vị trí xuất hiện đầu tiên của chúng là khác nhau
Từ mảng LCP đã xây dựng, dễ thấy phần tử lớn nhất của mảng này cũng chính là độ dài lớn nhất của chuỗi cần tìm Khi biết được LCP[i] mang giá trị lớn nhất, ta còn có thể xuất ra chuỗi con đó bằng cách lấy ra i ký
tự đầu tiên của hậu tố SA[i]
void printLSR() {
int L = 0, pos;
for(int i = 0; i < n; ++i)
if (L < LCP[i]) L = LCP[i], pos = i;
for(int i = 0; i < L; ++i) cout << T[SA[pos]+i];
}
e Bài toán tìm chuỗi con chung dài nhất (Longest Common Substring)
của hai chuỗi phải khác nhau — $ và #) Ghép hai chuỗi này lại để có chuỗi S = ‘MALAI#BALALAIKA$’
Trong chuyên đề này, để tiện cho việc mô tả, ta gọi gốc của một hậu tố
x là chuỗi S1 nếu x < strlen(S1), là chuỗi S2 nếu x ≥ strlen(S1)
hành như sau:
- Xây dựng mảng hậu tố, mảng LCP cho chuỗi S;
Trang 14- Khởi tạo biến L = 0 (độ dài chuỗi con chung dài nhất)
- Duyệt qua mảng SA, nếu hậu tố SA[i] và SA[i – 1] thuộc hai chuỗi khác nhau thì cập nhật L = max(L, LCP[i])
Việc kiểm tra hai hậu tố thuộc hai chuỗi khác nhau rất đơn giản Nếu
Tại sao chúng ta chỉ quan tâm hai hậu tố kề nhau trong từ điển?
Chúng ta sẽ chứng minh, khi xét hai hậu tố khác gốc không kề nhau, với mọi trường hợp đều quy về xét hai hậu tố có khoảng cách nhỏ dần cho đến khi chúng kề nhau
dài nhất độ dài k với một hậu tố SA[j] (j < i – 1) khác gốc của hậu tố SA[i] Do sắp xếp theo thứ tự từ điển, tiền tố chung dài nhất của hậu tố SA[j] so với một hậu tố SA[p] nào đó (j < p < i) sẽ không nhỏ hơn k Vậy
sẽ có lợi hơn nếu xét giữa SA[j] và SA[p] Nếu p == j + 1, khoảng cách không thể rút ngắn nữa Ngược lại, ta tiếp tục rút gọn khoảng cách này Lưu ý hậu tố SA[p] phải có gốc khác hậu tố SA[j]
3 Bài tập
a UVa 760 – DNA Sequencing
Tóm tắt: Cho hai chuỗi DNA, mỗi chuỗi được biểu diễn bằng dữ liệu kiểu string, chỉ gồm các ký tự a, t, g, c Tìm tất cả đoạn DNA chung dài nhất của hai chuỗi
Hướng dẫn: Giải bằng phương pháp nêu ở II.2.e
b SPOJ – SARRAY
Hướng dẫn: Bài tập thuần về mảng hậu tố Xem cách làm bằng phương pháp nhân đôi tiền tố tại II.2.a
c UVa 11512 – GATTACA
Hướng dẫn: Bài tập thuần về tìm chuỗi con lặp lại dài nhất Xem cách làm tại II.2.b
d SPOJ OI – PRINTER (trích từ đề thi IOI năm 2008)
Hướng dẫn: Lập mảng SA và LCP, từ đó dựng Trie hậu tố Bài toán trở thành độ dài đường đi ngắn nhất để thăm tất cả các nút lá trên một cây Mỗi bước đi xuống nút con tương ứng với việc viết nhãn nút con đó ra Mỗi lần kết thúc việc thăm nút lá tương ứng với lệnh Print (‘P’) Mỗi