Java根据经纬度计算两点之间的距离

1. 前言

  在我们平时使用美团,饿了么等app进行订餐,或者使用猫眼进行订电影票的时候,都有一个距离的排序,表明该家店距离我们当前的位置,这种基于地理位置的服务,统一被称为LBS(Location Based Service),而LBS的实现则是借助于GIS,WC(无线通信)等信息技术来实现。而今天我们所要讨论的就是这个距离的实现。

GIS,Geographic information system,地理信息系统。

2. 计算方式

  由于地球是一个椭圆形,我们在计算的时候有点麻烦,所以我们更常用的方式是将地球作为一个球形来计算,而计算球面上任意两点之间的距离的公式通常有两种:Great-circle distance和Haversine formula,而目前大多数公司都是用的是Haversine公式,原因可以参考:

Great-circle distance公式用到了大量余弦函数, 而两点间距离很短时(比如地球表面上相距几百米的两点),余弦函数会得出0.999…的结果, 会导致较大的舍入误差。而Haversine公式采用了正弦函数,即使距离很小,也能保持足够的有效数字。

而有关这两者的介绍可以参考维基百科:Haversine formula 维基百科Great-circle distance 维基百科。而最终该公式的形式为:

至于为什么是这种形式,其实目前网上有许多推导公式,感兴趣的可以看一下推导过程,顺便回忆一下自己当年学过的数学知识:
1. 关于已知两点经纬度求球面最短距离的公式推导
2. 根据经纬度计算两点之间的距离的公式推导过程以及google.maps的测距函数

而如果要考虑到高度的影响的话,可以参考:https://stackoverflow.com/questions/3694380/calculating-distance-between-two-points-using-latitude-longitude-what-am-i-doi

  另外,还有一种方式是 Vincenty's formulae,该方式也是用于计算球体表面两点之间距离的方式,而它所基于的就是地球是扁球体的形状,因此这种方式比假设地球是球体的方式应该更加准确,但实现起来比较麻烦。感兴趣的可以查看下维基百科:Vincenty's formulae 维基百科

3. Java实现

接下来,我们来看一下该公式的Java实现:

public final class DistanceUtils {

    /**
     * 地球半径,单位 km
     */
    private static final double EARTH_RADIUS = 6378.137;

    /**
     * 根据经纬度,计算两点间的距离
     *
     * @param longitude1 第一个点的经度
     * @param latitude1  第一个点的纬度
     * @param longitude2 第二个点的经度
     * @param latitude2  第二个点的纬度
     * @return 返回距离 单位千米
     */
    public static double getDistance(double longitude1, double latitude1, double longitude2, double latitude2) {
        // 纬度
        double lat1 = Math.toRadians(latitude1);
        double lat2 = Math.toRadians(latitude2);
        // 经度
        double lng1 = Math.toRadians(longitude1);
        double lng2 = Math.toRadians(longitude2);
        // 纬度之差
        double a = lat1 - lat2;
        // 经度之差
        double b = lng1 - lng2;
        // 计算两点距离的公式
        double s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) +
                Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(b / 2), 2)));
        // 弧长乘地球半径, 返回单位: 千米
        s =  s * EARTH_RADIUS;
        return s;
    }

    public static void main(String[] args) {
        double d = getDistance(116.308479, 39.983171, 116.353454, 39.996059);
        System.out.println(d);
    }
}

由于平时我们用到数学函数的地方不多,所以这里我们来简单介绍下用到的几个函数:

Math.pow(x,y)      //这个函数是求x的y次方
Math.toRadians     //将一个角度测量的角度转换成以弧度表示的近似角度
Math.sin           //正弦函数
Math.cos           //余弦函数
Math.sqrt          //求平方根函数
Math.asin          //反正弦函数

由于三角函数中特定的关联关系,Haversine公式的最终实现方式可以有多种,比如借助转角度的函数atan2:

public static double getDistance2(double longitude1, double latitude1,
                                        double longitude2, double latitude2) {

    double latDistance = Math.toRadians(longitude1 - longitude2);
    double lngDistance = Math.toRadians(latitude1 - latitude2);

    double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2)
            + Math.cos(Math.toRadians(longitude1)) * Math.cos(Math.toRadians(longitude2))
            * Math.sin(lngDistance / 2) * Math.sin(lngDistance / 2);

    double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    return c * EARTH_RADIUS;
}

返回的单位是千米,如果想返回米,可以修改地球半径的单位从千米到米,并且由于该结果是double类型的,所以还可以借助Math.round方法进行四舍五入为long类型,然后精确到米:

// ......
// 弧长乘地球半径(6378137)
s =  s * EARTH_RADIUS;
// 返回类型: long,单位: 米
return Math.round(s * 10000) / 10000;

接下来说几点概念:

3.1 地球半径

  由于地球不是一个完美的球体,所以并不能用一个特别准确的值来表示地球的实际半径,不过由于地球的形状很接近球体,用[6357km] 到 [6378km]的范围值可以涵盖需要的所有半径。并且通常情况下,地球半径有几个常用值:

  • 极半径,从地球中心至南极或北极的距离, 相当于6356.7523km;
  • 赤道半径,从地球中心到赤道的距离,大约6378.137km;
  • 平均半径,6371.393km,表示地球中心到地球表面所有各点距离的平均值;
  • RE,地球半径,有时被使用作为距离单位, 特别是在天文学和地质学中常用,大概距离是6370.856km;

所以我们通过地球半径进行计算的时候,通常情况下,我们可以使用上面的每一个值都可以进行计算,不过或多或少都会有误差的,但这样的误差是也是允许存在的。这里参考自维基百科:维基百科-地球半径

4. MySQL实现

同样,在MySQL中实现该功能,计算公式还是通过Haversine公式。不过在Google Map中,已经提供了相应的实现方式,我们先来看一下。

4.1 Google Map实现

首先,我们需要先创建表结构:

CREATE TABLE `markers` (
  `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY ,
  `name` VARCHAR( 60 ) NOT NULL ,
  `address` VARCHAR( 80 ) NOT NULL ,
  `lat` FLOAT( 10, 6 ) NOT NULL ,
  `lng` FLOAT( 10, 6 ) NOT NULL
) ENGINE = MYISAM ;

当然存储引擎可以是InnoDB。然后,进行初始化数据:

INSERT INTO `markers` (`id`, `name`, `address`, `lat`, `lng`) VALUES ('1','Heir Apparel','Crowea Pl, Frenchs Forest NSW 2086','-33.737885','151.235260');
INSERT INTO `markers` (`id`, `name`, `address`, `lat`, `lng`) VALUES ('2','BeeYourself Clothing','Thalia St, Hassall Grove NSW 2761','-33.729752','150.836090');
INSERT INTO `markers` (`id`, `name`, `address`, `lat`, `lng`) VALUES ('3','Dress Code','Glenview Avenue, Revesby, NSW 2212','-33.949448','151.008591');
INSERT INTO `markers` (`id`, `name`, `address`, `lat`, `lng`) VALUES ('4','The Legacy','Charlotte Ln, Chatswood NSW 2067','-33.796669','151.183609');
INSERT INTO `markers` (`id`, `name`, `address`, `lat`, `lng`) VALUES ('5','Fashiontasia','Braidwood Dr, Prestons NSW 2170','-33.944489','150.854706');
INSERT INTO `markers` (`id`, `name`, `address`, `lat`, `lng`) VALUES ('6','Trish & Tash','Lincoln St, Lane Cove West NSW 2066','-33.812222','151.143707');
INSERT INTO `markers` (`id`, `name`, `address`, `lat`, `lng`) VALUES ('7','Perfect Fit','Darley Rd, Randwick NSW 2031','-33.903557','151.237732');
INSERT INTO `markers` (`id`, `name`, `address`, `lat`, `lng`) VALUES ('8','Buena Ropa!','Brodie St, Rydalmere NSW 2116','-33.815521','151.026642');
INSERT INTO `markers` (`id`, `name`, `address`, `lat`, `lng`) VALUES ('9','Coxcomb and Lily Boutique','Ferrers Rd, Horsley Park NSW 2175','-33.829525','150.873764');
INSERT INTO `markers` (`id`, `name`, `address`, `lat`, `lng`) VALUES ('10','Moda Couture','Northcote Rd, Glebe NSW 2037','-33.873882','151.177460');

然后就可以根据经纬度值,然后基于Haversine公式来查询数据,假设我们要查询latitude=37.38714,longitude=-122.083235,范围在25英里内的前20条数据:

SELECT id, ( 3959 * acos( cos( radians(37) ) * cos( radians( lat ) ) * cos( radians( lng ) - radians(-122) ) + sin( radians(37) ) * sin( radians( lat ) ) ) ) AS distance 
FROM markers 
HAVING distance < 25 
ORDER BY distance 
LIMIT 0 , 20;

而如果我们要查询公里,将3959英里也就是地球半径,修改为6371即可。

Google Maps地址:Creating a Store Locator on Google Maps php&MySQL

4.2 st_distance函数

  MySQL其实在很早就提供了这种存储经纬度及相关运算的功能,这种数据类型叫做空间数据类型,而对应的索引被称为空间索引,但由于MySQL之前的版本对InnoDB支持的并不是太好,所以使用的并不多。不过MySQL5.6和MySQL5.7对此进行了优化,添加了st_distance等相关函数来支持经纬度相关的计算。
  这里只来看一下st_distance函数的使用,其他相关的函数我会专门写一篇文章来学习。我们还是拿上面Google Maps所建的表来测试,来按照距离进行查询:

SELECT
    s.*, 
    (st_distance(point(lng, lat), point(-122.083235, 37.38714) ) * 111195) AS distance
FROM
    markers s
ORDER BY
    distance

其中,point是MySQL的空间数据类型,先不多说这块。就这样,我们只需要通过st_distance函数就计算出了我们所需要查询的结果,不过这里需要说一下:

  1. st_distance 函数返回的单位是degrees,也就是空间单位的度数,我们如果要将degrees转换为米或者千米的话,需要乘以 EARTH_RADIUS * PI/180, EARTH_RADIUS 也就是地球半径,至于是米还是千米,就看该变量的单位。
  2. 该运算其实就相当于对地球半径进行弧度与角度的转换,也就是Math.toRadians,而上面我们写的111195其实是一个有误差的值,该值就是通过该计算得出的结果;我们可以简单看一下toRadians实现:
public static double toRadians(double angdeg) {
    return angdeg / 180.0 * PI;
}

这里的转换参考自:Stackoverflow - Get Distance in Meters instead of degrees in Spatialite

其实,MySQL有提供直接查询结果是米的函数:st_distance_sphere,并且该函数的计算结果要比st_distance转换为米的结果更精确。不过该函数是MySQL5.7之后才引入的,5.7之前还是需要通过计算转换成米。更多可参考官方文档地址:
MySQL 5.7 ST_Distance_Sphere(g1, g2 [, radius])

5 Geohash算法

  Geohash是目前比较主流的范围搜索的算法,比如说搜索附近500米内的地点这种问题。Geohash算法将二维的经纬度编码为一个字符串,每个字符串代表了某一矩形区域,也就是说,这个矩形区域内所有的点(经纬度坐标)都共享相同的GeoHash字符串,这样在查询的时候就可以对该字符串做索引,然后根据该字符串进行过滤。

Geohash算法的最大用途其实就是附近地址搜索了,不过,从geohash的编码算法中可以看出它的一个缺点,也就是边界问题:虽然两个地点距离很近,但恰好位于分界点的两侧,这样geohash字符串就会不相同,然后匹配的时候就会有问题。不过要解决这个问题也很简单,就是计算的时候,计算出8个分别分布在周围8个区域的地点。

  在实际应用中,可以先根据Geohash筛选出附近的地点,然后再算出距离附近地点的距离。而如果要计算Geohash,可以通过 spatial4j 工具包来实现,GeohashUtils.encodeLatLon(lat, lon),默认精度是12位,其中lucene就使用了spatial4j工具包来计算距离。

<dependency>
    <groupId>org.locationtech.spatial4j</groupId>
    <artifactId>spatial4j</artifactId>
    <version>0.7</version>
</dependency>

有管GeoHash算法,可参考:
1. Geohash - 维基百科
2. GeoHash介绍-核心原理解析
3. Github-Java实现Geohash算法- github.com/GongDexing/Geohash

6. 其他

其实实现距离的方式有好多种,比如说:

  • mysql sql查询
  • mysql+geohash
  • mysql 空间索引 (MySQL5.7版本以上)
  • PostgreSQL/mongodb + geohash
  • redis+geohash
  • Lucene/Solr/ES + Spatial/geohash

  并且,这种基于搜索排序的功能其实正是Lucene这种搜索引擎和非关系型数据库所擅长的。而对MySQL而言,一直以来MySQL在GIS上的功能支持都比较弱,并且仅有MyISAM引擎支持,不过MySQL5.7之后发生了改变,提供了InnoDB引擎的GIS支持。所以,针对MySQL的这块功能,等接下来专门来学习一下。

本文参考自:
1. 几个地理位置信息处理方案的对比和分析
2. 空间索引 - 各数据库空间索引使用报告
3. 美团技术团队-地理空间距离计算优化

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容