算法与数据结构 / 空间索引 · 从均匀网格到 geohash 待审核 7 页

空间索引 · 从均匀网格到 geohash

B+ tree 索引一列数字,是因为数字有全序:任何一个区间在索引里都是连续的一段。二维坐标没有这样的全序。把点按 xx 排好,「xx 在 100 到 160 之间」确实能二分定位,可 yy 那一维只能逐个过筛——512 见方的场上撒 2000 个点,60 见方的查询窗平均命中 27.25 个,xx 带里却躺着 234.13 个候选,8.59 倍的读放大全花在扔掉上。spatial index 要补的正是这一维。

本系列走四个结构加一条旁路。uniform grid 把平面等分成格子,定位是一次除法,代价是格子边长必须与查询半径匹配——实测 k=5k = 5 的平均查询半径 12.89,最省的格子边长恰好是 16。quadtree 改成按密度自适应细分,团簇分布下点级判定从 78.85 降到 21.70。k-d tree 轮流按维度切,最近邻查询「先走近侧、再用当前最优半径决定要不要回溯」这一步在 2 维只访问 0.74% 的节点,到 16 维升到 99.77%——维度灾难在这里是一条能画出来的曲线。R-tree 索引的是矩形而非点,插入选面积增量最小的孩子、分裂让两个新 MBR 重叠尽量小,把 Guttman 的二次分裂换成按次序对半切,同层兄弟重叠面积从 51.9 万涨到 800.8 万。

最后一条旁路不建树:把 xxyy 的二进制位交错成一个整数(Morton code),空间邻近性被部分保留,于是可以塞进任何一维有序结构。geohash 是它的 base32 编码版本,前缀相同即区域相同——Redis 的 GEO 命令就是把 geohash 当 zset 的 score。代价写在一个反例里:本初子午线两侧相距 13.9 m 的两点,9 位 geohash 的公共前缀是 0,所以邻域查询必须查 9 个格。

起点:查询谱系与等分平面

空间查询有三类形状:矩形范围(range)、最近邻(kNN)、对象相交。三类都要求索引保留邻近性,而把二维投影到一维必然丢掉一个方向的邻近性。最朴素的补法是把平面等分成格子——定位 O(1)O(1)、实现二十行,但分布一倾斜就同时出现空桶与热桶。

两个记账指标会给出相反的排名

本系列的每次查询都记两个数:访问了几个节点(或格子),以及做了几次点级判定。同一批查询下这两个数常常反向——2000 点、200 次 60 见方的窗口查询,uniform grid(边长 32)访问 8.04 个格子、做 62.13 次点判定;quadtree(容量 4)访问 65.76 个节点、只做 28.05 次点判定。 哪个更快取决于一次「访问节点」有多贵。内存里节点访问是一次指针解引用,判定次数说了算,quadtree 赢;索引在磁盘上时一次节点访问是一次页面 I/O,比几十次内存比较贵几个数量级,格子少的那个赢。R-tree 之所以长成高扇出的样子(MM 取几十上百),根源就在这条——它本来是为磁盘设计的。

空间查询与网格 · 延伸阅读

  • Spatial database — Wikipedia en.wikipedia.org 总览:空间查询的类型、常见索引结构,以及 PostGIS 与 Oracle Spatial 的实现选择。
  • Spatial hashing for collision detection gamedeveloper.com 游戏里最常见的空间哈希写法:格子号直接当哈希 key,不建显式网格数组,稀疏场景省内存。
  • PostGIS · Spatial Indexing postgis.net GiST 索引在 PostGIS 里怎么用,以及为什么空间查询要分成 index scan 加 recheck 两步。

自适应:按数据切,而不是按坐标切

均匀网格的格线位置由坐标系决定,与数据无关。两种改法:quadtree 保留「四等分」的规则形状但按点密度决定切到多深;k-d tree 干脆让格线穿过数据的中位数,于是每次切分都把点数对半分。前者的形状可预测、易并发,后者的树高有保证、但静态。

容量阈值不是越小越好

quadtree 的节点容量常被当成「越小越精确」的旋钮,实测不是。2000 个均匀点上把容量从 16 调到 1,点级判定从 56.13 降到 13.20,但访问的节点数从 32.78 涨到 151.06,树的节点总数从 349 涨到 5825。 容量的作用是在两种代价之间挪:容量小则树深、节点多、遍历贵;容量大则叶子胖、点判定多。经验值取 4 到 16 之间,判据是「一个叶子的点集能不能装进一个 cache line 或一个磁盘页」,而不是精确度。

quadtree 与 k-d tree · 延伸阅读

扩展:矩形对象与一维化

被索引的东西常常不是点,而是有形状的对象——地块、包围盒、时间区间。R-tree 让节点存孩子的最小外接矩形,于是同一套「剪掉不相交的子树」的逻辑照样成立,代价是兄弟 MBR 可以重叠、一次查询可能同时下几条分支。另一条路彻底放弃树:把二维坐标交错成一维整数,让现成的 B+ tree 或 zset 干活。

geohash 的前缀性质在格子边界处失效

geohash 最常被引用的性质是「前缀越长的两个串,位置越接近」。反过来那句不成立:位置接近的两个点,前缀可以完全不同。伦敦本初子午线两侧、纬度同为 51.5 的两点,经度差 0.0002 度、实际距离 13.9 m,9 位 geohash 分别是 gcpuzzrcju10hbp214,公共前缀长度为 0。 成因是二分:每一位记录的是「在当前区间的哪一半」,落在分界线两侧的点从第一位起就分道扬镳,而分界线在每一层都存在。工程上的处理是查 9 个格——目标格加八邻域,Redis 的 GEOSEARCH 正是这么做的。

R-tree 与一维化 · 延伸阅读

Z-order 与 geohash

  • Z-order curve — Wikipedia en.wikipedia.org 位交错的定义、与四叉树的对应关系,以及范围查询时怎么跳过 Morton 区间里的无关段。
  • geohash.org geohash.org Gustavo Niemeyer 2008 年提出的原始服务,可直接对照本系列引擎的编码结果。
  • Redis · GEOADD redis.io Redis GEO 的实现说明:52 位 geohash 整数当 zset 的 score,GEOSEARCH 查九格再精算距离。
  • Hilbert curve — Wikipedia en.wikipedia.org Hilbert 曲线的局部性优于 Z 曲线(相邻编码恒为相邻格),代价是编解码要逐位旋转坐标。

收束:同一批查询下的横向对照

四个结构在同一点集、同一批查询下的访问次数放在一起看,会发现「访问节点数」与「点级判定次数」给出的排名是反的。选型的判据不是渐近复杂度——它们都是同一量级——而是数据是否动态、对象是点还是矩形、以及索引在内存还是在磁盘。

工程实现 · 延伸阅读

  • rbush · JavaScript R-tree github.com Vladimir Agafonkin 的 R-tree 实现,采用 OMT 批量装载与 R*-tree 的重插策略,是前端做矩形相交检索的默认选择。
  • kdbush · 静态点集的 k-d tree github.com 为「建一次查很多次」优化的实现:扁平 typed array 存树,无对象分配。
  • S2 Geometry github.com Google 的球面索引库:把球面按 Hilbert 曲线映到 64 位 cell id,避开了经纬度矩形在两极的畸变。
  • H3 · 六边形分层索引 h3geo.org Uber 的六边形网格:邻居距离一致(正方形网格的对角邻居远 41%),代价是层级之间不能整齐嵌套。