Java : Comprendre et utiliser l’algorithme Geohash

Partager cet article

Temps estimé pour la lecture de cet article : 57 min

Geohash est un système de géocodage des coordonnées géographiques (latitude / longitude) inventé par Gustavo Niemeyer lors de l’écriture du site geohash.org, et mis dans le domaine public. Le système est basé sur une fonction de hachage qui subdivise la surface terrestre selon une grille hiérarchique.

Dans la vie d’un développeur, bien souvent on doit prendre en main des algorithmes afin de nous aider dans la conception de nos logiciels. Le système de geohash fait partie de ces algorithmes, qui sont à la fois simples mais élégants ! Le genre qui vous fait aimer la programmation, vous allez voir pourquoi !

Présentation de l’algorithme de geohash

Prenons une position sur une carte (notre beau point rouge), le but de l’algorithme va être de représenter cette position comme un tag (une chaine unique). On commence par diviser la carte du monde en deux grâce à une ligne verticale et on associe à chaque moitié une valeur binaire 0 ou 1. Notre point se situe à droite dans la partie « 1 ».

Geohash first scale

On pourrait alors partager la position à quelqu’un en lui disant de nous rejoindre au Geohash « 1 » mais comme vous l’aurez facilement observé, on donnerait une indication vraiment bien imprécise, « rejoins-moi dans la moitié est de la planète ». Notre ami mettrait un temps fou à nous retrouver et pourrait potentiellement pester contre nous :D.

Mais rassurez-vous, on peut facilement faire mieux, il suffit d’améliorer la résolution du geohash, en scindant à nouveau la partie « 1 », mais cette fois-ci avec une division horizontale et on assigne cette fois « 0 » en haut et « 1 » en bas. On obtient ceci :

geohash second scale

On avance, on a doublé les chances que notre ami nous retrouve et on voit qu’on arrive à savoir dans quel continent on va se rejoindre ! Continuons, on divise à nouveau le résultat en deux, de manière verticale cette fois-ci.

geohash third scale

Notre geohash vaut maintenant « 101 » et on a encore gagné en précision. Vous aurez sans doute noté qu’entre chaque sous-division on alterne entre une division verticale et horizontale. Cela va permettre d’entrelacer les informations de longitude et de latitude pour ne donner qu’une seule valeur. On va continuer cet ensemble de divisions jusqu’à obtenir une précision du niveau d’une rue, voire même au-delà.

Exemple de résultat de l’algorithme de geohash

À la fin l’algorithme, notre tag sera un plus long et ressemblera surement à quelque chose comme ceci : 0010110101011100011000110001101111000111. Or, ce binaire peut être représenté sous forme de caractères alphanumériques (codés sur 32 bits). Tous les 5 bits, l’algorithme va convertir la valeur binaire en un caractère correspondant. Ce qui nous donne alors une chaîne de caractères de cette forme-là : « 5pf666y7 ».

C’est génial ton truc, mais en quoi cela nous aide-t-il?

Intérêt de l’algorithme

Voici, donc les utilisations principales de notre super tag :

  • On peut l’utiliser, comme un identifiant unique: un geohash étant court et concis (au maximum, il fera 12 caractères). Ainsi, on peut facilement partager une position sur les réseaux sociaux.
  • Cela peut permettre de réaliser un système de cache à échelle. Au lieu d’avoir à stocker ce qu’on appelle une bounding box (un rectangle défini par deux positions: en haut à gauche et en bas à droite), nous n’avons qu’une chaine a stocker. Grâce à ça, on peut facilement utiliser des bases de données clés-valeurs comme MongoDB, Cassandra, Memcached et j’en passe, afin de stocker et récupérer rapidement toutes les informations d’une grille donnée.
  • Enfin, les geohashs permettent facilement de trouver les points les plus proches d’un lieu donné. En effet, comme les geohashs sont stockés dans un ordre alphanumérique. Cela signifie donc que les points les plus proches sont les chaînes les plus proches. Ainsi par exemple :
    • 5pf666y <-- Notre position
    • 5pf665b <-- Quelqu'un pas si éloigné de nous
    • 22rt841 <-- Position lointaine

Un petite apparté à propos de l’équivalence entre la longueur du hash et sa précision :

Longueur du geohash Lat bits Lng bits Erreur lat Erreur lng Erreur en km
1 2 3 ±23 ±23 ±2500
2 5 5 ±2.8 ±5.6 ±630
3 7 8 ±0.7 ±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.000085 ±0.00017 ±0.019

Limitations du geohash

On a parlé précédemment de la recherche de points situés à proximité les uns des autres mais dans ce cas précis, les tags de geohash ont un gros défaut ! Si vous regardez bien les schémas du début, on a des cas-limites, à savoir les frontières en effet, on peut avoir des adresses très proches qui vont avoir des geohashs totalement différents. Comme sur le schéma ci-dessous :

geohash limits

Ici, les valeurs binaires vont être totalement opposées. Ce qui entraine forcément une corrélation très faible entre le tag final obtenu et les deux points voisins. Il existe bien évidemment une solution, Un algorithme qui va calculer la valeur des geohash des huit carrés entourant notre centre dans toutes les directions. Ce qui va permettre de récupérer également les points voisins d’une frontière.

Encodage d’une position

Nous avons vu l’idée du fonctionnement de l’algorithme, passons maintenant a une partie plus technique. L’idée du geohashing, c’est la sauvegarde par intermédiaire du résultat de deux recherches binaires. Regardons ça de plus près :

private static final int MAX_BIT_PRECISION = 64;
private static final int MAX_GEOHASH_CHARACTER_PRECISION = 12;
private static final int[] BITS = { 16, 8, 4, 2, 1 };
private static final int BASE32_BITS = 5;

public static String getGeohash(Location location, int numberOfCharacters) {
	int desiredPrecision = getDesiredPrecsion(numberOfCharacters);
	GeoHash geohash = new GeoHash();

	boolean isEvenBit = true;
	double[] latitudeRange = { -90, 90 };
	double[] longitudeRange = { -180, 180 };

	while (geohash.getSignificantBits() < desiredPrecision) {
		if (isEvenBit) {
			divideRangeEncode(geohash, longitudeRange, location.lng());
		} else {
			divideRangeEncode(geohash, latitudeRange, location.lat());
		}
		isEvenBit = !isEvenBit;
	}

	geohash.leftShift(MAX_BIT_PRECISION - desiredPrecision);
	return geohash.toBase32();
}

private static int getDesiredPrecsion(int numberOfCharacters) {
	if (numberOfCharacters > MAX_GEOHASH_CHARACTER_PRECISION) {
		throw new IllegalArgumentException("A geohash can only be " + MAX_GEOHASH_CHARACTER_PRECISION + " character long.");
	}
	int desiredPrecision = 60;
	if (numberOfCharacters * 5 <= 60) {
		desiredPrecision = numberOfCharacters * 5;
	}
	return desiredPrecision = Math.min(desiredPrecision, MAX_BIT_PRECISION);
}

private static void divideRangeEncode(GeoHash geohash, double[] range, double value) {
	double mid = (range[0] + range[1]) / 2;
	if (value >= mid) {
		geohash.addOnBitToEnd();
		range[0] = mid;
	} else {
		geohash.addOffBitToEnd();
		range[1] = mid;
	}
}

public String toBase32() {
	if (significantBits % 5 != 0) {
		throw new IllegalStateException("Cannot convert a geohash to base32 if the precision is not a multiple of 5.");
	}
	StringBuilder buf = new StringBuilder();
	long firstFiveBitsMask = 0xf800000000000000l;
	int numberOfCharacters = (int) Math.ceil(((double) significantBits / 5));
	for (int i = 0; i < numberOfCharacters; i++) {
		int pointer = (int) ((bits & firstFiveBitsMask) >>> 59);
		buf.append(Constants.BASE32[pointer]);
		bits <<= 5;
	}
	return buf.toString();
}

D’abord, on choisit la taille du geohash désiré. Puis, on vérifie que cette taille ne dépasse pas 12 caractères (le nombre maximum de bits disponible). Ici, j’utilise une classe Geohash qui va nous permettre de stocker l’avancement de l’encodage (une variable bits) et la taille de ce dernier (significantBits). Tant que notre nombre de bits, n’est pas égal à la précision désirée on continue l’algorithme.

Au départ, on possède deux intervalles de référence: un pour la latitude (compris entre -90:90) et un pour la longitude (-180:180) On commence l’encodage par la longitude avec les bits pairs et on encode la latitude avec les bits impairs, à chaque tour, on va alterner. Pour chaque tour de boucle, on va augmenter ou réduire chaque range, en fonction de la valeur de la coordonnée de la position. Une fois, le tout obtenu, on va convertir les bits sous une forme de string en base 32. Attention, toutefois l’alphabet utilisé, ne contient pas les lettres suivantes: a, i, l et o.

geohash alphabet

Prenons, un exemple pour être plus clair.

Exemple d’encodage d’une position

On veut encoder la position suivante: 35.3606422, 138.7186086. Suivons l’évolution de la latitude, c’est-à-dire 35.3606422. Une latitude est toujours comprise entre -90 et 90, alors qu’une longitude est comprise entre -180 et 180.

Au premier tour de boucle, le range est donc compris entre -90 et 90. On va regarder si la valeur est supérieure ou inférieure au milieu de notre range c’est-à-dire 0. Comme c’est supérieur, on va réduire l’intervalle entre la moitié et la borne supérieure, ici: [0;90]. Dans le cas où c’est inférieur l’intervalle est réduit entre la borne inférieure et le milieu de l’intervalle. En continuant, ainsi l’algorithme, on va réussir à obtenir une précision importante.

Voici, par exemple l’intervalle obtenu après 5 passages :

desired lng: 35.3606422
current range: [-90.0;90.0]
middle interval: 0.0
new range: [0.0;90.0]
---
desired lng: 35.3606422
current range: [0.0;90.0]
middle interval: 45.0
new range: [0.0;45.0]
---
desired lng: 35.3606422
current range: [0.0;45.0]
middle interval: 22.5
new range: [22.5;45.0]
---
desired lng: 35.3606422
current range: [22.5;45.0]
middle interval: 33.75
new range: [33.75;45.0]
---
desired lng: 35.3606422
current range: [33.75;45.0]
middle interval: 39.375
new range: [33.75;39.375]

Voilà, vous devriez maintenant avoir une idée de comment encoder la position sous forme de bits.

Explication de l’algorithme de décodage d’un geohash

Pour le décodage, on va prendre l’exemple de Wikipédia qui est très clair ! On part du hash suivant: « ezs42 ». Nous allons voir comment à partir de ce hash, on va retrouver les coordonnées du lieu correspondant. Comme je vous l’ai dit pendant la phase d’encodage, on associe les bits à des caractères. Si on prend la lettre « e », par exemple, on voit que sa valeur vaut 13. Or, 13 en binaire peut s’écrire 01101 sur 5 bits. Si on décode comme ça tous le hash, on obtient les bits suivants: 01101 11111 11000 00100 00010. Comme pour l’encodage, on commence par la gauche à 0 et on alterne les bits. Ce qui nous donne, pour la longitude: 0111110000000 et pour la latitude: 101111001001.

On utilise ensuite chaque code binaire dans une série de divisions, un bit à la fois de la gauche jusqu’à la droite. On utilise toujours nos deux intervalles de départ pour la latitude (-90:90) et pour la longitude (-180:180). Si le bit est à 1, on va adapter notre intervalle à la valeur supérieure, c’est-à-dire la valeur de la moitié de l’intervalle et le maximum de l’intervalle donc par exemple pour la latitude, ça nous ferait 0:90. On répète cette procédure pour tous les bits du code.

Exemple de décodage

Pour la latitude, on part du code suivant : 101111001001. Comme le premier bit est à 1, on sait immédiatement que l’intervalle sera compris entre 0 et 90. Si on s’arrêtait ici, la latitude serait de 45, avec une erreur de ±45. En continuant de dérouler l’algorithme, on divise l’erreur et on gagne en précision. À chaque nouveau tour de boucle, l’erreur est divisée par deux.

Latitude: 101111001001

bit position bit value min mid max mean value maximum error
0 1 90.000 0.000 90.000 45.000 45.000
1 0 0.000 45.000 90.000 22.500 22.500
2 1 0.000 22.500 45.000 33.750 11.250
3 1 22.500 33.750 45.000 39.375 5.625
4 1 33.750 39.375 45.000 42.188 2.813
5 1 39.375 42.188 45.000 43.594 1.406
6 0 42.188 43.594 45.000 42.891 0.703
7 0 42.188 42.891 43.594 42.539 0.352
8 1 42.188 42.539 42.891 42.715 0.176
9 0 42.539 42.715 42.891 42.627 0.088
10 0 42.539 42.627 42.715 42.583 0.044
11 1 42.539 42.583 42.627 42.605 0.022

Longitude: 0111110000000

bit position bit value min mid max mean value maximum error
0 0 -180.000 0.000 180.000 -90.000 90.000
1 1 -180.000 -90.000 0.000 -45.000 45.000
2 1 -90.000 -45.000 0.000 -22.500 22.500
3 1 -45.000 -22.500 0.000 -11.250 11.250
4 1 -22.500 -11.250 0.000 -5.625 5.625
5 1 -11.250 -5.625 0.000 -2.813 2.813
6 0 -5.625 -2.813 0.000 -4.219 1.406
7 0 -5.625 -4.219 -2.813 -4.922 0.703
8 0 -5.625 -4.922 -4.219 -5.273 0.352
9 0 -5.625 -5.273 -4.922 -5.449 0.176
10 0 -5.625 -5.449 -5.273 -5.537 0.088
11 0 -5.625 -5.537 -5.449 -5.581 0.044
12 0 -5.625 -5.581 -5.537 -5.603 0.022

Remarque: les chiffres sont arrondis à 3 décimales pour plus de clarté.

Finalement, on retrouve la position associée au hash « ezs42 », cette dernière vaut: 42.605,-5.603. Ce qui nous amène à León en Espagne.

Décodage d’un geohash

Voici, le code correspondant à l’algorithme que je vous ai décrit :

public class Constants {
	public static final char[] BASE32 = { 
			'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' 
	};
	public static final byte[] BASE32_INV = new byte[(int) 'z' + 1];
	
	static {
		for (int i = 0; i < BASE32.length; i++) {
			BASE32_INV[(int) BASE32[i]] = (byte) i;
		}
	}
}

public static Location getLocation(String encodedGeohash) {
	double[] latitudeRange = { -90.0, 90.0 };
	double[] longitudeRange = { -180.0, 180.0 };

	boolean isEvenBit = true;
	GeoHash geohash = new GeoHash();

	for (int i = 0; i < encodedGeohash.length(); i++) {
		int characterToEncodeInBinary = Constants.BASE32_INV[encodedGeohash.charAt(i)];
		
		for (int j = 0; j < BASE32_BITS; j++) {
			int mask = BITS[j];
			boolean isBitActive = (characterToEncodeInBinary & mask) != 0;
			if (isEvenBit) {
				divideRangeDecode(geohash, longitudeRange, isBitActive);
			} else {
				divideRangeDecode(geohash, latitudeRange, isBitActive);
			}
			isEvenBit = !isEvenBit;
		}
	}
	
	double latitude = (latitudeRange[0] + latitudeRange[1]) / 2;
	double longitude = (longitudeRange[0] + longitudeRange[1]) / 2;

	return new Location(encodedGeohash, latitude, longitude);
}

private static void divideRangeDecode(GeoHash geohash, double[] range, boolean bitIsOn) {
	double mid = (range[0] + range[1]) / 2;
	if (bitIsOn) {
		geohash.addOnBitToEnd();
		range[0] = mid;
	} else {
		geohash.addOffBitToEnd();
		range[1] = mid;
	}
}

Utilisation du geohash pour le chargement de positions depuis une base de données

Imaginons maintenant que vous ayez une base de données dans laquelle vous assez sauvegardé un ensemble de lieux. Lors de l’insertion d’un nouveau lieu, vous pouvez calculer son geohash associé afin de pouvoir facilement lors du chargement d’un lieu récupérer les points les plus proches de ce dernier. Afin d’éviter les cas-limites dont on a discuté, il vous suffira de prendre votre lieu de départ, de trouver les voisins les plus proches. Si vous partez d’un point vous pouvez utiliser la formule d’Haversine, afin de retrouver la position la plus proche à une distance et à un degré donné. Vous pouvez trouver, un exemple dans ce gist.

Une fois que vous avez votre liste, il suffit de faire une requête SQL avec la fonction LIKE, rien de plus simple ! Par exemple :

private String buildGeoHashQuery(Location userLocation) {
    StringBuilder query = new StringBuilder("SELECT * FROM locations WHERE geohash LIKE ");
    List<Location> neighboursLocations = getUserLocationNeighbours(userLocation);
    for (Location location : neighboursLocations) {
        String currentGeoHash = GeoHashHelper.encode(location);
        query.append("'").append(currentGeoHash.substring(0, 4)).append("%' OR ").append("geohash").append(" LIKE ");
    }
    String userLocationGeoHash = GeoHashHelper.encodeBase(userLocation);
    query.append("'").append(userLocationGeoHash.substring(0, 4)).append("%;'");
    return query.toString();
}

Et c’est tout !!

Conclusion

J’espère avoir été le plus clair possible, si vous avez une demande concernant un point à éclaircir, il ne faut pas hésiter. Vous trouverez sur le repository suivant un exemple d’implémentation de cet algorithme, prêt à l’usage ;).

1 comment

  1. Ah Ok, pour le décodage, tu prend finalement le milieu de la bounding box !
    En tout cas c’est très clair ! Merci de ton article ça vas m’aider pour mon concours.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.