Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

5

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 26 · 臺南 AQI 21 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

陣列與鏈結串列

陣列與鏈結串列(進階):被大 O 隱藏的真相

從 cache 區域性、均攤分析到跳躍串列與無鎖結構,重新理解兩種資料結構在現代硬體上的真實效能

當「下一個元素在哪裡」決定了你的程式快不快

你已經知道陣列(array)用連續記憶體換來 $O(1)$ 隨機存取,而鏈結串列(linked list)用指標換來 $O(1)$ 的頭部插入。入門篇到此為止。但如果我問你一個問題:為什麼在現代電腦上,「走訪一個含一百萬個元素的陣列」幾乎總是比「走訪一個含一百萬個節點的鏈結串列」快上好幾倍——即使兩者的時間複雜度都是 $O(n)$?

答案不在大 O 符號裡。大 O 假設「每次記憶體存取的成本都一樣」,但這個假設在 1980 年代之後就不再成立了。真正主宰效能的,是一個入門課很少提的角色:快取記憶體(cache)與資料的空間區域性(spatial locality)。這一篇,我們就從這個被大 O 隱藏的真相出發,重新審視陣列與鏈結串列。

陣列與鏈結串列進階概念示意圖

記憶體不是平的:cache line 與區域性

入門時我們把記憶體想成一條長長的格子,每格存取成本相同。實際的硬體層級是這樣的(數字為數量級,依機型而異):

層級 存取延遲(約) 容量
暫存器(register) < 1 ns 數十個
L1 cache ~1 ns 32–64 KB
L2 cache ~4 ns 256 KB–1 MB
L3 cache ~15 ns 數十 MB
主記憶體(DRAM) ~80–100 ns 數十 GB

關鍵在於:CPU 從 DRAM 抓資料時,不是一次抓一個 byte,而是一次抓一整條 cache line(通常 64 bytes)。也就是說,當你讀取 arr[0],硬體會順手把 arr[0]arr[15](假設是 4-byte 的 int)一起載入 L1 cache。接下來讀 arr[1]...arr[15] 幾乎不花錢——它們早就在 cache 裡了。

這就是空間區域性:存取了某個位址,附近的位址很可能馬上也被存取。陣列把元素排在連續記憶體中,天生對 cache 友善。

鏈結串列則相反。每個節點是 malloc 出來的,散落在 heap 各處。走訪時每跳一個節點,下一個位址完全無法預測,cache line 幾乎每次都 miss,等於每一步都要付那 ~100 ns 的 DRAM 代價。

動手算一下:陣列 vs 鏈結串列的走訪成本

假設我們對一百萬個 int 求和。陣列版本:

long sum = 0;
for (int i = 0; i < N; i++) {
    sum += arr[i];          // 每 16 個元素才 miss 一次
}

每條 cache line(64 bytes)裝得下 16 個 int,所以一百萬個元素只造成約 1,000,000 / 16 ≈ 62,500 次 cache miss。

鏈結串列版本,每個節點假設是 { int val; Node* next; }(在 64-bit 系統上約 16 bytes,含對齊):

long sum = 0;
for (Node* p = head; p != NULL; p = p->next) {
    sum += p->val;          // 幾乎每次都 miss
}

若節點散落各處,最壞情況是每一步都 miss,約 1,000,000 次。

把延遲代進去估算總時間(只算 miss 成本):

  • 陣列:$62{,}500 \times 100\,\text{ns} = 6.25\,\text{ms}$
  • 鏈結串列:$1{,}000{,}000 \times 100\,\text{ns} = 100\,\text{ms}$

差距約 16 倍——而兩者的時間複雜度都寫成 $O(n)$。這就是為什麼「漸進複雜度相同」不代表「實際速度相同」。實務上你還會看到 CPU 的硬體預取器(hardware prefetcher)進一步幫陣列加速:它偵測到你在做線性掃描,會提前把後面的 cache line 拉進來,讓陣列的有效 miss 成本更低;而鏈結串列的隨機跳躍讓預取器無從預測。

動態陣列為什麼能「攤平」成本:均攤分析

入門篇可能告訴你「動態陣列(dynamic array,如 C++ 的 std::vector、Python 的 list)滿了會自動擴容」。但擴容要重新配置記憶體並複製所有舊元素,這是 $O(n)$ 的操作。那為什麼我們還敢說 append 是 $O(1)$?

答案是均攤分析(amortized analysis):雖然某一次 append 可能很貴,但把成本平均分攤到所有操作上,每次仍是常數。

關鍵在擴容策略:容量乘以一個常數倍率(通常是 2),而不是「每次加一個位置」。我們用會計法(accounting method)來看。設每次 append 收費 3 個「代幣」:1 個付這次寫入,2 個存起來。當容量從 $k$ 翻倍到 $2k$ 需要複製 $k$ 個元素時,這些複製的成本剛好由上一輪存下來的代幣支付。

看一個例子:倍增 vs 每次加一

用 Python 模擬兩種策略,計算把元素一個個加到 $n$ 個時,總共複製了幾次:

def total_copies(n, strategy):
    capacity = 1
    size = 0
    copies = 0
    for _ in range(n):
        if size == capacity:
            copies += size          # 擴容時複製現有元素
            if strategy == "double":
                capacity *= 2        # 倍增
            else:
                capacity += 1        # 每次加一
        size += 1
    return copies

for n in [1000, 10000, 100000]:
    d = total_copies(n, "double")
    a = total_copies(n, "add_one")
    print(f"n={n:>7}  倍增複製={d:>8}  加一複製={a:>10}")

輸出:

n=   1000  倍增複製=    1023  加一複製=    499500
n=  10000  倍增複製=   16383  加一複製=  49995000
n= 100000  倍增複製=  131071  加一複製=4999950000

倍增策略的總複製次數約是 $2n$,所以平均每次 append 只複製 $O(1)$ 次;而「每次加一」策略總複製約 $n^2/2$,平均每次 $O(n)$。倍增的代價是平均浪費約一半的空間(最壞時容量約是元素數的兩倍),這是經典的時間-空間權衡(time-space tradeoff)

數學上,倍增策略的幾何級數總和為:

$$1 + 2 + 4 + \dots + n = 2n - 1 = O(n)$$

$n$ 次操作總成本 $O(n)$,攤平到每次就是 $O(1)$。這就是為什麼業界普遍採用 1.5x 或 2x 倍率,而非固定增量。

不只是「指標」:鏈結串列家族的進階變體

入門的單向鏈結串列只是起點。當問題要求不同的存取模式,我們有更精巧的結構。

1. 雙向鏈結串列與 LRU 快取

雙向鏈結串列(doubly linked list)每個節點多一個 prev 指標,讓你能 $O(1)$ 從任一節點往回走、$O(1)$ 刪除已知節點(單向串列刪除需先找到前驅,是 $O(n)$)。

這個 $O(1)$ 任意刪除的能力是 LRU(Least Recently Used)快取的核心。LRU 要在 $O(1)$ 內:(1) 查某 key 是否存在;(2) 把剛用過的元素移到「最近使用」端;(3) 淘汰「最久未用」端。做法是雜湊表(hash map)配雙向鏈結串列

class Node:
    __slots__ = ("key", "val", "prev", "next")
    def __init__(self, key=None, val=None):
        self.key, self.val = key, val
        self.prev = self.next = None

class LRUCache:
    def __init__(self, capacity):
        self.cap = capacity
        self.map = {}                      # key -> Node
        # 用兩個哨兵節點(sentinel)省去邊界判斷
        self.head = Node()                 # 最近使用端
        self.tail = Node()
        self.head.next = self.tail
        self.tail.prev = self.head

    def _remove(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def _add_front(self, node):
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node

    def get(self, key):
        if key not in self.map:
            return -1
        node = self.map[key]
        self._remove(node)                 # O(1):因為是雙向
        self._add_front(node)              # 移到最近使用端
        return node.val

    def put(self, key, val):
        if key in self.map:
            self._remove(self.map[key])
        node = Node(key, val)
        self.map[key] = node
        self._add_front(node)
        if len(self.map) > self.cap:
            lru = self.tail.prev           # 最久未用
            self._remove(lru)
            del self.map[lru.key]

注意這裡的哨兵節點(sentinel node)技巧:在頭尾各放一個不存資料的虛擬節點,讓 _remove_add_front 永遠不必檢查「是不是第一個/最後一個」。這是鏈結串列工程實作中極常見的防呆手法,能消除大量邊界錯誤。

2. 跳躍串列(skip list):用機率換有序的對數搜尋

普通鏈結串列即使有序也只能 $O(n)$ 線性搜尋(無法二分,因為不能隨機跳到中間)。跳躍串列在原始串列之上疊加多層「快車道」:每往上一層,節點數約減半,搜尋時從最上層往下,像查字典先翻到大致位置再細找。

Level 3:  H --------------------------> 50 ----------> NULL
Level 2:  H ---------> 20 ------------> 50 ----------> NULL
Level 1:  H -> 10 ---> 20 ---> 30 ----> 50 -> 70 ----> NULL
Level 0:  H -> 10 -> 15 -> 20 -> 30 -> 50 -> 70 -> 90 -> NULL

每個節點以機率 $p$(通常 $0.5$)決定是否「升一層」。期望搜尋、插入、刪除都是 $O(\log n)$,且不需要像平衡樹那樣做複雜的旋轉——這是它廣受歡迎的原因(Redis 的 sorted set 底層就用跳躍串列)。

import random

class SkipNode:
    def __init__(self, val, level):
        self.val = val
        self.forward = [None] * (level + 1)  # 每層各一個 next 指標

class SkipList:
    def __init__(self, max_level=16, p=0.5):
        self.max_level = max_level
        self.p = p
        self.level = 0
        self.head = SkipNode(None, max_level)

    def _random_level(self):
        lvl = 0
        while random.random() < self.p and lvl < self.max_level:
            lvl += 1
        return lvl

    def insert(self, val):
        update = [None] * (self.max_level + 1)
        cur = self.head
        for i in range(self.level, -1, -1):       # 由上而下尋找插入點
            while cur.forward[i] and cur.forward[i].val < val:
                cur = cur.forward[i]
            update[i] = cur
        lvl = self._random_level()
        if lvl > self.level:
            for i in range(self.level + 1, lvl + 1):
                update[i] = self.head
            self.level = lvl
        node = SkipNode(val, lvl)
        for i in range(lvl + 1):                    # 接上各層指標
            node.forward[i] = update[i].forward[i]
            update[i].forward[i] = node

跳躍串列展示了一個深刻概念:用隨機化(randomization)達到的期望效能,可以媲美精心設計的確定性平衡結構,而程式碼簡單得多。

一個顛覆直覺的結論:什麼時候陣列贏過鏈結串列做插入?

入門課常說「鏈結串列適合頻繁插入刪除」。這個說法有個重要前提常被省略:前提是你已經握有插入位置的指標。如果你得先「找到第 $k$ 個位置」再插入,鏈結串列的尋找本身就是 $O(n)$。

更反直覺的是:即使在中間插入,陣列在實務上常常比鏈結串列快。原因又回到 cache。陣列中間插入要搬移後面元素($O(n)$ 次複製),但這些複製是連續記憶體的線性搬移,CPU 與 memmove 高度最佳化、cache 友善;而鏈結串列雖然「理論上」插入是 $O(1)$,但你得先用 $O(n)$ 的指標追逐(pointer chasing)找到位置,每一跳都 cache miss。

C++ 標準委員會成員 Bjarne Stroustrup 曾做過著名實驗:對於「保持序列有序,反覆隨機插入/刪除」的工作負載,std::vector(陣列)在幾乎所有規模上都打敗 std::list(鏈結串列),即使前者理論上每次操作是 $O(n)$、後者宣稱 $O(1)$。教訓是:漸進複雜度是分析的起點,不是效能的終點;常數因子與記憶體存取模式在現代硬體上往往才是決勝點。

重點回顧

  • 大 O 隱藏了記憶體層級:陣列的連續配置帶來空間區域性與 cache 友善,使其在線性走訪上實務速度遠勝散落的鏈結串列,即使兩者同為 $O(n)$。
  • 動態陣列的 $O(1)$ append 是均攤結果:倍增擴容讓總複製成本為 $O(n)$,攤平後每次 $O(1)$;代價是約一半的空間浪費(時間-空間權衡)。
  • 鏈結串列家族遠不只單向串列:雙向串列 + 雜湊表構成 $O(1)$ 的 LRU 快取,哨兵節點消除邊界判斷。
  • 跳躍串列用隨機化換對數搜尋:期望 $O(\log n)$ 且免於樹旋轉,是平衡結構的簡潔替代品。
  • 「鏈結串列適合插入」需加註腳:唯有握有位置指標時才成立;考慮 cache 後,陣列的線性搬移常勝過鏈結串列的指標追逐。

深入探討(研究所視角)

1. 均攤分析的三種形式化方法。 我們用了會計法(accounting method),但嚴格的分析還有聚合法(aggregate method)位能法(potential method)。位能法定義一個位能函數 $\Phi$ 映射資料結構狀態到非負實數,每次操作的均攤成本定義為 $\hat{c}_i = c_i + \Phi(D_i) - \Phi(D_{i-1})$。對動態陣列,可取 $\Phi = 2 \cdot \text{size} - \text{capacity}$,能優雅證明 $n$ 次操作總均攤成本 $O(n)$。位能法的威力在於它能處理「狀態相依」的成本變化,是分析 Fibonacci heap、splay tree 等進階結構的標準工具。

2. Cache-oblivious 演算法與資料結構。 既然 cache 如此重要,能否設計出不需知道 cache 參數(line size、cache size)就能對所有層級都最佳的演算法?這是 Frigo 等人提出的 cache-oblivious 模型。代表作如 van Emde Boas 佈局的搜尋樹:把樹遞迴地切成上下兩半的子樹並連續存放,使得任意 cache line 大小下的記憶體傳輸量都達到 $O(\log_B n)$($B$ 為 line size)的理論最優。這條路線把「資料結構的記憶體佈局」提升為一級設計考量,是現代資料庫與檔案系統索引(如 B-tree 的變體)的理論基礎。

3. 並行環境下的鏈結串列:lock-free 與 ABA 問題。 在多執行緒下,鏈結串列的指標操作會引發競態。無鎖(lock-free)串列用 compare-and-swap(CAS)原子指令實作插入,但會遇到惡名昭彰的 ABA 問題:執行緒讀到指標值 A,被搶佔後該位址歷經 A→B→A 的變化,CAS 卻誤判「沒變」而成功。解法包括帶版本號的指標(tagged pointer)、hazard pointer、或 epoch-based reclamation 來安全回收記憶體。Harris(2001)的 lock-free 鏈結串列是這個領域的奠基之作,值得作為並行資料結構的入門讀物。

4. 展開鏈結串列(unrolled linked list):兩種結構的雜交。 既然陣列 cache 友善、鏈結串列插入靈活,何不讓每個節點存一個小陣列(如 16 個元素)?這就是展開鏈結串列:它把指標追逐的次數降為 $1/16$,大幅改善 cache 行為,同時保留分塊插入的彈性。更進一步的 B-tree 與資料庫的 rope(用於文字編輯器的字串表示)都可視為這個「陣列分塊 + 樹/串列連結」思想的延伸。這提示我們:真實系統中最快的結構,往往不是教科書上的純陣列或純串列,而是針對特定存取模式與記憶體層級量身打造的混合體。

AI 共讀助教正在陪你讀:陣列與鏈結串列(進階):被大 O 隱藏的真相
嗨!我是這篇文章的共讀助教,只根據〈陣列與鏈結串列(進階):被大 O 隱藏的真相〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。