GeoHash算法原理及實現

GeoHash算法原理

GeoHash是目前比較主流實現位置服務的技術,Geohash算法將經緯度二維數據編碼爲一個字符串,本質是一個降維的過程

樣例數據(基於15次區域分割)

位置 經緯度 Geohash
北京站 116.433589,39.910508 wx4g19
天安門 116.403874,39.913884 wx4g0f
首都機場 116.606819,40.086109 wx4uj3

GeoHash算法思想

我們知道,經度範圍是東經180到西經180,緯度範圍是南緯90到北緯90,我們設定西經爲負,南緯爲負,所以地球上的經度範圍就是[-180, 180],緯度範圍就是[-90,90]。如果以本初子午線、赤道爲界,地球可以分成4個部分。

GeoHash的思想就是將地球劃分的四部分映射到二維座標上。

[-90˚,0˚)代表0,(0˚,90˚]代表1,[-180˚,0)代表0,(0˚,180˚]代表1
映射到二維空間劃分爲四部分則如下圖

在這裏插入圖片描述
但是這麼粗略的劃分沒有什麼意義,想要更精確的使用GeoHash就需要再進一步二分切割
在這裏插入圖片描述
通過上圖可看出,進一步二分切割將原本大略的劃分變爲細緻的區域劃分,這樣就會更加精確。GeoHash算法就是基於這種思想,遞歸劃分的次數越多,所計算出的數據越精確。

GeoHash算法原理

GeoHash算法大體上分爲三步:1. 計算經緯度的二進制、2. 合併經緯度的二進制、3. 通過Base32對合並後的二進制進行編碼。

  1. 計算經緯度的二進制
	//根據經緯度和範圍,獲取對應的二進制
	private BitSet getBits(double l, double floor, double ceiling) {
		BitSet buffer = new BitSet(numbits);
		for (int i = 0; i < numbits; i++) {
			double mid = (floor + ceiling) / 2;
			if (l >= mid) {
				buffer.set(i);
				floor = mid;
			} else {
				ceiling = mid;
			}
		}
		return buffer;
	}

上述代碼numbits爲:private static int numbits = 3 * 5; //經緯度單獨編碼長度也就是說將地球進行15次二分切割

注: 這裏需要對BitSet類進行一下剖析,沒了解過該類的話指定懵。

瞭解BitSet只需了去瞭解它的set()、get()方法就足夠了

  • BitSet的set方法
 /**
     * Sets the bit at the specified index to {@code true}.
     *
     * @param  bitIndex a bit index
     * @throws IndexOutOfBoundsException if the specified index is negative
     * @since  JDK1.0
     */
    public void set(int bitIndex) {
        if (bitIndex < 0)
            throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);

        int wordIndex = wordIndex(bitIndex);
        expandTo(wordIndex);

        words[wordIndex] |= (1L << bitIndex); // Restores invariants

        checkInvariants();
    }

set方法內wordIndex(bitIndex)底層將bitIndex右移6位然後返回,ADDRESS_BITS_PER_WORD爲常量6

/**
    * Given a bit index, return word index containing it.
    */
   private static int wordIndex(int bitIndex) {
       return bitIndex >> ADDRESS_BITS_PER_WORD;
   }

set方法內的expandTo(wordIndex)只是一個判斷數組是否需要擴容的方法

/**
     * Ensures that the BitSet can accommodate a given wordIndex,
     * temporarily violating the invariants.  The caller must
     * restore the invariants before returning to the user,
     * possibly using recalculateWordsInUse().
     * @param wordIndex the index to be accommodated.
     */
    private void expandTo(int wordIndex) {
        int wordsRequired = wordIndex+1;
        if (wordsInUse < wordsRequired) {
            ensureCapacity(wordsRequired);
            wordsInUse = wordsRequired;
        }
    }

set內重要的一行代碼words[wordIndex] |= (1L << bitIndex),這裏只解釋一下|=

a|=b就是a=a|b,就是說將a、b轉爲二進制按位與,同0爲0,否則爲1

  • BitSet的get方法
 /**
    * Returns the value of the bit with the specified index. The value
    * is {@code true} if the bit with the index {@code bitIndex}
    * is currently set in this {@code BitSet}; otherwise, the result
    * is {@code false}.
    *
    * @param  bitIndex   the bit index
    * @return the value of the bit with the specified index
    * @throws IndexOutOfBoundsException if the specified index is negative
    */
   public boolean get(int bitIndex) {
       if (bitIndex < 0)
           throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);

       checkInvariants();

       int wordIndex = wordIndex(bitIndex);
       return (wordIndex < wordsInUse)
           && ((words[wordIndex] & (1L << bitIndex)) != 0);
   }

get方法用一句話概括就是:如果傳入的下標有值,返回true;反之爲false

以天安門座標爲例:39.913884, 116.403874

	BitSet latbits = getBits(lat, -90, 90);
   	BitSet lonbits = getBits(lon, -180, 180);
   	// 緯度
   	for (int i = 0; i < numbits; i++) {
   		System.out.print(latbits.get(i) + " ");
   	}
   	// 經度
    for (int i = 0; i < numbits; i++) {
   		System.out.print(lonbits.get(i) + " ");
   	}

緯度經過轉換爲:

true false true true true false false false true true false false false true false 

轉爲二進制:

1 0 1 1 1 0 0 0 1 1 0 0 0 1 0 

經度經過轉換爲:

true true false true false false true false true true false false false true true 

轉爲二進制:

1 1 0 1 0 0 1 0 1 1 0 0 0 1 1 
  1. 合併經緯度二進制
    合併原則:經度佔偶數位,緯度佔奇數位。也就是說經緯度交替合併,首位0位置爲經度的0位置

合併後二進制編碼爲:

11100 11101 00100 01111 00000 01110
  1. 使用Base32對合並後的經緯度二進制進行編碼
  • 代碼實現
// Base32進行編碼
	public String encode(double lat, double lon) {
		BitSet latbits = getBits(lat, -90, 90);
		BitSet lonbits = getBits(lon, -180, 180);
		StringBuilder buffer = new StringBuilder();
		for (int i = 0; i < numbits; i++) {
			buffer.append((lonbits.get(i)) ? '1' : '0');
			buffer.append((latbits.get(i)) ? '1' : '0');
		}
		String code = base32(Long.parseLong(buffer.toString(), 2));
		return code;
	}

本文案例經緯度編碼後

wx4g0f

後續問題

如果要使用此功能實現附近的人。假如紅點爲使用者,經過Geohash算法分割後只會推薦同區域0011中的綠點,但是如下圖所示,藍色點相對於綠色點更接近用戶,所以區域劃分的弊端就展現在這裏。
在這裏插入圖片描述
針對上述問題,我們可以人爲獲取紅色用戶所在的0011區域周邊八個區域中的用戶,即獲取0011的同時還要獲取0100,0110,1100,0001,1001,0000,0010,1000

  • 代碼實現
	public ArrayList<String> getArroundGeoHash(double lat, double lon) {
		ArrayList<String> list = new ArrayList<>();
		double uplat = lat + minLat;
		double downLat = lat - minLat;

		double leftlng = lon - minLng;
		double rightLng = lon + minLng;

		String leftUp = encode(uplat, leftlng);
		list.add(leftUp);

		String leftMid = encode(lat, leftlng);
		list.add(leftMid);

		String leftDown = encode(downLat, leftlng);
		list.add(leftDown);

		String midUp = encode(uplat, lon);
		list.add(midUp);

		String midMid = encode(lat, lon);
		list.add(midMid);

		String midDown = encode(downLat, lon);
		list.add(midDown);

		String rightUp = encode(uplat, rightLng);
		list.add(rightUp);

		String rightMid = encode(lat, rightLng);
		list.add(rightMid);

		String rightDown = encode(downLat, rightLng);
		list.add(rightDown);

		return list;
	}

然後根據球體兩點間的距離計算紅色用戶與周邊區域用戶距離,從而進行附近的人功能實現

  • 通過兩經緯度計算距離java代碼實現
	static double getDistance(double lat1, double lon1, double lat2, double lon2) {
		// 經緯度(角度)轉弧度。弧度用作參數,以調用Math.cos和Math.sin
		double radiansAX = Math.toRadians(lon1); // A經弧度
		double radiansAY = Math.toRadians(lat1); // A緯弧度
		double radiansBX = Math.toRadians(lon2); // B經弧度
		double radiansBY = Math.toRadians(lat2); // B緯弧度

		// 公式中“cosβ1cosβ2cos(α1-α2)+sinβ1sinβ2”的部分,得到∠AOB的cos值
		double cos = Math.cos(radiansAY) * Math.cos(radiansBY) * Math.cos(radiansAX - radiansBX)
				+ Math.sin(radiansAY) * Math.sin(radiansBY);
		double acos = Math.acos(cos); // 反餘弦值
		return EARTH_RADIUS * acos; // 最終結果
	}

GeoHash算法代碼實現

public class GeoHash {
	public static final double MINLAT = -90;
	public static final double MAXLAT = 90;
	public static final double MINLNG = -180;
	public static final double MAXLNG = 180;

	private static int numbits = 3 * 5; //經緯度單獨編碼長度

	private static double minLat;
	private static double minLng;

	private final static char[] digits = {'0', '1', '2', '3', '4', '5', '6', '7', '8',
			'9', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p',
			'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'};

	//定義編碼映射關係
	final static HashMap<Character, Integer> lookup = new HashMap<Character, Integer>();

	//初始化編碼映射內容
	static {
		int i = 0;
		for (char c : digits)
			lookup.put(c, i++);
	}

	public GeoHash() {
		setMinLatLng();
	}

	// Base32進行編碼
	public String encode(double lat, double lon) {
		BitSet latbits = getBits(lat, -90, 90);
		BitSet lonbits = getBits(lon, -180, 180);
		StringBuilder buffer = new StringBuilder();
		for (int i = 0; i < numbits; i++) {
			buffer.append((lonbits.get(i)) ? '1' : '0');
			buffer.append((latbits.get(i)) ? '1' : '0');
		}
		String code = base32(Long.parseLong(buffer.toString(), 2));
		return code;
	}

	public ArrayList<String> getArroundGeoHash(double lat, double lon) {
		ArrayList<String> list = new ArrayList<>();
		double uplat = lat + minLat;
		double downLat = lat - minLat;

		double leftlng = lon - minLng;
		double rightLng = lon + minLng;

		String leftUp = encode(uplat, leftlng);
		list.add(leftUp);

		String leftMid = encode(lat, leftlng);
		list.add(leftMid);

		String leftDown = encode(downLat, leftlng);
		list.add(leftDown);

		String midUp = encode(uplat, lon);
		list.add(midUp);

		String midMid = encode(lat, lon);
		list.add(midMid);

		String midDown = encode(downLat, lon);
		list.add(midDown);

		String rightUp = encode(uplat, rightLng);
		list.add(rightUp);

		String rightMid = encode(lat, rightLng);
		list.add(rightMid);

		String rightDown = encode(downLat, rightLng);
		list.add(rightDown);

		return list;
	}

	//根據經緯度和範圍,獲取對應的二進制
	private BitSet getBits(double l, double floor, double ceiling) {
		BitSet buffer = new BitSet(numbits);
		for (int i = 0; i < numbits; i++) {
			double mid = (floor + ceiling) / 2;
			if (l >= mid) {
				buffer.set(i);
				floor = mid;
			} else {
				ceiling = mid;
			}
		}
		return buffer;
	}

	//將經緯度合併後的二進制進行指定的32位編碼
	private String base32(long i) {
		char[] buf = new char[65];
		int charPos = 64;
		boolean negative = (i < 0);
		if (!negative) {
			i = -i;
		}
		while (i <= -32) {
			buf[charPos--] = digits[(int) (-(i % 32))];
			i /= 32;
		}
		buf[charPos] = digits[(int) (-i)];
		if (negative) {
			buf[--charPos] = '-';
		}
		return new String(buf, charPos, (65 - charPos));
	}

	private void setMinLatLng() {
		minLat = MAXLAT - MINLAT;
		for (int i = 0; i < numbits; i++) {
			minLat /= 2.0;
		}
		minLng = MAXLNG - MINLNG;
		for (int i = 0; i < numbits; i++) {
			minLng /= 2.0;
		}
	}

	//根據二進制和範圍解碼
	private double decode(BitSet bs, double floor, double ceiling) {
		double mid = 0;
		for (int i = 0; i < bs.length(); i++) {
			mid = (floor + ceiling) / 2;
			if (bs.get(i))
				floor = mid;
			else
				ceiling = mid;
		}
		return mid;
	}

	//對編碼後的字符串解碼
	public double[] decode(String geohash) {
		StringBuilder buffer = new StringBuilder();
		for (char c : geohash.toCharArray()) {
			int i = lookup.get(c) + 32;
			buffer.append(Integer.toString(i, 2).substring(1));
		}

		BitSet lonset = new BitSet();
		BitSet latset = new BitSet();

		//偶數位,經度
		int j = 0;
		for (int i = 0; i < numbits * 2; i += 2) {
			boolean isSet = false;
			if (i < buffer.length())
				isSet = buffer.charAt(i) == '1';
			lonset.set(j++, isSet);
		}

		//奇數位,緯度
		j = 0;
		for (int i = 1; i < numbits * 2; i += 2) {
			boolean isSet = false;
			if (i < buffer.length())
				isSet = buffer.charAt(i) == '1';
			latset.set(j++, isSet);
		}

		double lon = decode(lonset, -180, 180);
		double lat = decode(latset, -90, 90);

		return new double[]{lat, lon};
	}

	public static void main(String[] args) {
		GeoHash geoHash = new GeoHash();
		// 北京站
		String encode = geoHash.encode(39.910508, 116.433589);
		System.out.println(encode);

		// 天安門
		System.out.println(geoHash.encode(39.913884, 116.403874));

		// 首都機場
		System.out.println(geoHash.encode(40.086109, 116.606819));

		BitSet latbits = geoHash.getBits(39.913884, -90, 90);
		BitSet lonbits = geoHash.getBits(116.403874, -180, 180);

//		for (int i=0; i< latbits.length(); i++) {
//			System.out.println(latbits.get(i));
//		}

		for (int i = 0; i < numbits; i++) {
//			System.out.print(latbits.get(i));
			System.out.print(latbits.get(i) ? '1' : '0');
			System.out.print(" ");
		}

		System.out.println();
		StringBuilder buffer = new StringBuilder();
		for (int i = 0; i < numbits; i++) {
			buffer.append((lonbits.get(i)) ? '1' : '0');
			buffer.append((latbits.get(i)) ? '1' : '0');
		}

		System.out.println(buffer.toString());

		System.out.println(geoHash.encode(39.913884, 116.403874));
	}

}

寫在最後

如果嫌GeoHash算法麻煩,但是還想用它,沒關係。
Redis知道你懶Redis官網GeoHash用法

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章