【算法导论】学习笔记——第10章 基本数据结构
基本数据结构主要包括:栈、队列、链表和有根树。
10.1 栈和队列
栈和队列都是动态集合,且在其上进行DELETE操作所移除的元素时预先设定的。在栈中,被删除的是最近插入的元素:栈实现的是一种后进先出(LIFO)策略。队列实现的是一种先进先出(FIFO)策略。
栈
栈上的INSERT操作称为压入(PUSH),无参数的DELETE操作称为弹出(POP)。栈操作的代码非常简单:
1 typedef struct { 2 int A[MAXN]; 3 int top; 4 } Stack_st; 5 6 int StackIsEmpty(Stack_st *S) { 7 if (S->top == 0) 8 return 1; 9 return 0; 10 } 11 12 void Push(Stack_st *S, int x) { 13 if (S->top+1 < MAXN) { 14 S->top = S->top + 1; 15 S->A[S->top] = x; 16 } 17 } 18 19 int Pop(Stack_st *S) { 20 if ( StackIsEmpty(S) ) { 21 perror("underflow\n"); 22 return -1; 23 } else { 24 --S->top; 25 return S->A[S->top+1]; 26 } 27 }
三种栈操作的执行时间都是O(1)。
队列
队列上的INSERT操作称为入队(ENQUEUE),DELETE操作称为出队(DEQUEUE)。队列操作的代码也非常简单:
1 typedef struct { 2 int A[MAXN]; 3 int head, tail; 4 int length; 5 } Queue_st; 6 7 int QueueIsEmpty(Queue_st *Q) { 8 if (Q->head == Q->tail) 9 return 1; 10 return 0; 11 } 12 13 int QueueIsFull(Queue_st *Q) { 14 if ((Q->tail+1)%Q->length == Q->head) 15 return 1; 16 return 0; 17 } 18 19 void Enqueue(Queue_st *Q, int x) { 20 if (QueueIsFull(Q)) { 21 perror("overflow\n"); 22 return ; 23 } 24 Q->A[Q->tail] = x; 25 if (Q->tail == Q->length) 26 Q->tail = 1; 27 else 28 ++Q->tail; 29 } 30 31 int Dequeue(Queue_st *Q) { 32 int x; 33 34 if (QueueIsEmpty(Q)) { 35 perror("underflow\n"); 36 return -1; 37 } else { 38 x = Q->A[Q->head]; 39 if (Q->head == Q->length) 40 Q->head = 1; 41 else 42 ++Q->head; 43 return x; 44 } 45 }
10.2 链表
链表(linked list)是一种这样的数据结构,其中的各对象按线性顺序排列。与数组不同的是,链表的顺序是由各个对象里的指针决定的。链表主要包括搜索、插入和删除操作。双向链表的操作代码如下。
1 typedef struct Node { 2 int key; 3 struct Node *pre, *next; 4 } Node; 5 6 typedef struct { 7 Node *head; 8 } List; 9 10 void List_Init(List *L) { 11 L->head = NULL; 12 } 13 14 Node *List_Search(List L, int k) { 15 Node *p = L.head; 16 while (p!=NULL && p->key!=k) 17 p = p->next; 18 return p; 19 } 20 21 void List_Insert(List *L, Node *x) { 22 x->next = L->head; 23 if (L->head != NULL) 24 L->head->pre = x; 25 L->head = x; 26 x->pre = NULL; 27 } 28 29 void List_Delete(List *L, Node *x) { 30 if (x->pre != NULL) 31 x->pre->next = x->next; 32 else 33 L->head = x->next; 34 if (x->next != NULL) 35 x->next->pre = x->pre; 36 free(x); 37 }
哨兵(sentinel)是一个哑对象,其作用是简化边界条件的处理。使用哨兵后,相关操作如下所示。
1 typedef struct Node { 2 int key; 3 struct Node *pre, *next; 4 } Node; 5 6 typedef struct { 7 Node *nil; 8 } LList; 9 10 void LList_Init(LList *L) { 11 L->nil = (Node *)malloc(sizeof(Node)); 12 L->nil->next = L->nil; 13 L->nil->pre = L->nil; 14 } 15 16 Node *LList_Search(LList L, int k) { 17 Node *p = L.nil->next; 18 L.nil->key = k; 19 while (p->key!=k) 20 p = p->next; 21 return p; 22 } 23 24 void LList_Insert(LList *L, Node *x) { 25 x->next = L->nil->next; 26 L->nil->next->pre = x; 27 L->nil->next = x; 28 x->pre = L->nil; 29 } 30 31 void LList_Delete(LList *L, Node *x) { 32 x->pre->next = x->next; 33 x->next->pre = x->pre; 34 free(x); 35 }
哨兵基本不能降低数据结构相关操作的渐近时间界,但可以降低常数因子。这哨兵其实就是《数据结构》里的头结点。
10.2-4 LIST_SEARCH'过程中的每一次循环迭代都需要两个测试,一是检查x!=L.nil,另一个是检查x.key!=k试说明如何在每次迭代中省略对x!=L.nil的检查。
解:x!=L.nil主要是为了防止循环无限查找,为了省略x!=L.nil的检查,即需要在检查到尾结点可退出循环,即尾结点的下一个结点需要满足x.key==k,则进行搜索过程后,将哨兵的key值赋为k即可,代码实现如下:
1 Node *LList_Search(LList L, int k) { 2 Node *p = L.nil->next; 3 L.nil->key = k; 4 while (p->key!=k) 5 p = p->next; 6 return p; 7 }
10.2-6 选用合适的数据结构,支持O(1)时间的UNION操作。
解:双向循环链表(其实带不带哨兵均可,以带哨兵为例)。没什么技巧,就是把头尾结点该链的链好。
1 void LList_Union(LList *L, LList *L1, LList *L2) { 2 L->nil->pre = L2->nil->pre; 3 L->nil->next = L1->nil->next; 4 L1->nil->pre->next = L2->nil->next; 5 L2->nil->next->pre = L1->nil->pre; 6 L2->nil->pre->next = L->nil; 7 L1->nil->next->pre = L->nil; 8 }
10.2-7 给出一个theta(n)时间的非递归过程,实现对一个含n个元素的逆转。要求除存储链表本身所需的空间外,该过程只能使用固定大小的存储空间。
解:还是比较简单的,解释改变单链表结点的连接关系。代码如下:
1 void List_Reverse(List *L) { 2 Node *cur = L->head; 3 Node *pre = NULL; 4 Node *tmp; 5 6 while (cur != NULL) { 7 tmp = cur->next; 8 cur->next = pre; 9 pre = cur; 10 cur = tmp; 11 } 12 L->head = pre; 13 }
10.2-8 说明如何在每个元素仅使用一个指针x.np(而不是普通的next和prev)的情况下实现双链表。假设所有指针的值都可视为k位的整型数,且定义x.np = x.next XOR x.prev,说明表头所需信息,并说明如何在该表上实现SEARCH、INSERT和DELETE操作,以及如何在O(1)时间内实现该表的逆转。
解:这题难度一般,主要利用异或的性质:A XOR (A XOR B) = B。表头需要两个指针,指向元素头以及元素尾。代码实现如下:
1 typedef struct Node { 2 int key; 3 struct Node *np; 4 } Node; 5 6 typedef struct { 7 Node *head; 8 Node *tail; 9 } List; 10 11 void List_Init(List *L) { 12 L->head = L->tail = NULL; 13 } 14 15 void List_Insert(List *L, Node *x) { 16 if (L->head != NULL) 17 L->head->np = (Node *)((int)L->head->np ^ (int)x); 18 x->np = L->head; 19 L->head = x; 20 if (L->tail == NULL) 21 L->tail = x; 22 } 23 24 Node *List_Search(List L, int k) { 25 Node *cur = L.head; 26 Node *pre = NULL; 27 Node *tmp; 28 29 while (cur!=NULL && cur->key!=k) { 30 tmp = cur; 31 cur = (Node *)((int)pre ^ (int)cur->np); 32 pre = tmp; 33 } 34 return cur; 35 } 36 37 void List_Delete(List *L, Node *x) { 38 Node *cur = L->head; 39 Node *pre = NULL; 40 Node *tmp, *next; 41 42 while (cur != x) { 43 tmp = cur; 44 cur = (Node *)((int)pre ^ (int)cur->np); 45 pre = tmp; 46 } 47 next = (Node *)((int)pre ^ (int)cur->np); 48 pre->np = (Node *)((int)pre->np ^ (int)cur ^ (int)next); 49 next->np = (Node *)((int)next->np ^ (int)cur ^ (int)pre); 50 free(x); 51 } 52 53 void List_Reverse(List *L) { 54 Node *tmp = L->head; 55 L->head = L->tail; 56 L->tail = tmp; 57 }
10.3 指针和对象的实现
对象的多数组表示,核心思想就是使用多个数组存储下标索引,使用索引代替指针。对象的单数组表示,就是使用一个数组,key、next、prev的偏移量分别为0,1,2。使用数组实现指针,还需要有自由表(free list)的配合,通过,ALLOCATE-OBJECT()过程分配一个对象,通过FREE-OBJECT(x)过程释放对象。这两个过程的代码如下:
1 typedef struct { 2 int next[MAXN], key[MAXN], pre[MAXN]; 3 int free; 4 } AList; 5 6 int Allocate_Object(AList *A) { 7 int x; 8 if (A->free == NULL) { 9 perror("out of space"); 10 return -1; 11 } else { 12 x = A->free; 13 A->free = A->next[x]; 14 return x; 15 } 16 } 17 18 void Free_Object(AList *A, int x) { 19 A->next[x] = A->free; 20 A->free = x; 21 }
初始时自由表有全部n个对象,同一个自由表可以被多个链表共用。
10.3-2 对一组同构对象使用单数组表示法实现,写出ALLOCATE-OBJECT过程和FREE-OBJECT过程。
解:代码如下:
1 typedef struct { 2 int A[3*MAXN]; 3 int free; 4 } AList; 5 6 int Allocate_Object(AList *A) { 7 int x; 8 if (A->free == NULL) { 9 perror("out of space"); 10 return -1; 11 } else { 12 x = A->free; 13 A->free = A->next[x+1]; 14 return x; 15 } 16 } 17 18 void Free_Object(AList *A, int x) { 19 A->next[x+1] = A->free; 20 A->free = x; 21 }
10.3-4 双向链表在多数组表示中保持紧凑,实现ALLOCATE-OBJECT过程和FREE-OBJECT过程。
解:核心思想是,每当执行FREE-OBJECT(x)操作时,则用栈顶的key值替换x处的key值,然后弹栈,并把当前栈顶的next值设为null。每当执行ALLOCATE-OBJECT()操作时,把当前栈顶的next值设置为栈顶+1,然后压栈,返回当前栈顶值。
代码实现如下:
1 typedef struct { 2 int key, next, pre; 3 } Node; 4 5 typedef struct { 6 Node A[MAXN]; 7 int top; 8 } Stack_st; 9 10 int Allocate_Object(Stack_st *S) { 11 int x; 12 if (S->top == MAXN - 1) { 13 perror("out of space"); 14 return -1; 15 } else { 16 S->A[S->top].next = S->top + 1; 17 ++S->top; 18 return S->top; 19 } 20 } 21 22 void Free_Object(Stack_st *S, int x) { 23 S->A[x].key = S->A[S->top].key; 24 --S->top; 25 S->A[S->top].next = NULL; 26 }
10.3-5 L是一个长度为n的双向链表,存储于长度为m的数组key、prev和next中。假设这些数组由维护双链自由表F的两个过程ALLOCATE-OBJECT()和FREE-OBJECT进行管理。又假设m个元素中,恰有n个元素在链表L上,m-n个在自由表上。给定链表L和自由表F,试写出COMPACITY-LIST(L, F),用来移动L中的元素使其占用数组中1,2...n的位置,调整自由表F以保持其正确性,并占用数组中n+1,n+2...m的位置。要求所写的过程运行时间应为theta(n),且只使用固定量的额外存储空间。请证明所写的过程是正确的。
解:这道题目其实很简单,但是我理解错题意了,题意是将已经在多数组中的L放置于1~n的位置,将n+1~m的位置空出来。重点是要维护好自由表F,其实也可采用如下的方法实现。
10.3-X 10.3-5开始想错题意,以为要将一个链表L以及已经在多数组中的双向链表F进行合并,其实这个操作的实现也挺麻烦。就留下来吧
解:首先,想到了第一种算法,伪代码如下:
1 COMPACITY-LIST(L, F) 2 p = L.head 3 q = F.head 4 pre = NULL 5 while F.free!=NULL 6 x = ALLOCATE-OBJECT() 7 if x<=n 8 cur = x 9 else 10 while q>n 11 q = F.next[q] 12 F.key[x] = F.key[q] 13 F.next[x] = F.next[q] 14 F.prev[x] = F.prev[q] 15 F.prev[F.next[q]] = x 16 F.next[F.prev[q]] = x 17 cur = q 18 q = F.next[q] 19 F.key[cur] = p.key 20 F.prev[cur] = pre 21 if pre != NULL 22 F.next[pre] = cur 23 pre = cur 24 F.next[cur] = NULL
先分析一下核心思想,每次分配一个空间x,如果该索引大于n,显然应该存放自由表中的元素,因此从自由表中不断搜索,直到找到一个q(q>n),然后将索引q的key、next、prev替换索引x的key、next、prev,注意,同时需要更新q.next.prev以及q.prev.next的值(均指向x),从而保证F的连同性不被破坏。然后该索引结点即可以使用。因此,每次while循环内都可以找到一个空闲结点存放链表L的数据。
这应该是一个很普通的方法:最坏情况下,内层的while采用均摊分析,共需循环m次,外层循环n次,因此每次均摊m/n次,故T(n)<n*(c2*m/n+c1) f(n) < (c2*m + c1*n),因为m大于n,因此无法保证O(n)复杂度。显然,这种均摊不符合需要,这也是显然的,因为对m-n数组均摊,则很难保证theta(n)的复杂度。因此,必须想办法对n进行均摊,n可以表示空闲的索引数目以及链表L的结点数目。假定,我们把n个空闲结点分配给链表L后,还剩余x个结点(均大于n),这些结点应该分配给自由表,因此,我们只要再遍历一次前n个结点能够找到那些属于F的结点既可以保证theta(n)的复杂度。又因为仅可以使用固定量的额外存储空间,因此O(n)的visit标记不适用。
想到这里,解决方法是使用prev属性,对于前n个元素,那x个结点的prev属性均为有效值;因此,每当ALLOCATE-OBJECT分配的可行结点,将它们的prev属性修改为无效值,如-1、m+1等,再次遍历时即可以区分是属于链表L的还是索引结点F的。当然,还需要注意的是,不要修改它们的next属性,因为调用ALLOCATE-OBJECT后,我们仍然可以根据这n个结点的next属性找到原有的free结点,而这次则将大于n的与那些不属于L的进行替换。最后,更新前n个元素的prev属性和next属性。伪代码如下:
1 COMPACITY-LIST(L, F) 2 p = L.head 3 q = F.free 4 while F.free!=NULL 5 x = ALLOCATE-OBJECT() 6 if x<=n 7 F.key[x] = p.key 8 F.prev[x] = -1 // invalid index means belong to L 9 p = p.next 10 for i=1 to n 11 if F.prev[i] != -1 12 while q<=n 13 q = F.next[q] 14 if (F.head == q) 15 F.head = i 16 F.key[q] = F.key[i] 17 F.prev[q] = F.prev[i] 18 F.next[q] = F.next[i] 19 F.next[F.prev[i]] = q 20 F.prev[F.next[i]] = q 21 q = F.next[q] 22 F.key[i] = p.key 23 p = p.next 24 for i=1 to n 25 F.next[i] = i+1 26 F.prev[i] = i-1 27 F.next[n] = NULL 28 F.prev[1] = NULL
该算法同样采用均摊分析,4~9为theta(n),因为free结点数目为n;24~26同样为theta(n);10~23的for循环,即检查前n个结点中属于自由表的结点,并将它们与自由结点中(索引大于n)的进行交换,再将链表L中的数据写回。内层的while至多共计循环n次(可能加上q!=NULL条件更清晰),外层for循环n次,因此每次均摊1次。同样的时间为theta(n)。因此,该算法整体的复杂度可实现theta(n)。
10.4 有根树的表示
10.4-5 O(n)时间内,非递归输出树的键值,且不能对树做任何修改。
解:这道题目非常有趣,解法是使用标记表示访问的方向已达到回溯。以flg作为标记,flg=1表示从父亲到左儿子方向,flg=2表示从父亲到右儿子方向,flg=-1表示从左儿子返回父节点,flg=-2表示从右儿子返回父节点。同时需要比较当前结点是其父节点的左儿子,还是右二子。伪代码如下:
1 Print-Key(T) 2 cur = T 3 flg = 0 4 while cur!=NULL 5 if (flg >= 0) 6 print(cur.key) 7 if cur.left!=NULL && flg>=0 8 flg = 1 9 cur = cur.left 10 else if cur.right!=NULL && flg!=-2 11 flg = 2 12 cur = cur.right 13 else 14 if (cur!=T && cur = cur.root.left) 15 flg = -1 16 else 17 flg = -2 18 cur = cur.root
10.4-6 任意有根树的左孩子、右兄弟结构,每个结点只有两个指针与一个布尔值,如何在与孩子数成线性关系的时间内访问其父亲结点或所有的孩子结点。
解:一个指针存放兄弟结点,另一个指针存放左孩子或父结点(由布尔值控制,布尔为真表示为孩子点,否则为父结点)。若布尔值为假,表示无孩子,存放父节点;若布尔值为真,表示有孩子,可以通过该孩子结点的兄弟结点开始遍历所有孩子结点,最右的孩子的兄弟结点存放其父节点。那么何时停止循环,伪代码如下:
1 if r.boolean == FALSE 2 Father = r.lchild 3 else 4 q = r.lchild 5 while q.boolean==FALSE || q.lchild!=r6 q = q.rchild 7 Father = q
第5行的while循环条件为结点的布尔值为假或者它的左孩子不等于r。当该兄弟结点没有孩子时,即q.boolean==FALSE(此时它的左孩子存放的是r,与if类似),因此需要继续探索其兄弟结点;当该兄弟结点有孩子时,可能已经是父节点了,因此通过q.lchild!=x判断。