赞
踩
我们学习完顺序表之后,难免发现其有一些问题,比如:
1.中间/头部的插入删除,时间复杂度为O(N)
2. 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
3. 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到
200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
那么我们自然而然回去思考:如何解决以上问题呢?
下面给出了链表的结构来看看。
百度百科上的解释:
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
光看文字可能略感觉有点抽象,那么我们可以结合我们生活实际的东西去理解它
这是我们熟悉的火车结构有一节一节的车厢,火车头它们之间用链条链起彼此,到末尾只有一个空链条。
那么,我们便可以联想出我们大厂必备的数据结构之一,链表。
链条其实对应我们链表的指针,而车厢对应我们链表中一个又一个节点
基于上面的火车,我们来引出链表的结构:
对于一个节点来说,链表中存着它要存储的数据和一个指向下一个节点的指针,方便能找到一个链表结构。
而对于所有的火车车厢组合而成的链表结构,就长成下面这样:
更抽象一点,在计算机中是这样的:
观察发现,链表内存的指针指向的是下一个链表节点的地址,每一个链表节点亦如此。plist是指向整个链表的指针,维护整个链表结构。形式上指向头结点。尾结点没有指向下一个节点,所以我们自然而然地将其置为空。
所以,理解了链表,我们再回过头看上面说的物理结构不连续和逻辑结构连续:逻辑结构连续是因为彼此之间有指针链接,可以通过头结点顺着找下去遍历和找到每一个节点。而物理结构不连续,浅显看来,就是上面每个不连续的地址,在计算机世界中其实是这样的:
相比于数组,它在同一块空间的不同位置创建节点,位置,地址是由系统分配和决定的。彼此之间是离散状态,不像数组那样连续开辟,所以说他是物理结构不连续。
实际中链表的结构非常多样,以下情况组合起来就有222=8种链表结构:
虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构:
1.单向无头不循环链表
2.双向循环带头链表
那么它们为什么会是我们具体学习的重点呢?
事实上:
无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
带头双向循环链表:结构最复杂,一般用在单独存储数据。**实际中使用的链表数据结构,都是带头双向循环链表。**另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
那么好,我们就来正式地整代码
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SLTNode;
实现的功能:
和顺序表一样,链表也有自己的增删查改和销毁,也是我们重点的研究和实现内容,链表的初始化,是直接用链表的插入代替的,因为插入是需要开辟空间,存放数据和指针的。就相当于初始化的过程了。
所以,我们先设计一个头文件,再去逐一测试和实现函数:
SList.h
#pragma once #include <stdio.h> #include <assert.h> #include <stdlib.h> typedef int SLTDataType; typedef struct SListNode { SLTDataType data; struct SListNode* next; }SLTNode; //链表打印 void SLTPrint(SLTNode* phead); //链表尾插 void SLTPushBack(SLTNode** pphead, SLTDataType x); //头插 void SLTPushFront(SLTNode** pphead, SLTDataType x); //尾删 void SLTPopBack(SLTNode** pphead); //头删 void SLTPopFront(SLTNode** pphead); //节点查找 SLTNode* SLTFind(SLTNode** pphead, SLTDataType x); //任意位置之前插入 void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x); //指定位置之后插入 void SLTInsertAfter(SLTNode* pos, SLTDataType x); //删除任意位置之前节点 void SLTErase(SLTNode** pphead, SLTNode* pos); //删除指定位置之后的节点 void SLTEraseAfter(SLTNode* pos); //销毁链表 void SLTDestroy(SLTNode** pphead);
ps:头文件在上文中已经有了,测试调用函数的代码是放在测试文件Test.c中,而函数实现的代码是放在SList.c文件中的。
在文件Test.c中:
void SListTest02() {
SLTNode* plist = NULL;
}
int main()
{
SListTest02();
return 0;
}
为了方便测试和研究,我们先设计一个链表打印函数:
在文件SList.c中:
void SLTPrint(SLTNode* phead) { //打印
SLTNode* pcur = phead;
while (pcur)
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
这一段是Test.c的main函数中调用函数的代码:
SLTPrint(plist);
定义pcur的目的是为了不改变原来的head的指针,pcur->data是结构体指针访问成员的写法,C语言阶段打下的基础正是在数据结构和以后C++阶段发挥的作用。
上面相当于是一个链表的遍历,每打印一个链表节点存储的数据,链表指针pcur就移动到下一个节点。以此往复,只到pcur指向空为止。
下面给大家看一个自制的GIF方便大家理解:
void SLTDestroy(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* pcur = *pphead;
while (pcur)
{
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL;
}
这里就有小伙伴会疑惑了,看不懂为什么突然来了一个传二级指针?
这也是我们在链表这一块遇到的第一个拦路虎,是一个不太好理解的难点。但是只要C语言基础够扎实,还是能够弄明白原因的。
SLTDestroy(&plist);
对比我们main函数中传参的代码,可以尝试先理解理解。(* ̄︶ ̄)
在讲这个问题之前,我们先来回顾指针中的知识:
A(即B的地址)是指向指针的指针,称为二级指针,用于存放二级指针的变量称为二级指针变量.根据B的不同情况,二级指针又分为指向指针变量的指针和指向数组的指针。——百度百科
我们知道,指针存放变量的地址**,地址本身也可被修改,改变。所以说,指针(即地址)它本身也是一种变量**,既然是变量,也就有对应的地址,有对应的指针指向他。而指向指针的指针我们就叫它二级指针,它指向的是一级指针的地址
结合这个图,我们再谈一谈变量,一级指针,二级指针之间的关系:
上面这个图描述的是指针指向的内容和各级指针的指向关系
二级指针存的是一级指针的地址0x0012ff48,一级指针存储的是变量a的地址0x0012ff50.小结一下,就是二级指针存放一级指针的地址,指向一级指针。一级指针的存放变量的地址,指向变量。一级指针解引用是拿到变量,二级指针解引用拿到的是一级指针。
但是光知道二级指针其实是不够的,我们还要知道函数传参的本质,函数传参并不是把参数真正地传给函数让其修改。实际上,形参是实参的一份临时拷贝,函数修改的其实是形式参数而不是实际参数。换句话说,形参的改变不影响实参的结果。
回到上面传二级指针的问题,如果我们不用pphead接收而是用*phead接收,那根据函数传参的特点,*phead是plist地址的一份临时拷贝,*phead的改变不影响plist,不能达到我们想要的效果。而函数传值调用是拷贝实参,不能解决问题,函数传址调用是借助指针访问,实参会发生改变。plist指向头结点。我们的目的正是改变plist,所以我们要传plist指针的地址,也就是二级指针/**pphead。
void SLTDestroy(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* pcur = *pphead;
while (pcur)
{
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL;
再来分析这个代码,我们先保证二级指针和一级指针都不为空,再实现遍历式的销毁。这里还是有一个要点我们需要在销毁之前用一个临时指针next把pcur的下一个节点存储起来,不然,我们free掉pcur后,就找不到pcur->next,pcur的下一个节点了。最后全部free完会剩下一个*pphead没有置空,把它置空避免内存泄漏就可以了,我们后面常常要用到类似这样的操作,避免空访问指针造成报错。
既然是插入,那要开辟空间创建节点,所以要现有SLTBuyNode函数
SLTNode* SLTBuyNode(SLTDataType x) { //创建节点
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = NULL;
return newnode;
}
在尾插时分为两种情况,链表为空和链表不为空
void SLTPushBack(SLTNode** pphead, SLTDataType x) { //尾插 assert(pphead); SLTNode* newnode = SLTBuyNode(x); //链表为空,新节点作为phead if (*pphead == NULL) { *pphead = newnode; return; } SLTNode* ptail = *pphead; while (ptail->next) { ptail = ptail->next; } //链表不为空,循环找尾,新节点作为尾结点 ptail->next = newnode; }
结合动图演示理解代码:
链表为空:
链表不为空:
头插也是类似的:
void SLTPushFront(SLTNode** pphead, SLTDataType x) { //头插
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
//newnode *pphead
newnode->next = *pphead;
*pphead = newnode;
}
动画:
尾删:
void SLTPopBack(SLTNode** pphead) { //尾删 //链表不能为空 assert(*pphead && pphead); //分链表只有一个节点和多个节点的情况 if ((*pphead)->next == NULL) { free(*pphead); *pphead = NULL; return; } SLTNode* ptail = *pphead; SLTNode* prev = NULL; while (ptail->next) { prev = ptail; ptail = ptail->next; } prev->next = NULL;//尾删,最后一个节点置空 //销毁尾结点 free(ptail); ptail = NULL; }
头删:
void SLTPopFront(SLTNode** pphead) {
assert(pphead && *pphead);
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
Test.c
SLTNode* FindRet = SLTFind(&plist, 2);//
if (FindRet) {
printf("找到了!\n");
}
else {
printf("未找到!\n");
}
SList.c
SLTNode* SLTFind(SLTNode** pphead, SLTDataType x) {
assert(pphead);
SLTNode* pcur = *pphead;
while (pcur) {
if (pcur->data == x) {
return pcur;
}
pcur = pcur->next;
}
return NULL;
}
这里设置成返回指针,是为了给下文一些指定位置相关的问题做铺垫。这个函数的返回判断单独拎出来到Test.c中,便于自己测试这个函数写的对不对。
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) { assert(pphead && pos && *pphead); SLTNode* newnode = SLTBuyNode(x); //分类,pos刚好是头节点和pos不是头节点的情况 if (pos == *pphead) { //头插 SLTPushFront(pphead, x); return; } SLTNode* prev = *pphead; while (prev->next != pos) { prev = prev->next; } prev->next = newnode; newnode->next = pos; }
有上面的经验,我们分情况去实现
头结点为空,那就是直接头插,直接调用上面写过的函数
头结点不为空,那就遍历去找前驱节点,借助前驱节点去插入
下面的演示例子,目标:pos指向d3节点,要在d3节点之前插入d5节点,d5是newnode的节点
void SLTInsertAfter(SLTNode* pos, SLTDataType x) {
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
过程和指定位置前插入相比,少了遍历找前驱节点这一步。但是这里有一点容易错要注意,不能先写pos->next=newnode,否则,你会找不到pos的next节点,除非你用一个临时指针把pos->next存起来。但在这里,我们最好先处理newnode的next,以免破坏pos->next,导致找不到。
所以,为什么我们任意位置的插入和删除要分前和后呢?其实它们的思路看似相同,但在细节上差异较大,一不小心就有可能弄出bug。这和链表本身的特性有很大的关联,因为单链表的单向性,可以往前不能往后。不像数组那样可以随意访问数据,所以遍历就成了链表中常常做的事情,这也是**链表的一大缺陷所在,会增加程序的运行和消耗。**有时代码的顺序像上面会影响程序的运行,小心驶得万年船,链表中随时可能找不到我们本来能访问到的节点,这一点,在我们后面学习的双向链表中也是很常见的。
void SLTErase(SLTNode** pphead, SLTNode* pos) { assert(pphead && *pphead && pos); if (*pphead == pos) { SLTPopFront(pphead); return; } SLTNode* prev = *pphead; while (prev->next != pos) { prev = prev->next; } prev->next = pos->next; free(pos); pos = NULL; }
不管是增还是删,都要考虑周全,要留个心眼,防止链表为空。链表为空无法删除,要及时用assert断言拦下,pos超出链表的个数,也要拦下。不只是pphead不为空。
当只有一个节点的时候,相当于头删功能,当不止一个节点的时候,如下演示:
void SLTEraseAfter(SLTNode* pos)
{
assert(pos && pos->next);
SLTNode* del = pos->next;
pos->next = pos->next->next;
free(del);
del = NULL;
}
同样,这里也是要记住pos->next的。以免找不到。
SList.h
#pragma once #include <stdio.h> #include <assert.h> #include <stdlib.h> typedef int SLTDataType; typedef struct SListNode { SLTDataType data; struct SListNode* next; }SLTNode; //链表打印 void SLTPrint(SLTNode* phead); //链表尾插 void SLTPushBack(SLTNode** pphead, SLTDataType x); //头插 void SLTPushFront(SLTNode** pphead, SLTDataType x); //尾删 void SLTPopBack(SLTNode** pphead); //头删 void SLTPopFront(SLTNode** pphead); //节点查找 SLTNode* SLTFind(SLTNode** pphead, SLTDataType x); //任意位置插入 void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x); //指定位置插入 void SLTInsertAfter(SLTNode* pos, SLTDataType x); //删除任意节点 void SLTErase(SLTNode** pphead, SLTNode* pos); //删除指定位置之后的节点 void SLTEraseAfter(SLTNode* pos); //销毁链表 void SLTDestroy(SLTNode** pphead);
SList.c
#define _CRT_SECURE_NO_WARNINGS #include "SList.h" void SLTPrint(SLTNode* phead) { //打印 SLTNode* pcur = phead; while (pcur) { printf("%d->", pcur->data); pcur = pcur->next; } printf("NULL\n"); } SLTNode* SLTBuyNode(SLTDataType x) { //创建节点 SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode)); newnode->data = x; newnode->next = NULL; return newnode; } void SLTPushBack(SLTNode** pphead, SLTDataType x) { //尾插 assert(pphead); SLTNode* newnode = SLTBuyNode(x); //链表为空,新节点作为phead if (*pphead == NULL) { *pphead = newnode; return; } SLTNode* ptail = *pphead; while (ptail->next) { ptail = ptail->next; } //链表不为空,循环找尾,新节点作为尾结点 ptail->next = newnode; } void SLTPushFront(SLTNode** pphead, SLTDataType x) { //头插 assert(pphead); SLTNode* newnode = SLTBuyNode(x); //newnode *pphead newnode->next = *pphead; *pphead = newnode; } void SLTPopBack(SLTNode** pphead) { //尾删 //链表不能为空 assert(*pphead && pphead); //分链表只有一个节点和多个节点的情况 if ((*pphead)->next == NULL) { free(*pphead); *pphead = NULL; return; } SLTNode* ptail = *pphead; SLTNode* prev = NULL; while (ptail->next) { prev = ptail; ptail = ptail->next; } prev->next = NULL;//尾删,最后一个节点置空 //销毁尾结点 free(ptail); ptail = NULL; } void SLTPopFront(SLTNode** pphead) { assert(pphead && *pphead); SLTNode* next = (*pphead)->next; free(*pphead); *pphead = next; } SLTNode* SLTFind(SLTNode** pphead, SLTDataType x) { assert(pphead); SLTNode* pcur = *pphead; while (pcur) { if (pcur->data == x) { return pcur; } pcur = pcur->next; } return NULL; } void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) { assert(pphead && pos && *pphead); SLTNode* newnode = SLTBuyNode(x); //分类,pos刚好是头节点和pos不是头节点的情况 if (pos == *pphead) { //头插 SLTPushFront(pphead, x); return; } SLTNode* prev = *pphead; while (prev->next != pos) { prev = prev->next; } prev->next = newnode; newnode->next = pos; } void SLTInsertAfter(SLTNode* pos, SLTDataType x) { assert(pos); SLTNode* newnode = SLTBuyNode(x); newnode->next = pos->next; pos->next = newnode; } void SLTErase(SLTNode** pphead, SLTNode* pos) { assert(pphead && *pphead && pos); if (*pphead == pos) { SLTPopFront(pphead); return; } SLTNode* prev = *pphead; while (prev->next != pos) { prev = prev->next; } prev->next = pos->next; free(pos); pos = NULL; } void SLTEraseAfter(SLTNode* pos) { assert(pos && pos->next); SLTNode* del = pos->next; pos->next = pos->next->next; free(del); del = NULL; } void SLTDestroy(SLTNode** pphead) { assert(pphead && *pphead); SLTNode* pcur = *pphead; while (pcur) { SLTNode* next = pcur->next; free(pcur); pcur = next; } *pphead = NULL; }
Test.c
#define _CRT_SECURE_NO_WARNINGS #include "SList.h" void SListTest02() { //初始化 SLTNode* plist = NULL;//定义链表指针 //---------------------------------------------------------尾插 SLTPushBack(&plist, 1); SLTPushBack(&plist, 2); SLTPushBack(&plist, 3); SLTPushBack(&plist, 4); /*SLTPrint(plist);*/ //---------------------------------------------------------头插 //SLTPushFront(&plist, 7); //SLTPushFront(&plist, 6); //SLTPushFront(&plist, 5); /*SLTPrint(plist);*/ //---------------------------------------------------------尾删 //SLTPopBack(&plist); //SLTPrint(plist); //SLTPopBack(&plist); //SLTPrint(plist); //SLTPopBack(&plist); //SLTPrint(plist); //SLTPopBack(&plist); //SLTPrint(plist); //SLTPopBack(&plist); //SLTPrint(plist); //-------------------------------------------------------头删 //SLTPopFront(&plist); //SLTPrint(plist); //------------------------------------------------- - 查找 SLTNode* FindRet = SLTFind(&plist, 2);// if (FindRet) { printf("找到了!\n"); } else { printf("未找到!\n"); } //在指定位置前,后插入数据 /*SLTInsert(&plist, FindRet, 100); SLTPrint(plist);*/ /*SLTInsertAfter( FindRet, 100); SLTPrint(plist);*/ ---------------------------------------------------删除pos节点 //SLTErase(&plist, FindRet); //SLTPrint(plist); ---------------------------------------------------删除pos之后的节点 SLTEraseAfter(FindRet); SLTPrint(plist); //---------------------------------------------------销毁链表 SLTDestroy(&plist); } int main() { SListTest02(); return 0; }
今天我们具体学习了链表的前两章,本篇文章除了图片,本人尝试着使用动画的形式代替了一部分文字和静图,方便大家更好地理解链表和程序运行的过程。当然,链表世界不止于此,链表还有各种OJ中的应用和操作,还有双向带头循环链表,以及各种算法中,其他数据结构中,C++中我们还会再深入研究链表结构的。欲知后事如何,且听下回分解,我们下期再见!
创作不易,还请各位多多支持,求求三连谢谢
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。