引言

Geohash是一种地理编码系统,可将地理坐标(纬度和经度)编码为简短的字母和数字字符串。由Gustavo Niemeyer于2008年开发,Geohash提供了一种高效的方式来表示空间数据、实现邻近搜索和创建空间索引。本指南探讨Geohash技术的核心原理、实现细节和实际应用。

📋 目录

关键要点

  • 分层结构:Geohash 将地球划分为一个分层的网格,其中较长的哈希值表示较小的区域,从而实现可变的精度。
  • Base32 编码:它使用 Base32 编码将经纬度的二进制表示转换为简短的字母数字字符串。
  • 邻近搜索:共享前缀通常表示处于同一层级单元,但跨单元邻居和精确距离仍需额外处理。
  • 数据索引:Geohash 是数据库中地理空间数据的优秀索引,可以加快基于位置的查询。
  • 精度权衡:Geohash 的长度决定了其精度,开发者可以根据应用需求选择合适的长度。
  • 适用边界:适合部分层级网格索引和候选集检索,但不能替代精确距离或空间谓词。

Geohash如何工作?

Geohash通过递归地将世界划分为更小的矩形网格来运作。每一步都将当前矩形一分为二,并根据坐标点落在哪个半区来确定一个二进制位(0或1)。

  1. 二分法:该过程从整个世界地图开始,经度范围为[-180, 180],纬度范围为[-90, 90]。
  2. 交替划分:算法首先按经度划分,然后按纬度划分,依此类推。如果坐标点在区间的上半部分,则附加一个1;如果在下半部分,则附加一个0
  3. 位交织:来自经度和纬度的二进制位交织在一起,形成一个单一的二进制字符串。
  4. Base32编码:最后,将二进制字符串转换为Base32编码,使用一个包含数字和字母的字符集(0-9b-z,不包括a, i, l, o)。

结果是一个简短的字符串,表示地球上的一个特定区域,字符串越长,区域越精确。

Geohash精度级别

Geohash的长度决定了其精度。每个额外的字符都显著提高了位置的准确性。下表概述了不同长度的Geohash的近似精度:

字符数 纬度半跨度 赤道处经度半跨度 典型单元尺度
1 ±23° ±23° 大陆级
2 ±2.8° ±5.6° 国家级
3 ±0.70° ±0.70° 区域级
4 ±0.087° ±0.087° 城市级
5 ±0.022° ±0.022° 局部区域
6 ±0.0027° ±0.0055° 社区级
7 ±0.00068° ±0.00068° 街道级
8 ±0.000085° ±0.00017° 建筑级
9 ±0.000021° ±0.000021° 细粒度
10 ±0.0000027° ±0.0000054° 取决于应用
11 ±0.00000067° ±0.00000067° 取决于应用
12 ±0.00000008° ±0.00000017° 取决于应用

JavaScript、Python和Java中的Geohash实现

Geohash可以在各种编程语言中实现。以下是JavaScript、Python和Java中的编码和解码示例。

JavaScript实现

javascript
// JavaScript Geohash实现
const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';

function encode(latitude, longitude, precision = 9) {
  if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90 ||
      !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
    throw new Error('纬度或经度超出有效 WGS84 范围');
  }
  if (!Number.isInteger(precision) || precision < 1 || precision > 12) {
    throw new Error('precision 必须是 1 到 12 的整数');
  }
  let latRange = { min: -90, max: 90 };
  let lonRange = { min: -180, max: 180 };
  let geohash = '';
  let bits = 0;
  let bit = 0;
  let isEven = true;

  while (geohash.length < precision) {
    if (isEven) {
      const mid = (lonRange.min + lonRange.max) / 2;
      if (longitude > mid) {
        bits = (bits << 1) | 1;
        lonRange.min = mid;
      } else {
        bits = (bits << 1) | 0;
        lonRange.max = mid;
      }
    } else {
      const mid = (latRange.min + latRange.max) / 2;
      if (latitude > mid) {
        bits = (bits << 1) | 1;
        latRange.min = mid;
      } else {
        bits = (bits << 1) | 0;
        latRange.max = mid;
      }
    }

    isEven = !isEven;
    bit++;

    if (bit % 5 === 0) {
      geohash += BASE32[bits];
      bits = 0;
    }
  }
  return geohash;
}

function decode(geohash) {
  let latRange = { min: -90, max: 90 };
  let lonRange = { min: -180, max: 180 };
  let isEven = true;

  for (let i = 0; i < geohash.length; i++) {
    const char = geohash[i];
    const charIndex = BASE32.indexOf(char);
    if (charIndex === -1) {
      throw new Error('Geohash 字符无效');
    }

    for (let j = 4; j >= 0; j--) {
      const bit = (charIndex >> j) & 1;
      if (isEven) {
        const mid = (lonRange.min + lonRange.max) / 2;
        if (bit === 1) {
          lonRange.min = mid;
        } else {
          lonRange.max = mid;
        }
      } else {
        const mid = (latRange.min + latRange.max) / 2;
        if (bit === 1) {
          latRange.min = mid;
        } else {
          latRange.max = mid;
        }
      }
      isEven = !isEven;
    }
  }

  return {
    latitude: (latRange.min + latRange.max) / 2,
    longitude: (lonRange.min + lonRange.max) / 2,
  };
}

// 示例
const geohash = encode(39.9288, 116.3884, 9); // wx4g0ec1s
const coords = decode(geohash);
console.log(geohash, coords);

Python实现

python
# Python Geohash实现
BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"

def encode(latitude, longitude, precision=9):
    lat_range = [-90.0, 90.0]
    lon_range = [-180.0, 180.0]
    geohash = []
    bits = 0
    bit = 0
    is_even = True

    while len(geohash) < precision:
        if is_even:
            mid = (lon_range[0] + lon_range[1]) / 2
            if longitude > mid:
                bits = (bits << 1) | 1
                lon_range[0] = mid
            else:
                bits = (bits << 1)
                lon_range[1] = mid
        else:
            mid = (lat_range[0] + lat_range[1]) / 2
            if latitude > mid:
                bits = (bits << 1) | 1
                lat_range[0] = mid
            else:
                bits = (bits << 1)
                lat_range[1] = mid
        
        is_even = not is_even
        bit += 1

        if bit % 5 == 0:
            geohash.append(BASE32[bits])
            bits = 0
    
    return "".join(geohash)

def decode(geohash):
    lat_range = [-90.0, 90.0]
    lon_range = [-180.0, 180.0]
    is_even = True

    for char in geohash:
        char_index = BASE32.index(char)
        for i in range(4, -1, -1):
            bit = (char_index >> i) & 1
            if is_even:
                mid = (lon_range[0] + lon_range[1]) / 2
                if bit == 1:
                    lon_range[0] = mid
                else:
                    lon_range[1] = mid
            else:
                mid = (lat_range[0] + lat_range[1]) / 2
                if bit == 1:
                    lat_range[0] = mid
                else:
                    lat_range[1] = mid
            is_even = not is_even
            
    return ((lat_range[0] + lat_range[1]) / 2, (lon_range[0] + lon_range[1]) / 2)

# 示例
geohash = encode(39.9288, 116.3884)
coords = decode(geohash)
print(geohash, coords)

Java实现

java
// Java Geohash实现
import java.util.HashMap;
import java.util.Map;

public class Geohash {
    private static final String BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
    private static final Map<Character, Integer> BASE32_DECODE_MAP = new HashMap<>();
    static {
        for (int i = 0; i < BASE32.length(); i++) {
            BASE32_DECODE_MAP.put(BASE32.charAt(i), i);
        }
    }

    public static String encode(double latitude, double longitude, int precision) {
        double[] latRange = { -90.0, 90.0 };
        double[] lonRange = { -180.0, 180.0 };
        StringBuilder geohash = new StringBuilder();
        boolean isEven = true;
        int bit = 0;
        int bits = 0;

        while (geohash.length() < precision) {
            if (isEven) {
                double mid = (lonRange[0] + lonRange[1]) / 2;
                if (longitude > mid) {
                    bits = (bits << 1) | 1;
                    lonRange[0] = mid;
                } else {
                    bits = (bits << 1);
                    lonRange[1] = mid;
                }
            } else {
                double mid = (latRange[0] + latRange[1]) / 2;
                if (latitude > mid) {
                    bits = (bits << 1) | 1;
                    latRange[0] = mid;
                } else {
                    bits = (bits << 1);
                    latRange[1] = mid;
                }
            }
            isEven = !isEven;
            bit++;
            if (bit % 5 == 0) {
                geohash.append(BASE32.charAt(bits));
                bits = 0;
            }
        }
        return geohash.toString();
    }

    public static double[] decode(String geohash) {
        double[] latRange = { -90.0, 90.0 };
        double[] lonRange = { -180.0, 180.0 };
        boolean isEven = true;

        for (int i = 0; i < geohash.length(); i++) {
            Integer charIndex = BASE32_DECODE_MAP.get(geohash.charAt(i));
            if (charIndex == null) {
                throw new IllegalArgumentException("无效的 Geohash 字符");
            }
            for (int j = 4; j >= 0; j--) {
                int bit = (charIndex >> j) & 1;
                if (isEven) {
                    double mid = (lonRange[0] + lonRange[1]) / 2;
                    if (bit == 1) {
                        lonRange[0] = mid;
                    } else {
                        lonRange[1] = mid;
                    }
                } else {
                    double mid = (latRange[0] + latRange[1]) / 2;
                    if (bit == 1) {
                        latRange[0] = mid;
                    } else {
                        latRange[1] = mid;
                    }
                }
                isEven = !isEven;
            }
        }
        return new double[] { (latRange[0] + latRange[1]) / 2, (lonRange[0] + lonRange[1]) / 2 };
    }

    public static void main(String[] args) {
        String geohash = encode(39.9288, 116.3884, 9);
        double[] coords = decode(geohash);
        System.out.println(geohash); // wx4g0ec1s
        System.out.println(coords[0] + ", " + coords[1]);
    }
}

实际应用

Geohash因其高效性和简单性而被广泛应用于各种应用中:

  • 基于位置的服务:查找附近的餐馆、出租车或兴趣点。
  • 地理围栏:在地图上创建虚拟边界,以在设备进入或离开时触发操作。
  • 空间索引:在数据库中高效查询地理空间数据。
  • 位置分析:分析地理模式,例如识别特定区域的热点。

常见问题(FAQ)

1. Geohash在现实世界中有哪些应用? Geohash被用于基于位置的服务(如Uber和Lyft)、社交媒体签到(如Foursquare)、地理空间分析和物联网(IoT)设备跟踪。

2. Geohash如何处理两极和日界线? Geohash在两极和日界线附近存在局限性,因为矩形单元格会变形。在这些区域,邻近搜索可能需要特殊处理,以确保准确性。

3. Geohash的主要限制是什么? 主要限制是其矩形单元格形状,这可能导致边界问题,即两个靠近的点可能最终位于不同的单元格中。此外,两极附近的精度会降低。

4. 如何为我的应用选择合适的 Geohash 精度? 应根据目标纬度、单元尺寸、查询半径、误报候选预算和边界行为选择精度。不要把 4、5、8 或 9 个字符当作通用的城市或建筑精度保证;候选集仍需用精确距离或空间谓词复核。

5. Geohash有哪些替代方案? 替代方案包括Uber的H3(使用六边形)、Google的S2(使用球形几何)和Microsoft的Bing Maps Quadkeys。每种方案都有其自身的优势和权衡。

限制与替代方案

Geohash 是矩形、经度可环绕的网格键,不是距离度量。相邻点可能落入不同前缀,字符串字典序也不代表地理距离顺序。半径查询应覆盖查询单元及相关邻居,再计算测地距离,或使用数据库的空间运算符;经度环绕、极区、坐标参考系和日界线必须显式处理。

替代方案包括适合层级六边形单元的 H3、球面单元几何的 S2、适合空间包围盒的 R-tree,以及数据库原生空间索引。应根据查询形状、更新频率、距离模型、极区/日界线需求和运维工具选择。

结论

当分层矩形键适合存储和查询负载时,Geohash 很有用;它不能替代精确距离计算、多边形谓词、授权过滤或数据库空间索引。应结合目标纬度、负载、坐标参考系和边界用例验证精度。

一手来源