ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C语言双向链表求和实战:从结构体到指针操作全解析

C语言双向链表求和实战:从结构体到指针操作全解析 双链表这玩意很多教材里讲得云里雾里真到自己上手写代码的时候十个人有九个栽在指针上。今天这篇就挑一个最简单也最实用的场景来落地——用C语言实现一个双向链表把链表中每个节点存的一堆数值加起来。这个需求听起来不难但它能把双链表的结构设计、指针操作、边界处理和内存管理全部串一遍非常适合刚学完结构体和指针、准备啃数据结构的同学也适合那些写过单链表但没怎么碰过双向链表的初学者。看完这篇你不仅能把“求和”写出来还能顺带搞定创建、插入、遍历、释放这些双链表的基本功以后再遇到类似题目就不慌了。1. 双链表结构与求和任务拆解1.1 为什么要为“求和”选双链表很多同学一看题目是“求和”第一反应是这不就是遍历一遍加起来吗用数组不香吗用单链表不也行吗没错从功能上讲它们都能完成求和但双链表在这里最大的价值不是“更好”而是“更完整地把结构体的特性用起来”。数组求和确实简单可数组是连续内存插入删除要移动大量元素。单链表虽然解决了插入删除的问题但只能从头往后走想从尾往前走就抓瞎了。双链表给每个节点加了一个prev指针让每个节点都知道自己的前一个节点是谁。这带来两个实际好处一是可以双向遍历二是删除节点时不需要额外保存前驱节点。求和这个动作刚好吃到了“双向遍历”的红利——正向能加反向也能加遇到某些业务场景还特别方便。另外双链表是很多后续数据结构的底层基础。比如LRU缓存淘汰、浏览器前进后退、编辑器撤销重做背后都有它的影子。你花半小时把双链表求和跑通相当于把这些高级应用的地基也打了一遍。别嫌题目简单简单题目能把指针的来龙去脉理清楚就达到练手目的了。1.2 求和场景的三种典型形态我总结了一下实际开发和学习里遇到的链表求和基本逃不开下面三种形态第一种是最常见的整数节点求和。每个节点里存一个int比如学生的学号、商品的数量、传感器的采样值把链表中所有data字段加起来就是结果。这种形态最容易理解也是后面所有扩展的基础。第二种是浮点数据求和。比如节点存单价、温度、成绩等带小数的数据。这时候求和函数里的累加变量就不能用int了得用double或float打印格式也要跟着调整。很多人第一次写浮点链表求和时直接照搬整数版本结果小数部分被截断或者输出一串奇怪的数字。第三种更偏工程一点节点存储的是字符串或结构体求和不再是对数字做加法而是对某个字段做聚合。比如每个节点保存一份订单订单里有个price字段那“求和”其实就是“求订单总金额”。这种情况你得先从节点里把数值字段取出来再做累加。后面我会专门说这类扩展。不管是哪种形态核心都是两件事把链表从头到尾走一遍以及正确地从节点中取出数值。接下来先把双链表的地基打牢。2. 从零搭建双链表先把地基打牢2.1 节点结构体和初始化双链表节点结构体最基础的版本长这样typedef struct DNode { int data; struct DNode *prev; struct DNode *next; } DNode;一个data存数据一个prev指向前一个节点一个next指向后一个节点。这里要注意prev和next都必须是struct DNode *类型因为链表节点之间就是这样互相引用的写的时候别漏了struct关键字。如果你希望节点能存不同类型的数据可以用宏或者泛型思路改造但学习阶段先用int最省心。等你把int版本跑通了改成浮点数也就是把int换double再把printf的格式化占位符换一下。初始化链表我建议先定义一个头指针head和一个尾指针tail都初始化为NULL。这样链表为空时可以很清楚判断出来。这里有一个小坑有人图省事只定义head结果尾插的时候每次都要从头遍历到尾效率低不说代码还难读。既然用了双链表尾指针这种天然优势就要用起来。2.2 建链、尾插、头插的完整代码创建一个新节点的函数要同时把prev和next都设置好。这里我习惯让新节点的prev和next先都指向NULL后续插入时再调整DNode *createNode(int data) { DNode *node (DNode *)malloc(sizeof(DNode)); if (node NULL) { printf(内存分配失败\n); exit(1); } node-data data; node-prev NULL; node-next NULL; return node; }尾插是所有插入方式里最容易理解的。假设你现在有一个非空链表head指向第一个节点tail指向最后一个节点。要在尾部插入新节点新节点只需三步void insertTail(DNode **head, DNode **tail, int data) { DNode *node createNode(data); if (*head NULL) { *head node; *tail node; return; } node-prev *tail; (*tail)-next node; *tail node; }注意我这里的参数是二级指针因为在空链表插入时head和tail的指向会被改变。如果只传一级指针你在函数里改了指针的指向外面的head还是NULL链表就白建了。这是新手特别容易踩的坑。头插的思路和尾插对称但在细节上要更小心void insertHead(DNode **head, DNode **tail, int data) { DNode *node createNode(data); if (*head NULL) { *head node; *tail node; return; } node-next *head; (*head)-prev node; *head node; }核心逻辑是新节点先断开原链表头部把自己的next指向原来的head再把原head的prev指向新节点最后更新head。这里有个细节很多人写错先更新head再做后面的操作结果原来的head已经不是原来的节点了指针就乱了。记住一个原则——在链表结构没稳定之前别急着移动头尾指针。2.3 别忘了释放链表写练习代码时内存泄漏往往不被重视但如果你跑的是几十万节点的规模每插入一次malloc一块内存最后不释放程序长时间运行内存会一直涨。面试或者实际项目里这基本属于硬伤。释放双链表最简单的方式就是从头开始遍历先保存当前节点的next指针再free当前节点然后移动到下一个节点。注意不能先free再访问next因为free之后那块内存已经被释放了里面的指针内容是不确定的。void freeList(DNode *head) { DNode *current head; while (current ! NULL) { DNode *next current-next; free(current); current next; } }我习惯在free之后把current指向保存好的next这样最安全。如果你想更细致一点可以把head也置为NULL避免野指针残留。释放链表这件事我感觉很多人学过就算真正在练习里坚持每次写函数都带上的不多但养成这个习惯之后配合后面的检查工具能省下不少排查内存问题的精力。3. 三种求和方法正向、反向、递归3.1 正向遍历求和正向遍历是最符合直觉的写法从第一个有效节点开始每次把当前节点的data累加到总和变量里然后通过next指针向后移动直到遇到NULL。long long sumList(DNode *head) { long long sum 0; DNode *p head; while (p ! NULL) { sum p-data; p p-next; } return sum; }这里的head是整个链表的第一个节点如果链表为空那么head就是NULL循环一次都不会执行返回0。这个行为很符合预期所以我不建议在这里额外加空链表判断反而显得冗余。为什么累加变量要用long long因为int是有符号32位整数最大值只有约21亿。如果链表里存的是几千个几百万的数加起来很容易就溢出了。溢出后结果直接变成负数极其隐蔽。用long long之后除非节点数量非常多或者数值极大否则都稳得很。输出的时候也记得格式化成%lld别用%d。我就见过有人求和函数写的long long主函数里却用%d打印结果前几位数字完全对不上排查了半天才发现是占位符的问题。3.2 用prev指针反向求和反向求和的代码和正向长得很像区别只在起点和移动方向。既然双链表有prev指针我们可以从尾节点开始往头节点方向移动long long sumListReverse(DNode *tail) { long long sum 0; DNode *p tail; while (p ! NULL) { sum p-data; p p-prev; } return sum; }如果你是带头结点的链表也就是头结点不存储有效数据那就不太一样了。带头结点的链表往往只有head没有专门的tail指针你可能还得先遍历到尾部再反向走。这种情况下牺牲了一点效率换来的是插入删除时对空链表的处理更统一。反向求和本身并没有让结果变得更准确因为加法顺序不影响最终和但它至少验证了双链表“双向可走”这个特性。在一些实际场景里反向遍历是非常有用的比如一个日志链表你需要统计最近N条日志里的错误码之和但又不想从头遍历整份日志这时tail指针加prev指针就可以直接从最新一条往前加省掉大量无效遍历。这种灵活度就是双链表对比单链表的一个实打实的优势。3.3 递归求和的边界与取舍递归版本的求和代码很漂亮逻辑非常干净long long sumRecursive(DNode *node) { if (node NULL) { return 0; } return node-data sumRecursive(node-next); }这个函数的核心思想是一个链表的和等于当前节点的数据加上剩下部分链表的和。递归调用会在节点为NULL时终止这也正是递归的边界条件。递归适合用来理解“分而治之”的思路面试时也偶尔会考。但真要实际使用我得提醒你递归每深入一层就会在栈上占一块空间。链表很长的时候比如十几万个节点把递归写成这样有可能导致栈溢出。所以我在工程代码里更倾向于用循环版本的求和递归版本更适合作为理解和练习。另外递归求和还提供了一种反向变体先递归到链表末尾再在“归”的过程中累加数据。这个过程本质上是利用函数调用栈帮我们实现了从尾部到首部的遍历代码写出来也很短。但同样的栈深度问题没法避免使用时要权衡。4. 实战扩展从整数求和到业务汇总4.1 浮点数据和字符串状态怎么办如果节点存的是浮点数比如商品价格、体温读数求和函数几乎不用大改只要把data字段类型和累加变量类型都换成float或doubletypedef struct DNodeDouble { double data; struct DNodeDouble *prev; struct DNodeDouble *next; } DNodeDouble; double sumDoubleList(DNodeDouble *head) { double sum 0; DNodeDouble *p head; while (p ! NULL) { sum p-data; p p-next; } return sum; }注意浮点数累加时存在精度问题。比如0.1加0.2在二进制浮点数里并不是精确等于0.3。如果你对精度要求很高建议用整数表示最小单位比如金额用“分”存储最后再除以100或者使用高精度库做专门处理。很多真实项目里订单金额的计算就是这么干的直接用double累加几十笔订单对账的时候经常差几分钱。如果节点里存的是字符串比如每行输入是一串包含价格信息的文本想对数值求和那就得先从字符串里解析数字。这类需求一般先用fgets读入一行再用sscanf或strtod提取其中的数值字段最后加入链表。这里重点不是链表了而是字符串解析。常见错误是sscanf格式串写错导致每次都解析出0或者对缓冲区大小处理不当。我在项目里习惯先把每行原始字符串打印出来确认内容再做解析避免被不可见字符干扰。4.2 求总和之外顺带统计个数、均值、极值有时候目的不只是求和而是要对链表做一次完整汇总。一次遍历就可以同时完成个数、总和、最大值、最小值统计没必要分别遍历四遍。代码结构可以设计成一个小函数用指针参数把多个结果带回去void summarizeList(DNode *head, long long *sum, int *count, int *maxValue, int *minValue) { *sum 0; *count 0; DNode *p head; if (p ! NULL) { *maxValue p-data; *minValue p-data; } while (p ! NULL) { (*sum) p-data; (*count); if (p-data *maxValue) { *maxValue p-data; } if (p-data *minValue) { *minValue p-data; } p p-next; } }这种函数把好几个结果放在一个结构体里返回会更优雅但对指针参数的使用也是一种基本训练。调用前先定义局部变量把地址传进去函数内部就能直接修改。平均值只需要用总和除以个数注意如果个数是0分母为0得单独判断。扩展到这里你已经能从“一个链表求和”延伸到“一条链表的一次遍历汇总”。这个能力在实际项目里特别常用因为很多数据统计需要的不是单一指标而是多个指标同时算出。5. 常见问题排查与调试心得5.1 五张速查表从段错误到数据溢出我把平时最容易遇到的高频问题整理成表格方便你排查问题原因解决方法程序直接段错误访问了未初始化或已释放的指针所有指针先初始化释放后不再使用链表只有第一个节点尾插时忘了更新tail插入后把tail指向新节点正向遍历正常反向遍历卡死插入时没维护节点的prev指针插入操作中同时设置新节点的prev和旧节点的next求和结果变成负数或异常大数int溢出累加变量改用long long释放链表时崩溃free前就去访问了next指针先保存next再free当前节点数据重复或丢失插入时把前后指针顺序搞乱按“先处理新节点再处理旧节点”的顺序操作段错误是双链表学习的头号敌人十次报错里有八次是指针问题。有一个实用习惯每次只改一个逻辑点然后跑一次看结果。不要一口气写完整个链表再调试那样定位问题会非常痛苦。5.2 调试双链表的两板斧我自己调试链表相关代码基本只用两招而且屡试不爽。第一招是“打印指针链路”。在创建完链表后写一个临时函数把每一个节点的data、prev指针、next指针都打印出来void debugList(DNode *head) { DNode *p head; int idx 0; while (p ! NULL) { printf(idx%d, addr%p, data%d, prev%p, next%p\n, idx, (void *)p, p-data, (void *)p-prev, (void *)p-next); p p-next; } }打印出来之后你马上就能看出哪里的prev或next指向了错误地址。比如中间某个节点的prev指向了自己那肯定是插入时指针交叉设错了重新画一遍图就能找到问题。第二招是“小数据手工推演”。在写插入、删除之前先在纸上画出三个节点把prev和next当作有向线头一步一步模拟代码执行。这个方法看起来笨但特别有效。我带了这么多年新手发现能用好铅笔和橡皮的人指针题一般都不会错得离谱。写代码前先花三十秒画图真的比在屏幕前猜半天强太多了。还有一个现代化做法就是用内存检测工具。Linux下用valgrind一条命令就能查内存泄漏和非法访问。Windows下跑VSCode MinGW环境的话可以先关注编译警告把-Wall和-Werror打开很多悬空指针和未初始化问题在编译阶段就能暴露出来。C语言学习环境不求花哨一个顺手的编辑器和能编译的gcc就够用关键是你能看懂那些错误提示而不是急着抄代码。踩过几次坑之后你会发现双链表求和这道题表面上是把一堆数字加起来实际上练的是你对“每个节点都知道自己的邻居”这种感觉的掌握程度。我把这些经验原原本本写出来也是希望你在调试时少花点冤枉时间。下次再遇到什么链表逆序、链表排序、LRU只要把这张图刻在脑子里很多问题自然就不攻自破了。
返回列表