
学数据结构去啃教材翻到链表的插入和删除那一节很多人会突然卡住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-checkfull ./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删除后内存不断增加删除结点时漏了 freevalgrind 查 definitely lost修改头指针无效函数参数只传了一级指针改成传 Node** 或使用返回值realloc 后数据部分丢失先丢了原指针或者扩容逻辑错误用临时变量接收 realloc 返回值失败时不覆盖原指针这几条基本覆盖了初学数据结构最常见的翻车现场。每条我都见过不止三五次属于非常典型的共性问题。8. 从哪里开始补一个可执行的 7 天自检路线如果有人问我“老师我现在 C 语言忘了大半数据结构要开始上课了我该怎么办”我给的建议基本是一条 7 天路线只要按顺序走完数据结构课堂上就不会再有“基础跟不上”的空洞感。第一天把指针重过一遍重点是 和 *、指针类型与步长、空指针和野指针。写 10 个调试小程序每个至少用一次指针遍历数组。第二天结构体和 typedef。自己定义一个“学生”结构体包含姓名、学号、成绩、指向下一个学生的指针然后写两个函数一个创建学生节点一个遍历打印链表。注意用 malloc 分配。第三天围绕 malloc/free 写内存实验申请数组、初始化、扩容realloc、释放。再试着故意漏 free用 valgrind 看泄漏报告对“泄漏”有直观认知。第四天函数参数。复习传值和传值的区别实现一个自己定义结构的“swap”再实现一个可以修改链头指针的函数。搞清楚什么时候要一级指针、什么时候要二级指针。第五天数组字符串。处理一个字符数组写逆置函数处理一个整数数组写一个返回数组中位数的函数同时把参数长度传好。注意sizeof在不同上下文的不同行为。第六天二维数组和指针数组。定义一个 3x4 矩阵写一个按行打印的函数再尝试使用“指针数组”表示矩阵或图结构。第七天综合。自己找一道“单链表按位置插入”的题目完整手写一遍跑通并通过 valgrind 检查。然后去看教材的链表章节应该能顺畅读下来了。七天之后你再翻开数据结构教材会发现以前很痛苦的代码现在能看懂七八成。剩下的卡壳点大概率是算法思路本身而不是 C 语言语法的问题——到那一步你才算是真正进入了数据结构的世界。我自己带过很多学生从实验报告写一行崩一次到后来能独立分析堆区和栈区的内存走向中间缺的往往不是智商而是“基础概念要不要较真”。指针、结构体、动态内存、函数传参、数组边界这五样在数据结构里就是地基。地基夯实了链表、栈、队列、树、图都是往上盖的房间而已。别急着刷难题先把这篇文里列出的代码亲手敲一遍、跑一遍、调一遍你会发现那些原本看起来玄乎的教材代码其实每一步都有迹可循。