節點指標:鏈結串列

cppds Chapter 4 — Linear Linked Structures(對應講義 04)
Node|head|prev/cur 雙指標|有序插入|doubly / circular|STL list
向下捲動開始互動
CONTENTS · 內容目錄
PROLOGUE · 開場

擺脫連續記憶體:用指標串起集合 cppds §4.1–4.2

陣列/vector 要求元素緊挨著放在記憶體裡;鏈結結構(linked structure)改用 節點(node)+指標(pointer):每個節點自己記得「下一個在哪」。 只要能從第一個節點(head)出發,相對順序就能靠指標一路走出來。

同一份資料 93→77→31,兩種擺法。按按鈕切換視角。
取捨一覽
隨機存取 a[i]陣列 O(1)|串列 O(n)
頭端插入/刪除陣列 O(n)|串列 O(1)
記憶體串列多存指標
作業系統的例子
分時系統把工作排成環狀串列輪流執行: 鏈結結構天生適合「不斷插入、移除、輪轉」的場景。
PART 01 · 節點

Node 類別:資料 + 一根指向下一個的指標 cppds §4.3.1

節點是鏈結串列的積木:data 放資料、next 指向下一個節點。 next == NULL 表示「後面沒有了」:這就是串列的結尾記號(grounded)。

按「new Node(93)」建立節點,觀察 data 與 next 欄位。
C++ 實作 CODE
template <typename T> // dscpp/linked_list.hpp class Node { private: T data; Node<T> *next; public: Node(T initdata) { data = initdata; next = NULL; } T getData() { return data; } Node<T>* getNext() { return next; } void setData(T d) { data = d; } void setNext(Node<T> *n) { next = n; } };
指標語法速記
Node *p = new Node(93);
p->data  // 93
p->next  // NULL
delete p; // 記得釋放!
PART 02 · 未排序串列

UnorderedList:add、search、remove 的指標舞步 cppds §4.3

未排序串列把新元素加在 head(最省事的位置,O(1))。 重點戲是 remove:需要 prev / cur 雙指標同行,找到後讓 prev「跳過」cur。 順序錯了串列就斷:先接住後面,再改前面。

初始串列 31→77→17→93(head 在左)。試試 add / search / remove。
三大操作(動畫對照)CODE
void add(int item): // 加在 head,O(1) Node *temp = new Node(item) temp->next = head // ① 先接住整條串列 head = temp // ② 再移動 head bool search(int item): Node *cur = head while cur != NULL: if cur->data == item: return true cur = cur->next // 走訪 traversal return false void remove(int item): // 雙指標:prev 跟著 cur cur = head; prev = NULL while cur->data != item: prev = cur; cur = cur->next if prev == NULL: head = cur->next else: prev->next = cur->next

完整類別長什麼樣(dscpp/linked_list.hpp)

動畫面板用的是簡化寫法;實際的類別把 head 藏在 private、透過 Node 的 getNext()/setNext() 操作。size()search()remove() 都建立在同一個技巧上:走訪(traversal),也就是從 head 出發、 一個節點一個節點地拜訪到 NULL 為止。

isEmpty / size CODE
bool isEmpty() { return head == NULL; } // O(1):只看 head int size() { Node<T> *current = head; int count = 0; while (current != NULL) { count++; current = current->getNext(); } return count; // O(n):串列不會免費告訴你長度 }
remove:雙指標+delete CODE
void remove(T item) { Node<T> *current = head, *previous = NULL; bool found = false; while (!found && current != NULL) { if (current->getData() == item) found = true; else { previous = current; current = current->getNext(); } } if (found) { if (previous == NULL) head = current->getNext(); else previous->setNext(current->getNext()); delete current; // C++ 沒有垃圾回收! } }
為什麼需要 previous? 單向串列不能往回走。current 停在要刪的節點上時,需要改的其實是「前一個節點的 next」, 但這時已經回不去了。解法是讓 previous 永遠跟在 current 後面一格。 刪 head 是特殊情況:previous 還是 NULL,這時改的是 head 本身。 講義留了一題思考:刪最後一個節點時,這兩個分支夠用嗎?(提示:夠。想想 current->getNext() 那時是什麼。)
QUIZ · add 的兩行順序

add 裡若把兩行寫反(先 head = temp、再 temp->next = head),會發生什麼事?

(A) temp 的 next 指向自己,其餘節點全部漏接
(B) 沒差,結果一樣
(C) 編譯錯誤
PART 03 · 排序串列

OrderedList:維持大小順序的插入 cppds §4.4–4.5

排序串列的 add 不能亂插:prev/cur 往前走,直到 cur->data ≥ item 就是插入點。 search 因此能提前失敗:一旦看到比目標大的值就確定不存在。

初始 17→26→31→54→77→93。add(40) 會逐步找到 31 與 54 之間。
排序版操作 CODE
void add(int item): // 排序版:先找位置 cur = head; prev = NULL while cur != NULL && cur->data < item: prev = cur; cur = cur->next Node *temp = new Node(item) if prev == NULL: // 插在最前 temp->next = head; head = temp else: temp->next = cur; prev->next = temp bool search(int item): // 可提前停 while cur != NULL: if cur->data == item: return true if cur->data > item: return false // 超過了 cur = cur->next return false
複雜度(n 節點)
未排序 addO(1)
排序 addO(n)
search / removeO(n)
鏈結串列不能二分搜尋:沒有 O(1) 隨機存取。

分析:哪些操作 O(1)、哪些逃不掉走訪(cppds §4.4.1)

判斷準則一句話:只碰 head 的操作是 O(1),其他都要走訪。 search 平均找到目標時走了 n/2 步:聽起來省一半,但 Big-O 只看成長率, 係數 1/2 會被丟掉,而且最壞情況(不在串列裡、或排在最後)還是得走完全程,所以仍是 O(n)。 下表是講義列的完整 List ADT,一次看清楚每個方法的代價:

方法做什麼未排序排序
is_empty()串列是空的嗎(看 head)O(1)O(1)
add(item)加入元素O(1)(插 head)O(n)(找位置)
size()邊走邊數O(n)O(n)
search(item)找得到嗎O(n)O(n),可提前停
remove(item)prev/cur 找到後摘除O(n)O(n)
append(item)接在尾端(補充練習)O(n);有 tail 指標可 O(1)不適用(位置由大小決定)
index(item)回報元素排第幾O(n)O(n)
insert(pos, item)插到指定位置O(n)(走到 pos)不適用
pop() / pop(pos)移除尾端/指定位置並回傳O(n)O(n)
QUIZ · 平均走一半,為什麼還是 O(n)?

search 平均只要走 n/2 個節點就找到目標。為什麼複雜度不寫成 O(n/2)?

(A) 係數在 Big-O 裡沒有意義,且最壞情況要走完全程
(B) 因為 n/2 四捨五入之後就是 n
(C) 平均情況根本不能分析
PART 04 · 變化型

雙向與環狀:多一根指標,換一批超能力 課程補充(cppds §4 延伸)

單向串列刪尾端很痛:要先走到倒數第二個節點。雙向串列(doubly linked) 給每個節點多存一根 prev,兩個方向都能走;環狀串列(circular) 讓 tail 的 next 指回 head,作業系統的分時排程(輪流給每個程式一點 CPU)就是這樣繞圈的。

雙向串列的插入要同時接好四根指標:新節點的 prev、next,加上左右鄰居各一根。 順序亂了照樣斷鏈,所以實務上常加哨兵節點(sentinel):在頭尾各放一個不存資料的假節點, 讓「插在最前」「刪到只剩一個」這些邊界情況消失,每次操作都變成「在兩個真實存在的節點之間動手」。 多花兩個節點的記憶體,換到少一半的 if,很划算。

切換三種串列型態,注意指標的方向與端點。
QUIZ · 尾端刪除

要讓「刪除尾端節點」變成 O(1),需要哪種配置?

(A) 雙向串列 + tail 指標
(B) 單向串列 + tail 指標
(C) 環狀單向串列
PART 05 · 工業級

STL 的現成品:std::forward_list 與 std::list cppds §4.5

理解原理之後,實戰請用 STL:std::forward_list 是單向串列、std::list 是雙向串列。 它們保證任意位置插入/刪除 O(1)(拿到迭代器之後),但不支援 [i] 隨機存取

vectorforward_list(單向)list(雙向)
隨機存取 [i]O(1)—(不提供)—(不提供)
頭端插入O(n)O(1) push_frontO(1) push_front
尾端插入O(1) 攤銷O(n)(無 tail)O(1) push_back
已知位置插刪O(n)O(1) insert_afterO(1) insert/erase
每節點額外空間01 指標2 指標
cache 友善度高(連續)低(節點分散)

forward_list 的操作長相(cppds §4.5)

單向串列的 STL 版本連命名都在提醒你它的限制:插入叫 insert_after、 刪除叫 erase_after,因為單向串列只能「在某個節點後面」動手。常用操作:

操作說明
push_front(x) / pop_front()頭端加入、移除,O(1)
emplace_front(...)就地建構元素後放到最前面
insert_after(it, x)在迭代器 it 的後面插入,O(1)
erase_after(it)刪掉 it 後面那個節點,O(1)
remove(x) / unique() / sort()串列自家的成員函式版本(不能用 std::sort,沒隨機存取)
工程直覺 現代 CPU 上 vector 常常「理論輸、實測贏」,因為連續記憶體對快取太友善。 選 list 家族的時機是:頻繁在中間插刪,而且手上已經握有位置(迭代器)。 沒有這兩個條件,先用 vector。
QUIZ · 為什麼叫 insert_after?

forward_list 只提供 insert_after,不提供 insert(插在某節點前面)。原因是?

(A) 單向串列拿不到「前一個節點」,只能在已知節點後面接
(B) 歷史包袱,沒有技術原因
(C) insert_after 比較快
EXERCISES · 練習

動手驗證 cppds §4 綜合

EXERCISE 1 · 走訪計數

一條長度 n 的單向串列,size()(用走訪實作)與「取第 k 個元素」的成本分別是?

(A) O(n) 與 O(k)
(B) O(1) 與 O(1)
(C) O(n) 與 O(log k)
EXERCISE 2 · remove 邊界

remove(item) 用 prev/cur 雙指標。刪除的是 head 節點時,正確動作是?

(A) head = cur->next(prev 是 NULL)
(B) prev->next = cur->next
(C) 先把串列反轉再刪
EXERCISE 3 · 記憶體

C++ 手刻串列 remove 之後少做了什麼,會發生什麼事?

(A)delete cur → 記憶體洩漏
(B)cur = NULL → 編譯錯誤
(C) 什麼都不會發生

補充練習:實作 append() 講義補充(課堂跳過)

講義留了一題課後練習:替 UnorderedList 加上 append(item),把新元素接在尾端。 走訪版的解答長這樣:

append:一路走到尾 CODE
void append(T item) { Node<T> *temp = new Node<T>(item); if (head == NULL) { head = temp; return; } // 空串列:直接當 head Node<T> *current = head; while (current->getNext() != NULL) current = current->getNext(); // 一路走到最後一個節點 current->setNext(temp); }
補充練習 · append 的成本

上面這個 append() 是 O(n)。要讓它變成 O(1),正確的做法是?

(A) 類別多存一根 tail 指標,append 直接接在 tail 後面
(B) 把串列改成從尾端往頭端指
(C) 先把串列反轉、插入、再反轉回來
REFERENCE · 總覽

鏈結串列 vs 陣列:一張表決勝負 cppds §4 總覽

操作vector/陣列UnorderedListOrderedList
存取第 k 個O(1)O(k)O(k)
插入(頭)O(n)O(1)O(n)(找位置)
searchO(n)/排序後 O(log n)O(n)O(n),可提前停
remove(已找到)O(n)(搬移)O(1)(改指標)O(1)(改指標)
關鍵概念複習 ① 鏈結 = 用指標買「頭端 O(1) 插刪」,代價是失去隨機存取。
② 指標操作口訣:先接住後面,再改前面
③ prev/cur 雙指標是 remove/有序插入的標準步法;head 是唯一入口,弄丟就全丟。
④ 下一章(cppds ch5 遞迴)會看到:串列的遞迴定義「node + 剩下的串列」。