【问题标题】:Build trie faster更快地构建 trie
【发布时间】:2013-09-28 14:48:21
【问题描述】:

我正在制作一个需要数千次快速字符串查找和前缀检查的移动应用程序。为了加快速度,我从我的单词列表中做了一个 Trie,它有大约 180,000 个单词。

一切都很好,但唯一的问题是在我的手机上构建这个巨大的树(它有大约 400,000 个节点)大约需要 10 秒,这真的很慢。

这是构建 trie 的代码。

public SimpleTrie makeTrie(String file) throws Exception {
    String line;
    SimpleTrie trie = new SimpleTrie();

    BufferedReader br = new BufferedReader(new FileReader(file));
    while( (line = br.readLine()) != null) {
        trie.insert(line);
    }
    br.close();

    return trie;
}

O(length of key) 上运行的insert 方法

public void insert(String key) {
    TrieNode crawler = root;
    for(int level=0 ; level < key.length() ; level++) {
        int index = key.charAt(level) - 'A';
        if(crawler.children[index] == null) {
            crawler.children[index] = getNode();
        }
        crawler = crawler.children[index];
    }
    crawler.valid = true;
}

我正在寻找直观的方法来更快地构建 trie。也许我只在笔记本电脑上构建了一次 trie,以某种方式将其存储到磁盘上,然后从手机中的文件中加载它?但我不知道如何实现。

或者是否有其他前缀数据结构可以花费更少的时间来构建,但具有类似的查找时间复杂度?

感谢任何建议。提前致谢。

编辑

有人建议使用 Java 序列化。我试过了,但这段代码非常很慢:

public void serializeTrie(SimpleTrie trie, String file) {
        try {
            ObjectOutput out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)));
            out.writeObject(trie);
            out.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public SimpleTrie deserializeTrie(String file) {
        try {
            ObjectInput in = new ObjectInputStream(new BufferedInputStream(new FileInputStream(file)));
            SimpleTrie trie = (SimpleTrie)in.readObject();
            in.close();
            return trie;
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
            return null;
        }
    }

上面的代码可以更快吗?

我的特里:http://pastebin.com/QkFisi09

词表:http://www.isc.ro/lists/twl06.zip

用于运行代码的Android IDE:http://play.google.com/store/apps/details?id=com.jimmychen.app.sand

【问题讨论】:

  • 我无法在安卓姜饼上安装 ide?​​span>
  • 我建议从分析开始。至少测量哪个部分用于(1)从文件读取,(2)在 trie 中查找位置和(3)创建新节点
  • @Bruce 你试过二分搜索技术吗?我看到了很好的结果。
  • @Justin 是的,我确实尝试过,但似乎并不太快。我只需要两个查询:是否存在前缀,是否存在单词。我不需要所有以前缀开头的字符串。顺便说一句,我计算了前缀存在搜索的数量,大约是 10,000.. 所以二进制搜索方法比较慢,因为使用 dawg,whole 算法在大约 60 毫秒内完成。
  • @Bruce 好的,很高兴您找到了解决方案。我从来没有找到比 1 毫秒慢的前缀查询,并且与单个字符串的存在相同,但也许我有一个更快的电话。

标签: performance algorithm optimization data-structures trie


【解决方案1】:

是空间效率低还是时间效率低?如果您正在滚动普通特里,那么在处理移动设备时空间可能是问题的一部分。查看 patricia/​​radix 尝试,尤其是当您将其用作前缀查找工具时。

尝试: http://en.wikipedia.org/wiki/Trie

帕特里夏/基数特里: http://en.wikipedia.org/wiki/Radix_tree

您没有提到一种语言,但这里有两个 Java 中前缀尝试的实现。

常规尝试: http://github.com/phishman3579/java-algorithms-implementation/blob/master/src/com/jwetherell/algorithms/data_structures/Trie.java

Patricia/​​Radix(节省空间)特里: http://github.com/phishman3579/java-algorithms-implementation/blob/master/src/com/jwetherell/algorithms/data_structures/PatriciaTrie.java

【讨论】:

  • 不,正如我在问题中提到的,问题是时间而不是空间。它必须占用大约 40MB,这是可行的。我已经实现了一切——我只是想加快速度。请查看已编辑的问题。
  • @Bruce 我发现从 180k 单词构建一个 trie 需要 10 秒,这让我感到惊讶。例如;在我的本地 PC(2.0 GHz 处理器和 1GB 内存)上从 200k 构建一个 trie 需要 471 毫秒并消耗 34MB,从相同数据构建一个压缩的 trie 需要 541 毫秒并消耗 22MB。我会尝试一个开源版本,看看你是否能得到更好的结果。
  • “我手机上的 10 秒”
  • @Bruce 我了解,但您的 trie 的性能要大得多,令人惊讶。我将在我的 HTC 上运行相同的代码并重新签入。
  • 谢谢!顺便说一句,这是我的尝试:pastebin.com/QkFisi09 单词列表:isc.ro/lists/twl06.zip 我在这个 IDE 上运行它:play.google.com/store/apps/…
【解决方案2】:

您可以将 trie 存储为节点数组,并将对子节点的引用替换为数组索引。您的根节点将是第一个元素。这样,您可以轻松地从简单的二进制或文本格式存储/加载您的 trie。

public class SimpleTrie {
    public class TrieNode {
        boolean valid;
        int[] children;
    }
    private TrieNode[] nodes;
    private int numberOfNodes;

    private TrieNode getNode() {
        TrieNode t = nodes[++numberOnNodes];
        return t;
    }
}

【讨论】:

  • 我想过这个,但无法继续下去。如何表示 trie 的递归结构?数组中的父索引和子索引如何相关?如何保证它生成完全相同的 trie,而不是具有相同字节表示的其他 trie?
  • @Bruce - 我看不出问题所在。树的递归结构由这些索引值定义,您将与其他所有内容一起对其进行序列化。父索引和子索引是相关的,因为子索引存储在父节点中,替换了子引用。您通过遍历整个数组进行序列化,忽略 Trie 结构。索引是一个索引,无论它是在文件中还是在数组中。您不必进行二进制序列化(但如果您愿意,也可以) - 如果您在每个文本行(例如 CSV 文件)序列化一个节点,则节点号也是行号。
  • 哦,对不起,我昨天完全看错了,我想我太累了。现在我明白了,很简单。会尝试让您知道。
【解决方案3】:

只需构建一个大的 String[] 并对其进行排序。然后您可以使用二进制搜索来查找字符串的位置。您也可以根据前缀进行查询,而无需太多工作。

前缀查找示例:

比较方法:

private static int compare(String string, String prefix) {
    if (prefix.length()>string.length()) return Integer.MIN_VALUE;

    for (int i=0; i<prefix.length(); i++) {
        char s = string.charAt(i);
        char p = prefix.charAt(i);
        if (s!=p) {
            if (p<s) {
                // prefix is before string
                return -1;
            }
            // prefix is after string
            return 1;
        }
    }
    return 0;
}

在数组中查找前缀的出现并返回它的位置(MIN 或 MAX 表示未找到)

private static int recursiveFind(String[] strings, String prefix, int start, int end) {
    if (start == end) {
        String lastValue = strings[start]; // start==end
        if (compare(lastValue,prefix)==0)
            return start; // start==end
        return Integer.MAX_VALUE;
    }

    int low = start;
    int high = end + 1; // zero indexed, so add one.
    int middle = low + ((high - low) / 2);

    String middleValue = strings[middle];
    int comp = compare(middleValue,prefix);
    if (comp == Integer.MIN_VALUE) return comp;
    if (comp==0)
        return middle;
    if (comp>0)
        return recursiveFind(strings, prefix, middle + 1, end);
    return recursiveFind(strings, prefix, start, middle - 1);
}

获取一个字符串数组和前缀,打印出数组中出现的前缀

private static boolean testPrefix(String[] strings, String prefix) {
    int i = recursiveFind(strings, prefix, 0, strings.length-1);
    if (i==Integer.MAX_VALUE || i==Integer.MIN_VALUE) {
        // not found
        return false;
    }
    // Found an occurrence, now search up and down for other occurrences
    int up = i+1;
    int down = i;
    while (down>=0) {
        String string = strings[down];
        if (compare(string,prefix)==0) {
            System.out.println(string);
        } else {
            break;
        }
        down--;
    }
    while (up<strings.length) {
        String string = strings[up];
        if (compare(string,prefix)==0) {
            System.out.println(string);
        } else {
            break;
        }
        up++;
    }
    return true;
}

【讨论】:

  • 字典单词。相信我,我需要在 O(length) 时间内找出一个键是否是字典中任何单词的前缀。否则会造成巨大的时间惩罚。通过使用数组,我如何找出一个键是任何单词的前缀?
  • 使用二进制搜索,您应该能够在 O(log N) 中找到前缀,其中 N 是字典中的单词数。我将在我的答案中添加一些代码来举例说明。
  • @Bruce 使用上面的算法,在我的手机上用不到 1 毫秒的时间就可以在 200000 个项目的字符串数组中找到 3 个字母前缀的存在。
  • @Bruce 它还在约 5 毫秒内找到了 1000 多个带有给定 3 个字符前缀的字符串。
  • @Bruce 具有讽刺意味的是,在我的手机上,这种方法的性能与执行前缀查找的 Trie 一样好,并且在返回所有包含前缀的字符串时击败了 Trie(以很大的优势)。
【解决方案4】:

这是一种在磁盘上存储 trie 的相当紧凑的格式。我将通过它的(有效的)反序列化算法来指定它。初始化一个栈,其初始内容是树的根节点。一个一个地读字符并解释如下。字母 A-Z 的含义是“分配一个新节点,使其成为当前栈顶的子节点,并将新分配的节点压入栈中”。字母表示孩子在哪个位置。空格的意思是“将栈顶节点的有效标志设置为真”。退格(\b)的意思是“出栈”。

例如输入

TREE \b\bIE \b\b\bOO \b\b\b

给出单词列表

TREE
TRIE
TOO

。在您的桌面上,使用任何一种方法构造 trie,然后通过以下递归算法(伪代码)进行序列化。

serialize(node):
    if node is valid: put(' ')
    for letter in A-Z:
        if node has a child under letter:
            put(letter)
            serialize(child)
            put('\b')

【讨论】:

    【解决方案5】:

    Double-Array tries 的保存/加载速度非常快,因为所有数据都存储在线性数组中。它们的查找速度也非常快,但插入的成本可能很高。我敢打赌,某处有一个 Java 实现。

    此外,如果您的数据是静态的(即您没有在手机上更新),请考虑使用 DAFSA 来完成您的任务。它是存储单词的最有效的数据结构之一(在大小和速度方面必须优于“标准”尝试和基数尝试,在速度方面优于简洁尝试,在大小方面通常优于简洁尝试)。有一个很好的 C++ 实现:dawgdic - 您可以使用它从命令行构建 DAFSA,然后使用 Java 阅读器获取结果数据结构(示例实现是 here)。

    【讨论】:

    • 嗨。经过大量的努力,我已经成功地创建了 DAWG 并从 Java 中读取了它。它很小 (537K) 并且速度极快。但是,有一个问题让我无法永久关闭这个问题——Github 代码只能检查一个字符串是否是字典中任何单词的前缀,它不能检查该字符串是否是一个完整的单词。我浪费了一整天的时间试图弄清楚这一点。没有它,我的应用程序将无法运行。你能看一下吗?
    • @Bruce:您可以在每个字典单词的末尾附加一些未使用的符号(如“$”)。然后,您只需搜索 'word' 的前缀和 'word$' 的完整单词。
    • @EvgenyKluev 是的,我可以这样做 - 但是我真的认为该代码中存在此功能 - 我只是找不到它。等待米哈伊尔的回复。顺便说一句,这里有一些测试 DAWG 的代码:dl.dropboxusercontent.com/u/19729481/DawgTest.7z 没有按预期工作。
    • 嗨@Bruce,'contains' 方法中缺少一个检查 - 只有当存在与索引关联的值时它才应该返回 True(return hasValue(index) 而不是 return true 应该可以工作)。我自己没有测试/使用过链接的 Java 实现;它可能适用于为其编写的软件,但不适用于一般的 Java 实现。很抱歉浪费您的时间。这个 Python 实现经过了大量测试,我很确定它可以正常工作:github.com/kmike/DAWG-Python/blob/… - 如果有疑问,请咨询。
    • 啊,当然还有“规范的”C++源代码:code.google.com/p/dawgdic/source/browse/trunk/src/dawgdic/…
    【解决方案6】:

    这不是灵丹妙药,但您可以通过一个大内存分配而不是一堆小内存来稍微减少运行时间。

    当我使用“节点池”而不是依赖单个分配时,我在下面的测试代码(C++,不是 Java,抱歉)中看到了约 10% 的加速:

    #include <string>
    #include <fstream>
    
    #define USE_NODE_POOL
    
    #ifdef USE_NODE_POOL
    struct Node;
    Node *node_pool;
    int node_pool_idx = 0;
    #endif
    
    struct Node {
        void insert(const std::string &s) { insert_helper(s, 0); }
        void insert_helper(const std::string &s, int idx) {
            if (idx >= s.length()) return;
            int char_idx = s[idx] - 'A';
            if (children[char_idx] == nullptr) {
    #ifdef USE_NODE_POOL
                children[char_idx] = &node_pool[node_pool_idx++];
    #else
                children[char_idx] = new Node();
    #endif
            }
            children[char_idx]->insert_helper(s, idx + 1);
        }
        Node *children[26] = {};
    };
    
    int main() {
    #ifdef USE_NODE_POOL
        node_pool = new Node[400000];
    #endif
        Node n;
        std::ifstream fin("TWL06.txt");
        std::string word;
        while (fin >> word) n.insert(word);
    }
    

    【讨论】:

      【解决方案7】:

      您可以使用 sqlite 之类的数据库和嵌套集或 celko 树来存储 trie,而不是简单的文件,您还可以使用三元搜索 trie 构建更快、更短(节点更少)的 trie。

      【讨论】:

        【解决方案8】:

        预分配空间的尝试所有可能的子节点 (256) 都会浪费大量空间。你正在让你的缓存哭泣。将这些指向子节点的指针存储在可调整大小的数据结构中。

        一些尝试会通过使用一个节点来表示一个长字符串来进行优化,并且仅在需要时才分解该字符串。

        【讨论】:

          【解决方案9】:

          我不喜欢通过数组中的索引来寻址节点的想法,但这只是因为它需要再添加一个(指针的索引)。但是使用预先分配的节点数组,您可能会在分配和初始化方面节省一些时间。您还可以通过为叶节点保留前 26 个索引来节省大量空间。因此,您无需分配和初始化 180000 个叶节点。

          还可以使用索引,以二进制格式从磁盘读取准备好的节点数组。这必须快几倍。但我不确定如何用你的语言做到这一点。这是 Java 吗?

          如果您检查了源词汇表是否已排序,您还可以通过将当前字符串的某些前缀与前一个字符串进行比较来节省一些时间。例如。前 4 个字符。如果他们是平等的,你可以开始你的

          for(int level=0 ; level

          从第 5 层循环。

          【讨论】:

            【解决方案10】:

            一般来说,避免在 Java 中使用大量从头开始的对象创建,这既慢又具有巨大的开销。更好地实现您自己的用于分配内存管理的池类,例如一次有 50 万个条目。

            此外,序列化对于大型词典来说太慢了。使用二进制读取来快速填充上面提出的基于数组的表示。

            【讨论】: