打开手机地图,搜索”附近的咖啡馆”,应用在毫秒内就从数百万个 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 等现代空间索引方案。
一、从 B-tree 到 R-tree:为什么一维索引不够
B-tree 是数据库索引的基石。它把一维的键值组织成平衡搜索树,查询的时间复杂度为 O(log n)。但空间数据有根本性的不同:
- 多维性:一个地理坐标有经度和纬度两个维度,一个矩形有四个坐标值。B-tree 只能对一个维度排序。
- 没有全序关系:一维数据可以排序——3 < 5 < 7。但二维点 (3, 7) 和 (5, 2) 谁在前?不存在自然的全序。
- 范围查询的几何性:空间查询通常是”找出与这个矩形/圆形重叠的所有对象”,而非简单的区间查询。
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 是一棵平衡树,具有以下性质:
- 叶节点包含 (MBR, object_id) 对,其中 MBR 是数据对象的最小边界矩形。
- 内部节点包含 (MBR, child_ptr) 对,其中 MBR 是子树中所有对象的最小边界矩形。
- 每个节点包含 m 到 M 个条目(根节点至少 2 个),其中 m <= M/2。
- 所有叶节点在同一层——树是严格平衡的。
- 根节点至少有 2 个子节点(除非它同时是叶节点)。
M 通常由磁盘页大小决定。假设一个 MBR 占 32 字节(4 个 double),一个指针占 8 字节,一个 4KB 的页可以存放约 100 个条目。
2.2 MBR 的核心操作
MBR 上需要定义几个基本操作(完整实现见第九节):
- overlaps(a, b):两个矩形是否在所有维度上都有交集。
- contains(a, b):a 在每个维度上是否包含 b。
- area(r):各维度边长之积。
- union(a, b):取每个维度的 min/max 极值,构成最小包围矩形。
- enlargement(a, b):area(union(a,b)) - area(a),衡量将 b 加入 a 的代价。
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)
关键特点:
- 不保证单路径。与 B-tree 不同,查询矩形可能与同一层的多个 MBR 重叠,搜索必须进入多个子树。
- 最坏情况是 O(n)。如果所有 MBR 都与查询窗口重叠,则退化为全表扫描。
- 平均情况远好于最坏情况。好的 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 删除
删除操作需要先搜索到目标条目,然后:
- 从叶节点移除该条目。
- 如果节点条目数低于 m,删除该节点并将剩余条目重新插入树中(condensation)。
- 沿路径向上调整 MBR。
重新插入策略的代价看起来很高,但在实践中被删除节点通常只有少量条目,总代价可控。
四、R*-tree:更聪明的分裂与重插入
Beckmann 等人在 1990 年提出的 R*-tree 对原始 R-tree 做了几项关键改进,在实验中查询性能提升了 30%-50%。
4.1 改进的子树选择
R*-tree 对叶节点和内部节点使用不同的策略:
- 选择子树到叶节点:最小化 MBR 重叠面积增量(而非面积增量)。
- 选择子树到内部节点:仍然使用最小面积增量。
这个改动的直觉是:对于叶节点,减少重叠比减少面积更重要,因为重叠直接导致搜索时进入多个子树。
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 个条目重新插入树中
否则:
执行分裂
强制重插入的好处:
- 动态重组织:等效于定期”洗牌”,减少因插入顺序导致的退化。
- 减少分裂次数:很多时候重插入后节点不再溢出,树的高度增长更慢。
- 改善空间利用率:重新分配的条目可能找到更合适的位置。
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 的分隔键)。
关键优势:
- 分裂变得简单:直接在 Hilbert 值的中点分裂,无需复杂的空间分析。
- MBR 重叠更少:Hilbert 曲线的局部性保证了空间邻近的对象被分配到同一叶节点。
- 支持 2-3 分裂:可以延迟分裂(类似 B*-tree 的兄弟节点合并),提高空间利用率到 ~80%。
- 批量加载极其高效:只需排序后顺序填充。
六、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 实现了这些接口:
Consistent:检查 MBR 是否重叠/包含。Union:计算 MBR 的并集。Penalty:面积增量。PickSplit:类似 R*-tree 的分裂策略。
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 │
└────┴────┘
- 点四叉树:每个内部节点存一个点,将空间分为四个象限。
- 区域四叉树:将空间均匀四分,直到每个格子包含少于阈值个对象。
- MX-Quadtree:固定深度的区域四叉树。
四叉树的优势在于实现简单,且空间分割不重叠。但它不是平衡的,数据分布不均匀时会退化。
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 的特点:
- 前缀性质:相同前缀的 Geohash 在空间上相邻。
- 可用普通 B-tree
索引:
WHERE geohash LIKE 'wx4g0%'利用 B-tree 的前缀匹配。 - 边界问题:两个空间相邻的点可能有完全不同的 Geohash(在分割边界两侧)。
- 精度由长度决定:6 位精度约 1.2km,8 位精度约 19m。
Redis 的 GEO 命令底层就使用了 Geohash + Sorted Set。
7.3 S2 Geometry(Google)
Google 的 S2 Geometry 将地球表面投影到外接正方体的六个面上,然后在每个面上使用 Hilbert 曲线:
核心概念:
- S2Cell:Hilbert 曲线上的一个区间对应球面上的一个四边形。
- S2CellId:64 位整数,编码了层级和位置。
- 30 级层级:最精细级别的 cell 约 1cm^2。
- S2Region:可以用多个 S2Cell 覆盖任意区域。
S2 相比 Geohash 的优势:
- 无极点奇异性:Geohash 在极点附近退化,S2 在球面上均匀。
- 面积均匀:同一级别的 S2Cell 面积变化不超过 2:1。
- 无边界效应:Hilbert 曲线保证连续性。
Google Maps、Google Earth Engine、BigQuery 都使用 S2。
7.4 H3(Uber)
Uber 的 H3 使用正六边形网格覆盖地球表面:
- 15 级分辨率:从 ~4M km^2 到 ~0.9 m^2。
- 六边形的优势:每个格子只有一个邻居距离(而正方形有两种——边相邻和角相邻)。
- 层级不严格嵌套:子六边形不完全填充父六边形,需要额外处理。
- 适合聚合分析:如”每个六边形内的订单数”。
7.5 对比总结
| 特性 | R-tree | Quadtree | Geohash | S2 | H3 |
|---|---|---|---|---|---|
| 维度 | 任意 | 2D | 2D | 球面 | 球面 |
| 动态更新 | 好 | 好 | 静态为主 | 静态为主 | 静态为主 |
| 点查询 | O(log n) | O(log n) | O(1) 哈希 | O(1) | O(1) |
| 范围查询 | 优秀 | 良好 | 需展开邻居 | 优秀 | 良好 |
| 面状对象 | 原生支持 | 需分解 | 近似 | 近似 | 近似 |
| 典型场景 | 数据库 | 游戏 | Redis | 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 性能调优清单
- 选择正确的索引类型:点数据用 SP-GiST(k-d tree),面数据用 GiST(R-tree)。
- 设置合理的
fillfactor:
CREATE INDEX ... WITH (fillfactor = 90)留出插入空间。 - 使用 CLUSTER
命令:
CLUSTER restaurants USING idx_restaurants_geom;使数据物理顺序与索引顺序一致。 - 监控索引膨胀:
SELECT pg_relation_size('idx_restaurants_geom');观察大小变化。 - 利用覆盖索引: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,还有:
- OpenStreetMap:Nominatim 地理编码器使用 PostgreSQL + PostGIS。
- QGIS:桌面 GIS 软件,其空间查询底层依赖 R-tree。
- Elasticsearch:Geo-shape 查询使用基于 BKD-tree(类似 R-tree 的多维索引)。
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、全文搜索树等完全不同的数据结构。
核心要点回顾
- R-tree 用层级化的 MBR 将空间搜索组织成树结构,搜索时逐层检查 MBR 重叠。
- R*-tree 通过改进分裂策略和强制重插入,将查询性能提升 30-50%。
- Hilbert R-tree 利用空间填充曲线的局部性,特别适合批量加载场景。
- PostGIS 的 GiST 框架将 R-tree 的思想融入通用搜索树接口。
- 现代空间索引(S2、H3)在球面数据场景下提供更好的均匀性和精确性。
- 空间索引的选择没有银弹,需要根据数据特征、查询模式和系统约束综合决策。
参考文献
- Guttman A. “R-trees: A Dynamic Index Structure for Spatial Searching.” SIGMOD, 1984.
- Beckmann N, Kriegel H P, Schneider R, et al. “The R*-tree: An Efficient and Robust Access Method for Points and Rectangles.” SIGMOD, 1990.
- Kamel I, Faloutsos C. “Hilbert R-tree: An Improved R-tree Using Fractals.” VLDB, 1994.
- Hellerstein J M, Naughton J F, Pfeffer A. “Generalized Search Trees for Database Systems.” VLDB, 1995.
- Leutenegger S T, Lopez M A, Edgington J. “STR: A Simple and Efficient Algorithm for R-tree Packing.” ICDE, 1997.
- Samet H. “Foundations of Multidimensional and Metric Data Structures.” Morgan Kaufmann, 2006.
- PostGIS Documentation. https://postgis.net/documentation/
- S2 Geometry Library. https://s2geometry.io/
- H3: Uber’s Hexagonal Hierarchical Spatial Index. https://h3geo.org/
- 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 深度解剖