土法炼钢兴趣小组的算法知识备份

R-tree 与空间索引:PostGIS 的底层结构

目录

打开手机地图,搜索”附近的咖啡馆”,应用在毫秒内就从数百万个 POI(Point of Interest)中筛选出方圆一公里内的结果。这背后不是暴力遍历——而是空间索引在起作用。空间索引的核心思想是:把多维空间中的对象组织成层级结构,让搜索只访问与查询窗口重叠的分支

R-tree 是空间索引家族中最广泛使用的成员。从 PostGIS 到 SQLite 的 SpatiaLite,从游戏引擎的碰撞检测到 Elasticsearch 的地理查询,它无处不在。本文将从 Guttman 1984 年的原始论文出发,深入 R-tree 的结构、搜索、插入与分裂,延伸到 R*-tree 和 Hilbert R-tree 的改进,再到 PostGIS 的 GiST 索引框架,最后涵盖 Quadtree、Geohash、S2、H3 等现代空间索引方案。

R-tree MBR 层级结构

一、从 B-tree 到 R-tree:为什么一维索引不够

B-tree 是数据库索引的基石。它把一维的键值组织成平衡搜索树,查询的时间复杂度为 O(log n)。但空间数据有根本性的不同:

  1. 多维性:一个地理坐标有经度和纬度两个维度,一个矩形有四个坐标值。B-tree 只能对一个维度排序。
  2. 没有全序关系:一维数据可以排序——3 < 5 < 7。但二维点 (3, 7) 和 (5, 2) 谁在前?不存在自然的全序。
  3. 范围查询的几何性:空间查询通常是”找出与这个矩形/圆形重叠的所有对象”,而非简单的区间查询。

R-tree 的核心洞察是:用最小边界矩形(MBR, Minimum Bounding Rectangle)来近似空间对象,然后像 B-tree 一样组织这些矩形

关键类比

特性 B-tree R-tree
索引对象 一维键值 多维空间对象
节点内容 键值 + 子指针 MBR + 子指针
搜索依据 键值比较 MBR 重叠/包含测试
分裂依据 中位数或前缀 空间分布(面积、周长等)
路径唯一性 搜索路径唯一 可能需要探索多条路径

最后一点至关重要——R-tree 中 MBR 可以重叠,这意味着搜索时可能需要同时进入多个子树。这是 R-tree 性能优化的核心挑战。

二、R-tree 的基本结构

Guttman 在 1984 年发表的论文”R-trees: A Dynamic Index Structure for Spatial Searching”定义了 R-tree 的基本结构。

2.1 结构定义

R-tree 是一棵平衡树,具有以下性质:

M 通常由磁盘页大小决定。假设一个 MBR 占 32 字节(4 个 double),一个指针占 8 字节,一个 4KB 的页可以存放约 100 个条目。

2.2 MBR 的核心操作

MBR 上需要定义几个基本操作(完整实现见第九节):

2.3 节点结构

#define MAX_ENTRIES 50   /* M:每个节点最多条目数 */
#define MIN_ENTRIES 20   /* m:每个节点最少条目数 */

typedef struct rtree_node {
    int count;                       /* 当前条目数 */
    int is_leaf;                     /* 是否为叶节点 */
    Rect rects[MAX_ENTRIES];         /* MBR 数组 */
    struct rtree_node *children[MAX_ENTRIES]; /* 子节点指针(叶节点为 NULL) */
    int data_ids[MAX_ENTRIES];       /* 数据 ID(仅叶节点使用) */
} RTreeNode;

三、搜索、插入与分裂

3.1 搜索(Search)

R-tree 的搜索过程从根节点开始,递归检查每个节点的 MBR 是否与查询窗口重叠。伪代码如下:

Search(node, query):
  for each entry E in node:
    if E.MBR overlaps query:
      if node is leaf:
        report E.data_id
      else:
        Search(E.child, query)

关键特点:

  1. 不保证单路径。与 B-tree 不同,查询矩形可能与同一层的多个 MBR 重叠,搜索必须进入多个子树。
  2. 最坏情况是 O(n)。如果所有 MBR 都与查询窗口重叠,则退化为全表扫描。
  3. 平均情况远好于最坏情况。好的 R-tree 实现通过减少 MBR 重叠来保证大部分查询只访问少量节点。

3.2 插入(Insert)

插入新对象时,从根节点出发选择”面积增长最小”的子树递归向下,直到叶节点。如果叶节点溢出(条目数超过 M),需要分裂并向上传播。

ChooseLeaf(node, new_rect):
  if node is leaf: return node
  选择 enlargement(E.MBR, new_rect) 最小的子条目 E
  (平局时选面积最小的)
  return ChooseLeaf(E.child, new_rect)

3.3 分裂策略(Node Splitting)

分裂是 R-tree 性能的关键。Guttman 提出了三种策略:

(一)穷举法(Exhaustive)

尝试所有可能的 (M+1) 个条目分成两组的方案,选择两组 MBR 总面积最小的。复杂度为 O(2^M),实际不可用。

(二)二次分裂(Quadratic Split)

QuadraticSplit:
  1. PickSeeds:找出放在一起"浪费面积"最大的两个条目作为种子
     waste = area(MBR(e_i, e_j)) - area(e_i) - area(e_j)
  2. 剩余条目依次分配:
     对于每个未分配的条目,计算它加入两组后各自的面积增量
     选择差值最大的条目,将其加入增量较小的组
  3. 如果某组条目数不足 m,将剩余全部分配给它

二次分裂的时间复杂度为 O(M^2),在大多数场景下是合理的折中。

(三)线性分裂(Linear Split)

PickSeeds 改为在每个维度上找最远的一对,总体复杂度降到 O(M)。但分裂质量较差。

3.4 删除

删除操作需要先搜索到目标条目,然后:

  1. 从叶节点移除该条目。
  2. 如果节点条目数低于 m,删除该节点并将剩余条目重新插入树中(condensation)。
  3. 沿路径向上调整 MBR。

重新插入策略的代价看起来很高,但在实践中被删除节点通常只有少量条目,总代价可控。

四、R*-tree:更聪明的分裂与重插入

Beckmann 等人在 1990 年提出的 R*-tree 对原始 R-tree 做了几项关键改进,在实验中查询性能提升了 30%-50%。

4.1 改进的子树选择

R*-tree 对叶节点和内部节点使用不同的策略:

这个改动的直觉是:对于叶节点,减少重叠比减少面积更重要,因为重叠直接导致搜索时进入多个子树。

4.2 改进的分裂策略

R*-tree 的分裂分为三步:

ChooseSplitAxis:
  对每个维度 d:
    将条目按 d 维的 min 排序,尝试所有合法的分割点
    将条目按 d 维的 max 排序,尝试所有合法的分割点
    计算所有分割的周长之和 S_d
  选择 S_d 最小的维度作为分裂轴

ChooseSplitIndex:
  在选定的维度上,尝试所有合法分割点
  选择两组 MBR 重叠面积最小的分割
  (平局时选总面积最小的)

为什么用周长而不是面积?直觉是:周长最小的分割倾向于产生更”正方形”的 MBR,而正方形的 MBR 在各个方向上的选择性更好。

4.3 强制重插入(Forced Reinsert)

这是 R*-tree 最重要的创新。当节点溢出时,不是立即分裂,而是:

OverflowTreatment:
  如果该层还没有执行过 reinsert:
    1. 计算所有条目的 MBR 中心到节点 MBR 中心的距离
    2. 按距离降序排列,取出 p 个最远的条目(p 通常为 30% * M)
    3. 将这 p 个条目重新插入树中
  否则:
    执行分裂

强制重插入的好处:

  1. 动态重组织:等效于定期”洗牌”,减少因插入顺序导致的退化。
  2. 减少分裂次数:很多时候重插入后节点不再溢出,树的高度增长更慢。
  3. 改善空间利用率:重新分配的条目可能找到更合适的位置。

4.4 性能对比

指标 R-tree(二次分裂) R*-tree
点查询 I/O 次数 基准 减少 30-50%
范围查询 I/O 次数 基准 减少 20-40%
MBR 重叠率 较高 显著降低
空间利用率 ~60% ~70%
插入代价 稍高(因重插入)

在绝大多数实际场景中,R*-tree 是比原始 R-tree 更好的选择。PostGIS 底层使用的 GiST 索引在实现时也借鉴了 R*-tree 的思想。

五、Hilbert R-tree:空间填充曲线的力量

Hilbert R-tree 由 Kamel 和 Faloutsos 在 1994 年提出,核心思想是用 Hilbert 空间填充曲线为空间对象定义一个一维排序。

5.1 Hilbert 曲线简介

Hilbert 曲线是一种空间填充曲线,它将二维平面上的每个点映射为一维线上的一个位置,同时保持良好的局部性——二维空间中相近的点在一维线上也倾向于相近。

Hilbert 值的计算核心思想是递归分割空间:

/* 计算二维坐标的 Hilbert 值 */
unsigned int xy_to_hilbert(unsigned int x, unsigned int y, int order)
{
    unsigned int d = 0;
    for (int s = order / 2; s > 0; s /= 2) {
        unsigned int rx = (x & s) > 0 ? 1 : 0;
        unsigned int ry = (y & s) > 0 ? 1 : 0;
        d += s * s * ((3 * rx) ^ ry);
        /* 旋转象限 */
        if (ry == 0) {
            if (rx == 1) {
                x = s - 1 - x;
                y = s - 1 - y;
            }
            unsigned int t = x;
            x = y;
            y = t;
        }
    }
    return d;
}

5.2 Hilbert R-tree 的结构

每个数据对象根据其 MBR 中心的 Hilbert 值排序。叶节点按 Hilbert 值顺序填充,内部节点存储子树的最大 Hilbert 值(类似 B+-tree 的分隔键)。

关键优势:

  1. 分裂变得简单:直接在 Hilbert 值的中点分裂,无需复杂的空间分析。
  2. MBR 重叠更少:Hilbert 曲线的局部性保证了空间邻近的对象被分配到同一叶节点。
  3. 支持 2-3 分裂:可以延迟分裂(类似 B*-tree 的兄弟节点合并),提高空间利用率到 ~80%。
  4. 批量加载极其高效:只需排序后顺序填充。

六、PostGIS 与 GiST 索引框架

6.1 GiST:通用搜索树

PostgreSQL 的 GiST(Generalized Search Tree)是一个可扩展的索引框架。它把 B-tree、R-tree 等索引结构统一到一个通用接口:

GiST 接口方法:
  Consistent(E, q)   — 条目 E 是否与查询 q 一致?
  Union(P)            — 计算条目集合 P 的联合键
  Penalty(E, new)     — 将 new 插入 E 的代价
  PickSplit(P)        — 将条目集合 P 分成两组
  Same(E1, E2)        — 两个键是否相同?
  Compress(E)         — 压缩条目的键
  Decompress(E)       — 解压条目的键

对于空间数据,PostGIS 实现了这些接口:

6.2 PostGIS 中使用空间索引

-- 创建空间表
CREATE TABLE restaurants (
    id SERIAL PRIMARY KEY,
    name TEXT,
    geom GEOMETRY(Point, 4326)  -- WGS84 坐标系
);

-- 创建 GiST 空间索引
CREATE INDEX idx_restaurants_geom ON restaurants USING gist(geom);

-- 范围查询:找出边界框内的餐厅
SELECT name FROM restaurants
WHERE geom && ST_MakeEnvelope(116.3, 39.9, 116.5, 40.0, 4326);

-- 距离查询:找出 1km 内的餐厅
SELECT name, ST_Distance(
    geom::geography,
    ST_SetSRID(ST_MakePoint(116.4, 39.95), 4326)::geography
) AS dist
FROM restaurants
WHERE ST_DWithin(
    geom::geography,
    ST_SetSRID(ST_MakePoint(116.4, 39.95), 4326)::geography,
    1000  -- 1000 米
)
ORDER BY dist;

-- k-NN 查询:找最近的 5 家餐厅(利用 GiST 的 KNN 支持)
SELECT name, geom <-> ST_SetSRID(ST_MakePoint(116.4, 39.95), 4326) AS dist
FROM restaurants
ORDER BY geom <-> ST_SetSRID(ST_MakePoint(116.4, 39.95), 4326)
LIMIT 5;

6.3 GiST vs SP-GiST

PostgreSQL 还提供了 SP-GiST(Space-Partitioned GiST),适用于不平衡的空间分割结构:

特性 GiST(R-tree 类) SP-GiST
树结构 平衡 可不平衡
分区方式 重叠 MBR 不重叠的空间分割
适用结构 R-tree Quadtree、k-d tree
点查询 可能多路径 单路径
适用场景 通用 点数据、均匀分布

6.4 BRIN 索引

对于按空间顺序存储的大表(如时序地理数据),PostgreSQL 的 BRIN(Block Range Index)也可以作为轻量级空间索引:

-- 对按空间顺序插入的数据,BRIN 索引极小但有效
CREATE INDEX idx_tracks_brin ON gps_tracks USING brin(geom);

BRIN 的索引大小通常只有 GiST 的 1%,但要求数据在物理存储上有空间局部性。

七、其他空间索引结构

7.1 Quadtree(四叉树)

四叉树递归地将二维空间等分为四个象限:

       ┌────┬────┐
       │ NW │ NE │
       ├────┼────┤
       │ SW │ SE │
       └────┴────┘

四叉树的优势在于实现简单,且空间分割不重叠。但它不是平衡的,数据分布不均匀时会退化。

typedef struct quad_node {
    double cx, cy;           /* 中心点 */
    double half_w, half_h;   /* 半宽和半高 */
    int point_count;
    double points_x[4];     /* 最多 4 个点 */
    double points_y[4];
    struct quad_node *nw, *ne, *sw, *se;
} QuadNode;

7.2 Geohash

Geohash 将二维坐标编码为一维字符串,通过交替细分经度和纬度范围:

经度范围 [-180, 180],纬度范围 [-90, 90]
第1位:经度 > 0 → 1,否则 → 0
第2位:纬度 > 0 → 1,否则 → 0
第3位:继续细分经度
...
每 5 位二进制映射为一个 Base32 字符

Geohash 的特点:

Redis 的 GEO 命令底层就使用了 Geohash + Sorted Set。

7.3 S2 Geometry(Google)

Google 的 S2 Geometry 将地球表面投影到外接正方体的六个面上,然后在每个面上使用 Hilbert 曲线:

核心概念:

S2 相比 Geohash 的优势:

  1. 无极点奇异性:Geohash 在极点附近退化,S2 在球面上均匀。
  2. 面积均匀:同一级别的 S2Cell 面积变化不超过 2:1。
  3. 无边界效应:Hilbert 曲线保证连续性。

Google Maps、Google Earth Engine、BigQuery 都使用 S2。

7.4 H3(Uber)

Uber 的 H3 使用正六边形网格覆盖地球表面:

7.5 对比总结

特性 R-tree Quadtree Geohash S2 H3
维度 任意 2D 2D 球面 球面
动态更新 静态为主 静态为主 静态为主
点查询 O(log n) O(log n) O(1) 哈希 O(1) O(1)
范围查询 优秀 良好 需展开邻居 优秀 良好
面状对象 原生支持 需分解 近似 近似 近似
典型场景 数据库 游戏 Redis Google Uber

八、空间填充曲线的数学

空间填充曲线是将一维连续区间映射到高维空间的连续满射。在空间索引中,我们使用它的离散版本——将多维网格映射为一维整数。

8.1 Z-order(Morton Code)

Z-order 曲线通过交错各维度的比特位来编码:

点 (x, y) 的 Morton code:
  x = b3 b2 b1 b0 (二进制)
  y = c3 c2 c1 c0
  Morton = c3 b3 c2 b2 c1 b1 c0 b0
/* 计算 Morton code(Z-order value) */
unsigned int morton_encode(unsigned int x, unsigned int y)
{
    x = (x | (x << 8)) & 0x00FF00FF;
    x = (x | (x << 4)) & 0x0F0F0F0F;
    x = (x | (x << 2)) & 0x33333333;
    x = (x | (x << 1)) & 0x55555555;

    y = (y | (y << 8)) & 0x00FF00FF;
    y = (y | (y << 4)) & 0x0F0F0F0F;
    y = (y | (y << 2)) & 0x33333333;
    y = (y | (y << 1)) & 0x55555555;

    return x | (y << 1);
}

Z-order 的局部性不如 Hilbert 曲线——Z 曲线在象限边界处有大跳跃。但 Z-order 的计算更简单,适合对性能要求极高但精度要求不高的场景。

8.2 Hilbert 曲线 vs Z-order

特性 Z-order Hilbert
计算复杂度 O(d) 位运算 O(d * order)
局部性 较好 最好
最大跳跃 较大 较小
实现难度 简单 中等
使用场景 内存索引 磁盘索引

Hilbert 曲线在磁盘访问场景下的优势更明显,因为磁盘的顺序读写远快于随机读写,而 Hilbert 曲线的连续段更长。

九、完整 C 实现

以下是一个完整的 R-tree 实现,支持范围查询和 k-NN 搜索:

/*
 * rtree.c — 完整的二维 R-tree 实现
 *
 * 支持:插入、范围查询、k-NN 查询
 * 分裂策略:二次分裂(Quadratic Split)
 *
 * 编译:gcc -O2 -o rtree rtree.c -lm
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <float.h>

/* ===== 配置参数 ===== */
#define DIMS        2
#define MAX_ENTRIES 8     /* 为便于演示,使用较小的 M */
#define MIN_ENTRIES 3     /* m = ceil(M * 0.4) */
#define KNN_HEAP_CAP 256

/* ===== MBR(最小边界矩形) ===== */
typedef struct {
    double lo[DIMS];
    double hi[DIMS];
} Rect;

static Rect rect_empty(void)
{
    Rect r;
    for (int d = 0; d < DIMS; d++) {
        r.lo[d] = DBL_MAX;
        r.hi[d] = -DBL_MAX;
    }
    return r;
}

static Rect rect_from_point(double x, double y)
{
    Rect r;
    r.lo[0] = r.hi[0] = x;
    r.lo[1] = r.hi[1] = y;
    return r;
}

static int rect_overlaps(const Rect *a, const Rect *b)
{
    for (int d = 0; d < DIMS; d++) {
        if (a->lo[d] > b->hi[d] || a->hi[d] < b->lo[d])
            return 0;
    }
    return 1;
}

static int rect_contains(const Rect *a, const Rect *b)
{
    for (int d = 0; d < DIMS; d++) {
        if (a->lo[d] > b->lo[d] || a->hi[d] < b->hi[d])
            return 0;
    }
    return 1;
}

static double rect_area(const Rect *r)
{
    double area = 1.0;
    for (int d = 0; d < DIMS; d++) {
        double side = r->hi[d] - r->lo[d];
        if (side <= 0.0) return 0.0;
        area *= side;
    }
    return area;
}

static Rect rect_expand(const Rect *a, const Rect *b)
{
    return rect_union(a, b);
}

static double rect_enlargement(const Rect *a, const Rect *b)
{
    Rect u = rect_union(a, b);
    return rect_area(&u) - rect_area(a);
}

/* 点到 MBR 的最小距离(用于 k-NN) */
static double rect_min_dist(const Rect *r, double px, double py)
{
    double dx = 0.0, dy = 0.0;

    if (px < r->lo[0])      dx = r->lo[0] - px;
    else if (px > r->hi[0]) dx = px - r->hi[0];

    if (py < r->lo[1])      dy = r->lo[1] - py;
    else if (py > r->hi[1]) dy = py - r->hi[1];

    return sqrt(dx * dx + dy * dy);
}

/* ===== R-tree 节点 ===== */
typedef struct rtree_node {
    int count;
    int is_leaf;
    int height;      /* 叶节点 height=0 */
    Rect rects[MAX_ENTRIES + 1];   /* +1 用于溢出时暂存 */
    union {
        struct rtree_node *children[MAX_ENTRIES + 1];
        int data_ids[MAX_ENTRIES + 1];
    };
} Node;

typedef struct {
    Node *root;
    int size;        /* 数据对象总数 */
    int height;      /* 树高 */
} RTree;

static Node *node_new(int is_leaf, int height)
{
    Node *n = (Node *)calloc(1, sizeof(Node));
    n->is_leaf = is_leaf;
    n->height = height;
    return n;
}

static Rect node_cover(const Node *n)
{
    Rect cover = n->rects[0];
    for (int i = 1; i < n->count; i++)
        cover = rect_union(&cover, &n->rects[i]);
    return cover;
}

/* ===== 二次分裂(Quadratic Split) ===== */
static void pick_seeds(Node *n, int *s1, int *s2)
{
    double worst = -DBL_MAX;
    *s1 = 0;
    *s2 = 1;

    for (int i = 0; i < n->count; i++) {
        for (int j = i + 1; j < n->count; j++) {
            Rect combined = rect_union(&n->rects[i], &n->rects[j]);
            double waste = rect_area(&combined)
                         - rect_area(&n->rects[i])
                         - rect_area(&n->rects[j]);
            if (waste > worst) {
                worst = waste;
                *s1 = i;
                *s2 = j;
            }
        }
    }
}

static void quadratic_split(Node *n, Node **out_a, Node **out_b)
{
    int total = n->count;  /* 此时 count == MAX_ENTRIES + 1 */
    int assigned[MAX_ENTRIES + 1];
    memset(assigned, 0, sizeof(assigned));

    int s1, s2;
    pick_seeds(n, &s1, &s2);

    Node *a = node_new(n->is_leaf, n->height);
    Node *b = node_new(n->is_leaf, n->height);

    /* 将种子分配到两组 */
    a->rects[0] = n->rects[s1];
    if (n->is_leaf) a->data_ids[0] = n->data_ids[s1];
    else            a->children[0] = n->children[s1];
    a->count = 1;
    assigned[s1] = 1;

    b->rects[0] = n->rects[s2];
    if (n->is_leaf) b->data_ids[0] = n->data_ids[s2];
    else            b->children[0] = n->children[s2];
    b->count = 1;
    assigned[s2] = 1;

    int remaining = total - 2;

    while (remaining > 0) {
        /* 检查是否需要将剩余全部分配给某组 */
        if (a->count + remaining == MIN_ENTRIES) {
            for (int i = 0; i < total; i++) {
                if (assigned[i]) continue;
                a->rects[a->count] = n->rects[i];
                if (n->is_leaf) a->data_ids[a->count] = n->data_ids[i];
                else            a->children[a->count] = n->children[i];
                a->count++;
                assigned[i] = 1;
            }
            break;
        }
        if (b->count + remaining == MIN_ENTRIES) {
            for (int i = 0; i < total; i++) {
                if (assigned[i]) continue;
                b->rects[b->count] = n->rects[i];
                if (n->is_leaf) b->data_ids[b->count] = n->data_ids[i];
                else            b->children[b->count] = n->children[i];
                b->count++;
                assigned[i] = 1;
            }
            break;
        }

        /* 选择下一个条目:最大偏好差异 */
        Rect cover_a = node_cover(a);
        Rect cover_b = node_cover(b);

        int best_idx = -1;
        double best_diff = -DBL_MAX;
        int best_group = 0;

        for (int i = 0; i < total; i++) {
            if (assigned[i]) continue;
            double ea = rect_enlargement(&cover_a, &n->rects[i]);
            double eb = rect_enlargement(&cover_b, &n->rects[i]);
            double diff = fabs(ea - eb);
            if (diff > best_diff) {
                best_diff = diff;
                best_idx = i;
                best_group = (ea < eb) ? 0 : 1;
            }
        }

        Node *target = (best_group == 0) ? a : b;
        target->rects[target->count] = n->rects[best_idx];
        if (n->is_leaf) target->data_ids[target->count] = n->data_ids[best_idx];
        else            target->children[target->count] = n->children[best_idx];
        target->count++;
        assigned[best_idx] = 1;
        remaining--;
    }

    *out_a = a;
    *out_b = b;
}

/* ===== 插入 ===== */
static int choose_subtree(Node *n, const Rect *r)
{
    int best = 0;
    double best_enlarge = rect_enlargement(&n->rects[0], r);
    double best_area = rect_area(&n->rects[0]);

    for (int i = 1; i < n->count; i++) {
        double enlarge = rect_enlargement(&n->rects[i], r);
        double area = rect_area(&n->rects[i]);
        if (enlarge < best_enlarge ||
            (enlarge == best_enlarge && area < best_area)) {
            best = i;
            best_enlarge = enlarge;
            best_area = area;
        }
    }
    return best;
}

/*
 * 递归插入,返回分裂产生的新节点(如果没有分裂则返回 NULL)
 * split_rect 返回新节点的 MBR
 */
static Node *insert_internal(Node *node, const Rect *r, int data_id,
                             int target_height, Rect *split_rect)
{
    if (node->height == target_height) {
        /* 在当前节点插入条目 */
        node->rects[node->count] = *r;
        if (node->is_leaf)
            node->data_ids[node->count] = data_id;
        else
            node->children[node->count] = NULL; /* 由调用者设置 */
        node->count++;

        if (node->count <= MAX_ENTRIES)
            return NULL;

        /* 需要分裂 */
        Node *a, *b;
        quadratic_split(node, &a, &b);

        /* 复制 a 的内容到当前节点 */
        node->count = a->count;
        memcpy(node->rects, a->rects, sizeof(Rect) * a->count);
        if (node->is_leaf)
            memcpy(node->data_ids, a->data_ids, sizeof(int) * a->count);
        else
            memcpy(node->children, a->children, sizeof(Node *) * a->count);
        free(a);

        *split_rect = node_cover(b);
        return b;
    }

    /* 选择子树 */
    int idx = choose_subtree(node, r);
    Rect child_split_rect;
    Node *new_child = insert_internal(node->children[idx], r, data_id,
                                       target_height, &child_split_rect);

    /* 更新该子树的 MBR */
    node->rects[idx] = node_cover(node->children[idx]);

    if (new_child == NULL)
        return NULL;

    /* 子节点分裂了,需要在当前节点加入新子节点 */
    node->rects[node->count] = child_split_rect;
    node->children[node->count] = new_child;
    node->count++;

    if (node->count <= MAX_ENTRIES)
        return NULL;

    /* 当前节点也需要分裂 */
    Node *a, *b;
    quadratic_split(node, &a, &b);

    node->count = a->count;
    memcpy(node->rects, a->rects, sizeof(Rect) * a->count);
    memcpy(node->children, a->children, sizeof(Node *) * a->count);
    free(a);

    *split_rect = node_cover(b);
    return b;
}

void rtree_insert(RTree *tree, double x, double y, int data_id)
{
    Rect r = rect_from_point(x, y);

    if (tree->root == NULL) {
        tree->root = node_new(1, 0);
        tree->root->rects[0] = r;
        tree->root->data_ids[0] = data_id;
        tree->root->count = 1;
        tree->size = 1;
        tree->height = 1;
        return;
    }

    Rect split_rect;
    Node *split_node = insert_internal(tree->root, &r, data_id,
                                        0, &split_rect);
    tree->size++;

    if (split_node != NULL) {
        /* 根节点分裂,创建新根 */
        Node *new_root = node_new(0, tree->root->height + 1);
        new_root->rects[0] = node_cover(tree->root);
        new_root->children[0] = tree->root;
        new_root->rects[1] = split_rect;
        new_root->children[1] = split_node;
        new_root->count = 2;
        tree->root = new_root;
        tree->height++;
    }
}

/* ===== 范围查询 ===== */
int rtree_search(Node *node, const Rect *query, int *results, int max_results)
{
    if (node == NULL) return 0;

    int found = 0;
    for (int i = 0; i < node->count && found < max_results; i++) {
        if (!rect_overlaps(&node->rects[i], query))
            continue;

        if (node->is_leaf) {
            results[found++] = node->data_ids[i];
        } else {
            found += rtree_search(node->children[i], query,
                                  results + found, max_results - found);
        }
    }
    return found;
}

/* ===== k-NN 查询(优先级队列方法) ===== */
typedef struct {
    double dist;
    int is_leaf_entry;
    union {
        Node *node;
        int data_id;
    };
    Rect rect;
} KNNEntry;

typedef struct {
    KNNEntry *items;
    int size;
    int cap;
} MinHeap;

static MinHeap *heap_new(int cap)
{
    MinHeap *h = (MinHeap *)malloc(sizeof(MinHeap));
    h->items = (KNNEntry *)malloc(sizeof(KNNEntry) * cap);
    h->size = 0;
    h->cap = cap;
    return h;
}

static void heap_push(MinHeap *h, KNNEntry e)
{
    if (h->size >= h->cap) {
        h->cap *= 2;
        h->items = (KNNEntry *)realloc(h->items, sizeof(KNNEntry) * h->cap);
    }
    int i = h->size++;
    h->items[i] = e;
    while (i > 0) {
        int parent = (i - 1) / 2;
        if (h->items[parent].dist <= h->items[i].dist) break;
        KNNEntry tmp = h->items[parent];
        h->items[parent] = h->items[i];
        h->items[i] = tmp;
        i = parent;
    }
}

static KNNEntry heap_pop(MinHeap *h)
{
    KNNEntry top = h->items[0];
    h->items[0] = h->items[--h->size];
    int i = 0;
    while (1) {
        int left = 2 * i + 1;
        int right = 2 * i + 2;
        int smallest = i;
        if (left < h->size && h->items[left].dist < h->items[smallest].dist)
            smallest = left;
        if (right < h->size && h->items[right].dist < h->items[smallest].dist)
            smallest = right;
        if (smallest == i) break;
        KNNEntry tmp = h->items[smallest];
        h->items[smallest] = h->items[i];
        h->items[i] = tmp;
        i = smallest;
    }
    return top;
}

/*
 * k-NN 搜索:使用"最佳优先搜索"策略
 * 将节点和数据点统一放入优先级队列,按到查询点的最小距离排序
 */
int rtree_knn(RTree *tree, double qx, double qy, int k,
              int *result_ids, double *result_dists)
{
    if (tree->root == NULL || k <= 0) return 0;

    MinHeap *pq = heap_new(KNN_HEAP_CAP);
    int found = 0;

    /* 将根节点的所有条目加入优先队列 */
    for (int i = 0; i < tree->root->count; i++) {
        KNNEntry e;
        e.dist = rect_min_dist(&tree->root->rects[i], qx, qy);
        e.rect = tree->root->rects[i];
        if (tree->root->is_leaf) {
            e.is_leaf_entry = 1;
            e.data_id = tree->root->data_ids[i];
        } else {
            e.is_leaf_entry = 0;
            e.node = tree->root->children[i];
        }
        heap_push(pq, e);
    }

    while (pq->size > 0 && found < k) {
        KNNEntry top = heap_pop(pq);

        if (top.is_leaf_entry) {
            result_ids[found] = top.data_id;
            result_dists[found] = top.dist;
            found++;
        } else {
            /* 展开内部节点或叶节点 */
            Node *child = top.node;
            for (int i = 0; i < child->count; i++) {
                KNNEntry e;
                e.dist = rect_min_dist(&child->rects[i], qx, qy);
                e.rect = child->rects[i];
                if (child->is_leaf) {
                    e.is_leaf_entry = 1;
                    e.data_id = child->data_ids[i];
                } else {
                    e.is_leaf_entry = 0;
                    e.node = child->children[i];
                }
                heap_push(pq, e);
            }
        }
    }

    free(pq->items);
    free(pq);
    return found;
}

/* ===== 释放内存 ===== */
void rtree_free(Node *node)
{
    if (node == NULL) return;
    if (!node->is_leaf) {
        for (int i = 0; i < node->count; i++)
            rtree_free(node->children[i]);
    }
    free(node);
}

/* ===== 测试代码 ===== */
int main(void)
{
    RTree tree = {NULL, 0, 0};

    /* 插入一些二维点 */
    struct { double x, y; } points[] = {
        {1.0, 2.0}, {3.0, 4.0}, {5.0, 1.0}, {7.0, 3.0},
        {2.0, 6.0}, {8.0, 8.0}, {4.0, 5.0}, {6.0, 7.0},
        {9.0, 2.0}, {1.5, 8.5}, {3.5, 3.5}, {5.5, 5.5},
        {7.5, 1.5}, {2.5, 4.5}, {4.5, 7.5}, {6.5, 6.5},
        {8.5, 4.5}, {0.5, 0.5}, {9.5, 9.5}, {5.0, 5.0},
    };
    int n = sizeof(points) / sizeof(points[0]);

    printf("=== 插入 %d 个点 ===\n", n);
    for (int i = 0; i < n; i++)
        rtree_insert(&tree, points[i].x, points[i].y, i);

    printf("树高: %d, 数据量: %d\n\n", tree.height, tree.size);

    /* 范围查询 */
    Rect query;
    query.lo[0] = 2.0; query.lo[1] = 2.0;
    query.hi[0] = 6.0; query.hi[1] = 6.0;

    int results[100];
    int count = rtree_search(tree.root, &query, results, 100);

    printf("=== 范围查询 [2,2]-[6,6] ===\n");
    printf("找到 %d 个点:\n", count);
    for (int i = 0; i < count; i++) {
        int id = results[i];
        printf("  点 %d: (%.1f, %.1f)\n", id, points[id].x, points[id].y);
    }

    /* k-NN 查询 */
    printf("\n=== k-NN 查询: 最近 5 个点(查询点 5.0, 5.0) ===\n");
    int knn_ids[5];
    double knn_dists[5];
    int knn_count = rtree_knn(&tree, 5.0, 5.0, 5, knn_ids, knn_dists);

    for (int i = 0; i < knn_count; i++) {
        int id = knn_ids[i];
        printf("  #%d: 点 %d (%.1f, %.1f), 距离 = %.3f\n",
               i + 1, id, points[id].x, points[id].y, knn_dists[i]);
    }

    rtree_free(tree.root);
    printf("\n测试完成。\n");
    return 0;
}

编译与运行:

gcc -O2 -o rtree rtree.c -lm
./rtree

十、工程实践与陷阱

10.1 常见陷阱表

陷阱 现象 解决方案
MBR 过大 查询命中大量无关节点 使用 R*-tree 的强制重插入减少 MBR 膨胀
批量插入顺序 按某一维排序插入导致退化 使用 STR 或 Hilbert 批量加载
坐标系混淆 经纬度当作平面坐标计算距离 PostGIS 中使用 geography 类型或 ST_Transform
维度灾难 高维空间 R-tree 退化 维度 > 10 时考虑 LSH 或降维
小多边形 + 大 MBR 狭长或 L 形对象的 MBR 浪费空间 将对象分解为多个子片段
忘记 VACUUM PostgreSQL 中删除大量数据后索引膨胀 定期执行 REINDEX 或 VACUUM
浮点精度 边界判断遗漏 使用 epsilon 容差或精确算术
跨越反子午线 经度 179 到 -179 的区域查询失败 将查询拆分为两个范围或使用球面索引
并发安全 读写锁粒度不当 使用 PostgreSQL 内置的并发控制
节点填充率过低 频繁删除后树变稀疏 R*-tree 的重插入 + 定期重建

10.2 性能调优清单

  1. 选择正确的索引类型:点数据用 SP-GiST(k-d tree),面数据用 GiST(R-tree)。
  2. 设置合理的 fillfactorCREATE INDEX ... WITH (fillfactor = 90) 留出插入空间。
  3. 使用 CLUSTER 命令CLUSTER restaurants USING idx_restaurants_geom; 使数据物理顺序与索引顺序一致。
  4. 监控索引膨胀SELECT pg_relation_size('idx_restaurants_geom'); 观察大小变化。
  5. 利用覆盖索引:GiST 索引可以包含额外列(PostgreSQL 12+),减少回表。

10.3 基准测试参考

在 100 万随机点上的大致性能(PostgreSQL 14,8 核 32GB):

操作 GiST 无索引
点查询 ~0.1 ms ~200 ms
范围查询(~100 条) ~1 ms ~300 ms
k-NN(k=10) ~0.5 ms ~500 ms
索引大小 ~80 MB 0

十一、应用场景深入

11.1 GIS 地理信息系统

这是 R-tree 最经典的应用场景。除了前面提到的 PostGIS,还有:

11.2 游戏碰撞检测

游戏引擎需要在每帧检测成千上万个对象之间的碰撞。两两比较是 O(n^2),空间索引将其降低到 O(n log n):

BroadPhase 碰撞检测:
  1. 为每个游戏对象计算 AABB(轴对齐边界框 = MBR)
  2. 将所有 AABB 插入空间索引(R-tree 或 grid)
  3. 对每个对象,查询与其 AABB 重叠的对象列表 → 候选对
  4. 对候选对进行 NarrowPhase 精确检测(GJK 算法等)

Box2D 物理引擎使用动态 AABB 树(类似 R-tree 但优化了动态场景),Unity 的 DOTS 物理系统也使用 BVH(Bounding Volume Hierarchy,R-tree 的变种)。

11.3 数据库范围查询

R-tree 不限于地理数据。任何多维范围查询都可以受益:

-- 多维属性查询:找出价格在 200-500 且面积在 80-120 的房源
-- 如果在 (price, area) 上建立多维索引
SELECT * FROM houses
WHERE price BETWEEN 200 AND 500
  AND area BETWEEN 80 AND 120;

PostgreSQL 的 GiST 支持在任意类型上建立空间索引,不限于几何类型。

11.4 计算机图形学

光线追踪中的 BVH(Bounding Volume Hierarchy)本质上就是 R-tree 的思想:用层级化的包围盒加速光线与场景的求交测试。NVIDIA RTX 系列显卡硬件加速的就是 BVH 遍历。天文学中的天体目录查询(如 SDSS 的 HTM 索引)也是类似的空间索引应用。

十二、个人观点与总结

对 R-tree 的几点看法

R-tree 是”足够好”的默认选择,但不是最优选择。 在大多数实际应用中,R*-tree 提供了良好的查询性能和可接受的更新代价。但如果你的场景有特殊特征——数据几乎不更新(用批量加载的 packed R-tree)、全是点数据(考虑 k-d tree)、在球面上工作(S2 更自然)——那么专用方案更好。

MBR 重叠是 R-tree 的阿喀琉斯之踵。 所有 R-tree 变体的核心目标都是减少 MBR 重叠。R*-tree 的强制重插入、Hilbert R-tree 的空间填充曲线排序、STR 批量加载——本质上都在攻击这个问题。

空间索引的选择取决于查询模式。 如果 90% 的查询是”附近 1km 内的 POI”,Geohash + B-tree 可能比 R-tree 更简单高效。复杂多边形相交查询,R-tree 几乎是唯一选择。球面数据需要精确距离计算,S2 是更好的抽象。

维度灾难是真实的。 R-tree 在 2-5 维表现优异,但超过 10 维,MBR 的选择性迅速下降,性能可能不如暴力扫描。应转向近似最近邻(ANN)方法,如 LSH、HNSW 或乘积量化。

PostGIS 的 GiST 是工程上的杰作。 它把空间索引抽象为一组可插拔的接口,使同一框架支持 R-tree、k-d tree、全文搜索树等完全不同的数据结构。

核心要点回顾

  1. R-tree 用层级化的 MBR 将空间搜索组织成树结构,搜索时逐层检查 MBR 重叠。
  2. R*-tree 通过改进分裂策略和强制重插入,将查询性能提升 30-50%。
  3. Hilbert R-tree 利用空间填充曲线的局部性,特别适合批量加载场景。
  4. PostGIS 的 GiST 框架将 R-tree 的思想融入通用搜索树接口。
  5. 现代空间索引(S2、H3)在球面数据场景下提供更好的均匀性和精确性。
  6. 空间索引的选择没有银弹,需要根据数据特征、查询模式和系统约束综合决策。

参考文献

  1. Guttman A. “R-trees: A Dynamic Index Structure for Spatial Searching.” SIGMOD, 1984.
  2. Beckmann N, Kriegel H P, Schneider R, et al. “The R*-tree: An Efficient and Robust Access Method for Points and Rectangles.” SIGMOD, 1990.
  3. Kamel I, Faloutsos C. “Hilbert R-tree: An Improved R-tree Using Fractals.” VLDB, 1994.
  4. Hellerstein J M, Naughton J F, Pfeffer A. “Generalized Search Trees for Database Systems.” VLDB, 1995.
  5. Leutenegger S T, Lopez M A, Edgington J. “STR: A Simple and Efficient Algorithm for R-tree Packing.” ICDE, 1997.
  6. Samet H. “Foundations of Multidimensional and Metric Data Structures.” Morgan Kaufmann, 2006.
  7. PostGIS Documentation. https://postgis.net/documentation/
  8. S2 Geometry Library. https://s2geometry.io/
  9. H3: Uber’s Hexagonal Hierarchical Spatial Index. https://h3geo.org/
  10. de Berg M, Cheong O, van Kreveld M, Overmars M. “Computational Geometry: Algorithms and Applications.” 3rd ed., Springer, 2008.

算法系列导航上一篇:Voronoi 图与 Delaunay 三角剖分 | 下一篇:点定位与梯形分解

相关阅读k-d tree | B-tree 深度解剖


By .