文章目录
- 1、unordered_map unordered_set
- 2、哈希表
- 1、闭散列
- 2、开散列(拉链法/哈希桶)
- 继续优化
- 3、封装unordered和迭代器
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个位置,但这个位置曾经又何尝不是别人的?看到这里你也许会想到希尔伯特悖论…
但这不是数学,也不是无限,这是有限的,如果说现有空间不足了,已经全部被占领了,那么就需要拓宽空间,如果在原空间上增加,就会出现一个问题,原有的位置也跟着改变了,因为空间不一样了,除数也不一样了,那么找到的映射位置就不一样了。想要规则化它们很麻烦,所以就重新开辟空间,替换掉之前的空间,再重新映射。
删除不能直接删除,空出来这一块位置如何处理?查找的时候,从映射位置开始,直到找到空结束,如果删除一个位置,把它置为空,就会影响查找。为了解决问题,可以用一个状态标识来表示有没有值存在,这样删除就只改状态,不实际作用于数据。每个数据的位置除了数据还要有状态标识。
#include <vector>
enum State
{
EMPTY,
EXITS,
DELETE
};
template<class K, class V>
struct HashDate
{
pair<K, V> _kv;
State _state;
};
template<class K, class V>
class HashTable
{
private:
vector<HashDate<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(newtable);
}
这个改进一下,用复用。
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)
{
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);
/*HashTable<K, V> newht;
newht.resize(newsize);
for (auto cur : _tables)
{
while (cur)
{
newht.Insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newht._tables);*/
}
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;
}
}
}
继续优化
如果用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;
}
};
template<class K, class V, class Hash = HashFunc<K>>
那么每个取模的地方都得改,
Hash hash;
size_t hashi = hash(key) % _tables.size();
这样就行了。但是字符串不支持显示整形,在调用测试函数前这样写,访问第一个字母
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了。
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31;
}
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)
{
const int PRIMECOUNT = 28;
static const size_t primeList[PRIMECOUNT] =
{
53ul, 97ul, 193ul, 389ul, 769ul,
1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
1572869ul, 3145739ul, 6291469ul, 12582917ul,
25165843ul,
50331653ul, 100663319ul, 201326611ul, 402653189ul,
805306457ul,
1610612741ul, 3221225473ul, 4294967291ul
};
size_t i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > prime)
return primeList[i];
}
return primeList[i];
}
把代码里的数字换成图片中就好。每次扩容时这样写就行
size_t newsize = GetNextPrime(_tables.size());
3、封装unordered和迭代器
迭代器最关键的就是++应该怎么办?这里就按照库的做法,再遍历一个桶之前,就先找下一个不为空的桶。这样一个桶结束后就跳去下一个桶;当然还会考虑一个问题,如果走到了尾还没有发现不为空的桶,这就在代码中展现。
为了优化迭代器,代码里还放上了简洁的日期类,日期类不支持转成整型,哈希表里的hashtable那里不能传 class Hash = HashFunc< K >,这个要放在map和set的文件里写。
迭代器里不可修改,所以要加上这个。
哈希表
结束。