Выбор структуры и сложность операций
Сорок тысяч проверок принадлежности. Через список - 609 миллисекунд. Через множество - 11,5. Пятидесятикратная разница, а в коде это одна строка: перегнать список в HashSet перед циклом.
«Какую коллекцию возьмёшь под задачу?» - вопрос, где зубрёжка не помогает. Проверяют, умеешь ли ты рассуждать от операций. Слабый ответ звучит как «HashMap, она быстрая». Сильный называет доминирующую операцию, потом структуру, потом худший случай и цену памяти.
// Формулировки: «чем проверять принадлежность миллиона идентификаторов?», «почему код простой, а работает медленно?», «какая коллекция под очередь задач?»
Карта выбора: от операции к структуре
Дефолты по доминирующей операции короткие. Доступ по индексу - ArrayList. Проверка принадлежности и уникальность - HashSet. Ключ и значение - HashMap. Нужна сортированность или запросы по диапазону - TreeMap и TreeSet. Очередь и стек - ArrayDeque. Нужен минимум - PriorityQueue.
Стоимости: у ArrayList доступ по индексу постоянный, вставка в середину линейная. Hash-структуры в среднем постоянные, в худшем случае логарифмические. Древовидные - логарифмические на всём. У PriorityQueue заглянуть в минимум стоит копейки, а добавить или извлечь - логарифм.
// Про PriorityQueue есть отдельная ловушка, я её проверил. Это двоичная куча, и упорядочен в ней только корень. Обход очереди из чисел 5, 1, 4, 2, 3, 9, 7 выдал [1, 2, 4, 5, 3, 9, 7] - почти отсортировано, но не отсортировано. По порядку элементы отдаёт только извлечение: [1, 2, 3, 4, 5, 7, 9].
- PriorityQueue
- двоичная куча: упорядочен только корень
- худший случай
- деградация на коллизиях и расширениях, её надо называть
Скрытые квадраты и цена обёрток
Самый частый тормоз на код-ревью - проверка принадлежности списку внутри цикла по другому списку. Линейная операция внутри линейного цикла даёт квадрат: миллион на миллион это триллион сравнений. Мой замер на сорока тысячах уже даёт 609 мс против 11,5 - а объём растёт квадратично.
Память - вторая скрытая цена. Замерил пять миллионов чисел: ArrayList<Integer> занял 98 мегабайт, обычный массив int - 21 мегабайт. Почти впятеро, потому что каждое число за пределами кэша обёрток это отдельный объект с заголовком плюс ссылка на него в массиве.
// На собеседовании это звучит как «код простой, а работает медленно и жрёт память - что смотришь первым». Ответ: проверку принадлежности внутри цикла и упаковку чисел в объекты.
// квадрат: contains бежит по списку
for (var id : requests) if (allowedList.contains(id)) ...
// линейно: множество решает
var allowed = new HashSet<>(allowedList);- скрытый квадрат
- линейная операция внутри линейного цикла
- цена упаковки
- Integer это объект с заголовком, а не четыре байта
Конкурентные аналоги и неизменяемые фабрики
Многопоточные дефолты: вместо HashMap - ConcurrentHashMap, для схемы «производитель и потребитель» - блокирующая очередь с ожиданием. А вот CopyOnWriteArrayList надо брать осознанно: каждая запись копирует ВЕСЬ массив.
Замерил цену: 20 000 добавлений в CopyOnWriteArrayList заняли 447 миллисекунд, в обычный синхронизированный список - 1,8. Разница в 248 раз. Список слушателей событий, который меняется раз в час, - да. Горячий журнал записей - категорически нет.
Обёртки вида synchronizedList и synchronizedMap - наследие: они вешают один общий замок, поэтому все операции выстраиваются в очередь, а обход всё равно требует ручной синхронизации. ConcurrentHashMap масштабируется несравнимо лучше, у неё замки на уровне отдельных ящиков.
// Неизменяемые фабрики List.of, Map.of и Set.of - хороший дефолт для констант и возвращаемых значений: их безопасно делить между потоками и невозможно случайно испортить.
- CopyOnWriteArrayList
- копия всего массива на каждую запись
- блокирующая очередь
- очередь с ожиданием для производителя и потребителя
Как отвечать: «Нужно проверять принадлежность миллиона идентификаторов - какую структуру возьмёшь?»
HashSet. Доминирующая операция здесь - проверка принадлежности, у множества она в среднем за постоянное время, у списка линейная, и внутри цикла это превращается в квадрат. Я мерил на сорока тысячах: списком 609 миллисекунд, множеством 11,5, и разрыв растёт квадратично. Худший случай оговорю сразу: при плохом hashCode ящики вырождаются, но с Java 8 длинные цепочки перестраиваются в дерево, так что деградация логарифмическая, а не линейная. Дальше посмотрю на ограничения. Если идентификаторы это примитивные числа и память критична, подумаю про отсортированный массив с двоичным поиском: логарифм вместо константы, зато без упаковки - я замерял, пять миллионов чисел в списке обёрток занимают 98 мегабайт против 21 у примитивного массива. А если множество только читают из многих потоков, просто сделаю Set.copyOf: неизменяемый, безопасный и без всякой синхронизации.
Сильный ответ: рассуждение идёт от операции к структуре, названы худший случай и цена памяти, предложена альтернатива под ограничения. Это ровно та схема, которую интервьюер хочет услышать, и числа делают её убедительной.
На чём валят
- −Проверяют принадлежность списку внутри цикла. На сорока тысячах это уже 609 мс против 11,5 у множества.
- −Ждут от обхода PriorityQueue отсортированного порядка. Упорядочен только корень, по порядку отдаёт лишь извлечение.
- −Берут CopyOnWriteArrayList под частую запись. У меня 20 000 добавлений заняли 447 мс против 1,8 у обычного списка.
- −Хранят миллионы чисел в списке обёрток. Пять миллионов заняли 98 мегабайт вместо 21 у примитивного массива.
- −Ставят TreeMap «на всякий случай» и платят логарифм там, где хватало константы.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 18, остальные разбираются в тренажёре.
- Нужно всегда быстро доставать минимальный элемент из меняющегося набора. Что взять?A)Отсортированный ArrayList: держать список отсортированным и брать нулевой элемент как минимумB)HashSet: он хранит элементы так, что минимальный оказывается первым при обходе множестваC)PriorityQueue (бинарная куча)D)TreeSet и полный обход всех элементов при каждом запросе, чтобы найти среди них наименьший
показать ответ и разбор
+C)PriorityQueue (бинарная куча)// разбор: PriorityQueue — бинарная куча: минимум (или максимум по компаратору) доступен на вершине за O(1) (peek), а offer/poll — O(log n). Идеально для «всегда доставать самый приоритетный»: планировщики, алгоритм Дейкстры, top-K. Важно: обход/toString кучи НЕ отсортирован — упорядочен только путь извлечения через poll. TreeSet тоже даёт минимум, но не хранит дубликаты и дороже на вставке.
- Часто вставляем/удаляем по индексу в СЕРЕДИНЕ большого списка. ArrayList или LinkedList?A)LinkedList однозначно: вставка в середину у него O(1) независимо от размера и позицииB)ArrayList однозначно: вставка по индексу в него не сдвигает элементы и потому стоит O(1)C)Разницы нет: у обоих вставка в середину по индексу стоит строго O(log n) за счёт бинарного поискаD)Оба слабы: ArrayList — сдвиг O(n), LinkedList — поиск позиции O(n)
показать ответ и разбор
+D)Оба слабы: ArrayList — сдвиг O(n), LinkedList — поиск позиции O(n)// разбор: Хитрый случай: у ArrayList вставка/удаление по индексу в середине — O(n) из-за сдвига хвоста. У LinkedList сама операция в узле O(1), НО чтобы дойти до i-й позиции по индексу, нужен проход O(n) — выгода теряется. LinkedList выигрывает лишь когда позиция уже известна через ListIterator (идём и правим по ходу). Если по-настоящему нужны частые вставки в середину — часто лучше другая структура (дерево, gap buffer).
- Когда предпочесть LinkedHashMap обычному HashMap?A)Когда нужен предсказуемый порядок обхода (вставки или доступа)B)Когда карта очень большая: LinkedHashMap заметно экономнее HashMap по памяти на тех же данныхC)Когда нужен максимально быстрый get: LinkedHashMap работает за O(1), а HashMap — за O(log n)D)Когда ключи должны сортироваться по возрастанию: LinkedHashMap автоматически упорядочивает их
показать ответ и разбор
+A)Когда нужен предсказуемый порядок обхода (вставки или доступа)// разбор: LinkedHashMap = HashMap + двусвязный список поверх записей, поэтому обход идёт в предсказуемом порядке ВСТАВКИ (а в access-order режиме — в порядке обращений, что даёт готовую основу для LRU-кэша через removeEldestEntry). Скорость операций как у HashMap (O(1)), но памяти чуть больше. Берут, когда важен стабильный порядок обхода, но сортировка (TreeMap) избыточна.
- Нужен неизменяемый снимок данных для передачи наружу. Что использовать?A)Collections.synchronizedList — он делает список неизменяемым и заодно потокобезопасным сразуB)List.copyOf(...) или List.of(...) — immutable-коллекцияC)Обычный ArrayList, просто помеченный ключевым словом final при объявлении переменной-поляD)Отдать наружу ссылку на внутренний изменяемый список — вызывающий всё равно не сможет его менять
показать ответ и разбор
+B)List.copyOf(...) или List.of(...) — immutable-коллекция// разбор: Чтобы отдать данные наружу без риска, что их изменят, возвращают неизменяемую коллекцию: List.copyOf(src) (делает независимую immutable-копию) или List.of(...). Тогда любые add/remove у получателя бросают UnsupportedOperationException. final у переменной фиксирует лишь ссылку, а не содержимое; synchronizedList — про потоки, не про неизменяемость; отдавать ссылку на внутренний изменяемый список — утечка инкапсуляции.
- Нужны уникальные элементы С сохранением порядка их добавления. Что взять?A)TreeSet: он хранит уникальные элементы и заодно сохраняет тот порядок, в котором их добавлялиB)ArrayList с ручной проверкой contains перед каждым добавлением — простой и эффективный способC)LinkedHashSetD)HashSet: он и обеспечивает уникальность, и обходит элементы ровно в порядке их вставки в множество
показать ответ и разбор
+C)LinkedHashSet// разбор: LinkedHashSet сочетает уникальность (как HashSet) с сохранением порядка ВСТАВКИ (связный список поверх). Это ровно «уникальные в порядке добавления» за амортизированное O(1). TreeSet тоже уникален, но упорядочивает по сортировке, не по вставке. ArrayList с проверкой contains даёт порядок, но каждая вставка — O(n) на поиск дубля. Частый практичный выбор для дедупликации с сохранением очередности.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.