您现在的位置是:首页 >其他 >【数据结构】C语言实现单链表网站首页其他

【数据结构】C语言实现单链表

shlyyy 2023-07-01 04:00:02
简介【数据结构】C语言实现单链表


一、单链表 Single linked list

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

链表分类:
单向链表、双向链表
有头结点、无头结点
循环、非循环

本节中的单链表:单向、无头、非循环链表

二、结点与接口定义

单链表(Single linked list)结点定义:

typedef int SLLDataType;
typedef struct SLLNode
{
	SLLDataType data;
	struct SLLNode* next;
}SLLNode;

常用接口定义:

// 打印
void SLLPrint(SLLNode* phead);

// 头插、尾插
void SLLPushFront(SLLNode** pphead, SLLDataType x);
void SLLPushBack(SLLNode** pphead, SLLDataType x);

// 头删、尾删
void SLLPopFront(SLLNode** pphead);
void SLLPopBack(SLLNode** pphead);

// 查找
SLLNode* SLLFind(SLLNode* phead, SLLDataType x);

// 在pos之前插入
void SLLInsert(SLLNode** pphead, SLLNode* pos, SLLDataType x);
// 在pos之后插入
void SLLInsertAfter(SLLNode* pos, SLLDataType x);

// 删除pos位置的值
void SLLErase(SLLNode** pphead, SLLNode* pos);
// 删除pos位置后面的值
void SLLEraseAfter(SLLNode* pos);

// 销毁
void SLLDestroy(SLLNode* phead);

三、单链表实现

3.1 打印单链表-遍历

void SLLPrint(SLLNode* phead)
{
	SLLNode* cur = phead;
	while (cur != NULL)
	{
		printf("%d->", cur->data);
		cur = cur->next;
	}

	printf("NULL
");
}

3.2 申请结点

因为要频繁新建节点,为此我们封装函数;并且我们只在.c源文件中使用,我们就不用在.h头文件中写出函数的定义。

SLLNode* CreateSLLNode(SLLDataType x)
{
	SLLNode* newnode = (SLLNode*)malloc(sizeof(SLLNode));
	if (newnode == NULL)
	{
		perror("malloc fail");
		return NULL;
	}
	newnode->data = x;
	newnode->next = NULL;

	return newnode;
}

3.3 头插PushFront

因为头插需要修改头指针的指向,指向新的头结点,因此在PushFront中我们需要传入头指针的指针。

在函数的接口中,我们需要在函数内部修改外部的值,函数外我们要修改一维指针的值,因此函数的参数是二维指针。(函数内改变类型type,需要传参类型为type*)

void SLLPushFront(SLLNode** pphead, SLLDataType x)
{
	assert(pphead);  // 链表为空,pphead也不为空,因为他是头指针plist的地址
	//assert(*pphead); // 不能断言,链表为空,也需要能插入

	SLLNode* newnode = CreateSLLNode(x);

	newnode->next = *pphead;
	*pphead = newnode;
}

3.4 尾插PushBack

尾插时要分两步,首先要找到尾结点,其次将新的节点插入到尾结点的后面,新的节点变为尾结点。

void SLLPushBack(SLLNode* phead, SLLDataType x)
{
	SLLNode* tail = phead;
    // 这里tail判断条件?
	while (tail != NULL)
	{
		tail = tail->next;
	}

	SLLNode* newnode = CreateSLLNode(x);
    // 新建节点未插入到原链表
	tail = newnode;
}

上面的代码很明显不正确:

  1. while循环出来tail为空,并没有找到真正的尾节点。
  2. tail是局部变量,将新建的节点赋值给tail,链表的原尾结点并未指向新建的节点。

根据上面代码存在的问题,我们修改代码:

void SLLPushBack(SLLNode* phead, SLLDataType x)
{
	SLLNode* tail = phead;
	while (tail->next != NULL)
	{
		tail = tail->next;
	}

	SLLNode* newnode = CreateSLLNode(x);
	tail->next = newnode;
}

上面的代码中,我们在while结束时找到尾结点,并且将新的节点插入到尾结点中。

好像该函数接口成功实现,但当我们插入一个空链表时,此时tail为空,空指针没有next,因此会报错。因此我们需要分为两种情况分别处理,如果链表为空的情况我们需要单独处理,如果链表不为空,就是上面代码的逻辑。

void SLLPushBack(SLLNode** pphead, SLLDataType x)
{
	assert(pphead); // 链表为空,pphead也不为空,因为他是头指针plist的地址
	//assert(*pphead); // 链表为空,可以尾插

	SLLNode* newnode = CreateSLLNode(x);
	
	if (*pphead == NULL)
	{
		// 1、空链表
		*pphead = newnode;
	}
	else
	{
		// 2、非空链表
		SLLNode* tail = *pphead;
		while (tail->next != NULL)
		{
			tail = tail->next;
		}

		tail->next = newnode;
	}
}

上面的代码我们还要注意,同头插PushFront的接口定义类似,我们需要在函数内部可能会修改外部的head链表头指针指向(当链表为空时插入第一个节点),因此需要传入二级指针pphead。

3.5 尾删PopBack

尾删时需要分两步,首先需要找到尾结点的前一个节点,其次将其next域置空,释放尾结点。

在找尾结点的前一个结点时,我们有两种方法:

1.同步双指针:在找尾结点的过程中,记录尾结点的前一个节点指针。

void SLLPopBack(SLLNode** pphead)
{
	SLLNode* prev = NULL;
	SLLNode* tail = *pphead;
	// 找尾
	while (tail->next)
	{
		prev = tail;
		tail = tail->next;
	}

	free(tail);
	prev->next = NULL;
}

这种情况下,当是一个空链表删除时,我们在接口开始通过断言的方式,直接报错,因为空链表不允许删除。
当链表仅有一个结点时,删除最后一个节点,这时prev会为空,将prev->next置空会报错。因此我们需要将只有一个节点的情况单独考虑。

2.直接找倒数第二个节点。

void SLLPopBack(SLLNode** pphead)
{
	SLLNode* tail = *pphead;
	// 找尾
	while (tail->next->next)
	{
		tail = tail->next;
	}

	free(tail->next);
	tail->next = NULL;
}

这种情况下,空链表的删除我们可以通过断言的方式直接报错不让调用者删除。
当链表只有一个节点,tail->next为空,tail->next->next会报错。因此链表只有一个节点的情况需要单独考虑。

总之,考虑上面两种方法的bug,我们加入考虑空链表的删除和只有一个节点的删除的代码逻辑:

void SLLPopBack(SLLNode** pphead)
{
	assert(pphead); // 链表为空,pphead也不为空
	assert(*pphead); // 链表为空,不能删除
	
	if ((*pphead)->next == NULL)
	{
		// 1.链表中只有一个节点
		free(*pphead);
		*pphead = NULL;
	}
	else
	{
		// 2.链表中有多个节点
		// 使用同步双指针
		SLLNode* prev = NULL;
		SLLNode* tail = *pphead;
		while (tail->next)
		{
			prev = tail;
			tail = tail->next;
		}

		free(tail);
		prev->next = NULL;
	}
}
void SLLPopBack(SLLNode** pphead)
{
	assert(pphead); // 链表为空,pphead也不为空
	assert(*pphead); // 链表为空,不能删除
	
	if ((*pphead)->next == NULL)
	{
		// 1.链表中只有一个节点
		free(*pphead);
		*pphead = NULL;
	}
	else
	{
		// 2.链表中有多个节点
        // 直接找到倒数第二个节点进行尾删
		SLLNode* tail = *pphead;
		while (tail->next->next)
		{
			tail = tail->next;
		}

		free(tail->next);
		tail->next = NULL;
	}
}

3.6 头删PopFront

头删时,首先要找到头结点,这很容易通过头指针找到,其次需要将头指针指向头结点的下一个节点,然后将原来的头结点释放。

考虑到空链表的情况,我们使用断言不让在空链表中删除。

void SLLPopFront(SLLNode** pphead)
{
	assert(pphead); // 链表为空,pphead也不为空
	assert(*pphead); // 链表为空,不能头删。

	SLLNode* del = *pphead;
	*pphead = (*pphead)->next;
	free(del);
}

3.7 查找Find

SLLNode* SLLFind(SLLNode* phead, SLLDataType x)
{
	//assert(phead);

	SLLNode* cur = phead;
	while (cur)
	{
		if (cur->data == x)
		{
			return cur;
		}

		cur = cur->next;
	}

    return NULL;
}

3.8 前插insert

参考stl中list的insert接口定义cplusplus-list-insert

iterator insert (iterator position, const value_type& val);

position指向待插入的位置。

然后定义自己的insert函数:

void SLLInsert(SLLNode** pphead, SLLNode* pos, SLLDataType x);

要在pos位置插入,则要找到其所指节点的前一个节点:

void SLLInsert(SLLNode** pphead, SLLNode* pos, SLLDataType x)
{
	assert(pphead);
	assert(pos);

	SLLNode* prev = *pphead;
	while (prev->next != pos)
	{
		prev = prev->next;
	}
	SLLNode* newnode = CreateSLLNode(x);
	prev->next = newnode;
	newnode->next = pos;
}

这时考虑链表只有一个元素,pos恰好指向第一个节点,此时prev初始也指向第一个节点,会进入while循环,这时prev->next永远也不会等于pos,当prev为空时,prev->next会报错。因此我们需要单独考虑插入第一个节点前的情况(pos为头结点):

void SLLInsert(SLLNode** pphead, SLLNode* pos, SLLDataType x)
{
	assert(pphead);
	assert(pos);

	SLLNode* prev = *pphead;
	if (prev == pos)
	{
		SLLPushFront(pphead, x);
	}
	else
	{
		while (prev->next != pos)
		{
			prev = prev->next;
		}

		SLLNode* newnode = CreateSLLNode(x);
		prev->next = newnode;
		newnode->next = pos;
	}
}

另一种偷梁换柱的方法:

新建一个节点,然后将pos节点的值拷贝到新节点中,新节点插入到pos之后,待插入元素插入到pos中。这种方法可以避免找pos的前一个节点。

void SLLInsert1(SLLNode** pphead, SLLNode* pos, SLLDataType x)
{
	assert(pphead);
	assert(pos);

	SLLNode* newnode = CreateSLLNode(pos->data);
	pos->data = x;
	newnode->next = pos->next;
	pos->next = newnode;
}

3.9 后插InsertAfter

由上面的inset可知,单链表更适合后插,不适合指定位置的前插:

void SLLInsertAfter(SLLNode* pos, SLLDataType x)
{
	assert(pos);

	SLLNode* newnode = CreateSLLNode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

3.10 删除Erase

与insert前插类似,要删除pos位置的节点,需要找到其前一个节点,修改其前一个节点的指向:

void SLLErase(SLLNode** pphead, SLLNode* pos)
{
	assert(pphead);
	assert(pos);

	SLLNode* prev = *pphead;
	if (pos == prev)
	{
		SLPopFront(pphead);
	}
	else
	{
		while (prev->next != pos)
		{
			prev = prev->next;
		}

		prev->next = pos->next;
		free(pos);
	}
}

3.11 后删EraseAfter

void SLLEraseAfter(SLLNode* pos)
{
	assert(pos);
	assert(pos->next);

	SLLNode* next = pos->next;
	pos->next = next->next;
	free(next);
}

3.12 销毁Destroy

遍历链表,进行头删。

void SLLDestroy(SLLNode* phead)
{
	SLLNode* cur = phead;
	while (cur)
	{
		SLLNode* del = cur;
		cur = cur->next;
		free(del);
	}
}

源代码

gitee地址-SingleLinkedList


总结

1.单链表适用于在头部进行操作:头插、头删代码比较简单。对头插代码需要注意插入结点时next指向的逻辑。

2.要注意单链表的尾部操作时的细节分析。

尾插时,需要对空链表单独处理,这在链表的刷题过程中会有体现,涉及到尾插单链表的操作时,要注意处理空链表的情况。其次需要找到末尾的结点才能进行插入,注意找末尾节点时循环处的代码逻辑。

尾删时,需要对只有一个结点的链表进行单独处理。其次就是找倒数第二个结点时有同步双指针法和通过循环迭代直接找到。

3.链表的头插还可以用于反转链表。

4.注意Insert的细节分析。在pos处插入,找pos前一个结点的过程,以及链表中只有一个结点时找前一个结点的方法不适用因此需要特殊处理,还有就是insert在使用时,如果是新的链表首次使用insert插入数据,这时链表没有结点传入的pos为空,这不是Insert的正确使用方式。

5.需要熟悉找末尾节点、倒数第二个结点的代码逻辑。同步双指针的方法的使用。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。