C语言描述数据结构 —— 单链表

前言

对于链表这部分,在数据结构里面是非常重要的。链表的学习周期长一些,博客的内容会多一些,所以对于链表的描述我将分为多篇。基本概念、基本实现、基本习题、进阶习题我都会通过博客向大家分享。那么本篇是对链表有一个初步的认识,并且能够掌握最基本的单链表的增删查改

1. 顺序表的问题及思考

在学习链表之前,我们回顾以下顺序表,那么我们来思考下面几个问题:

  1. 中间/头部的插入删除,时间复杂度为O(N)
  2. 增容需要申请空间,拷贝数据,释放旧空间。这就会造成不小的空间消耗
  3. 增容一般是呈 2 倍的增长,势必会有一定的空间浪费。例如当前容量为 100,满了以后增容到 200 ,我们再继续插入 5 个数据,那么就浪费了 95 个数据空间。

那么我们要解决上述问题,就必须学习链表。所以带着问题去学习,那么将事半功倍。

2. 链表的基本概念和基本结构

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

如何理解物理存储结构是不连续的呢?

C语言描述数据结构 —— 单链表_第1张图片

 链表与顺序表不一样的是,顺序表它是一个数组,但是链表不是,所有的连续数据都是通过指针来链接的。那么现在又引出了一个问题,链表的每个数据都要链接下一个数据,而链接的方式是通过指针,那么如何做到呢?我们可以运用结构体来解决这个问题,并且提出结点的概念。

C语言描述数据结构 —— 单链表_第2张图片

可以看到,每个结点都有数据域和指针域,指针是指向下一个结点的地址,那么需要补充的是,第一个结点的地址需要用一个指针来保存,最后一个结点的指针是指向空指针。 保存第一个结点的地址的意义在于:可以找到链表的头结点,从而进一步访问结点之后的结点。最后一个结点的指针指向空指针的意义在于:避免野指针的问题,客观上增加一种链表的结束条件,例如字符串的结束标志 '\0' 。

C语言描述数据结构 —— 单链表_第3张图片

那么我们就得出了链表的三个特征:

  1. 链表的结构在逻辑上是连续的,但是在物理上不一定连续
  2. 现实中的结点一般都是从堆上申请出来的
  3. 从堆上申请的空间,是按照一定的策略来分配的,两次申请的空间可能连续,也可能不连续 

3. 链表的分类 

链表的结构是有多种的,单向、双向、循环等等,但我们主要的侧重点在单向的不带哨兵卫的链表。

C语言描述数据结构 —— 单链表_第4张图片

4. 链表的实现

 结点的声明:

//结点的声明
typedef int LLDataType;
typedef struct LinkList
{
	LLDataType val;
	struct LinkList* next;
}LL;

存储头结点的指针在主函数中定义:

#include "LinkList.h"
int main()
{
	LL* head = NULL;
	return 0;
}

头插的实现:

//创建新结点
static LL* CreateNode(LLDataType x)
{
	LL* RetNode = (LL*)calloc(1, sizeof(LL));
	assert(RetNode);
	RetNode->val = x;
	RetNode->next = NULL;
	return RetNode;
}
//头插
void ListPushHead(LL** phead, LLDataType x)
{
	assert(phead);
	//此时我们还没有结点,需要创建一个结点
	LL* NewNode = CreateNode(x);
	//新结点的指针要指向以前的头结点
	NewNode->next = *phead;
	//头指针的内容更新为新结点的地址
	*phead = NewNode;
}

需要注意,即使存储头结点的指针是一个指针,但我们在函数内操作时,需要改变指针的指向位置,所以需要使用二级指针。我们可能对最后两条代码存在疑问,我在这里解释一下:

C语言描述数据结构 —— 单链表_第5张图片

链表打印实现:

//打印
void ListPrint(LL* phead)
{
	LL* cur = phead;//使用临时变量来操作链表,但因为是传址调用,不使用也是可以的
	while (cur)
	{
		printf("%d->", cur->val);
		cur = cur->next;
	}
	printf("NULL\n");
}

 测试:头插一个数据以及打印:

#include "LinkList.h"
int main()
{
	LL* head = NULL;
	ListPushHead(&head, 5);
	ListPrint(head);
	return 0;
}

尾插的实现:

//尾插
void LIstPushBack(LL** phead, LLDataType x)
{
	assert(phead);
	LL* newnode = CreateNode(x);
	//如果链表没有结点,新结点直接作为头结点
	if (*phead == NULL)
	{
		*phead = newnode;
	}
	else
	{
		LL* tail = *phead;
		//向后寻找链表的最后一个结点
		while (tail->next != NULL)
		{
			tail = tail->next;
		}
		tail->next = newnode;
	}
}

可以看到,我们尾插的情况是有两个的,一个链表里面没有结点,那么就让这个要尾插的结点直接为头。而另一种则是链表中存在结点,我们需要遍历找到最后一个结点,并让这个结点的指针指向新的结点。同时需要注意,我们传的参数是二级指针,我们要解引用才能找到存放头结点地址的指针。

C语言描述数据结构 —— 单链表_第6张图片

我们思考一个问题:我们能否像头插一样定义一个指针,专门用来存储最后一个结点的地址呢?当然可以,不过我们需要对函数的参数做出一些改变。

头插更改:

//头插
void ListPushHead(LL** phead, LL** ptail,LLDataType x)
{
	//assert(phead);
	此时我们还没有结点,需要创建一个结点
	//LL* NewNode = CreateNode(x);
	新结点的指针要指向以前的头结点
	//NewNode->next = *phead;
	头指针的内容更新为新结点的地址
	//*phead = NewNode;

	assert(phead);
	//此时我们还没有结点,需要创建一个结点
	LL* NewNode = CreateNode(x);
	//链表为空时,第一个头插的结点就是尾结点
	if (*phead == NULL)
	{
		*ptail = NewNode;
	}
	//新结点的指针要指向以前的头结点
	NewNode->next = *phead;
	//头指针的内容更新为新结点的地址
	*phead = NewNode;
}

尾插更改:

//尾插
void LIstPushBack(LL** phead,LL** ptail, LLDataType x)
{
	//assert(phead);
	//LL* NewNode = CreateNode(x);
	如果链表没有结点,新结点直接作为头结点
	//if (*phead == NULL)
	//{
	//	*phead = NewNode;
	//}
	//else
	//{
	//	LL* tail = *phead;
	//	//向后寻找链表的最后一个结点
	//	while (tail->next != NULL)
	//	{
	//		tail = tail->next;
	//	}
	//	tail->next = NewNode;
	//}

	assert(phead && ptail);
	LL* NewNode = CreateNode(x);
	//如果链表为空,那么头尾指针都指向这个新结点
	if (*phead == NULL)
	{
		*ptail = *phead = NewNode;
	}
	else
	{
		(*ptail)->next = NewNode;//尾结点的指针指向新结点
		(*ptail) = (*ptail)->next;//尾指针指向新结点
	}
}

我们对修改之后的代码进行数据测试:

#include "LinkList.h"
int main()
{
	LL* head = NULL;
	LL* tail = NULL;//专门用来存储最后一个结点的地址
	ListPushHead(&head,&tail, 5);
	ListPushHead(&head, &tail, 6);
	ListPushHead(&head, &tail, 7);
	ListPrint(head);

	LIstPushBack(&head,&tail, 3);
	LIstPushBack(&head, &tail, 4);
	LIstPushBack(&head, &tail, 5);
	ListPrint(head);

	return 0;
}

 可以看到,输出结果正是我们想要的。那么修改之后的代码有什么优势呢?其一就是减少了时间复杂度,用循环的方式去找尾,如果数据量足够大,是非常消耗时间的。其二就是我们掌握了这个方法以后,在一些题目上面,我们能取得一些优势

头删的实现:

//头删
void ListPopHead(LL** phead, LL** ptail)
{
	assert(phead && ptail);
	assert(*phead);//链表为空不需要删除,强制退出
	LL* cur = *phead;
	//如果链表仅有一个结点
	if (cur->next == NULL)
	{
		free(cur);//释放结点
		*phead = *ptail = NULL;//存储头尾结点地址的指针置空
	}
	//链表有两个或两个以上结点时
	else
	{
		*phead = cur->next;
		free(cur);
		//尾指针不需要变
	}
}

尾删的实现: 

//尾删
void ListPopBack(LL** phead, LL** ptail)
{
	assert(phead && ptail);
	assert(*phead);//链表里面没有结点不需要删除 
	LL* cur = *ptail;
	LL* NewPtail = *phead;
	//找到倒数第二个结点
	while (NewPtail->next != *ptail)
	{
		NewPtail = NewPtail->next;
	}
	//对删除之前的尾结点释放
	free(cur);
	//存储尾结点地址的指针更新
	*ptail = NewPtail;
	(*ptail)->next = NULL;
	
}

测试:头删和尾删:

#include "LinkList.h"
int main()
{
	LL* head = NULL;
	LL* tail = NULL;//专门用来存储最后一个结点的地址
	ListPushHead(&head,&tail, 5);
	ListPushHead(&head, &tail, 6);
	ListPushHead(&head, &tail, 7);
	ListPrint(head);

	LIstPushBack(&head,&tail, 3);
	LIstPushBack(&head, &tail, 4);
	LIstPushBack(&head, &tail, 5);
	ListPrint(head);

	ListPopHead(&head, &tail);
	ListPrint(head);
	ListPopBack(&head, &tail);
	ListPrint(head);
	return 0;
}

 

数据的查找:

//查找
LL* ListFind(LL* phead,LLDataType x)
{
	LL* cur = phead;
	while (cur)
	{
		if (cur->val == x)
			return cur;
		cur = cur->next;
	}
	return NULL;
}

我们的目的是找到数据的结点地址,如果确实有结点存储这个数据,那么就返回结点地址,否则返回空。如果有多个结点存储同一个数据,那么返回第一个结点的地址。这是符合常理的设计方法。

在 pos 位置插入数据:

//在 pos 位置插入
void ListInsert(LL** phead, LL** ptail, LL* pos, LLDataType x)
{
	assert(phead && ptail);
	assert(*phead);//确保链表有结点
	//如果 pos 位置与头结点位置重合就头插
	if (pos == *phead)
	{
		ListPushHead(phead, ptail, x);
	}
	else
	{
		//在 pos 位置之前插入
		LL* cur = *phead;
		while (cur->next != pos)
		{
			cur = cur->next;
		}
		LL* NewNode = CreateNode(x);
		cur->next = NewNode;
		NewNode->next = pos;
	}
}

测试:在 pos 位置插入数据(与查找配合):

#include "LinkList.h"
int main()
{
	LL* head = NULL;
	LL* tail = NULL;//专门用来存储最后一个结点的地址
	ListPushHead(&head,&tail, 5);
	ListPushHead(&head, &tail, 6);
	ListPushHead(&head, &tail, 7);
	ListPrint(head);

	LIstPushBack(&head,&tail, 3);
	LIstPushBack(&head, &tail, 4);
	LIstPushBack(&head, &tail, 5);
	ListPrint(head);

	ListPopHead(&head, &tail);
	ListPrint(head);
	ListPopBack(&head, &tail);
	ListPrint(head);

	ListInsert(&head, &tail, ListFind(head,5), 50);
	ListPrint(head);

	return 0;
}

 

在 pos 位置删除结点:

//在 pos 位置删除
void ListPopInsert(LL** phead, LL** ptail, LL* pos)
{
	assert(phead && ptail);
	assert(*phead);//确保链表有结点
	//如果 pos 位置是头结点就头删
	if (pos == *phead)
	{
		ListPopHead(phead, ptail);
	}
	//如果 pos 位置是尾结点就尾删
	else if (pos == *ptail)
	{
		ListPopBack(phead, ptail);
	}
	else
	{
		LL* cur = *phead;
		while (cur->next != pos)
		{
			cur = cur->next;
		}
		cur->next = pos->next;
		free(pos);
	}
}

测试:在 pos 位置删除结点:

#include "LinkList.h"
int main()
{
	LL* head = NULL;
	LL* tail = NULL;//专门用来存储最后一个结点的地址
	ListPushHead(&head,&tail, 5);
	ListPushHead(&head, &tail, 6);
	ListPushHead(&head, &tail, 7);
	ListPrint(head);

	LIstPushBack(&head,&tail, 3);
	LIstPushBack(&head, &tail, 4);
	LIstPushBack(&head, &tail, 5);
	ListPrint(head);

	ListPopHead(&head, &tail);
	ListPrint(head);
	ListPopBack(&head, &tail);
	ListPrint(head);

	ListInsert(&head, &tail, ListFind(head,5), 50);
	ListPrint(head);

	ListPopInsert(&head, &tail, ListFind(head, 5));
	ListPrint(head);


	return 0;
}

C语言描述数据结构 —— 单链表_第7张图片

 

5. 完整各文件代码

test.c 源文件

#define _CRT_SECURE_NO_WARNINGS 1

#include "LinkList.h"
int main()
{
	LL* head = NULL;
	LL* tail = NULL;//专门用来存储最后一个结点的地址
	ListPushHead(&head,&tail, 5);
	ListPushHead(&head, &tail, 6);
	ListPushHead(&head, &tail, 7);
	ListPrint(head);

	LIstPushBack(&head,&tail, 3);
	LIstPushBack(&head, &tail, 4);
	LIstPushBack(&head, &tail, 5);
	ListPrint(head);

	ListPopHead(&head, &tail);
	ListPrint(head);
	ListPopBack(&head, &tail);
	ListPrint(head);

	ListInsert(&head, &tail, ListFind(head,5), 50);
	ListPrint(head);

	ListPopInsert(&head, &tail, ListFind(head, 5));
	ListPrint(head);


	return 0;
}

 ListLink.c 源文件

#define _CRT_SECURE_NO_WARNINGS 1
#include "LinkList.h"

//创建新结点
static LL* CreateNode(LLDataType x)
{
	LL* RetNode = (LL*)calloc(1, sizeof(LL));
	assert(RetNode);
	RetNode->val = x;
	RetNode->next = NULL;
	return RetNode;
}
//头插
void ListPushHead(LL** phead, LL** ptail,LLDataType x)
{
	//assert(phead);
	此时我们还没有结点,需要创建一个结点
	//LL* NewNode = CreateNode(x);
	新结点的指针要指向以前的头结点
	//NewNode->next = *phead;
	头指针的内容更新为新结点的地址
	//*phead = NewNode;

	assert(phead);
	//此时我们还没有结点,需要创建一个结点
	LL* NewNode = CreateNode(x);
	//链表为空时,第一个头插的结点就是尾结点
	if (*phead == NULL)
	{
		*ptail = NewNode;
	}
	//新结点的指针要指向以前的头结点
	NewNode->next = *phead;
	//头指针的内容更新为新结点的地址
	*phead = NewNode;
}
//打印
void ListPrint(LL* phead)
{
	LL* cur = phead;//使用临时变量来操作链表,但因为是传址调用,不使用也是可以的
	while (cur)
	{
		printf("%d->", cur->val);
		cur = cur->next;
	}
	printf("NULL\n");
}
//尾插
void LIstPushBack(LL** phead,LL** ptail, LLDataType x)
{
	//assert(phead);
	//LL* NewNode = CreateNode(x);
	如果链表没有结点,新结点直接作为头结点
	//if (*phead == NULL)
	//{
	//	*phead = NewNode;
	//}
	//else
	//{
	//	LL* tail = *phead;
	//	//向后寻找链表的最后一个结点
	//	while (tail->next != NULL)
	//	{
	//		tail = tail->next;
	//	}
	//	tail->next = NewNode;
	//}

	assert(phead && ptail);
	LL* NewNode = CreateNode(x);
	//如果链表为空,那么头尾指针都指向这个新结点
	if (*phead == NULL)
	{
		*ptail = *phead = NewNode;
	}
	else
	{
		(*ptail)->next = NewNode;//尾结点的指针指向新结点
		(*ptail) = (*ptail)->next;//尾指针指向新结点
	}
}
//头删
void ListPopHead(LL** phead, LL** ptail)
{
	assert(phead && ptail);
	assert(*phead);//链表为空不需要删除,强制退出
	LL* cur = *phead;
	//如果链表仅有一个结点
	if (cur->next == NULL)
	{
		free(cur);//释放结点
		*phead = *ptail = NULL;//存储头尾结点地址的指针置空
	}
	//链表有两个或两个以上结点时
	else
	{
		*phead = cur->next;
		free(cur);
		//尾指针不需要变
	}
}
//尾删
void ListPopBack(LL** phead, LL** ptail)
{
	assert(phead && ptail);
	assert(*phead);//链表里面没有结点不需要删除 
	LL* cur = *ptail;
	LL* NewPtail = *phead;
	//找到倒数第二个结点
	while (NewPtail->next != *ptail)
	{
		NewPtail = NewPtail->next;
	}
	//对删除之前的尾结点释放
	free(cur);
	//存储尾结点地址的指针更新
	*ptail = NewPtail;
	(*ptail)->next = NULL;
}
//查找
LL* ListFind(LL* phead,LLDataType x)
{
	LL* cur = phead;
	while (cur)
	{
		if (cur->val == x)
			return cur;
		cur = cur->next;
	}
	return NULL;
}
//在 pos 位置插入
void ListInsert(LL** phead, LL** ptail, LL* pos, LLDataType x)
{
	assert(phead && ptail);
	assert(*phead);//确保链表有结点
	//如果 pos 位置与头结点位置重合就头插
	if (pos == *phead)
	{
		ListPushHead(phead, ptail, x);
	}
	else
	{
		//在 pos 位置之前插入
		LL* cur = *phead;
		while (cur->next != pos)
		{
			cur = cur->next;
		}
		LL* NewNode = CreateNode(x);
		cur->next = NewNode;
		NewNode->next = pos;
	}
}
//在 pos 位置删除
void ListPopInsert(LL** phead, LL** ptail, LL* pos)
{
	assert(phead && ptail);
	assert(*phead);//确保链表有结点
	//如果 pos 位置是头结点就头删
	if (pos == *phead)
	{
		ListPopHead(phead, ptail);
	}
	//如果 pos 位置是尾结点就尾删
	else if (pos == *ptail)
	{
		ListPopBack(phead, ptail);
	}
	else
	{
		LL* cur = *phead;
		while (cur->next != pos)
		{
			cur = cur->next;
		}
		cur->next = pos->next;
		free(pos);
	}
}

ListLink.h 头文件

#define _CRT_SECURE_NO_WARNINGS 1

#include 
#include 
#include 
//结点的声明
typedef int LLDataType;
typedef struct LinkList
{
	LLDataType val;
	struct LinkList* next;
}LL;
//存放头结点地址的指针


void ListPushHead(LL** phead, LL** ptail,LLDataType x);//头插
void ListPrint(LL* phead);//打印
void LIstPushBack(LL** phead,LL** ptail ,LLDataType x);//尾插 
void ListPopHead(LL** phead, LL** ptail);//头删
void ListPopBack(LL** phead, LL** ptail);//尾删
LL* ListFind(LL* phead,LLDataType x);//查找
void ListInsert(LL** phead, LL** ptail, LL* pos, LLDataType x);//在 pos 位置插入
void ListPopInsert(LL** phead, LL** ptail, LL* pos);//删除 pos 位置的数据

你可能感兴趣的:(数据结构,c语言,链表)