Мотивация
Hash map даёт O(1) point lookup, но:
- Не поддерживает prefix search ("все ключи на
dev*"). - Не поддерживает sorted iteration.
- Большой оверхед пустых bucket'ов.
Trie (префиксное дерево) лечит эти проблемы: структура разветвляется посимвольно, общий префикс хранится один раз, префиксный поиск -- естественная операция.
Trie: простая версия
Trie (произносится "try") -- дерево, где каждый узел соответствует одной букве (или биту), а путь от корня до узла -- префикс ключа.
Ключи: {"cat", "car", "cart", "can", "dog", "do"}
root
/ \
c d
| |
a o * (значение для "do")
/|\ |
n t r g *
* *|
car*
|
t *
* -- нода помечена как конец ключа (имеет значение)
Search "car":
- root → c → a → r → (помечен end-of-key) → найдено.
Insert "carp":
- root → c → a → r (уже есть), добавляем дочку
p→ мечаем как end.
Свойства
- Search / Insert: O(k), где k -- длина ключа. Не зависит от n (числа ключей)!
- Память: O(total chars in all keys) в худшем случае.
- Sorted iteration: DFS с предпочтением младшей буквы -- лексикографический порядок.
- Prefix search: "найти все на
ca*" = найти узелca, dump subtree.
Проблема наивного trie
Каждая нода хранит массив детей. Если алфавит 256 символов (байты), нода = 256 × 8 байт = 2 KB. На короткие ключи trie может быть больше самих данных.
Решения:
- Хеш-мап детей вместо массива.
- Sorted array + binary search.
- Bit-vector + popcount (как в FST).
Radix tree (compressed trie)
Radix tree (или compact prefix tree) объединяет цепочки узлов с одним ребёнком в одну ноду, подписанную всей подстрокой.
Обычный trie для {"romane", "romanus", "romulus"}:
r-o-m-a-n-e *
|
u-s *
r-o-m-u-l-u-s *
Radix tree:
"rom" ───┬──"an" ─┬─ "e" *
│ └─ "us" *
└──"ulus" *
Экономия для ключей с длинными префиксами огромна.
Свойства
- Search / Insert: O(k).
- Memory: ~O(n × avg_compressed_depth), часто в 10× меньше обычного trie.
- Branching factor небольшой (часто бинарный), но ребро подписано строкой.
Split при вставке
Вставка "roman": нужно разбить ноду "romulus" / "romane".
- Найти общий префикс между "roman" и существующим edge.
- Разбить edge на две ноды: одна с общим префиксом, две дочки с остатками.
Это единственная сложная операция в radix. Search тривиален.
Patricia trie
PATRICIA (Practical Algorithm To Retrieve Information Coded In Alphanumeric, Morrison 1968) -- бинарный radix tree, где:
- Разветвление только по битам (0/1), не по буквам.
- Каждая нода содержит bit-position для теста.
- Нет "пустых" нод -- если нет разветвления, нода сливается с ребёнком.
Патриция очень эффективна для IP routing tables, где ключи -- битовые строки фиксированной длины.
Пример: IP routing
Routes:
192.168.0.0/16 → gateway A
192.168.1.0/24 → gateway B
10.0.0.0/8 → gateway C
Patricia по битам:
root
bit 0 / \ bit 1
...
test bit 8
/ \
10 prefix 192.168 prefix
gw C / \
192.168 192.168.1
gw A gw B
Linux kernel использует Patricia-like trie (struct fib_table) для маршрутизации. Lookup IP = O(32) бит = константа.
Сравнение
| Свойство | Trie | Radix | Patricia |
|---|---|---|---|
| Узлы | Один символ | Подстрока | Бит-позиция |
| Ветвление | По алфавиту (до 256) | По символам подстроки | Бинарное (0/1) |
| Память | Большая | Компактная | Минимальная |
| Реализация | Простая | Средняя | Сложная |
| Best for | Маленький алфавит, автокомплит | Общий prefix tree | Битовые ключи (IP) |
Где используется
| Система | Структура | Назначение |
|---|---|---|
| Redis keys (dict) | Radix tree | Хранение ключей в Cluster, streams, KEYS pattern |
| Linux kernel FIB | Patricia (variant) | IP routing table |
| Elasticsearch / Lucene | FST (automaton) | Term dictionary, автокомплит |
| Aerospike | Radix | Primary index |
| PostgreSQL GIN | B-tree поверх trie-like | Text search |
| Ethereum | Merkle Patricia Trie | State tree |
| React Router | Radix | URL routing |
| Fast HTTP routers (Gorilla, gin, chi) | Radix | URL matching |
| Autocomplete (Google, Amazon) | Trie/radix | Prefix search |
| Prefix-based caching (Varnish) | Radix | URL prefix match |
Merkle Patricia Trie в Ethereum
Ethereum хранит state (accounts, storage) в модифицированной Patricia, где:
- Ключи -- hash адреса (32 байта = 256 бит).
- Каждая нода -- Merkle-нода (хеш содержимого).
- State root = хеш корня = компактное представление всей истории состояния.
Это позволяет:
- Light clients проверять membership через Merkle proof.
- Детерминированно хешировать state для consensus.
- Инкрементально обновлять при изменении одного аккаунта (O(log) работы).
Trie для autocomplete: реализация
<?php
declare(strict_types=1);
/**
* Standard trie for string autocomplete.
* For production at scale use FST (Finite State Transducer) or marisa-trie.
*/
final class TrieNode
{
/** @var array<string, TrieNode> */
public array $children = [];
public bool $isEnd = false;
public int $rank = 0; // popularity / frequency for ranking
}
final class Trie
{
private TrieNode $root;
public function __construct()
{
$this->root = new TrieNode();
}
public function insert(string $word, int $rank = 1): void
{
$node = $this->root;
foreach (mb_str_split(mb_strtolower($word)) as $ch) {
if (!isset($node->children[$ch])) {
$node->children[$ch] = new TrieNode();
}
$node = $node->children[$ch];
}
$node->isEnd = true;
$node->rank = max($node->rank, $rank);
}
public function contains(string $word): bool
{
$node = $this->descend($word);
return $node !== null && $node->isEnd;
}
/**
* Return up to $limit suggestions for given prefix, sorted by rank desc.
* @return list<array{word: string, rank: int}>
*/
public function autocomplete(string $prefix, int $limit = 10): array
{
$node = $this->descend($prefix);
if ($node === null) {
return [];
}
$results = [];
$this->collect($node, mb_strtolower($prefix), $results);
usort($results, fn(array $a, array $b): int => $b['rank'] <=> $a['rank']);
return array_slice($results, 0, $limit);
}
private function descend(string $prefix): ?TrieNode
{
$node = $this->root;
foreach (mb_str_split(mb_strtolower($prefix)) as $ch) {
if (!isset($node->children[$ch])) {
return null;
}
$node = $node->children[$ch];
}
return $node;
}
/** @param list<array{word: string, rank: int}> $results */
private function collect(TrieNode $node, string $path, array &$results): void
{
if ($node->isEnd) {
$results[] = ['word' => $path, 'rank' => $node->rank];
}
foreach ($node->children as $ch => $child) {
$this->collect($child, $path . $ch, $results);
}
}
}
// Usage
// $trie = new Trie();
// $trie->insert('golang', rank: 1000);
// $trie->insert('google', rank: 5000);
// $trie->insert('go', rank: 500);
// print_r($trie->autocomplete('go', limit: 3));
// // [['word'=>'google', 'rank'=>5000], ['word'=>'golang', 'rank'=>1000], ['word'=>'go', 'rank'=>500]]
| Операция | Trie | Hash map |
|---|---|---|
| Exact lookup | O(k) | O(k) hash + O(1) |
| Prefix match all | O(k + output) | O(n) linear scan |
| Sorted iteration | Native DFS | Нужна sorted struct |
Wildcard *abc* |
Поддерживается | Нет |
| Память | O(chars) | O(n × k) |
| Cache locality | Плохая (pointer chase) | Хорошая |
Hash map быстрее для exact lookup на практике. Trie выигрывает, когда запросы -- префиксные.
Production: FST (Finite State Transducer)
Lucene/Elasticsearch используют не trie, а FST -- детерминированный минимизированный automaton. Он сжимает общие суффиксы (trie сжимает только префиксы) и даёт ~5-10× меньшее потребление памяти на типичный dictionary.
Построение FST дороже, но индекс immutable, что делает его приемлемым.
Выводы
- Trie -- дерево, где путь от корня до узла = ключ; O(k) на операции.
- Radix tree -- сжатый trie: ребро подписано подстрокой, память ×10 меньше.
- Patricia -- бинарный radix с bit-testing; оптимален для IP routing и битовых ключей.
- Main use cases: autocomplete, prefix matching, IP routing, URL routing, term dictionaries.
- Ethereum Merkle Patricia Trie = Patricia + Merkle = state tree с authentic proofs.
- В production на больших словарях FST выигрывает у trie; Redis внутри использует radix tree для ключей.
- Для point lookup hash map часто быстрее на практике; trie нужен именно для prefix / sorted операций.