Bài 3 chúng ta đã đua các giải thuật sắp xếp mảng số. Bài này chuyển sang một bài toán rất khác: tìm kiếm chuỗi theo tiền tố — nền tảng của autocomplete trên thanh tìm kiếm, gợi ý gõ chữ trên điện thoại (T9), và bộ kiểm tra chính tả.

1. Vì sao Hash Table không đủ cho tìm kiếm tiền tố?

Hash Table cho tra cứu chính xác cực nhanh — $O(1)$ trung bình để kiểm tra một từ có tồn tại hay không. Nhưng thử hỏi: "liệt kê tất cả các từ trong từ điển bắt đầu bằng 'ca'"? Hash Table không có khái niệm "gần nhau" giữa các khóa đã băm — bạn buộc phải quét toàn bộ $n$ từ trong từ điển và kiểm tra từng từ một, tốn $O(n)$ dù từ điển đã được băm sẵn.

Trie (đọc là "try", từ chữ retrieval — tra cứu) giải quyết đúng vấn đề này: các từ có chung tiền tố sẽ chia sẻ chung một nhánh cây, nên tìm tất cả từ theo tiền tố chỉ cần đi tới đúng node tiền tố rồi duyệt nhánh con của nó — không cần quét cả từ điển.

2. Cấu trúc Trie: Node chia sẻ tiền tố chung

Mỗi node Trie đại diện cho một ký tự, có một tập con trỏ tới các node ký tự tiếp theo, và một cờ đánh dấu "đây có phải điểm kết thúc một từ hoàn chỉnh hay không":

trie-node.js
class TrieNode {
  constructor() {
    this.children = new Map(); // ký tự -> TrieNode con
    this.isEndOfWord = false;  // true nếu có từ kết thúc tại đây
  }
}

class Trie {
  constructor() {
    this.root = new TrieNode();
  }
}
ℹ️ Lưu ý: Vì sao dùng Map thay vì mảng cố định 26 phần tử?
Nhiều tài liệu tiếng Anh dùng children[26] (mảng cố định a-z) vì bảng chữ cái Latin chỉ có 26 ký tự. Nhưng tiếng Việt có dấu (á, ế, ộ, ư...) và Unicode nói chung có hàng ngàn ký tự khả dĩ — dùng Map linh hoạt hơn nhiều, chỉ tốn bộ nhớ cho các ký tự thực sự xuất hiện, và hoạt động đúng với mọi bảng chữ cái.

3. Chèn & Tìm kiếm trong Trie

Chèn một từ dài $L$ ký tự chỉ cần đi qua tối đa $L$ node, tạo mới node nào chưa tồn tại. Độ phức tạp luôn là $O(L)$ — không phụ thuộc vào việc từ điển có bao nhiêu từ khác:

trie-insert-search.js
Trie.prototype.insert = function (word) {
  let node = this.root;
  for (const ch of word) {
    if (!node.children.has(ch)) {
      node.children.set(ch, new TrieNode());
    }
    node = node.children.get(ch);
  }
  node.isEndOfWord = true; // đánh dấu điểm kết thúc từ
};

Trie.prototype.search = function (word) {
  let node = this.root;
  for (const ch of word) {
    if (!node.children.has(ch)) return false;
    node = node.children.get(ch);
  }
  return node.isEndOfWord; // PHẢI kiểm tra cờ này, không chỉ node tồn tại!
};

Trie.prototype.startsWith = function (prefix) {
  let node = this.root;
  for (const ch of prefix) {
    if (!node.children.has(ch)) return false;
    node = node.children.get(ch);
  }
  return true; // chỉ cần đường đi tồn tại, không cần isEndOfWord
};
🕳️ Cạm bẫy thường gặp: Nhầm "tiền tố tồn tại" với "từ tồn tại"
Lỗi phổ biến nhất khi cài Trie: chỉ kiểm tra node cuối cùng có tồn tại hay không mà quên kiểm tra isEndOfWord. Ví dụ nếu từ điển chỉ có từ "category" (không có "cat" riêng), đường đi c-a-t vẫn tồn tại trong cây (vì nó là tiền tố của "category"), nhưng search("cat") phải trả về false vì "cat" không phải là một từ hoàn chỉnh đã chèn — startsWith("cat") mới trả về true.

4. Autocomplete: Duyệt DFS từ node tiền tố

Để gợi ý autocomplete, ta chỉ cần đi tới node cuối của tiền tố người dùng gõ, rồi duyệt DFS toàn bộ cây con bên dưới, thu thập mọi node có isEndOfWord = true:

trie-autocomplete.js
Trie.prototype.autocomplete = function (prefix) {
  let node = this.root;
  for (const ch of prefix) {
    if (!node.children.has(ch)) return []; // không có từ nào khớp tiền tố
    node = node.children.get(ch);
  }

  const results = [];
  function dfs(node, path) {
    if (node.isEndOfWord) results.push(prefix.slice(0, -1) === '' ? path : prefix + path.slice(prefix.length));
    for (const [ch, child] of node.children) {
      dfs(child, path + ch);
    }
  }
  dfs(node, prefix);
  return results;
};

Độ phức tạp autocomplete là $O(L + k)$ với $L$ là độ dài tiền tố và $k$ là tổng số ký tự trong tất cả kết quả trả về — nhanh hơn rất nhiều so với quét toàn bộ $n$ từ trong từ điển khi $n$ lớn.

🔬 Đào sâu: Trie nén (Compressed Trie / Radix Trie)
Trie cơ bản tốn khá nhiều bộ nhớ vì mỗi ký tự đơn lẻ là một node riêng — một từ 20 ký tự tạo ra 20 node dù không phân nhánh. Radix Trie (hay Patricia Trie) nén các chuỗi node không phân nhánh thành một cạnh duy nhất chứa cả chuỗi con, giảm đáng kể số node. Đây chính là cấu trúc dữ liệu đứng sau bảng định tuyến IP (IP routing tables) trong router mạng — nơi cần tra cứu tiền tố CIDR cực nhanh trên hàng triệu route.

5. So sánh Trie với Hash Table và BST

Tiêu chí Hash Table BST (cây nhị phân) Trie
Tra cứu chính xác 1 từ $O(1)$ trung bình $O(\log n)$ $O(L)$ (L = độ dài từ)
Tìm mọi từ theo tiền tố $O(n)$ — phải quét hết Không tự nhiên, cần duyệt lệch $O(L + k)$ — rất nhanh
Bộ nhớ Gọn Gọn Tốn hơn (mỗi ký tự 1 node), có thể nén
Ứng dụng điển hình Cache, kiểm tra tồn tại nhanh Dữ liệu có thứ tự, range query Autocomplete, spell-check, IP routing
💡 Mẹo: Trie xuất hiện ở đâu trong thực tế?
Thanh tìm kiếm Google gợi ý từ khóa khi bạn gõ, bàn phím điện thoại dự đoán từ tiếp theo (T9), trình soạn thảo code gợi ý tên biến/hàm khi gõ vài ký tự đầu, và bộ kiểm tra chính tả đều dùng biến thể của Trie. Bất cứ khi nào bạn thấy gợi ý xuất hiện ngay khi đang gõ (chưa gõ xong), rất có thể phía sau là một cấu trúc Trie.

Sân chơi tương tác: Trie Autocomplete Visualizer

Thêm từ vào từ điển, rồi gõ vào ô "Autocomplete" để xem đường đi trong cây được tô sáng và danh sách gợi ý cập nhật theo từng ký tự bạn gõ.

🔤 Sân chơi tương tác: Trie Autocomplete

Quản lý từ điển

Autocomplete

trie-live.js

Trắc nghiệm ôn tập

Câu 1: Từ điển chỉ chứa duy nhất từ "category" (chưa từng chèn "cat"). Gọi search("cat")startsWith("cat") sẽ trả về gì?

Trắc nghiệm ôn tập

Câu 2: Vì sao thao tác autocomplete trên Trie ($O(L+k)$) nhanh hơn nhiều so với quét toàn bộ từ điển bằng Hash Table ($O(n)$) khi từ điển có hàng triệu từ?

Trắc nghiệm ôn tập

Câu 3: Vì sao Trie cho tiếng Việt nên dùng Map làm children thay vì mảng cố định 26 phần tử (a-z) như nhiều ví dụ tiếng Anh?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 5: Union-Find / Disjoint Set Bài 3: Sorting Algorithms Visualizer Quay lại Lộ trình Series DSA Series C: Mảng & Chuỗi Ký Tự