Поиск, хеширование и ДП
Когда наивное решение «слишком медленное», ускорение почти никогда не берётся из воздуха — за него платят памятью или предвычислением. Хеш-таблица убирает вложенный цикл, заменяя повторный скан на доступ по ключу за в среднем O(1), но берёт O(n) памяти под ключи. Динамическое программирование схлопывает экспоненциальное дерево пересчётов в линейный проход, но хранит кеш подзадач. LRU-кэш держит три операции константными, только сочетая map и двусвязный список сразу. А полный перебор — это признание, что срезать нечего: он честно платит экспонентой и потому обязан быть ограничен.
Сильный кандидат на собеседовании не просто называет приём — он называет цену. Go добавляет к этому свои детали: множество это map[T]struct{} с нулевым по размеру значением, ключом map может быть только сравнимый тип, а «O(1)» у хеш-таблицы — это средняя сложность, а не гарантия. Эта тема разбирает приёмы поиска и оптимизации по слоям — каждый со своим разменом времени и памяти.
Карта темы
- Хеш-таблица для поиска —
mapдаёт доступ по ключу за в среднем O(1) и сворачивает вложенный цикл O(n²) до одного прохода O(n) ценой O(n) памяти. - Множество и дедупликация —
map[T]struct{}как множество для проверки принадлежности и удаления дубликатов за O(1); ключ обязан быть сравнимым. - LRU-кэш —
mapплюс двусвязный список дают O(1) наget,putи вытеснение самого забытого элемента. - Динамическое программирование — перекрывающиеся подзадачи и оптимальная подструктура; мемоизация и таблица убирают пересчёт, скользящее окно сводит память к O(1).
- Полный перебор — исчерпывающее перечисление пространства кандидатов, когда структуры для срезания нет; цена экспоненциальная, её нужно ограничивать.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Решать вложенным циклом то, что решает хеш-таблица | O(n²) вместо O(n) — частая причина TLE на собеседовании |
Считать «O(1)» у map гарантированным | Это средняя сложность; коллизии и рост таблицы дают худший случай хуже O(1) |
Путать множество map[T]bool и map[T]struct{} | struct{}{} не занимает памяти под значение — идиоматичнее для set |
Брать slice, map или функцию ключом множества | Несравнимые типы — код не скомпилируется; нужен сравнимый ключ |
| Считать наивную рекурсию Фибоначчи приемлемой | Без мемоизации это O(2ⁿ) — одна подзадача пересчитывается в обеих ветках |
Хранить в LRU только map без списка | Нет O(1)-вытеснения самого старого элемента |
| Строить LRU на односвязном списке | Вырвать узел за O(1) нельзя — нужен prev, иначе поиск соседа за O(n) |
| Брать полный перебор там, где есть структура | Экспоненциальная цена вместо полиномиальной |
| Запускать перебор без ограничения | Формально «работает», но не завершится при жизни на большом пространстве |
Значение для собеседований
Секция про поиск и оптимизацию проверяет не зубрёжку алгоритма, а понимание размена: что мы тратим, чтобы выиграть время, и насколько надёжен этот выигрыш.
Что обычно проверяют:
- Когда хеш-таблица или множество сворачивают квадрат до линии и какова цена в памяти.
- Почему «O(1)» у
map— это средняя сложность, а не гарантия. - Какой тип годится в ключ
mapи почемуsliceне годится. - Чем
get/putLRU-кэша достигают O(1) и зачем там именно двусвязный список. - Что такое перекрывающиеся подзадачи и как мемоизация или таблица убирают пересчёт.
- Когда оправдан полный перебор и почему его обязательно ограничивают.
Типичный неверный ответ: «доступ к map — это O(1), значит решение всегда O(1)». Это запускает разбор того, что O(1) у хеш-таблицы — амортизированная средняя сложность: при коллизиях и при росте таблицы отдельная операция может стоить дороже, а сам выигрыш над двойным циклом оплачен O(n) дополнительной памяти.