从数组、链表到散列

为什么我们需要各种各样的数据结构?

对我们而言,通常对于数据的操作无外乎以下几种方式:增、删、改、查。其中除增加外,其他几种操作均要求对集合进行搜索。而结构化的数据模型可以通过数组、链表或者树形结构等建立,不同的建模方式对于数据处理中的各种操作有不同的性能表现。一般来讲,数据结构将直接影响对其处理的算法的选择,在本文中的散列函数算法又会反过来影响散列表这种结构之于数据的存贮效率,可以说,数据结构与算法的关系就好比是一卵双生。

数组、链表存储数据的方式

下面通过一张图让我们看清数组和链表是如何存储集合中的数据的:

这里写图片描述
通过上图我们可以得到以下发现:

用数组存储数据,我们利用了数组单元在物理位置上的邻接关系来表示表中元素之间的逻辑关系。由于这个原因,用数组有如下的优缺点。

优点是:
无须为表示表中元素之间的逻辑关系增加额外的存储空间;
可以方便地随机访问表中任一位置的元素。
缺点是:
插入和删除运算不方便,除表尾的位置外,在表的其他位置上进行插入或删除操作都必须移动大量元素,其效率较低;
由于数组要求占用连续的存储空间,存储分配只能预先进行静态分配。因此,当表长变化较大时,难以确定数组的合适的大小。确定大了将造成浪费。

用单向链表存储数据,一个单向链表的节点被分成两个部分。第一个部分保存或者显示关于节点的信息,第二个部分存储下一个节点的地址,而最后一个节点则指向一个空值。。单向链表只可向一个方向遍历。一般查找一个节点的时候需要从第一个节点开始每次访问下一个节点,一直访问到需要的位置。由于这个原因,用链表有如下的优缺点。

优点是:
向链表中插入或者从链表中删除一项的操作不需要移动很多项,而只涉及常数个节点的链的改变。
缺点是:
在删除最后一项比较复杂,因为必须找出指向最后节点的项,把它的next链改为null,然后再更新持有最后节点的链。
其无法提供随机访问能力,单向链表只可向一个方向遍历。

  • 最后我们知道这两种结构对存储空间的利用率都很高,谁说这不是一个优点呢,至少对于散列来说是的。
  • 而散列是一种用于以常数平均时间执行插入、删除和查找的技术。但是那些需要元素间任何排序信息的操作将不会得到有效支持。

什么是散列

在解释散列的含义之前,我们先来深入的观察一下普通数组这种数据结构,由上图所示,将集合{a,b,c,d,e,f}存储在数组中时占据了索引为0~5的6个存储空间,我们可以形式化的表示为有序对(An ordered pair)的集合,即{(a,0),(b,1),(c,2),(d,3),(e,4),(f,5)}。但其实这个集合中任意一个有序对的元素之间的关系是未定义的(undefined),因此这样的有序对集合可以有6!个。然而我们是否可以构造一个关系,使得待存储的集合{a,b,c,d,e,f}中的任意一个元素通过这个关系都能找到一个指定的索引值,由下图更形象的表示:
这里写图片描述

根据上图我们知道原有集合中的元素被散列之后所得到的有序对的集合为{(d,0),(b,2),(c,2),(a,3),(f,4),(e,5)}。其中,散列表中的1号槽为空,表示经过散列函数作用之后,原集合中没有任何元素被散列至该位置,而2号槽中则存在两个元素,将两个不同元素散列至相同位置的情形我们称为碰撞(Collision)。如上图所示,我们通过使用链表将不同元素链接起来解决碰撞,在后文中还将介绍另外一种称为开放寻址(Open addressing)的解决方法。这里更具体的说明一下链接法的链接形式:因为散列至同一个槽的元素并无顺序上的先后要求,因此为效率计,我们将总是采用在链表头插入碰撞元素的做法,最终导致的结果是,在一个链表中,越靠近表头的节点,在原数组中被散列的次序越靠后。综上所述,可以对散列函数hash进行如下形式化定义:
设在大小为N的集合中存在元素elem,存储原集合中元素的散列表共有m个槽位,则有
hash: elem→γ∈{0,1,2,…,m-1}
这就是散列的基本想法。剩下的问题就是要选择一个函数,决定当两个关键字散列到同一个值的时候(碰撞)应该做什么以及如何确定散列表的大小。

散列函数

这个散列函数涉及关键字中的所有字符,并且一般可以分布得很好。这个散列函数利用到事实:允许溢出。这可能会引进负数,因此在末尾有附加的测试。
这个散列函数就表的分布而言未必是最好的,但确实具有极其简单的优点而且速度也很快。如果关键字特别长,那么该散列函数计算起来将会花费过多的时间。在这种情况下通常的经验是不适用所有的字符。

public static int hash(String key,int tableSize){
    int hashVal = 0;
for(int i = 0; i <key.length(); i++)
    hashVal = 37*hashVal + key.charAt(i);
hashVal %= tableSize;
if(hashVal < 0)
hashVal += tableSize

return hashVal;
}

剩下的主要问题就是如果解决冲突的消除问题。如果当一个元素被插入时域一个已经插入的元素散列到相同的值,那么就会产生一个冲突,这个冲突需要消除。解决这种冲突的方法有几种,我们将讨论其中最简单的两种:分裂链接法和开放寻址法。

分离链接法

解决冲突的第一种方法叫做分离链接法(separate chaining),其做法是将散列到用一个值的所有元素保存到一个表中。
这里写图片描述
为执行一次查找,我们使用散列函数来确定究竟遍历哪一个链表。然后我们再在确定的链表中执行一次查找。为了执行insert,我们检查相应的链表看看该元素是否已经处在合适的位置(如果允许插入重复元,那么通常需要留出一个格外的域,这个域当出现匹配事件时增1)。如果这个元素是个新元素,那么它将被插入到链表的前端,这不仅因为方便,还因为常常发生这样的事实:新近插入的元素最有可能不久又被访问。
紧接着我们需要考虑:散列表的大小为多少较好?

设所有元素之间均相互独立,且每个元素被散列至每个槽的概率均相等 ————> 假设①
并设原集合的尺寸为n,散列表的大小为m,装载因子α=n/m ————–>假设②
最重要的是,我们并不考虑散列函数所消耗的计算开销 —————->假设③
由假设①,元素被散列至每个槽的概率相等,再根据假设②中散列表的大小为m,有P{elem→γ∈{0,1,2,…,m-1}}=1/m
设待查找的关键字为k,因此存在两种情况:Ⅰ、待查找关键字k不存在; Ⅱ、关键字k存在
然而其实情况Ⅰ的搜索开销即为情况Ⅱ的最坏情况运行时间,因为关键字k不存在时将查找整个链表
以下是更详细的分析:
——>Ⅰ、由假设①,并且根据假设②有装载因子α=n/m,所以α表示每个槽的结点数。因为当待查找的关键字k不存在时,我们必将搜索到该槽中的最后一个结点处,因此在这种情况下的搜索开销为O(α)。
——>Ⅱ、对于关键字k存在的情况,其实不做详细分析我们就已经知道,这种情况下的运行时间必然不超过O(α),然而单单分析本身就是一件很有趣的事情,为何不做得彻底一些?更重要的是,下面所用到的这种方法,其体现的思想对于分析随机过程将具有极大的益处。
这里写图片描述
综上可知,如果我们将散列表设定为与原集合大小相同时,装载因子α=1,此时的搜索时间为O(1),但其实设置为原集合尺寸的1/2或是2倍并没有多大区别。需要注意的是虽然散列表越大导致搜索时间减少,但其所占内存空间将会增大。

开放寻址法

分离链接散列算法的缺点是使用一些链表。由于给新单元分配地址需要时间(特别是其他语言中),因此这就导致算法的速度有一些减慢,同时算法实际上还要求对第二种数据结构的实现。同链接法一样,开放寻址是一种用于解决碰撞的策略。不同之处在于,在链接法中,每个关键字只能对应一个固定的槽,而在开放寻址中,每个关键字可以对应算列表中中的多个槽,因为有可能在首次寻址过程中,该槽已被先前的关键字所占据,因而接下来我们根据事先所制定的某个规则继续试探下一个槽是否可用,直至找到一个可用的槽并将关键字存储在其中为止。一般来说,因为所有的数据都要放置如表内,所以这方法需要的表的大小要比分离链接散列的表大,而且其装填因子应该低于α=0.5。我们把这样的表叫做探测散列表。

线性探测法

设 hash(key, i)=(hash’(key)+i) mod m,其中 i=0,1,…,m-1,hash’为辅助散列函数
在线性试探中,首次探查位置取决于hash’(key)的值,若发生碰撞,则接下来所探查的位置为(hash’(key)+1) mod m,从而所形成的探查序列为

平方探测法

设 hash(key,i)=(hash’(key)+c1i+c2i2) mod m,如上所述,i=0,1,…,m-1,hash’为辅助散列函数,c1,c2为辅助参数
可以发现,线性试探法实际是二次试探的特殊情况,若取参数c1=1,c2=0,那么二次试探将“退化”为线性试探。同样的,整个探查序列也是由hash’(key)所决定的,因为虽然探查序列中各元素之间的增量[c1i+c2i2]不再以线性的方式进行,但对于每个元素来说,引导因子i总是以步长1的方式递增,这就导致所有元素的探查序列的增加方式是相同的,因此整个散列表同样提供m种不同的探查序列。但随着散列表中元素的增加,这种跳跃式的增量方式使得插入/搜索操作的运行时间受到的影响较小。
虽然平方探测排除了一次聚集,但是散列到同一个位置上的那些元素将探测相同的备选单元,这叫做二次聚集。二次聚集是理论上的一个小缺憾。对于每次查找,它一般要引起另外的少于一半的探测。下面的技术将会排除这个缺憾,不过这要付出计算一个附加的散列函数的代价。

双散列

设 hash(key,i)=(hash’(key)+i×hash”(key)) mod m,其中i=0,1,…,m-1,hash’,hash”均为辅助散列函数
双重试探法的首个探查位置为hash’(key),当产生碰撞之后,接下来的探查位置为(hash’(key)+hash”(key)) mod m,因此我们发现在双重试探法中,不仅初始探查位置依赖于关键字key,探查序列中的增量hash”(key)同样依赖于关键字key,因而整个散列表提供了m2种不同的探查序列,较之于前两种开放寻址具备了更多的灵活性。这里还要注意的是应保证hash”(key)与m互质,因为根据固定的偏移量所寻址的所有槽将形成一个群,若最大公约数p=gcd(m, hash”(key))>1,那么所能寻址的槽的个数为m/p

再散列

对于使用平方探测的开放寻址散列法,如果散列表被填的太满,那么操作的运行时间将开始消耗过长,且插入操作可能失败。这可能发生在有太多的移动和插入混合的场景。此时,一种解决方法是建立一个大约两倍大的表(并且使用一个相关的新散列函数),扫描整个原始散列表,计算每个(未删除的)元素的新散列值并将其插入到新表中。
整个操作就叫做再散列(rehashing)。显然这时一种开销非常大的操作,其运行时间为O(N) ,因为有N个元素要再散列而表的大小约为2N,不过,由于不是经常发生。因此实际效果根本没有那么差。特别是在最后的再散列之前必然已经存在N/2次insert,因此添加到每个插入上的发费基本上是一个常数开销。如果这种数据结构是程序的一部分,那么其影响是不明显的。
再散列可以用平方探测以多种方法实现。一种做法是只要表满到一半就再散列。另一种极端的方法是只有当插入失败时才再散列。第三种方法即途中策略:当散列表到达某一个装填因子时进行再散列。由于随着装填因子的增长散列表的性能确实下降,所以第三种方式可能是理想的策略。

阅读更多
换一批

没有更多推荐了,返回首页