表
抽象数据类型
抽象数据类型(abstract data type,ADT) 是一些操作的集合。抽象数据类型是数学的抽象;在ADT的定义中根本没涉及如何实现操作的集合。
表ADT
将处理一般的形如的表。这个表的大小是。称大小为0的表为空表(empty list)。
对于除空表外的任何表,我们说后继(或继之后)并称前驱。表中的第一个元素是,而最后一个元素是。我们将不定义的前驱元,也不定义的后继元。元素在表中的位置为。
与这些“定义”相关的是我们要在表ADT上进行的操作的集合。和是常用的操作,其功能显而易见;返回关键字首次出现的位置;和一般是从表的某个位置插入和删除某个关键字;而则返回某个位置上(作为参数被指定)的元素。
表的简单数组实现
对表的所有操作都可以通过使用数组来实现。虽然数组是动态指定的,但是还是需要对表的大小的最大值进行估计。通常需要估计得大一些,从而会浪费大量的空间。这是严重的局限,特别是在存在许多未知大小的表的情况下。
数组实现使得和正如所预期的那样以线性时间执行,而则花费常数时间。然而,插入和删除的花费是昂贵的。这两种操作的最坏情况为。
因为插入和删除的运行时间是如此的慢以及表的大小还必须事先已知,所以简单数组一般不用来实现表这种结构。
链表
为了避免插入和删除的线性开销,我们需要允许表可以不连续存储,否则表的部分或全部需要整体移动。
链表由一系列不必在内存中相连的结构组成。每一个结构均含有表元素和只想包含该元素后继元的结构的指针。我们称之为指针。最后一个指针指向NULL。
程序设计细节
我们将留出一个标志结点,有时候称之为表头(header)或哑结点(dummy node)。
作为例子,我们将把这些表ADT的半数例程编写出来。首先,下面给出我们需要的声明。
#ifndef _List_h
#define _List_h
typedef int ElementType;
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 Delete(ElementType X, List L);
Position FindPrevious(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);
ElementType Retrieve(Position P);
#endif /* _List_h */
/* Place in the implementation file */
struct Node
{
ElementType Element;
Position Next;
};
按照C的约定,作为类型的List(表)和Postiion(位置)以及函数的原型都列在所谓的.h头文件中。具体的Node(结点)声明则在.c文件中。
我们将编写的第一个函数是测试空表的。当我们编写涉及指针的任意数据结构的代码时,最好总是要先画出一张图来。下面很容易写出了该函数。
/* 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 thie implementation */
int IsLast(Position P, List L)
{
return P->Next == NULL;
}
我们要写的下一个例程是。在下面表出,它返回某个元素在表中的位置。第6行用到与(&&)操作走了捷径,即结果与(and)运算的前半部分为假,那么结果就自动为假,而后半部分则不再执行。
/* 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;
}
有些编程人员发线递归地编写例程颇有吸引力,大概是因为这样可能避免冗长的终止条件。这是一个非常糟糕的想法,我们要不惜一切代价避免它。
第四个例程是删除表中的某个元素。我们的例程将删除第一次出现的,如果不在表中我们就什么也不做。为此,我们通过调用函数找出含有的表元的前驱元。下面是代码实现。
/* Delete first occurrence of X from a list */
/* Assume use of header node */
void Delete(ElementType X, List L)
{
Position P, TmpCell;
P = FindPrevious(X, L);
if(!IsLast(P, L)) /* Assumeption of header use */
{ /* X is found; deleted it */
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 */
/* Assumes a header */
Position FindPrevious(ElementType X, List)
{
Position P;
P = L;
while(P->Next != NULL && P->Next->Element != X)
P = P->Next;
return 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 = (struct Node*)malloc(sizeof(struct Node));
if (TmpCell == NULL)
FatalError("Out of space!!!");
TmpCell->Element = X;
TmpCell->Next = P->Next;
P->Next = TmpCell;
}
注意,我们已经把表传递给例程和例程,尽管它从未被使用过。之所以这么做,是因为别的实现方法可能会需要这些信息,因此,若不传递表有可能使得使用ADT的想法失败。
这个是合法的,不过有些编译器会发出警告。
除了和例程外(还有例程,它调用),我们已经编码的所有操作均需时间。对于例程和,在最坏的情况下运行时间是,因此此时若元素未找到或位于表的末尾则可能遍历整个表。平均来看,运行时间是,因为必须平均扫描半个表。
常见的错误
最常遇到的错误是你的程序因来自系统的棘手的错误信息而崩溃,比如“memory access violation”或“segmentation violation”这种信息通常意味着有指针变量包含了伪地址。一个通常的原因是初始化变量失败。一个典型的错误就是关于上面插入例程的代码中的最后一行,如果P是NULL,则指向是非法的。这个函数知道P不是NULL,所以例程没有问题。无论何时只要确定一个指向,那么你就必须保证该指针不是NULL。有些C编译器隐式地做了这种检查,不过这并不是C标准的一部分。当将程序从一个编译器移至另一个编译器下时,可能就会发现不再正常运行。这就是这种错误常见的原因之一。
有些空间不再需要时,可以用free命令通知系统来回收它。free§的结果是:P正在指向的地址没变,但在该地址处的数据此时已无定义了。
作为一个例子,下面代码就不是删除整个表的正确方法(虽然在有些系统上它能够运行)。
/* Incorrect DeleteList algorithm */
void DeleteList(List L)
{
Position P;
P = L->Next; /* Header assumed */
L->Next = NULL;
while(P != NULL)
{
free(P);
P = P->Next;
}
}
下面显示了删除工作的正确方法。
/* Correct DeleteList algorithm */
void DeleteList(List L)
{
Position P, Tmp;
P = L->Next; /* Header assumed */
L->Next = NULL;
while(P != NULL)
{
Tmp = P->Next;
free(P);
P = Tmp;
}
}
处理闲置空间的工作未必很快完成,因此可能要检查看是否处理的例程会引起性能下降,如果是则要考虑周密。
双链表
双链表(doubly linked list)只要在数据结构上附加一个域,使它包含指向前一个单元的指针即可。其开销是一个附加的链,它增加了空间的需求,同时也使得插入和删除的开销增加一倍,因为由更多的指针需要定位。另一方面,它简化了删除操作,不再被迫使用一个指向前驱元的指针来访问一个关键字。
循环链表
让最后的单元反过来直指第一个单元。它可以有表头,也可以没有表头(若有表头,则最后的单元就指向它),并且还可以是双向链表(第一个单元的前驱元指针指向最后的单元)。这无疑会影响某些测试,不过这种结构在某些应用程序中却很流行。
例子
我们提供三个使用链表的例子。第一例是表示一元多项式的简单方法。第二例是在某些特殊情况下以线性时间进行排序的一种方法。最后,我们介绍一个复杂的例子,它说明了链表如何用于大学的课程注册。
多项式ADT
我们可以用表来定义一种关于一元(具有非负次幂)多项式的抽象数据类型。令。如果大部分系数非零,那么我们可以用一个简单数组来存储这些系数。然后,可以编写一些对多项式进行加、减、乘、微分及其他操作的例程。下面代码给出类型声明。
typedef struct
{
int CoeffArray[MaxDegree + 1];
int HighPower;
} * Polynomial;
这时,我们就可编写进行各种不同的操作的例程了。加法和乘法是两种可能的运算;下面代码给出。
/* 将多项式初始化为零的过程 */
void ZeroPolynomial(Polynomial Poly)
{
int i;
for(i = 0; i <= MaxDegree; i++)
Poly->CoeffArray[i] = 0;
Poly->HighPower = 0;
}
/* 两个多项式相加的过程 */
void AddPolynomial(const Polynomial Poly1, const Polynomial Poly2, Polynomial PolySum)
{
int i;
ZeroPolynomial(PolySum);
PolySum->HighPower = Max(Poly1->HighPower, Poly2->HighPower);
for(i = PolySum->HighPower; i >= 0; i--)
{
PolySum->CoeffArray[i] = Poly1->CoeffArray[i] + Poly2->CoeffArray[i];
}
}
/* 两个多项式相乘的过程 */
void MultPolynomial(const Polynomial Poly1, const Polynomial Poly2, Polynomial PolyProd)
{
int i, j;
ZeroPolynomial(PolyProd);
PolyProd->HighPower = Poly1->HighPower + Poly2->HighPower;
if(PolyProd -> HighPower > MaxDegree)
Error("Exceeded array size");
else
for(i = 0; i <= Poly1->HighPower; i++)
for(j = 0; j <= Poly2->HighPower; j++)
PolyProd->CoeffArray[i + j] += Poly1->CoeffArray[i] * Poly2->CoeffArray[j];
}
另一种方法是使用单链表(singly linked list)。多项式的每一项含在一个单元中,并且这些单元以次数递减的顺序排序。下面代码实现了类型声明。
typedef struct Node *PtrToNode;
struct Node
{
int Coefficient;
int Exponent;
PtrToNode Next;
};
typedef PtrTONode Polynomial; /* Nodes sorted by exponent */
上述操作将很容易实现。唯一的潜在困难在于,当两个多项式相乘的时候所得到的多项式必须合并同类项。这可以用多种方法实现。
基数排序
使用链表的第二个例子叫做基数排序(radix sort)。基数排序有时也成为卡式排序(card sort),因为直到现代计算机出现之前,它一直用于对老式穿孔卡的排序。
如果我们有个整数,范围从到(或从到),我们可以利用这个信息得到一种快速的排序,叫做桶式排序(bucket sort)。我们留置一个数组,称之为,大小为,并初始化为零。于是,有个单元(或桶),开始时它们都是空的。当被读入时增。在所有的输入被读进以后,扫描数组,打印输出排好序的表。该算法花费。如果,则桶式排序为。
基数排序就是这种方法的推广。设我们有个数,范围在到之间,我们将其排序。一般来说,这是到间的个数,是某个常数。显然,我们不能使用桶式排序,那样桶就太多了。我们的策略是使用多趟桶式排序。我们用最低(有效)“位”优先的方式进行桶式排序,那么算法将得到正确结果。当然,有可能多余一个数落入相同的桶中,但有别于原始的桶式排序,这些数可能不同,因此我们把它们放到一个表中。注意,所有的数可能都有某位数字,因此如果使用简单的数组表示表,那么每个数组必然大小为,总的空间需求是。
下面例子说明10个数的桶式排序的具体做法。本例输入是64,8,216,512,27,729,0,1,343,125(前10个立方数,随机排列)。第一步按照最低位优先进行桶式排序。为使问题简化,此时操作按基是10进行,不过一般并不做这样的假设。下面显示出这些桶的位置。
0 | 1 | 512 | 343 | 64 | 125 | 216 | 27 | 8 | 729 |
---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
因此按最低位优先排序得到的表是0,1,512,343,64,125,216,27,8,729。
现在再按照次最低位(即10位上的数字)优先进行第二趟排序。
8 | 729 | ||||||||
---|---|---|---|---|---|---|---|---|---|
1 | 216 | 27 | |||||||
0 | 512 | 125 | 343 | 64 | |||||
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
第二趟排序输出0,1,8,512,216,125,27,729,343,64。
现在这个表是按两个最小的位排序得到的表。最后一趟桶式排序是按最高位进行的,其结果如下。
64 | |||||||||
---|---|---|---|---|---|---|---|---|---|
27 | |||||||||
8 | |||||||||
1 | |||||||||
0 | 125 | 216 | 343 | 512 | 729 | ||||
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
最得到的表是0,1,8,27,64,125,216,343,512,729。
为使算法能够得出正确的结果,要注意唯一出错的可能是如果两个数出自同一个桶但顺序确是错误的。不过,前面各趟排序保证了当几个数进入一个桶的时候,它们是以 排序的顺序进入的。
该排序的运行时间是,其中是排序的趟数,是要被排序的元素的个数,而是桶数。本例中。
多重表
最后一个例子阐述链表更复杂的应用。一所40000名学生和2500门课程的大学需要生成两种类型的报告。第一个报告列出每个班的注册者,第二个报告列出每个学生注册的班级。
常用的实现方法是使用二维数组。这样一个数组将有1亿项。平均大约一个学生注册三门课程,因此实际上有意义的数据只有120000项,大约占0.1%。
现在需要的是列出每个班及每个班所包含的学生的表。我们也需要每个学生及其所注册的班级的表。如下图所示实现方法。
如该图所显示的,我们已经把两个表合并成为一个表。所有的表都各有一个表头并且都是循环的。比如,为了列出C3班的所有学生,我们从C3开始通过向右行进而遍历其表。第一个单元属于学生S1。虽然不存在明显的信息,但是可以通过跟踪该生链表直达到该表表头而确定该生的信息。一旦找到该生信息,我们就转回到C3的表(在遍历该生的表之前,我们存储了在课表中的位置)并找到可以确定属于S3的另外一个单元,我们继续并发线S4和S5也在该班上。对任意一名学生,我们也可以用类似的方法确定该生注册的所有课程。
使用循环表节省空间但是要花费时间。在最坏的情况下,如果第一个学生注册了每一门课程,那么表中的每一项都要检测以确定该生的所有课程名。如果怀疑会产生问题,那么每一个(非表头)单元就要有直接指向学生和班的表头指针。这使空间的需求加倍,但是却简化和加速实现的过程。
链表的游标实现
诸如BASIC和FORTRAN等许多语言都不支持指针。如果需要链表又不能使用指针,那么就必须使用另外的实现方法。我们将描述这种方法并称为*游标(cursor)*实现法。
在链表的指针实现中有两个重要的特点。
- 数据存储在一组结构体中。每一个结构体包含有数据以及指向下一个结构体的指针。
- 一个新的结构体可以通过malloc而从系统全局内存(global memory)得到,并通过调用free而被释放。
游标法必须能够模仿实现这两条特性。满足条件1的逻辑方法是要有一个全局的结构体数组。对于该数组中的任何单元,其数组下标可以用来代表一个地址。下面代码给出链表游标实现的声明。
#ifndef _Cursor_h
#define _Cursor_h
typedef int ElementType;
typedef int PtrToNode;
typedef PtrToNode List;
typedef PtrToNode Position;
void InitializeCursorSpace(void);
List MakeEmpty(List L);
int IsEmpty(const List L);
int IsLast(const Position P, const List L);
Position Find(ElementType X, const List L);
void Delete(ElementType X, List L);
Position FindPrevious(ElementType X, const List L);
void Insert(ElementType X, List L, Position P);
void DeleteList(List L);
Position Header(const List L);
Position First(const List L);
Position Advance(const List L);
ElementType Retrieve(const Position P);
#endif /* _Cursor_h */
/* Place in the implementation file */
#define SpaceSize 10
struct Node
{
ElementType Element;
Position Next;
};
struct Node CursorSpace[SpaceSize];
现在我们必须模拟条件2,让CursorSpace数组中的单元代行malloc和free的职能。为此,我们将保留一个表(即freelist),这个表由不在任何表中的单元构成。该表用单元0作为表头。其初始配置为下图表示。
对于Next,0的值等价于NULL指针。CursorSpace的初始化是一个简单的循环结构。为执行malloc功能,将(在表头后面的)第一个元素从freelist中删除。为了执行free功能,我们将单元放在freelist的前端。下面表示出malloc和free的游标的实现。
Position CursorAlloc(void)
{
Position P;
P = CursorSpace[0].Next;
CursorSpace[0].Next = CursorSpace[P].Next;
return P;
}
void CursorFree(Position P)
{
CursorSpace[P].Next = CursorSpace[0].Next;
CursorSpace[0].Next = P;
}
注意,如果没有可用空间,那么我们的例程通过置P = 0会正确地实现。它表明没有空间可用,并且也可以使CursorAlloc的第二行成为空操作(no-op)。
为了前后一致,我们将用一个头结点实现链表。作为一个例子,下图中,如果L的值是5而M的值为3,则L表示链表a,b,e,而M表示链表c,d,f。
为了写出用游标实现链表的这些函数,我们必须传递和返回与指针实现时相同的参数。下面是一个测试表是否为空表的函数。
/* Return true if L is empty */
int IsEmpty(List L)
{
return CursorSpace[L].Next == 0;
}
下面实现对当前位置是否是表的末尾的测试。
/* 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 CursorSpace[P].Next == 0;
}
下面实现函数Find返回表L中X的位置。
/* Return Position of X in L; 0 if not found */
/* Uses a header node */
Position Find(ElementType X, List L)
{
Position P;
P = CursorSpace[L].Next;
while(P && CursorSpace[P].Element != X)
P = CursorSpace[P].Next;
return P;
}
实现删除的代码。
/* Delete first occurrence 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)) /* Assumeption of header use */
{ /* X is found; delete it */
TmpCell = CursorSpace[P].Next;
CursorSpace[P].Next = CursorSpace[TmpCell].Next;
CursorFree(TmpCell);
}
}
最后,给出Insert的游标实现。
/* Insert (after legal position P) */
/* Header implementation assumed */
/* Parameter L is unused in this implementation */
void Insert(ElementType X, List L, Position P)
{
Position P, TmpCell;
TmpCell = CursorAlloc();
if(TmpCell == 0)
FatalError("Out of space!!!");
CursorSpace[TmpCell].Element = X;
CursorSpace[TmpCell].Next = CursorSpace[P].Next;
CursorSpace[P].Next = TmpCell;
}
游标实现可以用来代替链表实现,实际上在程序的其余部分不需要变化。由于缺少内存管理例程,因此,如果运行的Find函数相对很少,则游标实现的速度会显著加快。
freelist从字面上看表示一种有趣的数据结构。从freelist删除的单元是刚刚由free放在那里的单元。因此,最后被放在freelist的单元是被最先拿走的单元。有一种数据结构也具有这种性质,叫做栈(stack)。