高效的多維空間點索引算法 — Geohash 和 Google S2

引子

每天我們晚上加班回家,可能都會用到滴滴或者共享單車。打開 app 會看到如下的界面:

app 界面上會顯示出自己附近一個範圍內可用的出租車或者共享單車。假設地圖上會顯示以自己爲圓心,5公里爲半徑,這個範圍內的車。如何實現呢?最直觀的想法就是去數據庫裏面查表,計算並查詢車距離用戶小於等於5公里的,篩選出來,把數據返回給客戶端。

這種做法比較笨,一般也不會這麼做。爲什麼呢?因爲這種做法需要對整個表裏面的每一項都計算一次相對距離。太耗時了。既然數據量太大,我們就需要分而治之。那麼就會想到把地圖分塊。這樣即使每一塊裏面的每條數據都計算一次相對距離,也比之前全表都計算一次要快很多。

我們也都知道,現在用的比較多的數據庫 MySQL、PostgreSQL 都原生支持 B+ 樹。這種數據結構能高效的查詢。地圖分塊的過程其實就是一種添加索引的過程,如果能想到一個辦法,把地圖上的點添加一個合適的索引,並且能夠排序,那麼就可以利用類似二分查找的方法進行快速查詢。

問題就來了,地圖上的點是二維的,有經度和緯度,這如何索引呢?如果只針對其中的一個維度,經度或者緯度進行搜索,那搜出來一遍以後還要進行二次搜索。那要是更高維度呢?三維。可能有人會說可以設置維度的優先級,比如拼接一個聯合鍵,那在三維空間中,x,y,z 誰的優先級高呢?設置優先級好像並不是很合理。

本篇文章就來介紹2種比較通用的空間點索引算法。

一. GeoHash 算法

1. Genhash 算法簡介

Genhash 是一種地理編碼,由 Gustavo Niemeyer 發明的。它是一種分級的數據結構,把空間劃分爲網格。Genhash 屬於空間填充曲線中的 Z 階曲線(Z-order curve)的實際應用。

何爲 Z 階曲線?

上圖就是 Z 階曲線。這個曲線比較簡單,生成它也比較容易,只需要把每個 Z 首尾相連即可。

Z 階曲線同樣可以擴展到三維空間。只要 Z 形狀足夠小並且足夠密,也能填滿整個三維空間。

說到這裏可能讀者依舊一頭霧水,不知道 Geohash 和 Z 曲線究竟有啥關係?其實 Geohash算法 的理論基礎就是基於 Z 曲線的生成原理。繼續說回 Geohash。

Geohash 能夠提供任意精度的分段級別。一般分級從 1-12 級。

字符串長度 cell 寬度 cell 高度

字符串長度 cell 寬度 cell 高度
15,000km×5,000km
21,250km×625km
3156km×156km
439.1km×19.5km
54.89km×4.89km
61.22km×0.61km
7153m×153m
838.2m×19.1m
94.77m×4.77m
101.19m×0.596m
11149mm×149mm
1237.2mm×18.6mm

還記得引語裏面提到的問題麼?這裏我們就可以用 Geohash 來解決這個問題。

我們可以利用 Geohash 的字符串長短來決定要劃分區域的大小。這個對應關係可以參考上面表格裏面 cell 的寬和高。一旦選定 cell 的寬和高,那麼 Geohash 字符串的長度就確定下來了。這樣我們就把地圖分成了一個個的矩形區域了。

地圖上雖然把區域劃分好了,但是還有一個問題沒有解決,那就是如何快速的查找一個點附近鄰近的點和區域呢?

Geohash 有一個和 Z 階曲線相關的性質,那就是一個點附近的地方(但不絕對) hash 字符串總是有公共前綴,並且公共前綴的長度越長,這兩個點距離越近。

由於這個特性,Geohash 就常常被用來作爲唯一標識符。用在數據庫裏面可用 Geohash 來表示一個點。Geohash 這個公共前綴的特性就可以用來快速的進行鄰近點的搜索。越接近的點通常和目標點的 Geohash 字符串公共前綴越長(但是這不一定,也有特殊情況,下面舉例會說明)

Geohash 也有幾種編碼形式,常見的有2種,base 32 和 base 36。

Decimal0123456789101112131415
Base 320123456789bcdefg
Decimal16171819202122232425262728293031
Base 32hjkmnpqrstuvwxyz

base 36 的版本對大小寫敏感,用了36個字符,“23456789bBCdDFgGhHjJKlLMnNPqQrRtTVWX”。

Decimal0123456789101112131415161718
Base 3623456789bBCdDFgGhHj
Decimal1920212223242526272829303132333435
Base 36JKILMnNPqQrRtTVWX

2. Geohash 實際應用舉例

接下來的舉例以 base-32 爲例。舉個例子。

上圖是一個地圖,地圖中間有一個美羅城,假設需要查詢距離美羅城最近的餐館,該如何查詢?

第一步我們需要把地圖網格化,利用 geohash。通過查表,我們選取字符串長度爲6的矩形來網格化這張地圖。

經過查詢,美羅城的經緯度是[31.1932993, 121.43960190000007]。

先處理緯度。地球的緯度區間是[-90,90]。把這個區間分爲2部分,即[-90,0),[0,90]。31.1932993位於(0,90]區間,即右區間,標記爲1。然後繼續把(0,90]區間二分,分爲[0,45),[45,90],31.1932993位於[0,45)區間,即右左區間,標記爲0。一直劃分下去。

左區間中值右區間二進制結果
-900901
045900
022.5451
22.533.75450
22.528.12533.751
28.12530.937533.751
30.937532.3437533.750
30.937531.64062532.343750
30.937531.289062531.6406250
30.937531.113281231.28906251
31.113281231.201171831.28906250
31.113281231.157226531.20117181
31.157226531.179199231.20117181
31.179199231.190185531.20117181
31.190185531.195678631.20117180

再處理經度,一樣的處理方式。地球經度區間是[-180,180]

左區間中值右區間二進制結果
-18001801
0901801
901351800
90112.51351
112.5123.751350
112.5118.125123.751
118.125120.9375123.751
120.9375122.34375123.750
120.9375121.640625122.343750
120.9375121.289062121.6406251
121.289062121.464844121.6406250
121.289062121.376953121.4648441
121.376953121.420898121.4648441
121.420898121.442871121.4648440
121.420898121.431885121.4428711

緯度產生的二進制是101011000101110,經度產生的二進制是110101100101101,按照“偶數位放經度,奇數位放緯度”的規則,重新組合經度和緯度的二進制串,生成新的:111001100111100000110011110110,最後一步就是把這個最終的字符串轉換成字符,對應需要查找 base-32 的表。11100 11001 11100 00011 00111 10110轉換成十進制是 28 25 28 3 7 22,查表編碼得到最終結果,wtw37q。

我們還可以把這個網格周圍8個各自都計算出來。

從地圖上可以看出,這鄰近的9個格子,前綴都完全一致。都是wtw37。

如果我們把字符串再增加一位,會有什麼樣的結果呢?Geohash 增加到7位。

當Geohash 增加到7位的時候,網格更小了,美羅城的 Geohash 變成了 wtw37qt。

看到這裏,讀者應該已經清楚了 Geohash 的算法原理了。咱們把6位和7位都組合到一張圖上面來看。

可以看到中間大格子的 Geohash 的值是 wtw37q,那麼它裏面的所有小格子前綴都是 wtw37q。可以想象,當 Geohash 字符串長度爲5的時候,Geohash 肯定就爲 wtw37 了。

接下來解釋之前說的 Geohash 和 Z 階曲線的關係。回顧最後一步合併經緯度字符串的規則,“偶數位放經度,奇數位放緯度”。讀者一定有點好奇,這個規則哪裏來的?憑空瞎想的?其實並不是,這個規則就是 Z 階曲線。看下圖:

x 軸就是緯度,y軸就是經度。經度放偶數位,緯度放奇數位就是這樣而來的。

最後有一個精度的問題,下面的表格數據一部分來自 Wikipedia。

Geohash 字符串長度 緯度 經度 緯度誤差 經度誤差 km誤差

Geohash 字符串長度緯度經度緯度誤差經度誤差km誤差
123±23±23±2500
255±2.8±5.6±630
378±0.70±0.70±78
41010±0.087±0.18±20
51213±0.022±0.022±2.4
61515±0.0027±0.0055±0.61
71718±0.00068±0.00068±0.076
82020±0.000085±0.00017±0.019
92223   
102525   
112728   
123030   

3. Geohash 具體實現

到此,讀者應該對 Geohash 的算法都很明瞭了。接下來用 Go 實現一下 Geohash 算法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package geohash
 
import (
    "bytes"
)
 
const (
    BASE32                = "0123456789bcdefghjkmnpqrstuvwxyz"
    MAX_LATITUDE  float64 = 90
    MIN_LATITUDE  float64 = -90
    MAX_LONGITUDE float64 = 180
    MIN_LONGITUDE float64 = -180
)
 
var (
    bits   = []int{16, 8, 4, 2, 1}
    base32 = []byte(BASE32)
)
 
type Box struct {
    MinLat, MaxLat float64 // 緯度
    MinLng, MaxLng float64 // 經度
}
 
func (this *Box) Width() float64 {
    return this.MaxLng - this.MinLng
}
 
func (this *Box) Height() float64 {
    return this.MaxLat - this.MinLat
}
 
// 輸入值:緯度,經度,精度(geohash的長度)
// 返回geohash, 以及該點所在的區域
func Encode(latitude, longitude float64, precision int) (string, *Box) {
    var geohash bytes.Buffer
    var minLat, maxLat float64 = MIN_LATITUDE, MAX_LATITUDE
    var minLng, maxLng float64 = MIN_LONGITUDE, MAX_LONGITUDE
    var mid float64 = 0
 
    bit, ch, length, isEven := 0, 0, 0, true
    for length < precision {
        if isEven {
            if mid = (minLng + maxLng) / 2; mid < longitude {
                ch |= bits[bit]
                minLng = mid
            else {
                maxLng = mid
            }
        else {
            if mid = (minLat + maxLat) / 2; mid < latitude {
                ch |= bits[bit]
                minLat = mid
            else {
                maxLat = mid
            }
        }
 
        isEven = !isEven
        if bit < 4 {
            bit++
        else {
            geohash.WriteByte(base32[ch])
            length, bit, ch = length+1, 0, 0
        }
    }
 
    b := &Box{
        MinLat: minLat,
        MaxLat: maxLat,
        MinLng: minLng,
        MaxLng: maxLng,
    }
 
    return geohash.String(), b
}

4. Geohash 的優缺點

Geohash 的優點很明顯,它利用 Z 階曲線進行編碼。而 Z 階曲線可以將二維或者多維空間裏的所有點都轉換成一維曲線。在數學上成爲分形維。並且 Z 階曲線還具有局部保序性。

Z 階曲線通過交織點的座標值的二進制表示來簡單地計算多維度中的點的z值。一旦將數據被加到該排序中,任何一維數據結構,例如二叉搜索樹,B樹,跳躍表或(具有低有效位被截斷)哈希表 都可以用來處理數據。通過 Z 階曲線所得到的順序可以等同地被描述爲從四叉樹的深度優先遍歷得到的順序。

這也是 Geohash 的另外一個優點,搜索查找鄰近點比較快。

Geohash 的缺點之一也來自 Z 階曲線。

Z 階曲線有一個比較嚴重的問題,雖然有局部保序性,但是它也有突變性。在每個 Z 字母的拐角,都有可能出現順序的突變。

看上圖中標註出來的藍色的點點。每兩個點雖然是相鄰的,但是距離相隔很遠。看右下角的圖,兩個數值鄰近紅色的點兩者距離幾乎達到了整個正方形的邊長。兩個數值鄰近綠色的點也達到了正方形的一半的長度。

Geohash 的另外一個缺點是,如果選擇不好合適的網格大小,判斷鄰近點可能會比較麻煩。

看上圖,如果選擇 Geohash 字符串爲6的話,就是藍色的大格子。紅星是美羅城,紫色的圓點是搜索出來的目標點。如果用 Geohash 算法查詢的話,距離比較近的可能是 wtw37p,wtw37r,wtw37w,wtw37m。但是其實距離最近的點就在 wtw37q。如果選擇這麼大的網格,就需要再查找周圍的8個格子。

如果選擇 Geohash 字符串爲7的話,那變成黃色的小格子。這樣距離紅星星最近的點就只有一個了。就是 wtw37qw。

如果網格大小,精度選擇的不好,那麼查詢最近點還需要再次查詢周圍8個點。

二. 空間填充曲線 和 分形

在介紹第二種多維空間點索引算法之前,要先談談空間填充曲線(Space-filling curve)和分形。

解決多維空間點索引需要解決2個問題,第一,如何把多維降爲低維或者一維?第二,一維的曲線如何分形?

1. 空間填充曲線

在數學分析中,有這樣一個難題:能否用一條無限長的線,穿過任意維度空間裏面的所有點?

在1890年,Giuseppe Peano 發現了一條連續曲線,現在稱爲 Peano 曲線,它可以穿過單位正方形上的每個點。他的目的是構建一個可以從單位區間到單位正方形的連續映射。 Peano 受到 Georg Cantor 早期違反直覺的研究結果的啓發,即單位區間中無限數量的點與任何有限維度流型(manifold)中無限數量的點,基數相同。 Peano 解決的問題實質就是,是否存在這樣一個連續的映射,一條能填充滿平面的曲線。上圖就是他找到的一條曲線。

一般來說,一維的東西是不可能填滿2維的方格的。但是皮亞諾曲線恰恰給出了反例。皮亞諾曲線是一條連續的但處處不可導的曲線。

皮亞諾曲線的構造方法如下:取一個正方形並且把它分出九個相等的小正方形,然後從左下角的正方形開始至右上角的正方形結束,依次把小正方形的中心用線段連接起來;下一步把每個小正方形分成九個相等的正方形,然後上述方式把其中中心連接起來……將這種操作手續無限進行下去,最終得到的極限情況的曲線就被稱作皮亞諾曲線。

皮亞諾對區間[0,1]上的點和正方形上的點的映射作了詳細的數學描述。實際上,正方形的這些點對於

,可找到兩個連續函數 x = f(t) 和 y = g(t),使得 x 和 y 取屬於單位正方形的每一個值。

一年後,即1891年,希爾伯特就作出了這條曲線,叫希爾伯特曲線(Hilbert curve)。

上圖就是1-6階的希爾伯特曲線。具體構造方式在下一章再說。

上圖是希爾伯特曲線填充滿3維空間。

之後還有很多變種的空間填充曲線,龍曲線(Dragon curve)、 高斯帕曲線(Gosper curve)、Koch曲線(Koch curve)、摩爾定律曲線(Moore curve)、謝爾賓斯基曲線(Sierpiński curve)、奧斯古德曲線(Osgood curve)。這些曲線和本文無關,就不詳細介紹了。

在數學分析中,空間填充曲線是一個參數化的注入函數,它將單位區間映射到單位正方形,立方體,更廣義的,n維超立方體等中的連續曲線,隨着參數的增加,它可以任意接近單位立方體中的給定點。除了數學重要性之外,空間填充曲線也可用於降維,數學規劃,稀疏多維數據庫索引,電子學和生物學。空間填充曲線的現在被用在互聯網地圖中。

2. 分形

皮亞諾曲線的出現,說明了人們對維數的認識是有缺陷的,有必要重新考察維數的定義。這就是分形幾何考慮的問題。在分形幾何中,維數可以是分數叫做分維。

多維空間降維以後,如何分形,也是一個問題。分形的方式有很多種,這裏有一個列表,可以查看如何分形,以及每個分形的分形維數,即豪斯多夫分形維(Hausdorff fractals dimension)和拓撲維數。這裏就不細說分形的問題了,感興趣的可以仔細閱讀鏈接裏面的內容。

接下來繼續來說多維空間點索引算法,下面一個算法的理論基礎來自希爾伯特曲線,先來仔細說說希爾伯特曲線。

三. Hilbert Curve 希爾伯特曲線

1. 希爾伯特曲線的定義

希爾伯特曲線一種能填充滿一個平面正方形的分形曲線(空間填充曲線),由大衛·希爾伯特在1891年提出。

由於它能填滿平面,它的豪斯多夫維是2。取它填充的正方形的邊長爲1,第n步的希爾伯特曲線的長度是2^n - 2^(-n)。

2. 希爾伯特曲線的構造方法

一階的希爾伯特曲線,生成方法就是把正方形四等分,從其中一個子正方形的中心開始,依次穿線,穿過其餘3個正方形的中心。

二階的希爾伯特曲線,生成方法就是把之前每個子正方形繼續四等分,每4個小的正方形先生成一階希爾伯特曲線。然後把4個一階的希爾伯特曲線首尾相連。

三階的希爾伯特曲線,生成方法就是與二階類似,先生成二階希爾伯特曲線。然後把4個二階的希爾伯特曲線首尾相連。

n階的希爾伯特曲線的生成方法也是遞歸的,先生成n-1階的希爾伯特曲線,然後把4個n-1階的希爾伯特曲線首尾相連。

3. 爲何要選希爾伯特曲線

看到這裏可能就有讀者有疑問了,這麼多空間填充曲線,爲何要選希爾伯特曲線?

因爲希爾伯特曲線有非常好的特性。

(1) 降維

首先,作爲空間填充曲線,希爾伯特曲線可以對多維空間有效的降維。

上圖就是希爾伯特曲線在填滿一個平面以後,把平面上的點都展開成一維的線了。

可能有人會有疑問,上圖裏面的希爾伯特曲線只穿了16個點,怎麼能代表一個平面呢?

當然,當n趨近於無窮大的時候,n階希爾伯特曲線就可以近似填滿整個平面了。

(2) 穩定

當n階希爾伯特曲線,n趨於無窮大的時候,曲線上的點的位置基本上趨於穩定。舉個例子:

上圖左邊是希爾伯特曲線,右邊是蛇形的曲線。當n趨於無窮大的時候,兩者理論上都可以填滿平面。但是爲何希爾伯特曲線更加優秀呢?

在蛇形曲線上給定一個點,當n趨於無窮大的過程中,這個點在蛇形曲線上的位置是時刻變化的。

這就造成了點的相對位置始終不定。

再看看希爾伯特曲線,同樣是一個點,在n趨於無窮大的情況下:

從上圖可以看到,點的位置幾乎沒有怎麼變化。所以希爾伯特曲線更加優秀。

(3) 連續

希爾伯特曲線是連續的,所以能保證一定可以填滿空間。連續性是需要數學證明的。具體證明方法這裏就不細說了,感興趣的可以點文章末尾一篇關於希爾伯特曲線的論文,那裏有連續性的證明。

接下來要介紹的谷歌的 S2 算法就是基於希爾伯特曲線的。現在讀者應該明白選擇希爾伯特曲線的原因了吧。

四. S? 算法

Google’s S2 library is a real treasure, not only due to its capabilities for spatial indexing but also because it is a library that was released more than 4 years ago and it didn’t get the attention it deserved

上面這段話來自2015年一位谷歌工程師的博文。他由衷的感嘆 S2 算法發佈4年沒有得到它應有的讚賞。不過現在 S2 已經被各大公司使用了。

在介紹這個重量級算法之前,先解釋一些這個算法的名字由來。S2其實是來自幾何數學中的一個數學符號 S?,它表示的是單位球。S2 這個庫其實是被設計用來解決球面上各種幾何問題的。值得提的一點是,除去 golang 官方 repo 裏面的 geo/s2 完成度目前只有40%,其他語言,Java,C++,Python 的 S2 實現都完成100%了。本篇文章講解以 Go 的這個版本爲主。

接下來就看看怎麼用 S2 來解決多維空間點索引的問題的。

1. 球面座標轉換

按照之前我們處理多維空間的思路,先考慮如何降維,再考慮如何分形。

衆所周知,地球是近似一個球體。球體是一個三維的,如何把三維降成一維呢?

球面上的一個點,在直角座標系中,可以這樣表示:

1
2
3
x = r * sin θ * cos φ
y = r * sin θ * sin φ 
z = r * cos θ

通常地球上的點我們會用經緯度來表示。

再進一步,我們可以和球面上的經緯度聯繫起來。不過這裏需要注意的是,緯度的角度 α 和直角座標系下的球面座標 θ 加起來等於 90°。所以三角函數要注意轉換。

於是地球上任意的一個經緯度的點,就可以轉換成 f(x,y,z)。

在 S2 中,地球半徑被當成單位 1 了。所以半徑不用考慮。x,y,z的值域都被限定在了[-1,1] x [-1,1] x [-1,1]這個區間之內了。

2. 球面變平面

接下來一步 S2 把球面碾成平面。怎麼做的呢?

首先在地球外面套了一個外切的正方體,如下圖。

從球心向外切正方體6個面分別投影。S2 是把球面上所有的點都投影到外切正方體的6個面上。

這裏簡單的畫了一個投影圖,上圖左邊的是投影到正方體一個面的示意圖,實際上影響到的球面是右邊那張圖。

從側面看,其中一個球面投影到正方體其中一個面上,邊緣與圓心的連線相互之間的夾角爲90°,但是和x,y,z軸的角度是45°。我們可以在球的6個方向上,把45°的輔助圓畫出來,見下圖左邊。

上圖左邊的圖畫了6個輔助線,藍線是前後一對,紅線是左右一對,綠線是上下一對。分別都是45°的地方和圓心連線與球面相交的點的軌跡。這樣我們就可以把投影到外切正方體6個面上的球面畫出來,見上圖右邊。

投影到正方體以後,我們就可以把這個正方體展開了。

一個正方體的展開方式有很多種。不管怎麼展開,最小單元都是一個正方形。

以上就是 S2 的投影方案。接下來講講其他的投影方案。

首先有下面一種方式,三角形和正方形組合。

這種方式展開圖如下圖。

這種方式其實很複雜,構成子圖形由兩種圖形構成。座標轉換稍微複雜一點。

再還有一種方式是全部用三角形組成,這種方式三角形個數越多,就能越切近於球體。

上圖最左邊的圖,由20個三角形構成,可以看的出來,菱角非常多,與球體相差比較大,當三角形個數越來越多,就越來越貼近球體。

20個三角形展開以後就可能變成這樣。

最後一種方式可能是目前最好的方式,不過也可能是最複雜的方式。按照六邊形來投影。

六邊形的菱角比較少,六個邊也能相互銜接其他的六邊形。看上圖最後邊的圖可以看出來,六邊形足夠多以後,非常近似球體。

六邊形展開以後就是上面這樣。當然這裏只有12個六邊形。六邊形個數越多越好,粒度越細,就越貼近球體。

Uber 在一個公開分享上提到了他們用的是六邊形的網格,把城市劃分爲很多六邊形。這塊應該是他們自己開發的。也許滴滴也是劃分六邊形,也許滴滴有更好的劃分方案也說不定。

回到 S2 上面來,S2是用的正方形。這樣第一步的球面座標進一步的被轉換成 f(x,y,z) -> g(face,u,v),face是正方形的六個面,u,v對應的是六個面中的一個面上的x,y座標。

3. 球面矩形投影修正

上一步我們把球面上的球面矩形投影到正方形的某個面上,形成的形狀類似於矩形,但是由於球面上角度的不同,最終會導致即使是投影到同一個面上,每個矩形的面積也不大相同。

上圖就表示出了球面上個一個球面矩形投影到正方形一個面上的情況。

經過實際計算髮現,最大的面積和最小的面積相差5.2倍。見上圖左邊。相同的弧度區間,在不同的緯度上投影到正方形上的面積不同。

現在就需要修正各個投影出來形狀的面積。如何選取合適的映射修正函數就成了關鍵。目標是能達到上圖右邊的樣子,讓各個矩形的面積儘量相同。

這塊轉換的代碼在 C++ 的版本里面纔有詳細的解釋,在 Go 的版本里面只一筆帶過了。害筆者懵逼了好久。

 面積比率邊比率對角線比率ToPointRawToPointFromPoint
線性變換5.2002.1172.9590.0200.0870.085
tan()變換1.4141.4141.7040.2370.2990.258
二次變換2.0821.8021.9320.0330.0960.108

線性變換是最快的變換,但是變換比最小。tan() 變換可以使每個投影以後的矩形的面積更加一致,最大和最小的矩形比例僅僅只差0.414。可以說非常接近了。但是 tan() 函數的調用時間非常長。如果把所有點都按照這種方式計算的話,性能將會降低3倍。

最後谷歌選擇的是二次變換,這是一個近似切線的投影曲線。它的計算速度遠遠快於 tan() ,大概是 tan() 計算的3倍速度。生成的投影以後的矩形大小也類似。不過最大的矩形和最小的矩形相比依舊有2.082的比率。

上表中,ToPoint 和 FromPoint 分別是把單位向量轉換到 Cell ID 所需要的毫秒數、把 Cell ID 轉換回單位向量所需的毫秒數(Cell ID 就是投影到正方體六個面,某個面上矩形的 ID,矩形稱爲 Cell,它對應的 ID 稱爲 Cell ID)。ToPointRaw 是某種目的下,把 Cell ID 轉換爲非單位向量所需的毫秒數。

在 S2 中默認的轉換是二次轉換。

1
#define S2_PROJECTION S2_QUADRATIC_PROJECTION

詳細看看這三種轉換到底是怎麼轉換的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#if S2_PROJECTION == S2_LINEAR_PROJECTION
 
inline double S2::STtoUV(double s) {
  return 2 * s - 1;
}
 
inline double S2::UVtoST(double u) {
  return 0.5 * (u + 1);
}
 
#elif S2_PROJECTION == S2_TAN_PROJECTION
 
inline double S2::STtoUV(double s) {
  // Unfortunately, tan(M_PI_4) is slightly less than 1.0.  This isn't due to
  // a flaw in the implementation of tan(), it's because the derivative of
  // tan(x) at x=pi/4 is 2, and it happens that the two adjacent floating
  // point numbers on either side of the infinite-precision value of pi/4 have
  // tangents that are slightly below and slightly above 1.0 when rounded to
  // the nearest double-precision result.
 
  s = tan(M_PI_2 * s - M_PI_4);
  return s + (1.0 / (GG_LONGLONG(1) << 53)) * s;
}
 
inline double S2::UVtoST(double u) {
  volatile double a = atan(u);
  return (2 * M_1_PI) * (a + M_PI_4);
}
 
#elif S2_PROJECTION == S2_QUADRATIC_PROJECTION
 
inline double S2::STtoUV(double s) {
  if (s >= 0.5) return (1/3.) * (4*s*s - 1);
  else          return (1/3.) * (1 - 4*(1-s)*(1-s));
}
 
inline double S2::UVtoST(double u) {
  if (u >= 0) return 0.5 * sqrt(1 + 3*u);
  else        return 1 - 0.5 * sqrt(1 - 3*u);
}
 
#else
 
#error Unknown value for S2_PROJECTION
 
#endif

上面有一處對 tan(M_PI_4) 的處理,是因爲精度的原因,導致略小於1.0 。

所以投影之後的修正函數三種變換應該如下:

1
2
3
4
5
6
7
8
9
// 線性轉換
u = 0.5 * ( u + 1)
 
// tan() 變換
u = 2 / pi * (atan(u) + pi / 4) = 2 * atan(u) / pi + 0.5
 
// 二次變換
u >= 0,u = 0.5 * sqrt(1 + 3*u)
u < 0,    u = 1 - 0.5 * sqrt(1 - 3*u)

注意上面雖然變換公式只寫了u,不代表只變換u。實際使用過程中,u,v都分別當做入參,都會進行變換。

這塊修正函數在 Go 的版本里面就直接只實現了二次變換,其他兩種變換方式找遍整個庫,根本沒有提及。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// stToUV converts an s or t value to the corresponding u or v value.
// This is a non-linear transformation from [-1,1] to [-1,1] that
// attempts to make the cell sizes more uniform.
// This uses what the C++ version calls 'the quadratic transform'.
func stToUV(s float64) float64 {
    if s >= 0.5 {
        return (1 / 3.) * (4*s*s - 1)
    }
    return (1 / 3.) * (1 - 4*(1-s)*(1-s))
}
 
// uvToST is the inverse of the stToUV transformation. Note that it
// is not always true that uvToST(stToUV(x)) == x due to numerical
// errors.
func uvToST(u float64) float64 {
    if u >= 0 {
        return 0.5 * math.Sqrt(1+3*u)
    }
    return 1 - 0.5*math.Sqrt(1-3*u)
}

經過修正變換以後,u,v都變換成了s,t。值域也發生了變化。u,v的值域是[-1,1],變換以後,是s,t的值域是[0,1]。

至此,小結一下,球面上的點S(lat,lng) -> f(x,y,z) -> g(face,u,v) -> h(face,s,t)。目前總共轉換了4步,球面經緯度座標轉換成球面xyz座標,再轉換成外切正方體投影面上的座標,最後變換成修正後的座標。

到目前爲止,S2 可以優化的點有兩處,一是投影的形狀能否換成六邊形?二是修正的變換函數能否找到一個效果和 tan() 類似的函數,但是計算速度遠遠高於 tan(),以致於不會影響計算性能?

4. 點與座標軸點相互轉換

在 S2 算法中,默認劃分 Cell 的等級是30,也就是說把一個正方形劃分爲 2^30 * 2^30個小的正方形。

那麼上一步的s,t映射到這個正方形上面來,對應該如何轉換呢?

s,t的值域是[0,1],現在值域要擴大到[0,230-1]。

1
2
3
4
// stToIJ converts value in ST coordinates to a value in IJ coordinates.
func stToIJ(s float64) int {
    return clamp(int(math.Floor(maxSize*s)), 0, maxSize-1)
}

C ++ 的實現版本也一樣

1
2
3
4
5
6
7
inline int S2CellId::STtoIJ(double s) {
  // Converting from floating-point to integers via static_cast is very slow
  // on Intel processors because it requires changing the rounding mode.
  // Rounding to the nearest integer using FastIntRound() is much faster.
  // 這裏減去0.5是爲了四捨五入
  return max(0, min(kMaxSize - 1, MathUtil::FastIntRound(kMaxSize * s - 0.5)));
}

到這一步,是h(face,s,t) -> H(face,i,j)。

5. 座標軸點與希爾伯特曲線 Cell ID 相互轉換

最後一步,如何把 i,j 和希爾伯特曲線上的點關聯起來呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const (
    lookupBits = 4
    swapMask   = 0x01
    invertMask = 0x02
)
 
var (
    ijToPos = [4][4]int{
        {0, 1, 3, 2}, // canonical order
        {0, 3, 1, 2}, // axes swapped
        {2, 3, 1, 0}, // bits inverted
        {2, 1, 3, 0}, // swapped & inverted
    }
    posToIJ = [4][4]int{
        {0, 1, 3, 2}, // canonical order:    (0,0), (0,1), (1,1), (1,0)
        {0, 2, 3, 1}, // axes swapped:       (0,0), (1,0), (1,1), (0,1)
        {3, 2, 0, 1}, // bits inverted:      (1,1), (1,0), (0,0), (0,1)
        {3, 1, 0, 2}, // swapped & inverted: (1,1), (0,1), (0,0), (1,0)
    }
    posToOrientation = [4]int{swapMask, 0, 0, invertMask | swapMask}
    lookupIJ         [1 << (2*lookupBits + 2)]int
    lookupPos        [1 << (2*lookupBits + 2)]int
)

在變換之前,先來解釋一下定義的一些變量。

posToIJ 代表的是一個矩陣,裏面記錄了一些單元希爾伯特曲線的位置信息。

把 posToIJ 數組裏面的信息用圖表示出來,如下圖:

同理,把 ijToPos 數組裏面的信息用圖表示出來,如下圖:

posToOrientation 數組裏面裝了4個數字,分別是1,0,0,3。

lookupIJ 和 lookupPos 分別是兩個容量爲1024的數組。這裏面分別對應的就是希爾伯特曲線 ID 轉換成座標軸 IJ 的轉換表,和座標軸 IJ 轉換成希爾伯特曲線 ID 的轉換表。

1
2
3
4
5
6
func init() {
    initLookupCell(0, 0, 0, 0, 0, 0)
    initLookupCell(0, 0, 0, swapMask, 0, swapMask)
    initLookupCell(0, 0, 0, invertMask, 0, invertMask)
    initLookupCell(0, 0, 0, swapMask|invertMask, 0, swapMask|invertMask)
}

這裏是初始化的遞歸函數,在希爾伯特曲線的標準順序中可以看到是有4個格子,並且格子都有順序的,所以初始化要遍歷滿所有順序。入參的第4個參數,就是從0 - 3 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// initLookupCell initializes the lookupIJ table at init time.
func initLookupCell(level, i, j, origOrientation, pos, orientation int) {
 
    if level == lookupBits {
        ij := (i << lookupBits) + j
        lookupPos[(ij<<2)+origOrientation] = (pos << 2) + orientation
        lookupIJ[(pos<<2)+origOrientation] = (ij << 2) + orientation
     
        return
    }
 
    level++
    i <<= 1
    j <<= 1
    pos <>1), j+(r[0]&1), origOrientation, pos, orientation^posToOrientation[0])
    initLookupCell(level, i+(r[1]>>1), j+(r[1]&1), origOrientation, pos+1, orientation^posToOrientation[1])
    initLookupCell(level, i+(r[2]>>1), j+(r[2]&1), origOrientation, pos+2, orientation^posToOrientation[2])
    initLookupCell(level, i+(r[3]>>1), j+(r[3]&1), origOrientation, pos+3, orientation^posToOrientation[3])
}

上面這個函數是生成希爾伯特曲線的。我們可以看到有一處對pos << 2的操作,這裏是把位置變換到第一個4個小格子中,所以位置乘以了4。

由於初始設置的lookupBits = 4,所以i,j的變化範圍是從[0,15],總共有16*16=256個,然後i,j座標是表示的4個格子,再細分,lookupBits = 4這種情況下能表示的點的個數就是256*4=1024個。這也正好是 lookupIJ 和 lookupPos 的總容量。

畫一個局部的圖,i,j從0-7變化。

上圖是一個4階希爾伯特曲線。初始化的實際過程就是初始化4階希爾伯特上的1024個點的座標與座標軸上的x,y軸的對應關係表。

舉個例子,下表是i,j在遞歸過程中產生的中間過程。下表是

lookupPos 表計算過程。

(i,j)ijij 計算過程lookupPos[i j]lookupPos[i j]計算過程實際座標
(0,0)0000(0,0)
(1,0)64(1*16+0)*4=6451*4+1=5(3,0)
(1,1)68(1*16+1)*4=6892*4+1=5(3,2)
(0,1)4(0*16+1)*4=4143*4+2=14(0,2)
(0,2)8(0*16+2)*4=8174*4+1=17(1,4)
(0,3)12(0*16+3)*4=12205*4+0=20(0,6)
(1,3)76(1*16+3)*4=76246*4+0=24(2,6)
(1,2)72(1*16+2)*4=72317*4+3=31(3,4)
(2,2)136(2*16+2)*4=136338*4+1=33(5,4)

取出一行詳細分析一下計算過程。

假設當前(i,j)=(0,2),ij 的計算過程是把 i 左移4位再加上 j,整體結果再左移2位。目的是爲了留出2位的方向位置。ij的前4位是i,接着4位是j,最後2位是方向。這樣計算出ij的值就是8 。

接着計算lookupPos[i j]的值。從上圖中可以看到(0,2)代表的單元格的4個數字是16,17,18,19 。計算到這一步,pos的值爲4(pos是專門記錄生成格子到第幾個了,總共pos的值會循環0-255)。pos代表的是當前是第幾個格子(4個小格子組成),當前是第4個,每個格子裏面有4個小格子。所以4*4就可以偏移到當前格子的第一個數字,也就是16 。posToIJ 數組裏面會記錄下當前格子的形狀。從這裏我們從中取出 orientation 。

看上圖,16,17,18,19對應的是 posToIJ 數組軸旋轉的情況,所以17是位於軸旋轉圖的數字1代表的格子中。這時 orientation = 1 。

這樣 lookupPos[i j] 表示的數字就計算出來了,4*4+1=17 。這裏就完成了i,j與希爾伯特曲線上數字的對應。

那如何由希爾伯特曲線上的數字對應到實際的座標呢?

lookupIJ 數組裏面記錄了反向的信息。lookupIJ 數組 和 lookupPos 數組存儲的信息正好是反向的。lookupIJ 數組 下表存的值是 lookupPos 數組 的下表。我們查 lookupIJ 數組 ,lookupIJ[17]的值就是8,對應算出來(i,j)=(0,2)。這個時候的i,j還是大格子。還是需要藉助 posToIJ 數組 裏面描述的形狀信息。當前形狀是軸旋轉,之前也知道 orientation = 1,由於每個座標裏面有4個小格子,所以一個i,j代表的是2個小格子,所以需要乘以2,再加上形狀信息裏面的方向,可以計算出實際的座標 (0 * 2 + 1 , 2 * 2 + 0) = ( 1,4) 。

至此,整個球面座標的座標映射就已經完成了。

球面上的點S(lat,lng) -> f(x,y,z) -> g(face,u,v) -> h(face,s,t) -> H(face,i,j) -> CellID。目前總共轉換了6步,球面經緯度座標轉換成球面xyz座標,再轉換成外切正方體投影面上的座標,最後變換成修正後的座標,再座標系變換,映射到 [0,230-1]區間,最後一步就是把座標系上的點都映射到希爾伯特曲線上。

6. S2 Cell ID 數據結構

最後需要來談談 S2 Cell ID 數據結構,這個數據結構直接關係到不同 Level 對應精度的問題。

在 S2 中,每個 CellID 是由64位的組成的。可以用一個 uint64 存儲。開頭的3位表示正方體6個面中的一個,取值範圍[0,5]。3位可以表示0-7,但是6,7是無效值。

64位的最後一位是1,這一位是特意留出來的。用來快速查找中間有多少位。從末尾最後一位向前查找,找到第一個不爲0的位置,即找到第一個1。這一位的前一位到開頭的第4位(因爲前3位被佔用)都是可用數字。

綠色格子有多少個就能表示劃分多少格。上圖左圖,綠色的有60個格子,於是可以表示[0,230 -1] * [0,230 -1]這麼多個格子。上圖右圖中,綠色格子只有36個,那麼就只能表示[0,218 -1]*[0,218 -1]這麼多個格子。

那麼不同 level 可以代表的網格的面積究竟是多大呢?

由上一章我們知道,由於投影的原因,所以導致投影之後的面積依舊有大小差別。

這裏推算的公式比較複雜,就不證明了,具體的可以看文檔。

1
2
3
MinAreaMetric = Metric{2, 8 * math.Sqrt2 / 9} 
AvgAreaMetric = Metric{2, 4 * math.Pi / 6} 
MaxAreaMetric = Metric{2, 2.635799256963161491}

這就是最大最小面積和平均面積的倍數關係。

level 0 就是正方體的六個面之一。地球表面積約等於510,100,000 km2。level 0 的面積就是地球表面積的六分之一。level 30 能表示的最小的面積0.48cm2,最大也就0.93cm2 。

7. S2 與 Geohash 對比

Geohash 有12級,從5000km 到 3.7cm。中間每一級的變化比較大。有時候可能選擇上一級會大很多,選擇下一級又會小一些。比如選擇字符串長度爲4,它對應的 cell 寬度是39.1km,需求可能是50km,那麼選擇字符串長度爲5,對應的 cell 寬度就變成了156km,瞬間又大了3倍了。這種情況選擇多長的 Geohash 字符串就比較難選。選擇不好,每次判斷可能就還需要取出周圍的8個格子再次進行判斷。Geohash 需要 12 bytes 存儲。

S2 有30級,從 0.7cm? 到 85,000,000km? 。中間每一級的變化都比較平緩,接近於4次方的曲線。所以選擇精度不會出現 Geohash 選擇困難的問題。S2 的存儲只需要一個 uint64 即可存下。

S2 庫裏面不僅僅有地理編碼,還有其他很多幾何計算相關的庫。地理編碼只是其中的一小部分。本文沒有介紹到的 S2 的實現還有很多很多,各種向量計算,面積計算,多邊形覆蓋,距離問題,球面球體上的問題,它都有實現。

S2 還能利用貪心算法求局部最優解。比如給定一個城市,求一個最優的解,多邊形剛剛好覆蓋住這個城市。

如上圖,生成的多邊形剛剛好覆蓋住下面藍色的區域。這裏生成的多邊形可以有大有小。不管怎麼樣,最終的結果也是剛剛覆蓋住目標物。

用相同的 Cell 也可以達到相同的目的,上圖就是用相同 Level 的 Cell 覆蓋了整個聖保羅城市。

這些都是 Geohash 做不到的。

8. S2 Cell 舉例

先來看看經緯度和 CellID 的轉換,以及矩形面積的計算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    latlng := s2.LatLngFromDegrees(31.232135, 121.41321700000003)
    cellID := s2.CellIDFromLatLng(latlng)
    cell := s2.CellFromCellID(cellID) //9279882742634381312
 
    // cell.Level()
    fmt.Println("latlng = ", latlng)
    fmt.Println("cell level = ", cellID.Level())
    fmt.Printf("cell = %d\n", cellID)
    smallCell := s2.CellFromCellID(cellID.Parent(10))
    fmt.Printf("smallCell level = %d\n", smallCell.Level())
    fmt.Printf("smallCell id = %b\n", smallCell.ID())
    fmt.Printf("smallCell ApproxArea = %v\n", smallCell.ApproxArea())
    fmt.Printf("smallCell AverageArea = %v\n", smallCell.AverageArea())
    fmt.Printf("smallCell ExactArea = %v\n", smallCell.ExactArea())

這裏 Parent 方法參數可以直接指定返回改點的對應 level 的 CellID。

上面那些方法打印出來的結果如下:

1
2
3
4
5
6
7
8
9
10
latlng =  [31.2321350, 121.4132170]
cell level =  30
cell = 3869277663051577529
 
****Parent **** 10000000000000000000000000000000000000000
smallCell level = 10
smallCell id = 11010110110010011011110000000000000000000000000000000000000000
smallCell ApproxArea = 1.9611002454714756e-06
smallCell AverageArea = 1.997370817559429e-06
smallCell ExactArea = 1.9611009480261058e-06

再舉一個覆蓋多邊形的例子。我們先隨便創建一個區域。

1
2
3
4
5
6
    rect = s2.RectFromLatLng(s2.LatLngFromDegrees(48.99, 1.852))
    rect = rect.AddPoint(s2.LatLngFromDegrees(48.68, 2.75))
 
    rc := &s2.RegionCoverer{MaxLevel: 20, MaxCells: 10, MinLevel: 2}
    r := s2.Region(rect.CapBound())
    covering := rc.Covering(r)

覆蓋參數設置成 level 2 - 20,最多的 Cell 的個數是10個。

接着我們把 Cell 至多改成20個。

最後再改成30個。

可以看到相同的 level 的範圍,cell 個數越多越精確目標範圍。

這裏是匹配矩形區域,匹配圓形區域也同理。

代碼就不貼了,與矩形類似。這種功能 Geohash 就做不到,需要自己手動實現了。

9. S2 的應用

S2 目前應用比較多,用在和地圖相關業務上更多。Google Map 就直接大量使用了 S2 ,速度有多快讀者可以自己體驗體驗。Uber 在搜尋最近的出租車也是用的 S2 算法進行計算的。場景的例子就是本篇文章引語裏面提到的場景。滴滴應該也有相關的應用,也許有更加優秀的解法。現在很火的共享單車也會用到這些空間索引算法。

最後就是外賣行業和地圖關聯也很密切。美團和餓了麼相信也在這方面有很多應用,具體哪裏用到了,就請讀者自己想象吧。

五. 最後

本篇文章裏面着重介紹了谷歌的 S2 算法的基礎實現。雖然 Geohash 也是空間點索引算法,但是性能方面比谷歌的 S2 略遜一籌。並且大公司的數據庫也基本上開始採用谷歌的 S2 算法進行索引。

關於空間搜索其實還有一大類問題,如何搜索多維空間線,多維空間面,多維空間多邊形呢?他們都是由無數個空間點組成的。實際的例子,比如街道,高樓,鐵路,河流。要搜索這些東西,數據庫表如何設計?如何做到高效的搜索呢?還能用 B+ 樹來做麼?

答案當然是也可以實現高效率的搜索,那就需要用到 R 樹,或者 R 樹 和 B+樹。

這部分就不在本文的範疇內了,下次有空可以再分享一篇《多維空間多邊形索引算法》

最後,請大家多多指點。

Reference:

  • 作者:一縷殤流化隱半邊冰霜

  • 鏈接:http://www.jianshu.com/p/7332dcb978b2

  • 來源:簡書

  • 著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

發佈了5 篇原創文章 · 獲贊 18 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章