Linked List(鏈結串列)
範圍 — 單向與雙向鏈結串列的指標手術:反轉、合併、重排、虛擬頭節點技巧,以及環的處理。 另見:linked_list_examples.md — 這些模板對應的完整題解;2_pointers_linkedlist.md — 快慢指標的專門篇;design.md — LRU 以及其他「串列 + 表」的設計題;heap.md — k 路串列合併;recursion.md — 用遞迴改寫串列。
LeetCode 題目清單
時間複雜度
| 資料結構 | 搜尋 | 插入 | 刪除 | 最小/最大值 |
|---|---|---|---|---|
| Linked List | O(n) | O(1) | O(1) | O(n) |
只要你手上已經握著目標節點(例如 head,或某個你本來就持有的節點),插入/刪除就是 O(1);但先找到那個節點是 O(n)。
0) 概念
- fucking algorithm : reverse part of linked list
- fucking algorithm : reverse k set of linked list
- fucking algorithm : check palindrome linked list
辨識 — 輸入是一個 ListNode head,答案是一條重新接線過的串列,或其中某個節點。追問幾乎都是「只用 O(1) 額外記憶體」,這就排除了把值複製進陣列的做法。
關鍵 — 單向節點只知道它後面是誰。要在某個節點刪除或插入,你必須站在它前一個節點上(cur.next = cur.next.next),所以下面每一個技巧,都是在確保動手術的當下你握著對的把手。
# python
# Definition for singly-linked list.
class ListNode(object):
def __init__(self, val=0, next=None):
self.val = val
self.next = next
// java
// Single Linkedlist
public class ListNode{
// attr
public int val;
public ListNode next;
// constructor
public ListNode(){
}
public ListNode(int val){
this.val = val;
}
ListNode(int val, ListNode next){
this.val = val;
this.next = next;
}
}
// init a ListNode
ListNode node1 = new ListNode(1);
ListNode node2 = new ListNode(2);
ListNode node3 = new ListNode(3);
// motify node's value
node1.val = 0;
// connect nodes
node1.next = node2;
node2.next = node3;
// java
// Double linked list
// LC 146
public class Node {
int key;
int val;
Node prev;
Node next;
public Node(int key, int val) {
this.key = key;
this.val = val;
this.prev = null;
this.next = null;
}
}
0-1) 型別
- 鏈結串列
- 環狀鏈結串列
- 雙向鏈結串列
- 雙向串列(Double Linked list)
- LC 146、LC 460
- 其他
- LC 138:
pythondic = dict() m = n = head dic[m] = Node(m.val)- LC 208:
- trie
pythonself.children = defaultdict(Node) - 題型
- 反轉
- 反轉整條串列
- LC 206
- 反轉指定起訖點之間的串列
- LC 92、LC 25
- 反轉串列的一部分
- 每 k 個一組反轉
- 反轉整條串列
- 合併
- 合併兩條串列
- 檢查
- 檢查串列有沒有環
- 找出環的起點
- 移除倒數第 N 個節點
- Remove Nth Node From End of List — LC 19
- 組合題
- 上述情況的各種組合
- 反轉
辨識
| 題目敘述裡的訊號 | 限制條件提示 | 該用 | 不是這個,如果 |
|---|---|---|---|
| 「反轉這條鏈結串列」 | O(1) 額外記憶體 | Template 1 迭代反轉 | 只反轉一段區間或每 k 個一組 → Template 4 |
| 「移除所有值為 … 的節點」、「刪除重複」、head 可能被刪 | 一趟 | Template 2 虛擬頭節點 | 你只拿到要刪的那個節點、拿不到 head(LC 237)→ 把下一個節點的值複製進來 |
| 「所有小於 x 的節點排在 … 之前」、「奇數位再接偶數位」、「保持相對順序」 | O(1) 額外記憶體 | Template 3 兩個虛擬節點 | 組內順序可以改變 → 陣列分割就夠了 |
| 「反轉位置 left 到 right 的節點」、「每 k 個一組」、「兩兩交換相鄰節點」 | 一趟、O(1) 記憶體 | Template 4 反轉一段 | 整條都要反轉 → Template 1 |
| 「中間節點」、「倒數第 n 個節點」、「切成兩半」、「重排」、「回文」 | 一趟、長度未知 | Template 5 快慢指標 | 「有沒有環/環從哪開始」→ 2_pointers_linkedlist.md |
| 「位數以逆序存放」、「把兩個數相加」 | 串列最長 100 個節點,任何固定寬度的整數都放不下 | Template 6 進位迴圈 | 最高位在前(LC 445)→ 先把兩條都反轉,或用兩個堆疊 |
| 「設計一個快取」、「get 和 put 都要 O(1)」、「淘汰最久沒用的」 | 每個操作 O(1) | Template 7 雜湊表 + 雙向鏈結串列 | 依使用頻率淘汰(LC 460)→ LFU 變體 |
1) 通用形式
下面每個模板都跑在上面定義的 ListNode 上。排列順序就是面試時一層層往上疊的順序:先反轉,再虛擬頭節點,接著是把兩者結合的技巧,最後才是快取設計。
Template 1: Iterative Reversal — LC 206 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「反轉一條單向鏈結串列」,或任何解法步驟裡有「反轉後半段」的題目(LC 143、LC 234)。追問:O(1) 額外記憶體,所以用迭代,不用遞迴。
關鍵 — prev 永遠是已反轉部分的頭,curr 是還沒動過那部分的頭;在翻指標之前先存下 curr.next,才讓沒動過的那部分還拿得到。
# python
# LC 206 - Reverse Linked List
# IDEA: walk once; at each node cache next, flip the link back to prev, advance both
# time = O(n), space = O(1)
class Solution(object):
def reverseList(self, head):
prev = None # head of the reversed part (empty at first)
curr = head # head of the part not yet reversed
while curr:
nxt = curr.next # 1) cache next: the flip below overwrites it
curr.next = prev # 2) flip the link
prev = curr # 3) advance prev
curr = nxt # 4) advance curr
return prev # curr is None, so prev is the old tail = new head
// java
// LC 206 - Reverse Linked List
// IDEA: walk once; at each node cache next, flip the link back to prev, advance both
// time = O(n), space = O(1)
public ListNode reverseList(ListNode head) {
ListNode prev = null; // head of the reversed part
ListNode curr = head; // head of the part not yet reversed
while (curr != null) {
ListNode next = curr.next; // 1) cache next
curr.next = prev; // 2) flip the link
prev = curr; // 3) advance prev
curr = next; // 4) advance curr
}
return prev; // the old tail is the new head
}
複雜度
- 時間 O(n):
while curr迴圈每個節點走一次,在那裡做四次賦值。 - 空間 O(1):只有
prev、curr和nxt;節點是重新接線,從不複製。
試跑
1 -> 2 -> 3
step prev curr reversed part
0 None 1 (empty)
1 1 2 1
2 2 -> 1 3 2 -> 1
3 3 -> 2 -> 1 None return prev
陷阱
- 在
nxt = curr.next之前寫curr.next = prev,後面整串就弄丟了:1 -> 2 -> 3回傳1。 - 回傳
head而不是prev:head現在是尾巴,所以[1,2,3]回傳[1]。 []或[1]不需要特例:迴圈跑零次或一次,prev就是對的。
不適用時 — 只反轉一部分(一段區間、每 k 個、成對)→ Template 4 的反轉一段,也就是這個迴圈跑 k 次再加兩次接線。
練習 — LC 206 ⭐ · LC 234 (反轉後半再比對) · LC 143 ⭐ (中點、反轉、交錯) · LC 92 (一段區間:Template 4)
遞迴寫法 — O(n) 堆疊
同一個答案,由上而下:先反轉 head 之後的全部,再把 head 掛到原本跟在它後面的那個節點之後。每個節點要吃一個 stack frame,所以它是第二個答案,不是第一個。
# python
# LC 206 - Reverse Linked List (recursive)
# IDEA: reverse the rest first; head.next is then the rest's tail, so hang head after it
# time = O(n), space = O(n) call stack
class Solution(object):
def reverseList(self, head):
if head is None or head.next is None: # empty, or the last node: already reversed
return head
new_head = self.reverseList(head.next) # the original last node, passed up unchanged
head.next.next = head # the node after head now points back at it
head.next = None # cut the old forward link, or 2 nodes form a cycle
return new_head
// java
// LC 206 - Reverse Linked List (recursive)
// IDEA: recurse to the tail, then let each frame flip the link BEHIND it on the way back.
// `newHead` is the original last node and is passed up unchanged.
// time = O(n), space = O(n) (call stack)
public ListNode reverseList(ListNode head) {
// base case: empty list, or already at the last node
if (head == null || head.next == null) {
return head;
}
// reverse everything after `head`; newHead is the tail of the ORIGINAL list
ListNode newHead = reverseList(head.next);
// head.next now sits BEHIND head in the reversed list: point it back at head
head.next.next = head;
// cut the old forward link, or the two nodes form a 2-cycle
head.next = null;
return newHead;
}
Template 2: Dummy Head Deletion — LC 203 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「移除所有值為 … 的節點」、「刪除重複」、「移除第 n 個節點」,或任何 head 本身可能被刪掉或換掉的修改。沒有虛擬節點,每一題都得多一個 if head 就是目標 的分支。
關鍵 — 虛擬節點讓 curr 有一個站在 head 之前的位置,而 curr 只看 curr.next。刪完之後 curr 原地不動,所以一整串命中([6,6,6])會一個連結一個連結地被移掉,一個都不漏。
# python
# LC 203 - Remove Linked List Elements
# IDEA: dummy before head; look ahead at curr.next; on a match skip it and do NOT advance
# time = O(n), space = O(1)
class Solution(object):
def removeElements(self, head, val):
dummy = ListNode(0)
dummy.next = head
curr = dummy # always the last node known to be kept
while curr.next:
if curr.next.val == val:
curr.next = curr.next.next # skip the match; curr stays, the new next is unchecked
else:
curr = curr.next # next is kept, so move onto it
return dummy.next # the head may have been removed
// java
// LC 203 - Remove Linked List Elements
// IDEA: dummy before head; look ahead at curr.next; on a match skip it and do NOT advance
// time = O(n), space = O(1)
public ListNode removeElements(ListNode head, int val) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode curr = dummy; // always the last node known to be kept
while (curr.next != null) {
if (curr.next.val == val) {
curr.next = curr.next.next; // skip the match; curr does not move
} else {
curr = curr.next; // no match: move forward
}
}
return dummy.next;
}
複雜度
- 時間 O(n):
while curr.next的每一輪不是刪掉一個節點,就是讓curr前進,所以總共最多 n 輪。 - 空間 O(1):一個虛擬節點和一個游標。
試跑([6,6,6,3]、val=6)
Initial: dummy -> 6 -> 6 -> 6 -> 3, curr at dummy
Step 1: curr.next.val = 6 (match!)
Action: curr.next = curr.next.next
Result: dummy -> 6 -> 6 -> 3 (curr STAYS at dummy)
Step 2: curr.next.val = 6 (match!)
Action: curr.next = curr.next.next
Result: dummy -> 6 -> 3 (curr STAYS at dummy)
Step 3: curr.next.val = 6 (match!)
Action: curr.next = curr.next.next
Result: dummy -> 3 (curr STAYS at dummy)
Step 4: curr.next.val = 3 (no match)
Action: curr = curr.next
Result: curr moves to node 3
Step 5: curr.next = null, exit loop
Return: dummy.next = [3]
陷阱
- 刪完之後還讓
curr前進,會跳過滑進curr.next的那個節點:[6,6,3]、val=6,回傳[6,3]。 - 回傳
head而不是dummy.next:[7,7,7]、val=7,回傳已被刪掉的7,而不是[]。 - 檢查
curr.val而不是curr.next.val:等你看到命中時已經站在它上面了,而單向節點走不回前一個節點。
不適用時 — 你只拿到要刪的那個節點、從來拿不到 head(LC 237):沒有 prev 可以站,所以把 node.next.val 複製進來,改刪下一個節點。
練習 — LC 203 · LC 83 (比較相鄰的值) · LC 19 ⭐ (間隔 n) · LC 82 (重複的全部刪) · LC 1474
詳寫:LC 19 與其他 Template 2 題目在 linked_list_examples;LC 82 在 2_pointers_linkedlist § 2-2。
Template 3: Two Dummies, Split then Join — LC 86 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 每個節點都要分進兩組之一,而且每一組都得保持原本的順序:「所有小於 x 的節點排在大於等於 x 的節點之前 … 保持相對順序」(LC 86)、「把奇數位的節點放在一起,後面接偶數位」(LC 328)。
關鍵 — 每條鏈都有一個虛擬節點(握著它的頭的固定把手)和一個尾指標;每個節點只會接到其中一條的尾巴,所以走完一趟後 p1.next = dummy_2.next 就能把兩條接起來,從來不用記第二條鏈的第一個節點。
| 把手 | 它是什麼 | 用在 |
|---|---|---|
dummy_1 |
站在第一條鏈的頭之前 | 答案 — return dummy_1.next |
p1 |
第一條鏈的尾巴 | 接起來:第一條鏈的尾巴要指向第二條鏈的頭 |
dummy_2 |
站在第二條鏈的頭之前 | 接起來:dummy_2.next 就是第二條鏈的頭 |
p2 |
第二條鏈的尾巴 | 收尾:p2.next = None |
# python
# LC 86 - Partition List
# IDEA: two dummies -> append each node to the tail of its chain, then
# terminate chain 2 and hang it off chain 1's tail via dummy_2.next
# time = O(n), space = O(1)
class Solution(object):
def partition(self, head, x):
dummy_1 = ListNode(0) # chain 1: values < x
dummy_2 = ListNode(0) # chain 2: values >= x
p1 = dummy_1 # tail of chain 1
p2 = dummy_2 # tail of chain 2
curr = head
while curr:
if curr.val < x:
p1.next = curr
p1 = p1.next
else:
p2.next = curr
p2 = p2.next
curr = curr.next
# terminate chain 2: p2 may still point at a node now in chain 1
p2.next = None
# join: dummy_2.next is chain 2's head, so it never has to be tracked
p1.next = dummy_2.next
return dummy_1.next
// java
// LC 86 - Partition List
// IDEA: two dummies, one tail per chain; cut chain 2's tail, then join chain 1's tail to it
// time = O(n), space = O(1)
public ListNode partition(ListNode head, int x) {
ListNode dummy1 = new ListNode(0); // chain 1: values < x
ListNode dummy2 = new ListNode(0); // chain 2: values >= x
ListNode p1 = dummy1;
ListNode p2 = dummy2;
ListNode curr = head;
while (curr != null) {
if (curr.val < x) {
p1.next = curr;
p1 = p1.next;
} else {
p2.next = curr;
p2 = p2.next;
}
curr = curr.next;
}
p2.next = null; // terminate chain 2, or the result can loop
p1.next = dummy2.next; // join: chain 1's tail -> chain 2's head
return dummy1.next;
}
複雜度
- 時間 O(n):單一一趟
while curr把每個節點接到某一條的尾巴;接起來是 O(1)。 - 空間 O(1):節點是重新串接,不是複製;新增的只有兩個虛擬節點。
試跑([1,4,3,2,5,2],x = 3)
node goes to chain 1 (< 3) chain 2 (>= 3)
1 chain 1 d1 -> 1 d2
4 chain 2 d1 -> 1 d2 -> 4
3 chain 2 d1 -> 1 d2 -> 4 -> 3
2 chain 1 d1 -> 1 -> 2 d2 -> 4 -> 3
5 chain 2 d1 -> 1 -> 2 d2 -> 4 -> 3 -> 5
2 chain 1 d1 -> 1 -> 2 -> 2 d2 -> 4 -> 3 -> 5
^p1 ^p2
p2.next = None 5.next was still the last 2 -> cut it
p1.next = d2.next 2 -> 4
d1 -> 1 -> 2 -> 2 -> 4 -> 3 -> 5 return d1.next => [1,2,2,4,3,5]
陷阱
- 忘了
p2.next = None會形成環。 節點是重新串接、不是複製,所以第二條鏈的最後一個節點還帶著它舊的next。在上面的追蹤裡就是5 -> 2,而最後那個 2 在第一條鏈裡、排在 5 之前 — 繞成一圈。只有第二條鏈需要切:第一條鏈的尾巴反正馬上會被接起來那一步覆寫。 - 用
dummy_2.next接,不是dummy_2。 虛擬節點只是個值為0的佔位;把虛擬節點本身掛到p1後面,會多出一個幽靈節點。 - 空組不需要防呆。 全部節點都
< x:dummy_2.next是None,接起來那步只是重新把第一條鏈收尾。全部都>= x:p1還停在dummy_1,所以接起來會讓dummy_1.next變成第二條鏈的頭。空輸入也一樣回傳None。
不適用時 — 組內順序可以改變,或輸入是陣列:那裡 LC 75 的原地荷蘭國旗交換比較省,但它不穩定,而 LC 86 要求每組內保持原本的相對順序。
| # | 題目 | 決定進哪條鏈的條件 |
|---|---|---|
| 86 | Partition List | node.val < x — 就是上面的模板 |
| 328 | Odd Even Linked List | 節點的索引是偶數 — 一樣兩個虛擬節點、p2.next = None、p1.next = dummy_2.next |
| 2161 | Partition Array According to Given Pivot | 陣列版:三桶(<、==、>),依序串接 |
| 21 | Merge Two Sorted Lists | 反方向的操作 — 一個虛擬節點、兩條輸入、輸出一條鏈 |
練習 — LC 86 · LC 21 ⭐ (一個虛擬節點、兩條輸入) · LC 328 (依索引拆) · LC 725 (k 條鏈)
Template 4: Reverse a Segment, Reconnect Both Ends — LC 92 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「反轉位置 left 到 right 的節點」(LC 92)、「每 k 個節點一組反轉」(LC 25)、「兩兩交換相鄰節點」(LC 24)。追問:一趟、O(1) 記憶體。
關鍵 — 從 start 反轉 k 個節點後會留下三個把手:新的前端(第 k 個節點)、新的尾巴(start 本身),以及這一段之後的第一個節點。接線永遠是 prev.next = new_head 和 new_tail.next = next_node;這些題目的差別只在那一段在哪裡。
| 回傳值 | 它是什麼 | 用來接哪裡 |
|---|---|---|
prev(new_head) |
反轉後這一塊的新 head | prev_of_segment.next = new_head |
head(new_tail) |
新的 tail(原本的第一個節點) | new_tail.next = next_node |
curr(next_node) |
這一段之後的第一個節點 | tail 必須指到這裡 |
# python
# LC 92 - Reverse Linked List II
# IDEA: walk prev to the node before `left`, reverse right-left+1 nodes with the helper,
# then reconnect both boundaries with the three handles it returns
# time = O(n), space = O(1)
class Solution(object):
def reverseBetween(self, head, left, right):
dummy = ListNode(0)
dummy.next = head
# 1) walk `prev` to the node BEFORE position `left`
prev = dummy
for _ in range(left - 1):
prev = prev.next
# 2) reverse (right - left + 1) nodes starting at prev.next
new_head, new_tail, next_node = self.reverse_helper(prev.next, right - left + 1)
# 3) reconnect both boundaries
prev.next = new_head # front: prev -> new head of reversed chunk
new_tail.next = next_node # back: old head (now tail) -> rest of list
return dummy.next
def reverse_helper(self, head, k):
"""reverse k nodes from head; return (new_head, new_tail, first node after)"""
prev = None
curr = head
while curr and k > 0:
nxt = curr.next # 1) cache next
curr.next = prev # 2) reverse the link
prev = curr # 3) advance prev
curr = nxt # 4) advance curr
k -= 1
return prev, head, curr
// java
// LC 92 - Reverse Linked List II
// IDEA: walk prev to the node before `left`, reverse right-left+1 nodes in place,
// then reconnect: prev -> new front, old start (now the tail) -> the node after
// time = O(n), space = O(1)
public ListNode reverseBetween(ListNode head, int left, int right) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode prev = dummy;
for (int i = 0; i < left - 1; i++) {
prev = prev.next; // the node BEFORE the segment
}
ListNode start = prev.next; // becomes the segment's tail
ListNode reversed = null; // front of the reversed segment
ListNode curr = start;
for (int i = 0; i < right - left + 1; i++) {
ListNode next = curr.next;
curr.next = reversed;
reversed = curr;
curr = next;
}
// curr is now the first node AFTER the segment
prev.next = reversed; // front
start.next = curr; // back
return dummy.next;
}
複雜度
- 時間 O(n):走到
left加上反轉 k 步,合起來每個節點最多碰一次。 - 空間 O(1):固定數量的指標;輔助函式是迴圈,不是遞迴。
試跑([1,2,3,4,5]、left=2、right=4 → 反轉 2,3,4 這 3 個節點)
dummy -> 1 -> 2 -> 3 -> 4 -> 5
└──── reverse these 3 ────┘
Step 1) walk prev (left-1 = 1 step) to node before segment
dummy -> 1 -> 2 -> 3 -> 4 -> 5
^prev ^start
(start = prev.next = node 2)
Step 2) reverse_helper(start=2, k=3)
-- reverses links of 2,3,4 in isolation --
before: 2 -> 3 -> 4 -> 5
after: 2 <- 3 <- 4 5
| |
new_tail new_head
returns:
new_head = 4 (was k-th node, now front of chunk)
new_tail = 2 (was `start`, now points nowhere yet)
next_node = 5 (first node after the reversed part)
Step 3) reconnect boundaries
(C1) prev.next = new_head
node1.next -> 4
(C2) new_tail.next = next_node
node2.next -> 5
Final:
dummy -> 1 -> 4 -> 3 -> 2 -> 5
└── reversed ──┘
return dummy.next => [1, 4, 3, 2, 5] ✓
陷阱
- 從
head而不是dummy開始走prev:left = 1時這一段前面沒有節點,所以[1,2,3]、left=1、right=2 的前端接不回去。 - 忘了
new_tail.next = next_node會弄丟尾巴的5:輔助函式已經把原本起點的next設成None,所以結果是1 -> 4 -> 3 -> 2 -> None,不會形成環。 - 在 LC 25 裡,對最後一組不足 k 個的節點也呼叫輔助函式,會把它一起反轉;先數 k 個(
has_k),不足的尾巴保持原狀。
不適用時 — 整條都要反轉 → Template 1;串列是旋轉(LC 61)而不是反轉 → 用間隔找到新的尾巴再重新縫合,完全不用反轉。
練習 — LC 92 · LC 206 ⭐ (k = 長度) · LC 24 (k = 2,展開) · LC 25 (迴圈呼叫輔助函式) · LC 61
詳寫:LC 24、LC 25 與 LC 430 在 linked_list_examples。
遞迴寫法 — 先反轉前 n 個,再反轉一段區間
同一個反轉一段的遞迴版本。reverseN 反轉前 n 個節點,並把它們之後的節點記在 successor;reverseBetween 把 left 一路減到 1,再呼叫它。它要 O(n) 的堆疊,所以是第二個答案。
// java
// LC 92 - Reverse Linked List II (recursive)
// IDEA: reverseN(head, n) reverses the first n nodes and reattaches `successor` (node n + 1);
// reverseBetween shifts the window one node per call until it starts at position 1
// time = O(n), space = O(n) (call stack)
ListNode successor = null; // node n + 1, recorded by the base case
ListNode reverseN(ListNode head, int n) {
if (n == 1) {
successor = head.next; // the first node after the reversed part
return head;
}
ListNode last = reverseN(head.next, n - 1);
head.next.next = head; // the node after head now points back at it
head.next = successor; // head is the new tail: reattach the rest
return last;
}
ListNode reverseBetween(ListNode head, int m, int n) {
if (m == 1) {
return reverseN(head, n);
}
// for head.next the range is [m - 1, n - 1]
head.next = reverseBetween(head.next, m - 1, n - 1);
return head;
}
Template 5: Fast/Slow Middle — LC 876 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「回傳中間節點」、「把串列切成兩半」(LC 143、LC 148、LC 234)、「倒數第 n 個節點」 — 任何需要從尾端或中間量位置、但沒給長度的串列題。
關鍵 — slow 每走一個節點,fast 就走兩個,所以當 fast 再也走不了完整的一步時,slow 剛好走了一半:它站在中點上。
# python
# LC 876 - Middle of the Linked List
# IDEA: fast takes two steps per slow step; when fast runs out, slow is at the middle
# time = O(n), space = O(1)
class Solution(object):
def middleNode(self, head):
slow = head
fast = head
while fast and fast.next: # fast can take two more steps
slow = slow.next
fast = fast.next.next
return slow # even length: the SECOND of the two middles
// java
// LC 876 - Middle of the Linked List
// IDEA: fast takes two steps per slow step; when fast runs out, slow is at the middle
// time = O(n), space = O(1)
public ListNode middleNode(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow; // even length: the second middle
}
複雜度
- 時間 O(n):
fast在while跑 n / 2 輪後到達尾端。 - 空間 O(1):兩個指標。
試跑
[1,2,3,4,5] [1,2,3,4,5,6]
slow fast slow fast
1 1 1 1
2 3 2 3
3 5 stop 3 5
4 None stop
return 3 return 4 (the second middle)
陷阱
while fast.next and fast在偶數長度的串列上會對None解參考;先檢查fast。- 為了合併排序(LC 148)或重排(LC 143)而切串列時,你需要的是第一個中點,左半才不會比較長:迴圈條件寫
while fast.next and fast.next.next。用第二個中點的寫法,[1,2]永遠切不開,合併排序就會無窮遞迴。
不適用時 — 「串列有沒有環/環從哪開始」(LC 141、LC 142)用的是同樣兩種速度但不同的停止條件 → 2_pointers_linkedlist.md;「倒數第 n 個」用的是同樣速度、間隔 n → LC 19 那張卡。
練習 — LC 876 · LC 141 ⭐ (環:同樣的速度,相遇就停) · LC 234 (中點 + 反轉) · LC 143 ⭐ (第一個中點、反轉、交錯) · LC 148 (第一個中點、合併排序)
Template 6: Carry Loop over a Dummy — LC 2 優先度 3/5 — 值得會 — 多半是必備模式的變形
辨識 — 「位數以逆序存放,每個節點只有一位數;把兩個數相加」。串列可以長達 100 個節點,所以這些數放不進任何固定寬度的整數:要一位一位地加。
關鍵 — 每一步寫下 (a + b + carry) % 10,並把 // 10 進位;只要任一條串列或進位還沒空,迴圈就繼續,因為進位可能活得比兩個輸入都久。
# python
# LC 2 - Add Two Numbers
# IDEA: walk both lists at once, append the digit sum mod 10 to a dummy-headed result, carry the rest
# time = O(max(m, n)), space = O(1) besides the output
class Solution(object):
def addTwoNumbers(self, l1, l2):
dummy = ListNode(0)
tail = dummy # last node of the result
carry = 0
while l1 or l2 or carry: # the carry can outlive both lists
total = carry
if l1:
total += l1.val
l1 = l1.next
if l2:
total += l2.val
l2 = l2.next
carry = total // 10
tail.next = ListNode(total % 10)
tail = tail.next
return dummy.next
// java
// LC 2 - Add Two Numbers
// IDEA: walk both lists at once, append the digit sum mod 10 to a dummy-headed result, carry the rest
// time = O(max(m, n)), space = O(1) besides the output
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
int carry = 0;
while (l1 != null || l2 != null || carry != 0) {
int total = carry;
if (l1 != null) {
total += l1.val;
l1 = l1.next;
}
if (l2 != null) {
total += l2.val;
l2 = l2.next;
}
carry = total / 10;
tail.next = new ListNode(total % 10);
tail = tail.next;
}
return dummy.next;
}
複雜度
- 時間 O(max(m, n)):
while每一步從每條串列各取一位,最後頂多再為進位多跑一步。 - 空間 O(1),不算結果串列;結果最多 max(m, n) + 1 個節點。
陷阱
- 迴圈寫成
while l1 and l2會丟掉較長那條的尾巴:[9,9,9]+[1]回傳[0]。 - 迴圈寫成
while l1 or l2會丟掉最後的進位:[5]+[5]回傳[0],而不是[0,1]。 - 把每條串列轉成整數在 Python 可行,但在 Java 遇到 100 位數的輸入會讓
long溢位;這不是面試官要的答案。
不適用時 — 最高位在前(LC 445):用 Template 1 把兩條串列都反轉,結果再反轉回來;或把位數推進兩個堆疊,用往前插入的方式建結果。
練習 — LC 2 · LC 369 (加一) · LC 445 (最高位在前)
Template 7: Hash Map + Doubly Linked List (LRU) — LC 146 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「設計一個快取」、「get 和 put 都必須平均 O(1)」、「淘汰最久沒用的 key」。任何同時需要依 key 做 O(1) 查找、又要以 O(1) 維護順序的結構。
關鍵 — 表把 key 帶到它的節點,而節點因為是雙向連結,能在 O(1) 內把自己摘下來。最近使用度就是串列的順序:最近用的節點在 tail 前面,所以淘汰對象永遠是 head.next。
head(dummy) <-> [LRU] <-> ... <-> [MRU] <-> tail(dummy)
關鍵輔助操作 — 整個類別就是這兩個,成對呼叫:
remove(node)— 用 O(1) 把節點從串列中摘掉;只需要節點本身,因為它帶著前後兩個鄰居add_to_tail(node)— 用 O(1) 把節點插到 dummy tail 前面(MRU 位置)- 「碰一下」某個 key =
remove(node)再add_to_tail(node)—get和put更新既有 key 時都用它
# python
# LC 146 - LRU Cache
# IDEA: map key -> node for O(1) lookup; the doubly linked list holds recency order,
# MRU just before `tail`, LRU just after `head`. put inserts first, then evicts
# `head.next` if the map has grown past capacity.
# time = O(1) per get/put, space = O(capacity)
class Node:
def __init__(self, key=0, val=0):
self.key = key # kept so eviction can delete the map entry
self.val = val
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.kv_map = {} # key -> Node
# sentinel boundaries: head <-> ... <-> tail
self.head = Node() # LRU side
self.tail = Node() # MRU side
self.head.next = self.tail
self.tail.prev = self.head
def remove(self, node):
prev_node = node.prev
next_node = node.next
prev_node.next = next_node
next_node.prev = prev_node
def add_to_tail(self, node): # insert just before tail (MRU)
prev_node = self.tail.prev
prev_node.next = node
node.prev = prev_node
node.next = self.tail
self.tail.prev = node
def get(self, key):
if key not in self.kv_map:
return -1
node = self.kv_map[key]
self.remove(node)
self.add_to_tail(node) # move to MRU
return node.val
def put(self, key, value):
# case 1) key exists: update in place, refresh to MRU — never evicts
if key in self.kv_map:
node = self.kv_map[key]
node.val = value
self.remove(node)
self.add_to_tail(node)
return
# case 2) new key: insert first ...
node = Node(key, value)
self.kv_map[key] = node
self.add_to_tail(node)
# ... then evict the LRU (the first REAL node, not the dummy) if over capacity
if len(self.kv_map) > self.capacity:
lru_node = self.head.next
self.remove(lru_node)
del self.kv_map[lru_node.key]
// java
// LC 146 - LRU Cache
// IDEA: map key -> node; a sentinel doubly linked list keeps recency order (MRU before tail).
// put inserts first, then evicts head.next if the map has grown past capacity.
// time = O(1) per get/put, space = O(capacity)
class LRUCache {
private static class Node {
int key;
int val;
Node prev;
Node next;
Node(int key, int val) {
this.key = key; // kept so eviction can delete the map entry
this.val = val;
}
}
private final int capacity;
private final Map<Integer, Node> map = new HashMap<>();
private final Node head = new Node(0, 0); // LRU side
private final Node tail = new Node(0, 0); // MRU side
public LRUCache(int capacity) {
this.capacity = capacity;
head.next = tail;
tail.prev = head;
}
private void remove(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void addToTail(Node node) { // insert just before tail (MRU)
node.prev = tail.prev;
node.next = tail;
tail.prev.next = node;
tail.prev = node;
}
public int get(int key) {
Node node = map.get(key);
if (node == null) {
return -1;
}
remove(node);
addToTail(node); // move to MRU
return node.val;
}
public void put(int key, int value) {
Node node = map.get(key);
if (node != null) { // existing key: update and refresh, never evict
node.val = value;
remove(node);
addToTail(node);
return;
}
node = new Node(key, value);
map.put(key, node);
addToTail(node);
if (map.size() > capacity) { // evict the first REAL node
Node lru = head.next;
remove(lru);
map.remove(lru.key);
}
}
}
第二種解法 — 函式庫寫法。 Python 的 OrderedDict 和 Java 的 LinkedHashMap 本身就是
架在雙向鏈結串列上的 hash map。面試時先講這個。多數面試官接著會要你手刻上面那條串列,
所以兩種都要能寫。
# python
# LC 146 - LRU Cache (library form)
# IDEA: OrderedDict keeps recency order: move_to_end on every use, popitem(last=False) evicts the oldest
# time = O(1) per get/put, space = O(capacity)
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.cache = OrderedDict() # oldest key first, most recent last
def get(self, key):
if key not in self.cache:
return -1
self.cache.move_to_end(key) # a read is a use
return self.cache[key]
def put(self, key, value):
if key in self.cache:
self.cache.move_to_end(key) # an update is a use too
self.cache[key] = value
if len(self.cache) > self.capacity:
self.cache.popitem(last=False) # evict the least recently used
// java
// LC 146 - LRU Cache (library form)
// IDEA: LinkedHashMap with accessOrder = true reorders on every get/put; removeEldestEntry evicts
// time = O(1) per get/put, space = O(capacity)
class LRUCache extends LinkedHashMap<Integer, Integer> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // true = iterate in access order, not insertion order
this.capacity = capacity;
}
public int get(int key) {
return super.getOrDefault(key, -1);
}
public void put(int key, int value) {
super.put(key, value);
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity; // called after every put
}
}
複雜度
- 每次呼叫 O(1) 時間:
get和put做一次表查找,加上remove/add_to_tail裡固定數量的指標賦值;沒有任何東西會走訪串列。 - 空間 O(capacity):表和串列各自最多存 capacity + 1 筆,而且只在插入到淘汰之間那一瞬間。
試跑(capacity=2,LC 146 範例 1)
put(1,1): head <-> [1] <-> tail
put(2,2): head <-> [1] <-> [2] <-> tail
get(1): head <-> [2] <-> [1] <-> tail ← 1 moved to MRU, returns 1
put(3,3): head <-> [2] <-> [1] <-> [3] <-> tail ← size 3 > 2
evict head.next=[2]
head <-> [1] <-> [3] <-> tail
get(2): -1
put(4,4): evict head.next=[1]
head <-> [3] <-> [4] <-> tail
get(1) = -1, get(3) = 3, get(4) = 4
陷阱
- 節點必須存自己的
key。 淘汰是透過head.next找到要淘汰的節點,接著必須刪掉它在表裡的 entry;少了node.key,表會留著過期的 entry,len(kv_map)永遠不會變小,get還會回傳一個早已不在串列裡的節點。 - 淘汰
head而不是head.next會把哨兵刪掉:下一次remove就會對None解參考。 - 在檢查既有 key 之前就淘汰,單純的更新也會丟掉一筆 entry:capacity 2,
put(1,1) put(2,2) put(1,10)會弄丟 key 2。 - 兩種方向混用 — 插在尾端卻淘汰
tail.prev— 會把剛碰過的 key 淘汰掉。
| 先插入、再淘汰(上面的模板) | 先淘汰、再插入 | |
|---|---|---|
| 容量判斷 | 插入後 len(kv_map) > capacity |
插入前 len(kv_map) == capacity |
| 淘汰對象 | head.next — 新節點在尾端,所以絕不會是它自己 |
head.next |
| 陷阱 | 沒有額外陷阱 | 淘汰必須放在「新 key」分支裡面;如果在檢查 key 是否存在之前就淘汰,單純的更新也會丟掉一筆 entry |
不適用時 — 依使用頻率淘汰、最近使用度只用來打破平手(LC 460)→ 每個頻率一條串列,見下。一個普通 dict 加一個 key 的 list 看起來像 LRU,但不是:每次 keys.remove(key) 都是 O(n)。
| # | 題目 | 差別在哪 |
|---|---|---|
| 146 | LRU Cache | 經典模式 — 淘汰最久沒用的 |
| 460 | LFU Cache | 兩層結構:每個頻率一條 LRU 串列 + min_freq — 詳見下方 |
| 432 | All O(1) Data Structure | 由計數桶組成的雙向鏈結串列 |
| 1472 | Design Browser History | 雙向鏈結串列,訪問新頁時把前方截斷 |
| 641 | Design Circular Deque | 固定容量的雙向鏈結串列,兩端都能操作 |
| 716 | Max Stack | 堆疊 + 雙向鏈結串列 + TreeMap,做到 O(log n) 的 popMax |
練習 — LC 146 ⭐ · LC 1472 (一個游標,往前截斷) · LC 641 (固定容量、兩端) · LC 460 (每個頻率一條串列:見下) · LC 432
LFU 變體 — 每個頻率一條串列,再加一個 min_freq 指標(LC 460) 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
關鍵 — 每一條頻率串列都是上面那條 LRU 串列;
min_freq指出淘汰對象住在哪一條,而它只會在 touch 時+1,或在插入時重設為1。
歸約到:Template 7 — 同一條帶哨兵的串列,每個頻率一條。
改了什麼:LRU 依最近使用度淘汰,所以一條串列就夠 — 它的 head.next 永遠是淘汰對象。
LFU 依使用頻率淘汰,最近使用度只拿來打破平手 — 所以那一條串列變成每個頻率一條 LRU
串列,再用一個整數 min_freq 指出淘汰對象住在哪一條。每一條頻率串列都正是上面模板裡那條
帶哨兵的串列;指標手術沒有任何新東西,新的只有外圍的記帳。
結構配置:
key_to_node : key -> Node(key, val, freq)
freq_to_list : freq -> head(dummy) <-> [LRU] <-> ... <-> [MRU] <-> tail(dummy)
min_freq : the smallest freq whose list is non-empty
freq 1: head <-> [c] <-> tail <- min_freq = 1, victim = this list's head.next
freq 2: head <-> [a] <-> [b] <-> tail <- a is older than b
freq 5: head <-> [d] <-> tail
三個動作 — 每個操作都是其中的一到兩個,依這個順序:
| 動作 | 做什麼 | min_freq 怎麼走 |
|---|---|---|
touch(node) — get,以及 put 更新既有 key |
從 freq_to_list[f] 摘下、f += 1、接到 freq_to_list[f] 的尾端 |
如果剛離開的那條串列空了而且 f == min_freq:min_freq += 1。這是精確值,不是搜尋 — 這個節點自己剛落在 f + 1,所以那條串列必定非空 |
evict — put 新 key 且快取已滿 |
淘汰對象是 freq_to_list[min_freq].head.next;把它摘下並刪掉 map 裡的 entry |
不動 — 接下來的插入會重設它 |
insert — put 新 key |
新的 Node(key, value, 1) 接到 freq_to_list[1] 的尾端 |
min_freq = 1,每次都是 — 全新的 key 依定義就是最不常用的 |
模板:
# python
# LC 460 - LFU Cache
# IDEA: the LRU template, once per frequency. key_to_node finds the node in O(1);
# freq_to_list[f] is a sentinel doubly linked list of every key seen f times,
# LRU at head.next and MRU before tail; min_freq names the list the victim is in.
# time = O(1) per get/put, space = O(capacity)
class Node:
def __init__(self, key=0, val=0, freq=0):
self.key = key # for eviction to delete the map entry (as in LRU)
self.val = val
self.freq = freq # and freq, to know which list to unlink from
self.prev = None
self.next = None
class DLinkedList:
"""the LRU template's list: sentinel head/tail, O(1) unlink and append"""
def __init__(self):
self.head = Node() # LRU side
self.tail = Node() # MRU side
self.head.next = self.tail
self.tail.prev = self.head
self.size = 0
def remove(self, node):
node.prev.next = node.next
node.next.prev = node.prev
self.size -= 1
def add_to_tail(self, node): # insert just before tail (MRU)
prev_node = self.tail.prev
prev_node.next = node
node.prev = prev_node
node.next = self.tail
self.tail.prev = node
self.size += 1
def pop_head(self): # the LRU node of this frequency
node = self.head.next
self.remove(node)
return node
class LFUCache:
def __init__(self, capacity):
self.capacity = capacity
self.key_to_node = {} # key -> Node
self.freq_to_list = {} # freq -> DLinkedList
self.min_freq = 0
def touch(self, node):
"""move node from its freq list to the freq+1 list, keeping min_freq exact"""
old_list = self.freq_to_list[node.freq]
old_list.remove(node)
if old_list.size == 0:
del self.freq_to_list[node.freq] # keep the map tidy
# the minimum list just emptied, and this node is about to land one up
if self.min_freq == node.freq:
self.min_freq += 1
node.freq += 1
if node.freq not in self.freq_to_list:
self.freq_to_list[node.freq] = DLinkedList()
self.freq_to_list[node.freq].add_to_tail(node)
def get(self, key):
if key not in self.key_to_node:
return -1
node = self.key_to_node[key]
self.touch(node)
return node.val
def put(self, key, value):
if self.capacity == 0:
return
# case 1) key exists: update in place, count the access — never evicts
if key in self.key_to_node:
node = self.key_to_node[key]
node.val = value
self.touch(node)
return
# case 2) new key and the cache is full: evict the LRU node of the least-frequent list
if len(self.key_to_node) == self.capacity:
victim_list = self.freq_to_list[self.min_freq]
victim = victim_list.pop_head()
del self.key_to_node[victim.key]
if victim_list.size == 0:
del self.freq_to_list[self.min_freq]
# case 3) insert at freq 1 — a brand-new key is the least frequent by definition
node = Node(key, value, 1)
self.key_to_node[key] = node
if 1 not in self.freq_to_list:
self.freq_to_list[1] = DLinkedList()
self.freq_to_list[1].add_to_tail(node)
self.min_freq = 1
視覺追蹤(capacity=2,LC 460 範例 1;每條串列依 LRU → MRU 顯示):
put(1,1): f1: [1] min_freq=1
put(2,2): f1: [1, 2] min_freq=1
get(1): f1: [2] f2: [1] -> 1 f1 still non-empty, min_freq stays 1
put(3,3): full -> evict f1.head.next = [2]
f1: [3] f2: [1] min_freq=1 (reset by the insert)
get(2): -1
get(3): f1: [] f2: [1, 3] -> 3 f1 emptied AND was the min -> min_freq=2
put(4,4): full -> evict f2.head.next = [1] 1 and 3 both have freq 2; 1 is the older
f1: [4] f2: [3] min_freq=1
get(1) = -1, get(3) = 3, get(4) = 4
陷阱 — 會把 O(1) 賠掉的那幾個:
min_freq += 1是精確值,從來不是掃描。 執行途中最小那條串列會變空的唯一方式,是它 最後一個節點搬到min_freq + 1,所以新的最小值不用找就知道。在這裡寫min(freq_to_list), 每次 touch 就變成 O(不同頻率的數量)。- 每次插入新 key 都要
min_freq = 1,不是只有快取為空時:不管裡面已經有什麼,新 key 都是最不常用的。 - 先淘汰再插入,而且只在新 key 的路徑上。
put更新既有 key 是一次 touch,絕不是淘汰 — 跟 LRU「先淘汰、再插入」順序的陷阱一模一樣。 capacity == 0的防護是必要的。 少了它,第一次put會去freq_to_list[0]淘汰, 而那條串列不存在。- 平手是用同一條串列內的最近使用度打破。 接在尾端、從開頭淘汰;兩端混用會把剛碰過的 key 淘汰掉,跟 LRU 完全一樣。
- 節點除了
key也要存freq。key是讓淘汰能走回 map;freq是讓 touch 能找到它 必須離開的那條串列。
函式庫捷徑:每個頻率一個 OrderedDict — popitem(last=False) 就是 pop_head,
把 key 接到尾端就是 add_to_tail — Java 則是每個頻率一個 LinkedHashSet<Integer>。結構一樣、
min_freq 規則一樣,程式碼只有三分之一:
# python
# LC 460 - LFU Cache (library form)
# IDEA: freq -> OrderedDict of keys (oldest first); min_freq names the bucket the victim lives in
# time = O(1) per get/put, space = O(capacity)
from collections import OrderedDict, defaultdict
class LFUCache:
def __init__(self, capacity):
self.capacity = capacity
self.key_to_val_freq = {} # key -> [value, freq]
self.freq_to_keys = defaultdict(OrderedDict) # freq -> keys, least recent first
self.min_freq = 0
def touch(self, key):
value, freq = self.key_to_val_freq[key]
del self.freq_to_keys[freq][key]
if not self.freq_to_keys[freq]:
del self.freq_to_keys[freq]
if self.min_freq == freq: # the minimum bucket just emptied
self.min_freq += 1
self.key_to_val_freq[key][1] = freq + 1
self.freq_to_keys[freq + 1][key] = None # appended = most recent in its bucket
def get(self, key):
if key not in self.key_to_val_freq:
return -1
self.touch(key)
return self.key_to_val_freq[key][0]
def put(self, key, value):
if self.capacity == 0:
return
if key in self.key_to_val_freq: # update: a touch, never an eviction
self.key_to_val_freq[key][0] = value
self.touch(key)
return
if len(self.key_to_val_freq) == self.capacity:
victim, _ = self.freq_to_keys[self.min_freq].popitem(last=False)
if not self.freq_to_keys[self.min_freq]:
del self.freq_to_keys[self.min_freq]
del self.key_to_val_freq[victim]
self.key_to_val_freq[key] = [value, 1]
self.freq_to_keys[1][key] = None
self.min_freq = 1
帶你走到這組搭配的那張表在 design.md。LC 432 再往前一步,把桶本身也串成一條雙向鏈結串列,於是連
min_freq 這個整數都不必維護。
2) 模式選擇
鏈結串列題其實很少真的在考串列。它們考的是:動手術的當下,你手上必須握著哪個把手 — 這份文件上的每個技巧,存在的理由都是確保你握著它。挑法要看答案需要什麼,不是看題目叫什麼名字。
| 如果題目要你… | 就用 | 因為 | 詳寫在 |
|---|---|---|---|
| 在任何位置刪除或插入,包含 head | 虛擬頭節點 | 它讓 prev 有個站在 head 之前的位置,「刪掉 head」就不再是特例 |
Template 2 |
| 移除所有符合某個值的節點 | 虛擬節點 + 檢查 curr.next |
刪完之後你必須能原地不動,否則像 [6,6,6] 這種連續值會漏掉一個 |
Template 2 |
| 把節點拆成兩組,各自保持原本順序 — 依值或依位置 | 兩個虛擬節點,每條鏈一個尾指標,最後接起來 | p1.next = dummy_2.next 不用另外記第二條鏈的頭就能接上;p2.next = None 擋掉環 |
Template 3 |
| 反轉整條串列 | 三步迴圈:先存下 next → 翻指標 → 前進 | O(1) 空間;遞迴版答案一樣,卻要每個節點吃掉一個 stack frame | Template 1 |
反轉一段 — [left, right]、每 k 個,或成對 |
反轉 k 個的輔助函式,回傳三個把手 | LC 92 / 25 / 24 的差別只在那一段在哪,反轉的方式完全一樣 | Template 4 |
| 找中點、偵測環,或走到倒數第 n 個 | 快慢指標 | 一趟掃完、O(1) 空間,也不用先算長度 | Template 5、2_pointers_linkedlist.md |
| 重排 — 交錯、切分、旋轉、回文判斷 | 快慢指標切一半 → 反轉後半 → 合併 | 每一題重排都是這三個基本操作依序組合;沒有一個是新東西 | LC 143、LC 234 |
| 合併兩條已排序串列 | 虛擬節點 + 一趟合併走訪,接節點而不是複製值 | 尾端指標就是全部的訣竅:cur.next = l1 or l2 收尾 |
LC 21 |
| 合併 k 條已排序串列,或把一條串列排序 | 分治法 — 兩兩合併,或用中點做合併排序 | O(n log k) / O(n log n);用堆積則是拿 O(k) 空間換掉遞迴 | heap § Template 3、LC 148 |
| 同時要任意位置讀取和 O(1) 淘汰 — 依最近使用度,或依使用頻率 | 雙向鏈結串列 + 雜湊表 — 一條串列,或每個頻率一條 | 表給你節點,雙向節點給你它的鄰居 — 少了任一個都不夠 | Template 7、LFU 變體、design.md |
| 對存成串列的位數做算術 | 在虛擬節點上跑進位迴圈,若最高位在前就先反轉 | 進位會活得比兩個輸入都久,所以迴圈條件是 l1 or l2 or carry |
Template 6、LC 369 |
| 回答需要隨機存取或視窗的問題 | 先倒進陣列,再用陣列的技巧 | 前綴和與單調堆疊都需要索引,串列沒有;而且通常允許 O(n) 額外空間 | LC 1171、LC 1019 |
四個陷阱
- 把串列弄丟。 還沒存下
curr.next就寫curr.next = prev,後面整串就沒了。先存起來 — 這就是反轉迴圈非得照那個順序寫的原因。 - 回傳錯的 head。 任何可能動到第一個節點的操作之後,要回傳
dummy.next,不是head:head可能已經不在串列裡了。 - 留下一個環。 遞迴反轉裡只寫
head.next.next = head,卻漏掉後面的head.next = null,最後兩個節點就會互指。 - 走過頭。 兩步跳要寫成
while (fast != null && fast.next != null)。兩個條件順序寫反,在偶數長度的串列上就會對 null 解參考。
3) 實戰題解
套用這些模板的題目,都以題目卡的形式放在 linked_list_examples.md,每個模板一節,順序相同:
| 模板 | 題目 |
|---|---|
| 迭代反轉 | LC 143, 1019 |
| 虛擬頭節點刪除 | LC 19, 147, 1171 |
| 兩個虛擬節點,先拆再接 | LC 21, 725, 817 |
| 反轉一段 | LC 24, 430, 25 |
| 快慢指標找中點 | 指向 LC 160, 234, 142, 148 |
| 進位迴圈 | LC 369 |
| 雜湊表 + 雙向鏈結串列 | LC 138 |
4) 練習路線
| 階段 | 題目(由易到難) | 它帶來什麼 |
|---|---|---|
| 核心 ⭐ | LC 206 again · LC 21 ok · LC 141 ok · LC 19 again · LC 143 again · LC 146 again · LC 23 again | 反轉、一個虛擬節點加一趟合併走訪、快慢指標(環與間隔)、三個重排基本操作一起用、快取設計,最後是 k 路合併 |
| 練習 | LC 203 未評定 · LC 876 未評定 · LC 83 again · LC 234 ok · LC 2 未評定 · LC 86 again · LC 92 again · LC 24 again · LC 328 未評定 · LC 82 未評定 · LC 445 未評定 · LC 148 ok | 每個模板在它的錨點題上各做一次,再做一個變化 |
| 進階 | LC 138 again · LC 25 未評定 · LC 460 未評定 · LC 432 未評定 | 用節點表複製;每組反轉;每個頻率一條串列;把桶串在串列上 |