哈希表概念
哈希表又被称为散列表,它通过建立键key与值value之间的映射,实现高效的元素查询
即我们想哈希表中输入一个键,则我们可以在O(1)的时间内查找到这个键key所对应的值
哈希表的数组实现
我们考虑最简单的情况,仅仅使用一个数组来实现哈希表
在哈希表中,我们将数组的每个空位称为桶,每个桶都可以存储一个键值对,因此查找键key就是查找到key所对应的桶,并在桶中取出值value
那我们如果通过键key来实现对桶的定位呢?
答:这是通过哈希函数实现的,哈希函数是将一个较大的输入空间映射到一个较小的输出空间,在哈希函数中,key代表的就是所有的输入空间,而输出空间就是所有桶的索引,即数组的下标,因此我们可以可以通过哈希函数得到该key对应的键值在数组中的存储位置
实现的步骤为:
1.通过某种哈希函数计算出哈希值
2.将哈希值对数组长度(桶的数量)取模,从而获得该key所对应的数组索引
哈希函数:
在我们设计哈希函数时,需要尽可能的简单,能够快速计算key对应的地址
哈希函数映射的地址应该均匀的分布在整个地址空间,可以避免冲突
简单的哈希函数实现
头文件:function.h //该文件包含了变量及函数的声明
#ifndef HASHFUCTION #define HASHFUCTION #define HASH_SIZE 20 //存储键对值 typedef struct { int key; //定义键 char *value; //定义值 }Pair; //使用数组来存储哈希表 typedef struct { Pair *buckets[HASH_SIZE]; }ArrayHashMap; //定义MapSet用于存储键的集合 typedef struct { void *set; //指向键或者值数组的指针 int len; //数组的长度 }MapSet; //函数声明 ArrayHashMap *newHashArrayMap(void); int hash_function(int key); void put(ArrayHashMap *hmap, const int key , char *value); void removeItem(ArrayHashMap *hmap , const int key); void pairSet(ArrayHashMap *hmap, MapSet *set); void KeySet(ArrayHashMap *hmap, MapSet *set); void valueSet(ArrayHashMap *hmap, MapSet *set); void print(ArrayHashMap *hmap); Pair *reseach_HASH(ArrayHashMap *hmap , const int key); #endif
函数定义的文件:function.c
#include"function.h" #include<stdlib.h> #include<string.h> #include<stdio.h> //构造一个哈希函数 ArrayHashMap *newHashArrayMap(void) { //创造一个哈希表,并为其分配内存空间 ArrayHashMap *hmap = malloc(sizeof(ArrayHashMap)); //返回新创造的哈希表的地址 return hmap; } //哈希函数的实现 int hash_function(int key) { return key % 10; } //向哈希表中添加新的元素 //hmap为要添加元素的哈希表,key为键,value为值 void put(ArrayHashMap *hmap , const int key , char *value) { //为新添加的元素分配内存空间 Pair *pair = (Pair *)malloc(sizeof(Pair)); //将新传入的键进行存储 pair->key = key; //存储新传入的值,因为C字符串以空字符结尾,因此需要加1 pair->value = malloc(sizeof(value)+1); strcpy(pair->value, value); //查找到key对应的next数组下标,将键值存储到哈希表中 int index = hash_function(key); hmap->buckets[index] = pair; } //删除哈希表中的元素 void removeItem(ArrayHashMap *hmap , const int key) { //先查找到key对应的数组索引 int index = hash_function(key); //删除哈希表中存储的值,释放该内存 free(hmap->buckets[index]->value); free(hmap->buckets[index]); hmap->buckets[index] = NULL; } //获取所有的键值对 void pairSet(ArrayHashMap *hmap , MapSet *set) { //指向Pair结构体的指针,用于存储键值对的数组 Pair *entries; //遍历哈希表,查找哈希表的元素 int i = 0 , index = 0; //计算哈希表中有效的键对数 int tatol = 0; for(i = 0 ; i < HASH_SIZE ; i++) { if(hmap->buckets[i] != NULL) tatol++; } //分配键对值的数组内存空间 entries = malloc(sizeof(Pair) * tatol); //遍历哈希表,将有效的键值对存储到entries数组中 for(i = 0; i < HASH_SIZE ; i++) { //判断哈希表中的元素是否有效 if(hmap->buckets[i] != NULL) { //将哈希表中的键存储到entries数组中对应位置的key成员中 entries[index].key = hmap->buckets[i]->key; //动态分配内存空间,便于存储字符串 entries[index].value = malloc(strlen(hmap->buckets[i]->value)+1); strcpy(entries[index].value , hmap->buckets[i]->value); index++; } } //将存储键值的数组指针赋值给MapSet中的set成员 set->set = entries; set->len = tatol; } //获取所有的键 void KeySet(ArrayHashMap *hmap , MapSet *set) { //定义一个指向整型数组的指针,用于存储键值 int *key; //循环计算器及数组下标 int i = 0, index = 0; //存储有效的键的数量 int tatol = 0; //查找有效的键 for(i = 0 ; i < HASH_SIZE ; i++) { if(hmap->buckets[i] != NULL) tatol++; } //为key数组分配内存空间 key = (int *)malloc(sizeof(int)*tatol); //再次遍历数组,如果key有效,则存储到key数组里 for(i = 0 ; i < HASH_SIZE ; i++) { if(hmap->buckets[i] != NULL) key[index++] = hmap->buckets[i]->key; } //将key数组传递给set,并传入有效值的数量 set->set = key; set->len = tatol; } //获得所有值 void valueSet(ArrayHashMap *hmap , MapSet *set) { //定义一个指向指针数组的指针,用来存储有效值 char **value; int i = 0 , index = 0; int tatol = 0; //计算有效值的数量 for(i = 0 ; i < HASH_SIZE ; i++) { if(hmap->buckets[i] != NULL) tatol++; } //为value分配内存空间 value = (char **)malloc(sizeof(char*) * tatol); //遍历哈希表,将有效值存储到value数组中 for(i = 0 ; i < HASH_SIZE ; i++) { if(hmap->buckets[i] != NULL) value[index++] = hmap->buckets[i]->value; } //将获取的有效值存储到MapSet,并传入有效值的数量 set->set = value; set->len = tatol; } //查找哈希表中的值 Pair *reseach_HASH(ArrayHashMap *hmap , const int key) { //通过key查找到其在哈希数组中的下标 int index = key % HASH_SIZE; Pair *pair = hmap->buckets[index]; if(pair == NULL || pair->key != key) return NULL; return pair; } //打印哈希表 void print(ArrayHashMap *hmap) { int i; //用于存储所有键值对的集合 MapSet set; //获取所有的键值对 pairSet(hmap,&set); //将set中的键值对转化为Pair类型的数组,因为set定义时是void类型的 Pair *entries = (Pair *)set.set; for(i = 0 ; i < set.len ; i++) { printf("%d -> %s\n",entries[i].key,entries[i].value); } free(set.set); }
主函数所在的文件main.c //对函数进行了测试
#include"function.h" #include<stdio.h> int main(int argc, char **argv) { //构造新的哈希表 ArrayHashMap *hmap = newHashArrayMap(); //向哈希表中添加元素 put(hmap,1001,"小白"); put(hmap,1022,"小红"); put(hmap,1064,"小篮"); put(hmap,1063,"小黑"); print(hmap); printf("\n"); //删除小红 removeItem(hmap,1022); print(hmap); printf("\n"); //查找key对应的元素 if(reseach_HASH(hmap,1010)) printf("1010 -> %s/n",reseach_HASH(hmap,1010)->value); else printf("NO\n"); if(reseach_HASH(hmap,1063)) printf("1063 -> %s/n",reseach_HASH(hmap,1063)->value); else printf("NO\n"); printf("\n"); return 0; }
测试所得的结果
哈希冲突
从本质上来看,哈希函数就是将所有的key构成的空间映射到数组索引构成的输出空间,而输入的空间往往会大于输出空间,因此理论上一定存在着多个输入对应一个输出的情况,例如
1001 % 10 = 1
1011 % 10 = 1
这种多个输入对应一个输出的情况,就是哈希冲突
我们可以知道,哈希数组容量的容量越大,那么多个key被分配到同一个桶中的概率就越低,冲突也就越少,因此我们可以通过扩容哈希表来减少哈希冲突,但是这种扩容的操作效率低
哈希冲突的解决方法
1.改变哈希表的结构,使得哈希表能够在发生冲突时也能正常工作
通过链式结构来进行解决
在原始的哈希表中,每个哈希桶只能存储一个键值对,链式结构将每个元素都转化为链表,将键值作为链表的节点,将所有发生冲突的值都存储在同一个链表中
此时使用链式地址来实现哈希表的操作就发生了变化,此时
查询元素:输入一个key,通过哈希函数找到对应哈希桶的索引,即可以访问的链表的头结点,通过该头结点去遍历链表,对比key值以查找目标键对值
添加元素:先通过哈希函数找到链表的头节点,然后将该元素作为节点再添加链表中
删除元素:通过哈希函数找到对应的链表的头结点,依次遍历链表找到符合条件的值进行链表节点的删除操作
缺点:
占用的内存空间大,查询元素效率低(遍历链表)
2.使用开放寻址的方法
开放寻址的方法是指不使用额外的数据结构,通过多次的探测来处理哈希冲突,包括线性探测,平方探测,多次哈希
线性探测
线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。
插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历,步长通常为1,即在原来的索引基础上+1
查找元素:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素,直接返回值,如果遇到空桶,说明目标元素不在哈希表中,返回空
例如:
200 % 100 = 0; index = 0
500 % 100 = 0; 发现冲突,index = 0+1 ;
缺点:
线性探测容易产生聚集现象,这又会导致哈希冲突发生的可能性增加
注意:我们不能直接在开放寻址的哈希表中直接删除元素,这样会导致空桶的产生,当我们查询元素时,线性探测会该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在
解决方法:
使用懒删除机制,即不直接从哈希表中删除元素,而是使用一个常量来代替它,空和该常量都代表这个桶为空,但线性探测到常量时应该继续遍历,因为剩下的部分可能存在键值对,
但是,懒删除会导致哈希表性能下降,因为每次删除操作都会产生一个删除标记,随着
常量数量
的增加,搜索时间也会增加,因为线性探测可能需要跳过多个常量才能找到目标元素,为此,考虑在线性探测中记录遇到的首个常量的索引,并将搜索到的目标元素与该常量交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。
平方探测
平方探测和线性探测一样,但是跨过的步长不一样,线性探测每次跨过固定的步长,而平方探测每次跨过的探测次数的平方的步数
这样就能缓解线性探测的聚集现象,因为每次跳过的距离更大,因此能让数据分布得更加均匀
缺点:
1.仍然有聚集现象,即某些位置比其他位置更容易被占用
2.由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它
多次哈希
在遇到冲突时,使用多个哈希函数进行探测
在插入元素时,如果发生冲突,则使用多个哈希函数,直到找到空位,在空位插入
在查找元素时,在相同的哈希函数的顺序下进行查找,直到找到目标元素再进行返回,如果遇到空位或者哈希函数已经全部使用,则说明该元素不存在,返回空
优点:
不容易产生聚集现象
缺点:
增加了计算的次数
总结
跟随Hello算法这本书使用C语言来实现了数组的哈希,理解了哈希的原理已经工作的方式,了解了哈希冲突发生的原因以及解决的办法,但使用的还不够熟练,而且没有进行解决哈希冲突的代码实现,需要进行补充学习