赞
踩
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。
通常情况下:
顺序查找时间复杂度为O(N),
平衡树中为树的高度,即O(
l
o
g
2
N
log_2 N
log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立
一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
插入元素:
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素:
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为** 哈希(散列)函数**,构造出来的结构称
为哈希表(Hash Table)(或者称散列表)
例:
集合{1,2,7,4,9,10}
我们通过哈希函数 hash(key) =key%capacity (capacity为哈希容器的大小),将集合中的树存在哈希表中,如图:图中的capacity为10 ,通过哈希函数分别将不同的数放在哈希表中的不同位置
概念:
对于两个数据元素的关键字 k i k_i ki和 k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) ==
Hash( k j k_j kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突
或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
例如:4和44,6和66,7和17 如果用上面的例子计算就会产生哈希冲突。
为了解决哈希冲突问题,下面有俩种解决方法
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
常见哈希函数
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有
空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
理念上解决了哈希冲突问题,但怎么寻找下一个空位置呢?
下面先介绍俩种方法
例:我们在上图的基础再插入44 和 77.
线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
线性探测的实现代码:
size_t start = key% _tables.size();
size_t i = 0;
size_t index = start;
while ( _tables[index]._status != EMPTY)
{
if (_tables[index]._data.first == key && _tables[index] ._status != DELETE)
{
return &_tables[index];
}
++i;
//二次搜索index = start + i*i;
index = start + i;
index %= _tables.size();
}
例:
假设我们需要将集合{4,14,24,34} 这4个数使用二次探测法插入到我们的哈希表中,以哈希表的capacity为10举例:
二次探测实现代码:
size_t start = key% _tables.size(); size_t i = 0; size_t index = start; while ( _tables[index]._status != EMPTY) { if (_tables[index]._data.first == key && _tables[index] ._status != DELETE) { return &_tables[index]; } ++i; //二次搜索 index = start + i*i; //index = start + i; index %= _tables.size(); }
但这俩个方法的弊端也很明显,就是其的删除,当我们去删除4这个数据的之后,我们又需要去查找44是否在我们的哈希表中,这时候就会出现误判,因为他一开始查找44最先查找的是hash[4],但是其中没有数据(4刚刚被我们删除),因此我们也不会继续线性探测(或二次探测),查找该元素,直接标记该元素不存在。
因此哈希表的删除采用标记的伪删除法来删除一个元素。通过标记这个位置为空,或者是存在元素,或者是被删除这三种状态。
除了删除以外,哈希表的扩容也至关重要,当我们的哈希表插入的数据越来越多,哈希表的插入和查找数据的效率都会大打折扣,这个时候就需要载荷因子。
实际上,散列表的平均查找长度是载荷因子α的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cachemissing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。
经过上面的分析所以,我们在最初的结构定义加上我们的状态定义,其中的HashFunc是用来处理不同类型的数据的,后面会讲解
template<class K, class V > enum status { EMPTY, EXIST, DELETE }; struct HashData { pair<K,V> _data; status _status = EMPTY; }; template<class K,class V,class HashFunc = Hash<K>> class HashTable { private: vector <HashData<K,V>> _tables; //封装数组 size_t _n=0; //记录数据个数 };
我们先来实现的哈希表的查找,因为后续的插入和删除都能用上他
查找的步骤:
HashData<K,V>* find(const K& key) { //为空则不可能寻找到- if (_tables.empty()) { return nullptr; } HashFunc hf; size_t start = hf(key) % _tables.size(); size_t i = 0; size_t index = start; while ( _tables[index]._status != EMPTY) { if (_tables[index]._data.first == key && _tables[index] ._status != DELETE) { return &_tables[index]; } ++i; //二次搜索index = start + i*i; index = start + i; index %= _tables.size(); } //走到此处说明没找到 return nullptr; }
数据插入的步骤:
而扩容的实现也较为简单,因为我们是封装了vector,所以只需要使用哈希表对象,进行插入,最后再哈希表的对象的vector容器和我们的vector容器互换一下就好,出了这个函数,这个哈希表对象就会自动被销毁。
bool Insert(const pair<K,V>& data) { HashData<K,V>* ret = find(data.first); if (ret) { return false; } //扩容 if (_tables.size() == 0 || _n*10/_tables.size()>=7 ) //控制载荷因子为0.7 { size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2; //定义一个HashTable对象 然后进行扩容 HashTable<K,V> NewHash; NewHash._tables.resize(newSize); //开始进行插入 for (size_t i = 0; i < _tables.size(); ++i) { if (_tables[i]._status == EXIST) { NewHash.Insert(_tables[i]._data); } } //结束之后 互换容器即可 _tables.swap(NewHash._tables); } HashFunc hf; //线性搜索 size_t start = hf(data.first) % _tables.size(); size_t i = 0; size_t index = start; while (_tables[index]._status == EXIST) { ++i; //二次搜索index = start + i*i; index = start + i; index %= _tables.size(); } //出来了 说明找到位置插入了 _tables[index]._data = data; _tables[index]._status = EXIST; ++_n; return true; }
哈希表的删除,我们只需要通过复用查找函数,找到该元素的位置,将其的状态改为DELETE即可
bool Erase(const K& key)
{
HashData<K, V>* ret = find(key);
if (ret == nullptr)
{
//没有此数据
return false;
}
ret->_status = DELETE;
--_n;
return true;
}
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任
何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在
搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出
必须考虑增容。
因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地
址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链
接起来,各链表的头结点存储在哈希表中。
例:
我们将集合{4,14,24,34,6,66,7,77},用我们的哈希桶存起来就像这样
哈希桶的结构定义:
//节点定义 template<class K, class V> struct HashNode //单链表 { HashNode* _next; pair<K, V> _kv; HashNode(const pair<K, V>& kv) :_next(nullptr) , _kv(kv) {} }; template<class K,class V, class HashFunc = Hash<K>> class HashTable { typedef HashNode<K, V> Node; private: vector<Node*> _tables; size_t _n=0; //记录数据个数 };
同闭散例一样,哈希桶的控制也需要载荷因子,因为哈希桶可能会出现一种极端情况,就是数据都在一个桶上挂着。此时就相当于退化成了线性结构,查找的效率将变成常数,当然除了控制载荷因子还不够,还可以在一个单链表的数据超过8个时,将其转成红黑树结构,也是不错的处理方式。
例:
插入步骤:
开散列增容:
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可
能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希
表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,
再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可
以给哈希表增容。
扩容:
直接移动节点,而不是复制节点出来,对新数组进行头插,因为如果进行复制节点,是方便了,但是删除原来表上挂着的单链表就会很麻烦
代码实现:
bool Insert(const pair<K, V>& kv) { //先判断此元素是否在哈希表中 Node* ret = Find(kv.first); if (ret) { return false; } //哈希函数 HashFunc hf; //扩容 if (_n == _tables.size()) { size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2; vector<Node*> NewTable; NewTable.resize(newSize); for (size_t i = 0; i < _tables.size(); ++i) { Node* cur = _tables[i]; while (cur) { //需提前保存下一个不然容易找不到 Node* next = cur->_next; size_t index =hf(cur->_kv.first) % NewTable.size(); cur->_next = NewTable[index]; NewTable[index] = cur; //迭代 cur = next; } } //交换容器即可 _tables.swap(NewTable); } //插入数据 size_t index = hf(kv.first) % _tables.size(); //寻找位置直接头插 Node* newnode = new Node(kv); newnode->_next = _tables[index]; _tables[index] = newnode; ++_n; }
查找步骤:
代码实现:
Node* Find(const K& Key) { if (_tables.empty()) { return nullptr; } HashFunc hf; size_t index = hf(Key) % _tables.size(); Node* cur = _tables[index]; while (cur) { if (cur->_kv.first == Key) { return cur; } else { cur = cur->_next; } } //走到这说明没找到 return nullptr; }
删除的步骤:
代码实现:
bool Erase(const K& Key) { Node* ret = Find(Key); if (ret==nullptr) { //没有此数据 return false; } HashFunc hf; size_t index = hf(Key) % _tables.size(); Node* cur = _tables[index]; Node* prev = nullptr; while (cur) { if(cur->_kv.first==Key) { //表头就是我们要删的数据 if (_tables[index] == cur) { _tables[index] = cur->_next; } else //中间删除 { prev->_next = cur->_next; } delete cur; return true; --_n; } prev = cur; cur = cur->_next; } }
而我们发现上面的哈希函数,都只能处理整形类型,而不能处理字符串类型,或者是自定义类型,这时候就需要仿函数的登场了,通过特化不同类型的处理函数,将这些类型转成整数形式,然后存于哈希表中。
这里不过多介绍,相关的字符串处理函数可看下面链接
template<class K> class Hash { public: size_t operator()(const K& Key) { return Key; } }; //特化string类型 template<> class Hash<string> { public: size_t operator()(const string& str) { size_t sum = 0; for (auto e : str) { sum*=31; sum += e; } return sum; } };
感谢各位大佬的观看,若此文章有什么错误的地方,希望大家能够指出来。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。