Como calcular a distância de Levenshtein em Java?

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:

  1. Inserção de um caractere c

  2. Exclusão de um caractere c

  3. 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:

  1. Verificadores ortográficos - detectar erros ortográficos no texto e encontrar a ortografia correta mais próxima no dicionário

  2. Detecção de plágio (consulte - IEEE Paper)

  3. Análise de DNA - encontrando similaridade entre duas seqüências

  4. 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:

  1. Substituição:

  2. 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

  3. 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] _

  4. Inserção:

  5. Insira um caractere em x para corresponder ao primeiro caractere em y, o custo dessa etapa seria um

  6. 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]) _

  7. Eliminação:

  8. Exclua o primeiro caractere de x, o custo dessa etapa seria um

  9. 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:

  1. Quando as duas Strings estão vazias, a distância entre elas é zero

  2. 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):

  1. 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]) _

  2. 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]) _

  3. 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].