Comment calculer la distance de Levenshtein en Java?

1. Introduction

Dans cet article, nous décrivons la distance de Levenshtein, également appelée distance de modification. L’algorithme expliqué ici a été conçu par un scientifique russe, Vladimir Levenshtein, en 1965.

Nous allons fournir une implémentation Java itérative et récursive de cet algorithme.

2. Quelle est la distance de Levenshtein?

La distance de Levenshtein est une mesure de la dissimilarité entre deux chaînes. Mathématiquement, étant donné deux chaînes et xy, la distance mesure le nombre minimal de modifications de caractères nécessaires pour transformer x en y .

Trois types de modifications sont généralement autorisés:

  1. Insertion d’un personnage c

  2. Suppression d’un caractère c

  3. Substitution d’un caractère c par c '

Exemple: Si x = 'shot' et y =' spot' , la distance d’édition entre les deux est égale à 1 car 'shot' peut être converti en 'spot' en remplaçant ' h ' par ' p ' .

Dans certaines sous-classes du problème, le coût associé à chaque type de modification peut être différent.

Par exemple, moins de coûts de substitution avec un caractère situé à proximité du clavier et plus de coût autrement. Par souci de simplicité, nous considérerons tous les coûts comme égaux dans cet article.

Certaines des applications de distance d’édition sont:

  1. Vérificateurs d’orthographe - détecter les fautes d’orthographe dans le texte et trouver le

orthographe correcte la plus proche dans le dictionnaire . Détection de plagiat (voir -

IEEE Paper ) . Analyse d’ADN - trouver une similarité entre deux séquences

  1. Reconnaissance vocale (voir -

3. Formulation d’algorithme

Prenons deux Strings x et y de longueurs m et n respectivement.

Nous pouvons désigner chaque chaîne comme x[1: m] et y[1: n].

Nous savons qu’à la fin de la transformation, les deux chaînes seront de même longueur et auront des caractères identiques à chaque position. Donc, si nous considérons le premier caractère de chaque String, nous avons trois options:

  1. Substitution:

    1. Déterminez le coût ( D1 ) de la substitution de x[1] par y[1] . le

le coût de cette étape serait nul si les deux caractères sont identiques. Si non, alors le coût serait un .. Après l’étape 1.1, nous savons que les deux chaînes commencent par le même

personnage. Par conséquent, le coût total serait maintenant la somme du coût de l’étape 1.1 et le coût de la transformation du reste de la String x[2: m] en y[2: n] . Insertion:

  1. Insérer un caractère dans x pour correspondre au premier caractère de y , le

le coût de cette étape serait un .. Après 2.1, nous avons traité un caractère de y . D’où le total

le coût serait maintenant la somme du coût de l’étape 2.1 (c’est-à-dire 1) et du coût de transformer le x[1: m] complet en y restant (y[2: n]) . Effacement:

  1. Supprimer le premier caractère de x , le coût de cette étape serait

un .. Après 3.1, nous avons traité un caractère de x , mais le y complet

reste à traiter. Le coût total serait la somme du coût de 3,1 (c’est-à-dire 1) et du coût de la transformation complète du x en entier y

La partie suivante de la solution consiste à déterminer quelle option choisir parmi ces trois options. Comme nous ne savons pas quelle option entraînerait un coût minimum à la fin, nous devons essayer toutes les options et choisir la meilleure.

4. Mise en œuvre naïve récursive

Nous pouvons voir que la deuxième étape de chaque option de la section 3 correspond essentiellement au même problème de distance d’édition, mais aux sous-chaînes de la chaîne Strings. originale. Cela signifie qu’après chaque itération, nous nous retrouvons avec le même problème mais avec des chaînes plus petites, Strings.

Cette observation est la clé pour formuler un algorithme récursif. La relation de récurrence peut être définie comme suit:

D (x[1: m], y[1: n]) = min \ {

D (x[2: m], y[2: n]) Coût du remplacement de x[1]par y[1],

D (x[1: m], y[2: n]) 1,

D (x[2: m], y[1: n]) 1

}

Nous devons également définir des cas de base pour notre algorithme récursif, qui dans notre cas correspond au moment où l’une des chaînes ou les deux deviennent vides

  1. Lorsque les deux chaînes sont vides, leur distance est égale à zéro

  2. Quand l’une des chaînes est vide, la distance d’édition entre

eux est la longueur de l’autre String, car nous avons besoin de tant de nombres d’insertions/suppressions pour transformer l’un en l’autre:

  • Exemple: si un Chaine est un «chien» et un autre Chaine est «»

(vide), nous avons besoin soit de trois insertions dans String vide pour le rendre «chien» , soit de trois suppressions dans «chien» pour le rendre vide. Par conséquent, la distance d’édition entre eux est de 3

Une implémentation récursive naïve de cet algorithme:

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);
    }
}
  • Cet algorithme a la complexité exponentielle. ** A chaque étape, nous nous séparons en trois appels récursifs, créant une complexité O (3 ^ n) .

Dans la section suivante, nous verrons comment améliorer cela.

5. Approche de programmation dynamique

En analysant les appels récursifs, nous observons que les arguments des sous-problèmes sont des suffixes de la chaîne Strings. originale. Cela signifie qu’il ne peut y avoir que des appels récursifs uniques m n (où m et n représentent un nombre de suffixes de x et y ) . La complexité de la solution optimale doit donc être quadratique, O (m n) .

Regardons quelques-uns des sous-problèmes (selon la relation de récurrence définie dans la section 4):

  1. Les sous-problèmes de D (x[1: m], y[1: n]) sont __D (x[2: m], y[2: n]), D (x[1: m],

y[2: n]) et D (x[2: m], y[1: n]) . Les sous-problèmes de D (x[1: m], y[2: n]) sont D (x[2: m], y[3: n]), D (x[1: m],

y[3: n]) et D (x[2: m], y[2: n]) . Les sous-problèmes de D (x[2: m], y[1: n]) sont D (x[3: m], y[2: n]), D (x[2: m],

y[2: n]) et D (x[3: m], y[1: n]) __

Dans les trois cas, l’un des sous-problèmes est D (x[2: m], y[2: n]) . Au lieu de calculer cela trois fois, comme nous le faisons dans l’implémentation naïve, nous pouvons le calculer une fois et réutiliser le résultat chaque fois que nécessaire.

Ce problème comporte de nombreux sous-problèmes qui se chevauchent, mais si nous connaissons la solution, nous pouvons facilement trouver la réponse au problème initial. Par conséquent, nous avons les deux propriétés nécessaires pour formuler une solution de programmation dynamique, à savoir, https://en.wikipedia.org/wiki/Overlapping subproblems[Overlapping Sub-Problems]et https://en.wikipedia.org/wiki/Optimal substructure[Sous-structure optimale].

Nous pouvons optimiser l’implémentation naïve en introduisant memoization , c’est-à-dire en enregistrant le résultat des sous-problèmes dans un tableau et en réutilisant les résultats mis en cache.

Alternativement, nous pouvons également implémenter cela de manière itérative en utilisant une approche basée sur des tables:

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()];
}
  • Cet algorithme fonctionne nettement mieux que la mise en œuvre récursive. Cependant, cela implique une consommation de mémoire importante. **

Cela peut encore être optimisé en observant que nous avons seulement besoin de la valeur de trois cellules adjacentes dans le tableau pour trouver la valeur de la cellule actuelle.

6. Conclusion

Dans cet article, nous avons décrit ce qu’est la distance de Levenshtein et comment elle peut être calculée à l’aide d’une approche récursive et basée sur la programmation dynamique.

La distance de Levenshtein n’est qu’une des mesures de la similarité des chaînes. Certaines des autres métriques sont https://en.wikipedia.org/wiki/Cosine similarity[Cosine Similarity](qui utilise une approche basée sur des jetons et considère les chaînes comme des vecteurs) , https://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice coefficient[Dice Coefficient], etc.

Comme toujours, vous trouverez la mise en œuvre complète des exemples à l’adresse https://github.com/eugenp/tutorials/tree/master/algorithms-misc Miscellaneous-1[over sur GitHub].