前言
“数据结构与算法”这个专栏是我为了记录对于数据结构与算法的复习和总结而开的专栏,所以内容可能更多偏向于个人向,但也不妨输出点有价值的知识。然后我复习用的书主要是《大话数据结构》,这本书的作者是《大话设计模式》的作者,两本书都是好书,也比较适合初学者去阅读。该专栏的大部分引用和截图都是出自该书籍,以后就不一一说明了。
然后哈希表是我想讲的第一个数据结构,是因为昨天面试,还有各种笔试的时候都遇到了他。自己对哈希表也忘得差不多了,今天特地复习了他,所以趁热记录在博客中。
什么是哈希表
哈希表又叫散列表,是用于解决查找的一种数据结构。他和数组一样占用一段连续的内存空间,但是数组的查找基本上都需要遍历一遍进行比较,那就是O(n)的时间复杂度,而哈希表我们基本认为他的查找是O(1)时间复杂度的。
哈希表的思路也比较简单:遍历和比较这两个操作本身也不是什么互斥的操作,我们为什么不将他们融合起来,让关键字(key)本身和他所在的位置有所关联呢。所以我们需要一个函数f,将关键字(key)的本身转换为他的存储地址,这个存储地址可以是数组的下标:
存储位置 = f(key)
这样我们就不要特地去比较就可以知道关键字的存储位置,这是一种散列技术,这个函数也被称为散列函数。
散列技术是在存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。
所以散列技术既是一种存储技术,也是一种散列技术。有了这种技术,才使得哈希表能够达到O(1)的时间复杂度。
散列策略
既然知道哈希表的思想,那么我们现在要做的事情就很简单了,那就是找到一个散列函数,使得关键字和存储位置能够一一对应。可想而知,这是很难实现的。所以我们也不强求什么绝对的策略,只要满足以下两点就好了:
- 计算不能太复杂
- 散列地址均匀分布
理由很简单:本来我们使用哈希表就是希望优化查找速度的,如果散列的计算太复杂了,那就得不偿失了;地址分布均匀是为了尽可能减少堆积。 这里给出几种的散列策略:
- 直接地址法
- 数字分析法
- 平方取中法
- 折叠法
- 除留余数法
- 随机数法
每种策略都各有利弊,其中用得最多的是除留余数法。下面时简单的介绍:
直接地址法
直接地址法比较简单粗暴,就是利用线性函数作为关键字的散列地址:
f(key) = a * x + b (a, b常数)
该方法的好处就是简单且分布均匀,因为线性函数一一对应的关系,也不会出现冲突。但是缺点也很明显,就是必须事先知道关键字的分布情况,才能合理选择a和b。所以虽然这个方法虽然简单,但是却不怎么使用。
数字分析法
对于较长的数字字符串关键字,我们可能不需要其中的大部分数字字符。比如说一个学校的学生学号是以“届-学院-专业-班级-个人”来进行编码的,那么一个班级的学生学号的前一大半的数字字符都是一样的。如果我要用一张哈希表来记录这个班级的学生,那我就不用考虑个人编码以外的其他数字了,直接利用个人的编码进行散列就好了。
这里用到了一个方法叫做——抽取。利用抽取,我们可以使用较长关键字的部分来进行散列,这样可以节省一定的计算资源,避免溢出的情况。但缺点也很明显,你需要对数字进行分析,针对性也比较明显。这个方法可以结合其他方法一起使用,特别是在处理较长关键字的时候。
平方取中法
这种方法也很直接,比如说有一个关键字是1234,他的平方是1522756。取多少位主要由你的哈希表的大小来定的,如果说哈希表的大小是1000,那我可能会取中间3位,即227作为散列地址。这个方法虽然简单粗暴,但也容易造成乘法溢出的情况。所以他比较适合不知道关键字分布情况,而且关键字比较小的情况。
折叠法
折叠法是将数字切分成n部分然后叠加得到散列地址,他也比较适合较大关键字的处理。然后分块的大小也是由哈希表的大小来决定的,比如说关键字9876543210,哈希表的大小是1000,那我会以3个数字为一分组,将其987|654|321|0,然后将其叠加得1962,然后再取结果的3位作为散列地址。
当然这个方法也非常的灵活,你可以将各部分取反再相加,也可以每加一次就取一次反。总之为了分布得较为均匀,你可以对其进行一下变化。这个方法适合不知道关键字分布情况,但关键字比较长的情况。
除留余数法
这个方法是最经常使用的方法了,他符合大多数的情况,也能将位置散分布得比较均匀。该方法的散列函数也十分简单:
f(key) = key % p (p <= size)
通过求余,能将位置控制在数组范围里,并且如果p选择得好,也能够使得位置比较均匀。根据前辈们的经验,p最好是接近表长的质数,或者是不包含小于20的质因子的合数。不过为了方便处理冲突,我们一般将p设为表长。
随机数法
顾名思义,就是采用随机数来作为关键字在数组的下标。这个方法比较危险,倒不是说随机数的随机性很危险,而是你得了解随机种子,以确保你能够准确找到关键字的随机数。在不使用种子的前提下,随机数是可以一样的,这样就保证了关键字和数组下标的一一对应关系。具体的机制得了解了解srand函数和rand函数。
总之在选取策略和设计策略的时候,要考虑以下几点:
- 计算散列地址所需的时间
- 关键字的长度
- 哈希表的大小
- 关键字的分布情况
- 查询的频率
解决冲突
可能看下来的同学或多或少发现,不管我怎么去设计散列策略,总是会有以下情况发生:对于key1 ≠ key2,有可能会出现f(key1) == f(key2),这就产生了冲突。 这是避免不了的,不管你采取什么策略,一定会发生的。也很难说你设计了一个复杂的散列算法就能解决,而且复杂的散列方法本身就不太推荐。
于是就有了解决冲突的算法,常用的有以下四种:
- 开放地址法
- 再散列函数法
- 链地址法
- 公共溢出区法
开放地址法
该方法的思想就是:如果散列一次冲突了,那就去寻找下一个空地址,只要表足够大,总有空余的空间。那么他的散列函数就应该是:
f(key) = (f(key) + d) % size (d = 1,2,3,4,...,q-1)
对于key集合{12, 67, 56, 16, 25, 37,…}(长度为12),前5个数用除留余数法 f(key) = key % 12 来做散列的话,会有以下结果:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|
12 | 25 | 16 | 67 | 56 |
但是对于37会有37 % 12 = 1,与25冲突了。那么我们就可以用上面的函数来解决冲突:f(37) = (f(37) + 1) % 12 = 2,2好位置正好是空的,就可以将37放入。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|
12 | 25 | 37 | 16 | 67 | 56 |
如果2也有东西,那么就f(37) = (f(37) + 2) % 12 = 3,就这样一直探测下去,直到不再产生冲突。这样处理方法我们称之为线性探测。
这个方法虽然简单,但也比较容易造成堆积问题——像25和37这两个本来都不是同义词,但却因为散列函数而被分配到了同一个内存地址,我们称这种现象为堆积。之前说的要使分布尽可能的均匀,为的就是避免这种堆积问题。
要解决堆积问题,就要对上面散列函数的d进行处理。我们不一定要求d是递增的,我们可以让 d = {1, -1, 22, -22, 32, -32, … , q2, -q2} (q <= size / 2)。这种方法我们称其为二次探测法。
当然我们也可以让d为一系列的随机数,道理和随机散列差不多,只要随机数的固定的那几个,就能保住关键字和散列地址的一一对应。这个方法叫做随机探测法,也不怎么推荐使用。
再散列函数法
该方法的思想是:如果散列一次冲突了,那么久换一个散列函数对上一次的散列结果再进行一次散列,总有一次能找到空闲的地址空间。这个方法就需要我们准备几个散列函数,如上面的折叠、平凡取半等,而且保证每次散列的顺序是一定的。这个方法没啥好说的了,优点就是不容易产生堆积,缺点就是计算稍微复杂点。
链地址法
该方法的思想是:为什么冲突了就一定要找下一个地址,我就地维护一个链表他不香吗。这个方法用下图表示就特别直观了:
区别就是哈希表存储的内容变成链表指针了,然后对于某个关键字还需要遍历一遍链表。在堆积问题不是很严重的前提下,遍历链表还是挺快的,还是可以认为其是O(1)的时间复杂度。
公共溢出区法
该方法的思想和链表地址法差不多,但变通了一下,就是将冲突的关键字统一放到一个数组中去管理,这个数组空间就是公共溢出区。同样在堆积问题不是很严重的情况下,数组也不会大到哪里去,简单便利一下还是很快能找到关键字的。
很明显,他的优点就是易于实现,缺点就是你得同时维护两张表,稍微浪费了点空间,而且要注意查找到的下标是基本表的下标还是溢出表的下标。
这个方法可以用以下图来描述:
代码实现
#pragma once
#include <algorithm>
#define EXPANSION 20 //扩容
class HashTable
{
public:
HashTable(size_t sz)
: capacity(sz + EXPANSION ) //空间越大,冲突频率越低
, elements(new int[capacity])
, size(0)
{
std::fill(elements, elements + capacity, INT_MIN);
}
~HashTable()
{
delete[] elements;
}
int insert(int key)
{
if(size >= capacity - EXPANSION){
//扩容只是为了空间换时间,但尽量还是不要大于原本的大小
return -1;
}
int idx = hash(key);
//这里使用开放地址法来解决冲突
int d = 1;
while (elements[idx] != INT_MIN) {
//idx = hash(idx + 1); //位移探测法
int dd = d * d;
if (d > 0) { //二次探测法
d = -d;
}
else {
d = (-d) + 1;
dd = -dd;
} //这样处理后,位移量dd = {1, -1, 4, -4, 9, -9, ...}
idx = hash(idx + dd);
}
elements[idx] = key;
++size;
return idx;
}
int getElement(int key)
{
int idx = hash(key);
int d = 1;
while (elements[idx] != key) {
//idx = hash(idx + 1);
int dd = d * d;
if (d > 0) {
d = -d;
}
else {
d = (-d) + 1;
dd = -dd;
}
idx = hash(idx + dd);
if (elements[idx] == INT_MIN || idx == hash(key)) {
//没有找到或者回到了最初的起点
return -1;
}
}
return idx;
}
private:
int capacity;
int *elements;
int size;
private:
int hash(int key)
{
return key % size; //除留余数法
}
};
总结
总结下来,哈希散列的两大问题:一个就是散列策略,另一个就是解决冲突。而且不难发现,哈希表的空间越大,冲突的频率就越低,这样时间复杂度就越接近O(1)。所以我们经常将哈希表的大小稍微设置得大一些,用空间来换时间也是挺值得的。