前言
看完散列和跳表的部分,最大的感受的就是已经开始摆脱基本的数据结构,比如栈和队列,代码的实现开始复杂起来。
散列和跳表为了提高降低操作的时间复杂度,都引入了“不可完全预测”的概念。跳表多级指针的分布是随机的,散列的Hash函数映射是不可完全预测的。当我们的数据量达到大量数据级别时,我们的数据结构具有了相对的随机性。这也是跳表和散列与之前数据结构的不同之处。
这一部分引出了一个新的概念 —— 字典。
字典
字典: 字典由一些形如(k,v)的数对所组成的集合,其中k是关键字(key),v是与关键字k对应的值(value)。比如学号和你的一门成绩对应,key便是你的学号,value便是你的成绩。
字典是如何和散列与跳表联系起来的呢?
我认为首先出现的应该是字典的需求,就像上面学号和成绩对应的情况。需求出现之后,可以用pair<K, E>结构体来储存字典的key和值。
如果将pair类型存储在数组中,且key的值是有序的。那么查找(二分查找)的时间复杂度为O(logn),插入和删除的时间复杂度为O(n)。
因为数组的随机存取在处理pair类型时失效了,我们可以取出任意位置的pair单元,但不知道其中的key和value的具体值。假如我有一个取出key=“10086”的需求,那么就只能使用二分查找,一个一个取出pair中的key值,进行比对,时间复杂度为O(logn)。
在动辄百万行的数据库中,或者有频繁存取的数据表中,这样的性能是不可接受的。
于是我们引出散列和跳表两个解决方案,其中跳表可以将查找、插入、删除的平均时间复杂度降低至Θ(logn)。散列可以将查找、插入、删除的平均时间复杂度降至Θ(1)。
(注意:这里是平均情况下的渐进等于记法Θ,在最坏情况下,跳表和散列的查找、插入、删除时间复杂度仍为Θ(n))
本文着重分析散列,跳表部分有余力时再进行详细分析。
散列
散列:散列是字典的一种表示方法,同样跳表是另一种表示方法。它用一个散列函数(hash function)把字典的数对映射到一个散列表(hash table)的特定位置。如果数对p的关键字为k,散列函数为f,那么在理想情况下,p在散列表的位置为f(k)。
散列的体系结构:
1. 散列的实现
2. 散列函数
3. 冲突与溢出
4. 散列性能分析
散列的实现:散列表可以由数组或者数组+链表实现。代码的实现涉及散列函数和如何处理冲突和溢出的问题,我将在这些问题分析完成之后在最后列出代码。
散列函数
散列函数是散列的核心,理想情况下,可以将关键字映射到关键字在表中的地址。散列函数并不是固定的,使用时要对问题的具体情况设置适合的散列函数。我在这里主要分析直接定址和除法取余散列函数。
直接定址函数:F(key) = a*key + b;
优点是计算简单,且不会产生冲突。
缺点是它只适合关键字的分布基本连续的情况,若关键字不连续,会导致空位,造成存储空间的浪费。
例子:还是学号与成绩,如果学号区间为“90052——91052”,那么可设散列函数为F(key) = key - 90052,把key映射到散列表“0—1000”的位置。之后便可通过映射函数进行基本操作,且时间复杂度为O(1)。
除法散列函数:F(key) = key % p;
p为常数,除法散列函数是最常用的方法,关键是根据问题的情况来选取p,使得关键字映射到散列表中时可以均匀分布,尽可能减少冲突的发生。
在现阶段主要考虑的因素为散列表的表长D。
选取p的流程大概如下:
- 取一个不大于D但最接近或等于D的素数
- 当不能用心算计算出第一条中的素数时,选择尽量大的素数
p这样选取的原因
原因的解释有篇文章总结的很好:https://blog.csdn.net/w_y_x_y/article/details/82288178
概括来说就是,p的选取不依赖于key的分布,key可能是等差、等比、随机和正态等等。我们选取的p要在综合概率下发生冲突的机率最小。
如果key是间隔为1的等差数列,那么p任选一个数都可以做到均匀分布。
如果key是间隔为2的等差数列,那么当p的因子中有2时,就会产生很多冲突,如果p的因子中没有2,就会均匀分布。
当我们的表长为D时,取最大接近表长的素数可以保证小于表长的间隔会均匀分布,取大于表长的素数也可以,但因为表中的桶数是固定的,效果合接近表长的素数相同,所以没必要取大于表长的素数。
冲突与溢出
冲突:当两个或两个以上关键字映射到同一个桶时,便发生了冲突。
溢出:如果散列表没有空间存储一个新数对,则发生溢出,只有在数组实现的链表中有这种情况。
处理冲突的方法
数组实现:
- 线性探测法
- 再散列法
线性探测法:
假设冲突发生在第i个桶,便探测第i+1个桶,若该桶为空,则将插入的新单元放入第i+1个桶。若不为空,则继续探测,直到找到空的桶插入,或返回到第i个桶(把散列表当作逻辑环,表示表已满)。
线性探测法并不适合经常性的插入和删除操作,特别是删除操作,删除一个数对,要移动若干个数对。即使是逻辑删除,也要不定时进行更新散列表。
再散列法
需要使用两个散列函数,当通过第一个散列函数得到的地址发生冲突时,则利用第二个散列函数计算该关键字的地址增量。再散列法最多经过m-1次探测就会遍历表中的所有位置。
公式:Fi = (F(key) + i*Fi-1(key)+a)% m;
链表实现:
链接法
为了避免线性探测法的“堆积”问题和溢出,我们可以把所有映射到同一桶的数对存储到一个线性链表中。如果表长为m,那么便有m个链表,查找、插入、删除操作在映射到某一个桶之后便在链表上进行。
链接法适用于经常需要插入删除的情况,因为基本操作在链表的实现比较简洁。
散列性能分析
- 负载因子: α = n / b α = n/b α=n/b,其中b为散列表中桶的个数,n为散列表中元素的个数。负载因子定义为一个散列表装满的程度,α越大,表示负载的记录越满,发生碰撞的可能性越大,反之发生冲突的可能性越小。
- 平均查找长度(普遍)
- 平均查找长度(局部)
散列实现
这里实现的是使用线性探查法的散列实现,没有实现删除方法
template <class E, class K>
class HashTable
{
public:
HashTable(int theDivisor = 23);
~HashTable();
pair<const K, E>* find(const K& theKey) const; //查找
void insert(const pair<const K, E>& thePair); //插入
private:
int hSearch(const K& theKey) const; //搜索到指定桶,因为查找和插入都会用到,写成单独的函数
pair<const K, E>** table; // 散列表
int dSize; //散列表中的元素个数
int divisor; //散列函数除数
};
template<class K, class E>
HashTable<K ,E> :: HashTable(int theDivisor)
{
divisor = tehDivisor;
dSize = 0;
table = new pair<const K, E>* [divisor];
for(int i =0; i < divisor; i++)
{
table[i] = null;
}
}
template<class K, class E>
HashTable<K, E> :: ~HashTable()
{
for(int i = 0; i < divisor; i++)
{
delete table[i];
}
}
template<class K, class E>
int HashTable<K, E> :: hSearch(const K& theKey) const
{
int i = theKey % divisor; //起始桶位置
int j = i; //遍历时的变量
do
{
if(table[j] == null || table[j]->first == theKey)
{
return j;
}
j = (j+1) % divisor;
}while(j != i);
return j;
}
template<class K, class E>
pair<const K, E>* HashTable<K,E> :: find(const K& theKey) const
{
int p = hSearch(theKey);
if(table[p] == null || table[p]->first != theKey)
return null;
return table[p];
}
template<class K, class E>
void HashTable<K, E> :: insert(const pair<const K, E>& thePair)
{
int p = hSearch(theKey);
// 桶为空, 可以直接插入
if(table[p] == null)
{
table[p] = new pair<const K, E>(thePair); // 利用复制构造函数
dSize++;
}
else
{
// 如果有相应key,直接更新value
if(table[p]->first == thePair.first)
{
table[p]->second = thePair.second;
}
else
{
throw "the hashTable is full."
}
}
}
删除方法叙述
- 第一种:对删除的元素进行标记,进行逻辑删除。并定期维护散列表,把标记删除的元素物理删除。
- 第二种:删除需要移动若干个数对,从删除位置的下一个桶开始,逐个检查每一个桶,以确定要移动的元素,直到到达一个空桶或回到删除位置为止。