

🔥@晨非辰Tong:个人主页
👀专栏:《C语言》、《数据结构与算法》、《数据结构与算法刷题集》
💪学习阶段:C语言、数据结构与算法初学者
⏳"人理解迭代,神理解递归。"
引言:征服了单链表,却总感束缚?无法回溯的遍历、繁琐的节点删除...是时候解锁双向链表,体验指针自由穿梭的真正力量。
目录
[1.1 带头、不带头](#1.1 带头、不带头)
[1.2 单向、双向](#1.2 单向、双向)
[1.3 循环、不循环](#1.3 循环、不循环)
[2.1 基本定义、结构](#2.1 基本定义、结构)
[2.2 "哨兵位"的初始化](#2.2 “哨兵位”的初始化)
[2.3 双链表尾插](#2.3 双链表尾插)
[2.4 双链表头插](#2.4 双链表头插)
[2.5 双链表尾删](#2.5 双链表尾删)
一、链表的分类、说明
--根据表格知道:链表的组合共有8种。那么具体是什么?
1.1 带头、不带头

--"头节点":一般是指不存储任何有效数据的节点。
--在前面的学习中,也说过"头节点"或者"首节点",但所谓的节点是存储着有效数据 的节点,是为了好说明。
--这里的头指的是前面刷题提到过的**"哨兵位"------>不存储任何有效数据 ,只是用来指向的第一个节点**(存储有效数据)。
1.2 单向、双向

--对比可以看到,双向链表比单向链表多一个指针(称为前驱指针)------>存储上一个节点地址,都有的指针在双链表称为后继指针。
--对于这样三个指针的节点结构:1指针-->前驱指针,2指针-->存储数据值,3指针-->后继指针。
1.3 循环、不循环

--对于前面所学单链表,到了尾节点就会指向NULL------>这就是不循环。
--但是对于循环的链表------>尾节点的后继指针(next)会指向第一个节点。(这样循环往复)
虽然说了有8种链表,但是常用 的组合为:不带头单向不循环链表 和 带头双向循环链表。
--下面见识一下双向链表。
二、双向链表
2.1 基本信息介绍
2.1 定义、结构

所谓双向链表:就如上面图示一样------>首先有头节点,然后存在着前驱指针指向前一个节点-->这就是双向,最后尾节点的后继指针指向头节点形成循环。
cpp
DList.h文件
#include <stdio.h>
#include <stdlib.h>
//定义双链表基本结构(节点)
typedef int DLTDataType;
typedef struct DListNode
{
DLTDataType data;
struct DListNode* next;//指向下一个节点
struct DListNode* prev;//指向前一个节点
}DLTNode;
2.2 "哨兵位"的初始化(准备工作)
--在实现链表的功能时,首先实现空链表:先将"哨兵位"的结构整出来。

双链表最开始为空 ,意味着"哨兵节点"始终指向自己(不是连"哨兵节点"都没有)------>前驱指针、后继指针都指向头节点本身。
cpp
DList.c文件
#include "DList.h"
//定义_初始化
void DLTInit(DLTNode** pphead)//二级指针接收"哨兵位"地址-->改变结构(test.c传的头节点)
{
//申请空间
*pphead = (DLTNode*)malloc(sizeof(DLTNode));//*pphead 是 phead
//判断非空
if (*pphead == NULL)
{
perror("malloc");
return 1;//申请失败
}
(*pphead)->data = -1;//随便一个值,后续不用
(*pphead)->next = (*pphead)->prev = *pphead;//指向自己
}
--"哨兵节点"初始化时的数据指针存储什么数据?
:随便放什么值,以后也不会用到 head->data。
cpp
test.c文件
#include "DList.h"
//测试
void test01()
{
DLTNode* plist = NULL;//不是双向链表,初始化
//初始化
DLTInit(&plist);//传址
}
int main()
{
test01();
return 0;
}

--可以看到:因为是传址调用,实参(plist)与形参(*pphead)节点结构一致,初始化成功。
2.2 链表基本功能实现
2.2.1 双链表尾插


--要尾插新节点,就要改变多处指针的指向。
--进行改变,先对newnode部分进行(若先变head、d3,导致指向变化,newnode无从下手),两图对照写。
--先将创建新节点定义出来:封装函数
cpp
DList.c文件
#include "DList.h"
//定义_由数值创建新节点
DLTNode* DLTBuyNode(DLTDataType x)
{
//申请空间
DLTNode* newnode = (DLTNode*)malloc(sizeof(DLTNode));
//判断非空
if (newnode == NULL)
{
perror("malloc");
return 1;//申请失败
}
newnode->data = x;
newnode->next = newnode->prev = newnode;//新节点先指向自己
return newnode;
}
--新申请的节点,刚开始前驱指针、后继指针都要指向自己,在后续的指向改变中完成变化。
cpp
DList.c文件
#include "DList.h"
//定义_尾插
void DLTPushBack(DLTNode* phead, DLTDataType x)//一级指针,只需要知道是哪个链表
{
//断言
assert(phead);
//创建新节点
DLTNode* newnode = DLTBuyNode(x);
//先对newnode进行改变,防止phead、尾节点指向改变
newnode->prev = phead->prev;
newnode->next = phead;
phead->prev->next = newnode;//->prev->next代表尾节点next
phead->prev = newnode;
}
cpp
test.c文件
#include "DList.h"
void test01()
{
DLTNode* plist = NULL;//不是双向链表,初始化
//初始化
DLTInit(&plist);//传址
//尾插
DLTPushBack(plist, 1);
DLTPushBack(plist, 2);
DLTPushBack(plist, 3);
DLTPushBack(plist, 4);
}
int main()
{
test01();
return 0;
}

2.2.2 双链表头插
--在头节点后,第一个节点前进行插入。

对于关于d1节点相关指针 的改变,通过 head 头节点的相关指针进行改变。
cpp
DList.c文件
#include "DList.h"
//定义_头插
void DLTPushFront(DLTNode* phead, DLTDataType x)
{
//断言
assert(phead);
//申请新节点
DLTNode* newnode = DLTBuyNode(x);
newnode->next = phead->next;
newnode->prev = phead;
phead->next->prev = newnode;
phead->next = newnode;
}
cpp
test.c文件
#include "DList.h"
void test01()
{
DLTNode* plist = NULL;//不是双向链表,初始化
//初始化
DLTInit(&plist);//传址
//头插
DLTPushFront(plist, 1);
DLTPushFront(plist, 2);
DLTPushFront(plist, 3);
DLTPushFront(plist, 4);
}
int main()
{
test01();
return 0;
}

2.2.3 双链表尾删
--在实现尾删操作前,先来定义判空的函数
cpp
//定义-判空
bool DLTEmpty(DLTNode* phead)
{
assert(phead);
return phead->next == phead;
}
--对于打印链表:为了体现尾删、头删的效果,将链表进行打印

cpp
//定义_打印
void DLTPrint(DLTNode* phead)
{
DLTNode* pcur = phead->next;//指向第一个节点
//循环遍历
while (pcur != phead)
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("\n");
}
--对于尾删:

尾删操作:将头节点及尾节点的上一个节点的前驱指针、后继指针改变指向,都不再指向尾节点。
cpp
DList.c文件
#include "DList.h"
//定义_尾删
void DLTPopBack(DLTNode* phead)
{
assert(!DLTEmpty(phead));
//首先定义指针指向尾节点
DLTNode* del = phead->prev;
del->prev->next = phead;
phead->prev = del->prev;
//对del节点空间进行释放
free(del);
del = NULL;
}
cpp
test.c文件
#include "DList.h"
void test01()
{
DLTNode* plist = NULL;//不是双向链表,初始化
//初始化
DLTInit(&plist);//传址
//尾插
DLTPushBack(plist, 1);
DLTPushBack(plist, 2);
DLTPushBack(plist, 3);
DLTPushBack(plist, 4);
DLTPrint(plist);
//尾删
DLTPopBack(plist);
DLTPrint(plist);
DLTPopBack(plist);
DLTPrint(plist);
DLTPopBack(plist);
DLTPrint(plist);
DLTPopBack(plist);
DLTPrint(plist);
}
int main()
{
test01();
return 0;
}

2.2.4 双链表头删
--对于头删的操作,画图弄清楚节点的前驱指针、后继指针的指向关系,即将头节点、第二个节点的前驱指针、后继指针不在指向第一个节点。
cpp
DList.c文件
#include "DList.h"
//定义_头删
void DLTPopFront(DLTNode* phead)
{
assert(!DLTEmpty(phead));
//定义指针指向第1个节点
DLTNode* del = phead->next;
phead->next = del->next;
del->next->prev = phead;
//释放del
free(del);
del = NULL;
}
先定义指针指向第一个节点,再根据图示,改变头节点后继指针指向第二个节点,改变第二个节点的前驱指针指向头节点。
cpp
test.c文件
#include "DList.h"
void test01()
{
DLTNode* plist = NULL;//不是双向链表,初始化
//初始化
DLTInit(&plist);//传址
//尾插
DLTPushBack(plist, 1);
DLTPushBack(plist, 2);
DLTPushBack(plist, 3);
DLTPushBack(plist, 4);
DLTPrint(plist);
//头删
DLTPopFront(plist);
DLTPrint(plist);
DLTPopFront(plist);
DLTPrint(plist);
DLTPopFront(plist);
DLTPrint(plist);
DLTPopFront(plist);
DLTPrint(plist);
}
int main()
{
test01();
return 0;
}

回顾:
《面试高频数据结构:单链表与顺序表之争,读懂"不连续"之美背后的算法思想》
《链表面试基础看点:这里不止"快慢指针"的完美实现,更懂"哨兵节点"的巧妙运用》
结语:从单链表的"单向枷锁"中解脱出来,我们初步领略了双向链表在头尾操作上的简洁与高效。但它的威力远不止于此。如何利用双指针的便利,实现比单链表更优雅、更高效的任意位置操作?这一切的背后,又隐藏着哪些不容错过的细节陷阱?下一篇,我们将拨开迷雾,一探究竟。