顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素
时,必须要经过关键码的多次比较 。 顺序查找时间复杂度为 O(N) ,平衡树中为树的高度,即 O(log_2 N) ,搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以 不经过任何比较,一次直接从表中得到要搜索的元素 。
如果构造一种存储结构,通过某种函数 (hashFunc) 使元素的存储位置与它的关键码之间能够建立
一一映射的关系,那么在查找时通过该函数可以很快找到该元素 。
当向该结构中:
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
取元素比较,若关键码相等,则搜索成功
该方式即为哈希 ( 散列 ) 方法, 哈希方法中使用的转换函数称为哈希 ( 散列 ) 函数,构造出来的结构称
为哈希表 (Hash Table)( 或者称散列表 )
例如:
数据集合 {1 , 7 , 6 , 4 , 5 , 9} ;
哈希函数设置为: hash(key) = key % capacity ;
capacity 为存储元素底层空间总的大小。
上图插入1,模10得到1,就填入到1的位置,其他元素也是同理。
如计数排序,其实就有哈希的思想。详情参考:计数排序 。
当我们向上图中再插入一个数字44的时候,44和4取10的模的余数都是4,那么都应该在4的位置。这该怎么填入数字呢?这种出现几个数字都符合一个位置的条件的情况,叫做哈希碰撞/冲突。
对于两个数据元素的关键字 k_i 和 k_j (i != j) ,有 k_i != k_j ,但有: Hash(k_i) == Hash(k_j) ,即: 不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突 或哈希碰撞 。
把具有不同关键码而具有相同哈希地址的数据元素称为 “ 同义词 ” 。
发生哈希冲突该如何处理呢?
引起哈希冲突的一个原因可能是: 哈希函数设计不够合理 。
哈希函数设计原则 :
常见哈希函数:
1. 直接定址法--(常用)undefined 取关键字的某个线性函数为散列地址: Hash ( Key ) = A*Key + Bundefined 优点:简单、均匀undefined 缺点:需要事先知道关键字的分布情况undefined 使用场景:适合查找比较小且连续的情况 2. 除留余数法--(常用)undefined 设散列表中允许的 地址数为 m ,取一个不大于 m ,但最接近或者等于 m 的质数 p 作为除数, 按照哈希函数: Hash(key) = key% p(p<=m), 将关键码转换成哈希地址 3. 平方取中法--(了解)undefined 假设关键字为 1234 ,对它平方就是 1522756 ,抽取中间的 3 位 227 作为哈希地址;再比如关键字为4321 ,对它平方就是 18671041 ,抽取中间的 3 位 671( 或 710) 作为哈希地址。 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况 4. 折叠法--(了解)undefined 折叠法是将关键字从左到右分割成位数相等的几部分 ( 最后一部分位数可以短些 ) ,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。undefined 折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况 5. 随机数法--(了解)undefined 选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 H(key) = random(key), 其中random为随机数函数。undefined 通常应用于关键字长度不等时采用此法 6. 数学分析法--(了解)undefined 设有 n 个 d 位数,每一位可能有 r 种不同的符号,这 r 种不同的符号在各位上出现的频率不一定undefined 相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只undefined 有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散undefined 列地址。 例如:
假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前 7 位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转( 如 1234 改成 4321) 、右环位移 ( 如 1234 改成 4123) 、左环移位、前两数与后两数叠加( 如 1234 改成 12+34=46) 等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的 若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
解决哈希冲突两种常见的方法是: 开散列 和 闭散列 。
1.线性探测
比如 2.1 中的场景,现在需要插入元素 44 ,先通过哈希函数计算哈希地址, hashAddr 为 4 ,
因此 44 理论上应该插在该位置,但是该位置已经放了值为 4 的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止 。
插入:
删除:
结构: 既然要线性存储,底层肯定要用vector来存储的。我们还需要设置一个size_t类型变量来记录哈希表中有效元素的数量。 每一个位置还需要有三种状态:空、存在、删除,以便后序的插入删除和查找。我们可以用枚举变量。 构造函数:我们给定元素个数为10。 查找:获取数据对应的hashi,找到数据对应的位置,如果不在该位置,则继续往后查找,直到位置上的状态为 EMPTY。(注意:这里是不能在状态DELETE的时候停下的,有可能会影响查找后面的元素。)在全是DELETE和EXIST的情况下,会陷入死循环,所以我们设置一个初始值 starti,每次查找完 hashi %= 哈希表元素个数,如果 hashi == starti ,那么说明回到了原点,就应该停止查找。 插入:我们首先需要判定是否已经存在了插入的元素的值。并且。我们还需要设定一个负载因子(负载因子 = 有效元素 / 总元素个数),如果负载因子超过了设定值,那么则需要扩容。由于传入的数据不一定是size_t类型的,我们需要构造一个仿函数Hashfunc来获取。通过获取数据对应的hashi,将数据插入到对应位置,若该位置已经有了数据,则++hashi。
删除:找到对应的元素,将其状态修改为DELETE即可。
关于负载因子:
代码:
#pragma once
#include <vector>
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
namespace closehash
{
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 Hash = HashFunc<K>>
class HashTable
{
typedef HashData<K, V> Data;
public:
HashTable()
:_n(0)
{
_tables.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
//大于标定负载因子,就需要扩容
if (_n * 10 / _tables.size() >= 7) //负载因子 0.7
{
HashTable<K, V, Hash> newHT;
newHT._tables.resize(_tables.size() * 2);
//重新插入存在的节点
for (auto e : _tables)
{
if (e._state == EXIST)
{
newHT.Insert(e._kv);
}
}
_tables.swap(newHT._tables);
}
//仿函数获取pair中first
Hash hf;
size_t hashi = hf(kv.first) % _tables.size();
while (_tables[hashi]._state == EXIST)
{
//线性探测
hashi++;
hashi %= _tables.size(); //保证不越界
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
Data* Find(const K& key)
{
Hash hf;
size_t hashi = hf(key) % _tables.size();
size_t starti = hashi; //防止死循环查询
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
//如果是delete也要继续往下走
++hashi;
hashi %= _tables.size();
//防止极端环境:全是delete和exist,这时会死循环
if (hashi == starti)
{
break;
}
}
return nullptr;
}
bool Erase(const K& key)
{
Data* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
private:
vector<Data> _tables;
size_t _n = 0; //表中存储的有效数据的个数
};
}
线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同
关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降
低。
开散列法又叫链地址法( 开链法 ),首先对关键码集合用散列函数计算散列地址,具有相同地undefined 址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链undefined 接起来,各链表的头结点存储在哈希表中。undefined
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
结构: 因为表中是存储的单链表,所以基础结构当然是链表节点。链表节点中存储着pair结构和状态_state。 插入: 如果有效数据个数和表大小相同的时候,需要扩容。重新创建节点插入的方法十分浪费空间,我们可以服用旧表的节点。获取对应的位置后插入节点到新表中。由于是单链表,插入节点如果是尾插,十分浪费时间,而链表头插十分方便,所以节点插入时采用头插的方式。 扩容:桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。 查找: 获取元素对应位置,通过链表一直遍历下去,找到了就返回该节点,没找到返回空指针。 删除:和查找类似,找到节点,需要判断一下删除节点是不是头结点,是的话需要改变头结点。不是头结点只需要前一个节点指向该节点后一个节点,再删除该节点即可。
代码:
#pragma once
#include <vector>
#include <string>
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
namespace buckethash
{
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 Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
//构造函数
HashTable()
:_n(0)
{
_tables.resize(10, nullptr);
}
//析构
~HashTable()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
if (_n == _tables.size())
{
//方法一:重新开辟相同节点插入
//缺点:消耗空间更大
/*HashTable<K, V> newHT;
newHT._tables.resize(2 * _n, nullptr);
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
newHT.Insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newHT._tables); */
//方法二:复用旧表中的节点
vector<Node*> newTables;
newTables.resize(_n * 2, nullptr);
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = Hash()(cur->_kv.first) % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
}
_tables.swap(newTables);
}
size_t hashi = Hash()(kv.first) % _tables.size();
Node* newNode = new Node(kv);
//头插新节点
newNode->_next = _tables[hashi];
_tables[hashi] = newNode;
++_n;
return true;
}
Node* Find(const K& key)
{
size_t hashi = Hash()(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
size_t hashi = Hash()(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
//需要判断是否为头节点
if (cur == _tables[hashi])
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _tables;
size_t _n = 0; //表中存储的有效数据的个数
};
}
对与能够强制转换为整形的类型,我们采用强制类型转换使其变成整形。
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
方法很多,我们值介绍常用的方法。
最常用的方法就是每次乘上一个数字,然后加上一个字符。返回最终获取到的数字。
不同的类型需要对应的转化方法,这点可以参考库里的实现方法。
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
在开辟哈希表的大小时,我们可以采用取一个素数的方式。
当使用素数作为除数时,能够更加均匀地散列 key 值,减少了哈希冲突的发生,而如果使用合数(即非素数)作为除数,那么就会有更多的键被映射到相同的索引上,从而增加哈希冲突的概率 – 合数有多个因子,取模后产生的余数可能比较集中,所以不能很好地散列 key 值。
另外,每个素数几乎都是接近二倍的关系,也基本符合我们的扩容两倍的要求。
源码:
// Note: assumes long is at least 32 bits.
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;
}
在某些极端情况下,可能会存在哈希表中某一条链表过长的情况,从而导致效率的低下。
为了解决这种问题,可能会将其转化成红黑树的结构。