数据结构(十一)——散列和字典小结

前言

看完散列和跳表的部分,最大的感受的就是已经开始摆脱基本的数据结构,比如栈和队列,代码的实现开始复杂起来。

散列和跳表为了提高降低操作的时间复杂度,都引入了“不可完全预测”的概念。跳表多级指针的分布是随机的,散列的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."
        }
    }
}

删除方法叙述
  1. 第一种:对删除的元素进行标记,进行逻辑删除。并定期维护散列表,把标记删除的元素物理删除。
  2. 第二种:删除需要移动若干个数对,从删除位置的下一个桶开始,逐个检查每一个桶,以确定要移动的元素,直到到达一个空桶或回到删除位置为止。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值