一、基础知识
哈希表又叫散列表,通过散列函数把键映射到数组的索引。
比如说,我们可以根据学号尾号不同将学员数据存放在不同的数组索引的空间中,如下图所示。
通过这个数组,我们想要查询张三的信息,就可以通过他的尾号来到下标为1的内存空间中查询他的信息。
- 那么怎么样获取学员学号尾号呢?
我们人类肉眼打眼一看就能看到尾号是多少,但是计算机不行,他得通过计算才能得到学员的尾号是多少,所以我们需要自己制定一个函数来求得学员的尾号,这个函数就叫做散列函数。 - 这时候就有朋友想要问问题了,要是我们几个学号尾号一样怎么办,那数组的相同空间每次更新数据了之前的就丢失了怎么办?
这是一个很典型的问题,叫做哈希冲突也叫散列冲突。
我们只能说冲突是无法避免的,只能够尽量减少。 - 你不要给我哇哇叫啊,你给我说出来减少的方法哇!!
减少哈希冲突的方法大抵有三种:
- 我们要选取尽可能平稳的,尽可能可以让所有数据平摊在所有的数组存储位置中。
散列函数构建的原则:执行速度快,关键字长度尽可能短,尽可能让元素均匀的分布在存储空间中。
这种方法其实也不是很好,你不管怎么选都会有重合的嘛,头都大了。我们来看第二种方法。 - 开放地址法:
开放地址法的原理就是:我通过散列函数计算得到了我要存放在这个位置,但是这个位置已经有了数据了,那么我就换个地方存储,怎么换呢,比如说我要存储到下标为2的位置,这个位置有了数据了那我就试试下标为3的位置,3也不行我试试4,4不行我试试5,等我找到空的我就存下来。
实际上这种方法也不是我们经常使用的方法,如果数据多,那总会有所有下标都存储了元素,而又有新元素存进来又会有冲突的问题,所以我们一般情况下都会采用第三种方法。 - 链地址法:
这种方法就是我们采取链表,数组的每一个元素都是链表的头结点,多一个元素我们尾插或者头插一个结点到这个头结点的后面,这种方法是比较合理的方法。我们本文就是主要讲解链地址法的数据结构代码。
链地址法如上图所示。
二、哈希表实现(链地址法)
我们知道,一个数组下标索引是一个关键字,后面跟着一个链表,链表的每个结点就是通过散列函数计算得到的元素。
所以我们定义两个结构体,一个是结点的结构体,一个是整个哈希表的数组的结构体。
结构体的大小我们需要根据散列函数计算后得到的关键字实际情况来具体的制定,包括散列函数我们在不同的情况下选取的散列函数也不相同。
1. 结构体和宏定义
# define Max 10
typedef struct Link
{
int data;
struct Link* next;
}Link;
typedef struct Hash
{
struct Link* arr[Max];
}
2. 初始化哈希表
Hash* CreateHash()
{
Hash* p = (Hash*)malloc(sizeof(Hash));
assert(p);
memset(p,0,sizeof(Hash));
return p;
}
3. 插入数据 – 头插法
将数据头插在链表的头部,和单链表头插不相同的是我们没有头结点,第一次插入的元素当做头结点,然后每次新插入的元素就是新的头结点,之前的头结点跟在新的头结点后面。
哈希表插入经常采用头插法,因为头插法的时间复杂度是O(1)
,而尾插法的时间复杂度是O(n)
。
我们在插入之前得知道这个元素要插入到那个关键字所代表的链表中,所以我们需要计算他的关键字,在这里我们是学号的最后一位,0-9
共有10
位,所以数组大小设置成了10
,取得最后一位的方法就是这个数字对10
进行模运算。
在不同的条件下这个offset
是不同的,我们也可以将计算关键字的这一步封装成一个函数,方便调用,这里比较简单就不赘述了。
void InsertHash(Hash* p,int num)
{
assert(p);
Link* node = (Link*)malloc(sizeof(Link));
assert(node);
memset(node,0,sizeof(Link));
node->data = num;
int offset = num % Max;
node->next = p->arr[offset];
p->arr[offset] = node;
}
4. 显示数据
void ShowHash(Hash* p)
{
assert(p);
int i = 0;
for(i; i < Max ; i++)
{
printf("%d :",i);
Link* tmp = p->arr[i];
while(tmp!=NULL)
{
printf("%d->",tmp->data);
tmp = tmp->next;
}
putchar('\n');
}
}
5. 查询数据
都是遍历,外层循环遍历哈希表的指针数组,内存循环遍历这指针数组存放的指针所跟的链表,和条件进行比对然后输出结果。
void SearchHash(Hash* p,int num)
{
assert(p);
int offset = num % Max;
Link* tmp = p->arr[offset];
int i = 0;
while(tmp!=NULL)
{
if(tmp->data == num)
{
printf("%d元素在关键字为%d的第%d位\n",num,offset,i);
return;
}
i++;
tmp = tmp->next;
}
printf("没找到\n");
}
6. 删除
计算完偏移量后找到这个关键字在指针数组中的位置,也就是这个关键字后面跟的链表的头结点。
我们在学习单链表等链表操作的时候知道删除一个结点需要两个指针的操作,我们需要知道前一个结点位置,才能删除这个结点后让前一个结点连接上删除掉的结点的后一个结点。
在这里也一样,我们刚开始只能找到头结点,然后定义一个前一个结点prev
先让他指向NULL
。
当头结点不为空的时候说明这个关键字后的链表存放东西了,这时候开始遍历,如果刚开始遍历的这个头结点就是要删除的结点,那么我们让头结点的下一个结点成为新的头结点,再删除掉之前的头结点就行。
如果删除的不是头结点,我们遍历的时候要让当前结点tmp
的前一个结点保存下来,让我们定义的prev
指针指向它。然后遍历遍历到tmp
是要删除的结点的时候,让前一个结点prev
连接上tmp
的下一个结点,然后删除掉tmp
就行。
void DeleteHash(Hash* p,int num)
{
assert(p);
int offset = num % Max;
Link* tmp = p->arr[offset];
Link* prev = NULL;
while(tmp!=NULL)
{
if(tmp->data == num)
{
if(prev == NULL)
{ //这种情况下是删除头结点
p->arr[offset] = tmp->next;
}
else
{ //这种情况下是删除中间结点或者尾结点
prev ->next = tmp->next;
}
free(tmp);
tmp = NULL;
return;
}
prev = tmp;
tmp = tmp->next;
}
}
7. 销毁
销毁就是遍历每一个结点,挨个释放它们
void DestroyHash(Hash* p)
{
assert(p);
int i = 0;
for(i; i < Max ;i++)
{
Link* tmp = p->arr[i];
while(tmp!=NULL)
{
Link* cur = tmp;
tmp = tmp->next;
free(cur);
}
p->arr[i] = NULL;
}
free(p);
}
三、总结
哈希表是非线性表,但是它的主要操作还是链表尤其是单链表,所以说单链表很重要,他基本从头到尾贯穿了整个数据结构,我们要学好单链表并且掌握单链表的思维。
哈希表说白了就是一个指针数组加单链表构成,指针数组存放的是每个关键字的链表的头结点。
哈希表比较重要的就是解决哈希冲突的方法,哈希函数。
在哈希表插入元素中用的最多的就是头插因为他的时间复杂度低,并且我们每个单链表的头结点就是一个元素,和之前写链表有个专门的头结点,然后后面的首元结点开始才是真正存储的数据这样的结构不同。所以我们要在头插的时候格外注意这个头结点。
还需要注意删除需要两个指针操作,所以当要删除的元素是头结点的时候,让下一个元素成为新的头结点。如果不是头结点那么在往后遍历的过程中保留下上一个结点,这样当找到要删除的元素了的时候,让上一个结点指向要删除的元素的下一个结点就好了,这样不会导致数据丢失。