针对已知密钥集的最快可能的字符串键查找

时间:2011-07-16 01:41:42

标签: hash lookup-tables perfect-hash

考虑具有以下签名的查找函数,该函数需要为给定的字符串键返回一个整数:

int GetValue(string key) { ... }

此外,在编写函数的源代码时,预先知道编号为N的键值映射,例如:

// N=3
{ "foo", 1 },
{ "bar", 42 },
{ "bazz", 314159 }

因此上述输入函数的有效(但不完美!)实现将是:

int GetValue(string key)
{
    switch (key)
    {
         case "foo": return 1;
         case "bar": return 42;
         case "bazz": return 314159;
    }

    // Doesn't matter what we do here, control will never come to this point
    throw new Exception();
}

事先还知道每个给定密钥在运行时将调用多少次(C> = 1)。例如:

C["foo"] = 1;
C["bar"] = 1;
C["bazz"] = 2;

但是,此类调用的顺序已知。例如。以上内容可以描述运行时的以下调用序列:

GetValue("foo");
GetValue("bazz");
GetValue("bar");
GetValue("bazz");

或任何其他序列,前提是呼叫计数匹配。

还有一个限制M,以任何最方便的单位指定,定义任何查找表的上限内存和GetValue可以使用的其他辅助结构(结构预先初始化;初始化不计入函数的复杂性)。例如,M = 100个字符,或M = 256 sizeof(对象引用)。​​

问题是,如何编写GetValue的正文以使其尽可能快 - 换句话说,所有GetValue次调用的总时间(请注意我们知道总计数)对于给定的N,C和M,每个上面的所有内容都是最小的。

该算法可能需要M的合理最小值,例如: M> = char.MaxValue。它也可能要求M与某个合理的边界对齐 - 例如,它可能只是2的幂。它还可能要求M必须是某种N的函数(例如,它可以允许有效的M = N,或者M = 2N,...;或者有效的M = N,或者M = N ^ 2, ......;等等。

算法可以用任何合适的语言或其他形式表达。对于生成的代码的运行时性能约束,假设生成的GetValue代码将使用C#,VB或Java(实际上,任何语言都可以,只要字符串被视为不可变的字符数组 - 即O( 1)长度和O(1)索引,并且没有提前为它们计算的其他数据)。此外,为了简化这一点,假设所有密钥的C = 1的答案被认为是有效的,尽管那些涵盖更一般情况的答案是首选。

关于可能的方法的一些思考

上面显而易见的第一个答案是使用完美的哈希,但找到一个的通用方法似乎并不完美。例如,可以使用Pearson散列为上面的示例数据轻松生成最小完美散列的表,但是每次调用GetValue时都必须对输入键进行散列,并且Pearson散列必须扫描整个输入字符串。但是所有样本键的第三个字符实际上都不同,因此只能将其用作哈希的输入而不是整个字符串。此外,如果要求M至少为char.MaxValue,则第三个字符本身将成为完美的哈希。

对于一组不同的键,这可能不再是真的,但是仍然可以在给出精确答案之前减少所考虑的字符数量。此外,在某些情况下, minimal 完美哈希需要检查整个字符串,有可能将查找减少到子集,或者使其更快(例如,更简单的哈希函数?)通过使散列非最小(即M> N) - 为了速度而有效地牺牲空间。

也许传统的哈希从一开始并不是一个好主意,并且将GetValue的主体构造为一系列条件更容易,这样安排首先检查“变量最大” “字符(在大多数键上变化的字符),根据需要进一步嵌套检查以确定正确的答案。请注意,此处的“方差”可能会受到每个键被查找的次数的影响(C)。此外,分支的最佳结构应该是什么并不总是显而易见的 - 例如,“大多数变量”字符只能让你区分100个中的10个键,但对于剩下的90个那个额外的检查没有必要区分它们,并且平均而言(考虑到C)每个键的检查次数多于使用 以“最多变量”字符开头的不同解决方案。然后,目标是确定完美的检查顺序。

8 个答案:

答案 0 :(得分:3)

你可以使用Boyer搜索,但我认为Trie将是一种更有效的方法。您可以修改Trie以折叠单词,同时为键零点击计数,从而减少您必须在更远的线下进行搜索的次数。您将获得的最大好处是您正在为索引进行数组查找,这比比较要快得多。

答案 1 :(得分:2)

在预计算时,您已经谈到了内存限制 - 是否还有时间限制?

我会考虑一个特里,但你不一定从第一个角色开始。相反,找到最能减少搜索空间的索引,并首先考虑。所以在你的示例中(“foo”,“bar”,“bazz”)你会得到第三个字符,它会立即告诉你它是哪个字符串。 (如果我们知道我们将总是给出一个输入词,我们可以在找到唯一的潜在匹配后立即返回。)

现在假设不是一个索引,它会让你找到一个唯一的字符串,你需要确定在那之后要查看的字符。从理论上讲,你预先计算trie来计算每个分支的 接下来要看的最佳字符是什么(例如“如果第三个字符是'a',我们需要看下一个字符;如果它是'o'我们需要查看下一个第一个字符)但是这可能需要更多时间和space 。另一方面,它可以保存 lot 时间 - 因为已经关闭了一个字符,每个分支可能有一个索引来选择哪个将唯一地标识最终字符串,但每次都是不同的索引。这种方法所需的空间量取决于相似程度字符串是,并且可能很难提前预测。能够为所有可能的节点动态执行此操作会很好,但是当您发现建筑空间不足时,确定单个订单对于“此节点下的所有内容”。(因此,您最终不会在该节点下的每个节点上存储“下一个字符索引”,只是单个seq如果不清楚,请告诉我,我可以尝试详细说明......

如何表示trie将取决于输入字符的范围。如果它们都在'a' - 'z'范围内,那么一个简单的阵列将非常快速地导航,并且对于可能存在大多数可用选项的trie节点而言是合理有效的。后来,当只有两三个可能的分支时,这会在内存中变得浪费。我建议使用多态Trie节点类,以便根据有多少个子分支构建最合适的节点类型。

这些都没有执行任何剔除 - 目前尚不清楚通过快速剔除可以实现多少。我可以看到它有帮助的一种情况是当来自一个trie节点的分支数量减少到1(因为删除了一个耗尽的分支)时,可以完全消除该分支。随着时间的推移,这可能会产生很大的不同,并且不应该太难以计算。基本上,当您构建时,您可以预测每个分支将被拍摄多少次,并且当您导航时,您可以在导航时从每个分支中减去一个它

到目前为止,我已经想到了这一切,并不是一个完整的实现 - 但我希望它有所帮助......

答案 2 :(得分:0)

这是确定哈希例程目标的最小字符子集的可行方法:

让:
k是所有关键字中不同字符的数量
c是最大关键字长度
n是关键字的数量
在你的例子中(填充较短的关键字w / space):

"foo "
"bar "
"bazz"

k = 7(f,o,b,a,r,z,),c = 4,n = 3

我们可以使用它来计算搜索的下限。我们至少需要log_k(n)字符来唯一标识关键字,如果log_k(n)> = c,那么您将需要使用整个关键字,并且没有理由继续。

接下来,一次删除一列,并检查是否仍有n个不同的值。使用每列中的不同字符作为启发式方法来优化我们的搜索:

2 2 3 2
f o o .
b a r .
b a z z

首先消除具有最低不同字符的列。如果剩下< = log_k(n)列,则可以停止。可选地,您可以随机化一点并消除第二低的不同col或尝试恢复,如果消除的col导致少于n个不同的单词。此算法大致为O(n!),具体取决于您尝试恢复的程度。它无法保证找到最佳解决方案,但这是一个很好的权衡。

获得字符子集后,继续执行生成完美哈希的常用例程。结果应该是最佳的完美哈希。

答案 3 :(得分:0)

表的二进制搜索真的太糟糕了吗?我将获取潜在字符串列表并“最小化”它们,排序它们,最后对它们进行二进制搜索。

通过最小化我的意思是将它们降低到他们需要的最小值,这是一种习惯性的阻塞。

例如,如果你有字符串:“alfred”,“bob”,“bill”,“joe”,我会将它们击倒为“a”,“bi”,“bo”,“j”。

然后将它们放入连续的内存块中,例如:

char *table = "a\0bi\0bo\0j\0"; // last 0 is really redundant..but
char *keys[4];
keys[0] = table;
keys[1] = table + 2;
keys[2] = table + 5;
keys[3] = table + 8;

理想情况下,编译器会为您完成所有这些操作:

keys[0] = "a";
keys[1] = "bi";
keys[2] = "bo";
keys[3] = "j";

但我不能说这是不是真的。

现在您可以搜索该表,并且密钥尽可能短。如果您点击密钥的末尾,则匹配。如果没有,则遵循标准的bsearch算法。

目标是将所有数据放在一起,并使代码保持简洁,以便它们都适合CPU缓存。您可以直接从程序中处理密钥,无需预处理或添加任何内容。

对于合理分布的相当多的密钥,我认为这会非常快。这实际上取决于所涉及的字符串数量。对于较小的数字,计算哈希值等的开销不仅仅是搜索这样的东西。对于更大的值,它是值得的。这些数字究竟取决于算法等。

然而,这可能是内存方面最小的解决方案,如果这很重要的话。

这也有简单的好处。

附录:

您对“字符串”之外的输入没有任何规范。还没有讨论你期望使用多少串,它们的长度,它们的共性或它们的使用频率。这些可能都来自“源”,但算法设计者没有计划。你要求的算法可以创建这样的东西:

inline int GetValue(char *key) {
    return 1234;
}

对于偶尔只使用一个密钥的小程序,一直到为数百万字符串创建完美哈希算法的东西。这是一个非常高的命令。

任何追求“挤压每一点性能”的设计都需要比“任何和所有字符串”更多地了解输入。如果您希望在任何条件下尽可能快地解决问题,那么问题空间太大了。

处理具有极长相同前缀的字符串的算法可能与处理完全随机字符串的算法完全不同。该算法可以说“如果密钥以”a“开头,则跳过接下来的100个字符,因为它们都是”。“

但是如果这些字符串是由人类提供的,并且他们使用相同字母的长字符串,而不是疯狂地试图维护这些数据,那么当他们抱怨算法表现不佳时,你会回复“你在做傻事,不要这样做“。但我们也不知道这些字符串的来源。

因此,您需要选择一个问题空间来定位算法。我们有各种各样的算法表面上做同样的事情,因为它们解决了不同的约束,并在不同的情况下更好地工作。

散列是昂贵的,布局散列图很昂贵。如果没有足够的数据,那么有比散列更好的技术。如果你有大的内存预算,你可以创建一个巨大的状态机,基于每个节点的N个状态(N是你的字符集大小 - 你没有指定 - BAUDOT?7位ASCII?UTF-32?) 。这将非常快速地运行,除非状态消耗的内存量会破坏CPU缓存或挤出其他东西。

你可能会为所有这些生成代码,但是你可能会遇到代码大小限制(你也没说什么语言 - 例如Java有64K方法字节代码限制)。

但是您没有指定任何这些约束。因此,很难为您的需求提供最高性能的解决方案。

答案 4 :(得分:0)

您想要的是查找表的查找表。 如果内存成本不是问题,你可以全力以赴。

const int POSSIBLE_CHARCODES = 256; //256 for ascii //65536 for unicode 16bit
struct LutMap {
    int value;
    LutMap[POSSIBLE_CHARCODES] next;
}
int GetValue(string key) {
    LutMap root = Global.AlreadyCreatedLutMap;
    for(int x=0; x<key.length; x++) {
        int c = key.charCodeAt(x);
        if(root.next[c] == null) {
            return root.value;
        }
        root = root.next[c];
    }
}

答案 5 :(得分:0)

我认为这就是找到正确的哈希函数。只要您事先知道键值关系是什么,就可以进行分析以尝试找到哈希函数以满足您的要求。以您提供的示例为例,将输入字符串视为二进制整数:

foo  = 0x666F6F (hex value)
bar  = 0x626172
bazz = 0x62617A7A

所有这些中的最后一列各有不同。进一步分析:

foo  = 0xF = 1111
bar  = 0x2 = 0010
bazz = 0xA = 1010

向右移位两次,丢弃溢出,每个都得到一个明确的值:

foo  = 0011
bar  = 0000
bazz = 0010

再次向右移位两次,将溢出添加到新缓冲区:     foo = 0010     bar = 0000     bazz = 0001

您可以使用它们来查询静态3条目查找表。我认为这个高度个人化的哈希函数将需要9个非常基本的操作来获得半字节(2),位移(2),位移和add(4)以及查询(1),并且很多这些操作都可以通过巧妙的装配使用进一步压缩。这可能比考虑运行时信息要快。

答案 6 :(得分:0)

你看过TCB了吗?也许在那里使用的算法可用于检索您的值。这听起来很像你试图解决的问题。根据经验,我可以说tcb是我用过的最快的密钥库查找之一。无论存储的密钥数量是多少,它都是一个恒定的查找时间。

答案 7 :(得分:0)

考虑使用Knuth–Morris–Pratt algorithm

预处理给定地图如下面的大字符串

String string = "{foo:1}{bar:42}{bazz:314159}";
int length = string.length();

根据string的KMP预处理时间将需要O(length)。 对于使用任何字词/密钥进行搜索,将O(w)复杂度,其中w是字/密钥的长度。

您需要对KMP算法进行2次修改:

  • 键应在加入的string
  • 中按顺序排列
  • 而不是返回true / false,它应该解析数字并将其返回

希望它能给出一个好的提示。