大数据地图场景中利用希尔伯特曲线实现空间索引

背景

空间索引是地图应用中比较重要的技术,用于检索地图中的点线面等元素,是实现鼠标拾取和框选交互的基础。在已有的地图应用中,我们通过 KD 树实现了地图上散点的索引,并通过在 WebGL 顶点 position 属性的缓冲区复用 KD 树的点坐标数组的方式大大减少了地图数据的内存开销。但 KD 树仍然存在构建耗时太长的问题(地图上构建 2500 万个散点的 KD 树在 9 代 i7 CPU 上需要耗时 66 秒)。而且由于 KD 树构建过程中,在划分左右子树时需要根据当前维度的数据对散点进行排序然后确定根节点,所以 KD 树的构建不适合利用 GPU 并行化以加快构建速率。在此背景下,我们尝试将地图上散点的二维坐标映射到一维的希尔伯特曲线上,然后通过在曲线上搜索离目标点最近的点的方式来完成地图散点元素的空间查询。

希尔伯特曲线

简介

希尔伯特曲线是一种空间填充曲线,在二维平面上无穷阶的希尔伯特曲线可以填满整个单位正方形平面。所以在对二维坐标进行归一化处理之后,任意二维点都映射到一维的希尔伯特曲线上,从而实现了多维数据到一维的映射。下图依次展示了一阶到三阶以及七阶希尔伯特曲线的构造 可以看出,七阶希尔伯特曲线在视觉上已经覆盖了整个平面。上图中值得注意的几点是:

  1. 绘制n 阶希尔伯特曲线时,先将单位正方形划分为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 ∗ 2 n 2^*2^n </math>2∗2n个单元格
  2. 单位正方形分为左下,左上,右上和右下四象限,分别称为第一、第二、第三和第四象限。希尔伯特曲线以左下角单元格为起点,依次通过第一二三四,四个象限中的所有单元格的中心点。
  3. 每个象限中的曲线都是前一阶曲线经过固定变换都得到的。具体变换为:
    • 第二和第三象限部分直接取前一阶曲线
    • 第一象限部分取前一阶曲线沿 x=y 直线翻转得到
    • 第四象限部分取前一阶曲线沿 x+y=1 直线翻转得到
    • 最后将四个象限部分曲线首尾端点相连即可得到指定阶数的希尔伯特曲线。

后面需要利用这几点构建地图索引

在地图空间索引上的应用

创建散点的空间索引

利用希尔伯特曲线创建地图上散点的空间索引时,并不需要将整个希尔伯特曲线上的点的坐标就求出来。我们只需要计算出地图上的各个散点映射到希尔伯特曲线上的点的位置(即点到曲线起点的距离称为 distance)。然后将记录了散点在原始数据中的 index 和 distance 信息的数组按照 distance 进行排序。排序后的数组即时该阶段的输出。大致步骤为:

  1. 遍历散点将散点坐标归一化到[0, 1]区间
  2. 确定希尔伯特曲线阶数为 n
  3. 将单位正方形 X、Y方向上分别划分 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 n 2^n </math>2n个单元格
  4. 遍历散点,因为希尔伯特曲线只会经过上一步中单元格的中心点,所以遍历散点时需要找到散点所在单元格的中心点,然后求中心点在希尔伯特曲线上的映射点,并记录该映射点到曲线起点的距离,作为散点的 distance。将 distance 和散点在原始数据中的 index 一起推入结果数组中
  5. 对结果数组按照散点的 distance 进行升序排列,称其为索引数组

利用希尔伯特曲线进行散点查询

  1. 获取鼠标点击处的屏幕二维坐标并将其归一化
  2. 计算点击点在希尔伯特曲线上的 distance
  3. 根据点击点的 distance 对索引数组进行二分查找,找到最接近distance的散点的index
  4. 根据散点 index 从原始数据中获取散点的二维坐标,计算散点与点击点的距离是否小于散点半径
  5. 如果小于散点半径则判断鼠标点击选中散点,反之则没有选中任何散点。 可以发现有了上一节构建够的索引数组,后续查询散点过程中利用二分查找法,查询的时间复杂度为logN

一个简单的例子

暴力穷举

假设有十个点随机分布在二维平面上如下图所示, 如果要实现鼠标点击选中某个点的功能。按照最简单的暴力穷举法,就需要

  1. 获取鼠标点击处的屏幕坐标(上图黑色方块),并转换成经纬度坐标 C1
  2. 遍历十个点,计算点坐标与 C1 之间的距离
  3. 取步骤 2 中距离最短的点,判断该点与 C1 的距离是否小于点的半径是的话就选中该点,否则判断没有选中 这种方法实现简单,但在数据量大的时候(千万级)就会因为耗时太长导致交互响应慢的问题。

希尔伯特曲线构建

以四阶希尔伯特曲线为例,我们先将平面划分为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 4 ∗ 2 2^4*2 </math>24∗2个格子,如下图所示 可以看到,每个点都落在了不同的格子里面。4 阶希尔伯特曲线会从左下角的单元格开始,依次穿过所有单元格的中心点,最后到右下角的单元格结束。如下图所示 从上图可以看到,曲线并没有穿过这十个点而是穿过的各个单元格的中心点。所以在计算绿色点在曲线上位置时需要将绿点坐标转换到所在单元格的中心点(红点处)。这样最终得到各个绿点在曲线上的位置如下图所示 二维点映射到希尔伯特曲线上后我们得到一个包含二维点信息以及二维点在希尔伯特曲线上位置信息(距离曲线起始点的距离)的一个数组,并且该数组要按照二维点在希尔伯特曲线上的位置进行排序。由于希尔伯特曲线的特性,在曲线上相近的点在二维平面中一般也相近。所以我们在查询的时候直接在曲线上找离该点在曲线上映射点最近的点即可。

通过希尔伯特曲线查询点

  先根据黑色正方形所在单元格位置将鼠标点坐标映射到希尔伯特曲线上,即下方直线上的黑色圆点。然后在上一节中生成的数组中根据二分查找法找到曲线上离黑点最近的绿点,然后取得该绿点的二维坐标,最后判断黑色正方形是否在二维平面上的绿点内部。这样查询的时间复杂度可以优化到 logN

构建二维点在希尔伯特曲线上位置数组的实现

通过上一节例子我们可以发现,要实现地图索引,并不需要得到完整的 N 阶希尔伯特曲线。**我们只需要得到地图上各个点在希尔伯特曲线上的位置信息,然后根据该位置信息对点进行排序。后续空间查询时对包含有点的 index 以及点在曲线上的位置信息的有序数组进行二分查找即可。**我们的构建步骤主要包含如下几步

1. 坐标转换

希尔伯特曲线默认是在边长为 1 的正方形内构建的,但为了处理方便,我们将正方形的边长设为 2 的 n 次方,n 为希尔伯特曲线的阶数。这样在后续计算过程中可以利用整数的位运算,以加快处理速度。   地图上点的坐标一般是由经纬度表示的,我们需要先遍历所有点,拿到经纬度的最大最小值,然后将各个点的经纬度转换到[0, 2**n - 1]范围内,并取整。这样各个点实际上已经位于了上一节中提到的各个单元格的中心点。

2. 二维坐标映射到希尔伯特曲线

这一步中,我们需要遍历地图上的所有点,调用映射方法获取该点在希尔伯特曲线上的位置(即距离曲线起点的距离),然后将点的信息和在曲线上的距离存入待排序的数组。希尔伯特曲线的映射方法参考自维基百科,翻译为 JavaScript 代码如下所示:

javascript 复制代码
//point为经过步骤1转换后的点的二维坐标,order为希尔伯特曲线的阶数
function point2Distance(point, order) {
     let rx = 0 //rx, ry 代表point处于哪个象限, 第一象限(0,0)、第二象限(0,1)、第三象限(1,1)、第四象限(1,0)
     let ry = 0
     let n = 1 << order //n为正方形边长
     let distance = 0 //distance为point在希尔伯特曲线上的距离
     let currOrder = order - 1
     //for循环中定位point在第几象限,然后递归向下进入该象限继续判断,知道曲线阶数为0
     for (let s = n / 2; s >= 1; s /= 2) {
          rx = (point.x & s) >> currOrder //获取point在第几象限
          ry = (point.y & s) >> currOrder
          //因为曲线是依次通过第一二三四现象的,所以将当前象限之前的曲线部分的长度累加到distance中
          distance += s * s * ((3 * rx) ^ ry)
          currOrder--
          rotate(point, rx, ry, n) //判断point所在的象限的曲线部分是否需要翻转
     }

     return distance

}

function rotate(point, rx, ry, n) {

     if (ry === 0) {

          //ry等于1,即point位于二三象限时不翻转
          if (rx === 1) {

               //point位于第四象限时沿x+y=1直线翻转曲线,相当于在进入前一阶曲线之前将前一阶的曲线位置还原
               point.x = n - 1 - point.x

               point.y = n - 1 - point.y

          }

          const t = point.x //point位于第一象限时沿x=y直线翻转该部分曲线

          point.x = point.y

          point.y = t

     }

}

最后将 point2Distance 方法返回的 distance 和点的 index 添加到待排序数组 distanceList 中

3. 结果排序

上一节中拿到的 distanceList 数组中是按照点的 index 进行排序的,我们需要根据数组内保存的点的 distance 值对数组进行排序。由于涉及大量数据排序会比较耗时,我们测试过程中发现 JavaScript 的原生 array 的排序耗时远大于 typedArray 数组的排序耗时(同样是 1000w 个数,Array 排序大概需要 45 秒,而 typedArray 只需要 0.8 秒),所以最好将 distanceList 设置为 typedArray 数组,然后再执行排序操作。

但存在的问题是 distanceList 数组中每一项都包含点的 index 和点的 distance 两个数据,而 TypedArray 中只允许存放数值类型的数据。如果将同一个点的 index 和 distance 在 distanceList 中交替存放,那么 TypedArray 自带的 sort 函数就无法完成按照 distance 排序的目的。

考虑到点的个数以及 distance 的大小都不太可能超过 2 的 32 次方(42 亿多个点)而且点的 index 和 distance 都是正整数,那么我们可以借助 JavaScript 中的 BigInt(64 位),将 distance 放在高 32 位中,点的 index 放在低 32 位中,从而将二者合并到一个 BigInt 中。distanceList 的类型就位 BigUint64Array,而且由于 distance 在高 32 位,直接调用 distanceList.sort 方法对 distanceList 做升序排列之后,数组中每个 BigInt 数中的 distance 部分也是升序排列的。

测试结果

我们测试了 100w、500w、1000w 和 2500w 个点数据构建 KD 树和希尔伯特空间填充曲线的耗时,结果如下图所示: 可以看出使用希尔伯特曲线构建索引数组的速度相比构建 KD 树还是有一定优势的。而且由于希尔伯特曲线映射时只需要当前点的二维坐标和曲线阶数,所以希尔伯特曲线的构建过程非常适合通过 GPU 并行实现,构建速度还会得到指数级提升。

相关推荐
DolphinScheduler社区5 分钟前
大数据调度组件之Apache DolphinScheduler
大数据
SelectDB技术团队5 分钟前
兼顾高性能与低成本,浅析 Apache Doris 异步物化视图原理及典型场景
大数据·数据库·数据仓库·数据分析·doris
请叫我欧皇i15 分钟前
html本地离线引入vant和vue2(详细步骤)
开发语言·前端·javascript
533_18 分钟前
[vue] 深拷贝 lodash cloneDeep
前端·javascript·vue.js
guokanglun24 分钟前
空间数据存储格式GeoJSON
前端
zhang-zan1 小时前
nodejs操作selenium-webdriver
前端·javascript·selenium
猫爪笔记1 小时前
前端:HTML (学习笔记)【2】
前端·笔记·学习·html
panpantt3211 小时前
【参会邀请】第二届大数据与数据挖掘国际会议(BDDM 2024)邀您相聚江城!
大数据·人工智能·数据挖掘
青云交1 小时前
大数据新视界 -- 大数据大厂之 Impala 性能优化:跨数据中心环境下的挑战与对策(上)(27 / 30)
大数据·性能优化·impala·案例分析·代码示例·跨数据中心·挑战对策
brief of gali1 小时前
记录一个奇怪的前端布局现象
前端