理解单链表

解读单链表-C语言实现

链表的概念及结构

概念: 链表是一种常见的数据结构,在计算机科学中被广泛使用。它是由一系列节点组成的集合,每个节点包含实际存储的数据信息(称为元素或数据域)以及一个指向下一个节点的引用(称为链接或指针)。链表不像数组那样在内存中连续存储元素,而是通过节点之间的链接来组织数据。
首先来梳理链表的一些基本概念:

  • 节点(Node):链表中的一个元素,通常包含数据域和链接域。数据域用于存放实际的数据值,而链接域则存储指向列表中下一个节点的引用。

  • 头结点(Head):链表的第一个节点。对于空链表来说,头结点为 null 或者说没有头结点。

  • 尾结点(Tail):链表的最后一个节点,其链接域通常为 null,表示链表的结束。

  • 单向链表(Singly Linked List):最简单的链表类型,其中每个节点只有一个指向其后继节点的链接。

  • 双向链表(Doubly Linked List):除了有一个指向前驱节点的额外链接外,其余与单向链表相同。这使得遍历可以从前向后也可以从后向前进行。

  • 循环链表(Circular Linked List):在这种类型的链表中,最后一个节点指向的是第一个节点,而不是 null。这样就形成了一个闭环,遍历时需要特别注意以避免陷入无限循环。

链表的主要优点包括插入和删除操作简单快速,因为只需要改变相关的链接即可。但是查找某个特定元素可能需要遍历整个链表,因此效率较低。此外,链表不支持随机访问,即不能直接通过索引访问链表中的元素,只能从头节点开始逐个遍历。

在FreeRtos中就的用到了大量的链表来实现任务控制块管理,以及消息队列等都是基于链表实现的。通过使用链表,FreeRTOS能够有效地管理内部资源,并提供高效的调度和其他系统服务。链表的动态特性和灵活性非常适合实时操作系统中资源管理的需求。虽然实时操作系统不需要我们来手写,但是其最基本的数据结构,我们有必要知道其所以然。链表描述起来比较抽象,所以直接看图。
结构
1.png
图中:2.3.4.5都是结构体,称之为结点,与顺序表不同的是,链表中的每个结点不是只单纯的存一个数据。而是一个结构体,结构体成员包括一个所存的数据,和下一个结点的地址。另外,顺序表中的地址是连续的,而链表中结点的地址是随机分配的。
运行
图中的phead指针中存放的是第一个结点的地址,那么根据指着地址我们就可以找到这个结构体,又因为这个结构体中存放了下一个结构体的地址,所以又可以找到第二个结构体,循环往复就可以找到所有的结点,直到存放空地址的结构体。
特点

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

链表的分类

  1. 单向或者双向
    2.png

  2. 带头或不带头
    3.png

  3. 循环或非循环
    4.png

  4. 最常用的两种结构
    5.png

链表的实现

无头单向非循环链表-C语言实现

目标
实现无头单向非循环链表的增、删、改、查。

  1. 节点创建头插尾插指定位置前插指定位置后插
  2. 头删尾删指定位置删除
  3. 指定节点改
  4. 查找指定数据节点

以及节点数据的遍历输出,方便观察结果,进行对比调试。

一个无头单向非循环链表的连接如图所示。
6.png

链表的数据结构

1
2
3
4
5
6
7
8
9
10
11
/* 数据类型定义*/
typedef uint8_t nodedata;

/**
* @brief 链表节点数据结构
*/
typedef struct node
{
nodedata data; //数据
struct node *next; //下一个节点的指针
} node;

链表创建

链表的每一个结点都是动态开辟(malloc)出来的,每一个结点的大小为该结构体的大小。开辟成功后,要将结点存放的数据置为需要存放的值,结点存放的地址置为NULL。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @brief linklist创建节点
* @param d 节点数据
* @return node* 创建节点的指针
*/
node *lLNodeCraet(nodedata d)
{
node *newnode = (node *)malloc(sizeof(node)); //申请内存空间
if (newnode == NULL) //如果申请失败 直接提示错误退出程序
{
perror("malloc:error");
exit(-1);
}
newnode->data = d; //节点的数据
newnode->next = NULL; //下一个数据先默认为空,等待后续处理
return newnode; //返回新节点的指针
}

链表头插

头插相对来说比较简单,直接将申请结点的next置为头结点,然后将头结点改成申请结点即可
注:这里不需要考虑链表是否为空,传入的指针指向空地址便是创建头节点。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* @brief 链表头插
* @param pphead 指向头节点的指针
* @param d 节点数据
*/
void lLPushFront(node **pphead, nodedata d)
{
assert(pphead);
node *newnode = lLNodeCraet(d);
newnode->next = *pphead; // 将申请结点中保存的地址置为头结点的地址
*pphead = newnode; // 再将头结点向右移动 更新头节点 这里使用指向指针的指针 才可以改变传入的原本的头节点指针
}

链表尾插

因为不能根据下标访问元素(即不能随机访问),当然我们也不知到最后一个结点的位置,所在尾插时,需要遍历找到最后一个结点的位置。

同时这里分为两种情况:

  1. 如果链表为空,则直接插入即可。
  2. 如果链表不为空,则需要找到尾结点再插入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @brief 链表尾插
* @param pphead 指向头节点的指针
* @param d 节点数据
*/
void lLPushBack(node **pphead, nodedata d)
{
assert(pphead);
node *newnode = lLNodeCraet(d); // 申请结点
if (*pphead == NULL) // 1.链表为空
{
*pphead = newnode; // 直接将头结点置为需要插入的结点
}
else
{
node *cur = *pphead; // 得到头节点
while (cur->next) // 找尾结点
{
cur = cur->next;
}
cur->next = newnode; // 将尾结点中存放的地址置为插入结点的地址即可
}
}

链表指定位置后插

这里在指定位置后面插入而不在前面插入是因为,在前面插入时需要找到插入位置前面的地址,而这样又会遍历一次链表,时间复杂度为O(N),而在后面插入则直接插入即可,时间复杂度为O(1)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @brief 链表指定位置后面插入
* @param pphead 指向头节点的指针
* @param pos 指定节点
* @param d 节点数据
*/
void lLInsertPB(node** pphead, node* pos, nodedata d)
{
assert(pphead && pos);
node* newnode = lLNodeCraet(d);//申请结点
node* next = pos->next;//找到插入位置的下一个结点的地址
pos->next = newnode;//插入结点
newnode->next = next;//连接到后面的链表

}

链表指定位置前插

指定位置前插需要对链表进行遍历,细分为两种情况:

  1. 指定位置为头节点
  2. 指定位置非头节点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* @brief 链表在指定位置之前去插入一个节点
* @param pphead 指向头节点的指针
* @param pos 指定节点
* @param d 节点数据
*/

void lLInsertPF(node** pphead, node* pos, nodedata d)
{
assert(pphead);
assert(pos);

node* newnode = lLNodeCraet(d);
if (*pphead == pos)
{
newnode->next = *pphead;
*pphead = newnode;
}
else
{
// 找到pos的前一个位置
node* posPrev = *pphead;
while (posPrev->next != pos)
{
posPrev = posPrev->next;
}

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

链表尾删

同尾插一样,我们也不知道尾结点的地址,所以需要先找到尾结点。同时这里需要考虑三种情况:

  1. 链表为空。
  2. 链表中只有一个结点。
  3. 链表中有一个以上结点。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* @brief 链表尾删
* @param pphead 指向头节点的指针
*/
nodedata lLPopBack(node **pphead)
{
// 1.链表为空不能删除结点,且该指针不能为空
assert(*pphead && pphead);

nodedata t;
// 2.链表中只有一个结点,直接释放该结点,然后将结点置为NULL
if ((*pphead)->next == NULL)
{
t = (*pphead)->data; // 得到尾节点的数据
free(*pphead);
*pphead = NULL;
return t;
}

// 3.链表中有一个以上结点,先找尾结点,释放掉尾结点,置为NULL
// 但这样还不够,因为倒数第二个结点还存有尾结点的地址,所以需要将他置为NULL
node *cur = *pphead; // 用来标记倒数第二个结点
node *next = (*pphead)->next; // 标记尾结点
while (next->next)
{
next = next->next;
cur = cur->next;
}
t = next->data; // 得到尾节点的数据
cur->next = NULL; // 将倒数第二个结点中存的地址置为NULL
free(next); // 释放尾结点
next = NULL;
return t;
}

链表头删

头删也比较简单,相当于将头指针移动到第二个结点上。这里分为两种情况:

  1. 链表为空。
  2. 链表不为空。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @brief 链表头删
* @param d 节点数据
*/
nodedata lLPopFront(node **pphead)
{
assert(*pphead && pphead); // 链表为空不能删除

nodedata t;
t = (*pphead)->data;
node *next = (*pphead)->next; // 记录第二个结点的地址
free(*pphead); // 释放头结点
*pphead = next; // 将指针指向第二个结点
return t;
}

链表指定位置删除

这里在指定位置删除,而不在前面删除或者后面删除,是因为在头删和尾删时会遇到一些麻烦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* @brief 链表指定位置删除
* @param pphead 指向头节点的指针
* @param pos 指定节点
* @param d 节点数据
* @retval nodedata 被删除的数据
*/
nodedata lLErase(node **pphead, node *pos)
{
assert(pphead && pos);
nodedata t;
if (*pphead == pos) // 如果头结点是要删除的结点
{
t = (*pphead)->data;
*pphead = (*pphead)->next;
free(pos);
pos = NULL;
return t;
}
else
{
node *cur = *pphead;
while (cur->next != pos) // 找到要删除的结点
{
cur = cur->next;
}
t = cur->data;
cur->next = pos->next; // 将需要删除的结点的上一个结点的next指向需要删除的下一个结点
free(pos);
pos = NULL;
return t;
}
}

链表通过数据查找节点

根据提供的数据,在链表中遍历每个结点,若某个结点中的数据与之相同则返回该结点的地址;若没有找到则返回NULL。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @brief 链表查找
* @param pphead 指向头节点的指针
* @param d 节点数据
*/
node* lLFind(node* phead, nodedata d)
{
while (phead)
{
if (phead->data == d)
{
return phead;
}
phead = phead->next;
}
return NULL;
}

链表销毁

保存下一个结点的地址,释放当前结点,再将指针指向下一个结点,释放下一个结点,直到链表为空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @brief 销毁链表
* @param pphead 指向头节点的指针
*/
void lLDestory(node** pphead)
{
assert(pphead);
while (*pphead)
{
node* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
}

链表遍历打印

保存下一个结点的地址,打印当前结点数据,再将指针指向下一个结点,直到链表为空,输出NULL。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* @brief 链表打印
* @param pphead 指向头节点的指针
*/
void lLPrint(node* phead)
{
node* cur = phead;//一般不直接移动头指针,而是创建一个指针变量来移动
while (cur)//当指针为空时结束循环
{
printf("%d->", cur->data);//打印该结点的数据
cur = cur->next;//将指针指向下一个结点
}
printf("NULL\n");
}

链表测试

测试所有的函数 观测输出结果是否和预期一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int main(void)
{
node *Head = NULL;

lLPushFront(&Head, 0); // 创建头节点

lLPushFront(&Head, 1); // 头插数据1
lLPushFront(&Head, 2);
lLPushFront(&Head, 3);
lLPushFront(&Head, 4);
lLPushFront(&Head, 5);

lLPushBack(&Head, 99); // 尾插数据99
lLPushFront(&Head, 66); // 头插数据66

node *index1 = lLFind(Head, 4); // 查找数据为4的节点

lLInsertPB(&Head, index1, 12); // 在数据为4的节点后插入12
lLInsertPF(&Head, index1, 13); // 在数据为4的节点前插入13

node *index2 = lLFind(Head, 2); // 查找数据为2的节点

lLPushBack(&Head, 91); // 尾插数据91
lLPushFront(&Head, 61); // 头插数据61
lLPushBack(&Head, 92); // 尾插数据92
lLPushFront(&Head, 62); // 头插数据62
lLPopBack(&Head); // 尾删
lLPopFront(&Head); // 头删

lLErase(&Head, index2); // 删除指定节点

lLPrint(Head); // 遍历打印节点

return 0;
}

从输出结果观测到 输出结果和预期表现一致,表面链表实现没有问题
image.png

源码获取

github https://github.com/freedom413/linklistDemo.git