0

Cấu trúc dữ liệu và Giải thuật Bài 72: Cây Suffix (Suffix Tree) - Xây dựng và tìm kiếm chuỗi con đỉnh cao.

Bước sang Chương 16: Cấu trúc dữ liệu chuỗi nâng cao, chúng ta sẽ lật ngược lại tư duy tìm kiếm văn bản.

Ở Bài 53, thuật toán KMP giúp chúng ta tìm một chuỗi mẫu (Pattern - độ dài MM) bên trong một văn bản (Text - độ dài NN) với tốc độ O(N+M)O(N + M). KMP tỏa sáng khi văn bản liên tục thay đổi. Nhưng hãy tưởng tượng bạn đang xây dựng một hệ thống cơ sở dữ liệu sinh học chứa chuỗi DNA dài 3 tỷ ký tự (văn bản tĩnh). Nếu bạn cần tìm kiếm 1 triệu đoạn gen (patterns) khác nhau trên chuỗi DNA này, KMP sẽ phải quét lại chuỗi 3 tỷ ký tự đó 1 triệu lần. Hệ thống của bạn sẽ sụp đổ.

Thay vì tiền xử lý chuỗi mẫu như KMP, tư duy đỉnh cao là Tiền xử lý toàn bộ văn bản tĩnh một lần duy nhất để bất kỳ truy vấn tìm kiếm chuỗi con nào sau đó cũng chỉ mất thời gian O(M)O(M) — hoàn toàn độc lập với độ dài khổng lồ của văn bản. Khái niệm đó gọi là Cây Hậu Tố (Suffix Tree).

1. Giải phẫu Suffix Tree: Bắt nguồn từ Suffix Trie

Để hiểu Suffix Tree, hãy bắt đầu bằng một cấu trúc đơn giản hơn: Suffix Trie (Cây tiền tố của mọi hậu tố).

Lấy ví dụ văn bản T = "BANANA$" (Ký tự $ được thêm vào để đánh dấu kết thúc chuỗi, đảm bảo không có hậu tố nào là tiền tố của hậu tố khác).

Tập hợp tất cả các hậu tố của BANANA$ từ dài đến ngắn:

  1. BANANA$ (Bắt đầu từ index 0)

  2. ANANA$ (Bắt đầu từ index 1)

  3. NANA$ (Bắt đầu từ index 2)

  4. ANA$ (Bắt đầu từ index 3)

  5. NA$ (Bắt đầu từ index 4)

  6. A$ (Bắt đầu từ index 5)

  7. $ (Bắt đầu từ index 6)

Nếu bạn lấy toàn bộ 7 hậu tố này và chèn vào một cây Trie (như Bài 52), bạn sẽ có một Suffix Trie. Bất kỳ chuỗi con (substring) nào của "BANANA" cũng chắc chắn là tiền tố của một hậu tố nào đó. Ví dụ: Chuỗi con "NAN" chính là tiền tố của hậu tố "NANA$". Do đó, tìm chuỗi con trong văn bản quy về việc tìm tiền tố trên cây Suffix Trie.

Từ Trie tiến hóa lên Tree (Path Compression)

Suffix Trie có một điểm yếu chí mạng: Nếu văn bản dài NN, cây có thể phình to ra O(N2)O(N^2) node.

Để tối ưu, các kỹ sư hệ thống thực hiện Nén đường đi (Path Compression): Bất kỳ Node nào chỉ có đúng 1 con (không rẽ nhánh) sẽ được gộp chung với Node con của nó, tạo thành một nhánh chứa một chuỗi ký tự thay vì một ký tự đơn lẻ.

Kết quả thu được là Suffix Tree — một cấu trúc được chứng minh toán học là luôn có chính xác tối đa 2N−12N - 1 node, đưa không gian lưu trữ về O(N)O(N) tuyến tính.

2. Bài toán xây dựng: Thuật toán Ukkonen

Xây dựng một Suffix Tree bằng cách chèn từng hậu tố một cách ngây thơ (Naive method) sẽ mất thời gian O(N2)O(N^2). Với văn bản dài 1 triệu ký tự, hệ thống vẫn sẽ treo.

Năm 1995, Esko Ukkonen đã công bố một thuật toán kiệt tác cho phép xây dựng Suffix Tree trực tuyến (Online) trong thời gian O(N)O(N) tuyệt đối. Thuật toán Ukkonen duy trì các con trỏ trạng thái (Active Point) và các liên kết hậu tố (Suffix Links) để nhảy cóc giữa các nhánh của cây, giúp chèn ký tự mới vào tất cả các hậu tố trong thời gian khấu hao O(1)O(1).

(Vì thuật toán Ukkonen dài hàng trăm dòng code và chứa các cỗ máy trạng thái phức tạp, trong kỹ thuật phần mềm thực tế, các backend developer thường sử dụng các thư viện C++ / Go CGO tối ưu sẵn để thực thi việc khởi tạo).

3. Triển khai Suffix Trie (Conceptual) bằng Golang

Dưới đây là mã nguồn minh họa nguyên lý cốt lõi: Cách xây dựng cây hậu tố (dạng chưa nén để dễ hiểu logic) và thuật toán tìm kiếm mọi vị trí xuất hiện của một chuỗi con chỉ trong thời gian O(M)O(M).

Go

package main

import "fmt"

// SuffixNode đại diện cho một node trong cây
type SuffixNode struct {
	children map[rune]*SuffixNode
	indices  []int // Lưu trữ các vị trí bắt đầu của hậu tố đi qua node này
}

type SuffixTree struct {
	root *SuffixNode
}

// Hàm khởi tạo và xây dựng Suffix Trie (Naive O(N^2) cho mục đích giáo dục)
func NewSuffixTree(text string) *SuffixTree {
	tree := &SuffixTree{
		root: &SuffixNode{children: make(map[rune]*SuffixNode)},
	}

	// Đưa tất cả các hậu tố vào cây
	for i := 0; i < len(text); i++ {
		tree.insertSuffix(text[i:], i)
	}
	return tree
}

func (st *SuffixTree) insertSuffix(suffix string, originalIndex int) {
	current := st.root
	for _, ch := range suffix {
		if _, exists := current.children[ch]; !exists {
			current.children[ch] = &SuffixNode{children: make(map[rune]*SuffixNode)}
		}
		current = current.children[ch]
		// Ghi nhận: Hậu tố bắt đầu tại originalIndex đi qua node này
		current.indices = append(current.indices, originalIndex)
	}
}

// Hàm tìm kiếm tất cả các vị trí xuất hiện của một chuỗi con (Pattern)
// Độ phức tạp thời gian: O(M) với M là độ dài của Pattern
func (st *SuffixTree) Search(pattern string) []int {
	current := st.root
	for _, ch := range pattern {
		if node, exists := current.children[ch]; exists {
			current = node
		} else {
			// Chuỗi con không tồn tại trong văn bản
			return nil 
		}
	}
	// Nếu đi hết Pattern, toàn bộ các indices lưu tại node cuối cùng
	// chính là các vị trí mà chuỗi con xuất hiện trong văn bản gốc.
	return current.indices
}

func main() {
	text := "BANANA"
	// Khởi tạo cây một lần duy nhất
	tree := NewSuffixTree(text)

	// Truy vấn siêu tốc
	pattern := "ANA"
	result := tree.Search(pattern)
	
	if result != nil {
		fmt.Printf("Chuỗi '%s' xuất hiện tại các chỉ mục: %v\n", pattern, result)
		// Output mong đợi: [1 3] (vị trí bANAna và banANA)
	} else {
		fmt.Printf("Không tìm thấy '%s'\n", pattern)
	}
}

4. Phân tích Hiệu năng (Big-O)

  • Thời gian Xây dựng (Build Time): O(N)O(N) bằng Thuật toán Ukkonen (hoặc O(N2)O(N^2) với Naive method). Thực hiện một lần duy nhất lúc khởi động hệ thống.

  • Thời gian Truy vấn (Query Time): O(M+K)O(M + K) với MM là độ dài chuỗi cần tìm, và KK là số lần chuỗi đó xuất hiện. Nó không hề phụ thuộc vào độ dài văn bản NN. Bạn tìm chuỗi dài 10 ký tự trong một văn bản dài 1 tỷ ký tự, nó vẫn chỉ tốn đúng 10 bước lặp.

  • Không gian (RAM): O(N)O(N) sau khi áp dụng Path Compression. Tuy nhiên, hằng số cấu trúc của Suffix Tree khá lớn (nhiều pointer liên kết), khiến nó ngốn một lượng RAM đáng kể trong thực tế ( 10~10 đến 2020 byte cho mỗi ký tự văn bản).

5. Ứng dụng Thực tế ở Kiến trúc Hệ thống

Suffix Tree là "cây đũa thần" xử lý hàng loạt bài toán chuỗi hóc búa nhất:

  • Tin sinh học (Bioinformatics): Lắp ráp bộ gen, tìm đoạn DNA chung dài nhất (Longest Common Substring) giữa hai chuỗi gen của virus, hoặc đếm số lượng đoạn mã lặp lại (Tandem repeats) để phân tích đột biến.

  • Hệ thống Chống đạo văn (Plagiarism Detection): Tìm kiếm các khối mã nguồn hoặc đoạn văn bản trùng lặp chính xác giữa hàng triệu tài liệu chỉ trong nháy mắt.

  • Nén dữ liệu mức hệ thống: Là nền tảng cho các thuật toán nén từ điển như LZ77 (được sử dụng trong định dạng .zip, .gzip và .png) hoặc BWT (Burrows-Wheeler Transform) giúp tìm kiếm mẫu chuỗi lặp để thu gọn kích thước tệp tin.

Vì Suffix Tree ngốn khá nhiều RAM do cấu trúc Pointer chằng chịt, các hệ thống cơ sở dữ liệu hiện đại (như ElasticSearch hoặc hệ thống tìm kiếm mã nguồn) thường ưu tiên một biến thể "phẳng hóa" của nó — lưu trữ hoàn toàn trên mảng tuyến tính để thân thiện với CPU Cache và giảm thiểu 80% RAM, đó chính là Mảng Hậu Tố (Suffix Array).


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí