数据结构篇(四):线性表——链表——双链表

前言

上一篇讲了单链表,单链表虽然解决了顺序表头部插入删除效率低的问题,但它自身也有痛点:不支持逆向遍历,尾插尾删效率是O(N),很多操作都要先找前驱节点。为了解决这些问题,带头双向循环链表 登场了------它是链表结构中最复杂,但实际工程中最常用的一种,C++ STL中的list底层就是它。本文将系统讲解它的结构与实现。


一、什么是双链表

双链表 (Doubly Linked List)的每个节点除了数据域 ,还有两个指针域 :一个指向前一个节点(prev),一个指向后一个节点(next)。

本文实现的是带头双向循环链表,它有三个关键特征:

  • 带头:有一个不存储有效数据的哨兵头节点(哨兵位),头节点永远存在,即使链表为空;
  • 双向:每个节点既能找到前驱,也能找到后继;
  • 循环 :最后一个节点的next指向头节点,头节点的prev指向最后一个节点,形成一个环。

带头双向循环链表看起来结构复杂,但正因为头节点永远存在,所以插入删除时不需要对"链表是否为空"做特殊判断,代码反而比单链表更简单统一,这是它的一大优势。


二、双链表的结构定义

cpp 复制代码
​typedef int LTDataType;

typedef struct ListNode {
    LTDataType data;         // 数据域
    struct ListNode* prev;   // 指向前一个节点
    struct ListNode* next;   // 指向后一个节点
} ListNode;

由于是带头循环结构,整个链表只需要一个头节点指针 即可代表,不再需要像单链表那样用二级指针传参


三、双链表的基本操作

3.1 创建新节点

cpp 复制代码
ListNode* BuyListNode(LTDataType x) {
    ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
    if (newNode == NULL) {
        perror("malloc fail");
        exit(-1);
    }
    newNode->data = x;
    newNode->prev = NULL;
    newNode->next = NULL;
    return newNode;
}

3.2 初始化(创建带头节点的空链表)

cpp 复制代码
ListNode* ListInit() {
    ListNode* head = BuyListNode(0); // 哨兵位,data值无意义
    head->next = head;
    head->prev = head;
    return head;
}

3.3 头插

cpp 复制代码
void ListPushFront(ListNode* phead, LTDataType x) {
    assert(phead != NULL);

    ListNode* newNode = BuyListNode(x);
    ListNode* first = phead->next;

    // 新节点插入头节点和原第一个节点之间
    phead->next = newNode;
    newNode->prev = phead;
    newNode->next = first;
    first->prev = newNode;
}

3.4 头删

cpp 复制代码
void ListPopFront(ListNode* phead) {
    assert(phead != NULL);
    assert(phead->next != phead); // 链表不能为空

    ListNode* first = phead->next;
    ListNode* second = first->next;

    phead->next = second;
    second->prev = phead;
    free(first);
}

3.5 尾插

得益于循环结构,phead->prev永远指向最后一个节点 ,所以尾插不需要遍历,直接是O(1)------这是双链表相比单链表最大的效率提升。

cpp 复制代码
void ListPushBack(ListNode* phead, LTDataType x) {
    assert(phead != NULL);

    ListNode* newNode = BuyListNode(x);
    ListNode* last = phead->prev;

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

3.6 尾删

cpp 复制代码
void ListPopBack(ListNode* phead) {
    assert(phead != NULL);
    assert(phead->next != phead);

    ListNode* last = phead->prev;
    ListNode* newLast = last->prev;

    newLast->next = phead;
    phead->prev = newLast;
    free(last);
}

3.7 查找

cpp 复制代码
ListNode* ListFind(ListNode* phead, LTDataType x) {
    assert(phead != NULL);

    ListNode* cur = phead->next;
    while (cur != phead) {
        if (cur->data == x) {
            return cur;
        }
        cur = cur->next;
    }
    return NULL; // 没找到
}

3.8 在指定位置之前/之后插入

有了prev指针,双链表可以在O(1)时间内完成任意已知位置的插入,不再需要像单链表那样遍历找前驱。

cpp 复制代码
// 在pos之前插入x(O(1))
void ListInsert(ListNode* pos, LTDataType x) {
    assert(pos != NULL);

    ListNode* newNode = BuyListNode(x);
    ListNode* prev = pos->prev;

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

// 在pos之后插入x(O(1))
void ListInsertAfter(ListNode* pos, LTDataType x) {
    assert(pos != NULL);

    ListNode* newNode = BuyListNode(x);
    ListNode* next = pos->next;

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

3.9 删除指定位置节点

cpp 复制代码
void ListErase(ListNode* pos) {
    assert(pos != NULL);

    ListNode* prev = pos->prev;
    ListNode* next = pos->next;

    prev->next = next;
    next->prev = prev;
    free(pos);
}

3.10 打印链表

cpp 复制代码
void ListPrint(ListNode* phead) {
    assert(phead != NULL);

    printf("head <-> ");
    ListNode* cur = phead->next;
    while (cur != phead) {
        printf("%d <-> ", cur->data);
        cur = cur->next;
    }
    printf("head\n");
}

3.11 销毁链表

cpp 复制代码
void ListDestroy(ListNode* phead) {
    assert(phead != NULL);

    ListNode* cur = phead->next;
    while (cur != phead) {
        ListNode* next = cur->next;
        free(cur);
        cur = next;
    }
    free(phead); // 别忘了释放头节点自己
}

四、完整测试代码

cpp 复制代码
int main() {
    ListNode* plist = ListInit();

    ListPushBack(plist, 1);
    ListPushBack(plist, 2);
    ListPushBack(plist, 3);
    ListPrint(plist); // head <-> 1 <-> 2 <-> 3 <-> head

    ListPushFront(plist, 0);
    ListPrint(plist); // head <-> 0 <-> 1 <-> 2 <-> 3 <-> head

    ListNode* pos = ListFind(plist, 2);
    if (pos) {
        ListInsert(pos, 100);
    }
    ListPrint(plist); // head <-> 0 <-> 1 <-> 100 <-> 2 <-> 3 <-> head

    ListPopFront(plist);
    ListPopBack(plist);
    ListPrint(plist); // head <-> 1 <-> 100 <-> 2 <-> head

    ListDestroy(plist);
    return 0;
}

五、时间复杂度分析

操作 时间复杂度 说明
头插/头删 O(1) 直接操作头节点
尾插/尾删 O(1) prev指针,无需遍历
指定位置插入/删除 O(1) 已知位置即可直接操作
查找 O(N) 仍需遍历
随机访问(下标) O(N) 不支持真正的随机访问

可以看到,除了查找和随机访问,双链表的增删操作全部是O(1),这是它相比单链表和顺序表最大的优势。


六、双链表 vs 单链表 vs 顺序表

特性 顺序表 单链表 双链表(带头循环)
存储方式 物理地址连续 物理地址不连续 物理地址不连续
随机访问 O(1) O(N) O(N)
头部插入删除 O(N) O(1) O(1)
尾部插入删除 O(1)(均摊) O(N) O(1)
任意位置插入删除 O(N) O(N)(需找前驱) O(1)(已知位置)
逆向遍历 支持 不支持 支持
空指针判断 不需要 需要频繁判断 头节点恒存在,几乎不需要
空间开销 可能有扩容冗余 一个指针 两个指针

可以看出,双链表几乎在所有增删操作上都做到了O(1) ,代价是每个节点多了一个prev指针的空间开销(空间换时间) ,以及实现相对更复杂。这也是为什么STL选择用带头双向循环链表实现list------用少量的额外空间换取了全方位的高效增删。


七、总结

双链表,尤其是带头双向循环链表,是链表结构的"完全体":

  • 因为带头,插入删除不用特判链表是否为空;
  • 因为双向,可以O(1)找到任意节点的前驱,也支持逆向遍历;
  • 因为循环phead->prev天然就是尾节点,尾插尾删也能做到O(1)。

理解了双链表的实现原理,再回头看STL的listunordered_map的哈希桶等结构会更加得心应手。

链表和顺序表是线性表的两种典型实现方式,二者各有优劣,没有绝对的孰优孰劣,需要根据实际的业务场景(是否频繁随机访问、是否频繁增删、数据规模是否已知)来做选择。理解透单链表的指针操作,尤其是二级指针的使用、边界条件的处理,是后续学习双向链表、栈、队列乃至STL中list容器的重要基础。

如果这篇文章对你有帮助,欢迎点赞收藏,后续会继续更新栈、队列、二叉树等数据结构内容!

相关推荐
AI情绪识别开源1 天前
检信 ALLEMOTION OS 加密打包可执行程序 — 全面测试报告版本: v1.3功能测试 / 性能测试 /
开发语言·数据结构·人工智能·功能测试
伟大的车尔尼1 天前
贪心的概念
数据结构·算法·贪心
AI情绪识别开源1 天前
检信AI高一分班分科智选评估系统 v2.0测试报告
开发语言·数据结构·人工智能·科技
CoderYanger1 天前
A.每日一题:输入单词需要的最少按键次数 Ⅰ+Ⅱ
java·数据结构·算法·leetcode·面试
雪碧聊技术1 天前
KMP算法详解
数据结构
positive_zpc1 天前
进阶数据结构图——关键路径(四)
数据结构·图论·关键路径
孙克旭_1 天前
单链表进阶实操:5 道常考面试题详细解析【Java 实现】
java·开发语言·数据结构·单链表
Chester_19992 天前
CSP202206C.角色授权
开发语言·数据结构·c++·蓝桥杯
旖旎夜光2 天前
LeetCode 238:除自身以外数组的乘积(前缀和) —— 题解
数据结构·c++·算法·leetcode·前缀和
一条大祥脚2 天前
26杭电暑期第八场(后半)快读|快写|tarjan|路径DP|mex转化|扫描线|前缀和|二分图
数据结构·算法·tarjan·杭电多校·强联通分量·动态规划dp