13

Hello fellow stackoverflowers!

I have a word list of 200.000 string entries, average string length is around 30 characters. This list of words are the key and to each key i have a domain object. I would like to find the domain objects in this collection by only knowing a part of the key. I.E. the search string "kov" would for example match the key "stackoverflow".

Currently I am using a Ternary Search Tree (TST), which usually will find the items within 100 milliseconds. This is however too slow for my requirements. The TST implementation could be improved with some minor optimizations and I could try to balance the tree. But i figured that these things would not give me the 5x - 10x speed improvement I am aiming at. I am assuming that the reason for being so slow is that i basically have to visit most nodes in the tree.

Any ideas on how to improve the speed of the algorithm? Are there any other algorithms that I should be looking at?

Thanks in advance, Oskar

4

7 回答 7

13

后缀数组和q -gram 索引

如果您的字符串对大小有严格的上限,您可能会考虑使用后缀数组:只需使用特殊字符(例如空字符)将所有字符串填充到相同的最大长度。然后连接所有字符串并在它们之上建立一个后缀数组索引。

这为您提供了m * log n的查找运行时间,其中m是查询字符串的长度,n是组合字符串的总长度。如果这仍然不够好并且您的m具有固定的小长度,并且您的字母 Σ 的大小受到限制(例如,Σ < 128 个不同的字符),您可以另外构建一个q -gram index这将允许在恒定时间内检索。但是,q -gram 表需要 Σ m个条目(在 3 个字符的情况下 = 8 MiB,4 个字符的情况下为 1 GiB!)。

使索引更小

可以通过调整散列函数来减小q -gram 表的大小(在最好的情况下是指数的)。您可能会使用有损散列函数,而不是为每个可能的q -gram分配唯一编号。然后,该表必须存储可能的后缀数组索引列表,而不是仅存储一个与精确匹配相对应的后缀数组条目。但是,这将需要查找不再是恒定的,因为必须考虑列表中的所有条目。

顺便说一句,我不确定您是否熟悉q -gram索引的工作原理,因为 Internet 对这个主题没有帮助。我之前在另一个主题中提到过这一点。因此,我在我的学士论文中包含了构造的描述和算法。

概念证明

我已经编写了一个非常小的 C# 概念证明(因为您另外声明您使用 C#)。它可以工作,但是由于两个原因它非常慢。首先,后缀数组创建只是对后缀进行排序。仅此一项就有运行时n 2 log n。有很多优越的方法。SubString然而,更糟糕的是我用来获取后缀的事实。不幸的是,.NET 为此创建了整个后缀的副本。要在实践中使用此代码,请确保使用不会不必要地复制任何数据的就地方法。从字符串中检索q -gram 也是如此。

不构造m_Data我的示例中使用的字符串可能会更好。相反,您可以保存对原始数组的引用并SubString通过处理此数组来模拟我的所有访问。

尽管如此,很容易看出这个实现基本上期望恒定时间检索(如果字典表现良好)!这是一项不可能被搜索树/特里树击败的成就!

class QGramIndex {
    private readonly int m_Maxlen;
    private readonly string m_Data;
    private readonly int m_Q;
    private int[] m_SA;
    private Dictionary<string, int> m_Dir = new Dictionary<string, int>();

    private struct StrCmp : IComparer<int> {
        public readonly String Data;
        public StrCmp(string data) { Data = data; }
        public int Compare(int x, int y) {
            return string.CompareOrdinal(Data.Substring(x), Data.Substring(y));
        }
    }

    private readonly StrCmp cmp;

    public QGramIndex(IList<string> strings, int maxlen, int q) {
        m_Maxlen = maxlen;
        m_Q = q;

        var sb = new StringBuilder(strings.Count * maxlen);
        foreach (string str in strings)
            sb.AppendFormat(str.PadRight(maxlen, '\u0000'));
        m_Data = sb.ToString();
        cmp = new StrCmp(m_Data);
        MakeSuffixArray();
        MakeIndex();
    }

    public int this[string s] { get { return FindInIndex(s); } }

    private void MakeSuffixArray() {
        // Approx. runtime: n^3 * log n!!!
        // But I claim the shortest ever implementation of a suffix array!
        m_SA = Enumerable.Range(0, m_Data.Length).ToArray();
        Array.Sort(m_SA, cmp);
    }

    private int FindInArray(int ith) {
        return Array.BinarySearch(m_SA, ith, cmp);
    }

    private int FindInIndex(string s) {
        int idx;
        if (!m_Dir.TryGetValue(s, out idx))
            return -1;
        return m_SA[idx] / m_Maxlen;
    }

    private string QGram(int i) {
        return i > m_Data.Length - m_Q ?
            m_Data.Substring(i) :
            m_Data.Substring(i, m_Q);
    }

    private void MakeIndex() {
        for (int i = 0; i < m_Data.Length; ++i) {
            int pos = FindInArray(i);
            if (pos < 0) continue;
            m_Dir[QGram(i)] = pos;
        }
    }
}

使用示例:

static void Main(string[] args) {
    var strings = new [] { "hello", "world", "this", "is", "a",
                           "funny", "test", "which", "i", "have",
                           "taken", "much", "too", "far", "already" };

    var index = new QGramIndex(strings, 10, 3);

    var tests = new [] { "xyz", "aki", "ake", "muc", "uch", "too", "fun", "est",
                         "hic", "ell", "llo", "his" };

    foreach (var str in tests) {
        int pos = index[str];
        if (pos > -1)
            Console.WriteLine("\"{0}\" found in \"{1}\".", str, strings[pos]);
        else
            Console.WriteLine("\"{0}\" not found.", str);
    }
}
于 2008-10-14T18:06:59.643 回答
2

这是给你的 WAG。 在我的算法精通方面,我绝不是 Knuthian

好的,所以朴素的 Trie 通过从树的根开始向下移动与键中的每个字母匹配的分支(从键的第一个字母开始)来对字符串键进行编码。因此键“foo”将被映射到(root)->f->fo->foo,值将存储在“foo”节点指向的位置。

您正在搜索键中的任何子字符串,而不仅仅是从键开头开始的子字符串。

因此,您需要做的是将节点与包含该特定子字符串的任何键相关联。在我之前给出的 foo 示例中,您不会在节点 'f' 和 'fo' 下找到对 foo 值的引用。在支持您要执行的搜索类型的 TST 中,您不仅会在所有三个节点('f'、'fo' 和 'foo')下找到 foo 对象,还会找到它在“o”和“oo”下也是如此。

扩展搜索树以支持这种类型的索引有几个明显的后果。首先,您刚刚分解了树的大小。惊人的。如果您可以存储它并以有效的方式使用它,您的搜索将花费 O(1) 时间。如果您的键保持静态,并且您可以找到一种对索引进行分区的方法,这样您就不会在使用它时承受巨大的 IO 损失,那么这可能是值得的。

其次,您会发现搜索小字符串会导致大量点击,这可能会使您的搜索毫无用处,除非您在搜索词上设置最小长度。

从好的方面来说,您可能还会发现您可以通过标记化(如 zip 压缩)或通过压缩不向下分支的节点(即,如果您有 'w'->'o'->' o'-> 并且第一个 'o' 没有分支,您可以安全地将其折叠为 'w'->'oo')。也许即使是一个邪恶的哈希也可以让事情变得更容易......

无论如何,正如我所说的那样,WAG。

于 2008-10-14T18:23:08.927 回答
0

/编辑:我的一个朋友刚刚在我构建 q-gram 表时指出了一个愚蠢的假设。结构可以变得更简单——因此也更快。我已经编辑了源代码和解释以反映这一点。我认为这可能是最终的解决方案

受到 Rafał Dowgird 对我之前的回答的评论的启发,我更新了我的代码。我认为这值得一个自己的答案,因为它也很长。此代码不是填充现有字符串,而是在原始字符串数组上构建索引。后缀数组不是存储单个位置,而是存储一对:目标字符串的索引和后缀在该字符串中的位置。结果,只需要第一个数字。但是,第二个数字对于构造q -gram 表是必需的。

新版本的算法通过遍历后缀数组而不是原始字符串来构建q -gram 表。这节省了后缀数组的二分查找。因此,构造的运行时间从O ( n * log n ) 下降到O ( n )(其中n是后缀数组的大小)。

请注意,就像我的第一个解决方案一样,使用会SubString导致大量不必要的副本。显而易见的解决方案是编写一个扩展方法来创建轻量级包装器,而不是复制字符串。然后必须稍微调整比较。这留给读者作为练习。;-)

using Position = System.Collections.Generic.KeyValuePair<int, int>;

class QGramIndex {
    private readonly int m_Q;
    private readonly IList<string> m_Data;
    private Position[] m_SA;
    private Dictionary<string, int> m_Dir;

    public QGramIndex(IList<string> strings, int q) {
        m_Q = q;
        m_Data = strings;
        MakeSuffixArray();
        MakeIndex();
    }

    public int this[string s] { get { return FindInIndex(s); } }

    private int FindInIndex(string s) {
        int idx;
        if (!m_Dir.TryGetValue(s, out idx))
            return -1;
        return m_SA[idx].Key;
    }

    private void MakeSuffixArray() {
        int size = m_Data.Sum(str => str.Length < m_Q ? 0 : str.Length - m_Q + 1);
        m_SA = new Position[size];
        int pos = 0;
        for (int i = 0; i < m_Data.Count; ++i)
            for (int j = 0; j <= m_Data[i].Length - m_Q; ++j)
                m_SA[pos++] = new Position(i, j);

        Array.Sort(
            m_SA,
            (x, y) => string.CompareOrdinal(
                m_Data[x.Key].Substring(x.Value),
                m_Data[y.Key].Substring(y.Value)
            )
        );
    }

    private void MakeIndex() {
        m_Dir = new Dictionary<string, int>(m_SA.Length);

        // Every q-gram is a prefix in the suffix table.
        for (int i = 0; i < m_SA.Length; ++i) {
            var pos = m_SA[i];
            m_Dir[m_Data[pos.Key].Substring(pos.Value, 5)] = i;
        }
    }
}

用法与另一个示例中的相同,但减去maxlen了构造函数所需的参数。

于 2008-10-15T14:40:50.123 回答
0

Would you get any advantage having your trie keys comparable to the size of the machine register? So if you are on a 32bit box you can compare 4 characters at once instead of each character individually? I don't know how bad that would increase the size of your app.

于 2008-10-14T17:57:58.617 回答
0

是否可以“散列”键值?基本上有第二棵树将搜索指向第一棵树的键列表的所有可能值。

你需要两棵树;第一个是域对象的哈希值。第二棵树是哈希值的搜索字符串。第二棵树具有相同哈希值的多个键。

示例树 1:STCKVRFLW -> 域对象

树 2:堆栈 -> STCKVRFLW,STCK 结束 -> STCKVRFLW,VRBRD,VR

因此,使用在第二棵树上的搜索为您提供了在第一棵树上搜索的键列表。

于 2008-10-14T18:16:23.527 回答
0

选择最小搜索字符串大小(例如四个字符)。浏览您的字符串条目列表并构建每四个字符子字符串的字典,映射到子字符串出现的条目列表。当您进行搜索时,根据搜索字符串的前四个字符进行查找以获取一个初始集,然后将该初始集缩小到仅与完整搜索字符串匹配的那些。

最坏的情况是 O(n),但只有当您的字符串条目几乎全部相同时,您才会得到它。查找字典可能非常大,因此将其存储在磁盘上或使用关系数据库可能是个好主意 :-)

于 2008-10-14T18:50:39.777 回答
0

要以有效的方式查询大量文本,您可以使用编辑距离/前缀编辑距离的概念。

编辑距离 ED(x,y):从 x 到 y 的最小转换次数

但是计算每个术语和查询文本之间的 ED 是资源和耗时的。因此,我们可以使用一种称为Qgram Index的技术来提取可能的匹配项,而不是首先为每个术语计算 ED 。然后对这些选定的术语应用 ED 计算。

Qgram 索引技术的一个优点是它支持模糊搜索

调整 QGram 索引的一种可能方法是使用 Qgram 构建倒排索引。在那里,我们存储了包含特定 Qgram 的所有单词(而不是存储完整的字符串,您可以为每个字符串使用唯一的 ID)。

col : col mbia, col ombo, gan col a, ta col ama

然后在查询时,我们计算查询文本和可用术语之间的常见 Qgram 数量。

Example: x = HILLARY, y = HILARI(query term)
Qgrams
$$HILLARY$$ -> $$H, $HI, HIL, ILL, LLA, LAR, ARY, RY$, Y$$
$$HILARI$$ -> $$H, $HI, HIL, ILA, LAR, ARI, RI$, I$$
number of q-grams in common = 4

对于具有大量常见 Qgram 的术语,我们根据查询术语计算 ED/PED,然后向最终用户建议该术语。

您可以在以下项目中找到该理论的实现。随意问任何问题。 https://github.com/Bhashitha-Gamage/City_Search

要了解更多关于编辑距离、前缀编辑距离 Qgram 索引的信息,请观看 Hannah Bast 教授的以下视频 https://www.youtube.com/embed/6pUg2wmGJRo(课程从 20:06 开始)

于 2017-03-29T09:11:55.263 回答