
从0理解链表:节点定义和创建就像搭积木
链表到底是什么?用生活例子给你讲透
你肯定用过数组吧?数组就像电影院的一排座位,每个座位有固定编号(下标),想找第5个人直接定位到下标4(从0开始)。但如果想在第3个座位和第4个座位之间加一个新座位,后面所有座位都得往后挪——这就是数组的”连续存储”特性带来的麻烦。
那链表是什么呢?你可以把它想象成一串冰糖葫芦:每个山楂(节点)除了自己的果肉(数据),还插着一根签子(指针),这根签子要戳进下一个山楂里。这样串起来的一串山楂,就是链表。和数组比,链表最大的好处是”想加山楂就加,想减山楂就减”,不用挪动其他山楂——这就是”非连续存储”的优势。
可能你会问:”那我什么时候该用链表,什么时候用数组?”我整理了一个对比表,你一看就明白:
对比项 | 数组 | 链表 |
---|---|---|
存储方式 | 连续内存空间 | 非连续,靠指针连接 |
插入/删除效率 | 低(需移动大量元素) | 高(只需改指针指向) |
访问元素效率 | 高(直接通过下标访问) | 低(需从头遍历) |
内存空间 | 固定大小,可能浪费或溢出 | 动态分配,用多少占多少 |
举个实际例子:如果你要存班级同学的成绩,人数固定,经常需要按学号(下标)查成绩,用数组就合适;但如果是实现一个随时可能增删联系人的电话簿,用链表会更灵活——我之前帮朋友写电话簿程序,一开始用数组,结果存到第50个人时想插在中间,代码改了半天还容易越界,后来换成链表,增删功能不到20行代码就搞定了。
手把手创建第一个链表:节点定义+初始化
理解了”为什么用链表”,接下来就是最关键的一步:怎么把这个”冰糖葫芦”搭起来?其实就两步:先定义”山楂”(节点),再把”山楂”串起来(连接节点)。
第一步:定义节点结构体——给”山楂”设计结构
每个节点要存两样东西:数据(比如联系人姓名、成绩等)和指向下一个节点的指针(连接下一个”山楂”的签子)。在C语言里,我们用结构体(struct)来定义它:
// 定义一个存储整数的链表节点
struct Node {
int data; // 节点存储的数据(可以是任何类型,这里用int举例)
struct Node next; // 指向下一个节点的指针(签子)
};
这里有个容易踩的坑:很多新手会把next
写成struct Node next
(普通变量),而不是指针。你想想,如果next
是普通变量,那每个节点里都会包含一个完整的下一个节点,这样节点就无限嵌套了,内存根本存不下!所以必须用指针——指针只存地址,相当于”我知道下一个山楂在哪里”,而不是”把下一个山楂整个吞进来”。我当时初学就犯了这个错,编译时提示”结构体递归定义”,查了半天才明白是少了个。
第二步:创建节点并连接——串起第一串”冰糖葫芦”
定义好节点后,我们需要创建具体的节点,并让它们的next
指针互相指向。这就像搭积木:先做三块积木(节点),再用胶水(指针)把它们粘起来。
#include
#include // 要用malloc函数分配内存
int main() {
// 创建第一个节点(头节点)
struct Node head = (struct Node)malloc(sizeof(struct Node));
head->data = 10; // 第一个节点存数据10
head->next = NULL; // 刚开始后面没有节点,指针指向NULL(空)
// 创建第二个节点
struct Node second = (struct Node)malloc(sizeof(struct Node));
second->data = 20;
head->next = second; // 让头节点的next指向第二个节点(把两个节点连起来)
second->next = NULL;
// 创建第三个节点
struct Node third = (struct Node)malloc(sizeof(struct Node));
third->data = 30;
second->next = third; // 第二个节点的next指向第三个节点
third->next = NULL;
// 此时链表结构:head(10) -> second(20) -> third(30) -> NULL
return 0;
}
这段代码里有几个关键点你一定要注意:
malloc
分配内存:每个节点都需要在堆区分配内存(就像给每个山楂找个位置放),如果忘了malloc
,指针会指向随机地址,程序很可能崩溃。我第一次写的时候图省事没写malloc
,结果运行直接报”段错误”,调试了半小时才发现是这个问题。 next
必须指向NULL
:这是链表的”结束标志”,就像冰糖葫芦的最后一颗山楂后面没有签子了。如果忘了设NULL
,遍历的时候可能会无限循环。 ->
:结构体指针访问成员时用->
,普通结构体变量用.
,别弄混了(比如head.data
是错的,必须是head->data
)。 如果你觉得这样一个个创建节点太麻烦,实际开发中我们会写一个”创建节点”的函数,传入数据就能返回一个新节点,代码更简洁。比如这样:
// 创建新节点的函数,data是要存储的数据
struct Node createNode(int data) {
struct Node newNode = (struct Node)malloc(sizeof(struct Node));
newNode->data = data;
newNode->next = NULL; // 新节点默认指向NULL
return newNode;
}
用这个函数,创建刚才的三个节点就变成:
struct Node head = createNode(10);
head->next = createNode(20);
head->next->next = createNode(30);
是不是清爽多了?这就是函数封装的好处——我后来写复杂链表时,把节点创建、释放都封装成函数,代码可读性一下提高了不少,调试也方便。
链表遍历和操作:从访问数据到增删节点全攻略
创建好链表后,我们需要能”数清楚有多少颗山楂”(遍历)、”在中间加一颗山楂”(插入)、”去掉一颗坏山楂”(删除)。这些操作听起来复杂,其实只要抓住”指针指向”这个核心,比你想象的简单。
遍历链表:像数火车车厢一样简单
遍历就是从头节点开始,逐个访问每个节点的数据,直到遇到NULL
(最后一节车厢)。想象你站在火车头(head节点),一节一节往后走,每到一节车厢就记录下乘客数(data),直到走到最后一节没有车厢的地方。
遍历代码怎么写?用while循环跟着指针走
// 遍历链表并打印所有数据
void traverse(struct Node head) {
struct Node current = head; // 从 head 开始走
while (current != NULL) { // 只要没到最后一节车厢(NULL)就继续
printf("%d ", current->data); // 打印当前节点数据
current = current->next; // 走向下一节车厢(当前指针 = next指针)
}
printf("n");
}
// 在main函数里调用:
traverse(head); // 会输出:10 20 30
这里的current
指针就像你的”脚步”,一开始站在head
,每走一步就更新为current->next
。我刚开始学的时候,总担心current
会把head
覆盖掉,其实不会——current
是临时变量,修改它不会影响head
的指向,你可以理解为”影子跟着你走,但影子动了不代表你动了”。
调试小技巧:打印指针地址跟踪节点
如果遍历结果不对(比如少打数据或死循环),你可以在循环里打印current
的地址和current->next
的地址,看看指针到底指向了哪里:
while (current != NULL) {
printf("当前节点地址:%p,数据:%d,下一个节点地址:%pn",
current, current->data, current->next);
current = current->next;
}
比如正常情况下,输出会像这样(地址是随机的,但后一个节点地址会等于前一个的next):
当前节点地址:0x55f8d2a0,数据:10,下一个节点地址:0x55f8d2c0
当前节点地址:0x55f8d2c0,数据:20,下一个节点地址:0x55f8d2e0
当前节点地址:0x55f8d2e0,数据:30,下一个节点地址:(nil)
我之前遇到过遍历只打印前两个节点的情况,用这个方法发现第三个节点的next
竟然指向了第二个节点(成了环),后来才想起是插入时指针指反了——所以打印地址是排查链表问题的”万能钥匙”,你一定要学会用。
节点增删:学会这两步,链表操作就通关了
增删是链表最核心的功能,也是最能体现它优势的地方。这里我们以”在指定位置插入节点”和”删除指定数据的节点”为例,带你一步步实现。
插入节点:在第n个位置加一颗”山楂”
插入分三种情况:插在头部、插在中间、插在尾部。其中最典型的是”插在中间”,我们以”在值为20的节点后插入值为25的节点”为例(链表目前是10->20->30):
步骤拆解:
prev
); next
指向prev
原来的下一个节点(30的节点); prev
的next
指向新节点。 用代码实现就是:
// 在值为target的节点后插入新数据newData
void insertAfter(struct Node head, int target, int newData) {
struct Node current = head;
//
找到target节点(遍历直到current->data == target)
while (current != NULL && current->data != target) {
current = current->next;
}
if (current == NULL) { // 没找到target节点
printf("没找到目标节点,无法插入n");
return;
}
//
创建新节点
struct Node newNode = createNode(newData);
//
新节点指向current原来的下一个节点
newNode->next = current->next;
//
current指向新节点
current->next = newNode;
}
// 在main函数调用:
insertAfter(head, 20, 25); // 在20后面插入25
traverse(head); // 此时输出:10 20 25 30
这里的关键是步骤3和4的顺序——必须先让新节点”抓住”后面的节点,再让前面的节点”抓住”新节点。如果先做步骤4(current->next = newNode),就会丢失原来current->next的地址(30的节点),新节点后面就接不上了。我第一次写插入时就搞反了顺序,结果插入后链表变成10->20->25->NULL,30的节点”丢了”,相当于把后面的山楂弄丢了,后来画图一步步走才发现问题。
删除节点:去掉值为25的节点
删除比插入简单一点,核心是”找到要删除节点的前一个节点,让它直接指向下下一个节点”,就像火车摘车厢:要摘掉中间一节,只需让前一节的挂钩直接连到后一节。
还是以刚才的链表(10->20->25->30)为例,删除25的节点:
步骤拆解:
prev
); delNode
); prev->next
指向delNode->next
(20直接指向30); delNode
的内存(防止内存泄漏)。 代码实现:
// 删除值为target的第一个节点
void deleteNode(struct Node head, int target) {
struct Node prev = NULL; // 前一个节点
struct Node current = head; // 当前节点
//
找到target节点和它的前一个节点
while (current != NULL && current->data != target) {
prev = current; // prev跟着current走,始终是current的前一个
current = current->next;
}
if (current == NULL) { // 没找到要删除的节点
printf("没找到目标节点,无法删除n");
return;
}
//
如果要删除的是头节点(prev为NULL)
if (prev == NULL) {
head = current->next; // 头节点变成原来的第二个节点
} else {
prev->next = current->next; // 前一个节点直接指向下下一个节点
}
//
释放删除节点的内存
free(current);
}
// 在main函数调用:
deleteNode(head, 25);
traverse(head); // 输出:10 20 30,25的节点被成功删除
这里要注意两点:一是删除头节点的特殊情况(prev为NULL),二是一定要用free释放内存——我之前写小程序时偷懒没free,结果跑了半小时内存占用越来越高,后来用valgrind
(一个内存调试工具)才发现有大量”未释放的节点”,所以养成free的习惯很重要。
如果你想练习,可以试试实现”在头部插入”和”删除尾部节点”,原理都差不多——记住:所有链表操作的核心都是”修改指针指向”,只要把指针的指向关系在纸上画清楚,代码就是水到渠成的事。我现在写链表代码前,都会先在纸上画节点关系图,比直接写代码效率高多了。
到这里,你已经掌握了链表的定义、创建、遍历、插入、删除——这些就是C语言链表的核心内容。其实你看,只要把”节点是山楂,指针是签子”这个比喻刻在脑子里,再结合画图和打印指针调试,链表真的没那么难。
你可以现在就把上面的代码复制到编译器里运行(推荐用Dev-C++或VS Code,记得包含stdlib.h头文件),如果遇到问题,欢迎在评论区告诉我你卡在哪里——是指针指向搞不清,还是内存分配报错?我会帮你一起排查。 编程这东西,动手做一遍比看十遍教程都有用,对吧?
你是不是也纠结过,写代码的时候到底该用数组还是链表?其实啊,这俩就像螺丝刀和扳手,没有绝对的好坏,关键看你要拧螺丝还是拆螺母。选数组还是链表,核心就看你最常做什么操作——要是你总需要“一下子定位到第几个元素”,比如查班级第5个同学的成绩,那数组肯定更顺手,毕竟它有下标,直接就能找到,就像电影院座位按号入座,不用一个个数。但要是你经常要“在中间插个新元素”或者“删掉某个元素”,比如通讯录里随时要加新联系人、删旧号码,那数组就麻烦了,你想想,数组里插个元素,后面所有元素都得往后挪位置,跟排队时硬要插进中间,后面的人都得动一动一样费劲。
那什么时候非用链表不可呢?我给你说个我自己的例子,去年帮朋友写个简单的任务管理工具,一开始图省事用了数组存任务,结果他每天都要加加减减好几个任务,数组扩容和元素移动的代码写得我头大,动不动就越界报错。后来换成链表, 瞬间清爽了——加任务就是新建个节点串进去,删任务就是把前后节点连起来,连内存都是用多少占多少,再也不用担心数组大小不够用的问题。所以啊,如果你要处理的数据量不固定,增删操作特别频繁,尤其是在中间位置操作,那链表绝对是更聪明的选择,就像串珠子,想加颗珠子直接穿进去,想拆一颗直接把线接好,其他珠子根本不用动。
链表和数组该怎么选?什么场景下用链表更合适?
链表和数组的选择主要看需求:如果需要频繁随机访问(比如通过下标快速找元素)、数据量固定,选数组;如果需要频繁插入/删除(尤其是中间位置)、数据量动态变化(不确定大小),选链表。例如通讯录管理(增删联系人频繁)、实现队列/栈(动态增减元素)时,链表更合适;而存储班级成绩(需按学号快速查询)、实现矩阵运算时,数组更高效。
实现链表时最容易犯哪些错误?怎么避免?
初学者常见错误有:① 指针指向错误(如插入时先改前节点next导致丢失后续节点), 画图梳理指针指向关系;② 忘记释放内存(删除节点后不free),每次删除节点后务必用free()释放;③ 未处理空链表/尾节点(遍历或操作时忽略NULL判断),所有指针操作前先检查是否为NULL;④ 结构体定义时next用普通变量而非指针,记住节点的next必须是struct Node*类型。
什么是头节点?一定要有头节点吗?
头节点是位于链表第一个元素节点前的特殊节点,数据域通常无意义,主要作用是简化操作(如插入/删除第一个元素时无需特殊处理)。不是必须有,但 使用:无表头链表操作第一个元素时需单独判断头指针是否为NULL,有表头则所有节点操作逻辑统一。例如文章中的示例用的是“无头节点链表”,若改为有头节点,初始化时head指向头节点,头节点next指向第一个元素节点。
循环链表和双向链表是什么?和单链表有什么区别?
循环链表是尾节点next指向头节点(或头节点)的链表,可从任意节点遍历整个链表,适合实现循环队列;双向链表每个节点除next外还有prev指针(指向前一个节点),可双向遍历,插入/删除时需同时维护prev和next,但查找前驱节点更方便。单链表只有单向指针,结构简单但功能有限;循环和双向链表是单链表的扩展,适用于更复杂场景(如操作系统的进程调度、LRU缓存等)。
如何判断链表中是否有环?
判断链表是否有环(如节点next指向前面已存在的节点),常用“快慢指针法”:定义两个指针slow和fast,slow每次走1步,fast每次走2步。如果链表有环,fast会先进入环并最终追上slow(两者指向同一节点);如果无环,fast会先到达NULL。例如链表1->2->3->2(有环),slow走1→2→3→2,fast走1→3→2→3→2,最终在节点2相遇,可判断有环。这是面试高频题,初学者需掌握基本原理。