赞
踩
前言:本篇文章将继续进行C++数据结构的讲解——哈希表。
目录
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
比如说:要统计一个全是小写英文字母的字符串中各个英文字母出现的个数,该怎么做???
很容易,因为小写英文字母有26个,所以我们直接创建一个大小为26的int数组并将值全部初始化为0,让数组的每一位都代表一个小写英文字母,该字母每出现一次,就让该怎么对于位置的值++,最终每个位置的值便是对应小写英文字母的个数。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表) 。
有时候我们可能不能像上边一样有多少种数据就创建多大的哈希表,比如说虽然我们只有1,12,103,1004,10005四个数要统计个数,难道要创建一个10005大小的数组吗???
实际上只需要创建大小为6的数组即可,此时我们可以使用哈希函数:除留取余法。
找到一个合适的除数,能够让上述数字取余之后分别为不同的值,从而拉进各个数字之间的距离,创建更小的哈希表。
比如说让上述数字均取余上10,得到的就会是1,2,3,4,5,刚好对应数组各个位置。
虽然上述函数可以解决大部分问题,但不妨有些时候会出现像1,11,111,1111这样的数字,它们%10之后得到的数字均为1,这样就会导致不同的值映射到相同的位置,从而导致哈希冲突。
那么为了解决哈希冲突,有两种常用的方法:闭散列和开散列。
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
通过哈希函数获取待插入元素在哈希表中的位置。
如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
哈希表的每个空间都给上一个标记
EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除,通过枚举实现:
enum State{EMPTY, EXIST, DELETE};
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。 由于其形状很像一个桶,所以开散列哈希表又叫哈希桶。
下面来看代码,我们先给出哈希表的基本框架:
- namespace close_address
- {
- enum State
- {
- EMPTY,
- EXIST,
- DELETE
- };
- template<class K,class V>
- struct HashData
- {
- pair<K, V> _kv;
- State _state = EMPTY;
- };
-
- template<class K, class V>
- class HashTable
- {
- public:
-
- private:
- vector<HashData<K, V>> _tables;
- size_t _n = 0;
- };
- }
这里的_n用来记录哈希表中数据的个数。
- bool Insert(const pair<K, V> kv)
- {
- if (Find(kv.first))
- return false;
- size_t hashi = kv.first % _tables.size();
- //线性探测
- while (_tables[hashi]._state == EXIST)
- {
- ++hashi;
- hashi %= _tables.size();
- }
- _tables[hashi]._kv = kv;
- _tables[hashi]._state = EXIST;
- ++_n;
- return true;
- }
插入步骤还是容易实现的,值得注意的是,哈希表也不允许相同数据存在,所以需要提前判断,这里直接调用后边的Find()函数来判断。
除此之外还有一个非常关键的点在于——扩容。
因为虽然我们的哈希表是用vector作为底层,但是实际上填入数据时并一定是挨着存放的,所以我们需要在插入之前,提前创造空间。
那么哈希表也要等数据存满的时候才扩容吗???并不是。
对于散列表,存在一个荷载因子的定义:
α = 填入表中的元素 / 散列表的长度
当表中的元素足够多但并未满时,此时如果继续插入数据,发生哈希冲突的可能性就会极大,导致插入时间变长。所以这里我们规定,当荷载因子的值 >= 0.7时就进行扩容:
- HashTable()
- {
- _tables.resize(10);
- }
在扩容之前,应该给表一个初始的大小,这里通过构造函数使哈希表的初始长度为10。
- //扩容
- if (_n * 10 / _tables.size() >= 7)
- {
- HashTable<K, V> newHT;
- newHT.tables.resize(_tables.size() * 2);
- for (size_t i = 0; i < _tables.size(); i++)
- {
- if (_tables[i]._state == EXIST)
- {
- newHT.Insert(_tables[i]._kv);
- }
- }
- _tables.swap(newHT._tables);
- }
随后进行扩容判断,这里有一个细节, 因为荷载因子是一个浮点数,如果直接进行比较,我们还需进行类型转换,所以我们直接让哈希表数据个数乘10再来进行计算。
判断成立则进行扩容,注意,哈希表的扩容并非像顺序表那样进行复制粘贴,因为哈希表扩容,代表着哈希函数的分母发生变化,所以其对应的取余后的下标位置也会发生变化。
这里我们通过新建一个二倍大小的哈希表,遍历原表数据并在新表中调用插入函数进行插入,最后在通过交换即可。
- //寻找
- HashData<K, V>* Find(const K& key)
- {
- size_t hashi = key % _tables.size();
- //线性探测
- while (_tables[hashi]._state == EXIST &&_tables[hashi]._state != EMPTY)
- {
- if (_tables[hashi]._kv.first == key)
- {
- return &_tables[hashi];
- }
- ++hashi;
- hashi %= _tables.size();
- }
- return nullptr;
- }
寻找就比较简单了, 通过要寻找的值计算出其在哈希表中的下标,判断是否存在且是否为空,存在且不为空则进行判断,相等返回,如果不相等,因为可能存在哈希冲突,所以循环往后遍历,直至遇到空位置仍然没有找到,说明其不在哈希表中,返回nullptr。
这里有一个细节,返回值为哈希表中对应位置的地址,这是为后边的删除做伏笔。
- //删除
- bool Erase(const K& key)
- {
- HashData<K, V>* ret = Find(key);
- if (ret == nullptr)
- {
- return false;
- }
- else
- {
- ret->_state = DELETE;
- --_n;
- return true;
- }
- }
删除我们直接借用Find函数去找到对应位置,无需在通过哈希函数去寻找。
如果要删除的元素不存在,则返回失败,反之,将其位置的标记改为DELETE,即伪删除。
下面来理解一下伪删除:
因为我们在寻找和插入时的判断条件均为标记位EMPTY,所以删除时只需将该位置的标记改为DELETE,这样就不会影响该位置对应的冲突位的寻找以及新的插入了。
我们上述的哈希表实现能够采用哈希函数进行取余操作的前提是数据为int类型,那如果是其他类型的数据,不能进行取余操作,又该如何使用哈希函数呢???
这里的方法是,通过建立仿函数,将其他类型转换成int类型:
- struct HashFunc
- {
- size_t operator()(const K& key)
- {
- return (size_t)key;
- }
- };
此外,有一个特例,string类型不能直接转换为int类型,所以想要满足string类型,我们需要为其单独创造一个仿函数:
- struct HashStringFunc
- {
- size_t operator()(const string& s)
- {
- size_t hash = 0;
- for (auto e : s)
- {
- hash += e;
- }
- return hash;
- }
- };
思想为,将string类型的每个字符的ascll码值加起来作为其int类型的数据。
随后需要在模板中进行添加:
template<class K, class V,class Hash = HashFunc<K>>
这里采用了缺省参数,默认情况下为其他类型,当为string类型时,在传入独有的模版。
- namespace hash_bucket
- {
- template<class K,class V>
- struct HashNode
- {
- pair<K, V> _kv;
- HashNode<K, V>* _next;
-
- HashNode(const pair<K,V>& kv)
- :_kv(kv)
- ,_next(nullptr)
- {}
- };
- template<class K,class V>
- class HashTable
- {
- typedef HashNode<K, V> Node;
- public:
- HashTable()
- {
- _tables.resize(10, nullptr);
- _n = 0;
- }
- private:
- vector<Node*> _tables;//指针数组
- size_t _n;
- };
- }
不同于闭散列的哈希表vector中存放的是数据本身,哈希桶中vector存放的为节点指针。
因为哈希桶可能会在一个位置下面插入很多的数据,如果采用尾插,就必须找到尾结点才能进行插入,效率会很低,所以我们采用头插的方式:
- //插入
- bool Insert(const pair<K, V>& kv)
- {
- //判断是否存在
- if (Find(kv.first))
- return false;
- //扩容
- //......
- size_t hashi = kv.first % _tables.size();
- Node* newnode = new Node(kv);
- //头插
- newnode->_next = _tables[hashi];
- _tables[hashi] = newnode;
- ++_n;
- }
创造一个新节点,让新节点去指向原来的头结点,再让新节点成为头结点。
下面我们来关注扩容问题,虽然哈希桶是通过链表的方式进行插入,原则上不用进行扩容就可以满足所有数据的存放。但是如果数据过大,会导致每个桶中的数据量过于庞大,导致寻找操作的效率大大降低。所以规定,当数据个数等于哈希表的大小,即荷载因子α为1时,进行扩容。
那么哈希桶的扩容又该如何进行呢???
因为是节点的缘故,如果像闭散列那样复用插入去扩容,那样就等于同一个节点又创建了一份,而原节点最后还需要进行销毁,这样未免过于麻烦和浪费时间。
所以更好一点的做法是,新建立一个哈希桶,遍历原桶的节点,让其按照哈希函数去直接指向新桶,最后再将两个桶进行交换:
- //扩容
- if (_n == _tables.size())
- {
- vector<Node*> newtables; (_tables.size() * 2, nullptr);
- for (size_t i = 0; i < _tables.size(); i++)
- {
- Node* cur = _tables[i];
- while (cur)
- {
- Node* next = cur->_next;
- size_t hashi = kv.first % newtables.size();
- cur->_next = newtables[hashi];
- newtables[hashi] = cur;
- cur = next;
- }
- _tables[i] = nullpter;
- }
- _tables.swap(newtables);
- }
- //寻找
- Node* Find(const K& key)
- {
- size_t hashi = key % _tables.size();
- Node* cur = _tables[hashi];
- while (cur)
- {
- if (cur->_kv.first == key)
- return cur;
- cur = cur->_next;
- }
- return nullptr;
- }
寻找操作较为简单,就不在过多分享,注意返回值为节点类型。
链表的删除无非就三种情况,删除的是头结点,或者是中间节点,尾结点。其中尾结点可以和中间节点共用一种方式。
那么如果删除中间节点,就必须提前记录该节点的前一个节点。
此外就是头结点,提前定义一个prev,并置空,如果cur不为头结点,就让prev继承cur,cur在往后,所以如果首次循环prev就为空,说明要删除的节点即为头结点。
- bool Erase(const K& key)
- {
- size_t hashi = key % _tables.size();
- Node* cur = _tables[hashi];
- Node* prev = nullptr;
- while (cur)
- {
- if (cur->_kv.first == key)
- {
- //删除的是第一个节点
- if (prev == nullptr)
- {
- _tables[hashi] = cur->_next;
- }
- else
- {
- prev->_next = cur->_next;
- }
- delete cur;
- }
- else
- {
- prev = cur;
- cur = cur->_next;
- }
- }
- return false;
- }
最后我们仍需使用仿函数来解决数据类型的问题,因方法与闭散列完全相同,这里不再重复。
关于哈希表就分享这么多,喜欢本篇文章记得一键三连,我们下期再见!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。