zlog日志库源码解析 —— 数据结构:哈希表 zc_hashtable

zc_hashtable设计思想

哈希表(hash table),又叫散列表,是根据关键字和值(key-value)直接进行访问的数据结构。哈希表可实现O(1)查找时间复杂度,用于频繁查找的场景,能大大提高效率。哈希表通过关键字和一个映射函数hash(key) 计算出对应value,这个映射函数称为哈希函数。

常用的哈希表设计思想,是利用数组+链表来实现。其中,数组用来存放记录,链表用来解决冲突。用除留余数法计算key,求出对应位置hash(key) = key % p,p是数组长度,hash(key)就是数组索引。

实践中,注意:
1)数组往往作为冲突链表的头结点,而不存放数据。
2)先将key通过一个计算哈希值的算法,将其转换为hashCode,然后再用哈希函数进行位置计算,并不是直接对key调用哈希函数进行位置计算。

zc_hashtable数据结构

类似zc_arraylist,zc_hashtable用void*类型,来支持各种类型的key和value,因此也必须由调用者来实现具体的key和value的操作函数,包括申请内存、释放内存、判等、比较大小等。

每个zc_hashtable_entry_t都是一个哈希表表项,包含(hash_key, key, value),以双链表形式将冲突链表串到一起;而哈希表zc_hashtable_t主要负责维护一个哈希数组(zc_hashtable_entry_t **tab),每一项tab[i]都指向一个哈希表表项。

思考:为什么zc_hashtable_t要用二维数组形式,维护哈希数组,而不用一维数组呢?
Re:如果是二维数组,那么每个链表头结点都是一个指针来表示,1)很容易判断是否为空;2)容易移动链表头结点,直接指针赋值即可,代价很小,无需头结点每个成员都赋值。个人觉得用一维数组应该也是可以的,而且实现更简单,但运行效率更低。

/**
* 哈希表项, 存放(key,value)
* @details 使用双向链表来组织链表, 便于查找. 因此, 在数组中, 该结构对应一个表项;
* 在链表中, 该结构对应一个链表节点.
*/
typedef struct zc_hashtable_entry_s {
    unsigned int hash_key;  /* hash值 */
    void *key;
    void *value;
    struct zc_hashtable_entry_s *prev;
    struct zc_hashtable_entry_s *next;
} zc_hashtable_entry_t;

/**
* 哈希表
* a_table = zc_hashtable_t<key, value>
*/
struct zc_hashtable_s {
    size_t nelem;    /* 元素总个数 */

    zc_hashtable_entry_t **tab;   /* hashtable 数组, 每个表项作为冲突链表头节点 */
    size_t tab_size; /* hashtable 数组大小*/

    zc_hashtable_hash_fn hash;     /* 计算hash值的函数 */
    zc_hashtable_equal_fn equal;   /* 判断2个元素key是否相等的函数 */
    zc_hashtable_del_fn key_del;   /* 根据key删除元素 */
    zc_hashtable_del_fn value_del; /* 根据value删除元素 */
};

typedef struct zc_hashtable_s zc_hashtable_t;

typedef unsigned int (*zc_hashtable_hash_fn) (const void *key);
typedef int (*zc_hashtable_equal_fn) (const void *key1, const void *key2);
typedef void (*zc_hashtable_del_fn) (void *kv);

zc_hashtable接口

提供基本的对象创建、析构,添加元素,根据key查找和删除元素的接口。zc_hashtable还提供顺序迭代,默认计算哈希值算法,key判等,等接口。
值得注意的是:虽然zc_hashtable有rehash、扩容的功能,但并未提供接口以供外部访问,而是内部调用。

/* 构造一个zc_hashtable对象 */
zc_hashtable_t *zc_hashtable_new( size_t a_size,
                zc_hashtable_hash_fn hash_fn,
                zc_hashtable_equal_fn equal_fn,
                zc_hashtable_del_fn key_del_fn,
                zc_hashtable_del_fn value_del_fn);

/* 析构一个zc_hashtable对象a_table */
void zc_hashtable_del(zc_hashtable_t *a_table);
/* 删除a_table所有元素 */
void zc_hashtable_clean(zc_hashtable_t *a_table);
/* 将(a_key, a_value)加入哈希表a_table */
int zc_hashtable_put(zc_hashtable_t *a_table, void *a_key, void *a_value);
/* 根据(a_key, a_value)在a_table中查找项 */
zc_hashtable_entry_t *zc_hashtable_get_entry(zc_hashtable_t *a_table, const void *a_key);
/* 根据a_key在a_table中查找a_value */
void *zc_hashtable_get(zc_hashtable_t *a_table, const void *a_key);
/* 从a_table中移除关键字为a_key的项 */
void zc_hashtable_remove(zc_hashtable_t *a_table, const void *a_key);
/* 找到a_table第1项, 用于顺序迭代a_table */
zc_hashtable_entry_t *zc_hashtable_begin(zc_hashtable_t *a_table);
/* 找到a_table中a_entry项的下一项 */
zc_hashtable_entry_t *zc_hashtable_next(zc_hashtable_t *a_table, zc_hashtable_entry_t *a_entry);

/* 循环遍历a_table */
#define zc_hashtable_foreach(a_table, a_entry) \
    for (a_entry = zc_hashtable_begin(a_table); a_entry; a_entry = zc_hashtable_next(a_table, a_entry))

/* 计算字符串str的哈希值, 采用DJBX33A算法 */
unsigned int zc_hashtable_str_hash(const void *str);
/* 判断2个key是否相等, 将key1和key2当做字符串 */
int zc_hashtable_str_equal(const void *key1, const void *key2);

接口实现

构造与析构

zc_hastable的构造函数,主要有这几部分工作:
1)申请zc_hashtable_t对象空间;
2)申请哈希表数组(二维数组)zc_hashtable_t::tab空间;
3)初始化其他成员,包括tab数组大小,元素个数,计算hash值函数,判等函数;
4)初始化key和value的删除函数;

至于表项(zc_hashtable_entry_t)初始化的工作,留到插入数据时再做,因为此时哈希表中并无元素。

构造函数:

/**
* 创建一个hashtable
* @param a_size hashtable数组初始大小
* @param hash 计算哈希值的哈希函数
* @param equal 比较函数, 用于比较key大小
* @param key_del 删除key所指对象
* @param value_del 删除value所指对象
* @return 如果成功, 返回新建的hashtable对象; 失败返回NULL, 环境变量ZLOG_PROFILE_ERROR
* 所指error log记录错误原因.
*/
zc_hashtable_t *zc_hashtable_new( size_t a_size,
                zc_hashtable_hash_fn hash,
                zc_hashtable_equal_fn equal,
                zc_hashtable_del_fn key_del,
                zc_hashtable_del_fn value_del)
{
    // FIXME: why zc_hashtable' initial size decided by user,
    // but that of zc_arraylist decided by self(default value)?

    zc_hashtable_t *a_table;

    a_table = calloc(1, sizeof(*a_table));
    if (!a_table) {
        zc_error("calloc fail, errno[%d]", errno);
        return NULL;
    }

    /* malloc + bzero */
    a_table->tab = calloc(a_size, sizeof(*(a_table->tab)));
    if (!a_table->tab) {
        zc_error("calloc fail, errno[%d]", errno);
        free(a_table);
        return NULL;
    }

    /* 初始化hashtable各成员值 */
    a_table->tab_size = a_size;
    a_table->nelem = 0;
    a_table->hash = hash;
    a_table->equal = equal;

    /* these two could be NULL */
    a_table->key_del = key_del;
    a_table->value_del = value_del;

    return a_table;
}

析构函数:
销毁整个哈希表的,对每个元素(key, value)调用相应的delete函数以释放空间。遍历哈希表的每个元素,依托于以每个数组元素为链表起点,用一个两层for循环搞定。
注意:delete key或value时,先判断空指针,避免造成未定义行为(因为不清楚外部传入的delete函数如何处理空指针)。

/**
* 销毁整个hashtable
*/
void zc_hashtable_del(zc_hashtable_t *a_table)
{
    size_t i;
    zc_hashtable_entry_t *p;
    zc_hashtable_entry_t *q;

    if (!a_table) {
        zc_error("a_table[%p] is NULL, just do nothing", a_table);
        return;
    }

    /* 变量hashtable数组, 释放每个元素 */
    for (i = 0; i < a_table->tab_size; i++) {
        for (p = (a_table->tab)[i]; p; p = q) { /* 遍历每个数组项为头结点的链表 */
            q = p->next;
            if (a_table->key_del) {
                a_table->key_del(p->key);
            }
            if (a_table->value_del) {
                a_table->value_del(p->key);
            }
            free(p);
        }
    }

    if (a_table->tab)
        free(a_table->tab);
    free(a_table);
}

插入元素

往hashtable插入一个元素(key, value),相当于C++ unordered_map<key, value>::insert。不过,由于C没有模板,由于要兼容不同类型的key、value,这里我们插入的key和value都是void*。

插入元素的策略是:
1)先查找该元素是否存在,如果存在,先释放旧的key、value,再更新key、value,元素计数保持不变;
2)如果不存在,就将其插入到对应链表表头,元素计数+1;

一个元素(key, value),如何找到该插入到什么位置呢?
先计算key对应hash值hash_key = hash(key);
然后对hash_key进行除留余数法,得到数为元素应该插到数组的哪个位置(哪个链表),index = hash_key % tab_size。
以tab[index]为链表起点,遍历冲突链表,顺序查找key值相等的项。如果能找到,说明元素已经存在;如果找不到或者链表为空,说明元素不存在。

如果插入元素后,哈希表元素太多导致查找效率降低怎么办?如何判断哈希表元素太多?
从上面问题分析可知,当冲突链表中元素过多时,哈希表退化成链表,查找效率由O(1)降为O(n)。当插入新元素时,如果哈希表当前元素个数nelem > 1.3 * 数组大小tab_size,就对哈希表进行扩容rehash(再散列)(见下文)。

/**
* 向hashtable插入一个元素(key, value)
* @details a_key和a_value应该由调用者提供, 并负责其初始化. 在冲突链表上插入项,
* 实际上用的直接插入查找算法在链表尾部插入(O(n)), 而非效率更高的头插法(O(1)).
*/
int zc_hashtable_put(zc_hashtable_t *a_table, void *a_key, void *a_value)
{
    unsigned int i;
    zc_hashtable_entry_t *p = NULL;

    /* 首先查找是否存在相同(key, value), 如果存在, 释放原来的key,value空间, 然后更新value;
     * 如果不存在, 就添加一个新节点(key, value)
     */

    i = a_table->hash(a_key) % a_table->tab_size; /* 除留取余法 */
    for (p = (a_table->tab)[i]; p; p = p->next) { /* 遍历链表, 找key对应节点 */
        if (a_table->equal(a_key, p->key)) {
            break;
        }
    }

    if (p) { /* a_key exist in hashtable */
        /* 释放旧的key, value空间 */
        if (a_table->key_del) {
            a_table->key_del(p->key);
        }
        if (a_table->value_del) {
            a_table->value_del(p->value);
        }
        /* 设置新的(key, value) */
        p->key = a_key;
        p->value = a_value;
        return 0;
    } else { /* a_key not exist in hashtable */
        if (a_table->nelem > a_table->tab_size * 1.3) { /* 负载因子 > 1.3, 需要rehash扩容 */
            int rc;

            rc = zc_hashtable_rehash(a_table);
            if (rc) {
                zc_error("rehash fail");
                return -1;
            }
        }

        p = calloc(1, sizeof(*p)); /* 新建链表节点, 用于存放(key, value) */
        if (!p) {
            zc_error("calloc fail, errno[%d]", errno);
            return -1;
        }

        p->hash_key = a_table->hash(a_key);
        p->key = a_key;
        p->value = a_value;
        p->next = NULL;
        p->prev = NULL;

        /* 如果对应链表非空, 就插入到链表头部
         * 注意: 双向链表不是双向循环链表
         */
        i = p->hash_key % a_table->tab_size;
        if ((a_table->tab)[i]) {
            (a_table->tab)[i]->prev = p;
            p->next = (a_table->tab)[i];
        }
        (a_table->tab)[i] = p;
        a_table->nelem++; /* 总元素个数+1 */
    }

    return 0;
}

注意:
1)zc_hashtable负责回调key、value的delete函数,但并不负责key、value的构造。因此在使用时,需要十分小心。

扩容rehash

zc_hashtable提供再散列(rehash)机制,通常rehash发生在哈希表容量扩大或缩小时,zc_hashtable只提供扩容rehash: zc_hashtable_rehash。

zc_hashtable_rehash 提供固定的2倍扩容,每调用一次就扩容一次。因此,元素是否过多的判断应该由调用者完成。扩容后,需要先把旧哈希表空间的元素“拷贝”到新空间,然后释放旧的空间。
注意:这里的“拷贝”并不是简单的再拷贝,而是重新计算其在数组中的位置,插入对应链表,即rehash(再散列)的过程。

/**
* 扩容 + rehash(再散列)
*/
static int zc_hashtable_rehash(zc_hashtable_t *a_table)
{
    size_t i;
    size_t j;
    size_t tab_size;
    zc_hashtable_entry_t **tab;
    zc_hashtable_entry_t *p;
    zc_hashtable_entry_t *q;

    zc_assert(a_table != NULL, );

    /* 申请新的hashtable, 数组容量为旧的2倍 */
    tab_size = 2 * a_table->tab_size; /* 数组扩容2倍 */
    tab = calloc(tab_size, sizeof(*tab));
    if (!tab) {
        zc_error("calloc fail, errno[%d]", errno);
        return -1;
    }

    /* 将旧hashtable数据拷贝到新hashtable */
    for (i = 0; i < a_table->tab_size; i++) {
        for (p = (a_table->tab)[i]; p; p = q) { /* p代表当前表项, q代表p->next */
            q = p->next;

            p->next = NULL;
            p->prev = NULL;
            /* 重新计算在新数组中的位置 */
            j = p->hash_key % tab_size;
            if (tab[j]) {
                tab[j]->prev = p;
                p->next = tab[j];
            }
            tab[j] = p;
        }
    }

    free(a_table->tab); /* 释放旧的hastable数组 */
    /* 更新新的hashtable数组 */
    a_table->tab = tab;
    a_table->tab_size = tab_size;

    return 0;
}

查找元素

zc_hashtable提供了2个接口,用于查找元素:
1)zc_hashtable_get_entry 根据key查找项zc_hashtable_entry_t;
2)zc_hashtable_get 根据key查找value;

由于插入数据(zc_hashtable_put)时,如果key相同,就会用新value覆盖旧value,而对旧的(key, value)调用delete函数以释放空间,因此,zc_hashtable中不存在相同key的元素。也就是说,zc_hashtable的数据(key)具有唯一性。

/**
* 根据key查找项
*/
zc_hashtable_entry_t *zc_hashtable_get_entry(zc_hashtable_t *a_table, const void *a_key)
{
    unsigned int i;
    zc_hashtable_entry_t *p;

    i = a_table->hash(a_key) % a_table->tab_size;
    for (p = (a_table->tab)[i]; p; p = p->next) { /* 从冲突链表头开始查找 */
        if (a_table->equal(a_key, p->key))
            return p;
    }

    return NULL;
}

/**
* 根据key查找value
*/
void *zc_hashtable_get(zc_hashtable_t *a_table, const void *a_key)
{
    unsigned int i;
    zc_hashtable_entry_t *p;

    // 下面代码等价于
    // p = zc_hashtable_get_entry(a_table, a_key);
    // return (!p ? NULL : p->value);

    i = a_table->hash(a_key) % a_table->tab_size;
    for (p = (a_table->tab)[i]; p; p = p->next) {
        if (a_table->equal(a_key, p->key))
            return p->value;
    }
    return NULL;
}

删除元素

根据key删除哈希表中的元素(key, value)。元素位于以数组项为起点的某个冲突链表中,需要先查找,然后再将其充链表中删除。

查找的过程,前面已经讲过:
1)计算数组索引index = hash(key) % tab_size;
2)以tab[index]为头结点,顺序查找冲突链表;

如果查找链表失败,则删除失败;如果查找成功,则说明元素存在,需要将其从链表中断开,然后delete元素。

如何将一个节点从链表断开?
冲突链表是一个双向链表(注意不是双向循环链表),删除的节点可能是表头,或者表中节点(或表尾)。
表头特点:prev为空;表尾特点:next为空;
而数组始终指向表头,因此将节点从链表中删除时,需要特别小心表头节点的处理。

/**
* 根据key删除项
*/
void zc_hashtable_remove(zc_hashtable_t *a_table, const void *a_key)
{
    zc_hashtable_entry_t *p;
    unsigned int i;

    if (!a_table || !a_key) {
        zc_error("a_table[%p] or a_key[%p] is NULL, just do nothing", a_table, a_key);
        return;
    }

    /* 查找key为a_key的项 */
    i = a_table->hash(a_key) % a_table->tab_size;
    for (p = (a_table->tab)[i]; p; p = p->next) {
        if (a_table->equal(a_key, p->key))
            break;
    }

    if (!p) { /* 查找失败 */
        zc_error("p[%p] not found in hashtable", p);
        return;
    }

    /* 查找成功, 此时p指向待删除项 */

    /* 释放原有的key, value */
    if (a_table->key_del) {
        a_table->key_del(p->key);
    }
    if (a_table->value_del) {
        a_table->value_del(p->value);
    }

    /* 断开链表与p的连接 */
    if (p->next) {
        p->next->prev = p->prev;
    }
    if (p->prev) {
        p->prev->next = p->next;
    } else {
        /* 双向链表prev为NULL, 表示该节点是头结点
           直接将p的下一个节点(next)衔接到头结点 */
        i = p->hash_key % a_table->tab_size;
        a_table->tab[i] = p->next;
    }

    free(p); /* 释放p所指项 */
    a_table->nelem--; /* 总元素个数-1 */
}

顺序迭代哈希表

如果外部用户要遍历哈希表,但又不想了解哈希表的细节实现,比如数组+链表的实现。该如何实现?
zc_hashtable提供了宏函数zc_hashtable_foreach,用来顺序遍历哈希表的每个元素。

/* 循环遍历a_table */
#define zc_hashtable_foreach(a_table, a_entry) \
    for (a_entry = zc_hashtable_begin(a_table); a_entry; a_entry = zc_hashtable_next(a_table, a_entry))

zc_hashtable_foreach的实现依赖于2个函数:zc_hashtable_begin和zc_hashtable_next。
zc_hashtable_begin用于获得哈希表中第一个包含元素的节点;zc_hashtable_next用于获取哈希表中,指定元素的下一个节点。当元素位于链表中(非表尾时,next域非空),表项的next节点就是下一个节点;当元素位于链表尾时(next域为空),需要换到数组对应位置的下个位置,即为链表尾的下一个元素,后续可以以该位置为新链表头结点。

/**
* 找到hashtable的第1个元素对应项
* @note 常用于迭代器遍历hashtable
*/
zc_hashtable_entry_t *zc_hashtable_begin(zc_hashtable_t *a_table)
{
    size_t i;
    zc_hashtable_entry_t *res = NULL;
    zc_hashtable_entry_t *p;

    /* 从数组的第1个链表开始遍历(i=0), 找到第一个非空节点 */
    for (i = 0; i < a_table->tab_size; i++) {
        for (p = (a_table->tab)[i]; p; p = p->next) {
            if (p) {
                res = p;
                break;
            }
        }
    }

    return res;
}

/**
* 返回下一个元素对应项
*/
zc_hashtable_entry_t *zc_hashtable_next(zc_hashtable_t *a_table, zc_hashtable_entry_t *a_entry)
{
    size_t i;
    size_t j;
    zc_hashtable_entry_t *res = NULL;

    /* a_entry位于非链表尾 */
    if (a_entry->next)
        return a_entry->next;

    /* a_entry位于链表尾, 需要换一个链表 */

    i = a_entry->hash_key % a_table->tab_size;
    for (j = i + 1; j < a_table->tab_size; j++) {
        if ((a_table->tab)[j]) {
            res = (a_table->tab)[j];
            break;
        }
    }

    return res;
}

用户如何实现遍历哈希表呢?
下面是一个例子,用于打印每个元素的key和value,其中,元素(key, value)都是常量字符串。

// test/test_hashtable.c

void myfree(void *kv) { }
int main(void)
{
    zc_hashtable_t *a_table;
    zc_hashtable_entry_t *a_entry = NULL;
    /* 构造哈希表并插入数据 */
    a_table = zc_hashtable_new(20,
        zc_hashtable_str_hash,
        zc_hashtable_str_equal,
        myfree, myfree);
    zc_hashtable_put(a_table, "aaa", "bnbb");
    zc_hashtable_put(a_table, "bbb", "bnbb");
    zc_hashtable_put(a_table, "ccc", "bnbb");

        // 顺序迭代哈希表a_table
    zc_hashtable_foreach(a_table, a_entry) {
        printf("k[%s],v[%s]\n", (char*)a_entry->key, (char*)a_entry->value);
    }

    zc_hashtable_del(a_table);
    return 0;
}

计算哈希值的算法

zc_hashtable提供默认的计算key对应哈希值(hash_key)的算法zc_hashtable_str_hash,实际上用的DJBX33A算法,适用于字符串计算哈希值。

/**
* 一种计算符串的hash值的算法
* @details DJBX33A, 又名Times33 哈希算法
* 公式: hash(i) = hash(i-1) * 33 + str[i]
*
* @see
* https://blog.csdn.net/weixin_43932088/article/details/85983436
* http://www.partow.net/programming/hashfunctions/
* http://www.cse.yorku.ca/~oz/hash.html
*/
unsigned int zc_hashtable_str_hash(const void *str)
{
    unsigned int h = 5381;
    const char *p = (const char *)str;

    while (*p != '\0') {
        h = ((h << 5) + h) + (*p++); /* hash * 33 + c */
    }
    return h;
}

关键字判等

key判等是实现查找、删除的重要环节,对于void*类型的key,如何判等呢?
zc_hashtable提供默认的判等函数zc_hashtable_str_equal,用于比较2个key是否相等,采用的办法是将key1, key2所对应内存解释为字符串,然后利用字符串比较函数strcmp进行比较。

/**
* 一种比较两个关键字是否相等的方法
* @details 将key1, key2所指内存当做字符串进行比较.
*/
int zc_hashtable_str_equal(const void *key1, const void *key2)
{
    return (STRCMP((const char*)key1, ==, (const char*)key2));
}

#define STRCMP(_a_, _C_, _b_) ( strcmp(_a_, _b_) _C_ 0 )

小结

1)zc_hashtable哈希表采用数组+链表形式组织数据,数组每个槽位对应一个链表即冲突链表,用于解决冲突。而每个数组的表项也能对应一个元素(key, value)。
2)冲突链表采用的是双向链表,注意与双向循环链表的区别。


参考

https://blog.csdn.net/Cdreamfly/article/details/125770858

原文链接: https://www.cnblogs.com/fortunely/p/16693363.html

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍;

也有高质量的技术群,里面有嵌入式、搜广推等BAT大佬

    zlog日志库源码解析 —— 数据结构:哈希表 zc_hashtable

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/402461

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年4月21日 上午11:04
下一篇 2023年4月21日 上午11:04

相关推荐