web-dev-qa-db-fra.com

Implémentation d'un simple Trie pour un calcul efficace de la distance Levenshtein - Java

UPDATE 3

Terminé. Vous trouverez ci-dessous le code qui a finalement réussi tous mes tests. Là encore, ceci est calqué sur la version modifiée de l'algorithme de Steve Hanov par Murilo Vasconcelo. Merci à tous ceux qui ont aidé!

/**
 * Computes the minimum Levenshtein Distance between the given Word (represented as an array of Characters) and the
 * words stored in theTrie. This algorithm is modeled after Steve Hanov's blog article "Fast and Easy Levenshtein
 * distance using a Trie" and Murilo Vasconcelo's revised version in C++.
 * 
 * http://stevehanov.ca/blog/index.php?id=114
 * http://murilo.wordpress.com/2011/02/01/fast-and-easy-levenshtein-distance-using-a-trie-in-c/
 * 
 * @param ArrayList<Character> Word - the characters of an input Word as an array representation
 * @return int - the minimum Levenshtein Distance
 */
private int computeMinimumLevenshteinDistance(ArrayList<Character> Word) {

    theTrie.minLevDist = Integer.MAX_VALUE;

    int iWordLength = Word.size();
    int[] currentRow = new int[iWordLength + 1];

    for (int i = 0; i <= iWordLength; i++) {
        currentRow[i] = i;
    }

    for (int i = 0; i < iWordLength; i++) {
        traverseTrie(theTrie.root, Word.get(i), Word, currentRow);
    }
    return theTrie.minLevDist;
}

/**
 * Recursive helper function. Traverses theTrie in search of the minimum Levenshtein Distance.
 * 
 * @param TrieNode node - the current TrieNode
 * @param char letter - the current character of the current Word we're working with
 * @param ArrayList<Character> Word - an array representation of the current Word
 * @param int[] previousRow - a row in the Levenshtein Distance matrix
 */
private void traverseTrie(TrieNode node, char letter, ArrayList<Character> Word, int[] previousRow) {

    int size = previousRow.length;
    int[] currentRow = new int[size];
    currentRow[0] = previousRow[0] + 1;

    int minimumElement = currentRow[0];
    int insertCost, deleteCost, replaceCost;

    for (int i = 1; i < size; i++) {

        insertCost = currentRow[i - 1] + 1;
        deleteCost = previousRow[i] + 1;

        if (Word.get(i - 1) == letter) {
            replaceCost = previousRow[i - 1];
        } else {
            replaceCost = previousRow[i - 1] + 1;
        }

        currentRow[i] = minimum(insertCost, deleteCost, replaceCost);

        if (currentRow[i] < minimumElement) {
            minimumElement = currentRow[i];
        }
    }

    if (currentRow[size - 1] < theTrie.minLevDist && node.isWord) {
        theTrie.minLevDist = currentRow[size - 1];
    }

    if (minimumElement < theTrie.minLevDist) {

        for (Character c : node.children.keySet()) {
            traverseTrie(node.children.get(c), c, Word, currentRow);
        }
    }
}

UPDATE 2

Enfin, j'ai réussi à ce que cela fonctionne pour la plupart de mes cas de test. Mon implémentation est pratiquement une traduction directe de version C++ de Murilo de algorithme de Steve Hanov . Alors, comment puis-je refactoriser cet algorithme et/ou faire des optimisations? Voici le code ...

public int search(String Word) {

    theTrie.minLevDist = Integer.MAX_VALUE;

    int size = Word.length();
    int[] currentRow = new int[size + 1];

    for (int i = 0; i <= size; i++) {
        currentRow[i] = i;
    }
    for (int i = 0; i < size; i++) {
        char c = Word.charAt(i);
        if (theTrie.root.children.containsKey(c)) {
            searchRec(theTrie.root.children.get(c), c, Word, currentRow);
        }
    }
    return theTrie.minLevDist;
}
private void searchRec(TrieNode node, char letter, String Word, int[] previousRow) {

    int size = previousRow.length;
    int[] currentRow = new int[size];
    currentRow[0] = previousRow[0] + 1;

    int insertCost, deleteCost, replaceCost;

    for (int i = 1; i < size; i++) {

        insertCost = currentRow[i - 1] + 1;
        deleteCost = previousRow[i] + 1;

        if (Word.charAt(i - 1) == letter) {
            replaceCost = previousRow[i - 1];
        } else {
            replaceCost = previousRow[i - 1] + 1;
        }
        currentRow[i] = minimum(insertCost, deleteCost, replaceCost);
    }

    if (currentRow[size - 1] < theTrie.minLevDist && node.isWord) {
        theTrie.minLevDist = currentRow[size - 1];
    }

    if (minElement(currentRow) < theTrie.minLevDist) {

        for (Character c : node.children.keySet()) {
            searchRec(node.children.get(c), c, Word, currentRow);

        }
    }
}

Merci à tous ceux qui ont contribué à cette question. J'ai essayé de faire fonctionner les automates Levenshtein, mais je n'y suis pas parvenu. 

Je recherche donc des suggestions de refactoring et/ou d’optimisations concernant le code ci-dessus. S'il vous plaît laissez-moi savoir s'il y a une confusion. Comme toujours, je peux fournir le reste du code source si nécessaire.


UPDATE 1

J'ai donc implémenté une structure de données Trie simple et j'essaie de suivre le tutoriel en python de Steve Hanov pour calculer la distance de Levenshtein. En fait, je suis intéressé par le calcul de la distanceminimumLevenshtein entre un mot donné et les mots du Trie, donc j'ai suivi version de l'algorithme de Steve Hanov par Murilo Vasconcelos . Cela ne fonctionne pas très bien, mais voici ma classe de Trie:

public class Trie {

    public TrieNode root;
    public int minLevDist;

    public Trie() {
        this.root = new TrieNode(' ');
    }

    public void insert(String Word) {

        int length = Word.length();
        TrieNode current = this.root;

        if (length == 0) {
            current.isWord = true;
        }
        for (int index = 0; index < length; index++) {

            char letter = Word.charAt(index);
            TrieNode child = current.getChild(letter);

            if (child != null) {
                current = child;
            } else {
                current.children.put(letter, new TrieNode(letter));
                current = current.getChild(letter);
            }
            if (index == length - 1) {
                current.isWord = true;
            }
        }
    }
}

... et la classe TrieNode:

public class TrieNode {

    public final int ALPHABET = 26;

    public char letter;
    public boolean isWord;
    public Map<Character, TrieNode> children;

    public TrieNode(char letter) {
        this.isWord = false;
        this.letter = letter;
        children = new HashMap<Character, TrieNode>(ALPHABET);
    }

    public TrieNode getChild(char letter) {

        if (children != null) {
            if (children.containsKey(letter)) {
                return children.get(letter); 
            }
        }
        return null;
    }
}

Maintenant, j'ai essayé d'implémenter la recherche en tant que Murilo Vasconcelos l'a, mais quelque chose ne fonctionne pas et j'ai besoin d'aide pour le déboguer. Merci de donner des suggestions sur la façon de le remodeler et/ou de le localiser. La toute première chose que j'aimerais refactoriser est la variable globale "minCost", mais c'est la plus petite des choses. Quoi qu'il en soit, voici le code ...

public void search(String Word) {

    int size = Word.length();
    int[] currentRow = new int[size + 1];

    for (int i = 0; i <= size; i++) {
        currentRow[i] = i;
    }
    for (int i = 0; i < size; i++) {
        char c = Word.charAt(i);
        if (theTrie.root.children.containsKey(c)) {
            searchRec(theTrie.root.children.get(c), c, Word, currentRow);
        }
    }
}

private void searchRec(TrieNode node, char letter, String Word, int[] previousRow) {

    int size = previousRow.length;
    int[] currentRow = new int[size];
    currentRow[0] = previousRow[0] + 1;

    int replace, insertCost, deleteCost;

    for (int i = 1; i < size; i++) {

        char c = Word.charAt(i - 1);

        insertCost = currentRow[i - 1] + 1;
        deleteCost = previousRow[i] + 1;
        replace = (c == letter) ? previousRow[i - 1] : (previousRow[i - 1] + 1);

        currentRow[i] = minimum(insertCost, deleteCost, replace);
    }

    if (currentRow[size - 1] < minCost && !node.isWord) {
        minCost = currentRow[size - 1];
    }
    Integer minElement = minElement(currentRow);
    if (minElement < minCost) {

        for (Map.Entry<Character, TrieNode> entry : node.children.entrySet()) {
            searchRec(node, entry.getKey(), Word, currentRow);
        }
    }
}

Je m'excuse pour le manque de commentaires. Alors qu'est-ce que je fais mal?

POSTE INITIAL

Je lisais un article, Fast and Easy Levenshtein distance utilisant un Trie , dans l’espoir de trouver un moyen efficace de calculer le Levenshtein Distance entre deux chaînes. Mon objectif principal avec ceci est, étant donné un grand ensemble de mots, pour pouvoir trouver la distance minimale de Levenshtein entre un ou plusieurs mots d'entrée et cet ensemble de mots. 

Dans mon implémentation triviale, je calcule la distance de Levenshtein entre un mot d'entrée et l'ensemble de mots, pour chaque mot d'entrée, et renvoie le minimum. Cela fonctionne, mais ce n'est pas efficace ... 

Je cherchais des implémentations d'un Trie, en Java, et je suis tombé sur deux sources apparemment bonnes:

Cependant, ces implémentations semblent trop compliquées pour ce que j'essaie de faire. En les parcourant pour comprendre leur fonctionnement et le fonctionnement des structures de données de Trie en général, je suis devenu de plus en plus confus.

Alors, comment pourrais-je implémenter une structure de données Trie simple en Java? Mon intuition me dit que chaque TrieNode devrait stocker la chaîne qu'il représente et faire également référence aux lettres de l'alphabet, pas nécessairement à toutes les lettres. Est-ce que mon intuition est correcte?

Une fois que cela est implémenté, la tâche suivante consiste à calculer la distance de Levenshtein. J'ai lu l'exemple de code Python dans l'article ci-dessus, mais je ne parle pas Python et mon implémentation Java manque de mémoire Heap une fois que j'ai lancé la recherche récursive. Alors, comment pourrais-je calculer la distance de Levenshtein en utilisant la structure de données Trie? J'ai une implémentation triviale, calquée sur ce code source , mais elle n'utilise pas de Trie ... elle est inefficace. 

Il serait vraiment agréable de voir du code en plus de vos commentaires et suggestions. Après tout, c’est un processus d’apprentissage pour moi… Je n’ai jamais implémenté de Trie…, j’ai donc beaucoup à apprendre de cette expérience. 

Merci.

p.s. Je peux fournir n'importe quel code source si besoin est. De plus, j'ai déjà lu et essayé d'utiliser un BK-Tree comme suggéré dans le blog de Nick Johnson , mais ce n'est pas aussi efficace que je le pense ... ou peut-être que ma mise en œuvre est fausse.

37
Hristo

J'ai implémenté l'algo décrit dans l'article "Rapide et facile Levenshtein utilisant un Trie" en C++ et il est vraiment rapide. Si vous voulez (comprendre mieux le C++ que Python), je peux faire passer le code quelque part.

Edit: Je l’ai posté sur mon blog .

8
Murilo Vasconcelos

D'après ce que je peux dire, vous n'avez pas besoin d'améliorer l'efficacité de Levenshtein Distance, vous devez stocker vos chaînes dans une structure qui vous empêche d'avoir à exécuter des calculs de distance autant de fois, c'est-à-dire en réduisant l'espace de recherche.

Comme la distance de Levenshtein est une métrique, vous pouvez utiliser n’importe quel indice d’espace métrique qui tire parti de l’inégalité des triangles - vous avez mentionné les arbres BK, mais il en existe d’autres, par exemple. Arbres de points de vue, arbres de requêtes fixes, arbres bissecteurs, arbres d'approximation spatiale. Voici leurs descriptions:

Arbre Burkhard-Keller

Les nœuds sont insérés dans l’arbre comme suit: Pour le nœud racine, sélectionnez un élément arbitraire Dans l’espace; ajoutez des enfants étiquetés uniques de telle sorte que la valeur de chaque bord soit la distance entre le pivot et cet élément ; appliquez-le de manière récursive en sélectionnant l'enfant comme pivot lorsqu'un Edge déjà existe.

Arbre de requêtes fixes

Comme avec les BKT sauf que: Les éléments sont stockés Au niveau des feuilles; Chaque feuille a plusieurs éléments: Pour chaque niveau de l'arbre, le même pivot est utilisé.

Arbre Bisecteur

Chaque nœud contient deux éléments de pivot Avec leur rayon de couverture (distance maximale de Entre l'élément central et L'un quelconque de ses éléments de sous-arbre); Filtrer en deux Ensembles les éléments les plus proches de Le premier pivot et ceux qui se rapprochent le plus de Secondes et construire de manière récursive deux sous-arbres À partir de ces ensembles.

Arbre d'approximation spatiale

Au début, tous les éléments sont dans un sac. Choisissez Un élément arbitraire comme pivot; Construisez Une collection de voisins les plus proches dans un rayon de Du pivot; Placez chaque Élément restant dans le sac de l'élément Le plus proche de la collection que vous venez de construire; Forme de manière récursive un sous-arbre à partir de chaque élément De cette collection.

Arbre de point de vue

Choisissez un pivot dans l’ensemble; Calculez la distance médiane entre ce Pivot et chaque élément de l’ensemble restant; Filtrer les éléments de l'ensemble dans les sous-arbres récursifs gauche et droit tels que Ceux dont la distance est inférieure ou égale à , La forme médiane à gauche et celle plus grande à la droite.

9
Robert

Voici un exemple de Levenshtein Automata in Java . Ceux-ci seront probablement également utiles:

http://svn.Apache.org/repos/asf/lucene/dev/trunk/lucene/src/Java/org/Apache/lucene/util/automaton/http://svn.Apache.org/repos/asf/lucene/dev/trunk/lucene/src/test/org/Apache/lucene/util/automaton/

Il semble que le code expérimental Lucene soit basé sur le paquet dk.brics.automaton .

L'utilisation semble être quelque chose de similaire à ci-dessous:

LevenshteinAutomata builder = new LevenshteinAutomata(s);
Automaton automata = builder.toAutomaton(n);
boolean result1 = BasicOperations.run(automata, "foo");
boolean result2 = BasicOperations.run(automata, "bar");
3
Taylor Leese

À bien des égards, l’algorithme de Steve Hanov (présenté dans le premier article lié à la question, Distance rapide et facile de Levenshtein utilisant un Trie ), les ports de l’algorithme créé par Murilo et vous (OP), et très probablement chaque algorithme pertinent impliquant une structure de Trie ou similaire, fonctionne un peu comme un automate de Levenshtein (qui a été mentionné à plusieurs reprises ici):

Given:
       dict is a dictionary represented as a DFA (ex. trie or dawg)
       dictState is a state in dict
       dictStartState is the start state in dict
       dictAcceptState is a dictState arrived at after following the transitions defined by a Word in dict
       editDistance is an edit distance
       laWord is a Word
       la is a Levenshtein Automaton defined for laWord and editDistance
       laState is a state in la
       laStartState is the start state in la
       laAcceptState is a laState arrived at after following the transitions defined by a Word that is within editDistance of laWord
       charSequence is a sequence of chars
       traversalDataStack is a stack of (dictState, laState, charSequence) tuples

Define dictState as dictStartState
Define laState as laStartState
Push (dictState, laState, "") on to traversalDataStack
While traversalDataStack is not empty
    Define currentTraversalDataTuple as the the product of a pop of traversalDataStack
    Define currentDictState as the dictState in currentTraversalDataTuple
    Define currentLAState as the laState in currentTraversalDataTuple
    Define currentCharSequence as the charSequence in currentTraversalDataTuple
    For each char in alphabet
        Check if currentDictState has outgoing transition labeled by char
        Check if currentLAState has outgoing transition labeled by char
        If both currentDictState and currentLAState have outgoing transitions labeled by char
            Define newDictState as the state arrived at after following the outgoing transition of dictState labeled by char
            Define newLAState as the state arrived at after following the outgoing transition of laState labeled by char
            Define newCharSequence as concatenation of currentCharSequence and char
            Push (newDictState, newLAState, newCharSequence) on to currentTraversalDataTuple
            If newDictState is a dictAcceptState, and if newLAState is a laAcceptState
                Add newCharSequence to resultSet
            endIf
        endIf
    endFor
endWhile

L'algorithme de Steve Hanov et ses dérivés susmentionnés utilisent évidemment une matrice de calcul de distance Levenshtein à la place d'un automate Levenshtein formel. Assez rapide, mais un automate Levenshtein formel peut avoir ses états paramétriques (états abstraits décrivant les états concrets de l'automate) générés et utilisés pour le parcours, en contournant tout calcul d'exécution lié à la distance d'édition . Donc, il devrait être exécuté encore plus rapidement que les algorithmes susmentionnés.

Si vous (ou toute autre personne) êtes intéressé par une solution formelle d’automatisme Levenshtein , consultez LevenshteinAutomaton . Il implémente l'algorithme susmentionné basé sur des états paramétriques, ainsi qu'un algorithme pur basé sur des états concrets (décrit ci-dessus) et des algorithmes basés sur la programmation dynamique (pour la détermination de la distance d'édition et des voisins). Il est maintenu par le vôtre vraiment :).

2
Kevin

Je laisserai simplement ceci ici au cas où quelqu'un chercherait un traitement supplémentaire de ce problème:

http://code.google.com/p/oracleofwoodyallen/wiki/ApproximateStringMatching

1
spieden

Je regardais votre dernière mise à jour 3, l'algorithme semble ne pas bien fonctionner pour moi.

Voyons que vous avez ci-dessous des cas de test:

    Trie dict = new Trie();
    dict.insert("arb");
    dict.insert("area");

    ArrayList<Character> Word = new ArrayList<Character>();
    Word.add('a');
    Word.add('r');
    Word.add('c');

Dans ce cas, la distance d'édition minimale entre "arc" et dict doit être de 1, qui est la distance d'édition entre "arc" et "arb", mais vos algorithmes renverront 2 à la place. 

Je suis passé par le code ci-dessous:

        if (Word.get(i - 1) == letter) {
            replaceCost = previousRow[i - 1];
        } else {
            replaceCost = previousRow[i - 1] + 1;
        }

Au moins pour la première boucle, la lettre est l'un des caractères du mot, mais vous devez plutôt comparer les noeuds du tri, afin qu'il y ait une ligne dupliquée avec le premier caractère du mot, n'est-ce pas? chaque matrice DP a la première ligne en double. J'ai exécuté exactement le même code que vous avez mis sur la solution.

1
zdlgrj

Comme je le vois bien, vous voulez parcourir toutes les branches du trie. Ce n'est pas si difficile d'utiliser une fonction récursive. J'utilise aussi un trie dans mon algorithme k-voisin le plus proche, en utilisant le même type de fonction. Je ne connais pas Java, cependant, mais voici un pseudocode:

function walk (testitem trie)
   make an empty array results
   function compare (testitem children distance)
     if testitem = None
        place the distance and children into results
     else compare(testitem from second position, 
                  the sub-children of the first child in children,
                  if the first item of testitem is equal to that 
                  of the node of the first child of children 
                  add one to the distance (! non-destructive)
                  else just the distance)
        when there are any children left
             compare (testitem, the children without the first item,
                      distance)
    compare(testitem, children of root-node in trie, distance set to 0)
    return the results

J'espère que ça aide.

1
Folgert

La fonction walk prend un test (par exemple une chaîne indexable ou un tableau de caractères) et un tri. Un trie peut être un objet avec deux emplacements. L'un spécifiant le noeud du trie, l'autre les enfants de ce noeud. Les enfants sont aussi des essais. En python, ce serait quelque chose comme:

class Trie(object):
    def __init__(self, node=None, children=[]):
        self.node = node
        self.children = children

Ou dans LISP ...

(defstruct trie (node nil) (children nil))

Maintenant, un trie ressemble à ceci:

(trie #node None
      #children ((trie #node f
                       #children ((trie #node o
                                        #children ((trie #node o
                                                         #children None)))
                                  (trie #node u
                                        #children ((trie #node n
                                                         #children None)))))))

Maintenant, la fonction interne (que vous pouvez également écrire séparément) prend le testitem, les enfants du nœud racine de l’arborescence (dont la valeur du nœud est Aucune ou autre) et une distance initiale définie à 0.

Ensuite, nous parcourons les deux branches de l’arbre de manière récursive, en partant de gauche à droite.

1
Folgert

Mon intuition me dit que chaque TrieNode devrait stocker la chaîne qu'il représente et faire également référence aux lettres de l'alphabet, pas nécessairement à toutes les lettres. Est-ce que mon intuition est correcte?

Non, un tri ne représente pas une chaîne, il représente un ensemble de chaînes (et tous leurs préfixes). Un trie noeud mappe un caractère saisi sur un autre noeud. Donc, il devrait contenir quelque chose comme un tableau de caractères et un tableau correspondant de références TrieNode. (Peut-être pas cette représentation exacte, en fonction de l'efficacité de votre utilisation.)

1
Darius Bacon

Eh bien, voici comment je l’ai fait il y a longtemps. J'ai stocké le dictionnaire en tant que trie, qui est simplement une machine à états finis restreinte à la forme d'un arbre. Vous pouvez l’améliorer en ne faisant pas cette restriction. Par exemple, les suffixes communs peuvent simplement être un sous-arbre partagé. Vous pouvez même avoir des boucles pour capturer des éléments tels que "nation", " national "," nationaliser "," nationaliser ", ...

Gardez le tri aussi simple que possible. N'allez pas y mettre des ficelles.

Rappelez-vous que vous ne faites pas ceci pour trouver la distance entre deux chaînes données. Vous l'utilisez pour rechercher dans le dictionnaire les chaînes les plus proches d'une chaîne donnée. Le temps que cela prend dépend de la distance que vous pouvez tolérer. Pour la distance zéro, il s’agit simplement de O(n) où n est la longueur du mot. Pour une distance arbitraire, il s'agit de O(N) où N est le nombre de mots du dictionnaire.

0
Mike Dunlavey

Corrigez-moi si je me trompe, mais je pense que votre mise à jour3 a une boucle supplémentaire qui est inutile et rend le programme beaucoup plus lent:

for (int i = 0; i < iWordLength; i++) {
    traverseTrie(theTrie.root, Word.get(i), Word, currentRow);
}

Vous ne devez appeler traverseTrie qu’une seule fois car dans traverseTrie, vous parcourez déjà tout le mot. Le code devrait être seulement comme suit:

traverseTrie(theTrie.root, ' ', Word, currentRow);
0
user4980248