Trie (cây tiền tố)
Cập nhật lần cuối
Trie (đọc là "try"), hay cây tiền tố (prefix tree), lưu một tập chuỗi theo các ký tự của chúng: mỗi cạnh được gắn nhãn bằng một ký tự, và một đường đi từ gốc ghép thành một tiền tố. Các từ có chung tiền tố dùng chung các nút, nên "car", "card" và "care" đều tái sử dụng đường đi c-a-r. Nhấn Phát ở trên để xem các từ được chèn vào từng ký tự một, chỉ rẽ nhánh ở chỗ chúng khác nhau.
Vì mỗi lần tra cứu đi qua một nút cho mỗi ký tự, tìm một từ có độ dài m tốn thời gian O(m) bất kể trie chứa bao nhiêu từ. Điều đó khiến trie lý tưởng cho tự động hoàn thành (autocomplete), kiểm tra chính tả và tìm kiếm theo tiền tố.
Độ phức tạp thời gian và không gian
| Thao tác | Độ phức tạp | Ghi chú |
|---|---|---|
| Chèn | O(m) | m = độ dài của từ |
| Tìm kiếm | O(m) | Mỗi ký tự một bước |
| Truy vấn tiền tố | O(m) | Đi đến nút của tiền tố |
| Không gian | O(total chars) | Tiền tố chung chỉ lưu một lần |
Từng bước (chèn)
| Bước | Điều gì xảy ra |
|---|---|
| 1 | Bắt đầu ở nút gốc. |
| 2 | Với mỗi ký tự trong từ, tìm cạnh con khớp với ký tự đó. |
| 3 | Nếu có, đi theo cạnh đó (tái sử dụng tiền tố chung). |
| 4 | Nếu không, tạo một nút con mới cho ký tự đó. |
| 5 | Sau ký tự cuối cùng, đánh dấu nút đó là kết thúc từ. |
Ví dụ minh họa
Chèn ["car", "card", "care"] vào một trie rỗng:
| Bước | Cấu trúc | Thao tác |
|---|---|---|
Chèn car | root → c → a → r✓ | Chưa có nút con nào khớp, nên tạo c, a, r và đánh dấu r là kết thúc từ. |
Chèn card | root → c → a → r✓ → d✓ | Tái sử dụng đường đi c-a-r có sẵn, rồi tạo một nút con mới d và đánh dấu nó là kết thúc từ. |
Chèn care | root → c → a → r✓ → {d✓, e✓} | Tái sử dụng c-a-r, rẽ nhánh từ r với một nút con mới e, và đánh dấu e là kết thúc từ. |
Tìm care | root → c → a → r → e✓ | Đi qua c, a, r, e; nút cuối được đánh dấu kết thúc từ, nên care có trong trie. |
Tìm ca | root → c → a | Đường đi tồn tại nhưng a không được đánh dấu kết thúc từ, nên ca là một tiền tố chứ không phải từ đã lưu. |
Khi nào nên dùng trie
| Nên dùng khi | Tránh dùng khi |
|---|---|
| Bạn cần truy vấn tiền tố hoặc tự động hoàn thành trên một tập chuỗi. | Bạn chỉ cần tra cứu theo nguyên khóa: bảng băm nhanh và nhẹ hơn. |
| Nhiều từ được lưu có chung tiền tố, nên các nút được tái sử dụng. | Các khóa dài và hiếm khi trùng nhau, gây lãng phí một nút cho mỗi ký tự. |
| Bạn muốn nhận các khóa theo thứ tự đã sắp xếp khi duyệt. | Bộ nhớ hạn hẹp: con trỏ đến các nút con ở mỗi nút làm tăng đáng kể chi phí bộ nhớ. |
| Chi phí tra cứu phải phụ thuộc vào độ dài khóa, không phải kích thước dữ liệu. | Bảng chữ cái rất lớn (ví dụ toàn bộ Unicode) và các nút con được lưu dày đặc. |
Code Trie (Prefix Tree)
Một cách cài đặt Trie (Prefix Tree) gọn gàng, chạy được ngay bằng Python, JavaScript, Java, C++, C. Chọn một ngôn ngữ, sao chép code hoặc mở sẵn trong Coddy Playground.
Code Trie (Prefix Tree) bằng Python
1class TrieNode:2 def __init__(self):3 self.children = {}4 self.is_word = False5
6
7class Trie:8 def __init__(self):9 self.root = TrieNode()10
11 def insert(self, word):12 node = self.root13 for ch in word:14 node = node.children.setdefault(ch, TrieNode())15 node.is_word = True16
17 def search(self, word):18 node = self._walk(word)19 return node is not None and node.is_word20
21 def starts_with(self, prefix):22 return self._walk(prefix) is not None23
24 def _walk(self, s):25 node = self.root26 for ch in s:27 if ch not in node.children:28 return None29 node = node.children[ch]30 return node31
32
33trie = Trie()34for word in ["car", "card", "care", "dog"]:35 trie.insert(word)36
37print("search(card): ", trie.search("card"))38print("search(ca): ", trie.search("ca"))39print("starts_with(ca): ", trie.starts_with("ca"))40print("starts_with(do): ", trie.starts_with("do"))41print("starts_with(cat): ", trie.starts_with("cat"))Code Trie (Prefix Tree) bằng JavaScript
1class TrieNode {2 constructor() {3 this.children = {};4 this.isEnd = false;5 }6}7
8class Trie {9 constructor() {10 this.root = new TrieNode();11 }12
13 insert(word) {14 let node = this.root;15 for (const ch of word) {16 if (!node.children[ch]) node.children[ch] = new TrieNode();17 node = node.children[ch];18 }19 node.isEnd = true;20 }21
22 // Walk the prefix; null when a character is missing23 findNode(prefix) {24 let node = this.root;25 for (const ch of prefix) {26 node = node.children[ch];27 if (!node) return null;28 }29 return node;30 }31
32 search(word) {33 const node = this.findNode(word);34 return node !== null && node.isEnd;35 }36
37 startsWith(prefix) {38 return this.findNode(prefix) !== null;39 }40}41
42const trie = new Trie();43for (const word of ["car", "card", "care", "dog"]) trie.insert(word);44console.log("search(\"card\"):", trie.search("card"));45console.log("search(\"ca\"):", trie.search("ca"));46console.log("startsWith(\"ca\"):", trie.startsWith("ca"));Code Trie (Prefix Tree) bằng Java
1public class Main {2 static class Node {3 Node[] children = new Node[26];4 boolean isWord;5 }6
7 static Node root = new Node();8
9 static void insert(String word) {10 Node cur = root;11 for (char c : word.toCharArray()) {12 int i = c - 'a';13 if (cur.children[i] == null) cur.children[i] = new Node();14 cur = cur.children[i];15 }16 cur.isWord = true;17 }18
19 // Follow the path for s; null means no word has this prefix20 static Node walk(String s) {21 Node cur = root;22 for (char c : s.toCharArray()) {23 cur = cur.children[c - 'a'];24 if (cur == null) return null;25 }26 return cur;27 }28
29 static boolean search(String word) {30 Node node = walk(word);31 return node != null && node.isWord;32 }33
34 static boolean startsWith(String prefix) {35 return walk(prefix) != null;36 }37
38 public static void main(String[] args) {39 insert("code");40 insert("coder");41 insert("cool");42 System.out.println("search code: " + search("code"));43 System.out.println("search cod: " + search("cod"));44 System.out.println("startsWith cod: " + startsWith("cod"));45 System.out.println("startsWith cat: " + startsWith("cat"));46 }47}Code Trie (Prefix Tree) bằng C++
1#include <iostream>2#include <string>3#include <unordered_map>4
5struct TrieNode {6 std::unordered_map<char, TrieNode*> children;7 bool isEnd = false;8};9
10struct Trie {11 TrieNode* root = new TrieNode();12
13 void insert(const std::string& word) {14 TrieNode* node = root;15 for (char c : word) {16 if (!node->children.count(c)) node->children[c] = new TrieNode();17 node = node->children[c];18 }19 node->isEnd = true;20 }21
22 // Follow the path for s; nullptr if a link is missing23 TrieNode* walk(const std::string& s) const {24 TrieNode* node = root;25 for (char c : s) {26 auto it = node->children.find(c);27 if (it == node->children.end()) return nullptr;28 node = it->second;29 }30 return node;31 }32
33 bool search(const std::string& word) const {34 TrieNode* node = walk(word);35 return node != nullptr && node->isEnd;36 }37
38 bool startsWith(const std::string& prefix) const {39 return walk(prefix) != nullptr;40 }41};42
43int main() {44 Trie trie;45 for (const std::string& word : {"car", "card", "care", "dog"}) {46 trie.insert(word);47 }48 std::cout << std::boolalpha;49 std::cout << "search(card): " << trie.search("card") << "\n";50 std::cout << "search(ca): " << trie.search("ca") << "\n";51 std::cout << "startsWith(ca): " << trie.startsWith("ca") << "\n";52 std::cout << "startsWith(dot): " << trie.startsWith("dot") << "\n";53 return 0;54}Code Trie (Prefix Tree) bằng C
1#include <stdbool.h>2#include <stdio.h>3#include <stdlib.h>4
5typedef struct TrieNode {6 struct TrieNode* children[26];7 bool isEnd;8} TrieNode;9
10TrieNode* newNode(void) {11 return calloc(1, sizeof(TrieNode)); // zeroed links and isEnd12}13
14void insert(TrieNode* root, const char* word) {15 for (; *word; word++) {16 int i = *word - 'a';17 if (root->children[i] == NULL) root->children[i] = newNode();18 root = root->children[i];19 }20 root->isEnd = true;21}22
23// Follow the path for s; NULL if a link is missing24TrieNode* walk(TrieNode* root, const char* s) {25 for (; *s; s++) {26 root = root->children[*s - 'a'];27 if (root == NULL) return NULL;28 }29 return root;30}31
32bool search(TrieNode* root, const char* word) {33 TrieNode* node = walk(root, word);34 return node != NULL && node->isEnd;35}36
37bool startsWith(TrieNode* root, const char* prefix) {38 return walk(root, prefix) != NULL;39}40
41int main(void) {42 TrieNode* root = newNode();43 const char* words[] = {"car", "card", "care", "dog"};44 for (int i = 0; i < 4; i++) insert(root, words[i]);45 printf("search(card): %s\n", search(root, "card") ? "true" : "false");46 printf("search(ca): %s\n", search(root, "ca") ? "true" : "false");47 printf("startsWith(ca): %s\n", startsWith(root, "ca") ? "true" : "false");48 printf("startsWith(dot): %s\n", startsWith(root, "dot") ? "true" : "false");49 return 0;50}Câu hỏi thường gặp về trie
Trie dùng để làm gì?
Trie là nền tảng của các tính năng dựa trên tiền tố: tự động hoàn thành và gợi ý tìm kiếm, kiểm tra chính tả, bảng định tuyến IP và tra từ điển. Ở bất cứ đâu bạn cần truy vấn nhanh kiểu "có từ nào đã lưu bắt đầu bằng tiền tố này không?", trie đều phát huy thế mạnh.
Độ phức tạp thời gian của trie là bao nhiêu?
O(m), trong đó m là độ dài của từ hoặc tiền tố, không phụ thuộc vào số từ đã lưu. Cái giá phải trả là bộ nhớ: trie có thể tốn nhiều không gian, dù các tiền tố chung chỉ được lưu một lần.Trie khác bảng băm như thế nào?
O(1) nhưng không trả lời được truy vấn tiền tố. Trie chậm hơn một chút mỗi lần tra cứu nhưng hỗ trợ tự nhiên tìm kiếm theo tiền tố, duyệt theo thứ tự và tự động hoàn thành, đó là lý do nó được ưu tiên cho những trường hợp này.Khi nào nên dùng trie thay vì cây tìm kiếm nhị phân?
O(m) theo độ dài khóa, trong khi một BST cân bằng tốn O(m log n) vì mỗi phép so sánh phải quét chuỗi và có log n phép so sánh như vậy. BST là lựa chọn tốt hơn khi khóa không phải dạng chuỗi hoặc khi chi phí bộ nhớ quan trọng hơn tốc độ tìm tiền tố.Làm sao xử lý điểm kết thúc của một từ trong trie?
card, nút của car tồn tại nhưng chỉ được coi là một từ nếu car cũng đã được chèn.Trie có luôn tiết kiệm bộ nhớ nhờ dùng chung tiền tố không?
Không phải lúc nào cũng vậy. Dùng chung chỉ có ích khi nhiều khóa trùng nhau; với các khóa dài và khác biệt, trie có thể tốn bộ nhớ hơn nhiều so với hash set, vì mỗi ký tự trở thành một nút riêng kèm các con trỏ đến nút con. Nếu không gian là vấn đề, radix tree nén sẽ gộp các chuỗi nút chỉ có một con để giảm chi phí đó.