0. 前言
在当前的编程环境下, Python 有 dict,Java 有 HashMap, Go 有 map 还有 C++ STL 的 unordered_map。对于这些高级语言来说,要创建一个哈希表何其简单,甚至只需要一行代码,插入数据甚至都不需要关心内存分配状况。
但是,接受了这种便利的同时,往往是要付出相应的代价的。
当我们习惯了这些高级语言的黑盒操作,就很容易陷入知其然但不知其所以然的状态。这些高级语言他们往往封装了很多的底层细节,虽然这种高度抽象可以在一定程度上提高开发效率,但却在无形中降低了我们对性能和内存的敏感度。
C 语言的标准库十分贫瘠,没有字典,没有集合,甚至没有动态数组。相信大家在网上一定看到过这样一句话:C 语言的缺点是任何东西都要自己实现,但他的优点也是任何东西都要自己实现。
C 语言只给了我们最原始的工具:指针和内存。我们必须亲手定义每一个结构体。亲自向操作系统申请每一块内存,并在用完后归还。当冲突发生时,我们必须自己编写逻辑把数据串成链表。
本篇文章,我将用 C 语言写一个基于链地址法的哈希表。这样,不仅能加深对指针和内存管理的理解,还能看清那些高级语言包装下哈希表的真正面目。
最后,我将对这个哈希表进行适当的修改,来完成 leetcode 上面的两数之和问题。
1. 核心原理
在动手写代码之前,我们必须先对基础知识有一定的了解。这一章结束后,需要能够在大脑中有一个哈希表的大概结构,知道它是用来干什么的,怎么进行操作,以及它的优点是什么,又会有哪些问题存在。
1.1 数组遍历
为了能弄懂哈希表能起到什么样的作用,我们先来看看下面的一个例子。
现在我们有一个结构体,如下:
typedef struct test{
const char *name;
int id;
}Test;
假设有 10 个Test类型的变量,我们目前的做法是暂时用一个数组来存放这 10 个变量。
Test arrays[10];
好,现在我们已经把这 10 个变量存进去了。我们要达到的目的是给出一个name,找到与这个name对应的id,这实际上并不难,我们通常的做法是挨个遍历数组中的 10 个元素,看看哪个元素的 name 成员与开始给出的 name 相同,然后将这个元素的id成员返回出去就行了。核心思路如下:
for(int i=0;i<10;i++)
{
if(strcmp(arrays[i].name,name) == 0)
{
return arrays[i].id;
}
}
到这里,我们的目的是达到了。但是试想一下,如果数组中有 10000 个元素,而我们要找的恰好就是最后一个呢?如果还使用这种方法那么效率会变得十分低下,前面 9999 次都是在做无用功,这就是 O(N) 的复杂度。
1.2 数组下标以及哈希表核心思想
既然遍历数组效率很低,那有没有什么办法,让我们不用遍历就能知道要找的元素在哪呢?有的。
我们都知道,在使用数组时,访问元素最快的办法那就是通过下标了。就拿arrays[9999]来说把,如果我们已经知道了要查找的元素的数组索引就是 9999 ,那我们直接通过下标访问这个元素就可以,一次无用功都不需要做。
换句话说,在我们已经知道了要访问的元素的索引的前提下,访问这个元素的时间复杂度是 O(1) ,也就是说,访问这个元素要消耗的时间与数组中的元素个数无关,就算这个数组中有一亿个元素,我们也能瞬间访问到目标元素。
现在我们知道了,要想访问数组中的每一个元素,效率最快的方法是先通过某种手段得到它的索引。那么如果我们能把 name 直接转换成数组的下标,问题不就解决了吗?
这正是哈希表的核心思想:将我们已知的关键字name映射为它的宿主结构体在数组中的位置,也就是映射为数组索引。
那么问题又来了,我们该如何进行映射呢?
实际上,我们需要先设计一个函数,这个函数就是哈希函数,比如,我们可以这样设计:
unsigned int hash(const char *name)
{
unsigned int sum = 0;
for(int i=0;name[i]!='\0';i++)
{
sum+=name[i];
}
return (sum % TABLE_SIZE);
}
这段代码是对name这个字符串的 每个字符的 ASCII 码 进行求和,然后再将求和得到的这个数字对 哈希表长度 取余。
对于哈希表长度,这里先说一个规则:这个哈希表长度我们 尽量将它取为一个质数,因为质数在一定程度上能够让计算出的结果尽可能地分散在整个地址空间内,从而尽可能的减少冲突。这在哈希函数本身不够强时,效果尤为明显。此外,一定要对哈希表长度取余,如果取余的数大于哈希表长度会造成溢出,小于哈希表长度会造成最后的位置永远不会被使用。
打个比方,比如,要存的元素有 10 个,那我们就取哈希表长度为 11,取余也是对 11 进行取余。
现在,我们已经有了哈希函数,只需要将name作为参数传进去,我们就能得到一个unsigned int型的数字,而这个数字正是我们需要的数组索引。
现在来梳理一下整个流程,我们在存放元素之前,只需要将结构体的name成员通过哈希函数进行计算,从而得知这个结构体应该存放在数组的哪个位置。在查询的时候,我们只要将name通过哈希函数计算一下,就能得知它在数组中的索引是什么,从而能够瞬间就找到它。
1.3 哈希冲突
但是实际上这个公式并不是万能的,它又引入了一个新的问题。
假设我们要存的结构体中,有如下两个:
name1 = "abcd" id1 = 1
name2 = "dcba" id2 = 2
这时我们会发现,name1和name2通过哈希函数计算出来的数组索引是相同的。因为我们的哈希函数是通过计算name的字符 ASCII 码的总和最终得到数组索引的,而name1和name2虽然不相同,但他们都包含相同的字母,也就是说 ASCII 码总和是相同的。
对于name1和name2的宿主结构体要放在数组的同一个位置,这种情况叫做 哈希冲突 。无论哈希函数设计得多么精妙,只要数组的空间是有限的,冲突就绝对 无法避免。
1.4 链地址法
既然冲突不可避免,我们就要想办法解决它,解决冲突的方法有很多,比如开放寻址法,但最直观,最常用的方法是 链地址法。
它的思路很简单,既然一个位置可能要放多个元素,那我们就不用数组来存放上面的Test结构体,我们存放指向Test结构体的指针,也就是说这个数组实际上是一个指针数组,里面存放的元素全是指向Test的指针,而对于每一个元素,我们都把它看作一个链表头。在初始化时我们把数组中的每个元素初始化为NULL,当一个元素要放在这里的时候,我们先看看要放入的位置是不是为NULL,如果为NULL,那就直接把指向这个结构体的指针放进去没问题,如果不为NULL,那说明这个位置已经有指针存在了,我们将这个需要存放的新元素插入到这个位置已经存在的元素的最前面(链表的头插法)。这样就实现了,多个元素存放在数组的同一个位置。
这样看来,其实也可以说数组的每一个元素都是一个链表。
我们查找时 ,通过哈希函数计算得到这个索引值,然后去遍历这个索引位置的链表,在链表里面找对应的元素即可。
使用这种方法,不管有多少人冲突,我都能把他们串在这个位置后面。这就是为什么我们在定义哈希表节点时,需要一个 next 指针的原因。为了实现链表,我们需要改造一下最开始的结构体,不仅仅要存数据,还要存一个指针指向下一个人,通常我们将这种结构称为节点。
//改造后的结构体
typedef struct Node{
char key[50];
int val;
struct Node *next;//加入一个next指针用于连接节点
}Node;
1.5 图解
为了能够更加形象的描述这个结构,这里我画一张图:
如图,正如上面所讲,数组中存放的已经不是结构体了,而是指向结构体的指针。
刚开始,数组的所有元素(都是指针)都为NULL,当我们通过key得到一个索引时,就让这个索引位置的指针(本来为NULL)指向这个key所在结构体的地址。如上图索引为 6 处的情况。
如果通过key得到的索引,这个位置的指针不为NULL,这时我们需要采用头插法将它插进去。首先让要插入的结构体中的next指针指向数组该位置的指针指向的结构体,然后再让数组该位置的指针指向新插入的这个结构体。这样一来,abc 就变成了新的链表头,整个插入过程的时间复杂度始终保持在 O(1) 。
2. C 语言代码实现
2.1 定义数据结构
首先,我们需要把刚才设计的节点和哈希表定义出来。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define TABLE_SIZE 11 //哈希表大小
//定义节点结构体,使用链地址法解决冲突
typedef struct Node{
char key[50];
int val;
struct Node *next;
}Node;
//定义全局哈希表数组并初始化,指针数组,每个元素都是指向Node的指针
Node *hashTable[TABLE_SIZE] = {NULL};
这里,我们把哈希表的大小设为 11,也就是说 1.5 节图中数组的元素个数为 11。
然后是节点结构体,这里我们把 key 定义为字符数组,在里面存放一个字符串,相当于一个名字。使用字符数组是因为操作方便,如果定义字符指针的话,还需要malloc,用完要free,这里就不搞这么麻烦了。
还定义了一个int型的val,可以理解为这个名字对应的ID。
接下来就是最重要的,指向下一个节点的指针next,用来解决哈希冲突。
再下来定义了一个哈希表数组,并将数组中的元素全部初始化为NULL。到这里已经有了我们 1.5 节图的雏形了,有一点要提一下,这里一定要把全部元素初始化为NULL,否则他们都是一些垃圾值。
2.2 哈希函数
//哈希函数,这里把字符串中所有的字符ASCLL码加起来,然后对数组大小取模
unsigned int hash(const char *key)
{
unsigned int sum = 0;
for(int i=0;key[i]!='\0';i++)
{
sum+=key[i];
}
return (sum % TABLE_SIZE);
}
这里我们在传参时在key前面加上const防止函数中意外修改我们的key。
2.3 插入数据
这是最关键的一步,我们要把图解中的头插法转化为代码。
//插入数据
void insert(const char *key,int value)
{
unsigned int index = hash(key);//计算下标
//创建新节点
Node *new_node = (Node *)malloc(sizeof(Node));
if(new_node == NULL)
{
printf("malloc failed.\n");
return;
}
strcpy(new_node->key,key);
new_node->val = value;
new_node->next = NULL;
//链地址法处理冲突
if(hashTable[index] == NULL)
{
hashTable[index] = new_node;
}
else//发生冲突,采用头插法
{
new_node->next = hashTable[index];
hashTable[index] = new_node;
}
}
下面我们仔细分析一下这段代码。
首先我们的目的是把数据插入到哈希表数组里面去。那我们肯定要知道要插入的数据是什么,如果我们这里没有把哈希表数组定义为全局的,还需要额外把哈希表数组的地址传进来,因为我们要对哈希表数组进行修改。现在我们来看,这个函数需要传入两个参数,一个是我们用来查找的字符串key,另一个时我们最终要查找的值val。
在函数内部,我们需要先把传进来的字符串key通过哈希函数得到对应的索引。
然后,我们需要为即将插入的数据创造一个节点,也就是刚开始定义的节点结构体。要注意一点,这里创建的是指向Node结构体的指针,这是因为哈希表数组中存的就是指针。给他分配内存之后要检查是否成功,如果没有分配成功就进行接下来的操作将会导致段错误,引发程序崩溃。
在确认内存分配成功之后,我们将val和key都进行初始化,要注意字符数组要使用strcpy进行拷贝。然后将这个节点的next指针初始化为NULL,因为目前为止它还只是一个节点,没有与其他节点形成链表。
再下面,就要将这个节点插入哈希表数组了,同时还要处理哈希冲突的问题。原理我们已经分析过了,这里代码也很简单。
正如前面原理部分所讲,插入之前,我们先判断哈希表数组的index索引对应的位置的指针是否为NULL,如果为NULL,那说明这个位置还没有任何节点,我们是第一个,直接让这个指针指向我们新创建的节点结构体。如果不为NULL,这说明发生了哈希冲突,这时我们要采用头插法将我们新创建的节点结构体插进去,要知道一点,现在哈希表数组中这个位置的指针已经指向了一个节点结构体,我们先让我们创建的新节点的next指针也指向这个节点结构体,然后再让哈希表数组中这个位置的指针指向我们新创建的节点结构体。这样就大功告成了。我们已经成功将一个节点插入哈希表数组。
2.4 查找数据
下面就要实现我们最终要使用的功能,就是查找数据。
//查找数据
int search(const char *key)
{
unsigned int index = hash(key);//先计算下标
Node *current = hashTable[index];
//遍历这个元素位置的链表寻找数据
while(current != NULL)
{
if(strcmp(current->key,key) == 0)
{
return current->val;//找到了
}
current = current->next;
}
return -1;
}
前面原理部分讲过,我们通过字符串key进行查找,首先要做的就是先通过哈希函数计算出对应的索引。但是由于我们不知道哈希表数组的这个位置到底存了多少个节点结构体,更不知道,我们要找的在哪,因此,需要一个一个进行遍历,并判断是不是我们要找的。
由于hashTable[index]是固定指向该位置链表的头节点的,我们不能修管他的指向,因此只能新定义一个Node *类型的指针current用来遍历。
当这个current指针不为NULL时,我们用strcmp函数比较一下它指向的节点结构体中的key和我们要查找的key是否相同,如果相同,那就说明,我们找到要查找的key所在的节点结构体了,这时直接返回该节点的val即可,任务完成。如果判断这两个key不相同,就让current指向它的下一个节点,从而继续遍历剩下的节点。
2.5 释放内存
//释放内存
void freeTable(Node** hashTable)
{
for (int i=0;i<TABLE_SIZE;i++)
{
Node *current = hashTable[i];
while (current != NULL) //注意循环内的顺序
{
Node *temp = current;
current = current->next;
free(temp);
}
hashTable[i] = NULL;//释放内存后将指针置为NULL
}
printf("内存释放完成\n");
}
这里的参数为什么是Node** hashTable,这可能会造成疑惑。下面我先解释一下这个参数为什么要这么写,首先我们都知道,要传进来的哈希表数组里面存放的都是指针,这里有两个关键点,一个是数组,一个是指针。在我们学习 C 语言基础的时候,都会学到,将一个数组名作为参数传递给函数的时候,它会退化为指向该数组第一个元素的指针,而我们已经多次提到过,这个数组里面的元素都是Node*类型的指针,也就是说数组名作为参数传递给函数的时候退化成了指向Node *类型的指针,也就是说他本身是Node **类型,即二重指针。
再来看函数内部,我们需要遍历并释放掉这个哈希表数组的所有通过malloc得到的节点。外层for循环是针对哈希表数组元素的遍历,内层while循环是针对数组某个位置的链表的遍历。
这里实际上使用hashTable[i]直接进行遍历是可以的,因为我们是从前往后释放,释放完成他也要置为NULL。但是为了代码的可读性,我还是定义了一个current用于遍历。此外正是由于我们是从前往后进行释放的,导致前面的节点释放后无法找到它的下一个节点,因为后面的节点都是由前一个节点的next指针指着的,我们才能找到。因此,这里还需要一个temp指针,在释放前一个节点之前,让temp先指向这个节点的下一个节点,防止节点丢失。
while循环内的逻辑就很常规了,只是要注意一下顺序,让temp先指向current指向的节点,再让current指向下一个节点,这时我们已经记住下一个节点的位置了,然后释放temp指向的节点。
2.6 打印整个哈希表
其实到上面主要的逻辑就全部完成了,但是我还是想写一个打印函数,方便程序执行的时候观察哈希表的结构。代码如下:
//打印整个哈希表
void printfTable()
{
printf("哈希表当前情况:\n");
for(int i=0;i<TABLE_SIZE;i++)
{
if(hashTable[i] == NULL)
{
printf("位置[%d]:NULL\n",i);
}
else
{
printf("位置[%d]:",i);
Node *temp = hashTable[i];
while(temp != NULL)
{
printf("(%s,%d)",temp->key,temp->val);
temp = temp->next;
}
printf("\n");
}
}
printf("打印完毕\n");
}
依然是上面的思想,外层for循环遍历哈希表数组的元素,内层while循环遍历链表。
其他没什么要注意的,还有一点就是else分支中的printf函数涉及到 C 库的行缓冲。C 库的行缓冲一般是1024个字节,只要缓冲区溢出或者遇到'\n'时才会刷新缓冲区。这里利用了这一特点,从而使得不同printf打印的内容能显示在同一行。
2.7 完整代码
这一节,我放出完整的可以直接复制,然后编译运行的代码:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define TABLE_SIZE 10 //哈希表大小
//定义节点结构体,使用链地址法解决冲突
typedef struct Node{
char key[50];
int val;
struct Node *next;
}Node;
//定义全局哈希表数组并初始化,指针数组,每个元素都是指向Node的指针
Node *hashTable[TABLE_SIZE] = {NULL};
//哈希函数,这里把字符串中所有的字符ASCLL码加起来,然后对数组大小取模
unsigned int hash(const char *key)
{
unsigned int sum = 0;
for(int i=0;key[i]!='\0';i++)
{
sum+=key[i];
}
return (sum % TABLE_SIZE);
}
//插入数据
void insert(const char *key,int value)
{
unsigned int index = hash(key);//计算下标
//创建新节点
Node *new_node = (Node *)malloc(sizeof(Node));
if(new_node == NULL)
{
printf("malloc failed.\n");
return;
}
strcpy(new_node->key,key);
new_node->val = value;
new_node->next = NULL;
//链地址法处理冲突
if(hashTable[index] == NULL)
{
hashTable[index] = new_node;
}
else//发生冲突,采用头插法
{
new_node->next = hashTable[index];
hashTable[index] = new_node;
}
}
//查找数据
int search(const char *key)
{
unsigned int index = hash(key);//先计算下标
Node *current = hashTable[index];
//遍历这个元素位置的链表寻找数据
while(current != NULL)
{
if(strcmp(current->key,key) == 0)
{
return current->val;//找到了
}
current = current->next;
}
return -1;
}
//打印整个哈希表
void printfTable()
{
printf("哈希表当前情况:\n");
for(int i=0;i<TABLE_SIZE;i++)
{
if(hashTable[i] == NULL)
{
printf("位置[%d]:NULL\n",i);
}
else
{
printf("位置[%d]:",i);
Node *temp = hashTable[i];
while(temp != NULL)
{
printf("(%s,%d)",temp->key,temp->val);
temp = temp->next;
}
printf("\n");
}
}
printf("打印完毕\n");
}
//释放内存
void freeTable(Node** hashTable)
{
for (int i=0;i<TABLE_SIZE;i++)
{
Node *current = hashTable[i];
while (current != NULL)
{
Node* temp = current;
current = current->next;
free(temp);
}
hashTable[i] = NULL;
}
printf("内存释放完成\n");
}
//测试
int main()
{
insert("ainert",44);
insert("bhahpo[a",19);
insert("cequeppo",27);
insert("djxzak[",51);
insert("ej[psPQ",33);
insert("fqsiopq",21);
//故意制造冲突
insert("abcde",12);
insert("edcba",5);
insert("mnbvc",44);
insert("cvbnm",3);
printfTable();//打印看一下
//查找试试
char *find_name = "djxzak[";
int val = search(find_name);
if(val != -1)
{
printf("查到的值为%d\n",val);
}
else
{
printf("查找失败\n");
}
//查一下冲突的
find_name = "cvbnm";
val = search(find_name);
if(val != -1)
{
printf("查到的值为%d\n",val);
}
else
{
printf("查找失败\n");
}
//查一下不存在的
find_name = "lgeybak";
val = search(find_name);
if(val != -1)
{
printf("查到的值为%d\n",val);
}
else
{
printf("查找失败\n");
}
freeTable(hashTable);
return 0;
}
我补充了main函数,里面是用来测试的内容,大家可以运行试试,我下一章也会放出,运行的结果。
3. 代码运行结果
这里,我专门用 1.5 小节中图解的形式打印出来,方便大家对比观察哈希表的元素和链表的结构。
然后进行了验证,不管是发生了哈希冲突的,还是没有发生的,都可以成功找到对应的值。
4. 实战:两数之和
4.1 实现代码
这里我们先给出完整的代码:
#include <stdlib.h>
#include <math.h>
#define TABLE_SIZE 10001
typedef struct Node{
int key; //存nums[i]
int val; //存i
struct Node *next;
}Node;
//哈希函数
int hash(int key)
{
int h = key % TABLE_SIZE;
return (h<0)?(-h):h; //对负数处理,确保下标为正
}
//插入数据
void insert(Node **hashTable,int key,int val)
{
int index = hash(key);
Node *new_node = (Node *)malloc(sizeof(Node));
if(new_node == NULL)
{
return;
}
//头插
new_node->key = key;
new_node->val = val;
new_node->next = hashTable[index];
hashTable[index] = new_node;
}
//查找数据
Node *find(Node **hashTable,int key)
{
int index = hash(key);
Node *temp = hashTable[index];
while(temp != NULL)
{
if(temp->key == key)
{
return temp;
}
temp = temp->next;
}
return NULL;
}
void freeTable(Node **hashTable)
{
for(int i=0;i<TABLE_SIZE;i++)
{
Node *curr = hashTable[i];
while(curr != NULL)
{
Node *temp = curr;
curr = curr->next;
free(temp);
}
}
free(hashTable);
}
int *twoSum(int *nums,int numsSize,int target,int *returnSize)
{
Node **hashTable = (Node **)calloc(TABLE_SIZE,sizeof(Node *));//所有指针初始化为NULL
int *result = (int *)malloc(sizeof(int) * 2);
*returnSize = 0;
//遍历数组
for(int i=0;i<numsSize;i++)
{
int current_num = nums[i];
int want_find_num = target - nums[i];
Node *find_node = find(hashTable,want_find_num);
if(find_node != NULL)
{
result[0] = find_node->val;
result[1] = i;
*returnSize = 2;
freeTable(hashTable);
return result;
}
insert(hashTable,current_num,i);
}
freeTable(hashTable);
return NULL;
}
4.2 部分内容讲解
由于这个代码中大部分上面见过的重复内容,上面已经讲的很详细了,我这里就只讲这道题独特的部分。
首先,这里的key需要定义为int类型,因为我们要存的是数字,然后把哈希表的长度改为10001,满足题目要求的同时取质数。
哈希函数也有所修改,这里我们不像前面使用字符串查询数字,而是直接使用数字进行查询,因此,为了哈希函数的简便,我们直接使用key值对哈希表长度进行取余。此外,这里还需要考虑负数的情况,因为我们的数组索引不可能是负的,我使用了一个三目运算符保证返回结果为正。
插入函数,就像我上面讲的,第二章我们定义的是全局的哈希表数组,别的函数自然也可以访问。这里我们是在main函数里面定义的,因此在编写函数是需要留一个接口,以便函数能知道要修改的哈希表是哪一个。插入函数的其他内容基本相同,这里由于是测试程序,我就没有打印调试信息,甚至这个判断内存分配是否成功的操作都可以去掉。
后面的查找,释放函数基本和上面相同。
接下来就是最重要的twoSum函数了,函数内部定义了指向哈希表数组第一个元素首地址的指针,也就是一个二重指针,至于为什么是二重指针,前面也讲过了,值得注意的是这里使用calloc在分配好内存的同时初始化为NULL。
然后就是遍历题目给定的数组,也就是最重要的逻辑,虽然很重要,但其实并不难。其实可以把这个操作看成一个累积的过程,刚开始我们申请好的哈希表数组还是空的,开始第一轮循环。
我们先将数组第一个元素赋给current_num,如果要满足题目要求,我们就要找到值为target - nums[i]的元素所在的位置,这就简单了,我们直接使用find函数从哈希表数组里面查找就可以,不过这才第一轮循环,这时哈希表还是空的,毫无疑问目前我们还找不到,然后,我们将这个节点插入到哈希表中。
相信到这里大家心中已经明了了,我们会循环遍历题目给定数组的每一个元素,如果不能找到这个元素,就把这个元素和它对应的数组索引存到哈希表中,这期间哈希表中的节点会越来越多,直到我们找到匹配的数字。
5. 总结
到这里整篇文章就结束了,我们先学习了哈希表的原理,然后用 C 语言实现了哈希表并验证了成功性,最后我们基于前面写的哈希表实现代码,进行适当的修改,最终解决了两数之和问题。
C 语言虽然简陋,但它诚实,每一行代码的代价都清晰可见,我们甚至可以在运行程序之前提前想象到程序执行的结果,执行只是为了验证罢了。
事已至此,what can i say ? 既然选择了 C,便只顾风雨兼程。