空间索引 - 四叉树

前言

作为程序员,应该都对二叉树都不陌生,我们都知道二叉树的变体二叉查找树,非常适合用来进行对一维数列的存储和查找,可以达到 O(logn) 的效率;我们在用二叉查找树进行插入数据时,根据一个数据的值和树结点值的对比,选择二叉树的两个叉之一向下,直到叶子结点,查找时使用二分法也可以迅速找到需要的数据。

但二叉树只支持一维数据,如一个标量数值,对地图上的位置点这种有xy两个方向上的信息却无能为力,那么是否有一种树能够支持二维数据的快速查询呢?

四叉树

介绍

四元树又称四叉树是一种树状数据结构,在每一个节点上会有四个子区块。四元树常应用于二维空间数据的分析与分类。它将数据区分成为四个象限。

今天要介绍的四叉树可以认为是二叉查找树的高维变体,它适合对有二维属性的数据进行存储和查询,当然四叉树存储的也不一定是二维数据,而是有着二维属性的数据,如有着 x,y 信息的点,用它还可以用来存储线和面数据。它有四个,在数据插入时,我们通过其二维属性(一般是 x,y)选择四个叉之一继续向下,直至叶子结点,同样使用“四分法”来迅速查找数据。四叉树的一般图形结构如下:

聪明的小伙伴一定想到了适合存储和查询三维数据的八叉树,它们原理是一致的,不过我们暂不讨论。

分类

四叉树常见的应用有图像处理、空间数据索引、2D中的快速碰撞检测、稀疏数据等,今天我们很纯粹地只介绍它在空间索引方面的应用。

根据其存储内容,四叉树可以分为点四叉树、边四叉树和块四叉树,今天我们实现的是点四叉树。

根据其结构,四叉树分为满四叉树和非满四叉树。

对于满四叉树,每个节点都有四个子结点,它有着固定的深度,数据全都存在最底层的子结点中,进行数据插入时不需要分裂。

满四叉树在确定好深度后,进行插入操作很快,可是如果用它来存储下图所示数据,我们会发现,四叉树的好多叉都是空的,当然它们会造成内存空间的大量浪费。

非满四叉树解决了此问题,它为每个结点添加一个“容量”的属性,在四叉树初始化时只有一个根结点,在插入数据时,如果一个结点内的数据量大于了结点“容量”,再将结点进行分裂。如此,可以保证每个结点内都存储着数据,避免了内存空间的浪费。

在查询时,只有找到了位置对应的结点,那么结点下的所有点都会是此位置的附近点,更小的“容量”意味着每个结点内点越少,也就意味着查询的精度会越高。

以下是一个非满点四叉树的实现:

附上 GitHub 仓库地址:枕边书-空间索引

代码实现

首先是数据结构的定义:

树结点:

struct QuadTreeNode {
    int depth; // 结点深度
    int is_leaf; // 是否是叶子结点
    struct Region region; // 区域范围
    struct QuadTreeNode *LU; // 左上子结点指针
    struct QuadTreeNode *LB; // 左下子结点指针
    struct QuadTreeNode *RU; // 右上子结点指针
    struct QuadTreeNode *RB; // 右下子结点指针
    int ele_num; // 位置点数
    struct ElePoint *ele_list[MAX_ELE_NUM]; // 位置点列表
};

为了加快插入和查询速度,数据结构设计稍微冗余了一些;

四叉树位置点的插入流程如下图所示:

结点的分裂是重点,这里介绍一下:

void splitNode(struct QuadTreeNode *node) {
    // 获取xy方向上的中间点,用来初始化子结点的范围
    double mid_vertical = (node->region.up + node->region.bottom) / 2;
    double mid_horizontal = (node->region.left + node->region.right) / 2;

    node->is_leaf = 0; // 将是否为叶子结点置为否
    // 填充四个子结点
    node->RU = createChildNode(node, mid_vertical, node->region.up, mid_horizontal, node->region.right);
    node->LU = createChildNode(node, mid_vertical, node->region.up, node->region.left, mid_horizontal);
    node->RB = createChildNode(node, node->region.bottom, mid_vertical, mid_horizontal, node->region.right);
    node->LB = createChildNode(node, node->region.bottom, mid_vertical, node->region.left, mid_horizontal);

    // 遍历结点下的位置点,将其插入到子结点中
    for (int i = 0; i < node->ele_num; i++) {
        insertEle(node, *node->ele_list[i]);
        free(node->ele_list[i]);
        node->ele_num--;
    }
}

更具体的代码见 GitHub 吧,我觉得我代码质量还看得过去,另外方法上面还有详细些的注释。

问题和优化

边界点问题

四叉树还是面临着边界点问题,每个结点内的点必然是相邻的,但相邻的点越不一定在同一个结点内,如下图,A点和B点相邻的很近,如果A点是我们查找的目标点,那么仅仅取出A点所在结点内的所有位置点是不够的,我们还需要查找它的周边结点。

这里我们要介绍四叉树的另一个特性。

字典树

字典树,又称前缀树或trie树,是一种有序树,用于保存关联数组,其中的键通常是字符串。与二叉查找树不同,键不是直接保存在节点中,而是由节点在树中的位置决定。一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,而根节点对应空字符串。

我们可以类比字典的特性:我们在字典里通过拼音查找晃(huang)这个字的时候,我们会发现它的附近都是读音为huang的,可能是声调有区别,再往前翻,我们会看到读音前缀为huan的字,再往前,是读音前缀为hua的字... 取它们的读音前缀分别为 h qu hua huan huang。我们在查找时,根据 abc...xyz 的顺序找到h前缀的部分,再根据 ha he hu 找到 hu 前缀的部分...最后找到 huang,我们会发现,越往后其读音前缀越长,查找也越精确,这种类似于字典的树结构就是字典树,也是前缀树。

四叉树也有此特性,我们给每一个子结点都编号,那么每个子结点会继承父结点的编号为前缀,并在此基础上有相对其兄弟结点的独特编号。

与 GeoHash 的相似之处

如果我们给右上、左上、左下、右下四个子结点分别编号为00 01 10 11,那么生成的四叉树就会像:

我们在查找到目标结点时,根据其编码获取到其周边八个结点的编码,再获取各个周边结点内的位置点。

看过我上一篇空间索引(详见:空间索引 - GeoHash算法及其实现优化)文章的小伙伴可能会说,这不就是 GeoHash 么?

嗯,这种通过编码来确定周边格子的方式确实跟 GeoHash 是相同的,但不要混淆了他们查找原理上的截然不同:

  • GeoHash 本质上是通过格子编码将二维信息用一维来表示,其查找原理从根本上来说是二叉树(B树),在查找时会根据格子编码选择两个方向之一继续精确,查询效率准确来说是 log2N;
  • 四叉树保留了其二维查找的特性,其查找会根据其 x,y 选择四个方向之一继续查找,忽略方向选择时的计算,其查询效率应该是 log4N;

我们可以使用此方法来继续优化四叉树,给结点添加一个“编号”属性即可,由于时(bo)间(zhu)关(fan)系(lan),这里不再实现了。

小结

由于 C 语言的高效率,由它实现的四叉树效率极高。 进行十万数据的插入和一次查询总操作为 7毫秒。在数据量更大的插入时,因为要进行结点的多次分裂,效率会有所下降,进行了8百万数据的测试插入需要两分钟多一些(16年的 mac pro),至于查询,都是一些内存寻址操作,时间可以忽略不计了。 更大量级的测试就不跑了,跑的时候散热风扇速转系统温度迅速上升。。。

不过这么高的效率是因为这些都是内存操作,真正的数据库中数据肯定是要落地的,那时候更多的就是些磁盘和 IO 操作了,效率也会有所下降,但最终的效率和结点数据的扩展能力,与 GeoHash 相比,还是四叉树更好一些。

最后,部分图片来源于网络,侵删。

如果您觉得本文对您有帮助,可以点击下面的 推荐 支持一下我。一直在更新,欢迎 关注

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏醒者呆

面向程序员编程——精研排序算法

这篇文章很长,我花了好久的时间(中间公司出了bug,加班了好几天( ¯ ¨̯ ¯̥̥ ))进行整理,如有任何疑问,欢迎随时留言。 关键字:排序算法,时间...

3255
来自专栏阮一峰的网络日志

快速排序(Quicksort)的Javascript实现

日本程序员norahiko,写了一个排序算法的动画演示,非常有趣。 这个周末,我就用它当做教材,好好学习了一下各种排序算法。 ? 排序算法(Sorting al...

2735
来自专栏智能算法

前端面试中的常见的算法问题

作者:Jack Pu 链接:www.jackpu.com/qian-duan-mian-shi-zhong-de-chang-jian-de-suan-fa-w...

4358
来自专栏take time, save time

你所能用到的数据结构(八)

十一、不能被应用的理论不是好研究 前面介绍了堆栈的一些小小的理论模型,那么这样一个东西有什么作用呢?实际中不可能有那么一辆停在站台前方堵死的火车的,即使有,也...

2534
来自专栏数据结构与算法

Splay详解(一)

前言 Spaly是基于二叉查找树实现的, 什么是二叉查找树呢?就是一棵树呗:joy: ,但是这棵树满足性质—一个节点的左孩子一定比它小,右孩子一定比它大 比如说...

3169
来自专栏desperate633

详解排序算法--堆排序选择排序堆排序

选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩...

653
来自专栏专注研发

哈夫曼树【最优二叉树】【Huffman】

        在很多问题的处理过程中,需要进行大量的条件判断,这些判断结构的设计直接影响着程序的执行效率。例如,编制一个程序,将百分制转换成五个等级输出。大家...

871
来自专栏King_3的技术专栏

leetcode-55-跳跃游戏

1、给定一个vector,里面存放着非负的int型整数,每一个整数代表在这个位置上可以跳跃的步数,要求判断最终能不能跳跃到vector的最后一位。

381
来自专栏数据结构与算法

P3379 【模板】最近公共祖先(LCA)

题目描述 如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先。 输入输出格式 输入格式: 第一行包含三个正整数N、M、S,分别表示树的结点个数...

3556
来自专栏aCloudDeveloper

公司数据结构+算法面试100题

1.把二元查找树转变成排序的双向链表(树) 题目: 输入一棵二元查找树,将该二元查找树转换成一个排序的双向链表。 要求不能创建任何新的结点,只调整指针的指向。 ...

5109

扫码关注云+社区