别再被数组虐哭!C语言链表双雄:单链表+循环链表通俗到爆,小白也能秒懂上手!
你是不是也被数组逼到过崩溃边缘?想往中间插个数据,得把后面所有元素挨个挪位置,累得像搬砖;想删个数据,又要费劲补窟窿,生怕留下空位;更气人的是数组长度固定死,多存一个数据都不行,少存了又浪费空间!
别急,C语言里藏着个“动态排队大师”——链表,早就把这些破事解决得明明白白!而链表家族里,单链表和循环链表就像一对性格迥异的兄弟:一个耿直boy,只认往前冲,到尾就停;一个恋家小弟,绕圈跑不停,永远能回到起点。
今天咱们就用唠嗑的方式,把这俩兄弟扒得底朝天,代码+大白话双管齐下,没有复杂术语,没有晦涩逻辑,小白也能看懂,看完直接上手写!
一、先搞懂核心:链表的“基本操作单元”——节点
不管是单链表还是循环链表,都是由“节点”串起来的。节点这玩意儿,其实就是个“多功能快递盒”,里面整整齐齐分两格:
- 一格装正经东西(数据域):比如1、2、3这些数字,也能换成字符、结构体啥的,看你需要;
- 另一格装下一个快递盒的地址(指针域):相当于快递盒上贴了张纸条,写着“下一个快递盒在这儿”,不然链表怎么知道该往哪走?
用C语言写出来就是这样(单链表和循环链表通用,直接抄就行):
// 通用节点结构体(单链表/循环链表都能用)typedefstructNode{intdata;// 数据域:装整型数据(想换类型直接改这儿)structNode*next;// 指针域:存下一个节点的地址}Node,*LinkedList;// Node:节点类型;LinkedList:节点指针类型(简化写法)简单说,链表就是把一个个这样的“快递盒”,通过指针域的“地址纸条”串起来,形成的动态队伍——想加人就加个快递盒,想删人就把纸条改了,不用挪动其他快递盒,主打一个灵活!
二、单链表 vs 循环链表:核心区别大揭秘
这俩兄弟看着像,但核心差异就两点:尾节点的“归宿”和遍历的“终止规则”,用大白话对比一下,一看就懂:
| 特性 | 单链表(耿直boy) | 循环链表(恋家小弟) |
|---|---|---|
| 尾节点指针 | 指向NULL(相当于排队排到最后,后面没人了,直接站定不动,主打一个“到点下班”) | 指向头节点(排到最后发现前面是队首,直接绕回去,形成一个圈,主打一个“永不散场”) |
| 遍历终止条件 | 走到NULL就停(到终点了,打道回府,不往前走了) | 走回头节点就停(绕圈跑,回到起点就收工,绝不多跑一步) |
| 空表判断 | 头指针是NULL(队伍里一个人都没有,空荡荡的) | 头节点的next指向自己(哨兵大哥自己跟自己玩,没人来排队) |
| 尾节点访问 | 得从头走到尾(跑遍全场才能找到最后一个人,费点劲) | 头节点的前面就是尾节点(哨兵大哥后面是队首,前面就是队尾,逻辑清晰,不用瞎跑) |
| 适用场景 | 单向走、频繁在开头或中间插人删人(比如食堂打饭,有人插队到前面,或者中间有人吃完走了) | 绕圈走、频繁找队首队尾(比如操场跑步、进程调度,一直循环转,离不开首尾) |
其实说白了,循环链表就是单链表的“升级版”——把单链表的尾节点从“指向NULL”改成“指向头节点”,直接变身“闭环队伍”,其他核心逻辑都差不多!
三、单链表:耿直boy的“单向排队指南”
单链表是链表家族的“基础款”,节点一个个单向串联,尾节点指向NULL,只能从头部往尾部走,不能回头,就像耿直boy一样,一条路走到黑。
1. 单链表的6个核心操作(代码+大白话)
(1)创建节点:造一个新的“快递盒”
// 创建一个新节点,返回节点指针;失败返回NULL(没地方放快递盒了)Node*create_node(intdata){Node*new_node=(Node*)malloc(sizeof(Node));// 申请内存(找地方放快递盒)if(new_node==NULL){// 没找到地方,申请失败perror("malloc failed");returnNULL;}new_node->data=data;// 把数据装进快递盒new_node->next=NULL;// 暂时没下一个快递盒,先留空returnnew_node;}大白话:创建节点就是“造快递盒”,先找块内存放盒子,再把数据装进去,最后告诉它“暂时没下一个盒子”,造不出来就报错,简单粗暴!
(2)头插法:插队到最前面(高效!逆序插入)
头插法就是新节点直接插到队伍最前面,相当于食堂打饭有人插队,不用管后面的人,效率超高,但插入顺序和最终顺序是反的。
// 头插法:将数据插入链表头部,返回新的头指针(插队成功,新人为队首)LinkedListinsert_head(LinkedList head,intdata){Node*new_node=create_node(data);// 先造个新快递盒if(new_node==NULL)returnhead;// 造失败了,队伍不变new_node->next=head;// 新盒子的下一个地址,指向原来的队首returnnew_node;// 新盒子成为新队首}例子:先头插1,再头插2,最后头插3,结果是3->2->1->NULL,相当于插队的人越来越多,后来的反而在前面!
(3)尾插法:乖乖排到队尾(顺序插入)
尾插法就是新节点排到队伍最后,得先找到队尾(next==NULL的节点),再把新节点接上,插入顺序和最终顺序一致,就像排队乖乖排到最后。
// 尾插法:将数据插入链表尾部,返回头指针(队首不变)LinkedListinsert_tail(LinkedList head,intdata){Node*new_node=create_node(data);// 造新快递盒if(new_node==NULL)returnhead;// 造失败,队伍不变if(head==NULL){// 队伍是空的,新盒子直接当队首returnnew_node;}Node*p=head;// 从队首开始找while(p->next!=NULL){// 一直找到队尾(后面没人的那个)p=p->next;}p->next=new_node;// 队尾指向新盒子,新盒子排到最后returnhead;}例子:尾插1,再尾插2,再尾插3,结果是1->2->3->NULL,规规矩矩,插多少就按顺序排多少!
(4)遍历单链表:从头走到尾“打卡”
遍历就是把链表的所有数据都看一遍,从队首开始,一个个往后走,直到走到NULL(队尾)。
// 遍历并打印单链表所有节点(打卡报到)voidtraverse_singly(LinkedList head){if(head==NULL){// 队伍是空的printf("链表为空\n");return;}Node*p=head;// 从队首开始while(p!=NULL){// 没走到队尾就一直走printf("%d -> ",p->data);// 打印当前数据,相当于打卡p=p->next;// 往下一个节点走}printf("NULL\n");// 打印队尾标志,告诉大家“到这儿就结束了”}(5)删除节点:把不想见的人“请出队伍”
按值删除,找到第一个符合条件的节点,把它从队伍里去掉,后面的人接上,避免队伍断开。
// 删除第一个值为data的节点,返回新头指针(如果删的是队首,队首会变)LinkedListdelete_node(LinkedList head,intdata){if(head==NULL)returnNULL;// 队伍是空的,没的删Node*temp=NULL;if(head->data==data){// 要删的是队首temp=head;// 记下队首的位置head=head->next;// 队首往后移一位free(temp);// 把原来的队首“请走”(释放内存)returnhead;}Node*p=head;// 从队首开始找// 找要删节点的前一个人,且没走到队尾while(p->next!=NULL&&p->next->data!=data){p=p->next;}if(p->next!=NULL){// 找到了要删的节点temp=p->next;// 记下要删的节点p->next=temp->next;// 前一个人直接指向后一个人,跳过要删的节点free(temp);// 把要删的节点“请走”}returnhead;}大白话:删除节点就像队伍里有人要走,要么是队首走了,后面的人顶上;要么是中间的人走了,前面的人直接拉着后面的人,不让队伍断开!
(6)释放单链表内存:把“快递盒”都回收
链表用完了,得把申请的内存都释放掉,不然就像借了别人的东西不还,内存会被占满(内存泄漏),程序会崩掉!
// 释放整个单链表内存(回收所有快递盒)voidfree_singly(LinkedList head){Node*p=head;while(p!=NULL){// 一个个回收,直到队尾Node*temp=p;// 记下当前快递盒p=p->next;// 往下一个走free(temp);// 回收当前快递盒}}2. 单链表示例:完整使用演示(直接抄来跑)
intmain(){LinkedList head=NULL;// 初始化队伍,空的// 尾插法构建链表:1->2->3->NULL(乖乖排队)head=insert_tail(head,1);head=insert_tail(head,2);head=insert_tail(head,3);traverse_singly(head);// 输出:1 -> 2 -> 3 -> NULL// 头插法插入4:4->1->2->3->NULL(4插队到最前面)head=insert_head(head,4);traverse_singly(head);// 输出:4 -> 1 -> 2 -> 3 -> NULL// 删除值为2的节点:4->1->3->NULL(把2从队伍里请出去)head=delete_node(head,2);traverse_singly(head);// 输出:4 -> 1 -> 3 -> NULLfree_singly(head);// 回收所有快递盒,不浪费内存return0;}运行结果和预期一致,一步步看着链表从无到有,再到插入、删除,最后回收,整个过程明明白白!
四、循环链表:恋家小弟的“闭环排队指南”
循环链表是单链表的“变种”,核心改动就一个:尾节点的next不指向NULL,而是指向头节点,形成一个闭环,就像恋家的小弟,绕圈跑再远,最后都会回到起点。
推荐用“带头节点”的循环链表(头节点是“哨兵大哥”,不存数据,只负责管理队伍),这样能简化空表和非空表的操作,减少出错概率!
1. 循环链表的5个核心操作(代码+大白话)
(1)初始化循环链表:创建“哨兵大哥”
// 初始化带头节点的循环链表,返回头节点指针(创建哨兵大哥)LinkedListinit_circular(){Node*head=(Node*)malloc(sizeof(Node));// 给哨兵大哥找个位置if(head==NULL){// 没找到位置,创建失败perror("malloc failed");returnNULL;}head->next=head;// 空表时,哨兵大哥自己跟自己玩(next指向自己)returnhead;}大白话:初始化循环链表就是先找个“哨兵大哥”,空表的时候他自己站着,有人来排队了,就把人插在他后面,最后一个人再指向他,形成闭环!
(2)尾插法插入节点:排到队尾,绕回哨兵
循环链表尾插不用找NULL,找“哨兵大哥的前一个节点”就是队尾,插入后新节点再指向哨兵,保持闭环。
// 尾插法:向循环链表插入数据(带头节点)voidinsert_circular_tail(LinkedList head,intdata){if(head==NULL)return;// 没有哨兵大哥,插不了Node*new_node=create_node(data);// 造新快递盒if(new_node==NULL)return;// 造失败,插不了Node*p=head;while(p->next!=head){// 找队尾:哨兵大哥的前一个就是队尾p=p->next;}p->next=new_node;// 队尾指向新盒子new_node->next=head;// 新盒子指向哨兵,保持闭环}大白话:循环链表尾插就像排队绕圈,排到最后一个人,后面就是哨兵大哥,新来人就站在最后一个人后面,再回头指向哨兵,圈子不变!
(3)遍历循环链表:绕圈打卡,回到哨兵就停
// 遍历并打印循环链表(带头节点)voidtraverse_circular(LinkedList head){if(head==NULL||head->next==head){// 没哨兵,或哨兵自己玩(空表)printf("循环链表为空\n");return;}Node*p=head->next;// 从队首开始(跳过哨兵大哥)while(p!=head){// 没回到哨兵,就一直绕圈printf("%d -> ",p->data);// 打卡报到p=p->next;}printf("(回到头节点)\n");// 提示:绕圈结束,回到哨兵了}(4)删除节点:从闭环中“请人走”
和单链表删除逻辑类似,但终止条件是“没回到哨兵大哥”,删除后还要保持闭环。
// 删除循环链表中第一个值为data的节点(带头节点)voiddelete_circular(LinkedList head,intdata){if(head==NULL||head->next==head)return;// 空表,没的删Node*p=head;// 找要删节点的前一个人,且没回到哨兵while(p->next!=head&&p->next->data!=data){p=p->next;}if(p->next!=head){// 找到了要删的节点Node*temp=p->next;// 记下要删的节点p->next=temp->next;// 前一个人指向后一个人,跳过要删的节点free(temp);// 把要删的节点“请走”}}(5)释放循环链表内存:先删小弟,再删哨兵
循环链表要先释放所有数据节点(小弟们),最后再释放头节点(哨兵大哥),不然会漏释放内存!
// 释放带头节点的循环链表(先删小弟,再删哨兵)voidfree_circular(LinkedList head){if(head==NULL)return;// 没有哨兵,不用删Node*p=head->next;while(p!=head){// 先删所有数据节点Node*temp=p;p=p->next;free(temp);}free(head);// 最后删哨兵大哥}2. 循环链表示例:完整使用演示(直接抄来跑)
intmain(){LinkedList clist=init_circular();// 初始化,创建哨兵大哥// 插入节点:1->2->3->(回到哨兵)(绕圈排队)insert_circular_tail(clist,1);insert_circular_tail(clist,2);insert_circular_tail(clist,3);traverse_circular(clist);// 输出:1 -> 2 -> 3 -> (回到头节点)// 删除值为2的节点:1->3->(回到哨兵)(把2请出圈子)delete_circular(clist,2);traverse_circular(clist);// 输出:1 -> 3 -> (回到头节点)free_circular(clist);// 先删小弟,再删哨兵,回收所有内存return0;}运行后能看到,循环链表一直绕圈,删除节点后还是闭环,完美保持“永不散场”的特性!
五、单链表 vs 循环链表:深度对比(一目了然)
除了前面的核心区别,再把操作细节对比一下,选的时候不纠结:
| 操作 | 单链表 | 循环链表(带头节点) |
|---|---|---|
| 初始化 | 头指针初始为NULL(队伍空着) | 头节点next指向自身(哨兵自己玩) |
| 尾插法 | 遍历到p->next==NULL(找队尾) | 遍历到p->next==头节点(找哨兵的前一个) |
| 遍历终止 | p==NULL(到队尾) | p==头节点(回到哨兵) |
| 空表判断 | head==NULL(没人排队) | head->next==head(哨兵自己玩) |
| 尾节点访问 | O(n)(跑遍全场) | O(n)(同样跑全场,但逻辑清晰) |
| 环形遍历 | 不支持(走到NULL就停,绕不了圈) | 天然支持(绕圈跑,回到哨兵就停) |
六、踩坑预警!这4个坑千万别踩(血的教训)
- 循环链表的死循环:遍历的时候别把终止条件写成p!=NULL!不然就像绕着操场跑步不知道停,程序直接卡死,正确的是p==头节点(回到哨兵);插入/删除后一定要让尾节点指向头节点,不然闭环断了,就不是循环链表了!
- 内存泄漏:用malloc申请的节点,用完必须free!单链表要一个个回收,循环链表要先回收数据节点,再回收头节点,漏一个都不行,不然内存越用越少,最后程序崩掉!
- 空表处理:单链表空表是headNULL,循环链表空表是head->nexthead,操作前一定要先判断空表,不然会访问NULL指针,程序直接报错!
- 头节点的价值:循环链表一定要用头节点(哨兵大哥)!能统一空表和非空表的操作逻辑,不用单独处理“插入到队首”的情况,减少边界错误,新手必用!
七、总结:该选单链表还是循环链表?
- 选单链表:如果只是单向遍历、频繁在头部或中间插人删人,比如实现栈、简单任务队列、临时存数据,选单链表就够了,结构简单,实现起来不费劲!
- 选循环链表:如果需要环形遍历、频繁访问队首队尾,比如约瑟夫环问题、进程调度、循环缓冲区,选循环链表,闭环特性直接适配场景,不用额外处理!
其实这俩兄弟核心操作都差不多,就差在“终止条件”和“尾节点处理”,学会单链表后,把尾节点从指向NULL改成指向头节点,再调整一下终止条件,分分钟搞定循环链表!
现在再回头看,链表是不是没那么难?代码+大白话,一步步拆解,小白也能看懂上手。下次再被数组虐,直接用链表解决,灵活又高效,赶紧动手试试吧!