目录
一,哈希结构的认识
1-1,哈希思想
1-2,哈希函数
1-3,哈希冲突
1-3-1,闭散列
1-3-2,开散列
二,哈希结构的封装实现
2-1,闭散列封装实现
编辑
2-2,开散列封装实现
一,哈希结构的认识
1-1,哈希思想
C++存储结构中,我们接触的顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log N)。其实,C++STL中还有一种平均情况下的常数时间复杂度(O(1))的查找、插入和删除操作的哈希结构,它是一种理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素,在许多应用中都非常高效。
C++11底层库中有 unordered_map、unordered_multimap、unordered_set、unordered_multiset等关联式哈希容器,都是通过键key与值value一一眏射的关系进行存储,与map、multimap、set、multiset等容器不同的是哈希存储结构内部都是无序的。
哈希常用于先构造一种哈希表数据结构,然后通过某种函数(哈希函数)计算键(key)获得一个数值(哈希值),此值对应元素的存储位置,这样一来元素的存储位置与它的关键码(键key)之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。具体如下:
当向该结构中插入元素时:根据待插入元素的关键码(键key值),以哈希函数计算出该元素的存储位置并按此位置进行存放。
当向该结构中搜索元素时:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。
1-2,哈希函数
运用以上方式进行增删查改叫哈希(或散列)方法,哈希方法中使用的转换函数称为哈希(或散列)函数,构造出来的结构称为哈希表(或者称散列表)。例如:数据集合{1,7,6,4,5,9};哈希函数设置为:hash(key) = key % capacity;其中 capacity 为存储元素底层空间总的大小,这里假设为10,对应的效果图如下:
类似于以上哈希函数的设置叫做除留余数法,也是最常用的一种方法。哈希函数的设置还有一种常用的方法叫做直接定址法。若使用直接定址法,如上哈希函数的设置为:hash(key) = key或hash(Key)= a*key + b或类似于取关键字的某个线性函数为散列地址。不难看出,此方法非常消耗空间。若存在类似于{1,1000,60000}数据时,哈希表必须设置非常大,也就是说当关键码非常大时,空间开辟也必须同样大,这将导致空间利用率极低,所以这种方法存在局限性,只有适合查找比较小且连续的情况才适用。
1-3,哈希冲突
哈希函数的设置其实还有很多种方法,但是大多数都不常用,这里我们只说明除留余数法。用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。但是,按照上述的哈希函数进行插入时,再向表中插入元素44、16、47,类似于这些关键码通过哈希函数计算出的相同的哈希值时,将会产生空间冲突,该现象叫做哈希冲突。
哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是这里无法避免哈希冲突。直接定址法和除留余数法彻底解决哈希冲突的方式相同,常见的两种方法是:闭散列和开散列。
1-3-1,闭散列
闭散列:也叫开放定址法。当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,此方法是将数据存放到冲突位置中的 “下一个” 空位置中去,若找到表尾位置,就要往头往后继续寻找。哈希冲突中,若连续存在的数据越多,哈希效率就越低,因此又有了负载因子(或载荷因子)的概念。负载因子=实际存储数据的个数/表的大小。负载因子越大,哈希冲突概率就越高,发生冲突时,连续存在多个数据的概率越大;负载因子越小,综合性能就越好,但是空间效率就越差,也就是说负载因子是拿空间换时间的做法。因此,通常将负载因子控制一定大小,一旦负载因子超出范围时就对哈希表进行扩容,其中这里的负载因子大于0,小于等于1。
控制负载因子还不能完全解决连续存在多个数据导致哈希效率低的情况,通常这里发生哈希冲突寻找下一个空位置时采用线性探测或二次探测。线性探测在寻找下一个位置时一次跳一步寻找下一个空位置,即:hash + i(i > 0),二次探测是以二次方的跳跃式探测下一个空位置,即:hash + i^2(i > 0)。二次探测可能会存在查找不到空位置的情况。研究表明:当表的长度为质数且负载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
线性探测实现简单,但是当连续存在多个数据时,会影响哈希效率。二次探测虽可以解决效率问题,但会降低空间利用率。
1-3-2,开散列
开散列:又叫拉链地址法(或哈希桶法)——首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
无论是哪种方法,若哈希冲突越多,哈希效率就越低。因此,这里的负载因子一定要合理设置,通常闭散列开放定址法将负载因子设置为0.7,开散列拉链法将负载因子设置为1。
二,哈希结构的封装实现
2-1,闭散列封装实现
由于哈希结构是在关联式容器基础上操作的(不完全是依靠关联式容器),不同的是哈希是无序,关联式容器是有序,所以这里也不支持修改操作。下面我们模拟实现插入、删除、修改操作。
插入操作:插入操作的哈希函数是通过键key值作为哈希码来计算哈希值,但问题是若键key值不是整数将会导致失败,因此在使用哈希函数时需要将哈希码转换为正整数,这里采用伪函数实现。至于在插入时的扩容操作,当负载因子超出或等于0.7时进行扩容,而且扩容时不能在原表上扩容,因为眏射位置会不同。比如:原本表的大小是10,根据哈希函数数据14应插入在4的位置,但是在原表上扩容后假设大小变为20,14应在14的位置上,眏射位置不同,导致后面插入或眏射关系混乱。扩容时需要重新建立哈希表,将全部数据重新确定眏射关系进行插入。
template<class K>
struct Conversion
{
size_t operator()(const K& data)
{
return data;
}
};
template<> //处理键是串的情况
struct Conversion<std::string>
{
size_t operator()(const std::string& data)
{
size_t count = 0;
for (auto e : data)
{
count += e;
//以下进行专门乘法运算防止出现类似"ab","ba"的串,这里专门乘131而不是其它数据是大佬研究出来最终很小概率不会面临重复数字,这里不做研究
count *= 131;
}
return count;
}
};//注意:只要是串,这里无论乘什么数据都会面临重复,因为size_t存储的数据大小有限,而string串的长度很大,当数据大到一定程度时将面临溢出,会出现数值相同现象
查找操作:直接通过计算哈希函数进行查找即可,思想跟插入操作一样。
删除操作:删除数据后要进行标记,表示此位置没有被占用,以便下次进行插入或查找。这里采用枚举表示数据状态,当存储数据时顺便也把数据状态一并存储。删除数据时,只讲状态进行更新即可,表示此数据已被删除。
//数据状态
enum State
{
EXIT, //存在数据——已插入
DELETE, //数据被删除——已删除
EMPTY //不存在数据——空位置
};//存储数据
template <class K, class V>
struct HashData
{
std::pair<K, V> _kv = std::pair<K, V>(); //数据
State _state = EMPTY; //状态
};
封装思路:
哈希表是一个vector顺序存储容器,存储数据类型为HashData。其次这里还需封装数据个数,即便计算负载因子。
//K—键 V—值 Con—转换size_t的类
template<class K, class V, class Con = Conversion<K>>
class HashTable
{
public://........
private:
std::vector<HashData<K, V>> _table; //哈希表
size_t _count; //数据个数
};
代码封装:
template<class K>
struct Conversion
{
size_t operator()(const K& data)
{
return data;
}
};
template<>
struct Conversion<std::string>
{
size_t operator()(const std::string& data)
{
size_t count = 0;
for (auto e : data)
{
count += e;
count *= 131;
}
return count;
}
};
enum State
{
EXIT, DELETE, EMPTY
};
template <class K, class V>
struct HashData
{
std::pair<K, V> _kv = std::pair<K, V>();
State _state = EMPTY;
};
//K—键 V—值 Con—转换size_t的类
template<class K, class V, class Con = Conversion<K>>
class HashTable
{
public:
HashTable(size_t n = 10)
: _count(0)
{
_table.resize(n); //默认开辟10大小空间
}
//查找: 当没有查找到数据时返回空,查找到数据返回此数据的指针
std::pair<K, V>* Find(const K& data)
{
Con conversion;
size_t pos = conversion(data) % _table.size();
while (_table[pos]._state == EXIT && _table[pos]._kv.first != data)
{
pos++;
pos %= _table.size();
}
if (_table[pos]._state != EXIT) return nullptr;
return &_table[pos]._kv;
}
//插入: 分扩容和插入两大步骤
bool Insert(const std::pair<K, V>& kv)
{
if (Find(kv.first)) return false;
//扩容,当负载因子超出0.7时进行扩容
if (_count * 1.0 / _table.size() >= 0.7)
{
//扩容时不能在原表上扩容,会导致眏射关系混乱
//_table.resize(2 * _table.size());
//注意: 建立新表时需要运用封装的专门算法,方便起见这里最好不要直接创建表vector,直接新创建对象
HashTable<K, V, Con> newtable;
newtable._table.resize(2 * _table.size());
for (auto& e : _table)
{
if (e._state == EXIT) newtable.Insert(e._kv);
}
_table.swap(newtable._table);
}
//插入数据
Con conversion;
size_t pos = conversion(kv.first) % _table.size();
while (_table[pos]._state == EXIT)
{
//不断往后面遍历,当遍历到表尾时从头遍历
pos++;
pos %= _table.size();
}
_table[pos]._kv = kv;
_table[pos]._state = EXIT;
_count++;
return true;
}
//删除: 找到数据后更新数据状态
bool Erase(const K& data)
{
if (!Find(data)) return false;
Con conversion;
size_t pos = conversion(data) % _table.size();
while (_table[pos]._kv.first != data)
{
pos++;
pos %= _table.size();
}
_table[pos]._state = DELETE; //更新数据状态
_count--;
return true;
}void Print()//测试输出
{
std::cout << "Size: " << _count << std::endl;
for (auto& e : _table)
{
if (e._state == EXIT)
{
std::cout << e._kv.first << ":" << e._kv.second << std::endl;
}
}
std::cout << std::endl;
}
private:
std::vector<HashData<K, V>> _table;
size_t _count;
};
样例测试1:
void HashTest1()
{
int a[] = { 1,4,24,34,7,44,17,37 };
HashTable<int, int> h;
for (auto e : a)
{
h.Insert(std::make_pair(e, e));
}h.Print();
std::pair<int, int>* it = h.Find(17);
if (it)
std::cout << it->first << std::endl;
else
std::cout << "无此数据" << std::endl;
std::cout << std::endl;h.Erase(17);
h.Erase(44);h.Print();
}
样例测试2:
void HashTest2()
{
HashTable<std::string, int> h;
h.Insert(std::make_pair("string", 0));
h.Insert(std::make_pair("vector", 0));
h.Insert(std::make_pair("list", 0));
h.Insert(std::make_pair("ingstr", 0));
h.Insert(std::make_pair("torvect", 0));
h.Insert(std::make_pair("stli", 0));h.Print();
std::pair<std::string, int>* it = h.Find("ingstr");
if (it)
std::cout << it->first << std::endl;
else
std::cout << "无此数据" << std::endl;
std::cout << std::endl;h.Erase("stli");
h.Erase("torvect");h.Print();
}
2-2,开散列封装实现
开散列插入的数据类型是结点,发生哈希冲突时使用链表结构连接,跟闭散列不同,不需要使用状态。有些人想到链表结构可能会首选库中list,即vector<list>。但是list是双向链表,双向链表的优势在于可在任意位置插入删除,这里显然没多大优势,因此采取这种方式会降低哈希性能,使用单链表足以。
开散列的查找和删除操作简单,插入操作时的扩容需注意不要使用闭散列的方式建立哈希表进行插入,因为这里的插入数据操作是结点,需要动态开辟空间,若原本数据量过多,这样持续的开辟删除会大大降低性能。这里直接创建表来重新搬运结点数据以重新建立哈希关系。封装如下:
//结点
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next;HashNode(const pair<K, V>& kv = pair<K, V>())
: _kv(kv)
, _next(nullptr)
{ }
};//哈希表
template <class K, class V, class Con = Conversion<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable(const size_t n = 10)
: _count(0)
{
_table.resize(n, nullptr);
}~HashTable()
{
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
_table[i] = cur->_next;
delete cur;
_count--;
cur = _table[i];
}
}
}
//查找
Node* Find(const K& data)
{
Con conversion;
size_t pos = conversion(data) % _table.size();
Node* cur = _table[pos];
while (cur)
{
if (cur->_kv.first == data) return cur;
cur = cur->_next;
}
return nullptr;
}
//插入
bool Insert(const pair<K, V>& kv)
{
//这里不允许插入相同数据
if (Find(kv.first)) return false;
//扩容: 负载因子到1就扩容
if (_count == _table.size())
{
//直接创建表来重新搬运结点数据
vector<Node*> newtable;
newtable.resize(2 * _table.size(), nullptr);
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
Con conversion;
size_t pos = conversion(cur->_kv.first) % newtable.size();
cur->_next = newtable[pos];
newtable[pos] = cur;
cur = next;
}
}
_table.swap(newtable);
}
//插入
Con conversion;
size_t pos = conversion(kv.first) % _table.size();
Node* node = new Node(kv);
//这里进行头插
node->_next = _table[pos];
_table[pos] = node;
_count++;
return true;
}
//删除
bool Erase(const K& data)
{
if (!Find(data)) return false;
Con conversion;
size_t pos = conversion(data) % _table.size();
Node* cur = _table[pos];
Node* prv = nullptr;
while (cur->_kv.first != data)
{
prv = cur;
cur = cur->_next;
}
if (cur == _table[pos]) _table[pos] = cur->_next;
else prv->_next = cur->_next;
delete cur;
cur = nullptr;
_count--;
return true;
}void Print() //测试输出
{
int num = 0, Maxbucketlength = 0, Bucketlength = 0;
for (size_t i = 0; i < _table.size(); i++)
{
Bucketlength = 0;
Node* cur = _table[i];
if (cur)
{
num++;
cout << "Bucket " << i << ": ";
}
else
{
cout << "Bucket " << i << ": " << "nullptr";
}
while (cur)
{
Bucketlength++;
cout << cur->_kv.first << ":" << cur->_kv.second << " ";
cur = cur->_next;
}
if (Bucketlength > Maxbucketlength) Maxbucketlength = Bucketlength;
cout << endl;
}
cout << "Bucket Node Count: " << _count << endl;
cout << "Bucket Group: " << num << endl;
cout << "Table Size: " << _table.size() << endl;
cout << "load factor: " << _count * 1.0 / _table.size() << endl;
cout << "Maxbucketlength: " << Maxbucketlength << endl;
cout << endl;
}
private:
vector<Node*> _table; //哈希表
size_t _count; //数据个数
};
注意:若哈希桶连接过长,哈希效率就接近于n。由于这里哈希表存储的都是桶,桶结构是链表,因此,极端情况下若存在桶长度过大,我们可以将此桶改为红黑树结构来提高哈希效率。
测试样例:
void HashTest1()
{
int a[] = { 1,4,24,34,7,44,17,37 };
HashTable<int, int> h;
for (auto e : a)
{
h.Insert(make_pair(e, e));
}h.Print();
HashNode<int, int>* Node = h.Find(17);
if (Node)
cout << Node->_kv.first << ":" << Node->_kv.second << endl;
else
cout << "无此数据" << endl;
cout << endl;h.Erase(17);
h.Erase(44);h.Print();
}