C++学习记录——이십삼 哈希表


1、unordered_map unordered_set

C++11提供,功能和map、set完全类似,不过它们底层实现是红黑树,而这两个底层是哈希表。从名字上可以看出,它们的迭代是无序的。之前的是双向迭代器,这两个则是单向迭代器。

#include <iostream>
#include <unordered_set>
#include <unordered_map>
#include <string>
using namespace std;

void test_set1()
{
    
    
	unordered_set<int> s;
	s.insert(1);
	s.insert(4);
	s.insert(7);
	s.insert(10);
	s.insert(2);
	unordered_set<int>::iterator it = s.begin();
	while (it != s.end())
	{
    
    
		cout << *it << " ";
		++it;
	}
	cout << endl;
	for (auto e : s)
	{
    
    
		cout << e << " ";
	}
	cout << endl;
}

void test_map1()
{
    
    
	string arr[] = {
    
     "西瓜", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉", "梨" };
	unordered_map<string, int> countMap;
	for (auto& e : arr)
	{
    
    
		countMap[e]++;
	}
	for (auto& kv : countMap)
	{
    
    
		cout << kv.first << ":" << kv.second << endl;
	}
}

int main()
{
    
    
	test_set1();
	test_map1();
	return 0;
}

在这里插入图片描述

它们也有multi版本。

相比之下,底层哈希表的map和set访问速度要更快。在release模式下测试一下。

void Time()
{
    
    
	const size_t N = 1000000;//测试数据,因为随机数最多产生3万多个,所以有大量重复数据
	unordered_set<int> us;
	set<int> s;
	vector<int> v;
	v.reserve(N);
	srand(time(0));

	//插入
	for(size_t i = 0; i < N; ++i)
	{
    
    
		//v.push_back(rand());
		v.push_back(rand() + i);//减少重复数据
		//v.push_back(i)这个就是纯粹的N是多少,就有多少个
	}
	size_t begin1 = clock();
	for (auto e : v)
	{
    
    
		s.insert(e);
	}
	size_t end1 = clock();
	cout << "set insert:" << end1 - begin1 << endl;
	size_t begin2 = clock();
	for (auto e : v)
	{
    
    
		us.insert(e);
	}
	size_t end2 = clock();
	cout << "unordered_set insert:" << end2 - begin2 << endl;

	//查找
	size_t begin3 = clock();
	for (auto e : v)
	{
    
    
		s.find(e);
	}
	size_t end3 = clock();
	cout << "set find:" << end3 - begin3 << endl;
	size_t begin4 = clock();
	for (auto e : v)
	{
    
    
		us.find(e);
	}
	size_t end4 = clock();
	cout << "unordered_set find:" << end4 - begin4 << endl;
 
    cout << s.size() << endl;
	cout << us.size() << endl;

	//删除
	size_t begin5 = clock();
	for (auto e : v)
	{
    
    
		s.erase(e);
	}
	size_t end5 = clock();
	cout << "set erase:" << end5 - begin5 << endl;
	size_t begin6 = clock();
	for (auto e : v)
	{
    
    
		us.erase(e);
	}
	size_t end6 = clock();
	cout << "unordered_set erase:" << end6 - begin6 << endl;
}

在这里插入图片描述

差不多底层哈希表的都要更快一些。特别是哈希表的查找,比红黑树快得多。如果是有序数据,比如v.push_back(i),红黑树比哈希表快。

2、哈希表

哈希也叫散列。哈希/散列实际上是一种方法。Key和存储位置有关,要建立映射关系。

对于哈希这个方法,如果范围比较集中,就可以将每个数据分配一个唯一位置;如果范围不集中,分布分散,那么会采取取模的办法,但这也有可能造成不同的值映射到同一个位置,这会叫做哈希冲突/碰撞。为了解决这个,有以下这几个办法

1、闭散列

闭散列:它的思路就是找下一个位置。线性探测就是一个个看哪个为空,就占据哪个,一些相邻聚集位置连续冲突,可能会形成“踩踏”;缓解的办法是2次探测,key % len(长度) ,然后一直+i的平方,i >= 0。但事实上来讲2次探测也不是有用的,闭散列的情况就如同占位置一样,今天你坐在第4个位置,明天发现第4个位置被占领了,你只好去第5个位置,但这个位置曾经又何尝不是别人的?看到这里你也许会想到希尔伯特悖论…

但这不是数学,也不是无限,这是有限的,如果说现有空间不足了,已经全部被占领了,那么就需要拓宽空间,如果在原空间上增加,就会出现一个问题,原有的位置也跟着改变了,因为空间不一样了,除数也不一样了,那么找到的映射位置就不一样了。想要规则化它们很麻烦,所以就重新开辟空间,替换掉之前的空间,再重新映射。

删除不能直接删除,空出来这一块位置如何处理?查找的时候,从映射位置开始,直到找到空结束,如果删除一个位置,把它置为空,就会影响查找。为了解决问题,可以用一个状态标识来表示有没有值存在,这样删除就只改状态,不实际作用于数据。每个数据的位置除了数据还要有状态标识。

namespace close
{
    
    
	enum State
	{
    
    
		EMPTY,
		EXIST,
		Delete
	};

	template<class K, class V>
	struct HashData
	{
    
    
		pair<K, V> _kv;
		State _state;
	};

	template<class K, class V>
	class HashTable
	{
    
    
	private:
		vector<HashData<K, V>> _tables;
		size_t _n = 0;//存储的数据个数
	};
}

插入(二次探测)

		bool Insert(const pair<K, V>& kv)
		{
    
    
			size_t hashi = kv.first % _tables.size();
			//二次线性探测
			size_t i = 0;
			size_t index = hashi;
			while (_tables[index]._state == EXIST)
			{
    
    
				index = hashi + i * i;//不是二次那就+i
				index %= _tables.size();//防止index走出去
				++i;
			}
			_tables[index]._kv = kv;
			_tables[index]._state = EXIST;
			_n++;
			return true;
		}

如果有100个位置,90个位置都有值了,那么再插入一个起冲突的概率就很大。这里有个载荷因子的概念,用来表示装满的程度。填入表中的元素个数 / 散列表的长度。C++把这个因子数控制在0.8之内。

扩容

		if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
		{
    
    
			size_t newsize = tables.size() == 0 ? 10 : _tables.size() * 2;
			vector<HashData> newtables(newsize);
			for (auto& data : _tables)
			{
    
    
				if (data._state == EXIST)
				{
    
    
					//重新算在新表的位置
				}
			}
			_tables.swap(newtables);
		}

这个改进一下,用复用。

		if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
		{
    
    
			size_t newsize = tables.size() == 0 ? 10 : _tables.size() * 2;
			//vector<HashData> newtables(newsize);
			HashTable<K, V> newht;
			newht._tables.resize(newsize);
			for (auto& data : _tables)
			{
    
    
				if (data._state == EXIST)
				{
    
    
					//重新算在新表的位置
					//复用方法
					newht.Insert(data._kv);
				}
			}
			_tables.swap(newht._tables);
		}

查找、删除

	HashDate<K, V>* Find(const K& key)
	{
    
    
	    if (_tables.size() == 0) return false;
		size_t hashi = kv.first % _tables.size();
		size_t i = 1;
		size_t index = hashi;
		while (_tables[index]._state != EMPTY)
		{
    
    
			if (_tables[index]._kv.first == key)
			{
    
    
				return &_tables[index];
			}
			index = hashi + i;
			index %= _tables.size();
			++i;
		}
		return nullptr;
	}

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

但是在查找函数,如果找到了与key相等的位置,就改变标识符,但实际上还是存在这个值的,映射时还会受影响。 所以if判断条件改为

_tables[index]._state == EXIST && _tables[index]._kv.first == key

所以可以在插入之前可以先find一下在不在,在就退出,但是会出现除0错误,所以find那里还需要一开头判断一下,如果大小为0,就return false。

如果全是删除状态呢?后者还有别的状态,比如插入一部分数据后,在扩容前,删除一部分数据,再插入数据,并且数据正好占据其他空位,导致表里除了存在就是删除,那么Find就无法正常运行,因为没有空状态了。

Find添加一个这个,index可能会走一圈又回到hashi的位置。

			if (index == hashi)
			{
    
    
				break;
			}

对于闭散列来讲,冲突越多越低效。i也可以随意变化,但实际上这样看下来,还是麻烦,考虑得多,并且效率也不是很好,所以要用开散列

2、开散列(拉链法/哈希桶)

对于每一个位置,都是一个子集合,都是一个桶,各个桶的元素通过一个单链表链接起来,链表头结点存储在哈希表中。桶中每个元素都是发生哈希冲突的元素。相当于这个数组是一个指针数组。

所以相当于数组中的元素是链表。载荷因子还是要有。载荷因子越大,冲突的概率越高,查找效率越低,空间利用率越高;载荷因子越小,冲突的概率越低,查找效率越高,空间利用率越低

扩容

			if (_n == _tables.size())
			{
    
    
				size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				HashTable<K, V> newht;
				newht.resize(newsize);
				for (auto cur : _tables)
				{
    
    
					while (cur)
					{
    
    
						newht.Insert(cur->_kv);
						cur = cur->_next;
					}
				}
				_tables.swap(newht._tables);
			}

这里还有优化空间。扩容有了新空间,需要释放原空间。

		~HashTable()
		{
    
    
			for (auto& cur : _tables)
			{
    
    
				while (cur)
				{
    
    
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}
				cur = nullptr;
			}
		}

这里的重点在于调用了insert,它会创建新结点,旧表要释放,完全要释放。所以不如之前插入的节点挪动到新的表中,而不是重新插入。这里的代码会复杂点,不过效率更高。

			if (_n == _tables.size())
			{
    
    
				size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				vector<Node*> newtables(newsize, nullptr);
				for (auto& cur : _tables)//Node*&
				{
    
    
					while (cur)
					{
    
    
						Node* next = cur->_next;
						size_t hashi = cur->_kv.first % newtables.size();
						cur->_next = newtables[hashi];
						newtables[hashi] = cur;
						cur = next;
					}
				}
				_tables.swap(newtables);

现在的整体代码

	template<class K, class V>
	struct HashNode
	{
    
    
		HashNode<K, V>* _next;
		pair<K, V> _kv;
		HashNode(const pair<K, V>& kv)
			:_next(nullptr)
			, _kv(kv)
		{
    
    }
	};
	template<class K, class V>
	class HashTable
	{
    
    
		typedef HashNode<K, V> Node;
	public:
		~HashTable()
		{
    
    
			for (auto& cur : _tables)
			{
    
    
				while (cur)
				{
    
    
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}
				cur = nullptr;
			}
		}

		bool Insert(const pair<K, V>& kv)
		{
    
    
			//扩容
			if (_n == _tables.size())
			{
    
    
				size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				vector<Node*> newtables(newsize, nullptr);
				for (auto& cur : _tables)//Node*&
				{
    
    
					while (cur)
					{
    
    
						Node* next = cur->_next;
						size_t hashi = cur->_kv.first % newtables.size();
						cur->_next = newtables[hashi];
						newtables[hashi] = cur;
						cur = next;
					}
				}
				_tables.swap(newtables);
			}
			size_t hashi = kv.first % _tables.size();
			//头插
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;
			++_n;
			return true;
		}
	private:
		vector<Node*> _tables;
		size_t _n = 0;//载荷因子
	};

查找

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

 //Insert就可以在一开始添加上
 if(Find(kv.first))
 {
    
    
     return false;
 }

删除不能用Find来帮助Erase。

		bool Erase(const K& key)
		{
    
    
			size_t hashi = 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;
					return true;
				}
				else
				{
    
    
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

继续优化

如果用HashTable<string, string>的数据来插入的话会报错,原因出在插入函数的这:size_t hashi = cur->_kv.first % newtables.size(),因为字符串不能取模,为了整体的泛型编程,我们在HashTable那里加一个模板,写一个仿函数用来类型转换。

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

    //HashTbale的模板参数
	template<class K, class V, class Hash = HashFunc<K>>

那么每个取模的地方都得改,

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

		bool Insert(const pair<K, V>& kv)
		{
    
    
			if (Find(kv.first))
			{
    
    
				return false;
			}
			Hash hash;
			//扩容
			if (_n == _tables.size())
			{
    
    
				size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				vector<Node*> newtables(newsize, nullptr);
				for (auto& cur : _tables)//Node*&
				{
    
    
					while (cur)
					{
    
    
						Node* next = cur->_next;
						size_t hashi = hash(cur->_kv.first) % newtables.size();
						cur->_next = newtables[hashi];
						newtables[hashi] = cur;
						cur = next;
					}
				}
				_tables.swap(newtables);
			}
			size_t hashi = hash(kv.first) % _tables.size();
			//头插
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;
			++_n;
			return true;
		}

		bool Erase(const K& key)
		{
    
    
			Hash hash;
			size_t hashi = hash(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;
					return true;
				}
				else
				{
    
    
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

这样就行了。但是字符串不支持显示整形,在调用测试函数前这样写,访问第一个字母

	struct HashStr
	{
    
    
		size_t operator()(const string& s)
		{
    
    
			return s[0];
		}
	};

	void TestHashTable3()
	{
    
    
		HashTable<string, string, HashStr> ht;
		ht.Insert(make_pair("sort", "排序"));
		ht.Insert(make_pair("left", "左边"));
		ht.Insert(make_pair("right", "右边"));
	}

但是这样不行,一个是传nullptr问题,一个是如果首字母都一样,那就分辨不出来了。

	struct HashStr
	{
    
    
		size_t operator()(const string& s)
		{
    
    
			size_t hash = 0;
			for (auto ch : s)
			{
    
    
				hash += ch;
			}
			return hash;
		}
	};

实际上这还有问题,就是打印四个字母,不同的字符串但是加起来相同,打印出来还是一样的数字

		HashStr hashstr;
		cout << hashstr("abcd") << endl;
		cout << hashstr("aadd") << endl;

关于字符串的哈希算法可以看这篇:字符串哈希算法

这里采用其中一个。改动时可以在 += ch后写上hash *= 31,也有其他值都可以,30什么的。

更好的写法是把这个针对字符串做的改动做成类的特化。外面的实例化对象也不需要传HashStr了。下面写两个方法,BKDR最好,AP次之。

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

	template<>
	struct HashFunc<string>
	{
    
    
	    //BKDR
		size_t operator()(const string& s)
		{
    
    
			size_t hash = 0;
			for (auto ch : s)
			{
    
    
				hash += ch;
				hash *= 31;
			}
			return hash;
		}
	};
	
	template<class T>
	size_t APHash(const T* str)
	{
    
    
		register size_t hash = 0;
		size_t ch;
		for (long i = 0; ch = (size_t)*str++; i++)
		{
    
    
			if ((i & 1) == 0)
			{
    
    
				hash ^= ((hash << 7) ^ ch ^ (hash >> 3));
			}
			else
			{
    
    
				hash ^= (~((hash << 11) ^ ch ^ (hash >> 5)));
			}
		}
		return hash;
	}

增删查改的时间复杂度最坏是O(N),但因为扩容的原因,一些冲突元素大概率不冲突,并且还有载荷因子的控制,所以最坏的情况很少出现,所以看平均复杂度O(1)即可。

为了检测,在类里写一个找出最深桶的函数,并且打印所有的桶大小。

		size_t MaxBucketSize()
		{
    
    
			size_t max = 0;
			for (size_t i = 0; i < _tables.size(); ++i)
			{
    
    
				auto cur = _tables[i];
				size_t size = 0;
				while (cur)
				{
    
    
					++size;
					cur = cur->_next;
				}
				printf("[%d]->%d\n", i, size);
				if (size > max)
				{
    
    
					max = size;
				}
			}
			return max;
		}

        void TestHashTable4()
	    {
    
    
		    size_t N = 100000;
		    HashTable<int, int>ht;
		    srand(time(0));
		    for (size_t i = 0; i < N; ++i)
		    {
    
    
			    size_t x = rand() + i;
			    ht.Insert(make_pair(x, x));
		    }
		    cout << ht.MaxBucketSize() << endl;
	    }

对于极端情况,比如链表长度比较大,那么一个解决办法就是挂红黑树而不是链表,java有这样做。

还有一个优化就是让除模那里的除数是素数。SGI版本对此的做法是给了一个素数表,数量是28个。

在这里插入图片描述

		size_t GetNextPrime(size_t prime)
		{
    
    
			// SGI
			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
			};
			size_t i = 0;
			for (; i < __stl_num_primes; ++i)
			{
    
    
				if (__stl_prime_list[i] > prime)
					return __stl_prime_list[i];
			}
			return __stl_prime_list[i];
		}

每次扩容时这样写就行

size_t newsize = GetNextPrime(_tables.size());

3、封装unordered和迭代器

迭代器最关键的就是++应该怎么办?这里就按照库的做法,再遍历一个桶之前,就先找下一个不为空的桶。这样一个桶结束后就跳去下一个桶;当然还会考虑一个问题,如果走到了尾还没有发现不为空的桶,这就在代码中展现。

为了优化迭代器,代码里还放上了简洁的日期类,日期类不支持转成整型,哈希表里的hashtable那里不能传 class Hash = HashFunc< K >,这个要放在map和set的文件里写。

迭代器里不可修改,所以要加上这个。

哈希表

结束。

猜你喜欢

转载自blog.csdn.net/kongqizyd146/article/details/130649231