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

Posted

技术标签:

【中文标题】实现一个简单的 Trie 以实现高效的 Levenshtein 距离计算 - Java【英文标题】:Implementing a simple Trie for efficient Levenshtein Distance calculation - Java 【发布时间】:2011-06-19 15:09:10 【问题描述】:

更新 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

最后,我设法让它适用于我的大多数测试用例。我的实现实际上是从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);
        
    

对于缺少 cmets,我深表歉意。那我做错了什么?

初始发布

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

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

我一直在寻找 Java 中 Trie 的实现,我发现了两个看似不错的资源:

Koders.com version code.google.com version (编辑:这似乎已经转移到github.com/rkapsi)

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

那么我将如何在 Java 中实现一个简单的 Trie 数据结构?我的直觉告诉我,每个 TrieNode 都应该存储它所代表的字符串以及对字母表字母的引用,不一定是所有字母。我的直觉正确吗?

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

很高兴看到除了您的 cmets 和建议之外的一些代码。毕竟,这对我来说是一个学习过程……我从未实施过 Trie……所以我可以从这次经历中学到很多东西。

谢谢。

附言如果需要,我可以提供任何源代码。另外,我已经阅读并尝试使用 Nick Johnson's blog 中建议的 BK-Tree,但它的效率不如我认为的那么高......或者我的实现可能是错误的。

【问题讨论】:

@Hristo - 你提到了 Nick Johnson 的博客,所以你可能已经看过他的 Levenshtein Automata 代码。 Levenshtein Automata 代码是迄今为止我遇到过的最有效的代码。您只需要将他的 Python 版本转换为 Java。看到这个:blog.notdot.net/2010/07/… @Hristo - 这是 Levenshtein Automata 的要点:gist.github.com/491973 @Hristo 我认为 Trie 可以帮助您的唯一方法是,如果您基本上要实现与 Levenshtein Automata 相同的东西。 trie 只是识别其中单词的 DFA 的一个特例。 if (currentRow[size - 1] &lt; minCost &amp;&amp; !node.isWord) 这一行是错误的。如果有一个单词在该节点完成,您只能更新minCost,所以正确的是if (currentRow[size - 1] &lt; minCost &amp;&amp; node.isWord) @Murilo... 更改导致***Error,我相信是由于递归过多。在您的 C++ 版本中,您有 if ((current_row[sz-1] &lt; min_cost) &amp;&amp; (tree-&gt;word != ""))... 如果它的第二部分到底是什么意思? “”代表什么? 【参考方案1】:

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

由于 Levenshtein 距离是一个度量,您可以使用任何利用三角不等式的度量空间索引 - 您提到了 BK-Trees,但还有其他的,例如。有利点树、固定查询树、平分线树、空间逼近树。以下是他们的描述:

Burkhard-Keller 树

节点按如下方式插入到树中: 对于根节点,选择一个任意元素 从空间;添加唯一的边缘标记 孩子使得每条边的值是 从枢轴到那个的距离 元素;递归应用,选择 当边缘已经存在时,孩子作为枢轴 存在。

固定查询树

与 BKT 一样,除了:存储元素 在叶子处;每个叶子都有多个元素; 对于树的每一层,相同的枢轴是 用过。

平分线树

每个节点包含两个枢轴元素 与他们的覆盖半径(最大 中心元素和之间的距离 它的任何子树元素);过滤成两个 设置最接近的元素 第一个支点和最接近的支点 二、递归构建两个子树 从这些集合中。

空间逼近树

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

制高点树

从集合中任意选择一个枢轴; 计算这之间的中位数距离 枢轴和剩余的每个元素 放;将集合中的元素过滤到左侧 和右递归子树使得 距离小于或等于 中位数形成左边和那些更大的 形成右边。

【讨论】:

很好的答案... +1...谢谢!关于我正在寻找一种不必计算 Levenshtein 距离的方法,您是完全正确的。我将研究这些树并尝试了解它们的优点/缺点。但是,这需要我一段时间。同时...您对使用其中一种方法的好处有什么建议或其他建议? 很遗憾,您问的还早了几个月——我的荣誉项目就是这样,我还没有完成! 您在 cmets 中提到的荣誉项目的链接/PDF 是否有机会? /cc @Hristo 好的,如果你真的想要它... @Regexident robertgmoss.co.uk/hons_project_report.pdf【参考方案2】:

我已经用 C++ 实现了“使用 Trie 的快速和简单的 Levenshtein 距离”一文中描述的算法,它真的很快。如果您愿意(比 Python 更了解 C++),我可以将代码粘贴到某个地方。

编辑: 我把它贴在我的blog。

【讨论】:

是的,我比 Python 更了解 C++。除非有一堆棘手的、低级的东西……我想我会没事的。您可以在此处粘贴,也可以将文件上传到gist.github.com @Murilo... 我更新了我的帖子,试图用 Java 实现你的算法。你介意看看是否有任何明显的错误吗?我有点卡住了。 好的。找到最低成本的方法也来自我的博客,这与史蒂夫哈诺夫的方法有点不同。如果您引用它,我会很高兴:)【参考方案3】:

这是Levenshtein Automata in 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");

【讨论】:

哈哈哈......是的!我想知道 Lev1ParametricDescriptionLev2ParametricDescription 是什么......以及其他类 查看 dk.brics.automaton 包的 javaodcs。我相信 Lucene 刚刚合并了这个包,所以你可能想直接使用它。 啊……不错!这一切看起来都很棒。谢谢!我需要一些时间来通读这一切……它看起来超级复杂。但这应该很有趣! @Taylor... 我很难在 Eclipse 中包含 Lucene 源/.jar。似乎 3.0.3 版本下载不包括 Automaton 的东西。有什么建议吗? @Taylor... dk.brics.automaton 包不包括 Levenshtein Automata。您发送给我的 apache 链接的来源与 dk.brics.automaton 来源不同【参考方案4】:

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

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

Steve Hanov 的算法及其上述导数显然使用 Levenshtein 距离计算矩阵代替了正式的 Levenshtein Automaton。 相当快,但正式的 Levenshtein Automaton 可以有其参数状态(描述自动机具体状态的抽象状态)生成并用于遍历,绕过任何与编辑距离相关的运行时计算任何。因此,它应该比上述算法运行得更快。

如果您(或其他任何人)对正式的 Levenshtein Automaton 解决方案感兴趣,请查看 LevenshteinAutomaton。它实现了前面提到的基于参数状态的算法,以及纯基于具体状态遍历的​​算法(如上所述)和基于动态规划的算法(用于编辑距离和邻居确定)。它确实由您维护:)。

【讨论】:

【参考方案5】:

我的直觉告诉我,每个 TrieNode 都应该存储它所代表的字符串以及对字母表字母的引用,不一定是所有字母。我的直觉正确吗?

不,trie 不代表字符串,它代表一组字符串(及其所有前缀)。一个 trie 节点将一个输入字符映射到另一个 trie 节点。所以它应该包含一个字符数组和一个对应的 TrieNode 引用数组。 (也许不是那种精确的表示,取决于你使用它的效率。)

【讨论】:

【参考方案6】:

我没看错,你想遍历 trie 的所有分支。使用递归函数并不难。我在我的 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

希望对你有帮助。

【讨论】:

感谢您的回复。我有几个问题......你能解释一下每个函数的参数吗?它们代表什么?【参考方案7】:

函数 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。

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

【讨论】:

我已经实现了 Trie... 我只是对“testitem”是什么以及初始距离感到困惑。【参考方案8】:

我将把它留在这里,以防有人正在寻找解决此问题的另一种方法:

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

【讨论】:

【参考方案9】:

我正在查看您的最新更新 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;
        

至少对于第一个循环,字母是单词中的一个字符,但是相反,您应该比较 trie 中的节点,所以会有一行与单词中的第一个字符重复,是对吗?每个 DP 矩阵都有第一行作为副本。我执行了与您在解决方案中添加的完全相同的代码。

【讨论】:

【参考方案10】:

好吧,here's how I did it 很久以前。 我将字典存储为 trie,它只是一个受限于树形式的有限状态机。 您可以通过不进行限制来增强它。 例如,公共后缀可以简单地是共享子树。 您甚至可以使用循环来捕获诸如“国家”、“国家”、“国家化”、“国有化”等内容......

让尝试尽可能简单。不要往里面塞字符串。

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

【讨论】:

@Mike...感谢您的建议。我已经用 Java 中的 Trie 实现更新了我的帖子。我还包括了一个搜索方法,我用它来计算给定单词和 Trie 中的单词之间的 minimum Levenshtein 距离。它不能正常工作...您介意看一下是否可以捕获任何明显的错误吗?我有点卡住了。 @Hristo:我认为您的做法与我的做法有些不同。我的方法没有使用带有行的矩阵。该程序的基本形式是在 trie 上的深度优先遍历函数,然后通过添加参数对其进行修饰。一个论点是剩余的误差预算。下降时,如果 trie 字符与关键字符不匹配,则在从属调用上将预算减少 1。对于其他类型的不匹配也是如此。然后例程修剪预算为 @Hristo: ...首先预算为 0,然后预算为 1,依此类推。每当步行遇到匹配时,它会将匹配附加到结果列表中。一旦列表中出现一些匹配项,外部循环就会停止。由于小预算 B 所花费的时间是 B 的指数,因此使用较小的 B 进行额外的遍历并没有什么坏处。这样,您获得的第一个匹配是最低成本的。 @Mike...感谢您的回复。因此,假设我正在尝试计算单词“tihs”和 Trie 之间的最小 Levenshtein 距离。您的算法是否能够返回值 1,例如,“tihs”和“ties”之间的 Levenshtein 距离为 1?【参考方案11】:

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

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

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

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

【讨论】:

以上是关于实现一个简单的 Trie 以实现高效的 Levenshtein 距离计算 - Java的主要内容,如果未能解决你的问题,请参考以下文章

LeetCode 208. 实现 Trie (前缀树)

数据结构—前缀树Trie的实现原理以及Java代码的实现

208. 实现 Trie (前缀树)

字典树208. 实现 Trie (前缀树)

在哪里可以找到 Java 中基于标准 Trie 的地图实现? [关闭]

leetcode中等208实现 Trie (前缀树)