一、哈希概念
哈希(hash)又称散列,是⼀种组织数据的方式。哈希的本质关键字Key跟存储位置建立⼀个映射关系,使用哈希函数计算出key实际的存储位置,从而能实现快速插入、删除和查找。
易错点1:
哈希是一种用来进行高效查找的数据结构,查找的时间复杂度平均为O(1)。
哈希是以牺牲空间为代价,提高查询的效率。
易错点2:
哈希函数设计原则:
1. 哈希函数应该尽可能简单
2. 哈希函数的值域必须在哈希表格的范围之内
3. 哈希函数的值域应该尽可能均匀分布,即取每个位置应该是等概率的
易错点3:
常见的哈希函数有:直接定址法、除留余数法、平方取中法、随机数法、数字分析法、叠加法等。
常见哈希冲突处理:闭散列(线性探测、二次探测)、开散列(链地址法)、多次散列。
易错点4:
已知有一个关键字序列:(19,14,23,1,68,20,84,27,55,11,10,79)散列存储在一个哈希表中,若散列函数为H(key)=key%7,并采用链地址法来解决冲突,则在等概率情况下查找成功的平均查找长度为(1.5)
[0] [1] [2] [3] [4] [5] [6]
14 1 23 10 11 19 20
84 79 68 27
55
14、1、23、10、11、19、20: 比较1次就可以找到
84、79、68、27:需要比较两次才可以找到
55: 需要比较三次才可以找到
总的比较次数为:7+4*2+3=18,总共有12个元素
故:等概率情况下查找成功的平均查找长度为:18/12 = 1.5。
平均查找长度是按比较次数算的。
易错点5:
已知某个哈希表的n个关键字具有相同的哈希值,如果使用二次探测再散列法将这n个关键字存入哈希表,至少要进行()次探测。
0+1+2+⋯+(n−1)= n(n-1)/2
第一次插入:0次
第二次插入:1次
....
第n次插入:n-1次
二次探测再散列法这个只是误导内容,无论哪种探测方式,都至少要进行n(n-1)/2次探测,因为在各自探测方式中,探测的位置都是固定不变的。
二、直接定址法
数据范围比较集中时,直接定址法是一种简单高效的方法。
387. 字符串中的第一个唯一字符 - 力扣(LeetCode)
三、哈希冲突
不同的key映射到同一个位置,这种情况称为哈希冲突。
哈希冲突是不可避免的,我们能做的只有尽可能地设计更优的哈希函数减少冲突。
四、负载因子
假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么负载因子就是N/M。
负载因子越大,哈希冲突的概率越大,空间利用率越大。
负载因子越小,哈希冲突的概率越小,空间利用率越小。
五、哈希函数(重点)
1.除法散列法/除留余数法
除法散列法的公式:h(key) = key%M
%M的结果是在[0,M)区间之内,映射数组的下表恰好不会越界。
注意:
要尽量避免M为某些值,比如2的幂,10的幂,key%(2^x),相等于保留key的后x位。
%(10^x)就更不用说了,保留10进制的后x位。
当使用除法散列法时,M应取不太接近2的整数次幂的一个质数(素数)(只能被1和自身整除,更能区分)。
例如stl30的stl_hash.h实现如下:
static const int __stl_num_primes = 28; static const unsigned long __stl_prime_list[__stl_num_primes] = { 53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593, 49157, 98317, 196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843, 50331653, 100663319, 201326611, 402653189, 805306457, 1610612741, 3221225473, 4294967291 }; inline unsigned long __stl_next_prime(unsigned long n) { const unsigned long* first = __stl_prime_list; const unsigned long* last = __stl_prime_list + __stl_num_primes; const unsigned long* pos = lower_bound(first, last, n); return pos == last ? *(last - 1) : *pos; }
采取了素数表的形式,按照素数表的值进行扩容。
但Java的HashMap,就是用2的整数次幂做的M的值。
M = 2^x
处理时,hashi = h(key) = key%M = key%(2^x),这样就保留了2进制的后x位,但是前32-x位没有参与运算,于是做了这样的处理,hashi = hashi ^ (key>>(32-x)),将x位和32-x位进行异或。(但需注意,若x<16时,前32-x和后x位异或的值可能大于2^x,需要特殊处理)
目的:尽量让可以所有的位都参与运算,这样映射出的哈希值更均匀一些。
2.乘法散列法
h(key) = floor(M*((A*key)%1.0)) ((A*key)%1.0表示取A*key的小数部分)
实现思路:找出一个跟key相关的小数。
第一步:抽取key*A的小数部分。(A为常数,A的范围为(0,1))
第二步:再用M乘以(key*A)的小数部分,然后向下取整
其中常数A的值建议选择,(sqrt(5)-1)/2,即黄金分割率:0.618....
3.全域散列法
如果存在⼀个恶意的对手,他针对我们提供的散列函数,特意构造出⼀个发生严重冲突的数据集, 比如,让所有关键字全部落入同⼀个位置中。 只要散列函数是公开且确定 的,就可以实现此攻击。
应对方法:全域散列法
h ab ( key ) = (( a × key + b )% P )%MP需要选⼀个足够大的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了⼀个P*(P-1)组全域散列函数组。这样运行时,a,b是随机的选定的值。
六、处理哈希冲突
不论选择哪种哈希函数,哈希冲突是不可避免的。
1.开放定址法
其中开放定址法的解决哈希冲突分为三种:
1)线性探测
2)二次探测
3)双重散列
线性探测
以除法散列法举例:
线性探测公式: h(key) = (key%M+i)%M (i=1,2,3,...,M-1)
因为负载因子小于1,最多探测M-1次就可以找到。
线性探测思路:若当前位置有元素,往后找直到找到空位置,期间若走到尾又返回到头。
但需要注意的是:
若元素删除了,如何区分是否被删除了?
因为存在数组上的元素,一定会有一个值,且哈希表是散列分布的,元素的分布是无法确定的,因此无法像vector一样根据size来记录,vector是挨着放的。
那么有两种做法:
第一种:设置一个key的值,表示空。哈希表初始化时,所有位置都初始化成那个key值,删除完后,删除位置设置成那个值,但这样做有缺点:如何选择这个key值,因为key的类型是不确定的,选择一个key值必然会导致某些值不能使用。
第二种:给哈希表的节点值设置标志位,{EMPTY,EXIST,DELETE},标识节点的状态。
综合考虑,第二种明显更优。设置delete状态的原因:为了不影响后续冲突位置的查找。
二次探测:
h ( key ) = hash 0 = key % M , hash0位置冲突了,则二次探测公式为:hc ( key , i ) = hashi = ( hash 0 ± i^2 ) % M , i = {1, 2, 3, ..., M/2}需要注意的是:当hashi是负数时,需要+M,否则就越界了。
双重散列
第⼀个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出⼀个跟key相关的偏移量值,不断往后探测,直到寻找到下⼀个没有存储数据的位置为止。h 1 ( key ) = hash 0 = key % M , hash0位置冲突了,则双重探测公式为:hc ( key , i ) = hashi = ( hash 0 + i ∗ h 2 ( key )) % M , i = {1, 2, 3, ..., M }理解和分析:与其名称一致,双重散列通过两个散列函数(哈希函数)。要求 h 2 (key) < M 且 h2 (key )和M互为质数,有两种简单的取值方法:1、当M为2整数幂时, 从[0,M-1]任选⼀个奇数;2、当M为质数时,h 2 ( key ) = key % ( M − 1) + 1保证h 2 ( key )与M互质是因为若不互质,根据固定的偏移量所寻址的所有位置将形成⼀个群。
开放定址法插入的具体实现:
以下示例采用除法散列法,冲突时使用线性探测的方法:
对于扩容情况,创建一个新的哈希表,然后遍历一遍原哈希表,将值重新映射插入到新的哈希表中,然后交换两个表的vector。
bool insert(const pair
& kv) { if (find(kv.first)) return false; //负载因子为0.7时扩容 if (_n * 10 / _tables.size() >= 7) { Self newtable(__stl_next_prime(_tables.size() + 1)); for (size_t i = 0; i < _tables.size(); ++i) { if (_tables[i]._state == EXIST) { newtable.insert(_tables[i]._kv); } } _tables.swap(newtable._tables); } Hash hash; size_t tsize = _tables.size(); size_t hashi = hash(kv.first) % tsize; //线性探测 while (_tables[hashi]._state == EXIST) { ++hashi; hashi %= tsize; } _tables[hashi]._kv = kv; _tables[hashi]._state = EXIST; ++_n; return true; }
由于还要key默认支持转化成无符号整形
template
struct HashFun { size_t operator()(const K& key) { return static_cast (key); } }; template<> struct HashFun { size_t operator()(const string& key) { size_t ret = 0; for (auto e : key) { ret += e; ret *= 131; } return ret; } }; string作为常用的类型,应做模板的特化,特殊处理。
其中每次+=完之后*131,为了减少因顺序不同但值相同带来的哈希冲突。
开放定址法的HashTable模拟实现:
static const int __stl_num_primes = 28; static const unsigned long __stl_prime_list[__stl_num_primes] = { 53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593, 49157, 98317, 196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843, 50331653, 100663319, 201326611, 402653189, 805306457, 1610612741, 3221225473, 4294967291 }; inline unsigned long __stl_next_prime(unsigned long n) { const unsigned long* first = __stl_prime_list; const unsigned long* last = __stl_prime_list + __stl_num_primes; const unsigned long* pos = lower_bound(first, last, n); return pos == last ? *(last - 1) : *pos; } namespace hash_table { enum State { EXIST, EMPTY, DELETE }; template
struct HashData { pair _kv; State _state = EMPTY; }; template struct HashFun { size_t operator()(const K& key) { return static_cast (key); } }; template<> struct HashFun { size_t operator()(const string& key) { size_t ret = 0; for (auto e : key) { ret += e; ret *= 131; } return ret; } }; template > class HashTable { typedef typename HashData HashData; typedef typename HashTable Self; public: HashTable(const size_t N = 11) { _tables.resize(N); } bool insert(const pair & kv) { if (find(kv.first)) return false; //负载因子为0.7时扩容 if (_n * 10 / _tables.size() >= 7) { Self newtable(__stl_next_prime(_tables.size() + 1)); for (size_t i = 0; i < _tables.size(); ++i) { if (_tables[i]._state == EXIST) { newtable.insert(_tables[i]._kv); } } _tables.swap(newtable._tables); } Hash hash; size_t tsize = _tables.size(); size_t hashi = hash(kv.first) % tsize; //线性探测 while (_tables[hashi]._state == EXIST) { ++hashi; hashi %= tsize; } _tables[hashi]._kv = kv; _tables[hashi]._state = EXIST; ++_n; return true; } bool find(const K& key) { Hash hash; size_t tsize = _tables.size(); size_t hashi = hash(key) % tsize; //为delete往后找,可能因为冲突放后面去了 while (_tables[hashi]._state == EXIST || _tables[hashi]._state == DELETE) { if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key) return true; ++hashi; hashi %= tsize; } return false; } bool erase(const K& key) { Hash hash; size_t tsize = _tables.size(); size_t hashi = hash(key) % tsize; //为delete往后找,可能因为冲突放后面去了 while (_tables[hashi]._state == EXIST || _tables[hashi]._state == DELETE) { if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key) break; ++hashi; hashi %= tsize; } if (_tables[hashi]._state == EXIST) { _tables[hashi]._state = DELETE; --_n; return true; } else { return false; } } private: vector _tables; size_t _n = 0; // 表中存储的数据个数 }; }
2.链地址法
链地址法:
如图所示,散列方式采用的是:除法散列法,链地址法处理哈希冲突的方式是:在key映射的位置存一个单链表头结点的指针,key值映射的位置冲突时,就尾插到单链表的尾节点。
极端场景:节点全在一个链表上,退化成了一个单链表。
Java8:当单链表节点数量大于等于8时,就改为挂红黑树。
链地址法的插入:
思路:key转成无符号整形,再根据除法散列法,算出hashi,得到头指针,就转换成了单链表的插入。对于单链表的插入,头插,需要修改_bucket[hashi],即头结点的指针。
对于扩容:复用插入不是一种好的方法,因为复用插入,每次插入时都要new节点,最后还要把原哈希桶的所有节点delete,效率低。应该把原哈希桶的节点“拿下来”插入到新桶。
具体代码实现:
bool insert(const pair
& kv) { if (find(kv.first)) return false; //负载因子等于1时,扩容 if (_n == _bucket.size()) { //思路:将原哈希桶的节点拿下来,插入到新的桶 vector newbucket; newbucket.resize(__stl_next_prime(_bucket.size()) + 1); size_t newsz = newbucket.size(); Hash hash; //按原桶的vector下标遍历 for (size_t i = 0; i < _bucket.size(); ++i) { if (_bucket[i] != nullptr) { Node* cur = _bucket[i]; Node* next = cur->_next; //cur即为当前遍历的节点 // 将其 头插 到新的哈希桶中 while (cur) { size_t hashi = hash(cur->_kv.first) % newsz; //头插,cur head,cur成为新的头 cur->_next = newbucket[hashi]; newbucket[hashi] = cur; //迭代 cur = next; if (cur) next = cur->_next; } } _bucket[i] = nullptr; } _bucket.swap(newbucket); } Hash hash; size_t hashi = hash(kv.first) % _bucket.size(); Node* newnode = new Node(kv); //头插,newnode head,newnode成为新的头 newnode->_next = _bucket[hashi]; _bucket[hashi] = newnode; ++_n; return true; }
链地址法的删除
需要注意的是删除头结点时,要改变vector存的头结点的指针。
若没有找到,注意空指针解引用的问题。
bool erase(const K& key) { Hash hash; size_t hashi = hash(key) % _bucket.size(); Node* cur = _bucket[hashi]; //cur为头结点的指针 if (cur == nullptr) return false; //头删,_bucket[hashi](cur) curNext if (cur->_kv.first == key) { _bucket[hashi] = cur->_next; delete cur; --_n; return true; } //中间删除 else { Node* prev = nullptr; //prev cur curNext while (cur && cur->_kv.first != key) { prev = cur; cur = cur->_next; } if (cur == nullptr) { return false; } else { prev->_next = cur->_next; delete cur; --_n; return true; } } }
完整代码如下:
static const int __stl_num_primes = 28; static const unsigned long __stl_prime_list[__stl_num_primes] = { 53, 97, 193, 389, 769, 1543, 3079, 6151, 12289, 24593, 49157, 98317, 196613, 393241, 786433, 1572869, 3145739, 6291469, 12582917, 25165843, 50331653, 100663319, 201326611, 402653189, 805306457, 1610612741, 3221225473, 4294967291 }; inline unsigned long __stl_next_prime(unsigned long n) { const unsigned long* first = __stl_prime_list; const unsigned long* last = __stl_prime_list + __stl_num_primes; const unsigned long* pos = lower_bound(first, last, n); return pos == last ? *(last - 1) : *pos; } namespace hash_bucket { template
struct HashFun { size_t operator()(const K& key) { return static_cast (key); } }; template<> struct HashFun { size_t operator()(const string& key) { size_t ret = 0; for (auto e : key) { ret += e; ret *= 131; } return ret; } }; template struct ListNode { ListNode(const pair & kv) :_kv(kv) , _next(nullptr) {} pair _kv; ListNode * _next; }; template > class HashBucket { private: typedef typename ListNode Node; public: HashBucket(const size_t sz = 11) { _bucket.resize(sz); } ~HashBucket() { for (size_t i = 0; i < _bucket.size(); ++i) { if (_bucket[i] != nullptr) { Node* cur = _bucket[i]; Node* next = cur->_next; while (cur) { delete cur; cur = next; if (cur) next = cur->_next; } } } } bool insert(const pair & kv) { if (find(kv.first)) return false; //负载因子等于1时,扩容 if (_n == _bucket.size()) { //思路:将原哈希桶的节点拿下来,插入到新的桶 vector newbucket; newbucket.resize(__stl_next_prime(_bucket.size()) + 1); size_t newsz = newbucket.size(); Hash hash; //按原桶的vector下标遍历 for (size_t i = 0; i < _bucket.size(); ++i) { if (_bucket[i] != nullptr) { Node* cur = _bucket[i]; Node* next = cur->_next; //cur即为当前遍历的节点 // 将其 头插 到新的哈希桶中 while (cur) { size_t hashi = hash(cur->_kv.first) % newsz; //头插,cur head,cur成为新的头 cur->_next = newbucket[hashi]; newbucket[hashi] = cur; //迭代 cur = next; if (cur) next = cur->_next; } } _bucket[i] = nullptr; } _bucket.swap(newbucket); } Hash hash; size_t hashi = hash(kv.first) % _bucket.size(); Node* newnode = new Node(kv); //头插,newnode head,newnode成为新的头 newnode->_next = _bucket[hashi]; _bucket[hashi] = newnode; ++_n; return true; } bool find(const K& key) { Hash hash; size_t hashi = hash(key) % _bucket.size(); Node* cur = _bucket[hashi]; while (cur) { if (cur->_kv.first == key) { return true; } cur = cur->_next; } return false; } bool erase(const K& key) { Hash hash; size_t hashi = hash(key) % _bucket.size(); Node* cur = _bucket[hashi]; //cur为头结点的指针 if (cur == nullptr) return false; //头删,_bucket[hashi](cur) curNext if (cur->_kv.first == key) { _bucket[hashi] = cur->_next; delete cur; --_n; return true; } //中间删除 else { Node* prev = nullptr; //prev cur curNext while (cur && cur->_kv.first!=key) { prev = cur; cur = cur->_next; } if (cur == nullptr) { return false; } else { prev->_next = cur->_next; delete cur; --_n; return true; } } } private: vector _bucket; size_t _n = 0; // 表中存储的数据个数 }; }