30、动态数据结构(一):链表的概念与分类

说实话,很多初学者一听到「链表」两个字,心里就开始打鼓。我当年也是这样。数组用得好好的,为什么要搞个链表出来?

其实原因很简单——数组是静态的,一旦定义好大小,想改就麻烦了。你想想看,如果程序运行时数据量不确定,或者需要频繁插入删除,数组就显得很笨重。这时候,链表就派上用场了。

什么是链表?

链表是一种动态数据结构。它的每个元素(我们叫它「节点」)不光存数据,还存着下一个节点的地址。就像寻宝游戏一样,你找到第一个线索,它告诉你第二个线索在哪,第二个又指向第三个……直到最后一个节点指向空。

我习惯把链表想象成一列火车。每节车厢(节点)里装着货物(数据),车厢之间用挂钩(指针)连起来。你可以随时加一节车厢,也可以卸掉一节,非常灵活。

核心概念:链表通过指针将零散的内存块串联起来,不需要连续的内存空间。

链表的分类

链表不是只有一种。根据指针的连接方式,主要分为三类:

1. 单向链表

这是最基础的形式。每个节点只存一个指针,指向下一个节点。最后一个节点的指针指向 NULL。

单向链表的优点是结构简单,实现容易。缺点也很明显——只能从头往后找,不能回头。我在项目中遇到过需要频繁查找前驱节点的场景,单向链表就有点力不从心了。

2. 双向链表

双向链表在节点里多存了一个指针,指向前一个节点。这样你就可以双向遍历了。

你想想看,如果我要删除某个节点,单向链表需要从头遍历找到它的前驱节点,时间复杂度是 O(n)。而双向链表直接通过 prev 指针就能找到,时间复杂度 O(1)。

嗯,这里要注意:双向链表虽然方便,但每个节点多占了一个指针的内存。空间换时间,看你怎么权衡。

3. 循环链表

循环链表是单向链表的一个变种。它的最后一个节点不指向 NULL,而是指向头节点,形成一个环。

我曾经在实现一个任务调度器时用过循环链表。每个任务轮流执行,执行完一个就移到下一个,周而复始。用循环链表实现这种「轮转」逻辑,简直不要太顺手。

小技巧:判断循环链表是否遍历完一圈,可以看当前节点是否等于头节点。但要注意处理空链表的情况。

节点的定义

在 C 语言里,链表节点通常用结构体来定义。我习惯这样写:

// 单向链表节点
struct ListNode {
    int data;               // 数据域
    struct ListNode *next;  // 指针域,指向下一个节点
};

// 双向链表节点
struct DListNode {
    int data;                // 数据域
    struct DListNode *prev;  // 指向前一个节点
    struct DListNode *next;  // 指向后一个节点
};

为什么用 struct ListNode *next 而不是 ListNode *next?因为在 C 语言里,结构体还没定义完的时候,你不能直接用别名。用 struct 关键字是标准写法。

避坑指南:我曾经在定义节点时忘记写 struct 关键字,编译器报了一堆错误。后来养成了习惯,定义链表节点时一定带上 struct,或者用 typedef 取别名。

用 typedef 取别名后,代码会更简洁:

typedef struct ListNode {
    int data;
    struct ListNode *next;
} ListNode;

// 现在可以直接用 ListNode 了
ListNode *head = NULL;

链表的创建

创建链表,说白了就是不断创建节点,然后把它们串起来。我一般用两种方式:头插法和尾插法。

头插法

每次新节点都插在链表头部。新节点的 next 指向原来的头节点,然后更新头指针指向新节点。

ListNode* createByHead(int arr[], int n) {
    ListNode *head = NULL;
    for (int i = 0; i < n; i++) {
        ListNode *newNode = (ListNode*)malloc(sizeof(ListNode));
        newNode->data = arr[i];
        newNode->next = head;  // 新节点指向原来的头
        head = newNode;        // 更新头指针
    }
    return head;
}

头插法有个特点:最后插入的节点在链表最前面。如果你按顺序插入 1、2、3,最终链表是 3→2→1。顺序是反的。

尾插法

尾插法维护一个尾指针,每次新节点都加在链表末尾。这样插入顺序和链表顺序一致。

ListNode* createByTail(int arr[], int n) {
    ListNode *head = NULL, *tail = NULL;
    for (int i = 0; i < n; i++) {
        ListNode *newNode = (ListNode*)malloc(sizeof(ListNode));
        newNode->data = arr[i];
        newNode->next = NULL;
        
        if (head == NULL) {
            head = newNode;   // 第一个节点
            tail = newNode;
        } else {
            tail->next = newNode;  // 尾节点指向新节点
            tail = newNode;        // 更新尾指针
        }
    }
    return head;
}

我个人更推荐尾插法,因为顺序更符合直觉。但头插法在某些场景下也有优势,比如实现栈的时候。

链表的遍历

遍历链表很简单,从头节点开始,沿着 next 指针一路走,直到遇到 NULL。

void printList(ListNode *head) {
    ListNode *cur = head;
    while (cur != NULL) {
        printf("%d ", cur->data);
        cur = cur->next;  // 移动到下一个节点
    }
    printf("\n");
}

这里有个细节:遍历时不要直接移动头指针 head,否则你就找不到链表头了。用一个临时指针 cur 来遍历,这是基本操作。

经验之谈:我刚开始写链表时,经常在遍历时把 head 弄丢了。后来养成了习惯——永远不要动 head 指针,除非你明确知道自己在做什么。

链表 vs 数组

为了让你更直观地理解链表和数组的区别,我整理了一个对比表:

对比项 数组 链表
内存空间 连续 不连续
大小调整 固定,不易扩展 动态,灵活
随机访问 O(1),直接下标 O(n),需要遍历
插入/删除 O(n),需要移动元素 O(1),只需改指针
额外内存 每个节点多一个指针

说白了,数组适合「读多写少」的场景,链表适合「写多读少」的场景。没有绝对的好坏,看需求。

知识体系总览

下面这张图帮你梳理了本章的核心知识点:

链表知识体系 链表 单向链表 双向链表 循环链表 核心操作 创建(头插/尾插) 遍历 插入 / 删除 节点定义:struct + 数据域 + 指针域

这张图把链表的核心脉络都串起来了。从分类到操作,再到节点定义,每一步都环环相扣。你把这个图记在脑子里,链表的基本功就算打牢了。

链表是动态数据结构的入门,也是面试的高频考点。单向链表、双向链表、循环链表,这三种形式你都要能随手写出来。节点的定义、链表的创建和遍历,这些基础操作要练到肌肉记忆。

嗯,这一章的内容就到这里。链表的世界才刚刚打开,后面还有插入、删除、反转等操作等着你。慢慢来,把基础打扎实了,后面的路就好走了。


专注资料整理