数据结构进阶(C++) -- 哈希表的模拟实现

哈希

概念

哈希(hash)又称为散列,是一种组织数据的方式。从别名来看,有散乱排列的意思。本质上就是通过哈希函数把关键字 key 跟储存位置建立一个映射关系,查找时通过这个哈希函数计算出 key 存储的位置,进行快速查找。

直接定址法

当关键字的范围比较集中时,直接定址法就是最简单高效的方法,比如一组关键字都在 0-99 之间,那么我们开一个100个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都在[ a,z ]的小写字母,那么我们开一个26个数的数组,每个关键字ascii码就是存储位置的下标。也就是说直接定址法本质上是用关键字计算出一个绝对位置或者相对位置。这个方法我们在计数排序部分已经使用过了,其次在string章节的OJ题中也有讲解:

字符串中的第一个唯一字符

代码演示:

cpp 复制代码
class Solution {
public:
    int firstUniqChar(string s) {
        int arr[26] = {0};

        for(auto ch : s)
        {
            arr[ch-'a']+=1;
        }

        for(int i = 0;i < s.size(); i++)
        {
            if(1 == arr[s[i]-'a'])
            {
                return i;
            }
        }
        return -1;
    }
};

哈希冲突

直接定址法的缺点也非常明显,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。假设我们只有数据范围是[0,9999]的N个值,我们要映射到一个M个空间的数组中(一般情况下M>=N),那么就要借助哈希函数hf(hash function),关键字 key 被放到数组的h(key)位置,这里要注意的是h(key)计算出的值必须在[0,M)之间。

这里存在的一个问题就是:两个不同的 key 可能会映射到同一个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。理想情况是找出一个好的哈希函数来避免冲突,但是实际场景中冲突是不可避免的,所以我们需要尽可能设计出优秀的哈希函数,来减少冲突的次数,同时也要去设计出解决冲突的方案。

负载因子

假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么负载因子 == N/M,负载因子(load factor)在有些地方也被翻译为载荷因子或装载因子。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。

将关键字转换为整数

我们一般使用整数进行映射计算,将关键字映射到数组中,如果关键字类型不为整数,我们需要想办法转换成整数,这个细节我们后面代码实现中再进行演示。下面我们讨论哈希函数部分时,如果关键字不是整型,那么我们就默认key时关键字转换成整形之后的整数。


哈希函数

一个好的哈希函数应该让 N 个关键字被等概率的散列分布到哈希表的 M 个空间中,但是实际却很难做到,因此我们只能尽量往这个方向去考量设计。

除留余数法/除留散列法

  • 除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为: h(key) = key% M。
  • 当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。如果是2,那么key% 2^X 本质相当于保留 key 二进制的后X位,那么后x位相同的值,计算出的哈希值都是一样的,就冲突了。如:{63,31}看起来没有关联的值,如果M是16,也就是2,那么计算出的哈希值都是15,因为63的二进制后8位是00111111,31的二进制后8位是00011111。如果是10,就更明显了,保留的都是10进值的后x位,如:{112,12312},如果M是100,也就是10?,那么计算出的哈希值都是12。
  • 当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数)。
  • 需要说明的是,实践中也是八仙过海,各显神通,Java的HashMap采用除法散列法时就是2的整数次幂做哈希表的大小M,这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模更高效一些。但是他不是单纯的去取模,比如M是2^16次方,本质是取后16位,那么用key'=key>>16,然后把key和key'异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围内,但是尽量让kev所有的位都参与计算,这样映射出的哈希值更均匀一些即可。所以我们上面建议M取不太接近2的整数次幂的一个质数的理论是大多数数据结构书籍中写的理论吗,但是实践中,灵活运用,抓住本质,而不能死读书。(了解)

乘法散列法(了解)

  • 乘法散列法对哈希表大小M没有要求,他的大思路第一步:用关键字K乘上常数A(0<A<1),并抽
    取出 k*A的小数部分。第二步:后再用M乘以k*A的小数部分,再向下取整。
  • h(key) = floor(M x((A x key)%1.0)),其中floor表示对表达式进行下取整,A∈(0,1),这里最重要的是A的值应该如何设定,Knuth 认为 A=(V5-1)/2=0.6180339887.. (黄金分割点])比较好。
  • 乘法散列法对哈希表大小M是没有要求的,假设M为1024,key为1234,A =0.6180339887,
    A*key=762.6539420558,取小数部分为.6539420558,
    M x ((Axkey)%1.0)=0.6539420558*1024=669.6366651392,那么h(1234)=669。

全域散列法(了解)

  • 如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。
  • h(key) = ((a x key + b)%P)%M ,P需要选一个足够大的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了一个P*(P-1)组全域散列函数组。
    假设P=17,M=6,a=3,b=4,则h(8) = ((3 x 8 + 4) % 17 ) % 6 = 5。
  • 需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都必须固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的key了。

其他方法(了解)

上面的几种方法是《算法导论》书籍中讲解的方法。《殷人昆 数据结构:用面向对象方法与C++语言描述(第二版)》和 《[数据结构(C语言版)].严蔚敏 吴伟民》等教材型书籍上面还给出了平方取中法、折叠法、随机数法、数学分析法等,这些方法相对更适用于一些局限的特定场景,有兴趣可以去看看这些书籍。

处理哈希冲突

实践中哈希表一般还是选择除留余除法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,如何解决冲突呢?我们分为开放定址法和链地址法。

开放定址法

在开放定址法中所有的元素都放到哈希表里,当一个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到没有存储数据的位置进行存储,开放定址法中负载因子一定是小于1的。这里的规则有三种:线性探测、二次探测、双重探测。

线性探测
  • 从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。
  • h(key)= hash0 = key % M,hash0位置冲突了,则线性探测公式为:
    hc(key,i)=hashi=(hash0+i)%M,i={1,2,3,...,M-1},因为负载因子小于1则最多探测M-1次,一定能找到一个存储key的位置。
  • 线性探测的比较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。下面的二次探测可以一定程度改善这个问题。

下面演示{19,30,5,36,13,20,21,12}这一组值映射到M=11的表中的结果:

h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) = 10,h(12) = 1。

二次探测
  • 从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置;
  • h(key)= hash0 = key % M,hash0位置冲突了,则二次探测公式为:
    he(key,i)= hashi= (hash±)%M, i= {1,2,3,...,M/2 )
  • 二次探测当 hashi = (hash0 - i^2) % M时,当hashi < 0时,需要hashi += M。

下面演示{19,30,52,63,11,22}等这一组值映射到M=11的表中。

h(19)=8,h(30)=8,h(52)=8,h(63)=8,h(11)=0,h(22)=0。

双重散列(了解)
  • 第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟key相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。
  • h(key)= hash0= key%M,hash0位置冲突了,则双重探测公式为:
    hc(key,i) = hashi = (hash0 + i*h, (key) ) % M, i= {1,2,3,., M}
  • 要求 hz(key)< M且 hz(key)和M互为质数,不有两种简单的取值方法:
    1、当M为2整数幂时hz(key)从[0,M-1]任选一个奇数;
    2、当M为质数时,h(key)= key%(M-1)+ 1。
  • 保证 h(key)与M互质是因为根据固定的偏移量所寻址的所有位置将形成一个群,若最大公约数
    p= gcd(M,h(key))>1,那么所能寻址的位置的个数为 M/P<M,使得对于一个关键字来说无法充分利用整个散列表。举例来说,若初始探查位置为1,偏移量为3,整个散列表大小为12,那么所能寻址的位置为{1,4,7,10},寻址个数为 12/gcd(12,3) = 4。
开放定址法代码实现
cpp 复制代码
namespace open_address
{
	enum State
	{
		EXSIT,
		EMPTY,
		DELETE
	};

	template<class k, class v>
	struct HashData
	{
		pair<k, v> _kv;
		State _state = EMPTY;
	};

	template<class k, class v>
	class HashTable
	{
	public:
        //......
	private:
		vector<HashData<k, v>> _tables;
		size_t _n = 0;    //表中存储数据个数
	};
}
  • 这里定义了一个名为State的枚举类型。枚举类型用于定义一组具有明确名称的整型常量值,在这个例子中,EXSITEMPTYDELETE是三个不同的枚举值,默认情况下它们对应的值依次是012(按照定义顺序,第一个值为0,后续依次递增)。这种枚举类型可以方便地用来表示某种状态,在后续代码(比如表示哈希表中某个位置的状态)中起到标识作用。

  • 这是一个结构体模板定义,它依赖于两个模板参数kv,这两个参数通常会在实例化时被替换为具体的类型。
    1、pair<k, v> _kv;:结构体中有一个成员变量_kv,它的类型是std::pair,用于将键(类型为k)和值(类型为v)组合在一起,这符合哈希表中存储键值对的需求。
    2、State _state = EMPTY;:另一个成员变量_state,它的类型是前面定义的枚举类型State,并且初始化为EMPTY状态,用于表示这个哈希数据元素当前的状态,比如是否已经存储了有效数据、是否被删除等情况。

  • 定义了一个名为HashTable的类模板,同样依赖于模板参数kv,用来创建可以存储不同类型键值对的哈希表类。
    私有成员变量

    1、vector<HashData<k, v>> _tables;:定义了一个std::vector容器,其元素类型是前面定义的HashData<k, v>结构体模板实例化后的类型。这个容器用于实际存储哈希表中的数据元素(以键值对及对应状态的形式)。

    2、size_t _n = 0;:定义了一个size_t类型(通常用于表示无符号整数,常用来表示容器大小等情况)的变量_n,用于记录当前哈希表中已经存储的数据个数,初始化为0,后续随着数据的插入、删除等操作会相应地更新该值,以便跟踪哈希表的使用情况等。


插入函数:

cpp 复制代码
bool Insert(const pair<k, v>& kv)
{
	//不允许插入重复值
	if (Find(kv.first))
		return false;

	//扩容
	if ((_n * 10) / _tables.size() >= 7)
	{
		HashTable<k, v, HashFunc<k>> newht;
		newht._tables.resize(__stl_next_prime(_tables.size() + 1));
		for (auto& data : _tables)
		{
			if (data._state == EXSIT)
			{
				newht.Insert(data._kv);
			}
		}
		_tables.swap(newht._tables);
	}

	Hash hash;
	//插入
	size_t hash0 = hash(kv.first) % _tables.size();
	size_t hashi = hash0;
	size_t i = 1;
	while (_tables[hashi]._state == EXSIT)
	{
		hashi = (hash0 + i) % _tables.size();
		++i;
	}
	_tables[hashi]._kv = kv;
	_tables[hashi]._state = EXSIT;
	++_n;

	return true;
}
  • 第八步:这里调用了Find函数来检查要插入的键值对中的键是否已经存在于哈希表中。如果Find函数返回true,意味着已经存在相同键的元素,按照规则不允许重复插入,所以直接返回false表示插入失败。
  • 第四步:扩容条件判断 :通过计算当前存储数据个数_n与哈希表(_tables向量)容量大小的比例来决定是否需要扩容。当(_n * 10) / _tables.size() >= 7这个条件满足时,意味着哈希表的装填因子(这里简单理解为数据量和表容量的一种比例关系)达到了一定阈值(这里相当于 70% 左右,因为是乘以 10 后与容量比较),为了保证哈希表的性能,需要进行扩容操作。
  • 第五步:创建新哈希表并初始化容量 :首先创建了一个新的HashTable类型的对象newht,然后调用resize函数对其内部的_tables向量(用于存储哈希数据元素的容器)进行扩容,调用了__stl_next_prime函数(可能是自定义的用于获取下一个质数的函数,一般在哈希表实现中,扩容到质数大小的容量有助于数据分布更均匀等)来确定新的合适容量,这里传入的参数是当前表容量加 1,即扩到下一个合适的稍大容量。
  • 第六步:数据迁移 :通过循环遍历当前哈希表_tables中的每个元素,如果元素的状态是EXSIT(表示该位置存储着有效的数据),就调用新哈希表newhtInsert函数(这里是递归调用本类的Insert方法来将旧表的有效数据插入到新表中,递归调用时要确保逻辑正确不会造成无限循环等问题)将该数据插入到新哈希表中。
  • 第七步:交换表数据 :最后,使用swap函数将新哈希表的_tables容器和当前哈希表的_tables容器进行交换,这样就完成了扩容以及数据的迁移,当前哈希表对象就拥有了新的、更大容量的存储结构并且数据也正确迁移过来了。
  • 第一步:计算初始哈希位置 :首先创建了一个Hash类型的对象(这里Hash应该是一个可调用对象,比如函数对象或者函数指针等,用于根据键计算哈希值,具体实现未展示但应该能将键映射为合适的整数哈希值),然后通过hash(kv.first) % _tables.size()计算出初始的哈希位置hash0,也就是理论上要插入的键值对在哈希表中的理想位置(通过取余操作将哈希值映射到表的有效索引范围内),并将这个初始位置赋值给hashi用于后续查找合适的插入空位。
  • 第二步:处理冲突找空位 :接着进入一个while循环,只要当前hashi位置对应的元素状态是EXSIT(即已经被占用了,说明发生了哈希冲突,也就是不同的键计算出了相同的哈希位置),就通过一种线性探测的方式(这里采用的是简单的线性增量,每次i自增 1,然后重新计算hashi = (hash0 + i) % _tables.size();,即按照一定规律往后查找下一个位置)来寻找下一个可用的空位用于插入新数据。
  • 第三步:插入数据并更新状态和计数 :当找到合适的空位(即_tables[hashi]._state不为EXSIT的位置)后,将传入的键值对kv赋值给该位置的_kv成员(完成数据的插入),同时将该位置的状态_state设置为EXSIT表示此处存储了有效的数据,最后将记录哈希表中数据个数的变量_n自增 1,表示成功插入了一个新的数据元素。

查找函数:

cpp 复制代码
HashData<k, v>* Find(const k& key)
{
	Hash hash;
	size_t hash0 = hash(key) % _tables.size();
	size_t hashi = hash0;
	size_t i = 1;
	while (_tables[hashi]._state != EMPTY)
	{
		if (_tables[hashi]._state == EXSIT
			&& _tables[hashi]._kv.first == key)
		{
			return &_tables[hashi];
		}
		hashi = (hash0 + i) % _tables.size();
		++i;
	}
	return nullptr;
}
  • Find 函数是 HashTable 类模板的成员函数,其主要功能是在哈希表中查找给定键对应的元素。如果找到符合条件的元素,就返回指向该元素所在 HashData 结构体的指针;如果遍历完哈希表都没找到合适的元素,就返回 nullptr
  • 首先创建了一个 Hash 类型的对象(和 Insert 函数里的作用类似,用于根据键生成哈希值),然后通过 hash(key) % _tables.size() 计算出初始的哈希位置 hash0,也就是根据键计算出理论上这个键值对可能所在的哈希表位置,将其赋值给 hashi 用于后续查找操作的起始位置,同时初始化线性探测的步长变量 i1
  • 这里通过一个 while 循环来查找元素,只要当前位置 hashi 对应的元素状态不是 EMPTY(意味着这个位置要么存储着有效数据,要么是曾经存储过数据但已被删除的位置,总之不是空的就需要进一步检查),就进入循环内部进行判断:
    1、在循环内部,首先判断当前位置元素的状态是否为 EXSIT(表示此处存储着有效的数),并且该位置元素的键(通过 _tables[hashi]._kv.first 获取)是否和传入要查找的键 key 相等。如果这两个条件都满足,说明找到了目标元素,此时返回指向该位 HashData 结构体的指针(通过 &_tables[hashi] 获取指针)。
    2、如果当前位置不是要找的元素,就按照线性探测的方式来继续查找下一个位置。通 hashi = (hash0 + i) % _tables.size(); 计算下一个探测位置(其中 i 会随着每次循环自增,实现逐步向后探测的效果,以解决可能出现的哈希冲突),然后继续下一轮循环检查新位置的情况,直到找到目标元素或者遇到状态为 EMPTY 的位置(意味着遍历完了可能存储数据的有效区域都没找到,此时结束循环)。
  • 如果整个 while 循环结束后都没有找到符合条件的元素(也就是一直到遇到 EMPTY 状态的位置都没找到),则返回 nullptr,表示查找失败。

这里我们哈希表负载因子控制在0.7,当负载因子到0.7以后我们就需要扩容了,我们还是按照2倍扩容,但是同时我们要保持哈希表大小是一个质数,第一个是质数,2倍后就不是质数了。那么如何解决了,一种方案就是上面1.4.1除法散列中我们讲的Java HashMap的使用2的整数幂,但是计算时不能直接取模的改进方法。另外一种方案是sgi版本的哈希表使用的方法,给了一个近似2倍的质数表,每次去质数表获取扩容后的大小。

cpp 复制代码
inline unsigned long __stl_next_prime(unsigned long n)
{
	// Note: assumes long is at least 32 bits.
	static const int __stl_num_primes = 28;
	static const unsigned long __stl_prime_list[__stl_num_primes] =
	{
		53, 97, 193, 389, 769,
		1543, 3079, 6151, 12289, 24593,
		49157, 98317, 196613, 393241, 786433,
		1572869,3145739,6291469, 12582917,25165843,
		50331653,100663319,201326611,402653189, 805306457,
		1610612741, 3221225473, 4294967291
	};
	const unsigned long* first = __stl_prime_list;
	const unsigned long* last = __stl_prime_list +
		__stl_num_primes;
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

删除函数:

cpp 复制代码
bool Erase(const k& key)
{
	HashData<k, v>* ret = Find(key);
	if (ret)
	{
		ret->_state = DELETE;
		--_n;
		return true;
	}
	else
		return false;
}
  • Erase 函数同样是 HashTable 类模板的成员函数,用于从哈希表中删除给定键对应的元素。它首先调用 Find 函数查找要删除的元素,如果找到就将该元素的状态标记为 DELETE(表示已删除),同时更新哈希表中存储的数据个数 _n,并返回 true 表示删除成功;如果没找到对应的元素,则返回 false 表示删除失败。
  • 通过调用 Find 函数,传入要删除的键 key,尝试在哈希表中查找对应的元素。如果 Find 函数返回的指针不为 nullptr,说明找到了要删除的元素,该指针会被存储在 ret 变量中,用于后续操作;如果返回 nullptr,则表示没找到,直接进入 else 分支返回 false
  • ret 不为 nullptr 时,也就是找到了要删除的元素,通过 ret->_state = DELETE; 将该元素对应的 HashData 结构体中的状态成员 _state 修改为 DELETE,以此来标记这个位置的元素已经被删除(而不是真正地从容器中物理删除,这是一种常见的处理方式,避免频繁的删除和移动元素带来的开销,后续插入等操作可以复用这个位置)。然后将记录哈希表中存储数据个数的变量 _n 自减 1,表示成功删除了一个元素,最后返回 true 表示删除操作成功。

哈希函数:

cpp 复制代码
template<class k>
struct HashFunc
{
	size_t operator()(const k& key)
	{
		return (size_t)key;
	}
};
template<>
struct HashFunc<string>
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto& s1 : s)
		{
			hash += s1;
		}
		return hash;
	}
};


template<class k, class v, class Hash = HashFunc<k>>
class HashTable
{
};
  • 这是一个通用的函数对象结构体模板,名为 HashFunc,它定义了一个函数调用运算符 operator(),目的是为了将任意给定类型 k 的对象转换为一个 size_t 类型的哈希值。在很多场景下,比如哈希表实现中,需要这样的机制来为不同类型的键生成对应的哈希值,以便将键映射到哈希表的相应位置。
  • operator()(const k& key) 函数的实现中,简单地将传入的键 key 进行强制类型转换,转换为 size_t 类型并返回。这种实现方式比较简单直接,不过它有一定的局限性,只适用于那些本身可以合理地转换为 size_t 类型的键类型(比如整数类型等本身与 size_t 在数值表示上兼容的类型)。对于更复杂的类型,比如自定义类或者像 string 这样的标准库类型,这种简单转换通常是不合理的,需要专门的哈希计算逻辑,这也是后续特化版本存在的意义。
  • 所以我使用了模板特化(template<> 表示这是对之前定义的 HashFunc 模板针对 string 类型的特化)。因为对于 string 类型,不能简单地像基本类型那样直接转换为 size_t 来作为哈希值,所以需要专门定制适合 string 类型特点的哈希计算方法。
  • 在这个特化版本的 operator()(const string& s) 函数中,定义了一个 size_t 类型的变量 hash 并初始化为 0。然后通过一个范围 for 循环遍历输入的字符串 s 中的每一个字符(通过 auto& s1 : s 获取每个字符的引用),并将每个字符的值累加到 hash 变量上。最后返回这个累加得到的 hash 值作为字符串 s 的哈希值。这种简单的哈希计算方式是将字符串中各个字符的 ASCII 值(假设是 ASCII 编码环境)进行累加,虽然比较简单,但在一些简单场景下可以起到一定的区分不同字符串的作用,当然实际应用中可能有更复杂、效果更好的字符串哈希算法可以替换它。

完整代码:

cpp 复制代码
inline unsigned long __stl_next_prime(unsigned long n)
{
	// Note: assumes long is at least 32 bits.
	static const int __stl_num_primes = 28;
	static const unsigned long __stl_prime_list[__stl_num_primes] =
	{
		53, 97, 193, 389, 769,
		1543, 3079, 6151, 12289, 24593,
		49157, 98317, 196613, 393241, 786433,
		1572869,		3145739,		6291469, 12582917,	25165843,
		50331653,		 100663319,	  201326611,		402653189, 805306457,
		1610612741, 3221225473, 4294967291
	};
	const unsigned long* first = __stl_prime_list;
	const unsigned long* last = __stl_prime_list +
		__stl_num_primes;
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

namespace open_address
{
	enum State
	{
		EXSIT,
		EMPTY,
		DELETE
	};

	template<class k, class v>
	struct HashData
	{
		pair<k, v> _kv;
		State _state = EMPTY;
	};

	template<class k>
	struct HashFunc
	{
		size_t operator()(const k& key)
		{
			return (size_t)key;
		}
	};
	template<>
	struct HashFunc<string>
	{
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto& s1 : s)
			{
				hash += s1;
			}
			return hash;
		}
	};


	template<class k, class v, class Hash = HashFunc<k>>
	class HashTable
	{
	public:
		HashTable()
			:_tables(__stl_next_prime(0))
		{}

		bool Insert(const pair<k, v>& kv)
		{
			//不允许插入重复值
			if (Find(kv.first))
				return false;

			//扩容
			if ((_n * 10) / _tables.size() >= 7)
			{
				HashTable<k, v, HashFunc<k>> newht;
				newht._tables.resize(__stl_next_prime(_tables.size() + 1));
				for (auto& data : _tables)
				{
					if (data._state == EXSIT)
					{
						newht.Insert(data._kv);
					}
				}
				_tables.swap(newht._tables);
			}

			Hash hash;
			//插入
			size_t hash0 = hash(kv.first) % _tables.size();
			size_t hashi = hash0;
			size_t i = 1;
			while (_tables[hashi]._state == EXSIT)
			{
				hashi = (hash0 + i) % _tables.size();
				++i;
			}
			_tables[hashi]._kv = kv;
			_tables[hashi]._state = EXSIT;
			++_n;

			return true;
		}

		HashData<k, v>* Find(const k& key)
		{
			Hash hash;
			size_t hash0 = hash(key) % _tables.size();
			size_t hashi = hash0;
			size_t i = 1;
			while (_tables[hashi]._state != EMPTY)
			{
				if (_tables[hashi]._state == EXSIT
					&& _tables[hashi]._kv.first == key)
				{
					return &_tables[hashi];
				}
				hashi = (hash0 + i) % _tables.size();
				++i;
			}
			return nullptr;
		}

		bool Erase(const k& key)
		{
			HashData<k, v>* ret = Find(key);
			if (ret)
			{
				ret->_state = DELETE;
				--_n;
				return true;
			}
			else
				return false;
		}
	private:
		vector<HashData<k, v>> _tables;
		size_t _n = 0;
	};
}

链地址法

解决冲突的思路

开放定址法中所有的元素都放到哈希表里,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面,链地址法也叫做拉链法或者哈希桶。

h(19) = 8, h(30) = 8, h(5) = 5, h(36) = 3, h(13) = 2, h(20) = 9, h(21) = 10, h(12) = 1, h(24) = 2, h(96) = 88

开放定址法负载因子必须小于1,链地址法的负载因子就没有限制了,可以大于1。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低;stl中unordered xxx的最大负载因子基本控制在1,大于1就扩容,我们下面实现也使用这个方式。

如果极端场景下,某个桶特别长怎么办?其实我们可以考虑使用全域散列法,这样就不容易被针对了。但是假设不是被针对了,用了全域散列法,但是偶然情况下,某个桶很长,查找效率很低怎么办?这里在Java8的HashMap中当桶的长度超过一定阀值(8)时就把链表转换成红黑树。一般情况下不断扩容,单个桶很长的场景还是比较少的,下面我们实现就不搞这么复杂了,这个解决极端场景的思路,大家了解一下。

链地址法代码实现
cpp 复制代码
inline unsigned long __stl_next_prime(unsigned long n)
{
	// Note: assumes long is at least 32 bits.
	static const int __stl_num_primes = 28;
	static const unsigned long __stl_prime_list[__stl_num_primes] =
	{
		53, 97, 193, 389, 769,
		1543, 3079, 6151, 12289, 24593,
		49157, 98317, 196613, 393241, 786433,
		1572869,		3145739,		6291469, 12582917,	25165843,
		50331653,		 100663319,	  201326611,		402653189, 805306457,
		1610612741, 3221225473, 4294967291
	};
	const unsigned long* first = __stl_prime_list;
	const unsigned long* last = __stl_prime_list +
		__stl_num_primes;
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

namespace hash_bucket
{
	enum State
	{
		ESXIT,
		EMPTY,
		DELETE
	};

	template<class k, class v>
	struct HashNode
	{
		pair<k, v> _kv;
		HashNode<k, v>* _next;

		HashNode(const pair<k,v>& kv)
			:_kv(kv)
			,_next(nullptr)
		{}
	};

	template<class k>
	struct HashFunc
	{
		size_t operator()(const k& key)
		{
			return (size_t)key;
		}
	};

	template<>
	struct HashFunc<string>
	{
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto& s1 : s)
			{
				hash += s1;
			}
			return hash;
		}
	};

	template<class k, class v, class Hash = HashFunc<k>>
	class HashTable
	{
		typedef HashNode<k, v> Node;
	public:
		HashTable()
			:_tables(__stl_next_prime(0))
			,_n(0)
		{}

		~HashTable()
		{
			for (size_t i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}
				_tables[i] = nullptr;
			}
		}

		bool Insert(const pair<k, v>& kv)
		{
			if (Find(kv.first))
				return false;

			Hash hash;
			//扩容
			if (_n == _tables.size())
			{
				vector<Node*> newTable(__stl_next_prime(_tables.size()+1));
				for (size_t i = 0; i < _tables.size(); i++)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						Node* next = cur->_next;
						//头插到新表
						size_t hashi = hash(cur->_kv.first) % newTable.size();
						cur = newTable[hashi];
						newTable[hashi] = cur;

						cur = next;
					}
					_tables[i] = nullptr;
				}
				_tables.swap(newTable);
			}


			size_t hashi = hash(kv.first) % _tables.size();
			//头插
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;
			++_n;

			return true;
		}

		Node* Find(const k& key)
		{
			Hash hash;
			size_t hashi = hash(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (hash(cur->_kv.first) == hash(key))
				{
					return cur;
				}
				cur = cur->_next;
			}
			return nullptr;
		}

		bool Erase(const k& key)
		{
			Hash hash;
			size_t hashi = hash(key) % _tables.size();
			Node* cur = _tables[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (hash(cur->_kv.first) == hash(key))
				{
					if (prev)
					{
						prev->_next = cur->_next;
					}
					else
					{
						_tables[hashi] = cur->_next;
					}
					delete cur;
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}
			return false;
		}

	private:
		vector<Node*> _tables;
		size_t _n = 0;
	};
}
  1. 类型定义及模板参数说明

    在类模板内部通过 typedef HashNode<k, v> NodeHashNode<k, v> 重命名为 Node,方便后续代码书写。模板参数方面,k 表示键的类型,v 表示值的类型,Hash 是一个函数对象类型(默认使用 HashFunc<k>),用于计算键的哈希值,用户可以根据需要传入自定义的哈希函数对象类型。

  2. 构造函数

    构造函数初始化哈希表,调用 __stl_next_prime(0) 来确定初始的桶数量(即 _tables 向量的大小),并将存储数据个数的变量 _n 初始化为 0

  3. 析构函数

    析构函数用于释放哈希表占用的内存资源。它遍历 _tables 向量中的每个桶(元素为链表头指针),对于每个桶对应的链表,逐个删除节点,先保存当前节点的下一个节点指针,然后删除当前节点,直到链表为空,最后将桶指针置为 nullptr

  4. Insert 函数

    • 重复值检查 :首先调用 Find 函数检查要插入的键是否已存在,如果存在则直接返回 false,不允许插入重复值。
    • 扩容操作 :当存储数据个数 _n 等于桶数量 _tables.size() 时,进行扩容。创建一个新的桶向量 newTable,其大小通过调用 __stl_next_prime(_tables.size() + 1) 确定为下一个合适的质数容量。然后遍历原 _tables 中的每个桶链表,将节点逐个取出,重新计算哈希位置并头插到新表 newTable 对应的桶中,最后通过 swap 操作将新表和原表交换,完成扩容及数据迁移。
    • 插入数据 :计算要插入键值对的哈希位置 hashi,创建新节点 newnode,将其头插到对应桶的链表中(即新节点的 _next 指向原桶头节点,然后更新桶头指针为新节点),并将存储数据个数 _n1,最后返回 true 表示插入成功。
  5. Find 函数

    计算给定键的哈希位置 hashi,然后遍历对应桶链表中的节点,通过比较节点键的哈希值(调用 hash 函数对象计算)与给定键的哈希值是否相等来查找目标节点,如果找到则返回指向该节点的指针,若遍历完链表都没找到则返回 nullptr

  6. Erase 函数

    同样先计算键的哈希位置 hashi,然后遍历对应桶链表,查找要删除的节点。找到目标节点后,根据其是否为链表头节点(通过 prev 是否为 nullptr 判断)来进行不同的链表指针调整操作,删除该节点并返回 true;如果遍历完链表都没找到要删除的节点,则返回 false

相关推荐
零光速5 小时前
数据处理与统计分析——10-Pandas可视化-Matplotlib的常用API
数据结构·python·数据分析·pandas·matplotlib
天赐细莲7 小时前
(仓颉) Cangjie 刷力扣基础语法小结
数据结构·算法·leetcode·职场和发展
阿客不是客7 小时前
深入计算机语言之C++:STL之list的模拟实现
数据结构·c++·stl
uyeonashi9 小时前
【C++】刷题强训(day14)--乒乓球匡、组队竞赛、删除相邻数字的最大分数
开发语言·c++·算法·哈希算法
m0_5474866610 小时前
西安交通大学2001年数据结构试题
数据结构
23级二本计科11 小时前
分治_归并_归并排序(逆序对)
数据结构·算法·排序算法
CHENWENFEIc11 小时前
基础排序算法详解:冒泡排序、选择排序与插入排序
c语言·数据结构·算法·排序算法·学习方法·冒泡排序
yangpipi-12 小时前
数据结构(C语言版)-4.树与二叉树
c语言·开发语言·数据结构
C++oj12 小时前
普及组集训--图论最短路径设分层图
数据结构·算法·图论·最短路径算法
ruleslol12 小时前
java基础概念49-数据结构2
java·数据结构