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.

Diagram of a Linked List by Lasindi - Own work, Public Domain

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:

  1. current null, ami azt jelenti, hogy a lista rövidebb, mint index.
  2. i egyenlő index, ami azt jelenti, hogy current a index 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.

Hivatkozott lista eltávolításának ábrája

Az eltávolítás művelet valójában két műveletből áll:

  1. Keresd meg a megadott indexet (ugyanaz az algoritmus, mint a get()-ben)
  2. 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:

  1. A lista üres (nem lehetséges a bejárás)
  2. Az index kisebb, mint nulla
  3. Az index nagyobb, mint a lista elemeinek száma
  4. 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.

Vélemény, hozzászólás?

Az e-mail-címet nem tesszük közzé.