初步认识
链表可能是程序员会建立的最简单的数据结构。
然而,什么是数据结构?
数据结构是一种存储和组织数据的方式,旨在便于访问和修改。
在学链表之前,大家肯定学过数组,它也是一个很好用的数据结构,但为何还要学习链表呢?给大家讲个故事
程序猿A开发了一个新的项目,但这个项目要存放大量的数据基础去实现,在测试数据容量需求时候,发现至少需要10MB的容量,但通常栈的大小为2MB,这时候他想到了用动态内存分配。结果几次用malloc()函数申请空间都失败了。
这时候程序员B说malloc()函数申请的堆空间,是比栈大,但它不连续,现在你申请连续的10MB空间根本没有。但我通过动态内存管理发现你那里总共至少有15MB的可用空间,你试着想想办法?
通过上述案例,我们可知无论是在堆还是在栈上去直接开辟足够容下需求的空间都是不可行的。因为数组在空间上是连续!
这时候聪明的程序员C就想到了,既然一个一个不连续的数据存放可以存下,那就先存进去,然后用某种方式去链接起来就可以了!
由此链表就有用武之地了。
其实也可以存放后用指针数组记下每个的位置(地址)。但如果又没有足够的连续空间存放这个数组呢?
链表的基本概念
链表是由通常被称为单元格的对象构建而成。单元格类包含链表必须存储的数据和到另一个单元格的链接。该链接是一个简单引用或是指到另一个单元格类对象的指针。通常单元格的指针域被称为Next。
链表是由结点构成的链式结构,每个结点都包含着两个域:其中存储数据元素信息的域被称为数据域;存储直接后继存储位置 的域被称为指针域。指针域中存储的信息被称做指针或者链。n个结点链结成一个链表。
通过上述两种描述,大家应该对链表有初步的认识了,其实第一种描述的单元格和第二种描述的结点指的是链表中的同一内容。只是不同表达而已!
在第二种描述中我删除了“直接后继存储位置”,其实在单向链表里是完全正确的,但如果放在链表的其他形式中其实是有问题的,例如双向链表:指针域存储了直接后继和直接前驱两个的存储位置。
在C语言里面表示:
struct stu//单向链表结点结构定义
{
char name[20];//数据域
int no;//数据域
struct stu *next;//指针域
};
或者
typedef struct Node//双向链表结点结构定义
{
struct Node *prev;//指针域(指向直接前驱)
double Data;//数据域
struct Node *next;//指针域(指向直接后继)
}Node;
用图表示链表的多种方式
第一种是比较常用的,用矩形表示一个结点,左边为数据域右边为指针域,箭头指向表示指针域中指针指向的方向。
在学指针时候我们知道指针(变量)存放了另一个变量的地址,那么就称这个指针(变量)指向了这个变量,在途中一般用箭头来表示。
第二种表示方式也是很常见的一种表达方式,图形的样式可以是圆可以是其他,这种表示方式简约方便,图形内部只保留数据域,而指针域的指向有箭头来表示,无箭头指出通常认为此指针(变量)不指向其他地方(例如另一个结点)而是空。
第三种是将第二种的指针域展现了出来。
第四种是表示了链表在空间上的排布。白色区域代表堆区,绿色区域代表栈区。红色代表不可用区域(系统禁止或者其他因素)。
第五种用地址来清楚展现了指针域的内容。
学习数据结构最好用图来帮助自己理解,在脑海里形成对应的模样。
这里分享一个学习工具:ProcessOn
单链表的操作
这里以逻辑顺序去展现,不关操作难易程度。
这里假设结点的定义都为
typedef struct Node
{
double Data;
struct Node *next;
}Node;
建立
建立起一个链表是做其他操作的一个基础。
链表的一个用途是提供一个可以存储项的数据结构,这有点像一个当需要更多空间时能随时扩展的数组。
建立无非就是在开头或者结尾添加单元格。这里先谈谈如何添加:
在开头添加单元格
在这之前我们看一个图:
如图所示,如果我们要在开头添加一个新单元格并且命名为Data0。
我们脑海里就能浮现出添加后的结果图。那么如何去实现呢?
这里给出一种函数:
void AddAtBeginning(Node *Head,Node *new_cell)
{
new_cell->next=Head;
Head=new_cell;
}
这里我们传入了链表的头指针Head和指向需要添加的单元格的指针new_cell
接下来按步解析:
new_cell->next=Head;//第一步
new_cell->next//这是new_cell指向的单元格(结点)Data0的指针域中的next指针。
head//这是头指针指向的头节点的地址
那么指针(变量)之间的赋值意味着指针变量里面的地址进行了赋值,指针指向同一地方!
Head=new_cell;//第二步
new_cell//其实是单元格Data0的地址
那么其实就是让Head指向了单元格Data0,也就是意味着此单元格变成了新的头结点。
到这里在开头添加单元格的操作已经讲完了,这也给了链表建立的头插做了基础。
最后说明:因为是函数调用,改变了形参的头指针,如果要改变实际参数还需要返回新的头指针。
改进后正确代码:
Node * AddAtBeginning(Node *head,Node *new_cell)
{
new_cell->next=head;
head=new_cell;
return head;
}
最后我们看添加动图
在结尾添加单元格
同理如果我们想在此链表结尾添加一个新的单元格Data4。那么结果图就是:
这里同样给出代码和解析:
void AddAtEnd(Node *Head,Node *new_cell)
{
while(Head->next)//Head->next != NULL
Head = Head->next;
Head->next=new_cell;
new_cell->next=NULL;
}
首先我们函数收到了头指针和指向新单元格的指针。那么我们就可以通过头指针遍历来找到尾结点。
while(Head->next)//Head->next != NULL
Head = Head->next;
注释表示这两种放在条件里面都是一样的,因为NULL通常被定义为
((void *)0)
也是就是说如果头指针Head指向的结点的指针域中next指针为空指针时候,条件是为假的。
Head = Head->next; //这是一个很常见的移动(遍历)方式
如果用图去表示那就是
先判断指针Head指向的结点的指针域中next指针为不为空,第一次发现Head指向头结点Data0,Data0的指针域中next指针不为空,那么就进行移动,让其和单元格Data0的指针域的指针指向相同。
然后再判断下一个结点的指针域中next指针为不为空?然后再选择移动还是不移动。
以此类推:直到
这时候Head指向单元格Data3,但单元格Data3的指针域的next指针为空指针,条件不满足,循环结束,这时候Head找到了尾结点。
这时候我们只需要改变尾结点的指针域中的next指针,让其指向新单元格
也就是:
Head->next=new_cell;
然后更新一下尾结点的指针域,以便后续操作
new_cell->next=NULL;
最后讲讲这个写法的弊端:如果头指针Head是空指针,那么下面的步骤就是非法的,所以在这里改进了一下并且利用了尾指针Tail去找尾结点:
Node *AddAtEnd2(Node *Head,Node *new_cell)
{
if(!Head)return Head=new_cell;//如果是空表,直接添加即可
Node *Tail=Head;
while(Tail->next)//Tail->next != NULL
Tail = Tail->next;
Tail->next=new_cell;
new_cell->next=NULL;
return Head;//如果用头指针去找,最后无法返回有效的头指针(头指针没指向头结点)
}
开始建表
建立链表有两种方式,第一种是头插法:
Node * Head_Creat()
{
Node *Head=NULL;//初始化头指针,也就是建立空表
double num;
while(scanf("%lf",&num)&&num)//判断结束如果num==0那么结束
{
Node *p=(Node *)malloc(sizeof(Node));//开辟空间
p->Data=num;//更新数据域
p->next=NULL;//更新指针域(其实可以不需要)
Head=AddAtBeginning(Head,p);//在链表头部插入新结点
}
return Head;
}
空表:无结点的链表,也就是意味着头指针为空指针
实际将刚刚讲过的函数展开就是:
Node * Head_Creat()
{
Node *Head=NULL;
double num;
while(scanf("%lf",&num)&&num)
{
Node *p=(Node *)malloc(sizeof(Node));
p->Data=num;
p->next=NULL;
p->next=Head;//新结点指向头结点
Head=p;//头指针指向新结点,新结点成为新头结点
}
return Head;
}
第二种尾插法:
Node * Tail_Creat()
{
Node *Head,*Tail;
Head=Tail=NULL;//建立空表
double num;
while(scanf("%lf",&num)&&num)
{
Node *p=(Node *)malloc(sizeof(Node));//开辟空间
p->Data=num;//更新数据域
p->next=NULL;//更新指针域
if(!Head)//如果是空表,直接添加即可
Head=Tail=p;
else
AddAtEnd(Head,p);//如果不判断,Head等于NULL时候函数内部会出现错误
}
return Head;
}
或者是:
Node * Tail_Creat()
{
Node *Head,*Tail;
Head=Tail=NULL;
double num;
while(scanf("%lf",&num)&&num)
{
Node *p=(Node *)malloc(sizeof(Node));
p->Data=num;
p->next=NULL;
//if(!Head)
// Head=Tail=p;
//else
Head=AddAtEnd2(Head,p);
}
return Head;
}
展开来就是:
Node * Tail_Creat()
{
Node *Head,*Tail;
Head=Tail=NULL;//建立空表
double num;
while(scanf("%lf",&num)&&num)
{
Node *p=(Node *)malloc(sizeof(Node));
p->Data=num;
p->next=NULL;
if(!Head)
Head=Tail=p;//第一个结点建立
else
Tail->next=p;//原来的尾结点链接新的尾结点
Tail=p;//尾指针指向新的尾结点
}
return Head;
}
程序展示:
#include <stdio.h>
#include <stdlib.h>
typedef struct Node
{
double Data;
struct Node *next;
}Node;
Node * AddAtBeginning(Node *head,Node *new_cell);
Node *AddAtEnd2(Node *Head,Node *new_cell);
void AddAtEnd(Node *Head,Node *new_cell);
Node * Tail_Creat();
Node * Head_Creat();
void show(Node *p)
{
while(p)
{
printf("%.2f\n",p->Data);
p=p->next;
}
}
int main()
{
Node* Head=Tail_Creat();
Node *p=(Node*)malloc(sizeof (Node));
p->Data=3.1415;
p->next=NULL;
Node *q=(Node*)malloc(sizeof (Node));
q->Data=100.1;
q->next=NULL;
Head=AddAtEnd2(Head,p);
Head=AddAtBeginning(Head,q);
show(Head);
return 0;
}
Node * AddAtBeginning(Node *head,Node *new_cell)
{
new_cell->next=head;
head=new_cell;
return head;
}
void AddAtEnd(Node *Head,Node *new_cell)
{
while(Head->next)//Head->next != NULL
Head = Head->next;
Head->next=new_cell;
new_cell->next=NULL;
}
Node *AddAtEnd2(Node *Head,Node *new_cell)
{
if(!Head)return Head=new_cell;
Node *Tail=Head;
while(Tail->next)//Tail->next != NULL
Tail = Tail->next;
Tail->next=new_cell;
new_cell->next=NULL;
return Head;
}
Node * Tail_Creat()
{
Node *Head,*Tail;
Head=Tail=NULL;
double num;
while(scanf("%lf",&num)&&num)
{
Node *p=(Node *)malloc(sizeof(Node));
p->Data=num;
p->next=NULL;
if(!Head)
Head=Tail=p;
else
Tail->next=p;
Tail=p;
}
return Head;
}
Node * Head_Creat()
{
Node *Head=NULL;
double num;
while(scanf("%lf",&num)&&num)
{
Node *p=(Node *)malloc(sizeof(Node));
p->Data=num;
p->next=NULL;
p->next=Head;
Head=p;
}
return Head;
}
遍历
在建立完链表后,对应数据已经存进去了,那么如何去读取使用呢?
单向链表只能从头指针开始遍历(我们也只知道头指针)。
那么能不能从某一个结点开始呢?不行,前面的数据你不要了?
遍历的用途之一是搜索,能不能加快搜索进度呢?排序后建立并且使用跳表
假设程序已经建立了一个链表,遍历它的单元格是比较容易的。
下面给出C代码:
void show(Node *Head)
{
while(Head)
{
printf("%.2f\n",Head->Data);//打印数据域
Head=Head->next;//移动
}
}
其实打印数据域很简单,那么移动其实用图表示就是
开始判断是不是空表
while(Head)//Head != NULL
然后打印Head指向单元格的数据域。
接下来移动
Head=Head->next;//移动
Head->next //Head指向单元格的指针域中next指针
继续判断然后循环
这时候Head指针为空指针了,意味着链表结束。遍历完成!
因为没有对链表进行修改,所以函数无需返回值。
查找
查找其实就是在遍历的基础上实现的。也非常简单。
我们在遍历的时候是不是打印了所有单元格的数据域。
也就是意味着我们访问了所有单元格中的数据
只需要把访问加上判断就可以了!
这里显示了其中三种查询代码,
判断目标数据在不在链表中:
_Bool FindCell(Node *Head,double target)
{
while(Head)
{
if(Head->Data==target)return 1;//在
Head=Head->next;
}
return 0;//不在
}
第二种,给位置找数据:
double FindCell_Data(Node *Head,int target_size)
{
int count=1;//计数:第几个单元格(一开始头指针指向第一个单元格)
while(Head)
{
if(count==target_size)return Head->Data;//找到了,返回目标单元格的数据
Head=Head->next;count++;
}
return -1;//没找到
}
第三章,给数据找位置:
int FindCell_Data(Node *Head,double target)
{
int count=1;//依旧是计数
while(Head)
{
if(Head->Data==target)return count;//找到目标数据,返回在链表的位置
Head=Head->next;count++;
}
return -1;//NO FIND
}
插入
前面讲解了如何在链表开头和结尾添加单元格,但有的时候我们想在链表的中间某一个单元格后面去插入一个单元格,这个时候该怎么做呢?
假设我们有个链表:
还有一个被new_cell指针指向的单元格Data1.5,我们要把它插入到目标单元格Data1后面。
假设有现在有一个指针after_me已经指向了单元格Data1。
意味着我们需要在after_me所指向的单元格之后插入新的单元格。
下面给出C语言代码:
void InsertCell (Node *after_me,Node *new_cell)
{
new_cell->next=after_me->next;
after_me->next=new_cell;
}
这里给出开始前的状态:
第一步:
new_cell->next=after_me->next;//更新要插入的单元格的指针域
第二步:
after_me->next=new_cell;//将其插入链表中
插入其实已经接近尾声了,现在结合前面的查找就可以找到需要插入的地方也就是after_me。然后在考虑一下如果是在头插入,即在开头添加单元格的情况和尾插入,即在结尾添加单元格的情况即可。
下面给出完整代码:
Node * Find_Cell(Node *Head,double Data)//找到目标单元格
{
while(Head)
{
if(Head->Data>Data)return Head;
Head=Head->next;
}
return Head;
}
Node * Insert_Data (Node *Head,double DATA)//插入
{
Node *after_me= Find_Cell(Head,DATA);
Node *p=(Node *)malloc(sizeof (Node));
p->Data=DATA;
p->next=NULL;
if(after_me==Head)//如果是开头
Head= AddAtBeginning(Head,p);
else
{
if(after_me)//判断是不是在尾部
InsertCell(after_me,p);
else
Head=AddAtEnd2(Head,p);
}
return Head;
}
void InsertCell (Node *after_me,Node *new_cell)
{
new_cell->next=after_me->next;
after_me->next=new_cell;
}
删除
当一个数据不需要的时候,我们就可以把这个结点从链表中删除。
当我们找到了要删除的单元格的前一个单元格,我们可以直接改变此单元格的指针域,让其指向要删除的单元格的直接后继即可。
这里给出图示:
C语言代码表示
void DeleteAfter(Node *after_me)
{
after_me->next=after_me->next->next;
}
结果图示:
在C#和VB中使用一种有利于内存管理的垃圾回收方案。这意味着,当程序需要更多存储空间时,被删除的单元格会被自动回收。但如果是C语言,还是需要执行额外的工作来正确释放被删除的单元格。
例如:
void DeleteAfter(Node *after_me)
{
Node *p=after_me->next;
after_me->next=after_me->next->next;
free(p);
}
用单独的指针去保存要删除的单元格的地址,以便该单元格在移出链表后能被找到并且正确释放。
但如果要删除目标单元格,在程序中我们还需要利用查找去定位after_me。
接下来展示删除链表:
其实无非就是遍历加上删除单元格的操作
void DestroyList(Node *Head)
{
while(Head)
{
Node *p=Head->next;//保存下一个结点
free(Head);
Head=p;//更新新头节点
}
}
链表VS数组
在学完链表的一系列操作以后,我们可以将开头提及到的数组和链表进行比较。
链表优点
链表的优点大致可分为以下三个:
1.链表对内存的利用率比较高,无需连续的内存空间,即使有内存碎片,也不影响链表的创建;
2.链表的插入和删除的速度很快,无需像数组一样需要移动大量的元素;
3.链表大小不固定,可以很方便的进行动态扩展。
链表缺点
链表的主要缺点是不能随机查找,必须从第一个开始遍历,查找效率比较低,链表查询的时间复杂度是 O(n)。
数组的优点
数组的“连续”特征决定了它的访问速度很快,因为它是连续存储的,所以这就决定了它的存储位置就是固定的,因此它的访问速度就很快。
数组的缺点
缺点它对内存的要求比较高,必须要找到一块连续的内存才行。
数组的另一个缺点就是插入和删除的效率比较慢,假如我们在数组的非尾部插入或删除一个数据,那么就要移动之后的所有数据,这就会带来一定的性能开销
数组还有一个缺点,它的大小固定,不能动态拓展。