Вопросы по коллекциям Java на собеседовании
Коллекции спрашивают на каждом собесе Java, и это редкий блок, где ответ проверяется мгновенно: попросят объяснить, что произойдёт с HashMap при плохом hashCode, и картина сразу становится ясной.
Что спрашивают
- +HashMap внутри: бакеты и коллизии, переход в дерево, влияние equals и hashCode, поведение при изменении ключа
- +Выбор структуры: когда ArrayList, когда LinkedList, чем TreeMap отличается от HashMap по стоимости операций
- +List и Set: контракты, дубликаты, неизменяемые коллекции и их подводные камни
- +Generics: стирание типов, wildcards и PECS, почему нельзя создать массив дженериков
- +Итерация: fail-fast против fail-safe, ConcurrentModificationException, порядок обхода
Из чего состоит тема
Так тема разложена в тренажёре: движок ведёт прогресс по каждой подтеме отдельно и возвращает те, что просели.
- Map и HashMap внутри21
- List и Set19
- Выбор структуры и сложность18
- Итерация и порядок17
- Generics и wildcards16
Разборы подтем
Конспект по каждой: что это, как отвечать вслух, на чём валятся, плюс вопросы для самопроверки.
- HashMap в Java изнутри21 вопросов
- Выбор структуры и сложность операций18 вопросов
- Generics и wildcards в Java16 вопросов
- Итерация и порядок в коллекциях17 вопросов
- List и Set в Java19 вопросов
Примеры вопросов с разбором
- Нужен быстрый поиск «есть ли элемент» в большой коллекции. Что выбрать?A)HashSet — contains амортизированно O(1)B)ArrayList: метод contains проходит по элементам, но для больших списков он ускоряется автоматическиC)LinkedList: связная структура позволяет проверять наличие элемента быстрее, чем массивовый списокD)Обычный массив с линейным перебором — для проверки наличия это самый предсказуемый по скорости путь
показать ответ и разбор
+A)HashSet — contains амортизированно O(1)// разбор: Проверка наличия (contains) в List — линейный перебор O(n). В HashSet — вычисление бакета по хешу и сравнение внутри, амортизированно O(1). На больших объёмах разница огромна. Если нужна и уникальность, и быстрый поиск — HashSet; если ещё и порядок — LinkedHashSet; если сортировка — TreeSet (O(log n)). Список берут, когда важны индексный доступ и дубликаты.
- Зачем нужны дженерики (generics) в Java?A)Типобезопасность на компиляции без ручных приведений типовB)Чтобы ускорить программу в рантайме: обобщённые коллекции работают быстрее необобщённых за счёт спецкодаC)Чтобы одна коллекция могла одновременно хранить значения несовместимых типов без ограниченийD)Чтобы автоматически создавать объекты нужного типа во время выполнения по переданному параметру-классу
показать ответ и разбор
+A)Типобезопасность на компиляции без ручных приведений типов// разбор: Дженерики дают проверку типов на этапе КОМПИЛЯЦИИ: List<String> не даст положить Integer, и не нужно кастовать при чтении. Это ловит ошибки раньше и делает код читаемее. На производительность они не влияют — существуют только в компиляторе, в рантайме тип стирается (type erasure). Поэтому нельзя, например, создать new T[] или проверить instanceof List<String>.
- Что такое fail-fast итератор?A)Он бросает ConcurrentModificationException при структурном изменении во время обходаB)Итератор, который при ошибке молча пропускает проблемный элемент и продолжает обход дальше без сбояC)Итератор, надёжный потокобезопасный обход коллекции сразу из нескольких потоков без блокировокD)Итератор, который заранее копирует всю коллекцию и обходит копию, игнорируя изменения оригинала
показать ответ и разбор
+A)Он бросает ConcurrentModificationException при структурном изменении во время обхода// разбор: Итераторы обычных коллекций (ArrayList, HashMap) fail-fast: они запоминают счётчик структурных изменений modCount и, обнаружив на очередном шаге, что коллекцию поменяли мимо итератора, бросают ConcurrentModificationException. Это защита от неверного обхода (даже в одном потоке!). Fail-safe итераторы (CopyOnWriteArrayList, ConcurrentHashMap) работают по снимку/без CME, но могут не видеть свежих изменений.
- Как устроен ArrayList внутри и какова сложность доступа по индексу?A)Динамический массив; доступ по индексу — O(1)B)Двусвязный список узлов, поэтому доступ к элементу по индексу занимает O(1) за счёт прямой адресацииC)Хеш-таблица с бакетами: индекс считается как хеш, доступ амортизированно константный, но зависит от коллизийD)Сбалансированное дерево, где доступ по индексу требует спуска от корня и стоит O(log n) на каждый элемент
показать ответ и разбор
+A)Динамический массив; доступ по индексу — O(1)// разбор: ArrayList хранит элементы в массиве с запасом ёмкости, поэтому обращение по индексу — прямая адресация за O(1). Зато вставка/удаление в середину — O(n) (сдвиг хвоста), а при переполнении массив копируется в больший (рост примерно в 1.5 раза). Это структура выбора, когда нужен быстрый доступ по индексу и добавление в конец.
- Как HashMap находит значение по ключу в get()?A)По hashCode ключа выбирает бакет, внутри сверяет ключи по equalsB)Линейно перебирает все пары ключ-значение и для каждой вызывает equals, пока не найдёт совпадениеC)Держит ключи отсортированными и ищет нужный бинарным поиском по естественному порядку за O(log n)D)Хранит отдельный индекс-массив адресов и обращается к значению напрямую по номеру вставки ключа
показать ответ и разбор
+A)По hashCode ключа выбирает бакет, внутри сверяет ключи по equals// разбор: HashMap считает hashCode ключа, приводит его к индексу бакета, а внутри бакета сверяет ключи через equals (в бакете может лежать несколько пар из-за коллизий). При хорошем распределении хешей get/put — амортизированно O(1). Поэтому ключам нужны корректные equals/hashCode, а изменение полей ключа после вставки ломает поиск.
- Нужна структура «первым пришёл — первым обработан» (очередь). Что взять?A)ArrayList, добавляя в конец и удаляя из начала remove(0) — это и есть эффективная очередь на массивеB)ArrayDeque как Queue (offer/poll)C)Stack из java.util — он специально спроектирован под очередь и обрабатывает элементы в порядке FIFOD)TreeSet, потому что он держит элементы упорядоченными и поэтому естественно выдаёт их по очереди
показать ответ и разбор
+B)ArrayDeque как Queue (offer/poll)// разбор: ArrayDeque — быстрый дек на кольцевом массиве: O(1) добавление/удаление с обоих концов, поэтому годится и как FIFO-очередь (offer/poll), и как LIFO-стек (push/pop) — и в обеих ролях быстрее legacy-классов Stack и LinkedList. ArrayList под очередь плох: remove(0) сдвигает весь хвост (O(n)). Если нужен приоритет, а не FIFO — PriorityQueue (куча).
- Что такое стирание типов (type erasure)?A)Механизм, который в рантайме хранит полную информацию о типе-параметре и позволяет создавать new T()B)Дженерик-параметры существуют лишь на компиляции, в рантайме стёртыC)Оптимизация, удаляющая неиспользуемые generic-классы из скомпилированного байткода ради размераD)Автоматическое приведение всех числовых типов-параметров к double для единообразия вычислений
показать ответ и разбор
+B)Дженерик-параметры существуют лишь на компиляции, в рантайме стёрты// разбор: Дженерики реализованы стиранием: компилятор проверяет типы и вставляет касты, но в байткоде параметр стирается до его границы (Object или верхней границы). Отсюда ограничения: нельзя new T(), new T[], T.class, instanceof T или List<String> — рантайм этого типа не знает. Плюс — обратная совместимость со старым не-обобщённым кодом. Из-за стирания же есть unchecked-warnings.
- Как безопасно удалить элемент во время обхода коллекции?A)Вызвать collection.remove(x) прямо в теле for-each — это штатный и поддерживаемый способ удаленияB)Через iterator.remove() или Collection.removeIf()C)Присвоить удаляемому элементу значение null, а затем один раз почистить коллекцию от null после циклаD)Обходить коллекцию в двух вложенных циклах, удаляя элемент во внешнем и продолжая во внутреннем
показать ответ и разбор
+B)Через iterator.remove() или Collection.removeIf()// разбор: Iterator.remove() — единственный корректный способ удалить текущий элемент по ходу обхода через итератор: он синхронизирует внутренний счётчик и не ломает modCount. Ещё чище — removeIf(predicate) на коллекции, который сам обходит и удаляет безопасно. А вот collection.remove(...) в теле for-each меняет коллекцию мимо итератора и роняет CME. Занулять элементы — не удаление.
- В каком сценарии LinkedList реально выигрывает у ArrayList?A)Произвольный доступ по индексу — связному списку не нужно перебирать узлы, он адресует i-й напрямуюB)Частые вставки/удаления в известной позиции по итераторуC)Экономия памяти: LinkedList не хранит запасную ёмкость, а узлы занимают меньше места, чем ячейки массиваD)Перебор большого объёма в цикле: узлы лежат в памяти подряд, поэтому кэш процессора попадает чаще
показать ответ и разбор
+B)Частые вставки/удаления в известной позиции по итератору// разбор: LinkedList — двусвязный список: добавить/удалить элемент в уже найденной позиции (через ListIterator) — O(1), не сдвигая хвост. Но доступ по индексу — O(n) (перебор от края), памяти больше (два указателя на узел), а разбросанность узлов бьёт по кэшу. На практике ArrayList выигрывает почти всегда; LinkedList оправдан редко — как дек/очередь с интенсивными краевыми операциями.
это 9 из 91
Ещё 82 вопросов по теме — в тренажёре, с движком повторения
Прочитать разбор и ответить самому — разные навыки. В Сеньорчике вопросы приходят сессиями, а подтему, на которой ты споткнулся, движок принесёт снова: завтра, через три дня, через неделю. Бесплатно, с дневным лимитом вопросов.
Частые вопросы
Почему на собеседованиях так любят HashMap?
Потому что на нём видно сразу несколько вещей: понимание хеширования, знание контракта equals и hashCode, оценка сложности и внимательность к краевым случаям.
Что отвечать про сложность операций?
Стоит различать средний и худший случай и уметь объяснить, откуда берётся разница: например, доступ к HashMap в среднем константный, но деградирует при массовых коллизиях.
Нужны ли generics на джуна?
Базово да: параметризация коллекций и понимание, что типы стираются. Wildcards и PECS обычно спрашивают начиная с уровня мидла.