☰
C语言数据结构与算法:从内存视角吃透链表、排序与KMP
2026/9/25 6:32:50 网站建设 项目流程

简介:面向C语言学习者的数据结构与算法完整资料包,覆盖图与树存储结构、查找表、线性表、字符串、数组与广义表、栈与队列、排序算法等核心模块,包含冒泡、选择、插入、快速等内部排序及外部排序实现,既有基础概念讲解,也提供各算法在C语言中的具体代码与运行验证方式,适合初学者系统入门,亦有助于进阶者巩固编程思维。压缩包共558个文件,以c源码、dev工程、exe可执行程序、out输出、layout界面等类型为主,配套readme说明文档,整体约12.92MB,目录结构清晰便于按主题查阅。已有1977人学习使用,这份资料能帮助读者理解邻接矩阵、二叉树、顺序查找、二分查找等关键知识点,也可作为课程设计或期末复习的实用参考。

1. C语言数据结构与算法:先别急着刷题,把结构和算法在内存里跑起来

很多人一提到“C语言数据结构与算法”,第一反应是严蔚敏那本绿皮书,或者是王道408的复习题。但现实场景是:你用Python写一个链表,几分钟搞定,换成C语言却卡在“二级指针到底要不要用”“free之后指针为什么还在”这些细节上。C语言的数据结构与算法,本质上不是教你背概念,而是逼你回答一个问题——这段数据究竟在内存里怎么放、怎么访问、怎么释放。理解了这一点,不管是考研408、笔试手撕代码,还是嵌入式开发里需要自己维护缓冲区和链表,都不会再觉得数据结构是“黑匣子”。

这篇文章面向的读者是:学过C语言基础,但数据结构一直停留在“看得懂、写不出”阶段的从业者和学生。我会按“理论先立住,代码能复现,坑提前踩掉”的顺序,把线性表、树、图、排序、字符串匹配这些经典内容,用C语言逐一落地,并给出一套可以直接复用的练习环境。目标只有一个:让数据结构不再是“玄学”,而是你手底下能跑、能调、能验证的代码。

2. 从严蔚敏到王道408:C语言数据结构到底在学什么,理论怎么落到代码

2.1 数据结构五大件:线性表、树、图、查找、排序的掌握边界

数据结构这门课,说到底是围绕“数据怎么组织、怎么操作”展开的。用C语言学习时,我习惯把内容拆成五大块,每一块都有明确的“C语言落地标准”。

线性表是基础,包含顺序表和链表。顺序表就是动态数组,要掌握扩容策略;链表要掌握单链表、双链表和循环链表。判断标准:能在纸上画出插入、删除时指针的指向变化,然后用C语言写出来,并且不出现内存泄漏。

树这一块,重点是二叉树。掌握前中后序遍历、层序遍历、二叉搜索树、堆。对于C语言来说,还要能写出用递归和栈两种方式实现遍历——后者才是面试常考的点。图的部分相对抽象,重点掌握邻接矩阵和邻接表两种存储结构,以及深度优先搜索(DFS)和广度优先搜索(BFS)的代码实现。查找和排序是重头戏,从顺序查找、二分查找到哈希表,从冒泡排序、插入排序到归并排序、堆排序、快速排序,每一类都要能手写,并且说清楚时间复杂度和稳定性。

至于王道408里要求的“掌握程度”,我的经验是:概念题考的是你能否用一句话说清“什么是栈”之外的深层理解,比如“为什么DFS用栈而BFS用队列”。代码题考的是你在白板上写出的C语言能否直接运行。所以不要只背定义,把严蔚敏教材里的每一个ADT(抽象数据类型)用C语言实现一遍,比看十遍“知识点归纳”都有用。

2.2 为什么用C语言实现数据结构?三个理由和“伪代码害死人”的真相

很多教程喜欢用伪代码糊弄,比如“将p节点插入链表”。看起来很好懂,但一动手写C语言就崩。原因在于C语言没有自动垃圾回收,你必须自己管理节点的创建和释放。用C语言实现数据结构的第一个理由,就是它能暴露“内存生命周期”这个关键问题——节点是malloc出来的,什么时候free,free之后指针值不变但指向的内存已无效,这就是use-after-free的直接来源。第二个理由是C语言的结构体和指针,能让你一眼看清数据对象在内存里的布局。结构体成员有偏移量,数组是连续内存,链表节点通过指针串起来,这些在高级语言里被封装掉了,但理解它们才能理解算法为什么“快”或“慢”。第三个理由是面试和竞赛环境的主流仍是C/C++。手撕代码时,C语言不会给你语法糖,写不出来就是写不出来,反而逼你练真功夫。

至于“伪代码害死人”,我见过太多同学把“if p->next == NULL return false”背得滚瓜烂熟,但问“p为什么不空”“free之后要不要把p置空”就卡住。伪代码省略了指针处理和内存管理,而这两个恰恰是C语言的核心。所以我的做法是:每个数据结构都写一份最小可运行的程序,包含创建、销毁、插入、删除、遍历五个基本操作,跑起来后才算“过”。

2.3 最小练习闭环:用C语言把数组栈和链表栈各写一遍

栈是入门第一个数据结构,同时也是检验你C语言基础是否扎实的好工具。我建议用两种方式实现:顺序栈(数组)和链式栈(链表)。先看顺序栈:

#include <stdio.h> #include <stdlib.h> #include <stdbool.h> // 顺序栈结构:数组 + 栈顶下标 + 容量 typedef struct { int *data; int top; // 栈顶指针,指向下一个可写入位置 int capacity; // 当前容量 } ArrayStack; // 初始化:申请初始容量为4的数组 void initStack(ArrayStack *s) { s->capacity = 4; s->data = (int *)malloc(sizeof(int) * s->capacity); if (s->data == NULL) exit(1); s->top = 0; // 空栈时top=0 } // 入栈:先检查容量,再写入数据 void push(ArrayStack *s, int value) { if (s->top >= s->capacity) { s->capacity *= 2; s->data = (int *)realloc(s->data, sizeof(int) * s->capacity); if (s->data == NULL) exit(1); } s->data[s->top++] = value; } // 出栈:top先减一,再取数据 int pop(ArrayStack *s) { if (s->top == 0) { fprintf(stderr, "stack underflow\n"); exit(1); } return s->data[--s->top]; } // 销毁:释放数组内存 void destroyStack(ArrayStack *s) { free(s->data); s->data = NULL; s->top = s->capacity = 0; }

这里最容易踩的坑是realloc失败导致原指针丢失,所以先检查返回值再赋值。另一个坑是出栈时--s->top的顺序,很多初学者写成s->top--然后取data[s->top],虽然结果一样,但可读性差。我的习惯是data[--s->top],这样语义清晰:先下移top,再读取。

再看链式栈,核心是节点结构和入栈出栈时指针的更新:

typedef struct Node { int data; struct Node *next; } Node; // 链式栈只需要一个top指针指向栈顶节点 typedef struct { Node *top; } LinkedStack; // 入栈:新建节点,前插到top前面 void pushLinked(LinkedStack *s, int value) { Node *newNode = (Node *)malloc(sizeof(Node)); if (newNode == NULL) exit(1); newNode->data = value; newNode->next = s->top; // 新节点指向原来的栈顶 s->top = newNode; // 更新栈顶 } // 出栈:保存旧栈顶,top后移,free旧节点 int popLinked(LinkedStack *s) { if (s->top == NULL) { fprintf(stderr, "stack underflow\n"); exit(1); } Node *old = s->top; int value = old->data; s->top = old->next; free(old); // 只释放old节点,不影响新栈顶 return value; }

链式栈不用考虑容量,但每个节点都要单独malloc和free。我建议把两个栈都写一遍,然后对比:数组栈的入栈在扩容时可能触发拷贝,链式栈的每个节点都有指针开销。这个对比过程能帮你真正理解“空间换时间”的含义。写完后,用一组随机数压栈再弹栈,检查输出是否逆序,这就是第一个可复现的练习闭环。

3. 经典算法落地:冒泡排序、归并排序、KMP匹配的C语言实现与测试

3.1 排序算法:从冒泡到归并,参数和边界条件怎么调

排序是数据结构的“试金石”。我要求自己至少能手写四种:冒泡、插入、归并和堆排序。冒泡排序虽然效率低,但用来理解“相邻元素交换”最直观。一个容易忽略的优化是:如果某轮没有发生交换,说明已经有序,可以提前退出。

void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; // 本轮是否发生交换 for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = 1; } } if (!swapped) break; // 无交换则提前结束 } }

注意内层循环的范围是n - 1 - i,因为每轮结束后,最后i个元素已经是排好序的,不需要再比较。归并排序的难点则在于合并操作必须用临时数组,并且要把剩余元素拷贝回去:

// 合并两个有序区间 [l, mid] 和 [mid+1, r] void merge(int arr[], int l, int mid, int r) { int leftSize = mid - l + 1; int rightSize = r - mid; int left[leftSize], right[rightSize]; for (int i = 0; i < leftSize; i++) left[i] = arr[l + i]; for (int i = 0; i < rightSize; i++) right[i] = arr[mid + 1 + i]; int i = 0, j = 0, k = l; while (i < leftSize && j < rightSize) { if (left[i] <= right[j]) arr[k++] = left[i++]; else arr[k++] = right[j++]; } // 处理剩余元素 while (i < leftSize) arr[k++] = left[i++]; while (j < rightSize) arr[k++] = right[j++]; } void mergeSort(int arr[], int l, int r) { if (l >= r) return; int mid = l + (r - l) / 2; // 防止整数溢出 mergeSort(arr, l, mid); mergeSort(arr, mid + 1, r); merge(arr, l, mid, r); }

mid用l + (r - l) / 2而不是(l + r) / 2,是因为当l和r接近INT_MAX时,后者的加法会溢出。这个细节在笔试中不一定考,但在真实内存溢出排查中很可能遇到。归并排序是稳定的,代价是额外O(n)空间,这也是面试官喜欢追问的点:为什么快排不稳定,而归并稳定。

3.2 KMP的next数组到底怎么求:一段C代码说清字符串匹配

字符串匹配是C语言数据结构里的“魔王”。KMP算法的核心不是匹配过程,而是求next数组。很多教程用“最长公共前后缀”来解释,但一写代码就乱。我的记忆方法是:next[j] 表示“在模式串的第j位置发生失配时,下一次比较应该从模式串的哪个下标开始”。求next数组本质上是在模式串自身身上做前缀匹配。

#include <string.h> // 计算模式串pat的next数组,数组长度与pat相同 void getNext(const char *pat, int *next) { int len = strlen(pat); next[0] = -1; // 哨兵,表示首字符失配时主串指针也要前进 int i = 0, j = -1; while (i < len - 1) { if (j == -1 || pat[i] == pat[j]) { i++; j++; next[i] = j; } else { j = next[j]; // 回溯,这是KMP的精髓 } } } // KMP匹配:返回pat在text中首次出现的下标,没有则返回-1 int kmpSearch(const char *text, const char *pat, const int *next) { int i = 0, j = 0; int tLen = strlen(text), pLen = strlen(pat); while (i < tLen && j < pLen) { if (j == -1 || text[i] == pat[j]) { i++; j++; } else { j = next[j]; } } if (j == pLen) return i - j; return -1; }

next[0] = -1是C语言实现里的常见做法,它让回溯逻辑统一为j = next[j],而不用单独处理j==0的情况。我见过很多人求next时把i和j的移动顺序写错,导致死循环。建议在纸上手动演算一遍“ABCDABD”的next数组:-1,0,0,0,0,1,2,再对照代码,你会发现i是模式串的后缀指针,j是前缀指针,j == -1时就说明前缀已经到头,必须同时前进并重置。这道坎跨过去,KMP就算真正掌握了。

3.3 可复现的测试框架:用随机数据对比不同算法耗时

算法写对了没有,不能靠眼睛看。我习惯用一个最简单的测试框架:生成随机数组,分别调用排序函数,对比结果和耗时。这样既验证正确性,也能直观看到O(n^2)和O(n log n)的差距。

#include <time.h> #include <stdlib.h> #include <stdio.h> // 生成一个长度为n的随机数组,范围[0, maxVal) void genRandomArray(int arr[], int n, int maxVal) { srand(time(NULL)); // 用当前时间做种子 for (int i = 0; i < n; i++) { arr[i] = rand() % maxVal; } } // 检查数组是否非递减 int isSorted(int arr[], int n) { for (int i = 1; i < n; i++) { if (arr[i - 1] > arr[i]) return 0; } return 1; } // 计时并返回排序函数执行时间(毫秒) long timeSort(void (*sortFunc)(int *, int), int arr[], int n) { clock_t start = clock(); sortFunc(arr, n); clock_t end = clock(); return (end - start) * 1000 / CLOCKS_PER_SEC; }

这里有个坑:clock()测量的是CPU时间,不是墙钟时间,在多线程下可能不准,但单线程练习足够。另外,如果你要对比多个排序算法,必须复制一份相同的数组,否则第一个排序已经把它排好了,第二个算法测出来是0毫秒。这其实就是“控制变量”的思想,也是算法对比测试里最容易翻车的地方。KMP的测试一般是构造一个长文本和一个模式串,用朴素匹配和KMP各跑一遍,统计比较次数,比看耗时更有说服力——因为字符串匹配的耗时受缓存影响太大。

4. C语言数据结构避坑指南:指针悬挂、越界、递归溢出的5个血泪教训

4.1 删除链表节点后“忘了置空”:use-after-free崩溃

现象:链表删除函数执行后,遍历链表时偶发段错误,有时还能打印出已经“被删除”节点的值。

原因:free(p)只是释放了p指向的内存,但p指针本身的值没变,仍然指向那块已回收的内存。如果后续代码继续通过p访问,或者另一个节点还残留指向p的next指针,就成了悬挂指针。

解决:删除节点时,必须先把前驱节点的next指向p->next,再释放p。释放后把p置为NULL,避免误用。我一般会在free后加一行p = NULL;。更高要求的做法是:如果链表可能被多处引用,删除后返回新的头指针,并让调用方更新。这个习惯能规避大部分use-after-free问题。

4.2 数组下标差1:排序里最隐蔽的越界

现象:冒泡排序里内层循环写成j < n - i,归并排序的临时数组大小算错一位,结果要么越界写坏数据,要么排序结果里有一个元素始终不对。

原因:C语言数组下标从0开始,长度为n的数组,合法下标是0到n-1。排序循环里“最多需要比较n-1次”和“第i轮需要比较n-1-i次”很容易差1。越界写内存不会立刻崩溃,但会悄悄篡改相邻变量,导致数据错乱。

解决:写完排序后,用断言检查边界。比如在冒泡内层循环里加一句if (j + 1 >= n) break;,运行时立刻暴露问题。我还会用AddressSanitizer编译:gcc -fsanitize=address -g sort.c,这样越界访问会直接报错并给出调用栈,省去大量排查时间。

4.3 递归深度过大:二叉树深度优先遍历的栈溢出

现象:对一棵深度接近10000的二叉树做递归前序遍历,程序直接段错误,连报错都没有。

原因:递归调用依赖系统调用栈,Linux默认栈大小通常为8MB,每层递归函数只要分配几十字节上下文,10000层就可能溢出。数据结构教材里的递归遍历在理论上没问题,但真实生产环境的树可能非常深。

解决:把递归改成显式栈,或者用Morris遍历(利用空指针线索)。需要保留递归写法时,可以做两件事:一是让递归函数尽量少用局部变量,二是用ulimit -s查看并增大栈空间。但我的建议是:二叉树的非递归遍历是高频面试题,不如趁着这个机会把“用栈模拟递归”练熟,一劳永逸。

4.4 结构体按值传参:大数据结构体为什么慢

现象:把一个包含几万个节点的二叉树结构体直接传入函数,编译不开优化时,每次调用都要拷贝几十KB数据,明显感觉程序卡顿。

原因:C语言里结构体按值传参时,实参会完整复制到新栈帧。如果结构体很大,复制开销远大于传指针。

解决:传递指针,并在函数内只修改需要修改的部分。如果函数不需要修改原结构体,传const Node *node,既安全又能让编译器优化。这个教训在实现图和树的时候尤其重要,因为节点结构体往往包含多个数组或子节点指针,按值传参等于把整棵子树复制一遍,纯属浪费。

4.5 测试数据一锅端:有序、乱序、重复数据下的排序性能假象

现象:用一组几乎有序的数据测试快速排序,发现它跑得比冒泡还慢;又或者用全相同数据测试,有些排序直接段错误。

原因:快排在最坏情况下时间复杂度为O(n^2),当数据已经有序且选取中间元素作为基准时,递归深度可能接近n,导致栈溢出。而冒泡排序对有序数据反而能提前退出。

解决:测试排序算法时,至少要准备三种输入:随机乱序、升序、降序、全相同。针对快排,可以改为“三数取中”选取基准,或者随机选取基准,避免固定边界导致的最坏情况。我一般会在测试框架里生成这四组数据,分别跑一遍并打印耗时,这样才能真实评估一个算法的适用场景。

5. 工程化练习环境:VSCode + GCC + Makefile + valgrind 的最小配置

5.1 为什么我不用IDE直接写:三行Makefile带来的确定性

很多同学用Visual Studio或Clion,点一下运行就能看到输出。但数据结构练习需要“可复现、可检查、可调试”,IDE把这些细节藏起来了。我用VSCode配GCC和Makefile,理由是:编译命令是可见的,调试参数是显式的,内存检查是随手写的。当你亲手敲出gcc -g -Wall这几行,你会开始关注编译警告,而不是等程序崩了再去猜。

VSCode里只需要安装C/C++扩展,然后用终端命令编译运行。这样你在面试白板上写代码时,也不会依赖智能提示。我建议不要用一键运行按钮,而是养成在终端里执行make && ./main的习惯。

5.2 最小Makefile模板:支持调试与内存检查

下面这个Makefile我用了很久,足够覆盖数据结构练习的绝大多数需求:

CC = gcc CFLAGS = -Wall -Wextra -g -std=c11 LDFLAGS = # 需要根据实际文件调整:这里假设最终目标是main SRCS = main.c stack.c linkedlist.c OBJS = $(SRCS:.c=.o) main: $(OBJS) $(CC) $(LDFLAGS) -o $@ $^ %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f main $(OBJS) # 内存检查:编译后运行valgrind memcheck: main valgrind --leak-check=full --error-exitcode=1 ./main

参数说明:-Wall -Wextra开启严格警告,能提示未使用变量、符号比较等问题;-g生成调试信息,供gdb和valgrind使用;-std=c11避免一些旧标准下的隐晦行为。%.o: %.c是通用规则,每次新增源文件只需要在SRCS里加一行。运行make memcheck时,就会自动编译并调用valgrind检查内存泄漏。我第一次跑通这个模板时,发现自己冒泡排序里有个数组越界,是valgrind先报出来的,这感觉比事后查半天舒服多了。

5.3 gdb断点调试与valgrind内存泄漏检测的常用命令

用gdb调试链表时,我最常用的三条命令是break、next、print。例如在删除节点的函数开头设断点:

gdb ./main break deleteNode # 在deleteNode函数入口处暂停 run # 启动程序,停在断点 print p->data # 查看当前节点数据 next # 单步执行一行 print p->next # 看next指针指向哪里 continue # 继续运行

这里的print对指针结构体也能直接展开显示成员,省去手动查内存地址。而valgrind的命令更简单:

valgrind --leak-check=full --show-leak-kinds=all ./main

如果输出里有definitely lost: N bytes,说明内存泄漏了。定位到具体行号后,回头检查malloc和free是否成对。我见过有些同学总说“我的链表没内存泄漏”,直到valgrind报出一大堆,才发现销毁函数只free了头节点,后面的节点都丢了。这个教训让我养成习惯:每写一个涉及malloc的接口,就立刻写对应的free接口,并把两者在注释里对应起来。

6. 让算法真正长在身上:每学一个结构,先画图再写码,最后用随机数据验证

数据结构与算法这门课,最怕的就是“眼睛会了,手不会”。我自己的方法是固定三步走,坚持三个月后,手写常见数据结构的代码基本不会卡壳。

第一步是画图。学链表时,画两个方框代表节点,用箭头表示指针;学二叉树时,画一棵树,然后标出前序、中序、后序遍历的路径。画图能让你把“抽象结构”变成“记忆中的画面”,写代码时脑子里就有一个指针怎么移动的动画。我甚至会在草稿纸上模拟空指针、头节点、双向链表prev和next的对称性。这一步省不掉,尤其是KMP的next数组,不画图根本理解不了“回溯”。

第二步是写代码,但不能贴抄。我的习惯是早上看完原理,晚上合上书,从空白文件开始写。写的过程中不许查教材,只许查C语言语法(比如realloc的用法)。写完编译运行,如果报错,就用gdb定位。这样折腾出来的代码,比看十遍例题都印象深刻。关键点要画“记忆锚点”:比如KMP的next[0] = -1,链表删除时free(p); p = NULL;,这些是你未来面试手撕时的条件反射。

第三步是用随机数据验证。这是最容易被新手忽略的。我写的每个排序、查找、树操作,都会配一个测试函数:构造随机数据、执行操作、检查结果是否符合预期。比如测试二叉搜索树插入后,中序遍历应该是有序序列;测试栈后,弹出序列应该等于压栈逆序。这套验证方法还能帮你发现隐藏的性能问题——当数据规模从100变成100000时,O(n^2)和O(n log n)的耗时差距会变得触目惊心。

最后说一个我最近的教训:写堆排序时,我自认为理解了“下沉”操作,结果构建堆时把条件写反,导致排序结果完全错误。原因就是我没画图,只靠代码推理。后来我在纸上画出数组在完全二叉树中的对应位置,立刻发现下标计算差了一个偏移量。这件事让我更坚定:数据结构不是背出来的,是画出来、写出来、测出来的。如果你也正在被“看着都会,一写就废”困扰,不妨试试我这个三步法,先从今天学的一个栈或一个排序开始。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询