Vai al contenuto

La distanza di Levenshtein, a cosa serve? (2° parte)

Un’implementazione della distanza di Levenshtein può essere fatta in qualsiasi linguaggio. In questo esempio, verrà utilizzato interamente il linguaggio MYSQL.

Per prima cosa, bisogna creare una routine su MySql. Aiutatevi con phpmyadmin per fare ciò. Eseguite direttamente il codice definito in questo listato per creare e aggiungere la funzione levenshtein() al vostro database MySql.

 
DELIMITER ;;
CREATE FUNCTION `levenshtein`( s1 VARCHAR(255), s2 VARCHAR(255) ) RETURNS int(11)
    DETERMINISTIC
BEGIN
	DECLARE s1_len, s2_len, i, j, c, c_temp, cost INT;
	DECLARE s1_char CHAR;
	DECLARE cv0, cv1 VARBINARY(256); -- max strlen=255

	SET s1_len = CHAR_LENGTH(s1), 
		s2_len = CHAR_LENGTH(s2), 
		cv1 = 0x00, 
		j = 1, 
		i = 1, 
		c = 0;

	IF s1 = s2 THEN
		RETURN 0;
	ELSEIF s1_len = 0 THEN
		RETURN s2_len;
	ELSEIF s2_len = 0 THEN
		RETURN s1_len;
	ELSE
		WHILE j <= s2_len DO
			SET cv1 = CONCAT(cv1, UNHEX(HEX(j))), j = j + 1;
		END WHILE;
		WHILE i <= s1_len DO
			SET s1_char = SUBSTRING(s1, i, 1), c = i, cv0 = UNHEX(HEX(i)), j = 1;
			WHILE j <= s2_len DO
				SET c = c + 1;
				IF s1_char = SUBSTRING(s2, j, 1) THEN 
					SET cost = 0; ELSE SET cost = 1;
				END IF;
				SET c_temp = CONV(HEX(SUBSTRING(cv1, j, 1)), 16, 10) + cost;
				IF c > c_temp THEN 
					SET c = c_temp; 
				END IF;
				SET c_temp = CONV(HEX(SUBSTRING(cv1, j+1, 1)), 16, 10) + 1;
				IF c > c_temp THEN 
					SET c = c_temp; 
				END IF;
				SET cv0 = CONCAT(cv0, UNHEX(HEX(c))), j = j + 1;
			END WHILE;
			SET cv1 = cv0, i = i + 1;
		END WHILE;
	END IF;
	RETURN c;
END;

Se volete potete anche aggiungere quest’altra funzione, che restituisce la percentuale di esattezza della parola data.

 CREATE FUNCTION `levenshtein_ratio`( s1 VARCHAR(255), s2 VARCHAR(255) ) RETURNS int(11)
    DETERMINISTIC
BEGIN
	DECLARE s1_len, s2_len, max_len INT;
	SET s1_len = LENGTH(s1), s2_len = LENGTH(s2);

	IF s1_len > s2_len THEN 
		SET max_len = s1_len; 
	ELSE 
		SET max_len = s2_len; 
	END IF;
	RETURN ROUND((1 - LEVENSHTEIN(s1, s2) / max_len) * 100);
END

A questo punto l’unica cosa da fare è quella di creare una query per interrogare il database utilizzando in modo opportuno la funzione definita sopra.

 SELECT CityName, levenshtein("romme", CityName) AS Lev 
FROM tb_istat_cities 
WHERE levenshtein("romme", CityName) BETWEEN 0 AND 2 
ORDER BY Lev

Il risultato sarà questo:

levenshtein2

L’unico vero e grande problema è la lentezza dell’esecuzione dell’algoritmo. Per calcolare la distanza di levenshtein di “romme” su una base dati di 8,092 record, il tempo di esecuzione si aggira attorno ai 12 secondi, e peggiora in maniera esponenziale se la stringa da ricercare è più lunga (es. Castelbuono anzichè Roma). Purtroppo l’algoritmo contiene un doppio WHILE all’interno e questo potrebbe rappresentare un ostacolo, sopratutto in una tabella con migliaia di record.

Tuttavia possiamo cercare di ottimizzare la query MySql in fase di interrogazione in modo da cercare di dimezzare i tempi di ricerca. Una prima ottimizzazione è quella di selezionare solamente i termini più “vicini” a quello che stiamo cercando, confrontando la lunghezza degli stessi. Aggiungo quindi una condizione sulla lunghezza del campo CityName, che deve essere di un carattere più (+1) o meno (-1) lunga rispetto a ciò che sto cercando.

 SELECT CityName, levenshtein("romme", CityName) AS Lev 
FROM 
    (SELECT CityName FROM tb_istat_cities 
    WHERE LENGTH(CityName) BETWEEN LENGTH("romme")-1 
    AND LENGTH("romme")+1) AS MATCHES
WHERE levenshtein("romme", CityName) BETWEEN 0 AND 2 
ORDER BY Lev

 levenshtein3

Abbiamo un netto miglioramento in termini di prestazioni in fase di esecuzione: 1,17 secondi contro 12 secondi del primo! 

Esistono ovviamente altre ottimizzazioni, infatti ne ho implementata una che mi permette di raggiungere un tempo di esecuzione pari a 0,5740 secondi, ma in questo caso bisogna integrare qualche riga di codice PHP. Vi lancio la sfida di cercarne una! Qui sotto invece trovate un altra ipotesi di ottimizzazione…

[spoiler]

L’idea è quella di spezzare il nome in più parti e aggiungere un ulteriore condizione sul campo CityName in modo da filtrare e limitare i risultati velocizzando cosi l’esecuzione dell’algoritmo su un campione di dati molto più ristretto rispetto all’intera base dati.

 SELECT CityName, levenshtein("costelbuona", CityName) AS Lev 
FROM 
    (SELECT CityName FROM tb_istat_cities 
    WHERE LENGTH(CityName) BETWEEN LENGTH("costelbuona")-1 
    AND LENGTH("costelbuona")+1
    AND (CityName LIKE  '%cost%'
	OR CityName LIKE  '%oste%'
	OR CityName LIKE  '%stel%'
	OR CityName LIKE  '%telb%'
	OR CityName LIKE  '%elbu%'
	OR CityName LIKE  '%lbuo%'
	OR CityName LIKE  '%buon%'
	OR CityName LIKE  '%uona%'
	OR CityName LIKE  '%ona%'
	OR CityName LIKE  '%na%'
	OR CityName LIKE  '%a%')
    ) AS MATCHES
WHERE levenshtein("costelbuona", CityName) BETWEEN 0 AND 2 
ORDER BY Lev

[/spoiler]