在上一节C语言实现Hash Map(1):Map基础知识入门中,我们介绍了Map的基础概念和在C++中的用法。但我写这两篇文章的目的是,能够在C语言中实现这样的一个数据结构,毕竟有时我们的项目中可能会用到Map,但是C语言库中并没有提供相关的数据结构供我们使用。所以这一节,我们就来看一下在C语言中是如何实现Map的。
- 参考代码:https://github.com/rxi/map
文章目录
- 1 使用实例
- 2 代码分析
- 2.1 map_init
- 2.1.1 map相关数据结构
- 2.1.1.1 变量声明
- 2.1.1.2 map_t
- 2.1.1.3 map_base_t
- 2.1.1.4 map_node_t
- 2.1.2 初始化函数
- 2.2 通用函数分析
- 2.2.1 map_hash:求哈希值
- 2.2.2 map_newnode:创建新节点
- 2.2.3 map_bucketidx:计算桶
- 2.2.3.1 桶的数量和哈希值的关系
- 2.2.4 map_addnode:添加节点
- 2.2.5 map_resize:重新调整哈希表大小
- 2.3 map_set:设置键值
- 2.3.1 函数参数和值sizeof
- 2.3.2 map_getref
- 2.3.3 添加新节点
- 2.4 map_get:获取键对应的值
- 2.5 删除键值和遍历
- 3 总结
1 使用实例
我们学习的过程一定是从已经成熟运用的代码中学习的,所以本文就来学习一下Github中这个已经被很多人用在项目中的map库。文件很简单,就一个map.c
和map.h
。我们拿到这个代码就可以直接使用的,非常简单:
#include <stdio.h>
#include <stdlib.h>
#include "map.h"
static map_str_t langMap;
int main()
{
char *ret;
map_init(&langMap);
map_set(&langMap, "test", "1234");
ret = map_get(&langMap, "test");
if(ret != NULL)
{
printf("%s\r\n", ret);
}else
{
printf("NULL\r\n");
}
return 0;
}
程序输出如下,可以看到我们初始化之后只需要设置键和值,然后使用map_get
函数即可获取对应键的值了。
下面我们就来分析一下这里面的代码。
2 代码分析
现在,我们就基于我上面写的一个简单的例子,来分析一下代码完成了哪些操作。
2.1 map_init
2.1.1 map相关数据结构
2.1.1.1 变量声明
这里我声明了一个langMap
变量:
static map_str_t langMap;
在map.h
文件中有声明不同的typedef:
typedef map_t(void*) map_void_t;
typedef map_t(char*) map_str_t;
typedef map_t(int) map_int_t;
typedef map_t(char) map_char_t;
typedef map_t(float) map_float_t;
typedef map_t(double) map_double_t;
这里键的类型固定是char *
,上面我的例子中使用的是map_str_t
这个typedef,实际上就是定义值的类型,也就是这里键和值都是char *
。如果想要值是其他的类型,定义其它的类型就行了。
2.1.1.2 map_t
接下来看一下这个宏定义:
#define map_t(T)\
struct { map_base_t base; T ref;}
可以看到就是根据用户提供的数据类型,声明一个对应的ref
变量。
2.1.1.3 map_base_t
再来看一下map_base_t
的数据结构,这实际上也是我们map
的核心数据结构:
typedef struct {
map_node_t **buckets;
unsigned nbuckets, nnodes;
} map_base_t;
根据上一节我们学到的知识,通过nbuckets
和nnodes
的名字,我们就可以猜测其含义如下:
nbuckets
- 作用:表示哈希表中桶(bucket)的数量。桶是哈希表的基本存储单元,每个桶可以包含零个或多个键值对(节点)。
- 用途:
nbuckets
用于确定将键值对分配到哪个桶中。哈希值经过处理后,取模操作决定具体的桶索引。
nnodes
- 作用:表示哈希表中当前存储的键值对(节点)的数量。
- 用途:
nnodes
用于跟踪哈希表中的实际元素数目。这个信息对于决定是否需要调整哈希表的大小(例如扩展或收缩)非常重要。当nnodes
达到nbuckets
的某个临界值时(如nnodes
等于或超过nbuckets
),哈希表需要进行扩展以保持较低的碰撞率和较高的性能。
2.1.1.4 map_node_t
在map.h
中,声明了map_node_t
:
struct map_node_t;
typedef struct map_node_t map_node_t;
这个结构体的实例在map.c
中:
struct map_node_t {
unsigned hash;
void *value;
map_node_t *next;
/* char key[]; */
/* char value[]; */
};
这里的几个参数有什么作用,后面我们在代码中碰到了再分析。
2.1.2 初始化函数
这里的map_init
函数实际上只是一个宏定义:
#define map_init(m)\
memset(m, 0, sizeof(*(m)))
只是将map_str_t
中各个数据结构清零,在有些RAM中,上电后初始值不一定为0,所以保险起见,还是清空一下。
2.2 通用函数分析
在分析设置键值函数之前,我们首先来学习一下后面可能会在函数中用到的一些通用的函数。
2.2.1 map_hash:求哈希值
map_hash
是一个用于计算字符串哈希值的函数。它采用了经典的 DJB2 哈希算法,这是一种快速且分布均匀的字符串哈希算法。以下是对 map_hash
函数的详细介绍:
static unsigned map_hash(const char *str) {
unsigned hash = 5381;
while (*str) {
hash = ((hash << 5) + hash) ^ *str++;
}
return hash;
}
map_hash
函数利用 DJB2 哈希算法计算一个字符串的哈希值。DJB2 算法的核心思想是通过不断地乘以一个质数(在这里是33:左移5位+1)并进行异或操作来更新哈希值,以确保哈希值的分布均匀并减少冲突。
这个哈希函数在哈希表的实现中扮演着重要角色,因为它决定了键在哈希表中的存储位置。哈希值的质量直接影响哈希表的性能,包括查找、插入和删除操作的效率。
2.2.2 map_newnode:创建新节点
前面我们提到节点的数据结构是map_node_t
,这个函数就是动态分配一个map_node_t
节点并返回,实现如下:
static map_node_t *map_newnode(const char *key, void *value, int vsize) {
map_node_t *node;
int ksize = strlen(key) + 1;
int voffset = ksize + ((sizeof(void*) - ksize) % sizeof(void*));
node = MAP_MALLOC(sizeof(*node) + voffset + vsize);
if (!node) return NULL;
memcpy(node + 1, key, ksize);
node->hash = map_hash(key);
node->value = ((char*) (node + 1)) + voffset;
memcpy(node->value, value, vsize);
return node;
}
1、内存的分配和释放函数
大家可以移植单片机中的,比如有FreeRTOS,就可以移植vPortMalloc
和vPortFree
,我这里使用c库里的内存分配函数:
#define MAP_MALLOC malloc
#define MAP_FREE free
2、((sizeof(void*) - ksize) % sizeof(void*))
这很明显就是根据CPU的位数(sizeof(void *))来进行字节对齐。
再回来看一下map_node_t
的数据结构:
struct map_node_t {
unsigned hash;
void *value;
map_node_t *next;
/* char key[]; */
/* char value[]; */
};
这里内存分配的总大小是sizeof(*node) + voffset + vsize
。其中,node
为上面声明的map_node_t
数据结构的总大小,然后voffset
为键所占的字节对齐后的内存大小,value
为值所占的内存大小。这里键和值的内存由于是不固定的,所以没有声明在结构体中,我们直接将键和值放在map_node_t
的后面。
**如果后续匹配了,怎么获取键值?**获取键很容易,就在map_node_t
最后,对于值的话,每次通过键设置或查值的时候,再计算一下voffset
就行了。
2.2.3 map_bucketidx:计算桶
map_bucketidx
函数用于确定一个哈希值应该被放置到哈希表的哪个桶(bucket)中。很明显这个函数通过将哈希值与哈希表中的桶数量进行模运算来计算桶的索引。
- 这里使用位与运算代替取模的话可以加快运算速度,但需要保证
nbuckets
的值是2n
static int map_bucketidx(map_base_t *m, unsigned hash) {
/* If the implementation is changed to allow a non-power-of-2 bucket count,
* the line below should be changed to use mod instead of AND */
return hash & (m->nbuckets - 1);
}
2.2.3.1 桶的数量和哈希值的关系
在哈希表中,桶的数量(nbuckets
)和哈希值之间的关系如下:
- 哈希值:由
map_hash
函数计算得到,它是一个无符号整数,用于唯一标识一个键。 - 桶的数量(
nbuckets
):表示哈希表中可用桶的数量。每个桶可以包含零个或多个键值对(节点)。 - 桶索引:由
map_bucketidx
函数通过位与运算计算得到,用于决定哈希值被分配到哪个桶中。
2.2.4 map_addnode:添加节点
由前面的buckets
的声明我们知道,buckets
可以理解为map_node_t
的指针的数组,数组中的每一个元素代表一个桶,每个桶也是map_node_t
,里面有一个next
参数,这类似于链表的数据结构,就可以连接当前桶内的所有节点。
static void map_addnode(map_base_t *m, map_node_t *node) {
int n = map_bucketidx(m, node->hash);
node->next = m->buckets[n];
m->buckets[n] = node;
}
所以上面的函数就很好理解了,就是把新节点插入桶中链表的最前面。
2.2.5 map_resize:重新调整哈希表大小
map_resize
函数用于调整哈希表的大小(桶的数量)。当哈希表中的节点数超过一定比例时,通过增加桶的数量来减小冲突,提高查找、插入和删除操作的效率。具体来说,map_resize
函数将重新分配哈希表中的所有节点,使它们分布在新的桶中。下面是该函数的详细解释:
static int map_resize(map_base_t *m, int nbuckets) {
map_node_t *nodes, *node, *next;
map_node_t **buckets;
int i;
/* Chain all nodes together */
nodes = NULL;
i = m->nbuckets;
while (i--) {
node = (m->buckets)[i];
while (node) {
next = node->next;
node->next = nodes;
nodes = node;
node = next;
}
}
/* Reset buckets */
buckets = realloc(m->buckets, sizeof(*m->buckets) * nbuckets);
if (buckets != NULL) {
m->buckets = buckets;
m->nbuckets = nbuckets;
}
if (m->buckets) {
memset(m->buckets, 0, sizeof(*m->buckets) * m->nbuckets);
/* Re-add nodes to buckets */
node = nodes;
while (node) {
next = node->next;
map_addnode(m, node);
node = next;
}
}
/* Return error code if realloc() failed */
return (buckets == NULL) ? -1 : 0;
}
简单分析一下上面的代码:
1、链表化所有节点
将所有节点串成一个单链表。遍历当前所有桶,将节点从桶中移除并加入到新的链表 nodes
中。
2、重新分配桶
使用 realloc
函数重新分配桶数组的内存,使其大小调整为新的桶数量 nbuckets
。如果 realloc
成功,更新哈希表的桶指针和桶数量。
- 注意:前面提到我们可以替换内存分配和释放的宏定义为自己的,但是这里又出现一个realloc函数,这个是在stdlib.h中的,在FreeRTOS中肯定是没有的,我们最好也不要用两种内存分配的方法,后面我们对这部分的代码做一些优化。
3、重新初始化桶
如果桶重新分配成功,则将新的桶数组初始化为 0,并将所有节点重新插入到新的桶中。通过 map_addnode
函数重新计算每个节点的桶索引,并将节点添加到对应的桶中。
2.3 map_set:设置键值
从前面的例子中,初始化之后就直接设置键值了:
map_set(&langMap, "test", "1234");
这也是这里map
实现的核心,这就是一个简单的宏定义:
#define map_set(m, key, value)\
( map_set_(&(m)->base, key, value, sizeof(value)) )
我们主要来看一下map_set_
是如何实现的:
int map_set_(map_base_t *m, const char *key, void *value, int vsize) {
int n, err;
map_node_t **next, *node;
/* Find & replace existing node */
next = map_getref(m, key);
if (next) {
memcpy((*next)->value, value, vsize);
return 0;
}
/* Add new node */
node = map_newnode(key, value, vsize);
if (node == NULL) goto fail;
if (m->nnodes >= m->nbuckets) {
n = (m->nbuckets > 0) ? (m->nbuckets << 1) : 1;
err = map_resize(m, n);
if (err) goto fail;
}
map_addnode(m, node);
m->nnodes++;
return 0;
fail:
if (node) MAP_FREE(node);
return -1;
}
2.3.1 函数参数和值sizeof
先来看一下函数的参数,其中m
就是map_base_t
变量的地址,key
就是键,value
就是值的地址,vsize
就是值的大小。现在这里有一个问题,我们使用sizeof(value)
来获取值的长度,值的类型有以下几种:
typedef map_t(void*) map_void_t;
typedef map_t(char*) map_str_t;
typedef map_t(int) map_int_t;
typedef map_t(char) map_char_t;
typedef map_t(float) map_float_t;
typedef map_t(double) map_double_t;
对于void *
、char *
和int
来说都没什么问题,分别返回4,字符串的长度(如果输入的是一个字符串常量的话)和4。但是:
1、char
:如sizeof('c')
:
在C语言中,字符字面量(例如 'c'
)的类型是 int
,而不是 char
。因此,sizeof('c')
实际上会返回 sizeof(int)
的值,这通常是 4 字节(在大多数现代系统上)。这可能与期望的 sizeof(char)
返回值(通常为1字节)不同。
2、float
和double
大家可以试一下,sizeof(3.14)
和 sizeof(2.71828)
实际上都会返回 sizeof(double)
,因为在C语言中,字面值浮点数默认为 double
类型。
也就是说,这里的sizeof并不是实际的大小。
注意:在这个仓库的readme中,使用的是map_int_t类型举例的:map_set(&m, “testkey”, 123),这样明显也是不行的,因为第三个参数是void *,这里却直接传了一个数字。按照数据类型来看,这里还要声明一个int变量,然后map_set传地址才行,那这样完全变成了void *类型的了 基于此,使用map_str_t
肯定是没有问题的,但是使用其它的几个数据类型,程序肯定有问题,要么编译不通过,要么通过了也内存越界,大家可以自己试一下。
也就是说,虽然这个map实现在github中是star比较多的,但是bug还是挺多的。我们有时可能还是希望可以直接设置值,而不是还要声明一个变量。所以本篇文章仅以map_str_t
例子举例,实际产品用这个也是没问题的。
好了,我们暂时不纠结这个数据类型的问题,至少整个代码的map
实现逻辑是没有问题的,只是兼容性这边出了点问题。下面我们开始分析map_set_
函数。
2.3.2 map_getref
首先执行的是map_getref
函数,下面是这个函数的实现:
static map_node_t **map_getref(map_base_t *m, const char *key)
{
unsigned hash = map_hash(key);
map_node_t **next;
if (m->nbuckets > 0) {
next = &m->buckets[map_bucketidx(m, hash)];
while (*next) {
if ((*next)->hash == hash && !strcmp((char*) (*next + 1), key)) {
return next;
}
next = &(*next)->next;
}
}
return NULL;
}
我们暂时不知道nbuckets
和buckets
数组在哪里设置的,还有它们的作用是什么。但是从这个函数大概可以知道,大概就是先求键的哈希值,然后去寻找一下是否有相同的键(有可能不同的键有同一个hash),如果有的话就返回这个节点指针的地址,没有的话就返回NULL。来看一下代码:
next = map_getref(m, key);
if (next) {
memcpy((*next)->value, value, vsize);
return 0;
}
如果该key
的节点已经存在的话,就直接修改这个节点的值即可,函数直接返回。
2.3.3 添加新节点
继续分析map_set_
中的代码:
/* Add new node */
node = map_newnode(key, value, vsize);
if (node == NULL) goto fail;
if (m->nnodes >= m->nbuckets) {
n = (m->nbuckets > 0) ? (m->nbuckets << 1) : 1;
err = map_resize(m, n);
if (err) goto fail;
}
map_addnode(m, node);
m->nnodes++;
return 0;
fail:
if (node) MAP_FREE(node);
return -1;
简单分析一下:
1、节点不存在则创建节点
2、如果当前节点数量超过或等于桶的数量,计算新的桶数量**(这里设置为当前桶数量的两倍)**,然后调用 map_resize
函数调整哈希表大小。
- 刚运行没初始化的时候,
m->nbuckets
设置为1
3、添加新节点到对应的桶中,并增加 nnodes
个数
- 注意:从代码中可以看出桶的数量是我们设置节点的时候动态增加的,而且使用的是realloc函数,后续我们可以优化为上电初始化后默认有n个桶
2.4 map_get:获取键对应的值
在前面的示例代码中,设置完键值之后就可以使用map_get
获取对应键的值了,返回值就是值的地址:
ret = map_get(&langMap, "test");
同样,这个函数也是一个宏定义:
#define map_get(m, key)\
( (m)->ref = map_get_(&(m)->base, key) )
- 前面用宏定义
map_t
声明的不同数据类型的宏定义中的ref
变量,只是用来临时保存值的,这个变量在其它地方都没有使用到。
所以我们就来看一下map_get_
函数的实现:
void *map_get_(map_base_t *m, const char *key) {
map_node_t **next = map_getref(m, key);
return next ? (*next)->value : NULL;
}
前面分析过map_getref
函数了:根据哈希值找到对应的桶,然后在桶中找匹配的哈希值,若哈希值匹配(有可能不同的键有同样的哈希值),再比较键,若匹配,返回键的值。
2.5 删除键值和遍历
代码中还提供了删除键值的函数map_remove
,还有遍历map的函数map_iter
和map_next
,实际上就是链表的一些操作,本文就不做分析了。
3 总结
基于本篇文章,我们已经学习到了哈希map
实现的基本逻辑。另外,前面我们有提到,这个代码在值声明为其它几个数据类型的情况下,根本运行不了,或者并不方便我们开发程序(有时我们希望直接传值而不是变量地址),然后还有内存分配和初始化桶数量的地方可以优化。那么下一篇文章,我们就来解决这些问题,并优化这个代码。