既然要讲位运算,那么我先讲讲一个二进制串。
在只有一个 1 的二进制串里面,我们对它再减去 1 的时候,我们很快得到它的低位掩码。
比如:
0001000 - 1 = 0000111
,得到的 0000111
有什么用处呢?
如果我们把一个 hashcode
和这个数进行按位与,则得到的结果肯定是介于 000~111
之间,也就是 0~7
之间,这个时候我们思考一下,如果把这个 0001000
看做是桶数组的长度,那么这个按位与的结果就可以当做需要存入的桶的具体位置了。
基于这个理论,我们只要桶数组长度是 2的倍数
则 hashcode%size
可用 hashcode&(size-1)
来替代。
这样做有以下好处:
- 位运算的性能更好。
- 便于控制 hashcode 最终得出的结果,有些时候我们得到的
hashcode不够均匀
高位的1比较多,而低位的1比较少,这个时候可以利用hashcode^(hashcode>>16)
进行一定程度的打散,而这个打散的过程我们一般把它叫做扰动函数
。
哈希冲突
当出现键值运算结果得到的桶子位置是同一个的时候便产生了哈希冲突。
而解决哈希冲突的方案一般有以下三种:
链地址法
一旦发生哈希冲突,直接生成链表往后继续延伸。
开放地址法
简单来说就是给冲突的元素再在桶数组里找到一个空闲的位置。
而常用采用的方法有:
- 线性探查法: 从冲突的位置开始,依次判断下一个位置是否空闲,直至找到空闲位置。
- 平方探查法: 从冲突的位置x开始,第一次增加
1^2
个位置,第二次增加2^2
…,直至找到空闲的位置。 - 双散列函数探查法
…
再哈希法
再哈希法完全可以配备以上两类方法进行使用。
当然也可单独使用,单独使用的话就行配备多个哈希函数,一个不行的话换另一个哈希函数,直到不产生哈希冲突为止。
最终的选择:
而我们常用的是 链地址法 + 再哈希
,为了能够尽量减少内存空间的使用,我们默认从容量为 16 的桶数组开始,一旦装入的键值对超过 capacity * factor
个时,我们进行一次两倍的(左移1位)扩容,而由于扩容会导致 capacity
改变,所以通过 哈希函数 + 与运算
得出的位置也会出错,故需要经过 再哈希
。
我们其实可以继续细想,我们左移一位后,得出的结果再减一,它也仅仅多出一位掩码,而我们的 hashcode
只要在这一位上为 0 则最后得到的桶位置不会有任何改变,只有在这一位上为 1 的才会发生改变,所以根据这个特点,Java 8 进行了一些优化,更厉害的优化方式在于,只要链比较长,它还会转红黑树(这就在我的能力范围之外了)。
以上内容参考的文章为:一个HashMap和面试官扯了半小时
HashMap实现
类型定义(键值对以及对应节点)
C
#define DEFAULT\_CAPACITY 16 //初始的表长
#define DEFAULT\_FACTOR 0.75f //初始的装载因子
/\*类型定义 和 装载因子初始化\*/
typedef int key\_t;
typedef int val\_t;
static const float factor = DEFAULT_FACTOR; //装载因子
typedef struct node {//每个哈希表的键值对
key\_t key;
val\_t val;
struct node \*next;
} Node;
C++
全程用的泛型模板
哈希表的数据
C
typedef struct {
size\_t size; //记录已经存下的键值对数目
size\_t capacity; //记录表长
Node \*\*buckets; //桶子:用于记录的哈希桶,桶子中每个元素是Node\*
} HashMap;
C++
初始化方法(构造方法)
C
HashMap \*init() { //初始化得到一个哈希表
HashMap \*ret = (HashMap \*) malloc(sizeof(HashMap));
assert(ret != NULL);
ret->size = 0;
ret->capacity = DEFAULT_CAPACITY;
ret->buckets = (Node \*\*) calloc(DEFAULT_CAPACITY, sizeof(Node \*));
assert(ret->buckets != NULL);
return ret;
}
C++
根据散列函数得到位置
C
static inline size\_t getHashcode(key\_t key) {
return key ^ (key >> 16);//这是32位数的扰动函数
}
static inline size\_t getIndex(key\_t key, size\_t bucket_size) {//由于bucketsize一定是2的次方,所以size-1和key相与得到的就是下标
return getHashcode(key) & (bucket_size - 1);
}
C++
put方法
C
void put(HashMap \*map, key\_t key, val\_t val) {
assert(map != NULL);
//判断是否需要扩容
if (map->size >= map->capacity \* factor) resize(map);
putVal(key, val, map->buckets, &map->size, map->capacity);
}
static void putVal(key\_t key, val\_t val, Node \*\*buckets, size\_t \*returnSize, size\_t bucketSize) {
//获取位置
size\_t index = getIndex(key, bucketSize);
Node \*node = buckets[index];
if (node == NULL) {//插入位置为空
node = (Node \*) malloc(sizeof(Node));
assert(node != NULL);
node->val = val;
node->key = key;
node->next = NULL;
buckets[index] = node;
(\*returnSize)++; //哈希表内的元素增加
return;
}
//插入位置不为空,说明发生冲突,使用链地址法,遍历链表
while (node != NULL) {
//如果key相同就覆盖
if (node->key == key) {
node->val = val;
return;
}
node = node->next;
}
//当前的key不在链表中,则插入链表头部
Node \*newNode = (Node \*) malloc(sizeof(Node));
assert(newNode != NULL);
newNode->next = buckets[index];
buckets[index] = newNode;
(\*returnSize)++; //哈希表内元素增加
}
C++
扩容方法
C
static void resize(HashMap \*map) {
map->capacity <<= 1; //扩大两倍容量,相当于左移一位
Node \*\*tmp = map->buckets; //存下之前的内存地址
map->buckets = (Node \*\*) calloc(map->capacity, sizeof(Node \*)); //重新分配
assert(map->buckets != NULL);
rehash(map, tmp);//重新哈希处理
free(tmp); //释放之前的内存
}
static void rehash(HashMap \*map, Node \*\*preTable) {//采取java1.7的方式进行rehash也就是最简单直接的直接重新哈希插入
size\_t preCap = map->capacity / 2; //改变前的有效区域
for (size\_t i = 0; i < preCap; i++) {
if (preTable[i] != NULL) {//判断对应的key是否需要重新换位置,如果对最新掩码多出来的1敏感则需要rehash
Node \*preNode;
Node \*curNode = preTable[i];
while (curNode != NULL) {
preNode = curNode;
curNode = curNode->next;
insert(map, preNode);
}
}
}
}
C++
get方法
C
val\_t \*get(HashMap \*map, key\_t key) {//前面的写好后,那么get就很好写了
int index = getIndex(key, map->capacity);
Node \*node = map->buckets[index];
while (node != NULL) {
if (node->key == key) {
return &(node->val);
}
node = node->next;
}
return NULL;//没找到返回NULL指针
}
C++
C++标准中主要采用重载下标运算符的方式进行get。
完整代码
测试:LeetCode 1.两数之和
- 这效率在用C语言哈希表的方法里面应该是无敌的存在了。。。试了下比UT_HASH还要快一些☺
再看看之前写的比较烂的哈希表的效率😂
测试源码:
//
// Created by L\_B\_\_ on 2021/11/24.
//
//底层的表长都用2的次方,然后-1后可以得到低位掩码
//该设计理念急于java1.7的源代码,本来是想基于1.8的实现,
// 因为1.8巧妙太多,比如对rehash尽量能不动的就不动,再比如对于链比较长的结构直接转红黑树。可惜能力不能及
/\*\*
\* 简单说明:static修饰的函数为单文件的中间委托功能函数,不对外公开
\* 简单的功能函数介绍:
\* init()得到一个默认表长的哈希表
\* put()插入键值,内部自动进行内存的申请
\* get()得到key对应的val的地址,若不存在该键值对返回NULL
\* insert()外界已经分配好node的内存和key&val对,进行哈希运算后直接插入即可
\* destroy()把整个哈希表牵扯的所有内存释放
\* \*\*/
#include <assert.h>
#include <stdlib.h>
#ifndef MY\_TINY\_STL\_HASHMAP\_C\_H
#define MY\_TINY\_STL\_HASHMAP\_C\_H
#define DEFAULT\_CAPACITY 128 //初始的表长
#define DEFAULT\_FACTOR 0.75f //初始的装载因子
/\*类型定义 和 装载因子初始化\*/
typedef int key\_t;
typedef int val\_t ;
static const float factor = DEFAULT_FACTOR; //装载因子
typedef struct node{//每个哈希表的键值对
key\_t key;
val\_t val;
struct node\* next;
}Node;
typedef struct {
size\_t size; //记录已经存下的键值对数目
size\_t capacity; //记录表长
Node\*\* buckets; //桶子:用于记录的哈希桶,桶子中每个元素是Node\*
}HashMap;
/\*函数的声明\*/
HashMap\* init();
void put(HashMap\*,key\_t,val\_t);
void insert(HashMap\*,Node\*); //直接把已经分配好的内存插入哈希表
static void putVal(key\_t,val\_t,Node\*\*,size\_t\*,size\_t); //这个是put的委托函数,用于直接更新桶子,并更新HashMap的size
static inline size\_t getHashcode(key\_t ); //得到key对应的hashcode
static inline size\_t getIndex(key\_t,size\_t); //通过桶的大小和key映射位置,算是包含了关键的哈希函数:由于C不支持泛型也就无法针对不同类型作出不同的哈希了,我这里默认key为int
static void resize(HashMap\*); //如果插入的元素过多,\*2进行重新哈希分配
static void rehash(HashMap\*,Node\*\*); //重新设置长度则需要重新哈希一些key的位置
val\_t\* get(HashMap\*,key\_t); //得到key对应的val
static void del\_nodes(Node\*); //把单个链表销毁
void destroy(HashMap\*); //把哈希表的内存销毁
/\*函数实现\*/
HashMap\* init(){ //初始化得到一个哈希表
HashMap \* ret = (HashMap\*)malloc(sizeof(HashMap));
assert(ret!=NULL);
ret->size = 0;
ret->capacity = DEFAULT_CAPACITY;
ret->buckets = (Node\*\*)calloc(DEFAULT_CAPACITY,sizeof(Node\*));
assert(ret->buckets!=NULL);
return ret;
}
void insert(HashMap\* map,Node\* node){
assert(map!=NULL&&node!=NULL);
size\_t index = getIndex(node->key,map->capacity);
if(map->buckets[index]==NULL){
node->next = NULL;
map->buckets[index] = node;
}else{
node->next = map->buckets[index];
map->buckets[index] = node;
}
}
void put(HashMap\* map,key\_t key,val\_t val){
assert(map != NULL);
//判断是否需要扩容
if(map->size >= map->capacity\*factor) resize(map);
putVal(key,val,map->buckets,&map->size,map->capacity);
}
static inline size\_t getHashcode(key\_t key){
return key ^ (key>>16);//这是32位数的扰动函数
}
static inline size\_t getIndex(key\_t key,size\_t bucket_size){//由于bucketsize一定是2的次方,所以size-1和key相与得到的就是下标
return getHashcode(key)&(bucket_size-1);
}
static void putVal(key\_t key,val\_t val,Node\*\* buckets,size\_t\* returnSize,size\_t bucketSize){
//获取位置
size\_t index = getIndex(key,bucketSize);
Node \* node = buckets[index];
if(node==NULL){//插入位置为空
node = (Node\*)malloc(sizeof(Node));
assert(node!=NULL);
node->val = val;
node->key = key;
node->next = NULL;
buckets[index] = node;
(\*returnSize)++; //哈希表内的元素增加
return;
}
//插入位置不为空,说明发生冲突,使用链地址法,遍历链表
while (node!=NULL){
//如果key相同就覆盖
if(node->key==key){
node->val = val;
return;
}
node = node->next;
}
//当前的key不在链表中,则插入链表头部
Node\* newNode = (Node\*)malloc(sizeof(Node));
assert(newNode != NULL);
newNode->next = buckets[index];
buckets[index] = newNode;
(\*returnSize)++; //哈希表内元素增加
}
static void resize(HashMap\* map){
map->capacity <<= 1; //扩大两倍容量,相当于左移一位
Node \*\* tmp = map->buckets; //存下之前的内存地址
map->buckets = (Node\*\*)calloc(map->capacity,sizeof(Node\*)); //重新分配
assert(map->buckets!=NULL);
rehash(map,tmp);//重新哈希处理
free(tmp); //释放之前的内存
}
static void rehash(HashMap\* map,Node\*\* preTable){//采取java1.7的方式进行rehash也就是最简单直接的直接重新哈希插入
size\_t preCap = map->capacity / 2; //改变前的有效区域
for(size\_t i=0;i<preCap;i++){
if(preTable[i] != NULL){//判断对应的key是否需要重新换位置,如果对最新掩码多出来的1敏感则需要rehash
Node \*preNode;
Node \*curNode = preTable[i];
while (curNode!=NULL){
preNode = curNode;
curNode = curNode->next;
insert(map,preNode);
}
}
}
}
val\_t\* get(HashMap\* map,key\_t key){//前面的写好后,那么get就很好写了
int index = getIndex(key,map->capacity);
Node \* node = map->buckets[index];
while (node != NULL){
if(node->key==key){
return &(node->val);
}
node = node->next;
}
return NULL;//没找到返回NULL指针
}
static void del\_nodes(Node\* head){//删除单条链表
Node\* pre;
Node\* cur = head;
while (cur != NULL){
pre = cur;
cur = cur->next;
free(pre);
}
}
void destroy(HashMap\* map){//销毁整个哈希表
size\_t sz = map->capacity;
Node \*\* tmp = map->buckets;
for(size\_t i = 0;i<sz;i++){
if(tmp[i]!=NULL)
del\_nodes(tmp[i]);
}
free(tmp);
free(map);
}
#endif //MY\_TINY\_STL\_HASHMAP\_C\_H
/\*题目测试接口\*/
int\* twoSum(int\* nums, int numsSize, int target, int\* returnSize) {//两数之和万恶之源
HashMap\* map = init();
for(int i=0;i<numsSize;i++){
int\* t = get(map,target-nums[i]);
if(t != NULL){
int \*ret = (int\*)malloc(sizeof(int)\*2);
\*returnSize = 2;
ret[0] = \*t;
ret[1] = i;
return ret;
}
put(map,nums[i],i);
}
\*returnSize = 0;
destroy(map);
return NULL;
}
总结
通过这次手撕比较规范的哈希表的收获:
- 更加深入的理解了哈希表的基本实现思想。
- 从LeetCode刷题无用的感觉到有用,比如时刻需要的链表增删操作。
- 有点遗憾,没能按照java1.8的思路再进一步优化哈希表。
看了很多大厂的面试题,现在对哈希表这块数据结构的考量是越来越严苛了,比如你如果走的 java 后端岗位,面试可能需要你回答 java HashMap 源码的相关实现部分,深挖原理,而不是死记硬背了,所以最好的学习方式就是学习源码,然后根据学到的思想自己实现一些功能。
参考:
[1]. 《数据结构与算法》
[2]. HashMap跟面试官扯了半个小时