数据结构复习—线性表的链式表示

来源:互联网 发布:40不惑50知天命 编辑:程序博客网 时间:2024/06/05 14:18

1. 单链表的定义

线性表的链式存储又称为单链表,它是指通过一组任意的存储单元来存储线性表中的数据元素。为了建立起数据元素之间的线性关系,对每个链表结点,除了存放元素自身的信息之外,还需要存放一个指向其后继的指针。单链表结点如图所示,

data next

其中,data为数据域,存放数据元素;next为指针域,存放其后继结点的地址。
单链表中结点类型的描述如下:

typedef struct LNode {    ElemType data;    struct LNode *next;} LNode, *LinkList;

利用单链表可以解决顺序表需要大量的连续存储空间的缺点,但是单链表附加指针域,也存在浪费存储空间的缺点。由于单链表的元素是离散地分布在存储空间中的,所以单链表是非随机存取的存储结构,即不能直接找到表中某个特定的结点。查找某个特定的结点时,需要从表头开始遍历,依次查找。
通常用“头指针”来标识一个单链表,如单链表L,头指针为“NULL”时则表示一个空表。此外,为了操作上的方便,在单链表第一个结点之前附加一个结点,称为头结点。头结点的数据域可以不设任何信息,也可以记录表长等相关信息。头结点的指针域指向线性表的第一个元素节点。 如图所示。
这里写图片描述
头结点和头指针的区分:不管带不带头结点,头指针始终指向链表的第一个结点,而头结点是带头结点链表中的第一个结点,结点内通常不存储信息。
引入头结点后,可以带来两个优点:
①由于开始结点的位置被存放在头结点的指针域中,所以在链表的第一个位置上的操作和在表的其他位置上的操作一致,无须进行特殊处理。
②无论链表是否为空,其头指针是指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理也统一了。

2. 单链表上基本操作的实现

1.采用头插法建立单链表

该方法从一个空表开始,生成新结点,并将读取到的数据存放到新结点的数据域中,然后将新结点插入到当前链表的表头,即头结点之后,如图所示。
这里写图片描述
头插法建立单链表的算法如下:

LinkList CreateList(LinkList &L) {    LNode *s,int x;    L=(LinkList)malloc(sizeof(LNode));    L->next=NULL;    scanf("%d",&x);    while(x!=9999) {        s=(LNode*)malloc(sizeof(LNode));        s->data=x;        s->next=L->next;        scanf("%d",&x);    }    return L;}

采用头插法建立单链表,读入数据的顺序与生成的链表中元素的顺序是相反的。每个结点插入的时间为O(1),设单链表长为n,则总的时间复杂度为O(n)。

2.采用尾插法建立单链表

头插法建立单链表的算法虽然简单,但生成的链表中结点的次序和输入数据的顺序不一致。若希望两者次序一致,可采用尾插法。该方法是将新结点插入到当前链表的表尾上,为此必须增加一个尾指针r,使其始终指向当前链表的尾结点,如图所示。
这里写图片描述
尾插法建立单链表的算法如下:

LinkList CreateList2(LinkList &L) {    int x;    L=(LinkList)malloc(sizeof(LNode));    LNode *s,*r=L;    scanf("%d",&x);    while(x!=9999) {        s=(LNode*)malloc(sizeof(LNode));        s->data=x;        r->next=s;        r=s;        scanf("%d",&x);    }    r->next=NULL;    return L;}

因为附设了一个指向表尾结点的指针,故时间复杂度和头插法相同。

3.按序号查找结点值

在单链表中从第一个结点出发,顺指针next域逐个往下搜索,直到找到第i个结点为止,否则返回最后一个结点指针域NULL。
按序号查找结点值的算法如下:

LNode *GetElem(LinkList L,int i) {    int j=1;    LNode *p=L->next;    if(i==0)        return L;    if(i<1)        return NULL;    while(p&&j<i) {        p=p->next;        j++;    }    return p;}

按序号查找操作的时间复杂度为O(n)。

4.按值查找结点

从单链表第一个结点开始,由前往后一次比较表中各结点数据域的值,若某结点数据域的值等于给定值e,则返回该结点的指针;若整个单链表中没有这样的结点,则返回NULL。
按值查找结点的算法如下:

LNode *LocateElem(LinkList L,ElemType e) {    LNode *p = L->next;    while(p!=NULL&&p->data!=e)        p=p->next;    return p;}  

按值查找操作的时间复杂度为O(n)。

5.插入节点操作

插入操作是将值为x的新结点插入到单链表的第i个位置上。先检查插入位置的合法性,然后找到待插入位置的前驱结点,即第i-1个结点,再在其后插入新结点。
算法首先调用(3)中的按序号查找算法GetElem(L,i-1),查找第i-1个结点。假设返回的第i-1个结点。假设返回的第i-1个结点为*p,然后令新结点*s的指针指向*p的后继结点,再令结点*p的指针域指向新插入的结点*s。
实现插入结点的代码片段如下:

①p=GetElem(L,i-1);②s->next=p->next;③p->next=s;

这里写图片描述
算法中,语句②③的顺序不能颠倒,否则,当先执行p->next=s后,指向其原后继的指针就不存在了,再执行s->next=p->next时,相当于执行了s->next=s,显然是错误的。本算法主要的时间开销在于查找第i-1个元素,时间复杂度为O(n)。若是在给定的结点后面插入新结点,则时间复杂度仅为O(1)。
前插操作是指在某结点的前面插入一个新结点,后插操作的定义刚好与之相反,在单链表插入算法中,通常都是采用后插操作的。
此外,可以采用另一种方式将其转换为后插操作来实现,设待插入结点为*s,将 *s插入到 *p的前面。仍然将 *s插入到 *p的后面,然后将p->data与s->data交换即可,这样既满足了逻辑关系,又能使得时间复杂度为O(1)。算法的代码片段如下:

s->next=p->next;p->next=s;temp=p->data;p->data=s->data;s->data=temp;

6. 删除结点操作

删除操作是将单链表的第i个结点删除。先检查删除位置的合法性,然后查找表中第i-1个结点,即被删除结点的前驱结点,再将其删除,其删除过程如图所示。
这里写图片描述
假设结点*p为找到的被删结点的前驱结点,为了实现这一操作后的逻辑关系的变化,仅需修改 *p的指针域,即将 *p的指针域next指向 *q的下一结点。
实现删除结点的代码片段如下:

p=GetElem(L,i-1);q=p->next;p->next=q->next;free(p);

和插入算法一样,该算法的主要时间也是耗费在查找操作上,时间复杂度为O(n)。
要实现删除某一给定结点*p,通常的做法是先从链表的头结点开始顺序找到其前驱结点,然后再执行删除操作即可,算法的时间复杂度为O(n)。
其实,删除结点*p的操作可以用删除 *p的后继结点操作来实现,实质就是将其后继结点的值赋予其自身,然后删除后继结点,也能使得时间复杂度为O(1)。
实现上述操作的代码片段如下:

q=q->next;p->data=p->next->data;p->next=q->next;free(q);

7.求表长操作

求表长操作就是计算单链表中数据结点(不含头结点)的个数,需要从第一个结点开始顺序依次访问表中的每一个结点,为此需要设置一个计数器变量,每访问一个结点,计数器加1,直到访问到空结点为止。算法时间复杂度为O(n)。

3. 双链表

单链表结点中只有一个指向其后继的指针,这使得单链表只能从头结点依次顺序地向后遍历。若要访问某个节点的前驱结点(插入、删除操作时),只能从头开始遍历,访问后继结点的时间复杂度为O(1),访问前驱结点的时间复杂度为O(n)。
为了克服单链表的上述缺点,引入了双链表,双链表结点中有两个指针prior和next,分别指向其前驱结点和后继结点,如图所示。
这里写图片描述
双链表中结点类型的描述如下:

tydedef struct DNode {    ElemType data;    struct DNode *prior,*next;} DNode, *DLinklist;

双链表仅仅是在单链表的结点中增加了一个指向其前驱的prior指针,因此,在双链表中执行按值查找和按位查找的操作和单链表相同。但双链表在插入和删除操作的实现上,和单链表有着较大的不同。这是因为“链”变化时也需要对prior指针做出修改,其关键在于保证在修改的过程中不断链。此外,双链表可以很方便地找到其前驱结点,因此,插入、删除结点算法的时间复杂度仅为O(1)。

1.双链表的插入操作

在双链表中p所指的结点之后插入结点*s,其指针变换如图所示。
这里写图片描述
插入操作的代码片段如下:

① d->next=p->next;② p->next->prior=s;③ s->prior=p;④ p->next=s;

上述代码的语句顺序不是唯一的,但也不是任意的,①②两步必须在④步之前,否则*p的后继结点的指针就丢掉了,导致插入失败。

2. 双链表的删除操作

删除双链表中结点*p的后继结点 *q,其指针变换如图所示。
这里写图片描述
删除操作的代码片段如下:

p->next=q->next;q->next->prior=p;free(q);

4. 循环链表

1.循环单链表

循环单链表和单链表的区别在于,表中最后一个结点的指针不是NULL,而改为指向头结点,从而整个链表形成一个环。
在循环单链表中,表尾结点*r的next域指向L,故表中没有指针域为NULL的结点,因此,循环单链表的判空条件不是头结点的指针是否为空,而是它是否等于头指针。
循环单链表的插入、删除算法与单链表几乎一样,所不同的是如果操作是在表尾进行的,则执行的操作不相同,以让单链表继续保持循环的性质。正是因为循环单链表是一个“环”,因此,在任何一个位置上的插入和删除操作都是等价的,无须判断是否是表尾。
在单链表中只能从表头结点开始往后顺序遍历整个链表,而循环单链表可以从表中任一结点开始遍历整个链表。有时对单链表常做的操作是在表头和表尾进行的,此时可对循环单链表不设头指针而仅设尾指针,从而使得操作效率更高,其原因是若设的是头指针,对表尾进行操作需要O(n)的时间复杂度,而如果设的是尾指针r,r->next即为头指针,对于表头与表尾进行操作都只需要O(1)的时间复杂度。

2. 循环双链表

由循环单链表的定义不难退出循环双链表,不同的是在循环双链表中,头结点的prior指针还要指向表尾结点,如图所示。
这里写图片描述
在循环双链表L中,某结点*p为尾结点时,p->next==L;当循环双链表为空表时,其头结点的prior域和next域都等于L。

5. 静态链表

静态链表是借助数组来描述线性表的链式存储的,结点也有数据域data和指针域next,与前面所讲的链表中的指针不同的是,这里的指针是结点的相对地址(数组下标),又称为游标。和顺序表一样,静态链表也要预先分配一块连续的内存空间。
静态链表结构类型的描述如下:

#define MaxSize 50typedef struct {    ElemTYpe data;    int next;} SLinkList[MaxSize];

静态链表以next==-1作为其结束的标志。静态链表的插入、删除操作与动态链表相同,只需要修改指针,而不需要移动元素。

6. 顺序表和链表的比较

1.存取方式

顺序表可以顺序存取,也可以随机存取,链表只能从表头顺序存取元素。

2.逻辑结构与物理结构

采用顺序存储时,逻辑上相邻的元素,其对应的物理存储位置也相邻。而采用链式存储时,逻辑上相邻的元素,其物理存储位置则不一定相邻,其对应的逻辑关系是通过指针链接来表示的。

3.查找、插入和删除操作

对于按值查找,当顺序表在无序的情况下,两者的时间复杂度均为O(n);而当顺序表有序时,可采用折半查找,此时实际复杂度为O(log2x)。
对于按序号查找,顺序表支持随机访问,时间复杂度仅为O(1),而链表的平均时间复杂度为O(n)。顺序表的插入、删除操作,平均需要移动半个表长的元素。链表的插入、删除操作,只需要修改相关结点的指针域即可。由于链表每个结点带有指针域,因而在存储空间上比顺序存储要付出较大的代价,存储密度不够大。

4.空间分配

顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,如果再加入新元素将出现内存溢出,需要预先分配足够大的存储空间。预先分配过大,可能会导致顺序表后部大量闲置;预先分配过小,又会造成溢出。动态存储分配虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且若内存中没有更大块的连续存储空间将导致分配失败。链式存储的结点空间只在需要的时候申请分配,只要内存有空间就可以分配,操作灵活、高效。

阅读全文
0 0
原创粉丝点击