← Wszystkie wpisy

ArrayList kontra LinkedList — przestałem zgadywać

Wszystkie kursy mówią to samo zdanie o wstawianiu w środek. Sprawdziłem, czy to prawda, i wynik był inny, niż się spodziewałem.

Każdy kurs powtarza to samo: ArrayList jest szybki przy odczycie, LinkedList przy wstawianiu w środek. Powtarzałem to na pamięć, nie rozumiejąc dlaczego. Postanowiłem sprawdzić.

Skąd bierze się różnica

ArrayList trzyma elementy w jednej ciągłej tablicy. Kiedy proszę o element numer 5000, JVM liczy adres i wchodzi tam od razu.

LinkedList to łańcuch węzłów — każdy trzyma wartość i wskaźnik do następnego. Żeby dojść do elementu 5000, trzeba przejść przez 4999 poprzednich.

List<Integer> tablica = new ArrayList<>();
List<Integer> lancuch = new LinkedList<>();

for (int i = 0; i < 100_000; i++) {
    tablica.add(i);
    lancuch.add(i);
}

// to jest natychmiastowe
tablica.get(50_000);

// to musi przejść przez pół listy
lancuch.get(50_000);

Różnica w moim teście: 0,00002 ms kontra 0,4 ms. Dwadzieścia tysięcy razy.

Gdzie się zaczyna schody

Skoro LinkedList ma szybkie wstawianie, sprawdziłem to:

long start = System.nanoTime();
for (int i = 0; i < 50_000; i++) {
    lancuch.add(lancuch.size() / 2, i);   // wstaw w środek
}
System.out.println((System.nanoTime() - start) / 1_000_000 + " ms");

I LinkedList przegrał z ArrayList. To nie miało sensu, dopóki nie zrozumiałem, gdzie leży pułapka.

Gdzie się wyłożyłem

Samo wstawienie węzła w LinkedList jest faktycznie natychmiastowe — trzeba tylko przepiąć dwa wskaźniki. Ale żeby dojść do miejsca wstawienia, trzeba się tam doczołgać. Metoda add(index, element) to najpierw przejście przez pół listy, a dopiero potem tanie wstawienie.

Zdanie z kursów jest prawdziwe tylko wtedy, gdy już trzymasz iterator w tym miejscu:

ListIterator<Integer> it = lancuch.listIterator(polowa);
it.add(42);   // TO jest szybkie

Przez trzy dni myślałem, że kursy kłamią. Kursy po prostu pomijały połowę zdania.

Ściąga, którą sobie zapisałem

operacja ArrayList LinkedList
get(i) O(1) O(n)
add(el) na koniec O(1)* O(1)
add(0, el) na początek O(n) O(1)
add(i, el) w środek O(n) O(n)
pamięć na element mało dużo (2 wskaźniki)

* okazjonalnie O(n), kiedy tablica musi urosnąć

Wniosek, który mi został

W praktyce prawie zawsze ArrayList. Procesory lubią ciągłą pamięć — ciągła tablica mieści się w cache, rozrzucone węzły nie. LinkedList wygrywa w wąskim przypadku: dużo wstawiania i usuwania na początku kolekcji. A jeśli tego potrzebuję, to i tak lepszym wyborem jest ArrayDeque.

Następnie: HashMap i pytanie, dlaczego trzeba nadpisywać equals() razem z hashCode().