原文:
http://lotusroots.bokee.com/5787507.html
数据结构之线性结构(二,联合数组等)
作者:冲出宇宙
时间:2006.10.24
修改:2006.11.3
3 联合数组(Associative array)
联 合数组的别名有:Map,Hash,Dictionay, finite map, lookup table, index/index file。它是一个抽象数据结构,包含了一个key集合和一个value集合,每个key都对应一个value。它和数学中的函数十分相似,因为把key 当做输入的话,value就是其输出。正因为这样,key和value之间的关系有的时候被认为是映射关系。比如,如果key="good"而它对应的 value=3的话,我们说数组把good映射到了3上面。联合数组是基本数组的扩充形式,基本数组是把整数(索引)映射到值上面,而联合数组是把任何类 型的数映射到值上面。
一般的说,它支持4种操作:
1,Add: 加入一个新的key,value对;
2,Reassign: 把一个旧的key绑定到一个新的value上面;
3,Remove: 删除一个key,value对;
4,Lookup: 查找一个key对应的value值。
联合数组通常用在查找十分频繁的地方,所以,其查找性能是现实它的时候需要优先考虑的。目前主要有3种实现办法:
1,Hash table: 散列表是最常见的实现方式,其优点是查找快,缺点是占用空间多,未对key进行排序,设计一个好的一般hash算法比较困难。
2,Self-balancing binary search tree: 平衡查找树也是一个很常用的办法,其优点是查找和插入都是o(logn)的复杂度,同时对多个key对应一个value的情况支持很好。
3,Skip lists: 跳跃表主要的优点是插入和删除都是十分的快速,但是,其缺点是查找速度最差是o(n),平均是o(logn)。
最简单的实现联合数组的办法是联合表(Associative lists),就是把key和value对用list链接在一起。目前来说,比较好的实现方法是Patricia Trees和Judy arrays,一般推荐使用这2种结构来实现联合表。
Multimap是联合数组的变形,它定义为每个key可以对应于多个value。
3.1 散列表(Hash table)
散列表也叫hash map,它是一种联合了keys和values的数据结构。它提供的最有效的基本操作是查找。这种操作是基于hash函数的。下图是一个把名字映射到电话号码上的散列表示意图:
散列表常被用来构建联合数组、集合和缓冲。无论表里面有多少元素,散列表提供常数的平均查找复杂度,这点和数组是一样的(虽然它比数组慢许多倍)。但是,最坏的情况下,查找一个元素可能花费o(n)的时间。
散列表对碰撞的处理有很多办法,但是,最常用的只有2种:链表和地址散列。在使用链表处理散列表碰撞的时候,hash值一样的key-value被串到同一个链表上。查找的时候,先根据hash值找到链表的头节点,然后顺序查找下去。图示的是链表方式的碰撞处理。
地址散列试图在出现冲突之后在数组中找到一个空闲位置放数据。有3种比较著名的找空闲位置的办法:
1)线性探测。以一个固定的间隔步探测下一个空闲位置,这个间隔常常为1;
2)多项式探测。间隔步的计算方式是多项式。
3)2次hash。间隔步的计算方式是输入为记录的hash函数。
图示是一个地址散列解决冲突的例子:
显然,散列表的性能和Hash函数的构造是紧密联系的,如何构造Hash函数是一个很大的问题,作者不久将单独写一篇文章谈论Hash函数。
3.2 跳跃表(Skip list)
跳跃表是一个已经排序了的多层次链表,它由多个基本链表组成。第一层的链表包含了全部的元素,在第n层里面出现的元素在第n+1层出现的概率是p,这样就随机的构造出了多个层次的链表,最后那层的链表只有1个元素。
下面是一个例子:
1: 1 --> 2 --> 3 --> 4 --> 5 --> 6 --> 7 --> 8 --> 9 --> 10
2: 1 --------> 3 --> 4 --------------> 7 --------> 9
3: 1---------------> 4 --------------------------> 9
4: 1
除了这种分层次的表示方法以外,一般跳跃表都表示成下面的样子:
|
|-------------------->|--------------------------------->|
|------------>| ----->|------------------------->|------>|
1 --> 2 --> 3 --> 4 --> 5 --> 6 --> 7 --> 8 --> 9 --> 10
这里,每个节点包含多个指针。
根据跳跃表的定义,第i层链表拥有N*p^i个节点,跳跃表包含的所有节点数为N/(1-p)个,每个节点在1/(1-p)个链表里面出现。
查找数据M的时候,首先查找最高层的链表,找到最后一个小于等于待查数据的节点node,然后从下一层节点里面的包含了和node包含的数据一样的节点 开始查找。重复上述动作,直到找到为止。对于上面的例子,如果我们要查找8的话,首先查找第4层,止步的数据为1,继续查找第3层,中止的数据为4,查找 第2层,从4开始,止步的数据为7,查找第1层,从7开始,止步的数据为8。查询结束。
插入数据和删除数据的操作很容易根据定义得到,只是他们可能都需要对多个链表进行操作。
性能方面,查找一个数据的耗费显然为o(logN)。
根据已有的比较结果,skip list性能不如B树,同时,作者宣称的skip list比AVL树在性能上好的说法引起了很多争议。但是,skip list应用在并行环境下的时候,具有不错的效果。
4 栈(Stack)
堆栈是一种暂时性的数据结构(抽象数据结构)。它在现代计算机中到处可见,俨然就是现代计算机的支撑者。堆栈是一种后进先出(也就是先进后出)的数据结构,其实现往往使用数组。
堆栈支持2种基本操作:进栈(Push)和出栈(Pop)。进栈的时候,把数据放到栈的顶端;出栈的时候,把数据从栈的顶端移走。一般来说,实现堆栈的结构还会支持下面2种操作:查看顶(Peek)和计算栈长(getLength)。
5 队列(Queue)
在计算机科学中,队列是一种包含了许多待处理的数据的结构。最有名的队列是先进先出(FIFO)队列,在这种队列中,先进入的数据会先被处理。先进先出 队列支持2种基本操作:出列(Dequeue)和入队(Enqueue)。出列是把队列的头数据进行处理,而入队则是把新待处理数据加入到队列的末尾。当 然,某些队列的实现还会支持查看头数据(Peek)和计算队列长度(getLength)这2种操作。
根据队列的定义,最常用来实现它的是链表。所以,队列一般都具有链表的时间和空间性质。
5.1 优先队列(Priority queue)
优先队列和基本的队列唯一不同的地方在于:每个队列的元素都有一个优先度。同时,它支持3种操作:enqueue(包括优先度)、dequeue(把最高优先度的那个数据请出队列)和peek(查看最高优先度的那个数据)。
优先队列的实现是一个值得探讨的问题。最简单的说,我们可以维持一个队列数组,每个队列的优先度都一样,这样做的结果是,enqueue的速度很 快,dequeue和peek的速度就十分慢了(因为我们要找到最大优先度的那个队列)。显然,使用平衡树就能够加快dequeue和peek的速度,它 的3种操作的复杂度都是o(logn)。当优先度的取值范围比较小的时候,使用van Emde Boas树是更好的选择。各种Heap结构也可以用来构建优先队列。
6 双向队列(Deque)
双向队列是可以在前面或者后面加入或者删除数据的队列结构。它可以从2头出列和入队。
7 间隙缓冲(Gap buffer)
最早使用在emacs上面,它是一种用来存储大数组集合的结构,它提供了快速的插入和删除操作,前提是操作在当前位置附近发生。正因为这样,它在文本编 辑器中使用很多。举例来说,现在一个缓冲保存了一大段文字,这段文字被划分为2部分:A和B。现在,A保存在缓冲的前面,而B保存在缓冲的后面,它们之间 有一块空地方。假设鼠标指针在A和B之间,现在鼠标指针往后面移动,到达了一个新位置,这个新位置把B划分为C和D两部分,那么,gap buffer规则将会把C拷贝到A的后面,组成一个新的A。这样,现在缓冲的前面放的是新的A,后面放的是D,而它们之间是一块gap buffer区域(空白)。
虽然gap buffer对于快速插入小量数据十分有效,但当插入一块大数据的时候,gap buffer不得不拷贝大量的数据到新的缓冲去,这是很费时的操作。作为一种变形,可以把多个buffer串接起来(linked list),利用分治方式加快操作。