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

本文详细介绍了如何在C语言中实现单链表,包括结点定义、接口设计以及各种操作如打印、头插、尾插、头删、尾删、查找、插入和删除等。特别强调了在实现这些操作时的注意事项,如空链表处理、尾部操作的细节分析等。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


一、单链表 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\n");
}

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.需要熟悉找末尾节点、倒数第二个结点的代码逻辑。同步双指针的方法的使用。

二.内核链表 内核链表是一种链表,Linux内核中的链表都是用这种形式实现的 1.特性 内核链表是一种双向循环链表,内核链表的节点节点结构中只有指针域 使用内核链表的时候,将内核链表作为一个成员放入到一个结构体中使用 我们在链表中找到内核链表结构的地址,通过这个地址就可以找到外部大结构体的地址,通过大结构体就可以访问其中的成员 优势: 内核链表突破了保存数据的限制,可以用内核链表来保存任何数据(使用一种链表表示各种类型的数据,通用性很强) 内核链表中只有指针域,维护起来更加方便,效率更高 2.使用 内核链表在内核中已经被实现,我们只需要调用其接口直接使用即可 内核链表实现代码在内核源代码的list.h文件中 3.源代码分析 (1)节点结构: struct list_head { struct list_head *next, *prev;//前置指针 后置指针 }; (2)初始化 #define INIT_LIST_HEAD(ptr) do { \ (ptr)->next = (ptr); (ptr)->prev = (ptr); \ } while (0) (3)插入 //从头部插入 static inline void list_add(struct list_head *new, struct list_head *head)//传入要插入的节点和要插入的链表 { __list_add(new, head, head->next); } //从尾部插入 static inline void list_add_tail(struct list_head *new, struct list_head *head) { __list_add(new, head->prev, head); } (4)通过节点找到外部结构体的地址 //返回外部结构体的地址,第一个参数是节点地址,第二个参数是外部结构体的类型名,第三个参数是节点在外部结构体中的成员名 #define list_entry(ptr, type, member) ((type *)((char *)(ptr)-(unsigned long)(&((type *)0)->member))) (5)遍历内核链表 //遍历内核链表 #define list_for_each(pos, head) \ for (pos = (head)->next; pos != (head); \ pos = pos->next) //安全遍历内核链表 #define list_for_each_safe(pos, n, head) \ for (pos = (head)->next, n = pos->next; pos != (head); \ pos = n, n = pos->next) 二.内核链表 内核链表是一种链表,Linux内核中的链表都是用这种形式实现的 1.特性 内核链表是一种双向循环链表,内核链表的节点节点结构中只有指针域 使用内核链表的时候,将内核链表作为一个成员放入到一个结构体中使用 我们在链表中找到内核链表结构的地址,通过这个地址就可以找到外部大结构体的地址,通过大结构体就可以访问其中的成员 优势: 内核链表突破了保存数据的限制,可以用内核链表来保存任何数据(使用一种链表表示各种类型的数据,通用性很强) 内核链表中只有指针域,维护起来更加方便,效率更高 2.使用 内核链表在内核中已经被实现,我们只需要调用其接口直接使用即可 内核链表实现代码在内核源代码的list.h文件中 3.源代码分析 (1)节点结构: struct list_head { struct list_head *next, *prev;//前置指针 后置指针 }; (2)初始化 #define INIT_LIST_HEAD(ptr) do { \ (ptr)->next = (ptr); (ptr)->prev = (ptr); \ } while (0) (3)插入 //从头部插入 static inline void list_add(struct list_head *new, struct list_head *head)//传入要插入的节点和要插入的链表 { __list_add(new, head, head->next); } //从尾部插入 static inline void list_add_tail(struct list_head *new, struct list_head *head) { __list_add(new, head->prev, head); } (4)通过节点找到外部结构体的地址 //返回外部结构体的地址,第一个参数是节点地址,第二个参数是外部结构体的类型名,第三个参数是节点在外部结构体中的成员名 #define list_entry(ptr, type, member) ((type *)((char *)(ptr)-(unsigned long)(&((type *)0)->member))) (5)遍历内核链表 //遍历内核链表 #define list_for_each(pos, head) \ for (pos = (head)->next; pos != (head); \ pos = pos->next) //安全遍历内核链表 #define list_for_each_safe(pos, n, head) \ for (pos = (head)->next, n = pos->next; pos != (head); \ pos = n, n = pos->next) 二.内核链表 内核链表是一种链表,Linux内核中的链表都是用这种形式实现的 1.特性 内核链表是一种双向循环链表,内核链表的节点节点结构中只有指针域 使用内核链表的时候,将内核链表作为一个成员放入到一个结构体中使用 我们在链表中找到内核链表结构的地址,通过这个地址就可以找到外部大结构体的地址,通过大结构体就可以访问其中的成员 优势: 内核链表突破了保存数据的限制,可以用内核链表来保存任何数据(使用一种链表表示各种类型的数据,通用性很强) 内核链表中只有指针域,维护起来更加方便,效率更高 2.使用 内核链表在内核中已经被实现,我们只需要调用其接口直接使用即可 内核链表实现代码在内核源代码的list.h文件中 3.源代码分析 (1)节点结构: struct list_head { struct list_head *next, *prev;//前置指针 后置指针 }; (2)初始化 #define INIT_LIST_HEAD(ptr) do { \ (ptr)->next = (ptr); (ptr)->prev = (ptr); \ } while (0) (3)插入 //从头部插入 static inline void list_add(struct list_head *new, struct list_head *head)//传入要插入的节点和要插入的链表 { __list_add(new, head, head->next); } //从尾部插入 static inline void list_add_tail(struct list_head *new, struct list_head *head) { __list_add(new, head->prev, head); } (4)通过节点找到外部结构体的地址 //返回外部结构体的地址,第一个参数是节点地址,第二个参数是外部结构体的类型名,第三个参数是节点在外部结构体中的成员名 #define list_entry(ptr, type, member) ((type *)((char *)(ptr)-(unsigned long)(&((type *)0)->member))) (5)遍历内核链表 //遍历内核链表 #define list_for_each(pos, head) \ for (pos = (head)->next; pos != (head); \ pos = pos->next) //安全遍历内核链表 #define list_for_each_safe(pos, n, head) \ for (pos = (head)->next, n = pos->next; pos != (head); \ pos = n, n = pos->next) C语言下的单链表,可以增加,删除,查找,销毁节点。
在C语言中,单链表的逆置是一个常见的操作。逆置单链表意味着将链表的指针方向反转,使得最后一个节点变成第一个节点,依此类推。以下是一个简单的实现方法: ```c #include <stdio.h> #include <stdlib.h> // 定义单链表节点结构 typedef struct Node { int data; struct Node* next; } Node; // 创建新节点 Node* createNode(int data) { Node* newNode = (Node*)malloc(sizeof(Node)); if (!newNode) { printf("内存分配失败\n"); exit(1); } newNode->data = data; newNode->next = NULL; return newNode; } // 逆置单链表 Node* reverseList(Node* head) { Node* prev = NULL; Node* current = head; Node* next = NULL; while (current != NULL) { next = current->next; // 保存下一个节点 current->next = prev; // 反转当前节点的指针 prev = current; // 移动prev指针 current = next; // 移动current指针 } return prev; // 返回新的头节点 } // 打印单链表 void printList(Node* head) { Node* temp = head; while (temp != NULL) { printf("%d -> ", temp->data); temp = temp->next; } printf("NULL\n"); } // 主函数 int main() { // 创建链表 1->2->3->4->NULL Node* head = createNode(1); head->next = createNode(2); head->next->next = createNode(3); head->next->next->next = createNode(4); printf("原始链表: "); printList(head); // 逆置链表 head = reverseList(head); printf("逆置后链表: "); printList(head); return 0; } ``` 在这个实现中,`reverseList`函数通过三个指针`prev`、`current`和`next`来逆置链表。`current`指针遍历链表,`next`指针保存当前节点的下一个节点,`current->next`指针反转指向`prev`,然后`prev`和`current`指针分别向前移动。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

shlyyy

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

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

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

打赏作者

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

抵扣说明:

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

余额充值