☰
C语言数据结构与算法实战:从链表到二叉树的核心实现与避坑指南
2026/10/14 15:15:20 网站建设 项目流程

简介:这套C语言数据结构与算法资料包,系统梳理了数据结构课程核心知识点,覆盖线性表、字符串、数组与广义表、栈与队列、树与图、查找与排序等主题。每个主题均配有可运行的C语言代码:图和树部分包含邻接矩阵、邻接表、二叉树等存储与遍历实现;查找部分演示顺序查找与二分查找;排序部分实现插入、选择、冒泡、快速排序,并涉及外部排序;栈与队列则给出顺序栈等基本操作示例。资源内附说明文档,有助于快速了解压缩包结构与使用方式;配合源码、Dev-C++工程文件及可执行程序,可直接运行验证,适合课程实验、考研复习与求职笔试准备。整包共558个文件,以C源码为主,另有工程配置、编译目标文件、exe可执行文件等,整体大小约12.92MB。目前已有1977人学习下载,文件按功能分类,便于按需查阅,适合希望从代码层面深入理解数据结构与算法原理的C语言学习者。

1. C语言数据结构与算法:为什么学了语法还是写不出代码

很多初学者把C语言学完,指针、结构体、动态内存都过了,可真拿到一个“用链表存学生信息”的需求时,还是卡住半天。这不是语法问题,而是缺了数据结构与算法这根弦。数据结构解决的是“数据怎么组织”,算法解决的是“组织好之后怎么高效操作”。C语言为什么特别适合学这块?因为它没有内置容器,一切都得自己造,造的过程恰恰能让你看清每个结构的内部机制。这篇笔记就沿着「理论先立住、再动手能复现」的路线,把C语言数据结构与算法从选型到实现再到避坑讲透,适合刚学完C语法、准备补算法短板的人,也适合面试前想快速复习核心考点的人。

提示:本文所有示例都基于标准C语言编写,不依赖特定编译器扩展。建议使用任意支持C99的编译器即可运行。

我见过太多人把精力耗在背代码上,却不知道每个结构到底用在什么场景。这篇文章不谈虚的,直接告诉你每个结构解决什么问题、代码怎么写、参数怎么调、坑在哪。

2. 先立住核心判断:数据结构选型决定算法复杂度

2.1 线性表三兄弟:顺序表、单链表、双链表的适用边界

很多人一开始就纠结“到底该用顺序表还是链表”,其实答案取决于你的操作特征。顺序表(动态数组)的优势是随机访问O(1),劣势是中间插入删除要搬移元素;单链表优势是插入删除只改指针,劣势是随机访问要遍历。双链表比单链表多一个前驱指针,代价是每个节点多占一个指针的内存,换来的是双向遍历和前向删除的能力。

我一般会按这个思路选型:频繁按下标访问数据,选顺序表;频繁在头部或中间插入删除,选单链表;需要从后往前遍历,或者需要高效删除某个已知节点的前驱,选双链表。这个判断不是我拍脑袋,而是从复杂度分析推出来的。

结构随机访问头部插入中间插入额外内存
顺序表O(1)O(n)O(n)少量扩容预留
单链表O(n)O(1)O(1)需已知前驱每节点1个指针
双链表O(n)O(1)O(1)需已知前驱每节点2个指针

2.2 复杂度不是背公式,而是看数据规模

复杂度分析是数据结构的“仪表盘”,但新手最容易犯的错是只记结论不看常数。举个例子,顺序表在尾部插入是O(1),但如果频繁触发扩容,实际耗时可能比链表还高。反过来,链表虽然插入是O(1),但如果你每次都要先遍历找到插入位置,总代价还是O(n)。

我习惯在做任何结构选型之前,先估算数据规模:几百条数据用顺序表最省事,几十万条数据才需要认真考虑链表的缓存局部性。这里有个小经验:现代CPU对顺序内存访问有硬件预取优化,所以顺序表在实际运行中往往比链表快,哪怕链表理论复杂度更优。

2.3 C语言实现的内存视角:为什么不用现成容器

C语言没有标准库容器,这是劣势也是优势。劣势显而易见,什么都要自己写;优势是你被迫理解每个操作背后发生了什么。比如写链表时,你必须自己负责节点的分配和释放,这让你天然理解内存泄漏是什么。用其他高级语言写链表,你很少会关心节点销毁,但在C里不写释放函数,跑几次就崩。

这一章的意义在于让你建立选型意识。很多教程一上来就贴代码,读者抄完也不明白为什么单链表插入要分头插和尾插两种情况。我尽量把每个结构都放到具体需求里讲,这样你才能知道什么时候该用它。

3. 动手实现:从顺序表到单链表再到二叉树的核心代码

3.1 顺序表实现:动态数组的扩容策略与参数

顺序表的实现核心是动态数组扩容。常见做法是当容量不足时按1.5倍或2倍扩容。1.5倍扩容的好处是内存利用率较高,2倍扩容的好处是扩容次数少。我一般用2倍,因为实现简单,且大多数场景下空间浪费可以接受。

下面是最小可运行的顺序表实现,包含初始化、插入、删除、扩容四个关键操作:

#include <stdio.h> #include <stdlib.h> #include <string.h> #define INIT_CAPACITY 4 typedef struct { int *data; int size; // 当前元素个数 int capacity; // 已分配容量 } SeqList; // 初始化顺序表 void seqlist_init(SeqList *list) { list->data = (int *)malloc(sizeof(int) * INIT_CAPACITY); list->size = 0; list->capacity = INIT_CAPACITY; } // 扩容:重新分配更大的内存块 void seqlist_resize(SeqList *list) { int new_capacity = list->capacity * 2; int *new_data = (int *)malloc(sizeof(int) * new_capacity); // 把旧数据搬移到新内存 for (int i = 0; i < list->size; i++) { new_data[i] = list->data[i]; } free(list->data); list->data = new_data; list->capacity = new_capacity; } // 尾部插入 void seqlist_push_back(SeqList *list, int value) { if (list->size >= list->capacity) { seqlist_resize(list); } list->data[list->size++] = value; } // 按下标插入,index范围是[0, size] void seqlist_insert(SeqList *list, int index, int value) { if (index < 0 || index > list->size) { printf("插入位置越界\n"); return; } if (list->size >= list->capacity) { seqlist_resize(list); } // 从后往前搬移,腾出插入位置 for (int i = list->size; i > index; i--) { list->data[i] = list->data[i - 1]; } list->data[index] = value; list->size++; } // 按下标删除 void seqlist_delete(SeqList *list, int index) { if (index < 0 || index >= list->size) { printf("删除位置越界\n"); return; } // 从前往后覆盖,缩小有效范围 for (int i = index; i < list->size - 1; i++) { list->data[i] = list->data[i + 1]; } list->size--; } // 释放内存 void seqlist_destroy(SeqList *list) { free(list->data); list->data = NULL; list->size = 0; list->capacity = 0; }

逻辑说明:扩容时之所以先申请新内存再释放旧内存,是为了避免直接realloc在内存碎片化时可能出现的搬移异常。插入和删除的搬移方向很关键,插入要从后往前搬,否则会覆盖未处理的元素;删除要从前往后搬,否则会把后面的值覆盖掉还没用过的位置。参数方面,扩容倍率建议根据实际数据量调整,如果已知数据量很大,初始化时直接把capacity设成预估大小,减少扩容次数。

3.2 单链表实现:头节点与插入删除的指针操作细节

单链表最容易翻车的地方是指针操作顺序。删除节点时,如果先释放了节点再改前驱指针,就会访问已释放的内存。插入时,如果先改前驱的next再改新节点的next,会丢掉原有的后继节点。

下面是一个带头节点的单链表实现,头节点不存数据,用来统一空表和非空表的操作逻辑:

#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node; typedef struct { Node *head; // 头节点,不存数据 int size; } LinkedList; // 初始化:创建头节点 void list_init(LinkedList *list) { list->head = (Node *)malloc(sizeof(Node)); list->head->next = NULL; list->size = 0; } // 头插法:新节点插到头节点之后 void list_push_front(LinkedList *list, int value) { Node *node = (Node *)malloc(sizeof(Node)); node->data = value; node->next = list->head->next; list->head->next = node; list->size++; } // 尾插法:遍历到最后一个节点再插入 void list_push_back(LinkedList *list, int value) { Node *node = (Node *)malloc(sizeof(Node)); node->data = value; node->next = NULL; Node *cur = list->head; while (cur->next != NULL) { cur = cur->next; } cur->next = node; list->size++; } // 删除第一个值为value的节点,返回是否删除成功 int list_remove(LinkedList *list, int value) { Node *cur = list->head; while (cur->next != NULL) { if (cur->next->data == value) { Node *tmp = cur->next; cur->next = cur->next->next; free(tmp); list->size--; return 1; } cur = cur->next; } return 0; } // 遍历打印 void list_print(LinkedList *list) { for (Node *cur = list->head->next; cur != NULL; cur = cur->next) { printf("%d ", cur->data); } printf("\n"); } // 销毁:逐个释放节点 void list_destroy(LinkedList *list) { Node *cur = list->head; while (cur != NULL) { Node *tmp = cur; cur = cur->next; free(tmp); } list->head = NULL; list->size = 0; }

逻辑说明:头插法的关键是先让新节点指向原来的首节点,再让头节点指向新节点。如果顺序反过来,原首节点就丢了。删除时要用cur->next来判断,而不是cur本身,这样才能拿到前驱节点。参数方面,链表本身没有容量限制,但每插入一个节点就malloc一次,频繁分配销毁会产生内存碎片,所以链表更适合插入删除频繁、数据量不是极大的场景。

3.3 二叉树实现:递归建树与前中后序遍历

二叉树的核心是递归。递归的难点在于理解“函数调用自己时,每一层的局部变量是独立的”。很多初学者卡在递归上,其实只要抓住一个点:递归函数只需要处理当前节点,剩下的交给下一层。

下面是用数组构建二叉树的示例,我们用-1表示空节点。这种表示方法在刷题和竞赛里很常见:

#include <stdio.h> #include <stdlib.h> typedef struct TreeNode { int data; struct TreeNode *left; struct TreeNode *right; } TreeNode; // 根据数组递归建树,index表示当前处理的数组下标 TreeNode *build_tree(int *arr, int index, int size) { if (index >= size || arr[index] == -1) { return NULL; } TreeNode *node = (TreeNode *)malloc(sizeof(TreeNode)); node->data = arr[index]; // 左孩子下标为 index*2+1,右孩子下标为 index*2+2 node->left = build_tree(arr, index * 2 + 1, size); node->right = build_tree(arr, index * 2 + 2, size); return node; } // 前序遍历:根 -> 左 -> 右 void preorder(TreeNode *root) { if (root == NULL) { return; } printf("%d ", root->data); preorder(root->left); preorder(root->right); } // 中序遍历:左 -> 根 -> 右 void inorder(TreeNode *root) { if (root == NULL) { return; } inorder(root->left); printf("%d ", root->data); inorder(root->right); } // 后序遍历:左 -> 右 -> 根 void postorder(TreeNode *root) { if (root == NULL) { return; } postorder(root->left); postorder(root->right); printf("%d ", root->data); } // 释放整棵树 void destroy_tree(TreeNode *root) { if (root == NULL) { return; } destroy_tree(root->left); destroy_tree(root->right); free(root); } int main() { // 用数组表示一棵二叉树,-1代表空节点 int arr[] = {1, 2, 3, 4, 5, -1, 6}; int size = sizeof(arr) / sizeof(arr[0]); TreeNode *root = build_tree(arr, 0, size); printf("前序遍历: "); preorder(root); printf("\n中序遍历: "); inorder(root); printf("\n后序遍历: "); postorder(root); printf("\n"); destroy_tree(root); return 0; }

逻辑说明:数组下标和二叉树节点的对应关系是固定的,根节点下标为0,左孩子是index2+1,右孩子是index2+2。这种表示法不需要显式创建节点之间的连接指针,逻辑清晰。参数方面,-1作为空节点标记,如果数据本身可能为-1,就换成其他哨兵值。递归的三序遍历差异只在打印语句的位置,理解这个就掌握了三种顺序。

4. 排序与查找:把算法复杂度落成可运行的代码

4.1 快速排序的实现与复杂度边界

快排是面试和工程里最常用的排序算法,平均复杂度O(n log n),最坏O(n^2)。最坏情况发生在每次划分都极端不平衡时,比如数组已经有序且我们固定选第一个元素作为基准。解决方法是三数取中,或者随机选基准。

下面是一个三数取中快排的实现:

#include <stdio.h> // 交换两个元素 void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; } // 三数取中:从arr[l], arr[mid], arr[r]中选中间值作为基准 int median_of_three(int *arr, int l, int r) { int mid = l + (r - l) / 2; if (arr[l] > arr[mid]) { swap(&arr[l], &arr[mid]); } if (arr[l] > arr[r]) { swap(&arr[l], &arr[r]); } if (arr[mid] > arr[r]) { swap(&arr[mid], &arr[r]); } // 此时arr[mid]是中间值 return mid; } // 划分:返回基准元素的最终位置 int partition(int *arr, int l, int r) { int pivot_index = median_of_three(arr, l, r); int pivot = arr[pivot_index]; // 把基准移到末尾 swap(&arr[pivot_index], &arr[r]); int i = l - 1; for (int j = l; j < r; j++) { if (arr[j] < pivot) { i++; swap(&arr[i], &arr[j]); } } // 把基准放回正确位置 swap(&arr[i + 1], &arr[r]); return i + 1; } void quick_sort(int *arr, int l, int r) { if (l >= r) { return; } int pivot_pos = partition(arr, l, r); quick_sort(arr, l, pivot_pos - 1); quick_sort(arr, pivot_pos + 1, r); } int main() { int arr[] = {9, 3, 7, 1, 8, 2, 6}; int n = sizeof(arr) / sizeof(arr[0]); quick_sort(arr, 0, n - 1); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); return 0; }

逻辑说明:三数取中避免了最坏情况的发生概率,但并不能完全消除。i指针的含义是“最后一个小于基准的元素的下标”,j遍历整个区间。每找到一个小于基准的元素,就把它和i+1位置交换。基准最终放在i+1位置,这个位置就是它的正确下标。参数方面,递归深度在理想情况下是log n,栈空间够用;但极端情况下递归深度可能到n,所以在栈空间小的嵌入式环境中要谨慎。

4.2 二分查找:边界处理的三个关键点

二分查找看似简单,但边界条件写错就是死循环或者漏查。最常见的问题出现在区间定义上:用左闭右闭[l, r]还是左闭右开[l, r)。

下面是左闭右闭的标准实现:

#include <stdio.h> // 在有序数组arr中查找target,返回下标,找不到返回-1 int binary_search(int *arr, int n, int target) { int l = 0, r = n - 1; while (l <= r) { // 避免 (l+r) 溢出 int mid = l + (r - l) / 2; if (arr[mid] == target) { return mid; } else if (arr[mid] < target) { // 目标在右半部分 l = mid + 1; } else { // 目标在左半部分 r = mid - 1; } } return -1; } int main() { int arr[] = {1, 3, 5, 7, 9, 11, 13}; int n = sizeof(arr) / sizeof(arr[0]); int target = 7; printf("找到下标: %d\n", binary_search(arr, n, target)); return 0; }

逻辑说明:左闭右闭的循环条件是l <= r,因为当l == r时,区间里还有一个元素需要判断。mid的计算用l + (r - l) / 2而不是(l + r) / 2,是为了防止大数溢出。当arr[mid] < target时,说明mid位置一定不是目标,所以l直接跳到mid + 1;同理大目标时r跳到mid - 1。三个关键点:一是循环条件,二是mid取值方式,三是边界收缩时要不要加1或减1。这里面的血泪经验是:如果你把l <= r写成l < r,当目标恰好是最后一个元素时会返回-1。

4.3 排序稳定性与C语言库函数的使用边界

工程里绝大多数场景不需要自己写排序,直接调用qsort即可。qsort是不稳定排序,平均复杂度O(n log n)。如果排序后相同元素的相对顺序会影响业务逻辑,就需要用稳定排序,比如归并排序。

使用qsort时有个必须注意的点:比较函数怎么写。比较函数返回负数表示a在前,正数表示b在前,0表示相等。一个反直觉的坑是:直接返回a - b在极端数值下会溢出,导致排序结果错误。下面这个写法是安全的:

#include <stdio.h> #include <stdlib.h> // 安全比较函数,避免减法溢出 int cmp_int(const void *a, const void *b) { int ia = *(const int *)a; int ib = *(const int *)b; return (ia > ib) - (ia < ib); } int main() { int arr[] = {4, 1, 8, 3, 9, 2}; int n = sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), cmp_int); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); return 0; }

逻辑说明:(ia > ib) - (ia < ib)巧妙地避免了减法溢出,也避免了依赖int正负边界。这个写法在C语言里是完全可移植的。参数方面,qsort的第四个参数是比较函数的函数指针,这个指针的签名是固定的,不能随意改动。如果你排序的是结构体,比较函数里要访问结构体字段,做法是把void指针转换成对应结构体指针再取值。

5. 避坑:C语言数据结构与算法学习的常见问题与排查

5.1 指针操作导致程序崩溃

现象:链表插入或删除时程序时好时坏,偶尔崩,偶尔不崩。原因:很可能是访问了已释放的内存,或者指针指向了错误的节点位置。排查方法:在关键操作前后打印指针地址和节点数据,对比是否和预期一致。我习惯在insert和delete函数里加上断言(assert),确保传入的指针不为空。解决:统一规范操作顺序,删除节点时先保存next指针再释放当前节点。

提示:使用调试器追踪指针值是最有效的方式。单步执行时,观察每个节点的next是否指向预期地址。

5.2 内存泄漏:free少了或者free错了

现象:程序运行一段时间后内存占用持续升高,或者崩溃时显示堆损坏。原因:malloc出来的节点只分配不释放,或者free的时候传的不是malloc返回的原始指针。常见错误是把指针向后移动后再free,导致free一个非起始地址。解决:每个malloc必须配套一个free,并在free后将指针置为NULL,避免悬空指针被再次使用。这个习惯能避免90%的内存类问题。

5.3 递归深度过大导致栈溢出

现象:二叉树层数很深时,递归遍历直接崩溃。原因:递归调用使用系统栈,深度太大就把栈空间耗尽。解决:对于树高较高的场景,改用非递归遍历。用显式栈或队列模拟递归过程,把系统栈的压力转移到堆上。另一个方案是检查建树时是否出现了数据错误,导致退化成链表一样的单支结构。

5.4 数组越界:下标边界算错

现象:排序或查找时结果不对,偶尔读到垃圾值。原因:数组下标越界属于未定义行为,编译器不一定报错。解决:在关键循环里打印当前下标,或者在调试版本里开启数组边界检查。快排的递归边界要注意l >= r的判断,漏掉这个条件就会有无限递归。二分查找的左右边界更新时也要反复验证,死循环往往来自边界没有正确收缩。

5.5 结构体赋值导致浅拷贝问题

现象:用结构体封装链表头,直接list2 = list1之后,两个结构体指向同一块链表,修改一个另一个也变。原因:结构体直接赋值是逐字节拷贝,指针成员拷贝的是地址,不是指向的数据。解决:需要深拷贝时,手动遍历链表逐个复制节点。如果不需要独立副本,就明确共用一份数据,避免误操作。这个问题在封装复杂数据结构时非常常见。

6. 进阶:把数据结构与算法应用到面试与小型项目

6.1 用LeetCode风格题目练手时的三步法

刷题不是背答案,而是建立题感。我一般用三步法:第一步,读题后先确定数据规模,这决定你能接受的复杂度;第二步,想清楚用什么结构组织数据,是数组、链表还是哈希表;第三步,写代码前先手动画一遍流程。这三步做完,代码基本不会有大方向错误。很多新手上来就写,写到一半发现结构选错了,只能推翻重来。

6.2 从数据结构到项目:一个文本统计工具的实现思路

以“统计一篇英文文章中每个单词出现的频率”为例。这个需求的自然解法是:用哈希表存储单词和计数。C语言没有内置哈希表,需要自己实现一个简单版,或者用开源库。如果数据量不大,也可以用排序再做相邻统计。这个例子能帮你串联起哈希、排序、字符串处理等多个知识点。

6.3 调试技巧:打印日志、断言和使用调试器

数据结构写错时我最常犯的错是凭肉眼查代码。后来养成了用assert和日志的习惯。assert在调试版里能快速定位非法参数,日志能看出程序执行路径。调试器是最终武器,设置断点观察变量变化,比printf更高效。工程里还有一招:把关键函数单独写测试用例,小规模验证后再接入主逻辑,避免问题耦合在一起。

6.4 自定义内存池解决频繁malloc的性能问题

如果你的链表在循环里频繁插入删除,malloc和free的系统调用开销会很明显。常见做法是维护一个空闲节点池,节点释放后不还给系统,而是放回池子里,下次需要节点时优先从池里取。这个优化点在高性能场景很实用,面试时也是加分项。实现核心就是一个数组或链表,记录可复用的节点地址。

我自己的习惯是每学完一个结构,就手写一遍标准实现,不参考任何资料,写不出来就再看一遍。这个习惯帮我发现了很多自以为懂了其实没懂的地方。数据结构与算法这门课没有捷径,C语言又是最不留情面的语言,写对了就是对的,写错了就是错的。希望这篇笔记帮你少踩一些我踩过的坑,在C语言数据结构与算法的路上走得稳一点。希望帮到你。

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

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

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

立即咨询