哈希表
哈希表是常用的数据结构,在理想情况下,查找和删除元素的复杂度为O(1)。在自己实现一个简单的哈希表的过程中,也加深对哈希表原理上理解,把一个个抽象的概念具体化。
实际上我们可能已经接触过简单的哈希表,考虑下面这个例子:
统计一个段落中,小写英文字母的个数。
我们需要遍历完整个段落,用容器存放每个字母的个数,伪代码如下:
vector<int> container;
for (char ch : paragraph) {
container[ch - 'a']++;
}
我们可以把container作为一个哈希表,ch-'a'是哈希表的key,value则是每个字母的个数。
1 哈希函数
前面我们发现数组可以作为哈希表的底层容器。英文字母中每个字母都是独一无二,而且只涉及到单个字母,场景简单,如果key是长度不一的字符串呢?假设哈希表的底层容器数组,我们需要一个函数,通过这个函数我们计算出来一个数值,根据这个数值我们得到数组的索引。我们对于这个函数的要求是,尽最大可能每个长度不一的字符串计算出来的值是不同的。这个函数就是哈希函数。哈希函数有很多种计算方法,而不是一种唯一的计算方法。本文使用的是FNV-1a哈希算法:
#define FNV_PRIME 16777619
#define OFFSET_BASIS 2166136261
/**
* @brief use FNV-1a to calculate hash value
*
* @param value
* @param len
* @return * unsigned int hash value
*/
unsigned int hash(char* value, unsigned int len)
{
unsigned int i;
unsigned int hash_val = OFFSET_BASIS;
if (value == NULL) {
LOG_ERR("Input param is null!");
}
for (i = 0; i < len; ++i) {
hash_val ^= value[i];
hash_val *= FNV_PRIME;
}
return hash_val;
}
字符串的哈希值是尽可能唯一的,如果哈希表底层数组足够大,那么我们可以把字符串的哈希值作为数组的索引,那么就可以找到存放的位置。但是实际上数组是不可能无限大的,因此采用取余作为索引:
unsigned int hash_val = hash((char*)(key->key), key->key_len);
unsigned long bucket_idx = hash_val % array_size; // array_size是数组长度
2 哈希碰撞
尽管哈希函数使计算的哈希值不重复,但是实际上总是会有重复的,并且由于数组的容量是有限的,取余计算后就会计算得到相同的数组索引,这种情况称之为哈希碰撞。也就是两个不同的字符串计算得到的数组下表相同。解决哈希碰撞常用的方法有4种,本文使用的是链地址法。哈希表和桶的结构体定义如下:
struct bucket
{
struct key_field key;
struct value_field value;
unsigned int hash_val;
struct bucket* next;
};
struct hash_map
{
unsigned long capacity;
unsigned long buckets_cnt;
float load_factor;
unsigned long size;
struct bucket* buckets;
};
当哈希表插入数据时,如果哈希表中没有对应的key,就会插入到桶对应的链表下。struct bucket中的next指针就是用来挂链表的。
prev_target = find_key_node_before(bucket, *key);
if (prev_target->next) {
copy_field((unsigned char*)(&(prev_target->next->value)), (unsigned char*)(&val));
} else {
prev_target->next = malloc(sizeof(struct bucket));
memset(prev_target->next, 0, sizeof(struct bucket));
copy_field((unsigned char*)(&(prev_target->next->value)), (unsigned char*)(&val));
copy_field((unsigned char*)(&prev_target->next->key), (unsigned char*)(&key));
}
3 rehash
当我们不停往哈希表中插入数据,到一定程度之后,再插入数据就会提高哈希碰撞的概率。这个时候我们就需要对哈希表进行扩容。那么如何评估当前需要进行rehash的操作呢?
装载因子的值是哈希表容量与已存放元素之比。在理想情况下,一个元素计算完哈希要插入哈希表时,不存在冲突的情况,哈希表的桶都是用来存放元素。但是实际上不可能不冲突,这里采用的是链地址法来解决冲突。当哈希表存放到一定容量后,再插入元素就需要rehash的操作。装载因子就是这个警戒线。rehash需要对数组进行扩容,但是我们要遵循什么样的原则进行扩容呢?本文才用和C++的unordered_map相同的策略,找到第一个大于当前数组长度的素数作为新的数组长度。
const unsigned long __prime_list[] = // 256 + 1 or 256 + 48 + 1
{
2ul, 3ul, 5ul, 7ul, 11ul, 13ul, 17ul, 19ul, 23ul, 29ul, 31ul,
37ul, 41ul, 43ul, 47ul, 53ul, 59ul, 61ul, 67ul, 71ul, 73ul, 79ul,
83ul, 89ul, 97ul, 103ul, 109ul, 113ul, 127ul, 137ul, 139ul, 149ul,
157ul, 167ul, 179ul, 193ul, 199ul, 211ul, 227ul, 241ul, 257ul,
277ul, 293ul, 313ul, 337ul, 359ul, 383ul, 409ul, 439ul, 467ul,
503ul, 541ul, 577ul, 619ul, 661ul, 709ul, 761ul, 823ul, 887ul,
953ul, 1031ul, 1109ul, 1193ul, 1289ul, 1381ul, 1493ul, 1613ul,
....
}
__prime_list是个已排序素数表,当前只展示一部分。可以通过二分法查找到第一个大于当前数组长度的素数。
static struct hash_map* rehash(hash_map* map)
{
struct bucket* tmp;
unsigned long new_bucket_cnt, old_bucket_cnt;
unsigned long rehash_size;
unsigned long idx;
unsigned int hash_value;
struct bucket* old_buckets = map->buckets;
struct bucket* prev, *p;
old_bucket_cnt = map->buckets_cnt;
new_bucket_cnt = lower_bound(__prime_list, sizeof(__prime_list) / sizeof(unsigned long), 100);
rehash_size = new_bucket_cnt * map->load_factor;
tmp = malloc(rehash_size * sizeof(struct bucket));
if (tmp == NULL) {
LOG_ERR("malloc memory for resizing hash map failed.");
return NULL;
}
memset(tmp, 0, sizeof(rehash_size * sizeof(struct bucket)));
map->buckets_cnt = new_bucket_cnt;
map->capacity = rehash_size;
map->buckets = tmp;
/* 遍历整个哈希表,把哈希表的内容放到新的哈希表中 */
for (idx = 0; idx < old_bucket_cnt; ++idx) {
prev = &old_buckets[idx];
while(prev->next) {
insert_element(map, &(prev->next->key), &(prev->next->value));
p = prev->next;
prev = p->next;
release_bucket(p);
p = NULL;
if (!prev) {
break;
}
}
}
free(old_buckets);
return map;
}
4 插入操作
本文的哈希表在插入元素的时候,是直接挂载桶的链表下面的。在插入元素之前,需要确定哈希表中是否存在要插入的元素,如果有就更新对应的节点,否则就生成一个新的节点挂载链表最后:
static void insert_element(hash_map* map, key_field* key, val_field* val)
{
struct bucket* prev_target;
unsigned int hash_val = hash((char*)(key->key), key->key_len);
unsigned long bucket_idx;
struct bucket* bucket;
bucket_idx = hash_val % map->buckets_cnt;
bucket = &map->buckets[bucket_idx];
/* 查找是否存在要插入的元素节点 */
prev_target = find_key_node_before(bucket, *key);
if (prev_target->next) {
copy_field((unsigned char*)(&(prev_target->next->value)), (unsigned char*)(&val));
} else {
prev_target->next = malloc(sizeof(struct bucket));
memset(prev_target->next, 0, sizeof(struct bucket));
copy_field((unsigned char*)(&(prev_target->next->value)), (unsigned char*)(&val));
copy_field((unsigned char*)(&prev_target->next->key), (unsigned char*)(&key));
}
}
在查找的时候使用了一个小技巧,我们去找要插入的元素的前一个节点。这样做的好处是,能够提高在插入或删除操作的效率。因为在删除链表节点时需要知道前一个链表节点。这个也是C++的unordered_map实现中使用的技巧。
static struct bucket* find_key_node_before(struct bucket* p_prev, key_field key)
{
struct bucket* p, *tmp_prev;
if (!p_prev) {
return NULL;
}
tmp_prev = p_prev;
for (p = tmp_prev->next; p; p = p->next) {
if (p->key.key_type != key.key_type ||
p->key.key_len != key.key_len ||
memcmp((void*)(p->key.key), (void*)(key.key), key.key_len)) {
tmp_prev = p;
continue;
} else {
break;
}
}
return tmp_prev;
}
5 删除操作
删除操作和插入操作类似,找到待删除节点的前一个节点,然后删除节点并释放内存:
bool hashmap_delete(hash_map* map, key_field key)
{
if (map == NULL) {
return false;
}
unsigned int hash_val = hash((char*)(key.key), key.key_len);
unsigned long bucket_idx;
struct bucket* bucket, *del_bucket;
struct bucket* prev_target;
bucket_idx = hash_val % map->buckets_cnt;
bucket = &map->buckets[bucket_idx];
prev_target = find_key_node_before(bucket, key);
if (prev_target->next) {
del_bucket = prev_target->next;
prev_target->next = del_bucket->next;
release_bucket(del_bucket); /* 释放桶节点的内存 */
} else {
return false;
}
return true;
}

浙公网安备 33010602011771号