You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

95 lines
13 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 14 | 空间检索(下):“查找最近的加油站”和“查找附近的人”有何不同?
你好,我是陈东。
上一讲我们讲了对于查询范围固定的应用需求比如“查找附近的人”我们可以根据规划好的查询区域大小均匀划分所有的空间然后用GeoHash将坐标转换为区域编码以该区域编码作为Key开始检索。这样我们就可以查到并取出该区域中的目标数据对这些数据进行精准计算然后排序输出了。
但是,并不是所有应用的查询范围都是不变的。**在一些基于地理位置的服务中,我们并不关心检索结果是否就在我们“附近”,而是必须要找到“最近”的一批满足我们要求的结果**。这怎么理解呢?
我来举个例子我们在长途自驾游的时候突然发现车快没油了。这个时候我们要在一个导航地图中查找最近的k个加油站给车加油这些加油站可能并不在我们附近但地图又必须要返回最近的k个结果。类似的情况还有很多比如说我们要查询最近的医院有哪些查询最近的超市有哪些。那对于这一类的查询如果当前范围内查不到系统就需要自动调整查询范围直到能返回k个结果为止。
对于这种需要动态调整范围的查询场景,我们有什么高效的检索方案呢?今天,我们就来探讨一下这个问题。
## 直接进行多次查询会有什么问题?
我们就以查找最近的加油站为例一个直观的想法是我们可以先获得当前位置的GeoHash编码然后根据需求不停扩大查询范围进行多次查询最后合并查询结果。这么说比较抽象我们来分析一个具体的位置编码。
假设我们当前地址的GeoHash编码为wx4g6yc8那我们可以先用wx4g6yc8去查找当前区域的加油站。如果查询的结果为空我们就扩大范围。扩大查询范围的思路有两种。
第一种思路是一圈一圈扩大范围。具体来说就是我们第一次查询周边8个邻接区域如果查询结果依然为空就再扩大一圈查询再外圈的16个区域。如果还是不够下一次我们就查询再外圈的24个区域依此类推。你会发现这种方案的查询次数会成倍地增加它的效率并不高。
![](https://static001.geekbang.org/resource/image/b8/ea/b8c83e0e14cde461eec4b0b49f0cbfea.jpg "逐步扩大查询周边区域")
另一种思路是我们每次都将查询单位大幅提高。比如说直接将GeoHash编码去掉最后一位用wx4g6yc再次去查询。如果有结果返回但是不满足要返回Top K个的要求那我们就继续扩大范围再去掉一个编码用wx4g6y去查询。就这样不停扩大单位的进行反复查询直到结果大于k个为止。
![](https://static001.geekbang.org/resource/image/a1/fc/a1b1510445a0467d3a995620a80523fc.jpg "逐步扩大查询单位以二进制区域编码为例每次扩大4倍")
和第一种查询思路相比在第二种思路中我们每次查询的区域单位都得到了大范围的提升因此查询次数不会太多。比如说对于一个长度为8的GeoHash编码我们最多只需要查询8次如果要求精准检索那每次查询就扩展到周围8个同样大小的邻接区域即可后面我就不再解释了
这个检索方案虽然用很少的次数就能“查询最近的k个结果”但我们还需要保证每次的查询请求都能快速返回结果。这就要求我们采用合适的索引技术来处理GeoHash的每个层级。
比如说如果使用基于哈希表的倒排检索来实现我们就需要在GeoHash每个粒度层级上都分别建立一个单独的倒排表。这就意味着每个层级的倒排表中都会出现全部的加油站数据会被复制多次这会带来非常大的存储开销。那我们是否有优化存储的方案呢
我们可以利用GeoHash编码一维可排序的特点使用数组或二叉检索树来存储和检索。由于数组和二叉检索树都可以支持范围查询因此我们只需要建立一份粒度最细的索引就可以了。这样当我们要检索更大范围的区域时可以直接将原来的查询改写为范围查询。具体怎么做呢
我来举个例子。在检索完wx4g6yc8这个区域编码以后如果结果数量不够还要检索wx4g6yc这个更大范围的区域编码我们只要将查询改写为“查找区域编码在wx4g6yc0至wx4g6ycz之间的元素”就可以利用同一个索引来完成更高一个层级的区域查询了。同理如果结果数量依然不够那下一步我们就查询“区域编码在wx4g6y00至wx4g6yzz之间的元素”依此类推。
![](https://static001.geekbang.org/resource/image/e5/c6/e5c2a638c5a081469913e52aa98fe4c6.jpg "利用有序数组查询示例")
但是,这种方案有一个缺点,那就是在每次调整范围查询时,我们都要从头开始进行二分查找,不能充分利用上一次已经查询到的位置信息,这会带来无谓的重复检索的开销。那该如何优化呢?你可以先想一想,然后我们一起来看解决方案。
## 如何利用四叉树动态调整查询范围?
上一讲我们讲过许多系统对于GeoHash的底层实现其实都是使用二进制进行存储和计算的。而二进制区域编码的生成过程就是一个逐渐二分空间的过程经过二分后的区域之间是有层次关系的。如果我们把这个过程画下来它就很像我们之前讲过的树形结构。
因此,我们可以尝试用树形结构来进行索引。这里,我们就要引入一个新的数据结构**四叉树**了。四叉树的树根节点代表了整个空间,每个节点的四个分叉分别表示四个子空间。其中,树根和中间节点不存储数据,只记录分叉指针。而数据只记录在最小的区域,也就是叶子节点上。
如果我们从根节点开始,不停地四分下去,直到每个分支的叶子节点都是最小粒度区域。那这样构建出来的四叉树,每个节点都有四个子节点,就叫作**满四叉树**。
对于满四叉树的每个节点我们都可以编号。换句话说我们可以按00、01、10、11的编号来区分满四叉树的四个子节点。这样一来只要我们从根节点遍历到叶子节点然后将路径上每个节点的编号连起来那最后得到的编码就是这个叶子节点所代表的区域编码。
![](https://static001.geekbang.org/resource/image/85/f5/85674c6f1d812695e6512ea55cbe4ff5.jpg "满四叉树")
好了现在我们知道了四叉树的结构和特点了那我们怎么利用它完成自动调整范围的Top K检索呢下面我们通过一个例子来看看。
假设一个人所属的最小区域编码是0110那我们在检索的时候就以0110为Key沿着四叉树的对应分支去寻找相应的区域查询路径为01-10。如果查找到了叶子节点并且返回的结果大于k个就可以直接结束检索。如果返回结果不足k个我们就得递归返回到上一层的父节点然后以这整个父节点的区域编码为目标进行检索。这样我们就避免了要再次从树根检索到父节点的开销从而提升了检索效率。
![](https://static001.geekbang.org/resource/image/96/96/9661a343a32946b6bd6d96fd4736f196.jpg "自动调整范围的Top K检索")
## 如何利用非满四叉树优化存储空间?
尽管,我们使用以最小区域单位为叶子节点的满四叉树,能够很好的提升检索效率,但是在数据稀疏的时候,许多叶子节点中的数据可能是空的,这就很有可能造成大量的空间浪费。为了避免出现空间浪费,我们有一种改进方案是,使用动态节点分裂的**非满四叉树**。
首先我们可以给每个叶子节点规定一个容纳上限。比如说我们可以将上限设置为n。那么一开始的四叉树只有一个根节点这个根节点同时也是叶子节点它表明了当前的全部空间范围。当有数据加入的时候我们直接记录在这个节点中查询时也只查询这个节点即可。因此当插入的数据个数小于n时我们不需要进行任何复杂的查找操作只需要将根节点的所有数据读出然后进行距离计算并排序即可。
随着加入的数据越来越多如果一个叶子节点的容量超出了容纳上限我们就将该节点进行分裂。首先我们将该节点转为中间节点然后我们会为这个节点生成1至4个叶子节点注意不是一定要生成4个叶子节点并将原来存在这个节点上的数据都转入到对应的叶子节点中。这样我们就完成了分裂。
不过,有一种极端的情况是,这些数据都会转入到同一个下层叶子节点上。这时,我们就需要继续分裂这个叶子节点,直到每个叶子节点的容量在阈值下为止。
通过这种动态生成叶节点的方案,我们就能得到一棵非满四叉树。和满四叉树相比,它的叶子节点会更少,而且每个叶子节点表示的区域范围也可能是不一样的。这使得非满四叉树具有更好的空间利用率。非满四叉树的查询过程和满四叉树十分相似,也是根据当前的区域编码,找到对应的叶子节点,并根据该叶子节点上存储的数据数量,判断是否要递归扩大范围。这里我就不再详细说了。
![](https://static001.geekbang.org/resource/image/ee/c7/ee48d9c5df4625321c8a06db4dde7cc7.jpg "非满四叉树-动态分裂叶节点")
## 如何用前缀树优化GeoHash编码的索引
上面我们都是用二进制编码来说明的。你可能会问如果我们使用了GeoHash编码方式是否也可以用类似的检索技术来索引呢当然是可以的。实际上对于字符串的检索**有一种专门的数据结构叫作前缀树Trie树。**
前缀树的思路和四叉树非常相似它也是一种逐层划分检索空间的数据结构。它的根节点代表了整个检索空间然后每个中间节点和叶子节点都只存储一个字符代表一个分支。这样从根节点到叶子节点的路径连起来就是一个完整的字符串。因此当使用GeoHash编码来表示区域时我们可以建立一个前缀树来进行索引前缀树的每个节点最多会有32个子节点。
![](https://static001.geekbang.org/resource/image/a4/43/a466fc2217c89d537a587547a0589143.jpeg "前缀树")
那如何利用前缀树来检索呢举个例子当我们查询wx4g6yc8这个区域时我们会沿着w-x-4-g-6-y-c-8的路径检索到对应的叶子节点然后取出这个叶子节点上存储的数据。如果这个区域的数据不足k个就返回到父节点上检索对应的区域直到返回结果达到k个为止。由于整体思路和四叉树是十分相似的这里就不展开细说了。
此外前缀树除了用在GeoHash编码的检索上也经常用于字典的检索因此也叫字典树。字典树适用于匹配字符串的检索场合。
总结来说利用树形结构来划分空间提高检索效率的方案它的应用非常广泛。对于更高维度空间的最近邻检索我们也可以使用类似的检索方案来划分空间。比如说在三维空间中八叉树就是常见的检索方案。那拓展到更高的维度如k维我们还可以使用**k-d树**K-Dimensional Tree来检索。
k-d树一种是更通用的对任意维度都可以使用的检索方案。k-d树和四叉树、八叉树的检索思路并不相同它在划分子空间的时候并不是直接将整个空间划分为2^k个子空间而是会选出最有区分度的一个维度将该维度的空间进行二分然后对划分出的子空间再进行同样的二分处理所以它实际上是一个二叉树。而且由于它的分支数和维度k的具体值无关因此具有更好的通用性。
事实上k-d树在维度规模不大的场景下确实具有不错的检索效率。但是在成百上千的超高维度的场景中k-d树的性能会急剧下降。那在高维空间中我们又该如何快速地查找到最近的k个对象呢这个问题也是搜索引擎和推荐引擎在很多应用场景中都要解决问题。在后面两讲中我们会对它作详细讲解。
## 重点回顾
今天我们重点学习了在二维空间中利用四叉树来快速寻找最近的k个元素的方法。
在需要动态调整查询范围的场景下对于二进制编码的二维空间的最近邻检索问题我们可以通过四叉树来完成。四叉树可以很好地快速划分查询空间并通过递归的方式高效地扩大查询范围。但是满四叉树经常会造成无谓的空间浪费为了避免这个问题在实际应用的时候我们会选择使用非满四叉树来存储和索引编码。对于GeoHash编码的二维空间最近邻检索问题我们也能通过类似的前缀树来提高检索效率。
## 课堂讨论
在非满四叉树的分裂过程中为什么一个节点不一定会生成4个叶子节点你能举一个例子吗
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。