使用哈希表提高速度

2020-05-19 14:06:40

我分阶段编写了第四版Lisp Python Continuum(FLPC)的自托管编译器。当我完成解析器并给它提供越来越多的自己的源代码时,它运行得太慢了。我尝试了很多方法来加快速度,其中一个有用的方法是使用哈希表。

本文中的可运行示例代码是用更熟悉的Python编写的,而不是用FLPC编写的。在Python中,上述两个函数通常是__setitem__(self,key,value)和__getitem__(self,key)。但是,我们将避免使用过多的语言功能,以便可以更直接地翻译生成的原型。特别地,我们将假设Python列表是固定长度的,并且没有已经实现的索引和枚举之类的便利。

在哈希表之前,FLPC中的字典被实现为两个数组键和值,以便键[i]与每个索引i的值[i]相关联(即,get(keys[i])返回值[i])。

def array_index(array,key):for i in range(len(Array)):if array[i]==key:return I引发ValueError(f";value{key}未找到。";)def get(self,key):return self.values[array_index(self.key,key)]。

def set(self,key,value):try:i=array_index(self.keys[i])Expect ValueError:i=array_index(self.key,Empty)self.key[i]=key self.values[i]=value。

其中,Empty是设置给所有键的初始值,表示该索引处没有键[可调整大小]。在这里获取源代码。

现在让我们实现哈希表。我们实现的具体变体是具有开放寻址和线性探测的哈希表。

在KEYS数组中查找索引时,我们不是从数组的开头开始搜索,而是从数组的中间开始搜索,在到达末尾时回绕。

def array_index(array,key,i):While True:if array[i]==key:return i elif array[i]==Empty:Raise ValueError(f";value{key}不在字典";)i=(i+1)%len(Array)。

我们将使get和set仅根据密钥在某个位置开始搜索。

def get(self,key):返回self.values[array_index(self.key,key,start(Key)%len(self.key,key))]def set(self,key,value):try:i=array_index(self.key,key,start(Key)%len(self.key))Exclude ValueError:i=array_index(self.key,Empty,start(Key)%len(self.key))self.keys[i]=。

但是我们仍然需要定义Start(这里是源代码),知道为什么它有效,为什么它很快。

哈希表进行时空权衡。我们将选择一个较大的键(因此也就是值)数组,该数组比我们将实际存储在其中的键数多得多。在理想情况下,START在这个数组中散布足够多的键,以便我们将实际存储在哈希表中的所有键都从相同的索引开始搜索。也就是说,所有start(Key)%len(self.key)都是不同的。

则搜索始终在一个步骤中成功,并返回start(Key)%len(self.key)作为索引。这是因为在第一次将key设置为值之前,key[start(Key)%len(self.keys)]为空,或者之后keys[start(Key)%len(self.keys)]等于key。

我们最多只能走一步。以更多的内存为代价,我们已经获取并设置了与数组相同的运行速度,即使我们重新设置的密钥不是连续的。

关于我们如何选择发车,我不会说太多,但我的想法是选择一个尽可能分散发车位置的东西。

在不太理想的情况下,我们可以得到start(Key1)%len(Key)==start(Key2)%len(Key)。这就是所谓的索引索引。在这种情况下,写入键的两个键中的第一个将位于index,另一个位于index+1[级联]。关键字结束时距离其起始索引越远,搜索它所需的时间就越长。

键的值永远不会重写为一次为空,因此在以后的任何搜索中,我们将再次以相同的索引(在本例中为index+2)结束。

我们将选择start to be a hash function,将字符串映射到索引。关于散列函数,我们有很多要说的,我们不会这样做,而只需要选择一个已知的好的、易于实现的函数即可。

def djb2_hash(Key):h=5381 for c in key:h=(h<;<;5+h)+order(C))&;0xffffffff return hstart=djb2_hash。

关于这个特殊的功能djb2有一个相当困难的地方(但我不会在这里这样做)。

由于散列函数的目标是在我们的数组中均匀分布起始点,让我们看看如果我们的散列函数完全随机地选择开始位置,那么这是可行的(或者至少有很好的机会可行)。

下面是一个略微错误的分析,以得出这将需要多长时间的概念。或者,您可以改为读取正确的[LINEAR_PROBING]。

假设我们的哈希表有s个槽,已经有k个密钥,我们尝试插入一个新的k+1个密钥。

因为k+1个键有一个随机的起始位置,所以从k个槽中挑选一个已经有键的概率是k/s。如果我们运气不好,撞上了一个键,那么我们向前移动时撞到另一个键的概率是(k-1)/(s-1)。这是因为有s-1个插槽,不包括我们的起始插槽和那些插槽中的k-1个密钥。

这实际上不是真的,因为虽然在s-1个插槽中有k-1个密钥,但它们并不都一样有可能被填满!如果我们查看要检查的第i个时隙的随机时隙开始(KEY,i)而不是开始(KEY)+i,这将是真实的。

撇开这一点不谈,让我们继续吧。如果我们再次倒霉,看到一个空位,那么我们向前移动时击中另一个键的概率是(k-2)/(s-2)。我们可以继续走下去。

(k-i)/(s-i)至多为k/s,因此我们在搜索中看到t个已填满空位的概率至多为(k/s)t。我们看到的预期满位数(在最终找到空位之前)是这些值[sum]的总和。这至多是无穷和,如果我们现在计算插入密钥的最后一个空槽,它是一个值为1/(1-k/s)-1[几何]或1/(1-k/s)总槽数的几何级数。

这意味着如果我们有两倍于密钥的插槽,我们预计平均会有1/(1-1/2)=2个插槽!

线性探测的实际预期数量是1/(1-(k/s)2)[LINEAR_PROBING],这意味着我们将查看4个插槽,这仍然不是很糟糕,或者我们可能只有更多的插槽。

你会注意到,整篇帖子中明显没有按键删除,这让事情变得更简单了。如果我们只是将密钥设置为空来删除密钥,会发生什么情况呢?

作为一个以引导为中心的实现,FLPC对于字典来说面临着另一个鸡或蛋的问题:这篇文章中的实现需要对象和两个数组。但是对象需要查找属性,这些属性是使用字典实现的。实际上,我们需要一个字典,从函数名到存储函数的内存位置,或者字节码解释器的原语。

即使是简单的实现也需要某种类型的数组!为了解决这个问题,函数名称字典被硬编码到函数的主体中。

def names_get(Key):if key==";string 1";:如果key==";string 2";:返回value2 if key==";string 3";:返回值3.。

这并不能使我们前面所说的无效:确实,我们需要某种类型的数组。只是它恰好与我们用于函数的数组相同。FLPC函数在内存中表示为整数数组,每个整数数组编码一个内存位置、基元或(引用)常量。(有关该模型的详细说明,请参阅第四教程。(其他细节略有不同。)。但是它们没有提供单独的getter和setter方法。相反,memory y.get和memory y.set允许我们读写内存中的绝对位置(因此必须手动计算偏移量)。

我们仍然需要一个设置器来查找函数名。编译时,getter names.get的内存表示形式如下所示。

PICK1推送:";string 1";==PUSH:value1 return_ifick1推送:";string 2";==PUSH:value2 return_ifick1推送:";string 3";==PUSH:value3 return_if.end_of_function。

上面我们展示的是内存中的整数值代表的是什么,而不是整数本身。return_if接受布尔值和值,如果布尔值为TRUE,则返回值(如果布尔值为FALSE,则丢弃两者)。

因此,要设置一个新值,我们可以在names.get正文的末尾附加几个条目,例如PICK1 PUSH:";string 4";==PUSH:VALUE4 RETURN_IF,然后将调用移到end_of_function(请参阅functions.add in boot.flpc了解实际实现)。

这给我们带来了函数名称的朴素字典。要模仿本文前面实现的哈希表,我们需要能够从函数体中间的if语句开始,并且必须在末尾回绕。

在FLPC的引导序列中,我们将改用朴素的字典来实现从对象到哈希表对象的所有内容,然后重新绑定名称。进入使用这些哈希表的新函数hashtable.get(键名称)的主体。这解决了鸡或蛋的问题。天真的词典作为一堆if语句排在第一位。不幸的是,这意味着属性查找也使用某种简单的哈希表。它们看起来像这样。

hashtable.attrib<;-un[名称接收者搜索器]:return_if(string_equence(name";get";)hashtable.get)return_if(string_equence(name";set";)hashtable.set)return_if(string_equence(name";instance";)hashtable.instance)return_if(string_equence(name";print";)hashtable.print)。)memory y.get(接收方)return_if(string_equence(name";key";)memory y.get(接收方+1))return_if(string_equence(name";Values";)memory y.get(接收方+2))return_if(string_equence(name";type";)";hashtable";)return(instance_attrib(name_attrib(name接收方搜索器))。

还有一个剩余的挑战,我们必须将现有的键-值对插入到新的哈希表名称中。幸运的是,每个if语句占用7个单元格的固定长度。因此,我们可以遍历这部分内存并取出(已编译的!)。位于特定偏移量的键和值。这一切都发生在阶段1b2.flpc中。

CONVERT_NAMES<;-FUN[]:end=functions.end()index=names.get+5cond=not(index>;end)REPEAT_IF:drop1(`cond)Names。set(memory y.get(Index)memory y.get(index+3))index=`index+7 cond=not(index&>;end)。

INDEX和END将包含内存中的索引(=地址)。memy.get(Index)是一个字符串";string 1";、";string 2";依此类推,而memy.get(index+3)包含value1、value2等。

我使用时间对一些带有和不带有哈希表的预编译文件进行了计时。时间并不总是最好的工具,但在这里已经足够好了,可以得到更好的想法。我在活动前查看了其他结果,考虑加快命名速度。首先要做的就是做好这件事。

有点平淡无奇。它所做的一切只是让我们的速度提高了约4倍。但是我们可能已经将瓶颈从函数名查找上移开了。由于速度由最慢的部分(有时是部分)控制,即使是这种适度的加速也告诉我们,函数名查找是较慢的部分之一。

我们现在希望使用哈希表进行属性查找(比如上面的hashtable.attrib)。不幸的是,与names.get不同,每个if语句的主体在内存中不占用相同数量的单元格。例如

因为memy.get(Receiver+1)是在函数体本身中完成的。我们可以通过将每个值包装在函数中来潜在地避免这种情况。

而hashtable.get_()将只返回hashtable.get。但是取而代之的是,我们使用了一个难看的技巧,跳转表跳到了旧的hashtable.attrib(来自我们对hashtable.attrib的新定义)中。也许这个以后会被更好的东西取代。

[可调整大小]我们可以改为使用Python list的append,它可以随着列表的增长有效地调整列表的大小,但这会将要求从只需要数组增加到需要可调整大小的数组。而且我们不需要可调整大小的数组来实现固定/有界大小的哈希表。

[级联]这可能会产生级联效应。即使第三个键单独开始于index+1==start(Key3)%len(Key),key2可以首先写入key[index+1],因此key3在key[index+2]处结束(反之亦然)。

[LINEAR_PROBING]我找到的每一件事都可以追溯到“计算机编程的艺术”(The Art Of Computer Programming)卷3页,678-682页。不幸的是,它不是免费的,我的中等努力级别的搜索没有找到任何可以重新解释它的地方。这些页面上的一段有趣的话:

在这一点上,作者情不自禁地插入了一份传记:1962年,在开始从事计算机编程艺术的工作后不久,我第一次写出了下面这段话。因为这是我分析过的第一个令人满意的非平凡算法,所以它对这些书的结构有很大的影响。从那天起,算法分析实际上就成了我生活中的主要主题之一。

[SUM]基本上,如果我们先将矩阵的行或列相加,然后再将结果相加,这并不重要:我们最终将得到矩阵中所有条目的总和。

e1 e2 e3 e4..。E2 E3 E4..。E3 E4..。E4.

准确看到i个满时隙的概率与指示符随机变量用于准确看到i个满时隙的期望值Ei相同。预期的满位数是i*ei的i除以i(从1到k)的总和,这是先将列相加。我们计算的是至少看到i个满位的指标随机变量的期望值之和,这是先将行加起来(根据期望的线性)。

[几何]如果我们把t从0到无穷大的和乘以a,我们得到相同的和,除去第一项a 0=1。所以1-a乘以这个和是1,总和是1/(1-a)。