学数据结构去啃教材,翻到链表的插入和删除那一节,很多人会突然卡住:node->next = p->next这行到底在干什么?**list这个参数前面的两个星号又是几个意思?书上的单链表代码明明看懂了,自己一运行就段错误。坦白说,这些卡壳跟数据结构本身的关系不大,问题普遍出在 C 语言基础上——指针还停留在“听说过”,内存分配没亲手写过几次,函数传参搞不清“引用”和“值”的差别,一进数据结构这扇门,所有欠账就一起找上门了。
这篇文章就是来帮你把这些欠账慢慢清掉的。我按照《数据结构(C语言版)》这类教材里真正会用到的程度,把必须掌握的 C 语言知识点挑出来讲,不贪多,不讲偏冷门的语法,先把链表、栈、队列、二叉树、图和排序这几个核心结构背后的 C 语言功底打扎实。无论你是备考 408、期末复习,还是刚开始学数据结构和算法,都可以对着这份清单自查一遍——你不会的东西,基本都在这里了。
1. 指针之根:把地址和内存模型当作理解一切的主线
指针学不明白,数据结构里至少有七成代码读不下去。这句话不是危言耸听,你可以去翻任何一本数据结构教材的链表实现,里面几乎没有一行离得开*和&。先把指针的本质、类型、解引用和野指针这几个概念串成一条线,后面的代码就不再是天书。
1.1 指针变量存的是一个“门牌号”,不是具体数据
计算机的内存说白了就是一条非常长的带编号的街道,每个字节有一个编号,我们把这个编号叫“地址”。你定义一个int a = 10;,系统会在内存里找一块区域,这块区域的起始地址是多少,a就住在那里。而指针变量做的事很简单:它存的就是另一个变量的门牌号。
int a = 10; int *p; // 声明一个指针变量,它将来要存放int类型变量的地址 p = &a; // &意思是“取地址”,p现在存的是a的门牌号这里的p和a是两回事。p存的是地址,a存的是数值。打印printf("%p\n", p);你看到的是十六进制地址;打印printf("%d\n", *p);你才拿到地址里保存的数据 10。很多初学者混淆“p 的地址”和“随便给 p 一个数”,是因为没有建立这层记忆模型——指针变量是普通变量,它有自己存储的空间,只不过它里面的“值”是别人的门牌号。
什么叫“别人的门牌号”?就是不能随便写。你要是写p = 12345;,编译器通常不报错,但是后面一旦执行*p,程序就会尝试去访问地址 12345 这块区域——那块区域多半不存在或者没权限,结果是段错误,程序当场崩溃。所以指针有个铁律:指针指向哪里,必须由你对它赋值或者让它指向一个合法对象来决定,不能自己瞎猜地址。
1.2 指针的类型不能丢:决定了解引用的“步长”
同为地址,为什么int *p和char *c不能混着用?因为地址只告诉你门牌号,不告诉你门的宽度和房间的格局。int在常见平台占 4 字节,char占 1 字节,double占 8 字节。当你写*p,编译器需要知道从那个地址开始,连续读取多少字节,又按什么规则解释这些字节。
还有一个更隐蔽的问题:指针加减运算里的步长。p + 1加的并不是 1 个字节,而是“一个指针类型”的大小。比如int *p指向数组首元素,p + 1实际上是地址加了 4 个字节,指向下一个整数。这就是数组能用指针遍历的根本原因。在数据结构里,遍历动态数组、遍历顺序表,底层全在玩这套步长逻辑。
1.3&和*:互为逆转的两个操作
&a表示取 a 的门牌号;*p表示顺着 p 存的门牌号去屋子里把那个值取出来。这两个操作互为逆运算,方向感练不出来,代码就没法读。
int a = 10; int *p = &a; // p指向a *p = 20; // 既然是去a的屋子的门牌号,那就在a的屋子里写入20 printf("%d\n", a); // 打印20这就是“通过指针间接修改变量”的雏形。后面学函数时,传入一个“指向某结点的指针”来修改结点内部字段,和这里的原理一模一样。区别只是对象从int换成了结构体,从int *p;换成了Node *node;。
1.4 野指针、空指针与段错误:数据结构代码里的头号杀手
“野指针”是指针变量里保存的地址是一个完全随机的、不可预期的值,通常因为变量没有初始化,或者内存被释放后指针没有处理。它比“空指针”更危险:空指针好歹是 0,你还能判断;野指针指向哪里你根本不知道,*p一旦执行,程序去访问一个随机地址,经常直接段错误,也可能不报错但悄悄改坏了相邻内存——这种错误极难排查。
我在带学生做实验报告的时候,最常见的问题就是这样:
Node *p; // 没初始化,是野指针 p->data = 1; // 运行到这儿崩或者莫名其妙正确习惯是三句话:第一,定义指针就初始化,不知道指哪就先设为NULL;第二,free之后立刻把指针置为NULL,防止悬空;第三,使用前先判断指针是否为NULL。
Node *p = NULL; if (p != NULL) { p->data = 1; // 安全 }数据结构的代码量不大但环环相扣,一个野指针带走整棵二叉树。养成“初始化 + 判空”的习惯,比会写一百行高级语法都管用。
1.5 调试指针问题:别只靠肉眼瞪,上工具
崩了就崩溃了,怎么定位?三个工具按顺序来。第一是日志大法:关键节点打印%p,看指针的值是否符合预期;第二是用gdb跑,bt看调用栈,p直接打印某个指针的值和它指向的内容;第三是 Linux 下用valgrind查内存问题,它会明确告诉你哪一行发生了Invalid write、哪一块内存泄漏了。
这三样中学一个都行,但从经验来看,学数据结构阶段尽早接触 gdb 对排错提升最大。我自己写过不少排序和树的调试,没有 gdb 的时候全靠加打印重跑,效率极低。学会看一次指针的地址变化过程,比你背十遍指针语法都有用。
2. 结构体与typedef:数据结构的“细胞”如何构造
链表结点、树的结点、图里边的存储项,它们都不只包含一个数据,而是一组数据(数据域加若干指针域)。把这种“打包一项数据”的语法玩熟,是接触数据结构代码的第一关。
2.1 用结构体把散的数据打包成“一个结点”
结构体语法不复杂,但它在数据结构里的角色很关键:一个结点就是一个结构体实例。
struct Node { int data; // 数据域,存业务数据 struct Node *next; // 指针域,存下一个结点的地址 };有了这个定义,你才能写后面的p = p->next;。如果不理解结构体,你只能想象成“一个 int 后面跟一个指针”的两块内存,但写代码时必须用struct Node把它们绑定成一个类型——否则字典、链表、树的代码根本组织不起来。
值得注意:struct Node *next这种写法,在定义struct Node自己的内部出现“指向自己的指针”,这是合法的。编译器已经知道struct Node这个类型占用的存储中包含一个指针,指针本身就 8 个字节,和指向哪个类型没有关系,所以允许在尚未定义完时就声明指向自己的指针。这叫“自引用结构体”,是链表和树节点最核心的语法基础。
2.2 typedef:让接口更像“数据类型”
typedef的作用是给已有类型换个更短名字。如果你每次写struct Node *p;,代码很快就冗长得让人头疼。把它缩写掉,读起来就像在设计一个真正的“结点类型”。
typedef struct Node { int data; struct Node *next; } Node, *LinkList;这里我直接把常见教材的写法放出来了。Node成为“结构体本身”的别名,LinkList是“指向结构体的指针”的别名。从此声明一个链表头指针只需要写LinkList head = NULL;,声明一个结点指针写Node *p;。表面上看只是少打几个字,实际意义是接口清晰了:看到LinkList就知道这是个链表头指针,看到Node *就知道是结点指针。
在《大话数据结构》和很多考研参考书里,这种 typedef 风格很常见,408 考试的代码填空也喜欢考。你一定要能看懂两种写法之间的等价性,尤其要理解LinkList本质上Node *的同义词。
2.3 箭头运算符->与点运算符.的区别
结构体变量访问成员用.,指针访问成员用->。这个规则一年级就应该会,但很多人在学数据结构时才第一次大量使用->。
Node node1; node1.data = 1; // 用点,node1是结构体变量 Node *p = &node1; p->data = 2; // 用箭头,p是指针本质是一个语法糖:p->data等价于(*p).data。不要小看这一点,后面写链表的头插法时,head->next = p;和(*head).next = p;任意一种写都行,但整段代码大量用箭头后会形成一种固定的阅读节奏,习惯不了就会觉得教材代码很别扭。
2.4 结构体的 sizeof:别总是手动算字节
sizeof(struct Node)到底是多少?新手最容易犯的错是拿成员大小加起来:一个 int 4 字节,一个指针 8 字节,加起来 12?实际上在 64 位平台,受内存对齐影响,结果可能是 16 字节。因为 CPU 读取内存有对齐要求,编译器会在成员之间插入填充字节。
struct A { char c; // 1字节 int i; // 4字节 }; // 许多平台下 sizeof(struct A) == 8,不是5这个知识点在数据结构的实际代码中,主要体现在malloc申请结点空间时的写法上:
Node *p = (Node *)malloc(sizeof(Node));一定要写sizeof(Node),不能写sizeof(int) + sizeof(Node*)之类的硬凑。哪怕你此刻能用手算是多少,也很难判断不同平台有没有填充对齐的区别。一句话:sizeof是用来求的,不是让你心算的。
3. 动态内存管理:malloc、free 与堆区生存周期
链表的每个结点、树递归创建的每个结点,都不是编译器自动分配的栈变量,而是你主动去堆上申请的一块内存。学数据结构之前,我建议先把 malloc/free 玩熟,否则写出来的程序要么内存泄漏,要么 free 之后还去访问已经失效的内存。
3.1 栈上和堆上的内存有什么不同
一个局部变量(比如Node n;)在栈上分配,函数结束就自动销毁。但链表结点的宿命是要长期存活,直到你主动删除,它通常是函数A里创建、函数B里被遍历、函数C里被销毁的。这种生命周期不能靠在函数内部定义局部变量实现,因为函数一返回它就不存在了。
堆上内存不一样:malloc申请的空间由你决定什么时候释放,free之前它一直都在。代价是没有人帮你回收,忘了free就泄漏。再直白一点:栈是自动收银台,闭店就锁;堆是你租的仓库,交房前一直归你管,自己忘了退租,仓库就一直占着。
3.2 malloc 的基本姿势和错误点
Node *p = (Node *)malloc(sizeof(Node)); if (p == NULL) { // 内存不足,处理错误 return -1; } p->data = 10; p->next = NULL;这里有几个细节值得反复提醒。第一,malloc返回值类型是void *,在 C++ 里必须强转,在 C 里可以不转也能赋值给任何指针类型,但从考试和可读性的角度,很多教材会保留强转写法。第二,查到malloc返回值后立刻判空,尤其是写链表、树的大规模操作时,分配失败不处理很容易在下一行就崩。第三,malloc申请的内存内容未定义,也就是说它的值是随机的垃圾数据,作为一个有经验的写法,最好在 malloc 之后马上初始化字段。
为什么数据结构教材里,你总看到p->next = NULL;?因为如果申请完不这样写,这块内存里可能残留一个野值,后面遍历链表时就会顺着这个野值到处乱跑。
3.3 calloc、realloc 和扩容套路
calloc在 malloc 的基础上把所有字节清零,适合需要初始化为空的情况。它接受两个参数:元素个数和单个元素大小。
int *arr = (int *)calloc(n, sizeof(int)); // 申请n个int,且初始化为0realloc用来扩容或缩容一段已申请的堆内存:
int *newArr = (int *)realloc(arr, newSize * sizeof(int)); if (newArr != NULL) { arr = newArr; }用 realloc 有三个陈年旧坑。第一,它可能把原来的内存搬到另一个更大的区域,返回新地址,原来的指针失效;第二,扩容时新增加部分的字节不保证清零;第三,失败时返回 NULL,而原内存并没有释放——所以别直接写成arr = realloc(arr, ...),一旦失败你的原指针也丢了。正确写法是先存到临时变量,判断成功后再赋回。
这样一套 malloc/calloc/realloc/free,是顺序表、动态数组、哈希表扩容的实现底座。很多人看“顺序表插入”时只盯着下标,却忽略了它底层每一次扩容都走的是 realloc 逻辑。
3.4 valgrind 告诉你:内存泄漏不是看不到,是你从不查
写链表实验,写完能通过几个测试用例就以为结束了?不一定。很多程序隐藏的内存泄漏,要用工具才能看清。Linux 下运行:
valgrind --leak-check=full ./test它会报告definitely lost、indirectly lost等泄漏类型。我在给学生改“数据结构实验报告”时,经常发现他们的程序跑通了,但每个测试用例都泄漏几个结点——连起来一百多字节就丢了。这不是语法错,是没在删除函数里逐个free。从学习的第一天起就习惯用 valgrind 或者系统自带的内存检测工具,能让你的代码质量立刻上一个档次。
4. 函数与传参:理解传值、传址和函数指针
数据结构里函数是最主要的代码组织方式。绝大多数人写链表崩掉的真正原因,不是不会 ListNode,而是没搞清楚“函数传进去的参数究竟能不能被修改”。
4.1 为什么函数内部修改不了外部的变量
C 语言函数参数默认传值。你把变量a传进函数,函数拿到的是a的副本,改的是副本。
void swap(int x, int y) { int temp = x; x = y; y = temp; } int a = 1, b = 2; swap(a, b); // 没用,a和b没变要学会解释这是“快递单信息”的问题:你把收件人的名字抄一份给快递员,快递员怎么改这张单子,都不会影响你家户口本上的名字。想改原变量,必须告诉函数原变量的地址——也就是传指针。
void swap(int *x, int *y) { int temp = *x; *x = *y; *y = temp; } swap(&a, &b); // 这才有效数据结构里大量函数要做同一件事:修改一个结点的指针域。比如单链表的头插法,要修改head的指向,函数参数不能只传Node *head,因为这样你拿到的是head当前值的副本,函数里改的是副本,外面的head不受影响。正确做法是传“指向指针的指针”。
4.2 二级指针:为什么链表插入删除非要用**
看这段接口签名:
int insertNode(LinkList *L, int pos, int value); // 这里的L其实已经是 Node**注意,很多考研教材和严蔚敏版本的书,L的类型是LinkList *,本质上就是二级指针。为什么要这么设计?因为插入时可能要修改头指针本身。如果只传LinkList L,也就是Node *L,在函数内部给L重新赋值,外面的头指针不会变,链表等于没有被更新。
void deleteHeadWrong(Node *head) { // 想删除头结点 head = head->next; // 无效!改了副本 } void deleteHeadRight(Node **head) { if (*head == NULL) return; Node *tmp = *head; *head = (*head)->next; // 通过*head修改外面的头指针 free(tmp); }很多初学者在这一步卡了非常久:为什么单链表的函数,有的传Node *head,有的传Node **head?判定标准其实很简单——如果这个函数要修改指针变量本身(比如把 head 换成一个新结点、删除头结点),就必须多一级指针;如果只是修改指针所指向的结点的字段(比如改 data、改 next 指向),传一级指针就够了。
4.3 函数指针:先理解“函数也是一种可传递的地址”
高级数据结构教材里出现最多的函数指针场景是两个:一个是qsort的比较函数参数,一个是树的遍历函数里传入“对每个结点做什么”的回调函数。
概念上其实不复杂:函数本身也有地址,代码段里的一个位置。函数指针就是存这个位置的变量。格式:
int (*compare)(const void *, const void *);声明一个名为compare的变量,它是一个“函数指针”,指向的函数接受两个const void *参数,返回 int。之后把任何符合这个签名的函数名赋值给它都行:
int cmpInt(const void *a, const void *b) { return (*(int *)a) - (*(int *)b); } int (*compare)(const void *, const void *) = cmpInt;qsort就是这样的泛型排序框架:排序算法层面不关心你比较什么类型,只调用这个传入的函数指针获取两个元素的大小关系。学数据结构里的排序算法时,理解“把比较逻辑抽出来”这个思想,后面写二叉搜索树、哈希表里的自定义哈希函数都能复用。
4.4 const 限定符和只读接口的约定
翻教材的链表代码,常常看到:
int listLength(const Node *head);const Node *head的意思是:只能通过head读取结点内容,不能通过它修改。这样做的好处是接口语义明确:这个函数不会改动链表。训练自己写接口时给“只读函数”加上 const,既防止手误修改,又让读代码的人放心。
const int *p和int *const p的区别经常考:前者是“指向常量的指针”,不能改*p;后者是“常量指针”,p本身不能再指向其他地址。在数据结构代码里,前者常见于传参时“只想遍历不想修改”,后者则少见些,但出现时一定要能读懂。
5. 数组、字符串与边界的暗礁:不报错反而是最要命的
数据结构的大半题目都绕不开数组和字符串。一个额外的\0、一次越界访问,在 C 里往往不报错,而是悄悄破坏数据。这种错误比编译错误危险一百倍。
5.1 数组名是“退化”的指针,但不是指针本身
int a[10]中,a这个表达式在很多上下文中会退化成指向a[0]的指针,但有两处完全不同。
int a[10]; printf("%zu\n", sizeof(a)); // 40,整个数组的大小 printf("%zu\n", sizeof(a + 0)); // 8,退化成指针后的大小还有取地址运算符:&a的类型是“指向整个数组的指针”,和a指向首元素并不完全等价。这个知识点直接关联到二维数组传参、函数内计算数组长度等场景。在顺序表、数组实现的栈和队列代码里,你会经常见到“数组名作为实参传入函数”——此时形参接收的是一个指针,函数内再用sizeof(形参),只能得到 8,而不是数组总字节数。
5.2 数组长度到底怎么传
因为形参是退化的指针,函数内部永远不能通过sizeof(a) / sizeof(a[0])拿到长度。所以几乎所有数据结构函数的接口都要把长度作为参数传进来:
void printArray(int *arr, int len) { for (int i = 0; i < len; i++) { printf("%d ", arr[i]); } }这一行看着简单,却是一大批初学者把“数组越界”写出来的起点:忘了传长度,或者在函数内用了错误的长度方式。写顺序表、栈时,你要记住“数据区指针 + 当前长度 + 容量”是一套固定组合,缺少任一个都无法安全操作数组。
5.3 字符串的\0:长度统计和越界的根源
C 字符串本质上就是字符数组加一个终止符'\0'。strlen数的是'\0'之前的字符个数,不包括'\0'。分配空间时,就要多留一位:
char s[6] = "hello"; // 能放5个字符加一个'\0'在数据结构里做字符串哈希、字典树(Trie)、KMP 算法的匹配时,这类边界问题会成倍出现。比如计算字符串长度然后逐个字符处理,最后一位常常忘了特殊处理。另外一个重要的区别:
char *p = "hello"; // 字符串常量,不能修改 char arr[] = "hello"; // 可修改的本地副本很多人在练习时写了char *p = "hello"; p[0] = 'H';,虽然编译通过,但运行可能崩溃——因为p指向的字面量存放在只读区域。这类坑与数据结构本身无关,却能让你的实验报告卡壳。
5.4 队列和哈希表:边界、取模和容量循环
顺序队列、循环队列的下标计算是 C 语言边界的集中营。比如:
// 循环队列出队 front = (front + 1) % capacity;如果不理解取模运算,就不会明白为什么数组可以“循环”使用。哈希表的开放寻址法几乎全靠pos = (pos + 1) % tableSize;来探测下一个桶。这些地方的边界不只是越界,还包括下标为负、容量用完之后没有扩容、取模时容量为零等等。
顺手列一个高频错误清单:
- 当数组容量为 0 时,任何
size / capacity都是除零错误 - 队列满了再入队,容易覆盖还未取出的元素
- 哈希扩容后忘记重新计算所有元素的新位置
这些错误不是为了刁难你,而是因为 C 不检查边界,它把边界的管理权全部交给了你。学数据结构就是学“如何自己管理好这些边界”。
6. 二维与指针的组合:数组、指针数组和图的邻接表
结构化的数据从来不是单线性的。图、矩阵、二维表这类场景需要对“二维”这个概念有清晰认识,否则一看到邻接矩阵、邻接表就懵。
6.1 真正意义上的二维数组和“一堆指针”完全不同
int matrix[3][4]在内存中是连续排布的,matrix这个表达式的类型是“指向数组的指针”,整个矩阵所占空间是连续的 12 个 int。而int *rows[3]是一个指针数组:它是三个指针,每个指针可以各自指向一段独立空间。
选哪种取决于场景。矩阵大小固定、数据密集,用二维数组字段最合适;稀疏图、邻接表,用指针数组分配出每条链表最合适。两者的传参方式也完全不同。
6.2 二维数组怎么传进函数
函数形参必须写全列数:
void func(int a[][4], int rowNum); // 或者等价的指针形式 void func(int (*a)[4], int rowNum);为什么必须列数?因为编译器要计算a[i][j]的地址:a + i * 列数 + j。不知道列数,就无法定位元素。行数倒可以省略,因为行数不影响元素定位公式。如果你看到有人写void func(int **a)试图接收一个二维数组,多半是错——int **a希望拿到的是一个指针的指针,而二维数组名退化后是“指向数组的指针”,类型并不兼容。
6.3 图的邻接表:指针数组加链表的经典
数据结构 C 语言版的图章节里,邻接表的实现通常是:
#define MAXVEX 100 typedef struct EdgeNode { int adjvex; // 邻接点在数组中的下标 int weight; // 边的权值,可选 struct EdgeNode *next; // 指向下一条边 } EdgeNode; typedef struct VertexNode { char data; // 顶点信息 EdgeNode *firstEdge; // 指向第一条边 } VertexNode, AdjList[MAXVEX]; typedef struct { AdjList vertices; int numVertexes, numEdges; } GraphAdjList;看懂这段代码,需要你同时掌握:结构体定义、自引用指针、typedef 定义数组类型、指针数组的初始化、链表头插法。它几乎是“C 语言基础 + 数据结构”的第一场综合考试。考试里让你画邻接表、写 DFS 遍历,最终都要落在这套 C 语言的组合拳上。
所以 408 复习到图这一块时,如果觉得吃力,不妨回头对照一下:结构体、指针数组、链表插入,这三个基本功是否已经过关了。
7. 三个小自测题和常见错误排查清单
学到这里,光看不练等于白学。与其去找一堆零散练习题,不如按下面三个小自测动手写一遍。全是数据结构最基础的,但覆盖了绝大部分 C 语言基础点。
7.1 自测一:不看书手写单链表
要求:实现初始化、头插法插入、按值查找、按位置删除、输出遍历、销毁整个链表。
这道题覆盖的点:结构体定义、malloc 初始化、二级指针修改头指针、free 释放、边界判断(空表、删除头结点、遍历到尾)。写完运行,再用 valgrind 检查一遍是否完全无泄漏。能一次通过,说明第 1-4 章的基础已经扎实了。
7.2 自测二:用动态数组实现一个栈
要求自己完成扩容。用到realloc、数组指针、top 游标、判空判满。
这道题考察动态内存管理,尤其是扩容时不能丢失旧数据和旧指针的 bug。很多人在realloc上摔跟头后,才对“指针失效”这个概念有切身体会。
7.3 自测三:实现二叉排序树的插入和查找
要求用递归或非递归实现,并处理结点创建、指针赋值、递归中指针的修改。
树结点是两个指针(左孩子右孩子),比链表多一级,因此“在递归中修改指针”会挑战你对传参的理解。很多人在这一步开始明白,为什么树的插入函数要传Node **root或者在返回值中回传新子树。
7.4 出现频率最高的错误排查表
| 症状 | 常见原因 | 排查方向 |
|---|---|---|
| 一运行就段错误 | 野指针、未判空、访问已释放内存 | 打印指针地址、gdb bt 查看调用栈 |
| 程序能跑但结果全是垃圾数 | malloc 后未初始化字段 | 检查是否对新建结点赋初值 |
| 输出链表时死循环 | 尾结点 next 没置 NULL | 追踪创建结点的代码,确认 next 初始化为 NULL |
| 删除后内存不断增加 | 删除结点时漏了 free | valgrind 查 definitely lost |
| 修改头指针无效 | 函数参数只传了一级指针 | 改成传 Node** 或使用返回值 |
| realloc 后数据部分丢失 | 先丢了原指针,或者扩容逻辑错误 | 用临时变量接收 realloc 返回值,失败时不覆盖原指针 |
这几条基本覆盖了初学数据结构最常见的翻车现场。每条我都见过不止三五次,属于非常典型的共性问题。
8. 从哪里开始补:一个可执行的 7 天自检路线
如果有人问我“老师,我现在 C 语言忘了大半,数据结构要开始上课了,我该怎么办?”,我给的建议基本是一条 7 天路线,只要按顺序走完,数据结构课堂上就不会再有“基础跟不上”的空洞感。
第一天:把指针重过一遍,重点是 & 和 *、指针类型与步长、空指针和野指针。写 10 个调试小程序,每个至少用一次指针遍历数组。
第二天:结构体和 typedef。自己定义一个“学生”结构体,包含姓名、学号、成绩、指向下一个学生的指针,然后写两个函数:一个创建学生节点,一个遍历打印链表。注意用 malloc 分配。
第三天:围绕 malloc/free 写内存实验:申请数组、初始化、扩容(realloc)、释放。再试着故意漏 free,用 valgrind 看泄漏报告,对“泄漏”有直观认知。
第四天:函数参数。复习传值和传值的区别,实现一个自己定义结构的“swap”,再实现一个可以修改链头指针的函数。搞清楚什么时候要一级指针、什么时候要二级指针。
第五天:数组字符串。处理一个字符数组,写逆置函数,处理一个整数数组,写一个返回数组中位数的函数,同时把参数长度传好。注意sizeof在不同上下文的不同行为。
第六天:二维数组和指针数组。定义一个 3x4 矩阵,写一个按行打印的函数,再尝试使用“指针数组”表示矩阵或图结构。
第七天:综合。自己找一道“单链表按位置插入”的题目,完整手写一遍,跑通并通过 valgrind 检查。然后去看教材的链表章节,应该能顺畅读下来了。
七天之后你再翻开数据结构教材,会发现以前很痛苦的代码,现在能看懂七八成。剩下的卡壳点,大概率是算法思路本身,而不是 C 语言语法的问题——到那一步,你才算是真正进入了数据结构的世界。
我自己带过很多学生,从实验报告写一行崩一次,到后来能独立分析堆区和栈区的内存走向,中间缺的往往不是智商,而是“基础概念要不要较真”。指针、结构体、动态内存、函数传参、数组边界,这五样在数据结构里就是地基。地基夯实了,链表、栈、队列、树、图都是往上盖的房间而已。别急着刷难题,先把这篇文里列出的代码亲手敲一遍、跑一遍、调一遍,你会发现那些原本看起来玄乎的教材代码,其实每一步都有迹可循。