数据结构之双向链表

一、双向链表核心概念

1.1 双向链表的结构特点

  • 每个节点包含三部分:数据域 (存储实际数据)、前驱指针 prev (指向当前节点的前一个节点)、后继指针 next(指向当前节点的后一个节点)。
  • 支持两种遍历方式:从头到尾(通过 next 指针)、从尾到头(通过 prev 指针)。
  • 插入 / 删除操作时,需同时维护 prev 和 next 指针的指向,确保链表的完整性。

1.2 核心设计思路

本文实现的双向链表支持自定义数据类型(以学生信息为例),包含以下核心功能:

  • 链表初始化与销毁
  • 头部 / 尾部 / 指定位置插入
  • 从头到尾 / 从尾到头遍历
  • 按关键字查找、修改、删除
  • 链表反转
  • 判空、获取长度

二、头文件定义(doulink.h)

首先定义链表的节点结构、链表管理结构及相关枚举 / 宏定义,统一封装到头文件中,方便工程复用。

复制代码
#ifndef DOULINK_H
#define DOULINK_H

// 自定义数据类型:学生信息(可根据需求修改)
typedef struct {
    char name[20];  // 姓名
    int age;        // 年龄
    char sex;       // 性别('M'男/'F'女)
    int score;      // 成绩
} DATATYPE;

// 双向链表节点结构
typedef struct DouLinkNode {
    DATATYPE data;          // 数据域
    struct DouLinkNode* prev; // 前驱指针
    struct DouLinkNode* next; // 后继指针
} DouLinkNode;

// 链表管理结构(存储链表头节点和长度)
typedef struct {
    DouLinkNode* head;  // 头节点指针
    int clen;           // 链表实际节点个数
} DouLinkList;

// 遍历方向枚举
typedef enum {
    SHOW_FORWARD = 0,  // 从头到尾
    SHOW_BACKWARD      // 从尾到头
} SHOW_DIR;

// 函数声明
// 1. 创建双向链表(初始化)
DouLinkList* CreateDouLinkList();

// 2. 头部插入节点
int InsertHeadDouLinkList(DouLinkList* dl, DATATYPE* data);

// 3. 尾部插入节点
int InsertTailDouLinkList(DouLinkList* dl, DATATYPE* data);

// 4. 指定位置插入节点(pos从0开始)
int InsertPosDouLinkList(DouLinkList* dl, DATATYPE* data, int pos);

// 5. 遍历链表
int ShowDoulinkList(DouLinkList* dl, SHOW_DIR dir);

// 6. 按姓名查找数据(返回数据地址)
DATATYPE* FindDouLinkList(DouLinkList* dl, char* name);

// 7. 按姓名修改数据
int ModifyDouLinkList(DouLinkList* dl, char* name, DATATYPE* newdata);

// 8. 按姓名删除节点
int DeleteDoulinkList(DouLinkList* dl, char* name);

// 9. 反转链表
int ReverseDouLinkList(DouLinkList* dl);

// 10. 获取链表长度
int GetsizeDouLinkList(DouLinkList* dl);

// 11. 判断链表是否为空
int IsEmptyDouLinkList(DouLinkList* dl);

// 12. 销毁链表(释放内存)
int DestroyDouLinkList(DouLinkList** dl);

// 内部辅助函数:按姓名查找节点(返回节点指针)
static DouLinkNode* FindDouLinkList2(DouLinkList* dl, char* name);

#endif // DOULINK_H

三、核心功能实现(doulink.c)

基于头文件的声明,实现双向链表的所有功能,重点关注指针操作的正确性和内存安全。

3.1 链表初始化(CreateDouLinkList)

创建链表管理结构,初始化头节点为 NULL,长度为 0。

复制代码
#include "doulink.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

DouLinkList* CreateDouLinkList() {
    DouLinkList* dl = (DouLinkList*)malloc(sizeof(DouLinkList));
    if (NULL == dl) {
        printf("CreateDouLinkList: 内存分配失败!\n");
        return NULL;
    }
    dl->head = NULL;  // 初始时无节点,头指针为NULL
    dl->clen = 0;     // 链表长度为0
    return dl;
}

3.2 节点插入操作

插入操作是链表的核心,需处理头部、尾部、指定位置三种场景,确保指针指向正确。

3.2.1 头部插入(InsertHeadDouLinkList)

新节点成为新的头节点,原头节点的 prev 指针指向新节点。

复制代码
int InsertHeadDouLinkList(DouLinkList* dl, DATATYPE* data) {
    if (dl == NULL || data == NULL) {
        printf("InsertHeadDouLinkList: 参数非法!\n");
        return 1;
    }

    // 创建新节点
    DouLinkNode* newnode = (DouLinkNode*)malloc(sizeof(DouLinkNode));
    if (NULL == newnode) {
        printf("InsertHeadDouLinkList: 节点内存分配失败!\n");
        return 1;
    }

    // 复制数据到新节点
    memcpy(&newnode->data, data, sizeof(DATATYPE));
    newnode->prev = NULL;  // 头部节点的前驱为NULL
    newnode->next = NULL;

    // 新节点成为头节点
    newnode->next = dl->head;
    if (NULL != dl->head) {  // 若原链表非空,原头节点的prev指向新节点
        dl->head->prev = newnode;
    }
    dl->head = newnode;
    dl->clen++;  // 长度+1

    return 0;
}
3.2.2 尾部插入(InsertTailDouLinkList)

遍历到链表尾部,新节点的 prev 指向尾节点,尾节点的 next 指向新节点。

复制代码
int InsertTailDouLinkList(DouLinkList* dl, DATATYPE* data) {
    if (dl == NULL || data == NULL) {
        printf("InsertTailDouLinkList: 参数非法!\n");
        return 1;
    }

    // 若链表为空,直接调用头部插入
    if (IsEmptyDouLinkList(dl)) {
        return InsertHeadDouLinkList(dl, data);
    }

    // 创建新节点
    DouLinkNode* newnode = (DouLinkNode*)malloc(sizeof(DouLinkNode));
    if (NULL == newnode) {
        printf("InsertTailDouLinkList: 节点内存分配失败!\n");
        return 1;
    }
    memcpy(&newnode->data, data, sizeof(DATATYPE));
    newnode->prev = NULL;
    newnode->next = NULL;

    // 遍历到尾部节点
    DouLinkNode* tmp = dl->head;
    while (tmp->next != NULL) {
        tmp = tmp->next;
    }

    // 链接新节点
    newnode->prev = tmp;
    tmp->next = newnode;
    dl->clen++;  // 长度+1

    return 0;
}
3.2.3 指定位置插入(InsertPosDouLinkList)

先遍历到目标位置的前驱节点,再插入新节点,同时维护前后节点的指针。

复制代码
int InsertPosDouLinkList(DouLinkList* dl, DATATYPE* data, int pos) {
    if (dl == NULL || data == NULL) {
        printf("InsertPosDouLinkList: 参数非法!\n");
        return 1;
    }

    int size = GetsizeDouLinkList(dl);
    // 检查位置合法性(pos范围:0~size)
    if (pos < 0 || pos > size) {
        printf("InsertPosDouLinkList: 插入位置越界!\n");
        return 1;
    }

    // 特殊场景:头部插入
    if (pos == 0) {
        return InsertHeadDouLinkList(dl, data);
    }
    // 特殊场景:尾部插入
    if (pos == size) {
        return InsertTailDouLinkList(dl, data);
    }

    // 遍历到pos的前驱节点(pos-1位置)
    DouLinkNode* tmp = dl->head;
    for (int i = 0; i < pos - 1; i++) {
        tmp = tmp->next;
    }

    // 创建新节点
    DouLinkNode* newnode = (DouLinkNode*)malloc(sizeof(DouLinkNode));
    if (NULL == newnode) {
        printf("InsertPosDouLinkList: 节点内存分配失败!\n");
        return 1;
    }
    memcpy(&newnode->data, data, sizeof(DATATYPE));
    newnode->prev = NULL;
    newnode->next = NULL;

    // 插入新节点:维护四个指针
    newnode->prev = tmp;
    newnode->next = tmp->next;
    tmp->next->prev = newnode;
    tmp->next = newnode;

    dl->clen++;  // 长度+1
    return 0;
}

3.3 链表遍历(ShowDoulinkList)

支持双向遍历:从头到尾(通过 next 指针)、从尾到头(先找到尾节点,再通过 prev 指针)。

复制代码
int ShowDoulinkList(DouLinkList* dl, SHOW_DIR dir) {
    if (dl == NULL || IsEmptyDouLinkList(dl)) {
        printf("ShowDoulinkList: 链表为空!\n");
        return 1;
    }

    DouLinkNode* tmp = dl->head;
    if (SHOW_FORWARD == dir) {
        printf("=== 从头到尾遍历链表 ===\n");
        while (tmp != NULL) {
            printf("姓名:%s | 年龄:%d | 性别:%c | 成绩:%d\n",
                   tmp->data.name, tmp->data.age,
                   tmp->data.sex, tmp->data.score);
            tmp = tmp->next;
        }
    } else {
        printf("=== 从尾到头遍历链表 ===\n");
        // 先遍历到尾节点
        while (tmp->next != NULL) {
            tmp = tmp->next;
        }
        // 从尾节点反向遍历
        while (tmp != NULL) {
            printf("姓名:%s | 年龄:%d | 性别:%c | 成绩:%d\n",
                   tmp->data.name, tmp->data.age,
                   tmp->data.sex, tmp->data.score);
            tmp = tmp->prev;
        }
    }
    printf("\n");
    return 0;
}

3.4 查找与修改操作

提供两个查找函数:一个返回数据地址(供外部使用),一个返回节点指针(供内部删除 / 修改使用)。

复制代码
// 外部接口:返回数据地址
DATATYPE* FindDouLinkList(DouLinkList* dl, char* name) {
    if (dl == NULL || name == NULL || IsEmptyDouLinkList(dl)) {
        return NULL;
    }

    DouLinkNode* tmp = FindDouLinkList2(dl, name);
    return tmp ? &tmp->data : NULL;
}

// 内部辅助函数:返回节点指针(static限制作用域)
static DouLinkNode* FindDouLinkList2(DouLinkList* dl, char* name) {
    if (dl == NULL || name == NULL || IsEmptyDouLinkList(dl)) {
        return NULL;
    }

    DouLinkNode* tmp = dl->head;
    // 遍历链表匹配姓名(strcmp比较字符串)
    while (tmp != NULL) {
        if (0 == strcmp(tmp->data.name, name)) {
            return tmp;  // 找到返回节点指针
        }
        tmp = tmp->next;
    }
    return NULL;  // 未找到返回NULL
}
3.4.2 按姓名修改(ModifyDouLinkList)

先通过查找函数找到目标数据,再用 memcpy 覆盖修改。

复制代码
int ModifyDouLinkList(DouLinkList* dl, char* name, DATATYPE* newdata) {
    if (dl == NULL || name == NULL || newdata == NULL) {
        printf("ModifyDouLinkList: 参数非法!\n");
        return 1;
    }

    DATATYPE* olddata = FindDouLinkList(dl, name);
    if (NULL == olddata) {
        printf("ModifyDouLinkList: 未找到姓名为【%s】的节点!\n", name);
        return 1;
    }

    // 覆盖旧数据
    memcpy(olddata, newdata, sizeof(DATATYPE));
    printf("ModifyDouLinkList: 节点修改成功!\n");
    return 0;
}

3.5 节点删除(DeleteDoulinkList)

删除操作需处理三种场景:头节点、尾节点、中间节点,删除后释放节点内存,避免内存泄漏。

复制代码
int DeleteDoulinkList(DouLinkList* dl, char* name) {
    if (dl == NULL || name == NULL || IsEmptyDouLinkList(dl)) {
        printf("DeleteDoulinkList: 参数非法或链表为空!\n");
        return 1;
    }

    // 找到要删除的节点
    DouLinkNode* tmp = FindDouLinkList2(dl, name);
    if (NULL == tmp) {
        printf("DeleteDoulinkList: 未找到姓名为【%s】的节点!\n", name);
        return 1;
    }

    // 场景1:删除头节点
    if (tmp == dl->head) {
        dl->head = dl->head->next;  // 新头节点为原头节点的下一个
        if (dl->head != NULL) {     // 若链表非空,新头节点的prev置为NULL
            dl->head->prev = NULL;
        }
    }
    // 场景2:删除尾节点
    else if (tmp->next == NULL) {
        tmp->prev->next = NULL;  // 尾节点的前驱节点next置为NULL
    }
    // 场景3:删除中间节点
    else {
        tmp->prev->next = tmp->next;  // 前驱节点的next指向后继节点
        tmp->next->prev = tmp->prev;  // 后继节点的prev指向前驱节点
    }

    // 释放删除节点的内存
    free(tmp);
    tmp = NULL;
    dl->clen--;  // 长度-1
    printf("DeleteDoulinkList: 节点删除成功!\n");
    return 0;
}

3.6 链表反转(ReverseDouLinkList)

通过调整每个节点的 prev 和 next 指针方向,实现链表反转,时间复杂度 O (n)。

复制代码
int ReverseDouLinkList(DouLinkList* dl) {
    if (dl == NULL || GetsizeDouLinkList(dl) < 2) {
        printf("ReverseDouLinkList: 链表为空或节点数小于2,无需反转!\n");
        return 1;
    }

    DouLinkNode* curr = dl->head;  // 当前节点
    DouLinkNode* prev = NULL;      // 前驱节点(初始为NULL)
    DouLinkNode* next = NULL;      // 后继节点(临时存储)

    while (curr != NULL) {
        next = curr->next;  // 保存当前节点的下一个节点
        curr->next = prev;  // 反转当前节点的next指针(指向原前驱)
        curr->prev = next;  // 反转当前节点的prev指针(指向原后继)
        prev = curr;        // 前驱节点后移
        curr = next;        // 当前节点后移
    }

    dl->head = prev;  // 反转后,原尾节点成为新头节点
    printf("ReverseDouLinkList: 链表反转成功!\n");
    return 0;
}

3.7 链表销毁(DestroyDouLinkList)

遍历所有节点,逐个释放内存,最后释放链表管理结构,避免内存泄漏。

复制代码
int DestroyDouLinkList(DouLinkList** dl) {
    if (dl == NULL || *dl == NULL) {
        printf("DestroyDouLinkList: 链表已销毁或参数非法!\n");
        return 1;
    }

    DouLinkNode* tmp = (*dl)->head;
    DouLinkNode* next = NULL;

    // 逐个释放节点内存
    while (tmp != NULL) {
        next = tmp->next;
        free(tmp);
        tmp = next;
    }

    // 释放链表管理结构
    free(*dl);
    *dl = NULL;  // 置为NULL,避免野指针
    printf("DestroyDouLinkList: 链表销毁成功!\n");
    return 0;
}

3.8 辅助函数(Getsize / IsEmpty)

复制代码
// 获取链表长度
int GetsizeDouLinkList(DouLinkList* dl) {
    return (dl != NULL) ? dl->clen : 0;
}

// 判断链表是否为空
int IsEmptyDouLinkList(DouLinkList* dl) {
    return (dl != NULL && dl->clen == 0) ? 1 : 0;
}

四、测试用例(main.c)

编写测试代码验证所有功能的正确性:

复制代码
#include "doulink.h"
#include <stdio.h>

int main() {
    // 1. 创建链表
    DouLinkList* dl = CreateDouLinkList();
    if (NULL == dl) {
        return 1;
    }

    // 2. 定义测试数据
    DATATYPE stu1 = {"张三", 18, 'M', 90};
    DATATYPE stu2 = {"李四", 19, 'M', 85};
    DATATYPE stu3 = {"王五", 20, 'F', 95};
    DATATYPE stu4 = {"赵六", 17, 'M', 88};
    DATATYPE newStu = {"张三", 18, 'M', 98};  // 修改后的张三数据

    // 3. 尾部插入
    InsertTailDouLinkList(dl, &stu1);
    InsertTailDouLinkList(dl, &stu2);
    InsertTailDouLinkList(dl, &stu3);
    printf("=== 尾部插入3个节点后 ===\n");
    ShowDoulinkList(dl, SHOW_FORWARD);

    // 4. 头部插入
    InsertHeadDouLinkList(dl, &stu4);
    printf("=== 头部插入赵六后 ===\n");
    ShowDoulinkList(dl, SHOW_FORWARD);

    // 5. 指定位置插入(pos=2,插入到李四和王五之间)
    DATATYPE stu5 = {"孙七", 19, 'F', 92};
    InsertPosDouLinkList(dl, &stu5, 2);
    printf("=== 位置2插入孙七后 ===\n");
    ShowDoulinkList(dl, SHOW_FORWARD);

    // 6. 查找节点(查找张三)
    DATATYPE* findRes = FindDouLinkList(dl, "张三");
    if (findRes != NULL) {
        printf("=== 查找张三 ===\n");
        printf("姓名:%s | 年龄:%d | 成绩:%d\n\n",
               findRes->name, findRes->age, findRes->score);
    }

    // 7. 修改节点(修改张三的成绩为98)
    ModifyDouLinkList(dl, "张三", &newStu);
    printf("=== 修改张三成绩后 ===\n");
    ShowDoulinkList(dl, SHOW_FORWARD);

    // 8. 反转链表
    ReverseDouLinkList(dl);
    printf("=== 链表反转后 ===\n");
    ShowDoulinkList(dl, SHOW_FORWARD);

    // 9. 从尾到头遍历
    printf("=== 从尾到头遍历 ===\n");
    ShowDoulinkList(dl, SHOW_BACKWARD);

    // 10. 删除节点(删除李四)
    DeleteDoulinkList(dl, "李四");
    printf("=== 删除李四后 ===\n");
    ShowDoulinkList(dl, SHOW_FORWARD);

    // 11. 销毁链表
    DestroyDouLinkList(&dl);
    ShowDoulinkList(dl, SHOW_FORWARD);  // 此时链表已空

    return 0;
}

五、双向链表的优点与缺点分析

5.1 优点

  1. 插入删除效率高:头部、尾部插入 / 删除操作时间复杂度为 O (1),无需移动大量元素(数组需移动后续元素);指定位置插入 / 删除仅需遍历到目标位置(O (n)),核心指针调整操作仍为 O (1),整体效率优于数组。

  2. 支持双向遍历:借助 prev 指针可直接从尾到头遍历,无需像单链表那样从头节点重新遍历,适合需要反向访问数据的场景(如日志回溯、双向迭代器)。

  3. 内存利用率灵活:链表节点动态分配内存,无需提前预留连续空间,可根据实际数据量动态扩展,避免数组的内存浪费或溢出问题。

  4. 功能扩展性强:本文实现支持自定义数据类型,可轻松扩展为存储 int、string 或复杂结构体的数据结构;同时支持查找、修改、反转等丰富操作,满足多数线性存储场景需求。

  5. 边界处理清晰:通过链表长度计数器(clen)简化判空、越界检查,插入 / 删除时明确处理头节点、尾节点、中间节点三种场景,逻辑严谨。

5.2 缺点

  1. 随机访问效率低:无法像数组那样通过索引直接访问元素,查找、指定位置插入等操作需遍历链表(时间复杂度 O (n)),数据量较大时效率下降明显。

  2. 额外内存开销:每个节点需额外存储 prev 和 next 两个指针,相比数组(仅存储数据)占用更多内存,数据量极大时内存开销不可忽视。

  3. 指针操作复杂:插入、删除、反转等操作需同时维护 prev 和 next 指针,若逻辑处理不当易出现野指针、链表断裂等问题,调试难度高于数组。

  4. 缓存局部性差:数组元素存储在连续内存中,CPU 缓存命中率高;而链表节点分散在堆内存中,缓存无法有效预加载,频繁访问时性能不如数组。

  5. 不支持快速排序:链表缺乏随机访问能力,无法高效实现基于索引的排序算法(如快速排序),排序效率通常低于数组。

六、注意事项与优化建议

6.1 注意事项

  1. 指针合法性:所有操作前需检查指针是否为 NULL(如链表、节点、数据),避免野指针访问导致程序崩溃。
  2. 内存管理:插入节点时分配内存,删除 / 销毁时必须释放内存,否则会导致内存泄漏,长期运行可能引发内存溢出。
  3. 边界处理:插入位置越界、删除空链表、反转节点数小于 2 等异常场景需特殊处理,避免逻辑错误。
  4. 字符串比较 :使用strcmp比较字符串(不能直接用==判断地址),需确保包含<string.h>头文件。
  5. 线程安全 :当前实现未考虑多线程场景,若在并发环境中使用,需添加互斥锁(pthread_mutex_t)保护链表操作,避免数据竞争。

6.2 优化建议

  1. 引入哨兵节点:当前实现中头节点为实际数据节点,可添加头哨兵和尾哨兵(不存储数据),简化插入 / 删除时的边界判断(无需检查头节点是否为 NULL)。

  2. 优化查找效率:若需频繁按关键字查找,可引入哈希表辅助存储节点指针,将查找时间复杂度优化至 O (1);或实现有序链表(插入时排序),支持二分查找(O (log n))。

  3. 增加排序功能:扩展按数据域排序的功能(如按成绩升序 / 降序),实现冒泡排序或归并排序(链表归并排序时间复杂度 O (n log n),空间复杂度 O (1))。

  4. 内存池优化 :频繁创建 / 销毁节点时,可使用内存池预分配节点内存,减少malloc/free的开销,提升性能。

  5. 支持批量操作:增加批量插入、批量删除功能,减少循环中的重复判断,提升大数据量场景下的处理效率。

七、总结

本文基于 C 语言实现了一个功能完整的双向链表,涵盖初始化、插入、遍历、查找、修改、删除、反转、销毁等核心操作,通过自定义学生信息数据类型展示了链表的灵活性。双向链表的核心优势在于插入删除高效和支持双向遍历,适合数据动态变化、需频繁增删的场景(如消息队列、缓存队列、编辑器撤销栈等);但其随机访问效率低、指针操作复杂的缺点也限制了部分场景的应用。

相关推荐
秋深枫叶红1 小时前
嵌入式第二十六篇——数据结构双向链表
c语言·数据结构·学习·链表
_OP_CHEN1 小时前
【算法基础篇】(二十三)数据结构之并查集基础:从原理到实战,一篇吃透!
数据结构·算法·蓝桥杯·并查集·算法竞赛·acm/icpc·双亲表示法
liu****1 小时前
10.指针详解(六)
c语言·开发语言·数据结构·c++·算法
CQ_YM1 小时前
数据结构概念与顺序表
数据结构·算法·线性表
hweiyu001 小时前
数据结构:集合
数据结构
阿沁QWQ1 小时前
list模拟实现
数据结构·list
dragoooon343 小时前
[优选算法专题九.链表 ——NO.53~54合并 K 个升序链表、 K 个一组翻转链表]
数据结构·算法·链表
松涛和鸣3 小时前
22、双向链表作业实现与GDB调试实战
c语言·开发语言·网络·数据结构·链表·排序算法
云里雾里!10 小时前
力扣 209. 长度最小的子数组:滑动窗口解法完整解析
数据结构·算法·leetcode