双向链表专题:结构、实现与对比分析

双向链表专题:结构、实现与对比分析

双向链表是链表中结构最复杂但使用最广泛的一种。本文将深入讲解带头双向循环链表的结构特点,带您从零实现双向链表的核心接口(增删改查),并与顺序表进行全方位对比,分析各自的适用场景。

目录


一、双向链表的结构

1. 带头双向循环链表的定义

带头双向循环链表是链表中最复杂也最常用的一种结构。它具备三个特征:

  • 带头:存在一个哨兵位节点(不存储有效数据)
  • 双向 :每个节点既有指向下一个节点的指针 next,也有指向上一个节点的指针 prev
  • 循环 :尾节点的 next 指向哨兵位,哨兵位的 prev 指向尾节点
c 复制代码
typedef struct ListNode {
    struct ListNode* next;  // 指向下一个节点
    struct ListNode* prev;  // 指向上一个节点
    int data;               // 数据域
} LTNode;

2. 哨兵位的作用

哨兵位节点不保存有效数据,只作为链表头尾的标志。它的存在使得:

  • 空链表不再是 NULL,而是仅有一个哨兵位节点(其 nextprev 都指向自己)
  • 遍历链表时不会死循环,因为遇到哨兵位即表示遍历结束
  • 插入删除操作无需考虑头尾特殊处理,逻辑统一

二、实现双向链表

1. 节点结构与接口声明

c 复制代码
typedef int LTDataType;
typedef struct ListNode {
    struct ListNode* next;
    struct ListNode* prev;
    LTDataType data;
} LTNode;

// 创建哨兵位(初始化链表)
LTNode* LTInit();

// 销毁链表
void LTDestroy(LTNode* phead);

// 打印链表
void LTPrint(LTNode* phead);

// 判断链表是否为空(仅含哨兵位)
bool LTEmpty(LTNode* phead);

// 尾插 / 尾删
void LTPushBack(LTNode* phead, LTDataType x);
void LTPopBack(LTNode* phead);

// 头插 / 头删
void LTPushFront(LTNode* phead, LTDataType x);
void LTPopFront(LTNode* phead);

// 在 pos 位置之后插入
void LTInsert(LTNode* pos, LTDataType x);

// 删除 pos 位置节点
void LTErase(LTNode* pos);

// 查找值为 x 的节点
LTNode* LTFind(LTNode* phead, LTDataType x);

注意 :所有接口参数均为哨兵位指针 phead,无需二级指针,因为哨兵位永远固定不变。

2. 核心接口实现

创建哨兵位(初始化)
c 复制代码
LTNode* LTInit() {
    LTNode* phead = (LTNode*)malloc(sizeof(LTNode));
    if (phead == NULL) exit(-1);
    phead->next = phead;
    phead->prev = phead;
    return phead;
}
创建新节点
c 复制代码
LTNode* BuyLTNode(LTDataType x) {
    LTNode* node = (LTNode*)malloc(sizeof(LTNode));
    if (node == NULL) exit(-1);
    node->data = x;
    node->next = NULL;
    node->prev = NULL;
    return node;
}
尾插
c 复制代码
void LTPushBack(LTNode* phead, LTDataType x) {
    LTNode* newNode = BuyLTNode(x);
    LTNode* tail = phead->prev;   // 哨兵位的 prev 即尾节点
    tail->next = newNode;
    newNode->prev = tail;
    newNode->next = phead;
    phead->prev = newNode;
}
头插
c 复制代码
void LTPushFront(LTNode* phead, LTDataType x) {
    LTNode* newNode = BuyLTNode(x);
    LTNode* first = phead->next;
    phead->next = newNode;
    newNode->prev = phead;
    newNode->next = first;
    first->prev = newNode;
}
尾删
c 复制代码
void LTPopBack(LTNode* phead) {
    assert(!LTEmpty(phead));  // 不能删除哨兵位
    LTNode* tail = phead->prev;
    LTNode* prev = tail->prev;
    prev->next = phead;
    phead->prev = prev;
    free(tail);
}
头删
c 复制代码
void LTPopFront(LTNode* phead) {
    assert(!LTEmpty(phead));
    LTNode* first = phead->next;
    LTNode* second = first->next;
    phead->next = second;
    second->prev = phead;
    free(first);
}
在 pos 之后插入
c 复制代码
void LTInsert(LTNode* pos, LTDataType x) {
    assert(pos);
    LTNode* newNode = BuyLTNode(x);
    LTNode* next = pos->next;
    pos->next = newNode;
    newNode->prev = pos;
    newNode->next = next;
    next->prev = newNode;
}
删除 pos 节点
c 复制代码
void LTErase(LTNode* pos) {
    assert(pos);
    LTNode* prev = pos->prev;
    LTNode* next = pos->next;
    prev->next = next;
    next->prev = prev;
    free(pos);
}
打印链表
c 复制代码
void LTPrint(LTNode* phead) {
    LTNode* cur = phead->next;
    printf("哨兵位 <-> ");
    while (cur != phead) {
        printf("%d <-> ", cur->data);
        cur = cur->next;
    }
    printf("哨兵位\n");
}
判断空链表
c 复制代码
bool LTEmpty(LTNode* phead) {
    return phead->next == phead;
}
销毁链表
c 复制代码
void LTDestroy(LTNode* phead) {
    LTNode* cur = phead->next;
    while (cur != phead) {
        LTNode* next = cur->next;
        free(cur);
        cur = next;
    }
    free(phead);
}

三、顺序表和双向链表的对比分析

不同点 顺序表 双向链表(单链表类似)
存储空间 物理上一定连续 逻辑上连续,物理上不一定连续
随机访问 支持 O(1) 不支持,O(N)
任意位置插入删除 需要搬移元素,效率低 O(N) 只需修改指针指向 O(1)
容量管理 需要扩容,可能浪费空间或扩容耗时 无容量概念,按需分配节点
缓存友好性 好(空间局部性强) 差(节点分散,缓存命中率低)
适用场景 元素高效存储 + 频繁随机访问 任意位置频繁插入删除

总结:没有绝对优劣,只有是否适合场景。顺序表适合静态数据、频繁查找;双向链表适合动态数据、频繁增删。实际开发中两者常配合使用。


总结:双向链表通过哨兵位和双向指针,实现了优雅的循环结构和统一的操作逻辑,解决了单链表在删除、反向遍历上的不便。与顺序表相比,双向链表在任意位置插入删除上具有天然优势,但牺牲了随机访问和缓存性能。掌握双向链表是理解更复杂数据结构(如LRU缓存、双向队列)的基础,建议读者动手实现所有接口,并与单链表、顺序表进行对比测试。