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

前言

上一篇讲了单链表,单链表虽然解决了顺序表头部插入删除效率低的问题,但它自身也有痛点:不支持逆向遍历,尾插尾删效率是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容器的重要基础。

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

相关推荐
小七在进步9 小时前
数据结构:堆排序
数据结构
2401_8414956421 小时前
【数据结构】B+树
数据结构·数据库·c++·b+树·概念·结构·操作原理
皓月斯语1 天前
【C++基础】三目运算符
开发语言·数据结构·c++
剑锋所指,所向披靡!1 天前
数据结构之拓扑排序
数据结构
夜不会漫长1 天前
数据结构:链表
数据结构·链表
剑锋所指,所向披靡!1 天前
数据结构之关键路径
数据结构·算法
笨笨饿1 天前
101_详解USB协议
java·jvm·数据结构
J-Tony111 天前
【Redis】数据结构&&持久化
数据结构·数据库·redis
来一碗刘肉面1 天前
队列的链式实现
数据结构·c++·算法·链表