数据结构--链表

目录

一、什么是链表

二、常见的链表类型

[2.1 单向链表](#2.1 单向链表)

[2.1.1 定义结构体成员以及方法](#2.1.1 定义结构体成员以及方法)

[2.1.2 内存申请](#2.1.2 内存申请)

[2.1.3 插入元素](#2.1.3 插入元素)

[2.1.4 删除元素](#2.1.4 删除元素)

2.1.5查找元素

[2.1.6 打印元素](#2.1.6 打印元素)

[2.1.7 销毁链表](#2.1.7 销毁链表)

[2.1.8 测试](#2.1.8 测试)

[2.2 双向链表](#2.2 双向链表)

[2.2.1 定义结构体成员以及方法](#2.2.1 定义结构体成员以及方法)

[2.2.2 内存申请](#2.2.2 内存申请)

[2.2.3 初始化结构体成员变量](#2.2.3 初始化结构体成员变量)

[2.2.4 插入元素](#2.2.4 插入元素)

[2.2.5 删除元素](#2.2.5 删除元素)

2.2.6查找元素

[2.2.7 打印元素](#2.2.7 打印元素)

[2.2.8 销毁链表](#2.2.8 销毁链表)

[2.2.9 测试](#2.2.9 测试)

三、总结


一、什么是链表

链表是一种物理存储单元上非连续、非顺序的存储结构,其数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点组成,每个结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。

链表有以下优缺点:

  • 优点:动态内存分配,可以根据需要随时增删结点,不需要预先知道数据的大小。
  • 缺点:随机访问效率低,需要从头开始遍历。

二、常见的链表类型

链表的类型有很多,一共8种,分为单向和双向,带头和不带头、循环和不循环。

单向和双向:


带头和不带头:


循环和不循环:

别看有这么多,但其实我们只需要掌握其中两个,剩下的便都可以掌握,这两个分别是:单向不带头不循环链表(后面简称单向链表 )和双向带头循环链表 (后面简称双向链表)

2.1 单向链表

**概念:**每个节点包含一个数据域和一个指针域(也就是存放数据的地址)。指针域指向下一个节点,直到最后一个节点指向空指针(Null)

就像图中这样:

接下来,我来转换成代码的形式进行讲解。

2.1.1 定义结构体成员以及方法

cpp 复制代码
//这里用的是C语言哦
#pragma once

#include<stdio.h>
#include<stdlib.h>
#include<assert.h>

typedef int SLTDataType;
typedef struct SListNode
{
	SLTDataType data;//代表数据值,也就是数据域
	struct SListNode* next;//代表存放下一个数据的地址,也就是指针域
 }SLTNode;

//打印链表
void SLTPrint(SLTNode* phead);
//内存申请
SLTNode* BuySLTNode(SLTDataType x);
//尾插
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 SLTInsertFront(SLTNode** pphead, SLTNode* pos, int x);
//在某个元素之后插入
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
//删除指定元素
void SLTErase(SLTNode** pphead, SLTNode* pos);

做完这些基础工作之后,那我们就开始实现这些方法吧。

2.1.2 内存申请

当我们需要将元素插入链表中来,我们需要给这个元素申请一块内存空间才可以进行插入操作。

cpp 复制代码
SLTNode* BuySLTNode(SLTDataType x)
{
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));//确定创建内存的大小
	if (newnode == NULL)//如果创建失败,就返回NULL
	{
		perror("malloc");
		return NULL;
	}
    //成功的话,就进行赋值
	newnode->data = x;
    //因为它是个新节点,不知道要存放哪个数值的指针域地址,所以就将它的指针域设置为NULL
	newnode->next = NULL;
    //然后返回这个节点
	return newnode;
}

2.1.3 插入元素

插入元素分为:头插、尾插、在某个元素前插入和某个元素之后插入

我们来一一讲解:

  • 头插
cpp 复制代码
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
    //确保传入的指针地址有效,防止传来的值为NULL
	assert(pphead);
	SLTNode* newnode = BuySLTNode(x);
    //将新插入节点的指针域指向原来的头节点
	newnode->next = *pphead;
    //再将头节点的值改为现在新插入的节点
    //即可完成头插
	*pphead = newnode;
}

注意,这里使用二级指针作为参数是因为:main 函数中,头指针 plist 存储的是第一个节点的地址。头插时,新节点要成为新的头节点,所以必须把 plist 的值改为新节点的地址。C语言是值传递,如果只传一级指针 SLTNode*,函数内修改的是形参的副本,外部的 plist 不会改变。传二级指针 SLTNode** 才能通过地址直接修改外部的 plist。但当你使用一级指针 作为返回值时,就可以将二级指针参数改为一级指针参数。

  • 尾插
cpp 复制代码
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
    //确保传入的指针地址有效,防止传来的值为NULL
	assert(pphead);
	SLTNode* newnode =  BuySLTNode(x);
    //如果头节点为NULL的话,那么第一个插入的节点就作为头节点,之后在这个头节点的后面插入即可
	if (*pphead == NULL)
	{
		*pphead = newnode;
	}
	else
	{
        //从链表的头节点开始遍历,走到链表的末尾停
		SLTNode* tail = *pphead;
		while (tail->next != NULL)
		{
			tail = tail->next;
		}
        //走到链表的末尾之后,将当前最后一个节点的指针域指向新插入的节点,
        //这样新插入的节点,就变成末尾了。
		tail->next = newnode;
	}
}
  • 在某个元素前插入
cpp 复制代码
void SLTInsertFront(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
    //pphead--代表一整个链表
    //pos--代表链表上的某个节点,即插入的位置
    //x--代表要插入的值


    //确保要插入的位置节点 pos 是有效的,不为空。
	assert(pos);
    //确保传入的指针地址有效,防止传来的值为NULL
	assert(pphead);

    //如果给的位置是头节点那里,就直接进行头插
	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;
	}
}
  • 某个元素之后插入
cpp 复制代码
//使用这个函数需要搭配 SLTFind 这个函数
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
	assert(pos);
	SLTNode* newnode = BuySLTNode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

2.1.4 删除元素

删除元素分为三种:头删、尾删和指定位置删除

我们来一一讲解:

  • 头删
cpp 复制代码
void SLTPopFront(SLTNode** pphead)
{
    //确保传入的二级指针本身不为 NULL
	assert(pphead);
    //这一步是为确保链表不为空
	assert(*pphead);

    //将头节点转换到当前头节点的下一个节点
	SLTNode* head = *pphead;
	*pphead = head->next;

    //这两步是为了释放之前头节点的内存,防止变为野指针
	free(head);
	head->next = NULL;
}

我来讲解下 assert(*pphead) 这步操作:如果链表已经是空的(*pphead == NULL),头删操作就没有节点可以删除。如果没有这个断言,后续 SLTNode* head = *pphead; 会让 head 成为 NULL,然后 *pphead = head->next; 会试图访问 NULL->next,导致程序崩溃。这个断言拦截的是"在空链表上执行删除"这种逻辑错误。

  • 尾删
cpp 复制代码
void SLTPopBack(SLTNode** pphead)
{
	//确保传入的二级指针本身不为 NULL
	assert(pphead);
    //这一步是为确保链表不为空
	assert(*pphead);

    //这一步跟尾插一样,只不过一个是插入,一个是删除,大家类比记忆就好了
	SLTNode* tail = *pphead;

    //为什么使用 (*pphead)->next
    //是因为用来判断它是否只有一个节点
	if((*pphead)->next == NULL)
	{
		free(*pphead);
		*pphead = NULL;
	}
	else
	{
        //为什么使用tail->next->next
        //是因为要记录该链表的倒数第二个节点。
        //因为你要删除的元素是倒数第一个节点。
        //所以你需要知道倒数第二个元素节点,将倒数第二个节点的指针域指向 NULL
        //即可完成删除操作
        //建议画图理解
		while (tail->next->next)
		{
			tail = tail->next;
		}

        //释放节点内存,防止变为野指针
		free(tail->next);
		tail->next = NULL;
	}
}
  • 指定位置删除

代码如下:

cpp 复制代码
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
	//确保要插入的位置节点 pos 是有效的,不为空。
	assert(pos);
    //确保传入的指针地址有效,防止传来的值为NULL
	assert(pphead);
	if (*pphead == pos)
	{
		SLTPopFront(pphead);
	}
	else
	{
		SLTNode* prev = *pphead;

        //找到该位置的前一个节点
		while (prev->next != pos)
		{
			prev = prev->next;
		}

        //进行删除操作
		prev->next = pos->next;
        //释放节点内存,防止变为野指针
		free(pos);
//这里不写 pos = NULL 的理由是:pos 是一个形参(一级指针),它接收的是外部传入的节点地址的拷贝。
//在函数内部将 pos = NULL,只是把形参这个局部变量改为 NULL,完全不影响外部调用者传入的那个指针变量。
	}
}

2.1.5查找元素

大家在理解上面的插入的基础上,再来看这些东西就会非常简单。

查找元素非常简单,就是从头节点开始,一直遍历,遍历到 NULL 之后,就可以了。

代码如下:

cpp 复制代码
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
	SLTNode* cur = phead;
	while (cur)
	{
		if (cur->data == x)
		{
			return cur;
		}
		cur = cur->next;
	}
	return NULL;
}

2.1.6 打印元素

打印元素跟查找元素的逻辑一样,只不过是将遍历的值打印出来而已。

代码如下:

cpp 复制代码
void SLTPrint(SLTNode* phead)
{
	SLTNode* head = phead;
	while (head)
	{
		printf("%d->", head->data);
		head = head->next;
	}
	printf("NULL\n");
}

2.1.7 销毁链表

如果这个链表之后不需要用了,就可以调用该函数。

代码如下:

cpp 复制代码
void SLTDestroy(SLTNode** pphead)
{
    //确保要插入的位置节点 pos 是有效的,不为空。
	assert(pphead);
    //链表为空就不需要销毁了
	assert(*pphead);  

	SLTNode* cur = *pphead;
	while (cur != NULL)
	{
        //先保存下一个节点的地址
		SLTNode* next = cur->next;  
        //释放当前节点
		free(cur);   
        //移动到下一个节点               
		cur = next;                 
	}
    //最后把头指针置空
	*pphead = NULL;  
}

2.1.8 测试

写完上面的功能之后,我们来看看测试结果。

  • 测试代码:

    cpp 复制代码
    #include"SList.h"
    
    void test1()
    {
    	SLTNode* plist = NULL;
    	SLTPushBack(&plist, 1);
    	SLTPushBack(&plist, 2);
    	SLTPushBack(&plist, 3);
    	SLTPushBack(&plist, 4);
    	SLTPrint(plist);
    }
    
    void test2()
    {
    	SLTNode* plist = NULL;
    	SLTPushFront(&plist, 1);
    	SLTPushFront(&plist, 2);
    	SLTPushFront(&plist, 3);
    	SLTPushFront(&plist, 4);
    	SLTPrint(plist);
    	SLTPopBack(&plist);
    	SLTPrint(plist);
    	SLTPopFront(&plist);
    	SLTPrint(plist);
    }
    void test3()
    {
    	SLTNode* plist = NULL;
    	SLTPushBack(&plist, 1);
    	SLTPushBack(&plist, 2);
    	SLTPushBack(&plist, 3);
    	SLTPushBack(&plist, 4);
    	SLTPrint(plist);
    
    	// 值为2那个节点  *2
    	SLTNode* ret = SLTFind(plist, 2);
    	ret->data *= 2;
    	SLTPrint(plist);
    }
    void test4()
    {
    	SLTNode* plist = NULL;
    	SLTPushBack(&plist, 1);
    	SLTPushBack(&plist, 2);
    	SLTPushBack(&plist, 3);
    	SLTPushBack(&plist, 4);
    	SLTPrint(plist);
    	SLTNode* ret = SLTFind(plist, 2);
    	SLTInsertFront(&plist, ret, 66);
        SLTInsertAfter(ret, 666);
    	SLTPrint(plist);
    	SLTErase(&plist, ret);
    	SLTPrint(plist);
    }
    
    int main()
    {
    	test1();
    	printf("=====================================================================================\n");
    	test2();
    	printf("=====================================================================================\n");
    	test3();
    	printf("=====================================================================================\n");
    	test4();
    	printf("=====================================================================================\n");
    	return 0;
    }
  • 测试结果:

2.2 双向链表

双向链表跟单链表一样,同样有数据域和指针域。不同之处在于,指针域有左右(或上一个、下一个)之分,用来连接上一个结点、当前结点、下一个结点。

就像图中这样:

这种带哨兵位的头节点有以下好处:

  1. 统一空链表和非空链表的操作:无论链表是否为空,都有一个固定的头节点存在,不需要单独判断链表是否为空。

  2. 简化插入和删除操作:在头部插入或删除节点时,不需要特殊处理头指针的更新,因为头节点固定不变,操作的总是头节点之后的节点。

  3. 避免空指针异常:遍历或操作时,不需要额外判断头指针是否为NULL,因为哨兵位头节点始终存在。

  4. 代码更简洁:插入和删除操作的逻辑统一,不需要针对首节点写特殊分支,减少了代码重复和出错概率。

接下来,我来转换成代码的形式便于大家理解。

2.2.1 定义结构体成员以及方法

cpp 复制代码
#pragma once
#include<stdlib.h>
#include<assert.h>
#include<stdio.h>

typedef int LTDataType;

typedef struct ListNode
{
	LTDataType data;
    //记录当前节点的前一个节点的地址
	struct ListNode* prev;
    //记录当前节点的后一个节点的地址
	struct ListNode* next;
}LTNode;

//初始化
LTNode* LTInit();
//内存申请
LTNode* BuyListNode(LTDataType x);
//查找元素
LTNode* LTFind(LTNode* phead, LTDataType x);
//打印元素
void LTPrint(LTNode* phead);
//插入元素
void LTInsert(LTNode* pos, LTDataType x);
//删除元素
void LTErase(LTNode* pos);
//销毁链表
void LTDestory(LTNode* phead);

2.2.2 内存申请

cpp 复制代码
//这里的内存申请跟单向链表一样,只不过是多了个 prev,这里不再做过多解释
LTNode* BuyListNode(LTDataType x)
{
	LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
	if (newnode == NULL)
	{
		perror("malloc");
		return NULL;
	}
	newnode->data = x;
	newnode->next = NULL;
	newnode->prev = NULL;
	return newnode;
}

2.2.3 初始化结构体成员变量

那这里大家就会有疑问,为啥单向链表不需要初始化,而双向链表需要初始化了?

理由如下:

原因:双向带头循环链表有"哨兵头节点",必须初始化才能用

  1. 单向不带头不循环链表:用 NULL 表示空链表,不需要额外初始化。

  2. 双向带头循环链表:必须有一个始终存在的头节点,且其 prev 和 next 必须指向自己。不初始化就是野指针,操作会崩溃。

本质差异:前者"空"就是没有节点;后者"空"也必须有一个有效的头节点作为操作锚点。

按照上面的解释,我们便可以这样操作:

cpp 复制代码
LTNode* LTInit()
{
    //这里只是为了符合内存申请函数的条件才传的值
    //并无任何意义
    //这样就定义完哨兵位的节点了
	LTNode* phead = BuyListNode(-1);
	phead->next = phead;
	phead->prev = phead;
	return phead;
}

2.2.4 插入元素

cpp 复制代码
//pos--代表链表上的某个节点,即插入的位置
void LTInsert(LTNode* pos, LTDataType x)
{
    //判断你给的这个节点是否为 NULL
	assert(pos);
	LTNode* prev = pos->prev;
	LTNode* newnode = BuyListNode(x);
    //跟单向链表的插入原理相同,不再做过多解释
    //修改节点内部的成员:
    //传入的是节点地址(指针的值),通过这个地址直接访问堆上的节点对象。
    //修改的是对象本身的成员,不是指针变量,所以外部能看到变化。
	prev->next = newnode;
	newnode->prev = prev;

	newnode->next = pos;
	pos->prev = newnode;
}

这里大家看完代码之后,可能会问,为啥你的单向链表的插入操作传的参数是二级指针,但到了双向链表的插入操作之后传的参数是一级指针?

理由如下:

  • 单向链表:头节点本身可能被修改,一级指针只能改节点内容,改头地址需要二级指针。

  • 双向链表:插入仅修改节点前后的指针,头节点地址不变,只需一级指针操作节点。

  • 传入一级指针 LTNode* pos :函数修改的是 pos->prevpos->next,这两个是节点内部的成员 ,通过指针访问的是原节点本身,不是形参副本。

  • 双向链表的外部头指针 plist 从来没被修改过,所以不需要二级指针。

  • 类比:传地址是为了修改"地址指向的对象",而不仅仅是修改"地址本身"。双向链表插入只修改对象内部的数据,不修改对象指针本身。

如果实在记不住,你就只需要知道,头节点可能会被修改的用二级指针,头节点不管用什么操作都不会受到影响的,就用一级指针。也可以说,如果你想修改结构体里面内部的成员,就可以用一级指针,但如果你想修改整个结构体指针的话,就需要使用二级指针。

2.2.5 删除元素

cpp 复制代码
//pos--代表链表上的某个节点
void LTErase(LTNode* pos)
{
    //判断你给的这个节点是否为 NULL
    assert(pos);
    //修改节点内部的成员:
    //传入的是节点地址(指针的值),通过这个地址直接访问堆上的节点对象。
    //修改的是对象本身的成员,不是指针变量,所以外部能看到变化。
	LTNode* prev = pos->prev;
	LTNode* next = pos->next;
	prev->next = next;
	next->prev = prev;
	free(pos);
    //这里不把pos置为 NULL 的原因是,形参的改变,不会影响到实参
    //到时候在外面把它置为 NULL 就好了
}

2.2.6查找元素

cpp 复制代码
//pos--代表链表上的某个节点
LTNode* LTFind(LTNode* phead, LTDataType x)
{
    //判断你给的这个节点是否为 NULL
	assert(phead);
    //为什么取phead->next,因为 phead 代表的是哨兵位
    //哨兵位的节点不需要打印。
    //查找逻辑跟单向链表一样的
 	LTNode* cur = phead->next;
	while (cur != phead)
	{
		if (cur->data == x)
		{
			return cur;
		}
		cur = cur->next;
	}
	return NULL;
}

2.2.7 打印元素

cpp 复制代码
void LTPrint(LTNode* phead)
{
    //判断你给的这个节点是否为 NULL
	assert(phead);
    //因为不打印哨兵位的头节点,所以在这里直接打印一个 <=head=> 来表示哨兵位
    //打印逻辑跟单向链表一样的
	printf("<=head=>");
	LTNode* cur = phead->next;
	while (cur != phead)
	{
		printf("%d<=>", cur->data);
		cur = cur->next;
	}
	printf("\n");
}

2.2.8 销毁链表

cpp 复制代码
void LTDestory(LTNode* phead)
{
    //销毁逻辑跟单向链表一样的
    //判断你给的这个节点是否为 NULL
	assert(phead);
	LTNode* cur = phead->next;
	while (cur != phead)
	{
		LTNode* next = cur->next;
		free(cur);
		cur = next;
	}
	free(phead);
    //这里不把pos置为 NULL 的原因是,形参的改变,不会影响到实参
    //到时候在外面把它置为 NULL 就好了
}

2.2.9 测试

  • 测试代码:
cpp 复制代码
#include"list.h"

void test()
{
	LTNode* plist = LTInit();
    //自己动手画图理解,理解更深刻
	//这种代表尾插
	LTInsert(plist, 1);
	LTInsert(plist, 2);
	LTInsert(plist, 3);
	LTInsert(plist, 4);
	LTPrint(plist);
	printf("==============================================\n");
	//这种代表头插
	LTInsert(plist->next, 5);
	LTInsert(plist->next, 6);
	LTInsert(plist->next, 7);
	LTInsert(plist->next, 8);
	LTPrint(plist);
	printf("==============================================\n");
	LTNode* ret = LTFind(plist,3);
	LTErase(ret);
    //这里就是我说的,在外面把它置为 NULL
    ret = NULL;
	LTPrint(plist);
}

int main()
{
	test();
	return 0;
}

这里我画张图,方便你理解一下:

假设原来这个链表里面目前只有头节点和1这个节点,当我传的值为 plist 时候,那么此时会变成尾插,为什么是尾插,请看图(2是代表新插入的节点):

理解了这个之后,那你也可以自己动手画一画,看看为啥当我传的值为 plist->next 的时候,会变成头插。

  • 测试结果:

三、总结

以上便是我对链表部分的全部理解了。有啥不好的地方也欢迎大家积极指出,希望大家早日被自己喜欢的offer录取。那我们就下一个博客见。

相关推荐
ykdcdc1 小时前
DC-DC隔离和非隔离怎么选?工控/监控/医疗分别用哪种
数据结构·汽车·推荐算法
Angle.寻梦2 小时前
数据结构--堆
数据结构
雨落在了我的手上5 小时前
Java数据结构(六):链表的介绍
java·开发语言·数据结构
Angle.寻梦6 小时前
数据结构--栈与队列
数据结构
热爱前端的小张7 小时前
第六章 图
数据结构
hold?fish:palm8 小时前
11 滑动窗口最大值
数据结构·算法
xvhao20138 小时前
T750414 【游戏】计算24点 题解
数据结构·c++·算法·游戏
自然常数e9 小时前
顺序表专题
c语言·数据结构·visual studio
小七在进步9 小时前
数据结构:树的遍历
数据结构