Класс LinkedList в Java - Вопросы
Всего: 6 вопросов
1. Как устроен LinkedList в Java внутри?
Как устроен LinkedList в Java внутри?
LinkedList — двусвязный список, массива внутри нет. Каждый элемент лежит в отдельном объекте-узле Node — статическом вложенном классе с полями item (сам элемент), next (следующий узел) и prev (предыдущий узел). Сам список хранит только ссылки на первый (first) и последний (last) узлы и количество элементов (size). У первого узла prev == null, у последнего next == null.
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
В старых книгах узел называется Entry с полем element, а список замкнут в кольцо через пустой узел header — так было до Java 6 включительно. С Java 7 используются Node, first и last; принцип двусвязного списка не изменился.
2. Как работает get(index) в LinkedList и почему нельзя перебирать LinkedList циклом for с get(i)?
Как работает get(index) в LinkedList и почему нельзя перебирать LinkedList циклом for с get(i)?
get(index) не может сразу перейти к нужной ячейке, как массив: внутренний метод node() идёт по ссылкам от узла к узлу. Чтобы сократить путь, он начинает с ближнего конца — с first, если индекс в первой половине, и с last, если во второй. Поэтому get(0) и get(size() - 1) выполняются сразу, а элемент в середине ищется до n/2 шагов — это O(n). Через тот же node() работают set(i, e), add(i, e) и remove(i).
Цикл for (int i = 0; i < list.size(); i++) list.get(i); ищет каждый узел заново, и обход становится O(n²): в сумме около n²/4 переходов, для 100 000 элементов — около 2,5 миллиарда. For-each, forEach() и явный итератор запоминают текущий узел и проходят список за O(n) — те же 100 000 шагов.
3. Правда ли, что вставка и удаление в середине LinkedList выполняются за O(1)?
Правда ли, что вставка и удаление в середине LinkedList выполняются за O(1)?
Только когда узел уже найден. Сама перелинковка стоит O(1): чтобы удалить B из A–B–C, достаточно переписать A.next на C и C.prev на A, остальные узлы не трогаются. Но add(i, e), remove(i) и remove(Object) сначала ищут узел проходом по цепочке, поэтому в сумме это O(n).
Настоящее O(1) бывает в двух случаях: на концах списка (addFirst(), addLast(), removeFirst(), removeLast()) и в текущей позиции ListIterator, который уже стоит на узле:
List<String> list = new LinkedList<>(List.of("A", "B", "C", "D"));
ListIterator<String> it = list.listIterator();
while (it.hasNext()) {
String s = it.next();
if (s.equals("B")) it.add("B2"); // вставка после B за O(1)
else if (s.equals("C")) it.remove(); // удаление C за O(1)
}
System.out.println(list); // [A, B, B2, D]
4. Чем ArrayList отличается от LinkedList и что выбирать?
Чем ArrayList отличается от LinkedList и что выбирать?
ArrayList хранит элементы в массиве Object[], а LinkedList — в связанных узлах Node. Отсюда разница в сложности:
get(i),set(i, e): уArrayListO(1), уLinkedListO(n), до n/2 переходов;add(0, e),remove(0): уArrayListO(n) (сдвиг массива), уLinkedListO(1);add(i, e),remove(i)в середине: O(n) у обоих (уLinkedList— на поиск узла);- вставка/удаление через
ListIterator: уArrayListO(n), уLinkedListO(1).
На практике ArrayList часто быстрее даже там, где у LinkedList оценка лучше: элементы массива лежат в памяти подряд и хорошо попадают в кэш процессора, а System.arraycopy() сдвигает ссылки очень быстро. Узел LinkedList занимает около 24 байт, узлы разбросаны по куче, и каждый переход по next — возможный промах кэша.
Поэтому по умолчанию для списка берут ArrayList, а LinkedList — для частых вставок и удалений через итератор или на обоих концах, когда преимущество подтверждено замером. Даже автор класса Джошуа Блох признался: «I wrote it, and I never use it».
5. Какие конструкторы есть у LinkedList и каким типом объявлять переменную?
Какие конструкторы есть у LinkedList и каким типом объявлять переменную?
Конструкторов два. LinkedList() создаёт пустой список (first == last == null, size == 0), узлы появляются только при добавлении. LinkedList(Collection<? extends E> c) копирует элементы c в порядке её итератора; при c == null бросается NullPointerException, а null-элементы внутри c допускаются. Конструктора с ёмкостью нет: new LinkedList<>(100) не компилируется.
LinkedList реализует сразу List и Deque, поэтому переменную объявляют тем интерфейсом, который нужен: List<String> list = new LinkedList<>(); — для работы по индексу, Deque<String> deque = new LinkedList<>(); — для очереди или стека. Через переменную типа List методы Deque (push(), pollLast()) недоступны. В роли стека push() и pop() работают с головой, а add() и offer() добавляют в хвост.
6. Можно ли хранить null в LinkedList и потокобезопасен ли он?
Можно ли хранить null в LinkedList и потокобезопасен ли он?
null хранить можно: элемент лежит в поле item отдельного узла, и null там ничему не мешает (в отличие от ArrayDeque, который отмечает пустые ячейки массива значением null). contains(null) и indexOf(null) его находят. Но в роли очереди это рискованно: poll() и peekFirst() возвращают null и для пустого списка, и отличить одно от другого нельзя.
Потокобезопасности нет: при одновременной записи из нескольких потоков ссылки узлов могут разойтись, и элементы потеряются. Простой вариант — Collections.synchronizedList(new LinkedList<>()), для очереди между потоками лучше ConcurrentLinkedDeque или LinkedBlockingDeque.
Итераторы LinkedList — fail-fast: если внутри обхода вызвать list.add() или list.remove(), следующий it.next() бросит ConcurrentModificationException. Изменять список во время обхода нужно методами самого итератора.