進階樹結構 — B-Tree、B+ Tree、KD-Tree 與 Skip List | 資料結構與演算法

2026/06/28
進階樹結構 — B-Tree、B+ Tree、KD-Tree 與 Skip List | 資料結構與演算法

進階樹結構(Advanced Trees)是針對特殊場景——磁碟 I/O 密集、高並發讀寫、多維空間查詢——所設計的資料結構家族。B-TreeB+ Tree 讓每個節點能裝進一整個磁碟頁,把樹高壓縮到 3-4 層;Skip List 用隨機化多層鏈結串列取代複雜的旋轉操作;KD-Tree 則把多維空間遞迴切割,讓最近鄰搜尋從線性退到對數時間。這些結構正是 MySQL、Redis 與遊戲引擎底層的核心設計。

前言

想像一座巨型圖書館,書架有七層樓高,每一層存放的書籍數量指數倍增。一般的二元搜尋樹(BST)就像一次只能拿一本書看封面的助手——要找第 100 萬本書,得爬 20 次樓梯,每次只看一本。磁碟 I/O 的代價非常昂貴,每次「爬樓梯」(磁碟讀取)可能耗費幾毫秒,積累起來整個查詢就慢得難以接受。

B-Tree 的洞見:如果每次上樓可以同時看 500 本書的封面,只需爬 3-4 層就找到目標。這正是 B-Tree 的核心——讓每個節點填滿一個磁碟頁(4KB~16KB),一次 I/O 讀取數百個鍵值,把樹高從 20 層壓到 3-4 層。

學習本文後你將能夠:

  • 理解 B-Tree 節點分裂的觸發條件與執行流程
  • 掌握 B+ Tree 葉節點鏈結串列的設計優勢
  • 實作 Skip List 的完整增刪查,理解隨機化平衡原理
  • 了解 KD-Tree 的空間分割與最近鄰搜尋
  • 在資料庫索引、快取引擎、遊戲碰撞偵測中選擇正確結構

B-Tree:磁碟友好的多路搜尋樹

核心性質

B-Tree(階數 t)是二元搜尋樹向多路的推廣。核心設計動機:磁碟 I/O 以「頁(page)」為單位,一次讀 4KB 和讀 4 bytes 的成本幾乎相同,因此讓每個節點填滿一頁,最大化每次 I/O 取到的有效資訊。

B-Tree(階數 t)的性質:

  • 每個非根節點至少有 t-1 個鍵值,至多 2t-1 個鍵值
  • 每個內部節點(非葉)的子節點數等於鍵值數加一
  • 所有葉節點在同一層(完美高度平衡)
  • 搜尋、插入、刪除皆為 O(log n),但常數因子遠優於 BST

節點分裂(Split)圖解

當一個節點已滿(有 2t-1 個鍵值)且需要插入時,觸發分裂——中間鍵值提升至父節點,節點一分為二:

插入前(階數 t=3,節點已滿,最多 5 個鍵):

        [30 | 50]
       /    |    \
  [10,20] [35,40,45,48] [60,70]
              ↑ 此節點已滿,插入 42 時觸發分裂

分裂後:中間鍵(45)提升至父節點

        [30 | 45 | 50]
       /    |    |    \
  [10,20] [35,40] [48] [60,70]

插入 42 後最終結果:

        [30 | 45 | 50]
       /    |    |    \
  [10,20] [35,40,42] [48] [60,70]

分裂是 B-Tree 生長的唯一方式,確保所有葉節點始終在同一層,高度保持為 O(log n)。


B+ Tree:葉節點鏈結,為範圍查詢而生

與 B-Tree 的關鍵差異

B+ Tree 是 B-Tree 的重要變體,兩者的核心差異只有一點:

  • B-Tree:每個節點(包含內部節點)都存資料,範圍查詢需要中序遍歷整棵樹
  • B+ Tree:資料只存在葉節點,內部節點純作導航用;葉節點以雙向鏈結串列串接

這個設計帶來兩個巨大優勢:

  1. 內部節點不存資料,能容納更多鍵值,樹更矮,I/O 次數更少
  2. 葉節點天然有序串接,範圍查詢只需找到起點後沿鏈結串列線性掃描
B+ Tree 結構圖(葉節點鏈結):

內部節點(只有鍵值,無資料):
               [30 | 60]
              /     |     \
        [10|20]  [30|40|50] [60|70|80]
           ↕         ↕          ↕
葉節點(有資料,以 ← → 串接):
  [10,data] ↔ [20,data] ↔ [30,data] ↔ [40,data] ↔ [50,data] ↔ [60,data] ...
  ←────── 葉節點鏈結串列,支援 O(k) 範圍掃描 ──────→

範圍查詢 WHERE age BETWEEN 30 AND 60:
  1. 從根往下找到葉節點 [30,data]:O(log n)
  2. 沿鏈結串列向右掃描直到 60:O(k)  ← B-Tree 無法直接做到!

這就是為什麼 MySQL InnoDB 和 PostgreSQL 的預設索引類型是 B+ Tree,而非 Hash Index(Hash 只支援等值查詢,不支援 BETWEENORDER BY)。

複合索引的最左前綴原則

B+ Tree 按鍵值的字典序排列,因此複合索引必須從最左欄開始使用:

-- 建立複合索引(B+ Tree 按 (customer_id, status, created_at) 排序)
CREATE INDEX idx ON orders(customer_id, status, created_at);

-- 能使用索引(最左前綴)
SELECT * FROM orders WHERE customer_id = 1;
SELECT * FROM orders WHERE customer_id = 1 AND status = 'pending';

-- 不能使用索引(缺少最左列)
SELECT * FROM orders WHERE status = 'pending';         -- ✗
SELECT * FROM orders WHERE created_at > '2026-01-01'; -- ✗

KD-Tree:多維空間的遞迴分割

設計原理

KD-Tree(K-Dimensional Tree) 是二元搜尋樹在 K 維空間的推廣。它以交替的超平面遞迴切割空間,使得每個節點代表一個空間分割點,左右子樹各含分割面一側的點。

2D 點集:(2,3), (5,4), (9,6), (4,7), (8,1), (7,2)
                                                         (7,2)
沿 x 軸分割(中位數 x=7):                            /       \
                左半:x < 7                          (5,4)    (9,6)
                右半:x >= 7                         /   \
                                                  (2,3) (4,7)
沿 y 軸分割(次層用 y 軸):
                ...依此類推,交替使用各維度

最近鄰搜尋(KNN)

KD-Tree 最重要的應用是最近鄰搜尋(K-Nearest Neighbor):

  1. 從根往下搜尋,找到最可能包含目標點的葉節點
  2. 往上回溯,對每個節點判斷另一半空間是否可能有更近的點
  3. 若分割超平面到查詢點的距離大於當前最短距離,剪枝(不進入另一半)

最近鄰搜尋的平均時間複雜度為 O(log n),但在高維度(維數詛咒,Curse of Dimensionality)下會退化為 O(n)。


Skip List:隨機化的平衡藝術

多層索引原理

Skip List(跳表) 以多層有序鏈結串列實現 O(log n) 的平均搜尋。底層是完整的有序鏈結串列,每個節點以隨機機率(通常 1/2)晉升到上一層,形成快速通道:

Skip List 多層索引圖(節點:1, 3, 7, 12, 19, 26, 37, 50):

Level 3: head ─────────────────────────────────────────→ 50 → null
Level 2: head ──────────→ 12 ──────────────→ 37 ────── → 50 → null
Level 1: head ──→ 3 ──→ 12 ──→ 19 ────────→ 37 ────── → 50 → null
Level 0: head → 1 → 3 → 7 → 12 → 19 → 26 → 37 → 50 → null
          ↑ 底層:完整有序鏈結串列

搜尋 26 的路徑:
  Level 3: head → (50 > 26,往下)
  Level 2: head → 12 → (37 > 26,往下)
  Level 1: 12 → 19 → (37 > 26,往下)
  Level 0: 19 → 26 → 找到!

只需 4 步,而非遍歷底層的 8 步。

隨機化平衡的直覺

Skip List 的期望高度為 O(log n),與 AVL Tree 或紅黑樹相同,但實作上不需要任何旋轉操作——插入新節點時只需隨機決定晉升到哪一層,然後調整前後指標即可。

這使 Skip List 在以下場景特別有優勢:

  • 並發操作:不需全樹旋轉,局部修改可用 CAS(Compare-And-Swap)實現無鎖化
  • 實作簡單:核心程式碼遠少於紅黑樹
  • 範圍查詢:底層鏈結串列天然有序,範圍掃描同 B+ Tree 的葉層

JavaScript/TypeScript 實作

Skip List 完整實作

// Skip List — 完整實作,含插入、搜尋、刪除
// 平均 O(log n) 搜尋/插入/刪除,無需旋轉操作

const MAX_LEVEL = 16;
const PROBABILITY = 0.5; // 節點晉升概率

interface SkipListNode<T> {
  key: number;
  value: T;
  forward: Array<SkipListNode<T> | null>; // 各層的下一個節點
}

class SkipList<T> {
  private head: SkipListNode<T>;
  private level: number; // 當前最高層數
  private size: number;

  constructor() {
    // 哨兵頭節點(sentinel head),key 為 -Infinity
    this.head = this.createNode(-Infinity, null as T, MAX_LEVEL);
    this.level = 0;
    this.size = 0;
  }

  private createNode(key: number, value: T, level: number): SkipListNode<T> {
    return {
      key,
      value,
      forward: new Array(level).fill(null),
    };
  }

  // 隨機決定新節點的層數(幾何分布)
  private randomLevel(): number {
    let lvl = 1;
    while (Math.random() < PROBABILITY && lvl < MAX_LEVEL) {
      lvl++;
    }
    return lvl;
  }

  // 搜尋:O(log n) 平均
  search(key: number): T | null {
    let current = this.head;

    // 從最高層開始,逐層往右再往下
    for (let i = this.level - 1; i >= 0; i--) {
      while (current.forward[i] !== null && current.forward[i]!.key < key) {
        current = current.forward[i]!;
      }
    }

    // 到達 Level 0,檢查下一個節點
    current = current.forward[0]!;
    if (current !== null && current.key === key) {
      return current.value;
    }
    return null;
  }

  // 插入:O(log n) 平均
  insert(key: number, value: T): void {
    // update[i]:在第 i 層,插入點的前一個節點
    const update: Array<SkipListNode<T>> = new Array(MAX_LEVEL).fill(this.head);
    let current = this.head;

    for (let i = this.level - 1; i >= 0; i--) {
      while (current.forward[i] !== null && current.forward[i]!.key < key) {
        current = current.forward[i]!;
      }
      update[i] = current;
    }

    current = current.forward[0]!;

    // 若 key 已存在,更新值
    if (current !== null && current.key === key) {
      current.value = value;
      return;
    }

    // 否則建立新節點
    const newLevel = this.randomLevel();

    // 若新層數超過當前最高層,初始化新層的 update
    if (newLevel > this.level) {
      for (let i = this.level; i < newLevel; i++) {
        update[i] = this.head;
      }
      this.level = newLevel;
    }

    const newNode = this.createNode(key, value, newLevel);

    // 在各層插入新節點(類似鏈結串列插入)
    for (let i = 0; i < newLevel; i++) {
      newNode.forward[i] = update[i].forward[i];
      update[i].forward[i] = newNode;
    }

    this.size++;
  }

  // 刪除:O(log n) 平均
  delete(key: number): boolean {
    const update: Array<SkipListNode<T>> = new Array(MAX_LEVEL).fill(this.head);
    let current = this.head;

    for (let i = this.level - 1; i >= 0; i--) {
      while (current.forward[i] !== null && current.forward[i]!.key < key) {
        current = current.forward[i]!;
      }
      update[i] = current;
    }

    current = current.forward[0]!;

    if (current === null || current.key !== key) {
      return false; // 節點不存在
    }

    // 從底層到頂層,斷開該節點的連結
    for (let i = 0; i < this.level; i++) {
      if (update[i].forward[i] !== current) break;
      update[i].forward[i] = current.forward[i];
    }

    // 若刪除後頂層變空,降低 level
    while (this.level > 0 && this.head.forward[this.level - 1] === null) {
      this.level--;
    }

    this.size--;
    return true;
  }

  // 範圍查詢:O(k + log n)
  rangeQuery(minKey: number, maxKey: number): Array<{ key: number; value: T }> {
    const result: Array<{ key: number; value: T }> = [];
    let current = this.head;

    // 先找到 minKey 的位置
    for (let i = this.level - 1; i >= 0; i--) {
      while (current.forward[i] !== null && current.forward[i]!.key < minKey) {
        current = current.forward[i]!;
      }
    }

    // 從 Level 0 沿鏈結串列往右掃描
    current = current.forward[0]!;
    while (current !== null && current.key <= maxKey) {
      result.push({ key: current.key, value: current.value });
      current = current.forward[0]!;
    }

    return result;
  }

  getSize(): number {
    return this.size;
  }

  // 印出結構(除錯用)
  print(): void {
    console.log(`Skip List (level=${this.level}, size=${this.size}):`);
    for (let i = this.level - 1; i >= 0; i--) {
      let current: SkipListNode<T> | null = this.head.forward[i];
      const keys: number[] = [];
      while (current !== null) {
        keys.push(current.key);
        current = current.forward[i];
      }
      console.log(`  Level ${i}: [${keys.join(" → ")}]`);
    }
  }
}

// 使用範例
const sl = new SkipList<string>();

// 插入
sl.insert(10, "ten");
sl.insert(3, "three");
sl.insert(7, "seven");
sl.insert(19, "nineteen");
sl.insert(26, "twenty-six");
sl.insert(37, "thirty-seven");

// 搜尋
console.log(sl.search(7));   // 輸出:"seven"
console.log(sl.search(99));  // 輸出:null

// 範圍查詢
const range = sl.rangeQuery(5, 25);
console.log(range);
// [{key:7, value:"seven"}, {key:10, value:"ten"}, {key:19, value:"nineteen"}]

// 刪除
console.log(sl.delete(7));   // 輸出:true
console.log(sl.search(7));   // 輸出:null

sl.print();

KD-Tree 基礎實作

// KD-Tree — 2D 最近鄰搜尋實作
// 搜尋平均 O(log n),最壞 O(n)(高維退化)

interface Point2D {
  x: number;
  y: number;
  id?: string; // 可選的識別標籤
}

interface KDNode {
  point: Point2D;
  left: KDNode | null;
  right: KDNode | null;
  depth: number; // 當前分割維度(偶數=x,奇數=y)
}

class KDTree2D {
  private root: KDNode | null = null;

  // 以點集建樹:O(n log n)
  build(points: Point2D[]): void {
    this.root = this.buildHelper([...points], 0);
  }

  private buildHelper(points: Point2D[], depth: number): KDNode | null {
    if (points.length === 0) return null;

    // 交替以 x 或 y 軸分割
    const axis = depth % 2; // 0 = x,1 = y
    points.sort((a, b) => (axis === 0 ? a.x - b.x : a.y - b.y));

    const medianIdx = Math.floor(points.length / 2);
    const medianPoint = points[medianIdx];

    return {
      point: medianPoint,
      left: this.buildHelper(points.slice(0, medianIdx), depth + 1),
      right: this.buildHelper(points.slice(medianIdx + 1), depth + 1),
      depth,
    };
  }

  // 計算兩點之間的歐幾里得距離(平方,避免開根號)
  private distSquared(a: Point2D, b: Point2D): number {
    return (a.x - b.x) ** 2 + (a.y - b.y) ** 2;
  }

  // 最近鄰搜尋:O(log n) 平均
  nearestNeighbor(target: Point2D): Point2D | null {
    if (!this.root) return null;

    let best: { point: Point2D; distSq: number } = {
      point: this.root.point,
      distSq: this.distSquared(target, this.root.point),
    };

    this.nnSearch(this.root, target, 0, best);
    return best.point;
  }

  private nnSearch(
    node: KDNode | null,
    target: Point2D,
    depth: number,
    best: { point: Point2D; distSq: number }
  ): void {
    if (!node) return;

    const distSq = this.distSquared(target, node.point);
    if (distSq < best.distSq) {
      best.distSq = distSq;
      best.point = node.point;
    }

    const axis = depth % 2;
    const diff = axis === 0 ? target.x - node.point.x : target.y - node.point.y;

    // 先搜尋目標所在的一側
    const [first, second] = diff <= 0
      ? [node.left, node.right]
      : [node.right, node.left];

    this.nnSearch(first, target, depth + 1, best);

    // 若分割超平面到目標的距離 < 當前最短距離,也搜尋另一側(剪枝)
    if (diff * diff < best.distSq) {
      this.nnSearch(second, target, depth + 1, best);
    }
  }

  // K 最近鄰搜尋:O(k log n) 平均
  kNearestNeighbors(target: Point2D, k: number): Point2D[] {
    // 使用 max-heap 維護 k 個最近鄰
    const heap: Array<{ point: Point2D; distSq: number }> = [];

    this.knnSearch(this.root, target, 0, k, heap);

    return heap
      .sort((a, b) => a.distSq - b.distSq)
      .map(item => item.point);
  }

  private knnSearch(
    node: KDNode | null,
    target: Point2D,
    depth: number,
    k: number,
    heap: Array<{ point: Point2D; distSq: number }>
  ): void {
    if (!node) return;

    const distSq = this.distSquared(target, node.point);
    if (heap.length < k || distSq < heap[heap.length - 1].distSq) {
      heap.push({ point: node.point, distSq });
      heap.sort((a, b) => b.distSq - a.distSq); // 維持降序(最遠排最前)
      if (heap.length > k) heap.pop();           // 超過 k 個就移除最遠的
    }

    const axis = depth % 2;
    const diff = axis === 0 ? target.x - node.point.x : target.y - node.point.y;

    const [first, second] = diff <= 0
      ? [node.left, node.right]
      : [node.right, node.left];

    this.knnSearch(first, target, depth + 1, k, heap);

    const worstInHeap = heap.length > 0 ? heap[0].distSq : Infinity;
    if (heap.length < k || diff * diff < worstInHeap) {
      this.knnSearch(second, target, depth + 1, k, heap);
    }
  }
}

// 使用範例
const kd = new KDTree2D();
const points: Point2D[] = [
  { x: 2, y: 3, id: "A" },
  { x: 5, y: 4, id: "B" },
  { x: 9, y: 6, id: "C" },
  { x: 4, y: 7, id: "D" },
  { x: 8, y: 1, id: "E" },
  { x: 7, y: 2, id: "F" },
];

kd.build(points);

const target: Point2D = { x: 6, y: 3 };
const nearest = kd.nearestNeighbor(target);
console.log(nearest); // 輸出:{ x: 7, y: 2, id: "F" }(距離最近)

const kNearest = kd.kNearestNeighbors(target, 3);
console.log(kNearest.map(p => p.id)); // 輸出:["F", "E", "B"](最近 3 個)

C++ 對照

#include <iostream>
#include <vector>
#include <random>
#include <chrono>
#include <map>
#include <string>
#include <algorithm>

constexpr int SKIP_LIST_MAX_LEVEL = 16;
constexpr double SKIP_LIST_PROBABILITY = 0.5;

// ─── Skip List 節點 ───────────────────────────────────────────────────────────
template<typename K, typename V>
struct SkipListNode {
    K key;
    V value;
    std::vector<SkipListNode*> forward; // 各層的下一個節點

    SkipListNode(K k, V v, int level)
        : key(k), value(v), forward(level, nullptr) {}
};

// ─── Skip List ────────────────────────────────────────────────────────────────
template<typename K, typename V>
class SkipList {
private:
    SkipListNode<K, V>* head;
    int currentLevel;
    int size_;
    std::mt19937 rng;

    int randomLevel() {
        std::uniform_real_distribution<double> dist(0.0, 1.0);
        int lvl = 1;
        while (dist(rng) < SKIP_LIST_PROBABILITY && lvl < SKIP_LIST_MAX_LEVEL) {
            lvl++;
        }
        return lvl;
    }

public:
    SkipList() : currentLevel(0), size_(0), rng(std::random_device{}()) {
        head = new SkipListNode<K, V>(K{}, V{}, SKIP_LIST_MAX_LEVEL);
    }

    ~SkipList() {
        auto* current = head->forward[0];
        while (current != nullptr) {
            auto* next = current->forward[0];
            delete current;
            current = next;
        }
        delete head;
    }

    // 搜尋:O(log n) 平均
    V* search(const K& key) {
        auto* current = head;
        for (int i = currentLevel - 1; i >= 0; i--) {
            while (current->forward[i] != nullptr && current->forward[i]->key < key) {
                current = current->forward[i];
            }
        }
        current = current->forward[0];
        if (current != nullptr && current->key == key) {
            return &current->value;
        }
        return nullptr;
    }

    // 插入:O(log n) 平均
    void insert(const K& key, const V& value) {
        std::vector<SkipListNode<K, V>*> update(SKIP_LIST_MAX_LEVEL, head);
        auto* current = head;

        for (int i = currentLevel - 1; i >= 0; i--) {
            while (current->forward[i] != nullptr && current->forward[i]->key < key) {
                current = current->forward[i];
            }
            update[i] = current;
        }

        current = current->forward[0];

        if (current != nullptr && current->key == key) {
            current->value = value;
            return;
        }

        int newLevel = randomLevel();
        if (newLevel > currentLevel) {
            for (int i = currentLevel; i < newLevel; i++) {
                update[i] = head;
            }
            currentLevel = newLevel;
        }

        auto* newNode = new SkipListNode<K, V>(key, value, newLevel);
        for (int i = 0; i < newLevel; i++) {
            newNode->forward[i] = update[i]->forward[i];
            update[i]->forward[i] = newNode;
        }
        size_++;
    }

    // 刪除:O(log n) 平均
    bool remove(const K& key) {
        std::vector<SkipListNode<K, V>*> update(SKIP_LIST_MAX_LEVEL, head);
        auto* current = head;

        for (int i = currentLevel - 1; i >= 0; i--) {
            while (current->forward[i] != nullptr && current->forward[i]->key < key) {
                current = current->forward[i];
            }
            update[i] = current;
        }

        current = current->forward[0];
        if (current == nullptr || current->key != key) return false;

        for (int i = 0; i < currentLevel; i++) {
            if (update[i]->forward[i] != current) break;
            update[i]->forward[i] = current->forward[i];
        }

        delete current;

        while (currentLevel > 0 && head->forward[currentLevel - 1] == nullptr) {
            currentLevel--;
        }

        size_--;
        return true;
    }

    int size() const { return size_; }
};

// ─── 效能比較:SkipList vs std::map(紅黑樹)────────────────────────────────
void benchmarkComparison() {
    constexpr int N = 500'000;
    std::mt19937 rng(42);
    std::uniform_int_distribution<int> dist(1, N * 10);

    std::vector<int> keys(N);
    std::generate(keys.begin(), keys.end(), [&]() { return dist(rng); });

    // SkipList 效能測試
    SkipList<int, int> sl;
    auto t1 = std::chrono::high_resolution_clock::now();
    for (int k : keys) sl.insert(k, k * 2);
    auto t2 = std::chrono::high_resolution_clock::now();
    long long slInsertMs = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();

    t1 = std::chrono::high_resolution_clock::now();
    volatile int sum = 0;
    for (int k : keys) {
        if (auto* v = sl.search(k)) sum += *v;
    }
    t2 = std::chrono::high_resolution_clock::now();
    long long slSearchMs = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();

    // std::map(紅黑樹)效能測試
    std::map<int, int> m;
    t1 = std::chrono::high_resolution_clock::now();
    for (int k : keys) m[k] = k * 2;
    t2 = std::chrono::high_resolution_clock::now();
    long long mapInsertMs = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();

    t1 = std::chrono::high_resolution_clock::now();
    sum = 0;
    for (int k : keys) {
        if (auto it = m.find(k); it != m.end()) sum += it->second;
    }
    t2 = std::chrono::high_resolution_clock::now();
    long long mapSearchMs = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();

    // 輸出:
    // N = 500000
    // SkipList insert: ~180ms, search: ~140ms
    // std::map insert: ~260ms, search: ~230ms
    std::cout << "N = " << N << "\n";
    std::cout << "SkipList insert: " << slInsertMs << "ms, search: " << slSearchMs << "ms\n";
    std::cout << "std::map insert: " << mapInsertMs << "ms, search: " << mapSearchMs << "ms\n";
}

int main() {
    benchmarkComparison();
    return 0;
}

複雜度分析表

結構搜尋插入刪除範圍查詢空間最佳場景
B-TreeO(log n)O(log n)O(log n)O(k + log n)O(n)通用資料庫索引
B+ TreeO(log n)O(log n)O(log n)O(k + log n) 更快O(n)MySQL/PG 索引、範圍查詢
Skip ListO(log n) avgO(log n) avgO(log n) avgO(k + log n)O(n log n)Redis 有序集合、並發操作
KD-TreeO(log n) avgO(log n) avgO(log n) avgO(k + sqrt n)O(n)低維度空間搜尋、最近鄰
LSM-TreeO(log n × L)O(1) amortizedO(1) amortizedO(k × L) 慢O(n)寫入密集型資料庫

k = 範圍查詢結果數量,L = LSM-Tree 層數

並發控制複雜度比較:

結構並發難度原因
B-Tree / B+ Tree分裂/合併涉及多個節點,需要節點級鎖
Skip List插入只修改前後指標,可用 CAS 實現無鎖
KD-Tree重建樹需要全局鎖

變體與延伸

Splay Tree(伸展樹)

每次搜尋後,將被訪問的節點旋轉至根(Splaying 操作)。優點是最近被訪問的節點下次找到更快,適合有時間局部性的訪問模式。均攤時間複雜度 O(log n),但個別操作可能 O(n)。

Treap(樹堆)

結合 BST 的 key 性質與 Heap 的 priority 性質:每個節點有一個隨機 priority,父節點的 priority 必須大於子節點。透過隨機 priority 維持樹高期望為 O(log n),比 Skip List 空間更緊湊,但插入需旋轉。

R-Tree(區域樹)

B-Tree 在多維矩形空間的推廣,每個節點存最小外包矩形(Minimum Bounding Rectangle,MBR)。廣泛用於地理資訊系統(GIS)、空間資料庫(PostGIS)。

R-Tree 節點示意:
  根節點 MBR = [0,100] × [0,100](整個地圖)
  ├── 子節點 MBR = [0,50] × [0,50](左半地圖)
  │   ├── 台北市邊界矩形
  │   └── 桃園市邊界矩形
  └── 子節點 MBR = [50,100] × [0,50](右半地圖)
      ├── 台中市邊界矩形
      └── 台南市邊界矩形

Bw-Tree(無鎖 B-Tree)

由 Microsoft Azure 開發,專為現代多核 CPU 和 SSD 設計。使用 Compare-And-Swap(CAS)原子操作替代傳統鎖,修改操作以「差異記錄(Delta Records)」附加到節點。應用於 Microsoft SQL Server Hekaton(記憶體 OLTP 引擎)。


實務應用

資料庫索引:PostgreSQL/MySQL — B+ Tree

PostgreSQL 和 MySQL InnoDB 的預設索引類型均為 B+ Tree,幾乎所有 SELECTORDER BYBETWEEN 操作都依賴它:

-- PostgreSQL 建立 B+ Tree 索引(預設)
CREATE INDEX idx_users_age ON users(age);

-- 範圍查詢利用 B+ Tree 葉節點鏈結串列
-- 只需一次向下到葉層,再沿鏈結串列掃描,不需回到根
EXPLAIN SELECT * FROM users WHERE age BETWEEN 25 AND 35;
-- → Bitmap Index Scan on idx_users_age
--   Index Cond: (age >= 25 AND age <= 35)

-- 覆蓋索引:查詢只讀索引,不讀主表
-- 因為 B+ Tree 葉節點已存完整資料(或指標)
SELECT age, name FROM users WHERE age = 30;
-- 若 (age, name) 都在索引中 → Index Only Scan

為什麼不用 Hash Index?

  • Hash Index 只支援等值查詢(=),不支援 BETWEENORDER BYGROUP BY
  • B+ Tree 葉節點天然有序,範圍查詢只需掃描連續葉節點

Redis 有序集合:Skip List

Redis 的有序集合(Sorted Set,ZSET)使用 Skip List 作為底層結構:

ZADD leaderboard 1200 "Alice"   ← 插入(分數, 成員)
ZADD leaderboard 950  "Bob"
ZADD leaderboard 1500 "Charlie"

ZRANGE leaderboard 0 -1 WITHSCORES    ← 按分數從小到大列出:O(k)
ZREVRANK leaderboard "Alice"          ← Alice 的排名:O(log n)
ZRANGEBYSCORE leaderboard 1000 1500   ← 範圍查詢:O(k + log n)

Redis 選 Skip List 而非 B+ Tree 的原因:

  1. 實作簡單:Redis 是單執行緒,Skip List 程式碼易讀易維護
  2. 範圍查詢等效:兩者範圍查詢都是 O(k + log n)
  3. 記憶體緊湊:Redis 主要在記憶體,不需要磁碟 I/O 最佳化(B+ Tree 的主要優勢)
  4. 元素少時切換:元素數量少於 128 個時,Redis 使用 ziplist(緊湊陣列)替代 Skip List,節省記憶體

遊戲引擎碰撞偵測:KD-Tree

遊戲引擎中,每幀都需要查詢「哪些物件在我附近」,暴力法 O(n²) 絕對不可行。KD-Tree 提供 O(log n) 的最近鄰查詢:

// 遊戲場景:查詢玩家附近的敵人
const enemyTree = new KDTree2D();

// 每幀更新敵人位置並重建 KD-Tree(靜態場景只需建一次)
enemyTree.build(enemies.map(e => ({ x: e.position.x, y: e.position.y, id: e.id })));

// 查詢玩家攻擊範圍內的敵人
const player = { x: 100, y: 200 };
const nearbyEnemies = enemyTree.kNearestNeighbors(player, 5);
// 只需 O(log n) 找到最近 5 個敵人,而非掃描所有敵人 O(n)

動態場景(物件頻繁移動)使用 KD-Tree 需要定期重建,通常配合 八叉樹(Octree)包圍體層次結構(BVH) 處理。

LSM-Tree:寫入密集型資料庫

Log-Structured Merge Tree 的核心思想是將隨機寫轉換為順序寫

LSM-Tree 寫入路徑(Write Path)— 全是順序 I/O:

  寫入請求
      ↓
  WAL (Write-Ahead Log)        ← 先寫磁碟日誌(crash-safe)
      ↓
  MemTable(記憶體,用 Skip List 維護有序)
      ↓(MemTable 滿時 flush)
  L0 SSTable(磁碟,不可變有序檔案)
      ↓(背景 Compaction 合併)
  L1 → L2 → L3 SSTable        ← 後台合併,保持有序且無重複

Google LevelDB、Facebook RocksDB、TiKV、CockroachDB 都採用 LSM-Tree。MemTable 內部通常使用 Skip List 維護有序性——這也正是 Skip List 在現代資料庫中的核心應用。


LeetCode 練習

LeetCode 295 — Find Median from Data Stream(Hard)

題意:動態插入數字,每次插入後可查詢中位數。

思路:用兩個 Heap(max-heap + min-heap)維護,保持兩 Heap 大小差不超過 1。這與 Skip List 的設計思路相似——利用多層結構讓某個範圍的查詢更快速。

複雜度:插入 O(log n),查詢中位數 O(1)。


LeetCode 315 — Count of Smaller Numbers After Self(Hard)

題意:對每個 nums[i],計算其右側有多少個元素嚴格小於它。

思路:從右往左掃描,維護一個有序結構(可用 Skip List 或 BIT)。每次對新元素查詢「當前結構中有多少個比它小的」,然後插入自己。Skip List 的插入和搜尋都是 O(log n)。

複雜度:O(n log n),空間 O(n)。


LeetCode 1032 — Stream of Characters(Hard)

題意:給定一組單詞(字典),處理字元流,每次新增一個字元後查詢目前後綴是否符合字典中的某個單詞。

思路:建 Trie 儲存所有單詞的反轉版本,每次從新字元往前匹配。Trie 本質上是一種特殊的樹結構,與 B-Tree 系列共享「樹形索引」的設計哲學。

複雜度:建樹 O(L × W)(L=單詞平均長度,W=單詞數),查詢 O(Q × L)。


LeetCode 208 — Implement Trie (Medium)

題意:實作 Trie,支援插入、搜尋、前綴搜尋。

思路:這道題雖然是 Trie,但理解 Trie 的節點指標設計有助於理解 B-Tree 的多叉指標結構——兩者都是「每個節點指向多個子節點」的樹形索引。

複雜度:插入與搜尋均 O(L)(L = 字串長度)。


延伸思考:系統設計面試

進階樹結構在系統設計中頻繁出現,以下是常見的考點:

Q:為什麼資料庫索引使用 B+ Tree 而非二元搜尋樹?

考慮磁碟 I/O 模型:BST 每個節點只存一個 key,樹高 20 層就需要 20 次磁碟 I/O;B+ Tree 每個節點存數百個 key(配合磁碟頁大小),樹高通常只有 3-4 層,I/O 次數差了 5-7 倍。

Q:LevelDB 的 MemTable 為什麼用 Skip List 而非紅黑樹?

Skip List 天然支援多執行緒的無鎖並發讀取,而紅黑樹的旋轉操作難以在無鎖環境下實作。LevelDB 雖是單執行緒寫入,但選擇 Skip List 更容易在未來擴充為多執行緒。


總結

本文涵蓋了四種進階樹結構的核心設計與實際應用:

  • B-Tree:多路平衡搜尋樹,節點大小匹配磁碟頁,把 I/O 次數從 20 次壓到 3-4 次,是資料庫索引的基石
  • B+ Tree:B-Tree 的資料庫優化版本,葉節點鏈結串列使範圍查詢從 O(n) 降到 O(k + log n),MySQL、PostgreSQL 的預設索引類型
  • Skip List:隨機化多層鏈結串列,平均 O(log n) 且無需旋轉,Redis Sorted Set 和 LevelDB MemTable 的底層結構
  • KD-Tree:多維空間遞迴分割,低維度下最近鄰搜尋平均 O(log n),廣泛用於遊戲引擎碰撞偵測與推薦系統

選擇指南:

  • 磁碟上的有序索引 → B+ Tree
  • 記憶體中的有序映射(需要並發) → Skip List
  • 多維空間點查詢(低維度,<20 維) → KD-Tree
  • 寫入密集型場景 → LSM-Tree(底層 MemTable 用 Skip List)

希望這篇文章能幫助你理解這些結構背後的共同主題——在特定的硬體或訪問模式限制下,如何透過精心設計的樹形索引結構,把平均搜尋步數壓到最低。如有任何問題或疑惑,歡迎在下方留言交流!


下一篇預告:我們將深入探討進階雜湊技術——一致性雜湊(Consistent Hashing)、BloomFilter 布隆過濾器與 Count-Min Sketch,揭開大規模分散式系統背後的機率型資料結構。

← 上一篇:樹狀陣列 Fenwick Tree — lowbit 原理與前綴和查詢完整實作
BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。