目录
前言
在数据结构详解(其一) 顺序表中我们解读了顺序表这一数据结构,知道它存在着扩容时可能浪费空间、在头部或中间插入(删除)时需要挪动数据导致效率低下的问题。而链表却能完美的解决这两个问题,那链表就真的完美了吗?接下来让我们剖析一下链表,了解链表的特点以及它的优缺点。
一、概念
链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
当链表的每个结点只包含一个指针域时,我们称之为单链表。
此外链表中的以下三个概念经常容易混淆:
1. 哨兵位:在单链表的第一个存放数据的结点之前附设的一个结点,称之为哨兵位,它不存储有效数据。哨兵位对于链表来说,不是必须的。
2. 首元结点(首结点):首结点就是第一个存放数据的结点。
3. 头指针:链表的头指针永远指向链表中第一个节点,换句话说,如果链表有哨兵位,头指针指向哨兵位;否则,头指针指向首元结点。
4. 头结点 :链表中的第一个节点,如果链表有哨兵位,头结点就是哨兵位;否则,头结点就是首元结点。
一个链表可以没有哨兵位,但不能没有头指针,不然无法找到链表的入口。
二、结构
链表结构在逻辑上是连续的,但是在物理上不一定连续(开辟结点时用malloc函数从堆区中申请空间,每次申请空间,都要重新在堆区中查找足够大小的一个空闲块,故两次申请的空间不一定是连续的)。
链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据的数据域,另一个是存储下一个结点地址的指针域。
//链表结点的结构体包括两个部分:一个是存储数据的数据域,另一个是存储下一个结点地址的指针域
typedef struct SListNode //链表各个结点的结构体
{
SLTDataType data; //数据域:存放数据
struct SListNode* next; //指针域:存放下一个结点的地址(指向下一个结点)
}SLTNode;
上图为链表的物理结构可看出链表是通过当前结点地址域中存储的地址来找到下一个结点的。链表各个存储单元的地址不一定连续,链表也不是依靠连续的地址来找到下一个结点。
三、接口实现
以下接口的实现都是默认不使用哨兵位,因为在各种各样的OJ题中也都默认不带哨兵位的,如果想要带上哨兵位,在创建链表的第一个结点时,应该这样处理:
SLTNode* head = (SLTNode*)malloc(sizeof(SLTNode)); //开辟一个结点,不存放数据,并使头指针指向它
以下是单链表常用的接口(命名参考C++的STL):
1.打印函数(SLTPint)
在使用这个函数的过程中不需要改变实际参数,所以传值和传址调用都可以,由于在测试用例里创建的是头指针,我们靠头指针找到首元结点,故这里使用的是传址调用。
打印函数的实现非常简单,循环遍历链表,将其各个结点的值一一打印。
void SLTPint(SLTNode* phead)//打印函数 { SLTNode* cur = phead;//获取头结点 while (cur)//遍历各个结点,直到为NULL { printf("%d -> ", cur->data); cur = cur->next;//将cur的值变为下一个结点的地址 } puts("NULL");//puts()在输出字符串后,会自动换行 }
2.销毁函数(SLTDestroy)
主要是为了释放动态开辟的链表的各个结点空间。
实现这个接口的注意事项主要有:
1. 因为要在函数中改变头指针的指向,所以接收头指针的形参是二级指针。(头指针是一级指针,要在函数中改变一个指针,要传入指向保存该指针的空间的指针,即二级指针。)
2. 防御性检查(卫语句),防止恶意传入空指针。(存放NULL的地址不为NULL)
3. 判断头结点是否指向空,指向空表示链表为,空链表不需要销毁。
4. 循环遍历链表,销毁链表的各个结点。
5. 让链表的头结点指向NULL,防止野指针问题。
void SLTDestroy(SLTNode** phead)//销毁函数(要改变头指针的指向,故传入二级指针) { assert(phead != NULL);//防御性检查(卫语句) assert(*phead != NULL);//空链表无需销毁(卫语句) SLTNode* cur = *phead; SLTNode* next = cur->next; while (cur)//循环遍历,销毁各个结点 { next = cur->next; free(cur); cur = next; } *phead = NULL;//使头指针指向空(避免野指针) }
扩展:C/C++没有像Java那样的垃圾回收,动态开辟的空间不主动释放,在程序退出异常的情况下会成为僵尸,一直占用用户的内存,这就是通常所说的内存泄漏问题。
动态开辟1G空间,不释放:
程序运行前:
程序运行后:
试想一下,如果是需要长时间运行的程序,不断的内存泄漏到最后的程序崩溃是多么严重的事故,现在你理解使用free函数主动释放动态开辟的内存空间的重要性了吧。
3.创建结点函数(BuySLTNode)
由于头插,尾插,在指定结点之后插入都要创建新的结点,避免代码复用,将其封装为函数。
实现这个函数的主要步骤有:
1.使用malloc函数,开辟一个结点结构体的空间
2.判断空间是否开辟失败(使用malloc开辟空间,内存不足时可能失败)
3.使结点的地址域指向NULL
4.使结点的数据域存放指定数据
5.返回新开辟结点的地址
SLTNode* BuySLTNode(SLTDataType x)//创建结点函数 { SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));//开辟一个结点结构体的空间 assert(newnode != NULL);//判断结点是否开辟失败 newnode->next = NULL;//使结点的地址域指向NULL newnode->data = x;//使结点的数据域存放指定数据 return newnode;//返回新开辟结点的地址 }
4.尾插(SLTPushBack)
实现该函数的注意事项:
1.由于没有使用哨兵位,当链表为空时插入要改变头指针的指向,故要传入二级指针。
2.创建节点。
3.循环遍历找到尾结点,插入。void SLTPushBack(SLTNode** pphead, SLTDataType x)//尾插 (可能要改变头指针的指向,故要传二级指针) { assert(pphead);//传的二级指针一定不为空,要防御性检查 //1.创建节点 SLTNode* newnode = BuySLTNode(x);//调用创建结点函数 //2.找到尾结点插入 if (*pphead == NULL) { *pphead = newnode; } else { SLTNode* tail = *pphead; while (tail->next != NULL) { tail = tail->next; } //3.尾插 tail->next = newnode;//让尾结点指向新结点 } }
由上图可看出我们要修改头指针存储的地址才能使头指针指向新的结点,我们要在函数中改变实参,要传入实参(头指针)的地址(指针),指针的指针即二级指针。
5.头插(SLTPushFront)
实现该函数的注意事项:
1.由于没有使用哨兵位,头插时要改变头指针的指向(使头指针指向新结点),故要传入二级指针。
2.创建节点。
3.使新结点指向链表头节点。
4.更新首元结点(使头指针指向新结点)。void SLTPushFront(SLTNode** pphead, SLTDataType x)//头插 { assert(pphead);//传的二级指针一定不为空,要防御性检查 //1.创建节点 SLTNode* newnode = BuySLTNode(x); //2.头插 newnode->next = *pphead;//使新结点指向链表头节点 *pphead = newnode;//更新头结点 }
6.尾删(SLTPopBack)
插入时由于要改变头指针的指向,我们要传入二级指针,那尾删时我们可以只传一级指针吗?
同样的,因为没有哨兵位,在 仅有一个结点时尾删,我们要改变头指针的指向,使头指针指向NULL,如果只传一级指针,仅有一个结点时无法进行尾删,无法改变头指针的指向。实现该函数的注意事项:
1.由于没有使用哨兵位,在仅有一个结点时尾删要改变头指针的指向(使头指针指向NULL),故要传入二级指针。
2.循环遍历找到尾结点的前一个结点。
3.使用free函数释放结点空间。(销毁尾结点)
4.使尾结点的前一个结点指向NULL。(更新尾结点)void SLTPopBack(SLTNode** pphead)//尾删(传二级指针) { assert(pphead);//传的二级指针一定不为空,要防御性检查 //考虑没有结点的情况 assert(*pphead != NULL);//链表为空不能删除 //考虑只有一个结点 if ((*pphead)->next == NULL) { *pphead = NULL; } else { SLTNode* tail = *pphead; //考虑有多个结点的情况 while (tail->next->next != NULL)//找到倒数第二个结点 { tail = tail->next; } //找到后释放 SLTNode* del = tail->next; tail->next = NULL; free(del); } }
7.头删(SLTPopFront)
实现该函数的注意事项:
1.由于没有使用哨兵位,头删时要改变头指针的指向(使头指针指向首元结点的下一个结点),故要传入二级指针。
2.记录首元节点的下一个结点。
3.使用free函数释放首元结点的空间。(销毁首元节点)
4.使头指针的前一个结点指向NULL。(更新首元节点)void SLTPopFront(SLTNode** pphead)//头删 { assert(pphead);//传的二级指针一定不为空,要防御性检查 assert(*pphead);//空链表不能再继续删除 SLTNode* next = (*pphead)->next;//记录首元节点的下一个结点 //优先级问题,->的优先级高于*,故要用括号扩起*pphead free(*pphead);//释放首元节点 *pphead = next;//更新首元节点 }
8.在指定结点前插入新结点(SLTInsert)
实现该函数的注意事项:
1.由于没有使用哨兵位,当链表为空时插入要改变头指针的指向,故要传入二级指针。
2.指定结点不应为空。
3.当指定位置为首元结点时,相当于头插,可复用头插函数。
3.非头插时,我们要找到指定结点的前一个结点,才能在指定位置之前插入创建好的新结点。void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)//在指定结点前插入新结点 { assert(pphead);//传的二级指针一定不为空,要防御性检查 assert(*pphead);//要在指定位置前插入,那么链表至少有一个结点 assert(pos);//指定结点不应为空 //头插时 if (pos == *pphead)//==才是判断等于 { SLTPushFront(pphead, x); } else { //非头插时 //找到指定结点的前一个结点 SLTNode* prev = *pphead; while (prev->next != pos) { prev = prev->next; } //创建新结点 SLTNode* newnode = BuySLTNode(x); //在指定位置前插入 newnode->next = pos; prev->next = newnode; } }
由上图可看出要在指定位置前插入新结点,必须先找到指定位置的前一个结点。
9.删除指定位置的结点(SLTErase)
实现该函数的注意事项:
1.由于没有使用哨兵位,当链表仅有一个结点时尾删要改变头指针的指向,故要传入二级指针。
2.空链表不支持删除。
3.指定结点不应为空。
4.当指定位置为首元结点时,相当于头删,可复用头删函数。
3.非头删时,我们要找到指定结点的前一个结点,使其指向指定结点的下一个结点之后,才能删除指定位置的结点,否则链表将断开链接。void SLTErase(SLTNode** pphead, SLTNode* pos)//删除指定位置的结点 { assert(pphead != NULL);//传的二级指针一定不为空,要防御性检查 assert(*pphead != NULL);//空链表不支持删除 assert(pos != NULL);//指定结点不应为空 //头删时 if (pos == *pphead) { SLTPopFront(pphead); } //非头删 else { //找到该结点的前一个结点 SLTNode* prev = *pphead; while (prev->next != pos) { prev = prev->next; } //删除 prev->next = pos->next; free(pos); pos = NULL;//pos是形参,出了函数会销毁,无法访问,不用担心野指针问题。 } }
以上两个函数要都找之前的结点效率太低,改为之后删除和插入会好的多
10.在指定结点后插入新结点(SLTInsertAfter)
实现该函数的注意事项:
1.由于没有使用哨兵位,头插时要改变头指针的指向(使头指针指向首元结点的下一个结点),故要传入二级指针。
2.要先使新结点指向指定结点的下一个结点,再让指定节点指向新节点,不然会断开与后面结点的链接。void SLTInsertAfter(SLTNode** pphead, SLTNode* pos, SLTDataType x)//在指定结点后插入新结点 { assert(pphead); assert(pos); //创建新结点 SLTNode* newnode = BuySLTNode(x); //链接 newnode->next = pos->next;//使新结点指向指定结点的下一个结点 pos->next = newnode;//使指定结点指向新结点 }
由上图可知,如果直接让指定节点指向新节点,链表会断开链接。
11.删除指定结点后的结点(SLTEraseAfter)
实现该函数的注意事项:
1.由于没有使用哨兵位,头删时要改变头指针的指向(使头指针指向首元结点的下一个结点),故要传入二级指针。
2.要先记录要删除的结点。
3.将指定结点和要删除结点的下一个结点链接起来,不然直接删除会断开与后面结点的链接。
4.完成上述操作后,才能用free函数释放结点空间。void SLTEraseAfter(SLTNode** pphead, SLTNode* pos)//删除指定结点后的结点 { assert(pphead); //考虑空链表(不支持删除) assert(pos); //考虑指定结点为尾结点 if (pos->next == NULL) { return; } else { SLTNode* dle = pos->next;//记录要删除的结点 pos->next = dle->next;//将指定结点和要删除结点的下一个结点链接起来 free(dle);//释放结点空间(删除结点) dle = NULL; } }
12.查找函数(SLTFind)
循环遍历链表找到结点,找到后返回结点位置,找不到返回NULL。SLTNode* SLTFind(SLTNode* phead, SLTDataType x)//查找函数 { //传的是一级指针,而链表有可能为空,故不用进行防御性检查 //暴力查找,找到后返回结点的地址,找不到返回NULL SLTNode* cur = phead; while (cur != NULL) { if (cur->data != x) cur = cur->next; else return cur; } return NULL; }
四、单链表的优点与缺点
·优点:
1.按需申请释放空间,这样可以充分利用计算机内存空间,实现灵活的内存动态管理,不会造成额外的浪费。2.链表允许插入和移除表上任意位置上的结点,所以无论在任何位置存取数据,都不需要挪动数据。
·缺点:
1.链表物理地址不连续,虽然在任意位置插入/删除数据时不用挪动数据,但我们却要通过遍历链表找到前一个结点,仍非常麻烦。
2.链表由于增加了结点的指针域,空间开销比较大。(存储密度小)
可以说顺序表的优势就是单链表的缺点,而单链表的优点却是顺序表的缺陷,这样看起来单链表对比顺序表而言也没什么优势啊,确实,在实际生产中我们很少会使用单链表来存储数据,但这并不代表单链表不重要,要知道单链表还会作为其他数据结构的子结构如:哈希表、图的邻接表……另外这种结构在笔试面试中经常出现。
·思考:另外,链表也是可以克服以上缺点的,该如何克服呢?这就要说到被称为最优链表的带头双向循环链表了,请看:数据结构详解(其三) 带头双向循环链表
·单链表的经典OJ题和常见面试题:数据结构OJ(其二) 单链表
如果你还有其他想要学习的数据结构不妨在这里找找看:数据结构详解(序)
五、源码+详细注释(C语言实现)
1.SList.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
typedef int SLTDataType;//重定义数据类型,便于修改
typedef struct SListNode //链表各个结点的结构体
{
SLTDataType data; //存放数据
struct SListNode* next; //存放下一个结点的地址(指向下一个结点)
}SLTNode;
void SLTPint(SLTNode* phead);//打印函数
void SLTDestroy(SLTNode** phead);//销毁函数
SLTNode* BuySLTNode(SLTDataType x);//创建结点函数
void SLTPushBack(SLTNode** pphead, SLTDataType x);//尾插(back:后背,末尾)
void SLTPushFront(SLTNode** pphead, SLTDataType x);//头插
void SLTPopBack(SLTNode** pphead);//尾删(传二级指针)
void SLTPopBack1(SLTNode* pphead);//尾删(传一级指针)
//如果只传一级指针,仅有一个结点时无法进行尾删
void SLTPopFront(SLTNode** pphead);//头删
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);//查找函数
//void SLTModify(SLTNode* phead, SLTDataType x, SLTDataType y);//修改函数(与查找函数非常相似有兴趣的话可以尝试实现一下)
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);//在指定结点前插入新结点
void SLTErase(SLTNode** pphead, SLTNode* pos);//删除指定结点前的结点
//以上两个函数要找之前的结点效率太低,改为之后删除和插入会好的多
void SLTInsertAfter(SLTNode** pphead, SLTNode* pos, SLTDataType x);//在指定结点后插入新结点
void SLTEraseAfter(SLTNode** pphead, SLTNode* pos);//删除指定结点后的结点
2.SList.c
#include"SList2.h"
void SLTPint(SLTNode* phead)//打印函数
{
SLTNode* cur = phead;//获取头结点
while (cur)//遍历各个结点,直到为NULL
{
printf("%d -> ", cur->data);
cur = cur->next;//将cur的值变为下一个结点的地址
}
puts("NULL");//puts()在输出字符串后,会自动换行
}
SLTNode* BuySLTNode(SLTDataType x)//创建结点函数
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
assert(newnode != NULL);
newnode->next = NULL;
newnode->data = x;
return newnode;
}
void SLTDestroy(SLTNode** phead)//销毁函数(要改变头指针的指向,故传入二级指针)
{
assert(phead != NULL);//防御性检查
assert(*phead != NULL);//空链表无需销毁
SLTNode* cur = *phead;
SLTNode* next = cur->next;
while (cur)//循环遍历,销毁各个结点
{
next = cur->next;
free(cur);
cur = next;
}
*phead = NULL;//使头指针指向空(避免野指针)
}
void SLTPushBack(SLTNode** pphead, SLTDataType x)//尾插 (可能要改变头节点(一个结构体指针),故要传二级指针)
{
assert(pphead);//传的二级指针一定不为空,要防御性检查
//1.创建节点
//SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
//assert(newnode != NULL);//检查结点是否创建成功
//newnode->data = x;
//newnode->next = NULL;
SLTNode* newnode = BuySLTNode(x);
//2.找到尾结点
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
//3.尾插
tail->next = newnode;//让尾结点指向新结点
}
}
void SLTPushFront(SLTNode** pphead, SLTDataType x)//头插
{
assert(pphead);//传的二级指针一定不为空,要防御性检查
//1.创建节点
SLTNode* newnode = BuySLTNode(x);
//2.头插
newnode->next = *pphead;//新结点指向链表头节点
*pphead = newnode;//更新头结点
}
void SLTPopBack(SLTNode** pphead)//尾删(传二级指针)
{
assert(pphead);//传的二级指针一定不为空,要防御性检查
//考虑没有结点的情况
assert(*pphead != NULL);//链表为空不能删除
//考虑只有一个结点
if ((*pphead)->next == NULL)
{
*pphead = NULL;
}
else
{
SLTNode* tail = *pphead;
//考虑有多个结点的情况
while (tail->next->next != NULL)//找到倒数第二个结点
{
tail = tail->next;
}
//找到后释放
SLTNode* del = tail->next;
tail->next = NULL;
free(del);
}
}
void SLTPopBack1(SLTNode* pphead)//尾删(传一级指针)
{
//考虑没有结点的情况
assert(pphead != NULL);//链表为空不能删除
SLTNode* tail = pphead;
//考虑只有一个结点的情况
if (tail->next == NULL)
{
pphead = NULL;//如果只传一级指针,仅有一个结点时无法进行尾删
}
else
{
//考虑多个结点的情况
while (tail->next->next != NULL)//找到倒数第二个结点
{
tail = tail->next;
}
//找到后释放
SLTNode* next = tail->next;
tail->next = NULL;
free(next);
}
}
void SLTPopFront(SLTNode** pphead)//头删
{
assert(pphead);//传的二级指针一定不为空,要防御性检查
assert(*pphead);//空链表不能再继续删除
SLTNode* next = (*pphead)->next;//记录头节点的下一个位置
//优先级问题,要用括号扩起*pphead
free(*pphead);//释放头节点
*pphead = next;//更新头节点
}
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)//在指定结点前插入新结点
{
assert(pphead);//传的二级指针一定不为空,要防御性检查
assert(*pphead);
assert(pos);//指定结点不应为空
//头插时
if (pos == *pphead)//==才是判断等于
{
SLTPushFront(pphead, x);
}
else
{
//非头插时
//找到指定结点的前一个结点
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
//创建新结点
SLTNode* newnode = BuySLTNode(x);
//在指定位置前插入
newnode->next = pos;
prev->next = newnode;
}
}
void SLTErase(SLTNode** pphead, SLTNode* pos)//删除指定位置的结点
{
assert(pphead != NULL);//传的二级指针一定不为空,要防御性检查
assert(*pphead != NULL);//空链表不支持删除
assert(pos != NULL);//指定结点不应为空
//头删时
if (pos == *pphead)
{
SLTPopFront(pphead);
}
//非头删
else
{
//找到该结点的前一个结点
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
//删除
prev->next = pos->next;
free(pos);
pos = NULL;//pos是形参,出了函数会销毁,无法访问,不用担心野指针问题。
}
}
void SLTInsertAfter(SLTNode** pphead, SLTNode* pos, SLTDataType x)//在指定结点后插入新结点
{
assert(pphead);
assert(pos);
//创建新结点
SLTNode* newnode = BuySLTNode(x);
//链接
newnode->next = pos->next;//使新结点指向指定结点的下一个结点
pos->next = newnode;//使指定结点指向新结点
}
void SLTEraseAfter(SLTNode** pphead, SLTNode* pos)//删除指定结点后的结点
{
assert(pphead);
//考虑空链表(不支持删除)
assert(pos);
//考虑指定结点为尾结点
if (pos->next == NULL)
{
return;
}
else
{
SLTNode* dle = pos->next;//记录要删除的结点
pos->next = dle->next;//链接要删除结点后面的结点
free(dle);//释放结点空间(删除结点)
dle = NULL;
}
}
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)//查找函数
{
//传的是一级指针,而链表有可能为空,故不用进行防御性检查
//暴力查找,找到后返回结点的地址,找不到返回NULL
SLTNode* cur = phead;
while (cur != NULL)
{
if (cur->data != x)
cur = cur->next;
else
return cur;
}
return NULL;
}
3.SListTest.c(测试用例)
#include"SList2.h"
静态链表的建立与打印
//int main()
//{
// //1.开辟足够的结点
// SLTNode* n1 = (SLTNode*)malloc(sizeof(SLTNode));
// assert(n1 != NULL);//防止空间开辟失败。
// SLTNode* n2 = (SLTNode*)malloc(sizeof(SLTNode));
// assert(n2 != NULL);
// SLTNode* n3 = (SLTNode*)malloc(sizeof(SLTNode));
// assert(n3 != NULL);
// SLTNode* n4 = (SLTNode*)malloc(sizeof(SLTNode));
// assert(n4 != NULL);
//
// n1->data = 1;
// n2->data = 2;
// n3->data = 3;
// n4->data = 4;
//
// //2.链接各个结点形成静态链表
// n1->next = n2; //n1,n2是结点 结构体指针
// n2->next = n3;
// n3->next = n4;
// n4->next = NULL;
//
// //3.打印链表
// SLTPint(n1);
// return 0;
//}
//动态链表
int main()
{
SLTNode* head = NULL;
SLTPushBack(&head, 1);
SLTPushBack(&head, 2);
SLTPushBack(&head, 3);
SLTPint(head);
SLTPushFront(&head, 0);
SLTPint(head);
SLTPopFront(&head);
SLTPint(head);
SLTPopBack(&head);
SLTPint(head);
SLTInsert(&head, head->next, 1);
SLTPint(head);
SLTErase(&head, SLTFind(head, 2));
SLTPint(head);
SLTInsertAfter(&head, head->next, 2);
SLTPint(head);
SLTEraseAfter(&head, SLTFind(head, 1));
SLTPint(head);
SLTDestroy(&head);
}