一、线性表的链式存储结构
前面所讲的线性表的顺序存储结构是有缺点的,最大的缺点就是插入和删除时需要移动大量的元素,这显然就需要耗费大量时间。
仔细考虑一下产生该问题的原因,在于相邻元素的存储位置也具有邻居关系,它们在内存中是紧挨着的,没有空隙,自然也没有空位进行介入,而删除后留下的空隙自然也需要弥补。
为了解决上述问题,我们打破常规,不再让相邻元素在内存中紧挨着,而是上一个元素留存下一个元素的“线索”,这样我们找到第一个元素时,根据“线索”自然而然就找到下一个元素的位置;依次类推,通过遍历的方法每一个元素的位置都可以通过遍历找到。
1、线性表的链式存储定义
定义:节点(或译为“结点”)(Node):为了表示每个数据元素ai与其后续数据元素ai+1之间的逻辑关系,对数据ai来说,除了存储本身的数据信息之外,还需要存储一个指示其直接后继信息(即直接后继的存储位置)。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称作指针或链。这两部分信息组成数据元素ai的存储映像,称为节点(Node)。
定义:单链表:n个节点(ai的存储映像)链接成一个链表,即为线性表的链式存储结构。因为此链表的每个节点中只包含一个指针域,所以叫单链表。
定义:头指针:我们把链表中第一个节点的存储位置叫做头指针,整个单链表的存储就必须从头指针开始进行。
但是,单纯使用头指针无法区分一个单链表是否为空 还是 一个单链表不存在。因为二者从头指针的角度来说,都是指针为空,而单链表为空和单链表不存在是两种完全不同的概念。
为了解决这个问题,我们引入头结点head的概念。头结点即单链表的第一个节点,该节点不存储任何有效数据,实际链表的起点是头结点的后继节点。
当头结点的后继节点为空,即
head->next==NULL
时,此时我们判定该链表为空链表。而当头结点head不存在时,此时我们判定该单链表不存在。
2、线性表的链式存储结构代码描述
单链表的存储结构的C语言描述:
typedef struct Node
{
data_t data;
struct Node *next;
}Node;
typedef struct Node *LinkList;
由代码我们可以看出,节点是由存放数据元素的数据域和存放后继节点的指针域组成的。假设指针p是指向第i个元素ai的指针,则p->data表示ai的数据域,p->next表示ai的指针域。p->next指向下一个元素,即ai+1,也就是说,p->data=ai,p->next->data=ai+1。
1)单链表的整表创建
在顺序表中,顺序表的创建其实就是一个数组的初始化过程。而单链表和顺序表的存储结构不同,它不能像顺序表一样整体集中地操作数据,而且单链表是一种动态结构。所以创建单链表的过程实际上是将一个“空表”动态依次建立各元素节点并逐步插入到链表中的过程。
单链表的整表创建的算法思路(头插法):
⒈声明指针p和计数器i
⒉初始化一个空链表头结点head
⒊让head的结点指针指向NULL,即建立一个带头结点的空单链表
⒋循环以下过程:
⒋⒈通过指针p生成新节点
⒋⒉新节点获得数据,即p->data=数据
⒋⒊将p插入到头结点与前一新节点之间
//代码见附录
以上代码里,我们让新生成的节点始终处在第一个位置,我们把这种方法称为“头插法”。
实际上,按照日常生活中“先来后到”的思想,新生成的节点应当插入到当前链表的尾部。若采用这种方法创建单链表,我们称为“尾插法”。
尾插法的算法思路基本等同于头插法,只需将上文的头插法的算法
⒋⒊将p插入到头结点与前一新节点之间
改为:
⒋⒊将p插入到当前链表的尾节点之后
即可。
//代码见附录
注意代码中L与r的关系,L是指向整个单链表,而r是指向当前链表的尾节点。L不会随着循环变换位置,而r会随着循环实时变换位置。
2)单链表的整表删除
当我们不打算使用一个链表时,我们应当对其进行销毁,也就是在内存中释放这个链表,以便留出空间供其他程序使用。
单链表的整表删除的算法思路:
⒈声明指针p和q;
⒉将链表的第一个节点赋值给p
⒊循环以下过程:
⒊⒈将p的下一个节点赋值给q
⒊⒉释放p
⒊⒊将q赋值给p
//代码见附录
注意代码中指针q的作用,q的作用是引导指针p,若无指针q的话,在执行free(p)语句之后,指针p就无法找到其下一个节点p->next的位置了,因为该节点的指针域已经随节点一并释放了。
3)单链表的读取
在线性表的顺序存储结构中,我们要通过任意位置读取元素的值是十分方便容易的,但在单链表中,对于第i个元素具体在哪无法一开始就得知,必须从头指针开始寻找。因此对于单链表的读取第i个元素的操作在算法上相对要麻烦很多。
获得单链表第i个元素的算法:
⒈定义一个指针指向链表的第一个节点。初始化循环变量j
⒉当j<i时,不断让指针p向后移动
⒊若到链表结尾p为空,则说明第i个节点不存在,返回错误
⒋当p移动到i位置成功时,返回节点p的数据
//代码见附录
因为该链表的时间复杂度取决于位置i,因此该算法的时间复杂度为O(n)。
因为单链表结构定义时没有定义表长,所以无法事先获知循环次数,因此不推荐使用for循环。该算法的主要核心是“当前工作指针”,这其实也是多数关于链表算法的核心思想。
4)单链表的插入与删除
单链表的插入与删除操作是单链表的优势之一。插入和删除操作无需像线性表的顺序存储结构一样,插入或删除一个节点需要影响到众多其他节点。
假设在单链表中,待插入节点的指针为s,s节点的前驱节点为p,则插入操作只需2步即可:
s->next=p->next;p->next=s;
但是注意,这两句顺序不可交换。
如果先让p->next=s;那么下一句s->next=p->next就相当于s->next=s,这样新加入节点s就无法接入它的后继节点了,“临场掉链子”。
单链表的第i个数据位置插入节点的算法思路:
⒈声明一个指针p指向链表头结点,初始化j从1开始
⒉当j<i时,遍历链表,即指针p不断向后移动
⒊若到链表末尾p为空,则位置i不存在
⒋p找到第i个位置,生成待插入空结点s
⒌将数据元素e赋值给s->data
⒍执行单链表的插入语句:s->next=p->next;p->next=s;
⒎返回成功
//代码见附录
接下来我们来看单链表的删除。假设第i个位置节点为q,它的前驱节点是p,现在要删除节点q,其实只需将q的前驱节点p绕过q节点指向q的后继节点即可:
q=p->next;p->next=q->next;
单链表的第i个数据位置删除节点的算法思路:
⒈声明一个指针p指向链表头结点,初始化j从1开始
⒉当j<i时,遍历链表,即指针p不断向后移动
⒊若到链表末尾p为空,则说明第i个位置的节点不存在
⒋p指向q的前驱节点,即q==p->next
⒌执行单链表的删除语句:p->next=q->next;
⒍将q节点中数据取出作为结果
⒎释放q节点
⒏返回成功
//代码见附录
分析单链表的插入和删除代码,我们可以发现,它们的算法其实都是由两部分组成:第一部分是遍历查找第i个节点,第二部分是对它进行相应的操作。而且我们可以看出,对于第i个节点的操作不会影响到其他位置的节点,这也是单链表比顺序表优势的地方。显然,对于插入/删除比较频繁的操作,单链表的效率要明显高于顺序表。
3、顺序表与单链表的优缺点
//见附图2
通过对比,我们可以得出一些结论:
⒈若该线性表需要频繁进行查找操作,而很少进行插入/删除操作时,我们推荐采用顺序表存储。而若该线性表需要频繁进行插入/删除操作时,我们推荐使用单链表。例如在一款游戏中,玩家个人信息除注册时涉及到插入数据外,一般不会发生大的改变,因此我们使用顺序表存储。而玩家的装备列表则会随着玩家的游戏而发生改变,即随时会发生插入/删除操作,这时使用顺序表就不太合适,而应采用单链表。
⒉当线性表中的元素数量变化较大,或无法事先预制数目时,我们推荐使用单链表,这样可以无需考虑存储空间大小分配的问题。反之,若我们已事先知道了数据规模(例如1年有12月,1星期有7天等情况),则我们推荐使用顺序表,这样存储效率会高很多。
总之,顺序表与单链表各有其优缺点,需要在实际情况中权衡需要使用哪种方式。
练习1:单链表反序
已有单链表L,编写函数使得单链表的元素反序存储。
提示:函数原型:int ListReverse(LinkList L)
//代码见附录
练习2:已有单链表L存放的数据类型为整型,其头结点为head,编写函数,求单链表中相邻两点数据data之和为最大的一对节点的第一节点的指针。
提示:函数原型:LinkList Adjmax(LinkList h)
//代码见附录
二、循环链表
若将单链表的尾节点的指针由空改成指向头结点,则将整个单链表形成了一个环,这种头尾相接的单链表称为单循环链表,简称循环链表(Circular linked list)。
循环链表解决了一个单链表存在的很麻烦的问题:如何从链表的任意节点出发,访问到链表的全部节点。
同单链表一样,为了解决单循环链表的空表与非空表操作一致问题,我们通常会设置一个头结点,该头结点里不存任何数据(或只存其他无关数据)。这样对于循环的判断结束条件就从p->next是否为空,变成了p->next是否为头结点。
//代码见附录
练习:使用单项循环链表求解约瑟夫环问题,其中人数n为33人,每逢m=7人枪毙一人,起始位置为第k=1个人
/***********约瑟夫环问题描述******************/
约瑟夫入狱,监狱内共有n=33个犯人。某日33名犯人围成一圈,从第k=1个犯人开始报数,报到数字m=7的犯人出列,被枪毙,下一名犯人重新从1开始报数。依次类推,直至剩下最后1名犯人可被赦免。聪明的约瑟夫在心里稍加计算,算出了最后枪毙的位置,他站在这个位置,最终避免了自己被枪毙,逃出升天。
问:约瑟夫算出的是哪个位置?
/***********约瑟夫环问题描述end***************/
//代码见附录
三、双向链表
我们在单链表中,有了next指针,它使得我们要查找某节点的下一个节点的时间复杂度为O(1)。可是若要查找某节点的上一个节点,那时间复杂度就是O(n)了。因为我们每次要查找某节点的上一个节点,必须从头指针开始遍历查找。
为了克服单向性这一缺陷,我们设计出了双向链表。双向链表(double linked list)是在单链表的每个节点中,再设置一个指向其前驱节点的指针域。所以在双向链表的每个节点都有两个指针域,一个指向直接后继,另一个指向直接前驱
/*双向链表的存储结构*/
typedef struct DulNode
{
data_t data;
struct DulNode *prior;
struct DulNode *next;
}DulNode,*DuLinkList;
由于双向链表的节点指针有两个,那么对于某节点p,它的后继的前驱是其本身,它的前驱的后继也是其本身,即:
p->next->prior = p = p->prior->next
因为双向链表是单链表扩展出来的结构,因此它的很多操作与单链表是基本相同的。比如获得位置i的节点的数据、遍历打印整个链表、求表长等操作,这些操作都只涉及到一个方向的指针(prior或next),另一个方向的指针基本无用,因此操作与单链表本身并无区别。
对于双向链表的插入/删除操作,需要更改两个指针变量,因此双向链表的插入/删除操作需要注意两个指针变量的操作顺序。
双向链表的插入操作:向节点p与p->next之间插入节点s
//见附图3
注意4步的顺序不能错:
①s->prior = p;
②s->next = p->next;
③p->next->prior = s;
④p->next = s;
由于第二步与第三步都涉及到p->next,如果先行执行第四步的话,会使得p->next节点提前变成s,使得后续的工作无法完成。所以,实现双向链表的插入操作的顺序是:先解决s的前驱和后继,再解决后节点的前驱,最后解决前节点的后继。
//代码见附录
双向链表的删除操作比较简单,若要删除节点p,只需两个步骤即可:
//见附图3
p->prior->next = p->next;
p->next->prior = p->prior;
free(p);
//代码见附录
对于单链表来说,双向链表要复杂一些,对于插入和删除操作要注意其操作顺序。另外每个节点都使用了额外的存储空间来存储前驱节点。但是双向链表有良好的对称性,而且对某节点的前驱节点操作要方便许多。
线性表之链式存储(单链表)
//注意:该文件操作的单链表为带头结点单链表,头结点数据无效
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define OK 1
#define ERROR 0
typedef int data_t;
typedef struct Node
{
data_t data;
struct Node *next;
}Node;
typedef struct Node *LinkList;
int GetElem(LinkList L,int i,data_t *data)//读取单链表的第i个元素
{
int j;
LinkList p;//工作指针
p = L->next;
j = 1;
while(p && j<i)
{
p = p->next;//让p指向下一个节点
j++;
}
if(!p)
{
printf("%d position is error\n",i);
return ERROR;
}
*data = p->data;
return OK;
}
int ListInsert(LinkList L,int i,data_t e)//插入新节点,使其成为第i个节点
{
int j;
LinkList p,s;
p=L;
j=1;
while(p && j<i)//寻找i的位置
{
p=p->next;
j++;
}
if(!p)//说明p为NULL,即没有第i个节点,位置无效
{
printf("%d position is error\n",i);
return ERROR;
}
//若if没有执行则证明位置有效,可以插入数据
s=(LinkList)malloc(sizeof(Node));
s->data=e;
s->next=p->next;
p->next=s;
return OK;
}
int ListDelete(LinkList L,int i,data_t *e)//删除第i个位置节点,数据由e获得
{
int j;
LinkList p,q;
if(L->next==NULL)
{
printf("LinkList is Empty!\n");
return ERROR;
}
p=L;
j=1;
while(p->next && j<i)
{
p=p->next;
j++;
}
if(!(p->next))
{
printf("%d position is error\n",i);
return ERROR;
}
q=p->next;
p->next=q->next;
*e=q->data;
free(q);
return OK;
}
LinkList CreateEmptyLinklist()//创建一个空表,空表只有头结点
{
LinkList p;
p = (LinkList)malloc(sizeof(Node));
if(p==NULL)
{
perror("CreateEmptyLinkList error");
exit(0);
}
p->data=-255;//表示无效数据
p->next=NULL;
return p;
}
LinkList CreateListHead(LinkList L,int n)//创建链表(头插法)
{
LinkList p;
int i;
srand(time(NULL));//初始化随机数种子
for(i=0;i<n;i++)
{
p = (LinkList)malloc(sizeof(Node));
p->data = rand()%100+1;
p->next = L->next;
L->next = p;
}
return L;
}
LinkList CreateListTail(LinkList L,int n)//创建链表(尾插法)
{
LinkList p,r;
int i;
srand(time(NULL));
r = L;
for(i=0;i<n;i++)
{
p = (LinkList)malloc(sizeof(Node));
p->data = rand()%100+1;
r->next = p;
r = p;
}
r->next = NULL;//链表封尾
return L;
}
int ClearList(LinkList L)//清空链表
{
LinkList p,q;
p=L->next;
while(p)
{
q=p->next;
free(p);
p=q;
}
L->next=NULL;
return OK;
}
int PrintList(LinkList L)//遍历打印整个链表
{
LinkList p=L->next;
while(p)
{
printf("%d\t",p->data);
p=p->next;
}
printf("\n");
return OK;
}
int ListReverse(LinkList L)//练习1:单链表反序
{
if(!L)
{
printf("LinkList is not exist\n");
return ERROR;
}
LinkList p,q;
p=L->next;
L->next=NULL;
while(p!=NULL)
{
q=p;
p=p->next;
q->next=L->next;
L->next=q;
}
return OK;
}
LinkList Adjmax(LinkList h)//练习2:寻找最大元素对
{
LinkList p, p1, q;
int m0, m1;
p = h->next;
p1 = p;
if(p1 == NULL)
return p1; //表空返回
q = p->next;
if(q == NULL)
return p1; //表长=1时的返回
m0 = p->data + q->data; //相邻两结点data值之和
while (q->next != NULL)
{
p = q;
q = q->next; //取下一对相邻结点的指针
m1 = p->data + q->data;
if(m1 > m0)
{
p1 = p;
m0 = m1;
}
}//取和为最大的第一结点指针
return p1;
}
int main()
{
/*
LinkList head1,head2;
int i;
data_t data=12;
head1=CreateEmptyLinklist();
head2=CreateEmptyLinklist();
printf("head1\n");
head1=CreateListHead(head1,15);
PrintList(head1);
printf("head2\n");
head2=CreateListTail(head2,15);
PrintList(head2);
scanf("%d",&i);
printf("Insert head1 %d position, data is %d\n",i,data);
ListInsert(head1,i,data);
PrintList(head1);
scanf("%d",&i);
ListDelete(head1,i,&data);
printf("Delete head1 %d position, data is %d\n",i,data);
PrintList(head1);
LinkList adjmax = Adjmax(head1);
printf("Adjmax data is %d, Adjmax data next data is %d\n",adjmax->data,adjmax->next->data);
ListReverse(head1);
printf("Reserve head1:\n");
PrintList(head1);
if(ClearList(head1)==OK)
{
printf("head1 Clear success\n");
}
if(ClearList(head2)==OK)
{
printf("head2 Clear success\n");
}
*/
return 0;
}