You are on page 1of 68

Chuyên đề Câu trúc dữ liệu Trie

Cây tiền tố (Trie) là một kiểu dữ liệu tái cấu trúc thông tin. Tên trie bắt nguồn từ
infomation retrievaldata struct. Sử dụng Trie, ta có thể lưu trữ và tìm kiếm được một
xâu, chuỗi, hoặc một giá trị.
Trie có thể tìm thấy một từ trong một từ điển với chỉ một tiền tố của từ. Do vậy
cây tiền tố được sử dụng để quản lý và phục vụ tìm kiếm nhanh các đối tượng có phần
đầu giống nhau.
Ví dụ, khi tìm kiếm trên Google.com, nếu ta gõ vào cửa sổ tìm kiếm ký tự O hệ
thống sẽ đưa ra ra một số đối tượng bắt đầu bằng chữ cái O:
Ola
Ongame
One piece
Oggy
Nếu ta gõ tiếp ol thì thông tin đưa ra sẽ là:
Olympus
Ola
Ola me
Ole
Khi gõ Oly sẽ có nội dung gợi ý lựa chọn:
Olympus
Olympic
Olympia
Olympus has fallen
Nếu nội dung gõ trong thanh tìm kiếm là Olympiad in thì ở cửa sổ dự báo sẽ có
nội dung:
Olympiad india
Olympiad in informatics
Olympiad inequalities
Olympiad in chippenham
Để tìm kiếm nhanh các đối tượng có phần đầu giống nhau thông tin được lưu trữ
dưới dạng cây, mỗi nút là một ký tự.
Ví dụ
Trong hình vẽ trên, nút 1 là nút gốc, nút 7 thể hiện có 1 xâu là ‘bg’, nút 8 thể hiện
có 1 xâu là ‘db’, nút 9 thể hiện có 1 xâu là ‘dc’, nút 10 thể hiện có 1 xâu là ‘acd’, nút 5
thể hiện là có 1 xâu là ‘ab’.
Tuy nhiên, đối với một số nút, chẳng hạn nút 4, ta không biết nó là thể hiện kết thúc
1 xâu hay chỉ là 1 phần của đường đi từ nút 1 đến nút 9. Vì vậy, khi cài đặt, thông
thường, tại nút U ta cần lưu thêm thông tin nút U có là kết thúc của 1 xâu hay không,
hoặc nút U là kết thúc của bao nhiêu xâu, tuỳ theo yêu cầu bài toán.

a) Cách xây dựng Trie Tree:


Để hiểu rõ về cấu trúc cũng như cách xây dựng cây Trie ta sẽ xét một ví dụ cụ thể. Giả sử
ta có xâu S = ‘abc’. Cây trie bắt đầu được xây dựng như sau:
- Lúc đầu khởi tạo cây Trie chỉ có 1 nút duy nhất và ta sẽ bắt đầu xây dựng cây từ nút này

Hình 1. Cây ban đầu chỉ có duy nhất nút 1


- Ta xét kí tự đầu tiên của xâu S, đó là kí tự ‘a’. Do lúc này cây Trie mới chỉ có duy nhất
một nút khởi đầu nên ở bước này ta sẽ thêm vào cây Trie nút 2, đông thời tạo một cạnh
có hướng từ nút 1 đến nút 2 với trọng số là ‘a’.

Hình 2. Cây được thêm nút 2.


- Lúc này ta đang đứng ở nút 2. Xét kí tự tiếp theo của xâu S, kí tự ‘b’. Ta đang đứng ở
nút 2 và từ nút 2 cũng không hề có một đường đi nào khác nên ta tiếp tục bổ sung thêm
vào cây Trie nút 3, đồng thời tạo cạnh có hướng từ nút 2 đến nút 3 với trọng số là ‘b’.
Tương tự với kí tự ‘c’. Ta sẽ được cây Trie như sau:

Hình 3. Cây Trie tree của tập xâu {‘abc’}.


Như vậy nếu ta đi theo đường đi 1→2 →3→4 rồi ghép tất cả các trọng số trên đường đi
này lại ta sẽ được xâu S. Nút 4 là nút kết thúc việc tạo xâu nên ta cần đặt một dấu hiệu ở
đây, cho biết nút 4 là kết thúc của 1 đường đi tạo xâu.
Bây giờ ta sẽ thêm vào xâu S = ‘abd’. Vậy cây Trie sẽ thay đổi như thế nào? Ta lại tiếp
tục xuất phát từ nút gốc của cây, nút 1. Xét kí tự đầu tiên của xâu S. Ta thấy từ nút 1 đang
đứng ta có thể đi đến đỉnh 2 theo cạnh có trọng số bằng kí tự thứ nhất của xâu S. Vì vậy
ta sẽ đi đến đỉnh 2 mà không tạo thêm nút cho cây Trie. Tiếp tục xét đến kí tự tiếp theo,
kí tự ‘b’. Lúc này từ nút 2, ta cũng có thể đi theo 1 cạnh có trọng số bằng kí tự thứ 2 của
xâu S, vì vậy ta sẽ đi theo cạnh này đến nút 3. Xét đến kí tự cuối cùng của xâu S, kí tự
‘d’. Lúc này từ nút 3, ta chỉ có duy nhất 1 cạnh nối, tuy nhiên cạnh nối này lại có trọng số
khác với kí tự đang xét (kí tự ‘d’). Vì vậy ta phải tạo thêm nút 5 và một cạnh có hướng từ
nút 3 đến nút 5 với trọng số là ‘d’. Lúc này nút 5 cũng cần đặt một dấu hiệu kết thúc. Cây
trie của ta sẽ có dạng như sau:
hinh 4. Cây Trie tree của tập xâu {‘abc’, ‘abd’}
b) Tìm kiếm trên Trie Tree:
Sau khi đã xây dựng xong Trie Tree theo cách trên ta sẽ có thể kiểm tra xem 1 xâu
thuộc tập X có xuất hiện trong tập S hay không. Cách làm cũng tương tự như việc xây
dựng cây Trie. Ta xuất phát từ nút 1. Sau đó xét từng kí tự của xâu cần kiểm tra. Nếu có
cạnh nối từ đỉnh đang đứng với trọng số bằng kí tự đang xét thì ta tiếp tục đi theo cạnh
đó đến nút tiếp theo, ngược lại, nếu ở 1 kí tự nào đó ta không thể đi được tiếp thì xâu
này không xuất hiện trong tập S. Khi đã đi đến hết xâu cần kiểm tra. Giả sử ta đang đứng
ở nút p, nếu ở nút p có dấu hiệu kết thúc thì xâu này c
Chú ý
Người ta đã chứng minh được rằng số lượng nút trên cây Trie Tree không vượt
quá 4*L với L là tổng số kí tự của các xâu mà ta thực hiện thao tác Insert. Vì vậy trong
các bài toán về xâu, nếu muốn sử dụng Trie Tree cần phải chú ý đến giới hạn này. Bởi
vì khi L quá lớn, ta sẽ không đủ bộ nhớ để lưu Trie Tree, những bài toán như vậy ta
không nên sử dụng Trie. Cũng cần lưu ý thêm rằng, 4L là số lượng tính toán trên lý
thuyết, trên thực tế các bài toán ta chỉ cần cung cấp bộ nhớ L là đủ.

3. Bài toán
Bài toán: Cho tập S gồm N xâu 𝑆1,𝑆2,…,𝑆𝑁 và tập 𝑋 gồm 𝑀 xâu 𝑋1,𝑋2,…,𝑋𝑀. Các xâu
có độ dài không vượt quá 𝐿. Hãy kiểm tra xem mỗi xâu thuộc tập X có xuất hiện trong
tập 𝑆 hay không?
Nhận xét: Ta có thể giải bài toán này bằng cách duyệt tất cả các xâu thuộc S để kiểm tra
với từng xâu X. Độ phức tạp của thuật toán nàylà khoảng 𝑂(𝑁.𝑀.𝐿). Thuật toán này chỉ
có thể chạy được với các bộ dữ liệu nhỏ. Sau đây chúng ta sẽ xét một cấu trúc dữ liệu
rất hiệu quả để giải bài toán này với độ phức tạp 𝑂(𝑚𝑎𝑥(𝑀,𝑁).𝐿). Đó là cây tiền tố Trie
Tree.
#include<bits/stdc++.h>
using namespace std;
struct trie
{
int child[26];
// kiểm tra xem có đường đi với trọng số ‘char’ hay không. Nếu child[‘char’] = 0 là
không có cạnh nối với trọng số ‘char’ còn nếu child[‘char’] <>0 thì có cạnh trọng số char
đến nút child[‘char’]
bool f;//dấu hiệu kết thúc

};
Tùy theo yêu cầu của bài toán, thông tin của mỗi nút có thể thay đổi cho phù hợp.
int m,n,top;
string x[5001],s[5001];
trie tree[2000001];
void read()
{
cin>>n>>m;
for (int i=1;i<=n;i++) cin>>s[i];
for (int i=1;i<=m;i++) cin>>x[i];
}
void chen(string t) // thêm t vào trie tree
{
int p=1; // bắt đầu từ nút 1
for (int i=0 ;i<t.length();i++)
{
if (tree[p].child[t[i]-97]==0) // nếu không có đường đi
{
++top; // tăng số nút lên
tree[p].child[t[i]-97]=top; // thêm cạnh có hướng
}
p=tree[p].child[t[i]-97]; // đi đến nút tiếp theo
}
tree[p].f=true; // thêm dấu hiệu kết thúc
}
bool check(string t) // kiểm tra xâu t
{
int p=1; // xuất phát từ nút 1
for (int i=0; i<t.length();++i)
{
if (tree[p].child[t[i]-97]==0) return false; // không có đường đi
p=tree[p].child[t[i]-97]; // đi đến nút tiếp theo
}
return tree[p].f; // kiểm tra dấu hiệu kết thúc
}
void process()
{
top=1;
for (int i=1;i<=20000;i++)
tree[i].f=false;
for (int i=1;i<=n;i++) chen(s[i]);
for (int i=1;i<=m;i++)
if (check(x[i])) cout<<"yes";
else cout<<"No";
}
int main()
{
freopen("vd.inp", "r",stdin);
freopen("vd.out", "w",stdout);
read();
process();

}
Một số bài tập áp dụng.
Bài 1. Cho tập S gồm N xâu S1, S2,…SN và tập X gồm M xâu X1, X2,…XM. Các xâu có
độ dài không vượt quá 100. Hãy kiểm tra xem mỗi xâu thuộc tập X có xuất hiện trong tập
S hay không?
Input:
- Dòng đầu tiên chứa 2 số nguyên N, M (1 ≤ N, M ≤ 5000)
- N dòng tiếp theo, mỗi dòng chứa một xâu thuộc tập S
- M dòng tiếp theo, mỗi dòng chứa một xâu thuộc tập X

Output:
- Ghi ra M dòng, dòng thứ i ghi ra thông báo ‘YES’ nếu xâu Xi xuất hiện trong tập hợp S,
ngược lại ghi ra thông báo ‘NO’
1000.inp 1000.out
23 NO
abcacba YES
abcbbbb YES
abcbbba
abcbbbb
abcacba
Bài 2. Bạn được cho một danh sách các số điện thoại. Một danh sách được gọi là hợp
lệ nếu như không có số điện thoại nào là tiền tố của một số điện thoại khác ở trong
dãy. Nhiệm vụ của bạn là xác định xem danh sách đã cho có hợp lệ hay không?
Input
- Dòng đầu tiên chứa số nguyên t (1 ≤ t ≤ 40) là số lượng bộ test
- Mỗi bộ test được ghi trên N+1 dòng: Dòng đầu tiên chứa số nguyên N (1 ≤ N ≤
10000). N dòng sau mỗi dòng ghi một xâu (có độ dài không vượt quá 10) là một
số điện thoại trong danh sách
Output
- Ghi ra t dòng tương ứng với kết quả của t bộ test: in ra “YES” nếu danh sách là
hợp lệ và in ra “NO” trong trường hợp ngược lại
Example

5000.inp 5000.out
2 NO
3 YES
911
97625999
91125426
5
113
12340
123440
12345
98346
Bài 3. Bessie định dẫn đàn bò đi trốn. Để đảm bảo bí mật, đàn bò liên lạc với nhau bằng
cách tin nhắn nhị phân.
Từng là một nhân viên phản gián thông minh, John đã thu được M (1 ≤ M ≤ 50,000) tin
nhắn mật, tuy nhiên với tin nhắn i John chỉ thu được bi (1 ≤ bi ≤ 10,000) bit đầu tiên.
John đã biên soạn ra 1 danh sách N (1 ≤ N ≤ 50,000) các từ mã hóa mà đàn bò có khả
năng đang sử dụng. Thật không may, John chỉ biết được cj (1 ≤ cj ≤ 10,000) bit đầu
tiên của từ mã hóa thứ j.
Với mỗi từ mã hóa j, John muốn biết số lượng tin nhắn mà John thu được có khả năng
là từ mã hóa j này. Tức là với từ mã hóa j, có bao nhiêu tin nhắn thu được có phần
đầu giống với từ mã hóa j này. Việc của bạn là phải tính số lượng này.
Tổng số lượng các bit trong dữ liệu đầu vào (tổng các bi và cj) không quá 500000.
Input:
- Dòng 1: 2 số nguyên: M và N
- Dòng 2..M+1: Dòng i+1 mô tả tin nhắn thứ i thu được, đầu tiên là bi sau đó là
bi bit cách nhau bởi dấu cách, các bit có giá trị 0 hoặc 1.
- Dòng M+2..M+N+1: Dòng M+j+1 mô tả từ mã hóa thứ j, đầu tiên là cj sau đó là
cj
bit cách nhau bởi dấu cách.
Output:
- Dòng 1..N: Dòng j: Số lượng tin nhắn mà có khả năng là từ mã hóa thứ j
Example:

Input Output
45 1
3010 3
11 1
3100 1
3110 2
10
11
201
501001
211

0 chỉ có khả năng là 010 -> 1 tin nhắn. 1 chỉ có khả năng là 1, 100, hoặc 110 -> 3 tin
nhắn. 01 chỉ có thể là 010 -> 1 tin nhắn. 01001 chỉ có thể là 010 -> 1 tin nhắn. 11 chỉ
có thể là 1 hoặc 110 -> 2 tin nhắn.
Hướng dẫn: Trước tiên ta sẽ Insert tất cả các xâu trong tập M xâu thu được từ vào
Trie Tree Ta sẽ sử dụng 2 mảng như sau:
 sl[p] là số lượng các từ kết thúc tại nút p
 sl2[p] là số lượng các từ đi qua nút p
Với mỗi từ j trong tập N xâu, ta sẽ làm như sau:
 Khởi tạo số lượng từ có thể mã hóa được từ xâu j: cnt== 0
 Duyệt trên cây Trie, với mỗi đỉnh p trong quá trình duyệt ta sẽ cộng kết quả
thêm lượng là sl[p]: cnt+= sl[p]. Đây chính là bước tính số lượng các từ có
chiều dài ≤ chiều dài từ j mà mã hóa được từ j
 Trong quá trình duyệt, nếu ta không thể đi được hết xâu j thì ngay lập tức đưa
ra kết quả là cnt
Nếu xâu j có thể đi được hết trên Trie Tree, ta cần phải đếm thêm các từ có độ dài
lớn hơn độ dài xâu j mà có phần đầu trùng với xâu j, số lượng các xâu này chính là:
sl2[p]- sl[p] với p là nút cuối cùng trong quá trình duyệt xâu j trên Trie Tree.
Bài 4. Andrew và Alex là những nhà sáng tạo trò chơi, ngày hôm nay họ sáng tạo ra trò
chơi mới với xâu dành cho hai người
Giả sử ta có một tập hợp gồm n xâu khác rỗng. Với mỗi lượt chơi, 2 người chơi cùng
nhau xây dựng 1 xâu, lúc đầu xâu được khởi tạo rỗng. Mỗi người chơi sẽ đi theo lượt,
đến lượt của mình, người chơi phải thêm vào cuối xâu một kí tự duy nhất sao cho xâu
mới tạo thành là tiền tố của ít nhất 1 xâu trong tập hợp n xâu ban đầu. Một người sẽ
thua nếu như người đó không thể thực hiện được việc thêm kí tự vào xâu.
Andrew và Alex quyết định sẽ chơi k ván. Người thua cuộc ở ván thứ i sẽ là người đi
trước trong ván thứ (i+1). Người chiến thắng chung cuộc sẽ là người thắng ở ván đấu
thứ k (tức là ván đấu cuối cùng) chứ không phải là người thắng nhiều ván hơn. Giả sử
nếu hai người đều chơi theo chiến thuật tối ưu thì sau k ván ai sẽ là người chiến
thắng?
Input:
- Dòng đầu tiên chứa 2 số nguyên n và k (1 ≤ n ≤ 105; 1 ≤ k ≤ 109).
- Mỗi dòng trong số n dòng tiếp theo, mỗi dòng chứa một xâu khác rỗng nằm
trong tập hợp xâu ban đầu. Tổng số kí tự của tất cả các xâu trong tập hợp không
vượt quá 105, xâu chỉ bao gồm các chữ cái tiếng Anh in thường
Output:
- Gồm một dòng duy nhất: ghi ra ‘First’ nếu người đi trước ở ván đầu tiên chiến
thắng chung cuộc, ghi ra ‘Second’ trong trường hợp người đi sau ở ván đầu tiên
chiến thắng chung cuộc
Example:

Input Output
23 First
a
b
31 First
a
b
c
12 Second
Ab

Hướng dẫn:
Đây là một bài toán trò chơi. Một trong những kĩ thuật cơ bản nhất để giải các bài
toán trò chơi đó là xác định các vị trí thắng và vị trí thua trong bài toán. Và điểm mấu
chốt của bài toán này chính là xác định các vị trí đó.
Trước hết, ta sẽ xây dựng cây tiền tố Trie cho tập gồm n xâu ban đầu. Sau khi đã có
cây Trie, ta mô tả lại các bước đi của trò chơi như sau: lúc đầu người đi trước sẽ đứng
ở đỉnh 1 của cây Trie, sau đó đi theo 1 cạnh của cây để đến 1 đỉnh khác, sau đó người
thứ 2 lại tiếp tục như vậy, đến khi nào một người chơi đi đến một nút lá trên cây, tức
là người sau không thể đi tiếp được nữa thì người đó sẽ là người chiến thắng của ván
đấu.
Đến đây ta sẽ định nghĩa vị trí thắng, vị trí thua như sau: “ Một nút trên cây là vị trị có
thể thắng nếu người chơi đứng ở nút đó với cách chơi tối ưu luôn dành chiến thắng,
một nút trên cây là vị trí có thể thua nếu người chơi đứng ở nút đó, với cách chơi của
mình sẽ luôn thua “. Vậy tại sao ta lại dùng từ “có thể”? Bởi vì người chiến thắng là
người thắng được ván cuối cùng chứ không phải là người thắng nhiều ván hơn, vậy
nên có thể có người chơi CỐ TÌNH THUA ở một số ván để giành được chiến thắng ở
ván cuối cùng. Ta có thêm 1 số định nghĩa như sau: “ Một nút là nút thắng HOÀN
TOÀN, nếu từ nút đó ta chỉ có thể đi được đến các nút thua HOÀN TOÀN. Một nút là
nút thua HOÀN TOÀN nếu từ nút đó ta chỉ có thể đi được đến các nút thắng HOÀN
TOÀN. Một nút là nút tiềm năng nếu từ đó ta có thể đến được cả nút THẮNG HOÀN
TOÀN và THUA HOÀN TOÀN. Một nút là nút không xác định nếu từ nút đó ta chỉ đến
được các nút tiềm năng”. Như vậy với mỗi nút trên cây Trie, ngoài mảng next, ta có
thêm 2 biến boolean là win(W), lose(L). Nếu (W== True && L== False) thì nút là thắng
HOÀN TOÀN, nếu (W==F && L== T) thì nút là thua HOÀN TOÀN, nếu (W== T && L== F)
thì nút là tiềm năng, đặc điểm của nút không xác định ta sẽ xét phía dưới ( W= win, L=
lose, T= true, F= false)
Như vậy, từ những định nghĩa ban đầu như trên ta thấy rằng, mọi nút lá của Trie đều
là nút thua HOÀN TOÀN. Bây giờ ta cần tiến hành xây dựng trạng thái cho tất cả các
nút. Ở đây ta sử dụng quy hoạch động trên cây như sau: Nếu nút u có thuộc tính (W==
F) thì nút cha trực tiếp của u phải có thuộc tính (W== T). Nếu nút u có thuộc tính (L==F)
thì nút cha trực tiếp của u phải có thuộc tính (L==T). Lúc đầu các nút đều có trạng thái
là (W== F && L== F) , trừ nút lá có (W==F && L==T). Như đã nói ở phần trên, nút
không xác định là nút mà chỉ đi đến được các nút tiềm năng, theo cách qhđ của ta như
trên thì nút không xác định sẽ có thuộc tính là (W==F && L== F), và cũng theo cách
qhđ trên thì cha trực tiếp của nút không xác định chính là nút tiềm năng.
Sau khi thực hiện xong quy hoạch động ta sẽ tìm kết quả của bài toán:
- Nếu nút 1 là nút thua HOÀN TOÀN thì kết quả rõ ràng là người thứ 2 luôn
thắng (vì người thua lại phải đi trước ở ván sau)
- Nếu nút 1 là nút thắng HOÀN TOÀN thì nếu số ván là lẻ thì người 1 sẽ thắng,
ngược lại người 2 sẽ thắng
- Nếu nút 1 là nút tiềm năng thì người 1 luôn luôn thắng, vì nút 1 là tiềm năng tức
là nó có thể đến được nút thua hoàn toàn hoặc thắng hoàn toàn nên chiến thuật
của người 1 sẽ là:
k-1 ván đầu tiên đi vào nút thắng HOÀN TOÀN để người 2 thắng ván đó, đến
ván thứ k thì người 1 sẽ đi vào nút thua HOÀN TOÀN và như vậy người 2 sẽ
thua ván cuối cùng. Còn một trường hợp nữa là trong các con của nút 1 có nút
không xác định, thì người 1 chỉ cần đi vào nút không xác định này, đến bước
tiếp theo người 2 sẽ buộc phải đi vào 1 nút tiềm năng và người 1 lại áp dụng
chiến thuật như trên (do ta vừa nói ở trên, nút không xác định chỉ đến được các
nút tiềm năng)
- Nếu nút 1 là nút không xác định thì người 2 luôn thắng, bởi lẽ từ nút không xác
định chỉ có thể đi đến được các nút tiềm năng, và cũng như ở trường hợp trên,
lần này người thứ 2 sẽ thắng.
Nhận xét: Đây là một bài tập khó đối với các bạn mới lần đầu tiếp xúc với bài toán trò
chơi trong tin học. Vậy kinh nghiệm để giải các bài toán dạng này đó chính là sử dụng
thuật toán quy hoạch động trên tập hợp các vị trí thắng và thua, từ đó tìm ra kết quả
bài toán

Bài 5. Chuỗi từ có độ dài n là một dãy các từ w1, w2, ..., wn sao cho với mọi 1 ≤i < n,
từ wi là tiền tố của từ wi+1. Nhắc lại từ u có độ dài k là tiền tố của từ v có độ dài l nếu l
> k và các ký tự đầu tiên của v trùng với từ u. Cho tập hợp các từ S = {s1, s2, ..., sm}.
Tìm chuỗi từ dài nhất có thể xây dựng được bằng cách dùng các từ trong tập hợp S (có
thể không sử dụng hết các từ).
Input
Dòng đầu tiên chứa số nguyên m (1 ≤m ≤250000). Mỗi dòng trong số m dòng
sau chứa một từ trong tập S. Biết rằng mỗi từ có độ dài không quá 250000 ký tự và
tổng độ dài của các từ không vượt quá 250000 ký tự.
Output
In ra một số duy nhất là độ dài của chuỗi từ dài nhất xây dựng được từ các từ
trong tập đã cho.
Example:

Input Output
3 3
a
ab
abc
5 2
a
ab
bc
bcd
add

1.1 Bài toán Phone list


Cho một danh sách các số điện thoại, hãy xác định xem danh sách này có số điện thoại
nào là phần trước (tiền tố) của số khác hay không?Nếu không thì danh sách này được gọi
là nhất quán. Giả sử một danh sách có chứa các số điện thoại sau:
-          Số khẩn cấp:  911
-          Số của Alice: 97625999
-          Số của Bob:   91125426
Trong trường hợp này, ta không thể gọi cho Bob vì tổng đài sẽ kết nối với đường dây
khẩn cấp ngay khi bạn quay 3 số đầu trong số của Bob, vì vậy danh sách này là không
nhất quán.
Dữ liệu vào
Dòng đầu tiên chứa một số nguyên 1 ≤ t ≤ 40 là số lượng bộ test. Mỗi bộ test sẽ bắt đầu
với số lượng số điện thoại n được ghi trên một dòng, 1 ≤ n ≤ 10000. Sau đó là n dòng,
mỗi dòng ghi duy nhất 1 số điện thoại.Một số điện thoại là một dãy không quá 10 chữ số.
Dữ liệu ra
Với mỗi bộ dữ liệu vào, in ra “YES” nếu danh sách nhất quán và “NO” trong trường hợp
ngược lại.
Ví dụ
Dữ liệu Kết quả
2 NO
3 YES
911
97625999
91125426
5
113
12340
123440
12345
98346

Thuật toán:
Để giải bài toán này, chúng ta phải kiểm tra xem có bất kỳ từ nào là tiền tố của một từ
khác hay không? Có hàng trăm cách để giải quyết vấn đề này nhưng ở đây ta đi theo
hướng sử dụng cấu trúc dữ liệu cây tiền tố.
Ta lần lượt chèn các số điện thoại trong danh sách vào cây đồng thời thực hiện kiểm tra số
điện thoại đang chèn có là tiền tố của một số điện thoại nào đó trong cây hay không và
ngược lại. Dễ dàng nhận thấy có 2 trường hợp xảy ra:
Trường hợp 1: Từ đang chèn là tiền tố của một từ nào đó trong cây
Ví dụ
918123
918
Như đã trình bày ở trên, các từ sẽ lần lượt được chèn vào cây do đó sẽ không có vấn đề gì
xảy ra khi ta chèn từ “918123” nhưng trong khi chèn từ “918”các nút 9-1-8 sẽ lần lượt
được duyệtvà khi kết thúc quá trình chèn, ta nhận thấy rằng nút 8 không phải là 1 nút lá.
Điều này có nghĩa là ta vừa chèn vào 1 từ là tiền tố của một từ nào đó trong cây và kết quả
của bài toán sẽ là “NO”
Trường hợp 2: Một từ nào đó trong cây là tiền tố của từ đang chèn
Ví dụ
918
918123
Đây là trường hợp ngược lại của trường hợp 1.Với trường hợp này, sẽ không có vấn đề gì
xảy ra khi chèn từ 918.Tuy nhiên khi chèn từ 918123, ta sẽ lần lượt duyệt qua các nút 9-1-
8 và tại nút 8 ta nhận thấy rằng đây là 1 nút lá và từ đang chèn vẫn chưa được chèn xong.
Điều đó có nghĩa là có một từ nào đó trong cây là tiền tố của từ đang chèn và kết quả của
bài toán là “NO”
Kết quả của bài toán là “YES” cho các trường hợp còn lại.
Quá trình chèn và kiểm tra được thực hiện thông qua thủ tục sau:
Procedure Insert(st:string);
var p:trie;
i:word;
Begin
p:=root;
For i:=1 to length(st) do
begin
if p^.c[st[i]] = nil then add(p^.c[st[i]])
else if i=length(st) then ans:=false;
if p^.c[st[i]]^.isEnd = true then ans:=false;
if ans=true then p:=p^.c[st[i]] else exit;
end;
p^.isEnd:=true;
End;
Chương trình tham khảo:
type trie=^node;
node=record
isEnd:boolean;
c:array['0'..'9'] of trie;
end;
var root:trie;
f,g:text;
t,i:word;
ans:boolean;
st:string;
Procedure Add( var a:trie);
var t:char;
Begin
New(a);
a^.isEnd:=false;
For t:='0' to '9' do
a^.c[t]:=nil;
End;
Procedure Insert(st:string);
var p:trie;
i:word;
Begin
p:=root;
For i:=1 to length(st) do
begin
if p^.c[st[i]] = nil then add(p^.c[st[i]])
else if i=length(st) then ans:=false;
if p^.c[st[i]]^.isEnd = true then ans:=false;
if ans=true then p:=p^.c[st[i]] else exit;
end;
p^.isEnd:=true;
End;
Procedure MakeTrie;
var i,n:word;
Begin
add(root);
readln(f,n);
for i:=1 to n do
Begin
readln(f,st);
if ans = true then insert(st);
End;
End;
Begin
assign(f,'phonelst.inp');
reset(f);
assign(g,'phonelst.out');
rewrite(g);
readln(f,t);
for i:=1 to t do
begin
ans:=true;
MakeTrie;
if ans = true then writeln(g,'YES') else writeln(g,'NO');
end;
close(f);
close(g);
End.

Test case tham khảo:


PhoneLst.inp PhoneLst.out
2 NO
3 YES
911
97625999
91125426
5
113
12340
123440
12345
98346
2 YES
4 NO
1238
988123
812988
123988
5
123988676
988123
8819123
91212356
123
3 YES
4 NO
01238986 YES
123
8986123
0986123
4
7435687123
074356
7435687123
074356
5
647566
4756
7566
36475
566

1.2 Bài toán chuỗi từ


Chuỗi từ có độ dài n là một dãy các từ w1, w2, ..., wn sao cho với mọi 1 ≤ i < n, từ w i là
tiền tố của từ wi+1.
Nhắc lại từ u có độ dài k là tiền tố của từ v có độ dài l nếu l > k và các ký tự đầu tiên của
v trùng với từ u.
Cho tập hợp các từ S={s1, s2, ..., sm}. Tìm chuỗi từ dài nhất có thể xây dựng được bằng
cách dùng các từ trong tập hợp S (có thể không sử dụng hết các từ).
Dữ liệu
Dòng đầu tiên chứa số nguyên m (1 ≤ m ≤ 250000).Mỗi dòng trong số m dòng sau chứa
một từ trong tập S.
Biết rằng mỗi từ có độ dài không quá 250000 ký tự và tổng độ dài của các từ không vượt
quá 250000 ký tự.
Kết quả
In ra một số duy nhất là độ dài của chuỗi từ dài nhất xây dựng được từ các từ trong tập
đã cho.
Ví dụ
Dữ liệu Kết quả
3 3
a
ab
abc
5 2
ab
bc
bcd
add
adr
Thuật toán:
Sử dụng cấu trúc dữ liệu cây tiền tố để lưu trữ các từ trong tập S.
Với cách lưu trữ trên, các từ nằm trên cùng một nhánh của cây tạo thành một chuỗi từ vì
vậy chuỗi từ dài nhất chính là nhánh có nhiều nút là kết thúc của các từ nhất. Do đó ta sử
dụng phương pháp quy hoạch động trên cây để tìm nhánh có nhiều nút là kết thúc của các
từ nhất
Ta định nghĩa kiểu dữ liệu của các nút như sau:
Type trie = ^node;
node = record
f,u:longint;
c:array['a'..'z'] of trie;
End;
Trong đó:
- u là số lượng từ kết thúc tại nút đó
- c là mảng lưu địa chỉ đến con của nút đó
- f là độ dài chuỗi từ dài nhất xuất phát từ nút đó
Gọi a là 1 nút bất kì trên trie, a^.f sẽ được tính theo công thức sau:
a^.f = max (a^.c[t]^.f + a^.u), t = ’a’..’z’
Đây chính là công thức quy hoạch động của bài toán
Dựa trên công thức này ta xây dựng thủ tục DFS để tính độ dài chuỗi từ dài nhất bắt đầu
từ nút a như sau:
Procedure DFS(a:trie);
Var t:char;
Begin
a^.f:=a^.u;
For t:='a' to 'z' do
if a^.c[t] <> nil then
Begin
DFS(a^.c[t]);
a^.f:=Max(a^.f,a^.c[t]^.f+a^.u);
End;
End;
Và bây giờ ta chỉ cần gọi lại thủ tục này với tham số là gốc của cây root, kết quả của bài
toán sẽ là root^.f.
Begin
OpenFile;
MakeTrie;
Dfs(root);
Write(g,root^.f);
CloseFile
End.
Chương trình tham khảo:
type trie=^node;
node=record
f,u:longint;
c:array['a'..'z'] of trie;
end;
var root:trie;
f,g:text;
Procedure OpenFile;
Begin
Assign(f,'Chain2.inp');
reset(f);
Assign(g,'Chain2.out');
rewrite(g);
End;
Procedure CloseFile;
Begin
close(f);
close(g);
End;
Procedure Add( var a:trie);
var t:char;
Begin
New(a);
a^.u:=0; a^.f:=0;
For t:='a' to 'z' do
a^.c[t]:=nil;
End;
Procedure Insert(st:string);
var p:trie;
i:longint;
Begin
p:=root;
For i:=1 to length(st) do
begin
if p^.c[st[i]] = nil then add(p^.c[st[i]]);
p:=p^.c[st[i]];
end;
inc(p^.u);
End;
Procedure MakeTrie;
var i,n:longint;
st:string;
Begin
add(root);
readln(f,n);
For i:=1 to n do
Begin
readln(f,st);
insert(st);
End;
End;
Function Max(a,b:longint):longint;
Begin
if a>b then Max:=a else Max:=b;
End;
Procedure DFS(a:trie);
var t:char;
Begin
a^.f:=a^.u;
For t:='a' to 'z' do
if a^.c[t] <> nil then
Begin
dfs(a^.c[t]);
a^.f:=Max(a^.f,a^.c[t]^.f+a^.u);
End;
End;
Begin
OpenFile;
MakeTrie;
Dfs(root);
Write(g,root^.f);
CloseFile
End.
Test case tham khảo:
Chain2.inp Chain2.out
10 1
abcdigigjfgj
bcegokkf
cebgqteefgf
fgctazv
cvundhfjjj
bcegfdjj
qhfhdgjfjg
fgoqgjgjfgj
sdeghbjjkdd
quryyffjjfj
10 3
ab
bc
bcd
add
ad
acde
aced
edzev
edaddbcd
bcdacde
10 10
abc
abced
abcedfgohhfh
abcedfgohhfhqgh
abcedfgohhfhqghfhfhfh
abcedfgohhfhqghfhfhfhqgfaxv
abcedfgohhfhqghfhfhfhqgfaxvsewq
abcedfgohhfhqghfhfhfhqgfaxvsewqohfgfg
abcedfgohhfhqghfhfhfhqgfaxvsewqohfgfgutitto
abcedfgohhfhqghfhfhfhqgfaxvsewqohfgfgutittooruru

1.3 Bài toán tách từ


Một từ cần được tách thành các đoạn con sao cho mỗi đoạn con thuộc một tập các từ cho
trước.
Viết chương trình xác định số cách tách một từ cho trước.
Do kết quả có thể có giá trị lớn, chỉ cần in ra phần dư của kết quả cho 1337377.
Dữ liệu
 Dòng đầu tiên chứa một từ với tối đa 300000 ký tự.
 Dòng thứ hai chứa số nguyên N, 1 ≤ N ≤ 4000.
 Mỗi dòng trong số N dòng tiếp theo chứa một từ trong tập các từ. Mỗi từ có độ dài
không quá 100 ký tự. Không có hai từ nào giống nhau. Tất cả các ký tự đều là chữ
cái Latin in thường.
Kết quả
In ra một số nguyên duy nhất là phần dư của số cách tách từ khi chia cho 1337377.
Ví dụ
Dữ liệu Kết quả
abcd 2
4
a
b
cd
ab
afrikapaprika 1
4
afr
ika
pap
r
ababababababababababababababababababababab 759775
3
a
b
ab

Thuật toán:
Sử dụng cấu trúc dữ liệu cây tiền tố để lưu trữ các từ với cấu trúc mỗi nút như sau:
type trie = ^node;
node = record
u:longint;
c:array['a'..'z'] of trie;
End;

Sử dụng phương pháp quy hoạch động để tìm ra lời giải bài toán
Gọi f[i] là số cách tách đoạn từ 1 đến i của S.
Như vậy, f[i]=∑f[j] với mỗi j thoả mãn đoạn từ j+1..ilà một từ thuộc tập từ đã cho. Ta lần
lượt tính f[i] với i chạy từ 1 đến n. Với mỗi i, để kiểm tra mọi đoạn j+1..i có là một từ cho
trước không, chú ý là khi giảm j, các từ này có độ dài tăng lên, và từ trước là hậu tố của
từ sau, các từ có độ dài hơn kém nhau một đơn vị. Do đó, trên cây cây, ta có thể đi từ gốc
xuống các nút thể hiện các xâu này, nếu không đi được nữa, tức là không có từ nào thoả
mãn. Chú ý là khi thêm các xâu của tập đã cho, ta cần thêm các xâu này theo chiều ngược
(hoặc một cách xử lý khác là ta tính hàm f từ n đến 1).
Để tính mảng F ta xây dựng thủ tục DFS như sau:

Procedure DFS;
Begin
F[0]:=1;
For i:=1 to length(st) do
Begin
j:=i;
p:=root;
While (j>0) and (p^.c[st[j]]<>nil) do
Begin
p:=p^.c[st[j]];
dec(j);
if p^.u=1 then inc(F[i],F[j]);
End;
End;
End;
Bây giờ ta chỉ cần gọi thủ này để tính mảng F và đưa ra kết quả của bài toán
F[Length(st)]
Chương trình tham khảo:
type trie=^node;
node=record
u:longint;
c:array['a'..'z'] of trie;
end;
var root:trie;
f1,f2:text;
st:string;
i,j:longint;
p:trie;
F:array[0..300000] of longint;
Procedure OpenFile;
Begin
Assign(f1,'nksev.inp');
reset(f1);
Assign(f2,'nksev.out');
rewrite(f2);
End;
Procedure CloseFile;
Begin
close(f1);
close(f2);
End;
Procedure Add( var a:trie);
var t:char;
Begin
New(a);
a^.u:=0;
For t:='a' to 'z' do
a^.c[t]:=nil;
End;
Procedure Insert(st:string);
var p:trie;
i:longint;
Begin
p:=root;
For i:=length(st) downto 1 do
begin
if p^.c[st[i]] = nil then add(p^.c[st[i]]);
p:=p^.c[st[i]];
end;
inc(p^.u);
End;
Procedure MakeTrie;
var i,n:word;
st:string;
Begin
add(root);
readln(f1,n);
for i:=1 to n do
Begin
readln(f1,st);
insert(st);
End;
End;
Procedure DFS;

Begin
F[0]:=1;
For i:=1 to length(st) do
Begin
j:=i;
p:=root;
While (j>0) and (p^.c[st[j]]<>nil) do
Begin
p:=p^.c[st[j]];
dec(j);
if p^.u=1 then inc(F[i],F[j]);
End;
End;
End;
Begin
OpenFile;
Readln(f1,st);
MakeTrie;
Dfs;
write(f2,F[length(st)] mod 1337377);
CloseFile
End.
Test case tham khảo:
Nksev.inp Nksev.out
abcd 2
4
a
b
cd
ab
afrikapaprika 1
4
afr
ika
pap
r
ababababababababababababababababababababab 759775
3
a
b
ab

1.4 Bài toán SEC


Bessie định dẫn đàn bò đi trốn.Để đảm bảo bí mật, đàn bò liên lạc với nhau bằng cách tin
nhắn nhị phân.
Từng là một nhân viên phản gián thông minh, John đã thu được M (1 <= M <= 50,000)
tin nhắn mật, tuy nhiên với tin nhắn i John chỉ thu được b_i (1 <= b_i <= 10,000) bit đầu
tiên.
John đã biên soạn ra 1 danh sách N (1 <= N <= 50,000) các từ mã hóa mà đàn bò có khả
năng đang sử dụng. Thật không may, John chỉ biết được c_j (1 <= c_j <= 10,000) bit đầu
tiên của từ mã hóa thứ j.
Với mỗi từ mã hóa j, John muốn biết số lượng tin nhắn mà John thu được có khả năng là
từ mã hóa j này. Tức là với từ mã hóa j, có bao nhiêu tin nhắn thu được có phần đầu
giống với từ mã hóa j này. Việc của bạn là phải tính số lượng này.
Tổng số lượng các bit trong dữ liệu đầu vào (tổng các b_i và c_j) không quá 500,000.
Dữ liệu
 Dòng 1: 2 số nguyên: M và N
 Dòng 2..M+1: Dòng i+1 mô tả tin nhắn thứ i thu được, đầu tiên là b_i sau đó là b_i
bit cách nhau bởi dấu cách, các bit có giá trị 0 hoặc 1.
 Dòng M+2..M+N+1: Dòng M+j+1 mô tả từ mã hóa thứ j, đầu tiên là c_j sau đó là
c_j bit cách nhau bởi dấu cách.
Kết quả
 Dòng 1..M: Dòng j: Số lượng tin nhắn mà có khả năng là từ mã hóa thứ j
Ví dụ
Dữ liệu Kết quả
45 1
3010 3
11 1
3100 1
3110 2
10
11
201
501001
211

Giải thích
Có 4 tin nhắn và 5 từ mã hóa. Các tin nhắn thu được có phần đầu là 010, 1, 100 và 110.
Các từ mã hóa có phần đầu là 0, 1, 01, 01001, và 11.
0 chỉ có khả năng là 010 -> 1 tin nhắn. 1 chỉ có khả năng là 1, 100, hoặc 110 -> 3 tin nhắn.
01 chỉ có thể là 010 -> 1 tin nhắn. 01001 chỉ có thể là 010 -> 1 tin nhắn. 11 chỉ có thể là 1
hoặc 110 -> 2 tin nhắn.
Thuật toán:
Sử dụng cấu trúc dữ liệucây tiền tố để lưu các tin nhắn với cấu trúc của mỗi nút trên trie
như sau:
type trie=^node;
node=record
u,f:longint;
c:array[0..1] of trie;
end;
Trong đó:
- u: Số xâu đi qua nút đó
- f: Số xâu kết thúc tại nút đó
- c là mảng lưu địa chỉ đến con của nút đó
Với mỗi từ mã hóa thu được xta sẽ duyệt trên cây để đếm số xâu cần tìm. Với mỗi nút ta
cộng số lượng xâu tận cùng tại vị trí này (a->f) (tất là các xâu tiền tố của x), sau đó khi
duyệt xong (1 là hết xâu, 2 là không tìm được nữa trên trie), ta kiểm tra xem nếu nút sau
cùng tồn tại thì ta cộng số lượng xâu qua nút này (a->u) (điều này đảm bảo những xâu
khi cộng không trùng với những xâu đã cộng phía trên, tất là ta đang cộng những xâu mà
x là tiền tố của chúng). Từ đó ta có kết qủa cần tìm
Chương trình tham khảo:
type trie=^node;
node=record
u,f:longint;
c:array[0..1] of trie;
end;
var root:trie;
f,g:text;
m,n,i,k,j,count:longint;
t:byte;
p:trie;
Procedure OpenFile;
Begin
Assign(f,'sec.inp');
reset(f);
Assign(g,'sec.out');
rewrite(g);
End;
Procedure CloseFile;
Begin
close(f);
close(g);
End;
Procedure Add( var a:trie);
var t:byte;
Begin
New(a);
a^.u:=0;
a^.f:=0;
For t:=0 to 1 do
a^.c[t]:=nil;
End;
Procedure InitTrie;
Begin
add(root);
readln(f,m,n);
for i:=1 to m do
Begin
p:=root;
Read(f,k);
For j:=1 to k do
begin
Read(f,t);
if p^.c[t] = nil then add(p^.c[t]);
inc(p^.u);
p:=p^.c[t];
End;
inc(p^.f);
Readln(f);
End;
End;
Begin
OpenFile;
InitTrie;
For i:=1 to n do
Begin
Read(f,k);
count:=0;
p:=root;
For j:=1 to k do
Begin
Read(f,t);
if p^.c[t] <> nil then
Begin
count:=count+p^.c[t]^.f;
p:=p^.c[t];
End
Else Break;
End;
if p<>nil then count:=count+p^.u;
Writeln(g,count);
Readln(f);
End;
CloseFile;
End.
Test case tham khảo:
Sec.inp Sec.out
23 1
3010 1
6100010 1
201
210
3010
45 1
201 1
210 3
3001 2
10 2
11
210
10
6001100
60110001
45 1
3010 3
11 1
3100 1
3110 2
10
11
201
501001
211

1. Bài toán 1: Tìm kiếm nhiều mẫu


1.1. Đề bài (Nguồn: Truyền thống)

Cho xâu t và tập các xâu mẫu s1, s2, …, sn. Hãy đếm số lần xuất hiện của các xâu mẫu
trong xâu t.

Dữ liệu: Dòng đầu tiên chứa xâu t có độ dài không vượt quá 5×10 5. Dòng thứ hai ghi số
nguyên n (1 ≤ n ≤ 100). Dòng thứ i trong n dòng tiếp theo chứa xâu si có độ dài không
vượt quá 15. Các xâu si chỉ chứa các chữ cái Latin thường ‘a’..‘z’ và hoa ‘A’..‘Z’, các
chữ số ‘0’..’9’ và các ký hiệu “!?.,:;-_’#$%&/=*+(){}[]”. Xâu t cũng chứa các
ký tự đó và thêm ký tự dấu cách. Chú ý rằng có thể có một số xâu mẫu giống nhau và mỗi
vị trí nó xuất hiện trong xâu t ta chỉ đếm 1 lần.

Kết quả: Ghi ra số lần xuất hiện của các xâu mẫu s1, s2, …, sn trong xâu t.

Ví dụ:
find_patterns.inp find_patterns.out
shers 3
5
he
she
his
he
hers

Các xâu mẫu xuất hiện 3 lần trong xâu t là: shers, shers, shers.

1.2. Thuật toán

Chúng ta đã biết một số thuật toán tìm kiếm một mẫu trong một xâu như thuật toán
Rabin-Karp (RK), thuật toán Knuth-Moris-Pratt (KMP). Trong bài này, chúng ta sẽ xét
thuật toán tìm kiếm nhiều mẫu trong một xâu.

Rõ ràng một phương pháp đơn giản để giải bài toán trên là ta áp dụng thuật toán Rabin-
Karp hoặc thuật toán Knuth-Moris-Pratt với lần lượt từng xâu mẫu. Thuật toán theo cách
này có độ phức tạp là O(n×m), ở đó n là số xâu mẫu và m là độ dài của xâu t.
Một thuật toán hiệu quả để giải quyết bài toán tìm kiếm nhiều mẫu được phát minh bởi
Alfred V. Aho và Margaret J. Corasick và gọi là thuật toán tìm kiếm nhiều mẫu Aho-
Corasick, được mô tả như sau.

Đầu tiên chúng ta tạo một trie biểu diễn tập các xâu mẫu:
 Mỗi cạnh của trie có nhãn là một ký tự.
 Hai cạnh đi ra từ một nút có nhãn khác nhau.
 Nhãn của một nút v là ghép các nhãn của các cạnh trên đường đi từ nút gốc tới nút
v và kí hiệu là L(v). Khi đó với mỗi xâu mẫu si, tồn tại một nút v với L(v) = si.
 Nhãn L(v) của một nút lá v bất kỳ là bằng một xâu mẫu si nào đó.

Ví dụ cây trie với các xâu mẫu là “he”, “she”, “his”, “hers” như sau:

“he”
h e r s
0 1 2 8 9 “hers”

i
s
s 6 7 “his”

h e
3 4 5 “she”

Đoạn chương trình dưới đây cài đặt việc tạo trie. Giả thiết cho thấy tổng độ dài của các
xâu mẫu không lớn hơn 15×100=1500 nên ta có các khai báo biến và cài đặt như sau.

int n, to[1501][128], link[1501], word[1501], sz = 0;


bool leaf[1501];

void init_trie() {
memset(to, 0, sizeof(to));
memset(leaf, false, sizeof(leaf));
memset(link, 0, sizeof(link));
memset(word, 0, sizeof(word));
}

void add_str(string s, int id) {


int u = 0;
for (int i = 0; i < s.size(); i++) {
int j = s[i];
if (to[u][s[i]] == 0) to[u][j] = ++sz;
u = to[u][j];
}
leaf[u] = true; // nut u nay la diem cuoi cua mot
mau
word[u] = id; // ghi nhan chi so cua mau
}
Bây giờ giả sử ta cần tìm kiếm các mẫu trong xâu “shers”. Ta bắt đầu từ nút 0 và đi theo
cạnh ‘s’ tới nút 3, tiếp theo từ nút 3 đi theo cạnh ‘h’ tới nút 4, rồi từ nút 4 đi theo cạnh ‘e’
tới nút 5. Vì nút 5 là nút lá nên ta ghi nhận có một vị trí xuất hiện mẫu (chú ý đây là vị trí
cuối của mẫu xuất hiện). Nhưng từ nút 5 lại không có cạnh ‘r’ đi ra, vì vậy ta cần phải
quay lui lại phía trước xem có mẫu nào khác xuất hiện không. Việc quay lui lại là không
phải từ đầu mà chỉ quay lui lại nút có nhãn là phần hậu tố dài nhất của nút 5, tức là nút 2.
Nhưng nút 2 lại là nút lá nên ta lại ghi nhận thêm một vị trí nữa mẫu xuất hiện. Sau đó từ
nút 2 ta lại đi theo cạnh ‘r’ tới nút 8, rồi từ nút 8 đi theo cạnh ‘s’ tới nút 9. Nút 9 là nút lá
nên ta lại ghi nhận thêm một vị trí nữa mẫu xuất hiện. Đến đây ta dừng việc tìm kiếm vì
đã xét đến cuối xâu.

Như vậy với mỗi nút u ta cần biết nút v sao cho nhãn của nút v là phần hậu tố dài nhất của
nhãn nút u và ta đặt link[u] = v. Các đường nét đứt trong trie dưới đây là link của mỗi nút.

“he”
h e r s
0 1 2 8 9 “hers”

i
s
s 6 7 “his”

h e
3 4 5 “she”

Thủ tục push_link dưới đây sẽ xác định link cho mỗi nút của trie:

void push_link() {
queue<int> q;
q.push(0);
while (!q.empty()) {
int u = q.front(); q.pop();
int v = link[u];
leaf[u] |= leaf[v];
for (int i = 0; i < 128; i++)
if (to[u][i] != 0) {
link[to[u][i]] = ((u != 0) ? to[v][i] : 0);
q.push(to[u][i]);
}
else
to[u][i] = to[v][i];
}
}

Hàm search_str sau thể hiện việc tìm kiếm các mẫu và trả lại số mẫu xuất hiện trong xâu.

int search_str(string s) {
int cnt = 0, u = 0;
for (int i = 0; i < s.size(); i++) {
u = to[u][s[i]];
int v = u;
while (leaf[v]) {
if (word[v] != 0) cnt++;
v = link[v];
}
}
return cnt;
}
Thuật toán Aho-Corasick có độ phức tạp thời gian là O(z+n+m), ở đó z là tổng độ dài các
xâu mẫu, n là số mẫu và m là độ dài xâu.

1.3. Chương trình

#include <bits/stdc++.h>
using namespace std;

int n, to[1501][128], link[1501], word[1501], sz = 0;


bool leaf[1501];
string t;

void init_trie() {
memset(to, 0, sizeof(to));
memset(leaf, false, sizeof(leaf));
memset(link, 0, sizeof(link));
memset(word, 0, sizeof(word));
}

void add_str(string s, int id) {


int u = 0;
for (int i = 0; i < s.size(); i++) {
int j = s[i];
if (to[u][s[i]] == 0) to[u][j] = ++sz;
u = to[u][j];
}
leaf[u] = true;
word[u] = id;
}

void push_link() {
queue<int> q;
q.push(0);
while (!q.empty()) {
int u = q.front(); q.pop();
int v = link[u];
leaf[u] |= leaf[v];
for (int i = 0; i < 128; i++)
if (to[u][i] != 0) {
link[to[u][i]] = ((u != 0) ? to[v][i] : 0);
q.push(to[u][i]);
}
else
to[u][i] = to[v][i];
}
}
int search_str(string s) {
int cnt = 0, u = 0;
for (int i = 0; i < s.size(); i++) {
u = to[u][s[i]];
int v = u;
while (leaf[v]) {
if (word[v] != 0) cnt++;
v = link[v];
}
}
return cnt;
}

int main () {
freopen("find_patterns.inp", "r", stdin);
freopen("find_patterns.out", "w", stdout);
init_trie();
getline(cin, t);
scanf("%d\n", &n);
for (int i = 1; i <= n; i++) {
char s[15];
scanf("%s\n", &s);
add_str(s, i);
}
push_link();
printf("%d\n", search_str(t));
return 0;
}

1.4. Test

Đường dẫn tải test cho bài toán:


https://drive.google.com/file/d/0B-NTUSa9_289MUJBOHBYOU1yem8/view?
usp=sharing

1.5. Cảm nhận


Bài toán tìm kiếm nhiều mẫu là bài toán hết sức kinh điển. Rõ ràng là có nhiều thuật toán
cùng giải quyết bài toán với độ phức tạp khác nhau. Thuật toán Aho-Corasick đã sử dụng
cấu trúc cây tiền tố (trie) để giải quyết với độ phức tạp tuyến tính.

Thuật toán tìm kiếm nhiều mẫu được áp dụng rất nhiều trong thực tế. Chẳng hạn ta có
một danh sách các mẫu virus máy tính và ta cần xem mỗi tệp nào bị nhiễm các virus nào.
Hay là trong bài toán sinh học, ta cần xem các gen nào có trong DNA, …

2. Bài toán 2: Tách từ

2.1. Đề bài (Nguồn: Croatia 2006 / Final Exam #1)

Một xâu cần được chia thành các đoạn con mà mỗi đoạn con thuộc một tập các từ cho
trước.

Bạn hãy viết chương trình tính số cách khác nhau để chia một xâu cho trước. Vì số cách
chia có thể rất lớn nên bạn chỉ cần đưa số dư của nó khi chia cho 1337377.

Dữ liệu: Dòng đầu tiên chứa một xâu với độ dài tối đa 300.000 ký tự. Dòng thứ hai chứa
một số nguyên N (1 ≤ N ≤ 4000). Mỗi dòng trong N dòng tiếp theo chứa một từ trong tập.
Mỗi từ gồm nhiều nhất 100 ký tự. Không có hai từ nào giống nhau và tất cả các ký tự là
các chữ cái tiếng Anh in thường.

Kết quả: Chứa một số nguyên là số cách chia xâu thành các từ theo modun 1337377.

Ví dụ:
tach_tu.inp tach_tu.out
abcd 2
4
a
b
cd
ab
afrikapaprika 1
4
afr
ika
pap
r
ababababababababababababababababababababab 759775
3
a
b
ab

2.2. Thuật toán

Gọi t là xâu cần đếm số các tách thành các từ và các từ là s1, s2, …, sN. Giả thiết chỉ số các
ký tự của xâu được đánh chỉ số bắt đầu từ 0. Gọi cnt[i] là số cách tách xâu t[0..i] thành
các từ. Ta sẽ tính cnt[i] như sau. Xét các từ s1, s2, …, sN, nếu từ thứ j là hậu tố của xâu
t[0..i] thì ta công thêm vào cnt[i] một lượng là cnt[i-s[j].size()], tức là:
N
cnt[i]   cnt  i  s[j ].size() 
j 1

nếu xâu sj là một hậu tố của xâu t[0..i].

Bây giờ vấn đề là kiểm tra xem từ nào là hậu tố của xâu t[0..i]. Để làm điều này, ta xây
dựng một trie cho các từ và tìm kiếm xâu t trên trie. Khi xét đến kí tự thứ i của xâu t, ta
xem nút tương ứng với ký tự này trên trie có là nút lá hay không. Nếu nó là nút lá, tức là
có một từ xuất hiện ở phần hậu tố của xâu t[0..i] và khi đó ta tính cnt[i] theo công thức
trên.

Câu trả lời của bài toán là cnt[t.size()-1].

Độ phức tạp của thuật toán là O(N×100 + N + t.size()).

2.3. Chương trình

#include <bits/stdc++.h>
using namespace std;

const int MOD = 1337377;


string t;
int n, m, len[4001], to[400001][26], link[400001],
word[400001], sz = 0, cnt[300001];
bool leaf[400001];
void init_trie() {
memset(to, 0, sizeof(to));
memset(leaf, false, sizeof(leaf));
memset(link, 0, sizeof(link));
memset(word, 0, sizeof(word));
}

void add_str(string s, int id) {


int u = 0;
for (int i = 0; i < s.size(); i++) {
int j = s[i]-'a';
if (to[u][j] == 0) to[u][j] = ++sz;
u = to[u][j];
}
leaf[u] = true;
word[u] = id;
}

void push_link() {
queue<int> q;
q.push(0);
while (!q.empty()) {
int u = q.front(); q.pop();
int v = link[u];
leaf[u] |= leaf[v];
for (int i = 0; i < 26; i++)
if (to[u][i] != 0) {
link[to[u][i]] = ((u != 0) ? to[v][i] : 0);
q.push(to[u][i]);
}
else
to[u][i] = to[v][i];
}
}

int search_str(string s) {
int u = 0;
for (int i = 0; i < s.size(); i++) {
u = to[u][s[i]-'a'];
int v = u;
cnt[i] = 0;
while (leaf[v]) {
if (word[v] != 0)
if (i-len[word[v]] == -1)
cnt[i] = (cnt[i] + 1) % MOD;
else
cnt[i] = (cnt[i] + cnt[i-len[word[v]]])
% MOD;
v = link[v];
}
}
}

int main () {
freopen("tach_tu.inp", "r", stdin);
freopen("tach_tu.out", "w", stdout);
char s[300001];
scanf("%s\n%d\n", &s, &n);
t = s;
init_trie();
for (int i = 1; i <= n; i++) {
scanf("%s\n", &s);
add_str(s, i);
len[i] = strlen(s);
}
push_link();
search_str(t);
cout << cnt[t.size()-1] << "\n";
return 0;
}

2.4. Test

Đường dẫn tải test cho bài toán:


https://drive.google.com/file/d/0B-NTUSa9_289MEtTempvUTlKYTg/view?usp=sharing
2.5. Cảm nhận

Cấu trúc dữ liệu cây tiền tố được dùng trong các bài xử lý xâu, đặc biệt là việc tìm nhanh
xem một tập các xâu có xuất hiện trong một xâu cho trước và ở các vị trí nào. Bài toán
trên là ứng dụng của cấu trúc cây tiền tố để đếm số cách tạo một xâu từ các xâu mẫu.

3. Bài toán 3: Mật khẩu

3.1. Đề bài (Nguồn: Sưu tầm)

Người quản trị đang cố gắng xây dựng hệ thống bảo vệ dữ liệu cho công ty. Họ biên soạn
một cuốn từ điển gồm M (1 ≤ M ≤ 300) từ khác nhau, mỗi từ chỉ chứa các chữ cái Latin
in thường và có độ dài không vượt quá 300. Mật khẩu được tạo ra như bằng cách móc nối
các từ trong từ điển, một từ có thể xuất hiện nhiều lần. Để giữ bí mật, người dùng có thể
thêm các chữ cái in thường vào trước, sau hoặc giữa các từ trong từ điển, tạo thành mật
khẩu P là từ có chiều dài N (5 ≤ N ≤ 300.000).

Bạn hãy viết chương trình đọc vào từ P có chiều dài N, xác định số lượng tối thiểu các
chữ cái cần loại bỏ khỏi P để có được một từ mới là từ ghép của một số từ trong từ điển.

Ví dụ với từ “afbachtdspya”, cần loại bỏ các chữ cái ở vị trí in đậm để nhận được từ
“abacha” là ghép của các từ trong từ điển: “a”, “bach”, “a”.

Dữ liệu: Dòng đầu tiên ghi hai số nguyên N và M. Dòng thứ hai ghi từ P. Mỗi dòng trong
số M dòng tiếp theo sẽ ghi một từ trong từ điển.

Kết quả: Ghi ra số lượng chữ cái tối thiểu cần xóa.

Ví dụ:
mat_khau.inp mat_khau.out
12 5 6
afbachtdspya
aba
a
bach
dsy
zero
33 5 7
throughthestormwereachtheshoreyou
rough
the
storm
reach
shore

3.2. Thuật toán

Bài toán này về cơ bản là giống bài toán “tách từ” ở trên. Ở đây ta gọi cnt[i] là số lượng
tối thiểu các chữ cái cần loại bỏ khỏi xâu P[0..i] để có được một từ mới là từ ghép của
một số từ trong từ điển. Vì vậy nếu xâu sj là một hậu tố của xâu P[0..i] thì ta có:
cnt[i] = min(cnt[i - s[j].size()).

Câu trả lời của bài toán là cnt[N-1].

3.3. Chương trình

#include <bits/stdc++.h>
using namespace std;

int n, m, len[301], to[90001][26], link[90001], word[90001],


sz = 0, cnt[300001];
bool leaf[90001];
string p;

void init_trie() {
memset(to, 0, sizeof(to));
memset(leaf, false, sizeof(leaf));
memset(link, 0, sizeof(link));
memset(word, 0, sizeof(word));
}

void add_str(string s, int id) {


int u = 0;
for (int i = 0; i < s.size(); i++) {
int j = s[i]-'a';
if (to[u][j] == 0) to[u][j] = ++sz;
u = to[u][j];
}
leaf[u] = true;
word[u] = id;
}

void push_link() {
queue<int> q;
q.push(0);
while (!q.empty()) {
int u = q.front(); q.pop();
int v = link[u];
leaf[u] |= leaf[v];
for (int i = 0; i < 26; i++)
if (to[u][i] != 0) {
link[to[u][i]] = ((u != 0) ? to[v][i] : 0);
q.push(to[u][i]);
}
else
to[u][i] = to[v][i];
}
}

int search_str(string s) {
int u = 0;
for (int i = 0; i < s.size(); i++) {
u = to[u][s[i]-'a'];
int v = u;
cnt[i] = ((i == 0) ? 1 : cnt[i-1]+1);
while (leaf[v]) {
if (word[v] != 0)
cnt[i] = min(cnt[i], cnt[i-len[word[v]]]);
v = link[v];
}
}
}
int main () {
freopen("mat_khau.inp", "r", stdin);
freopen("mat_khau.out", "w", stdout);
scanf("%d%d\n", &n, &m);
char s[300001];
scanf("%s\n", &s);
p = s;
init_trie();
for (int i = 1; i <= m; i++) {
scanf("%s\n", &s);
add_str(s, i);
len[i] = string(s).size();
}
push_link();
search_str(p);
printf("%d\n", cnt[n-1]);
return 0;
}

3.4. Test

Đường dẫn tải test cho bài toán:


https://drive.google.com/file/d/0B-NTUSa9_289T3gtZldiQ2dUT0E/view?usp=sharing

3.5. Cảm nhận

Bài toán trên là ứng dụng của thuật toán quy hoạch động với cấu trúc dữ liệu cây tiền tố.

4. Bài toán 4: Biểu tượng cảm xúc

4.1. Đề bài (Nguồn: The 2007 ACM South American Programming Contest)

Biểu tượng cảm xúc được sử dụng trong chat và e-mail để thể hiện những cảm xúc mà
các từ rất khó diễn tả. Có rất nhiều biểu tượng cảm xúc đẹp, nhưng cũng có một số biểu
tượng cảm xúc xấu khiến một số người cảm thấy thực sự khó chịu.
George là một trong số những người như vậy. Anh ta không thích các biểu tượng cảm
xúc xấu, vì vậy anh ta đang chuẩn bị một kế hoạch để loại bỏ tất cả các biểu tượng cảm
xúc xấu trong tất cả các e-mail của mình. Bạn hãy giúp anh ta làm việc này.

Chương trình của bạn được cho trước danh sách các biểu tượng cảm xúc xấu. Mỗi biểu
tượng cảm xúc là một xâu ký tự không chứa ký tự dấu cách. Bạn cũng được cho trước các
dòng của văn bản. Bạn cần thay đổi tối thiểu một số ký tự của văn bản thành dấu cách sao
cho văn bản không còn biểu tượng cảm xúc xấu nào. Một biểu tượng cảm xúc được xem
xuất hiện trong văn bản nếu nó xuất hiện trên một dòng và được tạo thành bằng ký tự liên
tiếp.

Dữ liệu: Dòng đầu tiên chứa số hai số nguyên N và M (1 ≤ N, M ≤ 100) tương ứng là số
biểu tượng cảm xúc xấu và số dòng trong văn bản. Mỗi dòng trong số N dòng tiếp theo
chứa một xâu không rỗng (tối đa 15 ký tự) mô tả một biểu tượng cảm xúc xấu. Mỗi dòng
trong số M dòng tiếp theo chứa một dòng của văn bản (tối đa 80 ký tự). Các ký tự hợp lệ
với biểu tượng cảm xúc là các chữ cái tiếng Anh in thường và hoa, các chữ số và các ký
hiệu “!?.,:;-_’#$%&/=*+(){}[]”. Mỗi dòng của văn bản cũng chứa các ký tự
như trong biểu tượng cảm xúc và thêm ký tự dấu cách.

Kết quả: Ghi ra một số nguyên là số ký tự tối thiểu trong văn bản thay đổi thành dấu cách
sao cho văn bản không còn biểu tượng cảm xúc xấu nào.

Ví dụ:
emoticons.inp emoticons.out
4 6 11
:-)
:-(
(-:
)-:
Hello uncle John! :-) :-D
I am sad or happy? (-:-(?
I feel so happy, my head spins
(-:-)(-:-)(-:-)(-:-) :-) (-: :-)
but then sadness comes :-(
Loves you, Joanna :-)))))
3 1 8
:)
):
))
:):)):)):)):(:((:(((:):)

4.2. Thuật toán

Duyệt các mẫu xuất hiện trong văn bản theo vị trí cuối của mẫu xuất hiện theo thứ tự
tăng. Ta sẽ thay ký tự cuối cùng của mẫu bằng dấu cách, việc này sẽ xóa được nhiều biểu
tượng xấu nhất có thể.

Vì vậy theo thuật toán Aho-Corasick, khi tìm thấy vị trí cuối một mẫu xuất hiện ta sẽ tăng
biến đếm số ký tự cần xóa lên 1 và tìm kiếm tiếp tục từ vị trí tiếp theo, tức là trạng thái
mới u = 0.

4.3. Chương trình

#include <bits/stdc++.h>
using namespace std;

int n, m, to[1501][128], link[1501], word[1501], sz = 0, ans


= 0;
bool leaf[1501];
string t;

void init_trie() {
memset(to, 0, sizeof(to));
memset(leaf, false, sizeof(leaf));
memset(link, 0, sizeof(link));
memset(word, 0, sizeof(word));
}

void add_str(string s, int id) {


int u = 0;
for (int i = 0; i < s.size(); i++) {
int j = s[i];
if (to[u][s[i]] == 0) to[u][j] = ++sz;
u = to[u][j];
}
leaf[u] = true;
word[u] = id;
}

void push_link() {
queue<int> q;
q.push(0);
while (!q.empty()) {
int u = q.front(); q.pop();
int v = link[u];
leaf[u] |= leaf[v];
for (int i = 0; i < 128; i++)
if (to[u][i] != 0) {
link[to[u][i]] = ((u != 0) ? to[v][i] : 0);
q.push(to[u][i]);
}
else
to[u][i] = to[v][i];
}
}

int search_str(string s) {
int cnt = 0, u = 0;
for (int i = 0; i < s.size(); i++) {
u = to[u][s[i]];
int v = u;
if (leaf[v]) {
cnt++;
u = 0;
}
}
return cnt;
}

int main () {
freopen("emoticons.inp", "r", stdin);
freopen("emoticons.out", "w", stdout);
scanf("%d%d\n", &n, &m);
init_trie();
for (int i = 1; i <= n; i++) {
char s[15];
scanf("%s\n", &s);
add_str(s, i);
}
push_link();
for (int i = 1; i <= m; i++) {
getline(cin, t);
ans += search_str(t);
}
printf("%d\n", ans);
return 0;
}

4.4. Test

Đường dẫn tải test cho bài toán:


https://drive.google.com/file/d/0B-NTUSa9_289QVdHeGU4TkRqd00/view?usp=sharing

4.5. Cảm nhận

Ứng dụng của cây tiền tố trong các bài toán xử lý xâu.

5. Bài toán 5: Kiểm duyệt

5.1. Đề bài (Nguồn: USACO 2015)

Bác nông dân John đã đặt mua tạp chí Good Hooveskeeping cho các con bò của mình, vì
vậy chúng có rất nhiều tài liệu để đọc trong khi chờ đợi để vắt sữa. Thật không may trên
các tạp chí có một số từ mà bác John không muốn các con bò của mình nhìn thấy.

Bác John đã đưa tất cả các văn bản từ các tạp chí để tạo ra xâu S. Bác có một danh sách
các từ bị kiểm duyệt t1, t2, ..., tN, tức là bác muốn xóa chúng khỏi S. Để làm như vậy, bác
John tìm sự xuất hiện đầu tiên của một từ bị kiểm duyệt trong S (có chỉ số bắt đầu sớm
nhất) và loại bỏ nó ra khỏi S. Sau đó bác lặp lại quá trình này một lần nữa, xóa sự xuất
hiện đầu tiên của một từ bị kiểm duyệt khỏi S, lặp lại cho đến khi không xuất hiện một từ
bị kiểm duyệt nào trong S. Lưu ý rằng việc xóa một từ bị kiểm duyệt có thể tạo ra một sự
xuất hiện mới của một từ bị kiểm duyệt mà không tồn tại trước đó.

Bác John lưu ý không có từ bị kiểm duyệt nào xuất hiện như là một xâu con của một từ bị
kiểm duyệt khác. Vì vậy từ bị kiểm duyệt với chỉ số sớm nhất trong S được xác định duy
nhất.

Hãy giúp bác John xác định nội dung cuối cùng của S sau khi hoàn tất kiểm duyệt.

Dữ liệu: Dòng đầu tiên chứa xâu S bao gồm các chữ cái tiếng Anh thường (‘a’..’z’) và có
độ dài tối đa là 105. Dòng thứ hai chứa số nguyên dương N là số từ bị kiểm duyệt. Dòng
thứ i trong N dòng tiếp theo chứa từ bị kiểm duyệt ti. Các từ bị kiểm duyệt chỉ chứa các
chữ cái tiếng Anh thường (‘a’..’z’) và tổng độ dài của tất cả các từ bị kiểm duyệt không
vượt quá 105.

Kết quả: Ghi ra xâu S cuối cùng sau khi hoàn tất kiểm duyệt. Dữ liệu vào đảm bảo xâu S
sẽ khác rỗng sau khi hoàn tất kiểm duyệt.

Ví dụ:
kiem_duyet.inp kiem_duyet.out
begintheescapexecutionatthebreakofda beginthatthebreakofdaw
wn n
2
escape
execution

5.2. Thuật toán

Ta sẽ xây dựng xâu kết quả R với lần lượt từng ký tự một từ xâu S. Mỗi khi xuất hiện 1 từ
bị kiểm duyệt thì ta sẽ xóa nó khỏi R. Từ kiểm duyệt này xuất hiện ở cuối xâu R nên việc
xóa nó sẽ nhanh (ta dùng hàm thành phần resize của string).

Như vậy từ bị kiểm duyệt sẽ xuất hiện ở vị trí cuối nên ta dùng thuật toán Aho-Corasick
với cấu trúc trie. Khi xóa từ bị kiểm duyệt, ta cần trở lại trạng thái trước đó. Vì vậy cần
lưu trạng thái khi xử lý từng ký tự một để quay lui khi xóa từ bị kiểm duyệt.

5.3. Chương trình


#include <bits/stdc++.h>
using namespace std;

int n, to[100001][26], link[100001], word[100001],


len[100001], status[100001], sz = 0;
bool leaf[100001];
string s, r = "";
char t[100001];

void init_trie() {
memset(to, 0, sizeof(to));
memset(leaf, false, sizeof(leaf));
memset(link, 0, sizeof(link));
memset(word, 0, sizeof(word));
}

void add_str(string s, int id) {


int u = 0;
for (int i = 0; i < s.size(); i++) {
int j = s[i]-'a';
if (to[u][j] == 0) to[u][j] = ++sz;
u = to[u][j];
}
leaf[u] = true;
word[u] = id;
len[id] = s.size();
}

void push_link() {
queue<int> q;
q.push(0);
while (!q.empty()) {
int u = q.front(); q.pop();
int v = link[u];
leaf[u] |= leaf[v];
for (int i = 0; i < 26; i++)
if (to[u][i] != 0) {
link[to[u][i]] = ((u != 0) ? to[v][i] : 0);
q.push(to[u][i]);
}
else
to[u][i] = to[v][i];
}
}

void search_str(string s) {
int u = 0;
for (int i = 0; i < s.size(); i++) {
u = to[u][s[i]-'a'];
r += s[i];
status[r.size()] = u; // luu trang thai khi xu ly
den ki tu nay
int v = u; // tren xau r
while (leaf[v]) {
if (word[v] != 0) {
r.resize(r.size()-len[word[v]]);
u = status[r.size()]; // quay lai trang thai
khi xu ly ki } // tu
nay tren xau r
v = link[v];
}
}
}

int main () {
freopen("kiem_duyet.inp", "r", stdin);
freopen("kiem_duyet.out", "w", stdout);
init_trie();
scanf("%s\n%d\n", &t, &n);
s = t;
for (int i = 1; i <= n; i++) {
scanf("%s\n", &t);
add_str(t, i);
}
push_link();
search_str(s);
printf("%s\n", r.c_str());
return 0;
}

5.4. Test

Đường dẫn tải test cho bài toán:


https://drive.google.com/file/d/0B-NTUSa9_289aWpBTExYandQa3M/view?
usp=sharing

5.5. Cảm nhận

Bài toán trên cho ta thấy sự ứng dụng rất đa dạng của cấu trúc dữ liệu cây tiền tố trong xử
lý xâu.

6. Bài toán 6: Xor lớn nhất

6.1. Đề bài (Nguồn: Sưu tầm)

Cho dãy gồm n số nguyên không âm a1, a2, …, an. Hãy tìm giá trị xor lớn nhất của 2 phần
tử của dãy.

Ở đây xor là phép tính cộng bit không nhớ (phép xor trong Pascal hay ^ trong C/C++).

Dữ liệu: Dòng đầu tiên chứa số nguyên n (2 ≤ n ≤ 105). Dòng thứ hai chứa n số nguyên
a1, a2, …, an (0 ≤ ai ≤ 109) ngăn cách nhau bởi một dấu cách.

Kết quả: Ghi ra giá trị xor lớn nhất của 2 phần tử của dãy.

Ví dụ:
max_xor.inp max_xor.out
4 5
2 7 3 6

Ràng buộc:
 Subtask 1 (30%): 2 ≤ n ≤ 5000.
 Subtask 2 (70%): 2 ≤ n ≤ 105.
6.2. Thuật toán

Subtaks 1: Duyệt mọi cặp (ai, aj) với 1 ≤ i ≤ j ≤ n, tính ai xor aj và lưu kết quả lớn nhất.

#include <bits/stdc++.h>
using namespace std;

int n, a[100001], ans = 0;

int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= n; i++)
for (int j = i+1; j <= n; j++)
ans = max(ans, a[i] xor a[j]);
printf("%d\n", ans);
return 0;
}
Độ phức tạp của thuật toán: O(n2).

Subtask 2:
Do 0 ≤ ai ≤ 109 nên ta coi mỗi số ai như là một dãy 31 bít. Xét lần lượt từng số ai với i =
1, 2, …, n. Với mỗi số ai ta tìm trong các số a1, a2, …, ai-1 số nào xor với ai cho kết quả
lớn nhất. Để làm điều này ta cần tạo trie cho dãy các bít của a1, a2, …, ai-1. Xét các bít của
ai từ trọng số cao đến thấp và tìm đường đi trên trie theo dãy bít này sao cho xor cho giá
trị lớn nhất. Các chi tiết cụ thể xem chú thích trong chương trình sau.

6.3. Chương trình

#include <bits/stdc++.h>
using namespace std;

int n, to[3100001][2], sz = 0, ans = 0;

void init_trie() {
memset(to, 0, sizeof(to));
}
void add_bit(int a) {
for (int i = 30, u = 0; i >= 0; i--) {
int j = (a >> i) & 1;
if (to[u][j] == 0) to[u][j] = ++sz;
u = to[u][j];
}
}

int max_xor(int a) {
int num = 0;
for (int i = 30, u = 0; i >= 0; i--) {
int b = (a >> i) & 1; // bit thu i cua a
if (to[u][1-b] != 0) { // neu 2 bit khac nhau thi
xor bang 1
num += 1 << i; // ta di theo nhanh co bit
khac b truoc,
u = to[u][1-b]; // se cho so lon hon
}
else
u = to[u][b];
}
return num;
}

int main() {
freopen("max_xor.inp", "r", stdin);
freopen("max_xor.out", "w", stdout);
scanf("%d", &n);
init_trie();
int a;
scanf("%d", &a);
add_bit(a);
for (int i = 2; i <= n; i++) {
scanf("%d", &a);
ans = max(ans, max_xor(a));
add_bit(a);
}
printf("%d\n", ans);
return 0;
}

Độ phức tạp thuật toán là: O(n×31).

6.4. Test

Đường dẫn tải test cho bài toán:


https://drive.google.com/file/d/0B-NTUSa9_289TXNzVXF0SkZIUHc/view?usp=sharing

6.5. Cảm nhận

Bài toán này cho ta thấy ngoài việc dùng cấu trúc dữ liệu cây tiền tố để xử lý xâu, ta còn
ứng dụng vào việc xử lý các dãy bít.

7. Bài toán 7: Tổng xor

7.1. Đề bài (Nguồn: Regionals 2009 Asia - Amritapuri)

Cho dãy A gồm n số nguyên không âm a1, a2, …, an. Hãy chọn ra một dãy con gồm các
phần tử liên tiếp của dãy A sao cho xor tất cả các phần tử của dãy con là lớn nhất, tức là
tìm dãy con ai, ai+1, …, aj (1 ≤ i ≤ j ≤ n) sao cho (ai xor ai+1 xor … xor aj) lớn nhất.

Ở đây xor là phép tính cộng bit không nhớ (phép xor trong Pascal hay ^ trong C/C++).

Dữ liệu: Dòng đầu tiên chứa số nguyên n (2 ≤ n ≤ 105). Dòng thứ hai chứa n số nguyên
a1, a2, …, an (0 ≤ ai ≤ 109) ngăn cách nhau bởi một dấu cách.

Kết quả: Ghi ra giá trị xor lớn nhất của dãy con tìm được.

Ví dụ:
sum_xor.inp sum_xor.out
5 7
3 7 7 7 0
5 15
3 8 2 6 4
Ràng buộc:
 Subtask 1 (50%): 2 ≤ n ≤ 103.
 Subtask 2 (50%): 2 ≤ n ≤ 105.

7.2. Thuật toán

Gọi f(l, r) là xor của mảng con từ l đến r, tức là f(l, r) = al xor al+1 xor … xor ar. Rõ ràng
f(l, r) có tính chất sau: f(l, r) = f(1, r) xor f(1, l-1).

Bây giờ ta tính xor lớn nhất của mảng con kết thúc tại vị trí i. Tức là cần tính giá trị lớn
nhất của f(l, i) = f(1, i) xor f(1, l-1) ở đó l ≤ i. Giả sử rằng chúng ta đã chèn f(1, l-1) vào
trie với mọi l ≤ i. Rõ ràng đây chính là bài toán “xor lớn nhất” ở mục 6.

7.3. Chương trình

#include <bits/stdc++.h>
using namespace std;

int n, to[3100001][2], sz = 0;

void init_trie() {
memset(to, 0, sizeof(to));
}

void add_bit(int a) {
for (int i = 30, u = 0; i >= 0; i--) {
int j = (a >> i) & 1;
if (to[u][j] == 0) to[u][j] = ++sz;
u = to[u][j];
}
}

int max_xor(int a) {
int num = 0;
for (int i = 30, u = 0; i >= 0; i--) {
int b = (a >> i) & 1;
if (to[u][1-b] != 0) {
num += 1 << i;
u = to[u][1-b];
}
else
u = to[u][b];
}
return num;
}

int main() {
freopen("sum_xor.inp", "r", stdin);
freopen("sum_xor.out", "w", stdout);
scanf("%d", &n);
int pre = 0, ans = 0;
init_trie();
add_bit(0);
for (int i = 1; i <= n; i++) {
int a;
scanf("%d", &a);
pre ^= a;
ans = max(ans, max_xor(pre));
add_bit(pre);
}
printf("%d\n", ans);
return 0;
}

7.4. Test

Đường dẫn tải test cho bài toán:


https://drive.google.com/file/d/0B-NTUSa9_289SDduMV9Eakk1NFU/view?usp=sharing

7.5. Cảm nhận

 Bài toán 2 điểm động quy về bài toán 1 điểm động.


 Là sự mở rộng của bài toán 6 qua tính chất của phép xor.
 Ứng dụng của cấu trúc cây tiền tố và tính chất của phép xor.
8. Bài toán 8: Xor mảng con

8.1. Đề bài (Nguồn: http://www.spoj.com/problems/SUBXOR/)

Cho mảng gồm n số nguyên không âm a1, a2, …, an. Hãy tính số mảng con gồm các phần
tử liên tiếp sao cho khi xor tất cả các phần tử của mảng con được giá trị nhỏ hơn k, tức là
tính số mảng con ai, ai+1, …, aj (1 ≤ i ≤ j ≤ n) sao cho ai xor ai+1 xor … xor aj < k.

Ở đây xor là phép tính cộng bit không nhớ (phép xor trong Pascal hay ^ trong C/C++).

Dữ liệu: Dòng đầu tiên chứa 2 số nguyên n và k (1 ≤ n ≤ 105, 0 ≤ k ≤ 109). Dòng thứ hai
chứa n số nguyên a1, a2, …, an (0 ≤ ai ≤ 109) ngăn cách nhau bởi một dấu cách.

Kết quả: Ghi ra số mảng con có giá trị xor nhỏ hơn k.

Ví dụ:
sub_xor.inp sub_xor.out
5 2 3
4 1 3 2 7

Ràng buộc:
 Subtask 1 (50%): 1 ≤ n ≤ 103.
 Subtask 2 (50%): 1 ≤ n ≤ 105.

8.2. Thuật toán

Về cách tổ chức dữ liệu và cài đặt giống bài toán “Tổng xor”. Điểm khác biệt duy nhất là
thay vì tìm mảng con có xor lớn nhất, ta tìm mảng con có xor nhỏ hơn k. Chi tiết của việc
này xem chú thích trong chương trình dưới đây.

8.3. Chương trình

#include <bits/stdc++.h>
using namespace std;

int n, k, to[3100001][2], cnt[3100001][2], sz = 0;


long long ans = 0;
void init_trie() {
memset(to, 0, sizeof(to));
memset(cnt, 0, sizeof(cnt));
ans = 0;
}

void add_bit(int a) {
for (int i = 30, u = 0; i >= 0; i--) {
int j = (a >> i) & 1;
cnt[u][j]++; // so nut la neu di theo duong di nay
if (to[u][j] == 0) to[u][j] = ++sz;
u = to[u][j];
}
}

int get(int a) {
int num = 0;
for (int i = 30, u = 0; i >= 0; i--) {
int b = (a >> i) & 1; // bit thu i cua a
if (((k>>i)&1) == 1) { // bit thu i cua k
num += cnt[u][b];
if (to[u][1-b] == 0) break;
u = to[u][1-b];
}
else {
if (to[u][b] == 0) break;
u = to[u][b];
}
}
return num;
}

int main() {

scanf("%d%d", &n, &k);


init_trie();
add_bit(0);
int pre = 0;
for (int i = 1; i <= n; i++) {
int a;
scanf("%d", &a);
pre ^= a;
ans += get(pre);
add_bit(pre);
}
printf("%lld\n", ans);
return 0;
}

8.4. Test

Đường dẫn tải test cho bài toán:


https://drive.google.com/file/d/0B-NTUSa9_289OVhVMVpCYXlVcGM/view?
usp=sharing

8.5. Cảm nhận

 Là sự mở rộng của bài toán 7.


 Cũng là bài toán 2 điểm động quy về bài toán 1 điểm động.
 Ứng dụng của cấu trúc cây tiền tố và tính chất của phép xor.

9. Bài toán 9: Số lượng khoảng

9.1. Đề bài (Nguồn: Kỹ thuật lập trình - Nguyễn Thanh Tùng - Trường ĐHBK Hà
Nội)

Cho mảng số nguyên không âm a1, a2, ..., an. Với số x nguyên không âm cho trước hãy
tính số lượng cặp số (l, r) thỏa mãn các điều kiện:
 1≤l≤r≤n
 x ≤ al xor al+1 xor … xor ar

Ở đây xor là phép tính cộng bit không nhớ (phép xor trong Pascal hay ^ trong C/C++).
Dữ liệu: Dòng đầu tiên chứa 2 số nguyên n và x (1 ≤ n ≤ 105, 0 ≤ x ≤ 109). Dòng thứ hai
chứa n số nguyên a1, a2, ..., an (0 ≤ ai ≤ 109, i = 1÷n).

Kết quả: Đưa ra một số nguyên là số lượng cặp tìm được.

Ví dụ:

seg_num.inp seg_num.out
5 0 15
1 2 3 4 5
3 3 2
1 2 3

Ràng buộc:
 Subtask 1 (50%): 1 ≤ n ≤ 103.
 Subtask 2 (50%): 1 ≤ n ≤ 105.

9.2. Thuật toán

Về cơ bản bài toán này giống bài toán “Xor mảng con”, thay vì tính số lượng mảng con
có xor nhỏ hơn x bởi lớn hơn hoặc bằng x. Vì vậy ta chỉ cần chỉnh sửa lại đôi chút cài đặt
của bài toán trên.

9.3. Chương trình

#include <bits/stdc++.h>
using namespace std;

int n, x, to[3100001][2], cnt[3100001][2], sz = 0;


long long ans = 0;

void init_trie() {
memset(to, 0, sizeof(to));
memset(cnt, 0, sizeof(cnt));
}

void add_bit(int a) {
for (int i = 30, u = 0; i >= 0; i--) {
int j = (a >> i) & 1;
cnt[u][j]++;
if (to[u][j] == 0) to[u][j] = ++sz;
u = to[u][j];
}
}

int get(int a) {
int num = 0, i, u;
for (i = 30, u = 0; i >= 0; i--) {
int b = (a >> i) & 1; // bit thu i cua a
if (((x>>i)&1) == 0) { // bit thu i cua x
num += cnt[u][1-b];
if (to[u][b] == 0) break;
u = to[u][b];
}
else {
if (to[u][1-b] == 0) break;
u = to[u][1-b];
}
}
return (i < 0) ? num+1 : num;
}

int main() {
freopen("seg_num.inp", "r", stdin);
freopen("seg_num.out", "w", stdout);
scanf("%d%d", &n, &x);
init_trie();
add_bit(0);
int pre = 0;
for (int i = 1; i <= n; i++) {
int a;
scanf("%d", &a);
pre ^= a;
ans += get(pre);
add_bit(pre);
}
printf("%lld\n", ans);
return 0;
}

9.4. Test

Đường dẫn tải test cho bài toán:


https://drive.google.com/file/d/0B-NTUSa9_289Y1E3aEU5REFaX0U/view?usp=sharing

9.5. Cảm nhận

Sau khi làm bài toán 8, thì bài toán này là sự ôn tập lại kỹ năng về cấu trúc cây tiền tố.

10. Bài toán 10: Xor dãy số

10.1. Đề bài (Nguồn: VNOI Online 2016)

Cho dãy A gồm N số nguyên không âm. Ta lần lượt thực hiện Q thao tác trên dãy này:
 XOR x: Với mọi i: Ai = Ai xor x.
 FIND k: Tìm số lớn thứ k trong dãy A.

Hãy thực hiện các truy vấn trên.

Dữ liệu: Dòng đầu tiên ghi 2 số N và Q. Dòng thứ hai ghi N số là giá trị ban đầu của dãy
A. Tiếp theo là Q dòng, mỗi dòng ghi 1 trong 2 loại truy vấn.

Kết quả: Với mỗi truy vấn loại FIND, in ra kết quả tìm được.

Ví dụ:
seq_xor.inp seq_xor.out
4 9 4
1 2 3 4 3
FIND 1 2
FIND 2 1
FIND 3 7
FIND 4 5
XOR 6 4
FIND 1 2
FIND 2
FIND 3
FIND 4

Giải thích ví dụ: Trước truy vấn XOR 6, dãy số là 1, 2, 3, 4. Sau truy vấn XOR 6, dãy số
là 7, 4, 5, 2.

Ràng buộc:
 Subtask 1 (25%): N, Q ≤ 5000, 0 ≤ Ai ≤ 109, 0 ≤ x ≤ 109.
 Subtask 2 (40%): 0 ≤ x ≤ 100. Các subtask 2, 3 và 4 tiếp theo đều có: N ≤ 105, Q ≤
105, 0 ≤ Ai ≤ 109.
 Subtask 3 (10%): 0 ≤ x ≤ 109, x luôn có dạng 2k.
 Subtask 4 (25%): 0 ≤ x ≤ 109.

10.2. Thuật toán

Coi mỗi số như một dãy 31 bít. Dùng trie để biểu diễn n số a[i] (mảng to[u][i]). Với mỗi
nút ta tính xem nó chứa bao nhiêu nút lá (mảng cnt[u][i]).

Duyệt trie này từ nút gốc và theo thứ tự từ điển, ta sẽ được các số a[i] theo thứ tự tăng.

Vì phép xor có tính chất giao hoán nên mỗi phép xor x, ta không cập nhật ngay vào a[i]
mà ta xor các giá trị x này với nhau: X ^= x. Vì vậy trie không thay đổi.

Với phép “FIND k” tìm số lớn thứ k trong dãy, tức là tìm số thứ n-k+1 trong dãy theo thứ
tự tăng. Ta sẽ duyệt trie để lấy ra số mà sau khi “xor X” nó có thứ tự n-k+1 trong dãy
tăng. Sau đó lấy số này xor với X, ta thu được số lớn thứ k cần tìm.

10.3. Chương trình

#include <bits/stdc++.h>
using namespace std;

int n, x, to[3100001][2], cnt[3100001][2], sz = 0;


long long ans = 0;
void init_trie() {
memset(to, 0, sizeof(to));
memset(cnt, 0, sizeof(cnt));
}

void add_bit(int a) {
for (int i = 30, u = 0; i >= 0; i--) {
int j = (a >> i) & 1;
cnt[u][j]++;
if (to[u][j] == 0) to[u][j] = ++sz;
u = to[u][j];
}
}

int get(int a) {
int num = 0;
for (int i = 30, u = 0; i >= 0; i--) {
int b = (a >> i) & 1; // bit thu i cua a
if (((x>>i)&1) == 0) { // bit thu i cua x
num += cnt[u][1-b];
if (to[u][b] == 0) return num;
u = to[u][b];
}
else {
if (to[u][1-b] == 0) return num;
u = to[u][1-b];
}
}
return num+1;
}

int main() {
freopen("seq_xor.inp", "r", stdin);
freopen("seq_xor.out", "w", stdout);
scanf("%d%d", &n, &x);
init_trie();
add_bit(0);
int pre = 0;
for (int i = 1; i <= n; i++) {
int a;
scanf("%d", &a);
pre ^= a;
ans += get(pre);
add_bit(pre);
}
printf("%lld\n", ans);
return 0;
}

10.4. Test

Đường dẫn tải test cho bài toán:


https://drive.google.com/file/d/0B-NTUSa9_289ejZvbGZWWVVGbFk/view?
usp=sharing

10.5. Cảm nhận

Đây là bài toán về ứng dụng của cây tiền tố trong xử lý bít và thứ tự từ điển.

You might also like