深入理解鏈表(Linked List)與資料結構 - 以 JavaScript 實作

更新 發佈閱讀 25 分鐘


會開始寫有關演算法的文章,一部分是知道自己在演算法沒有這麼熟悉,另一個部分,則是發現現在面試考演算法的比例變高了。因此也下定決心,在這個過程慢慢地把演算法補齊。今天要介紹的資料結構便是——鏈表(Linked List)。


如何理解 Linked List 與 Array 的差異?

與常見的數組(Array)不同,我們可以用書櫃和貨櫃車的貨櫃來比喻。想像數組就像是一個書櫃,其中的書籍排列整齊,每本書都在自己的位置上。當你需要找一本書時,只要知道它的位置,就可以直接拿到它。這就像是在 Array 中,可以直接透過 index訪問元素。

但當你需要在書櫃中放入或拿出一本書時,可能需要移動其他書籍來為新書騰出空間,或移動被拿出的書籍所留下的空白,這就像 Array 在插入或刪除元素時,需要移動其他元素一樣。

鏈表更像是一列貨櫃車上的貨櫃。每個貨櫃都連接到下一個,但它們不一定需要排列在一條直線上。當你想增加或移除一個貨櫃時,只需要解開它與前後貨櫃的連接,然後將它移出或加入即可。這就像在鏈表中添加或刪除節點那樣,不需要移動其他節點,只需要更改一些鏈接即可。

不過,如果你需要找到特定順位的貨櫃,你就會需要從頭開始計算,直到找到目標為止,這也說明了 Linked List 在搜尋上的特性。


這一篇文章有什麼?

本文將深入探討鏈表的核心概念,並使用 JavaScript 來說明如何實現和操作鏈表。我們將一步一步說明鏈表的六大方法:

  1. append(添加到末尾)
  2. prepend(添加到開頭)
  3. insert(插入元素至特定位置)
  4. remove(刪除元素)
  5. find(查找元素)
  6. reverse(反轉鏈表)

每個方法都將通過詳細的程式碼和解釋,讓您不僅學會如何使用這些方法,還能深入理解其背後的原理。接著,就讓我們開始吧!


鏈表(Linked List)的核心概念

鏈表(Linked List)是一種數據結構,它由一系列節點(Node)組成,每個節點包含數據和指向下一節點(next)的連接。也是因為結構的特性,使得元素的插入和刪除等操作,可以在任何位置有效率地進行,不同於需要連續記憶體空間的數組(Array)。

例如 React 的 hooks 儲存,就是使用 Linked List 的結構進行儲存,這也是為什麼 hook 不能被放在條件式之中的主因。但礙於本篇的主題,之後有機會再開一篇。


為何 Linked List 插入與刪除優於 Array?

鏈表與數組的主要區別在於數據存儲和訪問方式。數組是連續存儲的,這意味著它們可以快速訪問任何位置的元素(時間複雜度 O(1) ),但在中間插入或刪除元素時可能效率較低。相反,鏈表在插入和刪除操作上更有優勢,但對於隨機訪問與搜尋來說效率較低。


常見的單鏈表與雙鏈表

鏈表可以分為兩類:單鏈表(Singly Linked List)和雙鏈表(Doubly Linked List)。單鏈表的節點僅包含指向下一節點(next)的鏈接,而雙鏈表的節點則包含兩個鏈接,分別指向前一個(prev)和下一個節點(next)。

  1. 單鏈表:在這種類型的鏈表中,每個節點只含有指向下一節點的鏈接。這意味著它們只能單向遍歷。單鏈表結構簡單,使用的記憶體較少,但在某些操作中,如反向遍歷或查找前節點時效率會很差。
  2. 雙鏈表:與單鏈表不同,雙鏈表的節點不僅有指向下一節點的鏈接,還有指向前一節點的鏈接。這種結構使得雙向遍歷成為可能,提高了某些操作的效率,例如反向遍歷和在特定位置插入或刪除節點。然而,這種靈活性是以增加額外內存為代價,因為每個節點需要額外的記憶體空間,來存儲前驅節點的引用。

因此,在選擇使用哪種類型的鏈表時,就會需要思考何種限制與需求需要被優先滿足。



實現鏈表的基礎結構

在 JavaScript 中實現一個鏈表,最重要的有兩個核心元素:

  1. 節點類(Node class)
  2. 鏈表類(LinkedList class)

首先需要創建一個節點類(Node class),用於表示鏈表中的每個節點。隨後,我們將建立一個鏈表類(LinkedList class),用於管理這些節點並提供鏈表操作的方法。


節點類(Node class)的實現

節點類通常包含兩個屬性:value(用於存儲數據)和 next(用於指向鏈表中的下一個節點)。以下會各自實現單向列表(Singly Linked List)與雙向列表(Doubly Linked List)。兩者唯一的差異,就在於每一個節點,需要多一個 prev 來記錄前一個 Node 的指向。

// Singly Node
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
}

// Doubly Node
class DoublyNode {
constructor(value) {
this.value = value;
this.prev = null;
this.next = null;
}
}

鏈表類(Linked List class)的初始結構

鏈表類則負責組織節點(Node),並提供各種操作鏈表的方法。在這個基本結構中,head 屬性指向鏈表的第一個節點,且因為尚未有任何節點,因此 tail 也指向 head 的 null。而 size 屬性則用於追蹤鏈表中的節點總數。

class LinkedList {
constructor() {
this.head = null;
this.tail = this.head;
this.size = 0;
}
}



如何實作 Linked List 常見的方法

接下來,筆者將六個常見的方法「append」、「prepend」、「insert」、「remove」、「find」和「reverse」等鏈表操作,以實際的程式碼逐行說明。但也要提醒大家,實際上不只有一種實現方式,這篇文章的只是其中一種實現方式,提供給大家參考與對照。


append - 在鏈表尾部添加元素

append 這個方法,主要用於在鏈表的末尾添加一個新節點。這個操作首先檢查是否存在頭節點(head)。如果沒有,新節點將成為頭節點。

接著,因為我們有設定 tail 來直接找到最後一個節點,不然就需要從頭遍歷鏈表,找到最後一個節點,並將其 next 指針指向新節點。

而雙向鏈表需要額外做的步驟,在於將新節點的 prev 與原先 tail 的節點相連,讓我們可以從新的節點找到前一個的節點。

// Singly Linked List
class LinkedList {
append(item) {
const newNode = new Node(item);
if (this.head === null) { // 如果為空鏈表
this.head = newNode; // 將 newNode 新增至 head
this.tail = this.head; // 將 newNode 新增至 tail
} else {
this.tail.next = newNode; // 將 newNode 接在 tail 的後方
this.tail = newNode; // 將 tail 換成 newNode
}
this.size++;
return this; // 方便鏈式調用
}
}

// Doubly Linked List
class DoublyLinkedList {
append(item) {
const newNode = new Node(item);
if (this.head === null) {
this.head = newNode;
this.tail = this.head;
} else {
newNode.prev = this.tail; // 將 newNode 的前節點設定為原先的 tail 節點
this.tail.next = newNode;
this.tail = newNode;
}
this.size++;
return this;
}
}



prepend - 在鏈表頭部添加元素

prepend 方法將新節點添加到鏈表的開頭。對於單向鏈表,這涉及將新節點的 next 指向當前的頭節點,然後更新頭節點為新節點。對於雙向鏈表,還需要設置新節點的前驅節點為 null 並更新原頭節點的 prev 為新節點。

// Singly Linked List
class LinkedList {
prepend(item) {
const newNode = new Node(item);
newNode.next = this.head; // 將 newNode 的 next 指向當前的頭節點 head。
this.head = newNode; // 更新 head 為 newNode。
return this;
}
}

// Doubly Linked List
class DoublyLinkedList {
prepend(item) {
const newNode = new DoublyNode(item);
if (!this.head) { // 如果鏈表為空,則將 newNode 設為頭節點和尾節點。
this.head = newNode;
this.tail = newNode;
} else {
newNode.next = this.head; // 將 newNode.next 指向當前的頭節點
this.head.prev = newNode; //將原頭節點的 prev 指向 newNode
this.head = newNode; // 更新 head 為 newNode
}
return this;
}
}



insert - 從鏈表的特定位置插入元素

在單向鏈表中,這需要遍歷鏈表到特定的 index 元素後,再更新前一節點的 next 指針以指向插入的新節點。並將新節點的 next 與原先的下一個節點連結在一起。而在雙向鏈表中,除了要更新前一個節點的 next 指針,也要更新插入節點的 prev ,以及下一個的 prev 指針。

// Singly Linked List
class LinkedList {
insert(item, index) {
if (index < 0 || index >= this.size) return null;
if (index === 0) return this.prepend(item);
if (index === this.size - 1) this.append(item);

const newNode = new Node(item);
let current = this.head;

for (let i = 0; i < index; i++) { // 找到要插入的前一個節點
current = current.next;
}

newNode.next = current.next; // 將 newNode 的 next 指向當前的頭節點 head。
current.next = newNode; // 更新 head 為 newNode。
this.size++; // 更新長度
return this;
}
}

// Doubly Linked List
class DoublyLinkedList {
insert(item, index) {
if (index < 0 || index >= this.size) return null;
if (index === 0) return this.prepend(item);
if (index === this.size - 1) this.append(item);

const newNode = new Node(item);
let current = this.head;
let previous = null;

for (let i = 0; i < index; i++) { // 找到插入的當前節點
previous = current;
current = current.next;
}

newNode.next = current; // 將 newNode 的 next 指向當前節點。
newNode.prev = previous; // 將新節點的 prev 接上上一個節點
current.prev = newNode; // 將當前節點的 prev 接上 newNode
previous.next = newNode;

this.size++; // 更新長度
return this;
}
}



remove - 從鏈表中刪除給定 index 元素

remove 方法用於從鏈表中刪除元素。在單向鏈表中,需要遍歷整個鏈表,以找到要刪除的元素 index,並更新前一節點的 next 指針以刪除目標節點。而在雙向鏈表中,除了更新前一節點的 next 指針,還需要更新後一節點的 prev 指針。

// Singly Linked List
class LinkedList {
remove(index) {
if (!this.head) return null; // 如果鏈表為空,直接返回
if (index < 0 || index >= this.size) return null; // index 為負數或超出範圍

// 如果頭節點是要刪除的節點
if (index === 0) {
const removedNode = this.head;
this.head = this.head.next; // 更新 head 節點
this.size--;
return removedNode;
}

let current = this.head; // 記錄當前的 Node
let previous = null; // 記錄上一個節點
let counter = 0; // 記錄遍歷到第幾次

// 找到 index 的當前 Node,且確認該節點不為空(超出範圍)​
while (counter < index && current !== null) {
[previous, current] = [current, current.next];
counter++;
}

// 如果確定當前節點不為空​,則將上一個節點的 next 接在 current 的下一個節點
if (current !== null) previous.next = current.next;

this.size--;
return this;
}
}

// Doubly Linked List
class DoublyLinkedList {
remove(index) {
if (!this.head) return null; // 如果鏈表為空,直接返回
if (index < 0 || index >= this.size) return null; // index 為負數

// 如果頭節點是要刪除的節點
if (index === 0) {
const removedNode = this.head;
this.head = this.head.next; // 更新頭節點

if (this.head) this.head.prev = null; // 如果新的頭節點存在,更新其 prev 指針
else this.tail = null; // 如果移除後沒有 Node,則將 tail 一併設為 null

this.size--;
return removedNode;
}

let current = this.head; // 從頭開始計算
let counter = 0; // 用以計數

while (counter < index && current !== null) {
current = current.next;
counter++;
}

const removedNode = current; // 保存被移除的節點

// 處理目標不為最後一個節點時,將下一個節點的 prev 接上上一個節點
if (current.next) current.next.prev = current.prev;

// 如果不為第一個節點,則將前一個節點的 next 接上下一個節點
if (current.prev !== null) current.prev.next = current.next;

// current 為最後一個節點的情況,將前一個節點更新為 tail
if (current === this.tail) this.tail = current.prev;

this.size--;

return removedNode; // 如果沒有找到,返回鏈表本身
}
}



find - 在鏈表中查找元素

find 方法用於在鏈表中查找特定元素。無論是單向鏈表還是雙向鏈表,此方法的基本操作相同:從頭節點開始遍歷,直到找到匹配的元素或達到鏈表末尾。

// Singly Linked List
class LinkedList {
find(value) {
let current = this.head;
while (current) {
if (current.value === value) return current; // 找到匹配的元素,返回該節點
current = current.next;
}
return null; // 沒有找到,返回 null
}
}

// Doubly Linked List
class DoublyLinkedList {
find(value) {
let current = this.head;
while (current) {
if (current.value === value) return current; // 找到匹配的元素,返回該節點
current = current.next;
}
return null; // 沒有找到,返回 null
}
}



reverse - 反轉鏈表

reverse 方法將鏈表的元素順序反轉。在單向鏈表中,此操作需要更改每個節點的 next 指針。在雙向鏈表中,還需要更改每個節點的 prev 指針。

// Singly Linked List with iteration
class LinkedList {
reverse() {
let current = this.head;
let prev = null;
let next = null;

while (current) {
next = current.next; // 保存下一個節點
current.next = prev; // 反轉當前節點的 next 指針
prev = current; // 移動 prev 節點
current = next; // 移動到下一個節點
}

this.head = prev; // 更新頭節點
return this;
}
}

// Singly Linked List with Recursion
class LinkedList {
reverse() {
// 定義 recursion 的函式
function reverseList(head) {
// 確定當前節點或下一個節點為空,則直接返回傳入的當前節點​
if (head === null || head.next === null) return head;
const reversedList = reverseList(head.next);
head.next.next = head; // 將下個節點的 next 接上當前節點
head.next = null; // 將當前節點的 next 暫時移除連接(因為往後執行時會再接上)
return reversedList;
}

// 處理無節點,或僅有一個節點的情況​
if (this.head === null || this.head.next === null) return this;

const originHead = this.head; // 記得原先的 head
this.head = reverseList(this.head);
this.tail = originHead; // 將原本的 head 換成 tail
return this;
}
}

// Doubly Linked List
class DoublyLinkedList {
reverse() {
let current = this.head;
let temp = null;

while (current) {
// 交換 prev 和 next 指針
[current.next, current.prev] = [current.prev, current.next];
current = current.prev; // 移動到原本的下一個節點
}

if (temp) this.head = temp.prev; // 更新頭節點
return this;
}
}

其中,要注意的是遞迴(Recursion)會將欲執行的函式疊在 Stack 中,因此最後執行的函式會被優先處理。換句話說,最後執行的結果就會是終止函式,接著才從終止函式中往回執行,直到處理第一個函式。



Linked List 的應用場景

鏈表(Linked List)作為一種靈活且強大的數據結構,目前搜尋到的有以下是幾個經典應用場景:

  1. 動態數據存儲:由於鏈表可以在運行時動態地增加或減少節點,它們非常適合用於不確定大小的數據集合,例如用戶生成的內容列表或實時消息體系。
  2. 實現隊列和堆棧:鏈表非常適合實現如 Queue 和 Stack 這樣的抽象數據類型。尤其是在需要頻繁進行插入和刪除操作的場景中,Linked List 的效能會優於 Array。
  3. 圖的表示:在圖形算法中,鏈表可用來有效地表示圖的邊和節點,尤其是在實現鄰接表時。
  4. 記憶體管理:操作系統中的記憶體管理,也常使用鏈表來追蹤空閒的記憶體塊。



結語

第一次在了解與實作 Linked List 時,花了非常多時間在了解其核心的觀念,以及如何對 Linked List 的資料結構進行操作,這些都讓一開始學習 Linked List 有著一定的門檻。筆者自己也是在一點一滴的實作中,才慢慢知道如何操作 Linked List,甚至慢慢能夠寫出常見的方法。

因此這一篇文章,希望能夠給剛接觸 Linked List 的人一些切入方向,並透過文章中的逐步說明,了解 Linked List 這一個資料結構的美妙!

如果以上內容有任何錯誤或建議,都歡迎直接留言或來信交流!


Reference 參考資料

留言
avatar-img
學.誌|Chris Kang的沙龍
7.5K會員
14內容數
此處作為整理前端(Frontend)和相關的 HTML、CSS、JavaScript、React 等前端觀念與技巧,全部都會收錄在這個專題之中。同時也會將相關的技術與反思記錄在此,歡迎各位讀者互相交流。
2024/04/08
本文帶你深入探索 TypeScript 中的 satisfies 特性,能幫助你實現精確的型別推導與型別檢查。透過實際案例,展示如何使用 satisfies 提升代碼的型別安全與程式碼的整潔,是每位 TypeScript 開發者不可或缺的知識。
Thumbnail
2024/04/08
本文帶你深入探索 TypeScript 中的 satisfies 特性,能幫助你實現精確的型別推導與型別檢查。透過實際案例,展示如何使用 satisfies 提升代碼的型別安全與程式碼的整潔,是每位 TypeScript 開發者不可或缺的知識。
Thumbnail
2024/03/26
本文介紹 TypeScript 常遇到的混合型別,以及如何透過五種型別防禦(Type Guard)來解決。涵蓋了使用型別斷言、型別謂詞、in 運算子、typeof 運算子以及 instanceof 運算子這幾種方式。透過本文的學習,能夠更好地運用 TypeScript 進行程式碼開發。
Thumbnail
2024/03/26
本文介紹 TypeScript 常遇到的混合型別,以及如何透過五種型別防禦(Type Guard)來解決。涵蓋了使用型別斷言、型別謂詞、in 運算子、typeof 運算子以及 instanceof 運算子這幾種方式。透過本文的學習,能夠更好地運用 TypeScript 進行程式碼開發。
Thumbnail
2023/08/08
「px」,即像素,是最基本的單位,它常被用於指定字體大小、邊框粗細等。「em」和「rem」通常用於調整相對大小,「em」在子元素中的適用,而「rem」則以根元素為參考。另一方面,「vh」和「vw」分別代表視窗的高度和寬度百分比,特別適合實現響應式設計。「vmin」和「vmax」則根據視窗的最小或最大
Thumbnail
2023/08/08
「px」,即像素,是最基本的單位,它常被用於指定字體大小、邊框粗細等。「em」和「rem」通常用於調整相對大小,「em」在子元素中的適用,而「rem」則以根元素為參考。另一方面,「vh」和「vw」分別代表視窗的高度和寬度百分比,特別適合實現響應式設計。「vmin」和「vmax」則根據視窗的最小或最大
Thumbnail
看更多
你可能也想看
Thumbnail
vocus 慶祝推出 App,舉辦 2026 全站慶。推出精選內容與數位商品折扣,訂單免費與紅包抽獎、新註冊會員專屬活動、Boba Boost 贊助抽紅包,以及全站徵文,並邀請你一起來回顧過去的一年, vocus 與創作者共同留下了哪些精彩創作。
Thumbnail
vocus 慶祝推出 App,舉辦 2026 全站慶。推出精選內容與數位商品折扣,訂單免費與紅包抽獎、新註冊會員專屬活動、Boba Boost 贊助抽紅包,以及全站徵文,並邀請你一起來回顧過去的一年, vocus 與創作者共同留下了哪些精彩創作。
Thumbnail
創業者常因資金困境而無法抓住機會,利用房產活化讓二胎房貸成為財務策略的有力夥伴。 諮詢國峯厝好貸的二胎房貸服務,讓你的房子成為你最強力的天使投資人,推動事業成長。
Thumbnail
創業者常因資金困境而無法抓住機會,利用房產活化讓二胎房貸成為財務策略的有力夥伴。 諮詢國峯厝好貸的二胎房貸服務,讓你的房子成為你最強力的天使投資人,推動事業成長。
Thumbnail
你好,在下最近在學習開發web,學了html css js,也得出一些心得,由於網路上已有許多教學,所以我會著重在如何開發出to do List,以及解釋我寫的程式碼。相關的教學我會直接貼網址。如果我有什麼地方出錯,或者是可以寫得更好,歡迎在下方留言,討論。 首先先介紹我的開發環境: 我用了vs
Thumbnail
你好,在下最近在學習開發web,學了html css js,也得出一些心得,由於網路上已有許多教學,所以我會著重在如何開發出to do List,以及解釋我寫的程式碼。相關的教學我會直接貼網址。如果我有什麼地方出錯,或者是可以寫得更好,歡迎在下方留言,討論。 首先先介紹我的開發環境: 我用了vs
Thumbnail
本章節旨在介紹JavaScript中的物件導向編程。內容包括類別(Class)的定義和使用,建構子的作用,以及公開,私有,受保護(Protected)等不同訪問修飾符的概念。此外,還涵蓋了繼承、多型、封裝、介面、抽象類別、靜態類別、列舉、委派、Lambda表達式、泛型、反射等物件導向的主要觀念。
Thumbnail
本章節旨在介紹JavaScript中的物件導向編程。內容包括類別(Class)的定義和使用,建構子的作用,以及公開,私有,受保護(Protected)等不同訪問修飾符的概念。此外,還涵蓋了繼承、多型、封裝、介面、抽象類別、靜態類別、列舉、委派、Lambda表達式、泛型、反射等物件導向的主要觀念。
Thumbnail
這些章節的目的是為了介紹JavaScript中的各種數據類型,包括基礎類型和物件類型,以及如何將數據從一種類型轉換為另一種類型。此外,還介紹了如何創建自定義類型,以及如何使用JavaScript中的陣列、集合和字典。
Thumbnail
這些章節的目的是為了介紹JavaScript中的各種數據類型,包括基礎類型和物件類型,以及如何將數據從一種類型轉換為另一種類型。此外,還介紹了如何創建自定義類型,以及如何使用JavaScript中的陣列、集合和字典。
Thumbnail
軟體系統的發展歷程大多相似,首重解決基本需求、提供操作介面,進而提升安全性、擴充功能、優化操作。
Thumbnail
軟體系統的發展歷程大多相似,首重解決基本需求、提供操作介面,進而提升安全性、擴充功能、優化操作。
Thumbnail
可選串聯(?.)運算符用於訪問 object 的屬性或調用函數。如果使用該運算符訪問的object 或調用的函式為 undefined 或 null,則表達式會回傳 undefined,而不是拋出錯誤。
Thumbnail
可選串聯(?.)運算符用於訪問 object 的屬性或調用函數。如果使用該運算符訪問的object 或調用的函式為 undefined 或 null,則表達式會回傳 undefined,而不是拋出錯誤。
Thumbnail
列出一套完整的程式 程式設計有許多種方法,不過通常會先列出清單的再逐一執行,這樣會加快程式設計的速度。設計通常會採取順推的辦法。所以順推的程式設計方式就是經歷觀念溝通、系統分析、資料統合、權限管理、頻率與時間、後台管理、畫面設計等等階段後,將框架設計完了以後,先列出一套完整的程式,將所有使用者都確
Thumbnail
列出一套完整的程式 程式設計有許多種方法,不過通常會先列出清單的再逐一執行,這樣會加快程式設計的速度。設計通常會採取順推的辦法。所以順推的程式設計方式就是經歷觀念溝通、系統分析、資料統合、權限管理、頻率與時間、後台管理、畫面設計等等階段後,將框架設計完了以後,先列出一套完整的程式,將所有使用者都確
Thumbnail
JavaScript 套件,頁碼 Pagination.js 搭配 axios API 請求範例
Thumbnail
JavaScript 套件,頁碼 Pagination.js 搭配 axios API 請求範例
Thumbnail
在之前的文章當中曾經提到過 JavaScript 中的物件有一個特別的機制:傳參考(Called by reference),如果正確性再高一點的話,則可以稱之為傳共享(Called by sharing)。
Thumbnail
在之前的文章當中曾經提到過 JavaScript 中的物件有一個特別的機制:傳參考(Called by reference),如果正確性再高一點的話,則可以稱之為傳共享(Called by sharing)。
Thumbnail
題目敘述 題目會給我們一組定義好的界面和需求,要求我們設計一個資料結構,可以滿足平均O(1)的插入元素、刪除元素、隨機取得元素的操作。 RandomizedSet() 類別建構子 bool insert(int val) 插入元素的function界面 bool remove(int val
Thumbnail
題目敘述 題目會給我們一組定義好的界面和需求,要求我們設計一個資料結構,可以滿足平均O(1)的插入元素、刪除元素、隨機取得元素的操作。 RandomizedSet() 類別建構子 bool insert(int val) 插入元素的function界面 bool remove(int val
追蹤感興趣的內容從 Google News 追蹤更多 vocus 的最新精選內容追蹤 Google News