【数据结构与算法】链表

什么是链表?

我们知道顺序表是以数组的形式存放数据,由于数组是连续存储的,所以顺序表是连续存放数据的,这个连续是实实在在的物理空间上的连续。

但这样存数据缺点很明显,静态则太死板,动态则容易造成空间浪费,那有没有一种数据结构能合理利用空间呢?链表它就来了。

链表 (ListNode) 是一种物理存储结构上非连续、非顺序的存储结构,它以节点 (Node) 为存储单位,每个节点存放一个数据,并用指针链接其他链表:

在这里插入图片描述
实际上,链表的种类是非常多的,总的来分有三大类:

  1. 单向和双向
  2. 带头和不带头
  3. 循环和非循环(带环和不带环)

排列组合一下,就有了八种结构:

  1. 单向不带头非循环:
    在这里插入图片描述

  2. 单向带头非循环:
    在这里插入图片描述

  3. 单向不带头循环:
    在这里插入图片描述

  4. 单向带头循环
    在这里插入图片描述

  5. 双向不带头非循环:
    在这里插入图片描述

  6. 双向带头非循环
    在这里插入图片描述

  7. 双向不带头循环
    在这里插入图片描述

  8. 双向带头循环
    在这里插入图片描述

链表结构五花八门,实际见得最多的主要有两种:
无头单向非循环带头双向循环

而下面就具体介绍两种链表增删查改等接口功能的实现。


单链表

单链表就是上面提到的无头单向非循环链表,它的结构是最简单的,一般不会单独用来存数据,而是作为其他数据结构的子结构。

因为它一旦丢失了头节点的地址,整个数据就都无了,这是它最大的缺点。
在这里插入图片描述

当然,也正是因为它访问数据的特殊性,是很多 oj 题的宠儿。

因为它的结构太简单了,所以接口功能的实现就比较复杂。下面就详细介绍单链表增删查改等各个接口的实现。


创建节点

单链表结构很简单,

由一个个节点组成,

每个节点存放两个数据 - 数据和下一个节点的地址。

所以声明一个结构体类型。

typedef int SLTDataType;
typedef struct SLTNode
{
	SLTDataType data;
	struct SLTNode* next;
}SLTNode;

开辟一个新节点

无论是后续的头插还是尾插还是随机插入,

都不可避免地要创建新节点,

所以为了避免代码冗余,

单独定义一个函数完成开辟节点的工作。

//开辟一个节点
SLTNode* BuySListNode(SLTDataType x)
{
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	newnode->data = x;
	newnode->next = NULL;

	return newnode;
}

单链表尾插

首先参数设计就有讲究。

我是要用一个指针 phead 指向链表的头,

而当链表为空的时候有 phead = NULL

此时尾插就要改变 phead 的值,

让它指向新开辟的节点。

如果不改变 phead 的话,

由于它是一个一级指针,

用一个一级指针接收即可。

而这里要改变 phead 的值,

传值调用显然就不行,

所以要用一个二级指针来接收。

现在进行尾插。

尾插就要找尾,

定义一个 tail 指针,

让它一直向后走,

走到尾再把新节点接上就OK了。

尾插需要遍历链表,所以时间复杂度是O(N)

//单链表尾插
void SListPushBack(SLTNode** pphead, SLTDataType x)
{
	SLTNode* newnode = BuySListNode(x);
    //链表为空
	if (*pphead == NULL)
		*pphead = newnode;
    //链表不为空
	else
	{
		SLTNode* tail = *pphead;
		while (tail->next != NULL)
		{
			tail = tail->next;
		}
		tail->next = newnode;
	}
}

单链表尾删

尾删是比较麻烦的,

因为我不仅要找到尾,

还要找到尾结点的前一个节点。

所以需要用到两个指针 tailprev

而一旦用到两个指针的时候就要注意,

链表为空还好处理。

当链表只有一个节点的时候,

prev 是会出问题的,

所以要单独考虑。

这样就有三种情况。

尾删需要遍历链表,所以时间复杂度是O(N)

//单链表尾删
void SListPopBack(SLTNode** pphead)
{
	SLTNode* tail = *pphead;
	SLTNode* prev = NULL;
	if ((*pphead) == NULL)
	{
		printf("链表为空无需删除\n");
		return;
	}
	else if ((*pphead)->next == NULL)
	{
		free(*pphead);
		*pphead = NULL;
	}
	else
	{
		while (tail->next != NULL)
		{
			prev = tail;
			tail = tail->next;
		}
		free(tail);
		prev->next = NULL;
	}
}

单链表头插

头插没什么复杂的,

创建一个新节点,

让新节点的 next 指向原来的头 phead

然后再把 phead 指向新节点。

需要注意的还是参数部分。

由于要改变 phead 的值,

所以这里还是要用一个二级指针来接收。

由于不需要遍历链表,所以时间复杂度是O(1)

//单链表头插
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
	SLTNode* newnode = BuySListNode(x);
	newnode->next = *pphead;
	*pphead = newnode;
}

单链表头删

头删也是比较简单的。

先找到第二个节点,

然后释放掉头节点,

将第二个节点作为新的头。

不过需要注意两个问题,

一个是上面提到的参数设计问题,

另一个就是链表为空的情况需要单独考虑。

由于不需要遍历链表,所以时间复杂度是O(1)

//单链表头删
void SListPopFront(SLTNode** pphead)
{
	if ((*pphead) == NULL)
	{
		printf("链表为空无需删除\n");
		return;
	}
	else
	{
		SLTNode* tmp = (*pphead)->next;
		free(*pphead);
		*pphead = tmp;
	}
}

单链表查找

因为后面要实现在指定位置进行插入或删除,

所以要先找到那个指定位置。

直接遍历链表,

目标存在就返回目标节点的地址,

不存在就返回空指针。

由于要遍历数组,所以时间复杂度是O(N)

// 单链表查找
SLTNode* SListFind(SLTNode* plist, SLTDataType x)
{
	SLTNode* cur = plist;
	while (cur != NULL)
	{
		if (cur->data == x)
			return cur;
		cur = cur->next;
	}
	return NULL;
}

单链表插入 - 在给定位置的后部插入

在给定位置的后部插入是比较简单的。

只需要找到指定位置和指定位置后面的一个节点,

将创建的新节点插在两个节点中间就OK。

这里就不会动头节点,所以只需要用一级指针来接受就可。

不需要遍历链表,所以时间复杂度是O(1)

//单链表插入 - 在目标后部插入
void SlistInsertAfter(SLTDataType x, SLTNode* pos)
{
	assert(pos);
	//走到这里目标节点一定存在
	SLTNode* newnode = BuySListNode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

单链表插入 - 在给定位置前面插入

后部插入简单,前面插入就恶心了。

想在前面插入,

我就要找到给定位置前面的那个节点,

然后把新节点插入两个节点之间,

这又要遍历链表。

而如果给定的位置就是头节点,

就相当于头插,

又得改变 phead 的指向,

又得拿二级指针来接收。

由于要遍历链表,所以时间复杂度是O(N)

//单链表插入 - 在目标前面插入
void SlistInsertBefore(SLTNode** pphead, SLTDataType x, SLTNode* pos)
{
	assert(pos);

	SLTNode* newnode = BuySListNode(x);
	if (pos == *pphead)
	{
		newnode->next = pos;
		*pphead = newnode;
	}
	else
	{
		SLTNode* cur = *pphead;
		SLTNode* prev = NULL;
		while (cur != pos)
		{
			prev = cur;
			cur = cur->next;
		}
		newnode->next = pos;
		prev->next = newnode;
	}
}

单链表删除 - 删除给定位置的节点

删除当前位置也是个麻烦事,

因为不光要找到它后面的一个节点,

还要找到它前面的一个节点,

把这两个连起来。

而删除的位置是头的时候就相当于头删,

参数又要那二级指针来接收。

由于要遍历链表,所以时间复杂度是O(N)

//单链表删除 - 删除目标节点
void SListEraseCur(SLTNode** pphead, SLTNode* pos)
{
	assert(pos);

	if (pos == *pphead)
	{
		//相当于头删
		SListPopFront(pphead);
	}
	else
	{
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		prev->next = pos->next;
		free(pos);
	}
}

单链表删除 - 删除给定位置后面的节点

这个就比上面那个简单多了,

只需要找到后面的第二个节点,

把给定位置处的节点和后面的第二个节点连起来即可。

当给定位置后面没有节点时就无需删除,

要单独拿出来讨论。

由于后删碰不到头节点,

所以用一个一级指针来接收就行。

//单链表删除 - 删除目标后面的节点
void SListEraseAfter(SLTNode* pos)
{
	assert(pos);

	if (pos->next == NULL)
	{
		printf("目标后面无节点,无需删除\n");
		return;
	}
	else
	{
		SLTNode* tmp = pos->next;
		pos->next = tmp->next;
		free(tmp);
	}
}

打印单链表

直接遍历打印,时间复杂度为O(N)

//打印单链表
void SListPrint(SLTNode* phead)
{
	SLTNode* cur = phead;
	while (cur != NULL)
	{
		printf("%d->", cur->data);
		cur = cur->next;
	}
	printf("NULL\n");
}

单链表销毁

单链表的每个节点都是动态内存开辟来的,

所以最后不能忘了释放内存。

时间复杂度为 O(N)

//销毁单链表
void SListPrint(SLTNode* phead)
{
	SLTNode* cur = phead;
	while (cur)
	{
		next = cur->next;
        free(cur);
        cur = next;
	}
}

小结

至此,单链表其实并没有完全写好,像统计单链表中的数据啦、判断链表是否为空啦、给单链表排序啦…都没有写,但是这些接口并不难,可以说是十分简单,这里只是把几个关键的接口讲解了一下。

其实看完这些就会发现,单链表是个什么小牛马,动不动就要遍历。但单链表还是有它的优点所在的,因为结构简单,所以它会作为其他数据结构的子结构,如哈希桶、图的邻接表等等。

此外,由于单链表插入或者删除数据时间复杂度比较高,所以在实际应用中很少用单链表去储存数据,至于更好的解决方案,请看下文。


带头双向循环链表

带头双向循环链表结构是真的复杂,

但它使用起来是真的方便。
在这里插入图片描述

它有一个哨兵位,

有了这个哨兵位,

以后怎么操作都不会改变链表的头,

所以在设计函数参数时就不用像单链表一样考虑那么多。

但哨兵位是不用来存数据的。

多提一嘴,实际中使用的链表数据结构,都是带头双向循环链表。

下面就具体介绍到头双向循环链表的接口功能实现。


声明定义节点结构

是链表就有节点,

有节点就有结构。

所以与单链表一样,

先用结构体定义节点。

typedef int DataType;
typedef struct ListNode
{
	struct ListNode* prev;
	struct ListNode* next;
	DataType data;
}ListNode;
开辟一个新节点

无论是开辟哨兵位的头节点,

还是尾插头插指定插,

都避不开开辟一个新节点,

所以把这部分单拎出来。

//创建一个新节点
ListNode* BuyListNode(DataType x)
{
	ListNode* node = (ListNode*)malloc(sizeof(ListNode));
	node->prev = NULL;
	node->next = NULL;
	node->data = x;
	return node;
}

创建哨兵位的头节点

哨兵位没啥讲究,

因为它存放的数据没用,

所以随便传一个数据就行。

带头双向循环链表是循环的,

是首尾相接的,

所以当它只有一个节点时直接让它自成一体,

自己指向自己。

//创建哨兵位的头节点
ListNode* ListCreate()
{
	ListNode* head = BuyListNode(0);
	head->next = head;
	head->prev = head;
	return head;
}

尾插

带头双向循环链表的尾插就简单的多。

首先要找尾。

它不会像之前单链表一样遍历去找尾,

因为它的哨兵位的 prev 指向的就是尾。

所以只需要将新创建的节点接到原来尾巴的后边,

然后在新节点和哨兵位之间建立联系就OK了。

具体操作为:

  1. 新节点的 prev 指向原尾巴;
  2. 原尾巴的 next 指向新节点;
  3. 新节点的 next 指向哨兵位;
  4. 哨兵位的 prev 指向新节点。

时间复杂度为O(1)

//尾插
void ListPushBack(ListNode* phead, DataType x)
{
	ListNode* newnode = BuyListNode(x);
	ListNode* tail = phead->prev;
	newnode->prev = tail;
    tail->next = newnode;
	newnode->next = phead;
	phead->prev = newnode;
}

尾删

它的尾删同样简单。

找到它的尾巴和尾巴的前一个,

在新尾巴与哨兵位之间建立联系,

然后释放掉旧尾巴就好了。

具体操作为:

  1. 新尾巴的 next 指向哨兵位;
  2. 哨兵位的 prev 指向新尾巴。

时间复杂度为O(1)

//尾删
void ListPopBack(ListNode* phead)
{
	if (phead->prev == phead)
	{
		printf("已经被掏空了,别删了\n");
		return;
	}
	ListNode* tail = phead->prev;
	ListNode* newtail = tail->prev;
	free(tail);
	newtail->next = phead;
	phead->prev = newtail;
}

头插

头插,

千万不要上来就操作它的 phead

因为 phead 是哨兵位,

而我们要做的是在哨兵位的后面位置插入数据。

所以只需要找到哨兵位的后面一个节点,

在该节点和哨兵位之间插入一个新节点就OK了。

具体操作为:

  1. 哨兵位的 next 指向 newnode
  2. newnodeprev 指向 哨兵位;
  3. 原头节点的 prev 指向 newnode
  4. newnodenext 指向 原头节点。

时间复杂度为O(1)

//头插
void ListPushFront(ListNode* phead, DataType x)
{
	ListNode* newnode = BuyListNode(x);
	ListNode* head = phead->next;
	phead->next = newnode;
	newnode->prev = phead;
	head->prev = newnode;
	newnode->next = head;
}

头删

当只有一个哨兵位的时候是不能再删除的。

正常情况下,

找到原来的头节点,

再找到原来的头节点的下一个节点,

在新头节点哨兵位之间建立联系,

最后别忘了释放掉原头节点。

具体操作为:

  1. 释放原头节点;
  2. 哨兵位的 next 指向新头节点;
  3. 新头节点的 prev 指向哨兵位。

时间复杂度为O(1)

void ListPopFront(ListNode* phead)
{
	if (phead->prev == phead)
	{
		printf("已经被掏空了,别删了\n");
		return;
	}
	ListNode* head = phead->next;
	ListNode* newhead = head->next;
	free(head);
	phead->next = newhead;
	newhead->prev = phead;
}

查找

后面还要在指定位置处插入或删除节点,

所以先把查找函数单拎出来。

直接遍历,

找到了就返回地址,

找不到就返回空指针。

时间复杂度为O(N)

//查找
ListNode* ListFind(ListNode* phead, DataType x)
{
	ListNode* cur = phead->next;
	while (cur != phead)
	{
		if (cur->data == x)
			return cur;
		cur = cur->next;
	}
	return NULL;
}

在给定位置前面进行插入

双向带头循环链表实现起来这个功能还是挺简单的。

因为我可以直接通过 pos 找到它前面的节点,

然后在两个节点之间插入新节点即可。

具体操作为:

  1. 前节点的 next 指向新节点;
  2. 新节点的 prev 指向前节点;
  3. 新节点的 next 指向 pos
  4. posprev 指向新节点。

时间复杂度为O(1)

//在目标位置前面进行插入
void ListInsert(ListNode* pos, DataType x)
{
	assert(pos);
	ListNode* newnode = BuyListNode(x);
	ListNode* prev = pos->prev;
	prev->next = newnode;
	newnode->prev = prev;
	newnode->next = pos;
	pos->prev = newnode;
}

删除给定位置的节点

通过 pos 找到它前后两个节点,

在前后两个节点之间建立联系,

然后释放掉 pos 处的节点即可。

时间复杂度为O(1)

//删除目标节点
void ListErase(ListNode* pos)
{
	assert(pos);
	ListNode* prev = pos->prev;
	ListNode* next = pos->next;
	free(pos);
	prev->next = next;
	next->prev = prev;
}

打印

遍历一遍哨兵位之后的节点就可。

时间复杂度为O(N)

//打印
void ListPrint(ListNode* phead)
{
	ListNode* cur = phead->next;
	while (cur != phead)
	{
		printf("%d->", cur->data);
		cur = cur->next;
	}
	printf("NULL\n");
}

销毁链表

因为每个节点都是 malloc 出来的,

所以最后一定不能忘记释放!

先把哨兵位之后的都释放掉,

最后释放哨兵位。

时间复杂度为O(N)

//销毁链表
void ListDestroy(ListNode* phead)
{
	ListNode* cur = phead->next;
	while (cur != phead)
	{
		ListNode* next = cur->next;
		free(cur);
		cur = next;
	}
	free(phead);
}

小结

至此,带头双向循环链表的几个主要功能就完成了。

很显然,虽然链表结构复杂了许多,但用起来也方便了许多,而且无论是插入数据还是删除数据时间复杂度都是O(1),十分的高效。所以经常拿它来存放数据。优势大大滴!


总结

顺序表和链表都写完了。

现在我们对比一下顺序表和链表的优缺点。

顺序表:

优点:

  1. 空间连续,支持通过下标进行随机访问
  2. CPU高速缓存命中率高

缺点:

  1. 空间不够需要增容,带来一定的性能消耗,且容易造成空间浪费
  2. 头部或中间插入数据需要进行挪动,时间复杂度为O(N),效率比较低

链表:

优点:

  1. 按需申请内存,不存在空间浪费
  2. 任意位置插入或删除数据的时间复杂度为O(1),效率高

缺点:

  1. 不支持下标的随机访问

其中讲顺序表的优点有一条是CPU高速缓存命中率高

CPU在处理数据的时候是不会直接从内存中进行读取的,而是在缓存中寻找数据,如果缓存中有想要的数据,那就是命中了,反之则没有命中,此时缓存会去内存中再次读取数据。而缓存空间是很小的,一次并不能从内存中读取大量数据。所以在读取顺序表时,由于空间是连续的,一次能读取多个有效数据进来,那么CPU在缓存中读取数据时可能连续命中多次。而链表则不然,空间的不连续导致缓存一个可能只读取到了一个有效数据,那CPU读两次就空一次,效率就有所降低。

所以,链表和顺序表之间并没有绝对的优劣,只是要根据应用场景来判断具体要用谁。比如栈的实现就是用的顺序表,而队列的实现则是用的链表。

  • 7
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

LeePlace

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值