【C语言-算法-链表】5. 进阶的数据结构基础——链表(指针的终极试炼)

开发技术 作者: 精灵星空 SKY315.COM 更新于: 浏览: 307
码字发文不易,转载请注明出处【精灵星空 SKY315.COM】

C语言在线编程环境:https://sky315.com:8080/c/。

前面咱们聊了数组、排序、字符串,这些都是C语言里的"基础款"。但说句实在话,这些内容学完之后,你对C语言的理解可能还停留在"用数组存数据、用循环处理数据"这个层面。真正让你对指针"开窍"的,是链表。

为啥这么说?因为链表强迫你真正理解"地址"这两个字是什么意思。数组是连续的内存块,你通过下标来访问,本质上是在做"基地址+偏移量"的运算。但链表不一样,它的每个节点都是独立分配的,散落在内存的各个角落,靠指针把它们串起来。你操作链表的时候,每一步都在跟地址打交道。

很多同学学到链表就开始懵了——struct Node *next是个什么东西?p = p->next到底指向了哪里?malloc出来的内存怎么释放?别急,今天咱们从头到尾捋一遍,从最基础的定义开始,把链表的创建、遍历、插入、删除挨个拆一遍。你跟着代码一步一步走,把每一行执行完内存里发生了什么想清楚,链表这关就过了。

一、先搞清楚一个问题:为啥要用链表?

在讲链表之前,你得先明白数组有啥毛病。

数组的优点是:连续存储、随机访问快(通过下标O(1)就能找到)。但它的缺点也很要命:

  • 大小固定。你定义了int a[100],就只能放100个数。如果来了第101个,放不下。

  • 插入和删除效率低。在数组中间插入一个元素,后面的所有元素都得往后挪;删除一个元素,后面的所有元素都得往前挪。数据量大的时候,这个开销很吓人。

链表就是来解决这些问题的。链表的特点是:

  • 大小不固定。需要多少就申请多少,随时可以增加或减少节点。

  • 插入和删除只需要改指针,不用挪动数据。

  • 代价是访问慢。想找第10个节点?得从头开始一个一个往后数,不能像数组那样直接下标定位。

所以数组和链表各有各的适用场景,没有谁比谁绝对好。

链表在内存里长啥样?想象一下:你有一堆小纸片(节点),每张纸片上写着数据,还有一行写着"下一张纸片放在哪里"(指针)。这些纸片不需要放在一起,你只要知道第一张纸片在哪儿,就能顺着"下一张在哪儿"的指引,一张一张找到所有纸片。

二、链表的节点长啥样?——结构体套指针

C语言里实现链表,靠的是结构体,结构体里有一个成员是指向同类型结构体的指针。

定义方式:

struct Node {
    int data;           // 存数据,这里以整数为例
    struct Node *next;  // 指向下一个节点的指针
};

struct Node *next这行是关键。它在说:我这个节点里存了一个地址,这个地址指向的是另一个struct Node类型的变量。这样就把节点和节点串起来了。

为了方便,通常用typedef给它起个别名,省得每次写struct Node那么长:

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

这样后面你就可以直接写Node *p;,不用写struct Node *p;了。

三、创建一个节点——malloc是你的好兄弟

链表的节点是在程序运行的时候动态创建的,用的是malloc函数。malloc从堆上申请一块内存,返回这块内存的起始地址。

#include <stdio.h>
#include <stdlib.h>   // malloc和free都在这个头文件里

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

// 创建一个新节点
Node* create_node(int value) {
    Node *newNode = (Node*)malloc(sizeof(Node));   // 申请内存
    if (newNode == NULL) {
        printf("内存分配失败\n");
        return NULL;
    }
    newNode->data = value;   // 存数据
    newNode->next = NULL;    // 一开始next指向空,表示后面没东西了
    return newNode;
}

拆开看:

  • malloc(sizeof(Node)):申请一块刚好能放一个Node结构体的内存,单位是字节。

  • (Node*)是强制类型转换,把malloc返回的void*转成Node*,这样赋值给newNode的时候类型匹配。

  • if (newNode == NULL):检查内存分配是否成功。如果内存不够,malloc会返回NULL。虽然现在电脑内存都大,写作业很难碰上失败,但养成检查的习惯是专业程序员的基本素养。

  • newNode->data = value;:用->运算符访问结构体指针的成员。相当于(*newNode).data = value;,但->更简洁。

  • newNode->next = NULL;:新节点的next先设为NULL,表示这个节点后面还没接东西。

四、链表的两种创建方式:头插法和尾插法

有了创建单个节点的函数,现在要把它们串起来。串起来有两种方式:从头插入和从尾插入。

头插法:新节点总是插在最前面

思路:新节点的next指向当前的头节点,然后头指针指向新节点。

Node* create_by_head_insert() {
    Node *head = NULL;   // 一开始链表是空的
    int value;

    printf("请输入数据(输入-1结束):\n");
    scanf("%d", &value);

    while (value != -1) {
        Node *newNode = create_node(value);
        newNode->next = head;   // 新节点指向原来的头
        head = newNode;         // 头指针更新为新节点
        scanf("%d", &value);
    }

    return head;
}

追踪一下执行过程:

输入3:创建节点3,next指向NULL(因为head此时是NULL),head指向节点3。链表:3
        输入5:创建节点5,next指向head(也就是节点3),head指向节点5。链表:5 -> 3
        输入2:创建节点2,next指向head(节点5),head指向节点2。链表:2 -> 5 -> 3

头插法的特点:你输入的顺序是3、5、2,输出的顺序是2、5、3,正好反过来。头插法简单,但如果你想要输入顺序和输出顺序一致,就得用尾插法。

尾插法:新节点总是接在最后面

思路:如果链表为空,新节点就是头。如果链表不为空,先找到最后一个节点(next为NULL的那个),把它的next指向新节点。

Node* create_by_tail_insert() {
    Node *head = NULL;
    Node *tail = NULL;   // 多一个指针记录尾巴在哪儿
    int value;

    printf("请输入数据(输入-1结束):\n");
    scanf("%d", &value);

    while (value != -1) {
        Node *newNode = create_node(value);
        if (head == NULL) {
            // 第一个节点,既是头也是尾
            head = newNode;
            tail = newNode;
        } else {
            // 接到尾巴后面
            tail->next = newNode;
            tail = newNode;   // 更新尾巴
        }
        scanf("%d", &value);
    }

    return head;
}

追踪执行过程:

输入3:head==NULL,head指向节点3,tail指向节点3。链表:3
        输入5:head不为空,tail->next = 节点5,tail指向节点5。链表:3 -> 5
        输入2:tail->next = 节点2,tail指向节点2。链表:3 -> 5 -> 2

尾插法的特点:输入顺序和输出顺序一致。多了一个tail指针,省去了每次插入都要从头遍历找尾巴的麻烦。

你现在写作业或者考试,建议用尾插法,因为保持输入顺序更符合直觉。但你一定要知道头插法,因为有些场景(比如栈)就是从头插的,用的就是头插法的思路。

五、遍历链表——把每个节点走一遍

有了链表,怎么把里面的数据全打出来?从头节点开始,沿着next指针一个一个往后走,直到走到NULL。

void print_list(Node *head) {
    Node *p = head;   // 从头开始
    while (p != NULL) {
        printf("%d ", p->data);
        p = p->next;   // 走到下一个节点
    }
    printf("\n");
}

这步特别重要,你要在脑子里把这个过程走通:

  • p指向head(第一个节点),打印它的data,然后p移动到p->next(第二个节点)。

  • 打印第二个节点的data,p移动到第三个。

  • 一直走,直到p变成NULL,说明到链表末尾了,退出循环。

遍历是链表所有操作的基础。查找、求长度、修改、删除,都得先把链表走一遍。

六、查找某个值在不在链表里

遍历的时候加上判断就行了。

Node* find_node(Node *head, int target) {
    Node *p = head;
    while (p != NULL) {
        if (p->data == target) {
            return p;   // 找到了,返回这个节点的地址
        }
        p = p->next;
    }
    return NULL;   // 没找到
}

返回的是节点的指针。调用的时候,如果返回值是NULL,说明没找到;如果不是NULL,你就拿到了那个节点,想对它干啥都行。

七、插入节点——这才是链表的优势

链表的插入不需要挪动数据,只需要改几个指针。这是链表比数组强的地方。

在链表头部插入:这个最简单,头插法就是干这个的。

Node* insert_at_head(Node *head, int value) {
    Node *newNode = create_node(value);
    newNode->next = head;
    head = newNode;
    return head;   // 新的头指针
}

在链表尾部插入:如果有一个tail指针,O(1)就能搞定。如果没有,得先遍历找到尾巴。

Node* insert_at_tail(Node *head, int value) {
    Node *newNode = create_node(value);
    if (head == NULL) {
        return newNode;   // 空链表,新节点就是头
    }
    Node *p = head;
    while (p->next != NULL) {   // 找到最后一个节点
        p = p->next;
    }
    p->next = newNode;
    return head;
}

在指定位置插入:比如在链表的第pos个位置插入一个新节点(从0开始数)。

思路:找到第pos-1个节点(也就是插入位置的前一个),让新节点的next指向前一个节点的next,再让前一个节点的next指向新节点。顺序不能乱。

Node* insert_at_pos(Node *head, int pos, int value) {
    if (pos == 0) {
        // 插在头部
        return insert_at_head(head, value);
    }

    Node *newNode = create_node(value);
    Node *p = head;
    int i = 0;

    // 找到第pos-1个节点
    while (i < pos - 1 && p != NULL) {
        p = p->next;
        i++;
    }

    if (p == NULL) {
        printf("位置无效\n");
        free(newNode);   // 插不进去,把申请的内存释放掉
        return head;
    }

    // 关键两步:先连后面,再连前面
    newNode->next = p->next;
    p->next = newNode;

    return head;
}

注意那两步的顺序:

  • newNode->next = p->next; 先让新节点指向后一个节点。

  • p->next = newNode; 再让前一个节点指向新节点。

如果你把顺序写反了——先p->next = newNode;,然后再newNode->next = p->next;,这时候p->next已经被改成新节点了,新节点的next指向自己,整个链表后面就丢了。这个顺序是个经典坑,每次写插入的时候都要在脑子里过一遍。

八、删除节点——改个指针就完事

删除某个节点,思路:找到要删的节点的前一个节点,让它直接指向要删节点的下一个节点,跳过要删的那个,然后释放掉要删的节点。

删除头部节点:最简单。

Node* delete_at_head(Node *head) {
    if (head == NULL) {
        return NULL;
    }
    Node *temp = head;     // 记住要删的节点
    head = head->next;     // 头指针移到下一个
    free(temp);            // 释放原来的头
    return head;
}

删除指定值的节点:删除第一个data等于target的节点。

Node* delete_by_value(Node *head, int target) {
    if (head == NULL) {
        return NULL;
    }

    // 如果要删的是头节点
    if (head->data == target) {
        return delete_at_head(head);
    }

    Node *p = head;
    // 找到目标节点的前一个节点
    while (p->next != NULL && p->next->data != target) {
        p = p->next;
    }

    if (p->next == NULL) {
        printf("没找到\n");
        return head;
    }

    Node *toDelete = p->next;    // 要删的节点
    p->next = toDelete->next;    // 跳过它
    free(toDelete);              // 释放内存

    return head;
}

注意:找到的是前一个节点,不是要删的那个。 如果你拿到了要删的节点本身,但不知道它的前一个是谁,你就没办法把前后串起来。所以要提前一个节点出手。

画个图你就明白了:A -> B -> C,你想删B。你找到A,让A->next直接从B变成C,然后把B free掉。如果你只有B的指针,你就不知道A在哪儿,没法改A的next。所以删除操作的核心是"找到前驱节点"。

九、释放整个链表——别忘了擦屁股

动态申请的内存,用完一定要释放。链表用完不释放,就会造成内存泄漏。

void free_list(Node *head) {
    Node *p = head;
    while (p != NULL) {
        Node *temp = p;    // 记住当前节点
        p = p->next;       // 先走到下一个
        free(temp);        // 再释放当前
    }
}

顺序很重要:先存下p->next(或者先把p->next赋值给p),再释放p。如果你先free(p),然后p = p->next,你访问的是一块已经释放的内存,这叫"野指针访问",程序会崩溃。

十、完整的例子:学生信息链表管理

把所有东西串起来,写一个完整的例子。需求很简单:用链表存几个学生的学号,能添加、删除、查找、打印。

#include <stdio.h>
#include <stdlib.h>

typedef struct Student {
    int id;
    struct Student *next;
} Student;

// 创建节点
Student* create_student(int id) {
    Student *new = (Student*)malloc(sizeof(Student));
    if (new == NULL) {
        printf("内存分配失败\n");
        return NULL;
    }
    new->id = id;
    new->next = NULL;
    return new;
}

// 尾插添加
Student* add_student(Student *head, int id) {
    Student *new = create_student(id);
    if (head == NULL) {
        return new;
    }
    Student *p = head;
    while (p->next != NULL) {
        p = p->next;
    }
    p->next = new;
    return head;
}

// 按学号查找
Student* find_student(Student *head, int id) {
    Student *p = head;
    while (p != NULL) {
        if (p->id == id) {
            return p;
        }
        p = p->next;
    }
    return NULL;
}

// 按学号删除
Student* delete_student(Student *head, int id) {
    if (head == NULL) return NULL;
    if (head->id == id) {
        Student *temp = head;
        head = head->next;
        free(temp);
        return head;
    }
    Student *p = head;
    while (p->next != NULL && p->next->id != id) {
        p = p->next;
    }
    if (p->next == NULL) {
        printf("学号%d不存在\n", id);
        return head;
    }
    Student *temp = p->next;
    p->next = temp->next;
    free(temp);
    return head;
}

// 打印所有
void print_students(Student *head) {
    if (head == NULL) {
        printf("链表为空\n");
        return;
    }
    Student *p = head;
    while (p != NULL) {
        printf("%d ", p->id);
        p = p->next;
    }
    printf("\n");
}

// 释放链表
void free_all(Student *head) {
    Student *p = head;
    while (p != NULL) {
        Student *temp = p;
        p = p->next;
        free(temp);
    }
}

int main() {
    Student *head = NULL;

    head = add_student(head, 101);
    head = add_student(head, 102);
    head = add_student(head, 103);
    head = add_student(head, 104);
    printf("当前学生:");
    print_students(head);   // 101 102 103 104

    head = delete_student(head, 102);
    printf("删除102后:");
    print_students(head);   // 101 103 104

    Student *found = find_student(head, 103);
    if (found != NULL) {
        printf("找到了学号%d\n", found->id);
    }

    free_all(head);
    return 0;
}

这个例子虽然简单,但把链表的核心操作——创建、添加、查找、删除、遍历、释放——全过了一遍。你把它敲一遍,跑起来,然后改一改加点功能,链表你就真正上手了。

几个必踩的坑:

坑1:malloc之后忘记free

程序跑完的时候操作系统会回收内存,所以写小作业的时候感觉不到问题。但如果你写的是长期运行的程序(比如服务器),每创建一个节点都不释放,内存会一点点被吃光,最后程序崩溃。养成习惯,用完就free。

坑2:删除节点的时候把链表弄断了

删除的时候,如果你没有先保存要删节点的next,而是直接把p->next改了,那后面的节点就丢了,内存也泄露了。正确的顺序是:先存要删的节点,再改指针,最后free。

坑3:对空指针解引用

p->next的前提是p不是NULL。如果你没检查就直接用p->next,而p是NULL,程序瞬间崩溃。每次访问指针成员之前,脑子里过一下:这个指针有没有可能是NULL?

坑4:插入的时候顺序写反了

 前面说了,插入的时候必须先连后面再连前面。顺序反了链表就断了。这个错误很难发现,因为编译能过,运行可能也不崩,但遍历链表的时候发现少了数据。每次写插入都把顺序默念一遍:"先连后,再连前"。

最后说两句:

链表是C语言指针知识的大汇总。你学完链表,再看int *p、&取地址、*解引用这些东西,感觉就不一样了——因为你真正用它们做过事了。

练到什么程度算过关? 不看书、不查资料,能独立写出链表的创建、遍历、插入(头尾和指定位置)、删除(头和指定值)、释放。并且每写一行代码都知道它改变的是内存里的什么东西,执行完指针指向了哪里。做到这一步,链表的精髓你就掌握了。

还是那句话,别光看。链表必须手敲,必须画图。每写一个操作,把链表的结构画在纸上,用箭头标出每个节点的next指向哪儿,然后把代码一步步走一遍,看指针的变化跟你的图是不是一致。这个过程做一遍,顶你看十遍教材。

去吧,打开编译器,先把那个学生管理的例子敲一遍,然后在它上面加功能——比如按学号修改数据、按位置插入、统计链表长度。改着改着,链表就是你的了。