1、抽样数据类型
程序设计的基本法则之一是例程不应该超过一页。这可以通过把程序分割为一些模块(module)来实现。每个模块是一个逻辑单元并执行某个特定的任务,它通过调用其他模块而本身保持很小。模块化有几个优点。首先,调试小程序比调试大程序容易得多。第二,多个人同时对一个模块式编程要更容易。第三,一个写的好的模块化程序把某些依赖关系值局限在一个例程中,这样使得修改起来会更容易。例如,需要以某种格式编写输出,那么重要的是让一个例程去实现它。如果打印语句分散在程序各处,那么修改所费的时间就会明显拖长。全局变量和副作用是有害的观念也正是出于模块化是有益的想法。
抽象数据类型(abstract data type,ADT)是一些操作的集合。抽象数据类型是数学的抽象;在ADT的定义中根本没有涉及如何实现操作的集合。这可以看作是模块化设计的扩充。
对诸如表、集合、图和它们的操作一起可以看作是抽象数据类型,就像整数、实数和布尔量是整数类型一样。整数、实数及布尔量有它们相关的操作,而抽样数据类型也有与它们自己相关的操作。对于集合ADT,我们可以有诸如(union)、交(intersection)、测定大小(size)以及取余(complement)等操作。或者,我们也可以只要两种操作:并和查找(find),这两种操作又在该集合上定义了一种不同的ADT。
我们基本的方法是,这些操作的实现只是在程序中编写一次,而程序中任何其他部分需要在该ADT上运行其中一种操作时可以通过调用适当的函数来进行。如果由于某些原因需要改变操作的细节,那么通过只修改运行这些ADT操作的例程应该更容易实现。在理想的情况下这种改变对于程序的其余部分通常是完全透明的。
对于每种ADT并不存在什么法则来告诉我们必须要有哪些操作。这是一种设计决策。错误处理和关系的重组(在适当的地方)一般也取决于程序设计者。
2.表ADT
将处理一般的形如A1,A2,A3,...,AN的表。我们说,这个表的大小是N。我们称大小为0的表为空表(empty list)。对于除空表外的任何表,我们说Ai+1后继Ai(或继Ai之后)并称Ai-1(i<N)前驱Ai(i>1)。表中的第一个元素时A1,而最后一个元素是AN。我们将不定义A1的前驱元,也不定义AN的后继元。元素Ai在表中的位置为i。为了简单起见,我们在讨论中将假设表中的元素是整数,但一般说来任意的复元素也是允许的。
与这些“定义”相关的是我们要在表ADT上进行的操作的集合。PrintList和MakeEmpty是常见的操作,其功能显而易见;Find返回关键字首次出现的位置;Insert和Delete一般是从表的某个位置插入和删除某个关键字;而FindKth则返回某个位置上(作为参数而被指定)的元素。如果34,12,52,16,12是一个表,则Find(52)会返回3;Insert(X,3)可能把表变成34,12,52,X,16,12(如果我们在给定位置的后面插入的话);而Delete(52)则将该表变为34,12,X,16,12。当然,一个函数的功能怎样才算恰当,完全要由程序设计员来确定,就像对特殊情况的处理那样(例如,上述Find(1)返回什么?)。我们还可以添加一些运算,比如Next和Previous,它们去一个位置作为参数并分别返回其后继元素和前驱元素的位置。
2.1 表的简单数组实现
对表的所有操作都可以通过使用数组来实现。虽然数组是动态指定的,但是还是需要对表的大小的最大值进行估计。通常需要估计得大一些,从而会浪费大量的空间。这是严重的局限,特别是在存在许多未知大小的表的情况下。
数组实现使得PrintList和Find正如所预期的那样以线性时间执行,而FindKth则花费常数时间。然而,插入和删除的花费是昂贵的。例如,在位置0的插入(这实际上是做一个新的第一元素)首先需要将整个数组后移一个位置以空出空间来,而删除第一个元素则需要将表中的所有元素前移一个位置,因此这两种操作的最坏情况为O(N)。平均来看,这两种运算都需要移动表的一般的元素,因此仍需要线性时间。只通过N次相继插入来建立一个表将需要二次时间。
因为插入和删除的运行时间是如此的慢以及表的大小还必须事先已知,所以简单数组一般不能用来实现表这种结构。
2.2 链表
为了避免插入和删除的线性开销,需要允许表可以不连续存储,否则表的部分或全部需要整体移动。下图给出了链表的一般想法。
链表由一系列不必在内存中相连的结构组成。每一个结构均含有表元素和指向包含该元素后继元的结构的指针。我们称之为Next指针。最后一个单元的Next指针指向NULL;该值由C定义并且与其他指针混淆。ANSI C规定NULL为零。
我们回忆一下,指针变量就是包含存储另外某个元数据的地址的变量。因此,如果P被声明为指向一个结构的指针,那么存储在P中的值就被解释为主存中的一个位置,在该位置能够找到一个结构。该结构的一个域可以通过P->FieldName访问,其中FieldName是我们想要考察的域的名字。下图表示表的具体表示。这个表含有五个结构,恰好在内存中分配给它们的位置分别是1000,800,712,992和692。第一个结构的指针含有值800,它提供了第二个结构所在的位置。其余每个结构也都有一个指针哟用于类似的目的。当然,为了访问该表,我们需要知道在哪里能够找到第一个单元。指针变量就能够用于这个目的。重要的是要记住,一个指针就是一个数。
为了执行PrintList(L)或Find(L, Key),我们只要将一个指针传递到该表的第一个元素,然后用一些Next指针穿越该表即可。这种操作显然是线性时间,虽然这个常数可能会比用数组实现时要大。FIndKth操作不如数组实现的效率高;FindKth(L,i)花费O(i)时间以显性方式穿越链表而完成。在实践中这个界是保守的,因为调用FindKth常常是以(按i)排序的方式进行。例如,FindKth(L,2),FindKth(L,3),FIndKth(L,4)以及FindKth(L,6)可以通过对表的一次扫描同时实现。
删除命令可以通过修改一个指针来实现,下图给出了在原表中删除第三个元素的结果。
插入命令需要使用一次malloc调用从系统得到一个新单元,并在此之后执行两次指针调整。其一般想法在下图中给出,虚线表示原来的指针。
2.3 程序设计细节
上面的描述实际上足以使每一部分都能正常工作,但还是有几处地方可能会出问题。首先,并不存在所给定义出发在表的前面插入元素的真正显性的方法。第二,从表的前面实行删除是一个特殊的情况,因为它改变了表的起始端;编程中的疏忽将造成表的丢失。第三个问题涉及一般的删除。虽然上述指针的移动很简单,但是删除算法要求我们记住被删除元素前面的表元。
事实上,少做一个简单的变化就能够解决所有这三个问题。我们将留出一个标志结点,有时候称之为表头(header)或哑结点(dummy node)。这是通常的一种习惯,再后面将会多从看到它。我们约定,表头在位置0处。下图给出了一个带有表头的链表,它表示A1,A2,...,A5。
为避免删除相关的一些问题,我们需要编写例程FindPrevious,它将返回我们要删除的表元的前驱元的位置。如果我们使用表头,那么当我们删除表的第一个元素时,FIndPrevious将返回表头的位置。头结点的使用多少是有些争议的一些人认为,添加假想的单元只是为了避免特殊情形,这样的理由不够充分;他们把头结点的使用看成与老式的随意删除没有多大区别。不过即使那样,我们还是在这里使用它,这完全因为它使我们能够表达基本的指针操作且又不致使特殊情形的代码含混不清。除此之外,要不要使用表头则是属于个人兴趣的问题。
作为例子,我们将把这些表ADT的半数例程编写出来。首先,在以下代码中给出我们要的声明。按照C的约定,作为类型的List(表)和Position(位置)以及函数的原型都列在所谓的.h头文件中。具体的Node(结点)声明则在.c文件中。
# ifndef _List_H
struct Node;
typedef struct Node *PtrToNode;
typedef PtrToNode List;
typedef PtrToNode Position;
List MakeEmpty( List L );
int IsEmpty( List L );
int IsLast( Position P, List L );
Position Find(ElementType X, List L);
void Insert(ElementType X, List L, Position P);
void DeleteList( List L );
Position Header( List L );
Position First( List L );
Position Advance( Position P );
ElememntType Retrieve( Position P );
# endif /* _List_H */
struct Node
{
ElementType Element;
Position Next;
};
一个空表如下图所示,
- 测试一个链表是否为空表
/* Return true if L is empty */
int
IsEmpty( List L )
{
return L->Next == NULL;
}
- 测试当前位置是否为链表的末尾函数
/* Return true if P is the last position in list L */
/* Parameter L is unused in this implementation */
int
IsLast( Position P, List L )
{
return P-> == NULL;
}
- 返回某个元素在表中的位置
/* Return Position of X in L; NULL if not Found */
Position
Find( ElementType X, List L )
{
Position P;
P = L->Next;
while(P != NULL && P->Element != X)
P = P->Next;
return P;
}
- 删除表L中的某个元素
如果X出现不止一次或者根本就没有,那么我们通过调用FindPrevious函数找出含有X的表示的前驱元素P。
/* Delete First occurance of X from a list */
/* Assume use of a header node */
void
Delete( ElementType X, List L )
{
Position P, TmpCell;
P = FindPrevious( X , L );
if( !IsLast( P , L ) ) /*Assumption of header use*/
{
TmpCell = P -> Next;
P->Next = TmpCell -> Next; /* Bypass deleted cell */
free(TmpCell);
}
}
/* If X is not found, then Next field of returned */
/* Position is NULL */
/* Assume a header */
Position
FindPrevious(ElementType X, List L)
{
Position P;
P = L;
while(P -> Next != NULL && P -> Next -> Element != X)
P = P -> Next;
retrun P;
}
将元素插入列表
/* Insert (after legal position P) */
/* Header implementation assumed */
/* Parameter L is unused in this implementation */
void
Insert( ElementType X, List L, Position P )
{
Position TmpCell;
TmpCell = malloc( sizeof( struct Node ) );
if( TmpCell == NULL )
FataError("Out of space!!!")
TmpCell->Element = X;
TmpCell->Next = P->Next;
P->Next = TmpCEell;
}
注意,我们已经把表L传递给Insert例程和IsLast例程,尽管它从未被使用过。之所以这么做,是因为别的方法可能会需要这些信息,因此,若不传递表L有可能使得使用ADT的想法失败。
我们要写的最后一个例程是插入例程。
将要插入的元素与表L和位置P一起传入。这个Insert例程将一个元素插入到P所示的位置之后。我们这个决定有随意性,它意味着插入操作如何实现并没有完全确定的规则。很有可能将新元素插入到位置P处(即在位置P处当时的元素的前面),但是这么做则需要知道位置P前面的元素。它可以通过调用FindPrevious而得到。因此重要的是要说明你要干什么。
注意,我们已将把表传递给Insert例程和IsLast例程,尽管它从未被使用过。之所以这么做,是因为别的实现方法可能会需要这些信息,因此,若不传递L有可能使得使用ADT的想法失败。除Find和FindPrevious例程外(还有例程Delete,它调用FindPrevious),我们已经编码所有操作均需O(1)时间。这是因为在所有的情况下,不管表有多大都只执行固定数目的指令。对于例程Find和FIndPrevious,在最坏的情况下运行时间是O(N),因为此时若元素未找到或位于表的末尾则可能遍历整个表。平均来看,运行时间是O(N),因为必须平均扫描半个表。
2.4 双链表
有时候以倒序扫描链表很方便。标注实现方法此时无能无能为力,然而解决方法却很简单。只要在数据结构上附加一个域,使它包含指指向前面一个单元的指针即可。其开销是一个附加的链,它增加了空间的需求,同时也使得插入和删除的开销增加一倍,因为有更多的指针需要定位。另一方面,它简化了删除操作,因为你不再被迫使用一个指向前驱元的种子指针来访问一个关键字;这个信息是现成的。下图表示一个双链表(doubly linked list)。
2.5 循环列表
让最后的单元反过来直接第一个单元是一种流行的做法。他可以有表头,也可以没有表头(若有表头,则最后的单元就指向它),并且还可以是双向链表(第一个单元的前驱元指针指向最后的单元)。这无疑会影响某些测试,不过这种结构在某些应用程序中却很流行,下图给出了一个无表头的双向循环列表。
2 栈ADT
栈(stack)是限制插入和删除只能在一个位置上进行的表,该位置是表的末端,叫做栈顶(top)。对栈的基本操作有Push(进栈)和PoP(出栈),前者相当于插入,后者则相当于删除最后插入的元素。最后插入的元素可以通过使用Top例程在执行Pop之前进行考察。对空栈进行的Pop或Top一般被认为是栈ADT的错误。另一方面,当运行Push时空间用尽是一个实现错误,但不是ADT错误。
栈有时又叫做LIFO(后劲先出)表。在上图中描述的模型只象征着Push是输入操作而Pop和Top是输出操作。普通的清空栈的操作和判断是否空栈的测试都是栈的操作指令系统的一部分,但是,对栈所能做的,基本上也就是Push和Pop操作。一般的模型是,存在某个元素位于栈顶,而该元素时唯一可见的元素。
由于栈是一个表,因此任何实现表的方式都能实现栈,下面给出链表实现方法。
通过在表顶端插入来实现Push,通过删除表顶端元素实现Pop。Top操作只是考察表顶端元素并返回它的值。有时Pop操作和Top操作合二为一。创建一个空栈也很简单,只要建立一个头结点;MakeEmpty设置Next指针指向NULL。Push是作为向链表前端进行插入而实现的,其中,表的前端作为栈顶。Top的实施是通过考察表在第一个位置上的元素而完成的。最后,Pop是通过删除表的前端的元素而实现的。
- 栈ADT链表实现的类型声明
# ifndef _Stack_h
strcuct Node;
typedef struct Node Node *PtrToNode;
typedef PtrToNode Stack;
int IsEmpty(Stack S);
Stack CreateStack( void );
void DisposeStack(Stack S);
void MakeEmpty(Stack S);
ElementType Top( Stack S );
void Pop( Stack S );
# endif /* _Stack_h */
/*Place in implementation file*/
/*Stack implementation is a linked list with a header*/
struct Node
{
ElementType Element;
PtrToNode Next;
}
- 测试一个栈是否为空
int
IsEmpty( Stack S )
{
return S->Next == NULL;
}
- 创建一个空栈
Stack
CreateStack( void )
{
Stack S;
S = malloc(sizeof(stuct Node));
if(S == NULL)
FatalError("Out of space!")
S -> Next == NULL;
Make Empty(S);
return S;
}
void
MakeEmpty( Stack S )
{
if( S == NULL )
Error("Must use CreateStack First");
else
while( !IsEmpty( S ) )
Pop( S );
}
- Push进栈
void
Push( ElementType S, Stack S )
{
PtrToNode TmpCell;
TmpCell = malloc(sizeof(struct Node));
if( TmpCell == NULL )
FataError( "Out of sapce!!" )
else
{
TmpCell -> Element = X;
TmpCell -> Next = S -> Next;
S -> Next = TmpCell;
}
}
- 返回栈顶元素
ElementType
Top( Stack S )
{
if( !IsEmpty( S ) )
return S->Next->Element;
Error( "Empty stack" );
return 0; /* Return value used to avoid warning */
}
- 从栈弹出元素
void
Pop( Stack S )
{
PtrToNode FirstCell;
if( IsEmpty( S ) )
Error("Empty stack");
else
{
FirstCell = S -> Next;
S -> Next = S -> Next -> Next;
free( FirstCell );
}
}
很清楚,所有的操作均花费常数时间,因为这些例程没有任何地方涉及到栈的大小(空栈除外),更不用说依赖于栈大小的循环了。这种实现方法的缺点在于对malloc和free的调用的开销是昂贵的,特别是与指针操作的例程相比尤其如此。有的缺点通过使用第二个栈可以避免。该第二个栈初始时为空栈,当一个单元从第一个栈弹出时,它只是被方法到了第二个栈中。此后,当地一个栈需要更新的单元时,它首先去检查第二个栈。
3、 队列模型
队列的基本操作是Enqueue(入队),它是在表的末端(rear)插入一个元素,还有Dequeue(出队),它是删除(或返回)在表的开头(叫做队头(front))的元素。下图显示一个队列的抽象模型。
对于队列而言任何表的实现都是合法的。像栈一样,对于每一种操作,链表实现和数组实现都给出快速O(1)运行时间。下面讨论队列的数组实现。
对于每一个队列数据结构,保留一个数组Queue[ ]以及位置Front和Rear,它们代表列表的两端。还要记录实际存在与队列中的元素的个数Size。所有这些信息是一个结构的一部分,除队列例程本身外通常不会有例程直接访问它们。下图表示处于某个中间状态的一个队列。顺便指出,图中那些空白单元是有着不确定的值的。特别地,前三个单元含有曾经属于该队列的元素。
操作应该是清楚地。为使一个元素X入队,让Size和Rear增1,然后置Queue[Rear] = X。若使一个元素出队,置返回值为Queue[Front],Size减1,然后使Front增1。也可以有其他的策略。现在讨论错误的检测。这种实现存在一个潜在的问题。经过10次入队后,队列似乎是满了,因为Rear现在是10,而下一次再入队就会是一个不存在的设置。然而,队列中也许只存在几个元素,因为若干元素可能已经出队了。像栈一样,即使在有许多操作的情况下队列也常常不是很大。简单的解决方法是,只要Front或Rear到达数组的尾端,它就又绕回到开头。下图显示在某些操作期间的队列情况。这叫做循环数组(cicular array)实现。实现回绕所需要的附加代码时极小的(虽然它可能使得运行时间加倍)。如果Front或Rear增1使得超越了数组,那么其值就要重置为数组的第一个位置。
初始状态
经过Enqueue(1)后
经过Enqueue(3)后
经过Dequeue并返回2后
经过Dequeue并返回4后
经过Dequeue并返回1后
经过Deuqueue并返回3后同时使队列为空
关于队列的循环实现,有两件事情要警惕。第一,检测队列是否为空是很重要的,因为当队列为空时一次Dequeue操作将不知不觉 地返回一个不确定的值。第二,某些程序设计人员使用不同的方法来表示队列的队头的队尾。例如,有些人并不用一个单元来表示队列的大小,因为它们依靠的是基准情形,即当队列为空时Rear = Front -1。队列的大小是通过比较Rear和Front隐式算出的。这是一种非常隐秘的方法,因为存在某些特殊的情形,因此,如果你需要修改用这种方式编写的代码,那么你就要特别仔细。如果队列的大小不是结构的一部分,那么若数组的大小为ASize,则当存在ASize-1个元素时队列就满了,因为只有ASize个不同的大小值可被区分,而0是其中的一个。采用任意一种你喜欢的风格,但要确保你的所有里程都是一致的。由于实现方法有多种选择,因此如果你不使用表示大小的域,那就很可能有必要进行一些讨论,否则会在一个程序中使用两种选择。
在保证Enqueue的次数不会大于队列的大小的应用中,使用回绕是没有必要的。向栈一样,除非主调例程肯定队列为空,否则Dequeue很少执行。因此对这种操作,只要不是关键的代码,错误的调用常常被跳过。一般来说这并不是无可非议的,因为你可能得到的时间节省量是极小的。
通常编写某些队列的例程来结束本节。首先在给出队列的声明。正如对栈的数组实现所做的那样,添加一个最大大小的域。还需要提供例程CreatQueue和DisposeQueue。此外,还要提供测试一个队列是否为空的例程以及构造一个空队列的例程。可以编写函数IsFull,它完成其名字所指处的功能。注意,Rear在Front之前与初始化为1。将要编写的最后的操作是Enqueue例程。严格遵循上面的描述,实现代码如下所示:
队列类型的声明:
# ifndef _Queue_h
struct QueueRecord;
typedef struct QueueRecord *Queue;
int IsEmpty(Queue Q);
int IsFull(Queue Q);
Queue CreateQueue( int MaxElements );
void DisposedQueue(Queue Q);
void MakeEmpty( Queue Q );
void Enqueue( ElementType X, Queue Q );
ElementType Front( Queue Q );
void Dequeue( Queue Q );
ElementType FrontAndDequeue( Queue Q );
#endif /*_Queue_h*/
/* Place in implementation file */
/* Queue implementation is a dynamically allocated array */
# define MinQueueSize( 5 )
struct QueueRecord
{
int Capacity;
int Front;
int Rear;
int Size;
ElementType *Array;
};
测试队列是否为空的例程------数组实现:
int
IsEmpty( Queue Q )
{
return Q->Size == 0;
}
构造空队列的例程------数组实现:
void
MakeEmpty( Queue Q )
{
Q->Size = 0;
Q->Front = 1;
Q->Rear = 0;
};
入队的例程------数组实现:
satic int
Succ( int Value, Queue Q )
{
if( ++Value == Q->Capacity )
Value - 0;
return Value;
}
void
Enqueue( ElementType X, Queue Q )
{
if( IsFull( Q ) )
Error("Full queue"):
else
{
Q->Size++;
Q->Rear = Succ( Q-Rear, Q );
Q->Array[ Q-Rear ] = X;
}
}
参考文献:
- MARKALLENWEISS. (2010). Data structures and algorithm analysis in C =. China Machine Press.