鏈結串列的題目幾乎不考「演算法多難」,而是考「指標操作夠不夠細心」——一個 next 接錯就斷鏈或無限迴圈。把這幾個核心動作練到肌肉記憶,這類題就穩了:
- 三指針翻轉(prev / curr / next 的舞步)
- dummy 頭(簡化「接在頭部」的邊界)
- 快慢指針(找環、找中點)
- HashMap + 雙向鏈結串列(LRU 這種 O(1) 設計題)
延續一貫的完整推導流程:
📋 題目 → 🧭 拆解 → 暴力 → 瓶頸 → 洞察 → 最優 → 程式碼
- 反轉(206)→ 合併(21)→ 快慢指針偵測環(141)→ 重排(143,綜合三招)→ LRU Cache(146,設計題)
🔗 節點定義:Python
node.val / node.next;C++ListNode { int val; ListNode *next; }。
① LC 206 — Reverse Linked List
📋 題目:反轉一條單向鏈結串列,回傳新的頭節點。
範例:1→2→3→4→5 → 5→4→3→2→1
🌐 EN — Reverse a singly linked list and return the new head. Example:
1→2→3→4→5→5→4→3→2→1
🧭 從讀題到解法
- 拆解:要把每個節點的
next反向指。難點在反指之前,得先記住原本的下一個,否則鏈就斷了。 - 關鍵洞察(三指針):維護
prev(已反轉部分的頭)、curr(當前)、nxt(暫存下一個)。每步:先存nxt = curr.next,再把curr.next指向prev,然後三者一起往前。 - 最優解:迭代,O(1) 空間。
def reverseList(head):
prev, curr = None, head
while curr:
nxt = curr.next # save next before breaking the link
curr.next = prev # reverse the pointer
prev = curr # advance both
curr = nxt
return prev # prev is the new head
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* curr = head;
while (curr) {
ListNode* nxt = curr->next; // save next
curr->next = prev; // reverse
prev = curr; // advance
curr = nxt;
}
return prev;
}
- 複雜度:時間 O(n),空間 O(1)。
- 坑:① 一定要先存
nxt再改curr.next,順序反了就斷鏈;② 最後回傳的是prev不是curr(迴圈結束時 curr 已是 null);③ 遞迴版優雅但 O(n) 堆疊空間。
② LC 21 — Merge Two Sorted Lists
📋 題目:合併兩條已排序的鏈結串列,回傳合併後仍排序的鏈。
範例:l1 = 1→2→4, l2 = 1→3→4 → 1→1→2→3→4→4
🌐 EN — Merge two sorted linked lists into one sorted list and return it. Example:
l1 = 1→2→4,l2 = 1→3→4→1→1→2→3→4→4
🧭 從讀題到解法
- 拆解:兩條都已排序,每次比較兩個頭、挑小的接上去——典型雙指針歸併。
- 瓶頸:「接在結果鏈尾」時,第一個節點沒有前驅,要寫一堆 if 處理頭。
- 關鍵洞察(dummy 頭):建一個虛擬頭節點
dummy,所有節點都接在它後面,最後回傳dummy.next——徹底消掉「第一個節點」的邊界判斷。 - 最優解:dummy + tail 指針,逐一接。
def mergeTwoLists(l1, l2):
dummy = tail = ListNode(0) # virtual head kills the edge case
while l1 and l2:
if l1.val <= l2.val:
tail.next = l1; l1 = l1.next
else:
tail.next = l2; l2 = l2.next
tail = tail.next
tail.next = l1 or l2 # attach the remaining tail
return dummy.next
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
ListNode dummy(0); ListNode* tail = &dummy;
while (l1 && l2) {
if (l1->val <= l2->val) { tail->next = l1; l1 = l1->next; }
else { tail->next = l2; l2 = l2->next; }
tail = tail->next;
}
tail->next = l1 ? l1 : l2; // attach the rest
return dummy.next;
}
- 複雜度:時間 O(m + n),空間 O(1)。
- 坑:① dummy 頭是鏈表題的萬用簡化器,凡是「結果鏈可能換頭」都用它;② 迴圈結束記得把剩下的那條尾巴整段接上(
l1 or l2);③ 用<=維持穩定性。
③ LC 141 — Linked List Cycle
📋 題目:判斷鏈結串列是否有環(某節點的 next 指回前面的節點)。
範例:3→2→0→-4,且 -4 指回 2 → true;無環 → false
🌐 EN — Determine whether a linked list has a cycle (some node's
nextpoints back to an earlier node). Example:3→2→0→-4with-4pointing back to2→true; no cycle →false
🧭 從讀題到解法
- 拆解:有環的話,從頭一直走會永遠走不到 null。
- 暴力解:用 HashSet 記走過的節點,遇到重複就有環,O(n) 時間 O(n) 空間。
- 關鍵洞察(快慢指針 / Floyd):一個
slow一次走 1 步、一個fast一次走 2 步。若有環,快指針一定會在環內追上慢指針(相對速度差 1,必相遇);若無環,快指針會先走到 null。空間降到 O(1)。 - 最優解:龜兔賽跑。
def hasCycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next # 1 step
fast = fast.next.next # 2 steps
if slow == fast: # they meet → cycle
return True
return False # fast hit null → no cycle
bool hasCycle(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true;
}
return false;
}
- 複雜度:時間 O(n),空間 O(1)。
- 坑:① 迴圈條件
fast and fast.next(快指針要走兩步,兩個都要存在);② 比較的是**節點本身(位址)**不是值;③ 進階 LC 142(找環入口):相遇後,把一個指針移回頭、兩者同速再走,相遇處即入口。
④ LC 143 — Reorder List
📋 題目:把鏈 L0→L1→…→Ln-1→Ln 重排成 L0→Ln→L1→Ln-1→L2→…,只能改指標、不能改值。
範例:1→2→3→4 → 1→4→2→3;1→2→3→4→5 → 1→5→2→4→3
🌐 EN — Reorder
L0→L1→…→LnintoL0→Ln→L1→Ln-1→L2→…, modifying pointers only (not values). Example:1→2→3→4→1→4→2→3;1→2→3→4→5→1→5→2→4→3
🧭 從讀題到解法
- 拆解:觀察結果——是「前半段」和「反轉的後半段」交錯穿插。
- 關鍵洞察(三招綜合):把它拆成三個已會的子問題:
- ① 找中點(快慢指針)→ 把鏈切成前後兩半。
- ② 反轉後半段(206 的三指針)。
- ③ 交錯合併兩半。
- 最優解:O(n) 時間、O(1) 空間,不用額外陣列。
def reorderList(head):
# 1. find middle (slow ends at the middle)
slow, fast = head, head.next
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# 2. reverse the second half
second = slow.next
slow.next = None # cut into two halves
prev = None
while second:
nxt = second.next
second.next = prev
prev = second
second = nxt
# 3. merge the two halves alternately
first, second = head, prev
while second:
f_nxt, s_nxt = first.next, second.next
first.next = second
second.next = f_nxt
first, second = f_nxt, s_nxt
void reorderList(ListNode* head) {
// 1. find middle
ListNode* slow = head; ListNode* fast = head->next;
while (fast && fast->next) { slow = slow->next; fast = fast->next->next; }
// 2. reverse second half
ListNode* second = slow->next;
slow->next = nullptr;
ListNode* prev = nullptr;
while (second) {
ListNode* nxt = second->next;
second->next = prev; prev = second; second = nxt;
}
// 3. merge alternately
ListNode* first = head; second = prev;
while (second) {
ListNode* fn = first->next; ListNode* sn = second->next;
first->next = second; second->next = fn;
first = fn; second = sn;
}
}
- 複雜度:時間 O(n),空間 O(1)。
- 坑:① 這題的價值在「拆成三個會的小問題」——面試講出這個拆法就贏一半;② 切兩半時記得
slow.next = None斷開;③ 合併時務必先暫存兩邊的 next 再改指標,否則斷鏈。
⑤ LC 146 — LRU Cache
📋 題目:設計一個 LRU(最近最少使用)快取,支援 get(key) 與 put(key, value),兩者都要 O(1)。容量滿時,淘汰最久沒用到的。
範例:capacity = 2;put(1,1) put(2,2) get(1)→1 put(3,3)(淘汰 2)get(2)→-1
🌐 EN — Design an LRU (Least Recently Used) cache with
get(key)andput(key, value), both in O(1). When full, evict the least recently used item. Example:capacity = 2;put(1,1) put(2,2) get(1)→1 put(3,3)(evicts 2)get(2)→-1
🧭 從讀題到解法
- 拆解:要 O(1) 的「查值」+ O(1) 的「更新使用順序」+ O(1) 的「淘汰最舊」。
- 瓶頸:單用 HashMap 能 O(1) 查值,但記不住使用順序;單用陣列/鏈表能記順序,但查值或移動是 O(n)。
- 關鍵洞察(HashMap + 雙向鏈結串列):
- HashMap:
key → 節點,做到 O(1) 定位。 - 雙向鏈結串列:維護使用順序(頭=最近用、尾=最久未用)。雙向才能 O(1) 把任意節點抽出/移到頭。
get:查到 → 移到頭。put:新增到頭;超容量 → 刪尾。
- HashMap:
- 最優解:兩者結合,所有操作 O(1)。
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.cache = OrderedDict() # ordered = built-in HashMap + DLL
self.cap = capacity
def get(self, key):
if key not in self.cache:
return -1
self.cache.move_to_end(key) # mark as most recently used
return self.cache[key]
def put(self, key, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.cap:
self.cache.popitem(last=False) # evict least recently used
class LRUCache {
int cap;
list<pair<int,int>> dll; // front = most recent
unordered_map<int, list<pair<int,int>>::iterator> pos;
public:
LRUCache(int capacity) : cap(capacity) {}
int get(int key) {
if (pos.find(key) == pos.end()) return -1;
dll.splice(dll.begin(), dll, pos[key]); // O(1) move to front
return pos[key]->second;
}
void put(int key, int value) {
if (pos.count(key)) {
pos[key]->second = value;
dll.splice(dll.begin(), dll, pos[key]);
return;
}
if ((int)dll.size() == cap) {
pos.erase(dll.back().first); // evict LRU (tail)
dll.pop_back();
}
dll.push_front({key, value});
pos[key] = dll.begin();
}
};
- 複雜度:
get/put皆 O(1),空間 O(capacity)。 - 坑:① 核心是「HashMap 定位 + 雙向鏈結串列記順序」——面試常要你從零手刻雙向鏈表(別只用 OrderedDict 帶過,要能解釋底層);② 用雙向鏈表才能 O(1) 抽節點(單向抽不掉前驅);③ C++
list::splice是 O(1) 移動節點的利器。
一張表記住全部
| 題 | 核心招式 | 時間 | 空間 | 關鍵 / 坑 |
|---|---|---|---|---|
| LC 206 | 三指針翻轉 | O(n) | O(1) | 先存 nxt 再改 next;回傳 prev |
| LC 21 | dummy 頭 + 歸併 | O(m+n) | O(1) | 剩餘尾巴整段接上 |
| LC 141 | 快慢指針 | O(n) | O(1) | 條件 fast and fast.next;比位址 |
| LC 143 | 找中點+反轉+合併 | O(n) | O(1) | 拆成三個小問題 |
| LC 146 | HashMap + 雙向鏈表 | O(1) | O(cap) | 雙向才能 O(1) 抽節點 |
面試辨識口訣
- 看到「反轉 / 部分反轉」→ 三指針 prev/curr/nxt(先存後改)。
- 看到「合併 / 可能換頭」→ dummy 虛擬頭。
- 看到「有沒有環 / 找中點 / 倒數第 k 個」→ 快慢指針。
- 看到「重排 / 回文鏈表」→ 找中點 + 反轉後半 + 合併(拆成小問題)。
- 看到「O(1) get/put、淘汰策略」→ HashMap + 雙向鏈結串列。
鏈表題的精髓不是演算法,而是指標操作的紀律:先暫存、再改指、最後推進;換頭就上 dummy;要順序就配雙向鏈表。把這幾個動作練到反射,鏈表題就不再斷鏈。