🍇个人主页:松花酿酒~🍒🍒
🍇当前专栏:数据结构与算法🍒🍒
🍇今日诗句:
⚡️最是人间留不住,朱颜辞尽花辞树⚡️ ————《蝶恋花》
前言
本篇将详细介绍单链表和双向链表的实现以及比较顺序表和链表的区别。
一.链表的概念与结构
1.概念
「链表 Linked List」是一种线性数据结构,其中每个元素都是单独的对象,各个元素(一般称为结点)之间通过指针连接。由于结点中记录了连接关系,因此链表的存储方式相比于数组更加灵活,系统不必保证内存地址的连续性。
2.结构
链表的「结点 Node」包含两项数据,一是结点「值 Value」,二是指向下一结点的「指针 Pointer」(或称「引 用 Reference」)。

注意:链表在逻辑上是连续的,但在物理上是不连续的,其存储链表的空间是离散的,可能就会出现下面的情况。

尾结点指向什么?我们一般将链表的最后一个结点称为「尾结点」,其指向的是「空」,在 C / C++ 中分别记为 NULL / nullptr 。
链表初始化方法。建立链表分为两步,第一步是初始化各个结点对象,第二步是构建引用指向关系。
到这就初步介绍了下链表,接下来就进入无头非循环单链表的实现及双向带头循环链表的实现。
二.无头非循环单链表
无头非循环单链表是一种数据结构,它是由若干个节点组成的,每个节点包含一个数据元素和一个指向下一个节点的指针。相较于循环单链表,它没有头结点,也就是说第一个节点就是链表的首节点,而且最后一个节点的指针指向 NULL,表示链表的结束。

注意
在无头非循环单链表中,我们通常需要维护一个指向首节点的指针,以便于对链表的操作。与其他链表相同,在进行插入、删除等操作时,必须注意节点的前驱节点和后继节点之间的指针关系,以保证链表的正确性。
1.创建一个新节点
a.定义一个节点结构体,包含数据域和指针域。
/* 链表结点结构体 */
struct ListNode {
int val; // 结点值
ListNode *next; // 指向下一结点的指针(引用)
ListNode(int x) : val(x), next(nullptr) {} // 构造函数
};
b.使用关键字 new 申请一个新节点的空间,并将数据域和指针域初始化。
ListNode* newNode = new ListNode(); // 申请新节点空间
newNode->val = 10; // 初始化数据域
newNode->next = NULL; // 初始化指针域
2.单链表打印
void printList(ListNode* head) {
ListNode* p = head;
while (p != NULL) {
cout << p->val << " -> ";
p = p->next;
}
cout << "NULL" << endl;
}
在上面的代码中,ListNode 是单链表节点的结构体定义。printList 函数用于输出单链表中所有节点的数据元素。首先将 p 指针指向链表的首节点,然后逐个访问节点并输出其数据元素,直到 p 指向空节点。
3.单链表尾插
ListNode* insertTail(ListNode* head, int val) {
ListNode* newNode = new ListNode(val);
if (head == NULL) {
head = newNode;
} else {
ListNode* p = head;
while (p->next != NULL) {
p = p->next;
}
p->next = newNode;
}
return head;
}
在上面的代码中,ListNode 是单链表节点的结构体定义。insertTail 函数用于向链表中插入一个值为 val 的节点,并返回修改后的链表头指针。如果链表为空,则将新节点 newNode 直接作为首节点;否则需要遍历链表找到尾节点,然后将新节点插入到尾节点的后面。
4.单链表头插
void insertToHead(int val) {
ListNode* newNode = new ListNode(val);
newNode->next = head;
head = newNode;
}
通过创建一个新的节点 newNode,并将其 next 指针指向当前的头节点 head,随后更新头指针 head 为新节点 newNode,完成了新节点的插入操作。
注意:在插入第一个节点时,头指针
head应当指向新节点newNode。同时,由于在insertToHead函数中使用了new运算符分配内存,因此在程序执行完毕后需要及时释放动态分配的内存以避免内存泄漏。
5.单链表尾删
void deleteFromTail() {
if (head == nullptr) {
return;
} else if (head->next == nullptr) {
delete head;
head = nullptr;
} else {
ListNode* cur = head;
while (cur->next->next != nullptr) {
cur = cur->next;
}
delete cur->next;
cur->next = nullptr;
}
}
若链表为空,则直接返回;若链表只有一个节点,则删除该节点并将头指针 head 赋为空指针 nullptr;否则,从头节点开始遍历链表,直至找到链表的倒数第二个节点,随后删除最后一个节点,并将倒数第二个节点的 next 指针赋为空指针 nullptr,完成了单链表尾删操作。
6.单链表头删
void deleteFromHead() {
if (head == nullptr) {
return;
}
ListNode* temp = head;
head = head->next;
delete temp;
}
若链表为空,则直接返回;否则,先保存头节点的地址 temp,然后将头指针 head 指向下一个节点,并删除原头节点 temp,完成了单链表头删操作。
7.单链表查找
ListNode* findNode(int val) {
ListNode* cur = head;
while (cur != nullptr) {
if (cur->val == val) {
return cur;
}
cur = cur->next;
}
return nullptr;
}
从头节点开始遍历链表,直至找到目标节点或者到达链表尾部。若找到目标节点,则返回该节点的地址或指针;否则,返回空指针 nullptr,表示未找到目标节点。
三.双向带头循环链表
双向带头循环链表是一种常见的链表结构,它与单向链表相比,每个节点多了一个指向前驱节点的指针,这样可以更方便地进行双向遍历;而带头循环则是在第一个节点和最后一个节点之间形成环状结构,便于对链表进行循环操作。
注意
在插入新节点和删除节点时,需要同时更新该节点前驱节点和后继节点的指针,否则可能会导致链表结构不正确或者指针访问错误。此外,在使用完链表后,需要释放动态分配的内存,避免出现内存泄漏的问题。
1.创建一个新节点
struct ListNode {
int val;
ListNode* prev;
ListNode* next;
ListNode(int x) : val(x), prev(nullptr), next(nullptr) {}
};
ListNode* head = new ListNode(0); // 头指针
2.双向链表的销毁
带头双向循环链表的销毁可以使用迭代或递归的方式,具体实现如下:
a.迭代实现方式
void destroyList(ListNode* head) {
ListNode* cur = head->next;
while (cur != head) {
ListNode* tmp = cur;
cur = cur->next;
delete tmp;
}
delete head;
}
b.递归实现方式
void destroyList(ListNode* head) {
if (head == nullptr || head->next == head) {
delete head;
return;
}
destroyList(head->next);
delete head;
}
注意
在销毁链表时需要先释放每个节点的内存空间,再释放头结点的内存空间,否则会导致内存泄漏。此外,在使用指针访问节点的值和指针时,需要进行空指针判断,避免出现访问非法内存的情况。
3.双向链表打印
a.迭代实现方式
void printList(ListNode* head) {
ListNode* cur = head->next;
while (cur != head) {
cout << cur->val << " ";
cur = cur->next;
}
cout << endl;
}
b.递归实现方式
void printList(ListNode* head) {
if (head == nullptr || head->next == head) {
cout << endl;
return;
}
cout << head->next->val << " ";
printList(head->next);
}
4.双向链表的尾插
void insertTail(ListNode* head, int val) {
ListNode* cur = head;
while (cur->next != head) { // 遍历链表找到尾部节点
cur = cur->next;
}
ListNode* newNode = new ListNode(val); // 新建一个节点并插入到尾部节点之后
newNode->prev = cur;
newNode->next = head;
cur->next = newNode;
head->prev = newNode;
}
注意
在进行尾插操作时,需要更新尾部节点及其前驱节点的指针,否则可能会导致链表结构不正确或者指针访问错误。
5.双向链表的尾删
void deleteTail(ListNode* head) {
ListNode* cur = head;
while (cur->next != head) { // 遍历链表找到尾部节点
cur = cur->next;
}
cur->prev->next = head; // 删除尾节点
head->prev = cur->prev;
delete cur;
}
6.双向链表的头插
void insertHead(ListNode* head, int val) {
ListNode* newNode = new ListNode(val); // 新建一个节点并插入到头结点之后
newNode->next = head->next;
newNode->prev = head;
head->next->prev = newNode;
head->next = newNode;
}
注意
在进行头插操作时,需要更新头结点及其后继节点的指针,否则可能会导致链表结构不正确或者指针访问错误。
7.双向链表的头删
void deleteHead(ListNode* head) {
ListNode* cur = head->next; // 找到头结点的后继节点
if (cur != head) {
head->next = cur->next; // 删除后继节点
cur->next->prev = head;
delete cur;
}
}
8.双向链表的查找
ListNode* search(ListNode* head, int val) {
ListNode* cur = head->next; // 从第一个节点开始遍历
while (cur != head && cur->val != val) { // 遍历链表直到找到目标节点或遍历到头结点
cur = cur->next;
}
if (cur == head) { // 没有找到目标节点
return nullptr;
} else { // 找到了目标节点
return cur;
}
}
注意
在进行查找操作时,需要注意边界条件的处理,避免出现访问非法内存的情况。
四.链表的优点
在链表中,插入与删除结点的操作效率高。比如,如果我们想在链表中间的两个结点 A , B 之间插入一个新结 点 P ,我们只需要改变两个结点指针即可,时间复杂度为 𝑂(1) ,相比数组的插入操作高效很多。
五.链表的缺点
1. 链表访问结点效率低。数组可以在 𝑂(1) 时间下访问任意元素,但链表无法直接访问任意结点。这 是因为计算机需要从头结点出发,一个一个地向后遍历到目标结点。例如,倘若想要访问链表索引为 index (即第 index + 1 个)的结点,那么需要 index 次访问操作。
2.链表的内存占用多。链表以结点为单位,每个结点除了保存值外,还需额外保存指针(引用)。这意味着同样 数据量下,链表比数组需要占用更多内存空间。
六.顺序表和链表的区别
总结
以上就是本篇文章的内容了,感谢你的阅读。如果喜欢本文的话,欢迎点赞和评论,写下你的见解。
本文介绍了链表的基本概念和结构,包括单链表和双向链表的创建、插入、删除等操作。对比了链表与顺序表在存储和访问效率上的区别,并分析了链表的优缺点。此外,还讨论了无头非循环单链表和双向带头循环链表的实现细节。


365

被折叠的 条评论
为什么被折叠?



