Thuật toán tìm kiếm xâu kí tự
Các thuật tốn tìm kiếm xâu ký tựĐinh Quang HuyBài tốn tìm kiếm xâu ký tự (string searching, hay đơi khi gọi là đối sánh xâu - string matching) là một trong những bài tốn cơ bản và quan trọng trong các thuật tốn xử lý về xâu ký tự hay xử lý văn bản (text processing). Ứng dụng của nó được áp dụng phổ biến trong các trình soạn thảo văn bản hay các chương trình tìm kiếm văn bản trên internet dựa vào các từ khóa, tất nhiên để thực thi các bài tốn phức tạp này cần rất nhiều kỹ thuật và cách xử lý khác đi kèm. Trong khn khổ bài báo giành cho học sinh phổ thơng chun Tin, tơi muốn trình bày một số phương pháp từ đơn giản đến phức tạp đối với bài tốn tìm kiếm xâu ký tự ở thể loại đơn giản nhất. Hy vọng qua bài báo các bạn có một cách nhìn tồn diện và sâu sắc về bài tốn này, trong bài viết tác giả có chú thích một số thuật ngữ tiếng Anh nhằm giúp bạn đọc có thêm một số thuật ngữ hay dùng trong tin học.1. Phát biểu bài tốnBài tốn được phát biểu một cách đơn giản như sau : Tìm một (hoặc nhiều) vị trí xuất hiện cuả một xâu ký tự P[1 m] (thường được gọi là một mẫu tìm kiếm - pattern) ở trong một xâu ký tự lớn hơn hay trong một đoạn văn bản nào đó T[1 n], m<=n. Ví dụ ta có thể tìm thấy vị trí của xâu “abc” trong xâu “abcababc” là 1 và 6.Phát biểu hình thức bài tốn như sau :Gọi Σ là một tập hữu hạn (finite set) các ký tự. Thơng thường, các ký tự của cả mẫu tìm kiếm và đoạn văn bản gốc đều nằm trong Σ. Tập Σ tùy từng ứng dụng cụ thể có thể là bảng chữ cái tiếng Anh từ A đến Z thơng thường, cũng có thể là một tập nhị phân chỉ gồm hai phần tử 0 và 1 (Σ = {0,1}) hay có thể là tập các ký tự DNA trong sinh học (Σ = {A,C,G,T}).Có rất nhiều cách tiếp cận, trong khn khổ bài báo, tác giả muốn trình bày cách tiếp cận đầu tiến và đơn giản nhất, sau đó là một số cách tiếp cận nổi tiếng, cùng với những phân tích và đánh giá về từng thuật tốn cụ thể.2. Cách tiếp cận đầu tiênPhương pháp đầu tiên và đơn giản nhất có thể nghĩ đến ngay là lần lượt xét từng vị trí i trong xâu ký tự gốc từ 1 đến n-m+1, so sánh T[i…(i+m-1)] với P[1 m] bằng cách xét từng cặp ký tự một và đưa ra kết quả tìm kiếm. Người ta còn gọi phương pháp này là cách tiếp cận ngây thơ (Nạve string search).Dưới đây là thủ tục đặc tả của phương pháp này :NẠ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 được9. return false. // không có vị trí nào thỏa mãnDễ 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-KarpThuậ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 P3. hs := hash(T[1 m]) // giá trị băm của xâu T4. for i from 1 to n-m+15. if hs = hsub6. if T[i i+m-1] = P7. return i8. hs := hash(T[i+1 i+m]) // giá trị băm của xâu T[i+1 i+m]9. return not foundVấ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 × 1012 (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 × 1010 = 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-PrattThuậ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=0m: 01234567890123456789012T: ABC ABCDAB ABCDABCDABDEP: ABCDABDi: 0123456Ta 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: 01234567890123456789012S: ABC ABCDAB ABCDABCDABDEW: ABCDABDi: 0123456Tạ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: 01234567890123456789012S: ABC ABCDAB ABCDABCDABDEW: ABCDABDi: 0123456Ta thấy trong mẫu không xuất hiện ký tự space, nên chuyển tiếp m=11+ m=11m: 01234567890123456789012S: ABC ABCDAB ABCDABCDABDEW: ABCDABDi: 0123456Ta 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=15m: 01234567890123456789012S: ABC ABCDAB ABCDABCDABDEW: ABCDABDi: 0123456Ta 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: Độ phức tạp thuật toán là O(k) với k là độ dài của xâu gốc T, chứng minh tương đối đơn giản nên không trình bày ở đây, bạn đọc quan tâm liên hệ với tác giả.Sau đây là cách xây dựng bảng lỗi (failure table), chú ý ở đây F(j) là chính độ dài của xâu con tiền tố (prefix) lớn nhất của P[0 j-1] Đây là một ví dụ với xâu mẫu “ABCDABD”Đơn giản tính được độ phức tạp là O(n) với n là độ dài của xâu mẫu P.Trên đây là các thuật toán khá hay trong bài toán tìm kiếm xâu ký tự, ngoài ra còn có thuật toán dược phát triển bởi Boyer và Moore vào năm 1977 cũng khá hay, nếu có dịp tác giả sẽ viết một bài trên Tin học nhà trường. Phần so sánh và bình luận về các thuật toán xin giành cho độc giả như một bài tập nhỏ, tác giả rất mong nhận được ý kiến trao đổi của bạn đọc gần xa, đặc biệt là học sinh khối PT chuyên Tin trên toàn quốc . 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-KarpThuật toán mang. Các thuật tốn tìm kiếm xâu ký tự inh Quang HuyBài tốn tìm kiếm xâu ký tự (string searching, hay đơi khi gọi là đối sánh xâu - string matching)