mój mental model aż do niedawna:
- struktury tablicowe (tablice, wektory, kopce, itp) są, co do stałej, wydajne
- struktury wskaźnikowe (drzewa, linked listy, itp) są, co do stałej, prostytutkowo niewydajne
powód jest taki: gdy wszystko jest w tablicy, to wszystkie elemnty tablicy będa trzymane w pamięci obok siebie
ale gdy mamy strukturę wskaźnikową, to poszczególne elementy mogą być od siebie dowolnie daleko
np załóżmy że mamy linked list na wskaźnikach. Mamy element pierwszy, dajmy na to pod adresem 0x12345678. Teraz dodajemy drugi element, wołamy jakieś new
czy inne malloc
, ten element otrzymuje adres 0x87654321. Linked lista obok elementu pierwszego przechowuje wskaźnik na element drugi. Iterowanie po linked liście to jest O(1), ale problem polega na tym, że przejście z 0x12345678 do 0x87654321 to niemal gwarantowany cache miss, więc procek nic nie robi, tylko czeka na pobranie kolejnej strony.
tymczasem gdybyśmy mieli tablicę to elementy są obok siebie i żyją sobie na jednej stronie. Gdy pobieramy jeden element to automatycznie pobieramy całą stronę więc pobranie elementu drugiego jest natychmiastowe, bo wszystkie już są w cache.
Różnica między linked listami a tablicami (co do złożoności asymptotycznej) jest taka:
- Pobranie ntego elementu w linked liście to O(n), tymczasem w tablicy to O(1)
- Ale gdy już jesteśmy przy n-tym elemencie, to usunięcie tegoż n-tego elementu albo wstawienie nowego elementu pomiędzy n-tym a (n+1)szym to w linked liście O(1), podczas gdy w tablicy to O(n)
- Bazując na linked listach można zaimplementować w pełni funkcyjną listę (pozwalającą na dodanie / usunięcie elementu bez mutowania) w O(1), podczas gdy w tablicach jest to O(n) (kopiowanie całej tablicy)
Dlatego ogólne zalecenie jest takie: o ile możemy uzywamy tablic, linked list tylko o ile musimy (jeśli NAPRAWDĘ potrzebujemy wstawiać / usuwać elementy ze środka listy w O(1) albo jesteśmy fanatykami FP i nie akceptujemy żadneog mutowania za żadne skarby świata) -- ale musimy pamiętac, że tak czyniąc będziemy rozrzucać dane po pamięci, więc ilość cache misses będzie szła pod orbitę, więc nie wolno nam tak postąpić, jeśli lista jest w jakiejś tight loop i wydajność samej listy zaczyna być wąskim gardłem (co może się zdarzyc przy algorytmach / implementacji AI / obliczeniach naukowych / itp, ale raczej nie przy "normalnym" klepaniu).
==================================
@Autysta i @Czitels zachwieli tym moim mental model:
Zamiast implementować double linked list w C++ na pointerach to będziesz implementował na indexach tablicy w rust. — Autysta 2024-02-08 21:34
Zamiast mieć pointer do następnego elementu to masz index do miejsca w tablicy gdzie jest następny element inaczej tego w Rust nie napiszesz, bo język cię będzie ograniczał. — Autysta 2024-02-08 22:15
@YetAnohterone: możnaby stworzyć strukturę która by przechowywała index i wartość a obiekty tej struktury byłyby elementami tablicy. Iterowanie byłoby po pętli, ale still da się to zrobić xd, tylko wszystkie operacje byłyby bardzo brzydkie. — Czitels 2024-02-09 00:46
(źródło: https://4programmers.net/Forum/C_i_C++/371797-kosci_wracaja_do_c?p=1949512#comment-957228)
To było tak sprzeczne z moim powyższym mental model, że w pierwszym odruchu po prostu na ślepo zaprzeczyłem. Przecież jeśli da się zaimplementować linked listę na tablicy (gdzie "linked lista" to lista umożliwiająca dodawanie/usuwanie elementów ze środka w O(1) i tym podobne gwarancje asymptotycznej złożoności zwyczajowo kojarzone z linked listami), no to spada nam główna i podstawowa bolączka związana z linked listami, czyli ich wrogość dla cache procesora i stąd wynikająca prostytutkowo duża stała przy pobieraniu każdego kolejnego elementu. Zatem nie ma żadnego sensu implementować linked list na wskaźnikach, należy je implementować tylko na tablicach. Tymczasem o ile mi wiadomo wszystkie bilbioteki standardowe implementują linked listy na wskaźnikach właśnie: w C++ std::list
jest strukturą wskaźnikową, w C# Systems.Collections.Generic.LinkedList
jest strukturą wskaźnikową, w Haskellu domyślnie wszystkie listy i stringi są strutkurami wskaźnikowymi (to są pod spodem po prostu linked listy) by zapewnić pełną funkcyjność (stąd wypływa ich niska wydajność i konieczność przejścia do z natury bardziej imperatywnych tablic, gdy wydjaność zaczyna byuć potrzebna (koci mi się niejasno, że gdzieś się po hackage wala imperatywna, mutowalna tablica do użycia w takich wypadkach), w Ruście std::collections::LinkedList
jest strukturą wskaźnikową (i dlatego oficjalna dokumentacja https://doc.rust-lang.org/std/collections/struct.LinkedList.html zaleca użycie tablic gdzie tylko jest to mozliwe właśnie z powodu działania cache procesora), itp, itd). Tak więc implementacja linked listy na tablicy nie może być możliwa, bo gdyby była możliwa, to z pewnością bilbioteki standardowe Rusta, C++, C#, Haskella i innych implementwały je dla wydajności właśnie na tbalicach, a nie na wskaźnikach, jak to czynią.
Po zastanowieniu wydaje mi sie, że to jednak @Czitels i @Autysta mieli rację - chyba istotnie jak najbradziej można zrobić ilnked listę na tablicy, nie trzeba tylko i wyłącznie na wskaźnikach.
Ale w takim razie czemu biblioteki standardowe chyba wszystkich języków implementują linked listy na wskaźnikach, biorąc pod uwagę, że wszelkie wskaźnikowe struktury danych z automatu będą musiały być prostytutkowo niewydajne ze względu na ciągłe cache misses?