如何实现哈希表(在c)中

2021-03-26 20:18:22

摘要:如何使用C编程语言实现简单的哈希表数据结构的说明。我简要展示了线性和二进制搜索,然后设计并实现了哈希表。我的目标是表明哈希表内部没有可怕,但 - 在某些约束范围内 - 很容易从头开始构建。

最近,我写了一篇文章,它比较了一个简单的程序,这些程序计算了各种语言的单词频率,以及提出的一些东西是C如何在其标准库中没有哈希表数据结构。

当您实现这一点时,您可以做很多事情:使用线性搜索,使用二进制搜索,抓取别人的哈希表实现,或写下自己的哈希表。或切换到更丰富的语言。我们将快速查看线性和二进制搜索,然后了解如何编写自己的哈希表。这通常是必需的,但如果使用另一种语言时需要自定义哈希表也可以很有用。

最简单的选择是使用线性搜索来扫描数组。如果您只有几个项目 - 在使用字符串的简单比较中,这实际上不是一个不好的策略,它比哈希表查找快到约7项(但除非您的程序非常敏感,否则这可能很好最多20或30件商品)。线性搜索还允许您将新项目附加到数组的末尾。使用此类搜索,您可以比较Num_keys / 2项的平均值。

假设您正在搜索以下数组中的键Bob(每个项目是具有关联整数值的字符串键):

您只需在开头开始(Foo在索引0)并比较每个密钥。如果关键与您正在寻找的键,则完成。如果没有,则移动到下一个插槽。搜索Bob需要五个步骤(索引0到4)。

以下是C中的算法(假设每个数组项是字符串键和整数值):

typedef struct {char *键; int价值; } 物品 ;项目* linear_search(项目*项目,size_t size,const char * key){for(size_t i = 0; i< size; i ++){if(strcmp(项目[i]。key,key)== 0 ){返回&物品[i]; }返回null; } int main(void){item项[] = {{" foo" ,10},{"酒吧" ,42},{" Bazz" ,36},{" Buzz" ,7},{"鲍勃" ,11},{" Jane" ,100},{" x" ,200}}; size_t num_items = sizeof(项目)/ sizeof(项目);项目*找到= linear_search(项目,num_items," bob");如果(!发现){返回1; printf(" linear_search:&#39的值; bob'是%d \ n",发现 - 值);返回0; }

另一种简单的方法是将项目放在一个按键排序的数组中,并使用二进制搜索来减少比较的数量。这是一种我们如何在(纸张)字典中的样子。

C甚至在其标准库中具有BSearch函数。二进制搜索即使是数百个项目(尽管与哈希表一样快),则是合理的,因为您只比较日志(num_keys)项的平均值。但是,由于阵列需要保持排序,因此无法在不复制其余的情况下插入物品,因此插入仍然需要NUM_KEY / 2操作的平均值。

通过二进制搜索,我们从中间开始(嗡嗡声),如果键大于我们正在寻找的键,我们将重复下半部分的过程。如果它更大,我们重复了更高的一半。在这种情况下,它会在索引3,1,2的步骤中产生三个步骤,然后我们拥有它。这是3个步骤而不是5个步骤,而且线性搜索的改进得到了(呈指数增长)更好地更好地更好地完成。

以下是您在c中的作用(有和没有bsearch)。项目结构的定义与上述相同。

int cmp(const void * a,const void * b){item * item_a =(项目*)a;项目* item_b =(项目*)b;返回strcmp(item_a - &gt; key,item_b - &gt; key); }项* binary_search(项目*项目,size_t size,const char * key){if(size + size&lt; size&lt; size){return null; //大小太大;避免溢出} size_t low = 0; size_t high = size;虽然(低&lt;高){size_t mid =(低+高)/ 2; int c = strcmp(项目[mid]。键,键); if(c == 0){return&amp;物品[mid]; }如果(c <0){low = mid + 1; }否则{high = mid; }}如果(低&lt; scize&amp; strcmp(项目[低]。键,key)== 0){return&amp;物品[低];返回null; } int main(void){item项[] = {{&#34; bar&#34; ,42},{&#34; Bazz&#34; ,36},{&#34;鲍勃&#34; ,11},{&#34; Buzz&#34; ,7},{&#34; foo&#34; ,10},{&#34; Jane&#34; ,100},{&#34; x&#34; ,200}}; size_t num_items = sizeof(项目)/ sizeof(项目);项目键= {&#34;鲍勃&#34; ,0};项目*找到= bsearch(&amp;密钥,项目,num_items,sizeof(项目),cmp); if(发现== null){返回1; Printf(&#34; bsearch:&#39的值; bob&#39;是%d \ n&#34;,发现 - 值);找到= binary_search(项目,num_items,&#34; bob&#34;); if(发现== null){返回1; Printf(&#34; binary_search:&#39的值; bob&#39;是%d \ n&#34;,发现 - 值);返回0; }

哈希表似乎很可怕:有很多不同的类型,以及您可以做的很多不同的优化。但是,如果使用一个名为“线性探测”的简单哈希函数,您可以很容易地创建一个体面的哈希表。

如果您不知道哈希表的工作原理,这里有一个快速的进修。哈希表是一个容器数据结构,允许您快速查找密钥(通常是一个字符串)以查找其对应的值(任何数据类型)。在引擎盖下,它们是由钥匙的哈希函数索引的阵列。

哈希函数将键变为随机查找号,并且必须始终返回给定相同键的相同数字。例如,通过我们将要使用的散列函数(64位fnv-1a),上述键的散列如下:

我展示了散列Modulo 16的原因是因为我们将以16个元素的数组开始,因此我们需要将散列限制在数组中的元素数量 - 模数运行划分为16并给出其余的,将数组索引限制为0到15的范围。

当我们将值插入哈希表时,我们会计算其哈希,模数到16,并使用它作为数组索引。因此,对于大小16的数组,我们将在索引10中插入栏,在8,4,等等。让我们将所有项目插入哈希表阵列(X除外 - 我们将在下面那样):

要查找值,我们只需获取阵列[哈希(key)%16]。如果数组大小是两个的功率,我们可以使用阵列[哈希(keh(key)&amp; 15]。注意元素的顺序如何不再有意义。

但是,如果两个键散列到相同的值(Modulo 16)是什么?根据散列函数和阵列的大小,这相当普遍。例如,当我们尝试将X添加到上面的数组时,它的哈希模数16是7。但我们已经在索引7中拥有foo,因此我们碰撞。

有各种处理碰撞的方式。传统上,您可以创建一定大小的哈希阵列,如果存在碰撞,则使用链接列表存储散列到同一索引的值。但是,链接列表通常需要额外的内存分配添加项目,并遍历它们意味着在内存中分散的指针,在现代CPU上相对较慢。

更简单和更快的处理碰撞方式是线性探测:如果我们尝试插入物品,但已经有一个已经存在了一个项目,只需移动到下一个插槽即可。如果下一个插槽也满是完整的,请再次移动,直到找到空的一个,如果击中数组的末尾,则会包装到开头。 (探测方法除了迁移到下一个插槽,还超出了这篇文章的范围。)这种技术比链接列表快得多,因为您的CPU的缓存可能已经获取了下一个项目。

在添加“碰撞”x(具有值200)后,哈希表数组的样子是什么样的。我们首先尝试索引7,但这持有foo,所以我们搬到索引8,但那是拿着bazz,所以我们再次移动到索引9,那是空的,所以我们将它插入:

当哈希表变得太满时,我们需要分配更大的数组并将物品移动到。当哈希表中的项目数达到阵列的大小时,这是绝对需要的,但通常您希望在表格为一半或三个季度时执行此操作。如果您不再提前调整大小,则碰撞将变得越来越普遍,并且查找和插入速度将变慢且较慢。如果你等到它几乎满了,你将基本上回到线性搜索。

通过良好的哈希函数,这种哈希表需要平均每次查找一个操作,加上钥匙的时间(但通常键是相对短的字符串)。

那就是它!你可以在这里做出巨大数量,这只是划伤了表面。我不会进入大o符号的科学分析,最佳阵列尺寸,不同类型的探测等。如果您想要这种细节,请阅读Donald Knuth的Taocp!

您可以在Github上找到此实现的代码,在github上,ht.h和ht.c.。对于它的价值,所有代码都在允许麻省理工学院许可证下发布。

我从代码审查堆栈Exchange获得了一些良好的反馈,有助于清除一些尖锐的边缘,这不是一个是由于我在ht_expand步骤期间调用strdup(修复此处)时的内存泄漏。我确认了使用Valgrind的泄漏,我应该早点跑。 Seth Arnold也给了我一些关于本文草案的有用反馈。谢谢,人们!

首先让我们考虑我们想要的api:我们需要一种方法来创建和销毁哈希表,获取给定密钥的值,为给定键设置值,获取项目的数量,并迭代物品。我不是针对最大效率的API,而是实施一个相当简单的效率。

经过几次迭代后,我解决了以下职能和结构(见HT.H):

//哈希表结构:使用ht_create创建,免费使用ht_destroy。 typedef struct ht; //创建哈希表并返回指针,或者如果内存不足则为null。 ht * ht_create(空白); //为哈希表分配的免费内存,包括分配的键。 void ht_destroy(ht *表); //从哈希表中获取具有给定密钥(NUL终止)的项目。返回//值(使用ht_set设置),如果找不到密钥,则为null。 void * ht_get(ht *表,const char *键); //将带有给定键(NUL终止)的项目设置为值(不得//为null)。如果尚未存在于表中,则将键复制到新//分配的内存(当ht_destroy为//调用时自动释放键)。返回复制密钥的地址,如果内存不足,则为null。 const char * ht_set(ht *表,const char *键,void *值); //返回哈希表中的项目数。 size_t ht_length(ht *表); //哈希表迭代器:使用ht_iterator创建,使用ht_next迭代。 typedef struct {char *键; //当前密钥void *值; //当前值// don&#39; t直接使用这些字段。 ht * _table; //引用哈希表迭代size_t _index; //将当前索引分为ht._entries} hti; //返回新的哈希表迭代器(与ht_next一起使用)。 hti ht_iterator(ht *表); //将迭代器移动到哈希表中的下一个项目,更新迭代器&#39; s键//和值到当前项,并返回true。如果没有更多//项目,则返回false。在迭代期间,Don&#39; t调用ht_set。 bool ht_next(hti *它);

为简单起见,我们使用C风格的NUL终止字符串。我知道串的处理有更有效的方法,但这适合C的标准库。

ht_set函数分配并复制键(如果第一次插入)。通常,您不希望呼叫者必须担心这一点,或确保关键记忆保持终止。请注意,ht_set返回指向重复密钥的指针。这主要用作“内存”错误信号 - 它返回失败时返回null。

但是,ht_set不会复制该值。这取决于来电者,以确保值指针对哈希表的生命周期有效。

值不能为null。这使得HT_Get的签名稍微简单,因为您不必区分为空值和尚未设置的一个。

HT_Length函数不是严格必需的,因为您可以通过迭代表来找到长度。然而,这是一点痛苦(和慢),所以拥有HT_Length非常有用。

我有各种方式可以做出迭代。使用具有一小时循环的显式迭代器类型似乎简单而自然,请参见下面的示例)。从ht_iterator返回的值是一个值,而不是指针,都是为了效率,因此呼叫者没有任何释放任何东西。

没有ht_remove从哈希表中删除项目。删除是线性探测的棘手的一件事(由于留下的“孔”),但我不需要在使用哈希表时删除物品,因此我将其作为读者练习作为练习。

以下是一个简单的程序(演示程序),它使用API​​的所有功能演示。它根据标准输入计算唯一,空间分隔的单词的频率,并打印结果(以任意顺序打印,因为我们的哈希表的迭代顺序未定义)。它通过打印唯一词的总数来结束。

//示例:// $ echo&#39; foo bar the bar bar bar the&#39; | ./demo // foo 1 // bar 4 // the // 3 void exit_nomem(void){fprintf(stderr,&#34;从内存\ n&#34;);出口(1); } int main(void){ht * counts = ht_create(); if(counts == null){ext_nomem(); } //从STDIN(最多100个字符长)读取下一个单词。字词[101];而(scanf(&#34;%100s&#34;,word)!= eof){//查找字。 void * value = ht_get(计数,单词); if(值!= null){//已存在,递增int该值指向。 int * pcount =(int *)值; (* pcount)++;继续 ;找不到单词,为新int分配空间并设置为1. int * pcount = malloc(sizeof(int)); if(pcount == null){ext_nomem(); } * pcount = 1; if(ht_set(count,word,pcount)== null){ext_nomem(); }} //打印出单词和频率,释放远程。 hti它= ht_iterator(counts);而(ht_next(&amp;它)){printf(&#34;%s%d \ n&#34;它。key,*(int *)它。值);免费(它。价值); } //显示唯一单词的数量。 printf(&#34;%d \ n&#34;(int)ht_length(counts)); ht_destroy(计数);返回0; }

分配新的哈希表相当直接。我们从初始数组容量开始为16(以容量存储),这意味着它可以在扩展之前最多容纳8项。有两个分配,一个用于哈希表结构本身,一个用于条目数组。请注意,我们使用Calloc for条目数组,以确保所有键都以null开头,这意味着所有插槽都是空的。

ht_destroy函数释放此内存,还可以从沿途分配的重复键(更多以下)中释放内存。

//哈希表条目(插槽可以填充或空)。 typedef struct {char *键; //键如果此插槽为空void *值,则为null; ht_entry; //哈希表结构:使用ht_create创建,免费使用ht_destroy。 struct ht {ht_entry *条目; //哈希槽size_t容量; // _entries array size_t长度的大小; //哈希表中的项目数量}; #define initial_capacity 16 //不得为零* ht_create(void){//为哈希表结构分配空间。 ht * table = malloc(sizeof(ht)); if(table == null){返回null;表 - &gt;长度= 0;表 - &gt;容量= initial_capacity; //分配(零&#39; d)入口桶的空间。表 - &gt;条目= calloc(表 - &gt;容量,sizeof(ht_entry)); if(表 - &gt;条目== null){免费(表); //错误,在我们回来之前免费表!返回null;返回表; void ht_destroy(ht *表){// firse free分配的键。 for(size_t i = 0; i&lt;表 - &gt;容量; i ++){if(表 - &gt;条目[i]。key!= null){free(表 - &gt;条目[i]。键); }} //然后是免费条目数组和表本身。免费(表 - &gt;条目);免费(表); }

接下来我们定义了我们的散列函数,这是FNV-1A散列算法的直接C实现。请注意,FNV不是随机或加密散列函数,因此攻击者可以创建具有大量冲突的键,并导致查找速度向下慢速 - Python因此原因切换远离FNV。但是,对于我们的用例,FNV很简单快捷。

就算法而言,FNV-1A只需使用“偏移”常量,以及字符串中的每个字节,XORS与字节的哈希值,然后将其乘以大素数。偏偏移和素数由博士学位的人仔细选择。

我们正在使用64位变体,因为好的,大多数计算机这些天是64位,似乎是一个好主意。你可以告诉我没有其中一个博士。 :-)认真,似乎比使用32位版本在我们有一个非常大的哈希表。

#define fnv_offset 14695981039346656037ul#define fnv_prime 1099511628211ul //返回键的64位fnv-1a散列(nul终止)。查看描述:// https://en.wikipedia.org/wiki/fowler-noll-vo_hash_function静态uint64_t _hash(const char * key){uint64_t hash = fnv_offset; for(const char * p =键; * p; p ++){hash ^ =(uint64_t)(无符号char)(* p); hash * = fnv_prime;返回哈希; }

我不会在此处进行详细的分析,但我已经包括一个小统计程序,它打印了从输入中的唯一单词创建的哈希表的平均探测长度。我们使用的FNV-1A哈希算法似乎在半百万英镑单词的列表中运行顺利(平均探测长度1.40),并且也适用于Word1,Word2和Word2和Word2等半百万个非常相似键的列表。所以(平均探针长度1.38)。

有趣的是,当我尝试了FNV-1算法时(如FNV-1A,但随着XOR之前的乘以完成),英语单词仍然给出了1.43的平均探测长度,但是类似的键非常糟糕 - 平均探测长度5.02。因此,FNV-1A在我的快速测试中是一个明确的赢家。

接下来让我们来看看HT_Get函数。 首先,它计算哈希,模数容量(条目数组的大小),它是通过anding的容量来完成的 - 1.使用且只是可能的,因为我们将在下面看到,我们确保我们的数组大小 为了简单起见,始终是两个的力量。 然后我们循环直到我们找到一个空的插槽,在这种情况下,我们没有找到密钥。 对于每个非空插槽,我们使用Strcmp检查此插槽的键是否是我们正在寻找的键(除非碰撞,否则它将成为第一个)。 如果没有,我们沿着一个插槽移动。 void * ht_get(ht * table,const char * key){//和哈希有容量-1,以确保条目阵列中的&#39; s。 uint64_t hash = hash_key(key); size_t index =(size_t)(hash&amp;(uint64_t)(表 - &gt;容量 - 1)); //循环直到我们找到空的条目。 而(表 - &gt;条目[index]。key!= null){if(strcmp(key ......