【数据结构】深入理解双向链表:结构、实现与对比分析

一、链表的分类

链表的结构多样,可通过以下三个维度组合出 8 种类型:

  • 方向
    • 单向:只能从一个方向遍历
      在这里插入图片描述

    • 双向:可以从两个方向遍历
      在这里插入图片描述

  • 头部:
    • 带头(有哨兵位)
      在这里插入图片描述

    • 不带头
      在这里插入图片描述

  • 循环性:
    • 循环
      在这里插入图片描述

    • 非循环
      在这里插入图片描述

常用链表类型

(1)无头单向非循环链表(单链表)

  • 特点: 结构简单,无哨兵位节点,最后一个节点的 nextNULL
  • 应用场景: 多作为其他数据结构的子结构(如哈希桶、图的邻接表),笔试面试高频考点

(2)带头双向循环链表(双向链表)

  • 特点: 包含哨兵位头节点,每个节点有 prevnext 两个指针,尾节点的 next 指向头节点
  • 优势: 操作统一(无需特殊处理空链表或首尾节点),实现简单
  • 应用场景: 单独存储数据(如 STL 中的 list

二、双向链表的结构

双向链表中,我们重点关注的是带头双向循环链表。这里的“带头”需要特别说明,它和单链表中我们常说的“头节点”不是一个概念。在单链表阶段,我们对“头节点”的称呼其实并不严谨,为了便于理解才那样称呼。而带头双向循环链表中的“头节点”,实际是“哨兵位”。
在这里插入图片描述

“哨兵位”节点不存储任何有效元素,它就像一个站在那里“放哨”的节点,其存在的最大意义是避免在遍历循环链表时出现死循环。有了这个哨兵位,我们在进行链表的遍历等操作时,就有了一个明确的起点和终点判断依据,大大降低了出错的概率。

三、双向链表的实现

1. 节点结构定义

双向链表的节点结构。每个节点不仅要存储数据,还要有两个指针,分别指向它的前一个节点和后一个节点,具体定义如下:

typedef int LTDataType;
typedef struct ListNode
{
    struct ListNode* next; // 下一个节点
    struct ListNode* prev; // 前一个节点
    LTDataType data; 
}LN;

2. 主要操作函数

(1) 初始化(LTInit)

初始化函数用于创建双向链表的哨兵位节点,并构建带头双向循环的初始结构。
注意:

  • 单链表初始化:空链表
  • 双向链表初始化:只有一个头节点(哨兵位)

实现思路:

  • 动态开辟一个哨兵位节点。
  • 让哨兵位节点的prevnext指针都指向自身,形成循环结构。

注意: 不可以把prevnext指向空,无法达到循环的效果

LN* LTInit()
{
    LN* phead = (LN*)malloc(sizeof(LN));
    if (phead == NULL)
    {
        perror("malloc fail");
        exit(-1);
    }
    phead->next = phead;
    phead->prev = phead;
    return phead;
}

该函数返回初始化好的哨兵位节点

(2)销毁函数(LTDestroy)

销毁函数用于释放链表中所有节点(包括哨兵位)的内存,避免内存泄漏。
实现思路:

  • 先判断链表是否为空,若为空则直接释放哨兵位。
  • 若不为空,通过遍历找到每一个节点并释放,最后释放哨兵位。
void LTDes(LN* phead)
{
	assert(phead);
	LN* pcur= phead->next;
	while (pcur->next != phead)
	{
		LN* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	free(phead);
	phead = NULL;
}

通过循环遍历释放除哨兵位外的所有节点,最后释放哨兵位,完成链表的销毁。

(3)打印(LTPrint)

实现思路:

  • 哨兵位没有有效数据,从哨兵位的下一个节点开始遍历。
  • 遍历至重新回到哨兵位时停止,依次打印每个节点的数据。
void LTPrint(LN* phead)
{
    assert(phead);
    LN* cur = phead->next;
    printf("哨兵位<->");
    while (cur != phead)
    {
        printf("%d<->", cur->data);
        cur = cur->next;
    }
    printf("\n");
}

(4)尾插(LTPushBack)

注意: 无论头插尾插还是头删尾删,都不可以改变哨兵位,因此这里传递一级指针即可

实现思路:

  • 找到链表的尾节点(哨兵位的prev指针所指节点)。
  • 建立新节点与尾节点、哨兵位之间的双向链接。 在这里插入图片描述
void LTPushBack(LN* phead ,LTDataType x)
{
	assert(phead);
	LN* newNode = LTBuyNode(x);
	//先改newNode
	newNode->prev = phead->prev;
	newNode->next = phead;

	//两行代码不能交换
	phead->prev->next = newNode;
	phead->prev = newNode;
}

(5)尾删(LTPopBack)

实现思路:

  • 先判断链表是否为空,为空则无法删除。
  • 找到尾节点及其前一个节点,通过调整指针解除尾节点的链接并释放其内存。

在这里插入图片描述

void LTPopBack(LN* phead)
{
	assert(phead && phead->next != phead);
	LN* del = phead->prev;

	del->prev->next = phead;
	phead->prev = del->prev;
	free(del);
	del = NULL;
}

(6)头插(LTPushFront)

头部插入函数用于在哨兵位之后添加新节点。
实现思路:

  • 找到哨兵位的下一个节点(原头节点)。
  • 建立新节点与哨兵位、原头节点之间的双向链接。
    在这里插入图片描述
void LTPushFront(LN* phead, LTDataType x)
{
	assert(phead);
	LN* newNode = LTBuyNode(x);

	newNode->next = phead->next;
	newNode->prev = phead;

	//这两行代码不可以交换
	phead->next->prev = newNode;
	phead->next = newNode;
}

利用双向链表的指针特性,快速完成头部插入操作。

(7)头删(LTPopFront)

头部删除函数用于移除哨兵位之后的第一个节点。
实现思路:

  • 先判断链表是否为空,为空则无法删除。
  • 找到原头节点及其下一个节点,调整指针解除原头节点的链接并释放其内存。在这里插入图片描述
void LTPopFront(LN* phead)
{
	assert(phead && phead->next != phead);
	LN* del = phead->next;

	phead->next = del->next;
	del->next->prev = phead;
	free(del);
	del = NULL;
}

(8)指定位置后插入函数(LTInsert)

实现思路:

  • 找到pos节点的下一个节点。
  • 建立新节点与pos节点、pos下一个节点之间的双向链接。
void LTInsert(LN* pos, LTDataType x)
{
	assert(pos);
	LN* newNode = LTBuyNode(x);

	newNode->prev = pos;
	newNode->next = pos->next;

	pos->next->prev = newNode;
	pos->next = newNode;
}

该方法可以包含头插操作,但是头插不包含该操作。根本区别是头插给定的节点是哨兵位,哨兵位不能存放有效数据

(9)指定位置删除函数(LTErase)

实现思路:

  • 找到pos节点的前一个和后一个节点。
  • 调整这两个节点的指针,解除与pos节点的链接并释放pos节点的内存。
void LTErase(LN* pos)
{
	assert(pos && pos->next!=pos);
	
	pos->next->prev = pos->prev;
	pos->prev->next = pos->next;

	free(pos);
	pos = NULL;
}

通过该函数可方便地实现头删、尾删等操作。

(10) 查找函数(LTFind)

查找函数用于在链表中寻找数据为x的节点。
实现思路:

  • 从哨兵位的下一个节点开始遍历。
  • 若找到数据为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;
}

注意
理论上来说以下三个函数应该传二级指针,但是这会增加调用方记忆成本。

  • void LTInsert(LN* pop, LTDataType x);
  • void LTErase(LN* pop);
  • void LTDes(LN* phead);

为了统一接口,可以统一传入一级指针,但存在的问题是:形参改变(phead置为NULL)不会影响实参,这就需要调用方手动把plist置空

四、顺序表和双向链表的优缺点分析

为了更好地理解双向链表的适用场景,我们将它与顺序表进行对比分析:

不同点顺序表链表(这里主要指双向链表)
存储空间上物理上一定连续逻辑上连续,但物理上不一定连续
随机访问支持,时间复杂度为O(1)不支持,时间复杂度为O(N)
任意位置插入或者删除元素可能需要搬移元素,效率低,时间复杂度为O(N)只需修改指针指向,效率高
插入相关动态顺序表在空间不够时需要扩容没有容量的概念,不需要扩容
应用场景适用于元素高效存储且需要频繁访问的场景适用于任意位置插入和删除操作频繁的场景

通过以上对比可以看出,顺序表和双向链表各有其优势和劣势。在实际开发中,我们需要根据具体的应用场景来选择合适的数据结构。如果需要频繁地访问元素,那么顺序表是更好的选择;如果需要频繁地进行插入和删除操作,尤其是在任意位置进行这些操作,那么双向链表会更合适。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值