Реализация простого Trie для эффективного вычисления расстояния Левенштейна - Java - PullRequest
37 голосов
/ 02 февраля 2011

ОБНОВЛЕНИЕ 3

Готово. Ниже приведен код, который наконец прошел все мои тесты. Опять же, это смоделировано после модифицированной версии алгоритма Стива Ханова Мурило Васконсело. Спасибо всем, кто помог!

/**
 * 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);
        }
    }
}

ОБНОВЛЕНИЕ 2

Наконец, мне удалось заставить это работать для большинства моих тестовых случаев. Моя реализация - это практически прямой перевод версии Мурило на C ++ Алгоритм Стива Ханова . Итак, как я должен реорганизовать этот алгоритм и / или провести оптимизацию? Ниже приведен код ...

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);

        }
    }
}

Спасибо всем, кто внес вклад в этот вопрос. Я пытался заставить работать автоматы Левенштейна, но не смог этого сделать.

Так что я ищу предложения по рефакторингу и / или оптимизации относительно приведенного выше кода. Пожалуйста, дайте мне знать, если есть путаница. Как всегда, я могу предоставить остальную часть исходного кода по мере необходимости.


ОБНОВЛЕНИЕ 1

Итак, я реализовал простую структуру данных 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;
            }
        }
    }
}

... и класс 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;
    }
}

Теперь я попытался выполнить поиск, так как Murilo Vasconcelos имеет его, но что-то не так, и мне нужна помощь в его отладке. Пожалуйста, дайте предложения о том, как реорганизовать это и / или укажите, где ошибки. Самое первое, что я хотел бы реорганизовать, - это глобальная переменная minCost, но это самая маленькая вещь. Во всяком случае, вот код ...

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);
        }
    }
}

Прошу прощения за отсутствие комментариев. Так что я делаю не так?

ПЕРВОНАЧАЛЬНЫЙ ПОЧТА

Я читал статью, Быстрое и простое расстояние Левенштейна с использованием Trie , в надежде найти эффективный способ вычисления Расстояние Левенштейна между двумя строками. Моя главная цель в этом заключается в том, чтобы, учитывая большой набор слов, найти минимальное расстояние Левенштейна между входным словом (ями) и этим набором слов.

В моей тривиальной реализации я вычисляю расстояние Левенштейна между входным словом и набором слов для каждого входного слова и возвращаю минимум. Это работает, но это не эффективно ...

Я искал реализации Trie на Java и наткнулся на два, казалось бы, хороших источника:

Однако эти реализации кажутся слишком сложными для того, что я пытаюсь сделать. Когда я читал их, чтобы понять, как они работают и как работают структуры данных Trie в целом, я только запутался.

Так как мне реализовать простую структуру данных Trie в Java? Моя интуиция подсказывает мне, что каждый TrieNode должен хранить строку, которую он представляет, а также ссылки на буквы алфавита, не обязательно все буквы. Правильна ли моя интуиция?

Как только это будет реализовано, следующая задача - вычислить расстояние Левенштейна. Я прочитал пример кода Python в статье выше, но я не говорю на Python, и моей реализации Java не хватает памяти кучи, как только я запускаю рекурсивный поиск. Итак, как бы я вычислил расстояние Левенштейна, используя структуру данных Trie? У меня есть тривиальная реализация, смоделированная после этого исходного кода , но он не использует Trie ... он неэффективен.

Было бы очень приятно увидеть код в дополнение к вашим комментариям и предложениям.В конце концов, это процесс обучения для меня ... Я никогда не использовал Trie ... поэтому у меня есть чему поучиться на этом опыте.

Спасибо.

ps Я могу предоставить любой исходный код, если это будет необходимо.Кроме того, я уже прочитал и попробовал использовать BK-Tree, как предложено в блоге Ника Джонсона , но это не так эффективно, как я думаю, что это может быть ... или, возможно, моя реализация неверна.

Ответы [ 11 ]

10 голосов
/ 02 февраля 2011

Из того, что я могу вам сказать, вам не нужно повышать эффективность расстояния Левенштейна, вам нужно хранить строки в структуре, которая избавит вас от необходимости многократно выполнять вычисления расстояния, т. Е. Сокращая пространство поиска.

Поскольку расстояние Левенштейна является метрикой, вы можете использовать любой из индексов метрических пространств, которые используют неравенство треугольника - вы упомянули BK-деревья, но есть и другие, например.Деревья точек обзора, Деревья с фиксированными запросами, Деревья бисекторов, Деревья пространственной аппроксимации.Вот их описания:

Дерево Буркхарда-Келлера

Узлы вставляются в дерево следующим образом: Для корневого узла выберите произвольный элемент из пространства;добавить уникальные дочерние ребра с метками ребер, чтобы значение каждого ребра было расстоянием от оси до этого элемента;применять рекурсивно, выбирая дочерний элемент в качестве стержня, когда ребро уже существует.

Дерево фиксированных запросов

Как и в случае с BKT, за исключением того, что элементы хранятся на листьях;Каждый лист имеет несколько элементов;Для каждого уровня дерева используется один и тот же стержень.

Дерево деления на секторы

Каждый узел содержит два элемента стержня с радиусом покрытия (максимальное расстояние между центральным элементом илюбой из его элементов поддерева);Отфильтруйте в два набора те элементы, которые находятся ближе всего к первому стержню, и те, которые ближе всего ко второму, и рекурсивно создайте два поддерева из этих наборов.

Дерево пространственного приближения

Изначально все элементы находятся в сумке;Выберите произвольный элемент, который будет стержнем;Создайте коллекцию ближайших соседей в радиусе действия разворота;Положите каждый оставшийся элемент в сумку ближайшего к нему элемента из только что построенной коллекции;Рекурсивно сформируйте поддерево из каждого элемента этой коллекции.

Дерево точек зрения

Выберите поворотную точку из набора абитрально;Рассчитайте медианное расстояние между этой опорой и каждым элементом оставшегося набора;Отфильтруйте элементы из набора в левые и правые рекурсивные поддеревья так, чтобы те, у которых расстояния меньше или равны медиане, образовывали левую, а большие - правую.

9 голосов
/ 02 февраля 2011

Я реализовал алгоритм, описанный в статье «Быстрое и простое расстояние Левенштейна с помощью Trie» в C ++, и это действительно быстро.Если хотите (лучше понимаете C ++, чем Python), я могу где-нибудь вставить этот код.

Редактировать: Я разместил его в своем блоге .

3 голосов
/ 02 февраля 2011

Вот пример автоматов Левенштейна в Java (РЕДАКТИРОВАТЬ: перемещено в github ). Возможно, они также будут полезны:

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/

РЕДАКТИРОВАТЬ: Вышеперечисленные ссылки, кажется, перешли на github:

https://github.com/apache/lucene-solr/tree/master/lucene/core/src/java/org/apache/lucene/util/automaton https://github.com/apache/lucene-solr/tree/master/lucene/core/src/test/org/apache/lucene/util/automaton

Это похоже на экспериментальный код Luceneоснован на пакете dk.brics.automaton .

Использование выглядит примерно так:

LevenshteinAutomata builder = new LevenshteinAutomata(s);
Automaton automata = builder.toAutomaton(n);
boolean result1 = BasicOperations.run(automata, "foo");
boolean result2 = BasicOperations.run(automata, "bar");
2 голосов
/ 02 июля 2016

Во многих отношениях алгоритм Стива Ханова (представлен в первой статье, связанной с вопросом, Быстрое и простое расстояние Левенштейна с использованием Три ), порты алгоритма, сделанные Мурило и вами (ОП)и вполне возможно, что каждый соответствующий алгоритм, использующий три или аналогичную структуру, функционирует во многом так же, как и автомат Левенштейна (который упоминался здесь несколько раз):

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

Алгоритм Стива Ханова и его вышеупомянутые производные, очевидно, используютМатрица вычисления расстояния Левенштейна вместо формального автомата Левенштейна. Довольно быстро, но формальный автомат Левенштейна может иметь свои параметрические состояния (абстрактные состояния, которые описывают конкретные состояния автомата) , сгенерированные и используемые для обхода, обходя любые вычисления времени выполнения, связанные с расстоянием редактирования вообще,Таким образом, он должен работать даже быстрее, чем вышеупомянутые алгоритмы.

Если вы (или кто-либо еще) заинтересованы в формальном решении Левенштейновского автомата , взгляните на LevenshteinAutomaton .Он реализует вышеупомянутый алгоритм на основе параметрического состояния, а также алгоритм на основе чистого обхода конкретного состояния (обрисован в общих чертах выше) и алгоритмы на основе динамического программирования (как для редактирования расстояния, так и для определения соседей).Это поддерживается вашими по-настоящему :).

1 голос
/ 13 ноября 2014

Я смотрел на ваше последнее обновление 3, алгоритм, кажется, не работает для меня.

Давайте посмотрим, у вас есть тестовые примеры ниже:

    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');

В этом случае минимальное расстояние редактирования между "arc" и dict должно быть 1, что является расстоянием редактирования между "arc" и "arb", но вместо этого вы получите 2 алгоритма.

Я прошел следующий фрагмент кода:

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

По крайней мере, для первого цикла буква является одним из символов в слове, но вместо этого вы должны сравнить узлы в дереве, поэтому будет одна строка, дублирующаяся с первым символом в слове, это так? каждая матрица DP имеет первую строку как дубликат. Я выполнил тот же код, который вы указали в решении.

1 голос
/ 24 марта 2012

Я просто оставлю это здесь, на случай, если кто-то ищет еще одну обработку этой проблемы:

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

1 голос
/ 04 февраля 2011

Функция walk принимает тестовый элемент (например, индексируемую строку или массив символов) и три.Три может быть объектом с двумя слотами.Один задает узел дерева, другой - дочерние узлы этого узла.Дети тоже стараются.В python это было бы что-то вроде:

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

Или в Лиспе ...

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

Теперь дерево выглядит примерно так:внутренняя функция (которую вы также можете написать отдельно) принимает тестовый элемент, дочерние элементы корневого узла дерева (значение узла которого равно None или что-либо еще), а начальное расстояние устанавливается равным 0.

Затем мы просто рекурсивно пересекаем обе ветви дерева, начиная слева и затем направо.

1 голос
/ 04 февраля 2011

Как я понимаю, вы хотите перебрать все ветви дерева. Это не так сложно, используя рекурсивную функцию. Я также использую trie в своем алгоритме k-ближайшего соседа, используя такую ​​же функцию. Я не знаю Java, однако, вот некоторый псевдокод:

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

Надеюсь, это поможет.

1 голос
/ 02 февраля 2011

Моя интуиция подсказывает мне, что каждый TrieNode должен хранить строку, которую он представляет, а также ссылки на буквы алфавита, а не обязательно на все буквы.Моя интуиция верна?

Нет, три не представляет строку, она представляет набор строк (и все их префиксы).Три-узел отображает входной символ в другой три-узел.Таким образом, он должен содержать что-то вроде массива символов и соответствующего массива ссылок TrieNode.(Может быть, это не совсем точное представление, в зависимости от эффективности вашего конкретного использования.)

0 голосов
/ 06 июня 2015

Поправьте меня, если я ошибаюсь, но я считаю, что ваше обновление 3 имеет дополнительный цикл, который не нужен и делает программу намного медленнее:

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

Вы должны вызывать traverseTrie только один раз, потому что внутри traverseTrie вы уже перебираете все слово. Код должен быть только следующим:

traverseTrie(theTrie.root, ' ', word, currentRow);
...