在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 l o g 2 N log_2N log2N,例如map和set。即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不是很理想。
最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器(unordered_map、unordered_set、unordered_multimap和unordered_multiset),这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,本篇中只对unordered_map和unordered_set进行介绍,并对其底层进行模拟实现,下一篇用本篇的模拟实现来封装unordered_map和unordered_set。
STL中给的unordered_map和unordered_set用法可以说和map和set一样。我就不过多介绍了,如果对于map和set的使用不了解的同学,可以看看这篇:【C++】STL map和set用法基本介绍。
我就直接用set来进行对比了。
下面代码为比较二者增删查的速度。
void test_op()
{
int n = 10000;
vector<int> v;
v.reserve(n); // 先开到n,等会将产生的数据放到v中
srand(time(0));
for (int i = 0; i < n; ++i)
{
v.push_back(rand() + i); // 重复少
//v.push_back(rand()); // 重复多
}
// set的插入时间
size_t begin1 = clock();
set<int> s;
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
// unordered_set插入的时间
size_t begin2 = clock();
unordered_set<int> us;
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "总共插入的数据个数:" << s.size() << endl << endl;
cout << "set insert:" << end1 - begin1 << endl;
cout << "unordered_set insert:" << end2 - begin2 << endl << endl;
// set的查找时间
size_t begin3 = clock();
for (auto e : v)
{
s.find(e);
}
size_t end3 = clock();
// unordered_set查找的时间
size_t begin4 = clock();
for (auto e : v)
{
us.find(e);
}
size_t end4 = clock();
cout << "set find:" << end3 - begin3 << endl;
cout << "unordered_set find:" << end4 - begin4 << endl << endl;
// set的删除时间
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
// unordered_set删除的时间
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "set erase:" << end5 - begin5 << endl;
cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
}
不断改变n的大小来改变插入元素的个数。下面的测试都是在debug版本下的:
因为随机数可能会产生重复的数,所以去重后的数据就会少一点。
可以看到,哈希还是很快的。
开始讲底层。
不知各位接触过计数排序没有。计数排序中就用到了哈希的思想。通过下标直接查找到某个数据。
这里给出1、3、0、12、5、14、2、4、7、6、9、8、11、10、15、13这个数,通过顺序表存储,我想要在O(1)的时间复杂度下查找任意一个数,各位想想有什么好办法?
可以直接通过下标产生对应的映射,什么意思呢?
就是下标0位置存放的数可以直接存储0,下标1位置存放的数可以直接存储1,下标2存储2,下标3存储3,下标4存储4,下标5存储5……一直到下标15。
那么假如顺序表名称为v,我们用v[5]就可以直接找到5,用v[13]就可以直接找到13……等等。找的时候根本不需要像红黑树那样不断对比才能找到,这样就是O(1)的时间复杂度。
那么哈希(也可叫散列)就是这样的思想,让每一个数据与其位置建立映射关系,通过位置直接查找出来所需的数据,这样查找起来就会非常的快。
但是有一个问题,就是当上面数据分布不是那么均匀(大的大,小的小)时该怎么办呢?
比如:3、7、19、25、36、300、7000
我们如果光对这7个数开7000个int,怕是有点不值得。得改一改思路。
我们可以将数进行取模运算,得到的余数即放到对应的顺序表中,比如这里把顺序表开到10个int。上面各个数对10进行取模就分别得到了3、7、9、5、6、0、0。
这样取模的方式就叫做除留余数法,是一种哈希函数(哈希函数传一个值能够产生对应的下标,哈希函数不止这里的除留余数法,还有其他的,不过除留余数法更常用,对于其他方法感兴趣的同学可以自己查查)。
那么存储下来就是这样:
虽然说解决了,但是还出现了一个问题,就是300和7000占用了同一个位置。
这个问题就叫做哈希冲突,也叫哈希碰撞。
有两个方法可以解决这个问题。分别为 闭散列 和 开散列。
闭散列 ===》 开放地址法。
开散列 ===》 拉链法/哈希桶。
那么我来挨个介绍,并进行模拟实现。
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
用闭散列的方式就是这样的:
将7000放到算出来的0下标的下一个位置1下标处。
如果再来一个20的话,余数还是0,此时还是继续往后寻找空位置,也就是2:
那么再来一个40呢?
余数还是0,被占用了,1、2、3也被占了,那就放到4处:
这里光一个0下标位置就几乎占了一半的下标了。
当空间快满的时候就需要进行扩容,这里可以搞一个负载因子来决定扩容时机。
负载因子 = 实际存储数据个数 / 当前顺序表的大小。这样的话负载因子取值范围就是[0, 1]。
我们可以控制当负载因子为0.7时就进行扩容。
接着上面的,如果我们此时查找20的话,先算出对应下标0,下标0处不是20,继续往后找,为7000,继续找,为20,此时即找到。
但如果找一个不存在的数呢?比如说66,余数为6,下标6不是,继续下标7,不是下标8找到空,此时就可以说明不存在了,因为我们存放的时候如果位置被占了,就会沿着继续往后找,直到找到一个空位置放进去就行。此时我们找一个数,位置被占了,沿着找,到了空就是没找到。
但是如果20被删除了呢?
找40,中间20是空的,就会出现找不到的情况。
我们可以将每一个位置设置一个标志位,EMPTY表示这个位置是空的,曾经没有数据。EXIST表示这个位置中有数据。DELETE表示这个位置没数据,但曾经是有数据的,只是被删除了,我们在查找的时候就不能跳过这个位置。其中EMPTY和DELETE能够插入数据。当想要删除数据的时候,只需要将标志位置为DELETE就行了。
如果说数据一直插入删除导致所有的位置的标志位都变为了DELETE。这样的话查找时就要先找到初始位置,然后从一个位置往后找到顺序表的末尾,再循环到顺序表的最开始找,直到又重新找到原始位置。但一般是不会出现这种情况的,因为
可以看到,这个方法并不是那么好,但是我们还是要学一学的。
下面我们就模拟实现一下。
还有问题。
库中是可以存放字符串的。而我们这里实现的可不行。
因为字符串可没有取模的功能。
想要改改的话,就得搞仿函数。当传一个string的时候,能够返回一个数就行。
那么上面的代码就要改改。在CloseHash的模版参数中添加一个参数hash用来传仿函数,然后在用到%的地方将pair的first套上仿函数。
对于像int、float、double、long等等这样的类型,直接返回其本身强转的值就够了:
而对于我们的string需要再写一个仿函数:
但是模版有特化,我们还可以这样写:
这样的话,自定义类型再多传一个模版参数就行了。
还有问题。
字符串冲突的概率还是比数字大不少的,比如说 “abcd” “bcad” “aadd” 还有 “eat” "ate"等等。
那么怎么解决呢?
有一篇博客详细介绍了:字符串哈希算法
里面有一种方法:
库中就用的是这个,我们也用:
这样产生的数重复就会少一点,而且相同字符串每次产生的树都是相同的。
上面就是对于闭散列进行的简单实现。实现的时候当数据重复了,就从余数下标位置开始找,一个一个的找空位置,这种一个一个找的方式就叫做线性探测。还有种查找空位置的方式叫做二次探测,可不要听名字就觉得是两个两个位置的找,是平方平方的找。
先说一下线性查找的优缺点:
线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同
关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降
低。
线性查找是 h a s h i = h a s h i + x ( x 取 1 , 2 , 3 … … ) hashi = hashi + x(x取1,2,3……) hashi=hashi+x(x取1,2,3……)
再来说二次探测。查找的方式稍微变了点: h a s h i = h a s h i + x 2 hashi = hashi + x^2 hashi=hashi+x2,x取值同上。
那么这样找的话每次加的就是 1、4、9、16……
其中插入一个44,hash(44) = 4,4下标处有数据,4加上1的平方为5,5再加2的平方为9,9加上3的平方为18,再模上10得8,此时8处为空,那么就把44放到8下标处。
闭散列就讲到这里,没什么太难的地方,下面说更为重要的开散列。
开散列法又叫链地址法(开链法),首先对关键码集合用哈希函数计算散列地址,具有相同地
址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链
接起来,各链表的头结点存储在哈希表中。
上面的专业术语看起来比较模糊,看图:
仍然是上面的几个数,不过插入发生哈希冲突的时候只需要搞成链表,把冲突的数据挂到链表上就行了。对于闭散列来说冲突时不会占用其他数据的位置了,而且这样的话查找起来会更加方便,更快。
大概的意思就讲完了,下面来模拟实现。
首先,开散列中存放的是链表,那么用该用八种链表中的哪种呢?
单链表就行了,因为现实中就算有哈希冲突也不会很多的,等会我们模拟实现完后存放一下随机数各位就知道了,每个哈希桶的长度大多都是1,极个别的才是2、3,4往上的可以说几乎就没有。
和上面闭散列同样的问题,当前实现的开散列的方式没法存储string等自定义类型,所以还
要搞仿函数。
仿函数和上面闭散列中用到的一模一样,这里就不给了。
哈希表大小设置为素数能够减少哈希冲突。这句话是大佬说的,人家有科学依据,而且STL库中也是这么干的。
虽然我不知道为啥,但是咱们照做就行。
搞素数的函数如下:
inline size_t __stl_next_prime(size_t n)
{
static const size_t __stl_num_primes = 28;
static const size_t __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
};
for (size_t i = 0; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return -1;
}
到这里开散列和闭散列就讲的差不多了,我们来看看插入随机数每个链有多长。
如下几个接口:
// 表的长度
size_t TablesSize()
{
return _tables.size();
}
// 桶的个数
size_t BucketNum()
{
size_t num = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i])
{
++num;
}
}
return num;
}
// 最长桶的长度
size_t MaxBucketLenth()
{
size_t maxLen = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
size_t len = 0;
Node* cur = _tables[i];
while (cur)
{
++len;
cur = cur->_next;
}
//if (len > 0)
//printf("[%d]号桶长度:%d\n", i, len);
if (len > maxLen)
{
maxLen = len;
}
}
return maxLen;
}
size_t Size()
{
return _size;
}
测试代码:
void TestHT3()
{
int n = 10000;
vector<int> v;
v.reserve(n);
srand(time(0));
for (int i = 0; i < n; ++i)
{
//v.push_back(i);
v.push_back(rand() + i); // 重复少
//v.push_back(rand()); // 重复多
}
size_t begin1 = clock();
HashTable<int, int> ht;
for (auto e : v)
{
ht.Insert(make_pair(e, e));
}
size_t end1 = clock();
cout << "数据个数:" << ht.Size() << endl;
cout << "表的长度:" << ht.TablesSize() << endl;
cout << "桶的个数:" << ht.BucketNum() << endl;
cout << "平均每个桶的长度:" << (double)ht.Size() / (double)ht.BucketNum() << endl;
cout << "最长的桶的长度:" << ht.MaxBucketLenth() << endl;
cout << "负载因子:" << (double)ht.Size() / (double)ht.TablesSize() << endl;
}
然后测试:
结果也是显而易见了。桶长度一般都是1,2的都少,3、4就更不用说了。
所以说哈希冲突不会那么多的。
上面的接口,STL库中也提供了。
像bucket_count就是桶个数,max_bucket_count就是最多能有多少个桶,bucket就是第几个桶的大小,bucket是返回某个关键字所在哪一个桶中。
load_factor就是负载因子。rehash和reserve就是扩容的东西。
这篇就到这里,下一篇讲解用本篇的代码封装unordered_map和unordered_set。
本篇的所有代码如下:
#pragma once
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc<string>
{
size_t operator()(const string& str)
{
size_t res = 0;
for (auto c : str)
{
res += c;
res *= 131;
}
return res;
}
};
/*struct HashFuncString
{
size_t operator()(const string& str)
{
size_t res = 0;
for (auto c : str)
res += c;
return res;
}
};*/
namespace FangZhang_CloseHash
{
// 位置的状态
enum State
{
EMPTY, // 位置为空
EXIST, // 存在数据
DELETE // 数据被删除
};
// 顺序表中存放的数据 此处是k/v模型
// 等到讲哈希的封装的时候会改
template<class K, class V>
struct HashData
{
HashData(const pair<K, V>& kv = make_pair(K(), V()))
:_kv(kv)
, _state(EMPTY)
{}
pair<K, V> _kv;
State _state;
};
// 闭散列
template<class K, class V, class Hash = HashFunc<K>>
class CloseHash
{
typedef HashData<K, V> Data;
public:
bool Insert(const pair<K, V>& kv)
{
// 去重
if (Find(kv.first))
return false;
// 扩容 _size / _tables.size()就是负载因子
if (_tables.size() == 0 || 10 * _size / _tables.size() >= 7)
{
// 新搞一个哈希表
CloseHash newCH;
// 新空间大小
size_t newSize = _tables.size() == 0 ? 10 : 2 * _tables.size();
// 给新的哈希表开空间
newCH._tables.resize(newSize);
// 扩容后,将原表中的数据重新映射到新表中
// 改变原来的映射关系,重新映射
// 能够将原表中重复占位的数减少一些
for (int i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXIST)
newCH.Insert(_tables[i]._kv);
}
// 将新表中的数据换到原表中
_tables.swap(newCH._tables);
}
// 正式插入
//Hash hash;
//size_t hashi = hash(kv.first) % _tables.size();
//while (_tables[hashi]._state == EXIST)
//{ // DELETE 和 EMPTY都可以插入
// // 先寻找插入位置
// ++hashi;
// // 循环找空位置
// hashi %= _tables.size();
//}
//_tables[hashi]._kv = kv;
//_tables[hashi]._state = EXIST;
//++_size;
// 正式插入
Hash hash;
size_t hashi = hash(kv.first) % _tables.size();
int i = 0;
while (_tables[hashi]._state == EXIST)
{
++i;
hashi += i * i;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_size;
return true;
}
Data* Find(const K& key)
{
if (_tables.size() == 0)
return nullptr;
Hash hash;
// 先找位置
size_t hashi = hash(key) % _tables.size();
// 记录初始位置
size_t start = hashi;
// 标志位为delete或tmpty都要继续找
while (_tables[hashi]._state != EMPTY)
{
// 如果找到了那个数并且标志位不为delete就返回对应的地址
if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key)
return &_tables[hashi];
++hashi;
// 循环找
hashi %= _tables.size();
// 绕了一圈都没找到
if (hashi == start)
break;
}
return nullptr;
}
bool Erase(const K& key)
{
Data* pd = Find(key);
if (!pd)
{
return false;
}
else
{
pd->_state = DELETE;
--_size;
return true;
}
}
void Print()
{
for (int i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXIST)
{
printf("[%d::%d] ", i, _tables[i]._kv.first);
}
else
{
printf("[%d::*] ", i);
}
}
}
private:
vector<Data> _tables;
size_t _size;
};
void testCH1()
{
/*CloseHash ch;
int arr[] = { 3,7,19,25,36,300,7000,22 };
for (auto i : arr)
{
ch.Insert(make_pair(i, i));
}
ch.Print();
ch.Insert(make_pair(2, 2));
ch.Insert(make_pair(95, 95));
ch.Insert(make_pair(20, 20));
ch.Erase(300);
ch.Print();
HashData* pd = ch.Find(7000);
cout << pd->_kv.first << endl;
pd = ch.Find(20);
cout << pd->_kv.first << endl;
pd = ch.Find(22);
cout << pd->_kv.first << endl;
pd = ch.Find(300);
cout << pd->_kv.first << endl;*/
int a[] = { 1, 9, 4, 5, 6, 7};
CloseHash<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(44,44));
ht.Print();
ht.Erase(4);
cout << ht.Find(44)->_kv.first << endl;
cout << ht.Find(4) << endl;
ht.Print();
ht.Insert(make_pair(-2, -2));
ht.Print();
cout << ht.Find(-2)->_kv.first << endl;
}
void testCH2()
{
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
CloseHash<string, int> countHT;
for (auto& str : arr)
{
auto ptr = countHT.Find(str);
if (ptr)
{
ptr->_kv.second++;
}
else
{
countHT.Insert(make_pair(str, 1));
}
}
}
void TestCH3()
{
HashFunc<string> hash;
cout << hash("abcd") << endl;
cout << hash("bcad") << endl;
cout << hash("eat") << endl;
cout << hash("ate") << endl;
cout << hash("abcd") << endl;
cout << hash("aadd") << endl << endl;
cout << hash("abcd") << endl;
cout << hash("bcad") << endl;
cout << hash("eat") << endl;
cout << hash("ate") << endl;
cout << hash("abcd") << endl;
cout << hash("aadd") << endl << endl;
}
}
namespace FangZhang_OpenHash
{
template<class K, class V>
struct HashNode
{
HashNode(const pair<K, V>& kv = make_pair(K(), V()))
:_kv(kv)
, _next(nullptr)
{}
pair<K, V> _kv;
HashNode<K, V>* _next;
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
bool Insert(const pair<K, V>& kv)
{
// 去重
if (Find(kv.first))
{
return false;
}
// 扩容
// 此处扩容就不需要让负载因子为0.7了
// 直接_size == _tables.size()就行
// 因为不会出现占用其他数据位置的情况
if (_size == _tables.size())
{
size_t newSize = __stl_next_prime(_size);
// 这里可以利用前面开辟好的节点
// 所以就不需要像闭散列那样再整一个哈希表了
// 复用Insert的话开销比较大,还要重新给节点开空间
// 所以直接给一个vector利用前面开的节点就好
vector<Node*> newTables;
newTables.resize(newSize);
for (auto& node : _tables)
{
Node* cur = node;
Hash hash;
while (cur)
{
Node* next = cur->_next;
size_t hashi = hash(cur->_kv.first) % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
node = nullptr;
}
_tables.swap(newTables);
}
// 真正插入
Hash hash;
size_t hashi = hash(kv.first) % _tables.size();
// 头插
Node* newNode = new Node(kv);
newNode->_next = _tables[hashi];
_tables[hashi] = newNode;
++_size;
return true;
}
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 Erase(const K& key)
{
if (!Find(key))
{
return false;
}
Hash hash;
size_t hashi = hash(key) % _tables.size();
Node* cur = _tables[hashi];
Node* prev = nullptr;
while (cur && cur->_kv.first != key)
{
prev = cur;
cur = cur->_next;
}
if (prev)
{
prev->_next = cur->_next;
}
else
{
_tables[hashi] = cur->_next;
}
delete cur;
return true;
}
// 表的长度
size_t TablesSize()
{
return _tables.size();
}
// 桶的个数
size_t BucketNum()
{
size_t num = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i])
{
++num;
}
}
return num;
}
// 最长桶的长度
size_t MaxBucketLenth()
{
size_t maxLen = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
size_t len = 0;
Node* cur = _tables[i];
while (cur)
{
++len;
cur = cur->_next;
}
//if (len > 0)
//printf("[%d]号桶长度:%d\n", i, len);
if (len > maxLen)
{
maxLen = len;
}
}
return maxLen;
}
size_t Size()
{
return _size;
}
private:
inline size_t __stl_next_prime(size_t n)
{
static const size_t __stl_num_primes = 28;
static const size_t __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
};
for (size_t i = 0; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return -1;
}
private:
vector<Node*> _tables;
size_t _size = 0;
};
void test_HB1()
{
int a[] = { 1, 11, 4, 15, 26, 7, 44, 55, 99, 78, 32, 23, 30, 13};
HashTable<int, int> ht;
for (auto i : a)
{
ht.Insert(make_pair(i, i));
}
cout << ht.Erase(4) << endl;
cout << ht.Erase(44) << endl;
cout << ht.Erase(4) << endl;
cout << ht.Erase(15) << endl;
}
void test_HB2()
{
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
HashTable<string, int> countHT;
for (auto& str : arr)
{
auto ptr = countHT.Find(str);
if (ptr)
{
ptr->_kv.second++;
}
else
{
countHT.Insert(make_pair(str, 1));
}
}
cout << endl;
}
void TestHT3()
{
int n = 19000000;
vector<int> v;
v.reserve(n);
srand(time(0));
for (int i = 0; i < n; ++i)
{
//v.push_back(i);
v.push_back(rand() + i); // 重复少
//v.push_back(rand()); // 重复多
}
size_t begin1 = clock();
HashTable<int, int> ht;
for (auto e : v)
{
ht.Insert(make_pair(e, e));
}
size_t end1 = clock();
cout << "数据个数:" << ht.Size() << endl;
cout << "表的长度:" << ht.TablesSize() << endl;
cout << "桶的个数:" << ht.BucketNum() << endl;
cout << "平均每个桶的长度:" << (double)ht.Size() / (double)ht.BucketNum() << endl;
cout << "最长的桶的长度:" << ht.MaxBucketLenth() << endl;
cout << "负载因子:" << (double)ht.Size() / (double)ht.TablesSize() << endl;
}
}
到此结束。