今天是封校的第18天,什么时候能出宿舍啊,我快发霉了…
笔记(2): 链表结构之单链表
上一篇:《数据结构》学习笔记(1)--------继疫情封宿舍摆烂16天之后被迫开始卷
目录
线性表链式的提出
顺序存储结构中,其缺点尤为明显:当插入和删除时,需要移动大量元素,很消耗时间。针对于上述问题。某大佬提出了这样一种思想:反正也是要让相邻元素知道互相的位置,那干脆不要所谓的物理上的顺序结构了,每个元素知道自己本身的值已经下一个元素的位置不就好了,小量牺牲空间,添加一个指针,这样在进行插入,删除或者其他操作的时候就能大量节省时间。
(当然,大佬的原话不是这样,我只是为了方便理解才这么写的)
线性表链式存储结构定义
链式存储结构其特点是用一组任意的的存储单元存储线性表的数据元素。其存储单元是连续的也可以是不连续的,而其物理空间地址可以存在于内存中任意没有被占用过的位置
如下图:
线性表链式存储结构一般是由各个结点连接而成,那么结点是什么呢?
结点
结点(Node)
以前的顺序结构中,每个数据元素只要存储元素本身的信息即可。而现在这种链式结构,有两部分:数据域和指针域。除了要存储数据信息之外,还要存储它的后继元素的相关线索(存储地址)
因此,为了表示每个数据元素a(i)与其直接后继数据元素a(i+1)之间的逻辑关系,对于数据元素a(i)来说,需要存储两个东西:本身数据 以及后继数据元素的地址信息。 这两部分信息组成数据元素a(i)的存储映像,
称之为结点(Node)
链表
链表(Linked list)
n个节点链成一个链表,即为线性表的链式存储结构,因为此链表的每个节点中只包含一个指针,所以叫做单链表,如下图
对于线性表来说,总有个头和尾,链表亦如此。我们将链表中的第一个结点的存储位置叫做头指针,整个链表的存取必须从头指针开始。之后,每一个结点包含自己的数据以及指向下一个结点的指针,那么以此类推,到最后一个结点呢?
最后一个结点包含数据域以及一个空指针。因为它是最后一个结点,而不再需要指向下一个结点了。(通常用NULL表示)
头指针和头结点
有时候,为了方便对链表进行操作,会在单链表之前设置一个结点,其称为头结点,头结点可以不存储任何信息,也可附存线性表长度等信息。头结点的指针域存储指向第一个结点的指针.
头指针:
①是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针。
②头指针有标识作用,常用头指针冠以链表的名字
③无论链表是否为空,头指针均不能为空。头指针是链表的必要元素。
头结点:
①为了操作的统一和方便而设立的,放在第一元素的结点之前,其数据域一般无意义(或者存放链表长度)
②有了头结点,对在第一元素结点前插入节点和删除第一节点与一般其他的结点的操作就统一了
③头结点不一定是链表必须要素
代码实现线性表链式存储结构
Typedef struct Node
{
ElemType data; //数据域
Struct Node *next; //指针域
}Node;
Typedef struct Node *LinkList; /*定义LinkList*/
后文不再赘述以上操作,默认已执行
从这个结构体定义中可知,结点由存放数据元素的数据域 和 存放后继结点地址的指针域组成。
假设p是指向线性表第i个元素的指针,那么p->data 的值是一个数据元素,结点a(i)的数据域可以用p->next来表示, p->next的值是一个指针,其指向了第i+1个元素,即指向a(i+1)的指针。也就是说p->next->data = a(i+1)
单链表的读取
在顺序存储结构中,想计算一个元素的存储位置很容易。但是在单链表中,因为物理地址的无序性(在一定程度上),我们不能直接计算出第i个元素的所在物理地址。因此,需要一点点的算法来解决这个问题
获取链表的第i个数据的算法思路是这样的:
①声明一个结点p指向链表第一个结点,初始化j从1开始;
②当j<1时,遍历链表,让p的指针向后移动,不断指向下一结点,j累加1
③若遇到链表末尾,也就是p为空,则说明第i个元素不存在
④否则查找成功,返回结点p的数据。
实现代码如下:
/*初始条件下,顺序线性表L已经存在,1 <= i <= ListLength(L)*/
Status GetElem(LinkList L, int I, ElemType *e)
{
int j;
LinkList p; //创建一个结点
p = L->next;//让p指向链表L的第一个结点
j = 1 ;//j为计数器
while(p && j<i) //p为空或者计数器j等于i时结束
{
p = p->next;
++j;
}
if (!p || j>i)
return ERROR;
*e = p->data;
return True;
}
上述代码,会从链表表头开始找,直到第i个元素位置用e返回L中第i个结点的数据,或者报错结束。这个算法的时间复杂度取决于i,最好的情况便是O(1) 最糟糕的情况就是O(n)。
由于在单链表中,并未定义表长,所以并不能够事先知道循环次数,也就不方便用for来控制,在单链表中其核心思想就是**“工作指针后移”**,虽然看上去及其复杂,但是链表却是大有用处。
单链表的插入与删除
插入
插入:对于单链表的插入,假设,我们将要存储的数据元素为e,但是并不能将元素直接插进链表中,要实现这个插入,先将e组装成一个结点s, 那么,现在将问题就转化为,如何把结点s插入链表
如上图,单链表的插入时,似乎不必像顺序结构链表那样大动干戈。只需要让s->next和p->next的指针做一点改变: s->next = p->next; p->next = s 即可,图示如下:
注意:这两句代码的先后顺序不可更改!!!!
如果先执行了p->next = s,后直行s->next = p->next 会发生什么? 那么会发现,原先a(i)的结点的指针域指向了s,但是s结点中的指针域又指向了自己,这就GG了,显然不行。
成功插入结点之后,如下图
对于单链表的表头和表尾的特殊情况,操作仍然相同
针对于结点插入的算法思路:
①声明一个结点p指向链表第一个结点,初始化j从1开始。
②当j<1时,就遍历链表,让p的指针向后移动,不断指向下一结点,j累加1
③若到链表末尾p为空,则说明第i个结点不存在
④如果查找成功,在系统中生成一个空结点s
⑤将数据元素e赋值给s->data
⑥单链表的插入标准语句 s->next = p->next, p->next = s;
⑦返回成功。
插入代码实现
//假设L链表已经存在
Status ListInsert(LinkList *L, int i, ElemType e)
{
int j;
LinkList p,s;
p = *L;
j = 1;
while(p && j < i) //寻找第i个结点
{
p = p->next;
++j;
}
if(!p || j>i)
{
return ERROR; //第i个元素不存在
}
s = (LinkList)malloc(sizeof(Node));//开辟空间生成新结点
s->data = e;
s->next = p->next;
p->next = s;
return True;
}
对于单链表的插入和删除,我们从代码中可以发现,都是由两部分组成: 遍历查找,对于单节点的插入或删除。对于上述,我们可以推导出其时间复杂度都是O(n)。但是针对于大量的数据的插入或者删除,单链表显然要优越于顺序结构。
删除
删除操作类似于插入。设存储元素a(i)的结点为q,要实现将结点q删除。其实就是让q之前和q之后进行连接,再将q释放即可。如下图
我们所要做的其实就是一步:p->next = p->next->next; 或者 p->next = q->next
算法思路:
①声明一节点p指向链表第一个结点,初始化j从1开始
②当j<i时,就遍历链表,让p指针后移,不断指向下一个节点,j累加1
③若查到链表末尾,则说明i不存在
④如查找成功,将将要删除的结点p->next赋值给q
⑤执行标准删除语句p->next = q->next
⑥将q结点中的数据赋值给e,作为返回值
⑦释放q,返回成功
删除代码实现
//假设L链表已经存在
Status LinkListDelete(LinkList *L, int I,ElemType *e)
{
int j=1;
LinkList p,q;
p = *L;
while(p->next && j<i)//遍历链表
{
p = p->next;
++j;
}
if(!(p->next) || j>i ) //查询失败
{
return ERROR;
}
q = p->next;
p->next = q->next;//将q的后继和q之前连接起来
*e = q->data; //将删除的数据返回给e
free(q); //释放内存
return True;
}
对于单链表的插入和删除,我们从代码中可以发现,都是由两部分组成: 遍历查找,对于单节点的插入或删除。对于上述,**我们可以推导出其时间复杂度都是O(n)。**但是针对于大量的数据的插入或者删除,单链表显然要优越于顺序结构。
单链表的整表创建
头插法
根据目前我们对单链表的了解,对于单链表的创建的思路可以分为两步:
①声明一个结点和计数器变量,初始化一个 空链表L,让L的头结点指向NULL,即创建一个带头结点的单链表
②然后循环:
生成一个结点赋值给p
将数据赋给p的数据域
将p插入到头结点与上次创建的新结点之间。
代码如下:
Void CreateListHead(LinkList *L, int n)
{
LinkList p;
int j;
*L = (LinkList)malloc(sizeof(Node));
(*L) - >next = NULL; //先建立起一个带头结点的单链表
srand(time(0));
for(i=0; i<n;i++)
{
p = (LinkList)malloc(sizeof(Node));
p->data = rand()%100 ; //生成100以内的数字
p->next = (*L)->next;//插到表头
(*L)->next = p;
}
}
通过对上述代码进行分析,我们可以获知其运行如下图所示,这总会将新的结点位于第一个结点。我们称之为头插法
尾插法
当然,尾插法也存在:
上代码!
Void CreateListTall(LinkList *L, int n)
{
int i;
LinkList p,r;
srand(time(0));
*L = (LinkList)malloc(sizeof(Node));//初始化整个线性表
r = *L;
for(i=0;i<n;i++)
{
p = (Node *)malloc(sizeof(Node));//创建结点
p->data = rand()%100;
r->next = p;//将表尾终结点的指针指向新结点
r = p; //将当前新结点定义为表尾终端结点
}
r->next = NULL;//当前链表结束
}
上述代码中,L是指整个单链表,而r是指向尾结点的r会随着循环不断的变化,而L则是随着循环增长为一个多结点的链表。
下图阐述了for循环中的最后两行代码
至此,单链表的创建就已经完成了。
单链表的整表删除
当我们不打算再使用某个已经创建过的链表时,需要将其销毁,也就是在内存中将它释放掉。以便留出空间给其他程序使用。
单链表的整表删除思路如下:
①声明结点p和q
②将第一个结点赋给p;
③循环:
将下一结点赋给q
释放p
将q赋给p
代码实现如下:
Status ClearList(LinkList *L)
{
LinkList p,q;
p = (*L)->next; //p指向第一个结点
while(p)//一直循环到表尾
{
q =p->next;
free(p);
p = q;
}
(*L)->next = NULL;//头结点的指针域直接指向NULL
return True;
}
这里创建了p和q,为什么要多于创建一个这样的结点呢?因为当程序在释放结点时,释放的不只是数据,还有指针域的指针。如果不提前将p的指针域保留下来,就无法继续索引到下一个结点了。
小结
简单总结一下单链表和顺序存储结构的区别:
一、存储分配方式:
顺序存储结构采用物理地址连续的的存储单元
单链表采用链式结构,任意的存储单元存放线性表的元素
二、时间性能:
对于顺序存储结构:
查找元素时其时间复杂度为O(1), 插入和删除的时间复杂度为O(n)
对于单链表:
查找元素时其时间复杂度为O(n), 插入和删除时时间复杂度也为O(n)
但是单链表耗时主要在查询,查询到之后,操作的复杂度仅为O(1)
三、空间性能:
顺序存储结构需要预分配存储空间,可能会造成浪费或者溢出。
单链表不需要分配存储空间,有需就可以分配,不受个数限制。