依稀还记得上学期期末,数据结构课设的抽签中抽中了哈希表实现的电话号码查询系统,再回过头来看自己写的,好菜。。。
github程序链接https://github.com/duchenlong/Cpp/tree/master/Hash/Hash
因为红黑树在查找中的效率为O(log N),为了能够更为快速的查找数据,最好是趋于O(1)的时间复杂度。于是C++11中新加入了unordered_map
和unordered_set
两个容器,不同于map和set
的是,map和set
的底层是红黑树的接口,而unordered_map
和unordered_set
的底层是哈希表的接口,可以让查找效率无限接近于O(1)。
首先有这样一个问题,就是给定一串数据,再给一个数字num,怎么在O(1)的时间复杂度内判断num在那一串数据中有没有出现?
直接定制法(适用于数据范围比较小的时候)
因为是数据类型,如果数据的范围不是很大的话,我们就可以开辟数据范围大小的数组,让数组的下标位置就表示这个数据,那么我们就可以在O(1)的时间范围内判断出该数据有没有出现,只需判断arr[num] == num
就可以知道答案了。
但是实际中,很有可能这个数据比较极端:
1,10000
这种的,明明只有两个数据,却要我们开10000个数据的空间,就造成了很大程度的浪费;
再比如4,294,967,296
这样的数据,表示的范围很大,我们在32位的系统中,就得开辟4G大小的空间
,这有点不现实。
再一种就是字符串(string)类型
的数据,char类型的字符
,我们还可以用ASCII码操作操作,但是字符串类型不能直接进行索引了。
对于字符串类型,处理方式比较简单,我们可以看成是一个个字符拼接而成的数据,依次相加每个字符的ASCII 码就可以操作了。
size_t ans = 0;
for (size_t i = 0; i < key.size(); i++)
{
ans *= 131;//玄学研究,*131可以很大程度上避免冲突
ans += key[i];
}
除留余数法
而对于其他两种情况,我们可以规定一个数组的大小,然后对这个数据取余就可以了pos = data % size
,这样就可以保证之后的数据都是在这个数组中。
对于哈希表的描述,我们可以这样来
template<class K, class T, class KOfT>
class HashTable
{
public:
HashTable()
:_num(0)
{
//初始的容量不能为0,
_table.resize(10);
}
private:
vector<HashData<T> > _table;
size_t _num;//所存储数据的数量
};
其中哈希表的数据类型,我们定义了一个结构体
//数组的类型
template<class T>
struct HashData
{
T _data;
Status _status;//当前位置的状态
};
又因为存在哈希冲突的问题,所以说我们在删除元素节点的时候,不能真正的删除掉,得采用惰性删除
的方式。也就是给每一个结点定义三个状态,存在数据,不存在数据,删除数据
//哈希表中每一个位置的状态
enum Status
{
EMPTY,//空
EXITS,//存在
DELETE,//删除
};
而KOfT容器呢,是我们自定义的一个容器,他重载了()
,作用就是获取计算权值的数据
因为这个数据不一定是数值类型,所以我们需要一个进行计算的模板
//得到当前位置的数据
template<class T>
struct _Hash
{
const T& operator()(const T& key)
{
return key;
}
};
//特化字符串类型数据
template<>
struct _Hash < string >
{
size_t operator()(const string& key)
{
size_t ans = 0;
for (size_t i = 0; i < key.size(); i++)
{
ans *= 131;
ans += key[i];
}
return ans;
}
};
在哈希表内部,我们写一个HashFunction函数来实现这一功能,根据数据来计算插入的位置
//得到当前位置的数据,转为数字类型
size_t HashFunc(const K& key)
{
Hash hash;
return hash(key);
}
那么问题又来了,如果我们数组的大小是10,当我们依次插入 1 ,11,21的时候,经过计算,他们的插入位置都是pos = 1
,这就发生了哈希冲突。
对于哈希冲突的问题,我们有着这样几种处理方式
闭散列法也叫开放定址法
。当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
问题又来了,如何找这个位置更好呢?
从发生冲突的位置开始,依次向后探测(如果到了数组的末尾,就需要来一个循环,从0号位置开始继续探测),直到寻找到下一个空位置为止
这样做有一个缺点,那就是如果连续插入几个相同pos的数据,很容易就会出现相同pos的数据进行扎堆的现象,也就是不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
//线性探测
size_t idx = _koft(d) % _table.size();
while (_table[idx]._status == EXITS)
{
//如果该数据已经存在
if (_koft(_table[idx]._data) == _koft(d))
return false;
//向后查找的时候,需要组成一个循环查找
idx = (idx + 1) % _table.size();
}
_table[idx]._data = d;
_table[idx]._status = EXITS;
_num++;
为了解决线性探测中,相同pos的数据扎堆出现的现象,二次探测如果发生了冲突,采取跳跃式的向后寻找插入位置。
这样由于指数增长的问题,如果直接访问的话,很快就会越界的,所以说还需要对数组的大小进行取模。
//二次探测
size_t start = _koft(d) % _table.size();
size_t idx = start;
int i = 1;
while (_table[idx]._status == EXITS)
{
if (_koft(_table[idx]._data) == _koft(d))
return false;
idx = start + i * i;
i++;
idx %= _table.size();
}
_table[idx]._data = d;
_table[idx]._status = EXITS;
_num++;
当数据在持续插入的情况下,我们很容易面对的一个问题就是 哈希表满了
对于哈希表,我们是不能让这个表满了的,所以说,得在这个哈希表快要满的时候,就进行扩容,起码闭散列是这样的
这里又引入了一个新的概念,负载因子
,就是元素的个数 除以 哈希表的大小,所得到的结果。所以说,负载因子在闭散列中只会是 0 - 1
中间的数据。
而在闭散列中,当负载因子在0.7 或者 0.8的时候,就需要进行扩容了。
对于扩容,我之前在课设验收的时候就被老师问到了这个问题,当时写的扩容被老师一眼看出来问题所在(太强了),后来想了想,应该就是下面这种扩容方式
其实哈希表的扩容没有什么技巧可言,就是把所有的数据,全部再插入到新的哈希表中就可以了,新的哈希表的容量一般是旧的哈希表的2倍
这里又两种写法
//是否需要扩容
if (_num * 10 / _table.size() >= 7)
{
//扩容
//第一种写法,建立一个vector
vector<HashData<T>> newTable;
newTable.resize(_table.size() * 2);
for (size_t i = 0; i < _table.size(); i++)
{
if (_table[i]._status == EXITS)
{
//计算在新表中的数据
size_t idx = _koft(_table[i]._data) % newTable.size();
//找到插入位置
while (newTable[idx]._status == EXITS)
idx = (idx + 1) % newTable.size();
newTable[idx]._data = _table[i]._data;
newTable[idx]._status = EXITS;
}
}
swap(newTable, _table);
}
//是否需要扩容
if (_num * 10 / _table.size() >= 7)
{
//扩容
//第二种写法,新建一个hashtable
HashTable<K, T, KofT<K>> newHash;
newHash._table.resize(_table.size() * 2);
for (size_t i = 0; i < _table.size(); i++)
if (_table[i]._status == EXITS)
newHash.Insert(_table[i]._data);
_table.swap(newHash._table);
}
开散列法
又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中
对于这样一个一个的桶,就不存在哈希冲突的问题了,我们先计算一个插入位置pos,那么这个位置所存储的都是这个pos权值的数据,他们以链表的形式连接在一起。(如果想要发散一下的话,也可以使用红黑树进行连接)
当我们在插入数据的时候,所选择的插入方式便是头插的方法
而在闭散列中,我们在负载因子=1 的时候进行扩容
//插入
pair<iterator, bool> insert(const T& data)
{
KeyOfT koft;
//桶哈希中,如果负载因子 = 1,就进行扩容
if (_table.size() == _num)
{
vector<node*> newTable;
size_t newSize = _table.size() * 2;
newTable.resize(newSize);
for (size_t i = 0; i < _table.size(); i++)
{
node* cur = _table[i];
while (cur != nullptr)
{
node* next = cur->_next;
//计算应该插入到新表中的位置
size_t idx = HashFunc(koft(cur->_data)) % newSize;
//进行头插
cur->_next = newTable[idx];
newTable[idx] = cur;
cur = next;
}
//这个节点以经没有数据了
_table[i] = nullptr;
}
_table.swap(newTable);
}
//插入这个新的数据
//计算插入位置
size_t idx = HashFunc(koft(data)) % _table.size();
//找到插入的地方
node* cur = _table[idx];
while (cur != nullptr)
{
//该数据已经在表中,不需要插入了
if (koft(cur->_data) == koft(data))
return make_pair(iterator(cur, this), false);
cur = cur->_next;
}
//确保表中没有该数据,进行头插
node* newNode = new node(data);
newNode->_next = _table[idx];
_table[idx] = newNode;
_num++;
return make_pair(iterator(newNode, this), true);
}
迭代器的框架
template<class K,class T,class KeyOfT,class Hash>
struct __HashTableIterator
{
typedef __HashTableIterator<K, T, KeyOfT, Hash> Self;
typedef HashTable<K, T, KeyOfT, Hash> HT;
typedef HashNode<T> node;
node* _node;
HT* _pht;
__HashTableIterator(node* n,HT* pht)
:_node(n)
, _pht(pht)
{}
};
我们所要实现的迭代器重要有*,->,++,==,!=
因为在++的操作中,如果出现下面这种情况
当我们把当前链表的数据都遍历完了,我们就需要到下一个pos位置去查找数据了。那么在查找的时候,我们需要访问的是这个哈希表的私有成员,所以我们就需要将迭代器设置为哈希表的友元。
//设置迭代器为自己的友元
friend struct __HashTableIterator < K, T, KeyOfT, Hash > ;
++的重载
Self operator++()
{
if (_node->_next != nullptr)
{
_node = _node->_next;
return *this;
}
//当前权值的桶已经走完了,需要找到下一个桶
KeyOfT koft;
size_t i = _pht->HashFunc(koft(_node->_data)) % _pht->_table.size();
i++;//到下一个桶的位置
for (; i < _pht->_table.size(); i++)
{
node* cur = _pht->_table[i];
if (cur)
{
_node = cur;
return *this;
}
}
_node = nullptr;//这里说明遍历完了最后一个桶
return *this;
}
他的底层是调用哈希表的接口,所以很多功能函数只是一个包装的作用,这里只是实现一下[]的重载
这是unordered_map
的大体框架。
template<class K,class V,class Hash = _Hash<K> >
class unordered_map
{
//计算权值的数据的访问器
struct MapKOfT
{
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
public:
typedef typename HashTable<K, pair<K, V>, MapKOfT, Hash>::iterator iterator;
private:
HashTable<K, pair<K, V>, MapKOfT, Hash> _ht;
};
[]的重载,跟map是一个道理,我们只需要进行插入,然后返回第二个数据的引用就可以了
V& operator[](const K& key)
{
pair<iterator, bool> ans = _ht.insert(make_pair(key, V()));
return ans.first->second;
}