筆者在《高效的多維空間點(diǎn)索引算法 — Geohash 和 Google S2》文章中詳細(xì)的分析了 Google S2 的算法實(shí)現(xiàn)思想。文章發(fā)出來以后,一部分讀者對(duì)它的實(shí)現(xiàn)產(chǎn)生了好奇。本文算是對(duì)上篇文章的補(bǔ)充,將從代碼實(shí)現(xiàn)的角度來看看 Google S2 的算法具體實(shí)現(xiàn)。建議先讀完上篇文章里面的算法思想,再看本篇的代碼實(shí)現(xiàn)會(huì)更好理解一些。
一. 什么是 Cell ?
Google S2 中定義了一個(gè)將單位球體分解成單元格層次結(jié)構(gòu)的框架。每個(gè) Cell 的單元格是由四個(gè)測(cè)地線限定的四邊形。通過將立方體的六個(gè)面投影到單位球上來獲得層級(jí)的頂層,通過遞歸地將每個(gè)單元細(xì)分為四個(gè)子層來獲得較低層。例如,下面的圖片顯示了六個(gè) face 中 Cell 的兩個(gè),其中一個(gè)已經(jīng)細(xì)分了幾次:
注意 Cell 邊緣似乎是彎曲的,這是因?yàn)樗鼈兪乔蛐螠y(cè)地線,即球體上的直線(類似于飛機(jī)飛行的路線)
S2 Level 對(duì)于空間索引和將區(qū)域逼近為單元集合非常有用。Cell 可用于表示點(diǎn)和區(qū)域:點(diǎn)通常表示為葉子節(jié)點(diǎn),而區(qū)域表示為任何 Level 的 Cell 的集合。例如,下面是夏威夷近似的22個(gè)單元的集合:
二. S(lat,lng) -> f(x,y,z)
緯度 Latitude 的取值范圍在 [-90°,90°] 之間。
經(jīng)度 Longitude 的取值范圍在 [-180°,180°] 之間。
第一步轉(zhuǎn)換,將球面坐標(biāo)轉(zhuǎn)換成三維直角坐標(biāo)
func makeCell() {
latlng := s2.LatLngFromDegrees(30.64964508, 104.12343895)
cellID := s2.CellIDFromLatLng(latlng)
}
上面短短兩句話就構(gòu)造了一個(gè) 64 位的CellID。
func LatLngFromDegrees(lat, lng float64) LatLng {
return LatLng{s1.Angle(lat) * s1.Degree, s1.Angle(lng) * s1.Degree}
}
上面這一步是把經(jīng)緯度轉(zhuǎn)換成弧度。由于經(jīng)緯度是角度,弧度轉(zhuǎn)角度乘以 π / 180° 即可。
const (
Radian Angle = 1
Degree = (math.Pi / 180) * Radian
}
LatLngFromDegrees 就是把經(jīng)緯度轉(zhuǎn)換成 LatLng 結(jié)構(gòu)體。LatLng 結(jié)構(gòu)體定義如下:
type LatLng struct {
Lat, Lng s1.Angle
}
得到了 LatLng 結(jié)構(gòu)體以后,就可以通過 CellIDFromLatLng 方法把經(jīng)緯度弧度轉(zhuǎn)成 64 位的 CellID 了。
func CellIDFromLatLng(ll LatLng) CellID {
return cellIDFromPoint(PointFromLatLng(ll))
}
上述方法也分了2步完成,先把經(jīng)緯度轉(zhuǎn)換成坐標(biāo)系上的一個(gè)點(diǎn),再把坐標(biāo)系上的這個(gè)點(diǎn)轉(zhuǎn)換成 CellID。
關(guān)于經(jīng)緯度如何轉(zhuǎn)換成坐標(biāo)系上的一個(gè)點(diǎn),這部分的大體思路分析見筆者的這篇文章,這篇文章告訴你從代碼實(shí)現(xiàn)的角度如何把球面坐標(biāo)系上的一個(gè)點(diǎn)轉(zhuǎn)換到四叉樹上對(duì)應(yīng)的希爾伯特曲線點(diǎn)。
func PointFromLatLng(ll LatLng) Point {
phi := ll.Lat.Radians()
theta := ll.Lng.Radians()
cosphi := math.Cos(phi)
return Point{r3.Vector{math.Cos(theta) * cosphi, math.Sin(theta) * cosphi, math.Sin(phi)}}
}
上面這個(gè)函數(shù)就是把經(jīng)緯度轉(zhuǎn)換成三維坐標(biāo)系中的一個(gè)向量點(diǎn),向量的起點(diǎn)是三維坐標(biāo)的原點(diǎn),終點(diǎn)為球面上轉(zhuǎn)換過來的點(diǎn)。轉(zhuǎn)換的關(guān)系如下圖:
θ 即為經(jīng)緯度的緯度,也就是上面代碼中的 phi ,φ 即為經(jīng)緯度的經(jīng)度,也就是上面代碼的 theta 。根據(jù)三角函數(shù)就可以得到這個(gè)向量的三維坐標(biāo):
x = r * cos θ * cos φ
y = r * cos θ * sin φ
z = r * sin θ
圖中球面的半徑 r = 1 。所以最終構(gòu)造出來的向量即為:
r3.Vector{math.Cos(theta) * cosphi, math.Sin(theta) * cosphi, math.Sin(phi)}
(x, y, z) 為方向向量,它們并不要求是單位向量。(x, y, z) 的取值范圍在 [-1,+1] x [-1,+1] x [-1,+1] 這樣的立方體三維空間中。它們可以被標(biāo)準(zhǔn)化以使得在單位球上獲得對(duì)應(yīng)的點(diǎn)。
至此,已經(jīng)完成了球面上的點(diǎn)S(lat,lng) -> f(x,y,z) 的轉(zhuǎn)換。
三. f(x,y,z) -> g(face,u,v)
接下來進(jìn)行 f(x,y,z) -> g(face,u,v) 的轉(zhuǎn)換
func xyzToFaceUV(r r3.Vector) (f int, u, v float64) {
f = face(r)
u, v = validFaceXYZToUV(f, r)
return f, u, v
}
這里的思路是進(jìn)行投影。
先從 x,y,z 三個(gè)軸上選擇一個(gè)最長(zhǎng)的軸,作為主軸。
func (v Vector) LargestComponent() Axis {
t := v.Abs()
if t.X > t.Y {
if t.X > t.Z {
return XAxis
}
return ZAxis
}
if t.Y > t.Z {
return YAxis
}
return ZAxis
}
默認(rèn)定義 x 軸為0,y軸為1,z軸為2 。
const (
XAxis Axis = iota
YAxis
ZAxis
)
最后 face 的值就是三個(gè)軸里面最長(zhǎng)的軸,注意這里限定了他們?nèi)叨荚?[0,5] 之間,所以如果是負(fù)軸就需要 + 3 進(jìn)行修正。實(shí)現(xiàn)代碼如下。
func face(r r3.Vector) int {
f := r.LargestComponent()
switch {
case f == r3.XAxis && r.X < 0:
f += 3
case f == r3.YAxis && r.Y < 0:
f += 3
case f == r3.ZAxis && r.Z < 0:
f += 3
}
return int(f)
}
所以 face 的6個(gè)面上的值就確定下來了。主軸為 x 正半軸,face = 0;主軸為 y 正半軸,face = 1;主軸為 z 正半軸,face = 2;主軸為 x 負(fù)半軸,face = 3;主軸為 y 負(fù)半軸,face = 4;主軸為 z 負(fù)半軸,face = 5 。
選定主軸以后就要把另外2個(gè)軸上的坐標(biāo)點(diǎn)投影到這個(gè)面上,具體做法就是投影或者坐標(biāo)系轉(zhuǎn)換。
func validFaceXYZToUV(face int, r r3.Vector) (float64, float64) {
switch face {
case 0:
return r.Y / r.X, r.Z / r.X
case 1:
return -r.X / r.Y, r.Z / r.Y
case 2:
return -r.X / r.Z, -r.Y / r.Z
case 3:
return r.Z / r.X, r.Y / r.X
case 4:
return r.Z / r.Y, -r.X / r.Y
}
return -r.Y / r.Z, -r.X / r.Z
}
上述就是 face 6個(gè)面上的坐標(biāo)系轉(zhuǎn)換。如果直觀的對(duì)應(yīng)一個(gè)外切立方體的哪6個(gè)面,那就是 face = 0 對(duì)應(yīng)的是前面,face = 1 對(duì)應(yīng)的是右面,face = 2 對(duì)應(yīng)的是上面,face = 3 對(duì)應(yīng)的是后面,face = 4 對(duì)應(yīng)的是左面,face = 5 對(duì)應(yīng)的是下面。
注意這里的三維坐標(biāo)軸是符合右手坐標(biāo)系的。即 右手4個(gè)手指沿著從 x 軸旋轉(zhuǎn)到 y 軸的方向,大拇指的指向就是另外一個(gè)面的正方向。
比如立方體的前面,右手從 y 軸的正方向旋轉(zhuǎn)到 z 軸的正方向,大拇指指向的是 x 軸的正方向,所以對(duì)應(yīng)的就是前面。再舉個(gè)例子,立方體的下面??,右手從 y 軸的負(fù)方向旋轉(zhuǎn)到 x 軸的負(fù)方向,大拇指指向的是 z 軸負(fù)方向,所以對(duì)應(yīng)的是下面??。
(face,u,v) 表示一個(gè)立方空間坐標(biāo)系,三個(gè)軸的值域都是 [-1,1] 之間。為了使得每個(gè) cell 的大小都一樣,就要進(jìn)行變換,具體變換規(guī)則就在下一步轉(zhuǎn)換中。
四. g(face,u,v) -> h(face,s,t)
從 u、v 轉(zhuǎn)換到 s、t 用的是二次變換。在 C ++ 的版本中有三種變換,至于為何最后選了這種二次變換,原因見這里。
// 線性轉(zhuǎn)換
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)
在 Go 中,轉(zhuǎn)換直接就只有二次變換了,其他兩種變換在 Go 的實(shí)現(xiàn)版本中就直接沒有相應(yīng)的代碼。
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)
}
(face,s,t) 表示一個(gè) cell 空間坐標(biāo)系,s,t 的值域都是 [0,1] 之間。它們代表了一個(gè) face 上的一個(gè) point。例如,點(diǎn) (s,t) = (0.5,0.5) 代表的是在這個(gè) face 面上的中心點(diǎn)。這個(gè)點(diǎn)也是當(dāng)前這個(gè)面上再細(xì)分成4個(gè)小 cell 的頂點(diǎn)。
五. h(face,s,t) -> H(face,i,j)
這一部分是坐標(biāo)系的轉(zhuǎn)換,具體思想見這里。
將 s、t 上的點(diǎn)轉(zhuǎn)換成坐標(biāo)系 i、j 上的點(diǎn)。
func stToIJ(s float64) int {
return clamp(int(math.Floor(maxSize*s)), 0, maxSize-1)
}
s,t的值域是[0,1],現(xiàn)在值域要擴(kuò)大到[0,230-1]。這里只是其中一個(gè)面。
六. H(face,i,j) -> CellID
在進(jìn)行最后的轉(zhuǎn)換之前,先回顧一下到目前為止的轉(zhuǎn)換流程。
func CellIDFromLatLng(ll LatLng) CellID {
return cellIDFromPoint(PointFromLatLng(ll))
}
func cellIDFromPoint(p Point) CellID {
f, u, v := xyzToFaceUV(r3.Vector{p.X, p.Y, p.Z})
i := stToIJ(uvToST(u))
j := stToIJ(uvToST(v))
return cellIDFromFaceIJ(f, i, j)
}
S(lat,lng) -> f(x,y,z) -> g(face,u,v) -> h(face,s,t) -> H(face,i,j) -> CellID 總共有5步轉(zhuǎn)換。
經(jīng)過上面5步轉(zhuǎn)換以后,等效于把地球上的經(jīng)緯度的點(diǎn)都轉(zhuǎn)換到了希爾伯特曲線上的點(diǎn)了。
在解釋最后一步轉(zhuǎn)換 CellID 之前,先說明一下方向的問題。
有2個(gè)存了常量的數(shù)組:
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)
}
這兩個(gè)二維數(shù)組里面的值用圖表示出來如下兩個(gè)圖:
上圖是 posToIJ ,注意這里的 i,j 指的是坐標(biāo)值,如上圖。這里是一階的希爾伯特曲線,所以 i,j 就等于坐標(biāo)軸上的值。posToIJ[0] = {0, 1, 3, 2} 表示的就是上圖中圖0的樣子。同理,posToIJ[1] 表示的是圖1,posToIJ[2] 表示的是圖2,posToIJ[3] 表示的是圖3 。
從上面這四張圖我們可以看出:
posToIJ 的四張圖其實(shí)是“ U ” 字形逆時(shí)針分別旋轉(zhuǎn)90°得到的。這里我們只能看出四張圖相互之間的聯(lián)系,即兄弟之間的聯(lián)系,但是看不到父子圖相互之間的聯(lián)系。
posToIJ[0] = {0, 1, 3, 2} 里面存的值是 ij 合在一起表示的值。posToIJ[0][0] = 0,指的是 i = 0,j = 0 的那個(gè)方格,ij 合在一起是00,即0。posToIJ[0][1] = 1,指的是 i = 0,j = 1 的那個(gè)方格,ij 合在一起是01,即1。posToIJ[0][2] = 1,指的是 i = 1,j = 1 的那個(gè)方格,ij 合在一起是11,即3。posToIJ[0][3] = 2,指的是 i = 1,j = 0 的那個(gè)方格,ij 合在一起是10,即2。數(shù)組里面的順序是 “ U ” 字形畫的順序。所以 posToIJ[0] = {0, 1, 3, 2} 表示的是圖0中的樣子。其他圖形同理。
這上面的四張圖是 ijToPos 數(shù)組。這個(gè)數(shù)組在整個(gè)庫(kù)中也沒有被用到,這里不用關(guān)系它對(duì)應(yīng)的關(guān)系。
初始化 lookupPos 數(shù)組和 lookupIJ 數(shù)組 由如下的代碼實(shí)現(xiàn)的。
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)
}
我們把變量的值都代進(jìn)去,代碼就會(huì)變成下面的樣子:
func init() {
initLookupCell(0, 0, 0, 0, 0, 0)
initLookupCell(0, 0, 0, 1, 0, 1)
initLookupCell(0, 0, 0, 2, 0, 2)
initLookupCell(0, 0, 0, 3, 0, 3)
}
initLookupCell 入?yún)⒂?個(gè)參數(shù),有4個(gè)參數(shù)都是0,我們需要重點(diǎn)關(guān)注的是第四個(gè)參數(shù)和第六個(gè)參數(shù)。第四個(gè)參數(shù)是 origOrientation,第六個(gè)參數(shù)是 orientation。
進(jìn)入到 initLookupCell 方法中,有如下的4行:
initLookupCell(level, i+(r[0]>>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])
這里順帶說一下 r[0]>>1 和 r[0]&1 究竟做了什么。
r := posToIJ[orientation]
r 數(shù)組來自于 posToIJ 數(shù)組。posToIJ 數(shù)組上面說過了,它里面裝的其實(shí)是4個(gè)不同方向的“ U ”字。相當(dāng)于表示了當(dāng)前四個(gè)小方格兄弟相互之間的方向。r[0]、r[1]、r[2]、r[3] 取出的其實(shí)就是 00,01,10,11 這4個(gè)數(shù)。那么 r[0]>>1 操作就是取出二位二進(jìn)制位的前一位,即 i 位。r[0]&1 操作就是取出二位二進(jìn)制位的后一位,即 j 位。r[1]、r[2]、r[3] 同理。
再回到方向的問題上來。需要優(yōu)先說明的是下面4行干了什么。
orientation^posToOrientation[0]
orientation^posToOrientation[1]
orientation^posToOrientation[2]
orientation^posToOrientation[3]
再解釋之前,先讓我們看看 posToOrientation 數(shù)組:
posToOrientation = [4]int{swapMask, 0, 0, invertMask | swapMask}
把數(shù)值代入到上面數(shù)組中:
posToOrientation = [4]int{1, 0, 0, 3}
posToOrientation 數(shù)組里面裝的原始的值是 [01,00,00,11],這個(gè)4個(gè)數(shù)值并不是隨便初始化的。
其實(shí)這個(gè)對(duì)應(yīng)的就是 圖0 中4個(gè)小方塊接下來再劃分的方向。圖0 中0號(hào)的位置下一個(gè)圖的方向應(yīng)該是圖1,即01;圖0 中1號(hào)的位置下一個(gè)圖的方向應(yīng)該是圖0,即00;圖0 中2號(hào)的位置下一個(gè)圖的方向應(yīng)該是圖0,即00;圖0 中3號(hào)的位置下一個(gè)圖的方向應(yīng)該是圖3,即11 。這就是初始化 posToOrientation 數(shù)組里面的玄機(jī)了。
posToIJ 的四張圖我們只能看出兄弟之間的關(guān)系,那么 posToOrientation 的四張圖讓我們知道了父子之間的關(guān)系。
回到上面說的代碼:
orientation^posToOrientation[0]
orientation^posToOrientation[1]
orientation^posToOrientation[2]
orientation^posToOrientation[3]
每次 orientation 都異或 posToOrientation 數(shù)組。這樣就能保證每次都能根據(jù)上一次的原始的方向推算出當(dāng)前的 pos 所在的方向。即計(jì)算父子之間關(guān)系。
還是回到這張圖上來。兄弟之間的關(guān)系是逆時(shí)針旋轉(zhuǎn)90°的關(guān)系。那這4個(gè)兄弟都作為父親,分別和各自的4個(gè)孩子之間什么關(guān)系呢?結(jié)論是,父子之間的關(guān)系都是 01,00,00,11 的關(guān)系。從圖上我們也可以看出這一點(diǎn),圖1中,“ U ” 字形雖然逆時(shí)針旋轉(zhuǎn)了90°,但是它們的孩子也跟著旋轉(zhuǎn)了90°(相對(duì)于圖0來說)。圖2,圖3也都如此。
用代碼表示這種關(guān)系,就是下面這4行代碼
orientation^posToOrientation[0]
orientation^posToOrientation[1]
orientation^posToOrientation[2]
orientation^posToOrientation[3]
舉個(gè)例子,假設(shè) orientation = 0,即圖0,那么:
00 ^ 01 = 01
00 ^ 00 = 00
00 ^ 00 = 00
00 ^ 11 = 11
圖0 的四個(gè)孩子的方向就被我們算出來了,01,00,00,11,1003 。和上面圖片中圖0展示的是一致的。
orientation = 1,orientation = 2,orientation = 3,都是同理的:
01 ^ 01 = 00
01 ^ 00 = 01
01 ^ 00 = 01
01 ^ 11 = 10
10 ^ 01 = 11
10 ^ 00 = 10
10 ^ 00 = 10
10 ^ 11 = 01
11 ^ 01 = 10
11 ^ 00 = 11
11 ^ 00 = 11
11 ^ 11 = 00
圖1孩子的方向是0,1,1,2 。圖2孩子的方向是3,2,2,1 。圖3孩子的方向是2,3,3,0 。和圖上畫的是完全一致的。
所以上面的轉(zhuǎn)換是很關(guān)鍵的。這里就是針對(duì)希爾伯特曲線的父子方向進(jìn)行換算的。
最后會(huì)有讀者有疑問,origOrientation 和 orientation 是啥關(guān)系?
lookupPos[(ij<<2)+origOrientation] = (pos << 2) + orientation
lookupIJ[(pos<<2)+origOrientation] = (ij << 2) + orientation
數(shù)組下標(biāo)里面存的都是 origOrientation,下標(biāo)里面存的值都是 orientation。
解釋完希爾伯特曲線方向的問題之后,接下來可以再仔細(xì)說說 55 的坐標(biāo)轉(zhuǎn)換的問題。前一篇文章《高效的多維空間點(diǎn)索引算法 — Geohash 和 Google S2》里面有談到這個(gè)問題,讀者有些疑惑點(diǎn),這里再最終解釋一遍。
在 Google S2 中,初始化 initLookupCell 的時(shí)候,會(huì)初始化2個(gè)數(shù)組,一個(gè)是 lookupPos 數(shù)組,一個(gè)是 lookupIJ 數(shù)組。中間還會(huì)用到 i , j , pos 和 orientation 四個(gè)關(guān)鍵的變量。orientation 這個(gè)之前說過了,這里就不再贅述了。需要詳細(xì)說明的 i ,j 和 pos 的關(guān)系。
pos 指的是在 希爾伯特曲線上的位置。這個(gè)位置是從 希爾伯特 曲線的起點(diǎn)開始算的。從起點(diǎn)開始數(shù),到當(dāng)前是第幾塊方塊。注意這個(gè)方塊是由 4 個(gè)小方塊組成的大方塊。因?yàn)?orientation 是選擇4個(gè)方塊中的哪一個(gè)。
在 55 的這個(gè)例子里,pos 其實(shí)是等于 13 的。代表當(dāng)前4塊小方塊組成的大方塊是距離起點(diǎn)的第13塊大方塊。由于每個(gè)大方塊是由4個(gè)小方塊組成的。所以當(dāng)前這個(gè)大方塊的第一個(gè)數(shù)字是 13 * 4 = 52 。代碼實(shí)現(xiàn)就是左移2位,等價(jià)于乘以 4 。再加上 55 的偏移的 orientation = 11,再加 3 ,所以 52 + 3 = 55 。
再說說 i 和 j 的問題,在 55 的這個(gè)例子里面 i = 14,1110,j = 13,1101 。如果直觀的看坐標(biāo)系,其實(shí) 55 是在 (5,2) 的坐標(biāo)上。但是現(xiàn)在為何 i = 14,j = 13 呢 ?這里容易弄混的就是 i ,j 和 pos 的關(guān)系。
注意:
i,j 并不是直接對(duì)應(yīng)的 希爾伯特曲線 坐標(biāo)系上的坐標(biāo)。因?yàn)槌跏蓟枰傻氖俏咫A希爾伯特曲線。在 posToIJ 數(shù)組表示的一階希爾伯特曲線,所以 i,j 才直接對(duì)應(yīng)的 希爾伯特曲線 坐標(biāo)系上的坐標(biāo)。
讀者到這里就會(huì)疑問了,那是什么參數(shù)對(duì)應(yīng)的是希爾伯特曲線坐標(biāo)系上的坐標(biāo)呢?
pos 參數(shù)對(duì)應(yīng)的就是希爾伯特曲線坐標(biāo)系上的坐標(biāo)。一旦一個(gè)希爾伯特曲線的起始點(diǎn)和階數(shù)確定以后,四個(gè)小方塊組成的一個(gè)大方塊的 pos 位置確定以后,那么它的坐標(biāo)其實(shí)就已經(jīng)確定了。希爾伯特曲線上的坐標(biāo)并不依賴 i,j,完全是由曲線的性質(zhì)和 pos 位置決定的。
我們并不關(guān)心希爾伯特曲線上小方塊的坐標(biāo),我們關(guān)心的是 pos 和 i,j 的轉(zhuǎn)換關(guān)系!
疑問又來了,那 i,j 對(duì)應(yīng)的是什么坐標(biāo)系上的坐標(biāo)呢?
i,j 對(duì)應(yīng)的是一個(gè)經(jīng)過坐標(biāo)變換以后的坐標(biāo)系坐標(biāo)。
我們知道,在進(jìn)行 ( u,v ) -> ( i,j ) 變換的時(shí)候,u,v 的值域是 [0,1] 之間,然后經(jīng)過變換要變到 [ 0, 230-1 ] 之間。i,j 就是變換以后坐標(biāo)系上的坐標(biāo)值,i,j 的值域變成了 [ 0, 230-1 ] 。
那初始化計(jì)算 lookupPos 數(shù)組和 lookupIJ 數(shù)組有什么用呢?這兩個(gè)數(shù)組就是把 i,j 和 pos 聯(lián)系起來的數(shù)組。知道 pos 以后可以立即找到對(duì)應(yīng)的 i,j。知道 i,j 以后可以立即找到對(duì)應(yīng)的 pos。
i,j 和 pos 互相轉(zhuǎn)換之間的橋梁就是生成希爾伯特曲線的方式。這種方式可以類比 Z - index 曲線的生成方式。
Z - index 曲線的生成方式是把經(jīng)緯度坐標(biāo)分別進(jìn)行區(qū)間二分,在左區(qū)間的記為0,在右區(qū)間的記為1 。將這兩串二進(jìn)制字符串偶數(shù)位放經(jīng)度,奇數(shù)位放緯度,最終組合成新的二進(jìn)制串,這個(gè)串再經(jīng)過 base-32 編碼以后,最終就生成了 geohash 。
那么 希爾伯特 曲線的生成方式是什么呢?它先將經(jīng)緯度坐標(biāo)轉(zhuǎn)換成了三維直角坐標(biāo)系坐標(biāo),然后再投影到外切立方體的6個(gè)面上,于是三維直角坐標(biāo)系坐標(biāo) (x,y,z) 就轉(zhuǎn)換成了 (face,u,v) 。 (face,u,v) 經(jīng)過一個(gè)二次變換變成 (face,s,t) , (face,s,t) 經(jīng)過坐標(biāo)系變換變成了 (face,i,j) 。然后將 i,j 分別4位4位的取出來,i 的4位二進(jìn)制位放前面,j 的4位二進(jìn)制位放后面。最后再加上希爾伯特曲線的方向位 orientation 的2位。組成 iiii jjjj oo 類似這樣的10位二進(jìn)制位。通過 lookupPos 數(shù)組這個(gè)橋梁,找到對(duì)應(yīng)的 pos 的值。pos 的值就是對(duì)應(yīng)希爾伯特曲線上的位置。然后依次類推,再取出 i 的4位,j 的4位進(jìn)行這樣的轉(zhuǎn)換,直到所有的 i 和 j 的二進(jìn)制都取完了,最后把這些生成的 pos 值安全先生成的放在高位,后生成的放在低位的方式拼接成最終的 CellID。
這里可能有讀者疑問了,為何要 iiii jjjj oo 這樣設(shè)計(jì),為何是4位4位的,谷歌開發(fā)者在注釋里面這樣寫道:“我們?cè)?jīng)考慮過一次組合 16 位,14位的 position + 2位的 orientation,但是代碼實(shí)際運(yùn)行起來發(fā)現(xiàn)小數(shù)組擁有更好的性能,2KB 更加適合存儲(chǔ)到主 cache 中。”
在 Google S2 中,i,j 每次轉(zhuǎn)換都是4位,所以 i,j 的有效值取值是 0 - 15,所以 iiii jjjj oo 是一個(gè)十進(jìn)制的數(shù),能表示的范圍是 210 = 1024 。那么 pos 初始化值也需要計(jì)算到 1024 。由于 pos 是4個(gè)小方塊組成的大方塊,它本身就是一個(gè)一階的希爾伯特曲線。所以初始化需要生成一個(gè)五階的希爾伯特曲線。
上圖是一階的希爾伯特曲線。是由4個(gè)小方格組成的。
上圖是二階的希爾伯特曲線,是由4個(gè) pos 方格組成的。
上圖是三階的希爾伯特曲線。
上圖是四階的希爾伯特曲線。
上圖是五階的希爾伯特曲線。pos 方格總共有1024個(gè)。
至此已經(jīng)說清楚了希爾伯特曲線的方向和在 Google S2 中生成希爾伯特曲線的階數(shù),五階希爾伯特曲線。
由此也可以看出,希爾伯特曲線的是由 “ U ” 字形構(gòu)成的,由4個(gè)不同方向的 “ U ” 字構(gòu)成。初始方向是開口朝上的 “ U ”。
關(guān)于希爾伯特曲線生成的動(dòng)畫,見上篇《高效的多維空間點(diǎn)索引算法 — Geohash 和 Google S2》—— 希爾伯特曲線的構(gòu)造方法 這一章節(jié)。
那么現(xiàn)在我們?cè)偻扑?5就比較簡(jiǎn)單了。從五階希爾伯特曲線開始推,推算過程如下圖。
首先55是在上圖中每個(gè)小圖中綠色點(diǎn)的位置。我們不斷的進(jìn)行方向的判斷。第一張小圖,綠點(diǎn)在00的位置。第二張小圖,綠點(diǎn)在00的位置。第三張小圖,綠點(diǎn)在11的位置。第四張小圖,綠點(diǎn)在01的位置。第五張小圖,綠點(diǎn)在11的位置。其實(shí)換算到第四步,得到的數(shù)值就是 pos 的值,即 00001101 = 13 。最后2位是具體的點(diǎn)在 pos 方格里面的位置,是11,所以 13 * 4 + 3 = 55 。
當(dāng)然直接根據(jù)方向推算到底,也可以得到 0000110111 = 55 ,同樣也是55 。
七. 舉例
最后舉個(gè)具體的完整的例子:
緯度 | 經(jīng)度 | ||
---|---|---|---|
直角坐標(biāo)系 | -0.209923466239598816018841 | 0.834295703289209877873134 | 0.509787031803590306999752 |
(face,u,v) | 1 | 0.25161758044776666 | 0.6110387837235114 |
(face,s,t) | 1 | 0.6623542747924445 | 0.8415931842598497 |
(face,i,j) | 1 | 711197487 | 903653800 |
上面完成了前4步的轉(zhuǎn)換。
最后一步轉(zhuǎn)換成 CellID 。具體實(shí)現(xiàn)代碼如下。由于 CellID 是64位的,除去 face 占的3位,最后一個(gè)標(biāo)志位 1 占的位置,剩下 60 位。
func cellIDFromFaceIJ(f, i, j int) CellID {
// 1.
n := uint64(f) << (posBits - 1)
// 2.
bits := f & swapMask
// 3.
for k := 7; k >= 0; k-- {
mask := (1 << lookupBits) - 1
bits += int((i>>uint(k*lookupBits))&mask) << (lookupBits + 2)
bits += int((j>>uint(k*lookupBits))&mask) << 2
bits = lookupPos[bits]
// 4.
n |= uint64(bits>>2) << (uint(k) * 2 * lookupBits)
// 5.
bits &= (swapMask | invertMask)
}
// 6.
return CellID(n*2 + 1)
}
具體步驟如下:
- 將 face 左移 60 位。
- 計(jì)算初始的 origOrientation。初始的 origOrientation 是 face 轉(zhuǎn)換得來的,face & 01 以后的結(jié)果是為了使每個(gè)面都有一個(gè)右手坐標(biāo)系。
- 循環(huán),從頭開始依次取出 i ,j 的4位二進(jìn)制位,計(jì)算出 ij<<2 + origOrientation,然后查 lookupPos 數(shù)組找到對(duì)應(yīng)的 pos<<2 + orientation 。
- 拼接 CellID,右移 pos<<2 + orientation 2位,只留下 pos ,把pos 繼續(xù)拼接到 上次循環(huán)的 CellID 后面。
- 計(jì)算下一個(gè)循環(huán)的 origOrientation。&= (swapMask | invertMask) 即 & 11,也就是取出末尾的2位二進(jìn)制位。
- 最后拼接上最后一個(gè)標(biāo)志位 1 。
這里說說第二步,origOrientation 的轉(zhuǎn)換。
我們知道 face 是有6個(gè)面的,編號(hào)依次是 000,001,010,011,100,101 。想讓這6個(gè)面都具有右手坐標(biāo)系的性質(zhì),就必須進(jìn)行轉(zhuǎn)換,轉(zhuǎn)換的規(guī)則其實(shí)進(jìn)行一次位運(yùn)算即可:
000 & 001 = 00
001 & 001 = 01
010 & 001 = 00
011 & 001 = 01
100 & 001 = 00
101 & 001 = 01
經(jīng)過轉(zhuǎn)換以后,face & 01 的值就是初始的 origOrientation 了。
用表展示出每一步(表比較長(zhǎng),請(qǐng)右滑):
i | j | orientation | ij<<2 + origOrientation | pos<<2 + orientation | CellID | |
---|---|---|---|---|---|---|
711197487 | 903653800 | 1 | ||||
對(duì)應(yīng)二進(jìn)制 | 101010011001000000001100101111 | 110101110111001010100110101000 | 01 | |||
進(jìn)行轉(zhuǎn)換 | i 左移6位,給 j 的4位和方向位 orientation 2位留出位置 | j 左移2位,給方向位 orientation 留出位置 | orientation 初始值是 face 的值 | [iiii jjjj oo] i的四位,j的四位,o的兩位依次排在一起組成10位二進(jìn)制位 | 從前面一列轉(zhuǎn)換過來是通過查 lookupPos 數(shù)組查出來的 | 初始值:face 左移 60 位,接著以后每次循環(huán)都拼接 pos ,注意不帶orientation ,即前一列需要右移2位去掉末尾的 orientation |
取 i , j 的首兩位 | 10 000000 | 11 00 | 01 | (00)10001101 | 101110 | 1101100000000000000000000000000000000000000000000000000000000 |
再取 i , j 的3,4,5,6位 | 1010 000000 | 0101 00 | 10 | 1010010110 | 111011110 | 1101101110111000000000000000000000000000000000000000000000000 |
再取 i , j 的7,8,9,10位 | 0110 000000 | 1101 00 | 10 | (0)110110110 | 1110011110 | 1101101110111111001110000000000000000000000000000000000000000 |
再取 i , j 的11,12,13,14位 | 0100 000000 | 1100 00 | 10 | (0)100110010 | 1110000001 | 1101101110111111001111110000000000000000000000000000000000000 |
再取 i , j 的15,16,17,18位 | 0000 000000 | 1010 00 | 01 | (0000)101001 | 1110110000 | 1101101110111111001111110000011101100000000000000000000000000 |
再取 i , j 的19,20,21,22位 | 0011 000000 | 1001 00 | 00 | (00)11100100 | 100011001 | 1101101110111111001111110000011101100010001100000000000000000 |
再取 i , j 的23,24,25,26位 | 0010 000000 | 1010 00 | 01 | (00)10101001 | 1110001011 | 1101101110111111001111110000011101100010001101110001000000000 |
再取 i , j 的27,28,29,30位 | 1111 000000 | 1000 00 | 11 | 1111100011 | 1010110 | 1101101110111111001111110000011101100010001101110001000010101 |
最終結(jié)果 | 11011011101111110011111100000111011000100011011100010000101011 (拼接上末尾的標(biāo)志位1) |
任意取出循環(huán)中的一個(gè)情況,用圖表示如下:
注意:由于 CellID 是64位的,頭三位是 face ,末尾一位是標(biāo)志位,所以中間有 60 位。i,j 轉(zhuǎn)換成二進(jìn)制是30位的。7個(gè)4位二進(jìn)制位和1個(gè)2位二進(jìn)制位。4*7 + 2 = 30 。iijjoo ,即 i 的頭2個(gè)二進(jìn)制位和 j 的頭2個(gè)二進(jìn)制位加上 origOrientation,這樣組成的是6位二進(jìn)制位,最多能表示 26 = 32,轉(zhuǎn)換出來的 pos + orientation 最多也是32位的。即轉(zhuǎn)換出來最多也是6位的二進(jìn)制位,除去末尾2位 orientation ,所以 pos 在這種情況下最多是 4位。iiiijjjjpppp,即 i 的4個(gè)二進(jìn)制位和 j 的4個(gè)二進(jìn)制位加上 origOrientation,這樣組成的是10位二進(jìn)制位,最多能表示 210 = 1024,轉(zhuǎn)換出來的 pos + orientation 最多也是10位的。即轉(zhuǎn)換出來最多也是10位的二進(jìn)制位,除去末尾2位 orientation ,所以 pos 在這種情況下最多是 8位。
由于最后 CellID 只拼接 pos ,所以 4 + 7 * 8 = 60 位。拼接完成以后,中間的60位都由 pos 組成的。最后拼上頭3位,末尾的1位標(biāo)志位,64位的 CellID 就這樣生成了。
到此,所有的 CellID 生成過程就結(jié)束了。
空間搜索系列文章:
空間搜索系列文章:
如何理解 n 維空間和 n 維時(shí)空
高效的多維空間點(diǎn)索引算法 — Geohash 和 Google S2
Google S2 中的 CellID 是如何生成的 ?
Google S2 中的四叉樹求 LCA 最近公共祖先
神奇的德布魯因序列
四叉樹上如何求希爾伯特曲線的鄰居 ?
GitHub Repo:Halfrost-Field
Follow: halfrost · GitHub