• 【大画数据结构】第二话 —— 无头单链表的基本操作


    在这里插入图片描述


    前言

    在上一篇文章中,我们学习了 顺序表 的基本操作,但是它也是有优缺点的;

    顺序表的优点:

    拥有⾮常⾼效的随机访问能⼒,只要给出下标,就可以⽤常量时间找到对应元素。
     
    例如:有⼀种⾼效查找元素的算法叫作 ⼆分查找,就是利⽤了这个优势。

    顺序表的缺点:

    1、空间不够了,需要扩容,扩容是有消耗的(原地增容 或者 异地增容)
     
    2、头部、尾部或者中间位置的插入删除,需要挪动数据,挪动数据也是有消耗的。
     
    3、为了避免频繁扩容,一次一般都是按倍数去扩容(2 倍),但是也存在一定空间的浪费。

    总的来说,顺序表 所适合的是读操作多、写操作少的场景,针对顺序表的缺陷,设计出了 链表

    什么是链表

    顺序表最大的缺点就是:插入和删除时需要移动大量元素,这显然就需要耗费时间。
     
    为什么当插入和删除时,就要移动大量元素?因为相邻两元素的存储位置在内存中的位置是挨着的,中间没有空隙,所以就无法快速插入;而删除后,当中就会留出空隙,自然需要弥补。
     
    既然要让相邻元素间留有足够余地,那干脆所有的元素都不要考虑相邻位置了,哪有空位就到哪里,并且让每个元素只知道它下一个元素的位置在哪里;
     
    这样,我们可以在第—个元素时,就知道第二个元素的位置(内存地址)。而找到它;
     
    在第二个元素时,再找到第三个元素的位置(内存地址)。这样所有的元素我们就都可以通过遍历而找到。

    线性表的链式存储结构的特点是:用—组任意的存储单元去存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的;

    这就意昧着:这些数据元素可以存在内存未被占用的任意位置,如图所示👇
    在这里插入图片描述

    存储结构定义

    以前在顺序表中,每个数据元素只需要存储数据元素信息就可以了。

    但是在链式结构中,除了要存储数据元素信息外,还要存储它的后一个元素的存储地址。

    我们把存储数据元素信息的域称为 数据域,把存储直接后继位置的域称为 指针域;指针域中存储的信息称作 指针;由两部分信息组成的数据元素被称为 结点(Node)

    n个结点链结成一个链表,即为线性表的链式存储结构,因为此链表的每个结点中只包含一个指针域,所以叫做 单链表

    单链表正是通过每个结点的指针域将 线性表的数据元素 按其逻辑次序链接在一起,如下图所示👇
    在这里插入图片描述

    对于线性表来说,总得有个头有个尾,链表也不例外。

    我们把链表中第一个结点的存储位置叫做头指针,那么整个链表的存取就必须是从头指针开始进行了。之后的每一个结点,其实就是上一个的后继指针指向的位置。想象一下,最后一个结点,它的指针指向哪里?

    最后一个,当然就意味着直接后继不存在了,所以我们规定,线性链表的最后一个结点指针为 ,通常用 NULL 表示,如下图所示👇
    在这里插入图片描述

    1. 单链表定义

    我们知道链表是由多个结点组成,所以要想创建一个链表,首先要创建一个结点。而一个结点存储的内容可以分为两部分:数据域,指针域。

    数据域: 用于存储数据。

    指针域: 用于存储下一个结点的地址,使链表 连起来

    因为指针域是指针类型(存储地址),而 数据域的类型多种多样,所以我们应该创建一个结构体类型的结点。

    📝 代码示例

    typedef int SLTDateType; // 定义数据类型
    
    typedef struct SListNode
    {
    	SLTDateType data; // 数据域, 存储数据
    	struct SListNode* next; // 指针域, 指向下一个节点的指针
    }SLTNode;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    从这个结构定义中,我们也就知道,结点由 存放数据元素的数据域存放后继结点地址的指针域 组成。

    假设 p 是指向线性表第 i 个元素的指针,则该结点 a i a_i ai 的数据域我们可以用 p->data 来表示,p->data 的值是一个数据元素,结点 a i a_i ai 的指针域可以用 p->next 来表示,p->next 的值是一个指针。

    p->next 指向谁呢?当然是指向第 i + 1 i+1 i+1 个元素,即指向 a i + 1 a_{i+1} ai+1 的指针。也就是说,如果 p->data 等于 a,那么 p->next->data 等于 a i + 1 a_{i+1} ai+1(如下图所示👇)
    在这里插入图片描述

    phead(头指针)指向头结点(第一个结点),之后的每个结点的指针域 next 指向下一个结点,其中尾结点的 next 为空,如图所示👇
    在这里插入图片描述

    2. 插入数据

    顺序表 稍微有点不同,单链表插⼊节点时,分为 2 种情况。
     
    (1)在单链表 头部 插入数据;
     
    (2)在单链表 尾部 插入数据;
     
    (3)在单链表指定 pos 位置插入数据 x
     
    (4)在单链表指定 pos 位置的 下一个 位置插入数据 x

    🍑 动态申请一个节点

    在插入数据前,我们的单链表还是 的,首先用 malloc 函数创建一个头结点(头结点不包含数据),该节点既是头结点,又是尾结点,于是头指针和尾指针都指向这个结点,并把该结点的指针域置为空。
    在这里插入图片描述

    📝 代码示例

    SLTNode* BuyListNode(SLTDateType x) {
    	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    	if (newnode == NULL) {
    		printf("malloc fail\n");
    		exit(-1); // 内存都申请失败了,说明空间不足,直接终止程序(不使用return)
    	}
    	newnode->data = x;
    	newnode->next = NULL;
    	return newnode;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    🍑 尾插

    尾部插⼊,是最简单的情况,把最后⼀个节点的 next 指针指向新插⼊的节点即可。,但是尾插也要分 2 种情况

    1 种情况,我们知道 phead(头指针)指向头结点(第一个结点),如果 头指针 为空,说明此时 单链表 里面啥都没有,那么直接把数据 newnode 的地址存到 头指针 指向的空间,如图所示👇
    在这里插入图片描述
    动图演示👇
    在这里插入图片描述

    2 种情况,如果单链表不为空,也就是假设链表里面已经存了 3 个元素了。

    那么我们可以遍历找到最后一个结点,也就是 尾节点(tail),然后把新开辟的空间 newnodenext 置为空,最后再使用尾结点的 next(tail) 连接新开辟的空间 newnode
    在这里插入图片描述

    动图演示👇
    在这里插入图片描述

    但是有两点需要注意:

    (1)phead 要进行判断是否为空,因为当链表为空时,phead 就为空,会引发异常。
     
    (2)要注意 函数传参,如果我们在实参部分直接传 phead,而形参部分拿 SLTNode* 接收的话,这就属于 值传递,值传递相当于 形参是实参的一份临时拷贝,形参的改变并不会影响实参的值。
     
    所以如果想要修改实参的值,就需要进行 传址 操作,在实参部分直接传 phead 的地址,那么形参部分就要拿 SLTNode** 二级指针接收。

    📝 代码示例

    // 因为传过来的是phead的地址,phead是一级指针的地址,所以要拿二级指针接收
    void SListPushBack(SLTNode** pphead, SLTDateType x) {
    	assert(pphead); // pphead不可以为空指针.
    	// 先增加一个节点
    	SLTNode* newnode = BuyListNode(x);
    
    	// 如果头节点是空,那么直接把newnode的地址赋值给头节点
    	if (*pphead == NULL) {
    		// *pphead:对pphead解引用,其实访问的就是plist;
    		*pphead = newnode;
    	}
    	else {
    		// 找到尾节点
    		SLTNode* tail = *pphead;
    		while (tail->next != NULL) {
    			tail = tail->next;
    		}
    		tail->next = newnode;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    代码解释

    SLTNode* tail = *pphead:首先定义一个指向 头节点 的指针 tail
     
    tail->next != NULL:如果 tail 节点的 next 不为 空指针 ,说明此时还没有找到尾节点,因为尾节点的 next 是为 空指针 的,则进入循环;
     
    tail = tail->next:因为当前节点的 next 存放的是下一个节点的地址,那么我们就是把下一个节点的地址赋给 tail,换句话说,也就是让 tail 指向第二个节点;
     
    以此类推,直到 tail->next空指针,说明 tail 已经找到了 尾节点,即跳出 while 循环;
     
    tail->next = newnode:然后把 newnode 节点的地址赋给 tail->next

    🍑 头插

    还是和尾插一样,需要改变 phead 的值,所以我们的形参需要用二级指针接收 phead 的地址。

    头部插⼊,可以分成两个步骤。

    1 步,把新节点 newnodenext 指针指向原先的头节点。

    2 步,把新节点变为链表的头节点,如下图所示👇
    在这里插入图片描述

    动图演示👇
    在这里插入图片描述

    注意:头插 就不用区分链表是否为空的

    📝 代码示例

    void SListPushFront(SLTNode** pphead, SLTDateType x) {
    	assert(pphead);
    	SLTNode* newnode = BuyListNode(x); // 1、创建新节点
    	newnode->next = *pphead; // 2、让新节点的next指向原来的头节点
    	*pphead = newnode; // 3、phead头指针 指向新节点
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    代码解释

    SLTNode* newnode = BuyListNode(x):先增加一个新节点;
     
    newnode->next = *pphead:对 pphead 解引用就是访问 pheadphead 为原来 头指针 指向的 头节点,那么把这个 地址 赋给新节点的 next 指针;
     
    *pphead = newnode:最后再把 头指针 指向的 头节点 更新为 新节点,此时 新节点 就是链表的第一个节点啦!

    3. 删除数据

    链表的删除操作,同样也分为 4 种情况。

    (1)在单链表 头部 删除数据;
     
    (2)在单链表 尾部 删除数据;
     
    (3)在单链表指定 pos 位置删除数据 x
     
    (4)在单链表指定 pos 位置的 下一个 位置删除数据 x

    🍑 尾删

    尾部删除,是最简单的情况,但是我们要考虑几种特殊情况:

    (1)如果链表为空,肯定不能删除
     
    (2)如果链表只有一个节点,那么直接删除
     
    (3)如果链表有两个以及两个以上的节点;

    对于第 3 种情况,如果有两个以上的节点,那么只需把倒数第 2 个节点的 next 指针指向空即可。如下图所示👇
    在这里插入图片描述
    动图演示👇
    在这里插入图片描述

    📝 代码示例

    void SListPopBack(SLTNode** pphead) {
    	// 判断链表为空的情况(粗暴的方式)
    	assert(*pphead != NULL);
    
    	// 1.如果只有一个节点的情况
    	if ((*pphead)->next == NULL) {
    		free(*pphead); // 释放头节点
    		*pphead = NULL;
    	}
    	else // 2.如果有两个以及两个以上的节点
    	{ 
    		SLTNode* prev = NULL;
    		SLTNode* tail = *pphead;
    		while (tail->next != NULL) {
    			prev = tail; // 找到tial前面的那个节点
    			tail = tail->next; // tial指向它的下一个节点
    		}
    		free(tail); // 释放最后一个节点的空间
    		tail = NULL; //  把空间置为空指针
    		prev->next = NULL; // 把tial上一个节点的next指向的元素置为NULL
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    代码解释

    我们直接看 else 语句的代码:
     
    SLTNode* prev = NULL:首先定义一个指针变量 prev,用来指向倒数第 2 个节点;
     
    SLTNode* tail = *pphead:然后再定义一个指针变量 tail,并把 头节点 的地址赋给它,让它用来找到 尾节点
     
    while 循环部分:当 tail->next 不等于 空指针 时,说明 尾节点 还没有找到,那么就把当前 tail 的地址赋给 prev,然后 tail 指向下一个节点去,以此循环,直到 tail->next 等于 空指针,然后跳出循环;
     
    最后释放掉 tail 的空间,然后把 prev->next 置为 空指针

    🍑 头删

    尾部删除,也要考虑几种特殊情况:

    (1)当链表为空时候,没有可以删除的元素,那么就终止程序;
     
    (2)如果链表只有一个节点,那么直接删除;
     
    (3)如果链表有两个以及两个以上的节点。

    对于第 3 种情况,如果有两个以上的节点,直接把链表的头节点设为原先头节点的 next 指针即可。如下图所示👇
    在这里插入图片描述

    动图演示👇
    在这里插入图片描述

    📝 代码示例

    void SListPopFront(SLTNode** pphead) {
    	assert(*pphead != NULL); // 判断链表为空的情况(粗暴的方式)
    	SLTNode* temp = (*pphead)->next;
    	free(*pphead);
    	*pphead = temp;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    代码解释

    定义一个指针变量 temp,把头节点的 next 指针指向的地址赋给它,此时 temp 就是指向第 2 个元素的;
     
    然后释放掉 头节点 的空间,再把 temp 的内容交给 头指针

    4. 单链表查找

    在查找元素时,链表不像 顺序表 那样可以通过下标快速进⾏定位,只能从头节点开始向后⼀个⼀个节点逐⼀查找。

    例如给出⼀个链表,需要查找从头节点开始的第 3 个节点(如下图所示👇)。
    在这里插入图片描述

    1 步,将查找的指针定位到头节点(如下图所示👇)。
    在这里插入图片描述

    2 步,根据头节点的 next 指针,定位到第 2 个节点(如下图所示👇)。
    在这里插入图片描述

    3 步,根据第 2 个节点的 next 指针,定位到第 3 个节点,查找完毕(如下图所示👇)。
    在这里插入图片描述

    动图演示👇
    在这里插入图片描述

    📝 代码示例

    SLTNode* SListFind(SLTNode* phead, SLTDateType x) {
    	SLTNode* cur = phead;
    	while (cur) {
    		if (cur->data == x) {
    			return cur;
    		}
    		else {
    			cur = cur->next;
    		}
    	}
    	return NULL; // 没找到
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    单链表中的数据只能按顺序进⾏访问,最坏的时间复杂度是 O ( n ) O(n) O(n)

    5. 单链表指定位置操作数据

    在插入数据和删除数据里面,分别还有两种方法没有讲到,那就是:

    (1)指定 pos 位置插入和删除;
     
    (2)指定 pos 位置的后面插入和删除;

    那么为什么到现在才讲呢?那是因为 单链表 不同于 顺序表,这里的 pos 位置,是通过上面的 查找 得来的!

    🍑 在 pos 位置的前面插入数据

    在单链表指定 pos 位置插入数据 x,实际上数据 x 会被插入到 pos前面 位置插入数据,同样也要考虑几种特殊情况:

    (1)如果链表为空,或者只有一个节点,那么就和头插一样,直接插入;
     
    (2)如果链表有两个以上的节点;

    对于第 2 种情况,如果链表有两个以上的节点,那么可以分为两个步骤。

    1 步,新节点的 next 指针,指向 插⼊位置的节点

    2 步,插⼊位置前置节点next 指针,指向新节点(如下图所示👇)。
    在这里插入图片描述

    动图演示👇
    在这里插入图片描述

    注意:首先需要调用 查找函数 找到 pos 位置的地址,然后才能进行插入;
     
    另外只要内存空间允许,能够插⼊链表的元素是⽆穷⽆尽的,不需要像 顺序表 那样考虑扩容的问题。

    📝 代码示例

    // 在pos前面的位置插入(pos是有find查找来的)
    void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDateType x) {
    	assert(pphead);
    	assert(pos);
    	// 1. pos是第一个节点,那么就是头插
    	if (*pphead == pos) {
    		SListPushFront(pphead, x);
    	}
    	else { // 2. pos不是第一个节点
    		// 找到pos的前一个位置
    		SLTNode* posPrev = *pphead;
    		while (posPrev->next != pos) {
    			posPrev = posPrev->next;
    		}
    		SLTNode* newnode = BuyListNode(x);
    		posPrev->next = newnode;
    		newnode->next = pos;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    🍑 删除在 pos 位置的数据

    直接删除 pos 位置的 当前 数据 x,同样也要考虑几种特殊情况:

    (1)如果链表为空,或者只有一个节点,那么就和头删一样,直接删除;
     
    (2)如果链表有两个以上的节点;
     
    注意:这里就是删除 pos 位置指向的数据,不是删除前面的节点,也不是删除后面的节点

    对于第 2 种情况,如果链表有两个以上的节点,同样很简单,把要删除节点的前置节点的 next 指针,指向要删除元素的下⼀个节点即可(如下图所示👇)。
    在这里插入图片描述

    动图演示👇
    在这里插入图片描述

    📝 代码示例

    void SListErase(SLTNode** pphead, SLTNode* pos) {
    	assert(pphead);
    	assert(pos);
    	// 如果pos是头节点,那么用头删除
    	if (*pphead == pos) {
    		SListPopFront(pphead);
    	}
    	else { // 如果pos在中间或者尾节点,那么就走else
    		SLTNode* posPrev = *pphead;
    		while (posPrev->next != pos) {
    			posPrev = posPrev->next;
    		}
    		posPrev->next = pos->next;
    		free(pos);
    		pos = NULL;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    6. 单链表指定位置的后面操作数据

    在单链表指定的 pos 位置的后面 插入数据删除数据

    🍑 在 pos 位置的后面插入数据

    这里与在 pos 位置的前面插入数据不同的是,这次插入数据,是直接插到 pos 的后面位置的,这里不需要考虑什么特殊情况;

    1 步,定义一个指针变量 posNext,用于存放 pos 位置的 next 指针地址

    2 步,把新节点 newnode 的地址赋给 pos 位置的 next 指针,然后把 posNext 的地址赋给新节点 newnode 的指针 next(如下图所示👇)。
    在这里插入图片描述

    📝 代码示例

    void SListInsertAfter(SLTNode* pos, SLTDateType x) {
    	assert(pos);
    	SLTNode* posNext = pos->next;
    	SLTNode* newnode = BuyListNode(x); 
    	pos->next = newnode;
    	newnode->next = posNext;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    🍑 删除在 pos 位置后面的数据

    这个也很简单,只需要考虑 pos 是否存在,如果 pos 为空,肯定不能删除;

    如果 pos 不为空,正常输出就好如下图所示👇
    在这里插入图片描述

    📝 代码示例

    void SListEraseAfter(SLTNode* pos) {
    	assert(pos);
    	SLTNode* posNext = pos->next;
    	if (posNext != NULL) {
    		pos->next = posNext->next;
    		free(posNext);
    		posNext = NULL;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    7. 单链表打印

    单链表的打印很简单,直接用 循环 依次打印 单链表 内的元素个数就好了。

    📝 代码示例

    void SListPrint(SLTNode* phead) {
    	SLTNode* cur = phead;
    	while (cur != NULL) {
    		printf("%d->", cur->data);
    		cur = cur->next;
    	}
    	printf("NULL\n");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    8. 单链表销毁

    顺序表 一样,都是使用动态内存开辟的空间,用完以后,需要释放

    📝 代码示例

    // 销毁链表
    void SListDestroy(SLTNode** pphead) {
    	assert(pphead);
    	SLTNode* cur = *pphead;
    	while (cur != NULL) { // 当前的节点不等于空指针
    		SLTNode* curNext = cur->next; // 保存 当前节点的第一个地址
    		free(cur); // 把当前节点置为空指针
    		cur = curNext; 
    	}
    	*pphead = NULL; // 最后释放头节点
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    9. 总结

    总结:

    1、单链表结构,适合头插头删
     
    2、尾部或者中间某个位置的插入和删除不适合
     
    3、如果要使用链表单独存储数据,那么双向链表更适合

    单链表学习的意义:

    1、单链表会作为学习高阶数据结构的子结构,如:图的邻接表、哈希桶
     
    2、单链表会出很多经典的面试题

    接口函数贴图

    最后附上一张完整的 单链表接口函数图
    在这里插入图片描述

  • 相关阅读:
    uniapp:启动图 .9png 制作教程
    Apache HTTPD 换行解析漏洞(CVE-2017-15715)
    wgcna 原文复现 小胶质细胞亚群在脑发育时髓鞘形成的作用 microglial
    centos执行systemctl restart命令报连接超时
    普及组算法汇总
    7. Spring Boot2.5 安全机制与 REST API 身份验证实战
    spring-初识spring
    vue3新特性 Ⅱ
    蓝桥杯刷题(一)
    2023年亚太杯数学建模亚太赛C题思路解析+代码+论文
  • 原文地址:https://blog.csdn.net/m0_63325890/article/details/125921117