大家好,这里是物联网心球。
谈到Linux高性能编程,我们绕不开高效数据结构,今天我们来讲解哈希表,哈希表是使用非常广泛的数据结构,很多开源项目都会用到哈希表,Linux内核也大量使用了哈希表。
1.什么是哈希表?
在介绍哈希表之前,我们先来思考一个问题:我们如何通过学生名从学生信息表中快速查找出学生信息?
图片
为了从学生信息表中快速查找学生信息,我们需要借助一种高效数据结构哈希表来完成。
哈希表(Hash table,也叫散列表),是根据关键字(Key)直接进行访问的数据结构。它通过把关键字映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数(也叫散列函数),存放记录的数组叫做哈希表。
图片
哈希表最大的特点就是可以快速实现查找、插入和删除操作,由于哈希表直接通过关键字映射查找,时间复杂度接近于O(1)。
哈希表广泛应用于数据库索引、缓存系统、字典实现、计数器等多种场景。
2.哈希表重要概念
学习哈希表我们需要掌握几个重要概念:哈希函数,哈希冲突,哈希扩容。
2.1 哈希函数
哈希函数是将任意长度的输入消息映射为固定长度输出的函数,哈希函数有很多种构造方法:
- 直接定址法:取关键字或关键字的某个线性函数值为哈希地址,形式为H(key)=key或H(key)=a*key+b(a、b为常数)。
- 数字分析法:适用于关键字位数多且某些位分布不均的情况,通过抽取关键字中分布均匀的若干位作为哈希地址。
- 平方取中法:当关键字中各位分布不均时,可求其平方值并取中间的几位作为哈希地址。
- 除留余数法:最常用的方法,形式为H(key)=key mod p(p<m,m为哈希表长)。
- 折叠法:适用于关键字位数多且分布均匀的情况,将关键字分割后叠加求和作为哈希地址。
- 随机数法:取关键字的随机函数值作为哈希地址,适用于关键字长度不等的情况。
2.2 哈希冲突
哈希冲突是指不同输入经哈希函数得到相同输出。
具体来说,在使用哈希函数时,可能会出现两个或更多的输入值被映射到同一哈希值的情况,这就是哈希冲突。在哈希表中,这种现象会导致不同的键被映射到同一个位置,从而可能引发数据丢失或检索效率下降的问题,因为需要额外的操作来处理这种冲突。
图片
哈希冲突的发生是由于哈希函数将任意长度的输入转换为固定长度的输出,而输出空间通常远小于输入空间,因此不同的输入可能会映射到相同的输出地址上。
解决哈希冲突的方法包括开放寻址法、链表法等。
1)开放寻址法
当哈希表中的一个位置被占用时,此方法会寻找下一个可用的位置来存放数据。此方法避免了使用额外的数据结构,如链表,来存储具有相同哈希值的键值对。
图片
2)链表法
链表法通过维护一个链表数组来解决哈希冲突。
每个链表头指针存储在数组的一个槽位中,具有相同哈希值的所有元素都将存储在同一个链表中。
这种方法允许哈希表有更高的装载因子,因为它不受聚集问题的影响。
图片
2.3 哈希扩容
哈希扩容是哈希表在数据量达到一定阈值时增加其容量的过程。
这里需要引入一个概念装载因子。
哈希表装载因子 = 插入表中的元素个数 / 哈希表长度
当哈希表中的元素数量超过设定的装载因子与表长度的乘积时,会触发扩容操作。哈希扩容的目的是减少哈希冲突,提高查询和插入效率。
图片
3.哈希表编程
Linux内核很多模块使用了哈希表,我们参考Linux内核哈希表(hlist)来设计我们自己的哈希表。
哈希表重要数据结构如下:
struct hlist_node { //哈希节点
struct hlist_node *next, **pprev; //后驱指针,前驱指针
};
struct hlist_head { //哈希链表头
struct hlist_node *first; //first指针
};
typedef struct pack {
struct hlist_node node; //哈希节点
int seq; //关键字
} pack_t;
typedef struct hash_table {
char name[24]; //哈希表名称
int num; //哈希表元素数量
int size; //哈希表大小
struct hlist_head htable[0]; //哈希表
}hash_table_t;
1) 创建哈希表
hash_table_t *hash_table_create(int size) {
hash_table_t *t = (hash_table_t *)malloc(sizeof(hash_table_t) + size * sizeof(struct hlist_head));
if (!t) return NULL;
t->num = 0;
t->size = size;
sprintf(t->name, "%d hash table", size);
for (int i = 0; i < size; i++) {
hlist_head_init(&t->htable[i]);
}
return t;
}
哈希表采用柔性数组htable[0]表示,malloc申请内存时,除了申请hash_table_t结构大小内存外,还要申请哈希表数组大小内存。
图片
申请完哈希表数组后,需通过hlist_head_init函数对哈希表数组每个链表进行初始化。
2)插入节点
void hash_table_insert(struct hlist_node *node, int key, hash_table_t *t) {
int index = key & (t->size - 1); //哈希函数,计算索引值
hlist_add_head(node, &t->htable[index]); //插入节点
t->num++; //键值对加1
printf("%s num/size:%d/%d insert key:%d\n", t->name, t->num, t->size, key);
}
pack_t *pkt = malloc(sizeof(pack_t)); //创建数据包节点
hlist_node_init(&pkt->node); //初始化节点
pkt->seq = seq; //设置关键字
hash_table_insert(&pkt->node, pkt->seq, t); //插入哈希表
哈希表插入节点步骤:
- 通过关键字计算出哈希表数组索引值,通过索引值找到链表头。
- 将节点插入链表头。
图片
为了保证哈希表通用性,Linux内核通常会把节点域和数据域进行解耦,节点域只负责完成节点的插入,查找,删除,数据域可以根据业务需求自行定义。
测试程序中的struct pack结构的node成员为节点域,seq为数据域。
3) 查询节点
#define hlist_for_each_safe(pos, n, head) \
for (pos = (head)->first; pos && ({ n = pos->next; 1; }); \
pos = n)
hlist_for_each_safe(pos, n, &t->htable[index]) {
if (pos) {
//查询成功
}
}
哈希表查询节点步骤:
- 通过关键字计算出哈希表数组索引值,通过索引值找到链表头。
- 通过for循环轮询链表,直到找到关键字匹配成功的节点。
4) 删除节点
void hlist_del_node(struct hlist_node *node) {
struct hlist_node *next = node->next;
struct hlist_node **pprev = node->pprev;
WRITE_ONCE(*pprev, next);
if (next)
WRITE_ONCE(next->pprev, pprev);
}
void hash_table_del(int key, hash_table_t *t, do_del del) {
int index = key & (t->size - 1);
struct hlist_node *pos, *n;
//查询链表
hlist_for_each_safe(pos, n, &t->htable[index]) {
if (pos) {
hlist_del_node(pos); //从链表删除节点
del(pos); //释放节点内存
t->num--;
printf("%s num/size:%d/%d del key:%d\n", t->name, t->num, t->size, key);
}
}
}
哈希表删除节点步骤:
- 通过关键字计算出哈希表数组索引值,通过索引值找到链表头。
- 通过for循环轮询链表,通过关键字找到匹配的节点并删除节点。
删除节点的步骤和插入节点的步骤相反。
图片
5)哈希扩容
void hash_table_resize(hash_table_t *t1, hash_table_t *t2, do_move move) {
printf("%s num/size:%d/%d move to %s num/size:%d/%d\n",
t1->name, t1->num, t1->size,
t2->name, t2->num, t2->size);
struct hlist_node *pos, *n;
for (int i = 0; i < t1->size; i++) { //轮询旧哈希表每个链表
hlist_for_each_safe(pos, n, &t1->htable[i]) {
if (pos) { //查找到生效节点
hlist_del_node(pos); //删除节点
move(pos, t2); //将节点移至新哈希表
t2->num++;
}
}
}
}
if (t->num > t->size) { //判断负载因子,触发哈希扩容
t2 = hash_table_create(t->size << 1); //新哈希表扩容至2倍
if (!t2) {
printf("hash table create error\n");
return -1;
}
hash_table_resize(t, t2, pack_move); //开始扩容
hash_table_destroy(t, pack_del); //释放就哈希表
t = t2;
}
当哈希表元素越来越多时,此时整个哈希表的插入,查询,删除效率会越来越低,通过装载因子判断是否需要进行哈希扩容。
哈希扩容步骤:
- 创建新哈希表,新哈希表的大小为旧哈希表的2倍。
- 将旧哈希表的所有节点通过新哈希函数移至新哈希表。
- 删除旧哈希表。
6) 释放哈希表
void hash_table_destroy(hash_table_t *t, do_del del) {
struct hlist_node *pos, *n;
for (int i = 0; i < t->size; i++) { //删除哈希表生效节点
hash_table_del(i, t, del);
}
free(t); //释放哈希表内存
}
释放哈希表需要清空哈希表数组每个链表中的节点,防止内存泄露,最后释放哈希表。