🐱作者:一只大喵咪1201
🐱专栏:《数据结构与算法》
🔥格言:你只管努力,剩下的交给时间!
哈希表
- 🎯哈希
- 🥊直接定址法
- 🥊除留余数法
- 🥊哈希冲突
- 🎯闭散列
- 🥊线性探测
- 哈希表类型定义
- 插入
- 🥏转换成int的仿函数
- 查找
- 删除
- 🥊二次探测
- 🎯开散列(哈希桶)
- 🥊数据类型定义
- 🥊插入
- 扩容
- 🥊查找
- 🥊删除
- 🎯除留余数法获取素数
- 🎯开散列与闭散列比较
- 🎯总结
🎯哈希
- 哈希(Hash):是一种方法,将数据的key值和存储位置建立关系。
在之前学习过的顺序结构以及平衡树中,所有数据的key值和存储位置之间都没有对应的关系。所以在查找一个数据时,都需要多次比较key值。
- 顺序结构:查找的时间复杂度是O(N)。
- 平衡树:查找的时间复杂度是O(log2N)。
无论是AVL树还是红黑树,在搜索方面的效率已经相当不错了,但仍然不是最理想的情况。
- 最理想搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素,此时搜索的时间复杂度就是O(1)。
- 哈希表:构造出的一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的key值之间建立一一映射的关系。
使用这种结构查找数据时,利用构建结构时的映射关系,可以非常快速的找到数据的存储位置,此时的时间复杂度是O(1)。
- 哈希函数:将key值和存储位置建立映射关系的方法,就是上面说到的hashFunc。
🥊直接定址法
直接定址法是常用的哈希函数之一。
如上图所示,这是直接定址法中的一种情况。
- 数组arr中的数据是待存储的数据,它们的key值就是本身。
- 下面的结构是这组数据的存储位置,其实也是数组,但是在这里是按照哈希的方法来构建的,所以这个数组本叫做哈希结构。
- 映射关系:哈希表中的下标Hash(Key) = 数据本身Key。
上面的映射关系,也就是哈希函数,可以归纳为一次函数Hash(key) = A*Key + B,其中A,B是常数。可以根据具体情况来定A和B的值,如上面所举例子中,A = 1,B = 0。
但是这种直接定址法存在一定的问题,所存放的数据不能够太分散,如上图所示,一共6个数据,但是哈希结构需要1000个位置来存放,造成很大的空间浪费。
- 直接定制法适用于数据相对集中的场景。
🥊除留余数法
这是另一种常用的哈希函数,并且克服了直接定址法存在的数据不能太分散的缺陷。
如上图所示,要存放的数据仍然是arr中的数据,哈希结构的大小是10。
- 哈希函数:Hash(Key) = Key % capacity。
- 小于10的数据,模capacity后,余数是其本身,并且作为哈希表中存储位置的下标。
- 大于等于10的数据,模capacity后,将得到是余数作为哈希表中存储位置的下标,如上图中的999%10 = 9,此时9就是数据999存放在哈希表中的下标。
可以看到,除留余数法解决了直接定址法数据不能太分散的问题,所以除留余数法比直接定址法更常作为哈希函数。
除此之外,还有很多的哈希函数,如平方取中法,折叠法,随机数法,数学分析法等等。最常用的还是除留余数法。
🥊哈希冲突
- 哈希冲突:不同key值通过相同的哈希函数计算出的哈希表位置相同,这种现象被叫做哈希冲突或者哈希碰撞。
如上面图示的结构中,如果此时再要插入的数据是44,使用除留余数法计算出的哈希表下标也是4,但是此时下标为4的位置已经存在了数据4,此时就发生了哈希冲突。
🎯闭散列
闭散列是最常见的解决哈希冲突的两种方法之一,也叫做开放定址法。
- 闭散列:当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
如上图所示,哈希表的大小是10,arr中待存放的数据,使用的哈希映射函数是除留余数法。
- 18存放在哈希表下标为8的位置,8也要存放在下标为8的位置,此时就发生了哈希碰撞。
- 使用闭散列的方法解决哈希碰撞,将8存放在18的“下一个位置”。
- 8存放在18的下一个下标为9的位置,如上图所示。
- 7按照除留余数法放在下标为7的位置,那么27呢?7的下一个位置也有数据了,是18,该存放到哪呢?
这里的“下一个”位置并不是紧挨着当前位置的下一个位置,而是哈希表中空余的位置,那么该怎么去寻找这些空余的位置呢?
🥊线性探测
- 线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。这是一种比较常用的方法。
- 当存放27的时候,发现哈希表中下标为7的位置已经有数据了,7后面的位置也有数据了。
- 继续从哈希表头部开始找空位置存放27。57也是这样存放的。
这种方式有那种走别人的路让别人无路可走的感觉,这样一来,存放的数据越多,发生的哈希冲突就越多,就会越混乱。
下面本喵用代码将闭散列线性探测用代码方式实现出来。
哈希表类型定义
enum State
{
EMPTY,//空
EXIST,//存在
DELETE,//删除
};
哈希表中,每个位置存在三种状态,分别是空,存在,删除。
- 如果不用空用什么来表示哈希表某位置可以存放数据呢?用0和-1等都不妥,有可能存放的就是0和-1,所以这里使用EMPTY来标识可以存放数据。
- 有空就有存在,当哈希表中某位置存放了数据后,需要改变其状态,与空相对应的就是存在EXIST了。
如上图所示,将哈希表中的27删除后,这里可以存放新的数据,如果将其状态设置成空EMPTY。此时是可以继续存放新的数据了,但是在查找时就存在了新的问题。
- 27已经被删除,该位置的状态是EMPTY,当在哈希表中查找57的时候,查到27时,因为该位置状态为空,所以认为后面没有数据了,从而没有找到27。
- 除非每次查找都会遍历一次哈希表,但这样又违背了哈希表设计时查找的时间复杂度是O(1)的初衷。
- 根据上面分析,当删除哈希表中的个元素后,最好的方法就是将这个位置的状态设置为删除DELETE,而不是空。
在插入新数据时,遇到EMPTY和DELETE状态的位置都可以插入,在查找的时候,遇到DELETE时继续向后查找,直到遇到EMPTY状态的位置才结束查找。
有人会觉得数据被删除了,它的位置还保留着,有点浪费空间,不能删除以后将该位置后的所有元素都整体向前移动一下吗?
- 首先,如果移动后面数据来覆盖这个位置,付出的代价很大,如果删除的是第一个元素,后面所有元素都向前移动,时间复杂度是O(N)。
- 最重要的是,移动过后,使用哈希映射函数得到的映射关系就变了。
将原本存放27的0号位置覆盖,后面所有数据向前移动一个位置,如上图所示,此时7所在哈希表的位置下标是6,原本的映射关系就变了。
当查找7的时候,使用除留余数法得到的下标仍然是7,此时的7存放的是18,就会向后寻找,寻找的57以后的位置是空,就不再往后找了,就会认为7不存在。
所以说,给哈希表中增加DELETE状态,是最好的选择。
template <class K, class V>
struct Hashdata
{
pair<K, V> _kv;
State _state = EMPTY;//默认状态为空
};
存放的数据同样是一个键值对,还要包含一个状态标志,默认为EMPTY。
template <class K, class V>
class HashTable
{
typedef Hashdata<K, V> Data;
public:
private:
vector<Data> _tables;//哈希表
size_t _n;//哈希表中数据个数
};
哈希表的底层使用vector,这样可以非常方便的进行扩容,获取哈希表大小等操作。为了方便实例化vector,使用typedef将存放的数据重命名为Data。
插入
使用哈希函数将key值和哈希表的存放位置建立映射关系,使用闭散列中的线性探测来解决哈希碰撞问题,找到存放位置后,将键值对存入,并且改变该位置的状态。
- 哈希表被创建时,底层的vector的size是0,在哈希映射时候%size()会发生除0错误,所以哈希表必须有构造函数来初始化vector的大小。
//构造函数
HashTable()
:_n(0)
{
_tables.resize(10);//哈希表初始值
}
这里将vector的初始大小设置为10。
通过调试可以看到,这7个数据成功被插入了。
如果哈希表满了呢,此时新插入的数据就没有位置放了,所以必须进行扩容。
- 哈希表是否进行扩容是根据负载/载荷因子来决定的,一般情况下,当负载因子大于0.7进行扩容。
- 负载/载荷因子 = 哈希表中有效数据的个数 / 哈希表的大小。
之所以按照负载因子来扩容,是为了减轻哈希碰撞。
- 负载因子越小,冲突概率越小,消耗空间越多。
- 负载因子越大,冲突概率越大,空间利用率越高。
扩容:
- 判断负载因子时,如果用_n/size()的话,得到的值始终都是0,因为是两个整数相除,所以将使用上图所示的方式来判断负载因子大于0.7。
在扩容的时候,因为哈希表扩大了,所以哈希映射关系就不一样了,比如17,在容量是10的时候,映射出的下标是7,而容量成为20的时候,映射出的下标是17。
所以在扩容后,并不是简单的将旧表中的数据复制到新表中,还需要按照新容量得到的映射关系来存放数据。
- 上图蓝色框中代码,巧妙的进行了复用,创建一个新哈希表,容量设置为原本的二倍,然后使用Insert将旧表中的数据插入到新表中。
🥏转换成int的仿函数
上面我们写的插入,都是针对int类型的数据,也只有整形数据才能使用除留余数法,才能进行取模,从而得到映射关系,如果插入的数据不是整形,而是string呢?右或者是其他自定义类型呢?
此时就使用仿函数的方法,“将自定义类型转换成整形”,这里的转换只是为了除留余数法能够使用。
上图所示代码是一个仿函数,用来将string转换成int,去进行除留余数法进行映射。
- string中的每个字符的ASCII码乘以131,然后再加起来。
- 相乘的数字可以是31,131,1313等等,这是专门经过研究的,比随意相乘一个数字好很多。
- s1和s2字符串中,ASCII码的和是一样的,字符也是一样的,只是顺序不同,形成了不同的字符串。
- 使用仿函数hf后,两个字符串得到的整数是不同的,如上图所示。此时就可以进行除留取余法了。
如上图所示,在进行除留取余法时,需要使用仿函数来转换成整形。
- 为了编程上的统一,对于可以直接转换成整形的类型K,调用上面普通模板中的仿函数。
- 对于string字符串,不能直接转换,需要进行特殊处理,所以使用特化模板专门处理string。
在哈希表的模板参数中,将转换整数的仿函数给缺省值,一般情况下,比如int,char等整形家族的数据,或者是地址等可以直接转换成整形的数据,还有字符串是不需要再传一个仿函数的。
- 如果插入的自定义类型是日期类等其他自定义类型,需要我们自己实现一个转换成整形的仿函数作为模板参数传给哈希表。
查找
根据key值在哈希表中进行查找时,必须根据哈希函数的映射方式去查找,这样才能符合哈希表设计的初衷。
- 拿着key值,按照哈希函数的映射关系直接定位到哈希表的位置,这一步是让哈希表查找效率是O(1)的关键。
- 在查找过程中,不仅要比较key值,还要看位置的状态,有可能key值相同,但是状态是DELETE,此时就不能算作找到,只有key相同,并且状态是TSIST才算是找到。
- 查找到状态是EMPTY即可,否则就成了遍历了,这样仍然能保证查找的此时是常数次。
在插入中,要先进行判断插入的key是否存在,如果存在则不再插入。
删除
//删除
bool Erase(const K& key)
{
Data* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
_n--;
return true;
}
else
{
return false;
}
}
删除非常简单,先进行查找,如果存在,则根据找到的地址将key所在位置的状态置位DELETE,如果没有找到,直接返回false即可。
void TestCloseHash5()
{
string arr[] = { "苹果", "西瓜", "香蕉", "草莓", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
CloseHash::HashTable<string, int> CountHt;
for (auto& e : arr)
{
CloseHash::Hashdata<string, int>* ret = CountHt.Find(e);
if (ret)
{
ret->_kv.second++;
}
else
{
CountHt.Insert(make_pair(e, 1));
}
}
}
可以看到,同样可以进行水果个数的统计。
上面代码实现起来其实很简单,主要的是几个点值得注意学习:
- 给哈希表每个位置状态标识。
- 使用仿函数将不同类型的K转换成整数,这一点与平衡树中获取key值的仿函数有异曲同工之妙。
线性探测存在很大的缺陷:
- 一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同Kkey值占据了可利用的空位置。
- 找某key值的位置需要许多次比较,导致搜索效率降低。
虽然有缺陷,但是比较的次数还是小于N次的,属于常数次,比遍历的方式强很多。
🥊二次探测
二次探测为了避免一次探测的数据堆积问题的。
- 线性探测:start + i,i = 0,1,2,3…
- 二次探测:start + i2,i = 0,2,3…
其中start是根据哈希函数的映射关系求出的哈希表中起始位置,二次探测每次向后探测的步长成平方次增长。
- 44最开始根据哈希函数求得的下标是4,当i等于1时,探测的下标是5,存在数据5,当i等于2时,探测的下标是8,此时为空,就可以插入了。
使用二次探测,查找时也是按照二次探测的方式去查找。
研究表明:
- 当表的长度为质数且表装载因子不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。
- 因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子不超过0.5,如果超出必须考虑增容。
具体的代码实现本喵就不演示了,有兴趣的小伙伴可以自行尝试。
无论是线性探测还是二次探测,闭散列方式的最大缺陷就是空间利用率较低。
🎯开散列(哈希桶)
- 开散列:又叫链地址法(开链法),首先对key值集合用哈希函数计算映射下标,具有相同下标的key值归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
如上图所示,此时的哈希表中存放的是一个单链表的头指针。
- 不同的数据,根据哈希函数算出的映射位置发生哈希碰撞时,这些碰撞的数据会挂在哈希表对应位置指向的单链表中。这些单链表被形象称为桶。
- 每个桶中放的都是发生哈希冲突的元素。
- 当有新数据插入时,进行头插。
如上图中所示,7,27,57,根据哈希函数都映射到哈希表下标为7的位置,这几个数据按照头插的顺序以单链表的形式挂在哈希表下标为7的位置。
新插入的数据如果尾插的话,在找单链表的尾部时,会有效率损失,由于没有排序要求,所以头插是效率最高的。
闭散列的方法,通常被称为哈希桶,使用的也最广泛,能够解决闭散列中空间利用率不高的问题。
🥊数据类型定义
采用哈希同的方式来解决哈希碰撞时,哈希表中存放的数据是单链表的头节点,如上图所示。
- 链表节点中,有键值对,还有下一个节点的指针。
- 仍然使用闭散列中转换整形的仿函数。
在哈希桶的构造函数中,哈希表的初始大小是10个元素,每个元素都是nullptr,因为此时还没有桶。
哈希桶必须有析构函数,闭散列的方式,默认生成的析构函数就能满足要求,但是哈希桶不可以。
- 如果只使用默认生成的析构函数,在哈希桶销毁的时候,默认的析构函数会调用vector的析构函数。
- vector的析构函数只会释放vector的本身,而不会释放vector上挂着的桶。
所以需要显示定义析构函数,在析构函数中将vector挂的桶进行释放。在释放的时候,需要将单链表的下一个节点记录下来,再释放当前节点,否则会找不到下一个节点。
🥊插入
- 在经过哈希函数映射后,将键值对插入到哈希表对应位置除的桶中。
- 插入到桶中时,使用头插,如上图中蓝色框所示,这俩步的不能反,必须按照这个顺序,否则无法实现头插。
如上图所示,哈希映射后下标相同的所有元素都挂在一个桶中,这个桶中的所有元素发生了哈希碰撞。
扩容
同样,哈希桶的方式中也会扩容,否则桶就会越挂越长,违背了哈希桶设计的初衷。
- 一般情况下,当哈希表的负载因子等于1的时候,发生扩容。
- 当负载因子等于1时,也就是数据个数和哈希表大小相等的时候进行扩容。
- 扩容和闭散列类似,将旧的哈希表中的数据插入到新哈希表中,复用Insert函数,然后旧表被释放,新表留下来。
但是这种方式不是很好,有很大的开销,效率有所损失:
在将旧表中的数据插入新表的时候,每插入一个,新表就需要new一个节点,旧表中的所有节点都会被new一遍。
然后将旧表中的所有节点再释放,这里做了没必要的工作。相同的一个节点,会先在新表中new一个,再释放旧表的。
新表中完全可以不再new新的节点,直接使用旧表中的节点。
- 旧表中可以直接复用的节点是:改变了哈希表容量以后,映射关系不变的节点。
- 比如节点27,哈希表的容量从10变成20,但是映射后的下标仍然是7,这样的节点就可以复用。
那些映射关系变了的节点就不可以直接复用了,需要改变所在桶的位置。
- 如节点18,哈希表的容量从10变成20,映射后的下标从8变成18,此时就需要改变18所在的桶了。
- 这里不用创建新的哈希桶结构,只创建底层的vector就可以,因为不再复用Insert了。
- 将旧表中的数据一个个拿出来,通过哈希函数重新计算映射关系,并且头插到新新表的桶中。
- 旧表的每个桶中的数据处理完后,必须把表中的单链表头置空,因为此时新表和旧表都指向这些桶,否则在旧表析构的时候会析构掉所有桶,导致新表中没有数据。
采用上面方法就可以直接继续使用旧表中的所有数据了,不用new新的节点。
如上图所示,旧表中的数据在新表中直接使用,数据的地址并没有改变。
🥊查找
- 根据哈希函数的映射关系,直接找到key值哈希表中的存放位置。
- 然后在该位置挂的桶中寻找key值。
哈希桶结构,查找的效率高就高在这里,可以直接根据key值定位哈希表,时间复杂度是O(1)。
🥊删除
如上图所示,使用Find先找到key值所在哈希表中的位置,然后删除。
- 哈希表挂的桶是单链表,只指定要删除节点是无法进行删除的,必须指定前一个节点,否则无法再链接。
所以上面的方法是不能用的,只能拿着key值通过哈希函数重新寻找哈希表中的key值,在这个过程中同时记录前一个节点prev。
- 根据哈希函数的映射关系,定位到对应哈希表中挂的某个桶上。
- 如果key是单链表的头节点,直接让它的下一个节点当头节点就可以。
- 如果key不是头节点,则在删除的时候,需要prev指针的辅助来链接单链表。
由于哈希映射的存在,在寻找key时的时间复杂度同样是O(1),所以删除的效率也很高。
🎯除留余数法获取素数
有研究表面,哈希表的大小最好是一个素数,这样的话能够提供哈希结构的效率,那么如何快速获取一个类似两倍关系的素数呢?
inline unsigned long __stl_next_prime(unsigned long n)
{
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
};
for (int i = 0; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return __stl_prime_list[__stl_num_primes - 1];
}
上面代码是STL库中获取素数的方式。
- 将素数放在一个数组中,两个素数之间的关系接近二倍,但是又要符合是一个素数。
- 当需要进行扩容时,就从数组中寻找一个比当前素数大的素数作为新的容量。
上面数组中虽然只有28个素数,但是完全够用了,最大的素数几乎等于232,就意味着哈希表有4GB个数据,每个数据是一个指针(32位),也就是4B大小,这样来看已经有16GB的数据量了,再考虑上挂的桶中的数据,数据量是非常大,正常情况下根本没有这么大量的数据。
- 在创建哈希表的时候,先开辟的哈希表大小为第一个素数,也就是53。
- 扩容的时候去素数数组中寻找比当前大的一个素数即可。
void TestHashBucket3()
{
srand((unsigned int)time(nullptr));
const size_t N = 10000;
HashBucket::HashTable<int, int> ht;
for (size_t i = 0; i < N; ++i)
{
int key = rand();
ht.Insert(make_pair(key, key));
}
}
那1000个随机数去测试,完全没有任何问题。
🎯开散列与闭散列比较
开散列也就是哈希桶,看起来每个节点中多了一个指针,比闭散列存放的数据大,但是它空间利用率高,负载因子大于1的时候才会扩容。
闭散列方式中必须有大量的空闲空间来保证搜索的效率,二次探测甚至要求负载因子必须小于等于0.7,并且表项所占的空间比哈希桶大的多。
- 在空间利用率上,哈希桶比闭散列更有优势。
在搜索上效率上,无论是开散列还是闭散列,都会通过哈希函数的映射关系之间在定位在表项中,然后在查询常数次,所以这两种方式的时间复杂度都是O(1)。
只是闭散列中,负载因子越多,哈希碰撞的概率就越大,查找时耗费的时间就比哈希桶长。
- 综合考虑,哈希结构的底层大多使用哈希桶结构,也就是开散列方式。
而且当哈希桶的单链表很长时,可以将挂的桶改成树结构来提高效率。
- 比如设置一个链表长度,当单链表的长度超过这个值时,就将桶改成树结构,如AVL树或者红黑树等等。
当那些特别设计过的数据,让所有数据挂在哈希桶的一个桶上,此时只用单链表就会导致效率低下,此时就可以使用红黑树来提高效率。
🎯总结
哈希表中,尤其值得我们学习的地方,一个是闭散列中,使用一个状态标志来标识哈希表中元素的状态,还有就是使用仿函数来将自定义类型转换成整形用于除留余数法。
虽然很大的篇幅在写闭散列,但是哈希桶才是高频使用的结构,而且哈希桶是建立在闭散列的基础上改进的。