open atlas
← Все проекты

algorithms · intermediate · 4d

Движок автодополнения на основе trie

Собери префиксное дерево для ранжированного автодополнения — вставляй слова с весами, проходи каждый префикс за O(длина_префикса + результаты) и детерминированно разрешай ничьи без базы данных.

Trie — это учебная структура данных для автодополнения, но правильное её построение обнажает три отдельные инженерные задачи: правильное проектирование узла и расположения памяти до любого обхода, написание DFS, накапливающего кандидатов без переполнения стека вызовов на длинных строках, и получение детерминированного ранжированного вывода, остающегося корректным при изменении весов без перестройки. Анализ компромисса по памяти в конце связывает алгоритм с реальным продуктовым решением — знание того, когда плоский Map выигрывает у trie, предотвращает избыточную инженерию.

Результат

Класс Trie, вставляющий слова с целочисленными весами, отвечающий на has/startsWith за O(k), возвращающий top-k дополнений для префикса, ранжированных по убыванию веса, затем по алфавиту, и корректно обрабатывающий обновление весов и отсутствующие префиксы.

Этапы

0/6 · 0%
  1. 01Спроектируй узел: карта потомков, флаг конца, вес

    До любого обхода зафиксируй форму одного TrieNode. Классическое представление хранит потомков в массиве из 26 слотов; практичное использует Map<string, TrieNode> (или простой объект), чтобы алфавит был открытым, а память масштабировалась с фактическим набором символов, а не с наихудшим. Каждому узлу нужны три вещи: его потомки, булево значение, помечающее его терминалом допустимого слова, и вес, связанный с этим словом (ноль или отсутствует, когда узел не является терминалом). Вес живёт на терминальном узле, а не на ведущем к нему ребре, потому что префикс разделяет рёбра с многими словами, и все их веса нельзя записать на одно ребро. Выбор неправильного представления здесь потребует полного переписывания на следующем этапе, поэтому явно продумай расположение памяти: массив из 26 слотов тратит 26 × pointer_size на каждый внутренний узел, даже когда присутствует только один потомок, что нормально при тысячах слов, но болезненно при миллионах. Задокументируй свой выбор и его компромисс до написания класса.

    Критерии готовности
    • Тип или класс TrieNode определён с потомками (Map или эквивалент), булевым isEnd и числовым полем weight, и ты написал однопараграфный комментарий, объясняющий, почему выбрал своё представление потомков вместо альтернативы.
    • Конструкция узла поддерживает открытый алфавит (символы не-ASCII могут храниться) без изменения кода, и ты можешь обосновать, почему это важно для реального автодополнения в продукте.
    Самопроверка

    Покажи определение TrieNode и комментарий, обосновывающий выбор представления потомков; senior-ревьюер проверяет, что ты учёл расход памяти на нетерминальный узел и что поле weight находится на терминальном узле, а не распределено по рёбрам.

  2. 02Вставка и точный поиск: insert, has, startsWith

    Реализуй три простейшие операции и зафиксируй их гарантии сложности точно. insert(word, weight) идёт от корня, создавая узлы по мере необходимости, затем помечает последний узел как терминал и записывает его вес. has(word) идёт по тому же пути и возвращает true только если последний узел существует и помечен как терминал — возврат true для хранимого префикса, но не полного слова, является классической ошибкой на единицу здесь. startsWith(prefix) идентичен has, но останавливается на узле префикса и возвращает true, если этот узел вообще существует, независимо от isEnd — именно эта операция делает trie эффективным для автодополнения. Все три операции O(k), где k — длина входной строки; рекурсивная реализация допустима, но итеративная избегает давления на стек вызовов при длинных строках. Семантика обновления веса: если insert вызван дважды для одного и того же слова, перезапиши вес, а не накапливай — тест-сьют утверждает это.

    Критерии готовности
    • insert, has и startsWith проходят юнит-тесты; has возвращает false для слова, являющегося строгим префиксом хранимого слова (например, insert('apple'), затем has('app') === false).
    • Вставка одного и того же слова дважды с разными весами оставляет второй вес, а не сумму; ты зафиксировал эту семантику обновления в комментарии.
    Самопроверка

    Пройди путь для has('ap') после вставки только 'app' и 'apple'; senior-ревьюер проверяет, что возврат false, потому что 'ap' не является терминусом, и что startsWith('ap') возвращает true для того же trie, потому что узел 'p' существует.

  3. 03Собери все слова под префиксом через DFS

    Реализуй обход поддерева, на котором строится автодополнение: дан префикс, перейди к узлу префикса, затем запусти поиск в глубину от этого узла для сбора каждого терминала с его словом и весом. Это операция O(префикс + размер_поддерева) — поддерево может быть большим (каждое слово, разделяющее префикс), поэтому DFS не должен выделять структуры на узел помимо стека рекурсии или небольшой очереди; просто накапливай пары (слово, вес). Построение слова-кандидата по мере рекурсии — стандартный приём: передавай накопленную строку вниз по DFS, а не восстанавливай её из пути. Отсутствующий префикс (нет узла для последнего символа) должен возвращать пустой коллектор, а не выбрасывать — это случай, который автодополнение видит при каждом нажатии клавиши, пока пользователь не набрал распознанный префикс. Выходом этого шага является несортированный массив всех подходящих пар (слово, вес); ранжирование происходит на следующем этапе.

    Критерии готовности
    • Приватный вспомогательный метод collectFrom(node, prefix) возвращает каждую пару (слово, вес), достижимую от этого узла через DFS, и autocomplete('', k) возвращает все вставленные слова.
    • autocomplete по неизвестному префиксу возвращает [] без выбрасывания исключений, и несортированный список кандидатов для 'ap' после вставки 'apple', 'app', 'apt' содержит ровно эти три слова.
    Самопроверка

    Покажи сигнатуру DFS-помощника и объясни, почему накопление строки слова на пути вниз эффективнее восстановления из родительских указателей; senior-ревьюер проверяет, что случай пустого префикса возвращает все слова и что несуществующий префикс возвращает [].

  4. 04Ранжируй и ограничь: autocomplete(prefix, k) с детерминированным разрешением ничьих

    Преврати несортированный список кандидатов из предыдущего этапа в ранжированный ограниченный результат. Сортируй собранные пары (слово, вес) по убыванию веса, а при равных весах — по слову в лексикографически возрастающем порядке — это правило разрешения ничьих делает вывод автодополнения предсказуемым между запусками и тестируемым с точными утверждениями. Возьми только первые k пар и верни их слова в виде строкового массива. Наивный подход — собери все, отсортируй все, обрежь — корректен и хорош для тысяч слов. На senior-глубине ты должен понимать, когда мин-куча размера k выигрывает у сортировки: если поддерево содержит N слов, сбор всех и последующая сортировка — O(N log N); поддержание кучи размера k во время DFS — O(N log k) и избегает материализации полного списка кандидатов, что важно при k << N. Сначала реализуй наивную сортировку, затем укажи альтернативу с кучей как комментарий, а не как преждевременную оптимизацию.

    Критерии готовности
    • autocomplete(prefix, k) возвращает не более k слов, отсортированных по убыванию веса, с лексикографически возрастающим порядком как разрешением ничьих — тест-сьют утверждает этот точный порядок для слов с равным весом.
    • Ты добавил комментарий, объясняющий O(N log k) альтернативу с кучей и когда она важна, без её реализации пока.
    Самопроверка

    Дай вывод autocomplete('a', 2) после вставки ('apple', 3), ('ant', 3), ('apt', 5); senior-ревьюер проверяет, что результат — ['apt', 'ant'] — первый по весу, затем лексикографически среди равных, с ограничением k=2.

  5. 05Обновление весов и консистентность перерасчёта рангов

    Заставь trie корректно реагировать на изменения весов и удаления без перестройки. Вызов insert для уже хранимого слова должен перезаписать его вес на месте (узел уже существует, просто обнови поле weight), и изменение должно немедленно отражаться в следующем вызове autocomplete без шага инвалидации кеша — потому что кеша нет; DFS пересчитывает ранжирование при каждом вызове. Удаление (если ты решишь его реализовать) сложнее: нужно сбросить флаг isEnd и обнулить вес, затем опционально обрезать листовые узлы без потомков-допустимых-слов для экономии памяти, но обрезка не требуется для корректности. Тонкая ошибка, которую надо избежать, — устаревшее агрегатное состояние: некоторые реализации trie кешируют 'максимальный вес в поддереве' на внутренних узлах для раннего обрезания DFS; если ты это делаешь, обновление веса должно распространить новый максимум вверх по цепочке предков. Реши заранее, хочешь ли ты эту оптимизацию и какой инвариант консистентности берёшь на себя поддерживать.

    Критерии готовности
    • Вставка слова дважды с разными весами отражает второй вес в последующих вызовах autocomplete, доказанная тестом, вставляющим ('apple', 1), затем ('apple', 10), затем утверждающим, что 'apple' появляется первым при конкуренции со словом с весом 5.
    • Ты зафиксировал в комментарии, кеширует ли реализация какое-либо агрегатное состояние по поддереву и, если да, какое правило распространения обновлений поддерживает его.
    Самопроверка

    Покажи тест, доказывающий, что повторная вставка веса является заменой, а не накоплением; senior-ревьюер проверяет, что тест изолирует это, сравнивая вывод autocomplete до и после повторной вставки, и что никакой перестройки trie не выполняется между двумя вставками.

  6. 06Анализ компромисса по памяти: плоский Map против сжатого trie

    Сравни свою реализацию с альтернативой на плоском Map и сформулируй, когда одна выигрывает у другой. Плоский Map<string, number> (слово → вес) поддерживает insert и has за O(k) хеш-время, autocomplete за O(N × k) время полного сканирования, где N — общее количество слов. Для небольших словарей плоский Map часто выигрывает по памяти, потому что trie выделяет один узел на символ, а не одну запись на слово. Trie выигрывает, когда запросы по префиксу часты, а словарь велик: O(префикс + результаты) выигрывает у O(N) для autocomplete, когда N >> результаты. Замерь пиковое количество узлов после вставки реалистичного списка слов (например, 5000 английских слов), вычисли примерную память на узел (запись Map ≈ 64 байта на V8, узел с потомками Map ≈ 96–128 байт) и напиши комментарий, указывающий точку пересечения — количество слов и паттерн запросов, при которых trie начинает окупаться в скорости запросов против накладных расходов по памяти. Это именно тот анализ, который senior-инженер делает перед выбором структуры данных в продакшне.

    Критерии готовности
    • Написанный комментарий или краткий документ констатирует: (a) количество узлов после вставки репрезентативного списка слов, (b) приблизительную память на узел против записи плоского Map, и (c) порог паттерна запросов или количества слов, при котором trie является лучшим выбором.
    • Ты можешь назвать один сценарий, где сжатый/radix trie вдвое сократит количество узлов по сравнению с твоей несжатой реализацией, и объяснить стоимость реализации этого сжатия.
    Самопроверка

    Вставь комментарий с количеством узлов и порогом пересечения; senior-ревьюер проверяет, что оценка подкреплена реальным измерением (а не предположением) и что сценарий radix-trie называет конкретный паттерн ввода (например, длинные общие префиксы, как URL или пути к файлам), где сжатие помогает.

Стартер

  • README.md
  • src/trie.ts
  • test/trie.test.ts
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Рубрика

Джуниор Миддл Сеньор
Структура trie и поиск TrieNode определён, insert обходит дерево, создавая узлы; has и startsWith возвращают true для любого вставленного слова или префикса, но has не различает полное слово от хранимого префикса. has проверяет isEnd на терминальном узле, поэтому has('ap') — false, даже когда хранится 'apple'; startsWith возвращает true для любого префикса, чей узел существует, независимо от isEnd; insert перезаписывает вес при дубликате, а не накапливает его. Представление узла обосновано в комментарии против альтернативы с 26-слотовым массивом с конкретной оценкой памяти на внутренний узел; ты можешь назвать количество слов пересечения, при котором сжатый trie оправдает стоимость своей реализации.
Корректность обхода по префиксу autocomplete переходит к узлу префикса и собирает слова; несуществующий префикс выбрасывает исключение или возвращает undefined вместо []. DFS накапливает строку слова на пути вниз; несуществующий префикс возвращает []; autocomplete('', k) возвращает все вставленные слова; список кандидатов для общего префикса содержит ровно нужные слова. Ты изложил O(N log k) альтернативу с кучей в комментарии и можешь обосновать, когда она выигрывает у подхода с сортировкой всех; ты замерил количество узлов после вставки реального списка слов и вывел из этого порог пересечения trie-vs-плоский-Map.
Ранжирование и масштабирование autocomplete возвращает слова под префиксом, но порядок недетерминирован или всегда лексикографический без учёта веса. Результаты отсортированы по убыванию веса, затем по алфавиту по возрастанию; k ограничивает количество результатов; слова с равным весом всегда появляются в согласованном алфавитном порядке, доказанном тестом с двумя словами с равным весом. Повторная вставка веса является заменой (а не накоплением) и немедленно перерасставляет ранги при следующем вызове; ты можешь обосновать общую сложность O(префикс + N log k) для варианта с кучей и назвать продакшн-сценарий (быстро обновляемый корпус предложений), где это важно.
Эталонный разбор (спойлер)

Почему вес живёт на терминальном узле, а не на ребре: ребро представляет символ, разделяемый каждым словом, проходящим через него, поэтому нет единственного веса для хранения на нём. Вес является свойством полного слова, и терминальный узел (где isEnd истинно) — его естественное место. Внутренние узлы могут опционально кешировать максимальный вес в своём поддереве для обрезки DFS, но это оптимизация, а не структурное требование.

Потомки Map против массива из 26 слотов: фиксированный массив даёт O(1) поиск потомка по индексу символа, но тратит 26 указателей на узел, даже когда большинство null — на 1 миллионе узлов это 200 МБ нулей на 64-битной системе. Map или простой объект масштабирует память к фактическому разветвлению, которое для естественного языка в среднем составляет 3–4 потомка на узел, сокращая потери в 7–8 раз. Точка безубыточности — около 10–15 потомков на узел в среднем, что происходит только при высокоравномерных распределениях символов.

Детерминированное разрешение ничьих важно в продакшне: если два слова имеют одинаковый вес и autocomplete может вернуть любое первым в зависимости от порядка хеша или порядка вставки, пользователи видят недетерминированные предложения, меняющиеся между деплоями или перезапусками. Лексикографически возрастающий порядок как разрешение ничьих предсказуем, легко тестируем и стабилен между средами — тот же выбор, что у большинства коммерческих реализаций автодополнения.

Когда плоский Map выигрывает у trie: для словаря из 10 000 слов со сканированием O(k) на слово для autocomplete стоит O(10 000 × средняя_длина_слова). На современных CPU это около 2 мс — достаточно быстро для многих случаев использования. Trie со средним 5-символьным поиском по префиксу стоит O(5 + результаты) независимо от размера словаря, явно выигрывая при 100 000+ словах. Точка пересечения — примерно при 50 000–100 000 словах для типичного английского словаря и k ≤ 10.

Семантика обновления весов в реальных системах: корпус поисковых предложений обычно выводит веса из кликовых показателей или частоты запросов, пересчитываемых ежедневно. Trie, накапливающий веса между обновлениями, сильно отдрейфует от истинного веса через несколько дней; перезаписывающий остаётся точным. Для обновлений весов в реальном времени (например, трендовый поиск) дополнительно нужен кеш максимального веса поддерева, описанный в старшем расширении, потому что DFS без обрезки масштабируется с размером поддерева, а не с размером результата.

Сделай по-сеньорски

  • Замени ранжирование с сортировкой всех на O(N log k) DFS, поддерживающий мин-кучу размера k, чтобы никогда не материализовывать полный список кандидатов — сравни с наивной сортировкой на trie из 100 000 слов и сообщи ускорение при k=5 против k=1000.
  • Реализуй сжатый (Patricia/radix) trie, объединяющий цепочки единственных потомков в одну метку ребра, и замерь сокращение количества узлов и изменение сложности insert/lookup на реальном корпусе английских слов.
  • Добавь операцию delete(word), сбрасывающую флаг терминала, обнуляющую вес и обрезающую недостижимые листовые узлы — докажи, что количество узлов trie после вставки и последующего удаления N слов равно количеству узлов пустого trie.
  • Кешируй maxWeightInSubtree на каждом внутреннем узле и используй это для обрезки ветвей DFS, которые не могут внести вклад в top-k результат — убедись, что обновление веса распространяет новый максимум всем предкам, и напиши тест, доказывающий, что обрезанная ветвь изменила бы результат без кеша.
  • Сериализуй и десериализуй trie в/из компактный двоичный формат (например, массив кортежей BFS-порядка (parentIndex, character, isEnd, weight)) и сравни время холодной загрузки с построением с нуля на словаре из 50 000 слов.

Навыки

trie / prefix treeDFS traversalpriority queue / heaplexicographic orderingweight aggregation

Рекомендуемый стек

typescript