哈希表

哈希表(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);
}

删除

先找这个结点,找到了就删除这个结点。

posted @ 2023-02-05 00:13  PassCo2  阅读(222)  评论(0)    收藏  举报