Cỏc thut toỏn tỡm kim xõu ký t
inh Quang Huy
Bi toỏn tỡm kim xõu ký t (string searching, hay ụi khi gi l i sỏnh xõu - string
matching) l mt trong nhng bi toỏn c bn v quan trng trong cỏc thut toỏn x lý v
xõu ký t hay x lý vn bn (text processing). ng dng ca nú c ỏp dng ph bin
trong cỏc trỡnh son tho vn bn hay cỏc chng trỡnh tỡm kim vn bn trờn internet da
vo cỏc t khúa, tt nhiờn thc thi cỏc bi toỏn phc tp ny cn rt nhiu k thut v
cỏch x lý khỏc i kốm. Trong khuụn kh bi bỏo ginh cho hc sinh ph thụng chuyờn
Tin, tụi mun trỡnh by mt s phng phỏp t n gin n phc tp i vi bi toỏn tỡm
kim xõu ký t th loi n gin nht. Hy vng qua bi bỏo cỏc bn cú mt cỏch nhỡn
ton din v sõu sc v bi toỏn ny, trong bi vit tỏc gi cú chỳ thớch mt s thut ng
ting Anh nhm giỳp bn c cú thờm mt s thut ng hay dựng trong tin hc.
1. Phỏt biu bi toỏn
Bi toỏn c phỏt biu mt cỏch n gin nh sau : Tỡm mt (hoc nhiu) v trớ xut hin
cu mt xõu ký t P[1..m] (thng c gi l mt mu tỡm kim - pattern) trong mt
xõu ký t ln hn hay trong mt on vn bn no ú T[1..n], m<=n. Vớ d ta cú th tỡm
thy v trớ ca xõu abc trong xõu abcababc l 1 v 6.
Phỏt biu hỡnh thc bi toỏn nh sau :
Gi l mt tp hu hn (finite set) cỏc ký t. Thụng thng, cỏc ký t ca c mu tỡm
kim v on vn bn gc u nm trong . Tp tựy tng ng dng c th cú th l bng
ch cỏi ting Anh t A n Z thụng thng, cng cú th l mt tp nh phõn ch gm hai
phn t 0 v 1 ( = {0,1}) hay cú th l tp cỏc ký t DNA trong sinh hc ( =
{A,C,G,T}).
Cú rt nhiu cỏch tip cn, trong khuụn kh bi bỏo, tỏc gi mun trỡnh by cỏch tip cn
u tin v n gin nht, sau ú l mt s cỏch tip cn ni ting, cựng vi nhng phõn
tớch v ỏnh giỏ v tng thut toỏn c th.
2. Cỏch tip cn u tiờn
Phng phỏp u tiờn v n gin nht cú th ngh n ngay l ln lt xột tng v trớ i
trong xõu ký t gc t 1 n n-m+1, so sỏnh T[i(i+m-1)] vi P[1..m] bng cỏch xột tng
cp ký t mt v a ra kt qu tỡm kim. Ngi ta cũn gi phng phỏp ny l cỏch tip
cn ngõy th (Naùve string search).
Di õy l th tc c t ca phng phỏp ny :
NAẽVE_STRING_MATCHER (T, P)
1. n ← length [T]
2. m ← length [P]
3. for s ← 1 to n-m+1 do
4. j ← 1
5. while j ≤ m and T[s + j] = P[j] do
6. j ← j +1
7. If j > m then
8. return s // s là vị trí tìm được
9. return false. // không có vị trí nào thỏa mãn
Dễ thấy độ phức tạp trung bình của thuật toán là O(n+m), nhưng trong trường hợp tồi nhất
độ phức tạp là O(n.m), ví dụ như tìm kiếm mẫu “”aaaab” trong xâu “aaaaaaaaab”. Như vậy
thuật toán đơn giản này có độ phức tạp bình phương, khó có thể áp dụng trong những úng
dụng lớn. Phần tiếp theo sẽ trình bày một số thuật toán hay và nổi tiếng cho bài toán tìm
kiếm xâu ký tự, có độ phức tạp thuật toán nhỏ hơn rất nhiều.
3. Thuật toán Rabin-Karp
Thuật toán mang tên hai nhà khoa học phát minh ra nó Michael O. Rabin (sinh năm 1931,
người Đức) and Richard M. Karp (sinh năm 1931, người Mỹ), đều được giải Turing
Award, giải thương uy tín nhất trong nghành khoa học máy tính và công nghệ thông tin
mang tên nhà khoa học máy tính lừng danh người Anh Alan Turing.
Tư tưởng chính của phương pháp này là sử dụng phương pháp băm (hashing). Tức là mỗi
một xâu sẽ được gán với một giá trị của hàm băm (hash function), ví dụ xâu “hello” được
gán với giá trị 5 chẳng hạn, và hai xâu được gọi là bằng nhau nếu giá trị băm của nó bằng
nhau. Chi tiết về hàm băm độc giả có thể tìm đọc trong chương 6 sách “Cấu trúc dữ liệu và
thuật toán”, tác giả Đinh Mạnh Tường, nhà xuất bản Khoa học kỹ thuật có bán trên các
hiệu sách toàn quốc. Như vậy thay vì việc phải đối sánh các xâu con của T với mẫu P, ta
chỉ cần so sánh giá trị hàm băm của chúng và đưa ra kết luận
Đặc tả chúng thuật toán như sau :
1. function Rabin_Karp(string T[1..n], string P[1..m])
2. hsub := hash(P[1..m]) // giá trị băm của xâu P
3. hs := hash(T[1..m]) // giá trị băm của xâu T
4. for i from 1 to n-m+1
5. if hs = hsub
6. if T[i..i+m-1] = P
7. return i
8. hs := hash(T[i+1..i+m]) // giá trị băm của xâu T[i+1..i+m]
9. return not found
Vấn đề đặt ra ở đây là khi có quá nhiều xâu sẽ tồn tại các trường hợp các xâu khác nhau có
giá trị băm giống nhau, do đó khi tìm thấy hai xâu có giá trị băm giống nhau vẫn phải kiểm
tra lại xem chúng có thực sự bằng nhau hay không (dòng 6), may mắn là trường hợp này
rất ít xảy ra với một hàm băm thiết kế đủ tốt.
Phân tích thuật toán ta thấy : dòng 2,3,6,8 có độ phức tạp là O(m), nhưng dòng 2,3 chỉ thực
hiện duy nhất một lần, dòng 6 chỉ thực hiện khi giá trị băm bằng nhau (rất ít), chủ yếu là
dòng số 8 sẽ quyết định độ phức tạp của thuật toán. Bởi khi tính giá trị băm cho
T[i+1..i+m] ta mất thời gian là O(m), công việc này được thực hiện trong n-m+1 lần như
vậy độ phức tạp không hơn gì so với phương pháp ở phần 2.
Như vậy ta phải tính lại giá trị hs trong thời gian hằng số (constant time), cách giải quyết
ở đây là tính giá trị băm của T[i+1..i+m] dựa vào giá trị băm của T[i..i+m-1] bằng cách sử
dụng cách băm tròn (rolling hash, là cách băm mà giá trị đầu vào được băm với một kích
thước cửa số cổ định trượt trên độ dài của giá trị cần băm). Cụ thể trong bài toán này, ta sử
dụng công thức sau để tính giá trị băm tiếp theo trong một khoảng thời gian hằng số :
hash(T[i+1…i+m]) = hash(T[i+1…i+m-1]) – ASCII(T[i]) + ASCII (T[i+m]), trong đó
ASCII(i) là mã ASCII của ký tự i.
Như vậy trong trường hợp này độ phức tạp chỉ còn là O(n).
Đó là một cách băm đơn giản, dưới đây sẽ trình bày một hàm băm phức tạp và tốt hơn cho
các trường hợp dữ liệu lớn. Đó là sử dụng các số nguyên tố lớn. Ví dụ như xâu “hi” băm
bằng số nguyên tố 101 sẽ có giá trị băm là 104 × 1011 + 105 × 1010 = 10609 (ASCII của
ký tự 'h' là 104 và của ký tự 'í là 105).
Thêm nữa, ta có thể tính giá trị băm của một xâu con dựa vào các xâu con trước nó, ví dụ
như ta có xâu "abracadabra", ta cần tìm một mẫu tìm kiếm có độ dài là 3. Ta có thể tính giá
trị băm của xâu “bra” dựa vào giá trị băm của xâu “abr” (xâu con trước nó) bằng cách lấy
giá trị băm của “abr” trừ đi giá trị băm của ký tự ‘a’ đầu tiên (ví dụ như 97 × 101
2
(97 là
giá trị ASCII của ký tự 'á và 101 là số nguyên tố đang sử dụng) và cộng thêm giá trị băm
cảu ký tự ‘a’ cuối cùng trong xâu “bra” (ví dụ như 97 × 101
0
= 97).
Còn rất nhiều cách xử lý hàm băm phức tạp nữa, nhưng khi lập trình chúng ta cần chú ý
đến giới hạn của kiểu dữ liệu (ví dụ số nguyên trong ngôn ngữ lập trình PASCAL là
32768), độc giả nào quan tâm có thể liên lạc với tác giả để trao đổi.
4. Thuật toán Knuth-Morris-Pratt
Thuật toán được phát minh năm 1977 bởi hai giáo sư của ĐH Stanford, Hoa Kỳ (1 trong số
ít các trường đại học xếp hàng số một về khoa học máy tính trên thế giới cùng với MIT,
CMU cũng của Hoa Kỳ và Cambridge của Anh) Donal Knuth và Vaughan Ronald Pratt,
Knuth (giải Turing năm 1971) còn rất nổi tiếng với cuốn sách Nghệ thuật lập trình (The
Art of Computer Programming) hiện nay đã có đến tập 6, 3 tập đầu tiên đã có xuất bản ở
Việt Nam, cuốn sách gối đầu giường cho bất kỳ lập trình viên nói riêng và những ai yêu
thích lập trình máy tính nói chung trên thế giới. Thuật toán này còn có tên là KMP lấy tên
viết tắt của ba người phát minh ra nó, chữ “M” là chỉ giáo sư J.H.Morris, một người cũng
rất nổi tiếng trong khoa học máy tính.
Ý tưởng chính của phương pháp này như sau : trong quá trình tìm kiếm vị trí của mẫu P
trong xâu gốc T, nếu tìm thấy một vị trí sai ta chuyển sang vị trí tìm kiếm tiếp theo và quá
trình tìm kiếm sau này sẽ được tận dụng thông tin từ quá trình tìm kiếm trước để không
phải xét các trường hợp không cần thiết.
Ví dụ : tìm mẫu P = “ABCDABD” trong xâu T = “ABC ABCDAB ABCDABCDABDE”
giả sử m và i là chỉ số chạy thuật toán tương ứng đối với xâu T và P. Ta lần lượt có các
bước của thuật toán như sau :
+ Đầu tiên, m=0, i=0
m: 01234567890123456789012
T: ABC ABCDAB ABCDABCDABDE
P: ABCDABD
i: 0123456
Ta thấy m=3 và i=3 xâu T và mẫu P không khớp nhau (T[3] = space, P[3] = ‘D’), nên sẽ
dừng so sánh và bắt đầu lại với m=1. Ta chú ý là ký tự đầu tiên của P là ‘A’ không xuất
hiện trong T từ vị trí 0 đến 3 nên ta chuyển đến xét m=4.
+ m=4, i=0
m: 01234567890123456789012
S: ABC ABCDAB ABCDABCDABDE
W: ABCDABD
i: 0123456
Tại m=10, i=6 xâu T và mẫu P không khớp nhau (T[10] = space, P[6] = ‘D’). Ta lại thấy
chuỗi “AB” trong mẫu P không xuất hiện trong T từ vị trí 5 đến vị trí 7, nên ta chuyển sang
m=8.
+ m=8
m: 01234567890123456789012
S: ABC ABCDAB ABCDABCDABDE
W: ABCDABD
i: 0123456
Ta thấy trong mẫu không xuất hiện ký tự space, nên chuyển tiếp m=11
+ m=11
m: 01234567890123456789012
S: ABC ABCDAB ABCDABCDABDE
W: ABCDABD
i: 0123456
Ta thấy m=17 của T không khớp với i=6 của P nên ta xét tiếp m=15
+m=15
m: 01234567890123456789012
S: ABC ABCDAB ABCDABCDABDE
W: ABCDABD
i: 0123456
Ta tìm được kết quả mẫu P xuất hiện trong xâu T ở vị trí 15.
Như vậy qua ví dụ ta thấy vấn đề chủ yếu ở đây là tìm vị trí tiếp theo để kiểm tra sau khi
bắt gặp một vị trí sai. Chúng ta hãy xem cách giải quyết của KMP.
Bây giờ ta giả sử có bảng đối sánh thành phần (partial match table) chỉ cho chúng ta biết
điểm xuất phát tiếp theo khi gặp một ví trí đối sánh sai (mismatch) F[1..m] trong đó giá trị
F[i] là tổng số ký tự ta lùi lại để xét tiếp trên xâu T sau khi gặp một vị trí sai trong khi đang
xét đến ký tự thứ i trong xâu mẫu tìm kiếm. Tức là nếu ở vị trí m mà T[m+i] khác P[i] thì
ta sẽ xét tiếp vị trí m+i-F[i] trên xâu T. Có hai ưu điểm ở đây : thứ nhất là F[0]=-1 tức là
nếu P[0] là vị trí sai thì ta sẽ chuyển ngay đến ký tự tiếp theo, thứ hai là mặc dù ta quay lại
vị trí m+i-F[i] là vị trí kiểm tra tiếp theo nhưng thực sự ta chỉ cần đối sánh mẫu từ vị trí
P[F[i]]. Chi tiết về xây dựng bảng này sẽ được đề cập đến ở phần cuối của mục này.Cụ thể
với bảng trên, lược đồ thuật toán KMP như sau :
1. Bắt đầu với i = m = 0; giả sử P có n ký tự và T có k ký tự
2. If m + i = k then
- Thoát, không có trường hợp nào thỏa mãn. Else
- So sánh P[i] với T[m + i]:
- If (bằng nhau) then i &lar;i+1. If i = n then ta tìm được xâu con của T thỏa mãn bắt đầu từ
vị trí m;
- If (không bằng nhau), gán e = T[i]. m=m+i-e, if i > 0, gán i = e.
3. Trở lại bước 2.
Dưới đây là đoạn source code mẫu viết bằng C: