Linked List(鏈結串列)

鏈結串列優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 單向與雙向鏈結串列的指標手術:反轉、合併、重排、虛擬頭節點技巧,以及環的處理。 另見: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) 概念

辨識 — 輸入是一個 ListNode head,答案是一條重新接線過的串列,或其中某個節點。追問幾乎都是「只用 O(1) 額外記憶體」,這就排除了把值複製進陣列的做法。

關鍵 — 單向節點只知道它後面是誰。要在某個節點刪除或插入,你必須站在它前一個節點上(cur.next = cur.next.next),所以下面每一個技巧,都是在確保動手術的當下你握著對的把手。

python
# python
# Definition for singly-linked list.
class ListNode(object):
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next
java
// 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
// 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:
    python
    dic = dict()
    m = n = head
    dic[m] = Node(m.val)
    
    python
    self.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
# 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
// 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;節點是重新接線,從不複製。

試跑

text
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
# 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
// 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
# 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
// 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)

text
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
# 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
// 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)

text
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
# 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
// 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 個節點)

text
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
// 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
# 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
// 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):兩個指標。

試跑

text
[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
# 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
// 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。

text
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
# 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
// 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
# 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
// 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)

text
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 指出淘汰對象住在哪一條。每一條頻率串列都正是上面模板裡那條 帶哨兵的串列;指標手術沒有任何新東西,新的只有外圍的記帳。

結構配置:

text
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
# 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 顯示):

text
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
# 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

四個陷阱

  1. 把串列弄丟。 還沒存下 curr.next 就寫 curr.next = prev,後面整串就沒了。先存起來 — 這就是反轉迴圈非得照那個順序寫的原因。
  2. 回傳錯的 head。 任何可能動到第一個節點的操作之後,要回傳 dummy.next,不是 head:head 可能已經不在串列裡了。
  3. 留下一個環。 遞迴反轉裡只寫 head.next.next = head,卻漏掉後面的 head.next = null,最後兩個節點就會互指。
  4. 走過頭。 兩步跳要寫成 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 未評定 用節點表複製;每組反轉;每個頻率一條串列;把桶串在串列上