实现一个简单的Trie,用于高效的Levenshtein距离计算 - Java

时间:2011-02-01 23:01:30

标签: java algorithm performance trie levenshtein-distance

更新3

完成。下面是最终通过我所有测试的代码。再次,这是模仿Murilo Vasconcelo的Steve Hanov算法的修改版本。感谢所有帮助!

/**
 * 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

最后,我设法让大多数测试用例都能正常工作。我的实施实际上是Murilo's C++ version Steve Hanov's algorithm的直接翻译。那么我该如何重构这个算法和/或进行优化呢?以下是代码......

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

        }
    }
}

感谢所有为此问题做出贡献的人。我试着让Levenshtein Automata工作,但我无法实现。

所以我正在寻找有关上述代码的重构和/或优化的建议。如果有任何混淆,请告诉我。与往常一样,我可以根据需要提供其余的源代码。


更新1

所以我实现了一个简单的Trie数据结构,我一直在尝试按照Steve Hanov的python教程来计算Levenshtein距离。实际上,我有兴趣计算给定单词和Trie中单词之间的 最小 Levenshtein距离,因此我一直关注Murilo Vasconcelos's version of Steve Hanov's algorithm。这不是很好,但这是我的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);
        }
    }
}

我为缺乏评论而道歉。那么我做错了什么?

INITIAL POST

我一直在阅读一篇文章Fast and Easy Levenshtein distance using a Trie,希望找到一种有效的方法来计算两个字符串之间的Levenshtein Distance。我的主要目标是,在一大堆单词的情况下,能够找到输入单词和这组单词之间的最小Levenshtein距离。

在我的简单实现中,我为每个输入单词计算输入单词和单词集之间的Levenshtein距离,并返回最小值。它有效,但效率不高......

我一直在寻找Java中Trie的实现,而且我遇到了两个看似很好的资源:

但是,这些实现对于我正在尝试的内容来说似乎太复杂了。正如我一直在阅读它们以了解它们如何工作以及Trie数据结构如何工作一般,我只会变得更加困惑。

那么我如何在Java中实现一个简单的Trie数据结构?我的直觉告诉我每个TrieNode应该存储它所代表的String,并且还引用字母表中的字母,而不是所有字母。我的直觉是否正确?

一旦实现,下一个任务是计算Levenshtein距离。我在上面的文章中阅读了Python代码示例,但我不会说Python,而且一旦我进行了递归搜索,我的Java实现就会耗尽堆内存。那么如何使用Trie数据结构计算Levenshtein距离?我有一个简单的实现,模仿this source code,但它没有使用Trie ...它是低效的。

除了您的意见和建议之外,还可以看到一些代码。毕竟,这对我来说是一个学习过程......我从来没有实现过Trie ......所以我有很多东西要学习这个经验。

感谢。

P.S。如果需要,我可以提供任何源代码。此外,我已经阅读并尝试使用Nick Johnson's blog中建议的BK树,但它不如我认为的那样有效......或者我的实现可能是错误的。

11 个答案:

答案 0 :(得分:9)

据我所知,你不需要提高Levenshtein Distance的效率,你需要将你的字符串存储在一个结构中,这样你就不需要多次运行距离计算,即修剪搜索空间。

由于Levenshtein距离是一个度量,你可以使用任何利用三角不等式的度量空间索引 - 你提到了BK-Trees,但还有其他例如。 Vantage Point Trees,Fixed-Queries Tree,Bisector Trees,Spatial Approximation Trees。以下是他们的描述:

Burkhard-Keller树

节点按如下方式插入树中: 对于根节点选择一个仲裁元素 来自太空;添加独特的边缘标记 孩子们每个边缘的价值都是 从枢轴到那个的距离 元件;递归申请,选择 当一个边缘已经是孩子作为枢轴 存在。

固定查询树

与BKT一样,除了:存储元素 在叶子;每片叶子都有多个元素; 对于树的每个级别,相同的枢轴是 使用

比特树

每个节点包含两个枢轴元素 覆盖半径(最大值) 中心元素和 任何子树元素);过滤成两个 设置最接近的元素 第一个枢轴和最接近的那个 第二,递归建立两个子树 从这些集合。

空间逼近树

最初所有元素都放在包里;选择 作为枢轴的任意元素;建立 内部最近邻居的集合 枢轴范围;把剩下的每个 元素放入最近的包里 刚刚建成的集合中的元素; 递归地形成每个子树 这个系列的元素。

Vantage Point Tree

从套装中选择一个支点; 计算它之间的中间距离 枢轴和剩下的每个元素 组;将元素从集合中过滤到左侧 和右递归子树这样的 距离小于或等于的人 左边的中位数和左边的更大 形成权利。

答案 1 :(得分:8)

我已经实现了在C ++中使用Trie“快速简便的Levenshtein距离”描述的算法,它真的很快。如果你想要(比Python更好地理解C ++),我可以在某个地方通过代码。

修改 我将其发布在blog上。

答案 2 :(得分:3)

以下是Levenshtein Automata in Java的示例。这些可能也会有所帮助:

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/

看起来实验性的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");

答案 3 :(得分:2)

在很多方面,Steve Hanov的算法(在问题中链接的第一篇文章中提出,Fast and Easy Levenshtein distance using a Trie),由Murilo和你(OP)制作的算法的端口,以及很可能涉及到的每个相关算法Trie或类似的结构,功能很像Levenshtein Automaton(这里已多次提到):

func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    ... 
    let headerViewGesture = UITapGestureRecognizer(target:self, action:#selector(MyClass.headerViewTap(_:)))
    headerView.addGestureRecognizer(mainViewGesture)
    ...
}

func headerViewTap(recognizer: UITapGestureRecognizer) {
    if recognizer.state == UIGestureRecognizerState.Recognized
    { 
       let touchLocation = recognizer.locationInView(recognizer.view)
       print(touchLocation)
       if CGRectContainsPoint(myButton.frame, touchLocation) {
            print("button was touched")
       }
    }
}
史蒂夫汉诺夫的算法及其上述衍生物显然使用Levenshtein距离计算矩阵代替正式的Levenshtein自动机。 非常快,但正式的Levenshtein自动机可以有其参数状态(抽象状态描述自动机的具体状态)生成并用于遍历,绕过任何与编辑距离相关的运行时计算任何。因此,它应该比上述算法运行得更快。

如果您(或其他任何人)对正式的Levenshtein自动机解决方案感兴趣,请查看LevenshteinAutomaton。它实现了上述基于参数状态的算法,以及基于纯混凝土状态遍历的​​算法(如上所述)和基于动态编程的算法(用于编辑距离和邻居确定)。它由你真正维护:)。

答案 4 :(得分:1)

  

我的直觉告诉我每个TrieNode应该存储它所代表的String,并且还引用字母表中的字母,而不是所有字母。我的直觉是否正确?

不,trie不表示String,它表示一组字符串(及其所有前缀)。 trie节点将输入字符映射到另一个trie节点。所以它应该包含类似字符数组和相应的TrieNode引用数组。 (可能不是那种确切的表示,取决于您特定使用它的效率。)

答案 5 :(得分:1)

正如我所看到的那样,你想要遍历特里的所有分支。使用递归函数并不困难。我在使用相同类型的函数的k近邻算法中也使用了trie。我不知道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

希望它有所帮助。

答案 6 :(得分:1)

函数walk使用testitem(例如可索引字符串或字符数组)和trie。 trie可以是具有两个槽的对象。一个指定trie的节点,另一个指定该节点的子节点。孩子们也在尝试。在python中它将是:

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

或者在Lisp ......

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

现在trie看起来像这样:

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

现在内部函数(你也可以单独编写)接受testitem,树的根节点的子节点(节点值为None或其他),初始距离设置为0。

然后我们只是递归遍历树的两个分支,从左到右开始。

答案 7 :(得分:1)

我会把这个放在这里以防万一有人正在寻找另一种解决这个问题的方法:

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

答案 8 :(得分:1)

我正在查看您的最新更新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"与字典之间的最小编辑距离应为1,这是"arc""arb"之间的编辑距离,但您的算法将返回2。

我浏览了以下代码:

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

至少对于第一个循环,字母是单词中的一个字符,而是应该比较trie中的节点,因此将有一行与单词中的第一个字符重复,是那对吗?每个DP矩阵的第一行都是重复的。我执行了与解决方案完全相同的代码。

答案 9 :(得分:0)

很久以前,here's how I did it很久以前。 我将字典存储为trie,它只是一种限制为树形式的有限状态机。 您可以通过不限制来增强它。 例如,常见后缀可以简单地是共享子树。 你甚至可以拥有循环,捕捉“民族”,“国家”,“国有化”,“国有化”等内容......

让trie尽可能绝对简单。不要在其中填充字符串。

请记住,您不要这样做以找到两个给定字符串之间的距离。您可以使用它来查找字典中最接近一个给定字符串的字符串。花费的时间取决于你能忍受的levenshtein距离。对于距离零,它只是O(n),其中n是字长。对于任意距离,它是O(N),其中N是字典中的单词数。

答案 10 :(得分:0)

如果我错了,请纠正我,但我相信你的update3有一个额外的循环,这是不必要的,并使程序更慢:

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

你应该只调用一次traverseTrie,因为在traverseTrie中你已经遍历了整个单词。代码应该只是如下:

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