第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 树。

构建分三层逻辑:

  1. 把所有条目按包络中心 x 排序,切成 ceil(sqrt(minLeafCount)) 个竖直条带(vertical slice),其中 minLeafCount = ceil(n / nodeCapacity)。
  2. 每个条带内部按包络中心 y 排序,每 nodeCapacity 个切成一个父节点。
  3. 对父节点层重复上述过程,直到只剩一个根节点。
构建过程(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 中同一个条目最多出现一次,不存在索引层面的重复。实际项目里遇到的“重复”通常来自下面几类:

  1. 同一个几何对象被重复 insert 了多次(数据源重复、循环写错),索引里就有多个条目,查询结果自然重复。
  2. 空间连接是“多对多”的:一个点可能落在多个相互重叠的面里,一个面也可能匹配多个点,输出的是“配对”而不是“对象”,需要按业务主键去重。
  3. 用多个候选查询(例如把查询几何拆成多个包络分别查)后简单拼接结果,同一对象会在多个分块里被命中。
  4. 用 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 索引复用原则

  1. 索引跟着数据集的生命周期走:静态底图数据(行政区划、路网、POI 底库)建一次索引长期复用;不要每次请求都重建。
  2. 请求级查询不要建索引:如果一次查询只查一次,建索引的成本可能高于扫描。经验阈值是“同一数据集上的查询次数 × 平均命中率 > 建索引开销”,实践中几百次以上查询就值得建。
  3. 增量数据用“追加批 + 定期重建”:STRtree 构建后不可插入,所以增量更新应维护一个小型 side buffer(内存列表或 Quadtree),查询时先查主索引再查 buffer,buffer 增长到一定比例后整体重建。
  4. 按瓦片分治:全国范围数据按 tile 切分,每个瓦片一个小 STRtree,查询时先算瓦片命中再进具体树。这样单树规模可控、构建快、并行友好,也是 GeoServer 等服务的常见做法。
  5. 索引 item 存主键而非几何:几何对象通常由缓存/存储另行持有。item 存 Long id 再回表,可以减少索引对几何对象的强引用,也避免 remove 时因引用不一致而失败。

13.11 实战:大点集与面图层的空间连接

13.11.1 问题

给定一个面图层(例如 4 个管理网格)和一个点图层(例如 5 个传感器的坐标),要求:

  1. 每个点落在哪些管理网格内(一对多);
  2. 每个网格命中多少个点;
  3. 落在网格边界上的点要能被识别出来(避免统计时被重复计入或漏计)。

这是最典型的空间连接。朴素写法是双重循环 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 相关资源