在開發中經常會遇到把數據庫已有經緯度的地方進行距離排序然后返回給用戶
例如一些外賣app打開會返回附近的商店,這個是怎么做到的呢?
思路一:
根據用戶當前的位置,用計算經緯度距離的算法逐一計算比對距離,然后進行排序。這里可以參考下面這個算法:
<?php
/**
* 查找兩個經緯度之間的距離
*
* @param $latitude1 float 起始緯度
* @param $longitude1 float 起始經度
* @param $latitude2 float 目標緯度
* @param $longitude2 float 目標經度
* @return array(miles=>英里,feet=>英尺,yards=>碼,kilometers=>公里,meters=>米)
* @example
*
* $point1 = array('lat' => 40.770623, 'long' => -73.964367);
* $point2 = array('lat' => 40.758224, 'long' => -73.917404);
* $distance = getDistanceBetweenPointsNew($point1['lat'], $point1['long'], $point2['lat'], $point2['long']);
* foreach ($distance as $unit => $value) {
* echo $unit.': '.number_format($value,4);
* }
*
* The example returns the following:
*
* miles: 2.6025 //英里
* feet: 13,741.4350 //英尺
* yards: 4,580.4783 //碼
* kilometers: 4.1884 //公里
* meters: 4,188.3894 //米
*
*/
function getDistanceBetweenPointsNew($latitude1, $longitude1, $latitude2, $longitude2) {
$theta = $longitude1 - $longitude2;
$miles = (sin(deg2rad($latitude1)) * sin(deg2rad($latitude2))) + (cos(deg2rad($latitude1)) * cos(deg2rad($latitude2)) * cos(deg2rad($theta)));
$miles = acos($miles);
$miles = rad2deg($miles);
$miles = $miles * 60 * 1.1515;
$feet = $miles * 5280;
$yards = $feet / 3;
$kilometers = $miles * 1.609344;
$meters = $kilometers * 1000;
return compact('miles', 'feet', 'yards', 'kilometers', 'meters');
}
?>
這個思路是要每次都獲取全部數據,然后進行不斷的循環計算,對於大數據量來說簡直是噩夢。
思路二:
利用二維的經緯度轉換成一維的數據,然后直接sql查詢,無須一一比對。
例如經緯度 110.993736,21.495705 => w7yfm9pjt9b4
這就是geohash算法,這里簡單說一下,geohash是一種地理位置編碼,通過數學的方法進行一定的轉換,使其與經緯度對應,變成一串可比對的字符串。
這里不做深入的了解,大概知道一下就好,轉換出來的編碼有一定的規律,例如同一個省份的前幾位字符是一樣的,字符數相似越多,證明距離越近。類似於公民身份證一樣。
有興趣的可以自行搜索了解一下。
下面直接給出轉換的php代碼
<?php
/**
* Encode and decode geohashes
*
*/
class Geohash {
private $coding = "0123456789bcdefghjkmnpqrstuvwxyz";
private $codingMap = array();
public function Geohash() {
//build map from encoding char to 0 padded bitfield
for ($i = 0; $i < 32; $i++) {
$this->codingMap[substr($this->coding, $i, 1)] = str_pad(decbin($i), 5, "0", STR_PAD_LEFT);
}
}
/**
* Decode a geohash and return an array with decimal lat,long in it
*/
public function decode($hash) {
//decode hash into binary string
$binary = "";
$hl = strlen($hash);
for ($i = 0; $i < $hl; $i++) {
$binary .= $this->codingMap[substr($hash, $i, 1)];
}
//split the binary into lat and log binary strings
$bl = strlen($binary);
$blat = "";
$blong = "";
for ($i = 0; $i < $bl; $i++) {
if ($i % 2) {
$blat = $blat . substr($binary, $i, 1);
} else {
$blong = $blong . substr($binary, $i, 1);
}
}
//now concert to decimal
$lat = $this->binDecode($blat, -90, 90);
$long = $this->binDecode($blong, -180, 180);
//figure out how precise the bit count makes this calculation
$latErr = $this->calcError(strlen($blat), -90, 90);
$longErr = $this->calcError(strlen($blong), -180, 180);
//how many decimal places should we use? There's a little art to
//this to ensure I get the same roundings as geohash.org
$latPlaces = max(1, -round(log10($latErr))) - 1;
$longPlaces = max(1, -round(log10($longErr))) - 1;
//round it
$lat = round($lat, $latPlaces);
$long = round($long, $longPlaces);
return array($lat, $long);
}
/**
* Encode a hash from given lat and long
*/
public function encode($lat, $long) {
//how many bits does latitude need?
$plat = $this->precision($lat);
$latbits = 1;
$err = 45;
while ($err > $plat) {
$latbits++;
$err /= 2;
}
//how many bits does longitude need?
$plong = $this->precision($long);
$longbits = 1;
$err = 90;
while ($err > $plong) {
$longbits++;
$err /= 2;
}
//bit counts need to be equal
$bits = max($latbits, $longbits);
//as the hash create bits in groups of 5, lets not
//waste any bits - lets bulk it up to a multiple of 5
//and favour the longitude for any odd bits
$longbits = $bits;
$latbits = $bits;
$addlong = 1;
while (($longbits + $latbits) % 5 != 0) {
$longbits += $addlong;
$latbits += !$addlong;
$addlong = !$addlong;
}
//encode each as binary string
$blat = $this->binEncode($lat, -90, 90, $latbits);
$blong = $this->binEncode($long, -180, 180, $longbits);
//merge lat and long together
$binary = "";
$uselong = 1;
while (strlen($blat) + strlen($blong)) {
if ($uselong) {
$binary = $binary . substr($blong, 0, 1);
$blong = substr($blong, 1);
} else {
$binary = $binary . substr($blat, 0, 1);
$blat = substr($blat, 1);
}
$uselong = !$uselong;
}
//convert binary string to hash
$hash = "";
for ($i = 0; $i < strlen($binary); $i += 5) {
$n = bindec(substr($binary, $i, 5));
$hash = $hash . $this->coding[$n];
}
return $hash;
}
/**
* What's the maximum error for $bits bits covering a range $min to $max
*/
private function calcError($bits, $min, $max) {
$err = ($max - $min) / 2;
while ($bits--) {
$err /= 2;
}
return $err;
}
/*
* returns precision of number
* precision of 42 is 0.5
* precision of 42.4 is 0.05
* precision of 42.41 is 0.005 etc
*/
private function precision($number) {
$precision = 0;
$pt = strpos($number, '.');
if ($pt !== false) {
$precision = -(strlen($number) - $pt - 1);
}
return pow(10, $precision) / 2;
}
/**
* create binary encoding of number as detailed in http://en.wikipedia.org/wiki/Geohash#Example
* removing the tail recursion is left an exercise for the reader
*/
private function binEncode($number, $min, $max, $bitcount) {
if ($bitcount == 0) {
return "";
}
#echo "$bitcount: $min $max<br>";
//this is our mid point - we will produce a bit to say
//whether $number is above or below this mid point
$mid = ($min + $max) / 2;
if ($number > $mid) {
return "1" . $this->binEncode($number, $mid, $max, $bitcount - 1);
} else {
return "0" . $this->binEncode($number, $min, $mid, $bitcount - 1);
}
}
/**
* decodes binary encoding of number as detailed in http://en.wikipedia.org/wiki/Geohash#Example
* removing the tail recursion is left an exercise for the reader
*/
private function binDecode($binary, $min, $max) {
$mid = ($min + $max) / 2;
if (strlen($binary) == 0) {
return $mid;
}
$bit = substr($binary, 0, 1);
$binary = substr($binary, 1);
if ($bit == 1) {
return $this->binDecode($binary, $mid, $max);
} else {
return $this->binDecode($binary, $min, $mid);
}
}
}
?>
把每一個經緯度都轉換成geohash編碼並儲存起來,比對的時候直接sql
$sql = 'select * from xxx where geohash like "'.$like_geohash.'%"';
這里like_geohash位數越多說明越精確。
下面是geohash經度距離換算關系,比如geohash如果有7位數,說明范圍在76米左右,八位數則是19米,可以根據這個進行查詢。
geohash長度 | Lat位數 | Lng位數 | Lat誤差 | Lng誤差 | km誤差 |
---|---|---|---|---|---|
1 | 2 | 3 | ±23 | ±23 | ±2500 |
2 | 5 | 5 | ± 2.8 | ±5.6 | ±630 |
3 | 7 | 8 | ± 0.70 | ± 0.7 | ±78 |
4 | 10 | 10 | ± 0.087 | ± 0.18 | ±20 |
5 | 12 | 13 | ± 0.022 | ± 0.022 | ±2.4 |
6 | 15 | 15 | ± 0.0027 | ± 0.0055 | ±0.61 |
7 | 17 | 18 | ±0.00068 | ±0.00068 | ±0.076 |
8 | 20 | 20 | ±0.000086 | ±0.000172 | ±0.01911 |
9 | 22 | 23 | ±0.000021 | ±0.000021 | ±0.00478 |
10 | 25 | 25 | ±0.00000268 | ±0.00000536 | ±0.0005971 |
11 | 27 | 28 | ±0.00000067 | ±0.00000067 | ±0.0001492 |
12 | 30 | 30 | ±0.00000008 | ±0.00000017 | ±0.0000186 |
這個思路明顯優於第一個思路,且查詢起來速度非常快,也不用管有多大的數據,直接在數據庫里面進行like查詢就好,不過要做好索引才行,缺點也是比較明顯
無法控制想要的精確訪問,對於返回的數據無法進行距離的先后排序,不過已經能滿足一定的需求,后期再結合思路一也可以做到距離的先后。
思路三:
前面兩種方法都是通過很生硬的數學方法進行比對,所計算的也都是直線距離,但是現實並不是數學那樣理想。
現實中兩個很靠近的經緯度中間也有可能隔着一條跨不過去的河導致要繞很遠的路,這時就要考慮實際情況。
很慶幸有些地圖廠商已經幫我們考慮到了,所以還可以借助第三方api。
這里簡單說一下高德地圖的[ 雲圖服務API ]()
1、注冊高德地圖賬戶,並申請雲圖key。
2、創建雲地圖,也就是把你現在的數據放到高德地圖上 [ 雲圖存儲API ](),這里可以手動創建也能調用相關的api創建。
可以把數據導出excel然后批量上傳,當然后期如果要新增盡量還是用它提供的接口進行增量添加。創建完成大概會生成這樣一張表,有tableid,這個后面查詢接口需要使用,字段可以自定義,方便業務邏輯。
3、使用高德api進行查詢你的雲地圖 [ 數據檢索 ]() ,這里使用周邊檢索,可以根據你當前的位置進行檢索。
過程其實也不復雜,就是把數據放到高德,高德幫你完成了距離的排序,當然它提供的是比較實際的距離。具體實現需要研究一下高德提供的接口。
這個思路可以解決精准度問題,但開發成本大,還要跑一遍第三方去獲取數據,可能會犧牲一定效率,具體取舍,仁者見仁吧。