在STL底层map和set使用红黑树封装实现的,而其复杂度为基本为logn因为高度可控,但往后发展还是有大神想出了另一种容器结构也就是哈希。也就是底层用哈希来封装出map和set
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
插入元素:
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素:
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:
每次给一个数对其进行取模,得到一个值对其进行插入,有点之前计数排序那意思。
差的元素多了不可避免地就会有重复的字眼。
对于不同关键字取模相同出一样的哈希地址,这个我们叫做哈希冲突。
通过哈希函数来解决。
设计原则:
常见哈希函数:直接定制法,除留余数法,平方取中法,折叠法,随机数法,数学分析法。
经常用的就是前两个
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个”空位置中去
1.线性探测
对于已经冲突的关键字,我们对其进行线性查找,通过哈希函数找到对应的位置,若没有就插入,若有就一次往后找空位置插入。
这里就对于散列的每一个位置进行状态的定义,采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素
扩容机制:
当所有元素插满时候,其状态都为存在那么就无法找出对应空的位置一致循环下去,所以我们要保证闭散列不能为满或者不能使它快满了,在达到一定的比例时,就要对其进行扩容机制。
2.二次探测
可以通过一次探测得出如果同一块区域有大量的数据堆积在一起,效率会降低,也就是将一个个探测变成次方的查找
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
空间利用率比较低,哈希的缺陷
enum State { EXIST, EMPTY, DELETE, }; template<class T> struct HashNode { State _state = EMPTY; // 状态 T _t; }; template<class K, class T, class HashFunc = Hash<K>> class HashTable { public: bool Insert(const T& t) { // 负载因子>0.7就增容 if (_tables.size() == 0 || _size * 10 / _tables.size() == 7) { size_t newsize = GetNextPrime(_tables.size()); HashTable<K, T> newht; newht._tables.resize(newsize); for (auto& e : _tables) { if (e._state == EXIST) { // 复用冲突时探测的逻辑 newht.Insert(e._t); } } _tables.swap(newht._tables); } HashNode<T>* ret = Find(t); if (ret) return false; size_t start = t % _tables.size(); // 线性探测,找一个空位置 size_t index = start; size_t i = 1; while (_tables[index]._state == EXIST) { index = start + i; index %= _tables.size(); ++i; } _tables[index]._t = t; _tables[index]._state = EXIST; _size++; return true; }
如果传入的是个字符串,那么我们就需要将其转成ASC||码进行计算,就需要引入仿函数特化一个结构体如果传入的是string就去调特化的。
template<class K> struct Hash { size_t operator()(const K& key) { return key; } }; // 特化 template<> struct Hash < string > { size_t operator()(const string& s) { size_t hash = 0; for (auto ch : s) { //hash += ch; hash = hash * 131 + ch; } return hash; } };
当然查找和删除也类似:查找存在并且相等的值,遇到空则失败。删除就得先查找然后设置状态。插入的时候要先对其进行查找如果有了就失败
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
开散列的扩容机制不会让自己的插入影响,如果通过一个桶中插入的节点过多不回去影响其他的只会影响自己的效率,最好的情况就是每个桶中一个节点就是不需要二次搜索,不会出现哈希冲突。
当进入size为0就给第一个素数(这里大神们研究的通过开成某些素数大小的容量不容易冲突),创建一个新对象并将旧表上的点一个个的插入新表当中,然后释放旧表节点并至空。
pair<Node*, bool> Insert(const T& t) { KeyOfT kot; // 负载因子 == 1时增容 if (_size == _tables.size()) { size_t newsize = GetNextPrime(_tables.size()); vector<Node*> newtables; newtables.resize(newsize, nullptr); for (size_t i = 0; i < _tables.size(); i++) { // 旧表中节点直接取下来挂到新表 Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; size_t index = HashFunc(kot(cur->_t), newtables.size()); // 头插 cur->_next = newtables[index]; newtables[index] = cur; cur = next; } _tables[i] = nullptr; } newtables.swap(_tables); } size_t index = HashFunc(kot(t), _tables.size()); // 查找t在在不在 Node* cur = _tables[index]; while (cur) { if (kot(cur->_t) == kot(t)) return make_pair(cur, false); cur = cur->_next; } Node* newnode = new Node(t); newnode->_next = _tables[index]; _tables[index] = newnode; return make_pair(newnode, true); }
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上: 由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间