spatial4j中Law of Cosines距离函数的使用优化
冯鸿光
2023-12-01
最近用了下https://github.com/spatial4j/spatial4j spatial4j 做地理位置的搜索。
其中给定两个点的经纬度计算距离的公式有3种:
Haversine http://en.wikipedia.org/wiki/Haversine_formula
Law of Cosines(余弦定理) http://en.wikipedia.org/wiki/Spherical_law_of_cosines
Vincenty http://en.wikipedia.org/wiki/Vincenty's_formulae
spatial4j 默认使用的是Haversine
其中根据http://stackoverflow.com/questions/2096385/formulas-to-calculate-geo-proximity/ 的讨论
从速度上讲: Law of Cosines > Haversine > Vincenty
然后我就用了 Law of Cosines,结果发现比另两个都慢。。。
这不科学啊,然后我就开始看code: https://github.com/spatial4j/spatial4j/blob/master/src/main/java/com/spatial4j/core/distance/DistanceUtils.java
里面的3个函数distHaversineRAD, distLawOfCosinesRAD 和 distVincentyRAD
给定的地球表面的两点,这三个函数都是计算球心到这两点的向量的夹角,有了这个夹角的话乘以地球半径就是距离。
把里面的3个函数拿出来跑测试,用同一组100万中国经纬度范围longitude (73, 135) latitude (3, 53)
的随机数据在我的MacBookPro测试一下:
distHaversineRAD cost: 193.561ms
distLawOfCosinesRAD cost: 407.285ms
distVincentyRAD cost: 264.404ms
distLawOfCosinesRAD 果然最慢的啊,仔细看了下这个函数。
public static double distLawOfCosinesRAD(double lat1, double lon1, double lat2, double lon2) {
// Check for same position
if (lat1 == lat2 && lon1 == lon2)
return 0.0;
// Get the m_dLongitude difference. Don't need to worry about
// crossing 180 since cos(x) = cos(-x)
double dLon = lon2 - lon1;
double a = DEG_90_AS_RADS - lat1;
double c = DEG_90_AS_RADS - lat2;
double cosB = (Math.cos(a) * Math.cos(c))
+ (Math.sin(a) * Math.sin(c) * Math.cos(dLon));
// Find angle subtended (with some bounds checking) in radians
if (cosB < -1.0)
return Math.PI;
else if (cosB >= 1.0)
return 0;
else
return Math.acos(cosB);
}
优化1:
直觉告诉我acos这个计算反余弦的肯定最耗时。其实在搜索中使用距离函数最多的场景是做Filter用,比如给定中心点的经纬度搜索10公里范围内的人。
假设中心点经纬度是(lon1, lat1),距离d,地球半径为r,要判断的点是(lon2, lat2)
spatial4j中判断是否在范围内的做法:distLawOfCosinesRAD(lat1, lon1, lat2, lon2) < d/r
注意到Cosine在[0, PI]之间是递减的,任意两个向量的夹角也是在[0, PI]之间,所以如果判断距离是不是在范围内,可以不计算出来角度, 只计算出Cosine即可。
Math.cos(distLawOfCosinesRAD(lat1, lon1, lat2, lon2)) > Math.cos(d/r)
其中Math.cos(d/r)只需要计算一次,存储下来。
这样做距离Filter用的时候,distLawOfCosinesRAD 变成了这个样子
public static double distLawOfCosinesRAD(double lat1, double lon1, double lat2, double lon2) {
if (lat1 == lat2 && lon1 == lon2) return 0.0;
// Get the m_dLongitude difference. Don't need to worry about
// crossing 180 since cos(x) = cos(-x)
double dLon = lon2 - lon1;
double a = DEG_90_AS_RADS - lat1;
double c = DEG_90_AS_RADS - lat2;
double cosB = (Math.cos(a) * Math.cos(c)) + (Math.sin(a) * Math.sin(c) * Math.cos(dLon));
return cosB;
}
distLawOfCosinesRAD cost: 79.048ms
耗时变成了原来的1/5。
优化2:
又看了一下这个函数,作为处女座的人完全无法接收这两行啊:
double a = DEG_90_AS_RADS - lat1;
double c = DEG_90_AS_RADS - lat2;
其中 DEG_90_AS_RADS = Math.PI / 2;
显然Math.cos(a) = Math.sin(Math.PI / 2 - a) 所以干掉这两行后,distLawOfCosinesRAD 变成了这个样子
public static double distLawOfCosinesRAD(double lat1, double lon1, double lat2, double lon2) {
// Check for same position
if (lat1 == lat2 && lon1 == lon2) return 0.0;
// Get the m_dLongitude difference. Don't need to worry about
// crossing 180 since cos(x) = cos(-x)
double dLon = lon2 - lon1;
double cosB = (Math.sin(lat1) * Math.sin(lat2)) + (Math.cos(lat1) * Math.cos(lat2) * Math.cos(dLon));
return cosB;
}
distLawOfCosinesRAD cost: 36.258ms
又减少了一半。这个下降的幅度显然不合理,因为只是少做了两次double减法。感觉这个提高是和数据分布有关系,在中国的经纬度范围内效果很好,其实在美国的经纬度范围longitude (-75,-120) latitude (30, 50) 效果也很好。
优化3:
一次搜索中,中心点的经纬度(lon1, lat1)是不变的,但是每次计算距离我们都要计算Math.sin(lat1) 和 Math.cos(lat1), 如果把Math.sin(lat1) 和 Math.cos(lat1) 只计算一次每次计算距离时作为参数直接传入,那么会各减少一次Math.sin 和 Math.cos 计算。
distLawOfCosinesRAD cost: 24.356ms
减少了10多ms,这个优化效果不明显了。
如果还要继续优化:
假设每次搜索过程中需要和中心点(lon1, lat1)比较的点(lon2, lat2)的集合只取决于索引(比如每个用户的经纬度),那么我们在建索引时可以将经纬度(lon, lat)
转为空间坐标系(x, y, z)比如可以这样映射:
x = Math.cos(lat) * Math.cos(lon);
y = Math.cos(lat) * Math.sin(lon);
z = Math.sin(lat);
然后把(x, y, z)建入索引。
那么当我们求向量夹角时,只需要做一次点乘操作。比如求(lon1, lat1)和 (lon2, lat2)的夹角,只需要计算
x1*x2 + y1*y2 + z1*z2 这样避免了做任何三角函数换算这个效率是最高的,做100万次这样的操作基本在10ms以内。
Bobo的GeoFacetFilter就是这样做的。https://github.com/senseidb/bobo/blob/master/bobo-browse/src/main/java/com/browseengine/bobo/facets/filter/GeoFacetFilter.java
其实
x1*x2 + y1*y2 + z1*z2
= Math.cos(lat1) *Math.cos(lon1)*Math.cos(lat2) * Math.cos(lon2) +
Math.cos(lat1) *Math.sin(lon1) * Math.cos(lat2)*Math.sin(lon2) +
Math.sin(lat1)*Math.sin(lat2)
= Math.cos(lat1)*Math.cos(lat2)*(Math.cos(lon1) * Math.cos(lon2) + Math.sin(lon1)*Math.sin(lon2)) + Math.sin(lat1)*Math.sin(lat2)
= Math.cos(lat1)*Math.cos(lat2)*Math.cos(lon1-lon2) + Math.sin(lat1)*Math.sin(lat2)
这个结果和distLawOfCosinesRAD完全等价。
由于计算距离的耗时只是整个搜索耗时中的一部分,所以第一个优化在线上体现最明显,第二个不太明显,第三个基本属于过度优化了。