就比如我們日常電腦美團或者餓了麼點外賣,附近的商家幾乎都是秒回的,最簡單的理解,我們可以用經緯度來計算。
經緯度
談到經緯度。想必大家在中學時代的地理課本裡早就學過了。我們把地球分成縱橫交錯的一些格子,每個點都可以用橫豎坐標來表示。橫線表示緯度,範圍在[-90°, +90°],豎線表示經度,範圍在[-180°, +180°]。
我們當前的經緯度,可以從wifi或者手機的GPS獲取。
計算距離
接下來我們計算兩點的距離。
地球是一個近乎標準的橢球體,它的赤道半徑為6378.140千米,極半徑為6356.755千米,平均半徑6371.004千米。如果我們假設地球是一個完美的球體,那麼它的半徑就是地球的平均半徑,記為R。如果以0度經線為基準,那麼根據地球表面任意兩點的經緯度就可以計算出這兩點間的地表距離(這裡忽略地球表面地形對計算帶來的誤差,僅僅是理論上的估算值)。
設第一點A的經緯度為(LonA, LatA),第二點B的經緯度為(LonB, LatB),按照0度經線的基準,東經取經度的正值(Longitude),西經取經度負值(-Longitude),北緯取90-緯度值(90-Latitude),南緯取90+緯度值(90+Latitude),則經過上述處理過後的兩點被計為(MLonA, MLatA)和(MLonB, MLatB)。那麼根據三角推導,可以得到計算兩點距離的如下公式:
C = sin(MLatA)*sin(MLatB)*cos(MLonA-MLonB) + cos(MLatA)*cos(MLatB)Distance = R*Arccos(C)*Pi/180
google maps的腳本裡代碼
private const double EARTH_RADIUS = 6378.137;
private static double rad(double d)
{
return d * Math.PI / 180.0;
}
public static double GetDistance(double lat1, double lng1, double lat2, double lng2)
{
double radLat1 = rad(lat1);
double radLat2 = rad(lat2);
double a = radLat1 - radLat2;
double b = rad(lng1) - rad(lng2);
double s = 2 * Math.Asin(Math.Sqrt(Math.Pow(Math.Sin(a/2),2) +
Math.Cos(radLat1)*Math.Cos(radLat2)*Math.Pow(Math.Sin(b/2),2)));
s = s * EARTH_RADIUS;
s = Math.Round(s * 10000) / 10000;
return s;
}
如果我們相距的兩點不是特別遠(相對地球半徑而言),我們就可以把他們近似看成平面上的兩點,可以用下面的公式計算距離:
當我們的商戶不是太多的時候,就可以遍歷所有商戶,依次計算出所有的商戶同我的距離d1 d2 ... dk,然後按照距離從小到大排序,得到我們想要的結果。
我們來算算時間複雜度。
1.遍歷所有的點,計算距離,是一個O(n)複雜度的算法;
2.排序做TopN,按照快排來算的話,基本上是一個O(nlogn)的複雜度;
所以總的複雜度趨近於O(nlogn)。
那麼當有大量商戶的時候該怎麼辦呢?
方案一:簡單的分布式計算:
將商鋪信息進行分組,分別進行排序取出前N的推薦,最後把前面排序的結果,再進行一次TopN排序,這樣就可以找到最近的商鋪信息了。
這種實現方式簡單,但是有幾個比較嚴重的問題:
隨著商戶的增長,進行的分組會越來越多,呈直線上升趨勢;有時結果不會特別精確。假設全部的最優解都在某一個分組裡,可能不會進到最後的歸併排序裡。
剛剛我們已經提到過了,我們用經線和緯線來分割地球,此時的地球已經被我們分成一塊一塊的了,我們看看下面這個圖:
如同我們的紅箭頭指的那個點,要找到它附近的點,是不是直接取出它所在的經緯度格子的所有點就可以了呢?再加上圍繞它所在格子的八個格子的所有點,那就一定是這個點周圍的所有點了!
那麼接下來就是如何給這些經緯度格子編碼的問題了!
編碼
我們用經度切割,以上海經緯度121.43333,34.50000來舉例:
以0°為中軸,將地球切成兩半[-180°,0°),[0°,180°],並對他們進行二進位編碼,左邊為0,右邊為1;
此時上海的經度編碼就是1。
我們再切割一次,將精度切成[-180°,-90°),[-90°,0°),[0°,90°),[90°,180°],按照二進位編碼,分別為:00,01,10,11
此時上海的經度編碼就是11
如此這樣重複N次,我們就可以將地球按經度切割成很多很多的小格子,如果切割的次數足夠多,每一個格子相當於一個點,那也會得到對應這個小塊兒的二進位編碼。比如上海的的經度121.43333經過多次切割得到如下這個表格:
比如此時我們分割了8次,上海的經度編碼就是11010110。隨著切分的繼續,我們可以得到更長的編碼,這樣就可以對應更細緻的區間。
按照同樣的方法,我們切割一下緯度,則上海的緯度34.50000可以得到如下表格編碼:
上海的緯度編碼就是:10110001
最終我們得到的上海經緯度編碼為
(121.43333,34.50000)-->(11010110,10110001)
統一編碼
為了方便記錄,我們把經度和維度的二進位格子編碼進行合併,按經度、緯度、經度、維度……這樣的順序,一位一位的進行放置:
(11010110,10110001)-->1110011100101001
奇數位的紅色是經度編碼,偶數位的黑色是緯度編碼
我們可以用16進位、32進位、64進位這樣的進位來縮短編碼長度。這裡業界推薦的是32進位
所以1110011100101001的32編碼為:wwn(取三位有效)
精度
有了這樣的編碼,那到底要劃分多少次,我們的數據才足夠精確呢?維基百科上找到了這樣的一張對應表:
當有一個32位數字的時候,精細度大概是2500公裡,當有8個數字的時候,精細度大概是0.019km = 19米。也就是說,8個32位的數字 對應 8*5=40個二進位數,也就是經緯度分別劃20次,就可以達到19米的精細度。
這個就是著名的 Geohash
值得注意的是:
1.Geohash比直接用經緯度的高效很多,而且使用者可以發布地址編碼,既能表明自己位於某地方附近,又不至於暴露自己的精確坐標,有助於隱私保護。
2.GeoHash用一個字符串表示經度和緯度兩個坐標。在資料庫中可以實現在一列上應用索引(某些情況下無法在兩列上同時應用索引)
3.GeoHash表示的並不是一個點,而是一個矩形區域
4.GeoHash編碼的前綴可以表示更大的區域。例如wx4g0ec1,它的前綴wx4g0e表示包含編碼wx4g0ec1在內的更大範圍。這個特性可以用於附近地點搜索
查找
通過上面的方法,我們就可以將所有商鋪的經緯度給一個編碼存進資料庫,建立索引。這樣根據當前自己的經緯度計算相應的編碼,查詢資料庫
select * from merchant where code = 'xxx'
這樣就可以獲取附近的商鋪了,是不是超級開心!
當然不要忘記,如果兩個點距離很近,但是劃到了兩個格子裡,這樣是找不到的,所以我們還要把附近的8個格子的編碼分別算出來一起查詢,最後進行匯總!