← Назад к вопросам
Почему временная сложность операций в LinkedList имеет именно такие характеристики?
1.6 Junior🔥 121 комментариев
#Коллекции
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Почему временная сложность операций в LinkedList имеет такие характеристики
Это глубокий вопрос о том, как устроена структура данных LinkedList и почему разные операции имеют разные сложности. За 10+ лет я видел много bagov именно потому что разработчики не понимали сложность операций в LinkedList.
Внутренняя структура LinkedList
public class LinkedList<E> {
transient int size = 0;
/**
* Pointer на первый node
*/
transient Node<E> first;
/**
* Pointer на последний node
*/
transient Node<E> last;
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev; // Двусвязный список!
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
}
Визуально:
Две указателя:
first ──┐
↓
[Node1] ←→ [Node2] ←→ [Node3]
↑
last ──┐
Каждый Node содержит:
- item (значение)
- next (указатель на следующий)
- prev (указатель на предыдущий)
Временная сложность операций
| Операция | Сложность | Почему |
|---|---|---|
| get(index) | O(n) | Нужно идти по цепочке |
| add(E) | O(1) | Добавляем в конец (last) |
| add(int, E) | O(n) | Нужно найти индекс сначала |
| remove(int) | O(n) | Нужно найти элемент |
| remove(Object) | O(n) | Нужно найти элемент |
| addFirst(E) | O(1) | Добавляем в начало (first) |
| addLast(E) | O(1) | Добавляем в конец (last) |
| getFirst() | O(1) | У нас есть указатель first |
| getLast() | O(1) | У нас есть указатель last |
| removeFirst() | O(1) | Переставляем указатель first |
| removeLast() | O(1) | Переставляем указатель last |
Почему get(index) это O(n)
public E get(int index) {
checkElementIndex(index);
return node(index).item; // Вот это дорого!
}
private Node<E> node(int index) {
// Оптимизация: если index в первой половине, идём с начала
// Если во второй половине, идём с конца
if (index < (size >> 1)) { // size/2
Node<E> x = first;
for (int i = 0; i < index; i++) // ← O(index) итераций
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--) // ← O(size-index) итераций
x = x.prev;
return x;
}
}
Примеры:
LinkedList с 1000 элементов:
get(0): O(1) — x = first ✅ Быстро
get(1): O(1) — x = first, x = x.next ✅ Очень быстро
get(500): O(500) — идём с конца: 1000-500=500 шагов
get(999): O(1) — x = last ✅ Быстро
get(100): O(100) — идём с начала ❌ Медленно
Почему add(E) это O(1)
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last; // Берём последний node O(1)
final Node<E> newNode = new Node<>(l, e, null); // Создаём новый O(1)
last = newNode; // Обновляем last O(1)
if (l == null)
first = newNode; // Если это первый элемент O(1)
else
l.next = newNode; // Связываем с предыдущим O(1)
size++; // Увеличиваем счетчик O(1)
}
Визуально:
До добавления:
first ──→ [1] ←→ [2] ←→ [3] ←── last
Добавляем 4:
last указывает на [3], создаём новый [4]
После добавления:
first ──→ [1] ←→ [2] ←→ [3] ←→ [4] ←── last
Время: O(1)
Потому что мы сразу знаем where to add (last)
Не нужно ничего искать или сдвигать
Почему add(int, E) это O(n)
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element); // O(1)
else
linkBefore(element, node(index)); // node(index) это O(index)
}
private void linkBefore(E e, Node<E> succ) {
final Node<E> pred = succ.prev; // O(1)
final Node<E> newNode = new Node<>(pred, e, succ); // O(1)
succ.prev = newNode; // O(1)
if (pred == null)
first = newNode; // O(1)
else
pred.next = newNode; // O(1)
size++; // O(1)
}
Примеры:
LinkedList с 1000 элементов:
add(0, value): O(1) — вставляем перед first ✅
add(1000, value): O(1) — вставляем в конец (linkLast) ✅
add(500, value): O(500) — node(500) это O(500) ❌
add(100, value): O(100) — node(100) это O(100) ❌
Почему remove(int) это O(n)
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index)); // node(index) это O(index)
}
E unlink(Node<E> x) {
final E element = x.item; // O(1)
final Node<E> next = x.next; // O(1)
final Node<E> prev = x.prev; // O(1)
if (prev == null) {
first = next; // O(1)
} else {
prev.next = next; // O(1)
x.prev = null; // O(1)
}
if (next == null) {
last = prev; // O(1)
} else {
next.prev = prev; // O(1)
x.next = null; // O(1)
}
x.item = null; // O(1)
size--; // O(1)
return element;
}
Визуально:
До удаления:
first ──→ [1] ←→ [2] ←→ [3] ←── last
Удаляем элемент с индексом 1 ([2]):
Сначала находим его: node(1) это O(1) шаг
Потом распутываем:
prev ([1]).next = next ([3])
next ([3]).prev = prev ([1])
После удаления:
first ──→ [1] ←→ [3] ←── last
Общая сложность: O(1) для распутывания + O(n) для поиска = O(n)
Сравнение с ArrayList
// ArrayList
get(100): O(1) — прямой доступ по индексу
add(): O(1)* — амортизированная O(1)
add(100): O(n) — нужно сдвинуть элементы
remove(): O(n) — нужно сдвинуть элементы
// LinkedList
get(100): O(100) — идём по цепочке
add(): O(1) — всегда O(1)
add(100): O(100) — нужно найти место
remove(): O(n) — нужно найти элемент
Выбор:
// Если часто делаете get(index) по случайному индексу
List<String> list = new ArrayList<>(); // ✅
// Если часто добавляете/удаляете из конца
List<String> list = new LinkedList<>(); // ✅
// Если часто добавляете/удаляете из начала
List<String> list = new LinkedList<>(); // ✅
list.addFirst(element); // O(1)
list.removeFirst(); // O(1)
// Если случайный доступ + добавление
List<String> list = new ArrayList<>(); // ✅ ArrayList лучше
Оптимизация в Java LinkedList
// Оптимизация 1: Двусвязный список
// Позволяет идти как с начала, так и с конца
private Node<E> node(int index) {
if (index < (size >> 1)) // Если первая половина
return fromFirst(index); // Идём с начала
else // Если вторая половина
return fromLast(index); // Идём с конца
}
// Оптимизация 2: last указатель
public void add(E e) {
linkLast(e); // O(1) потому что у нас есть last
}
// Оптимизация 3: first указатель
public E getFirst() {
return first.item; // O(1) потому что у нас есть first
}
На практике
// ❌ ПЛОХО: O(n²) сложность!
public void badExample(LinkedList<Integer> list) {
for (int i = 0; i < list.size(); i++) {
Integer value = list.get(i); // O(i) для каждой итерации
}
// Общая сложность: 1+2+3+...+n = n(n+1)/2 = O(n²)
}
// ✅ ХОРОШО: O(n) сложность
public void goodExample(LinkedList<Integer> list) {
for (Integer value : list) { // Итератор, O(1) за итерацию
// ...
}
// Общая сложность: O(n)
}
// ✅ ХОРОШО: O(n) сложность
public void goodExample2(LinkedList<Integer> list) {
ListIterator<Integer> iter = list.listIterator();
while (iter.hasNext()) { // O(1) за итерацию
Integer value = iter.next();
}
}
Вывод: Временная сложность операций
LinkedList сложности:
add(E) O(1) — добавляем в конец (last известен)
addFirst(E) O(1) — добавляем в начало (first известен)
addLast(E) O(1) — добавляем в конец (last известен)
add(int, E) O(n) — нужно найти индекс
get(int) O(n) — нужно идти по цепочке
getFirst() O(1) — first известен
getLast() O(1) — last известен
remove(int) O(n) — нужно найти индекс
removeFirst() O(1) — first известен
removeLast() O(1) — last известен
remove(Object) O(n) — нужно найти элемент
contains(Object) O(n) — нужно линейный поиск
indexOf(Object) O(n) — нужно линейный поиск
Почему именно так:
- get(index) это O(n) — потому что LinkedList это цепочка, нет индексации
- add() это O(1) — потому что у нас есть указатель last
- add(int, E) это O(n) — потому что нужно сначала найти индекс
- remove это O(n) — потому что нужно найти элемент
- Операции с first/last это O(1) — потому что у нас есть прямые указатели
Это базовые знания структур данных, критичные для Java разработчика.