Вопросы по коллекциям 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 обычно спрашивают начиная с уровня мидла.