← 文章列表
Engineering

Python 資料結構深度解析:不只是背複雜度,而是知道什麼時候該用哪一個

2026-03-04 · — views

寫給已經會寫 Python、但想搞清楚「為什麼選這個」的工程師。

前言:Vibe Coding 時代,你更需要懂資料結構

現在是 Vibe Coding 的時代。Cursor、Copilot、Claude Code 幫你寫完大部分的程式碼,你一個下午就能 ship 一個 MVP。但問題來了——

當 AI 幫你選了 list 來做 queue、用 lru_cache 快取有時效性的行情資料、或者在 hot path 上用 pandas.DataFrame 做逐筆更新的時候,你看得出來這些是不好的選擇嗎?

Vibe Coding 讓你寫得更快,但不代表你可以不懂。恰恰相反,當產出速度變快,你需要更強的判斷力來 review AI 生成的程式碼——尤其是它選的資料結構到底合不合理。 AI 很擅長寫出「能跑」的程式碼,但它不一定會選「最適合你場景」的方案。

這篇文章不是要教你從零學資料結構,而是讓你在看到一段程式碼時,能快速判斷:「這裡的選擇合理嗎?有沒有更好的替代方案?瓶頸可能在哪?」


大部分教學會告訴你 dict 是 O(1) 查詢、list.append() 是 O(1)。但很少人會告訴你:list.pop(0) 是 O(n) 會害死你、deque[i] 其實不是真正的 O(1)、lru_cache 在有時效性的場景下是個地雷。

這篇文章把 Python 常用的資料結構攤開來講,包含底層實作原理、完整的時間與空間複雜度、實務應用場景,以及 LeetCode 上哪些題會用到


1. list — 動態陣列

底層實作

Python 的 list 是一個指標陣列(array of pointers),每個元素存的是一個指向 PyObject 的指標,不是值本身。底層是一塊連續的記憶體,長度不夠時會自動擴容(大約 1.125 倍)。

這意味著:

  • list 的元素在記憶體中不是連續儲存的(指標連續,但指向的物件散落各處)
  • 每個元素除了值本身,還有 PyObject header 的 overhead(約 28 bytes per int)

時間複雜度

操作複雜度說明
list[i]O(1)直接用 index 算 offset,極快
list.append(x)amortized O(1)偶爾觸發擴容時是 O(n),但均攤下來是 O(1)
list.pop()O(1)從尾端移除
list.pop(0)O(n)所有元素往前搬一位,這是最常見的效能陷阱
list.insert(0, x)O(n)同上,所有元素往後搬
list.insert(i, x)O(n - i)搬 i 之後的元素
x in listO(n)線性掃描,沒有 hash
list.sort()O(n log n)Timsort,穩定排序
list[i:j]O(j - i)slice 會建新 list
list.extend(iterable)O(k)k 是 iterable 的長度
del list[i]O(n - i)搬 i 之後的元素
list.remove(x)O(n)先找到 x,再搬元素
len(list)O(1)內部有維護 length 變數

空間複雜度

  • 每個元素:8 bytes(指標)+ PyObject overhead(int 約 28 bytes)
  • 預分配空間:list 會多分配約 12.5% 的空間做 buffer,避免頻繁 resize
  • sys.getsizeof([]) = 56 bytes(空 list 的 overhead)
  • 實際佔用 ≈ 56 + 8n bytes(不含元素本身),元素本身另計

應用場景

  • 適合:需要 random access(list[i])、大量尾端操作(append/pop)
  • 不適合:頻繁頭部操作(insert(0)/pop(0))、大量 in 查詢
  • 實務用途:Stack(用 append + pop)、排序後的結果儲存、batch 資料收集

LeetCode 常見用法

題號題目用法
#20Valid Parentheses當 Stack 用(append + pop)
#155Min Stack雙 list 模擬 min stack
#56Merge Intervalssort 後遍歷合併
#153Sumsort + 雙指標
#78Subsetsbacktracking 收集結果
#739Daily TemperaturesMonotonic Stack(list 當 stack)

注意

# 這是 O(n),千萬不要在迴圈裡用
for _ in range(n):
    list.pop(0)  # 每次都搬整個陣列

# 用 deque 取代
from collections import deque
dq = deque(list)
for _ in range(n):
    dq.popleft()  # O(1)

2. collections.deque — 雙端佇列

底層實作

deque 的底層是一個 doubly-linked list of fixed-size blocks,每個 block 存 64 個元素。不是教科書上的「每個節點一個元素」的 linked list,所以記憶體效率比純 linked list 好很多。

[block0: 64 items] <-> [block1: 64 items] <-> [block2: 64 items]

時間複雜度

操作複雜度說明
dq.append(x)O(1)右端加入
dq.appendleft(x)O(1)左端加入
dq.pop()O(1)右端移除
dq.popleft()O(1)左端移除
dq[i]O(n)不是 O(1)!要走過 i//64 個 block
dq.rotate(k)O(k)旋轉 k 步
x in dqO(n)線性掃描
len(dq)O(1)內部維護 length
dq.remove(x)O(n)找到並移除
dq.extend(iterable)O(k)k 是 iterable 長度

空間複雜度

  • 每個 block:64 個指標 + linked list 的 prev/next 指標
  • 比 list 稍多一點 overhead(因為 block linking),但不需要 resize 時的整塊搬遷
  • deque(maxlen=N) 會自動丟棄超出的元素,記憶體上限固定

應用場景

  • 適合:Queue(FIFO)、雙端操作、固定窗口(maxlen)、BFS
  • 不適合:頻繁 random access(dq[i])、需要 slice 操作
  • 實務用途:BFS 的佇列、sliding window 的窗口容器、producer-consumer pattern

LeetCode 常見用法

題號題目用法
#102Binary Tree Level Order TraversalBFS 佇列
#200Number of IslandsBFS 佇列
#239Sliding Window MaximumMonotonic deque(核心題)
#862Shortest Subarray with Sum at Least KMonotonic deque
#346Moving Average from Data Streamdeque(maxlen=N) 完美匹配
#641Design Circular Deque直接用 deque 實作

deque 的隱藏特性

# maxlen:自動維護固定窗口,超出就丟掉最舊的
dq = deque(maxlen=5)
for i in range(10):
    dq.append(i)
print(dq)  # deque([5, 6, 7, 8, 9], maxlen=5)

# rotate:O(k) 旋轉,某些題目很好用
dq = deque([1, 2, 3, 4, 5])
dq.rotate(2)   # 往右轉 2 步 → deque([4, 5, 1, 2, 3])
dq.rotate(-1)  # 往左轉 1 步 → deque([5, 1, 2, 3, 4])

3. dict — 雜湊表

底層實作

Python 3.6+ 的 dict 使用 compact dict

  • 一個 indices 陣列(存 hash → entry index 的映射)
  • 一個 entries 陣列(按插入順序存放 key-value pair)

這個設計讓 dict 既保持 O(1) 查詢,又保持插入順序(Python 3.7+ 正式保證)。Hash collision 用 open addressing(probing)處理,load factor 超過 2/3 時自動 resize。

時間複雜度

操作平均最差說明
d[key]O(1)O(n)最差情況是所有 key hash 衝突
d[key] = valO(1)O(n)可能觸發 resize
del d[key]O(1)O(n)
key in dO(1)O(n)
d.get(key, default)O(1)O(n)
d.keys() / d.values()O(1)回傳 view,不是 copy
for k in dO(n)遍歷所有 entry
len(d)O(1)
d.pop(key)O(1)O(n)
d1.update(d2)O(len(d2))
{**d1, **d2}O(len(d1) + len(d2))建新 dict

空間複雜度

  • 空 dict:64 bytes
  • 每個 entry:約 50~70 bytes(key hash + key pointer + value pointer)
  • Load factor < 2/3,所以至少有 1/3 的空間是浪費的
  • Resize 時容量翻倍,瞬間記憶體使用翻倍

int key vs str key 效能差異

hash(42)        # → 42(直接回傳自己,幾乎 instant)
hash("AAPL")    # → 需要遍歷字串計算 hash(Python 會 cache)

在 hot path 上,int key 的 dict 查詢比 str key 快 20~40%。實務上常見的做法是:建一個 symbol_to_id mapping,之後全程用 int。

應用場景

  • 適合:幾乎所有需要快速查詢的場景、計數、分組、快取
  • 不適合:需要排序遍歷、key 不是 hashable 的
  • 實務用途:counter、lookup table、graph adjacency list、JSON-like 資料

LeetCode 常見用法

題號題目用法
#1Two Sumhash map 存 complement
#49Group Anagramssorted string 當 key 分組
#128Longest Consecutive Sequenceset/dict 查 O(1)
#146LRU Cachedict + doubly linked list
#560Subarray Sum Equals Kprefix sum + dict 計數
#3Longest Substring Without Repeatingdict 記錄最後出現位置
#138Copy List with Random Pointerold node → new node mapping
#347Top K Frequent Elementsdict 計數 + heap

4. collections.defaultdict — 帶預設值的 dict

底層實作

defaultdictdict 的子類別,唯一的差異是:存取不存在的 key 時,不會丟 KeyError,而是自動呼叫 default_factory 建立預設值並插入。

from collections import defaultdict

dd = defaultdict(list)
dd["a"].append(1)  # 不用先 if "a" not in dd: dd["a"] = []

時間 / 空間複雜度

dict 完全相同。唯一的額外成本是:miss 時呼叫一次 default_factory(通常是 list()int()set() 等,都是 O(1))。

常見 default_factory

Factory預設值用途
int0計數器
list[]分組收集
setset()去重分組
lambda: float('inf')inf最短距離初始化

應用場景

  • 適合:需要頻繁對不存在的 key 做 append/add/increment 的場景
  • 陷阱:查詢不存在的 key 會自動插入,可能造成 dict 意外膨脹
dd = defaultdict(int)
val = dd["not_exist"]  # 現在 dd 裡多了一個 {"not_exist": 0}
# 如果只是要查,用 dd.get("key", 0) 更安全

LeetCode 常見用法

題號題目用法
#49Group Anagramsdefaultdict(list) 分組
#207Course Scheduledefaultdict(list) 建 adjacency list
#269Alien Dictionarydefaultdict(set) 建 graph
#323Number of Connected Componentsdefaultdict(list) 建無向圖
#314Binary Tree Vertical Orderdefaultdict(list) 按 column 分組

5. collections.OrderedDict — 有序字典

底層實作

OrderedDict 內部維護一個 doubly-linked list 來追蹤插入順序。Python 3.7+ 的普通 dict 也保證插入順序,所以 OrderedDict 的存在意義主要是它提供的額外方法:

  • move_to_end(key, last=True) — 把某個 key 移到頭或尾,O(1)
  • popitem(last=True) — 從頭或尾 pop,O(1)

這兩個方法讓它成為實作 LRU Cache 的理想基礎。

時間複雜度

操作複雜度說明
od[key]O(1)同 dict
od[key] = valO(1)同 dict
od.move_to_end(key)O(1)OrderedDict 專屬,dict 沒有
od.popitem(last=False)O(1)從頭 pop(FIFO),dict 只能 pop 尾
del od[key]O(1)

空間複雜度

dict 多一個 doubly-linked list 的開銷(每個 entry 多兩個指標,約 16 bytes)。

應用場景

  • 適合:需要 LRU 淘汰策略、需要 move_to_end 的場景
  • 不需要用的場景:只需要插入順序遍歷(普通 dict 就夠了)

LeetCode 常見用法

題號題目用法
#146LRU Cache經典題OrderedDict 一步到位
#460LFU Cache搭配 defaultdict(OrderedDict)

LRU Cache 的兩種實作

# 方法一:用 OrderedDict(面試推薦,簡潔)
class LRUCache:
    def __init__(self, capacity):
        self.cache = OrderedDict()
        self.cap = capacity

    def get(self, key):
        if key not in self.cache:
            return -1
        self.cache.move_to_end(key)
        return self.cache[key]

    def put(self, key, value):
        if key in self.cache:
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.cap:
            self.cache.popitem(last=False)

# 方法二:dict + doubly-linked list(面試考手刻能力時用)
# 省略,但邏輯是一樣的,只是要自己管 linked list

6. heapq — 堆積(優先佇列)

底層實作

Python 的 heapq 是用 list 實作的 min-heap(最小堆)。heap[0] 永遠是最小元素。底層就是一個普通的 list,透過 index 關係維護 heap property:heap[k] <= heap[2k+1]heap[k] <= heap[2k+2]

要用 max-heap?把值取負:heappush(h, -val)

時間複雜度

操作複雜度說明
heapq.heappush(h, x)O(log n)插入
heapq.heappop(h)O(log n)彈出最小值
h[0]O(1)查看最小值(不彈出)
heapq.heappushpop(h, x)O(log n)push 再 pop,比分開做快
heapq.heapify(list)O(n)把既有 list 轉成 heap
heapq.nlargest(k, iterable)O(n log k)
heapq.nsmallest(k, iterable)O(n log k)

空間複雜度

  • 就是一個 list,O(n)
  • 沒有額外的結構開銷

關鍵限制

  1. 不支援任意位置刪除 — 這是 heap 最大的弱點。你無法高效地刪除 heap 中間的某個元素。常見 workaround 是 lazy deletion(標記為已刪除,pop 時跳過),但會累積垃圾。
  2. 不支援 decrease-key — 無法高效更新已在 heap 中的元素的 priority。在 Dijkstra 演算法中,這意味著你只能用「重複 push + lazy deletion」來處理。
  3. 只有 min-heap — 要 max-heap 就取負值。

應用場景

  • 適合:Top-K 問題、合併 K 個有序序列、排程(priority queue)
  • 不適合:需要任意刪除的場景(如 order book)、需要遍歷所有元素的場景
  • 實務用途:工作排程、Dijkstra、Huffman coding、streaming median

LeetCode 常見用法

題號題目用法
#215Kth Largest Elementmin-heap 維護大小 K
#23Merge k Sorted Listsheap merge K 個序列
#295Find Median from Data Stream雙 heap(max-heap + min-heap)
#347Top K Frequent Elementsdict 計數 + min-heap
#743Network Delay TimeDijkstra 的 priority queue
#621Task Schedulermax-heap 排程
#973K Closest Points to Originmin-heap / max-heap
#378Kth Smallest Element in Sorted Matrixheap merge

實務 pattern:用 tuple 排序 + sequence number 避免比較問題

import heapq

seq = 0
heap = []

def push(priority, item):
    global seq
    heapq.heappush(heap, (priority, seq, item))
    seq += 1  # 確保相同 priority 時按插入順序排

# 為什麼要 seq?
# 如果兩個 tuple 的第一個元素相同,Python 會比較第二個
# 如果 item 沒有實作 __lt__,會直接報 TypeError
# seq 保證第二個元素永遠不同,避免比較到 item

7. set / frozenset — 集合

底層實作

set 的底層就是一個只有 key 沒有 value 的 hash table,實作方式跟 dict 幾乎一樣(open addressing + probing)。frozenset 是 immutable 版本,可以被 hash,所以可以當 dict 的 key 或放進另一個 set。

時間複雜度

操作平均最差說明
x in sO(1)O(n)hash lookup
s.add(x)O(1)O(n)
s.remove(x)O(1)O(n)不存在會丟 KeyError
s.discard(x)O(1)O(n)不存在不報錯
`s1s2` (union)O(len(s1) + len(s2))
s1 & s2 (intersection)O(min(len(s1), len(s2)))掃短的那個
s1 - s2 (difference)O(len(s1))
s1 ^ s2 (symmetric diff)O(len(s1) + len(s2))
s1 <= s2 (subset)O(len(s1))

空間複雜度

  • 類似 dict,load factor < 2/3
  • 每個元素約 50 bytes(hash + pointer + overhead)

應用場景

  • 適合:去重、成員檢查(O(1) vs list 的 O(n))、集合運算
  • 不適合:需要排序、需要 index access
  • 實務用途:URL visited 檢查、白名單/黑名單、graph 的 visited set

LeetCode 常見用法

題號題目用法
#3Longest Substring Without Repeatingset 當 sliding window 的字元集
#128Longest Consecutive Sequenceset 查 O(1),避免排序
#36Valid Sudokuset 檢查行 / 列 / 格重複
#139Word Breakset 存字典做 O(1) lookup
#200Number of Islandsvisited set 搭配 BFS/DFS
#127Word Ladderset 當 word bank + visited

set vs list 的 in 查詢

# 你以為差不多,其實天差地別
my_list = list(range(100000))
my_set = set(range(100000))

99999 in my_list  # O(n),掃到最後才找到
99999 in my_set   # O(1),hash 一次就找到

# 經驗法則:只要你要做 `in` 查詢超過 10 次,就該用 set

8. namedtuple / dataclass — 結構化記錄

namedtuple

from collections import namedtuple

Point = namedtuple('Point', ['x', 'y'])
p = Point(1, 2)
p.x  # 1(有名稱,比 tuple[0] 可讀)

底層:就是 tuple 的子類別,immutable。記憶體跟 tuple 一樣小。

特性說明
記憶體很小,跟 tuple 相同
存取速度O(1),同 tuple
Immutable是,修改要用 _replace() 建新物件
Hashable是,可以當 dict key
型別提示弱(可以用 typing.NamedTuple 改善)

dataclass

from dataclasses import dataclass

@dataclass
class Position:
    symbol: str
    quantity: int = 0
    pnl: float = 0.0

底層:普通的 class,自動生成 __init____repr____eq__ 等。加上 slots=True 可以大幅降低記憶體。

特性dataclassdataclass(slots=True)
記憶體中等(有 __dict__小(接近 C struct)
Mutable
型別提示完整支援完整支援
IDE 支援
動態加屬性可以不行(這是 trade-off)

怎麼選?

場景選擇
不需要修改、要放進 set 或當 dict keynamedtuple
需要頻繁修改欄位dataclass
需要最小記憶體 + 頻繁修改dataclass(slots=True)
只需要打包幾個值回傳普通 tuple 就好

LeetCode 常見用法

題號題目用法
#253Meeting Rooms IInamedtuple 存 interval
#295Find Median from Data Streamdataclass 封裝 heap 操作
通用Graph node, Trie nodedataclass 定義節點結構

9. queue.Queue / asyncio.Queue — 執行緒安全佇列

對比表

維度dequequeue.Queueasyncio.Queue
Thread-safe(單一操作 atomic,複合操作不安全)(single event loop)
Blocking get不支援支援(q.get(timeout=N)支援(await q.get()
效能最快慢 5~10x(有 Lock)快(但限 async)
適用架構單執行緒多執行緒asyncio

時間複雜度

三者的 enqueue / dequeue 都是 O(1),差別在常數(lock 的開銷)。

GIL 的 thread-safety 迷思

# deque.append() 和 deque.popleft() 單一操作在 CPython 下是 atomic
# 但複合操作不是:

if len(dq) > 0:       # ← GIL 可能在這裡 release
    item = dq.popleft() # ← 到這裡 dq 可能已經空了

# 多執行緒下永遠用 queue.Queue

LeetCode 常見用法

queue 模組在 LeetCode 中幾乎不用(因為都是單執行緒),但在系統設計面試中是重要概念。


10. functools.lru_cache — 記憶化快取

底層實作

lru_cache 在內部用一個 dict + circular doubly-linked list 實作 LRU 淘汰。有一個全域 Lock 保證 thread-safety。

@lru_cache(maxsize=128)
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

時間複雜度

操作複雜度
Cache hitO(1)
Cache missO(1)(dict 插入 + LRU 更新) + 原函數執行時間
cache_clear()O(n)
cache_info()O(1)

限制

  1. 參數必須 hashable — 不能傳 list、dict
  2. 沒有 TTL — cache 住了就不會過期
  3. 只能 cache_clear() 全部清除 — 不能清單一 entry
  4. 有全域 Lock — 高並發下可能成為瓶頸
  5. maxsize=None — 無限制 cache,記憶體可能爆炸

Python 3.9+:@cache

from functools import cache

@cache  # 等同於 @lru_cache(maxsize=None),更簡潔
def factorial(n):
    return n * factorial(n-1) if n else 1

應用場景

  • 適合:遞迴 DP(避免重複計算)、純函數的 memoization
  • 不適合:有時效性的資料、參數不是 hashable 的

LeetCode 常見用法

題號題目用法
#70Climbing Stairs遞迴 + @lru_cache
#322Coin ChangeTop-down DP
#1143Longest Common SubsequenceTop-down DP
#494Target Sum遞迴搜索 + memo
#329Longest Increasing Path in MatrixDFS + @lru_cache
#1335Minimum Difficulty of a Job ScheduleTop-down DP

面試技巧

# 面試時用 @lru_cache 把 top-down 遞迴直接變成 DP
# 省去手動建 dp table 的麻煩

@lru_cache(maxsize=None)
def solve(i, j):
    if base_case:
        return ...
    return min(solve(i+1, j), solve(i, j+1)) + cost[i][j]

# 面試官可能會 follow-up:
# 「這個 cache 的空間複雜度是多少?」→ O(n*m),跟 bottom-up DP table 一樣
# 「如果 n 很大,遞迴深度會不會爆?」→ 會,Python 預設 recursion limit 1000
#   可以 sys.setrecursionlimit(),但 bottom-up 更安全

11. sortedcontainers.SortedList / SortedDict — 排序容器(第三方)

底層實作

sortedcontainers 是純 Python 實作,底層是 list of sorted lists(分段排序)。每個 segment 約 1000 個元素,用 binary search 定位 segment,再在 segment 內做 bisect。

雖然是純 Python,但因為 cache locality 好(連續記憶體),實測經常比 C 擴展的紅黑樹(如 bintrees)還快。

時間複雜度

操作複雜度說明
sl.add(x)O(log n)bisect + insert
sl.discard(x)O(log n)bisect + remove
sl[i]O(log n)需要定位 segment
sl.bisect_left(x)O(log n)找插入位置
x in slO(log n)bisect 查找
sl[i:j]O(log n + j - i)
sl.peekitem(0) (SortedDict)O(log n)取最小/最大 key

空間複雜度

  • O(n),跟普通 list 差不多
  • segment 之間有少量 metadata overhead

應用場景

  • 適合:需要排序 + 動態插入刪除(order book、即時排行榜)
  • 不適合:Python 標準庫不包含,部署時要額外裝套件

LeetCode 常見用法

題號題目用法
#220Contains Duplicate IIISortedList 維護 sliding window
#352Data Stream as Disjoint IntervalsSortedList 合併區間
#327Count of Range SumSortedList + bisect 計數
#480Sliding Window MedianSortedList 動態維護排序窗口
#715Range ModuleSortedList 管理區間

12. 總整理:速查表

按使用場景選擇

需求首選別用
Stack (LIFO)list(append + pop)deque(overkill)
Queue (FIFO)deque(append + popleft)list(pop(0) 是 O(n))
快速查詢 key-valuedictlist(in 是 O(n))
去重 / 成員檢查setlist(in 是 O(n))
排序 + 動態增刪SortedListlist + 反覆 sort
Top-K / Priorityheapqsort 全部再取前 K
遞迴 DP memo@lru_cache手動建 dict(可以但麻煩)
LRU CacheOrderedDictlru_cache(沒有 TTL)
結構化資料(mutable)dataclassdict of dict(沒型別提示)
結構化資料(immutable)namedtupledataclass(如果不需要修改)

完整複雜度速查

結構查詢插入刪除排序遍歷記憶體
listO(1) index / O(n) searchO(1) tail / O(n) headO(1) tail / O(n) headO(n log n)
dequeO(n)O(1) 兩端O(1) 兩端不支援
dictO(1)O(1)O(1)不支援
setO(1)O(1)O(1)不支援
heapqO(1) minO(log n)O(log n) pop min不直接支援
SortedListO(log n)O(log n)O(log n)O(n)
OrderedDictO(1)O(1)O(1)O(n) 插入序

結語:資料結構的選擇不在於「哪個最快」,而在於你的場景裡哪個操作最頻繁。搞清楚讀寫比例、是否需要排序、是否需要 thread-safety,答案自然就出來了。