写数据结构代码这件事,很多朋友可能都觉得“不就是上课学过、考试背过的东西嘛”,但真到了面试手写、或者项目里需要自己封装一个队列缓冲串口数据的时候,才发现里面全是细节。我用C语言把链表、栈、队列这三种最基础的线性结构从头到尾实现过很多遍,踩过不少内存上的坑,也总结出一套比较顺手的写法。这篇内容就把我的实现思路、核心代码、边界处理和调试心得完整地分享出来,给正准备系统过一遍数据结构、或者想提升C语言功底的朋友做个参考。
1. 做之前先把思路理清楚:三种结构到底在解决什么问题
1.1 三者的本质是一种“治理数据”的策略
链表、栈、队列看着是三个东西,本质上是同一类问题——线性数据如何组织——的三种不同治理策略。线性结构意味着数据之间是一对一的前后关系,每个元素最多只有一个直接前驱和一个直接后继。区别在于你怎么限制对这个序列的访问:
- 链表给你的是“任意位置插入删除”的自由,代价是你得管理好指针;
- 栈给你的是“后进先出”的规则,限制了你只能在一端操作;
- 队列给你的是“先进先出”的规则,限制你一头进、另一头出。
我把这个关系想明白之后,写代码的心态就完全不一样了。以前是“背接口”,后来是“按约束设计接口”。这三个结构其实就是同一批节点、同一批内存操作,换不同的访问规则而已。理解到这一层,你再看那些五花八门的实现版本,核心逻辑都是同一套。
1.2 数组明明也能用,为什么还要折腾指针
很多初学者会问:数组不是也能存一串数据吗?为什么还要搞链表这种麻烦的东西?
数组的问题是“连续内存”这个强约束。插入一个元素,后面的全部要往后挪;删除一个元素,后面的全部要往前挪。在数据量小、操作不频繁的场景下这没啥,但一旦数据量大、插入删除很频繁,这种搬运成本就是灾难级的。链表的节点散落在内存各处,用指针串起来,插入删除只需要改相邻节点的指针,不涉及数据搬运。
代价也有——每个节点要额外存一个指针,内存开销比数组大,而且节点散落导致缓存命中率低。这就是典型的空间换时间、灵活性换局部性的取舍。实际项目里,如果你能预估容量、操作也不频繁,数组往往更香;如果你需要频繁增删、不知道总量上限,链表栈队列这些动态结构才真正发挥价值。
1.3 为什么非要用C语言实现一遍
有人会质疑:现在写业务代码都用高级语言,哪还有机会手写链表?我的观点是,C语言实现数据结构是性价比最高的训练方式,没有之一。
用C就必须亲手处理三件事:节点的内存分配、指针的指向改动、内存的释放归还。这三个正是数据结构的核心难点。Java、Python这类语言帮你屏蔽了指针和手动内存管理,写起来确实舒服,但也正因为太舒服了,你很难体会到“改一个指针顺序就可能段错误”的那种紧张感。C语言逼着你直面内存布局,你把C版本写明白了,再用任何高级语言实现同款结构,思路都是顺手拈来。
还有一个现实原因:很多嵌入式开发、底层中间件、高性能网络库,核心数据结构就是用C写的。你在Linux内核、网络协议栈、消息队列框架里都能看到链表和环形队列的影子,这些代码不会因为你用的是高级语言就消失。会读、会写C版本的数据结构,是啃这类源码的基础能力。
2. 链表实战:从单向链表到双向链表的完整落地
2.1 节点定义与接口设计:从struct开始的一小步
我在实现链表时,第一步不是急着写插入删除,而是先把节点类型和整个接口清单列出来。单向链表节点的定义就三行:
typedef struct Node { int data; // 这里先用int,实际项目可以换成void*或泛型宏 struct Node *next; } Node;data用int只是方便演示,真实的链表节点经常是结构体、指针、甚至另一个链表的头节点。用void*保存数据是通用做法,但会引入额外的内存管理问题,后面我会聊这个话题。单链表只保留next指针,因为单向遍历就够用;双链表则多一个prev指针。
接口清单我一般固定做这几个函数:
Node *list_create_node(int val):创建一个新节点并初始化;void list_insert_head(Node **head, int val):头插法;void list_insert_tail(Node **head, int val):尾插法;int list_delete_value(Node **head, int val):按值删除第一个匹配节点;void list_reverse(Node **head):反转链表;void list_destroy(Node **head):释放整条链表。
为什么入参都写成Node **head而不是Node *head?这是在给“头节点可能变化”留余地。头插法、删除头节点、反转链表,这些操作都会让链表的头指针指向一个新的节点地址。如果只传Node *head,你在函数内部改了头指针,函数外的主调方感知不到。传二级指针,就是允许函数内部修改头指针本身。
2.2 插入操作的三个位置:头插、尾插、中间插
插入是链表最基础的操作,但三个位置的处理方式完全不同,我拆开讲。
头插法是所有操作里最痛快的。新节点的next指向当前头节点,然后把头指针更新为新节点就行:
void list_insert_head(Node **head, int val) { Node *new_node = list_create_node(val); if (!new_node) return; new_node->next = *head; *head = new_node; }这个实现天然适配空表。空表时*head是NULL,新节点next指向NULL,头指针更新为新节点,链表就从无到有建立起来了,不需要额外判断空表分支。
尾插法稍复杂一些,因为要找到最后一个节点。我的写法是:
void list_insert_tail(Node **head, int val) { Node *new_node = list_create_node(val); if (!new_node) return; if (*head == NULL) { *head = new_node; return; } Node *cur = *head; while (cur->next != NULL) { cur = cur->next; } cur->next = new_node; }空表时直接更新头指针,否则遍历到尾节点再接上新节点。尾插的缺点是每次都要遍历整条链表,时间复杂度O(n)。如果你经常要在尾部追加数据,建议额外维护一个tail指针,代价是插入时多维护一次尾指针。
中间插是最容易写错的地方。假设要在某个节点pos之后插入新节点,关键点在于先连新节点和后一个节点,再接前一个节点和新节点:
// 在pos节点之后插入new_node new_node->next = pos->next; pos->next = new_node;这两步顺序不能反。如果先执行pos->next = new_node,那pos原本的next就丢了,后面的节点全找不到了。这个顺序问题在删除操作里也有对应版本,是我见过初学者最容易踩的坑。
2.3 删除节点:指针改动里藏着的三个坑
删除操作的C语言实现长这样:
int list_delete_value(Node **head, int val) { if (*head == NULL) return -1; // 情况一:要删的是头节点 if ((*head)->data == val) { Node *tmp = *head; *head = (*head)->next; free(tmp); return 0; } // 情况二:删的是中间或尾节点 Node *cur = *head; while (cur->next != NULL && cur->next->data != val) { cur = cur->next; } if (cur->next == NULL) return -1; // 没找到 Node *tmp = cur->next; cur->next = tmp->next; free(tmp); return 0; }这里有三个坑,我都踩过:
第一个坑是删头节点和删非头节点必须分开处理。删除头节点时,头指针要改成第二个节点;删除非头节点时,要改的是前一个节点的next。如果你用一个统一的“前驱节点”思路去处理,空表、单节点表、删头节点这些边界会把你绕晕。分开写,逻辑最清晰。
第二个坑是free之后指针没置空。free(tmp)之后,tmp变成了悬垂指针,如果你后面还去访问tmp->data就是典型的野指针操作。函数局部变量还好,如果这个tmp在别处还被引用,风险更大。我的习惯是free之后立刻把局部指针置NULL,宁可多写一行,不给段错误留机会。
第三个坑是遍历找前驱时条件写错。我用的是cur->next != NULL && cur->next->data != val,这个条件的含义是“当前节点的下一个节点存在,且下一个节点的值不是目标值”。注意这里看的是cur->next的data,而不是cur->data。很多初学者写成cur->data != val然后move,这就需要额外记住一个前驱指针,代码复杂度明显更高。
2.4 链表反转:面试常客的迭代解法
反转链表是考察指针操作功底的经典题目。递归写法代码短但不好理解,我推荐先把迭代写法吃透:
void list_reverse(Node **head) { Node *prev = NULL; Node *cur = *head; while (cur != NULL) { Node *next = cur->next; // 先保存后继 cur->next = prev; // 指向前驱 prev = cur; // 前进 cur = next; } *head = prev; // 最后prev就是新的头 }这段代码的精髓是“三个指针在链子上走”:prev是已经反转部分的头,cur是当前待处理的节点,next是防丢失的后继缓存。链表反转不是要你真把节点移动,只要把每条next指针调个头就行,头指针最终落到原来的尾节点上。我建议你手动画一遍这个过程:画一条四节点的链表,用箭头标出每一步prev、cur、next指向哪里,画完三遍你就彻底明白了。
2.5 单向和双向到底怎么选
双向链表比单向多了prev指针,插入删除时不需要找前驱,直接就能拿到前后节点。但代价是每个节点多8字节(64位系统一个指针8字节),而且插入删除时要多改两条指针。
我的选型原则很简单:
- 如果只需要从头部遍历到尾、且主要是头插,选单向;
- 如果要从两个方向遍历,或者按节点快速删除(已经拿到了这个节点的地址),选双向;
- 如果宿主机内存很紧张,能单向就单向,别为了“用起来方便”多烧内存。
真正常见的做法是带头节点的双向链表,头节点不存数据,只作为哨兵,能让所有插入删除逻辑统一,不用特判空表。Linux内核的链表实现就是这种思路的极致版——把链表节点嵌入到结构体内部,再通过offsetof操作找回宿主结构体。感兴趣的朋友可以去翻翻list.h的实现,那才是工业级的链表设计,看完你会觉得教科书里的实现都太基础了。
3. 栈的落地:数组栈和链式栈怎么选
3.1 栈接口与“只有一端能动”的核心约束
栈的核心约束可以一句话概括:只允许在栈顶操作。入栈、出栈、取栈顶,全都围绕栈顶指针进行。这种约束带来了一个额外的好处——实现极其简单。
我先定义接口:
typedef struct { int *data; // 动态数组 int capacity; // 容量 int top; // 栈顶下标,初始为-1 } ArrayStack;为什么top初始值是-1而不是0?这事儿得配合数组下标来理解。栈空时没有元素,top=-1。压入一个元素后,数据放到data[0],top变成0。所以“入栈就是data[++top] = val,出栈就是val = data[top--]”。这种写法干净利落,不会跟“元素个数”和“栈顶下标”这几个概念混淆。top等于-1代表空栈,top等于capacity-1代表满栈,判断条件非常直观。
3.2 数组栈的扩容策略:按需翻倍而不是每次加一
数组栈写起来很简单,但有一个绕不开的问题:容量不够了怎么办。我的策略是扩容时直接翻倍:
int ensure_capacity(ArrayStack *s) { if (s->top + 1 < s->capacity) return 0; int new_cap = s->capacity ? s->capacity * 2 : 4; int *new_data = (int *)realloc(s->data, sizeof(int) * new_cap); if (!new_data) return -1; s->data = new_data; s->capacity = new_cap; return 0; }为什么翻倍而不是每次加一?把账算一下:如果容量是n,从空栈开始压入n个元素,需要做n次入栈操作。每次扩容加一格的话,每入一个元素就要realloc一次,realloc的代价是可能把整块内存搬到新地址,还要把旧数据拷过去,总耗时接近O(n²)。翻倍扩容的话,每次扩容后能扛住之前所有扩容次数的总和,摊还下来单次入栈是O(1)。这跟动态数组的提法如出一辙,工程上几乎都这么做,除非你对内存极其敏感、只能用固定容量。
3.3 链式栈:入栈出栈就是头插头删
链式栈完全复用链表的节点定义,入栈在头部操作,出栈也在头部操作:
typedef struct Node { int data; struct Node *next; } Node; // 入栈:头插 void stack_push(Node **top, int val) { Node *new_node = (Node *)malloc(sizeof(Node)); if (!new_node) return; new_node->data = val; new_node->next = *top; *top = new_node; } // 出栈:头删 int stack_pop(Node **top, int *out) { if (*top == NULL) return -1; Node *tmp = *top; *out = tmp->data; *top = tmp->next; free(tmp); return 0; }链式栈的典型特点是永远没有“栈满”的问题,只要内存能malloc成功就能继续压栈。代价是每个节点都要带指针,内存开销明显更大。这种结构在面试里经常被用来考察“你能否把链表知识换个场景用起来”,理解了链表的头插和头删,链式栈就是送分题。
3.4 栈的经典应用:括号匹配和后缀表达式
栈不是教学玩具,实际场景里我用得最多的两个是括号匹配和后缀表达式求值。
括号匹配的核心逻辑是:遇到左括号就入栈,遇到右括号就出栈校验是否匹配。左括号和右括号种类多了以后(圆括号、方括号、花括号),关键是匹配关系要正确:
int is_matched(const char *expr) { Node *stack = NULL; for (const char *p = expr; *p; p++) { if (*p == '(' || *p == '[' || *p == '{') { stack_push(&stack, *p); } else if (*p == ')' || *p == ']' || *p == '}') { int top; if (stack_pop(&stack, &top) != 0) return 0; if (top == '(' && *p != ')') return 0; if (top == '[' && *p != ']') return 0; if (top == '{' && *p != '}') return 0; } } return stack == NULL ? 1 : 0; }注意最后要检查栈是否为空,否则左括号多出来的情况会漏判。
后缀表达式求值就更直接了:遇到数字就压栈,遇到运算符就弹出两个操作数计算,结果再压栈。表达式结束时栈顶就是最终结果。这种算法比中缀转后缀简单太多了,也侧面说明选择合适的数据结构能大幅简化编程。
3.5 数组栈和链式栈的选型对照
我在项目里遇到栈需求时,选型基本看这几点,整理成一个表方便对照:
| 维度 | 数组栈 | 链式栈 |
|---|---|---|
| 内存占用 | 有预留容量,可能浪费 | 每节点多一个指针,但不预留 |
| 上界限制 | 可扩容,但极端情况realloc频繁 | 受堆内存限制,理论无上限 |
| CPU缓存 | 连续内存,缓存友好 | 节点分散,缓存不友好 |
| 实现复杂度 | 需要处理扩容 | 复用链表头插头删 |
| 典型场景 | 编译器语法分析、函数调用栈模拟 | 内存极度碎片、无法预估上限 |
如果是嵌入式环境且栈深度可预期,我强烈推荐数组栈,提前分配一块固定内存还能避免malloc的开销。如果是面试手写题,链式栈往往更好写,因为代码量少、逻辑直白。这两个都值得写熟。
4. 队列实现:环形队列和链式队列,这几行代码最容易写错
4.1 队列接口与front、rear的语义定义
队列的接口不过五个:初始化、入队、出队、判空、判满。难的不是接口,而是front和rear到底怎么定义。一旦定义不清晰,后面所有代码都跟着乱。
我用环形数组队列来讲解,这是最常用、也最容易写错的一种实现:
typedef struct { int *data; // 存储区 int capacity; // 容量 int front; // 队头下标:指向队首元素 int rear; // 队尾下标:指向队尾元素的下一个空位 int count; // 当前元素个数 } CircularQueue;我的定义是这样的:front指向队首元素的位置,rear指向队尾元素的下一个空位,count记录当前元素个数。为什么用count而不是经典的“浪费一个存储单元”方案?因为count方案最直观,判空判满直接看count,根本不用记那些取模边界,调试起来省非常多力气。
4.2 环形队列的入队与出队:取模操作是灵魂
有了清晰的定义,入队出队代码就很简洁:
// 入队 int queue_enqueue(CircularQueue *q, int val) { if (q->count == q->capacity) return -1; // 队列满 q->data[q->rear] = val; q->rear = (q->rear + 1) % q->capacity; q->count++; return 0; } // 出队 int queue_dequeue(CircularQueue *q, int *out) { if (q->count == 0) return -1; // 队列空 *out = q->data[q->front]; q->front = (q->front + 1) % q->capacity; q->count--; return 0; }取模% capacity的作用,是让front和rear在到达数组末尾时能自动“绕”回开头,形成一个环形逻辑。比如capacity是5,front原来指向4,出队一个元素后front变成(4+1)%5=0,直接回到数组头部。没有这步,队列很快就“越界”了。
为什么我坚持用count而不是经典的“浪费一个空格”判满法?因为count方案里,front和rear的初始值都设成0就可以,空队列和满队列区分得很清楚,不需要额外约定。经典方案是rear == front判空、(rear+1)%n == front判满,这个范式很多人都会用,但实现的时候容易在边界值上混乱。我的建议是,做小型项目时优先count方案,代码可读性最好;做底层库时再考虑省那一个格子的空间,改成经典方案。
4.3 链式队列:一个带头节点的小技巧
链式队列也复用链表的节点,但在实现细节上我有个习惯:用一个dummy头节点(哨兵节点),这样能省略大量空表特判。
typedef struct { Node *front; // 指向dummy头节点 Node *rear; // 指向尾节点 } LinkedQueue;初始化时申请一个dummy节点,front和rear都指向它。入队时在rear后面挂新节点并更新rear,出队时删掉front的下一个节点:
void queue_push(LinkedQueue *q, int val) { Node *new_node = (Node *)malloc(sizeof(Node)); new_node->data = val; new_node->next = NULL; q->rear->next = new_node; q->rear = new_node; } int queue_pop(LinkedQueue *q, int *out) { if (q->front->next == NULL) return -1; // 空 Node *tmp = q->front->next; *out = tmp->data; q->front->next = tmp->next; if (tmp == q->rear) q->rear = q->front; // 删的是最后一个元素 free(tmp); return 0; }这个实现里最容易被忘掉的是出队时“删的是最后一个节点”的情况。如果队里只有一个有效节点(rear指向它),把它删掉之后,rear必须重新指向dummy节点,否则rear就成野指针了。这个判断我线下编程时至少漏过两次,每次都是跑测试的时候卡死的,后来干脆在代码里直接加上这行。什么时候漏掉?就是你只测出队不出队到空的情况,以为“空表判断在前面的front->next == NULL就挡掉了”,但rear这个指针它不管你这些,你不手动更新它就悬空。
4.4 环形队列还是链式队列,看场景
队列在项目里的出现频率比栈更高——网络数据收发的缓冲、任务调度的等待队列、生产者消费者模型、日志异步落盘,这些全都用得上队列。选型上我是这么分的:
- 数据量可预估、追求性能:选环形数组队列,内存连续、无malloc开销、缓存友好;
- 数据量未知、峰值波动大:选链式队列,天然不限深度,但每次节点都要malloc/free,性能有损耗;
- 多线程环境:无论选哪个都必须加大锁或改造为无锁队列;无锁环形队列才是并发下的王者,但也更考验内存序理解。
如果只是入门练手,我建议先写环形数组队列,因为取模指针、判满判空、容量计算这些细节比链式队列更有嚼劲,写一遍能帮你把“下标从头绕到尾”的感觉建立起来。
4.5 队列容量与长度的计算演示:一页纸算清楚
很多人困惑于“队列还剩多少空间”“现在有多少元素”,有了count方案这都简单,但为了对比,我还是把经典方案的公式列出来,方便你查:
设容量为n,front指向队首,rear指向队尾元素的下一个空位。
- 元素个数 =
(rear - front + n) % n - 剩余空间 =
n - 1 - 元素个数(经典方案浪费一格) - 判空:
front == rear - 判满:
(rear + 1) % n == front
写个例子感受一下:n=5,front=2,rear=4,那么元素个数=(4-2+5)%5=2,也就是说data[2]和data[3]有数据,data[4]是rear指向的下一个空位。这时候还能入队吗?(rear+1)%5=0,不等于front=2,所以还能入。再入一个,rear变成0,元素个数=(0-2+5)%5=3,还剩一个格子,但经典方案下这个格子不能用了,因为它必须保持“空一格”来判断满。这种浪费是刻意的,是为了让满状态和空状态不一样。我的count方案没有这个限制,也能装n个,代码反而更简单。
5. 调试与避坑:这些错误在我机器上真实发生过
5.1 段错误的常见位置和排查路径
C语言数据结构最常见的崩溃就是Segmentation Fault,我总结下来九成出在三个地方:
第一个是空指针解引用。最常见的是没有判断malloc返回值,malloc失败返回NULL,然后直接node->data = val就崩了。要是系统内存充足时几乎测不出来,但一旦跑在内存受限环境就容易暴雷。我的习惯是所有malloc之后立刻判空,不为偷懒省这一行。
第二个是野指针和悬垂指针。free之后忘记置NULL,或者局部指针离开了作用域但还在别处被引用。比如你删除节点后保存了它的next指针,但next也一起被free了,再访问就是未定义行为。对付这类问题,最好的办法就是“谁free谁负责置NULL”,并且不让一个节点被free两次。
第三个是边界条件处理错误。空链表上执行删除、队列空时执行出队、栈满时执行入队,这些动作如果不先判空判满,就会访问到非法地址。我调试最多的就是这些边界场景。排查段错误的路径我一般这样走:
- 开gdb,跑
bt看调用栈,定位崩溃函数; - 打印关键指针,看哪个是NULL或者地址异常(比如0x7f开头的是堆地址,0x0开头是空指针);
- 回看代码,重点查malloc、free、指针赋值三个位置;
- 如果崩溃位置是库函数(比如memcpy),往前追传进去的指针是不是有效。
5.2 内存泄漏怎么查:用工具也用好习惯
内存泄漏在C语言里是老大难。链表删了一半节点漏一半,队列重排后老节点没free,这些都要靠工具+习惯双管齐下。
工具上我用valgrind的memcheck,跑一下就能看到“definitely lost”“indirectly lost”这些报告,还自带释放位置栈。如果是Linux环境,这是最省事的检测方式。没有valgrind的环境,就靠代码习惯兜底:每次malloc都对应一次free,写在注释里;每个销毁函数必须把结构体内的所有节点全部释放完,且把头指针置NULL;每次销毁完立即用valgrind过一次测试。
一个细节:链表销毁时,不要直接用free(head)了事,要先循环释放每个节点。正确的销毁习惯是:
void list_destroy(Node **head) { Node *cur = *head; while (cur != NULL) { Node *next = cur->next; free(cur); cur = next; } *head = NULL; }这里先把next存下来再free,顺序不能反,否则free之后cur->next已经是非法访问了。
5.3 边界条件自查清单:写代码前先列出来
我写这些结构的时候,会先列一份边界条件清单,挨个过关:
| 操作 | 空结构 | 单元素 | 满结构 |
|---|---|---|---|
| 链表头插 | 正常 | 正常 | 不涉及 |
| 链表尾插 | 头指针更新 | 正常 | 不涉及 |
| 链表删除 | 返回失败 | 头指针置NULL | 不涉及 |
| 栈入栈 | 正常 | 正常 | 先扩容或返回失败 |
| 栈出栈 | 返回失败 | 栈顶指针复位 | 正常 |
| 队列入队 | 正常 | 正常 | 返回失败 |
| 队列出队 | 返回失败 | front/rear复位 | 正常 |
每次写完一个函数,先把这个表里的情况手动过一遍,很多隐藏bug根本不用等测试来暴露。比如删除单元素链表后,头指针是否变成NULL;队列出队到最后一个时rear是否更新。这些细节,跑测试时不好抓,但用这个清单自己过一遍,一分钟就能发现问题。
5.4 综合自测题:三道题验证你是否真会了
如果前面的代码你都写了一遍,我建议做三道综合题自测。这几道题综合了多种结构混用,很能检验你对内存和指针的掌控:
- 用两个栈实现一个队列,要求入队和出队的均摊复杂度O(1)。这题的核心是“一个栈管入队、一个栈管出队”,出队时倒一次,把后进先出倒成先进先出。
- 判断一条单链表是否有环。用快慢指针,快指针一次走两步,慢指针一次走一步,有环就一定会相遇。
- 给定一个链表,每K个节点翻转一次,不足K个保持不变。这题除了反转链表,还要求精确的区间定位,综合考察链表操作用利索不利索。
你能独立把这三道题写对,说明链表栈队列的基础已经算扎实了。注意第三题是最难的,我建议先画图再动手。
6. 测试与验证思路:怎么确定你写的结构真的没问题
6.1 先写测试,再写实现:一个反直觉但好用的顺序
我自己的习惯是先写一个简单的测试驱动,把整个接口的预期行为定义清楚,然后再补实现。这个思路是从某次项目里学来的,那次我先写了实现,结果测试不通过,我花了两天调试才发现是“测试写错了”而不是“实现写错了”。从那以后,我什么都不写,先写一份测试用例。
比如链表,我会先写:创建一个空链表,断言head == NULL;头插三个值,断言链表长度和每个节点值;删除头节点,断言新头节点值正确。这些测试代码本身不依赖具体实现,接口不变测试就不变,实现怎么改都行。测试先行的最大好处是,你把“正确行为”定义在前面,后面写代码的时候就知道边界该怎么处理了。
6.2 边界用例清单:表格化避免漏测
实际测试中我通常按下面的用例清单来跑:
| 用例ID | 场景 | 输入 | 期望结果 |
|---|---|---|---|
| 01 | 空链表头插 | head=NULL, val=1 | head指向新节点,node->next==NULL |
| 02 | 链表删除不存在值 | 3节点,删除999 | 返回-1,链表不变 |
| 03 | 链表删除唯一节点 | 1节点,删除该值 | 返回0,head==NULL |
| 04 | 空栈出栈 | top=-1 | 返回-1,无副作用 |
| 05 | 栈满入栈 | 容量4,入4次后再入 | 第5次返回-1或触发扩容 |
| 06 | 空队列出队 | count=0 | 返回-1,无副作用 |
| 07 | 队列入队到满 | capacity=3,入3个 | 第4次返回-1 |
| 08 | 队列出队到空再入队 | 反复操作 | 元素正确,无内存错误 |
我测的时候会把01到08挨个跑,中间穿插valgrind检查。最有价值的其实是“先满再空再满”这种反复测试,它能真实暴露rear指针在绕圈时是否出问题。这个用例我至少见过三次不同的bug现场。
6.3 断言与打印:小技巧大作用
写测试时,断言是最快的错误定位手段。C语言里用assert(condition),条件不满足就崩溃,附带文件名和行号。比如链表删除后,可以用assert(head->data == 2)验证删除正确,如果不对,直接崩在断言行。
但assert有个缺点:一旦编译时定义了NDEBUG,所有assert就变成空语句,不会帮你查错了。所以我还会配合一个debug打印宏:
#ifdef DEBUG #define LOG(fmt, ...) fprintf(stderr, "[DEBUG] %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__) #else #define LOG(fmt, ...) do {} while (0) #endif每次入队、出队、插入、删除,在该打日志的位置打一条。跑一轮测试,看打印出来的操作序列,能快速看出逻辑在哪一步断开。这个习惯被很多人忽视,但我觉得排查诡异bug时比断点还快——因为你不用亲手一步步找状态,日志自动告诉你状态变迁。
6.4 工具组合:gdb加valgrind,一对黄金搭档
我调试C语言数据结构的基本组合是gdb加valgrind。gdb负责定位段错误的精准行号,valgrind负责扫地雷一样找内存泄漏和非法读写。
在gdb里如果程序段错误,我会直接run,等它崩溃后输入bt看调用栈,然后切到对应的栈帧,print关键变量。排查悬垂指针时,print node->next如果显示0xffffffffffffffff之类的大地址,基本就是非法指针。
valgrind更厉害,它对**“读已释放内存”**这类未定义行为也能报错。我写过一段删除节点后还访问next的代码,gdb一路跑着没崩,valgrind一上去就报了Invalid read,属于内存调试神器级别。这两者搭配使用,一个管逻辑一个管内存,数据结构的基本盘就能稳住。
7. 写在最后:这遍写完我的一些真实体会
再分享两个小经验作为收尾。第一个是,我每次写完链表都要停下来画一遍指向图,哪怕只是三行代码的删除逻辑。画到“删除最后一个节点”的时候,你一定会发现问题:rear指针没更新、头指针没置空、或者少了free。这个习惯救了我很多次,比任何调试工具都有效。
第二个是,这三个结构不要分开学,要放在一起对比着写。链表是基础,栈和队列复用链表的节点,但改了访问规则和操作位置。你就能体会到,同一个节点在不同结构里的角色变化——同一个next指针,在链表里是“后继”,在栈里是“栈顶下一层”,在队列里是“队尾再往后”,代码结构其实一模一样,变的只是操作边界。我建议你写一个基础链表版本,然后只改十几个函数就把栈和队列全部实现出来,这份融会贯通的感觉比自己死记三个独立版本要扎实得多。数据结构这东西,说到底就是内存里的那点事,用C语言亲手写过一遍,才算真正理解指针和内存管理的分量。