Como calcular a distância de Levenshtein em Java?
*1. Introdução *
Neste artigo, descrevemos a distância de Levenshtein, também conhecida como distância de edição. O algoritmo explicado aqui foi desenvolvido por um cientista russo, Vladimir Levenshtein, em 1965.
Forneceremos uma implementação Java iterativa e recursiva desse algoritmo.
===* 2. Qual é a distância de Levenshtein? *
A distância de Levenshtein é uma medida de dissimilaridade entre duas Strings. Matematicamente, dadas duas Strings x e y, a distância mede o número mínimo de edições de caracteres necessárias para transformar x em y.
Normalmente, três tipos de edições são permitidos:
-
Inserção de um caractere c
-
Exclusão de um caractere c
-
Substituição de um caractere c por c ‘
Exemplo:* Se x = 'shot' e y =' spot', a distância de edição entre os dois é 1 porque 'shot' pode ser convertido em 'spot' substituindo 'h' por 'p' . *
Em certas subclasses do problema, o custo associado a cada tipo de edição pode ser diferente.
Por exemplo, menor custo para substituição por um caractere localizado próximo ao teclado e mais custo caso contrário. Para simplificar, consideraremos todos os custos iguais neste artigo.
Algumas das aplicações da distância de edição são:
-
Verificadores ortográficos - detectar erros ortográficos no texto e encontrar a ortografia correta mais próxima no dicionário
-
Detecção de plágio (consulte - IEEE Paper)
-
Análise de DNA - encontrando similaridade entre duas seqüências
-
Reconhecimento de fala (consulte - Microsoft Research)
===* 3. Formulação de algoritmos *
Vamos pegar duas Strings x e y de comprimentos m e n respectivamente. Podemos denotar cada String como x [1: m] _ e _y [1: n] .
Sabemos que, no final da transformação, as duas Strings terão o mesmo comprimento e terão caracteres correspondentes em cada posição. Portanto, se considerarmos o primeiro caractere de cada _String, _ teremos três opções:
-
Substituição:
-
Determine o custo (D1) de substituir _x [1] _ por _y [1] _. O custo desta etapa seria zero se os dois caracteres forem iguais. Caso contrário, o custo seria um
-
Após a etapa 1.1, sabemos que ambas as Strings começam com o mesmo caractere. Portanto, o custo total seria agora a soma do custo da etapa 1.1 e o custo de transformar o restante da _String x [2: m] _ em _y [2: n] _
-
Inserção:
-
Insira um caractere em x para corresponder ao primeiro caractere em y, o custo dessa etapa seria um
-
Após o 2.1, processamos um caractere de y. Portanto, o custo total seria agora a soma do custo da etapa 2.1 (ou seja, 1) e o custo de transformar o total _x [1: m] _ no restante _y (y [2: n]) _
-
Eliminação:
-
Exclua o primeiro caractere de x, o custo dessa etapa seria um
-
Após a versão 3.1, processamos um caractere de x, mas o y completo ainda precisa ser processado. O custo total seria a soma do custo de 3,1 (ou seja, 1) e o custo de transformar x restante no total y
A próxima parte da solução é descobrir qual opção escolher dentre essas três. Como não sabemos qual opção levaria a um custo mínimo no final, devemos tentar todas as opções e escolher a melhor.
===* 4. Implementação recursiva ingênua *
Podemos ver que o segundo passo de cada opção na seção 3 é basicamente o mesmo problema de distância de edição, mas nas sub-strings das Strings. originais. Isso significa que após cada iteração, terminamos com o mesmo problema, mas com Strings. menores.
Esta observação é a chave para formular um algoritmo recursivo. A relação de recorrência pode ser definida como:
D (x [1: m], y [1: n]) _ _ = min \ {
_D (x [2: m], y [2: n]) + Custo de substituição de x [1] para y [1], _
_D (x [1: m], y [2: n]) + 1, _
D (x [2: m], y [1: n]) + 1
}
Também devemos definir casos base para o nosso algoritmo recursivo, que no nosso caso é quando uma ou as duas Strings ficam vazias:
-
Quando as duas Strings estão vazias, a distância entre elas é zero
-
Quando uma das Strings está vazia, a distância de edição entre elas é o comprimento da outra _String, _ já que precisamos de muitos números de inserções/exclusões para transformar uma na outra:
-
Exemplo: se um String for _ "dog" _ e outro String for _ "" _ (vazio), precisaremos de três inserções em String vazio para torná-lo _ "dog" _, ou precisaremos de três exclusões em _ "dog" _ para torná-lo vazio. Portanto, a distância de edição entre eles é 3
-
Uma implementação recursiva ingênua desse algoritmo:
public class EditDistanceRecursive {
static int calculate(String x, String y) {
if (x.isEmpty()) {
return y.length();
}
if (y.isEmpty()) {
return x.length();
}
int substitution = calculate(x.substring(1), y.substring(1))
+ costOfSubstitution(x.charAt(0), y.charAt(0));
int insertion = calculate(x, y.substring(1)) + 1;
int deletion = calculate(x.substring(1), y) + 1;
return min(substitution, insertion, deletion);
}
public static int costOfSubstitution(char a, char b) {
return a == b ? 0 : 1;
}
public static int min(int... numbers) {
return Arrays.stream(numbers)
.min().orElse(Integer.MAX_VALUE);
}
}
*Esse algoritmo possui complexidade exponencial.* Em cada etapa, dividimos em três chamadas recursivas, criando uma complexidade _O (3 ^ n) _.
Na próxima seção, veremos como melhorar isso.
*5. Abordagem de programação dinâmica *
Ao analisar as chamadas recursivas, observamos que os argumentos para subproblemas são sufixos das Strings. originais. Isso significa que só pode haver m* n chamadas recursivas únicas (onde m e n são vários sufixos de x e y) . Portanto, a complexidade da solução ótima deve ser quadrática, _O (m * n) _.
Vamos examinar alguns dos subproblemas (de acordo com a relação de recorrência definida na seção # 4):
-
Os subproblemas de _D (x [1: m], y [1: n]) _ são _D (x [2: m], y [2: n]), D (x [1: m], y [ 2: n]) _ e _D (x [2: m], y [1: n]) _
-
Os subproblemas de _D (x [1: m], y [2: n]) _ são _D (x [2: m], y [3: n]), D (x [1: m], y [ 3: n]) _ e _D (x [2: m], y [2: n]) _
-
Os subproblemas de _D (x [2: m], y [1: n]) _ são _D (x [3: m], y [2: n]), D (x [2: m], y [ 2: n]) _ e _D (x [3: m], y [1: n]) _
Nos três casos, um dos subproblemas é D (x [2: m], y [2: n]) . Em vez de calcular isso três vezes como fazemos na implementação ingênua, podemos calcular isso uma vez e reutilize o resultado sempre que necessário novamente.
Esse problema tem muitos subproblemas sobrepostos, mas se soubermos a solução para os subproblemas, podemos encontrar facilmente a resposta para o problema original. Portanto, temos as duas propriedades necessárias para a formulação de uma solução de programação dinâmica, por exemplo, Subproblemas sobrepostos e https://en.wikipedia.org/wiki/Subestrutura ideal [Subestrutura ótima].
Podemos otimizar a implementação ingênua introduzindo memoization, isto é, armazene o resultado dos subproblemas em uma matriz e reutilize os resultados armazenados em cache.
Como alternativa, também podemos implementar isso iterativamente usando uma abordagem baseada em tabela:
static int calculate(String x, String y) {
int[][] dp = new int[x.length() + 1][y.length() + 1];
for (int i = 0; i <= x.length(); i++) {
for (int j = 0; j <= y.length(); j++) {
if (i == 0) {
dp[i][j] = j;
}
else if (j == 0) {
dp[i][j] = i;
}
else {
dp[i][j] = min(dp[i - 1][j - 1]
+ costOfSubstitution(x.charAt(i - 1), y.charAt(j - 1)),
dp[i - 1][j] + 1,
dp[i][j - 1] + 1);
}
}
}
return dp[x.length()][y.length()];
}
*Este algoritmo tem desempenho significativamente melhor que a implementação recursiva. No entanto, isso envolve um consumo significativo de memória.*
Isso pode ser otimizado ainda mais, observando que precisamos apenas do valor de três células adjacentes na tabela para encontrar o valor da célula atual.
6. Conclusão
Neste artigo, descrevemos o que é a distância de Levenshtein e como ela pode ser calculada usando uma abordagem recursiva e uma programação dinâmica.
A distância de Levenshtein é apenas uma das medidas de similaridade de cadeias, algumas das outras métricas são Cosine Similarity (que usa uma abordagem baseada em token e considera as cadeias como vetores) , Dice Coefficient, etc.
Como sempre, a implementação completa de exemplos pode ser encontrada https://github.com/eugenp/tutorials/tree/master/algorithms-misc Miscellaneous-1[over no GitHub].