[数据结构——lesson4.双向链表]
引言
在 数据结构——lesson3.单链表中我们学习了数据结构中的单链表,它解决了顺序表中插入删除需要挪动大量数据的缺点。但同时也有需要改进的地方。
比如说:当我们需要寻找某个节点的前一个节点,对于单链表而言只能遍历,这样就可能造成大量时间的浪费。为了解决这个问题,我们需要学习新的内容——带头双向循环链表。
注意:
带头链表里的头节点,实际为“哨兵点”,哨兵节点(Sentinel Node)是一个附加的节点,它不存储实际数据,通常作为链表的 "伪头" 或 "伪尾" 存在。
哨兵位(哨兵节点)存在的核心意义是简化边界处理,统一操作逻辑,具体体现在:
-
消除空链表 / 边界节点的特殊判断
无论链表是否为空,哨兵节点始终存在(如作为伪头),避免了对 “头节点是否为空”“链表是否只有一个节点” 等场景的单独处理。 -
统一插入 / 删除操作的逻辑
例如删除节点时,无需区分 “删除的是头节点还是中间节点”;插入时,无需判断 “链表是否为空”,所有操作都能以相同的代码逻辑实现。 -
减少条件分支,降低出错概率
省去大量边界条件的判断(如if (head == null)
),使代码更简洁,逻辑更清晰,减少因漏判边界导致的 bug。
简言之,哨兵位通过 “增加一个无意义的节点”,换取了链表操作的统一性和简洁性。
双向链表的定义
双向链表是一种链式存储结构,其每个节点包含三个部分:
- 数据域:存储节点的实际数据
- 前驱指针(prev):指向当前节点的前一个节点
- 后继指针(next):指向当前节点的后一个节点
如下图:
双向链表的节点定义:
struct ListNode {int data; // 数据域struct Node* prev; // 前驱指针struct Node* next; // 后继指针
};
双向链表的功能
我们今天学习的双链表要实现一下几个功能:
初始化双向链表中的数据。
打印双向链表中的数据。
对双向链表进行尾插(末尾插入数据)。
对双向链表进行头插(开头插入数据)。
对双向链表进行尾删(末尾删除数据)。
对双向链表进行头删(开头删除数据)。
对双向链表数据进行查找。
对双向链表数据进行修改。
对指定位置的数据删除
对指定位置的数据插入。
销毁双向链表。
双向链表的功能实现
1.初始化双向链表中的数据
在初始化双向链表时,我们需要创建一个头节点,也就是我们常说的哨兵位头节点。
(1)创建头节点
//申请节点
LTNode* LTCreateNode(LTDataType x)
{LTNode* node = (LTNode*)malloc(sizeof(LTNode));// 如果node为NULL,说明内存分配失败if (node == NULL){perror("malloc fail");exit(1);}node->data = x;// 将前后节点都指向自己node->next = node->prev = node;return node;
}
(2)初始化
在初始化的时候我们已经将前后指针指向自己本身了,我们在这里把哨兵位头节点设置为-1
LTNode* LTInit()
{LTNode* phead = LTCreateNode(-1);return phead;
}
2.打印双向链表中的数据
核心原理
双向链表的每个节点包含 数据域(存数据)、前驱指针(指向前一个节点)和 后继指针(指向后一个节点),打印的核心是 “按顺序遍历节点并输出数据”,关键逻辑如下:
- 确定遍历起点:通常从 “头节点的后继节点” 开始(头节点是哨兵节点,不存有效数据,仅用于简化链表操作);
- 控制遍历终止:遍历到 “回到头节点” 时停止(避免循环遍历);
- 遍历与输出:从起点开始,依次通过节点的
next
指针访问下一个节点,同时打印当前节点的有效数据,直到触发终止条件。
代码实现
//打印
void LTPrint(LTNode* phead)
{LTNode* pcur = phead->next;// 使用while循环遍历链表,直到回到哨兵节点while (pcur != phead){printf("%d->", pcur->data);pcur = pcur->next;}printf("\n");
}
3.双向链表尾插
我们这里还要了解双向链表的插入过程
代码实现
//尾插
void LTPushBack(LTNode* phead, LTDataType x)
{assert(phead);LTNode* newnode = LTCreateNode(x);// 新节点的prev指针指向当前链表的最后一个节点//(即phead的prev指向的节点)newnode->prev = phead->prev;// 新节点的next指针指向头节点phead,这样就形成了双向循环newnode->next = phead;// 更新当前链表最后一个节点的next指针,使其指向新节点phead->prev->next = newnode;// 更新头节点phead的prev指针,使其指向新节点phead->prev = newnode;
}
4.双向链表头插
这里头插和尾插方法是一样的,不过要区分插入的位置是在节点前还是节点后!!!
代码实现
//头插
void LTPushFront(LTNode* phead, LTDataType x)
{assert(phead);LTNode* newnode = LTCreateNode(x);// 新节点的next指针指向当前头节点的下一个节点 newnode->next = phead->next;// 新节点的prev指针指向头节点phead newnode->prev = phead;// 更新原来头节点的下一个节点的prev指针phead->next->prev = newnode;// 更新头节点phead的next指针phead->next = newnode;
}
5.双向链表尾删
同样,我们要了解双向链表的删除过程
要注意,千万不要把头节点给删了。
//尾删
void LTPopBack(LTNode* phead)
{// 链表必须有效并且链表不能为空(即不能只有哨兵节点)assert(phead && phead->next != phead);LTNode* del = phead->prev;del->prev->next = phead;phead->prev = del->prev;//删除del节点free(del);del = NULL;
}
6.双向链表头删
代码实现
//头删
void LTPopFront(LTNode* phead)
{// 断言检查phead是否为空// 并且链表是否只有一个哨兵节点(即链表为空)assert(phead && phead->next != phead);// 指向要删除的节点,即当前头节点的下一个节点LTNode* del = phead->next;// 更新头节点的next指针,使其指向要删除节点的下一个节点phead->next = del->next;// 更新被删除节点的下一个节点的prev指针del->next->prev = phead;free(del);del = NULL;
}
7.双向链表数据查找
遍历整个链表,如果找到则返回当前节点的指针,如果遍历完整个链表都没找到则返回NULL。
//查找数据
LTNode* LTFind(LTNode* phead, LTDataType x)
{LTNode* pcur = phead->next;// 遍历链表,直到回到哨兵节点(表示已经遍历了整个链表)while (pcur != phead){if (pcur->data == x){return pcur;}pcur = pcur->next;}return NULL;
}
8.双向链表数据修改
代码实现
//修改数据
void LTModify(LTNode* phead, LTNode* pos, LTDataType x)
{assert(phead);assert(pos != phead);LTNode* pcur = phead->next;// 遍历链表,直到回到头节点while (pcur != phead){if (pcur == pos){pcur->data = x;}pcur = pcur->next;}
}
9.指定位置数据删除
代码实现
//删除pos节点
void LTErase(LTNode* pos)
{assert(pos);// 将pos的下一个节点的prev指针指向pos的前一个节点pos->next->prev = pos->prev;// 将pos的前一个节点的next指针指向pos的下一个节点pos->prev->next = pos->next;free(pos);pos = NULL;
}
10.指定位置数据插入
指定位置数据插入分为指定位置前后插入数据。
(1)在指定位置前插入数据
//在pos之前插入数据
void LTInsert(LTNode* pos, LTDataType x)
{assert(pos);LTNode* newnode = LTCreateNode(x);// 设置新节点的next指针指向pos,这样新节点就“指向”了posnewnode->next = pos;// 设置新节点的prev指针指向pos的前一个节点newnode->prev = pos->prev;// 更新pos前一个节点的next指针,使其指向新节点pos->prev->next = newnode;// 更新pos的prev指针,使其指向新节点pos->prev = newnode;
}
(2)在指定位置后插入数据
//在pos之后插入数据
void LTInsertAfter(LTNode* pos, LTDataType x)
{assert(pos);LTNode* newnode = LTCreateNode(x);// 设置新节点的next指针指向pos的下一个节点newnode->next = pos->next;// 设置新节点的prev指针指向posnewnode->prev = pos;// 更新pos的下一个节点的prev指针pos->next->prev = newnode;// 更新pos的next指针pos->next = newnode;
}
11.销毁双向链表
代码实现
//销毁数据
void LTDestroy(LTNode* phead)
{assert(phead);LTNode* pcur = phead->next;// 遍历整个链表while (pcur != phead){if (pcur == NULL){exit(1);}LTNode* next = pcur->next;free(pcur);pcur = NULL;}free(phead);phead = NULL;
}
完整代码
List.h
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>typedef int LTDataType;
//定义双链表的结构
typedef struct ListNode {LTDataType data;struct ListNode* next;struct ListNode* prev;
}LTNode;//初始化
//void LTInit(LTNode** pphead);
LTNode* LTInit();
//打印
void LTPrint(LTNode* phead);//插入数据之前,链表必须初始化到只有一个头节点的情况
//不改变哨兵位的地址,因此只需要传一级指针//尾插
void LTPushBack(LTNode* phead, LTDataType x);
//头插
void LTPushFront(LTNode* phead, LTDataType x);
//尾删
void LTPopBack(LTNode* phead);
//头删
void LTPopFront(LTNode* phead);
//在pos之前插入数据
void LTInsert(LTNode* pos, LTDataType x);
//在pos之后插入数据
void LTInsertAfter(LTNode* pos, LTDataType x);
//删除pos节点
void LTErase(LTNode* pos);
//查找数据
LTNode* LTFind(LTNode* phead, LTDataType x);
//销毁数据
void LTDestroy(LTNode* phead);
//修改数据
void LTModify(LTNode* phead, LTNode* pos, LTDataType x);
List.c
#define _CRT_SECURE_NO_WARNINGS 1
#include"List.h"//申请节点
LTNode* LTCreateNode(LTDataType x)
{LTNode* node = (LTNode*)malloc(sizeof(LTNode));// 如果node为NULL,说明内存分配失败if (node == NULL){perror("malloc fail");exit(1);}node->data = x;// 将前后节点都指向自己node->next = node->prev = node;return node;
}//初始化
//void LTInit(LTNode** pphead)
//{
// //给双向链表创建一个哨兵位
// *pphead = LTBuyNode(-1);
//}
LTNode* LTInit()
{LTNode* phead = LTCreateNode(-1);return phead;
}//打印
void LTPrint(LTNode* phead)
{LTNode* pcur = phead->next;// 使用while循环遍历链表,直到回到哨兵节点while (pcur != phead){printf("%d->", pcur->data);pcur = pcur->next;}printf("\n");
}//尾插
void LTPushBack(LTNode* phead, LTDataType x)
{assert(phead);LTNode* newnode = LTCreateNode(x);// 新节点的prev指针指向当前链表的最后一个节点//(即phead的prev指向的节点)newnode->prev = phead->prev;// 新节点的next指针指向头节点phead,这样就形成了双向循环newnode->next = phead;// 更新当前链表最后一个节点的next指针,使其指向新节点phead->prev->next = newnode;// 更新头节点phead的prev指针,使其指向新节点phead->prev = newnode;
}//头插
void LTPushFront(LTNode* phead, LTDataType x)
{assert(phead);LTNode* newnode = LTCreateNode(x);// 新节点的next指针指向当前头节点的下一个节点 newnode->next = phead->next;// 新节点的prev指针指向头节点phead newnode->prev = phead;// 更新原来头节点的下一个节点的prev指针phead->next->prev = newnode;// 更新头节点phead的next指针phead->next = newnode;
}//尾删
void LTPopBack(LTNode* phead)
{// 链表必须有效并且链表不能为空(即不能只有哨兵节点)assert(phead && phead->next != phead);LTNode* del = phead->prev;del->prev->next = phead;phead->prev = del->prev;//删除del节点free(del);del = NULL;
}//头删
void LTPopFront(LTNode* phead)
{// 断言检查phead是否为空// 并且链表是否只有一个哨兵节点(即链表为空)assert(phead && phead->next != phead);// 指向要删除的节点,即当前头节点的下一个节点LTNode* del = phead->next;// 更新头节点的next指针,使其指向要删除节点的下一个节点phead->next = del->next;// 更新被删除节点的下一个节点的prev指针del->next->prev = phead;free(del);del = NULL;
}//查找数据
LTNode* LTFind(LTNode* phead, LTDataType x)
{LTNode* pcur = phead->next;// 遍历链表,直到回到哨兵节点(表示已经遍历了整个链表)while (pcur != phead){if (pcur->data == x){return pcur;}pcur = pcur->next;}return NULL;
}//在pos之前插入数据
void LTInsert(LTNode* pos, LTDataType x)
{assert(pos);LTNode* newnode = LTCreateNode(x);// 设置新节点的next指针指向pos,这样新节点就“指向”了posnewnode->next = pos;// 设置新节点的prev指针指向pos的前一个节点newnode->prev = pos->prev;// 更新pos前一个节点的next指针,使其指向新节点pos->prev->next = newnode;// 更新pos的prev指针,使其指向新节点pos->prev = newnode;
}//在pos之后插入数据
void LTInsertAfter(LTNode* pos, LTDataType x)
{assert(pos);LTNode* newnode = LTCreateNode(x);// 设置新节点的next指针指向pos的下一个节点newnode->next = pos->next;// 设置新节点的prev指针指向posnewnode->prev = pos;// 更新pos的下一个节点的prev指针pos->next->prev = newnode;// 更新pos的next指针pos->next = newnode;
}//删除pos节点
void LTErase(LTNode* pos)
{assert(pos);// 将pos的下一个节点的prev指针指向pos的前一个节点pos->next->prev = pos->prev;// 将pos的前一个节点的next指针指向pos的下一个节点pos->prev->next = pos->next;free(pos);pos = NULL;
}//销毁数据
void LTDestroy(LTNode* phead)
{assert(phead);LTNode* pcur = phead->next;// 遍历整个链表while (pcur != phead){if (pcur == NULL){exit(1);}LTNode* next = pcur->next;free(pcur);pcur = NULL;}free(phead);phead = NULL;
}//修改数据
void LTModify(LTNode* phead, LTNode* pos, LTDataType x)
{assert(phead);assert(pos != phead);LTNode* pcur = phead->next;// 遍历链表,直到回到头节点while (pcur != phead){if (pcur == pos){pcur->data = x;}pcur = pcur->next;}
}
总结:顺序表和链表的区别
1. 存储方式
-
顺序表:
采用连续的内存空间存储数据,元素在内存中紧密排列(如数组)。
特点:通过下标(索引)可直接定位元素位置,内存地址连续。 -
链表:
采用离散的内存空间存储数据,每个元素(节点)包含数据域和指针域(指向下一个 / 上一个节点地址)。
特点:元素在内存中不连续,依赖指针关联前后元素。
不同点 | 顺序表 | 链表 |
存储空间上 | 物理上一定连续 | 逻辑上连续但物理上不一定连续 |
随机访问 | 支持O(1) | 不支持:O(N) |
任意位置插入或者删除元 素 | 可能需要搬移元素,效率低O(N) | 只需修改指针指向 |
插入 | 动态顺序表空间不够时需要扩容 | 没有容量的概念 |
应用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除频繁 |
缓存利用率 | 高 | 低 |
其他关键区别:
-
内存利用率:
顺序表可能存在内存浪费(预分配空间未用完),或因空间不足需整体扩容;
链表内存利用率更高,按需分配节点,但指针域会额外消耗少量内存。 -
缓存友好性:
顺序表的连续内存布局更符合 CPU 缓存机制,访问速度更快;
适用场景
-
顺序表:
适合频繁访问元素(如随机读写场景)、元素数量固定或变化不大的情况(如数据库索引、数组)。 -
链表:
适合频繁插入 / 删除元素(如链表式队列、栈)、元素数量动态变化较大的场景(如链表式哈希表)。
简言之,顺序表是 “以空间换时间”(连续存储提升访问速度),链表是 “以时间换空间”(灵活存储优化插入删除效率)。
结束语
这一节内容我们学习到了带头循环双向链表的结构和功能以及它的实现方式。
感谢您的三连支持!!!