Раздел 1 — Постановка задачи

Система автодополнения прогнозирует и предлагает варианты завершения запроса по мере того, как пользователь вводит текст, символ за символом. Цель заключается в снижении утомляемости при наборе текста, демонстрации популярных или релевантных запросов и улучшении пользовательского опыта (UX) за счет скорости.

 Примеры из реального мира:

  • Google Поиск — самые популярные глобальные варианты автодополнения, персонализированные по истории.
  • YouTube — запросы с видеоинтентом, актуальные темы, названия каналов.
  • Amazon — поиск товаров с подсказками с учетом категорий.
  • GitHub — названия репозиториев, имена пользователей, пути к файлам, символы кода.

 Функциональные требования: учитывая префикс, введенный пользователем, возвращать топ-K (обычно 5–10) наиболее релевантных вариантов завершения в рамках строгого SLA по задержке (менее 100 мс от начала до конца).

 Нефункциональные требования: доступность 99,99%, горизонтальная масштабируемость до миллиардов запросов в день, итоговая согласованность для обновлений частотности (устаревание на несколько минут допустимо), время отклика p99 на уровне ниже 50 мс на уровне API, поддержка нескольких регионов.

Самое жесткое ограничение — это  бюджет задержки. В масштабах Google каждое нажатие клавиши отправляет запрос. Пользователь, вводящий "machine learning", инициирует 16 запросов. При миллионах одновременных пользователей это означает десятки миллионов запросов в секунду при жестком SLA менее 100 мс.---

Раздел 2 — Структура данных Trie

 Trie (префиксное дерево) — это древовидная структура данных, где каждый узел представляет отдельный символ. Путь от корня к любому узлу образует префикс. Путь к отмеченному узлу образует полное слово.

 Почему Trie для автодополнения? Поиск по префиксу выполняется за O(L), где L — длина префикса, что не зависит от размера словаря. При использовании хэш-мапы пришлось бы сканировать все ключи. С отсортированным массивом можно было бы использовать бинарный поиск, но нельзя эффективно извлекать все совпадающие продолжения. Структура Trie буквально отражает саму проблему: навигация вниз по дереву И ЕСТЬ поиск по префиксу.

 Структура узла:

 TrieNode {
    children: Map<char, TrieNode>   // up to 26 for a-z, more for Unicode
    isEndOfWord: bool
    frequency: int                  // how many times this word was searched
    topK: List<{word, freq}>        // cached top-K at this node
}
 


 Операции:

  •  insert(word, freq)  — обход/создание узлов посимвольно, установка  isEndOfWord=true , установка частотности. O(L).
  •  search(prefix)  — переход к узлу префикса. Если не найден, возвращается []. Иначе собираются все слова в поддереве с корнем в этом узле. O(L + N), где N — размер поддерева.
  •  delete(word)  — переход к конечному узлу слова, снятие отметки  isEndOfWord . Опциональная очистка листовых узлов снизу вверх. O(L).

 Сводка по сложности:

Операция Время Память
Вставка O(L) O(L × алфавит)
Поиск по префиксу O(L + N) O(1) дополнительно
Удаление O(L) O(1)
Полное Trie O(N × L × алфавит)

 Преимущества: быстрый поиск по префиксу, естественная группировка префиксов, поддержка подстановочных знаков (wildcards) и нечеткого поиска.  Недостатки: высокий расход памяти — каждый узел хранит до 26+ указателей на дочерние элементы. Для 100 млн терминов со средней длиной 10 наивному Trie требуются десятки гигабайт оперативной памяти. Это вынуждает использовать сжатие и шардирование.


Раздел 3 — Создание автодополнения с использованием Trie

Когда пользователь вводит префикс  q :

  1. Начинаем с корня, проходим по одному узлу для каждого символа  q .
  2. Если у какого-то символа нет соответствующего дочернего ребра, возвращаем пустой результат — вариантов продолжения нет.
  3. В узле префикса выполняем поиск в глубину (DFS) или в ширину (BFS), чтобы собрать всех потомков с  isEndOfWord=true .
  4. Сортируем собранные слова по частотности, возвращаем топ-K.

Наивная версия шага 3 имеет стоимость O(размер_поддерева). Для популярных односимвольных префиксов вроде "a" поддерево может содержать миллионы слов, что абсолютно неприемлемо. Именно поэтому нам требуется кэширование топ-K в каждом узле.


Раздел 4 — Подсказки Топ-K

 Топ-K означает: для любого заданного префикса возвращать только K наиболее релевантных вариантов завершения. Ключевая идея заключается в том, чтобы  предварительно вычислять и кэшировать топ-K в каждом узле Trie, а не обходить все поддерево во время запроса.

 Стратегии ранжирования:

  • Частота поиска — "apple", которую искали 10 млн раз, ранжируется выше, чем "applesauce", которую искали 2 тыс. раз. Базовый сигнал.
  • Актуальность (Recency) — недавние поисковые запросы получают буст с временным затуханием (time-decay). Экспоненциальное затухание:  score = freq × e^(-λ × days_ago) . Позволяет улавливать трендовые темы.
  • Персонализация — ваша собственная история ("я всегда ищу новости об акциях apple") поднимает персональные термины. Вычисляется для каждого пользователя, не может храниться в глобальном Trie.
  • Тренды — обнаружение внезапных всплесков с помощью счетчиков скользящего окна. "Чемпионат мира" дает всплеск во время турниров.

 Хранение топ-K в узлах Trie:

Каждый узел хранит список размером до K пар  {word, score} , представляющих лучшие варианты продолжения, достижимые из этого узла. При вставке нового слова или обновлении частотности изменения распространяются вверх: каждый узел-предок объединяет нового кандидата со своим списком топ-K.

 Куча с минимумами (Min Heap) против Кучи с максимумами (Max Heap):

Используйте  Min Heap размера K для эффективного поддержания топ-K. При обработке нового кандидата:

  • Если размер кучи < K: добавляем напрямую.
  • Если оценка кандидата > минимума в куче: извлекаем минимум, добавляем кандидата.
  • В противном случае: отбрасываем.

Это дает O(log K) на каждую вставку. Окончательное извлечение топ-K происходит за O(K log K).

Для  Max Heap: лучше, когда нужно извлекать элементы в порядке убывания по одному за раз (потоковый вывод). Но для построения топ-K на большом наборе данных подход с Min Heap более эффективен по памяти. С топ-K, кэшированным в каждом узле, запрос префикса  q  становится равным O(L) — обходим L узлов, читаем предвычисленный список топ-K. Сканирование поддерева не требуется.


Раздел 5 — Слой кэширования

Само по себе Trie все еще недостаточно. Даже с поиском за O(L) дерево Trie живет в памяти одного процесса. В масштабах Google вам нужно распределенное кэширование для обработки миллионов QPS без перегрузки серверов Trie.

 Архитектура «кэш на префикс»: Для каждой строки префикса (например, "a", "ap", "app") кэшируйте список результатов топ-K в Redis. Ключ кэша = строка префикса. Значение кэша = JSON-массив подсказок топ-K.

 cache["a"]    → ["apple", "amazon", "airbnb", "android", "api"]
cache["ap"]   → ["apple", "api", "app store", "apex", "apply"]
cache["app"]  → ["app store", "apple music", "apple id", "application", "applebee's"]
 


markdown

 Дизайн ключей кэша: Используйте нормализованный префикс в нижнем регистре в качестве ключа. Для Redis:  autocomplete:v2:{prefix}  — префикс версии позволяет выполнять атомарную инвалидацию кэша при пересборке Trie.

 Стратегии инвалидации кэша:

  • На основе TTL — устанавливайте TTL от 1 до 24 часов в зависимости от популярности префикса. Короткий TTL для горячих/трендовых префиксов, более длинный для стабильных. Просто, но может выдавать устаревшие результаты после вирусного события.
  • Инвалидация на основе событий — при пересборке Trie (см. Раздел 7) публикуйте сообщения об инвалидации в Redis Pub/Sub. Подписчики удаляют затронутые ключи. Более сложно, но данные свежее.
  • Версионированные ключи — при пересборке переключайтесь с  autocomplete:v2:  на  autocomplete:v3: . Старые ключи истекают по TTL. Переход без простоев (zero-downtime).

 Коэффициенты попадания в кэш (hit rates) на практике: Префикс "t" получает миллионы обращений в час — он остается в памяти L1 Redis. "the quick brown" — редкиий запрос, скорее всего будет промах кэша (cache miss). Это степенное распределение (power-law) означает, что примерно 20% префиксов обрабатывают 80% трафика. Кэшируйте их агрессивно; редкие префиксы пусть передаются в службу Trie.

 Проблема лавины кэша (cache stampede): Когда популярный ключ кэша истекает, тысячи одновременных запросов дают промахи и одновременно бомбардируют Trie. Решения: (1) вероятностное раннее истечение срока — обновление ключа до истечения его срока с небольшой вероятностью; (2) мьютекс/блокировка при промахе кэша — первый поток вычисляет, остальные ждут; (3) фоновое обновление — воркер проактивно обновляет топ-N префиксов до истечения TTL.


Раздел 6 — Обновление частоты поисковых запросов

 Поток данных:

  1. Пользователь отправляет поисковый запрос → логируется в поток поисковых событий (топик Kafka  search-events ).
  2. Служба агрегации частотности потребляет этот поток и инкрементирует счетчики в быстром хранилище счетчиков (Redis  INCR  или специализированная служба счетчиков, такая как Druid).
  3. Периодически эти счетчики поступают в офлайн-конвейер пересборки Trie.

 Компромисс между реальным временем и пакетной обработкой:

Обновления в реальном времени Пакетные обновления
Свежесть частотности Секунды Часы
Стоимость записи в Trie Высокая (каждый поиск изменяет Trie) Низкая (пересборка раз за цикл)
Сложность Очень высокая (распределенные блокировки, гонки данных) Умеренная
Выбор для продакшена Только для слоя трендов/сигналов Да, для основного Trie

В масштабах Google мутация Trie в реальном времени нецелесообразна — миллионы записей в секунду с распространением до узлов-предков создают огромную контентцию. Стандартом индустрии являются пакетные пересборки с оверлеем трендов в реальном времени.

 Стратегия инкрементального обновления: Вместо пересборки с нуля каждый час ведите дельта-лог. Применяйте только изменившиеся частотности. Для слова, чья частотность изменилась с 500 до 600, обновите затронутые узлы и пересортируйте списки топ-K снизу вверх. Стоимость: O(L × K log K) на одно обновленное слово.


Раздел 7 — Офлайн-задача обновления Trie

Основной принцип:  никогда не обновляйте живое Trie при каждом поиске. Вместо этого периодически пакетно обрабатывайте поисковые логов и пересобирайте Trie (или применяйте дельты) офлайн, а затем атомарно подменяйте новую версию "на лету" (hot-swap).

 ETL-конвейер: Blue-Green деплоймент версий Trie: В любой момент времени существуют два экземпляра Trie — "синий" (активный, live) и "зеленый" (пересобираемый). Как только зеленый проходит валидацию, обновление конфигурации маршрутизации атомарно перенаправляет трафик. Синий становится целью для отката. Если зеленый показывает аномалии (регрессии, падение покрытия), одно изменение конфигурации выполняет откат за секунды. Никаких простоев, никаких потерь запросов.

 Производственный цикл (cadence): Большинство систем выполняют ежечасные пересборки во время окон с низким трафиком со слоем "переопределения трендов" (trending override), который внедряет сигналы реального времени (всплески поисковых запросов за последние 15 минут) поверх ежечасного Trie.


Раздел 8 — Проектирование масштаба уровня Google

 Допущения: 1 млрд поисков в день = ~11 500 QPS в среднем. С пиковым коэффициентом 3х = ~35 000 QPS в пике. 100 млн уникальных терминов. p99 задержки < 100 мс.  Математика емкости в масштабе:

  • Слой кэша: 100 млн уникальных префиксов × в среднем 200 байт/запись = ~20 ГБ на кластер Redis. С фактором репликации 3 = 60 ГБ. Легко помещается в обычный Redis.
  • Память Trie: 100 млн терминов × в среднем 10 символов × 28 байт/узел ≈ 28 ГБ "сырых" данных. Списки топ-K (K=10, 20 байт/запись) на узел: +20 ГБ. ~50 ГБ всего на одну полную реплику Trie.
  • При разделении 20 узлов Trie на 5 диапазонов префиксов с 4-кратной репликацией = 20 узлов × ~10 ГБ каждый = управляемо.

 Стратегия шардирования: Шардирование по першим 2 символам префикса. "aa"–"am" → шард 1, "an"–"az" → шард 2 и т. д. Каждый шард обрабатывает часть пространства префиксов. Кольцо консистентного хэширования (см. Раздел 9) управляет ребалансировкой при добавлении узлов.

 Высокая доступность (High availability): Каждый шард Trie имеет 3 реплики (лидер + 2 фолловера). Чтения идут на любую реплику. Записи (развертывание нового Trie) идут лидеру, синхронизируются с фолловерами. Если лидер падает, фолловер продвигается в течение секунд через консенсус Raft.

 Аварийное восстановление (Disaster recovery): Межрегиональная репликация снимков (snapshots) Trie в S3/GCS. RPO = последний ежечасный снимок. RTO = ~5 минут на восстановление из снимка в свежий кластер.


Раздел 9 — Распределенное Trie

Одиночное Trie для 100 млн терминов со списками топ-K весит более 50 ГБ и обрабатывает миллионы QPS — его невозможно запустить на одной машине с приемлемой задержкой и надежностью.

 Шардирование по префиксу (на основе диапазонов):

 Shard 0: a–f        (prefixes starting with a,b,c,d,e,f)
Shard 1: g–m
Shard 2: n–s
Shard 3: t–z + digits
 

Простая, предсказуемая маршрутизация. Запрос префикса "apple" всегда идет на шард 0.  Проблема: "Горячие" префиксы. "a" ищут гораздо чаще, чем "x". Шард 0 получает в 10 раз больше трафика, чем шард 3 — это проблема "горячего шарда".

 Шардирование по хэшу: Хэширование строки префикса для определения шарда.  shard = hash("apple") % N . Равномерно распределяет нагрузку.  Проблема: Двухсимвольные префиксы "ap" и "app" попадают на разные шарды, нарушая локальность префиксов. Каждый запрос требует бродкаста на все шарды и слияния результатов — это дорого.

 Консистентное хэширование (Consistent hashing): Размещение шардов на виртуальном кольце. Отображение префикса в точку на кольце; шард по часовой стрелке от этой точки владеет им. Добавление/удаление шардов перераспределяет лишь часть ключей. Поддерживает виртуальные узлы для балансировки нагрузки. Лучше всего подходит для шардирования по диапазонам префиксов, когда требуется плавная ребалансировка.

 Рекомендация для продакшена: Шардировать по первим 2 символам с использованием взвешенной схемы разделения (выделять "a-" больше шардов, чем "x-" пропорционально ожидаемому трафику). Реплицировать каждый шард 3 раза. Использовать консистентное хэширование для назначения шардов, чтобы обеспечить эластичное масштабирование.


Раздел 10 — Альтернативы Trie

Структура данных Память Поиск Лучше всего подходит для Слабые стороны
Стандартное Trie Высокая (26 указателей/узел) O(L) Обучения, небольших словарей Раздувание памяти
Сжатое Trie (Radix) Умеренная O(L) Продуктового поиска по префиксу Сложность реализации
Дерево Патриции (Patricia Trie) Низкая O(L) IP-маршрутизации, бинарных ключей Сложная вставка
Тернарное поисковое дерево (TST) Умеренная O(L + log N) Проверки правописания, упорядоченных операций Медленнее, чем Trie
FST (Конечный преобразователь состояний) Очень низкая (общие суффиксы) O(L) Lucene, продакшн NLP Неизменяемо после построения
Elasticsearch/Lucene Высокая (инвертированный индекс) O(log N) Полнотекстового поиска + автодополнения Избыточно для чистого префикса

 FST (Finite State Transducer) используется в Lucene/Elasticsearch под капотом. Он сжимает как общие префиксы, так и общие суффиксы в ориентированный ациклический граф. Использование памяти в 10–100 раз меньше, чем у стандартного Trie для больших словарей. Компромисс: построение FST обходится дорого, а структура обычно неизменяема — при обновлении происходит пересборка.

 Radix Tree (Радикс-дерево) — это практический выбор для продакшена в кастомных реализациях. Оно сжимает цепочки узлов с одним дочерним элементом (путь "a→p→p" в стандартном Trie становится одним узлом "app"). Экономия памяти составляет примерно ~10 раз для английского словаря. Поиск выполняется за те же O(L).

 Elasticsearch обеспечивает толерантность к опечаткам (поиск по расстоянию редактирования), языковой анализ, скоринг с помощью TF-IDF или BM25 и горизонтальное масштабирование из коробки. Цена за это — значительно большая задержка (10–50 мс) и объем памяти. Для чистого автодополнения (без полнотекстового поиска) это часто избыточная инженерия. Для поиска, совмещающего автодополнение с полнотекстовым ранжированием (поиск кода на GitHub, поиск сообщений в Slack), это правильный выбор.


Раздел 11 — Проблемы продакшена

 Горячие префиксы: Префикс "the" получает астрономически больше трафика, чем "xyz". Пограничный слой CDN обрабатывает это, кэшируя топ-200 тыс. префиксов глобально, что снижает нагрузку на источник на 95%+. Для оставшихся горячих префиксов, не попавших в CDN, слой Redis поглощает нагрузку.

 Лавина кэша (Cache stampede): Когда кэшированный популярный префикс истекает, тысячи одновременных потоков получают промах кэша одновременно. Решение: вероятностное раннее истечение срока (probabilistic early expiry). С вероятностью  p = max(0, (remaining_TTL - beta × log(rand())) / TTL)  превентивно обновлять данные. Это распределяет работу по обновлению по всему окну TTL, а не концентрирует ее в момент истечения срока.

 Потребление памяти: «Сырые» Trie используют слишком много ОЗУ. Решения: (1) сериализация Trie в компактный бинарный формат (в стиле FST), (2) хранение только топ-K вариантов завершения в узле (отбрасывание полного списка слов), (3) агрессивное шардирование для распределения памяти по многим узлам.

 Толерантность к опечаткам: Чистое Trie не может обработать "appel" → "apple". Решения: (1) генерация кандидатов с расстоянием редактирования 1 на стороне клиента и запросы для каждого (дорого), (2) использование отдельного слоя исправления опечаток (SymSpell, BK-деревья) перед поиском в Trie, (3) использование Elasticsearch с запросом  fuzzy  для автодополнения, устойчивого к опечаткам.

 Интернациональные языки: Префиксы UTF-8 означают до 65 000+ возможных "символов" на узел. Решения: (1) хранение дочерних элементов в виде HashMap (а не фиксированного массива из 26 элементов), (2) использование нормализации Unicode (NFC/NFD) для схлопывания вариантов с диакритическими знаками, (3) использование языко-специфичной токенизации (в китайском/японском используются посимвольные Trie, так как там нет пробелов).

 Персонализация: Персональные результаты нельзя хранить в глобальном Trie. Архитектура: (1) сервер возвращает топ-K глобальных подсказок плюс топ-K персональных подсказок из легковесного хранилища личной истории (последние N поисковых запросов пользователя в сортированном множестве); (2) слияние на стороне клиента переранжирует комбинированный список на основе смеси глобальных и персональных оценок.


Раздел 12 — Взгляд с собеседования

 Как это встречается на собеседованиях в FAANG: Обычно формулируется как «Спроектируйте автодополнение для поисковой системы Google» или «Спроектируйте функцию автовывода (type-ahead) для Твиттера». У вас есть 45 минут. Интервьюеры хотят видеть, что вы продвигаетесь от основ Trie к проблемам масштабирования и продакшн-компромиссам.

 Ожидаемый прогресс:

  1. Уточнение требований (SLA по задержке, масштаб, значение топ-K, нужна ли персонализация?)
  2. Описание Trie + топ-K в каждом узле (модель данных в первую очередь)
  3. Выявление проблемы масштабирования с одним Trie → введение слоя кэширования
  4. Введение офлайн-пересборки во избежание конфликтов записи
  5. Рассмотрение распределения: стратегия шардирования с ее компромиссами
  6. Упоминание продакшн-вопросов: толерантность к опечаткам, горячие префиксы, мультирегиональность

 Типичные дополнительные вопросы:

  • «Как бы вы обрабатывали трендовые запросы, появившиеся за последние 10 минут?» → Ответ: слой оверлея трендов в реальном времени, отдельный от ежечасного Trie, внедряемый в момент выдачи.
  • «Как бы вы добавили персонализацию?» → Ответ: личная история хранится для каждого пользователя в сортированном множестве Redis, объединяется на уровне API с глобальным топ-K.
  • «Как бы вы реализовали удаление термина (нарушение авторских прав)?» → Ответ: черный список проверяется во время выдачи, а не в самом Trie. Это быстрее обновлять и не требует пересборки Trie.
  • «Какова ваша стратегия инвалидации кэша?» → Ответ: версионированные ключи + TTL + проактивное обновление для горячих префиксов.

 Распространенные ошибки кандидатов:

  • Переход к распределенным системам без предварительного объяснения базового алгоритма Trie
  • Забывание о том, что наивный поиск по префиксу (без топ-K в узлах) имеет сложность O(размер_поддерева) — интервьюер будет копать в эту сторону
  • Отсутствие упоминания слоя кэша — одно Trie не справляется с 35 000 QPS
  • Выбор шардирования по хэшу для Trie (ломает локальность префиксов)
  • Неспособность решить проблему пути записи (как обновляются частотности)

 Ожидания уровня Senior/Architect: Вы должны затронуть: предотвращение лавины кэша, blue-green деплоймент Trie, компромиссы FST и Trie, архитектуру слоя трендов в реальном времени и различие между глобальными и персонализированными подсказками.


Раздел 13 — Реализация на PHP

Давайте реализуем четыре слоя: базовое Trie, Trie с частотностью, Trie с Top-K и интеграцию с кэшем. PHP недоступен в этой среде, но код полон и корректен. Позвольте скопировать все файлы в выходной каталог, чтобы вы могли их скачать и запустить. Запустите локально с помощью команды  php demo.php  — требуется PHP 8.1+. Демонстрация покажет время попадания и промаха кэша, переранжирование топ-K после обновления частотности и изменение версий кэша.


Раздел 14 — Итоговая сквозная архитектура---

Основное резюме

Вот ментальная модель, которую стоит пронести через любое собеседование:

Система имеет два совершенно раздельных пути —  путь чтения (read path), который должен быть невероятно быстрым, и  путь записи (write path), который может быть медленным и пакетно-ориентированным.

 Путь чтения: Нажатие клавиши пользователем → CDN (первая линия обороны, обрабатывает 60–70% трафика) → Балансировщик нагрузки → Сервер без сохранения состояния (Stateless API Server; нормализует, убирает дребезг) → Поиск в кэше Redis (попадание более 95%) → при промахе, служба Trie (в памяти, шардированная по префиксу, реплицированная). Сам узел Trie возвращает предвычисленный топ-K за время O(L). Итого: менее 100 мс.

 Путь записи: Каждый завершенный поиск → Kafka → агрегация частотности Spark (ежечасное окно) → сборщик Trie (вставка с распространением топ-K) → Валидация → переключение blue-green деплоймента + инкремент версии Redis. Запускается каждый час. Трендовые сигналы могут накладываться практически в реальном времени как отдельный легковесный слой.

 Три инсанта, отличающие ответы уровня Senior:

  1. Наивное Trie (DFS во время запроса) проваливается в масштабе. Кэширование топ-K в каждом узле — это решение.
  2. Слой кэша не опционален — именно он делает возможными 35 000 QPS без взрыва кластера Trie.
  3. Вы никогда не обновляете живое Trie при каждом поиске. Офлайн пакетная пересборка + blue-green подмена — это стандартный паттерн для продакшена.

Пример кода на Laravel