Trie
Накратко: Trie е дърво от букви, в което общото начало на думите се пази веднъж, затова търсенето по префикс (автодовършване) струва колкото дължината на префикса.
Проблем
Полето за търсене
Магазинът има 200 000 продукта. Клиентът пише в полето за търсене „ка“, и под него трябва да излязат предложения: „кафе“, „кана“, „какао“. С всяка нова буква предложенията се сменят.
Първото решение минава през всички имена и проверява кое започва с написаното. Това е линейно търсене: 200 000 проверки при всеки натиснат клавиш. Хиляда клиента, които пишат едновременно, правят стотици милиони проверки в секунда.
А повечето имена дори не започват с „к“. Защо изобщо ги гледаш?
Идеята
Дърво от букви
Нарисувай имената като пътища. От корена тръгват клони по първата буква, от всеки от тях по втората, и т.н. „Кафе“, „какао“ и „кана“ почват с „ка“: тези две букви се пазят веднъж, а клоните се разделят чак след тях.
Това дърво е trie (от retrieval, най-често се чете „трай“). Всеки възел пази:
- децата си, по буква: колекция буква → възел;
- знак, че тук свършва име. Едно име може да свършва вътре в друго: „кафе“ и „кафемелачка“.
Търсенето по префикс има две части:
- Слизане по буквите на префикса, по една стъпка на буква. Ако някоя буква липсва, няма такива продукти.
- Събиране: обхождане на клона под последния възел. Всеки възел с „тук свършва име“ е предложение.
Слизането струва колкото буквите на префикса, а не колкото продуктите.
Виж го
Вмъкване и търсене по префикс
Map пази децата по реда на вмъкване. Затова събирането подрежда буквите със sort, по азбучен ред.class Node { kids = new Map(); end = false; count = 0;}
class Trie { #root = new Node();
insert(name, count) { let at = this.#root; for (const ch of name) { if (!at.kids.has(ch)) at.kids.set(ch, new Node()); at = at.kids.get(ch); } at.end = true; at.count = count; }
complete(prefix) { let at = this.#root; for (const ch of prefix) { at = at.kids.get(ch); if (at === undefined) return []; } const found = []; this.#collect(at, prefix, found); return found; }
#collect(at, word, found) { if (at.end) found.push(word); const letters = [...at.kids.keys()].sort(); for (const ch of letters) { this.#collect(at.kids.get(ch), word + ch, found); } }}Събирането е рекурсия, както в урока „Рекурсия“: всеки възел събира своите имена и пита децата си. Дъното е възел без деца.
Четири продукта и три търсения:
const trie = new Trie();trie.insert("кафе", 120);trie.insert("какао", 15);trie.insert("кана", 40);trie.insert("чай", 60);console.log(`ка: [${trie.complete("ка").join(", ")}]`);console.log(`каф: [${trie.complete("каф").join(", ")}]`);console.log(`м: [${trie.complete("м").join(", ")}]`);ка: [какао, кана, кафе]каф: [кафе]м: []class Node { kids = new Map(); end = false; count = 0;}
class Trie { #root = new Node();
insert(name, count) { let at = this.#root; for (const ch of name) { if (!at.kids.has(ch)) at.kids.set(ch, new Node()); at = at.kids.get(ch); } at.end = true; at.count = count; }
complete(prefix) { let at = this.#root; for (const ch of prefix) { at = at.kids.get(ch); if (at === undefined) return []; } const found = []; this.#collect(at, prefix, found); return found; }
#collect(at, word, found) { if (at.end) found.push(word); const letters = [...at.kids.keys()].sort(); for (const ch of letters) { this.#collect(at.kids.get(ch), word + ch, found); } }}Стъпка по стъпка, с думи
- Празно trie: само коренът. Всяка стъпка надолу е една буква, затова пътят от корена изписва име.
- insert(„кафе“): букви още няма, затова всяка стъпка прави нов възел. Последният става зелен: тук свършва име.
- „какао“ почва като „кафе“: първите две букви ги има и само се минава през тях. Нови възли има само там, където имената се разделят.
- „кана“ също споделя началото, а „чай“ почва свой клон от корена. Четири имена, общо начало.
- complete(„ка“): две стъпки надолу, по една за всяка буква на префикса. Това е цялото търсене, колкото и продукта да има.
- Събирането: обхожда се само клонът под префикса, децата по азбучен ред. Всеки зелен възел е име и отива в списъка.
- Три допълнения: какао, кана, кафе. „чай“ изобщо не е погледнат: клонът му не е под префикса.
„Чай“ изобщо не е погледнат: клонът му не е под „ка“.
Пробвай
Предвиди изхода
Три продукта. Едното име е начало на друго:
const trie = new Trie();trie.insert("кафе", 1);trie.insert("кабел", 1);trie.insert("кафемелачка", 1);console.log(`каф: [${trie.complete("каф").join(", ")}]`);Пробвай
Попълни
Кое прави възела предложение при събирането?
if (at.) found.push(word);Не всеки възел е име: „ка“ и „как“ са само начала. Име е там, където вмъкването е сложило знака за край.
Идеята
Колко струва
- Вмъкване: една стъпка на буква от името: O(m), където m е дължината му.
- Слизане по префикс: една стъпка на буква от префикса: O(m), колкото и продукта да има.
- Събиране: колкото е клонът под префикса. Ако показваш само първите 5 предложения, спираш след петото.
Пусни трите начина. Каталогът е n имена от 4 до 8 букви, по азбучен ред. Клиентът пише 20 префикса от по две букви, и за всеки трябват първите 5 имена. Броят се знаците, които търсенето поглежда: сравнение на буква от името с буква от префикса или стъпка от възел към дете. Trie-то се строи веднъж, при зареждането на каталога, и не се брои.
- Обхождане на всички имена
O(n) - Двоично търсене в подредения каталог
O(log n) - Trie
O(1)
| Алгоритъм | n = 10 | n = 1000 | n = 10 000 | n = 100 000 |
|---|---|---|---|---|
| Обхождане на всички имена | 223 | 11 778 | 93 071 | 1 303 482 |
| Двоично търсене в подредения каталог | 125 | 542 | 676 | 808 |
| Trie | 46 | 403 | 342 | 266 |
class Node { kids = new Map(); end = false; count = 0;}
class Trie { #root = new Node();
insert(name, count) { let at = this.#root; for (const ch of name) { if (!at.kids.has(ch)) at.kids.set(ch, new Node()); at = at.kids.get(ch); } at.end = true; at.count = count; }
complete(prefix) { let at = this.#root; for (const ch of prefix) { at = at.kids.get(ch); if (at === undefined) return []; } const found = []; this.#collect(at, prefix, found); return found; }
#collect(at, word, found) { if (at.end) found.push(word); const letters = [...at.kids.keys()].sort(); for (const ch of letters) { this.#collect(at.kids.get(ch), word + ch, found); } }}При 100 000 имена обхождането поглежда 1 303 482 знака, двоичното търсене в подредения каталог 808, а trie-то 266. Колкото повече са имената, толкова по-гъсто е дървото и толкова по-бързо се събират петте: при 1000 имена trie-то поглежда 403 знака, при 100 000 по-малко.
Двоичното търсене е тук, защото каталогът е подреден: тогава то намира първото име с префикса за log₂ n сравнения (двоично търсене). За него виж по-долу.
Виж го
Първо най-купуваното
Азбучният ред не е най-полезен. Ако клиент пише „ка“, по-вероятно търси кафе, отколкото какао. Затова всеки възел, в който свършва име, пази и колко пъти е купуван продуктът (count). Предложенията се подреждат по него:
countOf(name) { let at = this.#root; for (const ch of name) at = at.kids.get(ch); return at.count;}
suggest(prefix, k) { const names = this.complete(prefix); names.sort((a, b) => this.countOf(b) - this.countOf(a)); return names.slice(0, k);}const trie = new Trie();trie.insert("кафе", 120);trie.insert("какао", 15);trie.insert("кана", 40);trie.insert("чай", 60);trie.insert("кафемелачка", 8);trie.insert("кабел", 25);console.log(`ка: [${trie.suggest("ка", 3).join(", ")}]`);ка: [кафе, кана, кабел]Подреждането е стабилно (виж стабилността): при равен брой купувания остава азбучният ред.
По-точно, за любопитните
Тук всяко търсене събира всички имена под префикса и после ги подрежда. При „к“ в истински каталог това са десетки хиляди. Затова големите търсачки пазят във всеки възел готовите първи 5–10 предложения за клона под него. Търсенето става само слизане, а цената е при вмъкването: всеки възел по пътя на ново име проверява дали то влиза в неговите първи.
Пробвай
Намери грешката
Колега е написал събирането, но предложенията са пълни с парчета от думи:
#collect(at, word, found) { found.push(word); const letters = [...at.kids.keys()].sort(); for (const ch of letters) { this.#collect(at.kids.get(ch), word + ch, found); }}const trie = new Trie();trie.insert("кафе", 120);trie.insert("какао", 15);trie.insert("кана", 40);trie.insert("чай", 60);console.log(`ка: [${trie.complete("ка").join(", ")}]`);ка: [ка, как, кака, какао, кан, кана, каф, кафе]На кой ред е грешката?
Избери реда с грешката.
Всеки възел се добавя като име, дори „ка“ и „как“, които са само начала. Трябва проверка на знака за край: if (at.end). С нея изходът е ка: [какао, кана, кафе].
Идеята
Кирилица и главни букви
За trie-то „К“ и „к“ са различни букви: различни деца на възела. Клиент, който пише „Кафе“ или „ кафе“ с интервал отпред, няма да намери нищо. Затова и името при вмъкване, и написаното при търсене минават през едно и също нормализиране: без интервали в краищата, един интервал между думите, малки букви.
function normalize(text) { return text.trim().toLowerCase().replace(/\s+/g, " ");}const trie = new Trie();trie.insert("кафе", 120);trie.insert("какао", 15);trie.insert("кана", 40);trie.insert("чай", 60);trie.insert("кафемелачка", 8);trie.insert("кабел", 25);console.log(`КаФе: [${trie.complete(normalize(" КаФе ")).join(", ")}]`);console.log(`кафe: [${trie.complete(normalize("кафe")).join(", ")}]`);КаФе: [кафе, кафемелачка]кафe: []Второто търсене не намира нищо, макар че „кафe“ изглежда точно като „кафе“. Последната буква е латинското „e“, а не кирилското „е“: друг знак, друго дете в дървото. Така се случва, когато клиентът забрави да смени клавиатурата. Нормализирането не го поправя само: трябва изрична замяна на латинските букви, които изглеждат като кирилски, ако искаш да ги прощаваш.
По-точно, за любопитните
toLowerCase() не зависи от езика на машината. toLocaleLowerCase("tr") би сменил „I“ на „ı“. for…of обхожда низа по знаци (code points), затова и емоджи са по една „буква“. Индексът name[i] обаче е по 16-битови единици.Как е в другите езици: Java и C# обхождат по 16-битов char, Python и for…of в JavaScript по знаци. За кирилицата и латиницата няма разлика.
Идеята
Паметта и кога стига подреден масив
Всеки възел пази своите деца. Има два начина:
- Колекция буква → възел, както тук: възелът пази само децата, които има.
- Масив от места, по едно за всяка буква: 26 за латиницата, 30 за българската азбука, 32 с интервал и тире. Детето се намира веднага по номера на буквата, но всеки възел пази всичките места, и празните.
Колко празни са? В trie-то на Bench за 100 000 имена (631 833 букви) има 210 642 възела, средно по едно дете на възел: общото начало е кратко, после всяко име си върви само. Масив от 30 места на възел пази над 6,3 милиона препратки, от които заети са 210 641. Колекцията е по-малка, а масивът е по-бърз само в горните нива, където децата са много.
Ако имената се сменят рядко и ти трябва само „всички, които започват с …“, често стига подреден масив. Двоичното търсене намира първото име, което не е преди префикса, а съвпаденията стоят едно до друго след него:
const names = ["кабел", "какао", "кана", "кафе", "кафемелачка", "чай"];const prefix = "ка";let lo = 0;let hi = names.length;while (lo < hi) { const mid = Math.floor((lo + hi) / 2); if (names[mid] < prefix) lo = mid + 1; else hi = mid;}const found = [];for (let at = lo; at < names.length && names[at].startsWith(prefix); at++) { found.push(names[at]);}console.log(`${prefix}: [${found.join(", ")}]`);ка: [кабел, какао, кана, кафе, кафемелачка]< сравнява низовете по кода на знака, както sort() без функция.Trie печели, когато имената се добавят и махат постоянно, когато ти трябват бройки и най-купувани за всеки префикс, или когато търсиш буква по буква, докато клиентът пише: всяка нова буква е една стъпка от възела, до който вече си стигнал.
Упражнение
Колко продукта започват с…
Реши задачата в редактора си.
Брой под префикса. До всяко предложение полето показва колко продукта започват с написаното. Добави във възела брояч колко имена минават през него и метод countPrefix, който слиза по префикса и връща брояча, без да събира имена. Вмъкни целия каталог от картата „Първо най-купуваното“ и провери префиксите „ка“, „каф“, „ч“ и „м“:
ка: 5каф: 2ч: 1м: 0Още задачи
- Изтриване. Продуктът спира да се продава. Махни името от trie-то, без да счупиш имената, които минават през същите възли.
- Брой възли. Преброй възлите след вмъкването на каталога и сравни с общия брой букви в имената.
- Буква по буква. Направи търсене, което пази възела, до който е стигнало, и при всяка нова буква прави само една стъпка.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Сложност по памет · Линейно търсене · Двоично търсене · Стабилно сортиране и вграденият sort
- Продължава в: Как да разпознаеш DP задача
- Задачи по темата: Структури от данни