在数据结构的世界里,双向链表就像一列首尾相连的火车,每个车厢(节点)都能向前后两个方向自由通行。相比单向链表只能单向遍历的局限性,双向链表通过增加prev指针实现了双向遍历能力,在插入、删除等操作上展现出独特的优势。
带头双向循环链表是双向链表的完全体形态,它具备三个关键特征:
这种结构虽然需要额外空间存储prev指针,但在实际工程中能显著提升操作效率。比如在文本编辑器的撤销操作、浏览器历史记录管理等场景,双向链表都是理想的选择。
双向链表的节点结构如同一个双面胶带,前后都能粘合:
c复制typedef int LTDataType;
typedef struct List {
LTDataType Data; // 数据域
struct List* next; // 后继指针
struct List* prev; // 前驱指针
}LTNode;
注意:使用typedef定义类型别名是良好习惯,方便后续类型修改。比如将int改为其他数据类型时,只需修改一处定义。
哨兵位(头节点)的初始化很有讲究:
c复制LTNode* LTInit() {
LTNode* phead = LTBuyNode(-1); // 数据域赋无效值
phead->next = phead->prev = phead; // 自循环
return phead;
}
这个空头节点就像交通环岛的中心点,所有车辆(数据节点)都围绕它有序通行。采用自循环设计后,空链表判断简化为phead->next == phead。
申请新节点时要注意三个细节:
c复制LTNode* LTBuyNode(LTDataType x) {
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (!node) {
perror("malloc fail");
exit(EXIT_FAILURE); // 比exit(1)更专业
}
node->Data = x;
node->next = node->prev = node; // 新节点自循环
return node;
}
打印链表时的遍历技巧:
c复制void LTPrint(LTNode* phead) {
for(LTNode* pcur = phead->next; pcur != phead; pcur = pcur->next) {
printf("%d->", pcur->Data);
// 可扩展为打印prev和next指针值用于调试
}
printf("NULL\n");
}
尾插操作就像在火车末端加挂车厢,需要四步指针调整:
c复制void LTPushBack(LTNode* phead, LTDataType x) {
assert(phead); // 防御性编程
LTNode* newnode = LTBuyNode(x);
// 关键四步曲(建议按此顺序不易出错)
newnode->prev = phead->prev; // 1.新节点前驱指向原尾节点
newnode->next = phead; // 2.新节点后继指向头节点
phead->prev->next = newnode; // 3.原尾节点后继指向新节点
phead->prev = newnode; // 4.头节点前驱指向新节点
}
头插操作则是把新节点插入到头节点之后:
c复制void LTPushFront(LTNode* phead, LTDataType x) {
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->next = phead->next; // 1.新节点后继指向原首节点
newnode->prev = phead; // 2.新节点前驱指向头节点
phead->next->prev = newnode; // 3.原首节点前驱指向新节点
phead->next = newnode; // 4.头节点后继指向新节点
}
经验:画出示意图再写指针调整代码能避免混乱。记住"新节点先定前后,再改原有连接"的口诀。
尾删操作要特别注意链表非空检查:
c复制void LTPopBack(LTNode* phead) {
assert(phead && phead->next != phead); // 防御空链表
LTNode* del = phead->prev;
del->prev->next = phead; // 1.新尾节点后继指向头节点
phead->prev = del->prev; // 2.头节点前驱指向新尾节点
free(del); // 3.释放原尾节点
// del = NULL; 此处赋值无意义(局部变量)
}
头删操作同理:
c复制void LTPopFront(LTNode* phead) {
assert(phead && phead->next != phead);
LTNode* del = phead->next;
del->next->prev = phead; // 1.新首节点前驱指向头节点
phead->next = del->next; // 2.头节点后继指向新首节点
free(del);
}
在pos位置后插入新节点:
c复制void LTInsert(LTNode* pos, LTDataType x) {
assert(pos);
LTNode* newnode = LTBuyNode(x);
newnode->next = pos->next; // 1.新节点后继指向pos的后继
newnode->prev = pos; // 2.新节点前驱指向pos
pos->next->prev = newnode; // 3.pos原后继的前驱指向新节点
pos->next = newnode; // 4.pos的后继指向新节点
}
删除pos位置节点:
c复制void LTErase(LTNode* pos) {
assert(pos && pos->next != pos); // 防止删除头节点
pos->prev->next = pos->next; // 1.pos前驱的后继指向pos后继
pos->next->prev = pos->prev; // 2.pos后继的前驱指向pos前驱
free(pos);
}
销毁链表需要特别注意:
c复制void LTDestroy(LTNode** pphead) { // 使用二级指针
assert(pphead && *pphead);
LTNode* pcur = (*pphead)->next;
while(pcur != *pphead) {
LTNode* next = pcur->next;
free(pcur);
pcur = next;
}
free(*pphead);
*pphead = NULL; // 彻底置空
}
c复制void LTDebugPrint(LTNode* phead) {
printf("头节点: %p\n", phead);
LTNode* pcur = phead->next;
while(pcur != phead) {
printf("[%p | %d | 前驱:%p | 后继:%p]\n",
pcur, pcur->Data, pcur->prev, pcur->next);
pcur = pcur->next;
}
}
c复制bool LTIntegrityCheck(LTNode* phead) {
if(!phead) return false;
LTNode* pcur = phead->next;
while(pcur != phead) {
if(pcur->next->prev != pcur || pcur->prev->next != pcur)
return false;
pcur = pcur->next;
}
return true;
}
c复制// 错误示例(会导致指针丢失)
newnode->next = pos->next;
pos->next = newnode; // 此时pos->next已改变
newnode->next->prev = newnode; // 实际修改的是newnode自己
c复制// 危险代码
void BadPopBack(LTNode* phead) {
LTNode* del = phead->prev;
// 如果链表为空,del就是phead,将导致头节点被释放!
free(del);
}
c复制void LeakExample() {
LTNode* list = LTInit();
// ...各种操作...
// 忘记调用LTDestroy(list);
}
在实际项目中,双向链表常用于:
掌握双向链表的实现原理后,可以进一步拓展学习: