单链表专题

单链表专题

1.链表的概念及结构

概念:链表是⼀种物理存储结构上⾮连续、⾮顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。

注意: 链表也是线性表的一种

线性表的概念

线性表 (linear list)是n个具有相同特性的数据元素的有限序列。 线性表是一种在实际中广泛使用的数据结构,常见的线性表: 顺序表、链表、栈、队列、字符串…线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的线性表.

前面我们学过顺序表

顺序表的物理结构和逻辑结构都是线性

但是链表 逻辑结构是线性的 物理结构不是线性

其实可以通过火车车厢去理解

image-20240418201717925

链表的结构跟火车车厢是相似的。有时候根据人流量的需求,我们会不断的去调整车厢的数量。有时候我们在中间增加一个车厢或者减少一个车厢,都不会影响其他车厢,每节车厢都是独立存在的。

而火车是用一种钩子的结构去链接在一起的.

对于链表来说 这个钩子就是指针

注意了

  1. 链表是由一个一个节点组成的(节点也叫结点)
  2. 链表的每一个节点都放着访问到下一个节点的指针
  3. 节点是由存储的数据和指向下一个节点的指针组成的
  4. 最后一个节点的指针要置为NULL

那我们如何去弄出一个链表呢?

  1. 其实我们只需要定义链表的节点的结构
  2. 再用指针去把他们链接起来
  3. 这样一个链表就形成了

那我们如何去定义链表的节点的结构呢?

struct SListNode
{
    int data; // 节点所存储的数据
    struct SListNode* next;// 指向下一个节点的指针
}

现在我们已经大概的了解了链表的概念和结构

我们之前说顺序表有三个问题,其实这三个问题总结下来就是:

  1. 中间/头部的插入效率地下
  2. 增容降低运行效率
  3. 增容造成空间浪费

那么对于链表来说,这三个问题就能得到解决

注意!:

本文章中的所有头节点的说法 其实都是不太正确,不太严谨的,在本文章中为了便于理解,我将使用头节点作为链表的第一个节点的说法。

实际上头节点(放哨位)是带头链表中的的一个说法

2.单链表的实现

# pragma once
#define  _CRT_SECURE_NO_WARNINGS 1
# 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* phead, SLTDataType x);

//在指定位置之前插⼊数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);

链表的打印:

// 链表的打印
void SLTPrint(SLTNode* phead)
{
	SLTNode* pcur = phead; // 创建一个临时指针 指向phead

	// 首先判断传进来的地址是否是NULL
	while (pcur)// pcur != NULL
	{
		printf("%d->", pcur->data);
		pcur = pcur->next; // 让指针指向下一个节点 (这就是打印链表数据的关键)
	}
	printf("NULL\n");
}

image-20240418222413145

思考:

为什么顺序表可以用++的方式去访问下一个数据 链表不可以呢

因为链表的节点在内存的存储中是不连续的,这就是为什么说链表的物理结构不是线性的原因!

因此只能采用指针的方式,去找到下一个节点.

链表的尾插:

尾插的代码:

// 链表的尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
	// 对pphead进行断言  pphead不能为NULL 不然无法解引用
	assert(pphead);

	// 想要插入一个节点 那我们就得申请一个节点
	SLTNode* newnode = SLTBuyNode(x);

	// 对于链表的尾插  我们要分为 空链表和非空链表这两种情况
	if (*pphead == NULL) // 空链表
	{
		*pphead = newnode;
	}
	else // 非空链表
	{
		// 想要尾插,先要找尾 也就是最后一个节点
		// 让ptail从链表的第一个节点开始找 直至找到NULL
		SLTNode* ptail = *pphead;
		while (ptail->next != NULL) 
		// ptail->next是一个对结构体的解引用 我们不能对一个空指针NULL解引用 因此空链表的情况无法处理
		{
			ptail = ptail->next;
		}

		// 此时的ptail指向的就是尾节点
		// 让尾节点指向我们的新节点 这样就完成了尾插
		ptail->next = newnode;
	}
} 

这是尾插的测试代码:

image-20240419111837311

我们会发现 我们传给尾插函数SLTPushBack的实参是&plist,也就是一个二级指针, 而这是为什么呢?

因为我们需要在函数当中通过形参改变我们实参的值 让函数中赋予形参的值能够赋予我们的实参,而想要实现这个就需要——传址调用

如图所示:

image-20240419111803599

到这里我们再来测试一次:

void SListTest02()
{
	SLTNode* plist = NULL;
	SLTPushBack(&plist, 1);
	SLTPushBack(&plist, 2);
	SLTPushBack(&plist, 3);
	SLTPushBack(&plist, 4);
	SLTPrint(plist); // 1->2->3->4->NULL
}

image-20240419113047670

可以看我们调试的图片。

image-20240419113439421

注意了:
我们可以通过调试代码 去走一遍函数的运行思路,能懂调试的思路,过程,才能更好的理解函数!!!

链表的头插:

头插的思路:

  1. 首先创建一个新的节点 去指向原来的第一个节点 ,取而代之成为新的第一个节点。
  2. 与此同时还要让 原来指向第一个节点的指针去指向新节点的地址
  3. 不然即使存在新的头节点,后面访问链表也不回访问到新的头节点
  4. 也就是如下图所示,让*pphead去指向新的节点,再让新的节点指向原来的头节点

image-20240419115623004

头插的代码:

// 链表的头插
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
	assert(pphead);

	// 创建一个要插入链表的新节点
	SLTNode* newnode = SLTBuyNode(x);
	
	//让新节点的指针指向原来的头节点
	newnode->next = *pphead;
	// 再让指向链表头节点的指针 指向新的头节点
	*pphead = newnode;
	// 无论是空链表还是 非空链表 上面的代码都能完成任务 无需分类讨论了

}

头插的测试代码:

	// 测试头插
	SLTPushFront(&plist, 6);
	SLTPushFront(&plist, 7);
	SLTPrint(plist);// 7->6->1->2->3->4->NULL

学完尾插之后 ,再来编写头插就不再难以理解

链表的尾删:

尾删的思路:

  1. 首先我们要先找到最后一个节点并将其删除
  2. 这个时候倒数第二个节点prev的指向尾节点的的指针就为野指针
  3. 我们要将倒数第二个节点的指针置为NULL

image-20240419131946737

尾删的代码:

// 链表的尾删
void SLTPopBack(SLTNode** pphead)
{
	assert(pphead);

	// 判断链表是否为空 (空链表还怎么删节点)
	assert(*pphead);
	// assert(pphead && *pphead);  可以两个断言合并起来

	// 找到尾节点并进行删除
	// 要分链表有一个节点和有多个节点分类讨论
	if ((*pphead)->next == NULL) // 一个节点
	{
		free(*pphead);
		*pphead = NULL;
	}
	else // 多个节点 
	{
		SLTNode* prev = *pphead;
		SLTNode* ptail = *pphead;
		// 先找尾节点
		while (ptail->next)
		{
			prev = ptail; // 找到最后一个尾节点的时候 这个此时的prev指向的是倒数第二个节点
			ptail = ptail->next;
		}
		// 删除尾节点
		free(ptail);
		ptail = NULL;

		prev->next = NULL; //让新的尾节点存储的指针为NULL 这样才是新的尾节点
		// 如果只有一个节点的话 这里的解引用就会失败 因为prev和ptail指向的是一个节点 
		// 当把节点空间释放掉的时候 这里的prev指向的就是NULL 对NULL进行结构体访问成员的 -> 当然会报错
	}
}

尾删的测试:

	//测试尾删
	SLTPopBack(&plist);
	SLTPrint(plist);

	SLTPopBack(&plist);
	SLTPrint(plist);

	SLTPopBack(&plist);
	SLTPrint(plist);

	SLTPopBack(&plist);
	SLTPrint(plist);

	SLTPopBack(&plist);
	SLTPrint(plist);

	SLTPopBack(&plist);
	SLTPrint(plist);

	SLTPopBack(&plist);
	SLTPrint(plist);

image-20240419133516927

可以看到我们的代码是可以正常运行的

说明我们的尾删函数是可以正常工作的

链表的头删:

头删的思路:

1.首先是多个节点的思路:

  1. 首先就是要找到头节点
  2. 找到头节点不能着急去释放掉其空间,应该就把下一个节点的地址保存下来
  3. 并且让访问链表头节点的指针指向下一个节点的地址 让其成为新的头节点
  4. 最后才是把第一个节点给删掉,也就是释放掉其空间

如图所示:

image-20240419181202689

先将第二个节点的地址保存用next保存下来

然后再用*pphead去指向第二个节点的地址 这样以后访问这个链表的时候,就是从第二个节点开始访问,这个节点就变成了新的头节点

2.只有一个节点的思路 :

  1. 当只有一个节点的时候就不需要去考虑那么多了
  2. 只需要将其空间释放掉,并将访问链表的指针置为空。

头删的代码:

// 链表的头删
void SLTPopFront(SLTNode** pphead)
{
	assert(pphead);
	
	// 判断是否为空链表
	assert(*pphead);

	// 首先保存下第二个节点的地址
	SLTNode* next = (*pphead)->next;
	// 删除第一个节点
	free(*pphead);
	// 让访问链表的指针指向第二个节点的地址
	*pphead = next;
	// 这个可以处理1个节点和多个节点的情况
}

头删的测试:

	// 测试头删
    // 7->6->1->NULL

	SLTPopFront(&plist);
	SLTPopFront(&plist);
	//SLTPopFront(&plist);
	//SLTPopFront(&plist);  // Assertion failed: *pphead
	SLTPrint(plist);// 1->NULL

我们现在再来复习一下各个形参和实参的关系:

image-20240419225321578

链表的查找:

查找的思路:

  1. 首先是让一个新的指针去指向链表的头节点
  2. 然后遍历所有节点 查找是否有数据和要找的相同
  3. 找到了就返回节点
  4. 没找到就返回NULL

查找的代码:

// 链表的查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
	// 让pcur来取代访问链表的头节点的指针 
	//【这样子如果我们后面还想用指向头节点的指针,phead就还是指向头节点】
	SLTNode* pcur = phead; 
	// 遍历链表所有的节点
	while (pcur)
	{
		if (pcur->data == x)
		{
			return pcur; // 返回pcur此时指向的节点
		}
		pcur = pcur->next; // 一次找不到就继续找下一个节点
	}

	// 走到这里说明没找到
	return NULL;
}

查找的测试:

	// 测试 查找
	SLTNode* find = SLTFind(plist, 3);
	if (find == NULL)
	{
		printf("没有找到\n");
	}
	else
	{
		printf("找到了")}

链表的指定位置之前插入数据:

思路:

  1. 首先我们要遍历整个链表查看是否有pos这个位置的节点
  2. 让prev指向第一个节点,不停的向后查找,直至prev指向的节点所存储的指针指向的是pos位置的节点,这个时候就停下来
  3. 然后找到了pos位置之后 我们要申请一个新的节点newnode
  4. 然后再将新的节点的存储的指针指向pos位置的节点
  5. 然后再让pos前一个位置的指针prev节点中存储的指针去指向newnode

image-20240419235634845

但是这里有一个问题:

  1. 那就是当pos == *pphead 的时候, 也就是头插的时候,这个时候prev和pos都指向 第一个节点
  2. 那我们再遍历链表的时候,prev直至找完整个链表都找不到pos位置的节点
  3. 这个时候我们就要分类讨论了

image-20240419235958063

这个时候还有一个问题:

  1. 如果传进来的pos既不是NULL 也不是链表当中节点的地址
  2. 是一个不知道哪里的野指针,那我们要针对这个情况进行处理
  3. 不然代码就会陷入死循环无法逃脱

思路清晰了之后,我们就来看看代码是如何实现的:


// 在链表的指定位置之前插入节点
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
	assert(pphead);
	// 链表也不能为空 因为如果为空 都没有位置了,还怎么在指定位置之前插入节点
	assert(*pphead);
	// 你要所选择的位置也不能是NULL 
	assert(pos);

	// 分类 讨论  头插和 其他情况
	if (pos == *pphead) // 头插
	{
		SLTPushFront(pphead, x); // 引用头插函数
	}
	else // 其他情况
	{
		// 首先创建要插入的节点
		SLTNode* newnode = SLTBuyNode(x);
		// 接着我们需要 去遍历整个链表 去找到pos位置
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			if (prev->next == NULL)
			{
				printf("没有找到pos位置的节点!,请输入合法的pos值\n");
				return;
			}
			prev = prev->next;
		}
		// 走到这里就是找到了pos位置的节点
		// 这个时候我们就让三个节点手牵手 prev -> newnode -> pos
		prev->next = newnode;
		newnode->next = pos;
	}
}

测试代码:

	// 测试 查找
	SLTNode* find = SLTFind(plist, 3); // find 就是查找函数返回的这个节点的地址
	// 测试 在指定位置之前插入数据
	//SLTInsert(&plist, plist, 0); // 相当于头插 也可以让find查找头节点 然后返回的就是头节点的地址
	SLTInsert(&plist, find, 9);
	//SLTInsert(&plist, 0x11111111, 9);
	SLTPrint(plist);//  1->2->9->3->4->NULL

在链表的指定位置之后插入数据:

思路:

  1. 首先遍历整个链表找到pos指定的位置的节点
  2. 如果找到了就申请一个新的节点 出来
  3. 让pos位置的节点所存储的指针指向新的节点
  4. 再让新的节点存储的指针指向pos位置的下一个节点
  5. 完成三个节点的链接

image-20240420112843485

即便pos是在头节点,这个逻辑也是走的通过的,因为是在后面插入,不是在前面插入,因此我们不再需要把指向头节点的指针传给函数了

我们来看看代码是如何是实现的:

// 在链表指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
	assert(pos);

	// 申请要出插入的节点
	SLTNode* newnode = SLTBuyNode(x);
	// 插入
	newnode->next = pos->next;
	pos->next = newnode; // 注意了这个代码不能和上面的代码顺序相反
}

测试代码:

	// 测试 在在指定位置之后插入数据
	//  1->2->9->3->4->NULL
	SLTInsertAfter(plist, 11);// 在头节点之后插入数据  头插
	SLTInsertAfter(find, 11); // 在尾节点之后插入数据  尾插
	SLTPrint(plist);//  1->11->2->3->9->4->11->NULL

在链表的指定位置删除节点:

思路:

  1. 首先遍历整个链表找到pos节点
  2. 然后我们的目标是删除pos节点 并将prev节点和pos->next节点链接起来

image-20240420114639098

那我们还得注意一个问题:

这个问题在我们之前也遇到过

那就是pos == *pphead的时候,prev找不到pos

这个时候就要分类讨论。我们来看看代码是怎么实现的:

//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
	assert(pphead);
	// 链表也不能为空
	assert(*pphead);
	// 你也不能传个空节点让我删除
	assert(pos);

	// 分类讨论, pos是头节点和pos不是头节点
	if (pos == *pphead)
	{
		// 删除头节点
		/*
		*pphead = pos->next; // 让访问链表的指针指向新的头节点
		free(pos);
		pos = NULL;
		*/
		// 这里其实就是头删的操作,直接调用头删就行
		SLTPopFront(pphead);
	}
	else
	{
		// 遍历链表找到pos节点
		// 并用prev来找到pos节点的前一个节点
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			if (prev->next == NULL)
			{
				printf("没找到pos节点,请输入合法的pos\n");
				return;
			}
			prev = prev->next;
		}
		// 走到这里说明找到了  
		// 删除pos节点  并完成链接
		prev->next = pos->next;
		free(pos); // 要放到上面代码的后面,不然pos节点存储的数据和指针会丢失
		pos = NULL;
	}
}

测试代码:

	// 测试 删除pos位置节点
	// 1->2->3->9->4->11->NULL
	find = SLTFind(plist, 11);
	SLTErase(&plist, plist); //头删
	SLTErase(&plist, find); // 尾删
	SLTPrint(plist);// 2->3->9->4->NULL

删除链表指定位置之后的节点:

思路:

  1. 首先遍历链表找到pos节点
  2. 然后我们要找到pos下下个节点
  3. 并完成链接和删除pos节点

image-20240420121439732

这里的思路即便是pos == *pphead的时候也是走的通的

无需分类讨论

我们来看代码如何实现

//删除pos之后的节点
void SLTEraseAfter(SLTNode* pos)
{
	assert(pos);
	// pos下一个节点也不能是空的 不然怎么删除
	assert(pos->next);

	// 开始删除
	// 这样删除是错误的
	/*pos->next = pos->next->next;
	free(pos->next); // 此时释放掉的是pos节点的下下个节点
	pos->next = NULL;*/
	// 来看正确的代码
	SLTNode* del = pos->next;
	pos->next = del->next; // del->next == pos->next->next
	free(del);
	del = NULL;

	// 下面这段代码 也可以实现删除pos之后的节点的功能
	//SLTNode* next = pos->next->next;
	//free(pos->next);
	//pos->next = NULL;
	//pos->next = next;
}

测试代码:

// 测试删除pos位置之后的节点
find = SLTFind(plist, 9);
SLTEraseAfter(plist);
SLTEraseAfter(find);
SLTPrint(plist); // 2->9->NULL

链表的销毁:

思路:

  1. 首先通过遍历链表 把每一个链表的节点都给删除
  2. 要注意先把下一个节点的地址存储起来,不然节点空间释放之后
  3. 在想去找到下一个节点的地址就找不到了

我们来看代码的实现:

//销毁链表
void SListDesTroy(SLTNode** pphead)
{
	assert(pphead);
	assert(*pphead);// 链表为空怎么销毁

	// 开始销毁
	SLTNode* pcur = *pphead;
	SLTNode* next = NULL;
	while (pcur)
	{
		next = pcur->next;
		free(pcur);
		pcur = next;
	}
	// pcur 此时为 NULL
	*pphead = NULL;
}

我们来看测试代码:

	//测试链表的销毁
	SListDesTroy(&plist);
	SLTPrint(plist);// NULL

3.链表的分类

image-20240421213907945

image-20240421214330130

在前面实现链表中,口头上提到的头结点实际上指的是第一个有效节点,这不是正确的称呼~但是为了好理解才这样错误的称呼为头结点,实际在链表中,头结点指的是哨兵位!

image-20240421214510559

单向链表已经学习了,双向链表就是可以通过一个节点找到下一个节点,也可以找到上一个节点.

单向链表只能从单方向遍历

双线链表可以从两个方向遍历

image-20240421214547387

本章节我们学习的是: 不带头单项不循环链表

虽然有这么多的链表的结构,但是我们实际中最常⽤还是两种结构: 单链表 和 双向带头循环链表

  1. ⽆头单向⾮循环链表:结构简单,⼀般不会单独⽤来存数据。实际中更多是作为其他数据结

构的⼦结构,如哈希桶、图的邻接表等等。另外这种结构在笔试⾯试中出现很多。

  1. 带头双向循环链表:结构最复杂,⼀般⽤在单独存储数据。实际中使⽤的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使⽤代码实现以后会发现结构会带来很多优势,实现反⽽简单了,后⾯我们代码实现了就知道了。

image-20240421215146610
看测试代码:**

	//测试链表的销毁
	SListDesTroy(&plist);
	SLTPrint(plist);// NULL

3.链表的分类

image-20240421213907945

image-20240421214330130

在前面实现链表中,口头上提到的头结点实际上指的是第一个有效节点,这不是正确的称呼~但是为了好理解才这样错误的称呼为头结点,实际在链表中,头结点指的是哨兵位!

image-20240421214510559

单向链表已经学习了,双向链表就是可以通过一个节点找到下一个节点,也可以找到上一个节点.

单向链表只能从单方向遍历

双线链表可以从两个方向遍历

image-20240421214547387

本章节我们学习的是: 不带头单项不循环链表

虽然有这么多的链表的结构,但是我们实际中最常⽤还是两种结构: 单链表 和 双向带头循环链表

  1. ⽆头单向⾮循环链表:结构简单,⼀般不会单独⽤来存数据。实际中更多是作为其他数据结

构的⼦结构,如哈希桶、图的邻接表等等。另外这种结构在笔试⾯试中出现很多。

  1. 带头双向循环链表:结构最复杂,⼀般⽤在单独存储数据。实际中使⽤的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使⽤代码实现以后会发现结构会带来很多优势,实现反⽽简单了,后⾯我们代码实现了就知道了。

image-20240421215146610

  • 24
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值