☰
用C语言手写链表、栈、队列:核心实现与避坑指南
2026/10/19 7:11:53 网站建设 项目流程

写数据结构代码这件事,很多朋友可能都觉得“不就是上课学过、考试背过的东西嘛”,但真到了面试手写、或者项目里需要自己封装一个队列缓冲串口数据的时候,才发现里面全是细节。我用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两次。

第三个是边界条件处理错误。空链表上执行删除、队列空时执行出队、栈满时执行入队,这些动作如果不先判空判满,就会访问到非法地址。我调试最多的就是这些边界场景。排查段错误的路径我一般这样走:

  1. 开gdb,跑bt看调用栈,定位崩溃函数;
  2. 打印关键指针,看哪个是NULL或者地址异常(比如0x7f开头的是堆地址,0x0开头是空指针);
  3. 回看代码,重点查malloc、free、指针赋值三个位置;
  4. 如果崩溃位置是库函数(比如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 综合自测题:三道题验证你是否真会了

如果前面的代码你都写了一遍,我建议做三道综合题自测。这几道题综合了多种结构混用,很能检验你对内存和指针的掌控:

  1. 用两个栈实现一个队列,要求入队和出队的均摊复杂度O(1)。这题的核心是“一个栈管入队、一个栈管出队”,出队时倒一次,把后进先出倒成先进先出。
  2. 判断一条单链表是否有环。用快慢指针,快指针一次走两步,慢指针一次走一步,有环就一定会相遇。
  3. 给定一个链表,每K个节点翻转一次,不足K个保持不变。这题除了反转链表,还要求精确的区间定位,综合考察链表操作用利索不利索。

你能独立把这三道题写对,说明链表栈队列的基础已经算扎实了。注意第三题是最难的,我建议先画图再动手。

6. 测试与验证思路:怎么确定你写的结构真的没问题

6.1 先写测试,再写实现:一个反直觉但好用的顺序

我自己的习惯是先写一个简单的测试驱动,把整个接口的预期行为定义清楚,然后再补实现。这个思路是从某次项目里学来的,那次我先写了实现,结果测试不通过,我花了两天调试才发现是“测试写错了”而不是“实现写错了”。从那以后,我什么都不写,先写一份测试用例。

比如链表,我会先写:创建一个空链表,断言head == NULL;头插三个值,断言链表长度和每个节点值;删除头节点,断言新头节点值正确。这些测试代码本身不依赖具体实现,接口不变测试就不变,实现怎么改都行。测试先行的最大好处是,你把“正确行为”定义在前面,后面写代码的时候就知道边界该怎么处理了。

6.2 边界用例清单:表格化避免漏测

实际测试中我通常按下面的用例清单来跑:

用例ID场景输入期望结果
01空链表头插head=NULL, val=1head指向新节点,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语言亲手写过一遍,才算真正理解指针和内存管理的分量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询