
这类主题最常被问到的其实是两个问题一是学了C语言语法后数据结构怎么上手练习才有效二是面对一堆概念和算法怎么写出能跑、能懂、能改的代码而不是对着理论发呆。如果你正在从C语法过渡到数据结构或者被链表、树、排序这些练习卡住不知道代码从哪开始写、写完怎么验证那这篇梳理的经验和路径应该能帮你把“练习”这件事落到实处。我建议先别急着找“大全”或“终极笔记”而是按“理解结构 - 写出最小实现 - 处理边界 - 整合应用”这个顺序来。下面我会用完全可操作的代码和验证步骤带你过一遍几个核心数据结构链表、栈、队列、树的练习关键点。环境就用最普通的gcc 命令行或者你用 VS Code 配置好的环境也行重点是把代码敲出来跑通再自己改几个地方试试。1. 练习前先明确目标不是背代码而是建立“数据操作”的肌肉记忆很多人把数据结构练习当成默写这是第一个容易走偏的地方。数据结构的核心是在内存里组织数据并定义一套操作这些数据的规则。所以练习的目标应该是对每一种结构你都能清楚地回答下面几个问题并且能用代码实现这个结构在内存里大概长什么样比如链表是一串通过指针连接的节点树是分层指向的节点最基本的操作有哪些创建、插入、删除、查找、遍历……这些操作的时间复杂度是多少为什么比如链表插入 O(1)查找 O(n)常见的“坑”会在哪里比如链表删除节点时忘记处理前驱指针树遍历时递归栈溢出有了这个目标我们再看具体结构时就会聚焦于“如何用C语言的基本元素结构体、指针、内存分配把它构建出来”而不是死记硬背一段看不懂的代码。2. 从链表开始重点练透指针操作和边界处理链表是理解指针和动态内存的绝佳练习。别一上来就写复杂的双向链表或循环链表先从单链表把基础打牢。2.1 单链表的最小可用实现先定义节点结构这是所有链式结构的基础typedef struct ListNode { int val; // 数据域这里用int举例 struct ListNode *next; // 指针域指向下一个节点 } ListNode;接下来实现四个最核心的操作创建节点、头插法插入、删除节点、遍历打印。我建议你按这个顺序写并确保每一步都能编译运行。1. 创建节点内存申请与初始化ListNode* createNode(int val) { ListNode* newNode (ListNode*)malloc(sizeof(ListNode)); if (newNode NULL) { printf(内存分配失败\n); exit(1); // 简单处理实际项目可能有更优雅的错误处理 } newNode-val val; newNode-next NULL; // 初始化指针为NULL是关键 return newNode; }为什么先写这个因为后续所有插入操作都依赖它。这里要注意malloc的返回值检查以及一定要把next指针初始化为NULL避免野指针。2. 头插法插入理解指针修改顺序void insertAtHead(ListNode** head, int val) { ListNode* newNode createNode(val); newNode-next *head; // 新节点指向原头节点 *head newNode; // 头指针更新为新节点 }参数为什么是ListNode** head因为我们要修改调用函数里的头指针本身一个ListNode*变量所以需要传入它的地址即ListNode**。这是链表操作第一个易错点。3. 删除指定值的节点处理前驱指针int deleteNode(ListNode** head, int val) { ListNode *current *head; ListNode *prev NULL; while (current ! NULL) { if (current-val val) { if (prev NULL) { // 要删除的是头节点 *head current-next; } else { // 要删除的是中间或尾部节点 prev-next current-next; } free(current); return 1; // 删除成功 } prev current; current current-next; } return 0; // 未找到值 }这是链表练习的重点和难点。关键逻辑在于删除一个节点必须让它的前一个节点prev的next指针绕过它指向它的后一个节点。如果prev是NULL说明要删除的是头节点需要特殊处理直接移动*head。很多人的代码在这里出错就是因为prev指针没维护好。4. 遍历打印验证链表状态void printList(ListNode* head) { ListNode* current head; while (current ! NULL) { printf(%d - , current-val); current current-next; } printf(NULL\n); }这个函数是你的“调试器”。每次插入或删除后立刻调用它打印链表能直观地看到操作是否正确。2.2 写一个完整的测试程序验证把上面几个函数拼起来在main函数里写个测试流程#include stdio.h #include stdlib.h // 这里粘贴上面的结构体定义和四个函数 int main() { ListNode* head NULL; // 链表初始为空 printf(1. 头插法插入 3, 2, 1:\n); insertAtHead(head, 3); insertAtHead(head, 2); insertAtHead(head, 1); printList(head); // 预期输出: 1 - 2 - 3 - NULL printf(\n2. 删除中间节点 2:\n); if (deleteNode(head, 2)) { printf(删除成功。\n); } printList(head); // 预期输出: 1 - 3 - NULL printf(\n3. 删除头节点 1:\n); if (deleteNode(head, 1)) { printf(删除成功。\n); } printList(head); // 预期输出: 3 - NULL printf(\n4. 删除不存在的节点 5:\n); if (!deleteNode(head, 5)) { printf(未找到节点删除失败。\n); } printList(head); // 预期输出: 3 - NULL // 释放剩余内存练习时可先省略但好习惯要养成 while (head ! NULL) { ListNode* temp head; head head-next; free(temp); } return 0; }自己动手做把这段完整代码保存为list_practice.c。用gcc list_practice.c -o list_practice编译。运行./list_practice看输出是否和预期注释一致。尝试修改把insertAtHead改成尾插法insertAtTail。这会迫使你思考如何找到链表末尾。2.3 链表练习的常见“坑”与排查当你自己写链表代码出错时按这个顺序查段错误 (Segmentation fault)几乎都是指针问题。立刻检查malloc后是否检查了返回值访问current-val或current-next前current指针是否为NULL在循环中current current-next;会不会导致current变成NULL后还继续访问删除或插入后链表乱了画图用笔在纸上画出几个节点和指针模拟你的代码逻辑。重点看prev和next指针在操作前后指向哪里。内存泄漏程序结束前写一个freeList函数遍历链表释放所有节点。用valgrind工具检查valgrind ./your_program是专业做法。链表练到这里的程度你对指针和动态内存的理解会上一个台阶。接下来用类似的“实现验证”思路攻克栈和队列。3. 栈与队列用数组和链表两种方式实现理解抽象栈 (Stack) 和队列 (Queue) 是两种受限的线性表核心在于操作规则。栈是后进先出 (LIFO)队列是先进先出 (FIFO)。练习时我强烈建议你分别用数组和链表来实现它们。这会让你深刻体会“数据结构是一种抽象”同样的逻辑可以用不同的底层存储来实现。3.1 用数组实现栈顺序栈数组实现的特点是简单、访问快但大小固定。#define MAX_SIZE 100 // 栈的最大容量 typedef struct { int data[MAX_SIZE]; int top; // 栈顶指针数组下标 } ArrayStack; void initStack(ArrayStack* s) { s-top -1; // -1 表示空栈 } int isEmpty(ArrayStack* s) { return s-top -1; } int isFull(ArrayStack* s) { return s-top MAX_SIZE - 1; } int push(ArrayStack* s, int val) { if (isFull(s)) { printf(栈已满无法入栈。\n); return 0; } s-data[(s-top)] val; // 先移动top再赋值 return 1; } int pop(ArrayStack* s, int* val) { if (isEmpty(s)) { printf(栈为空无法出栈。\n); return 0; } *val s-data[(s-top)--]; // 先取值再移动top return 1; } int peek(ArrayStack* s, int* val) { if (isEmpty(s)) { printf(栈为空。\n); return 0; } *val s-data[s-top]; return 1; }关键点top指针的初始化和移动逻辑。push时先top再存数据pop时先取数据再top--。一定要先判断栈空/栈满这是健壮性的基础。3.2 用链表实现栈链式栈链表实现没有容量限制直到内存耗尽但每个节点需要额外指针。typedef struct StackNode { int val; struct StackNode* next; } StackNode; typedef struct { StackNode* top; // 只需要一个头指针即可 } LinkedStack; void initLinkedStack(LinkedStack* s) { s-top NULL; } int isEmptyLinked(LinkedStack* s) { return s-top NULL; } void pushLinked(LinkedStack* s, int val) { StackNode* newNode (StackNode*)malloc(sizeof(StackNode)); // 省略错误检查 newNode-val val; newNode-next s-top; // 新节点指向原栈顶 s-top newNode; // 更新栈顶指针 } int popLinked(LinkedStack* s, int* val) { if (isEmptyLinked(s)) { printf(栈为空无法出栈。\n); return 0; } StackNode* temp s-top; *val temp-val; s-top s-top-next; // 栈顶指针下移 free(temp); return 1; }对比学习链式栈的push就是链表的头插法pop就是删除头节点。这样一看栈就是操作受限的链表。写完后你可以写个测试程序对比两种实现的用法差异主要是容量限制。3.3 用链表实现队列队列需要两个指针队头 (front) 和队尾 (rear)。typedef struct QueueNode { int val; struct QueueNode* next; } QueueNode; typedef struct { QueueNode* front; QueueNode* rear; } LinkedQueue; void initQueue(LinkedQueue* q) { q-front q-rear NULL; } int isEmptyQueue(LinkedQueue* q) { return q-front NULL; } void enqueue(LinkedQueue* q, int val) { // 入队 QueueNode* newNode (QueueNode*)malloc(sizeof(QueueNode)); // 省略错误检查 newNode-val val; newNode-next NULL; if (isEmptyQueue(q)) { // 队列为空新节点既是队头也是队尾 q-front q-rear newNode; } else { // 队列不为空加到队尾 q-rear-next newNode; q-rear newNode; // 更新队尾指针 } } int dequeue(LinkedQueue* q, int* val) { // 出队 if (isEmptyQueue(q)) { printf(队列为空无法出队。\n); return 0; } QueueNode* temp q-front; *val temp-val; q-front q-front-next; // 如果出队后队列为空记得把rear也置为NULL if (q-front NULL) { q-rear NULL; } free(temp); return 1; }队列的易错点出队 (dequeue) 时如果出队后队列变空即q-front变成NULL必须把q-rear也设为NULL。否则rear会变成一个指向已释放内存的“悬空指针”后续入队操作会出错。这个边界条件很多初学者会漏掉。练习建议自己动手实现一个数组实现的循环队列。这是经典的面试题和考试题核心是处理队头、队尾指针到达数组末尾时的“循环”逻辑以及如何判断队空和队满。4. 二叉树理解递归遍历和层次遍历的代码转换树尤其是二叉树是练习递归思维的绝佳场景。很多人卡在递归上是因为试图在大脑里展开整个递归过程这很容易乱。我建议先接受递归的“定义式”写法再通过画栈帧图来理解。4.1 二叉树节点的定义与创建typedef struct TreeNode { int val; struct TreeNode* left; struct TreeNode* right; } TreeNode; TreeNode* createTreeNode(int val) { TreeNode* node (TreeNode*)malloc(sizeof(TreeNode)); // 省略错误检查 node-val val; node-left node-right NULL; return node; }4.2 递归遍历前序、中序、后序这三种遍历的代码结构极其相似差别仅在于访问根节点 (printf) 的时机。// 前序遍历根 - 左 - 右 void preOrderTraversal(TreeNode* root) { if (root NULL) { return; // 递归基 } printf(%d , root-val); // 访问根 preOrderTraversal(root-left); // 遍历左子树 preOrderTraversal(root-right); // 遍历右子树 } // 中序遍历左 - 根 - 右 void inOrderTraversal(TreeNode* root) { if (root NULL) { return; } inOrderTraversal(root-left); // 遍历左子树 printf(%d , root-val); // 访问根 inOrderTraversal(root-right); // 遍历右子树 } // 后序遍历左 - 右 - 根 void postOrderTraversal(TreeNode* root) { if (root NULL) { return; } postOrderTraversal(root-left); // 遍历左子树 postOrderTraversal(root-right); // 遍历右子树 printf(%d , root-val); // 访问根 }如何理解递归不要纠结于每一层怎么返回。你只需要相信preOrderTraversal(root-left)这个调用会完整地遍历完整个左子树。写递归函数时重点想清楚两件事递归基 (Base Case)什么情况下不需要再递归了对于遍历就是遇到NULL节点。递归步骤 (Recursive Step)在当前节点需要做什么对于前序就是先访问自己然后递归处理左孩子和右孩子。自己测试手动构建一棵简单的树。int main() { // 构建树: 1 // / \ // 2 3 // / \ // 4 5 TreeNode* root createTreeNode(1); root-left createTreeNode(2); root-right createTreeNode(3); root-left-left createTreeNode(4); root-left-right createTreeNode(5); printf(前序遍历: ); preOrderTraversal(root); // 输出: 1 2 4 5 3 printf(\n); printf(中序遍历: ); inOrderTraversal(root); // 输出: 4 2 5 1 3 printf(\n); printf(后序遍历: ); postOrderTraversal(root); // 输出: 4 5 2 3 1 printf(\n); // 释放内存后续遍历顺序释放最方便 // 略... return 0; }4.3 层序遍历使用队列层序遍历是非递归的需要用到我们前面实现的队列。它按从上到下、从左到右的顺序访问节点。// 假设使用前面定义的 LinkedQueue 及其函数 void levelOrderTraversal(TreeNode* root) { if (root NULL) return; LinkedQueue q; initQueue(q); enqueue(q, (intptr_t)root); // 注意这里需要存储指针简单处理可把队列数据类型改为 TreeNode* while (!isEmptyQueue(q)) { intptr_t nodePtr; dequeue(q, nodePtr); TreeNode* node (TreeNode*)nodePtr; printf(%d , node-val); if (node-left ! NULL) { enqueue(q, (intptr_t)node-left); } if (node-right ! NULL) { enqueue(q, (intptr_t)node-right); } } }层序遍历的核心思想初始化一个空队列。将根节点入队。当队列不为空时循环 a. 出队一个节点并访问它。 b. 将该节点的左孩子如果存在入队。 c. 将该节点的右孩子如果存在入队。这个算法保证了节点是按层被访问的。这里暴露了一个问题我们之前队列存储的是int现在要存TreeNode*。更好的设计是让队列的数据域类型是void*或使用泛型但为了初学者理解可以简单地将队列节点结构中的int val改为TreeNode* data并相应修改入队出队函数。这是一个很好的扩展练习修改你的链表队列使其能存储任意类型的指针。4.4 二叉树练习的深入方向当你掌握了基本遍历后可以挑战这些常见问题它们都是对递归和树结构的深化理解求二叉树深度深度 1 max(左子树深度 右子树深度)。判断两棵树是否相同根节点值相同且左右子树分别相同。判断是否对称二叉树左子树的左孩子 vs 右子树的右孩子左子树的右孩子 vs 右子树的左孩子。翻转二叉树交换每个节点的左右子树。每个问题都尝试先自己思考递归公式再动手编码最后用我们构建的小树验证。5. 排序算法在理解原理的基础上关注“比较”与“交换”排序是数据结构的综合应用。对于C语言练习我建议重点吃透冒泡排序、选择排序、插入排序、快速排序这四种。前三种帮助你理解最基本的“比较-交换”思想快排则是递归和分治的经典应用。5.1 从冒泡排序理解“交换”冒泡排序的核心是相邻元素两两比较逆序则交换。void bubbleSort(int arr[], int n) { for (int i 0; i n - 1; i) { // 进行 n-1 轮 // 优化如果某一轮没有发生交换说明已有序可提前结束 int swapped 0; for (int j 0; j n - 1 - i; j) { // 每轮比较范围逐渐缩小 if (arr[j] arr[j 1]) { // 交换 arr[j] 和 arr[j1] int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped 1; } } if (!swapped) break; // 本轮无交换提前结束 } }为什么叫“冒泡”因为每一轮都会把当前未排序部分的最大元素“浮”到最后面。自己画图用一个数组[5, 3, 8, 1]一步步跟踪i和j的变化看元素如何移动。这是理解循环边界 (n-1-i) 的最好方法。5.2 从选择排序理解“选择最小”选择排序的核心是每次从未排序部分选出最小元素放到已排序部分的末尾。void selectionSort(int arr[], int n) { for (int i 0; i n - 1; i) { int minIndex i; // 假设当前位置是最小值 for (int j i 1; j n; j) { if (arr[j] arr[minIndex]) { minIndex j; // 更新最小值的下标 } } // 将找到的最小值与当前位置交换 if (minIndex ! i) { int temp arr[i]; arr[i] arr[minIndex]; arr[minIndex] temp; } } }和冒泡的区别冒泡是不断交换相邻元素可能一轮交换多次选择排序是每轮只做一次交换将最小元素交换到正确位置。选择排序的交换次数更少。5.3 从插入排序理解“构建有序序列”插入排序像打扑克牌时整理手牌将未排序的元素逐个插入到已排序序列的合适位置。void insertionSort(int arr[], int n) { for (int i 1; i n; i) { // 从第二个元素开始 int key arr[i]; // 待插入的元素 int j i - 1; // 将比 key 大的元素都向后移动一位 while (j 0 arr[j] key) { arr[j 1] arr[j]; j--; } arr[j 1] key; // 插入 key 到正确位置 } }关键点内层循环是元素的向后移动为key腾出插入空间。对于小规模或基本有序的数据插入排序效率很高。5.4 快速排序理解递归与分治快速排序是面试和笔试的常客一定要理解其“分而治之”的思想。// 分区函数选取一个基准将数组分为小于基准和大于基准的两部分 int partition(int arr[], int low, int high) { int pivot arr[high]; // 选择最后一个元素作为基准 int i (low - 1); // 指向小于基准的区域的末尾 for (int j low; j high - 1; j) { if (arr[j] pivot) { i; // 交换 arr[i] 和 arr[j] int temp arr[i]; arr[i] arr[j]; arr[j] temp; } } // 将基准放到正确位置 int temp arr[i 1]; arr[i 1] arr[high]; arr[high] temp; return (i 1); // 返回基准的最终位置 } // 递归函数 void quickSort(int arr[], int low, int high) { if (low high) { int pi partition(arr, low, high); // 获取分区点 quickSort(arr, low, pi - 1); // 递归排序左半部分 quickSort(arr, pi 1, high); // 递归排序右半部分 } } // 包装函数方便调用 void quickSortWrapper(int arr[], int n) { quickSort(arr, 0, n - 1); }快速排序的步骤分区 (Partition)在数组中选择一个元素作为“基准”。重新排列数组所有比基准小的放在左边比基准大的放在右边。分区完成后基准就位于其最终排序后的正确位置。递归 (Recurse)递归地将小于基准的子数组和大于基准的子数组排序。如何理解分区函数变量i维护了一个“小于基准的边界”。遍历数组时每当找到一个比基准小的元素就把它交换到i的后面然后i向前移动。遍历结束后i1的位置就是基准应该放的地方。自己测试排序算法void printArray(int arr[], int size) { for (int i 0; i size; i) { printf(%d , arr[i]); } printf(\n); } int main() { int arr[] {64, 34, 25, 12, 22, 11, 90}; int n sizeof(arr) / sizeof(arr[0]); printf(原始数组: ); printArray(arr, n); // 测试不同的排序算法记得每次用原始数组的副本 int arr_bubble[n]; memcpy(arr_bubble, arr, sizeof(arr)); bubbleSort(arr_bubble, n); printf(冒泡排序后: ); printArray(arr_bubble, n); // 同理测试 selectionSort, insertionSort, quickSortWrapper // ... return 0; }6. 从练习到应用如何组织你的代码和下一步方向当你把上面这些基本数据结构的代码都亲手敲过、调试通过后你可能会觉得它们还是一个个孤立的片段。如何把它们串起来形成解决实际问题的能力我建议从两个方向入手6.1 代码组织模块化与头文件不要把所有代码都堆在一个main.c里。尝试为每个数据结构创建独立的.c和.h文件。list.h/list.c链表相关函数声明和定义。stack.h/stack.c栈的实现数组和链表版。queue.h/queue.c队列的实现。tree.h/tree.c二叉树相关。sort.h/sort.c排序算法。main.c包含头文件调用函数进行测试。这能让你练习多文件编译gcc -c生成.o文件再链接并理解接口头文件与实现源文件分离的思想。6.2 设计综合小项目找一些能综合运用多个数据结构的小题目例如表达式求值使用栈来处理运算符优先级中缀转后缀再求值。简单的文件目录遍历使用树的层次遍历配合队列来模拟ls -R。通讯录管理使用链表来存储联系人信息实现增删改查和排序。使用链表实现一个简单的内存池深入理解内存分配和管理。在做这些小项目时你会遇到比单纯练习更复杂的问题比如数据如何持久化文件读写、如何设计更合理的数据结构比如通讯录用哈希表可能更快、如何优化性能。这才是真正的“通关”。6.3 调试与验证养成好习惯单元测试思维为每个函数如insertAtHead,push,preOrderTraversal编写小的测试用例验证其正确性。使用调试器不要只靠printf。学习使用gdbGNU Debugger设置断点、单步执行、查看变量。这是理解程序运行时状态的利器。内存检查养成在程序结束前释放所有动态分配内存的习惯。用valgrind检查内存泄漏。边界测试给你的函数传入NULL、空链表、空树、空数组、只有一个元素的数组等边界情况看它是否健壮。最后回到最初的目标数据结构练习本质是训练你用代码建模和操作现实问题的能力。不要满足于“这个代码我抄懂了”要追求“这个问题我可以用链表/树/栈来建模并且能写出正确、高效的代码”。当你拿到一个新问题能自然地想到“这里可以用一个哈希表来快速查找”或者“这个递归过程可以用栈来模拟”时你就真正通关了。