сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Коллекции Java

List и Set в Java

List и Set: где живут вопросы с подвохом

Замерил на списках по 200 000 элементов. Две тысячи обращений по индексу: ArrayList - 0,27 миллисекунды, LinkedList - 274 миллисекунды. Тысячекратная разница на одной и той же операции. А вот 100 000 вставок в начало переворачивают картину: ArrayList 476 мс, LinkedList 17 мс.

Коллекции - сердце Java-собеса, без них не проходит ни одно интервью. Проверяют два пласта: понимаешь ли устройство и знаешь ли подвохи API, на которых код компилируется, но делает не то.

// Формулировки: «ArrayList или LinkedList?», «как Set понимает, что элемент дубликат?», «что будет, если удалить элемент в foreach?»

ArrayList против LinkedList: что показал замер

ArrayList - динамический массив. Доступ по индексу это арифметика над адресом, отсюда 0,27 мс на две тысячи обращений. Добавление в конец амортизированно дешёвое: когда место кончается, массив растёт в полтора раза с копированием. Я подсмотрел вместимость на первых сорока вставках: 10, 15, 22, 33, 49 - тот самый множитель.

LinkedList - двусвязный список. До i-го элемента он идёт по ссылкам, поэтому 274 мс на тех же двух тысячах обращений. Дело тут даже не в количестве шагов: узлы разбросаны по куче, и каждый переход - промах кэша процессора. Видно это на полном обходе: там счёт по ссылкам идёт по порядку, и разрыв падает до 5,48 мс против 6,69.

Но честный замер показывает и обратную сторону. Вставка в начало у LinkedList - перевесить две ссылки, у ArrayList - сдвинуть весь хвост. 100 000 вставок: 17 мс против 476. То есть LinkedList выигрывает там, где позиция уже в руках.

// Практический вывод всё равно в пользу ArrayList: в реальном коде до позиции сначала надо дойти, а это те самые 274 мс. Для очереди и стека берут ArrayDeque - он тоже на массиве и быстрее LinkedList в обеих ролях.

амортизированная стоимость
редкое дорогое расширение, размазанное по дешёвым вставкам
промах кэша
данные не рядом в памяти, процессор ждёт

Set-семейство: три способа быть уникальным

HashSet внутри это HashMap, где элементы играют роль ключей: проверка принадлежности в среднем за постоянное время, порядок обхода не гарантирован. LinkedHashSet добавляет к этому предсказуемый порядок вставки. TreeSet держит элементы отсортированными в красно-чёрном дереве: все операции за логарифм, зато можно спрашивать диапазоны.

Кто решает, что элемент - дубликат? В hash-структурах пара equals и hashCode. А в TreeSet - метод compareTo или переданный компаратор, equals там не зовут вовсе.

// Отсюда неочевидное следствие: объект, у которого compareTo несогласован с equals, в HashSet и TreeSet живёт по-разному. Один и тот же набор даёт разное число элементов, и BigDecimal с его 1.0 и 1.00 - живой тому пример.

LinkedHashSet
уникальность плюс предсказуемый порядок вставки
TreeSet
отсортированное множество, дубликат определяет compareTo

Три ловушки API, которые я проверил запуском

Первая: метод remove у списка перегружен. Взял список из 10, 20, 30 и вызвал remove(1) - получил [10, 30], то есть удалился элемент по ИНДЕКСУ один. Чтобы удалить по значению, нужен remove(Integer.valueOf(30)) - тогда получается [10]. Для списка чисел эти два вызова выглядят одинаково, а делают разное.

Вторая: неизменяемые обёртки ведут себя по-разному. У Arrays.asList метод set РАБОТАЕТ - список изменился на [9, 2, 3], потому что это вид поверх массива фиксированного размера. А add у него уже даёт UnsupportedOperationException. У List.of запрещено и то и другое, а ещё он не принимает null - падает с NullPointerException прямо в конструкторе.

Третья и самая интересная: удаление внутри foreach. Взял список из четырёх элементов и удалял по очереди каждый. Три случая из четырёх дали ConcurrentModificationException. А удаление ТРЕТЬЕГО элемента из четырёх прошло молча, список стал [a, b, d], никакого исключения.

// Это значит, что защита от изменения на обходе - детектор, а не гарантия. Он проверяет счётчик модификаций перед следующим шагом, а после удаления предпоследнего обход просто заканчивается раньше проверки. Законные способы - iterator.remove() и removeIf.

List<Integer> l = new ArrayList<>(List.of(10, 20, 30));
l.remove(1);                   // [10, 30] - по ИНДЕКСУ
l.remove(Integer.valueOf(30)); // [10]     - по значению
CME
ConcurrentModificationException - изменение во время обхода
вид поверх массива
Arrays.asList: set можно, add нельзя

Как отвечать: «ArrayList или LinkedList - что выберешь и почему?»

Почти всегда ArrayList, и я могу сказать почему на числах. Мерил на двухстах тысячах элементов: две тысячи обращений по индексу у ArrayList заняли 0,27 миллисекунды, у LinkedList - 274. Разница в тысячу раз, потому что список идёт по ссылкам, а каждый переход это ещё и промах кэша процессора - данные лежат вразброс. Честно скажу и обратное: на вставках в начало LinkedList действительно выигрывает, 17 миллисекунд против 476. Но в реальном коде до места вставки надо сначала дойти, и весь выигрыш съедается поиском. Плюс у ArrayList данные лежат непрерывно, а добавление в конец амортизированно дешёвое: массив растёт в полтора раза, я смотрел последовательность вместимостей - 10, 15, 22, 33, 49. Если нужна очередь или стек, беру ArrayDeque, он тоже на массиве и обходит LinkedList в обеих ролях. Реальный сценарий именно для LinkedList мне назвать сложно, и это нормальный ответ.

Сильный ответ: не пересказ таблицы сложностей, а объяснение через кэш и амортизацию, подкреплённое числами. Отдельно ценится честность про случай, где LinkedList выигрывает - это отличает измерявшего от заучившего.

На чём валят

  • Берут LinkedList «для быстрых вставок». Доступ по индексу у меня вышел в тысячу раз медленнее.
  • Зовут list.remove(1) на списке чисел и удаляют по индексу вместо значения. Классическое «что напечатает».
  • Мутируют Arrays.asList или List.of. У первого set работает, а add уже нет; у второго запрещено всё.
  • Удаляют элемент в foreach. Обычно это ConcurrentModificationException, но у меня один случай из четырёх прошёл молча.
  • Кладут в HashSet объекты без equals и hashCode. Множество честно хранит оба «одинаковых» элемента.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 19, остальные разбираются в тренажёре.

  1. #list_set1 / 5
    Чем HashSet обеспечивает уникальность элементов?
    A)Через оператор ==: перед вставкой новый элемент сравнивается по ссылке со всеми уже лежащими в множестве
    B)Через метод compareTo(): HashSet держит элементы отсортированными и сравнивает соседей по порядку
    C)Через hashCode() и equals() элементов
    D)Через сериализацию: элемент превращается в байты, и множество сверяет получившиеся массивы байтов
    показать ответ и разбор
    +C)Через hashCode() и equals() элементов

    // разбор: HashSet внутри — это HashMap, где элементы лежат ключами. Новый элемент считается дубликатом, если у него совпал бакет (по hashCode) и equals с уже лежащим вернул true. Поэтому у элементов обязательно должны быть согласованные equals/hashCode — иначе «одинаковые» объекты попадут как разные. Порядок обхода не гарантирован.

  2. #list_set2 / 5
    Чем отличаются HashSet, LinkedHashSet и TreeSet по порядку обхода?
    A)Все три обходят элементы в порядке вставки; различаются они только внутренней скоростью операций add
    B)HashSet сортирует по возрастанию, LinkedHashSet — по убыванию, а TreeSet сохраняет порядок вставки
    C)Порядок у всех трёх случайный и меняется от запуска к запуску, полагаться на него не получится ни в одном
    D)HashSet — без порядка, LinkedHashSet — порядок вставки, TreeSet — сортировка
    показать ответ и разбор
    +D)HashSet — без порядка, LinkedHashSet — порядок вставки, TreeSet — сортировка

    // разбор: HashSet не гарантирует порядок (обход зависит от хешей и ёмкости). LinkedHashSet добавляет связный список поверх, сохраняя порядок ВСТАВКИ. TreeSet держит элементы в красно-чёрном дереве и обходит их ОТСОРТИРОВАННО (по Comparable/Comparator), но операции стоят O(log n) вместо амортизированного O(1). Выбор — по тому, нужен ли порядок и какой.

  3. #list_set3 / 5
    Что вернёт List, созданный через List.of(...), при попытке add()?
    A)Бросит UnsupportedOperationException — список неизменяемый
    B)Спокойно добавит элемент: List.of создаёт обычный изменяемый ArrayList, просто с коротким синтаксисом
    C)Молча проигнорирует добавление и вернёт false, оставив список в прежнем неизменном состоянии без ошибки
    D)Создаст под капотом новую копию списка с добавленным элементом и вернёт её, не трогая исходный
    показать ответ и разбор
    +A)Бросит UnsupportedOperationException — список неизменяемый

    // разбор: List.of (как и Set.of, Map.of, Arrays.asList частично) возвращает НЕИЗМЕНЯЕМЫЙ список: любые add/remove/set бросают UnsupportedOperationException. Это удобно для констант и защищённых от изменения данных, но легко напороться, если ожидать мутабельность. Чтобы получить изменяемую копию — new ArrayList<>(List.of(...)). List.of к тому же запрещает null-элементы.

  4. #list_set4 / 5
    Что происходит с ArrayList, когда при add() заканчивается внутренняя ёмкость?
    A)Каждый add сразу расширяет массив ровно на один элемент, поэтому лишней памяти список не резервирует
    B)Выделяется больший массив (примерно ×1.5) и элементы копируются
    C)Список превращается во внутренний связный список узлов, чтобы не копировать уже существующие элементы
    D)ArrayList бросает исключение переполнения, и разработчик обязан заранее вызвать ensureCapacity вручную
    показать ответ и разбор
    +B)Выделяется больший массив (примерно ×1.5) и элементы копируются

    // разбор: Внутри ArrayList — массив фиксированного размера. Когда он заполнен, создаётся новый массив большего размера (oldCapacity + oldCapacity/2, т.е. ~×1.5), и элементы копируются в него — эта операция O(n), но происходит редко, поэтому add в конец амортизированно O(1). Если размер известен заранее, ensureCapacity/конструктор с capacity избавляют от лишних перекопирований.

  5. #list_set5 / 5
    В чём разница между массивом (T[]) и ArrayList<T>?
    A)Массив умеет автоматически расти при выходе за границу, а ArrayList имеет жёстко фиксированный размер
    B)Массив может хранить только примитивы, а ArrayList — только объекты, поэтому они несовместимы вообще
    C)Массив фиксированной длины и ковариантен; ArrayList растёт и дженерик
    D)Разницы по сути нет: ArrayList — это синтаксический сахар, компилируемый ровно в обычный массив T[]
    показать ответ и разбор
    +C)Массив фиксированной длины и ковариантен; ArrayList растёт и дженерик

    // разбор: Массив имеет фиксированную длину, знает свой тип в рантайме и КОВАРИАНТЕН (Object[] a = new String[...] допустимо, но кидает ArrayStoreException при неверной записи). ArrayList динамически растёт, использует дженерики (типобезопасность на компиляции, стирание в рантайме) и не ковариантен. Массивы быстрее и компактнее для примитивов (int[]), списки удобнее и безопаснее для объектов.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.