第13章 - 空间索引体系
几何运算的性能瓶颈,十有八九不在算法本身,而在“找对象”这一步。给定 10 万个点、一批地块或一条道路网,如果每次查询都要把所有对象拿出来比一遍,再精巧的谓词实现也救不了你。空间索引的全部意义,就是把“在所有对象里找”变成“在少数几个可能命中的对象里找”,并保证这个“少数”与数据总量基本无关。
本章讨论 JTS 的 org.locationtech.jts.index 包。它不是一个单一的索引,而是一族针对不同数据形态的索引结构:处理二维对象包络的 STRtree 与 Quadtree,处理点集的 KdTree,处理一维区间的 SIRtree 与 SortedPackedIntervalRTree,以及 1.20 之后被大量内部算法采用的 HPRtree。理解它们各自的结构、构建方式与失效场景,比记住某个方法名重要得多。
读完后你会掌握:如何用 SpatialIndex 与 ItemVisitor 写出不产生中间集合的查询;STRtree 的批量构建、最近邻与 k 近邻如何工作;Quadtree / KdTree / HPRtree / IntervalRTree 分别在什么场景下更合适;以及如何用索引把“点落在哪个面内”“两个图层相距多近”这类空间连接问题从 O(n×m) 降到接近 O(n log m)。
前置知识:第 4 章的几何对象模型(尤其是 Geometry.getEnvelopeInternal()),第 8 章的空间关系谓词(索引只做一级过滤,真假判定仍要靠谓词),以及第 11 章的距离度量(最近邻索引依赖距离度量接口)。
13.1 从全表扫描到空间索引
13.1.1 一个窗口查询的代价
设有一个包含 n 个几何对象的集合,要找出所有与查询矩形 R 相交的对象。最直接的做法是遍历集合,对每个对象调用 env.intersects(R) 或 geometry.intersects(queryGeometry)。这个算法的复杂度是 O(n),与查询窗口的大小无关。当 n = 1000 时它几乎无感,当 n = 100 万、且每秒要回答上百次查询时,它就变成了灾难。
空间索引把这个过程改造成 O(log n + k),其中 k 是实际命中的对象数。代价是:建索引需要额外的内存与一次性的构建时间;插入、删除的语义会受限(多数打包索引不支持构建后再插入);并且索引返回的结果只是“候选”,还需要二次判定。
13.1.2 两级过滤:primary filter 与 secondary filter
JTS 的 SpatialIndex 接口注释里写得很清楚:空间索引通常只提供“范围矩形查询的一级过滤(primary filter)”,精确相交判断需要调用方自己做“二级过滤(secondary filter)”。这是所有空间数据库引擎共用的设计,也是理解 JTS 索引行为的关键。
查询几何 / 查询矩形
│
▼
┌───────────────────────┐
│ 一级过滤(索引) │ 只比较 Envelope
│ STRtree / Quadtree │ 代价低、允许误报
└───────────┬───────────┘
│ 候选集合(可能包含不相交对象)
▼
┌───────────────────────┐
│ 二级过滤(谓词) │ intersects / contains / relate
│ RelateNG / 精确距离 │ 代价高、结果精确
└───────────┬───────────┘
│
▼
最终结果
一级过滤只比较包络(Envelope),因此对形状复杂、包络很大的对象(例如一条斜贯全省的高速公路)会产生大量误报。误报率取决于数据的“包络填充率”:包络越贴合几何,索引收益越高;反之,长斜线、L 形地块、细长河道都会让一级过滤效率下降。这一点在第 13.9 节讨论的 MonotoneChain 里会得到缓解——JTS 内部算法并不直接索引整条线,而是先把线拆成若干单调链再入索引。
13.1.3 JTS 索引家族一览
org.locationtech.jts.index 下的索引可以按数据维度与构建方式分类:
| 索引类 | 维度/对象 | 构建方式 | 支持删除 | 典型用途 |
|---|---|---|---|---|
STRtree |
二维包络 | 打包(bulk,惰性构建) | 支持(构建前 remove) |
通用二维查询、空间连接、最近邻 |
HPRtree |
二维包络 | 打包 + Hilbert 排序 | 不支持(remove 恒返回 false) |
只读批量数据、叠加与节点化内部加速 |
Quadtree |
二维包络 | 动态四叉细分 | 支持 | 动态插入/删除、无界数据、教学演示 |
KdTree |
点(带容差去重) | 动态二分 | 不支持 | 点集查询、点去重、捕捉索引 |
SIRtree |
一维区间 | 打包(STR 的一维版本) | 支持(构建前) | 一维范围查询 |
SortedPackedIntervalRTree |
一维区间 | 排序后打包成二叉树 | 不支持 | 只读区间查询 |
VertexSequencePackedRtree |
坐标数组下标 | 打包 | 支持(标记删除) | 多边形三角剖分等内部点定位 |
对应源码路径:
modules/core/src/main/java/org/locationtech/jts/index/
├── SpatialIndex.java 索引接口
├── ItemVisitor.java 访问者接口
├── ArrayListVisitor.java 把访问结果累积成 List
├── VertexSequencePackedRtree.java 顶点序列索引
├── strtree/ STRtree、SIRtree、ItemDistance
├── quadtree/ 四叉树
├── hprtree/ Hilbert 打包 R 树
├── kdtree/ KdTree
├── intervalrtree/ 一维区间树
├── chain/ MonotoneChain(供内部算法使用)
├── bintree/ 一维 BinTree
└── sweepline/ 扫描线索引(供内部算法使用)
注意最后两项:chain 与 sweepline 并不是给业务代码用的通用索引,而是被叠加、缓冲、节点化等算法内部复用的构件。第 13.9 节会专门讨论 MonotoneChain,因为理解它才能理解为什么 JTS 1.20 的叠加分析在大量线段场景下明显变快。
13.1.4 一个可以量化的实验
下面这段程序在 100×100 的规则格网上建 10000 个点,然后做一次窗口查询,分别统计“全表扫描”和“索引查询”的工作量。坐标是整数,查询窗口完全覆盖网格右上角的一部分,因此结果是确定的。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.index.strtree.STRtree;
public class GridQueryDemo {
public static void main(String[] args) {
GeometryFactory gf = new GeometryFactory();
Point[] pts = new Point[100 * 100];
STRtree index = new STRtree();
for (int i = 0; i < 100; i++) {
for (int j = 0; j < 100; j++) {
Point p = gf.createPoint(new Coordinate(i, j));
pts[i * 100 + j] = p;
// 只把包络放进索引,几何对象本身作为 item 保存
index.insert(p.getEnvelopeInternal(), p);
}
}
Envelope window = new Envelope(95, 105, 95, 105);
// 全表扫描:每个点都要和窗口比一次
int scanHits = 0;
for (Point p : pts) {
if (window.contains(p.getCoordinate())) scanHits++;
}
// 索引查询:只访问可能命中的叶节点
int indexHits = index.query(window).size();
System.out.println("全表扫描:10000 次判定,命中 " + scanHits + " 个点");
System.out.println("STRtree:query 返回 " + indexHits + " 个候选");
System.out.println("tree.size()=" + index.size() + ", tree.depth()=" + index.depth());
}
}
示例输出:
全表扫描:10000 次判定,命中 25 个点
STRtree:query 返回 25 个候选
tree.size()=10000, tree.depth()=5
这里“候选数”恰好等于“命中数”,是因为查询窗口完整包含了这些点的包络(点没有面积,包络相交等价于点落在窗口内)。换成面状数据后,候选数通常会大于命中数,差额就是二级过滤要处理的工作量。
13.2 SpatialIndex 接口与 ItemVisitor 模式
13.2.1 接口定义
SpatialIndex 只有四个方法,定义在 org.locationtech.jts.index.SpatialIndex 中:
public interface SpatialIndex {
/** 插入一个由 Envelope 指定范围的空间对象 */
void insert(Envelope itemEnv, Object item);
/** 查询与 searchEnv 相交的所有对象,返回 List */
List query(Envelope searchEnv);
/** 查询与 searchEnv 相交的所有对象,逐个交给 visitor */
void query(Envelope searchEnv, ItemVisitor visitor);
/** 从索引中删除一个对象 */
boolean remove(Envelope itemEnv, Object item);
}
四个方法的分工与注意事项:
| 方法 | 返回 | 是否保证精确相交 | 典型场景 |
|---|---|---|---|
insert |
无 | 不适用 | 建索引;注意包络必须非空 |
query(Envelope) |
List |
否,仅包络相交 | 结果集较小、后续要排序或批量处理 |
query(Envelope, ItemVisitor) |
无(回调) | 否,仅包络相交 | 结果集大、只需流式处理、避免中间集合 |
remove |
boolean |
不适用 | 动态维护;打包索引构建后通常不可用 |
13.2.2 为什么要 Visitor
query(Envelope) 内部其实也是 new 一个 ArrayListVisitor 再调用 visitor 版本,然后把 ArrayList 返回给你。两种写法在功能上等价,差别在内存与调用方式:
- 结果集可能有几十万条时,
List版本会先分配一个巨大的ArrayList,再让你遍历;Visitor 版本可以让每命中一个对象就立即处理、立即丢弃,峰值内存低。 - Visitor 版本可以在回调里做二次过滤,让“候选”在遍历过程中就被消化掉,不产生一个完整的候选列表。
- 但
ItemVisitor没有“中止”能力:visitItem返回void,接口里没有isDone()。想提前结束只能抛异常(不推荐)或自己在回调里设标志位跳过后续处理(遍历仍会发生)。
ItemVisitor 与 ArrayListVisitor 的源码非常短,可以直接读:
public interface ItemVisitor {
void visitItem(Object item);
}
public class ArrayListVisitor implements ItemVisitor {
private ArrayList items = new ArrayList();
public void visitItem(Object item) { items.add(item); }
public ArrayList getItems() { return items; }
}
13.2.3 调用链
以 STRtree 为例,一次 query(Envelope, ItemVisitor) 的调用链如下:
STRtree.query(env, visitor)
└─ AbstractSTRtree.query(searchBounds, visitor)
├─ build() 首次查询时才真正建树
├─ isEmpty() ? return
├─ root.getBounds().intersects(searchBounds) ? 否则直接返回
└─ queryInternal(searchBounds, root, visitor)
└─ 递归:子节点包络不相交则整枝剪掉
└─ ItemBoundable → visitor.visitItem(item)
注意 build() 是被查询“顺带”触发的。这意味着:如果你先 insert 了 10 万个对象,然后调用 size()、depth()、query() 中的任何一个,建树开销就发生在那一次调用上;如果只 insert 不查询,索引其实还只是一个线性列表。
13.2.4 完整示例:用 Visitor 做二级过滤
下面例子索引 4 个正方形面,用 Visitor 在回调里完成“包络相交 + 精确相交”的两级过滤,并统计两级过滤各自放行了多少对象。
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.index.ItemVisitor;
import org.locationtech.jts.index.strtree.STRtree;
import org.locationtech.jts.io.WKTReader;
public class VisitorFilterDemo {
public static void main(String[] args) throws Exception {
WKTReader reader = new WKTReader();
GeometryFactory gf = new GeometryFactory();
STRtree tree = new STRtree();
String[] wkts = {
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
"POLYGON ((20 0, 30 0, 30 10, 20 10, 20 0))",
"POLYGON ((0 20, 10 20, 10 30, 0 30, 0 20))",
"POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))"
};
for (String wkt : wkts) {
Geometry g = reader.read(wkt);
tree.insert(g.getEnvelopeInternal(), g);
}
// 查询几何是一个覆盖左上、右下两块的斜三角形
Geometry query = reader.read(
"POLYGON ((0 0, 30 0, 30 30, 0 0))");
final List<Geometry> exactHits = new ArrayList<Geometry>();
final int[] primaryCount = new int[1];
tree.query(query.getEnvelopeInternal(), new ItemVisitor() {
@Override
public void visitItem(Object item) {
primaryCount[0]++;
Geometry g = (Geometry) item;
// 二级过滤:索引只保证包络相交
if (g.intersects(query)) {
exactHits.add(g);
}
}
});
System.out.println("一级过滤放行:" + primaryCount[0] + " 个");
System.out.println("二级过滤命中:" + exactHits.size() + " 个");
for (Geometry g : exactHits) {
System.out.println(" " + g);
}
}
}
示例输出:
一级过滤放行:2 个
二级过滤命中:1 个
POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))
三角形式的查询几何也演示了一级过滤的局限:它的包络覆盖了整个 0~30 区域,但实际只占一半面积,所以总会带入一些“包络相交、几何不相交”的候选。
13.3 Envelope:索引的通用语言
13.3.1 为什么索引只认 Envelope
不同索引对“键”的要求不同,但 SpatialIndex 的签名统一把键限定为 Envelope。原因很实际:包络是二维几何的最小外接矩形,比较两个包络是否相交只需要四次数值比较,而比较任意两个几何是否相交可能需要完整的 DE-9IM 计算。索引的职责是便宜地排除“肯定不可能”的对象,而不是给出精确答案。
包络可从几何直接取得:Geometry.getEnvelopeInternal() 返回内部缓存的 Envelope 对象。对 Point 而言,包络是退化矩形(宽高为 0);对空几何而言,包络是“空包络”(isNull() == true)。后者是索引使用中最常见的坑,下一小节专门处理。
13.3.2 Envelope 常用 API
| 方法 | 作用 | 备注 |
|---|---|---|
getMinX()/getMaxX()/getMinY()/getMaxY() |
取四边 | 空包络返回 0,需先 isNull() 判断 |
getWidth()/getHeight()/getArea() |
尺寸 | 退化包络宽高可能为 0 |
getDiameter() |
对角线长度 | Voronoi 默认裁剪范围会用到 |
intersects(Envelope) |
包络是否相交 | 索引一级过滤的核心谓词 |
contains(Envelope) |
完全包含 | 注意与 covers 的边界语义差异 |
covers(Coordinate) |
覆盖点(含边界) | 判断点是否落在包络内更稳妥 |
expandBy(double) |
四周外扩 | 缓冲区查询、容差查询常用 |
expandToInclude(Envelope) |
合并包络 | 构造数据范围、累加候选 |
distance(Envelope) |
两包络最近距离 | 最近邻剪枝的判据 |
intersection(Envelope) |
求交包络 | 返回新对象,不修改原对象 |
copy() |
拷贝 | 索引存的是引用,复用变量时必须拷贝 |
13.3.3 一个容易踩的坑:包络的别名
STRtree.insert(Envelope, Object) 保存的是你传入的 Envelope 对象的引用,而不是拷贝。如果你在循环里复用同一个 Envelope 变量:
Envelope env = new Envelope();
for (Geometry g : geometries) {
env.init(g.getEnvelopeInternal()); // 复用同一个对象
tree.insert(env, g); // 危险:所有条目共享同一个包络
}
那么树里所有条目的包络都会指向最后写入的值,查询结果将完全错误。正确写法是每次传入新对象,或者传入 Geometry.getEnvelopeInternal() 返回的、几何自带的包络对象(不要再去修改它):
for (Geometry g : geometries) {
tree.insert(g.getEnvelopeInternal(), g); // 几何自带包络,只读使用
}
同理,如果查询之后你把某个 Envelope 又拿去 expandBy 并再次插入,也要确认前一次插入是否尚未 build——打包索引在构建后会复制/重排内部结构,但构建前的引用语义仍然是共享的。
13.3.4 空包络与退化包络
STRtree.insert 的第一行就是:
public void insert(Envelope itemEnv, Object item) {
if (itemEnv.isNull()) { return; } // 空包络直接丢弃
super.insert(itemEnv, item);
}
所以插入空几何(空 Point、空 Polygon)不会报错,但对象也不会进入索引,后续查询永远查不到它。这在“过滤掉空几何”的数据清洗流程里通常没问题,但如果你指望“索引里条目数等于输入数”,务必先检查 isEmpty()。
Quadtree 对待退化包络的方式不同:Quadtree.insert 会调用 ensureExtent(itemEnv, minExtent),把宽或高为 0 的包络向两侧各撑开 minExtent / 2,并且会随着插入数据不断缩小 minExtent(初始为 1.0)。这意味着 Quadtree 里点的包络不再精确等于点本身,查询边界处的行为要留意。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.index.quadtree.Quadtree;
public class DegenerateEnvelopeDemo {
public static void main(String[] args) {
Envelope pt = new Envelope(new Coordinate(5, 5));
System.out.println("原始点包络: " + pt + ", width=" + pt.getWidth());
System.out.println("isNull=" + pt.isNull());
Quadtree qt = new Quadtree();
qt.insert(pt, "A");
System.out.println("Quadtree.size=" + qt.size() + ", query=" + qt.query(pt));
STRtreeInsertDemo();
}
private static void STRtreeInsertDemo() {
org.locationtech.jts.index.strtree.STRtree tree =
new org.locationtech.jts.index.strtree.STRtree();
tree.insert(new Envelope(), "empty"); // 空包络被忽略
tree.insert(new Envelope(new Coordinate(1, 1)), "point");
System.out.println("STRtree.size=" + tree.size());
}
}
示例输出:
原始点包络: Env[5.0 : 5.0, 5.0 : 5.0], width=0.0
isNull=false
Quadtree.size=1, query=[A]
STRtree.size=1
两点说明:new Envelope(Coordinate) 产生的是退化包络而不是空包络,isNull() 为 false;空包络只能用 new Envelope() 并保持未初始化状态得到。上面的 STRtreeInsertDemo 故意插入了一个未初始化的空包络,条目被静默丢弃,最终 size() 为 1。
13.4 STRtree:Sort-Tile-Recursive 打包 R 树
13.4.1 结构与构建过程
STRtree 是 JTS 默认推荐的通用二维索引,名字来自它的构建算法 Sort-Tile-Recursive(排序—分块—递归)。它属于打包 R 树(packed R-tree):不做动态平衡,而是一次性把所有条目排好序、切成叶子节点,自底向上逐层建树。这带来两个直接后果:
- 构建完成后不能再插入(源码中
AbstractSTRtree.insert有Assert.isTrue(!built, "Cannot insert items into an STR packed R-tree after it has been built.")断言)。 - 树的平衡性由构建算法保证,节点填充率高、层数低、包络重叠小,查询性能通常优于动态 R 树。
构建分三层逻辑:
- 把所有条目按包络中心 x 排序,切成
ceil(sqrt(minLeafCount))个竖直条带(vertical slice),其中minLeafCount = ceil(n / nodeCapacity)。 - 每个条带内部按包络中心 y 排序,每
nodeCapacity个切成一个父节点。 - 对父节点层重复上述过程,直到只剩一个根节点。
构建过程(nodeCapacity = 4,共 12 条):
第 1 步:按中心 x 排序,切成 ceil(sqrt(3)) = 2 个竖条带
┌────────────┬────────────┐
│ 条带 1 │ 条带 2 │
│ ########## │ ########## │
└────────────┴────────────┘
第 2 步:每个条带内按中心 y 排序,每 4 条打成一个叶节点
┌────────────┬────────────┐
│ [0][1][2][3]│ [6][7][8][9]│ ← 叶节点 A / C
│ [4][5] │ [10][11] │ ← 叶节点 B / D
└────────────┴────────────┘
第 3 步:叶节点再打包,得到根节点
┌──────────┐
│ Root │
└──┬────┬──┘
┌─────┘ └─────┐
┌──▼──┐ ┌──▼──┐
│ A B │ │ C D │
└─────┘ └─────┘
节点容量 nodeCapacity 默认是 10,可通过构造器 new STRtree(int nodeCapacity) 调整,源码注释给出的最小建议值是 4。容量越大树越矮、单节点扫描条目越多,对内存缓存友好但比较次数上升;容量小则相反。对绝大多数场景,默认值 10 是稳妥选择。
13.4.2 构建与查询:完整示例
下面的例子把 4 个方块放入 STRtree,然后做包络查询、最近邻查询与 k 近邻查询,输出均为真实运行结果(JTS 1.20.0)。
import java.util.List;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.index.strtree.GeometryItemDistance;
import org.locationtech.jts.index.strtree.STRtree;
import org.locationtech.jts.io.WKTReader;
public class STRtreeBasicDemo {
public static void main(String[] args) throws Exception {
WKTReader reader = new WKTReader();
GeometryFactory gf = new GeometryFactory();
STRtree tree = new STRtree();
String[] wkts = {
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
"POLYGON ((20 0, 30 0, 30 10, 20 10, 20 0))",
"POLYGON ((0 20, 10 20, 10 30, 0 30, 0 20))",
"POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))"
};
for (String wkt : wkts) {
Geometry g = reader.read(wkt);
tree.insert(g.getEnvelopeInternal(), g);
}
// 1) 范围查询:返回 List
Envelope q = new Envelope(5, 25, 5, 25);
List hits = tree.query(q);
System.out.println("范围查询命中:" + hits.size() + " 个");
for (Object o : hits) {
System.out.println(" " + ((Geometry) o).getEnvelopeInternal());
}
// 2) 最近邻:查询对象不必在索引里
Geometry probe = gf.createPoint(new org.locationtech.jts.geom.Coordinate(12, 12));
Object nearest = tree.nearestNeighbour(
probe.getEnvelopeInternal(), probe, new GeometryItemDistance());
System.out.println("最近邻:" + nearest);
// 3) k 近邻:返回按距离由近到远排列的数组
Object[] knn = tree.nearestNeighbour(
probe.getEnvelopeInternal(), probe, new GeometryItemDistance(), 3);
System.out.println("k=3 近邻个数:" + knn.length);
for (Object o : knn) {
System.out.println(" " + ((Geometry) o).getEnvelopeInternal()
+ " 距离=" + ((Geometry) o).distance(probe));
}
}
}
示例输出:
范围查询命中:4 个
Env[0.0 : 10.0, 0.0 : 10.0]
Env[20.0 : 30.0, 0.0 : 10.0]
Env[0.0 : 10.0, 20.0 : 30.0]
Env[20.0 : 30.0, 20.0 : 30.0]
最近邻:POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))
k=3 近邻个数:3
Env[0.0 : 10.0, 0.0 : 10.0] 距离=2.8284271247461903
Env[20.0 : 30.0, 0.0 : 10.0] 距离=8.246211251235321
Env[0.0 : 10.0, 20.0 : 30.0] 距离=8.246211251235321
范围查询用了 [5,25] × [5,25] 的窗口,4 个方块的包络都与它相交,因此全部命中;这再次说明一级过滤不等于精确相交。查询点 (12,12) 到左下、右上两个方块的距离相同,最近邻返回先被遍历到的那个属于实现细节,不应当依赖。
13.4.3 惰性构建与批量装载
AbstractSTRtree 用一个 built 标志记录状态:
insert()只把ItemBoundable追加到itemBoundables列表,不做任何树结构工作。- 第一次调用
query()、size()、depth()、getRoot()时才调用build()。 build()完成后把itemBoundables置为null,释放对插入列表的引用(1.13 起的优化),此后任何insert都会触发断言失败。build()是synchronized的;1.14 起 STRtree 通过它实现“多线程首次查询安全”。
这带来一条实用建议:如果数据是批量到达的,先全部 insert,再显式调用一次 tree.build()。这样建树开销发生在你预期的时间点,也避免第一个查询线程替所有人承担构建成本。
STRtree tree = new STRtree();
for (Geometry g : geometries) {
tree.insert(g.getEnvelopeInternal(), g);
}
tree.build(); // 显式构建,之后只读
// 多线程共享 tree 做查询
反过来,如果需要“边插入边查询”,STRtree 不是合适的选择:第一次查询后就不能再插入。此时应使用 Quadtree(支持动态插入删除),或者干脆维护“可重建的索引快照”——增删达到一定比例后整体重建。打包索引的重建成本是 O(n log n) 量级,在批量更新远多于单条更新的场景里,重建通常比维护动态结构更划算。
13.4.4 最近邻:分支限界算法
nearestNeighbour 系列方法解决的不再是“谁在矩形里”,而是“谁离我最近”。它使用的算法是分支限界(branch and bound),核心数据结构是 BoundablePair 与优先队列:
BoundablePair 队列(按包络距离升序)
初始: [Root × Query]
│ 弹出距离最小的 pair
▼
若 pair 两侧都还是节点 → 展开面积较大的一侧,子对入队
若 pair 两侧都是条目 → 记录当前最优上界
│
▼
队列头距离 ≥ 当前最优上界 → 停止(其余对不可能更近)
BoundablePair.distance() 在“叶对”时调用 ItemDistance.distance() 计算真实距离,在“节点对”时只算两个包络之间的距离作为下界。因为包络距离永远小于等于内部对象的真实距离,这个下界是安全的,可以放心用来剪枝。
四个重载的语义:
| 方法签名 | 含义 | 返回 |
|---|---|---|
nearestNeighbour(ItemDistance) |
在树内部找距离最近的一对条目 | Object[2],或 null |
nearestNeighbour(Envelope env, Object item, ItemDistance) |
找树中离外部对象最近的一个条目 | Object,或 null |
nearestNeighbour(STRtree tree, ItemDistance) |
两棵树之间找最近的一对 | Object[2],或 null |
nearestNeighbour(Envelope env, Object item, ItemDistance, int k) |
找 k 个最近条目 | Object[k](不足则更短),空树返回长度 0 数组 |
还有一个容易被忽略的 isWithinDistance(STRtree tree, ItemDistance itemDist, double maxDistance):它判断两棵树中是否存在距离小于阈值的条目对,使用与最近邻不同的上下界剪枝,能在找到第一对满足条件的条目时立即返回,非常适合“两图层是否相距小于 X 米”这类判定。
自定义 ItemDistance 时要注意:distance(ItemBoundable item1, ItemBoundable item2) 会被传入 ItemBoundable,条目原始对象通过 getItem() 取。若距离计算可能返回 0(例如两几何相交),分支限界会在 distanceLowerBound == 0 时提前结束,这是正确的优化。
import org.locationtech.jts.index.strtree.ItemBoundable;
import org.locationtech.jts.index.strtree.ItemDistance;
import org.locationtech.jts.geom.Geometry;
/**
* 只按包络中心点距离计算的廉价度量。
* 仅用于演示:这种度量不满足“包络距离下界”的前提时,
* 剪枝可能得到错误结果。
*/
public class EnvelopeCentreDistance implements ItemDistance {
@Override
public double distance(ItemBoundable item1, ItemBoundable item2) {
Geometry g1 = (Geometry) item1.getItem();
Geometry g2 = (Geometry) item2.getItem();
return g1.getEnvelopeInternal().centre()
.distance(g2.getEnvelopeInternal().centre());
}
}
上面这段特意保留了“反例”性质:中心点距离不是包络距离的下界,用 nearestNeighbour 的节点对剪枝会得到不可靠结果。自定义 ItemDistance 的正确前提是——对任意一对条目,返回值必须不小于对应包络距离(即真实距离),否则分支限界的安全性被破坏。日常使用 GeometryItemDistance(内部调用 Geometry.distance)即可,它满足这一性质。
13.4.5 结果语义与“去重”
STRtree 的每个条目只存放在唯一一个叶节点里,因此一次 query 中同一个条目最多出现一次,不存在索引层面的重复。实际项目里遇到的“重复”通常来自下面几类:
- 同一个几何对象被重复
insert了多次(数据源重复、循环写错),索引里就有多个条目,查询结果自然重复。 - 空间连接是“多对多”的:一个点可能落在多个相互重叠的面里,一个面也可能匹配多个点,输出的是“配对”而不是“对象”,需要按业务主键去重。
- 用多个候选查询(例如把查询几何拆成多个包络分别查)后简单拼接结果,同一对象会在多个分块里被命中。
- 用
nearestNeighbour(tree, dist)求“最近的一对”时,如果ItemDistance不是反自反的(distance(x, x) != 0),同一元素可能和自己配对。
第一种情况应在入库前去重,或插入前用主键集合做一次 Set 过滤;第二种情况应在结果层按业务键去重;第三种情况用 Set 合并或改用一次查询;第四种情况按 Javadoc 要求保证距离函数反自反(对象与自身距离为 0,且严格正距离)。
STRtree tree = new STRtree();
Set<String> seen = new HashSet<String>();
for (Geometry g : geometries) {
String id = (String) g.getUserData();
if (!seen.add(id)) continue; // 按业务主键去重
tree.insert(g.getEnvelopeInternal(), g);
}
13.4.6 容量与堆内存
STRtree 的每个条目会创建一个 ItemBoundable(持有包络引用与 item 引用),每个父节点是一个 STRtreeNode 加一个 ArrayList。粗算下来,除几何对象本身外,每个条目的索引开销在数十字节量级;节点容量越小、层数越多,对象头开销越大。百万级条目时,索引占用通常在几十 MB 到一百多 MB,具体取决于 JVM 对象布局。如果内存吃紧,可以考虑:
- 只索引必要的图层,不要把整库数据塞进一个树;
- 用
Envelope作为键、用主键(Integer/Long)作为 item,几何放在外部数组或 map 里,避免索引强引用所有几何; - 分块(tile)建多个小树,查询时只访问与窗口相交的分块。
本章第 13.10 节会给出一个可复用的成本估算表。
13.5 Quadtree:动态四叉细分
13.5.1 结构原理
Quadtree(四叉树)用递归细分的方式组织空间:每个节点代表一个正方形(或矩形)区域,节点中心把区域分成四个象限,条目按包络所在象限下放到子节点;跨中心线的条目留在当前节点。与 STRtree 的“一次性打包”相反,Quadtree 是动态结构,插入时按需创建子节点,删除时可以回收空节点。
Quadtree 的节点划分(每个节点最多 4 个子节点)
┌─────────────────────────┐
│ │ │
│ 西北 2 │ 东北 3 │
│ │ │
├────────────┼────────────┤
│ │ │
│ 西南 0 │ 东南 1 │
│ │ │
└─────────────────────────┘
条目落在哪个象限,就进哪个子节点;
跨过中心线的条目(包络同时覆盖左右或上下)留在本节点。
JTS 的 Quadtree 有一个容易被忽略的事实:根节点固定在坐标原点 (0,0)。Root.insert 用 getSubnodeIndex(itemEnv, origin.x, origin.y) 判断条目落在原点划分的哪个象限,然后按需创建覆盖范围更大的子节点(Node.createExpanded)。因此数据全部位于原点同一侧时,树会退化成一条“单边链”,深度偏大;这也是 Quadtree 在地理坐标系(经度、纬度都为正)下表现不如 STRtree 的原因之一。
13.5.2 层级从哪来
节点层级不是人为指定,而是由条目包络的尺寸算出来的。Key.computeQuadLevel(env) 取宽高的较大值 dMax,用 DoubleBits.exponent(dMax) + 1 得到层级,再把坐标向下取整到 2^level 的格子原点。这样处理的含义是:包络越大,层级越大(格子越粗),越小则层级越小(格子越细)。Root.insert 中如果发现现有子节点包络不能包含新条目,就创建更大的节点并把旧节点塞进去,实现动态扩张。
由于层级依赖浮点指数,Quadtree 对坐标数量级很敏感。经度 100 与纬度 30 的包络,dMax = 100,exponent(100) = 6,层级为 7;换成投影坐标(米)后 dMax 可能是几十万,层级随之变大。数值本身不影响正确性,但会影响树的形状与深度。
13.5.3 使用示例
import java.util.List;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.index.quadtree.Quadtree;
import org.locationtech.jts.io.WKTReader;
public class QuadtreeDemo {
public static void main(String[] args) throws Exception {
WKTReader reader = new WKTReader();
Quadtree tree = new Quadtree();
String[] wkts = {
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
"POLYGON ((20 0, 30 0, 30 10, 20 10, 20 0))",
"POLYGON ((0 20, 10 20, 10 30, 0 30, 0 20))",
"POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))"
};
for (String wkt : wkts) {
Geometry g = reader.read(wkt);
tree.insert(g.getEnvelopeInternal(), g);
}
Envelope window = new Envelope(5, 25, 5, 25);
List hits = tree.query(window);
System.out.println("size=" + tree.size() + ", query=" + hits.size()
+ ", depth=" + tree.depth() + ", queryAll=" + tree.queryAll().size());
// 删除:Quadtree 支持动态维护
Geometry first = reader.read(wkts[0]);
boolean removed = tree.remove(first.getEnvelopeInternal(), first);
System.out.println("remove=" + removed + ", size=" + tree.size()
+ ", query=" + tree.query(window).size());
}
}
示例输出(删除前后各查询一次):
size=4, query=4, depth=3, queryAll=4
remove=true, size=3, query=3
这里 remove=true 能删掉“重新 read 出来”的等价几何,是因为 Geometry.equals(Object) 在 JTS 里被重写为 equalsExact(逐顶点结构比较),不是引用比较。这一点很容易记反,值得展开:
| 比较方法 | 语义 | 用途 |
|---|---|---|
equals(Object) |
equalsExact,要求结构逐点相等(含顶点顺序) |
索引/集合去重、remove |
equalsExact(Geometry) |
同上,显式命名 | 结构比较 |
equalsTopo(Geometry) |
拓扑相等(允许顶点顺序、方向不同) | 业务“同一个面”判断 |
equalsNorm(Geometry) |
规范化后再 equalsExact(允许起终点不同、方向不同) | 规范化后比较 |
所以“按对象删除”在 JTS 里实际是按“结构完全相同”删除。两个几何拓扑相同但顶点起点不同,equals 返回 false,remove 同样删不掉:
Geometry a = reader.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))");
Geometry b = reader.read("POLYGON ((10 0, 10 10, 0 10, 0 0, 10 0))");
System.out.println("a.equals(b)=" + a.equals(b)); // false
System.out.println("a.equalsTopo(b)=" + a.equalsTopo(b)); // true
System.out.println("a.equalsNorm(b)=" + a.equalsNorm(b)); // true 或 false,取决于规范化结果
这也是为什么“索引 item 存业务主键”更稳妥:主键的相等语义明确,不受几何结构、顶点顺序、精度的影响。若一定要按几何删除,插入前保存对象引用是最省事的做法;跨会话/跨进程删除则只能依赖主键或 equalsExact。
13.5.4 Quadtree 与 STRtree 的取舍
| 维度 | Quadtree |
STRtree |
|---|---|---|
| 构建方式 | 动态、插入即建 | 惰性打包,首次查询建 |
| 构建后再插入 | 支持 | 断言失败 |
| 删除 | 支持(按引用) | 支持(构建前有效) |
| 查询性能 | 中等,依赖数据分布 | 通常更好、更稳定 |
| 跨原点数据 | 单侧数据易退化 | 不受影响 |
| 线程安全 | 1.19 起内部条目列表同步 | build() 同步,构建后只读安全 |
| 适合场景 | 频繁增删、数据量中等 | 批量只读、空间连接、最近邻 |
结论很直接:默认用 STRtree;只有当索引生命周期内需要频繁插入删除、且数据量不大(几万到几十万)时,Quadtree 才值得考虑。JTS 自身在老代码里保留 Quadtree,更多是兼容与教学价值。
13.6 一维索引:SIRtree、IntervalRTree 与顶点序列索引
13.6.1 SIRtree:一维的 STRtree
SIRtree 是 AbstractSTRtree 的一维子类,键不是 Envelope 而是 Interval(min、max 两个 double)。它的 API 与 STRtree 不同:
public class SIRtree extends AbstractSTRtree {
public void insert(double x1, double x2, Object item);
public List query(double x); // 查询包含点 x 的区间
public List query(double x1, double x2); // 查询与 [x1,x2] 相交的区间
}
典型用途是“按时间/里程/价格区间检索”:例如把每条道路的桩号范围 [k0, k1] 放进 SIRtree,给定一个桩号就能查出所有覆盖它的路段。注意它不实现 SpatialIndex 接口,因为一维区间查询不是 Envelope 查询。
import java.util.List;
import org.locationtech.jts.index.strtree.SIRtree;
public class SIRtreeDemo {
public static void main(String[] args) {
SIRtree tree = new SIRtree();
tree.insert(1, 5, "A");
tree.insert(3, 9, "B");
tree.insert(20, 30, "C");
System.out.println("query(4) = " + tree.query(4));
System.out.println("query(10) = " + tree.query(10));
System.out.println("query(2,6)= " + tree.query(2, 6));
}
}
示例输出:
query(4) = [A, B]
query(10) = []
query(2,6)= [A, B]
13.6.2 SortedPackedIntervalRTree:只读区间索引
org.locationtech.jts.index.intervalrtree.SortedPackedIntervalRTree 先把所有区间按起点排序,再两两合并成静态二叉树。它的插入必须在第一次查询之前完成,源码中的限制很明确:
public void insert(double min, double max, Object item) {
if (root != null)
throw new IllegalStateException("Index cannot be added to once it has been queried");
leaves.add(new IntervalRTreeLeafNode(min, max, item));
}
查询接口只有 visitor 形式,没有返回 List 的重载:
public void query(double min, double max, ItemVisitor visitor);
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.index.ItemVisitor;
import org.locationtech.jts.index.intervalrtree.SortedPackedIntervalRTree;
public class IntervalRTreeDemo {
public static void main(String[] args) {
SortedPackedIntervalRTree tree = new SortedPackedIntervalRTree();
tree.insert(1, 5, "A");
tree.insert(3, 9, "B");
tree.insert(20, 30, "C");
final List<Object> hits = new ArrayList<Object>();
tree.query(4, 6, new ItemVisitor() {
@Override
public void visitItem(Object item) { hits.add(item); }
});
System.out.println("query(4,6)=" + hits);
}
}
示例输出:
query(4,6)=[A, B]
它适合“一次性装载、长期只读”的场景,比如样式渲染时按比例尺区间查符号、按高程带查配色。与 SIRtree 相比,它没有节点容量与比较器的开销,构建更快,但伸缩性差(不能增删)。
13.6.3 VertexSequencePackedRtree:给顶点数组建索引
org.locationtech.jts.index.VertexSequencePackedRtree 的键是 Coordinate[] 的下标,构造时一次性为整个数组建包络树,query(Envelope) 返回命中的下标数组 int[]。它被 PolygonEarClipper 用来加速“耳朵裁剪时判断三角形内是否有其他顶点”(第 15 章会用到)。
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.index.VertexSequencePackedRtree;
public class VertexRtreeDemo {
public static void main(String[] args) {
Coordinate[] pts = new Coordinate[] {
new Coordinate(0, 0), new Coordinate(10, 0),
new Coordinate(10, 10), new Coordinate(0, 10),
new Coordinate(5, 5)
};
VertexSequencePackedRtree index = new VertexSequencePackedRtree(pts);
int[] hits = index.query(new Envelope(4, 6, 4, 6));
System.out.println("命中下标:");
for (int i : hits) {
System.out.println(" " + i + " -> " + pts[i]);
}
}
}
示例输出:
命中下标:
4 -> (5.0, 5.0)
该索引只能按构造时的数组顺序使用;remove(int index) 只是把下标记为已删除,不移动数组。若要复用,必须重新构造。
13.6.4 扫描线索引
org.locationtech.jts.index.sweepline.SweepLineIndex 是算法内部构件:把一维区间转成“插入事件”和“删除事件”,排序后扫描,用 SweepLineOverlapAction 回调重叠对。它与业务代码几乎无交集,但理解它有助于读懂老版本叠加与缓冲实现。当前主线叠加已经走 OverlayNG,扫描线更多出现在 buffer 与部分旧路径中。
13.7 HPRtree:Hilbert 曲线与批量装载
13.7.1 Hilbert 排序
HPRtree 是 Hilbert Packed R-tree 的缩写。它和 STRtree 一样是打包索引,区别在于排序键:STRtree 用“先 x 后 y”的条带切分,HPRtree 用 Hilbert 曲线把二维空间映射成一维码值,再按码值排序、按节点容量切块。
Hilbert 曲线的性质是“空间邻近的点,一维码值也邻近”,因此按码值排序后连续切块得到的节点,包络通常又小又方,磁盘/缓存局部性更好。JTS 的实现里,Hilbert 层级固定为 HILBERT_LEVEL = 12(即 4096×4096 的网格),节点容量默认 DEFAULT_NODE_CAPACITY = 16。
Hilbert 曲线(level = 2,网格 4×4)的填充顺序
┌───┬───┬───┬───┐
│ 1 │ 2 │15 │16 │
├───┼───┼───┼───┤
│ 4 │ 3 │14 │13 │
├───┼───┼───┼───┤
│ 5 │ 8 │ 9 │12 │
├───┼───┼───┼───┤
│ 6 │ 7 │10 │11 │
└───┴───┴───┴───┘
编码时取条目包络中心所在格子,
一维码值相近 => 二维位置相近 => 节点包络紧凑
13.7.2 1.20 的性能重写
版本历史中 1.20.0 有一条不起眼但影响很大的记录:Improve HPRtree performance (#1012),同一提交把 MCIndexNoder 的默认索引从 STRtree 换成了 HPRtree。重写后的结构要点:
- 用扁平数组代替对象树:
double[] nodeBounds(每节点 4 个 double)、double[] itemBounds、Object[] itemValues、int[] layerStartIndex,查询时没有节点对象分配。 - 排序用“分区快排”:
quickSortItemsIntoNodes只排到“左右指针落在同一个节点块内”就停止,因为块内查询本来就是顺序扫描,不需要全序。 - 查询是循环而非递归:
queryTopLayer从顶层逐层下钻,queryNode用intersects手工比较四个边界值。 - 构建是惰性的:首次
query或显式build()时执行prepareIndex()与prepareItems(),之后itemsToLoad置空让 GC 回收。
这些优化让 HPRtree 在“批量装载 + 大量范围查询”的场景下比 STRtree 更快、更省内存,也解释了为什么 1.20 的叠加分析、节点化在长线段多的数据上明显提速。
13.7.3 使用示例
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.index.hprtree.HPRtree;
public class HPRtreeDemo {
public static void main(String[] args) {
GeometryFactory gf = new GeometryFactory();
HPRtree tree = new HPRtree(); // 默认节点容量 16
for (int i = 0; i < 20; i++) {
Point p = gf.createPoint(new Coordinate(i, 0));
tree.insert(p.getEnvelopeInternal(), p);
}
System.out.println("size=" + tree.size());
// 惰性构建;items 少于等于节点容量时不会建内部节点
List hits = tree.query(new Envelope(5, 14, 0, 0));
System.out.println("query(5..14)=" + hits.size());
System.out.println("节点包络数=" + tree.getBounds().length);
}
}
示例输出:
size=20
query(5..14)=10
节点包络数=2
20 个条目、节点容量 16,被切成 2 个叶节点,所以顶层只有 2 个包络;如果条目数不超过 16,prepareIndex 直接跳过,getBounds() 会抛 NullPointerException——这是使用 HPRtree 时最好记住的一个边界。
13.7.4 限制与适用场景
| 特性 | STRtree |
HPRtree |
|---|---|---|
| 排序依据 | x 条带 + y 排序(STR) | Hilbert 码 |
| 节点容量默认值 | 10 | 16 |
| 内部存储 | 对象节点 + 列表 | 扁平 double 数组 |
支持 remove |
支持(构建前) | 不支持,remove 恒返回 false |
| 构建后插入 | 断言失败 | 抛 IllegalStateException |
| 最近邻查询 | 支持(nearestNeighbour) |
不支持 |
| 序列化 | 支持(Serializable) |
不支持 |
| 适用场景 | 通用、需要最近邻/删除 | 只读批量数据、内部算法加速 |
因此:业务代码做空间查询优先 STRtree;只有明确“只读 + 批量 + 不需要最近邻”时,HPRtree 才可能带来性能收益。JTS 内部之所以敢全线换成 HPRtree,是因为节点化、叠加这些流程都满足“一次装入、只查不改、不需要最近邻”。
13.8 KdTree:点集专用索引
13.8.1 结构
KdTree 是二叉空间划分树,每个节点保存一个点,按 x、y 交替切分:根节点按 x 分左右,下一层按 y 分上下,如此循环。JTS 的 KdNode 用 axisIsX 标志记录当前节点按哪个轴切分,子节点轴取反。
两个关键设计:
- 容差去重:构造器
KdTree(double tolerance)接受容差。插入时如果新点与已有点距离不超过容差,不创建新节点,而是把已有节点的count加一。这点对“坐标抖动导致的重复点”治理非常有用。 - 重复点计数:
KdNode.getCount()记录重合点个数,toCoordinates(nodes, includeRepeated)可决定输出时是否展开重复点。
KdTree 插入 (5,5)、(3,7)、(8,2)、(3.1,7.05),tolerance=0.2
(5,5) axisX
/ \
(3,7) (8,2)
axisY axisY
|
(3.1,7.05) 与 (3,7) 距离约 0.11 < 0.2
=> 不新建节点,(3,7).count 变为 2
13.8.2 API 一览
| 方法 | 说明 |
|---|---|
KdTree() / KdTree(double tolerance) |
无容差 / 带容差构造 |
insert(Coordinate) / insert(Coordinate, Object data) |
插入点,返回对应 KdNode |
query(Envelope) |
返回 List<KdNode> |
query(Envelope, KdNodeVisitor) |
访问者版本 |
query(Coordinate) |
等值查找(equals2D),未找到返回 null |
findBestMatchNode(Coordinate) |
找容差范围内最近的点(1.20 为 private,1.21 起 public) |
nearestNeighbor(Coordinate) |
1.21 新增,最近点(单个) |
nearestNeighbors(Coordinate, int k) |
1.21 新增,最近 k 个点 |
getNodes()(1.21 起) / size() / depth() |
遍历与统计 |
toCoordinates(Collection, boolean) |
节点转坐标数组 |
nearestNeighbor 与 nearestNeighbors 是 1.21 才加入的(版本历史 #1114),1.20 及更早版本要用 findBestMatchNode 或自己遍历。教程示例以 1.20 可用 API 为主,1.21 特性会单独标注。
13.8.3 使用示例
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.index.kdtree.KdNode;
import org.locationtech.jts.index.kdtree.KdTree;
public class KdTreeDemo {
public static void main(String[] args) {
KdTree tree = new KdTree(0.5); // 容差 0.5
tree.insert(new Coordinate(0, 0), "P0");
tree.insert(new Coordinate(1, 0), "P1");
KdNode dup = tree.insert(new Coordinate(0.2, 0), "P2");
System.out.println("size=" + tree.size() + ", depth=" + tree.depth());
System.out.println("重合点 data=" + dup.getData() + ", count=" + dup.getCount());
List hits = tree.query(new Envelope(-0.1, 0.2, -0.1, 0.1));
System.out.println("query 命中节点数=" + hits.size());
// 1.20 没有 getNodes(),想遍历全部节点就查一个大包络
List all = tree.query(new Envelope(-1, 2, -1, 1));
Coordinate[] coords = KdTree.toCoordinates(all, true);
System.out.println("全部节点数=" + all.size()
+ ", 展开重复点后坐标数=" + coords.length);
// 1.20 的 findBestMatchNode 是 private,手动找最近点
Coordinate q = new Coordinate(0.9, 0.1);
KdNode best = null;
double bestDist = Double.MAX_VALUE;
for (Object o : all) {
KdNode n = (KdNode) o;
double d = n.getCoordinate().distance(q);
if (d < bestDist) {
bestDist = d;
best = n;
}
}
System.out.println("最近点=" + best.getCoordinate() + ", 距离=" + bestDist);
// 点等值查询:按 snap 后的存储坐标查,按原始坐标查会返回 null
KdNode stored = tree.query(new Coordinate(0, 0));
System.out.println("query(0,0)=" + (stored == null ? null : stored.getCoordinate()));
KdNode inserted = tree.query(new Coordinate(0.2, 0));
System.out.println("query(0.2,0)=" + (inserted == null ? null : inserted.getCoordinate()));
}
}
示例输出:
size=2, depth=2
重合点 data=P0, count=2
query 命中节点数=1
全部节点数=2, 展开重复点后坐标数=3
最近点=(1.0, 0.0, NaN), 距离=0.1414213562373095
query(0,0)=(0.0, 0.0, NaN)
query(0.2,0)=null
注意最后两行:(0.2,0) 被捕捉到 (0,0) 后,节点里存的是 (0,0),按原始坐标 (0.2,0) 做点等值查询返回 null;按存储坐标 (0,0) 才能查到。KdTree 的 query(Coordinate) 是 equals2D 精确匹配,不是“容差匹配”,容差只在 insert 与 findBestMatchNode 中生效。
1.21 起这两个操作都有公开 API:
// JTS 1.21+
KdNode nearest = tree.nearestNeighbor(new Coordinate(0.9, 0.1));
List<KdNode> nearest3 = tree.nearestNeighbors(new Coordinate(0.9, 0.1), 3);
nearestNeighbor 内部用显式栈做深度优先 + “近侧优先、远侧按距离剪枝”,nearestNeighbors 用大小为 k 的最大堆维护当前最差距离。两者都不依赖容差,返回的是实际最近节点。
注意 size() 返回的是节点数(2),不是插入次数(3);(0.2,0) 因为落在 (0,0) 的容差范围内被合并,data 保留先插入的 "P0"。如果业务上必须保留每个点,容差请传 0。
13.8.4 KdTree 在 JTS 内部的应用
KdTree 不只给业务用,内部至少两处关键算法依赖它:
SnappingPointIndex(第 14 章捕捉节点化):用KdTree(snapTolerance)存放捕捉点,相同容差内的点被吸附到同一点。ConformingDelaunayTriangulator(第 15 章约束三角剖分):用 KdTree 做站点索引与容差合并,findNonGabrielPoint通过 KdTree 的包络查询找侵占点。
这也是理解 KdTree 价值的角度:它不是“通用空间索引”,而是“点集 + 容差”这一特定问题的专用结构,比 STRtree 更省内存、更快。
13.9 内部索引:MonotoneChain 与 SegmentMCIndex
13.9.1 为什么要把线拆成单调链
一条复杂线串(例如一条几百个顶点的道路)的包络可能覆盖很大区域,直接用它的一级过滤几乎筛不掉东西。JTS 的解法是先把线串拆成单调链(monotone chain):从某个顶点出发,沿 x、y 都单调(同向)的一段连续顶点。每条链的包络都小而贴合,查询时按链建索引,命中哪条链只需要判断那条链上的线段。
一条折线被拆成 3 条单调链(按象限变化切分)
(0,3)┐
│ ╲
│ ╲ (2,1)
│ ╲
(1,0)┘ ╲
╲(4,0)
链 1: (0,3)-(2,1) 象限 SE
链 2: (2,1)-(4,0) 象限 SE(同象限可继续)
链 3: 遇到象限变化处切开
实际实现不是“同象限就不断开”这么简单,MonotoneChainBuilder.findChainEnd 用 Quadrant.quadrant(p[i-1], p[i]) 判断相邻线段方向,方向象限变化处断开,并跳过重复点。拆分出的 MonotoneChain 保存起止下标、上下文对象(一般是原 SegmentString)、包络与 id。
13.9.2 MonotoneChain 的 API
public class MonotoneChain {
public MonotoneChain(Coordinate[] pts, int start, int end, Object context);
public Envelope getEnvelope(); // 懒计算包络
public Envelope getEnvelope(double expansionDistance); // 带外扩,供容差查询
public void select(Envelope searchEnv, MonotoneChainSelectAction mcs);
public void computeOverlaps(MonotoneChain mc, MonotoneChainOverlapAction mco);
public void computeOverlaps(MonotoneChain mc, double overlapTolerance,
MonotoneChainOverlapAction mco);
}
select 与 computeOverlaps 都是二分递归:把链一分为二,用包络判断哪半可能相交,直到剩下一条线段才回调。这样即使一条链有上千个顶点,也能在 O(log n) 内定位到真正可能相交的线段。
13.9.3 三处使用者
| 使用位置 | 索引类型 | 用途 |
|---|---|---|
MCIndexNoder |
1.20 起为 HPRtree |
节点化时找线段交点 |
MCIndexSegmentSetMutualIntersector |
STRtree |
两组线段集合求交(判断是否相交) |
operation.buffer.SegmentMCIndex |
STRtree |
缓冲/偏移曲线中按包络找邻近线段 |
MCIndexNoder 的 1.20 变更值得记住:源码里 private SpatialIndex index = new HPRtree();,而 1.19 及以前是 new STRtree()。因为节点化是“一次装入、反复查询、绝不删除”的批处理,HPRtree 的扁平数组与 Hilbert 排序正好对口。
13.9.4 手写一个线段索引
下面例子演示用 MonotoneChainBuilder + STRtree 给一串折线建“链级”索引,然后查询与某个矩形相交的链,并打印每条链覆盖的顶点范围。这类写法在自定义算法(例如自研叠加、裁剪、简化前的候选筛选)里很常用。
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.index.chain.MonotoneChain;
import org.locationtech.jts.index.chain.MonotoneChainBuilder;
import org.locationtech.jts.index.strtree.STRtree;
public class ChainIndexDemo {
public static void main(String[] args) {
Coordinate[] pts = new Coordinate[] {
new Coordinate(0, 0), new Coordinate(1, 1), new Coordinate(2, 2),
new Coordinate(3, 1), new Coordinate(4, 0), new Coordinate(4, -2)
};
STRtree index = new STRtree();
List chains = MonotoneChainBuilder.getChains(pts, "road-1");
for (Object o : chains) {
MonotoneChain mc = (MonotoneChain) o;
index.insert(mc.getEnvelope(), mc);
}
System.out.println("链数=" + chains.size());
for (Object o : chains) {
MonotoneChain mc = (MonotoneChain) o;
System.out.println(" 链 context=" + mc.getContext()
+ " 顶点范围=" + mc.getStartIndex() + ".." + mc.getEndIndex()
+ " env=" + mc.getEnvelope());
}
Envelope window = new Envelope(2.5, 4.1, -0.5, 1.1);
System.out.println("窗口命中的链:");
for (Object o : index.query(window)) {
MonotoneChain mc = (MonotoneChain) o;
System.out.println(" " + mc.getStartIndex() + ".." + mc.getEndIndex());
}
}
}
示例输出:
链数=2
链 context=road-1 顶点范围=0..2 env=Env[0.0 : 2.0, 0.0 : 2.0]
链 context=road-1 顶点范围=2..5 env=Env[2.0 : 4.0, -2.0 : 2.0]
窗口命中的链:
2..5
这个例子也说明了单调链的局限:链是“按方向连续性”切出来的,不是按几何长度均衡切分,所以链长可能很不均匀。对超长链,可以再按顶点数二次切分(JTS 内部不做,因为它依赖 computeOverlaps 的二分递归来抵消)。如果要在业务代码里复用这套思路,建议在建索引前对输入做 CoordinateArrays.removeRepeatedPoints 预处理,避免重复点产生零长度链。
13.10 构建成本、内存与索引复用
13.10.1 成本模型
| 索引 | 构建复杂度 | 查询复杂度(平均) | 每条目额外开销 | 增量维护 |
|---|---|---|---|---|
STRtree |
O(n log n) | O(log n + k) | ItemBoundable + 叶/父节点对象 |
构建前可 insert/remove |
HPRtree |
O(n log n) | O(log n + k) | 4 个 double + 1 个引用(扁平数组) | 不支持 |
Quadtree |
近 O(n log n) | O(log n + k),随分布波动 | 每节点 4 个子节点引用 + 条目列表 | 支持 |
KdTree |
O(n log n) 期望 | O(sqrt(n) + k)(二维) | 每点一个 KdNode(点 + 左右引用 + count) |
不支持删除 |
SIRtree |
O(n log n) | O(log n + k) | 一维 Interval + 节点 |
构建前可 insert |
其中 k 是命中条目数。需要强调的是:索引的收益取决于“剪枝率”。如果查询窗口覆盖了数据范围的一大半,索引路径与全表扫描的实际工作量会迅速接近;此时反而应检查是不是查询写法有问题(例如用整幅图的范围去查局部)。
13.10.2 一个粗略的内存测量
JVM 上精确测量对象内存要用 JOL 之类的工具。日常可以用 Runtime 做量级估算,但要接受噪声:GC 时机、TLAB、JIT 都会影响数字。
public class MemoryProbe {
public static void main(String[] args) {
System.gc();
pause(300);
long before = used();
// 在这里构建索引
Object index = buildIndex();
System.gc();
pause(300);
long after = used();
System.out.println("索引占用约 " + (after - before) / 1024 + " KB");
System.out.println(index != null);
}
private static Object buildIndex() { return new Object(); }
private static long used() {
Runtime rt = Runtime.getRuntime();
return rt.totalMemory() - rt.freeMemory();
}
private static void pause(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
输出数值因机器与 JVM 参数而异,这里只强调方法:比较“建索引前”和“建索引后”的堆占用,且要保证测量之间没有其他大对象分配。真实项目中更实用的指标是“每万条目的 KB 数”,用同一份数据重复测几次取中位数。
13.10.3 索引复用原则
- 索引跟着数据集的生命周期走:静态底图数据(行政区划、路网、POI 底库)建一次索引长期复用;不要每次请求都重建。
- 请求级查询不要建索引:如果一次查询只查一次,建索引的成本可能高于扫描。经验阈值是“同一数据集上的查询次数 × 平均命中率 > 建索引开销”,实践中几百次以上查询就值得建。
- 增量数据用“追加批 + 定期重建”:STRtree 构建后不可插入,所以增量更新应维护一个小型 side buffer(内存列表或 Quadtree),查询时先查主索引再查 buffer,buffer 增长到一定比例后整体重建。
- 按瓦片分治:全国范围数据按
tile切分,每个瓦片一个小 STRtree,查询时先算瓦片命中再进具体树。这样单树规模可控、构建快、并行友好,也是 GeoServer 等服务的常见做法。 - 索引 item 存主键而非几何:几何对象通常由缓存/存储另行持有。item 存
Long id再回表,可以减少索引对几何对象的强引用,也避免remove时因引用不一致而失败。
13.11 实战:大点集与面图层的空间连接
13.11.1 问题
给定一个面图层(例如 4 个管理网格)和一个点图层(例如 5 个传感器的坐标),要求:
- 每个点落在哪些管理网格内(一对多);
- 每个网格命中多少个点;
- 落在网格边界上的点要能被识别出来(避免统计时被重复计入或漏计)。
这是最典型的空间连接。朴素写法是双重循环 O(n×m);用 STRtree 把面图层索引起来后,复杂度降到 O(n log m + 命中对数)。
13.11.2 完整实现
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.index.strtree.STRtree;
import org.locationtech.jts.io.WKTReader;
public class SpatialJoinDemo {
public static void main(String[] args) throws Exception {
WKTReader reader = new WKTReader();
GeometryFactory gf = new GeometryFactory();
// 1. 面图层:四个 10x10 的网格,ID 即用户数据
String[] zoneWkts = {
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))",
"POLYGON ((10 0, 20 0, 20 10, 10 10, 10 0))",
"POLYGON ((0 10, 10 10, 10 20, 0 20, 0 10))",
"POLYGON ((10 10, 20 10, 20 20, 10 20, 10 10))"
};
STRtree zoneIndex = new STRtree();
List<Geometry> zones = new ArrayList<Geometry>();
for (int i = 0; i < zoneWkts.length; i++) {
Geometry zone = reader.read(zoneWkts[i]);
zone.setUserData("Z" + (i + 1));
zones.add(zone);
zoneIndex.insert(zone.getEnvelopeInternal(), zone);
}
// 2. 点图层:注意 (10,10) 落在四个网格的公共角点上
double[][] coords = { {5, 5}, {15, 5}, {5, 15}, {15, 15}, {10, 10} };
List<Point> points = new ArrayList<Point>();
for (int i = 0; i < coords.length; i++) {
Point p = gf.createPoint(new Coordinate(coords[i][0], coords[i][1]));
p.setUserData("P" + (i + 1));
points.add(p);
}
// 3. 点 -> 面:一次索引查询 + 精确谓词
Map<String, Set<String>> zoneToPoints = new HashMap<String, Set<String>>();
List<String> boundaryPoints = new ArrayList<String>();
for (Point p : points) {
List candidates = zoneIndex.query(p.getEnvelopeInternal());
Set<String> matched = new HashSet<String>();
for (Object o : candidates) {
Geometry zone = (Geometry) o;
if (!zone.intersects(p)) continue;
matched.add((String) zone.getUserData());
if (!zone.contains(p)) {
// intersects 为真但 contains 为假 => 点落在网格边界上
boundaryPoints.add((String) p.getUserData());
}
}
for (String zid : matched) {
if (!zoneToPoints.containsKey(zid)) {
zoneToPoints.put(zid, new HashSet<String>());
}
zoneToPoints.get(zid).add((String) p.getUserData());
}
}
// 4. 统计输出
int pairCount = 0;
for (Map.Entry<String, Set<String>> e : zoneToPoints.entrySet()) {
pairCount += e.getValue().size();
System.out.println(e.getKey() + " -> " + e.getValue());
}
System.out.println("命中的唯一网格数=" + zoneToPoints.size());
System.out.println("一对多配对数=" + pairCount);
System.out.println("边界点(去重前)=" + boundaryPoints);
System.out.println("边界点(去重后)=" + new HashSet<String>(boundaryPoints));
}
}
示例输出:
Z2 -> [P2, P5]
Z1 -> [P1, P5]
Z4 -> [P4, P5]
Z3 -> [P3, P5]
命中的唯一网格数=4
一对多配对数=8
边界点(去重前)=[P5, P5, P5, P5]
边界点(去重后)=[P5]
输出里有三个值得记住的点:
- 4 个普通点各命中 1 个网格,角点
P5同时命中 4 个网格,所以配对数为 8 而不是 5。这是空间连接的正常结果(DE-9IM 下点与边界相交),不是索引 bug。 - 统计“每个网格的点数”时,边界点会被重复计入 4 个网格。业务上通常需要先定义规则:边界点归属唯一网格(例如规定“左闭右开”),或单独标记为“边界待定”。
contains与intersects的差异正好可以用来识别边界点:intersects为真而contains为假,说明两者只共享边界。
13.11.3 反向连接与去重
如果面少点多,反向做法(把点建索引、遍历面查点)会更快。两种方向的查询复杂度是 O(点数 × log 面数) 与 O(面数 × log 点数),按较小的一侧建索引是通用优化:
STRtree pointIndex = new STRtree();
for (Point p : points) {
pointIndex.insert(p.getEnvelopeInternal(), p);
}
for (Geometry zone : zones) {
List candidates = pointIndex.query(zone.getEnvelopeInternal());
int inside = 0;
for (Object o : candidates) {
if (zone.contains((Point) o)) inside++;
}
System.out.println(zone.getUserData() + " 内部点数=" + inside);
}
对“几百万点、几百面”的场景,这个反向写法通常比正向上百万次查询快得多。至于“最近面”而不是“落在面内”,可以直接用 IndexedFacetDistance(内部用 STRtree 索引 FacetSequence)或 STRtree.nearestNeighbour(" + "new GeometryItemDistance()),两者都能避免 O(n×m)。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.operation.distance.IndexedFacetDistance;
public class NearestZoneDemo {
public static void main(String[] args) throws Exception {
// zones / probe 的构造省略,假设已有 Geometry 列表
Geometry probe = new org.locationtech.jts.io.WKTReader().read("POINT (25 25)");
double min = Double.MAX_VALUE;
Geometry nearest = null;
for (Geometry zone : zones) {
double d = IndexedFacetDistance.distance(zone, probe);
if (d < min) { min = d; nearest = zone; }
}
System.out.println("最近网格=" + nearest.getUserData() + ", 距离=" + min);
}
}
上面的 zones 变量需要与前一段示例共享;实际使用时把 IndexedFacetDistance 缓存为字段(它内部持有已建好的 STRtree),只对探针几何重复调用 distance,可避免每次重建索引。
13.12 常见问题与避坑
1. 空包络导致对象“凭空消失”。
STRtree.insert 对 isNull() 的包络直接 return,空几何插不进索引。建索引前先过滤 g.isEmpty(),或者至少断言插入后 tree.size() 与预期一致。
2. 复用 Envelope 变量造成全树包络错乱。
索引保存的是包络引用。循环里 env.init(...) 复用同一个对象会让所有条目的包络变成最后一个值。每次插入都传 g.getEnvelopeInternal() 或 new Envelope(...)。
3. 构建后又 insert,触发断言。
AbstractSTRtree.insert 在 built 后抛 IllegalStateException(Assert 默认开启断言时)或产生未定义行为。插入必须在首次查询/size/depth 之前完成;显式 build() 后同样不可插入。
4. remove 删不掉“看起来一样”的对象。
STRtree.remove 与 Quadtree.remove 都是按 item 的 equals 匹配,几何默认按引用相等。要删除必须传入插入时的同一个对象,或者 item 改用可比较的主键。
5. 把候选当结果。
索引只做包络过滤,复杂几何的误报率可能很高。任何要求精确语义的流程都必须再跑 intersects/contains/relate。
6. 多线程共享未构建的索引。
build() 是 synchronized 的,但 insert 不是;多线程同时插入会让 ArrayList 内部结构损坏。正确模式是“单线程建好 → build() → 多线程只读查询”。
7. HPRtree 的 getBounds() 在条目不超过节点容量时 NPE。
因为此时 trees 未构建、nodeBounds 仍为 null。不要在空树或小树上调用;调试时先 size() > 16。
8. KdTree 容差不是“精度”。 容差只影响“是否合并为同一点”,不改变坐标值;被合并的点保留先插入的坐标与 data。业务需要精确点集时容差传 0。
9. 用中心点距离之类的自定义度量做最近邻。
nearestNeighbour 的剪枝前提是度量值不小于包络距离。违反前提可能返回错误的“最近对象”。稳妥做法是 GeometryItemDistance,或自己实现时先检查下界条件。
10. 索引越多越好? 每个索引都占用堆内存、延长 GC 停顿。百万级图层建多个索引前,先评估是否能用“一次查询 + 结果复用”替代。
11. Quadtree 对全正坐标数据退化。 根节点固定在原点,第一象限数据只能往一个方向展开。地理经纬度数据(全为正)尤其明显,优先 STRtree。
13.13 本章小结
- 空间索引提供的是一级过滤:只比较
Envelope,允许误报;精确判定必须自己补二级过滤。 SpatialIndex只有 insert / query / query+visitor / remove 四个方法;结果集大时优先用ItemVisitor避免中间集合。STRtree是 Sort-Tile-Recursive 打包 R 树:默认节点容量 10,惰性构建,构建后不可插入,适合批量只读、空间连接与最近邻。- 最近邻使用分支限界:
BoundablePair+ 优先队列,支持单点最近邻、树内最近对、跨树最近对与 k 近邻;自定义ItemDistance必须满足下界性质。 Quadtree是动态四叉细分,支持增删但查询性能依赖数据分布,根节点固定在原点,地理坐标数据慎用。- 一维场景用
SIRtree(可增删)或SortedPackedIntervalRTree(只读、必须插完再查);VertexSequencePackedRtree是顶点数组的内部索引。 HPRtree用 Hilbert 曲线排序 + 扁平数组,1.20 起内部节点化默认改用它;不支持 remove、最近邻与序列化。KdTree面向点集,自带容差去重与重复点计数,是捕捉索引与约束三角剖分的底层依赖。- 性能收益来自“包络贴合度”:长斜线、复杂面应拆成
MonotoneChain或按瓦片建树,而不是指望一个巨大包络。 - 索引复用的核心原则:按数据生命周期建树、按查询规模选索引、按主键存 item、构建后只读共享。
13.14 相关资源
- JTS Javadoc:
org.locationtech.jts.index包与子包 https://locationtech.github.io/jts/javadoc/org/locationtech/jts/index/package-summary.html - JTS Javadoc:
STRtreehttps://locationtech.github.io/jts/javadoc/org/locationtech/jts/index/strtree/STRtree.html - JTS Javadoc:
HPRtreehttps://locationtech.github.io/jts/javadoc/org/locationtech/jts/index/hprtree/HPRtree.html - 源码路径:
modules/core/src/main/java/org/locationtech/jts/index/ - 版本说明:
doc/JTS_Version_History.md(1.20.0:HPRtree 性能改进 #1012;1.21:KdTree 新增 nearestNeighbor/nearestNeighbors #1114) - 相关章节:第04章 - 几何对象模型详解、第08章 - 空间关系谓词与 DE-9IM、第11章 - 距离度量与几何统计、第20章 - 性能优化与最佳实践