数据结构中字典(dictionary)的概念:支持在一个集合中插入和删除元素以及测试元素是否属于集合的操作的动态集合被称为字典。
动态集合假定对象中的一个属性被标识为关键字(key),对象可能包含卫星数据,它们与其他对象属性一起移动。
一、基本数据结构
1 栈和队列
栈(stack):后进先出(LIFO)
队列(queue):先进先出(FIFO)
栈顶指向最近被推入栈的元素的位置。
栈判空(时间复杂度O(1)):
STACK-EMPTY(S)
if S.top==0
return TRUE
else return FALSE
入栈(时间复杂度O(1),不考虑上溢):
PUSH(S,x)
S.top=S.top+1
S[S.top]=x
出栈(时间复杂度O(1)):
POP(S)
if STACK-EMPTY(S)
error "underflow" //下溢
else S.top=S.top-1
return S[S.top+1]
队列的队头指向队列的第一个元素,队列的队尾指向下一个元素存放的位置。
入队(时间复杂度O(1),省略判断上溢,如果要判断上溢,应在入队之前,判断队尾的下一个位置是否与队头重合):
ENQUEUE(Q,x)
Q[Q.tail]=x
if Q.tail==Q.length
Q.tail=1
else Q.tail=Q.tail+1
出队(时间复杂度O(1),省略判断下溢,如果要判断下溢,应在出队前,判断当前队列是否为空):
DEQUEUE(Q)
x=Q[Q.head]
if Q.head=Q.length
Q.head=1
else Q.head=Q.head+1
return x
2 链表
双向链表(doubly linked list):每个对象有关键字key,两个指针(next,prev),卫星数据。
单链表(single linked list):每个对象有关键字key,一个指针(next),卫星数据。
循环链表(circular list):本文讨论双向循环链表,表头的prev指向表尾,表尾的next指向表头。
链表的搜索(时间复杂度O(n)):
LIST-SEARCH(L,k)
x=L.head
while x!=NIL and x.key!=k
x=x.next
return x
链表的插入(时间复杂度O(1),双向链表,从表头插入):
LIST-INSERT(L,x)
x.next=L.head
if L.head!=NIL
L.head.prev=x
L.head=x
x.prev=NIL
链表的删除(时间复杂度O(1),双向链表,输入的是要删除的元素的位置,如果输入的是要删除的元素,要先搜索找到位置,时间复杂度变为O(n))
LIST-DELETE(L,x)
if x.prev!=NIL
x.prev.next=x.next
else L.head=x.next
if x.next!=NIL
x.next.prev=x.prev
链表的删除(忽视表头和表尾出删除的边界条件)
LIST-DELETE‘(L,x)
x.prev.next=x.next
x.next.prev=x.prev
利用设置哨兵(sentinel)的方式使得链表可以忽略边界条件。可以在链表L中设置一个对象L.nil,该对象代表NIL,但是也具有和其他对象相同的各个属性。
下图是带哨兵的双向循环链表:
链表的搜索:
LIST-SEARCH‘(L,k)
x=L.nil.next
while x!=L.nil and x.key!=k
x=x.next
return x
链表的插入:
LIST-INSERT‘(L,x)
x.next=L.nil.next
L.nil.next.prev=x
L.nil.next=x
x.prev=L.nil
哨兵的优点:不降低数据结构相关操作的渐进时间界,但是可以降低常数因子,使代码更简洁。
哨兵的缺点:针对多个很短的链表,哨兵要占用额外的存储空间,造成严重的资源浪费。
3 指针和对象的实现
对象的多数组表示:同一属性存放在同一数组
对象的单数组表示:通过加偏移量的方式找到属性
它允许不同长度的对象存储在同一数组中,但是管理一组异构的对象比管理一组同构的对象更困难。
对象的分配与释放:
某些系统有垃圾收集器(garbage collector)的功能自动处理。
人为地,可以用自由表(free list)存放自由对象。可以让多个对象共用一个自由表。
(a为原表,b为向自由表请求空间,c为自由表收集释放的空间)
向自由表请求空间(时间复杂度O(1))
ALLOCATE-OBJECT()
if free==NIL //free指向自由表的第一个元素
error "out of space" //自由表中没有空间可供释放
else x=free //x指向自由表释放的空间
free=x.next
return x
自由表收集被释放的空间(时间复杂度O(1))
FREE-OBJECT(x) //本质上是向表头插入元素
x.next=free
free=x
4 有根树的表示
表示链表的方法可以推广到任意同构的数据结构上,比如,分支有限制的有根树都可以用相似的范式表示。特别的有:二叉树T:具有属性p,left,right,指向父节点,左子节点,右子节点。T.root指向根节点。
该方法不适用的场景:
①分支无限制的有根树无法用上述方法表示。
②如果树的孩子树限制在比较大的常数内,但是多数节点只有少量的孩子,会浪费大量存储资源。
解决问题的方式:左孩子右兄弟表示法,对任意n个节点的有根树,只需要O(n)的存储空间。每个节点中包含:
①x.leftchild:指向当前节点的孩子节点中最左的那一个
②x.rightsibling:指向当前节点的兄弟节点中最右的那一个
二、散列表
散列表(hash table)是实现字典操作的一种有效数据结构。最坏情况下,散列表查找元素的时间与链表相同,为O(n)。但是如果在合理的假设下,散列表中查找一个元素的平均时间是O(1)。
1 直接寻址表
适用场景:关键字的全域U比较小时。
缺点:如果全域很大,不可能存储一张完整的直接寻址表。实际存储的关键字集合K相对U来说较小时,会造成空间浪费。
表中的每个位置成为槽(slot)。
直接寻址表中存放的是指向元素的指针,表中某个位置没有对应的元素就存放NIL。(有些情况下也可以直接把元素存在表中而不是存在外部。)
查找直接寻址表(时间复杂度O(1))
DIRECT-ADDRESS-SEARCH(T,k)
return T[k]
插入直接寻址表(时间复杂度O(1))
DIRECT-ADDRESS-INSERT(T,x)
T[x.key]=x //x是一个指针,指向元素存放的位置
删除直接寻址表(时间复杂度O(1))
DIRECT-ADDRESS-DELETE(T,x)
T[x.key]=NIL
2 散列表
直接寻址表的最坏情况时间复杂度是O(1),散列表的平均时间复杂度是O(1)。
直接寻址表的空间复杂度是全域大小O(|U|),散列表的空间复杂度是实际关键字集合大小O(|K|)。
散列表利用散列函数(hash function)h计算槽的位置。若k为元素的关键字,那么h(k)就是k的散列值。两个关键字映射到同一个槽中,称为冲突(collision)。解决冲突的方法有链接法(chaining)和开放寻址法(open addressing)。
通过链接法解决冲突:
插入散列表(时间复杂度O(1),如果需要检查元素是否已经出现在表中,需要做搜索):
CHAINED-HASH-INSERT(T,x)
insert x at the head of list T[h(x.key)]
搜索散列表(平均时间复杂度O(1))
CHAINED-HASH-SEARCH(T,k)
search for an element with key k in list T[h(k)]
删除散列表中的元素(在单链表的情况下,删除和查找操作的渐进运行时间相同,平均时间复杂度为O(1),因为要先找到x的前驱节点。在双向链表的情况下,最坏时间复杂度为O(1))(其实单链表的删除也有比较简单的思路,剑指offer中介绍了这样一种方法:用x的后继覆盖x,然后将x的后继删除,这样就免去了查找x前驱的麻烦,但是如果要删除的x在链表的表尾,依然要查找它的前驱。)
CHAINED-HASH-DELETE(T,x)
delete x from the list T[h(x,key)]
给定一个能存放n个元素,具有m个槽位的散列表T,定义T的装载因子(load factor)为n/m,用a表示。散列表的平均性能依赖于所选取的散列函数h,将所有关键字集合分布在m个槽位上的均匀程度。
在简单均匀的假设下,对于用链接法解决冲突的散列表,一次不成功查找的平均时间为O(1+a),一次成功查找的时间为O(1+a)。
3 散列函数
一个好的散列函数应(近似地)满足简单均匀散列假设:每个关键字都被等可能地散列到m个槽位中的任意一个。
① 除法散列法:h(k)=k%m
注意事项:要避免选择m的某些值,比如m不应为2的幂,m不应为2^p-1。一个不太接近2的整数幂的素数是m的一个较好的选择。(原因移步http://blog.csdn.net/makenothing/article/details/40863365)
② 乘法散列法:h(k)=floor(m(kA%1))
第一步:用关键字k乘以常数A(A在0到1之间),提取kA的小数部分;
第二步:用m乘以这个值,再向下取整。
优点:对m的选择不是特别关键,一般选择它为2的某个幂次。
对任何A值都适用,但是对某些值效果更好,最佳的选择与待散列的数据特征有关。(sqrt(5)-1)/2是一个不错的选择。
③ 全域散列法:随机选择散列函数,使之独立于要存储的关键字(但是不以为着对每一个元素就有一个不同的散列函数,一旦开始散列这个函数就被确定下来了,只不过最开始选取的时候是随机的)。
4 开放寻址法
在开放寻址法中,所有元素都在散列表里,不使用链表。散列表可能会被填满,以至于不能插入任何新元素,导致的结果是装载因子a不会超过1.
优点:不用存储指针节省的空间可以用来提供更多的槽,潜在地减少了冲突,提高检索速度。
为了使用开放寻址法插入一个元素,需要连续地检查散列表,称为探查(probe)。
用开放寻址法插入:
HASH-INSERT(T,k)
i=0
repeat
j=h(k,i)
if T[j]==NIL
T[j]=k
return j //返回新插入的元素在表中的位置
else i=i+1
until i==m //表中已经没有空间存放新元素
error "hash table overflow"
用开放寻址法查找:
HASH-SEARCH(T,k)
i=0
repeat
j=h(k,i)
if T[j]==k
return j
i=i+1
until T[j]==NIL or i==m
//查找过程中碰到一个空槽时,查找就(非成功地)停止
return NIL
从开放寻址法的散列表中删除操作元素时,用一个特定的值DELETED代替NIL来标记该槽,避免查找时无法检索后序的位置。DELETED在插入时看作空槽,在查找时不看做空槽。
线性探查(linear probing):h(k,i)=(h’(k)+i)%m
缺点:可能造成一次群集(primary clustering),随着被占用的槽不断增加,平均查找时间也不断增加。
二次探查(quadratic probing):h(k,i)=(h’(k)+c1*i+c2*i^2)%m
缺点:导致轻度的群集,二次群集(secondary clustering)。
双重散列(double hashing):h(k,i)=(h1(k)+i*h2(k))%m
要求:值h2(k)必须与表的大小m互质。可以采取的策略是:m取2的幂,h2总是产生奇数。也可以让m是质数,h2返回比m小的正整数。
※给定一个装在因子为a的开放寻址散列表,均匀散列,对于一次不成功的查找,期望的查找次数至多为1/(1-a),对于一次成功的查找,期望的查找次数至多为(1/a)*ln(1/(1-a)),向表中插入一个元素至多要做1/(1-a)次探查。
完全散列(perfect hashing):最坏情况下的时间复杂度仍为O(1)。可以用**二次散列表**Sj以及相关的散列函数hj实现。利用精心选择的散列函数hj,可以确保在第二级上不出现冲突。
三、二叉搜索树
二叉搜索树上的基本操作花费的时间和树的高度成正比,最坏运行时间为O(lgn)。
1 什么是二叉搜索树
二叉搜索树中关键字的存储方式:设x是二叉搜索树的一个节点,y是x左子树的一个节点,那么y.key<=x.key。如果y是x右子树的一个节点,那么y.key>=x.key。
遍历方式:先序遍历(preorder tree walk),中序遍历(inorder tree walk),后序遍历(postorder tree walk)。
中序遍历:
INORDER-TREE-WALK(x)
if x!=NIL
INORDER-TREE-WALK(x.left)
print x.key
INORDER-TREE-WALK(x.right)
关于遍历的更多资源:http://blog.csdn.net/sallyxyl1993/article/details/56025929
2 查询二叉搜索树
在二叉搜索树上查找给定关键字的节点(时间复杂度O(h))
递归版本:
TREE-SEARCH(x,k) //k为关键字,x为节点,一开始x为根节点
if x==NIL or k==x.key
return x
if k<x.key
return TREE-SEARCH(x.left,k)
else return TREE-SEARCH(x.right,k)
迭代版本(更加高效):
ITERATIVE-TREE-SEARCH(x,k)
while x!=NIL and k!=x.key
if k<x.key
x=x.left
else x=x.right
return x
查找最小关键字的元素(时间复杂度为O(h),相当于找到最左下的节点):
TREE-MINIMIUM(x)
while x.left!=NIL
x=x.left
return x
查找最大关键字的元素(时间复杂度为O(h),相当于找到最右下的节点):
TREE-MAXIMUM(x)
while x.right!=NIL
x=x.right
return x
定义前驱为比当前节点小的节点中最大的那个,定义后继为比当前节点大的节点中最小的那个。
找后继(时间复杂度为O(H))(这本质上是从当前节点开始继续进行中序遍历找到的下一个节点)(前驱的代码和后继的是对称的,把right换成left,MINIMUM换成MAXIMUM就行了)
TREE-SUCCESSOR(x)
if x.right!=NIL //如果有右子树,那就是找右子树中最小的节点
return TREE-MINIMUM(x.right)
y=x.p //如果没有右子树
while y!=NIL and x==y.right
x=y
y=y.p
return y
比如13的后继是15.
3 插入和删除
插入(时间复杂度为O(h))
TREE-INSERT(T,z)
y=NIL //y存放父节点
x=T.root
while x!=NIL //如果树非空
y=x
if z.key<x.key
x=x.left
else x=x.right
z.p=y
if y==NIL //如果是空树
T.root=z
elseif z.key<y.key //更新父节点的状态
y.left=z
else y.right=z
在二叉搜索树中删除节点要分三种情况讨论:
① 当前节点没有孩子节点:直接删除(下图a);
② 当前节点有一个孩子:让孩子替代当前节点(下图b);
③ 当前节点有两个孩子:找当前节点的后继(由于当前节点的右子树一定存在,也就是找右子树上最小的节点)替代当前节点(下图c和d)
为了方便删除,需要子函数实现功能:在二叉树内移动子树,它用另一棵子树替换一棵子树并成为双亲的孩子节点。
TRANSPLANT(T,u,v) //用以v为根的子树替换一棵以u为根的子树
if u.p==NIL //如果u是根节点
T.root=v
elseif u==u.p.left //如果u是它父节点的左孩子
u.p.left=v
else u.p.right=v //如果u是它父节点的右孩子
if v!=NIL //如果v非空,要同步更新v状态
v.p=u.p
删除节点(时间复杂度为O(h))
TREE-DELETE(T,z)
if z.left==NIL //情况①和②
TRANSPLANT(T,z,z.right)
elseif z.right=NIL //情况②
TRANSPLANT(T,z,z.left)
else y=TREE-MINIMUM(z.right) //情况③,上图d的前半
if y.p!=z //如果当前节点的右孩子有左孩子
TRANSPLANT(T,y,y.right)
y.right=z.right
y.right.p=y
TRANSPLANT(T,z,y) //上图d的后半和上图c
y.left=z.left
y.left.p=y
4 随机构建二叉树(主要介绍几个概念)
一棵n个不同关键字的随机构建二叉树的期望高度为O(lgn)。
当构造一棵有n个关键字的二叉搜索树时,选择一个关键字作为树根,并设Rn为一个随机变量,表示这个关键字在n个关键字集合中的秩(rank)。
四、红黑树
红黑树(red-black tree)是许多“平衡”搜索树中的,可以保证在最坏情况下基本动态集合操作的时间复杂度为O(lgn)。
1 红黑树的性质
红黑树的每个节点加了一个存储为表示颜色。红黑树确保没有一条路径会比其他路径长2倍,因而是近似于平衡的。
树中每个节点包含五个属性:color,key,left,right,p
一棵红黑树是满足下列红黑性质的二叉搜索树:
① 每个节点是红色的或是黑色的;
② 根节点是黑色的;
③ 每个叶节点(NIL)是黑色的;
④ 如果一个节点是红色的,那它的两个子节点都是黑色的;
⑤ 对每个节点,从该节点到所有后代叶节点的简单路径上,均包含相同数目的黑色节点。
为处理边界条件,定义哨兵T.nil来代表所有的NIL。
从某个节点x出发(不含该节点)到达一个叶节点的任意一条简单路径上的黑色节点个数称为该节点的黑高(black-height),记为bh(x)。根据性质⑤,定义红黑树的黑高为根节点的黑高。
一棵有n个内部节点的红黑树的高度至多为2lg(n+1)。
2 旋转(左旋和右旋)
左旋(时间复杂度O(1)):
LEFT-ROTATE(T,x)
//代码中主要需要注意的是父节点的状态更新
//因为需要更新父节点的状态,所以需要判空,需要判断当前节点是左孩子还是右孩子
y=x.right
x.right=y.left
if y.left!=T.nil
y.left.p=x
y.p=x.p
if x.p==T.nil
T.root=y
elseif x==x.p.left
x.p.left=y
else x.p.right=y
y.left=x
x.p=y
右旋(时间复杂度O(1)):
RIGHT-ROTATE(T,x)
y=x.p
y.left=x.right
if x.right!=T.nil
x.right.p=y
x.p=y.p
if y.p==T.nil
T.root=x
elseif y==y.p.left
y.p.left=x
else y.p.right=x
x.right=y
y.p=x
3 插入
在红黑树中插入节点的思路是,先把红黑树当成一棵普通的二叉树插入节点,而后再通过不断旋转使得新二叉树满足红黑树的性质。红黑树的插入和二叉树的插入有些微不同,在下面的代码中得以体现。
红黑树的插入(时间复杂度为O(lgn)
RB-INSERT(T,z) //向红黑树T中插入节点z
y=T.nil //记录父节点
x=T.root //从根节点开始遍历
while x!=T.nil //二叉树中的空节点NIL都用哨兵替换
y=x
if z.key<x.key
x=x.left
else x=x.right
z.p=y //更改父节点的属性
if y==T.nil
T.root=z
elseif z.key<y.key
z.left=T.nil
z.right=T.nil
z.color=RED //插入z节点时初始的颜色为红色
RB-INSERT-FIXUP(T,z) //修正二叉树使它符合红黑树的性质