说明:该系列博客整理自《算法导论(原书第二版)》,但更偏重于实用,所以晦涩偏理论的内容未整理,请见谅。另外本人能力有限,如有问题,恳请指正!
基本数据结构包括:栈、队列、链表、有根树。
1、栈
栈和队列都是动态集合。栈实现了一种 后进先出(LIFO) 的策略,队列实现了一种 先进先出(FIFO) 的策略。栈操作的时间复杂度为O (1)。
作用于栈上的 INSERT 操作称为 压入 ( PUSH ),而无参的 DELETE 操作常称为 弹出 ( POP )。可以使用一个数组 S [ 1 .. n ]来实现一个至多有 n 个元素的栈。数组 S 有个属性 S.top ,它指向最近插入的元素。由S 实现的栈包含元素S[1...S.top],其中S[1]是栈底元素,S[S.top]是栈顶元素。当S.top=0时栈中不包含任何元素,此时栈为空栈。如果试图对一个空栈做弹出操作,则称栈“下溢”。如果S.top>n,则称栈“上溢”。
STACK-EMPTY(S)
1 if S.top == 0//注意S.top的值是从1开始的
2 return TRUE
3 else
4 return FALSE
PUSH(S, x)
1 S.top = S.top + 1
2 S[S.top] = x
POP(S)
1 if STACK-EMPTY(S)
2 error "underflow"
3 else
4 S.top = S.top - 1
5 return S[S.top + 1]
2、队列
栈和队列都是动态集合。栈实现了一种 后进先出(LIFO)的策略,队列实现了一种 先进先出(FIFO)的策略。队列操作的时间复杂度为O(1)。
我们把作用于队列上的 INSERT 操作称为 入队 ( ENQUEUE ),把作用于队列上的 DELETE 操作称为 出队 ( DEQUEUE )。队列有 头 和 尾 。当一个元素入队时,将排在队尾,而出队的元素总是队首元素。
用一个数组 Q [ 1 .. n ]来实现一个至多含 n - 1 个元素的队列。队列具有属性 Q.head ,它指向队列的头,另一个属性为 Q.tail ,它指向新元素将会被插入的地方。队列需要在最后一个位置进行“卷绕”,即队列中的n个元素形成环形,位置1接在位置n之后。当Q.head = Q.tail时队列为空,当Q.head = Q.tail+1时队列为满。下面列出队列的常用操作,但是省略了溢出检查部分。
ENQUEUE(Q, x)
1 Q[Q.tail] = x //Q.tail 指向新元素将会被插入的地方
2 if Q.tail == Q.length
3 Q.tail = 1
4 else
5 Q.tail = Q.tail + 1
DEQUEUE(Q)
1 x = Q[Q.head]
2 if Q.head == Q.length
3 Q.head = 1
4 else
5 Q.head = Q.head + 1
6 return x
3、无哨兵链表
在 链表 中,各对象按线性顺序排序。其顺序由各对象中的指针决定。本节介绍的是无序的双链表。 双链表 的每一个元素都是一个对象,每个对象包含一个关键字域和两个指针域: next 和 prev 。对链表中的某个元素 x , x.next 指向链表中 x 的后继元素,而 x.prev 则指向链表中 x 的前驱元素。如果x.prev=nil,则它是链表的第一个元素;如果x.next=nil,则它是链表的最后一个元素。L.head指向链表的第一个元素,如果L.head=nil,则链表为空。下面给出的是链表的基本操作。
查找操作的最坏时间复杂度为Θ(n),插入操作运行时间为O (1),删除操作运行时间为O(1)。
LIST-SEARCH(L, k)
1 x = L.head
2 while x != NIL and x.key != k
3 x = x.next
4 return x
LIST-INSERT(L, x)
1 x.next = L.head
2 if L.head != NIL
3 L.head.prev = x
4 L.head = x
5 x.prev = NIL
LIST-DELETE(L, x)
1 if x.prev != NIL
2 x.prev.next = x.next
3 else
4 L.head = x.next
5 if x.next != NIL
6 x.next.prev = x.prev
4、哨兵链表=对无哨兵链表的改进
哨兵可以简化边界条件。例如,假设有链表L和一个对象L.nil,后者表示NIL,但也包含和其他元素一样的各个域。将链表算法中出现的每次对NIL的引用,用哨兵元素L.nil的引用来代替,这样,就可以将一个一般的双向链表变成一个带哨兵的环形双向链表,哨兵元素L.nil介于头和尾之间。L.nil.next指向链表的头,L.nil.prev指向链表的尾,因为L.nil.next指向链表的头,所以去掉了L.head属性。一个空链表仅包含哨兵元素,此时L.nil.next=nil,且L.nil.prev=nil。
LIST-DELETE(L, x)
1 x.prev.next = x.next
2 x.next.prev = x.prev
LIST-SEARCH(L, k)
1 x = L.nil.next
2 while x != L.nil and x.key != k
3 x = x.next
4 return x
LIST-INSERT(L, x)
1 x.next = L.nil.next
2 L.nil.next.prev = x
3 L.nil.next = x
4 x.prev = L.nil
哨兵元素基本上不能降低施于有关数据结构上的各种操作的渐近时间界,但他们可以降低常数因子。在循环结构中使用哨兵的好处主要有两方面:一是使得代码更加简洁,但对于速度则没有什么帮助;二是可以使循环部分的代码更加紧凑,因而可以降低运行时间中项n或项n2的系数。
要注意不能不加区别的使用哨兵。如果有很多较短的链表,则使用哨兵后就会造成存储的浪费。在本书中,当哨兵真正能简化代码时,我们才加以采用。
5、有根树的表示
在这一节中,我们来讨论用链接数据结构表示有根树的问题。首先,要讨论二叉树,然后提出一种适用于结点子女数任意的有根树表示方法。
我们用一个对象来表示树的一个结点。对链表,假设每个结点都有一个关键字域,其余域包括指向指向其他结点的指针,而且要根据不同类型的树而发生变化。
1)、二叉树
如下图所示,用域 p , left , right 来存放指向二叉树 T 中的父亲,左儿子和右儿子的指针。如果 x.p = NIL ,则 x 为根。如果结点 x 无左儿子,则 x.left = NIL ,对右儿子也类似。整个树 T 的根由属性 T.root 指向。如果 T.root = NIL ,则树为空。
2)、分支数无限的有根树
上面二叉树的表示方法可以推广至每个结点的子女数至多为常数k的任意种类的树:用chaild1...childk来取代left和right域。如果树中结点的子女数是无限制的,那么这种方法就不适用了,因为我们无法事先知道有多少域需要分配。此外,即使结点子女数k以一个很大的常数为界,但多数结点只有少数子女,则会浪费大量的存储空间。
值得庆幸的是,可以用二叉树很方便地表示具有任意子女数的树。该方法的优点是对任意含 n 个结点的有根树仅用 O ( n )空间。这种 左孩子 , 右兄弟 的表示如下图所示。每个结点都包含一个父亲指针 p , T.root 指向树 T 的根。每个结点 x 不再包含指向每个孩子结点的指针,而仅包含两个指针:
a、x.left-child 指向结点 x 的最左孩子。
b、x.right-sibling 指向结点 x 紧右边的兄弟。
如果 x 没有孩子,则 x.left-child = NIL ;如果 x 是其父结点的最右孩子,则 x.right-sibling = NIL 。
3、树的其他表示
有时,可以用另外一些方法来表示有根树。例如,在第6章中,我们用了一个数组加上下标的形式来表示基于完全二叉树的堆。将在21章中出现的树只可由页向根的方向遍历,故只用到父指针,而没有指向子女的指针。另外,还有很多其他的方法,哪一种最好要视具体的应用而定。