C语言实现Hash Map(2):Map代码实现详解

在上一节C语言实现Hash Map(1):Map基础知识入门中,我们介绍了Map的基础概念和在C++中的用法。但我写这两篇文章的目的是,能够在C语言中实现这样的一个数据结构,毕竟有时我们的项目中可能会用到Map,但是C语言库中并没有提供相关的数据结构供我们使用。所以这一节,我们就来看一下在C语言中是如何实现Map的。

1 使用实例

我们学习的过程一定是从已经成熟运用的代码中学习的,所以本文就来学习一下Github中这个已经被很多人用在项目中的map库。文件很简单,就一个map.cmap.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;

根据上一节我们学到的知识,通过nbucketsnnodes的名字,我们就可以猜测其含义如下:

  1. nbuckets
    • 作用:表示哈希表中桶(bucket)的数量。桶是哈希表的基本存储单元,每个桶可以包含零个或多个键值对(节点)。
    • 用途:nbuckets 用于确定将键值对分配到哪个桶中。哈希值经过处理后,取模操作决定具体的桶索引。
  2. 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,就可以移植vPortMallocvPortFree,我这里使用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、floatdouble

大家可以试一下,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;
}

我们暂时不知道nbucketsbuckets数组在哪里设置的,还有它们的作用是什么。但是从这个函数大概可以知道,大概就是先求键的哈希值,然后去寻找一下是否有相同的键(有可能不同的键有同一个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_itermap_next,实际上就是链表的一些操作,本文就不做分析了。

3 总结

基于本篇文章,我们已经学习到了哈希map实现的基本逻辑。另外,前面我们有提到,这个代码在值声明为其它几个数据类型的情况下,根本运行不了,或者并不方便我们开发程序(有时我们希望直接传值而不是变量地址),然后还有内存分配和初始化桶数量的地方可以优化。那么下一篇文章,我们就来解决这些问题,并优化这个代码。

  • 9
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

tilblackout

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值