哈希表(hash table)又叫散列表,是一种关联式容器,存储的是一对值,一般是一个key对应一个value(又叫键值对)。数组、链表、栈、队列都是序列式容器,存储的都是一个元素。
C++ STL中的map就是一个散列表。
散列函数:
散列函数的要求:
- hash(key)的结果是非负整数(数组下标)
- if(key1==key2),hash(key1)==hash(key2)
- if(key1!=key2),hash(key1)!=hash(key2)
然而实际情况中,第三点是基本做不到的,就算是著名的哈希算法MD5,SHA都难以避免会有不同key算出相同的hash值的情况,又称为哈希冲突。哈希算法有很多种,但是好的哈希算法应该尽量少地出现哈希冲突。
哈希冲突:
解决方法:开放地址法、链表法、建立公共溢出区。
开放地址法
(1)线性探测法
当我们的所需要存放值的位置被占了,我们就往后面一直加1并对m取模直到存在一个空余的地址供我们存放值,取模是为了保证找到的位置在0~m-1的有效空间之中。
存在问题:出现非同义词冲突(两个不想同的哈希值,抢占同一个后续的哈希地址)被称为堆积或聚集现象。
公式:
h(x)=(Hash(x)+i)mod (Hashtable.length)(i会逐渐递增加1)
(2)平方探测法(二次探测)
当我们的所需要存放值的位置被占了,会前后寻找而不是单独方向的寻找。
公式:
h(x)=(Hash(x) +k)mod (Hashtable.length)(k依次为+(i^2)和-(i^2))(i会逐渐递增加1)
(3)再哈希法:
同时构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个……等其他的哈希函数计算地址,直到不发生冲突为止。虽然不易发生聚集,但是增加了计算时间。
链表法
链表法在数组的每个槽都维护一个链表,当发生散列冲突的时候,存储在链表的结点里,插入、查找、删除的时间复杂就是链表插入、查找、删除的时间复杂度。
建立公共溢出区
将哈希表分为基本表和溢出表,将发生冲突的都存放在溢出表中。
两种方法优缺点比较
开放地址法:
优点:
所有数据都存在数组里,可以有效利用CPU缓存
所有数据都存在数组里,便于序列化
缺点:
删除需要使用特殊标志
发生冲突时插入查找删除都需要探测,代价较高
装载因子不能太大,需要提前申请好内存,这也导致了比链表法更加浪费内存
适用情况:
数据量小,装载因子小。
链表法:
优点:
需要的时候才申请结点而不是一开始就申请好,内存利用率更高
对装载因子容忍度高,就算装载因子很大,只要哈希函数分布比较平均,链表长度虽然变长,但是相当于均摊到每一条链表了,所以比起纯数组还是要快。
当数据量大时,可以使用红黑树或者跳表代替链表实现优化,使得查找效率从O(n)变成O(logn)。
缺点:
链表结点在内存中不连续,对CPU缓存不友好
需要存储额外的指针,如果存储小的对象会消耗更多的内存,如果存储较大的对象的话指针的消耗可以忽略不计。
适用情况:
存储大对象,大数据量。
装载因子
装载因子loadfactor = 填入表中的元素个数 / 散列表的长度
装载因子越大,说明散列表越满,哈希冲突的概率越大,开放地址法的探测次数增加,链表法的链表长度会增大,导致散列表的性能会下降。
一般我们会为装载因子设置一个阈值,当到达或者超过这个阈值后散列表进行动态扩容。
装载因子的阈值设置需要权衡时间,空间的需求。如果对内存充足,对执行效率性能要求比较高,可以将阈值设置小一点;如果内存紧张,对执行效率性能要求不敏感,可以将阈值设置大一些,甚至可以超过1。Java的HashMap中默认的最大装载因子是0.75。
动态扩容
上面说到装载因子达到某个阈值时,散列表需要动态扩容以减小散列冲突:申请更大的数组空间,旧数据重新计算哈希值,搬移到新的空间上。
在实际中,如果我们提供对外服务,在插入某个数据后启动动态扩容,一次性将所有旧数据计算哈希值并搬移到新空间上,可能会导致某一段时间无法响应用户的请求。
因此,在动态扩容时,我们可以先申请空间,但是先不计算哈希值和搬移数据。在需要插入新数据时,我们将新数据插入新的空间,然后从旧散列表中取一个数据计算哈希值插入新的空间里。这样的话在查找的时候也需要兼顾新旧两个散列表,先从新散列表里找,如果找不到再到旧散列表里找。
这种做法我们将动态扩容均摊到插入操作中,每次插入操作的时间复杂度是O(1),这样的做法会更加地柔和,避免了一次性动态扩容耗时过高。
实现:(链表法)
定义链表结点结构体
struct Item {
string key;
string value;
Item* next;
Item()
{
this->key = "empty";
this->value = "empty";
this->next = nullptr;
}
Item(string k, string v)
{
this->key = k;
this->value = v;
this->next = nullptr;
}
};
初始化哈希表
const int tablesize = 20;
struct Item* hashtable[tablesize];
void InitHashtable()
{
for (int i = 0; i < tablesize; ++i)
hashtable[i] = new Item();
}
哈希函数
int hashFunction(string key)
{
int sum = 0;
for (int i = 0; i < key.size(); ++i)
sum += static_cast<int>(key[i]);
return sum % tablesize;
}
在链表中的位置
int NumOfList(string keys)
{
int index = hashFunction(keys);
int num = 1;
struct Item* temp = hashtable[index]->next;
while (temp->key != keys)
{
temp = temp->next;
++num;
}
return num;
}
插入
这里需要注意的是,数组的槽里的结点是不存储数据的,也就是table[i]这个结点是不存储数据的,可以理解成是一个哨兵,table[i]->next所指向的结点才是第一个存储数据的。
void AddItem(string key, string value)
{
int index = hashFunction(key);
struct Item* temp = new Item(key, value);
temp->next = hashtable[index]->next;
hashtable[index]->next = temp;
}
查找
计算哈希值,然后遍历链表。
void FindValue(string key, int& index, int& num)
{
index = hashFunction(key);
num = NumOfList(key);
}
删除
先找这个结点,找到了就删除这个结点。
标签:哈希,int,next,链表,Item,key From: https://www.cnblogs.com/coto/p/17092695.html