算法导论学习(三)——数据结构

数据结构中字典(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) //修正二叉树使它符合红黑树的性质
时间: 2024-11-08 18:39:27

算法导论学习(三)——数据结构的相关文章

算法导论 学习资源

学习的过程会遇到些问题,发现了一些比较好的资源,每章都会看下别人写的总结,自己太懒了,先记录下别人写的吧,呵呵. 1  Tanky Woo的,每次差不多都看他的 <算法导论>学习总结 - 1.前言 <算法导论>学习总结 - 2.第一章 && 第二章 && 第三章 <算法导论>学习总结 - 3.第四章 && 第五章 <算法导论>学习总结 - 4.第六章(1) 堆排序 <算法导论>学习总结 - 5.第六

算法导论学习---红黑树具体解释之插入(C语言实现)

前面我们学习二叉搜索树的时候发如今一些情况下其高度不是非常均匀,甚至有时候会退化成一条长链,所以我们引用一些"平衡"的二叉搜索树.红黑树就是一种"平衡"的二叉搜索树,它通过在每一个结点附加颜色位和路径上的一些约束条件能够保证在最坏的情况下基本动态集合操作的时间复杂度为O(nlgn).以下会总结红黑树的性质,然后分析红黑树的插入操作,并给出一份完整代码. 先给出红黑树的结点定义: #define RED 1 #define BLACK 0 ///红黑树结点定义,与普通

算法导论学习---红黑树详解之插入(C语言实现)

前面我们学习二叉搜索树的时候发现在一些情况下其高度不是很均匀,甚至有时候会退化成一条长链,所以我们引用一些"平衡"的二叉搜索树.红黑树就是一种"平衡"的二叉搜索树,它通过在每个结点附加颜色位和路径上的一些约束条件可以保证在最坏的情况下基本动态集合操作的时间复杂度为O(nlgn).下面会总结红黑树的性质,然后分析红黑树的插入操作,并给出一份完整代码. 先给出红黑树的结点定义: #define RED 1 #define BLACK 0 ///红黑树结点定义,与普通的二

算法导论 第三版 中文版

下载地址:网盘下载 算法导论 第三版 中文版 清晰 PDF,全书共8部分35章节,内容涵盖基础知识.排序和顺序统计量.数据结构.高级设计和分析技术.高级数据结构.图算法.算法问题选编.以及数学基础知识.非常实用的参考书和工程实践手册.此外,其他资源也已经上传,全部免费,欢迎大家下载! 第3版的主要变化 1.新增了van Emde Boas树和多线程算法,并且将矩阵基础移至附录. 2.修订了递归式(现在称为"分治策略")那一章的内容,更广泛地覆盖分治法. 3.移除两章很少讲授的内容:二项

【算法导论学习-015】数组中选择第i小元素(Selection in expected linear time)

1.算法思想 问题描述:从数组array中找出第i小的元素(要求array中没有重复元素的情况),这是个经典的"线性时间选择(Selection in expected linear time)"问题. 思路:算法导论215页9.2 Selection in expect linear time 2.java实现 思路:算法导论216页伪代码 /*期望为线性时间的选择算法,输入要求,array中没有重复的元素*/ public static int randomizedSelect(i

【算法导论学习-014】计数排序(CountingSortTest)

参考:<算法导论>P194页 8.2节 Counting sort 1.Counting sort的条件 待排序数全部分布在0~k之间,且k是已知数:或者分布在min~max之间,等价于分布在0~max-min之间,max和min是已知数. 2.java 实现 /** * 创建时间:2014年8月17日 下午3:22:14 项目名称:Test * * @author Cao Yanfeng * @since JDK 1.6.0_21 类说明: 计数排序法,复杂度O(n), 条件:所有数分布在0

【算法导论学习-015】基数排序(Radix sort)

1.<算法导论>P197页 8.3节Radix sort 2.java实现 这里仅仅对[算法导论学习-014]计数排序 的参数进行了修改,同时仅仅修改了一行代码. /** * 创建时间:2014年8月17日 下午4:05:48 * 项目名称:Test * @author Cao Yanfeng * @since JDK 1.6.0_21 * 类说明: 利用计数排序实现基数排序 * 条件:待排序的所有数位数相同,注意,即便不相同,也可以认为是最多那个位数,如下面的例子可以认为都是3位数 */ p

【算法导论学习-016】两个已排过序的等长数组的中位数(median of two sorted arrays)

问题来源 <算法导论>P223 9.3-8: Let X[1..n] and Y[1..n] be two arrays, each containing nnumbers already in sorted order. Give an O(lgn)-time algorithm to find themedian of all 2n elements in arrays X and Y. 翻译过来即:求两个等长(n个元素)的已排序数组A和B的中位数 方案1:对两个数组进行归并直到统计到第n

【算法导论学习-012】n个数随机等概率的抽样m个

算法法导论>P129页课后题5.3-7 suppose we want to create a random sample of the set {1,2,3,-,n}, thatis, an m-element subset S, where0≤m≤n, such that each m-subset is equally likely to be created. One waywould be to set A[i]=i for i=1,2,3,-,n, call RANDOMIZE-IN

【算法导论学习笔记】第3章:函数的增长

????原创博客,转载请注明:http://www.cnblogs.com/wuwenyan/ ? 当算法的输入n非常大的时候,对于算法复杂度的分析就显得尤为重要,虽然有时我们能通过一定的方法得到较为精确的运行时间,但是很多时候,或者说绝大多数时候,我们并不值得去花精力求得多余的精度,因为精确运行时间中的倍增常量和低阶项已经被输入规模本身的影响所支配.我们需要关心的是输入规模无限增加,在极限中,运行时间是如何随着输入规模增大而增加的,通常来说,在极限情况下渐进地更优的算法在除很小的输入外的所有情