2009-ben kihívást állítottam magam elé, hogy egész évben hetente egy blogbejegyzést írok. Azt olvastam, hogy a legjobb módja annak, hogy több látogatót szerezzen egy blog, a következetes posztolás. A heti egy bejegyzés reális célnak tűnt az összes cikkötletem miatt, de kiderült, hogy jóval kevesebb, mint 52 ötletem volt. Átnéztem néhány félig megírt fejezetet, amiből végül a Professional JavaScript lett, és sok anyagot találtam a klasszikus számítástechnikai témákról, beleértve az adatszerkezeteket és algoritmusokat. Ezt az anyagot 2009-ben és (és néhányat 2012-ben) több bejegyzéssé alakítottam, és rengeteg pozitív visszajelzést kaptam róluk.
Most, ezeknek a bejegyzéseknek a tízéves évfordulóján úgy döntöttem, hogy 2019-ben frissítem, újra kiadom és kibővítem őket JavaScript segítségével. Érdekes volt látni, hogy mi változott és mi nem, és remélem, tetszeni fognak.
Mi az a linkelt lista?
A linkelt lista egy olyan adatszerkezet, amely több értéket tárol lineárisan. Az összekapcsolt listában minden értéket egy saját csomópont tartalmaz, egy olyan objektum, amely az adatot tartalmazza a lista következő csomópontjára mutató hivatkozással együtt. A hivatkozás egy másik csomópont objektumra mutató mutató, vagy null
, ha nincs következő csomópont. Ha minden csomópontnak csak egy mutatója van egy másik csomópontra (leggyakrabban next
), akkor a listát egyszeresen kapcsolt listának (vagy csak kapcsolt listának) tekintjük, míg ha minden csomópontnak két linkje van (általában previous
és next
), akkor kétszeresen kapcsolt listának tekintjük. Ebben a bejegyzésben az egyszeresen kapcsolt listákra koncentrálok.
Miért használjunk kapcsolt listát?
A kapcsolt listák elsődleges előnye, hogy tetszőleges számú értéket tartalmazhatnak, miközben csak annyi memóriát használnak, amennyi ezekhez az értékekhez szükséges. A memória megőrzése nagyon fontos volt a régebbi számítógépeken, ahol a memória szűkös volt. Abban az időben a C-ben beépített tömböknél meg kellett adni, hogy hány elemet tartalmazhat a tömb, és a program lefoglalta ezt a memóriamennyiséget. A memória lefoglalása azt jelentette, hogy a memóriát nem lehetett használni a program többi része vagy más, egyidejűleg futó program számára, még akkor sem, ha a memória soha nem telt meg. Egy memóriaszegény gépen a tömbök használatával könnyen elfogyhatott a rendelkezésre álló memória. Az összekapcsolt listákat ennek a problémának a kiküszöbölésére hozták létre.
Az összekapcsolt listák, bár eredetileg a jobb memóriakezelést szolgálták, akkor is népszerűvé váltak, amikor a fejlesztők nem tudták, hogy egy tömb végül hány elemet fog tartalmazni. Sokkal egyszerűbb volt egy összekapcsolt listát használni és szükség szerint értékeket hozzáadni, mint pontosan kitalálni, hogy egy tömb legfeljebb hány értéket tartalmazhat. Így a linkelt listákat gyakran használják a különböző programozási nyelvek beépített adatstruktúráinak alapjául.
A beépített JavaScript Array
típus nem linkelt listaként van megvalósítva, bár a mérete dinamikus, és mindig a legjobb választás, ha ezzel kezdjük. Lehet, hogy az egész pályafutásodat úgy fogod leélni, hogy nem kell összekapcsolt listát használnod JavaScriptben, de a kapcsolt listák még mindig jó lehetőséget nyújtanak a saját adatszerkezetek létrehozásának megtanulására.
A kapcsolt lista felépítése
A kapcsolt lista legfontosabb része a csomópontstruktúrája. Minden csomópontnak tartalmaznia kell valamilyen adatot és egy mutatót a lista következő csomópontjára. Íme egy egyszerű ábrázolás JavaScriptben:
class LinkedListNode { constructor(data) { this.data = data; this.next = null; }}
A LinkedListNode
osztályban a data
tulajdonság tartalmazza azt az értéket, amelyet a linkelt lista elemének tárolnia kell, a next
tulajdonság pedig egy mutató a lista következő elemére. A next
tulajdonság null
-nak indul, mert még nem ismerjük a következő csomópontot. Ezután a LinkedListNode
osztály segítségével a következőképpen hozhatunk létre egy kapcsolt listát:
// create the first nodeconst head = new LinkedListNode(12);// add a second nodehead.next = new LinkedListNode(99);// add a third nodehead.next.next = new LinkedListNode(37);
A kapcsolt lista első csomópontját általában fejnek nevezzük, így ebben a példában a head
azonosító az első csomópontot jelöli. A második csomópontot létrehozzuk és hozzárendeljük a head.next
-hez, hogy egy két elemet tartalmazó listát hozzunk létre. Egy harmadik csomópontot adunk hozzá a head.next.next
-hez rendelve, ami a lista második csomópontjának next
mutatója. A lista harmadik csomópontjának next
mutatója null
marad. A következő kép az így kapott adatszerkezetet mutatja.
A linkelt lista szerkezete lehetővé teszi, hogy az összes adatot végigjárjuk az egyes csomópontok next
mutatóját követve. Íme egy egyszerű példa arra, hogyan lehet egy összekapcsolt listát végigjárni, és minden egyes értéket kiírni a konzolra:
let current = head;while (current !== null) { console.log(current.data); current = current.next;}
Ez a kód a current
változót használja a mutatónak, amely végigmegy az összekapcsolt listán. A current
változót a lista fejére inicializáljuk, és a while
ciklus addig folytatódik, amíg a current
nem lesz null
. A cikluson belül a current
csomóponton tárolt értéket kiírjuk, majd a next
mutatót követjük a következő csomópontig.
A legtöbb kapcsolt lista művelet ezt vagy valami hasonlót használ, ezért ennek az algoritmusnak a megértése fontos a kapcsolt listák általános megértéséhez.
A LinkedList osztály
Ha linkelt listát írnánk C-ben, akkor ezen a ponton megállhatnánk, és befejezettnek tekinthetnénk a feladatot (bár osztály helyett struct-ot használnánk az egyes csomópontok reprezentálására). Az objektumorientált nyelvekben, mint például a JavaScript, azonban sokkal inkább szokás egy osztályt létrehozni ennek a funkcionalitásnak a kapszulázására. Íme egy egyszerű példa:
const head = Symbol("head");class LinkedList { constructor() { this = null; }}
A LinkedList
osztály egy összekapcsolt listát reprezentál, és metódusokat fog tartalmazni a benne lévő adatokkal való interakcióhoz. Az egyetlen tulajdonság egy head
nevű szimbólum tulajdonság, amely a lista első csomópontjára mutató mutatót fogja tartalmazni. Szimbólum tulajdonságot használunk a karakterlánc tulajdonság helyett, hogy egyértelművé tegyük, hogy ezt a tulajdonságot az osztályon kívül nem kívánjuk módosítani.
Új adatok hozzáadása a listához
Egy elem hozzáadása egy összekapcsolt listához megköveteli a struktúra bejárását a megfelelő hely megtalálása érdekében, egy új csomópont létrehozását és a helyére való beillesztését. Az egyetlen speciális eset az, amikor a lista üres, ilyenkor egyszerűen létrehozunk egy új csomópontot, és hozzárendeljük a head
:
const head = Symbol("head");class LinkedList { constructor() { this = null; } add(data) { // create a new node const newNode = new LinkedListNode(data); //special case: no items in the list yet if (this === null) { // just set the head to the new node this = newNode; } else { // start out by looking at the first node let current = this; // follow `next` links until you reach the end while (current.next !== null) { current = current.next; } // assign the node into the `next` pointer current.next = newNode; } }}
A add()
módszer egyetlen argumentumot fogad el, bármilyen adatot, és hozzáadja a lista végéhez. Ha a lista üres (this
null
), akkor this
egyenlő az új csomóponttal. Ha a lista nem üres, akkor a már létező listán kell végigmennünk, hogy megtaláljuk az utolsó csomópontot. A végigjárás egy while
ciklusban történik, amely a this
-nél kezdődik, és az egyes csomópontok next
linkjeit követi, amíg az utolsó csomópontot meg nem találjuk. Az utolsó csomópont next
tulajdonsága null
-nak felel meg, ezért fontos, hogy a traverzálást ezen a ponton állítsuk le, nem pedig akkor, amikor current
null
(mint az előző szakaszban). Ezután hozzárendelhetjük az új csomópontot ehhez a next
tulajdonsághoz, hogy hozzáadjuk az adatot a listához.
A add()
módszer bonyolultsága O(n), mivel a teljes listát át kell járnunk, hogy megtaláljuk az új csomópont beszúrásának helyét. Ezt a bonyolultságot O(1)-re csökkenthetjük, ha a fej mellett a lista végét (általában farok) is nyomon követjük, így azonnal beilleszthetünk egy új csomópontot a megfelelő pozícióba.
Adatok kinyerése a listából
A linkelt listák nem teszik lehetővé a véletlenszerű hozzáférést a tartalmához, de ettől még bármelyik pozícióban lévő adatot kinyerhetjük a lista bejárásával és az adatok visszaadásával. Ehhez hozzáadsz egy get()
metódust, amely elfogadja a lekérdezendő adat nulla alapú indexét, így:
class LinkedList { // other methods hidden for clarity get(index) { // ensure `index` is a positive value if (index > -1) { // the pointer to use for traversal let current = this; // used to keep track of where in the list you are let i = 0; // traverse the list until you reach either the end or the index while ((current !== null) && (i < index)) { current = current.next; i++; } // return the data if `current` isn't null return current !== null ? current.data : undefined; } else { return undefined; } }}
A get()
metódus először ellenőrzi, hogy index
pozitív érték-e, különben undefined
-t ad vissza. A i
változót arra használjuk, hogy nyomon kövessük, milyen mélyre ment a listában a keresztezés. Maga a ciklus ugyanaz az alapvető traverzálás, mint amit korábban láttunk, azzal a hozzáadott feltétellel, hogy a ciklusnak ki kell lépnie, ha i
egyenlő index
-gyel. Ez azt jelenti, hogy a ciklus két feltétellel léphet ki:
-
current
null
, ami azt jelenti, hogy a lista rövidebb, mintindex
. -
i
egyenlőindex
, ami azt jelenti, hogycurrent
aindex
pozícióban lévő csomópont.
Ha current
null
, akkor undefined
, egyébként pedig current.data
. Ez az ellenőrzés biztosítja, hogy a get()
soha nem fog hibát dobni egy olyan index
-re, amely nem található a listában (bár dönthetünk úgy is, hogy hibát dobunk ahelyett, hogy undefined
-t adnánk vissza).
A get()
módszer bonyolultsága az O(1)-től az első csomópont eltávolításakor (nincs szükség átjárásra) az O(n)-ig terjed, amikor az utolsó csomópontot távolítjuk el (a teljes lista átjárása szükséges). A bonyolultságot nehéz csökkenteni, mert mindig keresésre van szükség a helyes visszaadandó érték azonosításához.
Adatok eltávolítása egy összekapcsolt listából
Az adatok eltávolítása egy összekapcsolt listából egy kicsit trükkös, mert biztosítani kell, hogy az összes next
mutató érvényes maradjon egy csomópont eltávolítása után. Ha például egy három csomópontos listából a második csomópontot akarjuk eltávolítani, akkor gondoskodnunk kell arról, hogy az első csomópont next
tulajdonsága a második helyett a harmadik csomópontra mutasson. A második csomópont ilyen módon történő átugrása ténylegesen eltávolítja azt a listából.
Az eltávolítás művelet valójában két műveletből áll:
- Keresd meg a megadott indexet (ugyanaz az algoritmus, mint a
get()
-ben) - Eltávolítsd a csomópontot ezen az indexen
A megadott index megtalálása ugyanaz, mint a get()
módszerben, de ebben a ciklusban a current
előtti csomópontot is követni kell, mert módosítani kell az előző csomópont next
mutatóját.
Négy speciális esetet is figyelembe kell vennünk:
- A lista üres (nem lehetséges a bejárás)
- Az index kisebb, mint nulla
- Az index nagyobb, mint a lista elemeinek száma
- Az index nulla (a fej eltávolítása)
Az első három esetben az eltávolítási művelet nem fejezhető be, ezért van értelme hibát dobni; a negyedik speciális esetben a this
tulajdonság átírása szükséges. A remove()
metódus implementációja így néz ki:
class LinkedList { // other methods hidden for clarity remove(index) { // special cases: empty list or invalid `index` if ((this === null) || (index < 0)) { throw new RangeError(`Index ${index} does not exist in the list.`); } // special case: removing the first node if (index === 0) { // temporary store the data from the node const data = this.data; // just replace the head with the next node in the list this = this.next; // return the data at the previous head of the list return data; } // pointer use to traverse the list let current = this; // keeps track of the node before current in the loop let previous = null; // used to track how deep into the list you are let i = 0; // same loops as in `get()` while ((current !== null) && (i < index)) { // save the value of current previous = current; // traverse to the next node current = current.next; // increment the count i++; } // if node was found, remove it if (current !== null) { // skip over the node to remove previous.next = current.next; // return the value that was just removed from the list return current.data; } // if node wasn't found, throw an error throw new RangeError(`Index ${index} does not exist in the list.`); }}
A remove()
metódus először két speciális esetet ellenőriz, az üres listát (this
null
) és a nullánál kisebb index
értéket. Mindkét esetben hibát dob.
A következő speciális eset az, amikor index
0
, ami azt jelenti, hogy eltávolítjuk a lista fejét. Az új listafejnek a lista második csomópontjának kell lennie, tehát this
egyenlő this.next
-gyel. Nem számít, ha csak egy csomópont van a listában, mert this
végül null
lesz egyenlő, ami azt jelenti, hogy a lista az eltávolítás után üres. Az egyetlen bökkenő az, hogy az eredeti fejből származó adatokat egy helyi változóban, a data
-ban kell tárolni, hogy vissza lehessen adni.
A négy speciális esetből háromról gondoskodtunk, most már folytathatjuk a get()
módszerben találhatóhoz hasonló traverzálást. Mint korábban említettük, ez a ciklus némileg különbözik annyiban, hogy a previous
változót arra használjuk, hogy nyomon kövessük a current
előtt közvetlenül megjelenő csomópontot, mivel ez az információ szükséges egy csomópont valószínű eltávolításához. A get()
-hez hasonlóan a ciklus kilépésekor a current
null
lehet, jelezve, hogy az indexet nem találták meg. Ha ez megtörténik, akkor hibaüzenetet kapunk, ellenkező esetben previous.next
current.next
lesz, ami ténylegesen eltávolítja current
-et a listából. A current
-en tárolt adatok utolsó lépésként visszakerülnek.
A remove()
módszer bonyolultsága megegyezik a get()
módszerével, és az O(1)-től az első csomópont eltávolításakor az O(n)-ig terjed, amikor az utolsó csomópontot távolítja el.
A lista iterálhatóvá tétele
A JavaScript for-of
ciklusával és a tömbök destruktúrálásával való használathoz az adatgyűjteményeknek iterálhatónak kell lenniük. A beépített JavaScript-gyűjtemények, például a Array
és Set
alapértelmezés szerint iterálhatóak, és a saját osztályainkat is iterálhatóvá tehetjük, ha megadunk egy Symbol.iterator
generáló metódust az osztályon. Én inkább először egy values()
generáló metódust implementálok (a beépített gyűjteményosztályokon található metódusnak megfelelően), majd a Symbol.iterator
közvetlenül hívja meg a values()
metódust.
A values()
metódusnak csak a lista alapvető átszelését és yield
az egyes csomópontok által tartalmazott adatok
class LinkedList { // other methods hidden for clarity *values(){ let current = this; while (current !== null) { yield current.data; current = current.next; } } () { return this.values(); } }
A values()
metódust csillaggal (*
) jelöljük, hogy ez egy generátor metódus. A metódus végigjárja a listát, és yield
segítségével minden egyes adatot, amellyel találkozik, visszaad. (Megjegyezzük, hogy a Symbol.iterator
metódus nincs generátorként jelölve, mert a values()
generátor metódusból egy iterátort ad vissza.)
Az osztály
Ha elkészült, a linkelt lista implementációját így használhatjuk:
const list = new LinkedList();list.add("red");list.add("orange");list.add("yellow"); // get the second item in the listconsole.log(list.get(1)); // "orange"// print out all itemsfor (const color of list) { console.log(color);}// remove the second item in the list console.log(list.remove(1)); // "orange" // get the new first item in the listconsole.log(list.get(1)); // "yellow"// convert to an arrayconst array1 = ;const array2 = ;
A linkelt lista ezen alapvető implementációja kiegészíthető egy size
tulajdonsággal, amely megszámolja a listában lévő csomópontok számát, és más ismert módszerekkel, mint például a indexOf()
. A teljes forráskód elérhető a GitHubon a Computer Science in JavaScript projektemben.
Következtetés
A linkelt listákat valószínűleg nem használjuk minden nap, de a számítástechnikában alapvető adatszerkezetek. Az egymásra mutató csomópontok használatának koncepcióját számos más adatszerkezetben használják, amelyek számos magasabb szintű programozási nyelvbe vannak beépítve. Az összekapcsolt listák működésének jó megértése fontos más adatszerkezetek létrehozásának és használatának általános megértéséhez.
A JavaScript programozáshoz szinte mindig jobban jár, ha a beépített gyűjteményosztályokat használja, mint például a Array
, mintha sajátot hozna létre. A beépített gyűjtőosztályok már optimalizálva vannak a termelési használatra, és jól támogatottak a különböző végrehajtási környezetekben.