Bootstrap

C++:哈希表

目录

unordered_set和unordered_map

unordered_set(map)的介绍

unordered_set(map) 和 set(map) 的差异

unordered_multiset / unordered_multimap

介绍哈希表

哈希概念

直接定址法

哈希冲突

负载因子

常见哈希函数

除法散列法(重点)

乘法散列法

哈希表的实现

开发定址法(闭散列)

整体框架

哈希表的插入

哈希表的查找

 哈希表的删除

测试开放定址法实现的哈希表

链地址法(开散列)(重点)

整体框架

哈希表的插入

哈希表的查找

哈希表的删除

测试链地址法实现的哈希表


unordered_set和unordered_map

在 C++ 中,unordered_set 和 unordered_map 是两种基于哈希表(Hash Table)的容器,它们是 C++11 标准模板库的一部分,提供了高效的元素存储和访问

unordered_set(map)的介绍

unordered_set 是一个无序集合,它存储唯一的元素,并且不允许重复。unordered_set 提供了平均常数时间复杂度为 O(1) 的插入、删除和查找操作。unordered_set 维护元素的唯一性,如果尝试插入一个已存在的元素,它不会被添加到集合中。

unordered_set 的声明如下

template < class Key,                        // unordered_set::key_type/value_type
           class Hash = hash<Key>,           // unordered_set::hasher
           class Pred = equal_to<Key>,       // unordered_set::key_equal
           class Alloc = allocator<Key>      // unordered_set::allocator_type
           > class unordered_set;
  • 第一个模板参数 Key:Key 是 unordered_set 底层关键字类型。

  • 第二个模板参数 Hash(仿函数):unordered_set 默认要求Key支持转换成整形,如果Key不支持或者想按自己的需求将Key转换成整数,可以自己实现并传入。

  • 第三个模板参数 Pred(仿函数):unordered_set 默认要求Key支持比较相等不需要支持比较大小,这是因为 unordered_set 的底层是哈希表(哈希桶), 其实现不需要比较Key的大小但其Find函数和Erase函数需要比较Key是否相等。如果 Key 支持或者想按自己的需求来,可以自己实现并传入。

  • 第四个模板参数 Alloc:空间配置器,unordered_set 底层存储数据的内存是从空间配置器申请的,如果需要可以自己实现内存池并传入。

unordered_map 的声明如下

template < class Key,                                    // unordered_map::key_type
           class T,                                      // unordered_map::mapped_type
           class Hash = hash<Key>,                       // unordered_map::hasher
           class Pred = equal_to<Key>,                   // unordered_map::key_equal
           class Alloc = allocator< pair<const Key,T> >  // unordered_map::allocator_type
           > class unordered_map;

 unordered_map 和 unordered_set 除了参数多了T,其余都是一样的,而这个 T 就是 Key-Value 中的value类型。

unordered_set(map) 和 set(map) 的差异

unordered_set 和 set 的增删查操作是一摸一样的,不作演示了。

  • unordered_set 和 set的第一个差异是对Key的要求不同set 要求支持小于比较, 因为底层是红黑树(二叉搜索树),unordered_set 要求支持转换成整型支持等于比较

  • unordered_set 和 set的第二个差异是迭代器的不同set 是双向迭代器,而 unordered_set 是单向迭代器set 底层是红黑树,中序遍历是有序的,迭代器遍历是有序的,而 unordered_set 底层是哈希表迭代器遍历是无序的

  • unordered_set 和 set的第三个差异是性能上的差异unordered_set 的增删查操作是 O (1) 的时间复杂度,而set 的 增删查操作是 O(logN) 的时间复杂度,unordered_set 的速度较快些。

unordered_map 和 map 的差异跟 unordered_set 和 set 的差异基本上是一模一样的,不过多解释了。

unordered_multiset / unordered_multimap

  • unordered_multiset / unordered_multimap 跟 multiset / multimap 功能完全一样,支持Key冗余。
  • unordered_multiset / unordered_multimap 和 multiset / multimap 的差异也是 key 要求的差异iterator 及其遍历顺序的差异性能的差异

介绍哈希表

哈希概念

哈希(hash)又称散列,本质是通过哈希函数把关键字 Key 跟存储位置建立一个映射关系查找时通过这个哈希函数计算出Key存储的位置,进行快速查找

  • 说简单点就是,将关键字 Key 转换成地址编号,当你需要 Key 或者 Key-Value 中的 Value 直接根据地址编号就能找到,这样就不用经过任何的 Key 比较,搜索效率有了极大地提高。   

 其中哈希函数的作用是将关键字 Key 跟存储位置建立映射关系,其实就是将 Key 转换成整数。

一个好的哈希函数应该让 N 个关键字被等概率的均匀的分布在哈希表的 M 个空间,实际上很难做到,但是要可能往这方面设计。

例如:数据集合{1,4,5,6}

哈希函数设置为:hash(key) = key % capacity,capacity为存储空间的总大小。

直接定址法

直接定址法适合关键字的范围比较集中时实现哈希表,比如一组关键字在 [0, 49] 之间,那么我们开个大小为50个数的数组,每个关键字直接对应着数组下标,再比如一组关键字在 [a, z] 的小写字母,那就开个26个数的数组,每个关键字的 ASCII 码就是数组下标。这个方法可以在解决 OJ 上的一些问题。

387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

class Solution {
public:
    int firstUniqChar(string s) {
        vector<int> cnt(26,0);
        int n = s.size();
        for(auto ch : s)
        {
            cnt[ch - 'a']++;
        }

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

直接定址法也有缺点,那就是当数据范围比较分散且上下限差距较大时开的空间比较大会浪费内存

哈希冲突

回到一开始举的例子

 假如往哈希表里插入元素 44 ,会发生什么事情?hash(44) = 44 % 10 = 4 ,此时两个不同的Key通过同一个哈希函数映射到了同一位置上去。

这时就出现了哈希冲突,解决方法就是设计一个更好的哈希函数以减少冲突,但实际上,无论什么哈希函数哈希冲突都是不可避免的

负载因子

  • 假设哈希表存储了 N 个值哈希表的大小为 M ,其负载因子 = N / M

负载因子越大,哈希冲突的概率也越高,空间利用率也越高。

负载因子越小,哈希冲突的概率也越低,空间利用率也越低。

常见哈希函数

哈希函数的设计尽可能的减小哈希冲突发生的概率,一下是一些常见的哈希函数。

除法散列法(重点)

假设哈希表的大小为 M ,通过 key 除以 M 的余数作为映射位置的下标。

哈希函数为:hash(key) = key % M。

M 也是有要求的,尽可能避免取2的幂(2 ^ x,x是正整数)、10的幂等整数,尽可能取不接近2的幂的质数。

为什么不取2的幂、10的幂等整数?

因为 key % 2 ^ x 相当于保留 key 二进制数的后 x 位,这样引发哈希冲突的概率会大些,比如,M = 16 = 2 ^ 4,63 和 31 的二进制数的后 4 位都是相同的 1111, 而模上 16 后得到的都是 15,引发了哈希冲突。

话虽如此,但 Java 的 HashMap 采用除法散列就是用2的整数幂作为哈希表的大小 M ,这样就不用取模,可以直接位运算。比如,M 是 2 ^ 16 ,那就是取后 16 位,key ^= key >> 16 ,其结果作为哈希值,映射的值还是在 [0, M) 范围内,但是让 key 所有的位都参与计算,这样映射出的哈希更均匀一下。

乘法散列法

乘法散列法对哈希大小 M 没有要求,其实现首先对 key 乘以常数 A(0 < A < 1),,并抽取 key * A的小数部分,然后用 M 乘以 key * A的小数部分再舍去小数向下取整

hash(key) = floor(M * ((A * key) % 1.0)) ,floor 表示向下取整。那问题又来了,A 的值如何设定,一般将 A 取黄金分割点比较好,A = 0.6180339887... 。

假设 M 为1024,key 为 1234 ,A = 0.6180339887,A * key = 762.6539420558,取小数部分为0.6539420558, M * ((A × key) % 1.0) = 0.6539420558 * 1024= 669.6366651392,那么hash(1234) = 669 。

其实还有其他哈希函数,比如全域散列法、平方取中法、折叠法、随机数法和数学分析法,这里就不过多讲解了。

哈希表的实现

实际上实现哈希表用的最多的除法散列法,但是无论什么哈希函数都无法避免哈希冲突的问题,接下来就是两种解决哈希冲突的方法,也是两种不同形式的哈希表实现。

开发定址法(闭散列)

该方法将所有元素放到哈希表中,当出现哈希冲突时按照某种方法(线性探测、二次探测和双重探测)寻找下一个没有存储数据的位置进行存储,开放定址法中负载因子一定小于 1 。

线性探测:当 key 取模得到的 hashi 位置已经有了元素后,那么 hashi++ 向后寻找空位置,若 hashi > 哈希表的空间大小 M,让 hashi 回到哈希表头。

线性探测也有其缺点,若一个位置发生连续冲突时,会发生群集/堆积现象,这样又会带来一个问题,删除某个元素后,可能会影响后面冲突的查找,这时又需要给每个元素增加状态标识。

这里我们用开放定址实现Key-Value的哈希表。

整体框架
​
​
​
//状态标识,防止元素删除后对查找后面冲突值的影响
enum State
{
	EXIST,
	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);
	}
};

//string做哈希表的key很常见,对哈希函数进行string特化
template<>
struct HashFunc<string>
{
	// 字符串转换成整形,可以把字符asci码相加即可
	// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
	// 这⾥⽤上次的计算结果去乘以⼀个质数去解决,这个质数⼀般取
	// 31, 131等效果会⽐较好
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 131;
		}
		return hash;
	}
};

//找比n大,离n最近的质数
inline unsigned long __stl_next_prime(unsigned long n)
{
	//质数数组
	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;
	//在first和last找大于等于n位置的指针
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

//哈希表
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
    //构造函数
	HashTable()
		:_tables(__stl_next_prime(0))
		,_n(0)
	{}

    //......
	

private:
	vector<HashData<K, V>> _tables;  //用vector来存储元素
	size_t _n;                       //记录有效元素的个数
};

​
哈希表的插入

1.扩容函数:负载因子大于等于0.7时需要扩容,如果采用旧表映射到新表的方法,这样的逻辑跟Insert函数太重复了,有点浪费,我们可以直接在函数栈帧建立一个新表复用Insert函数,最后交换两个tables就可以完成扩容了。

2.线性探测:通过 key 计算得到 hashi 后,还不能直接往其插入,得判断元素状态。如果 hashi 位置的状态为 EXIST ,则不能往该位置插入,因为被其他元素占有了,得线性探测往后寻找空位置。

void CheckCapacity()
{
	//当负载因子>=0.7时,扩容
	if (_n * 10 / _tables.size() >= 7)
	{
		//方法一:旧表映射到新表
		/*vector<HashData<K, V>> newtables(_tables.size() * 2);
		for (auto& data : _tables)
		{
			if (data._state == EXIST)
			{
				size_t newhashi = HashFunc(data._kv.first) % newtables.size();
				newtables[newhashi]._kv = data._kv;
				newtables[newhashi]._state = EXIST;
			}
		}

		_tables.swap(newtables);*/

		//方法二:利用新哈希表
		HashTable<K, V> newht;
		//newht._tables.resize(_tables.size() * 2);
		newht._tables.resize(__stl_next_prime(_tables.size() + 1));

		for (auto& data : _tables)
		{
			if (data._state == EXIST)
			{
				newht.Insert(data._kv);
			}
		}

		_tables.swap(newht._tables);
	}
}

bool Insert(const pair<K, V>& kv)
{
	//如果找到了重复元素,直接返回false
	if (Find(kv.first))
		return false;
	
	//检查负载因子是否需要扩容
	CheckCapacity();
	
	//哈希函数
	Hash hashfunc;
	size_t hashi = hashfunc(kv.first) % _tables.size();
	
	//线性探测
	while (_tables[hashi]._state == EXIST)
	{
		++hashi;
		hashi %= _tables.size();
	}

	_tables[hashi]._kv = kv;
	_tables[hashi]._state = EXIST;
	++_n;

	return true;
}
哈希表的查找

哈希表的查找也要线性探测,当前元素状态不为空还不行,还需要该元素状态是存在且 key 相等,如果元素状态是空了,那说明在哈希表中真的找不到该元素了。

HashData<K, V>* Find(const K& key)
{
	Hash hashfunc;
	size_t hashi = hashfunc(key) % _tables.size();

	while (_tables[hashi]._state != EMPTY)
	{
		if (_tables[hashi]._state == EXIST
			&& _tables[hashi]._kv.first == key)
		{
			return &_tables[hashi];
		}

		hashi++;
		hashi %= _tables.size();
	}

	return nullptr;

}
 哈希表的删除

直接复用 Find 函数即可,找到了将其元素状态置删除,没找到就返回 false 即可。

bool Erase(const K& key)
{
	HashData<K, V>* ret = Find(key);
	if (ret)
	{
		ret->_state = DELETE;
		return true;
	}
	else
	{
		return false;
	}

}
测试开放定址法实现的哈希表
void test_open_hashtable2()
{
	srand((unsigned int)time(0));
	int N = 100000;
	vector<int> v(N);
	for (int i = 0; i < N; ++i)
	{
		v[i] = rand() + i;
	}

	open_address::HashTable<int, int> ht;
	for (auto e : v)
	{
		ht.Insert({ e, e });
	}
	auto ret = ht.Find(19);
	if (ret)
		cout << "找到了" << endl;
	else
		cout << "没找到" << endl;
}

链地址法(开散列)(重点)

开放地址法将所有元素放入哈希表中,链地址法就不是这样。链地址法中哈希表中存储一个指针没有数据映射这个位置时,这个指针为空有多个数据映射到这个位置时,把冲突的数据链接成一个链表,挂在哈希表下面,这种方法又叫哈希桶。

整体框架

//哈希函数
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return size_t(key);
	}
};

//string做哈希表的key很常见,对哈希函数进行string特化
template<>
struct HashFunc<string>
{
	// 字符串转换成整形,可以把字符asci码相加即可
	// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
	// 这⾥⽤上次的计算结果去乘以⼀个质数去解决,这个质数⼀般取
	// 31, 131等效果会⽐较好
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 131;
		}
		return hash;
	}
};

//找比n大,离n最近的质数
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;
	//在first和last找大于等于n位置的指针
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

//哈希表里存节点的指针
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, class V, class hash = HashFunc<K>>
class HashTable
{
	typedef HashNode<K, V> Node;
public:
	HashTable()
		: _tables(__stl_next_prime(0))
		,_n(0)
	{}

    //...

private:
	vector<Node*> _tables;
	size_t _n = 0;
};
哈希表的插入
void CheckCapacity()
{
	//当哈希表里节点的数量与哈希表大小相等时扩容
	if (_n == _tables.size())
	{
		//把哈希桶里的链表每个节点拆下来插入newht效率太低了
		/*HashTable<K, V> newht;
		newht._tables.resize(__stl_next_prime(_tables.size() + 1));
		for (size_t i = 0; i < _tables.size(); ++i)
		{
			Node* cur = _tables[i];
			while (cur)
			{
				newht.Insert(cur->_kv);
				cur = cur->_next;
			}
		}
		_tables.swap(newht._tables);*/

		hash hashfunc;
		vector<Node*> newtables(__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 = hashfunc(cur->_kv.first) % newtables.size();
				//头插
				cur->_next = newtables[hashi];
				newtables[hashi] = cur;

				cur = next;
			}

			_tables[i] = nullptr;
		}
		_tables.swap(newtables);
	}
}

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

	//检查扩容
	CheckCapacity();

	hash hashfunc;
	size_t hashi = hashfunc(kv.first) % _tables.size();

	//头插
	Node* newnode = new Node(kv);
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;
	++_n;

	return true;
}

这里如果利用新哈希表复用 Insert 函数来完成扩容的话,效率太低了。因为这种方式本质是将原本哈希表的数据复制到新哈希表里,需要 new  出新节点;当将新哈希表与旧哈希表交换后,此时新哈希表挂着原来的链表数据,由于新哈希表是局部对象出了作用域会调用哈希表的析构函数(当然这里还没写),又 new 节点 ,又析构,这样消耗太大了,导致效率很低。

所以这里选择直接将旧节点拿下放入新 tables 中,而且必须要一个一个拿,不能直接拿下头节点指针然后存入新 tables 。因为扩容后原本的冲突的数据大概率就不冲突了,在新哈希表的位置就不一样了。

哈希表的查找

查找很简单,不过多叙述了。

Node* Find(const K& key)
{
	hash hashfunc;
	size_t hashi = hashfunc(key) % _tables.size();
	Node* cur = _tables[hashi];

	while (cur)
	{
		if (cur->_kv.first == key)
			return cur;
		cur = cur->_next;
	}

	return nullptr;
}
哈希表的删除

与单链表的删除类似,找删除节点的过程要记录其前一个节点,也要特殊处理删除头节点的情况,也比较容易,不做解释。

bool Erase(const K& key)
{
	hash hashfunc;
	size_t hashi = hashfunc(key) % _tables.size();

	Node* prev = nullptr;
	Node* cur = _tables[hashi];
	while (cur)
	{
		if (cur->_kv.first == key)
		{
			//头节点的情况特殊处理
			if (prev == nullptr)
			{
				_tables[hashi] = cur->_next;
			}
			else
			{
				prev->_next = cur->_next;
			}

			delete cur;
			--_n;
			return true;
		}
		else
		{
			prev = cur;
			cur = cur->_next;
		}
	}

	return false;
}
测试链地址法实现的哈希表
void test_bucket_hashtable1()
{
	int a[] = { 19,30,5,36,13,20,21,12,24,96, 19};
	hash_bucket::HashTable<int, int> ht;
	for (auto e : a)
	{
		ht.Insert({ e, e });
	}

	auto ret = ht.Find(12);
	if (ret)
		cout << "找到了" << endl;
	else
		cout << "没找到" << endl;

	ht.Erase(12);

	ret = ht.Find(12);
	if (ret)
		cout << "找到了" << endl;
	else
		cout << "没找到" << endl;
}

void test_bucket_hashtable2()
{
	srand((unsigned int)time(0));
	int N = 100000;
	vector<int> v(N);
	for (int i = 0; i < N; ++i)
	{
		v[i] = rand() + i;
	}

	hash_bucket::HashTable<int, int> ht;
	for (auto& e : v)
	{
		ht.Insert({ e, e });
	}
	 
	auto ret = ht.Find(12);
}


 拜拜,下期再见😏

摸鱼ing😴✨🎞

;