chundev
日期:2026-04-22 情境: 接續 ES、Neo4j、Polyglot Persistence 三篇深入文,本篇是「演算法基礎篇」 — 把那些深入文裡掛名的所有演算法(inverted index / skip list / FST / BM25 / BFS / bidirectional BFS / subgraph isomorphism / HNSW / Volcano iterator / CBO …)由簡入深走一遍。
資料庫的速度全部建立在「用對的資料結構 + 對的演算法」上。本文按四層 progression 走:
LIKE 快幾個量級每節先給直覺 + 圖示,再給複雜度分析 + 何時該用 / 不該用。
最熟悉但常被低估的資料結構。
key "alice"
↓ hash()
↓ → 12345
↓ % bucket_count(64)
↓ → bucket 25
↓
[bucket 0] [bucket 1] ... [bucket 25 → ("alice", value)] ... [bucket 63]
操作複雜度:
用在哪:
SELECT * WHERE id = 5)為什麼不能全用 hash: 範圍查詢和排序輸出是 OLTP 必需,hash 完全不支援。
sorted: [3, 7, 12, 18, 25, 33, 41, 50, 67, 89]
找 25:
step 1: mid=index 4 (25) ✓ 中
找 30:
step 1: mid=index 4 (25), 30 > 25 → 看右半
step 2: mid=index 7 (50), 30 < 50 → 看左半 [33, 41]
step 3: mid=index 5 (33), 30 < 33 → 看左半 [33] (空) → not found
每步把搜尋範圍砍半,n 個元素只要 log₂(n) 步。
問題: insert / delete 是 O(n)(要搬後面所有元素)。所以只適合「大量讀、少量寫」場景。
B-tree 是 sorted array 的「分塊版本」:
graph TD
Root["[10 | 20 | 30]"]
L1["[3, 7]"]
L2["[11, 15, 18]"]
L3["[22, 25, 28]"]
L4["[33, 41, 50]"]
Root -->|< 10| L1
Root -->|10-20| L2
Root -->|20-30| L3
Root -->|> 30| L4
style Root fill:#fef3c7,stroke:#92400e,stroke-width:2px
style L1 fill:#dbeafe
style L2 fill:#dbeafe
style L3 fill:#dbeafe
style L4 fill:#dbeafe
每個節點存多個 key(不像 binary tree 只存一個),fanout 高(一個節點通常 100-200 個 key)。
為什麼 RDBMS index 全用 B-tree(不是 binary tree):
B+ tree 是 B-tree 的常見變體(PostgreSQL / MySQL 都用):所有資料只放 leaf,內部節點只放 key 當索引 — 範圍掃描更快(leaf 之間有指標串起來,順序走過去就好)。
複雜度總結:
| 操作 | Hash Table | Sorted Array | B-tree | B+ tree |
|---|---|---|---|---|
| Lookup | O(1) | O(log n) | O(log n) | O(log n) |
| Insert | O(1) amortized | O(n) | O(log n) | O(log n) |
| Range scan | ❌ | O(log n + k) | O(log n + k) | O(log n + k) 最快 |
| Sort output | ❌ | ✓ | ✓ | ✓ |
LIKE 慢SELECT * FROM messages WHERE body LIKE '%詐騙%';
每筆 row 都要把 body 整串掃過、找子字串。100 萬筆 × 平均 500 字 = 5 億次字元比對。O(n × m)。
加 trigram index(PG 的 pg_trgm)能改善但天花板低 — 中文 trigram 的 selectivity 差。
原始文件:
doc 1: "張三今天去詐騙"
doc 2: "李四在詐騙集團"
倒排索引:
"張三" → posting list: [1]
"李四" → posting list: [2]
"今天" → posting list: [1]
"詐騙" → posting list: [1, 2]
"集團" → posting list: [2]
查「詐騙」O(1) 找到 posting list [1, 2],O(k) 走完 — 完全不掃原文。
兩個 posting list 做 AND:
list A: [1, 5, 9, 12, 18, 25, 33, 41, 50, ...] (1000 個)
list B: [3, 18, 50, 100, ...] (10 個)
| 從 list B 出發(短的),對每個 b 找 list A 是否有 = b 的元素。Naive 線性 scan 是 O( | A | ) per b。 |
Skip list 在 list A 上加多層 skip pointer:
level 2: [1, ──────────, 50, ──────, ... ]
level 1: [1, ──, 18, ──, 50, ──, ... ]
level 0: [1, 5, 9, 12, 18, 25, 33, 41, 50, ...]
要找 18:從 level 2 跳到 50 太遠 → 退回 level 1,從 1 跳到 18 → ✓。O(log n) per lookup。
| 兩個 posting list AND 的整體複雜度:O(( | A | + | B | ) × log( | A | / | B | ))。實戰中常見 100K + 100 的兩 list AND 在毫秒內完成。 |
Skip list 是隨機資料結構:每個元素隨機決定要不要在 level 1 / level 2 出現(機率 1/2、1/4 …),平均高度 O(log n)。優點是不用像 B-tree 那樣 rebalance。
100 萬個 unique terms 的 inverted index,光 term 列表就佔大量空間。Lucene 用 FST 把 term → posting list ID 的對應壓縮成有限狀態機。
terms: ["alice", "alpha", "amy", "andrew", "ann"]
FST 共享 prefix:
start → 'a' → ╭→ 'l' → 'i' → 'c' → 'e' → posting_id=0
├→ 'l' → 'p' → 'h' → 'a' → posting_id=1
├→ 'm' → 'y' → posting_id=2
├→ 'n' → 'd' → 'r' → 'e' → 'w' → posting_id=3
╰→ 'n' → 'n' → posting_id=4
共享 prefix 大幅壓縮儲存 — Lucene 的 term dictionary 通常 5-10× 壓縮率。同時 FST 支援 prefix scan(all terms starting with “al”)— startsWith query 走 FST 就行。
score(q, d) = Σ IDF(qi) · (f(qi, d) · (k1 + 1))
/ (f(qi, d) + k1 · (1 - b + b · |d|/avgdl))
直覺:
完整推導見 s3 ES 那篇。
| 演算法 | 解的問題 | 複雜度 |
|---|---|---|
LIKE '%xxx%' 掃表 |
子字串搜尋 | O(n × m) |
| Trigram index | 短字串搜尋 | O(n) 但高 constant |
| Inverted index + Skip list | 詞層級 boolean query | O((m + log n) × k) |
| FST | term dictionary 壓縮 + prefix scan | O(L) per term |
| BM25 | 相關性排序 | O(matched docs × avg query terms) |
Adjacency Matrix(密圖友善):
A B C D
A [ 0 1 0 1 ]
B [ 1 0 1 0 ]
C [ 0 1 0 1 ]
D [ 1 0 1 0 ]
查 A↔B 是否有邊 O(1),但稀疏圖浪費 O(V²) 空間。
Adjacency List(稀疏圖友善,95% 真實圖都是稀疏的):
A → [B, D]
B → [A, C]
C → [B, D]
D → [A, C]
空間 O(V + E),遍歷 A 的鄰居 O(degree(A))。
Neo4j 把 adjacency list 物理化進 record:每個 node record 直接記第一個 relationship 的 disk offset,每個 relationship record 直接記下一個 relationship 的 offset。
走鄰居 = 指標跳躍(O(1) per hop),完全不需要任何 index。
從 A 開始 BFS:
level 0: {A}
level 1: {B, D} ← A 的所有鄰居
level 2: {C} ← B, D 的鄰居 - 已訪問
複雜度 O(V + E)。要找 A 到 G 最短路徑就是 BFS 走到 G 那層為止。
問題:6 度分隔內找全部 → 平均 degree=100 的圖要走 100^6 = 10^12 個節點。爆炸。
graph LR
subgraph forward ["前向 BFS (從 A)"]
A["A
level 0"]
AB["A 的鄰居
level 1"]
AC["...
level k/2"]
A --> AB --> AC
end
subgraph backward ["後向 BFS (從 G)"]
G["G
level 0"]
GB["G 的鄰居
level 1"]
GC["...
level k/2"]
G --> GB --> GC
end
AC -.->|frontier 相交| GC
style A fill:#fef3c7,stroke:#92400e,stroke-width:2px
style G fill:#dcfce7,stroke:#166534,stroke-width:2px
關鍵:兩邊各走 k/2 層,總工作量 2 × d^(k/2) « d^k。
對 k=6, d=100:
百萬倍 的差異。Neo4j 的 shortestPath() 內部就用 bidirectional BFS。
實作細節:交替擴展兩邊,每次選 frontier 較小的那邊先擴 — 確保 worst-case balanced。
「圖中是否存在符合特定 pattern 的子結構」 — e.g. 找所有「三人互相認識」的 triangle。
通用演算法:VF2 (Cordella et al. 2004)、Ullmann’s algorithm — 都是 backtracking + 條件剪枝。一般情況 NP-hard。
Neo4j 不跑通用演算法:
對「三角形」pattern:
MATCH (a)-[:KNOWS]->(b)-[:KNOWS]->(c)-[:KNOWS]->(a)
實際 NP-hardness 主要影響「unlabeled、無 indexed 起點、深度大」的查詢。OLTP 場景幾乎都有起點,多項式時間就夠。
| 演算法 | 解的問題 | 複雜度 |
|---|---|---|
| Adjacency list | 圖儲存 | 空間 O(V+E) |
| Index-free adjacency | 圖 traversal | O(1) per hop |
| BFS | 最短路徑 / k-hop | O(V+E) |
| Bidirectional BFS | 已知終點的最短路徑 | O(d^(k/2)) vs O(d^k) |
| Subgraph isomorphism (general) | 找 pattern | NP-hard |
| Pattern matching with pinning | 找有起點的 pattern | 多項式時間 |
| PageRank / Louvain | 中心性 / 社群偵測 | O(V+E) per iteration |
query vector q (1024-dim)
資料庫 100 萬個 vector
for each v in 1M vectors:
similarity = cosine(q, v)
top_k = sort by similarity, return top 10
每筆比對是 1024 次浮點乘加 + 1 次平方根。100 萬筆 ≈ 10^9 次運算 ≈ 1-10 秒。太慢,不可接受。
KD-tree 是 binary tree 的高維變體:
低維 (d ≤ 20) 效果好。高維 (d > 20) 因為「curse of dimensionality」失效 — 大部分情況要看遍所有 leaf。
對 1024 維的 embedding 完全不適用。
LSH 設計一系列 hash function 滿足「相似的 vector 容易撞到同一個 bucket」(與一般 hash 反過來)。
LSH bucket:
bucket "abc" → [v1, v3, v7, v12] ← 這些彼此相似機率高
bucket "xyz" → [v2, v8, v15]
Query 時計算 q 的 LSH hash,只比對同 bucket 內的 vector — O(bucket size) 而非 O(n)。
問題:召回率 (recall) 取決於 hash function 設計,調參困難。對高品質結果不夠。
當前向量檢索的事實標準(FAISS、Milvus、ES、Weaviate 都用)。核心是「多層導航圖」:
graph TB
subgraph L2 ["Layer 2 (稀疏 - 樞紐節點)"]
A2((A)) --- E2((E))
end
subgraph L1 ["Layer 1 (中等)"]
A1((A)) --- C1((C)) --- E1((E)) --- G1((G))
end
subgraph L0 ["Layer 0 (全量節點)"]
A0((A)) --- B0((B)) --- C0((C)) --- D0((D)) --- E0((E)) --- F0((F)) --- G0((G)) --- H0((H))
end
A2 -.->|跳到下層| A1
E2 -.->|跳到下層| E1
A1 -.->|跳到下層| A0
C1 -.->|跳到下層| C0
E1 -.->|跳到下層| E0
G1 -.->|跳到下層| G0
style A2 fill:#fef3c7,stroke:#92400e,stroke-width:2px
style E2 fill:#fef3c7,stroke:#92400e,stroke-width:2px
搜尋演算法 (greedy):
1. 從最頂層的某個入口節點 ep 開始
2. 在當前層做 greedy search:
- 看 ep 的所有 neighbor,找 cosine 最近 q 的那個 nearest
- 如果 nearest 比 ep 還近 → 移動到 nearest,重複
- 否則停下
3. 跳到下一層繼續 (起點 = 上一層找到的位置)
4. 到 layer 0 收集 ef (e.g. 100) 個候選
5. Rerank 候選回 top-k
複雜度近 O(log N)。100 萬個 1024-dim 向量大約 100 步內找到 top-10。
為什麼 small-world? HNSW 的圖建構過程模擬「small-world graph」 — 大部分連線是局部的(短距離 neighbor),少量是長距離(樞紐連線)。這跟現實社交網路(六度分隔)拓撲類似 — 任何兩點都可以快速連到。
參數調整:
M:每個節點 neighbor 數(預設 16)— 大 = 召回好 / 記憶體多efConstruction:建 index 時的搜尋深度(預設 200)— 大 = 索引品質好 / 建索引慢ef:query 時的搜尋深度(預設 = k)— 大 = 召回好 / query 慢實戰:建一次的索引品質決定永久召回上限,所以 efConstruction 寧可大也不要小。Query 時 ef 動態調。
| 演算法 | 複雜度 | 適用 |
|---|---|---|
| Brute force KNN | O(n × d) | 小資料量 < 10K |
| KD-tree | 低維 O(log n),高維退化 | 維度 < 20 |
| LSH | O(bucket size) | 召回要求不高、超大規模 |
| HNSW | O(log n) approximate | 中大規模 + 高品質要求 |
| IVF (Inverted File) | O(sqrt(n)) | 超大規模 + 願意調參 |
前面的演算法是「實作層」的。對 query 而言,還有「怎麼拼出正確順序」的演算法。
由 Goetz Graefe 1993 提出。核心:每個 operator 是 iterator,pull-based — 上層向下層 pull next row。
SELECT name FROM users WHERE age > 30 ORDER BY name
graph TD
PR["ProduceResult [name]"]
SO["Sort [by name]"]
FI["Filter [age > 30]"]
TS["TableScan [users]"]
PR -->|next()| SO
SO -->|next()| FI
FI -->|next()| TS
style TS fill:#fef3c7,stroke:#92400e,stroke-width:2px
執行時:ProduceResult.next() 呼叫 Sort.next(),Sort 必須先讀完所有 input 才能 sort,於是 Sort 一直 pull Filter.next(),Filter 一直 pull TableScan.next() 直到結束。
好處:每個 operator 獨立、容易組合、容易加新 operator。 壞處:每筆 row 一次 virtual function call → CPU 效率不夠(後來有 vectorized execution / code generation 改善,但 Volcano 仍是教學起點)。
PostgreSQL、MySQL、Neo4j、Spark 都用 Volcano-style executor。
同一條 query 可以有多種 plan。e.g.:
SELECT * FROM users u JOIN orders o ON u.id = o.user_id WHERE u.country = 'TW';
可能 plan:
CBO 用 statistics 估每個 plan 的成本:
estimated_rows(users WHERE country='TW')
= total_users × selectivity('country=TW')
= 1M × 0.05
= 50,000
estimated_cost(Nested Loop) = 50K × log(orders.size) × index_lookup_cost
estimated_cost(Hash Join) = orders.size + users.size × hash_cost
選最低 cost 的。
CBO 的關鍵:statistics 要準。PostgreSQL 用 ANALYZE 重算統計、Neo4j 用 db.stats.retrieve('GRAPH COUNTS')。統計過期 → optimizer 走錯路 → query 慢 100 倍。
動態規劃 (DP) 找最佳 join order:
讀完該回到「什麼問題用什麼工具」:
| 問題類型 | 演算法 | 實作工具 |
|---|---|---|
| 主鍵 lookup | Hash table | Redis / PG hash index |
| 範圍掃描 | B+ tree | PG / MySQL primary index |
| 子字串搜尋 | Trigram index | PG pg_trgm |
| 詞層級全文檢索 | Inverted index + BM25 | Elasticsearch / Lucene / OpenSearch |
| 多跳關係 | Index-free adjacency + BFS | Neo4j / TigerGraph |
| 兩點最短路徑 | Bidirectional BFS | Neo4j shortestPath |
| Pattern 偵測 | Subgraph isomorphism (with pinning) | Neo4j Cypher MATCH |
| 語意相似搜尋 | HNSW | Elasticsearch / FAISS / Milvus / pgvector + HNSW |
| 數值聚合 | Column store + SIMD | ClickHouse / DuckDB |
讀完 primer 再回去看那 3 篇 deep-dive,每個演算法的「為什麼」應該都通了。