1. 从“会写”到“会想”为什么你需要这40道C语言经典例题如果你正在学习C语言或者刚刚学完语法面对一个空白的编辑器窗口感到无从下手这种感觉我太熟悉了。十几年前我也是从“Hello World”开始然后对着指针、结构体、链表这些概念一脸茫然。我知道很多教材会告诉你语法规则但很少告诉你如何把这些规则组合起来去解决一个具体的问题。这就像给你一堆砖头和水泥却不告诉你如何盖房子。今天我想分享的这40道经典例题就是帮你完成从“认识砖头”到“盖出房子”这个关键跨越的脚手架。这些题目不是我凭空编造的它们是我从自己大学时期的作业、各种竞赛、面试题以及后来工作中遇到的真实问题里筛选、整理出来的。它们覆盖了从基础数据类型、流程控制到数组、函数、指针、结构体、文件操作等C语言的核心知识模块。更重要的是每一道题都指向一个编程中必须掌握的“思维模式”或“解题套路”。比如如何用循环处理批量数据如何用函数分解复杂任务指针到底在内存里玩什么“魔术”文件读写如何让程序的结果“落地生根”很多人学编程卡在“看懂了但写不出”的阶段。解决这个问题的唯一方法就是动手。不是漫无目的地写而是有目标、有阶梯地去练习。这40道题就是这样一个阶梯。通过它们你不仅能巩固语法更能训练“计算思维”——如何把一个现实问题抽象成计算机可以执行的步骤。接下来我会把这些题目分成几个核心能力模块并挑选其中最具代表性的题目带你一起拆解思路手把手写出代码并分享那些只有踩过坑才知道的“潜规则”。2. 夯实基础流程控制与基本算法的实战演练编程的核心是逻辑而逻辑最直接的体现就是流程控制顺序、分支、循环和基础算法。这一部分的题目看似简单却是构建复杂程序的基石。它们训练的是你将问题“步骤化”和“模式化”的能力。2.1 循环与迭代从累加求和到寻找素数让我们从一个最经典的问题开始计算1到100之间所有奇数的和。这道题考察的是循环和条件判断的配合。很多新手会写出这样的代码int sum 0; for(int i 1; i 100; i) { if(i % 2 ! 0) { // 判断是否为奇数 sum i; } } printf(1到100的奇数和为%d\n, sum);这完全正确。但这里有一个可以优化的思维点我们真的需要检查每一个数吗既然奇数序列是1, 3, 5, 7...我们能不能直接生成这个序列当然可以这就是“迭代步长”的思维。int sum 0; for(int i 1; i 100; i 2) { // 直接从1开始每次加2 sum i; } printf(1到100的奇数和为%d\n, sum);第二种方法循环次数减半效率更高。在编程中寻找更优的“遍历路径”是一种重要思维。再来看一个稍微进阶的题目判断一个数是否为素数质数。素数是指大于1且只能被1和自身整除的自然数。最直观的想法是对于一个数n用2到n-1之间的每一个数去试除。如果都不能整除n就是素数。int n, i, isPrime 1; // 先假设是素数 printf(请输入一个正整数); scanf(%d, n); if(n 1) { isPrime 0; // 小于等于1的数不是素数 } else { for(i 2; i n; i) { if(n % i 0) { isPrime 0; // 能被整除不是素数 break; // 已经确定不是素数立即跳出循环 } } } if(isPrime) { printf(%d是素数。\n, n); } else { printf(%d不是素数。\n, n); }这里引入了isPrime这个“标志变量”这是一个非常常用的技巧用于记录某种状态。同时一旦在循环中发现n能被某个数整除我们立即用break跳出循环避免无谓的后续计算这叫做“短路优化”。但算法还可以进一步优化。试想如果n不是素数它一定有一个因子小于或等于√n它的平方根。例如判断100是不是素数我们不需要试除到99只需要试除到10即可因为如果100能被大于10的数整除比如20那么它的配对因子5一定小于10我们在试除5的时候就已经发现了。因此循环条件可以优化为i sqrt(n)。使用数学库函数sqrt需要包含头文件math.h并在编译时链接数学库通常加-lm参数。#include math.h // ... 其他代码 for(i 2; i sqrt(n); i) { // 优化后的循环条件 if(n % i 0) { isPrime 0; break; } }这个优化将试除次数从O(n)量级降低到了O(√n)量级当n很大时效率提升是巨大的。这就是从“暴力求解”到“基于数学性质优化”的思维跃迁。2.2 数组应用排序、查找与矩阵运算当数据量变大时我们需要容器来存储这就是数组。数组相关的题目是理解内存连续存储和下标操作的绝佳练习。题目将一个数组中的元素逆序存放并输出。例如数组[1,2,3,4,5]逆序后为[5,4,3,2,1]。关键思路是“对称交换”。我们不需要第二个数组只需要在原数组上进行操作。定义两个“指针”下标一个i从头部开始一个j从尾部开始交换它们指向的元素然后i向后移动j向前移动直到两者相遇或交错。#define N 5 int arr[N] {1, 2, 3, 4, 5}; int i, j, temp; printf(原始数组); for(i 0; i N; i) printf(%d , arr[i]); printf(\n); // 逆序操作 for(i 0, j N - 1; i j; i, j--) { // i和j向中间靠拢 temp arr[i]; // 经典的三步交换法 arr[i] arr[j]; arr[j] temp; } printf(逆序后数组); for(i 0; i N; i) printf(%d , arr[i]); printf(\n);这里有一个细节循环条件是i j而不是i j。当数组元素个数为偶数时i和j会完美错过当为奇数时最中间的那个元素不需要和自己交换。i j的条件会导致奇数长度数组的中间元素与自己进行一次无意义的交换虽然结果正确但多了一次操作。题目实现冒泡排序算法。冒泡排序是理解排序思想的入门算法其核心是“相邻比较交换将最大或最小的元素像气泡一样‘浮’到序列顶端”。int arr[] {64, 34, 25, 12, 22, 11, 90}; int n sizeof(arr) / sizeof(arr[0]); // 计算数组长度 int i, j, temp; printf(排序前); for(i 0; i n; i) printf(%d , arr[i]); printf(\n); // 冒泡排序 for(i 0; i n - 1; i) { // 外层循环控制排序轮数n个数需要n-1轮 for(j 0; j n - 1 - i; j) { // 内层循环进行相邻比较 if(arr[j] arr[j 1]) { // 如果前面的数比后面大则交换升序排序 temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } printf(排序后); for(i 0; i n; i) printf(%d , arr[i]); printf(\n);理解冒泡排序的关键在于内层循环的边界n-1-i。每一轮i轮结束后最大的那个数已经被“冒泡”到了最后的位置下标n-1-i之后的位置因此下一轮比较时就不需要再考虑已经排好序的后i个元素了。这是减少不必要比较的优化。注意计算数组长度sizeof(arr) / sizeof(arr[0])这行代码只有在数组定义在当前作用域内时才有效。如果你把数组作为参数传递给一个函数在函数内部使用sizeof(arr)得到的是指针的大小通常是4或8字节而不是数组的总大小。这是C语言初学者常踩的一个坑。3. 深入核心指针、函数与内存的“三位一体”C语言的威力与灵活性很大程度上来自于指针。指针、函数和内存管理是C语言中最核心、也最容易让人困惑的部分。这里的题目旨在帮你建立清晰的“内存视图”。3.1 指针基础交换变量与数组遍历题目编写一个函数交换两个整型变量的值。这是理解指针“间接访问”能力的经典入门题。如果不用指针你会写出这样的函数void swap_wrong(int a, int b) { int temp a; a b; b temp; } // 调用 int x 5, y 10; swap_wrong(x, y); printf(x%d, y%d\n, x, y); // 输出仍然是 x5, y10为什么没换因为C语言的函数参数是“值传递”。调用swap_wrong(x, y)时只是将x和y的值5和10复制给了函数内部的形参a和b。函数内部交换的是a和b这两个副本对原始的x和y没有任何影响。函数结束时副本被销毁一切如常。要想真正交换x和y必须把它们的“地址”传给函数让函数通过地址找到原始的内存位置并进行修改。这就是指针的作用。void swap(int *pa, int *pb) { // 参数是指向int的指针 int temp *pa; // *pa 表示获取pa指针所指向地址的值即变量x的值 *pa *pb; // 将pb指向的值赋给pa指向的地址即修改了x *pb temp; // 将temp的值赋给pb指向的地址即修改了y } // 调用 int x 5, y 10; swap(x, y); // 传递x和y的地址 printf(x%d, y%d\n, x, y); // 输出 x10, y5这里的*符号有两个含义在声明时int *pa表示pa是一个指针变量在使用时*pa表示对指针pa进行“解引用”即获取它指向的那个整数值。是取地址运算符。这个过程就像是你把家里的钥匙地址给了朋友朋友才能进门帮你搬动家具修改数据。题目使用指针遍历数组。数组名本身在大多数情况下可以看作一个指向数组首元素的常量指针。int arr[] {10, 20, 30, 40, 50}; int *p arr; // p指向数组第一个元素arr[0] int n sizeof(arr) / sizeof(arr[0]); // 方法1指针偏移 for(int i 0; i n; i) { printf(%d , *(p i)); // *(pi) 等价于 arr[i] } printf(\n); // 方法2指针自增 p arr; // 重新指向开头 for(int i 0; i n; i) { printf(%d , *p); p; // 指针移动到下一个元素 } printf(\n);*(pi)和p[i]在此时是完全等价的。指针的算术运算加、减是以它指向的数据类型大小为单位的。p意味着p的值增加了sizeof(int)个字节从而指向了下一个整数。理解这一点就理解了数组在内存中是连续存储的本质。3.2 函数与递归分解问题与自我调用函数是代码复用的单元而递归是一种强大的问题分解思想它让函数调用自身。题目计算一个整数的阶乘使用递归和非递归两种方式。非递归方式迭代很直接long long factorial_iter(int n) { long long result 1; if(n 0) return -1; // 处理非法输入 for(int i 1; i n; i) { result * i; } return result; }递归方式则需要理解递归的两个要点1. 递归出口基线条件2. 递归式如何缩小问题规模。 对于阶乘n!递归出口0! 1。递归式n! n * (n-1)!。long long factorial_rec(int n) { if(n 0) return -1; // 非法输入 if(n 0) return 1; // 递归出口 return n * factorial_rec(n - 1); // 递归调用 }递归函数的执行会形成一个“调用栈”。计算factorial_rec(3)时栈的情况大致是factorial_rec(3)等待factorial_rec(2)的结果。factorial_rec(2)等待factorial_rec(1)的结果。factorial_rec(1)等待factorial_rec(0)的结果。factorial_rec(0)返回 1。factorial_rec(1)收到1计算1 * 1 1返回。factorial_rec(2)收到1计算2 * 1 2返回。factorial_rec(3)收到2计算3 * 2 6返回最终结果。递归代码简洁符合数学定义但对于大的n如50阶乘结果会超出long long的范围且递归深度过大会导致栈溢出。迭代方式通常效率更高、更安全。题目汉诺塔问题。这是一个经典的递归问题能极好地训练递归思维。 问题描述有三根柱子A、B、CA柱上有n个大小不同的圆盘从小到大叠放。要求把所有圆盘从A柱移动到C柱每次只能移动一个圆盘且任何时候大盘子都不能放在小盘子上面。递归思路要移动n个盘子从A到C可以分解为三步将A上面的n-1个盘子借助C柱移动到B柱。将A剩下的最大的那个盘子直接移动到C柱。将B柱上的n-1个盘子借助A柱移动到C柱。 可以看到步骤1和步骤3本身又是规模为n-1的汉诺塔问题。递归出口是当n1时直接移动。void hanoi(int n, char from, char to, char aux) { if(n 1) { printf(移动盘子 1 从 %c 到 %c\n, from, to); return; } // 将 n-1 个盘子从 from 移动到 aux借助 to hanoi(n - 1, from, aux, to); // 移动最大的盘子 printf(移动盘子 %d 从 %c 到 %c\n, n, from, to); // 将 n-1 个盘子从 aux 移动到 to借助 from hanoi(n - 1, aux, to, from); } // 调用 int num; printf(请输入汉诺塔的层数); scanf(%d, num); hanoi(num, A, C, B); // 从A移到CB作为辅助理解这个递归的关键是不要试图在大脑里模拟每一步而是相信递归函数能正确完成“移动n-1个盘子”这个子任务。你只需要定义清楚任务是什么移动n个从X到Y借助谁Z以及最基础的一步怎么做。递归的魅力在于用有限的代码描述无限的过程。4. 构建复杂结构结构体、链表与文件操作当基本数据类型不够用我们需要描述一个具有多个属性的实体如一个学生、一本书时结构体就派上用场了。而链表则提供了动态管理内存中数据集合的能力。文件操作让程序的数据得以持久化保存。4.1 结构体与联合体自定义数据类型的组织题目定义一个学生结构体包含学号、姓名、三门课成绩并计算平均分和总分。#include string.h #define NAME_LEN 50 struct Student { int id; char name[NAME_LEN]; float score_math; float score_english; float score_c; float total; float average; }; int main() { struct Student stu; printf(请输入学号); scanf(%d, stu.id); getchar(); // 吸收掉输入缓冲区里残留的回车符为后面输入字符串做准备 printf(请输入姓名); fgets(stu.name, NAME_LEN, stdin); // fgets会读入换行符我们需要去掉它 stu.name[strcspn(stu.name, \n)] \0; printf(请输入数学、英语、C语言成绩用空格隔开); scanf(%f %f %f, stu.score_math, stu.score_english, stu.score_c); // 计算总分和平均分 stu.total stu.score_math stu.score_english stu.score_c; stu.average stu.total / 3.0; // 输出信息 printf(\n学生信息如下\n); printf(学号%d\n, stu.id); printf(姓名%s\n, stu.name); printf(数学%.2f 英语%.2f C语言%.2f\n, stu.score_math, stu.score_english, stu.score_c); printf(总分%.2f 平均分%.2f\n, stu.total, stu.average); return 0; }这里有几个关键点struct Student定义了一个新的数据类型它把相关的数据成员捆绑在一起。输入字符串时我用了fgets而不是scanf(“%s”)因为fgets可以安全地读取包含空格的字符串如中文名或英文名带空格并指定最大长度防止缓冲区溢出。getchar()用来清理输入缓冲区。当你用scanf读入一个整数后按回车这个回车符\n会留在缓冲区。紧接着的fgets看到\n就会立刻结束读取导致你根本没机会输入姓名。getchar()可以把这个\n消耗掉。strcspn(stu.name, “\n”)函数返回字符串中第一个匹配到\n的字符位置我们将该位置设为字符串结束符\0从而去掉换行符。有时我们需要一种能在不同情况下存储不同类型数据的结构但又希望节省内存因为几种情况不会同时发生这时可以用联合体union。例如描述一个设备传感器的数据可能是整数、浮点数或一个短字符串。union SensorData { int i_val; float f_val; char str_val[20]; }; struct Sensor { int data_type; // 0: int, 1: float, 2: string union SensorData data; };union的所有成员共享同一块内存空间其大小由最大的成员决定。通过外层的data_type来标识当前存储的是哪种类型的数据然后去访问对应的成员data.i_val,data.f_val或data.str_val。这是C语言实现“变体”类型的一种方式。4.2 动态数据结构单向链表的创建、插入与删除数组的大小在编译时就必须确定而链表可以在运行时动态地添加或删除节点非常灵活。链表由一系列节点组成每个节点包含数据和指向下一个节点的指针。题目实现一个管理学生信息的单向链表支持添加节点、遍历打印和按学号删除节点。首先定义节点结构typedef struct StudentNode { int id; char name[50]; float score; struct StudentNode *next; // 指向下一个节点的指针 } StudentNode;typedef关键字为我们创建的类型struct StudentNode起了一个别名StudentNode这样后面写起来更简洁。链表的操作核心在于指针的维护。我们通常用一个head指针指向链表的第一个节点。// 创建新节点 StudentNode* createNode(int id, const char* name, float score) { StudentNode* newNode (StudentNode*)malloc(sizeof(StudentNode)); if(newNode NULL) { printf(内存分配失败\n); exit(1); } newNode-id id; strcpy(newNode-name, name); newNode-score score; newNode-next NULL; // 新节点暂时不指向任何地方 return newNode; } // 在链表尾部添加节点 void appendNode(StudentNode** headRef, int id, const char* name, float score) { StudentNode* newNode createNode(id, name, score); if(*headRef NULL) { // 如果链表为空新节点就是头节点 *headRef newNode; } else { // 找到最后一个节点 StudentNode* current *headRef; while(current-next ! NULL) { current current-next; } // 将新节点链接到最后 current-next newNode; } } // 遍历打印链表 void printList(StudentNode* head) { StudentNode* current head; printf(学号\t姓名\t成绩\n); printf(-------------------\n); while(current ! NULL) { printf(%d\t%s\t%.2f\n, current-id, current-name, current-score); current current-next; // 移动到下一个节点 } } // 按学号删除节点 int deleteNode(StudentNode** headRef, int id) { StudentNode* current *headRef; StudentNode* prev NULL; // 遍历寻找要删除的节点 while(current ! NULL current-id ! id) { prev current; current current-next; } if(current NULL) { // 没找到 printf(未找到学号为 %d 的学生。\n, id); return 0; // 删除失败 } // 找到了要删除的节点 current if(prev NULL) { // 要删除的是头节点 *headRef current-next; } else { // 要删除的是中间或尾部节点 prev-next current-next; } free(current); // 释放该节点占用的内存 printf(已删除学号为 %d 的学生。\n, id); return 1; // 删除成功 } // 释放整个链表的内存防止内存泄漏 void freeList(StudentNode* head) { StudentNode* current head; StudentNode* nextNode; while(current ! NULL) { nextNode current-next; // 先保存下一个节点的地址 free(current); // 释放当前节点 current nextNode; // 移动到下一个节点 } }链表操作中最容易出错的就是指针的指向。在删除节点时必须处理好前驱节点prev的next指针。如果删除的是头节点需要更新head指针。另外务必记得用free()释放被删除节点或整个链表的内存否则会造成内存泄漏。重要心得画图是理解链表操作的不二法门。在纸上画出每个节点一个方框里面写上数据和next指针用箭头表示指针的指向。进行插入、删除操作时一步一步地修改箭头代码逻辑就会清晰很多。永远先想清楚指针该怎么变再写代码。4.3 数据持久化文件读写操作程序运行时的数据存储在内存中程序结束就消失了。文件操作允许我们将数据保存到硬盘上下次程序启动时可以读取。题目将学生链表中的数据保存到文件并能从文件加载数据重建链表。我们将使用二进制文件格式”wb”,”rb”来保存和读取结构体数据这样效率更高。// 保存链表到文件 void saveListToFile(StudentNode* head, const char* filename) { FILE* file fopen(filename, wb); // 以二进制写入模式打开 if(file NULL) { printf(无法打开文件 %s 用于写入。\n, filename); return; } StudentNode* current head; while(current ! NULL) { // 将当前节点的数据写入文件 // 注意这里写入的是结构体数据本身不是指针 fwrite(current, sizeof(StudentNode), 1, file); current current-next; } fclose(file); printf(链表数据已保存到文件 %s\n, filename); }等等上面的写法有一个巨大的隐患我们直接把StudentNode结构体写入了文件而这个结构体里包含一个next指针。指针的值是一个内存地址这个地址只在本次程序运行中有效。下次程序启动时内存布局完全不同这个地址值就是无效的甚至是有害的。我们把无效的指针值读回来链表就完全乱套了。正确的做法是只把业务数据id, name, score写入文件忽略next指针。我们需要一个只包含业务数据的结构体。typedef struct { int id; char name[50]; float score; } StudentData; // 正确的保存函数 void saveListToFileCorrect(StudentNode* head, const char* filename) { FILE* file fopen(filename, wb); if(file NULL) { perror(打开文件失败); // perror可以打印系统错误信息 return; } StudentNode* current head; StudentData data; while(current ! NULL) { // 将链表节点的业务数据复制到临时结构体 data.id current-id; strcpy(data.name, current-name); data.score current-score; // 写入业务数据 fwrite(data, sizeof(StudentData), 1, file); current current-next; } fclose(file); printf(链表数据已保存到文件 %s\n, filename); } // 从文件加载数据并重建链表 StudentNode* loadListFromFile(const char* filename) { FILE* file fopen(filename, rb); if(file NULL) { printf(文件 %s 不存在或无法打开。\n, filename); return NULL; } StudentNode* head NULL; StudentData data; // 循环读取文件直到文件结束 while(fread(data, sizeof(StudentData), 1, file) 1) { // 每读出一条记录就创建一个新节点并添加到链表尾部 appendNode(head, data.id, data.name, data.score); } fclose(file); printf(已从文件 %s 加载数据。\n, filename); return head; }这里的关键点分离数据与指针用于文件存储的结构体StudentData只包含纯粹的数据不包含任何指向内存的指针。这是序列化Serialization的基本思想。检查文件操作返回值fopen、fread、fwrite等函数都可能失败。fopen失败返回NULLfread返回成功读取的元素个数。检查这些返回值是编写健壮程序的基本要求。使用perror当标准库函数出错时全局变量errno会被设置。perror(“提示信息”)会打印你提供的提示信息并附带系统对errno的解释例如“Permission denied”对于调试非常有用。模式字符串”wb”表示以二进制模式写入如果文件存在则清空不存在则创建。”rb”表示以二进制模式读取。与之对应的文本模式是”w”和”r”在Windows系统下文本模式会对换行符\n进行转换处理二进制数据如图片、结构体时必须用二进制模式。文件操作的最后别忘了用fclose关闭文件。操作系统对同时打开的文件数量是有限制的不关闭文件会导致资源泄漏。5. 综合挑战与进阶思考掌握了以上模块你已经可以解决大多数C语言基础问题了。最后我们来看两个综合性的题目它们融合了多个知识点并引导你思考更优的解决方案。5.1 字符串处理与内存管理实现一个简单的字符串工具库C语言没有内置的字符串类型而是用字符数组和一系列标准库函数在string.h中来处理。理解这些函数的内部原理和正确使用它们至关重要。题目自己实现strlen,strcpy,strcat,strcmp这几个常用字符串函数。// 1. 计算字符串长度不包括结尾的\0 int my_strlen(const char* str) { int len 0; while(str[len] ! \0) { // 遍历直到遇到字符串结束符 len; } return len; } // 2. 字符串复制 char* my_strcpy(char* dest, const char* src) { int i 0; while(src[i] ! \0) { dest[i] src[i]; i; } dest[i] \0; // 不要忘记添加结束符 return dest; // 标准库strcpy返回目标字符串的起始地址 } // 3. 字符串连接 char* my_strcat(char* dest, const char* src) { // 先找到dest的结尾 int dest_len my_strlen(dest); int i 0; // 从dest的结尾开始追加src while(src[i] ! \0) { dest[dest_len i] src[i]; i; } dest[dest_len i] \0; // 添加结束符 return dest; } // 4. 字符串比较 int my_strcmp(const char* str1, const char* str2) { int i 0; // 逐个字符比较直到遇到不相等的字符或某个字符串结束 while(str1[i] ! \0 str2[i] ! \0 str1[i] str2[i]) { i; } // 返回两个字符的ASCII码差值 return (unsigned char)str1[i] - (unsigned char)str2[i]; // 使用unsigned char是为了正确处理大于127的字符如中文GBK编码的一部分 }自己实现这些函数能让你深刻理解字符串必须以\0空字符结尾这是所有字符串函数工作的前提。strcpy和strcat不检查目标数组是否有足够空间这是不安全的。在实际项目中应该使用更安全的版本如strncpy、strncat或者计算好长度再操作。strcmp的比较规则是逐字符比较ASCII码直到出现不同或遇到\0。返回值小于0表示str1小于str2等于0表示相等大于0表示str1大于str2。5.2 算法思维提升二分查找与动态规划初探题目在一个已排序的整数数组中使用二分查找算法寻找特定元素。二分查找是效率极高的查找算法时间复杂度为O(log n)但前提是数组必须有序。int binarySearch(int arr[], int size, int target) { int left 0; int right size - 1; while(left right) { int mid left (right - left) / 2; // 防止(leftright)可能溢出 if(arr[mid] target) { return mid; // 找到目标返回下标 } else if(arr[mid] target) { left mid 1; // 目标在右半部分 } else { right mid - 1; // 目标在左半部分 } } return -1; // 未找到 }关键细节循环条件是left right而不是left right。考虑数组只有一个元素的情况left0, right0如果条件是则不会进入循环直接返回-1这是错误的。计算中间下标时使用mid left (right - left) / 2而不是(left right) / 2是为了防止leftright的值超过int类型的最大值导致溢出。这是一种安全的写法。题目斐波那契数列递归与动态规划对比。 斐波那契数列定义为F(0)0, F(1)1, F(n)F(n-1)F(n-2) (n2)。递归解法非常直观但效率极低long long fib_rec(int n) { if(n 1) return n; return fib_rec(n-1) fib_rec(n-2); }为什么效率低以计算fib_rec(5)为例函数调用树如下fib(5) / \ fib(4) fib(3) / \ / \ fib(3) fib(2) fib(2) fib(1) / \ / \ / \ fib(2) fib(1) fib(1)fib(0) ... / \ fib(1) fib(0)fib(3)被计算了2次fib(2)被计算了3次存在大量的重复计算。时间复杂度是指数级的O(2^n)。动态规划Dynamic Programming的思想是“记住已经算过的结果”避免重复计算。我们可以用一个数组来保存子问题的解。long long fib_dp(int n) { if(n 1) return n; long long dp[n1]; // C99支持变长数组部分编译器可能需要动态分配 dp[0] 0; dp[1] 1; for(int i 2; i n; i) { dp[i] dp[i-1] dp[i-2]; } return dp[n]; }这个算法的时间复杂度是O(n)空间复杂度也是O(n)。我们还可以进一步优化空间因为计算dp[i]只需要前两个值dp[i-1]和dp[i-2]。long long fib_dp_opt(int n) { if(n 1) return n; long long prev2 0; // F(i-2) long long prev1 1; // F(i-1) long long current; for(int i 2; i n; i) { current prev1 prev2; prev2 prev1; prev1 current; } return current; }优化后空间复杂度降为O(1)。这个例子清晰地展示了从“暴力递归”到“记忆化搜索/动态规划”的优化路径是算法学习中非常重要的思维模式。从这些例题出发你可以尝试去解决更复杂的问题比如用链表实现队列或栈用结构体数组管理一个通讯录或者尝试一些简单的算法问题。编程能力的提升没有捷径就是理解原理、反复练习、不断思考和总结。这40道题是一个起点希望它们能帮你打好基础建立起解决问题的信心和思维框架。当你能够独立完成并理解所有这些题目时你会发现C语言不再是一门令人畏惧的课程而是一个得心应手的工具你可以用它去构建更复杂、更有趣的程序世界。