本节我们讨论常见常用的数据结构——表。
如果要通俗简单的说什么是表,那我们可以这样说:按顺序排好的元素集合就是表。
很显然,除了第一个元素,表中的其他元素都有排在自己前面的那个元素,我们称其为前驱元(简称前驱),除了最后一个元素,表中的其他元素都有排在自己后面的那个元素,我们成为后继元(简称后继)。
正如什么是数据结构与算法分析一文中所说,数据结构就是研究如何组织大量数据的方法。所以对于表,我们也要知道该如何组织元素们,或者说该如何存储元素们,也就是存储表,这也是本节讨论的事情。
最简单的方法就是使用数组,数组本身看起来就是个表。对于一个表可能存在的操作,找到元素、增加元素、删除元素等,数组都可以满足。但是使用数组的问题在于增删元素的效率!
数组对于表的找到元素操作完美支持。但是增加和删除元素有很大的问题。
第一个问题是效率,如果我们要在数组的第一个元素前面插入一个元素,则我们需要将整个数组的元素都向后移动一位,删除第一个元素也是一样(向前移动一位),这将导致巨大的开销!(插入或删除某个元素,则其对应位置后的所有元素均需移动)
第二个问题是空间的浪费,当你增加元素达到数组填满了的时候,如果不开辟新的空间,则无法继续增加元素!所以数组该设置多大是一个需要解决的问题,但有时候你无法确定到底需要多大的数组来存储一个大小在变化的表!(解决的其中一个办法是当发现数组满,申请一个新的更大的数组,然后将元素全部移至新的数组。但怎么处理掉原数组也是一个问题!)
为了解决上面两个问题,我们提出存储表的新的方法,那就是“链表”(如果只是想解决增删元素效率的问题,游标数组即可满足!使用指针的链表不仅仅是为了满足增删元素的效率问题,还有动态分配空间的问题!)
首先我们思考如何解决分配大小的问题,很显然如果我们使用数组来存储表,那么一开始我们就需要指出数组要多大,但很显然,最佳的方法是“我们有一个元素就要一个元素大小的空间”,这一点通过C的malloc函数可以很容易的实现。
但表的另一个特点是“元素按顺序排好”,所以我们不仅仅要把元素存起来,而且得让它们保持顺序连接的状态,我们可不想把元素存起来后就再也找不到了,也不想让它们忘记自己的前驱和后继是谁。解决这个问题的能手显然是C的招牌—指针!我们令每个元素变胖一点,不仅存储原来的数据,还新增了一个指针,这个指针指向它们的后继。这样一来整个表就连接起来了。
一些简单的声明:
struct Node
{
ElementType element;
struct Node * next;
}
typedef struct Node * PtrToNode;
typedef PtrToNode List;
typedef PtrToNode Position;
完成了解决大小这一步后,其实我们其实也就解决了增删元素效率的问题了。
很显然,在某两个节点之间增加元素,只需要让新节点的next指向后驱,然后让原前驱的next指向新节点即可。
删除某个元素将需要知道它的前驱,我们让前驱的next指向被删除节点的后继即可(将被删除节点的next赋值给前驱的next)
通过这样的方法,我们只需要较少的操作即可增删元素,而不需要移动大量的元素来实现。
在我们给出一些例程之前,我们首先需要引入一个关键的概念与方法,那就是——头结点
所谓头结点,就是在真正的表中第一个元素前面的节点,这个节点不存储表的元素,只是代表表的开头,令List指向头结点,然后头结点的next指向第一个元素。
使用头结点的好处在于,令表的开始List一直指向一个地方,这样的好处首先是有助于防止编程时的失误导致丢失整个表,另外又可以使我们之后对第一个元素的操作(增删)与对表中其他元素的操作保持统一。
在链表中查找一个元素将比在数组中慢一点,但依然是花费线性时间
Find例程:
Position Find ( ElementType X,List L)
{
Position p=L->next;
while(p!=NULL&&p->element!=X)
p=p->next;
return p;
}
删除节点例程需要使用的FindPrevious例程:
Position FindPrevious(ElementType X,List L)
{
Position p=L;
while(p->next!=NULL&&p->next->element!=X)
p=p->next;
return p;
}
删除节点例程:
void Delete(ElementType X,List L)
{
Position p,Temp;
p=FindPrevious ( X,L );
if ( p->next!=NULL)
{ Temp=p->next;
p->next=Temp->next;
free(Temp);
}
}
如果我们已经知道了插入元素在节点P的后面,则插入例程为(插入时的malloc需要注意,malloc(PtrNode)语法正确但是没有达到目的,因为它只是分配了一个指针空间):
void Insert ( ElementType X,List L, Position p)
{
Position Temp;
Temp=malloc(sizeof(struct Node));
if ( Temp==NULL)
error; //伪代码
Temp->element=X;
Temp->next=p->next;
p->next=Temp;
}
销毁整个表对于不熟悉指针的人来说会有些麻烦,首先我们不能仅仅
free(L->next);
因为这样做仅仅是删除了表的头结点,这将导致我们失去整个表然而这个表却依然占用着空间。另外需要注意的是逐个释放节点空间时可能犯的错误:
p=L->next;
L->next=NULL;
while(p!=NULL)
{ free(p);
p=p->next; //此处错误
}
所以正确的删除整个表的例程为:
void DeleteList (List L)
{
Position p=L->next,temp;
L->next=NULL;
while(p!=NULL)
{ temp=p->next;
free(p);
p=temp;
}
}
在删除例程中,我们需要使用到一个FindPrevious函数。但如果细心一点的人就会发现,其实只要对节点稍作修改,就可以更加方便的寻找一个元素的前驱。那就是令节点结构再新增一个prev指针,这个指针指向节点的前驱!有着前驱指针的链表,就是我们所说的——双链表
还有一种常见链表,叫循环链表,就像字面意思一样,当我们走到链表末端的时候,继续“向前走”将会走回链表的开头,实现的办法也很简单,就是令最后一个节点的next不为空,而是指向头结点
知道了循环链表后,我们现在可以引出一种较为复杂的链表结构了,那就是——多重表
假设一所大学有40000名学生和2500门课程,现在需要两份报告,一份是40000名学生分别选了什么课,一份是每门课有哪些学生。最不假思索的实现方法是使用二维数组a[40000][2500],但实际上一个学生可能最多选了10门课,一门课可能最多几十个学生,这样一来这个二维数组中其实有很多空间是浪费无用的!
节省空间的做法就是使用多重表,令每个节点存储学生姓名与课程信息,并且有两个next指针,一个是nextCourse指向同一名学生的下一门课,一个是nextStudent指向同一门课的下一个学生!
可以想象nextCourse从上至下形成了一名学生的“一列”,而nextStudent形成了一门课程的“一行”,行列交错,其实起到了和二维数组相似的存储效果,但却省去了二维数组中无用的地方(相当于用next指针跳过了空处)
现在将要介绍最开始时我们提到的一种结构,就是那个能够加快增删过程,却又没有解决大小问题的——游标数组
游标数组的思想来自于BASIC和FORTRAN等不支持指针的语言。
在链表结构中,有两个重要的特点使得其可以完成快速增删及分配空间(当然,使用数组注定了我们其实对于内存空间已经没法做到自由分配了):
1.每个节点包含着数据元素以及指向下一元素的指针
2.可以通过malloc和free使系统从全局内存中分配出空间给新的节点
那么,我们现在没有指针,必须使用数组,所以我们只能让数组本身成为那个“全局内存”了。
先解决第一个问题,如何模拟指向下一元素的指针?幸运的是,我们有着数组下标!一个表中的元素必然有对应的一个下标,这个下标即其“地址”(相对于“全局内存”即数组来说的地址),我们只要让节点的指针变成一个整型变量next,存储下一元素的下标即可!而NULL我们用0代替即可(放弃下标0的位置)
接下来要解决第二个问题,即模拟内存分配。我们已经假装分配出的数组空间为“全局内存”了(例如500大小数组,我们就假装我们的内存总共就能存储500个元素之多吧~),重点在于分配一个数组位置和收回一个数组位置该怎么实现
想象一下,我们在申请内存分配的时候,其实是跟系统说了一声我要,然后系统去帮我找的位置。所以我们可以再做一个扮演“系统”角色的数组出来!我们每当想malloc时,就让(去)这个数组找位置,等我们free时,就跟这个数组说一声这个下标我们不用了
所以,这个扮演“系统”的数组应该是一个整型数组,且初始化为a[i]=i+1的状态!(a[end]=0)
此处给出模拟malloc的例程或许会方便理解:
Position CursorAlloc(void)
{
Position p=CursorSpace[0].next;
CursorSpace[0].next=CursorSpace[p].next;
//CursorSpace[0].next代表下一个可用下标,分配掉之后,通过上一行赋值语句
//使CursorSpace[0].next指向下一个下标,也就是下一个可用下标
return p;
}
Free的例程则是如此:
void CursorFree(Position p)
{
CursorSpace[p].next=CursorSpace[0].next;
CursorSpace[0].next=p;
//令CursorSpace[p].next指向下一个可用下标,然后令CursorSpace[0].next指
//向释放回来的p
}
(注意,“系统”内存方便了分配位置,但“内存”数组(即存储元素的数组)中的每个元素依然是有next域的!“系统”数组中的next和“内存”数组中的next意义不同!,前者意味着下一个可用空间,后者意味着下一个元素下标!)
讲到此处,关于游标数组的核心部分就算讲完了,其他的具体实现并不困难,不予赘述。
链表的思想极其重要,学习查找树,图等数据结构时会发现其实存储它们都用到了链表的结构!如记录图的邻接表即是多重表的一种!