List и Set в Java
Замерил на списках по 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, остальные разбираются в тренажёре.
- Чем HashSet обеспечивает уникальность элементов?A)Через оператор ==: перед вставкой новый элемент сравнивается по ссылке со всеми уже лежащими в множествеB)Через метод compareTo(): HashSet держит элементы отсортированными и сравнивает соседей по порядкуC)Через hashCode() и equals() элементовD)Через сериализацию: элемент превращается в байты, и множество сверяет получившиеся массивы байтов
показать ответ и разбор
+C)Через hashCode() и equals() элементов// разбор: HashSet внутри — это HashMap, где элементы лежат ключами. Новый элемент считается дубликатом, если у него совпал бакет (по hashCode) и equals с уже лежащим вернул true. Поэтому у элементов обязательно должны быть согласованные equals/hashCode — иначе «одинаковые» объекты попадут как разные. Порядок обхода не гарантирован.
- Чем отличаются HashSet, LinkedHashSet и TreeSet по порядку обхода?A)Все три обходят элементы в порядке вставки; различаются они только внутренней скоростью операций addB)HashSet сортирует по возрастанию, LinkedHashSet — по убыванию, а TreeSet сохраняет порядок вставкиC)Порядок у всех трёх случайный и меняется от запуска к запуску, полагаться на него не получится ни в одномD)HashSet — без порядка, LinkedHashSet — порядок вставки, TreeSet — сортировка
показать ответ и разбор
+D)HashSet — без порядка, LinkedHashSet — порядок вставки, TreeSet — сортировка// разбор: HashSet не гарантирует порядок (обход зависит от хешей и ёмкости). LinkedHashSet добавляет связный список поверх, сохраняя порядок ВСТАВКИ. TreeSet держит элементы в красно-чёрном дереве и обходит их ОТСОРТИРОВАННО (по Comparable/Comparator), но операции стоят O(log n) вместо амортизированного O(1). Выбор — по тому, нужен ли порядок и какой.
- Что вернёт 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-элементы.
- Что происходит с 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 избавляют от лишних перекопирований.
- В чём разница между массивом (T[]) и ArrayList<T>?A)Массив умеет автоматически расти при выходе за границу, а ArrayList имеет жёстко фиксированный размерB)Массив может хранить только примитивы, а ArrayList — только объекты, поэтому они несовместимы вообщеC)Массив фиксированной длины и ковариантен; ArrayList растёт и дженерикD)Разницы по сути нет: ArrayList — это синтаксический сахар, компилируемый ровно в обычный массив T[]
показать ответ и разбор
+C)Массив фиксированной длины и ковариантен; ArrayList растёт и дженерик// разбор: Массив имеет фиксированную длину, знает свой тип в рантайме и КОВАРИАНТЕН (Object[] a = new String[...] допустимо, но кидает ArrayStoreException при неверной записи). ArrayList динамически растёт, использует дженерики (типобезопасность на компиляции, стирание в рантайме) и не ковариантен. Массивы быстрее и компактнее для примитивов (int[]), списки удобнее и безопаснее для объектов.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.