🎯 什麼情境該想到我

當你「以為需要 插入刪除」的時候。
★ 這一頁最重要的結論是反直覺的:現代硬體上,鏈結串列幾乎總是錯的選擇。
教科書上 插入的優勢,在真實 CPU 上大多被快取失誤吃光。

⚙️ 怎麼用(機制與現代觀點)

結構

單向:  [a|•]──▶[b|•]──▶[c|nil]
雙向:  nil◀─[•|a|•]⇄[•|b|•]⇄[•|c|•]─▶nil
操作陣列/slice鏈結串列
隨機存取 a[i]
在「已知節點」處插入/刪除(要搬移)
找到那個節點— ← ★ 常被忽略
記憶體配置一整塊每個節點一次
快取局部性極好極差

★ 為什麼「 插入」是個陷阱

「在中間插入是 」的前提是「你已經握有那個節點的指標」。
如果你只知道「第 個」或「值等於 的那個」,光是找到它就要 ——
而這 的走訪,每一步都是一次可能的 cache miss。

陣列的 搬移 vs 鏈結串列的 走訪,在真實硬體上差很多:

陣列搬移 :memmove,連續記憶體,CPU 預取器完全命中
           → 實際上快到接近 O(1) 的常數

鏈結走訪 :每個 next 指標都可能指向記憶體的任意位置
           → ★ 每一步都可能是一次 cache miss(~100 個 cycle)

結果:在幾千個元素以內,陣列的「 插入」實測常常比鏈結串列的「 查找 + 插入」更快。

★ 這是「漸進複雜度會騙人」的第二個經典案例(第一個見 插入排序)。
面試時能講出「快取局部性」這一層,比背出複雜度表有價值得多。

★ 那它什麼時候是對的

場景為什麼鏈結串列勝出
★ LRU 快取雜湊表 + 雙向鏈結串列:hash 給 定位,鏈結串列給 移到頭部。
★ 關鍵是「已經握有節點指標」(從 hash 拿到),所以真的是 (見 雜湊表)
侵入式鏈表(intrusive)節點指標內嵌在物件本身,不需要額外配置。Go runtime、Linux 核心大量使用
雜湊表的碰撞鏈每條鏈通常只有幾個元素,走訪成本可忽略
無鎖資料結構CAS 改一個指標就完成插入,不需要搬移整塊記憶體
合併排序鏈結串列上可做到 額外空間的 排序(只改指標)
不能移動的元素元素被外部持有指標時,陣列擴容會讓指標失效

LRU 的骨架(最值得記住的應用)

type entry struct {
    key, val   int
    prev, next *entry        // ★ 雙向:刪除時需要 prev
}
 
type LRU struct {
    cap        int
    m          map[int]*entry   // key → 節點,給 O(1) 定位
    head, tail *entry           // 哨兵節點,省掉一堆 nil 判斷
}
 
func (c *LRU) Get(key int) (int, bool) {
    e, ok := c.m[key]
    if !ok { return 0, false }
    c.moveToFront(e)            // ★ O(1):因為我們已經握有 e 的指標
    return e.val, true
}

★ 用哨兵(sentinel)頭尾節點可以消除幾乎所有的 nil 檢查——這是鏈結串列實作最實用的技巧。

常考的操作

題目關鍵技巧
反轉三個指標 prev, cur, next 逐步翻轉
找中點快慢指標:快的走兩步、慢的走一步
偵測環Floyd 龜兔賽跑:快慢指標若相遇則有環;再從頭與相遇點同步走可找到環的入口
合併兩個有序串列哨兵節點 + 雙指標(就是 合併排序 的 merge)
刪除倒數第 k 個雙指標間隔

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • ★ Go 裡不要用 container/list。每個節點獨立配置、快取不友善、沒有泛型(存 any 要型別斷言)、GC 壓力大。
    要堆疊佇列用 slice 或 channel(見 堆疊與佇列);真的需要鏈結語義就自己定義 struct(可以用泛型且能內嵌)。
  • ★ 預設用 slice。除非你能明確指出「為什麼這裡陣列不行」,否則 slice 幾乎總是更好。
  • 每個節點一次配置 → 大量小物件對 GC 是負擔。侵入式設計可以避免。
  • 無法二分搜尋(需要隨機存取,見 二分搜尋)。
  • 刪除單向串列的節點需要前驅,所以要嘛用雙向、要嘛走訪時記著 prev。
  • 並行修改極難寫對。多執行緒的鏈結串列(尤其無鎖版本)是公認難寫的——優先用 channel 或有鎖的 slice。

🔗 相關工具