在实现基于地图的业务时,当地图上需要展示的兴趣点(POI)过多时,一般会基于图面效果和渲染性能的考虑,在大比例尺展示完整的业务数据,而在小比例尺展示聚合态数据。在处理不同数量级、不同分布形态的 POI 时,如何通过算法取得更加合理的聚合效果,同时既能支持离线的预处理聚合,也能较好的满足实时聚合的性能要求是本文主要讨论的内容。
注:兴趣点(Point of Interest,通常缩写成 POI)是指电子地图上的某个地标、景点,用以标示出该地所代表的政府机关、商业机构(加油站、超市、餐厅、酒店等)、风景名胜、公共厕所、交通设施等处所。
本文通过对现有聚合算法进行预研,并结合实际业务对其进行改进,构建了一套能够满足多种业务需求的数据聚合方案。其中包含多种针对不同业务场景、不同数量级的 POI 聚合算法,并将聚合算法的配置进行了抽象,实现了聚合效果好,性能高且使用方便的聚合算法库,目前已应用到离线聚合和线上实时聚合的各个业务场景中。
本节介绍目前使用较多的点聚合算法,并对不同点聚合算法的性能和聚合效果进行横向对比。针对不同的场景给出了参考使用的点聚合算法。在此基础上构建了一套相对通用的点聚合算法工具,使用者可以根据自身业务的特点,通过一定的配置来自定义具体使用的算法、策略和参数等等。
这里对使用较多的点聚合方案做简单介绍。
kmeans 算法主要通过初始指定 k 个聚类质心,而后按照“距离”来聚拢“相近”的数据点,而后重新计算新的质心,以此往复。不断迭代计算 k 个聚类点的质心,最终回归到 k 个聚类结果中,主要有以下两个缺点:
将地图划分为若干个网格,将落在对应网格中的点聚合在小网格的中心点。每个小网格只显示一个中心点,值为网格内的点数量。具体计算公式如下:
与 2.1.2 方法基本一致,不同点在于,算法在将点划分到不同的网格中以后,会对每个网格的质心重新计算,得到更精确的聚类中心点。此外,还对经过质心计算的网格聚类中心点,进行了合并。(如果不进行合并,可能导致不同网格质心相近,造成覆盖)。网格质心的合并算法以一个网格质心为中心,画一个圆圈(或方格),将在这个范围内的网格质心都进行合并。圆圈或者方格的覆盖范围,可以作为配置来调整。
初始时没有任何已知聚合点,然后对每个点进行迭代,计算一个点的外包正方形,若此点的外包正方形与现有的聚合点的外包正方形不相交,则新建聚合点(这里不是计算点与点间的距离,而是计算一个点的外包正方形,正方形的边长由用户指定或程序设置一个默认值),若相交,则把该点聚合到该聚合点中,若点与多个已知的聚合点的外包正方形相交,则计算该点到到聚合点的距离,聚合到距离最近的聚合点中,如此循环,直到所有点都遍历完毕。
首先明确,四叉树算法本身不提供数据聚合的能力,它的存在是为了配合之前的网格点聚合算法,提供快速查找到某一个网格下的所有 POI 数据的能力。
对应于二叉树,二叉查找树适合用来存储和查找一维数据,可以达到 O(logn)的效率。在用二叉查找树进行插入数据时,查找一个数据只需要和树结点值的对比,选择二叉树的两个叉之一向下,直到叶子结点,查找时使用二分法也可以迅速找到需要的数据。对应于地图数据的二维坐标来说,四叉树可以很好地对二维数据进行存储和查找。它能够将数据分成四个象限,在查找数据时,通过对二维属性(一般是 x, y)进行判断,选择四个分叉(象限)之一向下查找,直至叶子节点。(同样的对于三维数据来说,使用八叉树来进行存储和查询)。
采用网格质心算法来进行点的聚合,四叉树提供数据查询的能力。简单来说就是把屏幕分割成若干个区域,每个区域最多显示一个标注,每个区域的数据内容由四叉树来进行查询和提供。然后根据地图缩放比例去设置每个网格区域的大小以达到较好的展示效果。
a. 首先对 POI 数据建立四叉树索引,四叉树的结构可以用如下代码表示。( 创建四叉树的代价比较大 )
typedef struct TBQuadTreeNodeData { //四叉树中存储的数据点(即poi信息)(一个四叉树节点可能包含多个数据点)
double x; //经纬度坐标
double y;
void* data; //额外的补充信息
} TBQuadTreeNodeData;
TBQuadTreeNodeData TBQuadTreeNodeDataMake(double x, double y, void* data);
typedef struct TBBoundingBox { //每个四叉树节点所表示的区间范围
double x0; double y0; //横纵坐标的最小值
double xf; double yf; //横纵坐标的最大值
} TBBoundingBox;
TBBoundingBox TBBoundingBoxMake(double x0, double y0, double xf, double yf);
typedef struct quadTreeNode { //四叉树节点
struct quadTreeNode* northWest; //西北象限(子节点)
struct quadTreeNode* northEast; //东北象限(子节点)
struct quadTreeNode* southWest; //
struct quadTreeNode* southEast; //
TBBoundingBox boundingBox; //本节点所表示的数据范围
int bucketCapacity; //本节点的数据容纳量(上限)
TBQuadTreeNodeData *points; //本节点存储的数据点信息(poi信息)
int count; //目前已经存储的数据点(poi点)个数
} TBQuadTreeNode;
TBQuadTreeNode* TBQuadTreeNodeMake(TBBoundingBox boundary, int bucketCapacity);
b. 建立四叉树的过程如下图所示,在到达节点的容量上限之后节点就会将其表达的数据范围从中心点开始划分为四个象限,构成它的四个子节点,子节点如果达到容量上限,同样重复这一过程。
c. 查找一定范围内的 POI 数据的过程如下图所示(如下图中查找红色边框圈出来的范围)。比较红色区域是否和四叉树元素的根节点有交集,无交集则舍弃,有交集继续向四叉树的分支中进行查找。
2.四叉树建好并且能够进行区域查询。接下来点聚合算法就可以开始执行了,点聚合算法首先会先将当前屏幕划分为若干个网格(grid),然后对每一个网格通过四叉树来查找该网格内的 POI,等找到同一个网格中的所有 POI 数据之后,计算其平均质心,并统计该网格中一共存在多少数据,即可完成聚合。
K-D 树(k-dimensional 树的简称)是一种分割 k 维数据空间的数据结构,主要应用于多维空间数据的搜索(如:范围搜索和最近邻搜索)。了解 K-D 树可以从理解线段树开始。
class Node:
def __init__(self, value, lchild, rchild):
self.value = value
self.lchild = lchild
self.rchild = rchild
def build(arr):
n = len(arr):
left, right = arr[: n//2], arr[n//2:]
lchild, rchild = self.build(left), self.build(right)
return Node(max(lchild.value, rchild.value), lchild, rchild)
线段树维护的是一个区间内的元素,是一个一维的序列。如果我们将数据的维度扩充一下,扩充到多维呢?KD-Tree 就可以理解成是线段树 拓展到多维空间 当中的情况。以二维空间数据来说明 K-D 树如何建立。
a. 一个二维的平面中分布着若干个点坐标。
b. 选择一个维度(比如选择 X 轴),将数据一分为二。
c. 经过切分之后,左右两侧的点被分成了两棵子树,对于这两个部分的数据来说我们 更换一个维度在进行二分 ,(其实可以选择方差最大的维度进行划分,以此来保证更平均的分配,和更好的区分度)。此处我们选择 y 轴进行划分,就可以得到:
d. 重复上述过程,一直到点不能进行分割为止。即可得到一颗 KD 树。因为每次划分都是选择中位数来进行划分,所以可以保证根节点到叶子节点的深度不会超过 log(N)。
最终建立的 K-D 树存储上的形式如下图所示:
K-D 树的一个最大的优点在于,能够快速的进行批量查询,如查询 K 个距离最近的数据有哪些、查询距离满足一定阈值的数据有哪些等。
a. 假设我们要查找距离给定目标点最近的 3 个点。首先会创建一个候选集来存储答案。当找到叶子节点(叶子节点代表一块儿小区域)之后,这个区域当中只有一个点,把它加入候选集。如下图所示,“绿色✔️ ”表示样本被放入了候选集当中,“黄色✔️ ”表示已经访问过。
b. 通过判断样本和当前分割轴的距离,来确定分割轴的另一侧有没有更临近的数据点。
(a 步骤中的节点为叶子节点,本轮向上查找父节点,同时候选集中不满 3 个,父节点加入候选集)
c. 分析 b 步骤中的节点,虽然是父节点,但是另一侧没有数据,所以也向上查找他的父节点,同时目前候选集不满 3 个,也加入候选集。
d. 当前节点作为父节点,且右子节点不为空,所以需要判断目标点到分界线的距离,目标点到分界线的距离 d1 小于目标点到最远候选集中点的距离 d2,所以分界线另一侧有可能有更小的值,需要遍历。
e. 找到边界另一侧的叶子节点,发现他到目标点距离,大于候选集到目标点的距离,同时候选集已经有了 K 个值,所以不能构成新的答案。需要继续向上查找他的父节点。
f. 发现其父节点到目标点的距离,比已有的候选集中的点更小,更新候选集。
依托于之前进行的调研,开始对点聚合方案进行实现。本文尝试了多种算法的实现,并就其性能和效果进行了对比。其中包含:
如下图所示,目前效果测试使用的数据集为 GPS 点坐标信息,下图中每个黑色的圆点代表一个坐标数据,共 175 个。
(GPS 点分布图)
(同上图,无地图信息)
a. 网格质心算法(划分网格,然后计算每个网格的质心作为聚类中心)由于网格是固定的,出现了“聚集点被网格割裂”的情况。
b. 网格质心合并算法(在网格质心算法的基础上,对两个距离较近的聚合点,进行合并)
将点聚合的结果,再进行一次聚合,以期望被网格割裂开来的聚集点数据,能够重新聚合在一起。
c. 网格距离算法(即 2.2.4 中的算法)
以原始数据点自身为中心,按照一定的距离范围,去聚合周围距离最近的 聚合点 。与其他算法不同,网格距离算法的设计思想是 原始数据点和聚合点的合并,而不是原始数据点之间的相互合并 。如果没有在聚合范围内找到聚合点,就以自身位置新建一个聚合点。
(参数:0.04)
(参数:0.03)
d. 效果对比:
i. 因为网格距离算法是 将原始数据点聚合到“距离最近的”聚合点中 ,能够在一定程度上避免原始数据点聚合到“距离更远”的聚合点。
ii. 网格距离算法中,对于每一个原始数据点的遍历中,它能够影响的只有自己这一个原始数据点。而直接距离算法中每一个原始数据点的遍历,可能会影响到多个原始数据点。虽然两种算法都会受到原始数据点的遍历顺序影响,但影响的程度相差很大。总体来说,网格距离算法表现更好。
iii. 如网格距离算法中所展示的两张图,聚合点的分布并没有被网格所局限。而对比网格质心算法所展示的效果图,其每个网格只能出现了一个聚合点。
其中,原始 POI 数据量的大小分界线,以 10K 为分界线,超过 10K 可以认为数据量较大,算法之间的性能会有差别。聚合点数量的多少,以 1K 为边界,超过 1K 个聚合点可以认为聚合点数量较大。准确率和效率的要求,需要使用者根据业务的具体要求来做判断。
参考文献
[1]. 丁立国, 熊伟, 周斌. 专题图空间点聚合可视化算法研究[J]. 地理空间信息, 2017, 15(5): 6-9.
[2]. Theodore Calmes. How To Efficiently Display Large Amounts of Data on iOS Maps. https://thoughtbot.com/blog/how-to-handle-large-amounts-of-data-on-maps. 2015.
[3]. Liu Y, Li Z, Xiong H, et al. Understanding of internal clustering validation measures[C]//2010 IEEE International Conference on Data Mining. IEEE, 2010: 911-916.
[4]. 承志. 机器学习--详解 KD-Tree 原理. https://juejin.im/post/6844904117702246407, 2020.
[5]. Viky. 在线地图的点聚合算法及现状. http://www.doczj.com/doc/f5a88999284ac850ad0242a2.html, 2014.
本文转载自公众号高德技术(ID:amap_tech)。
原文链接:
领取专属 10元无门槛券
私享最新 技术干货