Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

陣列與鏈結串列

陣列與鏈結串列(進階):被大 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 隱藏的真相〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。