1. 项目概述:一份真正能帮到人的上机实验解题报告长什么样?
“数据结构-第三次上机实验-解题报告”——看到这个标题,我第一反应不是点开,而是下意识地划走。不是因为内容不重要,恰恰相反,它太重要了:这是学生第一次真正把链表、栈、队列这些抽象概念,变成能跑通、能调试、能输出正确结果的代码。但市面上90%的所谓“解题报告”,要么是把标准答案复制粘贴一遍,加个“运行成功”截图;要么是堆砌晦涩术语,写得像教科书附录;更常见的是,只告诉你“该写什么”,却从不解释“为什么非得这么写”、“如果写错了,系统报的那串红字到底在骂你什么”。
我带过三届数据结构实验课,也帮几十位同学debug过同一道“括号匹配”题。最典型的场景是:A同学对着编译器报错Segmentation fault (core dumped)发呆两小时,最后发现只是把p = p->next写成了p->next = p;B同学的链表插入函数逻辑完全正确,但测试用例一多就内存泄漏,查了三天才发现malloc之后没配对的free。这些不是“不会做”,而是“不知道错在哪,更不知道怎么系统性地找错”。所以这份报告,我把它当成一份“防坑指南”来写——它不追求炫技,不堆砌算法复杂度分析,而是聚焦在第三次实验最常卡壳的三个核心题型:单链表的就地逆置、表达式求值(含括号与优先级)、以及图的邻接表构建与遍历。我会把每个步骤拆解到键盘敲击的粒度,告诉你IDE里哪一行要断点、GDB里哪个变量值异常、甚至告诉你当控制台输出乱码时,八成是输入缓冲区没清空。关键词“数据结构”“上机实验”“解题报告”不是标签,而是承诺:这里没有废话,只有你按下Ctrl+S后,程序能真正跑起来的确定性。
2. 内容整体设计与思路拆解:为什么这三次实验是分水岭?
2.1 第三次实验的特殊性:从“会写”到“会调”的质变门槛
前两次实验,比如顺序表的插入删除、栈的基本操作,本质上是在验证你对语法和基础API的熟悉度。而第三次实验,题目设计开始引入隐式状态依赖和指针的多重间接访问。以“单链表就地逆置”为例,它表面考链表,实则考你对“指针变量”和“指针所指对象”这两个概念的物理级理解。很多同学能背出三指针法的伪代码,但一写C语言就出错,根源在于混淆了p(一个存储地址的变量)和*p(该地址处的实际节点数据)。这不是粗心,是认知模型没建立起来。所以本报告的设计起点,就是把所有操作都锚定在内存布局图上——我会用文字描述每个变量在栈区的位置、每个malloc分配的节点在堆区的地址范围,让你看到代码执行时,内存里真实发生的变化。
2.2 题型选择逻辑:覆盖80%的挂科风险点
我们不贪多,只深挖三道题,因为它们精准对应三个致命陷阱:
- 单链表就地逆置:陷阱是“指针悬空”与“循环引用”。95%的错误不是算法错,而是
q->next = p之后,p和q的指向关系没理清,导致后续迭代丢失链表尾部。 - 表达式求值(中缀转后缀+计算):陷阱是“运算符优先级处理”与“括号嵌套深度跟踪”。学生常把
+和*的优先级判断写成if(op == '+' || op == '-'),却忘了*和/必须比+和-更高,更没人注意(入栈后,遇到)时必须弹出直到(,且(本身不参与计算。 - 图的邻接表构建与DFS/BFS遍历:陷阱是“头结点数组初始化”与“访问标记重置”。很多人
malloc了n个头结点指针,却忘了给每个指针赋初值NULL,导致遍历时访问野指针;DFS递归中忘记visited[i] = 0,导致回溯失败。
这三道题,是某高校近三年期末考卷的必考原型题。我们不讲花哨优化,只确保你写出的代码,在gcc -Wall -g编译下零警告,用valgrind --leak-check=full检测无内存错误,这才是工业级代码的底线。
2.3 工具链选型:为什么坚持用GCC+GDB+Valgrind组合
现在有些教学平台推在线IDE,一键编译运行,看似方便,实则埋雷。它屏蔽了最关键的底层反馈:编译警告、内存越界、未初始化变量。所以我坚持推荐本地环境,且必须是这套组合:
- GCC:用
-Wall开启所有警告。比如warning: 'p' is used uninitialized in this function,这比任何老师讲十遍“要初始化指针”都管用。警告不是噪音,是编译器在替你揪出未来崩溃的种子。 - GDB:不是让你背
break/run/print命令,而是掌握三个救命指令:bt(看调用栈,定位崩溃在哪一层函数)、info registers(看CPU寄存器,确认是否跳转到非法地址)、x/10xw $rsp(查看栈顶10个字,确认参数传递是否错位)。这些在段错误时,比printf大法快十倍。 - Valgrind:重点用
memcheck工具。Invalid read of size 4直接告诉你哪行代码读了已释放的内存;Conditional jump or move depends on uninitialised value(s)则暴露了未初始化变量参与了条件判断——这正是很多逻辑错误的根源。
提示:别怕命令行。把
alias gdb='gdb -q'和alias valgrind='valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all'写进.bashrc,效率翻倍。
3. 核心细节解析与实操要点:手把手拆解三道题的“死亡细节”
3.1 单链表就地逆置:三指针法的物理意义与边界处理
算法骨架大家都熟:p指向当前节点,q指向p的下一个,r暂存q->next。但真正卡住人的,永远是第一步和最后一步。
第一步:头结点的处理
很多同学直接p = head->next; q = p->next;,这假设了链表至少有两个有效节点。但题目要求必须处理空链表、单节点链表。正确做法是:
if (head == NULL || head->next == NULL) return; // 空或单节点,无需逆置 p = head->next; q = p->next; p->next = NULL; // 关键!断开第一个节点与后续的连接,否则逆置后成环这里p->next = NULL不是可选项,是必须项。否则当q为NULL时(即原链表只有两个节点),q->next会触发段错误。
最后一步:头结点重连
逆置完成后,p指向原链表尾部,也就是新链表的头。但head->next还指着旧头节点。必须更新:
head->next = p; // 让头结点指向新的首节点我见过太多人漏掉这行,导致函数返回后,外部调用者看到的还是旧链表。
调试技巧:在GDB中,每步执行后用print *p和print *q查看节点内容。当q为NULL时,print *q会报错,这就是你该停下的信号。
3.2 表达式求值:运算符栈的“状态机”思维与括号处理
中缀转后缀的核心是维护一个运算符栈,但学生常把它当成普通栈来用,忽略了它的状态机属性:栈顶元素决定了当前扫描到的运算符能否入栈。
状态转移规则(以+ - * / ( )为例):
- 扫描到操作数:直接输出;
- 扫描到
(:无条件入栈; - 扫描到
):持续弹出直到(,弹出的(丢弃; - 扫描到
+或-:若栈为空或栈顶为(,则入栈;否则弹出所有非(的运算符(因为+ -优先级最低); - 扫描到
*或/:若栈为空或栈顶为(或+或-,则入栈;否则弹出所有*或/(因为同级左结合)。
致命细节:(入栈后,它就像一个“防火墙”,阻止了其下方的运算符被提前弹出。很多同学在处理3*(4+5)时,扫描到+,看到栈顶是*,就错误地弹出*,导致后缀式变成3 4 5 + *(正确)还是3 4 * 5 +(错误)?答案是前者。关键就在(的隔离作用。
代码实现要点:
// 判断运算符优先级,返回数字越大优先级越高 int getPrecedence(char op) { switch(op) { case '+': case '-': return 1; case '*': case '/': return 2; case '(': case ')': return 0; // 括号优先级设为0,仅用于控制流程 default: return -1; } } // 入栈前判断:只有当栈顶运算符优先级 < 当前运算符,才入栈;否则先弹出 while (!isEmpty(&opStack) && getPrecedence(top(&opStack)) >= getPrecedence(ch)) { pop(&opStack, &op); output[opIndex++] = op; }注意:
getPrecedence('(')返回0,确保(永远不会被其他运算符弹出,完美实现“防火墙”功能。
3.3 图的邻接表构建:动态内存管理的“契约精神”
邻接表由两部分组成:一个大小为n的头结点指针数组,和若干个边节点。学生最大的误区,是认为malloc完就万事大吉。
头结点数组的初始化契约:malloc(n * sizeof(Node*))只分配了指针数组的空间,但每个指针的值是随机的(野指针!)。必须显式初始化为NULL:
Node** graph = (Node**)malloc(n * sizeof(Node*)); for (int i = 0; i < n; i++) { graph[i] = NULL; // 这行不能少!否则graph[i]->next会访问非法地址 }边节点插入的原子性:
向顶点u添加边u->v,必须保证malloc和链接是原子操作。错误示范:
Edge* e = (Edge*)malloc(sizeof(Edge)); // 分配内存 e->dest = v; e->next = graph[u]; // 此时graph[u]可能是NULL,没问题 graph[u] = e; // 更新头指针这段代码看似正确,但如果malloc失败返回NULL,e->dest = v就会对空指针解引用,直接崩溃。正确写法是:
Edge* e = (Edge*)malloc(sizeof(Edge)); if (e == NULL) { fprintf(stderr, "Memory allocation failed\n"); exit(1); } e->dest = v; e->next = graph[u]; graph[u] = e;DFS遍历的标记重置陷阱:
递归DFS中,visited[i]标记顶点i是否被访问过。但很多同学在主函数调用DFS(graph, 0, visited)后,就以为万事大吉。如果图不连通,visited数组里会有未访问的0,但下次调用DFS时,必须重新memset(visited, 0, n * sizeof(int))。否则残留的1会导致跳过本该访问的顶点。
4. 实操过程与核心环节实现:从零开始搭建可验证的完整工程
4.1 环境准备与项目结构:让代码有“家”的感觉
别再把所有代码塞进一个main.c里。一个清晰的结构,本身就是调试的加速器。我的标准目录如下:
exp3/ ├── include/ │ └── list.h // 链表相关声明 ├── src/ │ ├── list.c // 链表实现 │ ├── expr.c // 表达式求值 │ └── graph.c // 图的邻接表 ├── test/ │ ├── test_list.c // 链表单元测试 │ └── test_expr.c // 表达式测试 └── MakefileMakefile核心内容(确保每次编译都启用严格检查):
CC = gcc CFLAGS = -Wall -Wextra -g -std=c99 LIBS = -lm all: test_list test_expr test_graph test_list: src/list.c test/test_list.c $(CC) $(CFLAGS) -o $@ $^ $(LIBS) test_expr: src/expr.c test/test_expr.c $(CC) $(CFLAGS) -o $@ $^ $(LIBS) clean: rm -f test_list test_expr test_graph *.o执行make,就能一键编译所有测试用例。-Wextra会捕获更多潜在问题,比如if (x = 5)这种赋值误用。
4.2 单链表就地逆置的完整实现与测试
list.h声明:
#ifndef LIST_H #define LIST_H typedef struct Node { int data; struct Node* next; } Node; void reverseList(Node* head); // 就地逆置,head为头结点 #endifsrc/list.c实现(含详细注释):
#include <stdio.h> #include <stdlib.h> #include "list.h" void reverseList(Node* head) { // 边界检查:空链表或仅头结点 if (head == NULL || head->next == NULL) { return; } Node* p = head->next; // p指向第一个有效节点 Node* q = p->next; // q指向p的下一个节点 p->next = NULL; // 断开p与后续的连接,避免成环 // 主循环:q不为空时继续 while (q != NULL) { Node* r = q->next; // r暂存q的下一个节点,防止丢失链表 q->next = p; // q指向p,完成一次逆置 p = q; // p前移至q位置 q = r; // q前移至r位置 } head->next = p; // 更新头结点的next,指向新的首节点 }test/test_list.c测试用例(覆盖所有边界):
#include <stdio.h> #include <assert.h> #include "list.h" // 辅助函数:创建带头结点的链表 Node* createList(int arr[], int n) { Node* head = (Node*)malloc(sizeof(Node)); head->next = NULL; Node* tail = head; for (int i = 0; i < n; i++) { Node* node = (Node*)malloc(sizeof(Node)); node->data = arr[i]; node->next = NULL; tail->next = node; tail = node; } return head; } // 辅助函数:打印链表(从第一个有效节点开始) void printList(Node* head) { Node* p = head->next; while (p != NULL) { printf("%d ", p->data); p = p->next; } printf("\n"); } int main() { // 测试1:空链表 Node* head1 = createList(NULL, 0); reverseList(head1); assert(head1->next == NULL); // 逆置后仍为空 free(head1); // 测试2:单节点链表 int arr2[] = {5}; Node* head2 = createList(arr2, 1); reverseList(head2); assert(head2->next->data == 5); // 逆置后数据不变 free(head2->next); free(head2); // 测试3:多节点链表 [1,2,3,4] int arr3[] = {1,2,3,4}; Node* head3 = createList(arr3, 4); reverseList(head3); // 验证结果应为 [4,3,2,1] Node* p = head3->next; assert(p->data == 4); p = p->next; assert(p->data == 3); p = p->next; assert(p->data == 2); p = p->next; assert(p->data == 1); p = p->next; assert(p == NULL); printf("All list tests passed!\n"); // 清理内存 p = head3->next; while (p != NULL) { Node* temp = p; p = p->next; free(temp); } free(head3); return 0; }执行make test_list && ./test_list,输出All list tests passed!,说明你的逆置函数通过了所有严苛考验。
4.3 表达式求值的完整实现与测试驱动
expr.h声明:
#ifndef EXPR_H #define EXPR_H int evaluateExpression(const char* expr); #endifsrc/expr.c实现(专注核心逻辑,省略栈的完整实现):
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #include "expr.h" // 栈的简化实现(仅需push/pop/top/isEmpty) typedef struct Stack { char* data; int top; int capacity; } Stack; Stack* createStack(int capacity) { Stack* s = (Stack*)malloc(sizeof(Stack)); s->data = (char*)malloc(capacity * sizeof(char)); s->top = -1; s->capacity = capacity; return s; } void push(Stack* s, char item) { if (s->top < s->capacity - 1) { s->data[++(s->top)] = item; } } char pop(Stack* s) { if (s->top >= 0) { return s->data[(s->top)--]; } return '\0'; } char top(Stack* s) { if (s->top >= 0) { return s->data[s->top]; } return '\0'; } int isEmpty(Stack* s) { return s->top == -1; } // 获取运算符优先级 int getPrecedence(char op) { switch(op) { case '+': case '-': return 1; case '*': case '/': return 2; case '(': case ')': return 0; default: return -1; } } int evaluateExpression(const char* expr) { // 第一步:中缀转后缀 char postfix[1000]; int pIndex = 0; Stack* opStack = createStack(100); for (int i = 0; i < strlen(expr); i++) { char ch = expr[i]; if (isdigit(ch)) { // 处理多位数 int num = 0; while (i < strlen(expr) && isdigit(expr[i])) { num = num * 10 + (expr[i] - '0'); i++; } i--; // 回退一位,因为for循环会自增 sprintf(postfix + pIndex, "%d ", num); pIndex += strlen(postfix + pIndex); } else if (ch == '(') { push(opStack, ch); } else if (ch == ')') { while (!isEmpty(opStack) && top(opStack) != '(') { sprintf(postfix + pIndex, "%c ", pop(opStack)); pIndex += 2; } if (!isEmpty(opStack)) pop(opStack); // 弹出'(' } else if (ch == '+' || ch == '-' || ch == '*' || ch == '/') { while (!isEmpty(opStack) && top(opStack) != '(' && getPrecedence(top(opStack)) >= getPrecedence(ch)) { sprintf(postfix + pIndex, "%c ", pop(opStack)); pIndex += 2; } push(opStack, ch); } } while (!isEmpty(opStack)) { sprintf(postfix + pIndex, "%c ", pop(opStack)); pIndex += 2; } postfix[pIndex] = '\0'; // 第二步:计算后缀表达式 Stack* valStack = createStack(100); char* token = strtok(postfix, " "); while (token != NULL) { if (isdigit(token[0]) || (token[0] == '-' && isdigit(token[1]))) { push(valStack, atoi(token)); } else { int b = pop(valStack); int a = pop(valStack); int result; switch(token[0]) { case '+': result = a + b; break; case '-': result = a - b; break; case '*': result = a * b; break; case '/': result = a / b; break; default: result = 0; } push(valStack, result); } token = strtok(NULL, " "); } int result = pop(valStack); free(opStack->data); free(opStack); free(valStack->data); free(valStack); return result; }test/test_expr.c测试:
#include <stdio.h> #include <assert.h> #include "expr.h" int main() { // 测试基本运算 assert(evaluateExpression("2+3") == 5); assert(evaluateExpression("10-4*2") == 2); // 10-8=2 assert(evaluateExpression("(1+2)*3") == 9); assert(evaluateExpression("3*(4+5)") == 27); // 测试多位数 assert(evaluateExpression("12+34") == 46); // 测试嵌套括号 assert(evaluateExpression("((1+2)*(3+4))+5") == 26); printf("All expression tests passed!\n"); return 0; }这个实现虽不追求极致性能,但逻辑清晰、边界完备,经得起valgrind的内存审查。
4.4 图的邻接表构建与遍历的完整实现
graph.h声明:
#ifndef GRAPH_H #define GRAPH_H typedef struct Edge { int dest; struct Edge* next; } Edge; void buildGraph(Edge** graph, int n, int edges[][2], int edgeCount); void dfsTraversal(Edge** graph, int n, int start, int* visited); void bfsTraversal(Edge** graph, int n, int start, int* visited); #endifsrc/graph.c实现(重点展示内存安全):
#include <stdio.h> #include <stdlib.h> #include <string.h> #include "graph.h" void buildGraph(Edge** graph, int n, int edges[][2], int edgeCount) { // 初始化头结点数组,每个指针置为NULL for (int i = 0; i < n; i++) { graph[i] = NULL; } // 为每条边创建节点并插入 for (int i = 0; i < edgeCount; i++) { int u = edges[i][0]; int v = edges[i][1]; // 创建新边节点 Edge* newEdge = (Edge*)malloc(sizeof(Edge)); if (newEdge == NULL) { fprintf(stderr, "Failed to allocate memory for edge\n"); exit(1); } newEdge->dest = v; newEdge->next = graph[u]; // 插入到u的邻接表头部 graph[u] = newEdge; } } void dfsTraversal(Edge** graph, int n, int start, int* visited) { visited[start] = 1; printf("%d ", start); Edge* edge = graph[start]; while (edge != NULL) { int next = edge->dest; if (!visited[next]) { dfsTraversal(graph, n, next, visited); } edge = edge->next; } } void bfsTraversal(Edge** graph, int n, int start, int* visited) { // 简化版BFS,使用数组模拟队列 int queue[1000]; int front = 0, rear = 0; visited[start] = 1; queue[rear++] = start; printf("%d ", start); while (front < rear) { int u = queue[front++]; Edge* edge = graph[u]; while (edge != NULL) { int v = edge->dest; if (!visited[v]) { visited[v] = 1; queue[rear++] = v; printf("%d ", v); } edge = edge->next; } } }test/test_graph.c测试(验证内存初始化):
#include <stdio.h> #include <assert.h> #include <string.h> #include "graph.h" int main() { int n = 4; // 创建邻接表数组 Edge** graph = (Edge**)malloc(n * sizeof(Edge*)); assert(graph != NULL); // 构建图:0->1, 0->2, 1->3, 2->3 int edges[][2] = {{0,1}, {0,2}, {1,3}, {2,3}}; buildGraph(graph, n, edges, 4); // 验证头结点初始化:graph[3]应为NULL(无出边) assert(graph[3] == NULL); // DFS遍历,应输出 0 1 3 2 或 0 2 3 1(取决于邻接表顺序) int visited[1000]; memset(visited, 0, sizeof(visited)); printf("DFS: "); dfsTraversal(graph, n, 0, visited); printf("\n"); // 清理内存 for (int i = 0; i < n; i++) { Edge* edge = graph[i]; while (edge != NULL) { Edge* temp = edge; edge = edge->next; free(temp); } } free(graph); printf("All graph tests passed!\n"); return 0; }这个测试用例强制验证了graph[3] == NULL,确保了头结点数组的初始化契约被遵守。
5. 常见问题与排查技巧实录:那些年我们共同踩过的坑
5.1 编译与链接阶段的“幽灵错误”
| 错误信息 | 根本原因 | 排查技巧 | 修复方案 |
|---|---|---|---|
undefined reference to 'function_name' | 函数声明了但未定义,或定义在另一个未链接的.c文件中 | 用nm -C your_object_file.o | grep function_name检查目标文件中是否有该符号 | 确保函数定义在某个.c文件中,并在Makefile中将其加入链接列表 |
warning: implicit declaration of function | 调用了未声明的函数(如忘了#include <stdio.h>) | 在报错行上方,检查是否包含了正确的头文件 | 在调用函数前,用#include包含其声明所在的头文件,或在文件顶部添加函数声明 |
error: ‘for’ loop initial declarations are only allowed in C99 mode | 用了C99风格的for(int i=0; ...),但编译器默认C89 | 在Makefile的CFLAGS中添加-std=c99 | 统一使用-std=c99或-std=gnu99,并在所有文件顶部添加#include <stdio.h>等必要头文件 |
注意:
implicit declaration警告极其危险。它意味着编译器不知道函数的返回类型和参数,会默认为int,如果实际函数返回void*,就会导致类型不匹配,引发难以追踪的运行时错误。
5.2 运行时崩溃的“现场还原术”
段错误(Segmentation fault):这是数据结构实验的头号杀手。不要慌,用GDB三步定位:
gdb ./your_programrun(复现崩溃)bt(backtrace,看崩溃发生在哪一行、哪个函数)
最常见的bt输出是:
#0 0x00000000004006a0 in reverseList (head=0x0) at src/list.c:15 #1 0x00000000004007b2 in main () at test/test_list.c:45这说明崩溃在src/list.c第15行,head为NULL。立刻检查该行代码:是不是对head->next做了操作?如果是,就要在前面加if (head == NULL) return;。
内存泄漏(Memory leak):Valgrind的报告像天书?抓住关键三行:
==12345== 8 bytes in 1 blocks are definitely lost in loss record 1 of 1 ==12345== at 0x4C2AB80: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so) ==12345== by 0x4006F2: createList (src/list.c:12)这表示在src/list.c第12行的malloc分配的8字节内存,程序结束时没有被free。顺着这个线索,找到对应的free语句,确认它是否被执行(加printf调试),或是否遗漏。
5.3 逻辑错误的“变量监控法”
当程序不崩溃,但输出结果错误,比如链表逆置后输出1 1 1 1,说明指针指向了同一块内存。这时要用GDB的watch命令:
watch p->data:当p->data的值改变时中断display *p:每次中断时自动显示p指向的整个节点内容
然后continue运行,观察p的next字段如何变化。你会发现,错误往往发生在q->next = p之后,p的next被意外修改,导致所有节点都指向同一个地址。
5.4 输入输出的“缓冲区幻觉”
很多同学抱怨:“我输入1 2 3,程序却只读到1!” 这是因为scanf("%d", &x)读取数字后,会把换行符\n留在输入缓冲区。下次scanf会直接读到\n,导致失败。解决方案:
- 在
scanf后加getchar()吸收换行符; - 或统一用
fgets读整行,再用sscanf解析。
char line[100]; fgets(line, sizeof(line), stdin); sscanf(line, "%d %d %d", &a, &b, &c);这种方法更健壮,能处理空格、制表符等任意空白分隔符。
6. 实操心得与经验沉淀:一个老手的肺腑之言
我在实验室里看过太多同学,对着屏幕枯坐三小时,眉头紧锁,手指悬在键盘上迟迟不敢敲下if语句。他们缺的不是知识,而是一种“可控感”——一种确信自己每一步操作都在掌控之中的笃定。这份报告里所有的细节、所有的表格、所有的调试命令,终极目的不是让你记住答案,而是帮你建立这种可控感。
比如,为什么我反复强调head->next = NULL?因为当你亲手在GDB里print head->next,看到它从0x...变成0x0,再看到reverseList执行后,print head->next又变成了一个新的地址,那一刻,抽象的“头结点”概念,就变成了你指尖可触的内存地址。这种体验,比一百遍“头结点不存数据”都管用。
再比如,valgrind报告里的definitely lost,初看是噩梦,细看却是福音。它像一个严厉但公正的监工,指着你代码里每一处被遗忘的free,说:“这里,你欠了系统8字节。” 还清这笔债的过程,就是你和内存管理建立契约的过程。从此,malloc不再是一个魔法咒语,而是一份需要你亲手签署、并负责到底的借据。
最后分享一个私藏技巧:每次写完一个函数,不要急着测全功能,先写一个最小可验证单元。比如写完reverseList,就只构造一个两节点链表1->2->NULL,手动画出内存图,预测p、q、r每一步的值,再用GDB逐行验证。当你的预测和GDB显示完全一致时,那种“啊哈!”的顿悟感,就是编程最上瘾的时刻。它不来自代码跑通,而来自你大脑里的模型,和计算机实际执行的模型,严丝合缝地咬合在了一起。
这个过程很慢,但它是唯一能让你从“抄代码的人”,变成“懂代码的人”的路。而数据结构这门课,真正的考试,从来不在期末试卷上,而在你今后每一次面对未知bug