如何快速搜索基于字符串的键/值集合
Posted
技术标签:
【中文标题】如何快速搜索基于字符串的键/值集合【英文标题】:How to search a string based key/value collection fast 【发布时间】:2010-09-17 03:48:21 【问题描述】:大家好,***ers!
我有一个包含 200.000 个字符串条目的单词列表,平均字符串长度约为 30 个字符。这个单词列表是关键,每个关键我都有一个域对象。我想通过只知道密钥的一部分来找到这个集合中的域对象。 IE。例如,搜索字符串“kov”将匹配键“***”。
目前我正在使用三元搜索树 (TST),它通常会在 100 毫秒内找到项目。然而,这对我的要求来说太慢了。 TST 实现可以通过一些小的优化来改进,我可以尝试平衡树。但我认为这些东西不会给我目标的 5 倍到 10 倍的速度提升。我假设这么慢的原因是我基本上必须访问树中的大多数节点。
关于如何提高算法速度的任何想法?还有其他我应该研究的算法吗?
提前致谢, 奥斯卡
【问题讨论】:
今天学到了一个新东西:Trie。 我认为应该是“Trie”或“Ternary Search Tree”。 您使用什么语言工作?需要此信息,因为所有语言处理搜索和集合的方式不同 这就是我喜欢的问题:没有什么比时不时的挑战更好...... :-) A.您能否解释一下您是如何设法使用 TST 来搜索既不是前缀也不是后缀的东西? (在您的示例中,“kov”既不是“***”的前缀也不是后缀),即您能否描述将元素插入到 TST 中的方式? B. 你能否 - 再说一遍“kov”的具体示例 - 描述你的 TST search 函数实现如何知道如何/何时从检查中排除某些节点(再次假设 A您正在寻找既不是前缀也不是后缀的术语)? 【参考方案1】:后缀数组和q-gram索引
如果您的字符串对大小有严格的上限,您可以考虑使用suffix array:只需使用特殊字符(例如空字符)将所有字符串填充到相同的最大长度。然后连接所有字符串并在它们之上建立一个后缀数组索引。
这为您提供 m * log n 的查找运行时,其中 m 是查询字符串的长度,n 是组合字符串的总长度。如果这仍然不够好并且您的 m 具有固定的小长度,并且您的字母 Σ 的大小受到限制(例如,Σ q-gram 索引。这将允许在恒定时间中检索。但是,q-gram 表需要 Σm 个条目(在 3 个字符的情况下 = 8 MiB,4 个字符的情况下为 1 GiB !)。
缩小索引
可以通过调整散列函数减小 q-gram 表的大小(在最好的情况下是指数的)。您可以使用有损散列函数,而不是为每个可能的 q-gram 分配唯一编号。然后,该表必须存储可能的后缀数组索引列表,而不是仅存储一个与精确匹配相对应的后缀数组条目。但是,这意味着查找不再是恒定的,因为必须考虑列表中的所有条目。
顺便说一下,我不确定您是否熟悉 q-gram 索引的工作原理,因为 Internet 对这个主题没有帮助。我之前在另一个主题中提到过这一点。因此,我在bachelor thesis 中包含了构造的描述和算法。
概念证明
我已经编写了一个非常小的 C# 概念证明(因为您另外声明您使用 C#)。它可以工作,但是由于两个原因,它非常很慢。首先,后缀数组创建只是对后缀进行排序。仅此一项就有运行时n2 日志n。有很多优越的方法。然而,更糟糕的是我使用SubString
来获取后缀。不幸的是,.NET 为此创建了整个后缀的副本。要在实践中使用此代码,请确保使用不会不必要地复制任何数据的就地方法。从字符串中检索 q-grams 也是如此。
最好不要构造我的示例中使用的m_Data
字符串。相反,您可以保存对原始数组的引用,并通过处理此数组来模拟我的所有 SubString
访问。
不过,很容易看出这个实现本质上是预期的恒定时间检索(如果字典表现良好)!这是一个搜索树/trie 无法超越的成就!
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);
【讨论】:
有没有办法拆分一个 q-gram 表,这样你就不会使用它来破坏磁盘? 我不知道有什么办法。您最好的选择可能是通过将多个字符散列到同一个键来减少字母表,从而也以指数方式减小表大小。但是,您需要处理碰撞。 为什么需要填充字符串?后缀数组比后缀树好吗? @Rafał:我正在填充字符串,这样我就可以轻松地计算它的索引,形成后缀数组中的位置。还有其他解决方案,但这些都需要修改后缀数组,使构建更加困难。 后缀数组比后缀树更好,因为它可以更节省空间地存储。更重要的是,您需要一个后缀 array 才能有效地创建 q-gram 索引(至少我不知道任何用于为后缀树创建 q-gram 索引的算法)。跨度> 【参考方案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。
【讨论】:
这和康拉德所说的 q-gram 索引不一样吗?【参考方案3】:如果你的 trie 密钥与机器寄存器的大小相当,你会得到什么好处吗?所以如果你在一个 32 位的盒子上,你可以一次比较 4 个字符而不是单独比较每个字符?我不知道这会增加您的应用程序的大小。
【讨论】:
【参考方案4】:是否可以“散列”键值?基本上有第二棵树将搜索指向第一棵树的键列表的所有可能值。
你需要两棵树;第一个是域对象的哈希值。第二棵树是哈希值的搜索字符串。第二棵树有多个相同哈希值的键。
示例 树1: STCKVRFLW -> 领域对象
树 2: 堆栈-> STCKVRFLW,STCK 结束 -> STCKVRFLW,VRBRD,VR
因此,使用在第二棵树上的搜索为您提供了在第一棵树上搜索的键列表。
【讨论】:
【参考方案5】:选择最小搜索字符串大小(例如四个字符)。浏览您的字符串条目列表并构建每四个字符子字符串的字典,映射到子字符串出现的条目列表。当您进行搜索时,根据搜索字符串的前四个字符进行查找以获取一个初始集,然后将该初始集缩小到仅匹配完整搜索字符串的那些。
最坏的情况是 O(n),但只有当您的字符串条目几乎全部相同时,您才会得到这种情况。查找字典可能非常大,因此将其存储在磁盘上或使用关系数据库可能是个好主意:-)
【讨论】:
【参考方案6】:/编辑:我的一个朋友刚刚指出了我在构建 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
参数。
【讨论】:
【参考方案7】:要以有效的方式查询大量文本,您可以使用编辑距离/前缀编辑距离的概念。
编辑距离 ED(x,y):从 x 到 y 的最小转换次数
但是计算每个术语和查询文本之间的 ED 既耗费资源又耗费时间。因此,我们可以使用称为 Qgram Index 的技术来提取可能的匹配项,而不是首先为每个术语计算 ED。然后对这些选定的术语应用 ED 计算。
Qgram 索引技术的一个优点是它支持模糊搜索。
调整 QGram 索引的一种可能方法是使用 Qgram 构建倒排索引。在那里我们存储所有包含特定 Qgram 的单词(而不是存储完整的字符串,您可以为每个字符串使用唯一的 ID)。
col : colmbia, colombo, gancola, tacolama
然后在查询时,我们计算查询文本和可用词之间的公共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开始上课)
【讨论】:
以上是关于如何快速搜索基于字符串的键/值集合的主要内容,如果未能解决你的问题,请参考以下文章
春眠不觉晓,Redis数据类型知多少?String,List,Set,SortedSet,Hash,Bitmap,HyperLogLogs