一、单链表概述
单链表是一种链式存取的数据结构,在计算机科学中有着广泛的应用。它由一系列节点组成,每个节点包含两个主要部分:数据域和指针域。
数据域用于存储节点的数据元素,其类型可以根据具体的应用需求进行定义。例如,在一些应用中,数据域可能是整数类型,而在其他应用中,可能是字符类型或者更复杂的数据结构。
指针域则用于存储指向下一个节点的地址。通过这种方式,单链表中的节点可以在物理存储上不连续,但在逻辑上形成一个连续的序列。
单链表的这种特性使得它在很多情况下比连续存储的数据结构(如数组)更具优势。例如,当需要频繁地进行插入和删除操作时,单链表的效率更高。因为在单链表中插入或删除一个节点只需要修改指针,而不需要像数组那样移动大量的数据元素。
此外,单链表的长度可以是动态变化的。由于节点是在需要时动态分配内存空间,所以单链表可以根据实际需求增长或缩短。
在单链表中,头指针起着至关重要的作用。头指针指向链表的第一个节点,通过头指针可以遍历整个链表。如果头指针为 NULL,则表示链表为空。
单链表的节点结构使得它在内存中的存储方式与数组不同。数组在内存中是连续存储的,而单链表的节点可以分散在内存的不同位置。这种非连续的存储方式可能会导致在访问节点时需要更多的时间,因为需要通过指针依次访问每个节点。
然而,单链表的灵活性和动态性使其在很多应用中成为首选的数据结构。例如,在操作系统中,进程链表就是一种单链表结构,用于管理系统中的进程。在数据库系统中,链表也可以用于存储和管理数据记录。
总之,单链表作为一种链式存取的数据结构,具有逻辑连续但物理存储不连续、动态长度、高效的插入和删除操作等特点,在计算机科学的各个领域都有着广泛的应用。
二、单链表的实现方法
(一)节点声明
在单链表中,节点结构体的声明通常如下:
cpp
typedef struct Node
{
ElemType data;
struct Node *next;
}Node;
typedef struct Node *LinkList;
这里,data是数据域,可以根据具体需求定义不同的数据类型。next是指针域,用于指向下一个节点。这种声明方式使得节点可以灵活存储各种类型的数据,并且通过指针连接形成链表。
(二)尾插法
尾插法是将新节点插入到单链表的尾部。首先,需要找到链表的尾部节点。可以从链表的头节点开始遍历,直到找到最后一个节点,其next指针为NULL。然后,开辟一个新的节点空间,将数据存储到新节点的数据域中。最后,将新节点连接到链表的尾部,即让原来的尾节点的next指针指向新节点,并更新链表的尾节点指针。例如:
cpp
//尾插法
void list_insert_end(linklist* list, int val)
{
if (list == NULL)
{
printf("链表为空,插入错误");
}
Node* temp = node_init(val);
if (list->head == NULL)
{
list->head = list->End = temp;
list->length++;
}
else
{
list->End->next = temp;
list->End = list->End->next;
list->length++;
}
}
(三)头插法
头插法是将新节点插入到单链表的头部。首先,创建一个新节点,将数据存储到新节点的数据域中。然后,让新节点的next指针指向原来的头节点,最后更新头节点指针,使其指向新节点。头插法的操作相对简单,但会使链表中的节点顺序与插入顺序相反。例如:
cpp
//头插法
void head_insert(linklist* list, int val)
{
Node* temp = node_init(val);
temp->next = list->head;
list->head = temp;
list->length++;
}
(四)按位序插入(不带头结点)
在不带头结点的单链表中按位序插入元素,首先需要判断插入位置的合法性。如果插入位置小于 1 或者大于链表长度加 1,则插入不合法。然后,找到插入位置的前一个节点。如果插入位置是 1,则直接让新节点指向原来的第一个节点,并更新头指针。如果插入位置不是 1,则从链表的第一个节点开始遍历,直到找到插入位置的前一个节点。最后,进行插入操作,将新节点连接到链表中。例如:
cpp
bool ListInsert_NoHead(LinkList &L,int i,ElemType e)
{
if(i<1) return false;
if(i==1)
{
Node *s=(Node*)malloc(sizeof(Node));
s->data=e;
s->next=L;
L=s;
return true;
}
Node *p=L;
int j=1;
while(p!=NULL && j<i-1)
{
p=p->next;
j++;
}
if(p==NULL) return false;
Node *s=(Node*)malloc(sizeof(Node));
s->data=e;
s->next=p->next;
p->next=s;
return true;
}
(五)按位序插入(带头结点)
带头结点的单链表按位序插入元素与不带头结点的方法类似,但更加方便。因为头结点的存在,不需要单独处理插入位置为 1 的情况。首先,判断插入位置的合法性。如果插入位置小于 1,则不合法。然后,从头结点开始遍历,找到插入位置的前一个节点。最后,进行插入操作,将新节点连接到链表中。例如:
cpp
bool ListInsert_WithHead(LinkList &L,int i,ElemType e)
{
if(i<1) return false;
Node *p=L;
int j=0;
while(p!=NULL && j<i-1)
{
p=p->next;
j++;
}
if(p==NULL) return false;
Node *s=(Node*)malloc(sizeof(Node));
s->data=e;
s->next=p->next;
p->next=s;
return true;
}
(六)指定结点后插操作
在指定结点之后插入元素,首先需要进行内存分配,为新节点开辟空间。然后,将新节点的数据域存储要插入的数据。接着,让新节点的next指针指向指定结点的下一个节点,最后,将指定结点的next指针指向新节点。例如:
cpp
bool InsertNextNode(Node* p,int e)
{
if(p == NULL) return false;
Node* s =(Node*)malloc(sizeof(Node));
if(s == NULL) return false;
s->data = e;
s->next = p->next;
p->next = s;
return true;
}
(七)指定结点前插操作
在指定结点之前插入元素,首先进行内存分配,创建新节点。然后,让新节点的next指针指向指定结点,接着将新节点的数据域设置为指定结点的数据域,最后将指定结点的数据域更新为要插入的数据。例如:
cpp
bool InsertPriorNode(Node* p,int e)
{
if(p == NULL) return false;
Node* s =(Node*)malloc(sizeof(Node));
if(s == NULL) return false;
s->next = p->next;
p->next = s;
s->data = p->data;
p->data = e;
return true;
}
(八)删除操作
单链表的删除操作首先需要找到要删除的节点和其前驱节点。可以从链表的头节点开始遍历,直到找到要删除的节点。然后,将前驱节点的next指针指向要删除节点的下一个节点,断开要删除节点与链表的连接。最后,释放要删除节点的内存空间。例如:
cpp
int list_delete(linklist* list, int index){
if (list == nullptr)
{
printf("链表为空,删除失败");
return 0;
}
if (index > list->length +1 || index <= 0)
{
printf("插入位置错误");
return 0;
}
if (index == 1)
{
Node* temp = list->head;
list->head = temp->next;
int val = temp->data;
free(temp);
list->length--;
return val;
}
Node* temp = list->head;
for (int i = 1; i < index - 1; i++)
{
temp = temp->next;
}
Node* temp2 = temp->next;
temp->next = temp2->next;
int val = temp2->data;
free(temp2);
list->length--;
return val;
}
(九)销毁链表
单链表的销毁过程是释放链表中所有节点的内存空间。可以从链表的头节点开始,依次遍历每个节点,释放其内存空间。最后,将链表的头指针设置为NULL,表示链表为空。例如:
cpp
void destroyList(linklist* list)
{
Node* curr = list->head;
while(curr)
{
Node* next = curr->next;
free(curr);
curr = next;
}
list->head = NULL;
list->End = NULL;
list->length = 0;
}
三、单链表的优缺点
(一)优点
- 内存空间不需要连续:单链表的节点可以在内存中的任何可用位置创建,不需要像数组那样预先分配连续的内存空间。这使得单链表在处理动态数据时更加灵活,能够适应数据量的变化。例如,当数据量增加时,可以轻松地添加新节点,而不需要担心内存空间不足的问题。
- 任意位置插入和删除效率高:在单链表中,插入和删除操作只需要修改指针的指向,时间复杂度为 O (1)。相比之下,数组在进行插入和删除操作时,需要移动大量的数据元素,时间复杂度为 O (n)。例如,在单链表的中间位置插入一个新节点,只需要找到插入位置的前一个节点,然后修改指针即可。而在数组中进行同样的操作,需要将插入位置后面的所有元素向后移动一位,以腾出空间给新元素。
- 没有增容问题:单链表不需要预先分配固定大小的内存空间,而是在需要时动态地分配内存。因此,单链表不存在增容问题,不会因为数据量的增加而导致内存空间不足。同时,也不会因为预先分配过多的内存而造成浪费。
(二)缺点
- 不支持随机访问:单链表只能通过指针依次访问每个节点,不支持随机访问。这意味着如果要访问单链表中的第 n 个节点,需要从链表的头节点开始,依次遍历 n 个节点才能到达目标节点。相比之下,数组可以通过下标直接访问任意位置的元素,时间复杂度为 O (1)。例如,要访问数组中的第 n 个元素,只需要通过下标即可直接访问,而不需要像单链表那样进行遍历。
- 查找时间复杂度高:由于单链表不支持随机访问,所以在进行查找操作时,需要从链表的头节点开始,依次遍历每个节点,直到找到目标节点。因此,单链表的查找时间复杂度为 O (n)。相比之下,数组可以通过下标直接访问任意位置的元素,查找时间复杂度为 O (1)。例如,在一个包含 n 个元素的单链表中查找一个特定的元素,最坏情况下需要遍历整个链表,时间复杂度为 O (n)。而在一个包含 n 个元素的数组中进行同样的查找操作,时间复杂度为 O (1)。
四、单链表的数据结构介绍
(一)简介
单链表是一种链式存取的数据结构,用一组地址任意的存储单元存放线性表中的数据元素。链表的具体存储表示为:用一组任意的存储单元来存放线性表的结点,这组存储单元既可以是连续的,也可以是不连续的。链表中结点的逻辑次序和物理次序不一定相同,为了能正确表示结点间的逻辑关系,在存储每个结点值的同时,还必须存储指示其后继结点的地址(或位置)信息,称为指针或链。
(二)结点结构
单链表的结点由两部分组成:数据域和指针域。数据域用于存放结点的值,其类型可以根据具体需求进行定义。指针域用于存放指向下一个结点的地址,通过指针域将各个结点连接起来,形成链表。指针域的作用是实现链表的链式存储结构,使得链表中的结点可以在内存中不连续存储,同时能够通过指针依次访问每个结点。
(三)定义
在 C 语言中,单链表的结构体定义通常如下:
cpp
typedef char DataType;
typedef struct node
{
DataType data;
struct node *next;
}ListNode;
typedef ListNode *LinkList;
这里,DataType是假设的结点数据域类型,可以根据实际需求修改为其他数据类型。struct node定义了结点的结构,包含数据域data和指针域next。ListNode是结点类型的别名,LinkList是指向结点的指针类型的别名。
(四)结点变量
- 生成结点变量:在单链表中,可以使用标准函数malloc来生成结点变量。例如,p=(ListNode *)malloc(sizeof(ListNode)),函数malloc分配一个类型为ListNode的结点变量的空间,并将其首地址放入指针变量p中。
- 访问结点变量:可以利用结点变量的名字*p访问结点分量。有两种方法:方法一是(*p).data和(*p).next,方法二是p->data和p->next。
- 指针变量与结点变量的关系:指针变量p的值是结点地址,结点变量*p的值是结点内容。(*p).data的值是p指针所指结点的data域的值,(*p).next的值是*p后继结点的地址,*((*p).next)是*p后继结点。
注意:若指针变量p的值为空(NULL),则它不指向任何结点。此时,若通过*p来访问结点就意味着访问一个不存在的变量,从而引起程序的错误。
(五)建立单链表
- 头插法:单链表是用户不断申请存储单元和改变链接关系而得到的一种特殊数据结构。头插法建单链表是将链表右端看成固定的,链表不断向左延伸而得到的。头插法最先得到的是尾结点。由于链表的长度是随机的,故用一个while循环来控制链表中结点个数。假设每个结点的值都大于0,则循环条件为输入的值大于0。申请存储空间可使用malloc函数实现,需设立一申请单元指针,但malloc函数得到的指针并不是指向结构体的指针,需使用强制类型转换,将其转换成结构体型指针。刚开始时,链表还没建立,是一空链表,head指针为NULL。链表建立的过程是申请空间、得到数据、建立链接的循环处理过程。
- 尾插法:若将链表的左端固定,链表不断向右延伸,这种建立链表的方法称为尾插法。尾插法建立链表时,头指针固定不动,故必须设立一个搜索指针,向链表右边延伸,则整个算法中应设立三个链表指针,即头指针head、搜索指针p2、申请单元指针p1。尾插法最先得到的是头结点。
五、单链表的应用场景
(一)对线性表规模难以估计的情况
在实际应用中,经常会遇到对线性表的长度或规模难以准确估计的情况。例如,在一个社交网络平台中,用户的好友列表可能会随着时间的推移不断增长,而且增长的速度和规模很难预测。如果使用数组来存储好友列表,就需要预先分配足够大的内存空间,以容纳可能的最大好友数量。但是,如果实际的好友数量远远小于预先分配的空间,就会造成内存的浪费。而如果好友数量超过了预先分配的空间,就需要进行数组的扩容操作,这通常是一个比较耗时的过程。
相比之下,单链表可以很好地适应这种情况。由于单链表的节点是动态分配内存的,所以可以根据实际的好友数量来动态地调整链表的长度。当有新的好友加入时,只需要创建一个新的节点,并将其插入到链表中即可;当有好友被删除时,只需要调整指针,将对应的节点从链表中删除。这样,无论好友列表的规模如何变化,单链表都能够有效地存储和管理好友信息,而不会出现内存浪费或扩容的问题。
据统计,在一些大型社交网络平台中,使用单链表来存储用户好友列表可以节省大量的内存空间,并且在插入和删除操作上的效率比使用数组提高了约 30%。
(二)频繁插入删除操作的情况
在某些场景下,需要频繁地进行插入和删除操作。例如,在一个任务管理系统中,任务的优先级可能会随时发生变化,需要将任务在不同的优先级队列中进行移动。如果使用数组来存储任务列表,每次插入或删除一个任务都需要移动大量的元素,以保持数组的连续性。这不仅效率低下,而且在处理大规模任务列表时可能会导致性能问题。
而单链表在这种情况下就具有很大的优势。插入和删除操作只需要修改指针,时间复杂度为 O (1)。例如,当需要将一个任务插入到特定位置时,只需要找到插入位置的前一个节点,然后调整指针,将新节点插入到链表中即可。同样,当需要删除一个任务时,只需要找到要删除的节点和其前驱节点,然后调整指针,将该节点从链表中删除。这种高效的插入和删除操作使得单链表非常适合用于频繁进行插入和删除操作的场景。
实验数据表明,在一个包含 10000 个任务的列表中,进行 1000 次随机插入和删除操作,使用单链表的时间大约为使用数组的时间的三分之一。
(三)构建动态性强的线性表
单链表非常适合构建动态性强的线性表。在一些应用中,线性表的结构可能会随着时间的推移而不断变化,需要能够灵活地添加和删除节点。例如,在一个图形编辑软件中,图形元素的列表可能会根据用户的操作不断变化。用户可以随时添加新的图形元素,也可以删除不需要的图形元素。使用单链表可以方便地实现这种动态性。
当用户添加一个新的图形元素时,可以创建一个新的节点,并将其插入到链表中合适的位置。当用户删除一个图形元素时,只需要找到对应的节点,然后调整指针,将该节点从链表中删除。这种灵活性使得单链表在构建动态性强的线性表方面具有很大的优势。
此外,单链表还可以方便地进行节点的遍历和操作。通过指针的连接,可以从链表的头节点开始,依次访问每个节点,进行各种操作,如修改节点的数据、查找特定的节点等。这种遍历和操作的便利性也是单链表在构建动态性强的线性表中被广泛应用的原因之一。
六、单链表与其他数据结构的比较
(一)与顺序表的比较
1.存储分配方式:
- 顺序表采用一段连续的存储单元依次存储线性表的数据元素,而单链表用一组任意的存储单元存放线性表的元素。
- 例如,在顺序表中,数据元素紧密排列,如同数组一样,存储位置是连续的;而单链表的节点可以分散在内存的不同位置,通过指针连接起来。
2.空间利用率:
- 顺序表需要预先分配内存大小,分大了浪费,小了不够,元素个数受限制。若不考虑顺序表中的备用结点空间,则顺序表存储空间利用率为 100%。
- 单链表不需要预先分配,用的时候在分配,元素个数不限。但单链表的每个结点除了数据域之外,还要额外设置指针域,从存储密度来讲,这是不经济的。一般情况下,存储密度越大,存储空间利用率越高。单链表的存储密度小于 1,存储空间利用率相对较低。
3.对 CPU 高速缓存的影响:
- 顺序表的空间一般是连续开辟的,而且一次会开辟存储多个元素的空间,所以在使用顺序表时,可以一次把多个数据写入高速缓存,再写入主存,顺序表的 CPU 高速缓存效率更高,且 CPU 流水线也不会总是被打断。
- 单链表是每需要存储一个数据才开辟一次空间,所以每个数据存储时都要单独的写入高速缓存区,再写入主存,这样就造成了,单链表 CPU 高速缓存效率低,且 CPU 流水线会经常被打断。
4.时间复杂度:
- 查找:顺序表是随机存取结构,指定任意一个位置序号 i,都可以在 O (1) 时间内直接存取该位置的元素,即取值操作的效率高;而单链表是一种顺序存储结构,按位置访问链表第 i 个元素的时候,只能从表头开始依次向后遍历链表,直到找到第 i 个位置上的元素,时间复杂度为 O (n),即取值操作的效率低。
- 插入和删除:对于顺序表,进行插入或删除时,平均需要移动表中近一半的结点,时间复杂度为 O (n)。对于单链表,在确定插入和删除的位置后,插入或删除操作无需移动数据,只需要修改指针,时间复杂度为 O (1)。
(二)选择存储结构的考虑因素
1.存储方面:
- 当线性表的长度变化较大,难以预估存储规模的时候,最好采用链表作为存储结构。因为顺序表需要预先分配存储空间,容易造成存储空间浪费或者空间溢出的现象;而链表不需要预先为其分配空间,只要内存空间允许,链表中的元素个数就没有限制。
- 当线性表的长度变化不大,易于事先确定其大小时,为了节约存储空间,宜采用顺序表作为存储结构。因为顺序表的存储密度为 1,存储空间利用率高;而单链表因为有指针域的原因,存储空间利用率较低。
2.运算方面:
- 若线性表的主要操作是和元素位置紧密相关的这类取值操作,很少做插入和删除时,益采用顺序表作为存储结构。因为顺序表是随机存取结构,取值操作的效率高;而单链表是顺序存储结构,取值操作的效率低。
- 对于频繁进行插入或删除操作的线性表,益采用链表作为存储结构。因为单链表在插入或删除操作时,只需要修改指针,时间复杂度为 O (1);而顺序表进行插入或删除时,平均需要移动表中近一半的结点,时间复杂度为 O (n)。
3.环境方面:
- 顺序表容易实现,任何高级语言中都有数组类型,操作相对简单。链表的操作是基于指针的,相对来讲实现较为复杂。
- 总之,在实际应用中,应根据具体问题的主要因素来选择单链表或顺序表作为存储结构。通常 "较稳定" 的线性表选择顺序存储,而频繁做插入删除操作的,即动态性较强的线性表适合用链式存储。