1. 链表的概念及其结构

链表的特点:
- 链表是由一系列节点组成的,每个节点包含一个数据元素和一个指向下一个节点的指针。
- 链表中的节点可以在内存中不连续的位置上存储,因此插入和删除节点时不需要移动其他节点。
- 相比于数组,链表在插入和删除操作上更高效,因为只需要修改节点的指针,而不需要移动其他元素。
- 链表内部的元素可以动态分配内存,可以根据需要灵活地进行插入和删除操作。
- 链表的访问效率较低,因为要访问特定位置的元素需要从头开始遍历链表,时间复杂度为O(n)。
- 链表可以用于实现其他数据结构,如栈和队列,以及高级数据结构,如图和树。
- 链表可以是单向链表,即每个节点只有一个指向下一个节点的指针;也可以是双向链表,每个节点既有一个指向下一个节点的指针,又有一个指向前一个节点的指针。
- 链表的长度可以动态变化,可以根据需要增加或减少节点。
- 链表不需要预先指定大小,可以根据需要灵活地分配内存。
2. 链表的分类
1. 单向链表或双向链表
2. 带哨兵位头解点和不带哨兵位头解点
3.循环链表和非循环链表
PS:虽然有这么多类型,但是很多时候我们会把他们结合在一起使用,比如双向带头循环链表(这是我个人比较喜欢的一种类型),因为使用方便且可以减少很多错误。
3. 链表的优势
1.灵活的内存使用:适合数据量大小不固定的情况。
2. 高效的局部操作:如在已知位置附近进行插入和删除。
4. 链表的劣势
1.随机访问效率低:不能像数组那样通过索引直接访问特定位置的元素。
2.额外的内存开销:每个节点需要存储指针,增加了空间复杂度。
5. 链表的实现
我们以链表中较为复杂的双向带头循环链表来讲解链表,以下是我们在链表中常常使用的一些函数:
1. 初始化链表,返回头节点指针
BuyListNode这个函数我们并不会在text文件里面使用,我们之所以写他是因为他可以使我们更方便,更快速的写好其他代码,同时提高代码的可读性。
BuyListNode这个函数的主要目的是创建一个新的 LTNode
类型的节点,并进行必要的初始化操作。如果内存分配失败,会打印错误信息并终止程序。最后函数返回新创建的节点的指针。在LTInit中可以理解为用于给phead也就是头节点初始化。
LTNode* BuyListNode(LTDataType x)
{
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL)
{
perror("malloc fail");
//return NULL;
exit(-1);
}
node->next = NULL;
node->prev = NULL;
node->data = x;
return node;
}
这段代码的作用通常是初始化一个双向带头循环链表的头节点。通过将头节点的 next
和 prev
指针都指向自身,形成一个只有头节点的环形结构。
LTNode* LTInit()
{
LTNode* phead = BuyListNode(-1);
phead->next = phead;
phead->prev = phead;
return phead;
}
2. 销毁链表
首先使用 assert
宏检查传入的头节点指针 phead
是否为空。如果为空,程序会在调试时终止并报错。然后获取头节点的下一个节点,并将其指针存储在 cur
中,准备从第一个非头节点开始释放内存,通过一个 while
循环,保存当前节点的下一个节点指针到 next
中。释放当前节点 cur
所占用的内存。将 cur
更新为 next
,以便在下一次循环处理下一个节点。循环结束后,释放头节点 phead
所占用的内存。最后将头节点指针 phead
设置为 NULL
,以避免出现空指针,防止后续代码误使用已释放的内存。总的来说,LTDestroy
函数通过遍历链表并依次释放每个节点的内存,实现了对整个链表的安全销毁,同时进行了必要的指针处理和错误检查,以保证程序的正确性和稳定性。
void LTDestroy(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
phead = NULL;
}
3. 打印链表
函数开始用 assert
确保传入的头节点指针 phead
有效。接着打印 <=head=>
表示开头。然后获取头节点的下一个节点给 cur
,只要 cur
不是头节点,就打印其数据并跟 <=>
,再更新 cur
指向下一节点。循环结束后打印 NULL
表示链表结束并换行。整个函数实现了对链表内容的有序打印。
void LTPrint(LTNode* phead)
{
assert(phead);
printf("<=head=>");
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d<=>", cur->data);
cur = cur->next;
}
printf("NULL");
printf("\n");
}
4.判断链表是否为空
首先通过 assert
检查头节点指针的有效性,然后根据链表为空的特定条件(头节点的下一个节点是自身)返回相应的布尔值(即true或者false)。
bool LTEmpty(LTNode* phead)
{
assert(phead);
return phead->next == phead;
}
5.在链表尾部插入节点
首先创建一个带有数据 x
的新节点 newnode
。接着找到链表的尾节点 tail
(即头节点 phead
的前一个节点)。然后,依次更新尾节点、新节点和头节点的指针:让尾节点的 next
指向新节点,新节点的 prev
指向尾节点,新节点的 next
指向头节点,头节点的 prev
指向新节点。这样,新节点就成功添加到了链表的尾部,维持了链表双向链接的结构。
void LTPushBack(LTNode* phead, LTDataType x)
{
LTNode* newnode = BuyListNode(x);
LTNode* tail = phead->prev;
tail->next = newnode;
newnode -> prev = tail;
newnode->next = phead;
phead->prev = newnode;
}
6. 删除链表尾部节点
首先,通过 assert
断言确保头节点指针 phead
非空且链表不为空。然后,找到尾节点 tail
及其前一个节点 tailprev
。接着,修改 tailprev
的 next
指针使其指向头节点,更新头节点的 prev
指针使其指向 tailprev
,从而将尾节点从链表中移除。最后,释放尾节点的内存,并将尾节点指针置为 NULL
,防止出现空指针。
void LTPopBack(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTNode* tail = phead->prev;
LTNode* tailprev = tail->prev;
tailprev->next = phead;
phead->prev = tailprev;
free(tail);
tail = NULL;
}
7. 在链表头部插入节点
函数先通过 assert
确保头节点指针 phead
非空。接着创建一个新节点 newnode
并初始化数据。然后获取头节点的下一个节点 first
。之后,让新节点的 next
指向 first
,first
的 prev
指向新节点,头节点的 next
指向新节点,新节点的 prev
指向头节点。这样就成功把新节点插入到了链表头部,保持了链表的双向链接关系。
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode= BuyListNode(x);
LTNode* first = phead->next;
newnode->next = first ;
first ->prev = newnode;
phead->next = newnode;
newnode->prev = phead;
}
8. 删除链表头部节点
首先,通过两个 assert
断言确保头节点指针 phead
非空且链表不为空。然后定义指针 second
指向链表的第二个节点。接着修改 second
的 prev
指针指向头节点,头节点的 next
指针指向 second
,从而将首节点从链表的链接关系中剔除。之后定义指针 first
并使其指向首节点。最后使用 free
释放 first
所指首节点的内存。
void LTPopFront(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
second=phead->next->next;
second->prev=phead;
phead->next=second;
first=phead->next;
free(first);
}
9. 在指定位置插入节点
首先,通过断言确保 pos
不为空。接着获取 pos
前一个节点 prev
,创建新节点 newnode
并初始化。然后,让 prev
的 next
指向新节点,新节点的 prev
指向 prev
,新节点的 next
指向 pos
,pos
的 prev
指向新节点。这样就完成了在指定位置前插入新节点的操作,保证了链表结构的正确。
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode*newnode= BuyListNode(x);
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
10. 删除指定位置的节点
首先,使用 assert
断言来确保传入的节点指针 pos
非空。这是为了防止在后续操作中因空指针而导致错误。然后,获取指定节点 pos
的前一个节点 p
和后一个节点 n
。接下来,通过修改指针的指向来完成节点的删除操作。将前一个节点 p
的 next
指针指向后一个节点 n
,后一个节点 n
的 prev
指针指向前一个节点 p
,这样就将指定节点从链表的链接关系中移除。最后,使用 free
函数释放指定节点所占用的内存,并将 pos
指针置为 NULL
,以避免出现悬空指针的问题。
void LTErase(LTNode* pos)
{
assert(pos);
LTNode* p = pos->prev;
LTNode* n = pos->next;
p->next = n;
n->prev = p;
free(pos);
pos = NULL;
}
11.查找指定x的位置
首先,通过 assert
断言确保传入的头节点指针 phead
不为空。然后,从链表的第一个有效节点(phead->next
)开始,将其指针存储在 cur
中。通过一个 while
循环遍历链表,只要当前节点 cur
不等于头节点 phead
,就继续查找。在循环内部,如果当前节点 cur
的数据等于要查找的指定数据 x
,则返回当前节点的指针,表示找到了匹配的节点。如果遍历完整个链表都没有找到匹配的数据,循环结束后返回 NULL
,表示未找到。
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
总结:
链表是一种重要的数据结构,由一系列节点组成。每个节点包含数据域和指针域,通过指针相互连接。链表的优势在于能灵活地动态分配内存,可方便地进行节点的插入和删除操作,尤其在中间位置时,只需修改相关节点的指针,无需大规模的数据移动。然而,链表也有不足,如随机访问元素的效率较低,因为必须从头或尾依次遍历才能找到指定节点,且每个节点的指针会带来额外的空间开销。它常用于数据量不固定、频繁增删元素的场景。