C 语言编程经典 100 例(详解版)

本系列面向基础Base或刚开始学习 C 语言的同学,共收录 程序 1 ~ 程序 100 一百道题目,按知识点从易到难编排,讲解循序渐进。


如何使用本文档

  • 每一题包含:题目 → 解题思路 → 完整代码 → 代码讲解 → 知识点 → 数学原理(如适用) → 扩展思考 / 其他解法
  • 讲解中第一次出现的概念会展开解释;后面的题目会直接引用ref前面的概念,不再重复展开。
  • 建议学习方式:先自己读题、动手写,写不出来再看解题思路,最后对照完整代码逐行理解,再合上文档自己重写一遍。
  • 文中代码均采用符合现代 C 标准的写法(补全 #include、使用 int main()),多数代码在 Dev-C++、Visual Studio、Code::Blocks、VS Code + GCC 等环境下可直接编译Compiler运行。
  • 特殊环境说明:程序 32~35 涉及终端Terminal颜色与光标控制,推荐在 Windows Terminal / VS Code 终端 / Linux / macOS 终端等现代终端中运行;程序 56~65 为图形绘制题,使用轻量的 ACLLib 图形库(GitHub:wengkai/ACLLib)实现,需要先按第六部分的说明配置(Windows + MinGW / Dev-C++ / VS Code + GCC)。

零基础读者必读:预备知识

如果你完全没接触过编程,请先花十几分钟读完这一节。这一节里提到的名词,后面的每一题都会用到。

1. 什么是程序?什么是 C 语言?

计算机只会"执行指令"。程序就是把你想让计算机做的事,用某种"人看得懂、机器能执行"的语言写下来的一串指令。C 语言是其中最经典的一种,它语法简洁、运行速度快,是很多大学的第一门编程语言,也是操作系统Operating System(Linux、Windows 内核Kernel)、嵌入式系统的基础语言。

2. 从"源代码"到"可运行的程序"

用 C 语言写的文件叫源代码(后缀通常是 .c),它本身计算机还不能直接执行,需要经过两个步骤:

  1. 编译:编译器(如 GCC)把源代码翻译成机器能懂的二进制代码。
  2. 链接:把我们用到的库函数(比如 printf)和主程序"拼"在一起,生成可执行文件(Windows 下是 .exe)。

所以,编程界有一句话:"代码写错一处,编译报错一片"。不要怕报错,报错信息是编译器在帮你。

3. 程序的基本框架(几乎每个程序都一样)

#include <stdio.h>      // 头文件:引入 printf、scanf 等函数

int main()              // 主函数:程序从这里开始执行
{
    // 在这里写你的代码
    return 0;           // 返回 0 表示程序正常结束
}
  • #include <stdio.h>stdio 是"标准输入输出"(Standard Input Output)。printf(输出)、scanf(输入)都在这个头文件里声明。用了就要 #include
  • main主函数,程序唯一的人口。程序一运行,编译器就会从 main 的第一行开始执行。
  • 花括号 { }:把一组语句"圈"在一起,叫一个代码块
  • 每一条语句末尾要有分号 ;,就像中文句子末尾的句号。

4. 变量与数据类型

变量就是"存放数据的盒子"。用之前要先"声明"(告诉计算机盒子的名字和它能装什么类型的数):

类型 含义 示例
int 整数(不含小数) int age = 18;
long / long long 更大的整数 long long x;
float 小数(单精度) float pi = 3.14;
double 小数(双精度,更精确) double pi = 3.1415926;
char 单个字符 char c = 'A';
  • 赋值:age = 18; 表示把 18 放进 age 这个盒子。
  • 变量名:只能由字母、数字、下划线组成,不能以数字开头,不能用关键字(如 intif)。

为什么有 intfloat 之分?因为计算机内存里整数和小数的存储方式完全不同。用 int 装小数会丢失小数部分,用 float 装大整数可能产生误差。后面很多题(如程序 3、20、24)会体现这一点。

5. 运算符

  • 算术运算符+ 加、- 减、* 乘、/ 除、% 取余(求余数,如 7 % 3 = 1)。

    注意:整数除以整数结果还是整数!例如 5 / 2 在 C 里结果是 2 而不是 2.5(小数部分被丢掉)。想得到小数,要让其中一个数是小数,如 5 / 2.0(float)5 / 2

  • 关系运算符> 大于、< 小于、>= 大于等于、<= 小于等于、== 等于(注意是两个等号!)、!= 不等于。结果只有两种:成立(真,记为 1)或不成立(假,记为 0)。
  • 逻辑运算符&& 与(两边都成立才成立)、|| 或(一边成立就成立)、! 非(取反)。
  • 自增自减i++ 表示 i = i + 1i-- 表示 i = i - 1
  • 复合赋值a += 3 等价于 a = a + 3a *= 2 等价于 a = a * 2

6. 流程控制:让程序"会思考"

  • if 语句:如果条件成立就做某事。

    if (score >= 60)
      printf("及格了\n");
    else
      printf("不及格\n");
  • switch 语句:多选一(根据某个整数的值跳到对应分支)。
  • for 循环:重复执行某段代码固定次数。

    for (i = 1; i <= 10; i++)   // 从 1 数到 10
      printf("%d ", i);        // 循环体:重复做的事

    for 括号里分三部分:初始; 条件; 每轮结束后的动作。先执行初始,然后判断条件,成立就执行循环体,再执行"每轮动作",再判断条件……直到条件不成立为止。

  • while 循环:条件成立就一直循环;do...while:先做一次再判断条件(至少执行一次)。

7. 数组:一排变量

数组是一串同类型变量的集合。int a[10]; 表示一次声明了 10 个整数盒子,用下标访问:a[0]a[1]……a[9]

重要:C 语言下标从 0 开始a[10] 的第一个元素是 a[0],最后一个元素是 a[9]

8. 函数:把代码"打包"

函数就是一段有名字、可以反复调用的代码。比如把"交换两个数"写成函数后,需要时调用一下即可。写函数就是"先声明参数Parameter(输入),计算,返回结果":

int add(int a, int b)   // 两个参数 a、b,返回 int 类型结果
{
    return a + b;       // return 把结果交回去
}

9. 指针:盒子的"门牌号"

每个变量在内存里都有一个地址(门牌号)。指针就是用来存放地址的变量。int *p = &x; 表示:p 这个指针变量存放了变量 x 的地址;*p 表示"通过这个地址找到那个盒子里的值"。

初学者会觉得指针很难,其实只要记住两句话:

  • &变量名 → 取地址(找到门牌号)
  • *指针名 → 取内容(按门牌号进门拿东西)

10. 结构体:自己发明的"复合盒子"

结构体(struct) 允许你把不同类型的数据打包成一个整体。例如一个"学生"可以同时包含学号、姓名、成绩:

struct student {
    int num;        // 学号
    char name[20];  // 姓名
    float score;    // 成绩
};

11. 字符串与 \0

C 语言没有专门的"字符串类型",字符串就是一个字符数组,并且末尾自动带一个 \0(空字符)作为结束标志。printf("%s", s) 遇到 \0 才知道字符串到哪结束。这正是后面很多题(程序 70、82、86 等)的解题关键。

12. 一个实用的调试建议

写代码遇到问题,先看两处:① 每条语句末尾有没有分号;② 括号是否配对。编译报错不可怕,把第一条报错信息(往往在文件最上面)看懂并改掉,通常后面的错误也会一起消失。


第一部分:入门基础题(程序 1 ~ 10)


【程序 1】用 1、2、3、4 能组成多少个互不相同且无重复数字的三位数?

题目:有 1、2、3、4 四个数字,能组成多少个互不相同且无重复数字的三位数?都是多少?

解题思路

这就是"枚举 + 筛选":

  1. 百位、十位、个位三个位置,每个位置都可以放 1、2、3、4 中的任意一个,所以一共有 $4 \times 4 \times 4 = 64$ 种放法(这叫穷举)。
  2. 题目要求三个数字互不相同,所以从 64 种里把"有重复数字"的组合筛选掉。

用三重循环把三个位置的所有组合都走一遍,再用一个 if 判断三个数是否两两不同,满足就输出。这体现了一种非常经典的编程思想:先暴力枚举所有可能,再过滤掉不合条件的

完整代码

#include <stdio.h>

int main()
{
    int i, j, k;
    for (i = 1; i <= 4; i++)        // 百位
        for (j = 1; j <= 4; j++)    // 十位
            for (k = 1; k <= 4; k++)// 个位
            {
                if (i != j && i != k && j != k)  // 三个数字互不相同
                    printf("%d%d%d\n", i, j, k);
            }
    return 0;
}

代码讲解

  • 三重 for 循环嵌套:最外层 i(百位)每取一个值,内层的 j(十位)就要完整跑一遍 1~4,同理 k(个位)又要完整跑一遍。总执行次数 $4 \times 4 \times 4 = 64$ 次,这就是"枚举所有可能"。
  • i != j && i != k && j != k!= 是"不等于",&& 是"并且"。三个条件同时成立才说明三个数字互不相同。
  • 题目问"多少个":可以加一个计数器 count++,最后打印 count,这就是简单的统计思想(第 12 题也会用到)。

知识点

  • for 循环嵌套
  • 关系运算符 !=、逻辑运算符 &&
  • 穷举(枚举)思想:把所有可能列出来再筛选

扩展思考(其他解法)

题目要的是"个数",还可以用排列组合的数学方法直接算:百位有 4 种选择,十位不能和百位重复剩 3 种,个位剩 2 种,所以总数是 $4 \times 3 \times 2 = 24$ 个。这个题的目的不是要"算得快",而是要你学会用循环枚举让计算机替你做重复劳动——这也是编程和手算的本质区别。


【程序 2】奖金提成问题

题目:企业发放的奖金根据利润提成:利润 I ≤ 10 万时,提 10%;10 万 < I ≤ 20 万时,超出 10 万的部分提 7.5%;20 万 < I ≤ 40 万时,超出 20 万的部分提 5%;40 万 < I ≤ 60 万时,超出 40 万的部分提 3%;60 万 < I ≤ 100 万时,超出 60 万的部分提 1.5%;I > 100 万时,超出 100 万的部分提 1%。从键盘输入利润 I,求应发奖金总额。

解题思路

这是典型的分段函数问题。关键思想:先在数轴上把每个"分界点"的累计奖金算出来,再根据输入落在哪一段,只计算"超出的那一小段"。

以 10 万为例:利润低于 10 万时,奖金 = I × 10%;利润在 10~20 万之间时,前 10 万固定拿 1 万($100000 \times 0.1$),多出的部分按 7.5% 计算。所以先算出 bonus1(正好 10 万时的奖金)、bonus2(正好 20 万时的奖金)……存起来,后面判断落在哪段就直接用。

完整代码

#include <stdio.h>

int main()
{
    long profit;        // 利润(元)
    double bonus;       // 奖金
    int bonus1, bonus2, bonus4, bonus6, bonus10;

    printf("请输入当月利润(元):");
    scanf("%ld", &profit);

    /* 先算出各个分界点对应的累计奖金 */
    bonus1  = 100000 * 0.1;                    // 利润正好10万时的奖金
    bonus2  = bonus1  + 100000 * 0.075;        // 利润正好20万时
    bonus4  = bonus2  + 200000 * 0.05;         // 利润正好40万时
    bonus6  = bonus4  + 200000 * 0.03;         // 利润正好60万时
    bonus10 = bonus6  + 400000 * 0.015;        // 利润正好100万时

    if (profit <= 100000)
        bonus = profit * 0.1;
    else if (profit <= 200000)
        bonus = bonus1 + (profit - 100000) * 0.075;
    else if (profit <= 400000)
        bonus = bonus2 + (profit - 200000) * 0.05;
    else if (profit <= 600000)
        bonus = bonus4 + (profit - 400000) * 0.03;
    else if (profit <= 1000000)
        bonus = bonus6 + (profit - 600000) * 0.015;
    else
        bonus = bonus10 + (profit - 1000000) * 0.01;

    printf("应发奖金为 %.2f 元\n", bonus);
    return 0;
}

代码讲解

  • 先算 bonus1 等"分界点的累计奖金",再写 if...else if...else 判断利润落在哪一段。每一段都是:"前面已算好的累计奖金 + 超出部分 × 对应提成比例"。
  • scanf("%ld", &profit)%ld 对应 long 类型,&profit 表示把输入的值存到 profit 这个盒子里& 是取地址,见预备知识第 9 节)。
  • 注意不要把 bonus 定义为 int:那会让奖金的小数部分被丢弃,这里使用了 double

知识点

  • if...else if...else 多分支判断
  • 整数 long 与浮点数 double 混用的类型问题
  • scanf 从键盘读入数据

数学原理

分段函数(piecewise function)的思想:奖金总额是利润的分段线性函数,每段的"斜率"(提成比例)不同。分段点的累计值是预先算好的"基准",避免每段都从头累加。这也是现实世界很多计费系统(如阶梯电价、个税)的模型。

扩展思考(其他解法)

  • 用数组 + 循环改写:把分界点和比例分别存进数组,用循环从高段往低段找,代码更简洁、也更容易扩展到更多档位。可以自己尝试。
  • 本题的"多个方案"不是重点,重点是理解分段处理的思想。

【程序 3】求一个数:它加上 100 后是完全平方数,再加 168 又是完全平方数

题目:一个整数,它加上 100 后是一个完全平方数,再加上 168 又是一个完全平方数,请问该数是多少?

解题思路

完全平方数就是能写成"某个整数的平方"的数,比如 1、4、9、16、25……

设这个数为 $x$,条件就是:$x+100 = a^2$ 且 $x+100+168 = x+268 = b^2$,其中 $a$、$b$ 都是整数。

思路:在 1~100000 的范围内逐个检查 $x$,对每个 $x$ 算出 $\sqrt{x+100}$ 和 $\sqrt{x+268}$,如果这两个平方根"开方开得尽"(即平方根再平方回去等于原数),就说明是完全平方数。

判断"开方开得尽"的技巧:(int)sqrt(n) * (int)sqrt(n) == n。因为 sqrt 返回的是 double 小数,强转成 int 会把小数部分丢掉,若开方开得尽,丢不掉东西,两者相等。

完整代码

#include <stdio.h>
#include <math.h>

int main()
{
    long int x;
    int a, b;
    for (x = 1; x < 100000; x++)
    {
        a = (int)sqrt(x + 100);        // 平方根取整
        b = (int)sqrt(x + 268);        // x+100 再加 168,即 x+268
        if (a * a == x + 100 && b * b == x + 268)
            printf("%ld\n", x);
    }
    return 0;
}

代码讲解

  • sqrt 是"开平方"函数,声明在 <math.h> 里,所以要多一个 #include <math.h>
  • a = (int)sqrt(x + 100);(int)强制类型转换,把 sqrt 返回的小数转成整数(小数部分直接丢掉)。
  • a * a == x + 100:如果"整数平方根再平方"还能等于原数,说明原数恰好能开方开尽 → 是完全平方数。
  • 一个 && 同时验证两个条件都成立。

知识点

  • <math.h> 头文件与数学函数 sqrt
  • 强制类型转换 (int)
  • 完全平方数的判断技巧

数学原理

这里值得展开讲讲:题目其实有更妙的数学解法。设 $b^2 - a^2 = 168$,即 $(b-a)(b+a) = 168$。因为 168 是偶数,$b-a$ 与 $b+a$ 同奇偶,且 $168 = 2 \times 84 = 4 \times 42 = 6 \times 28 = 12 \times 14$,可以解出有限组整数解,从而得到 $x$。用程序枚举则更"暴力"但更通用——计算机擅长的就是这种笨办法,把数学推导交给代码去试

小知识:直接运行可得到答案 21(对应 $21+100=121=11^2$,$21+268=289=17^2$)等结果。


【程序 4】判断某年某月某日是这一年的第几天

题目:输入某年某月某日,判断这一天是这一年的第几天。

解题思路

以 3 月 5 日为例:先把 1 月和 2 月的总天数加起来(31 + 28 = 59 天),再加上本月的 5 天,就是第 64 天。唯一要注意的是闰年:闰年 2 月有 29 天,所以如果输入的是闰年且月份在 3 月及以后,要在结果上加 1 天。

闰年的判断规则(格里高利历):能被 400 整除,或者能被 4 整除但不能被 100 整除。写成表达式是 year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)

完整代码

#include <stdio.h>

int main()
{
    int year, month, day, sum, leap;
    printf("请输入年,月,日:");
    scanf("%d,%d,%d", &year, &month, &day);

    /* 先累加"这个月之前"所有月份的天数 */
    switch (month)
    {
        case 1:  sum = 0;   break;
        case 2:  sum = 31;  break;
        case 3:  sum = 59;  break;
        case 4:  sum = 90;  break;
        case 5:  sum = 120; break;
        case 6:  sum = 151; break;
        case 7:  sum = 181; break;
        case 8:  sum = 212; break;
        case 9:  sum = 243; break;
        case 10: sum = 273; break;
        case 11: sum = 304; break;
        case 12: sum = 334; break;
        default: printf("月份输入错误\n"); return 1;
    }

    sum = sum + day;   // 再加上本月的天数

    /* 判断闰年 */
    if (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0))
        leap = 1;
    else
        leap = 0;

    if (leap == 1 && month > 2)   // 闰年且月份在 2 月之后,多算一天
        sum++;

    printf("这一天是这一年的第 %d 天\n", sum);
    return 0;
}

代码讲解

  • switch (month) 按月份取"该月之前的总天数",每个分支末尾的 break 很关键——没有 break,程序会"掉下去"继续执行下一个 case(这叫"贯穿",有时是特性,这里必须避免)。
  • 各个月的累计天数:1 月前 0 天、2 月前 31 天、3 月前 31+28=59 天……注意这里先按平年算(2 月 28 天)。
  • 最后用闰年判断修正:leap == 1 && month > 2

知识点

  • switch...case...break 多分支结构
  • 闰年的判断逻辑(||&& 的组合)
  • scanf 一次读入多个值(用逗号分隔的输入格式要和代码里的一致,即输入 2024,3,5

扩展思考(其他解法)

可以用数组替代 switch,把每个月之前的天数放进数组:

int days[13] = {0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};
sum = days[month] + day;

(下标 0 不用,days[m] 直接存"m 月之前的总天数"。)这样代码短很多,也体现了用数据代替分支的思想——能用查表解决的就不用一堆 if。另外还可以把"每月的天数"用数组存起来再循环累加,作为第三种方法,这里不再展开。


【程序 5】把三个整数从小到大输出

题目:输入三个整数 x, y, z,把它们按从小到大输出。

解题思路

"冒泡式比较 + 交换":先把最小的数送到 x,再把剩下的最小的送到 y,剩下的 z 自然就是最大的。

具体做法:依次比较 xy,若 x > y 就交换,使 x <= y;再比较 xz,若 x > z 就交换,这样 x 一定是三个数中最小的;最后比较 yz,使 y <= z

交换三个变量的值需要借助一个临时变量 tt = a; a = b; b = t;。直接写 a = b; b = a; 是不行的——a 被覆盖后,原来的值就丢了。

完整代码

#include <stdio.h>

int main()
{
    int x, y, z, t;
    scanf("%d%d%d", &x, &y, &z);

    if (x > y) { t = x; x = y; y = t; }   // 交换 x、y,使 x <= y
    if (x > z) { t = x; x = z; z = t; }   // 交换 x、z,使 x <= z(现在 x 最小)
    if (y > z) { t = y; y = z; z = t; }   // 交换 y、z,使 y <= z

    printf("从小到大:%d %d %d\n", x, y, z);
    return 0;
}

代码讲解

  • 三步比较的顺序不能乱:必须先让 x 成为最小值,再处理 yz
  • 花括号内是三条赋值语句,借助 t 完成交换。

知识点

  • 交换两个变量的经典写法(临时变量法)
  • 分支结构的组合应用

数学原理

这种"比较-交换"其实就是选择排序的雏形:每轮找出当前最小(大)的元素放到最前(后)。程序 37 的"对 10 个数排序"就是对本题思想的推广。可以想象成打扑克时把牌一张张理整齐的过程。

扩展思考(其他解法)

  • if 嵌套穷举:把所有 6 种大小关系都写出来,代码长但直观。
  • 数组 + 排序:把三个数放进数组,用程序 37 的排序方法处理,适用于更多个数。

【程序 6】用 * 号输出字母 C 的图案

题目:用 * 号在屏幕上输出字母 C 的图案。

解题思路

先把字母 C 想象成由 * 组成的"像素画",然后在纸上(或脑子里)数好每一行要打几个 *、前面要空几格,再用 printf 把每一行原样打印出来。这题没有算法,纯粹是练习 printf 的排版能力。

完整代码

#include <stdio.h>

int main()
{
    printf("  ****\n");
    printf(" *\n");
    printf("*\n");
    printf(" *\n");
    printf("  ****\n");
    return 0;
}

代码讲解

  • \n换行符,表示"光标移到下一行开头"。字符串里的空格原样输出,用来控制 * 的缩进。
  • 连续几个 printf 依次输出每一行,拼起来就是字母 C。

知识点

  • printf 的基本用法与转义字符 \n\t(制表符)等
  • 字符画的基本思路:把图形按行拆解

扩展思考

  • 这类"画图题"在后面的程序 8(九九乘法表)、9(棋盘)、10(楼梯)、23(菱形)、61(杨辉三角)里会升级成用循环控制,那才是重点。本题只是热身。
  • 想画别的字母?把 C 的轮廓数据换掉即可,这就是"点阵字"的雏形。

【程序 7】输出特殊图案(ASCII 字符画)

题目:输出一个由特殊字符组成的图案,运行后很漂亮。

解题思路

计算机里的每个字符都有一个编号(ASCII 码),范围 0~255。某些编号对应的是"图形字符"(如块状、条状符号),在文本模式下可以拼出图案。本题把字符码 176 和 219 交替排列,组成对称的图案。

完整代码

#include <stdio.h>

int main()
{
    char a = 176, b = 219;
    printf("%c%c%c%c%c\n", b, a, a, a, b);
    printf("%c%c%c%c%c\n", a, b, a, b, a);
    printf("%c%c%c%c%c\n", a, a, b, a, a);
    printf("%c%c%c%c%c\n", a, b, a, b, a);
    printf("%c%c%c%c%c\n", b, a, a, a, b);
    return 0;
}

代码讲解

  • char a = 176;:把 176 这个整数当作字符码存进 a%c 是按"字符"打印,printf("%c", a) 就会输出编号为 176 的字符。
  • 每个 printf 打一行 5 个字符,5 行组成菱形图案。

知识点

  • ASCII 码与 %c 输出
  • 用整数给字符变量赋值

注意:字符 176、219 属于"扩展 ASCII"图形字符,在老的 DOS 文本模式下显示为花纹方块;现代 Windows 终端可能显示为问号或其他符号,属正常现象。若想在现代环境重现效果,可以改用普通字符(如 #*)排版。本题的主要目的是让你理解"字符的本质是数字"。


【程序 8】输出 9×9 乘法口诀表

题目:输出 9×9 乘法口诀表。

解题思路

口诀表是一个 9 行 9 列的表格:第 i 行第 j 列的内容是 i × j。用双重循环:外层 i 控制行(1~9),内层 j 控制列(1~9),输出 i*j 的结果。关键是控制格式,让表格对齐。

完整代码

#include <stdio.h>

int main()
{
    int i, j, result;
    for (i = 1; i <= 9; i++)
    {
        for (j = 1; j <= i; j++)   // 只打印到 j <= i,得到"下三角"表格
        {
            result = i * j;
            printf("%d*%d=%-3d  ", i, j, result);  // %-3d 左对齐,占3位
        }
        printf("\n");   // 一行结束换行
    }
    return 0;
}

代码讲解

  • 内层 j <= i:只输出下三角(第 1 行 1 个、第 2 行 2 个……),这是口诀表的常见排法。若写成 j <= 9 则是完整矩形。
  • %-3d- 表示左对齐3 表示这个数字至少占 3 个字符宽。这样 1、12、81 等不同位数也能对齐。格式控制符在排版题里非常常用。

知识点

  • 双重循环(外层控制行、内层控制列,经典套路)
  • 输出格式控制 %d%-3d%5d

扩展思考(其他解法)

可以只用一重循环加字符串拼接实现,但可读性差,不推荐。本题的标准解法就是双重循环,已经足够好,不需要硬凑第二种方案。重点是把"行列"对应关系想清楚。


【程序 9】输出国际象棋棋盘

题目:输出 8×8 的国际象棋棋盘(黑白相间)。

解题思路

棋盘是 8×8 的格子,相邻格子颜色相反。把行号 i、列号 j 加起来看奇偶:(i + j) % 2 == 0 时是黑格,否则是白格(或者反过来)。这是经典的奇偶性判断技巧。

完整代码

#include <stdio.h>

int main()
{
    int i, j;
    for (i = 0; i < 8; i++)
    {
        for (j = 0; j < 8; j++)
        {
            if ((i + j) % 2 == 0)
                printf("  ");      // 白格(两个空格)
            else
                printf("##");      // 黑格(两个#,模拟方块)
        }
        printf("\n");
    }
    return 0;
}

代码讲解

  • 字符码 219 是"实心方块"图形字符,在部分终端中不显示;这里改用 ## 更通用(任何终端都显示)。
  • 每格打两个字符是为了让格子近似正方形(终端字符高 > 宽,打两个字符视觉上更接近方块)。
  • (i + j) % 2:相邻格子 i+j 奇偶性必然相反,所以能保证黑白相间。

知识点

  • % 取余判断奇偶
  • 二维图形与双重循环的对应关系

数学原理

棋盘染色问题(棋盘格定理):$(i+j) \bmod 2$ 相同的位置同色,这背后是"平面二染色"的数学思想,在国际象棋走法、染色问题(如马踏棋盘)里都会用到。

扩展思考

想验证黑白是否相间正确:可以用字符 (Unicode 方块字符)在现代终端输出,效果更接近真棋盘。


【程序 10】打印楼梯,同时在楼梯上方打印两个笑脸

题目:打印一个楼梯图案,并在楼梯上方打印两个"笑脸"。

解题思路

楼梯就是每行方块数递增的三角形:第 i 行输出 i 个方块。用外层循环控制行,内层循环按行号控制每行输出的个数——这是所有"三角形图案"题的通用套路。

完整代码

#include <stdio.h>

int main()
{
    int i, j;
    printf("^^\n");          // 上方两个"笑脸"(ASCII 码 1 的转义字符在现代终端不显示,改用 ^^)
    for (i = 1; i <= 10; i++)
    {
        for (j = 1; j <= i; j++)
            printf("#");     // 每行 i 个方块
        printf("\n");
    }
    return 0;
}

代码讲解

  • printf("\1\1\n") 输出 ASCII 码 1 的两个字符当作"笑脸",这在现代终端不显示,这里换成 ^^
  • 内层循环 j <= i:行号越大,输出的 # 越多,形成楼梯/三角形。

知识点

  • 循环嵌套中,内层循环次数依赖Dependencies外层循环变量(j <= i
  • 三角形/阶梯图形的通用画法

扩展思考

j <= i 改成 j <= 2*i-1 就是"每行奇数个"的三角形,改造成本极小——图形题的本质就是找出每行个数与行号的数学关系。程序 23 的菱形就用了这个思路,可以对照学习。

第二部分:数学算法题(程序 11 ~ 20)


【程序 11】古典问题:兔子生小兔(斐波那契数列)

题目:有一对兔子,从出生后第 3 个月起每个月都生一对兔子,小兔子长到第 3 个月后又每个月生一对兔子。假如兔子都不死,问每个月的兔子总数为多少?

解题思路

先算前几个月:第 1 个月 1 对,第 2 个月 1 对,第 3 个月变成 2 对,第 4 个月 3 对,第 5 个月 5 对……规律是 1, 1, 2, 3, 5, 8, 13, 21, ...,也就是著名的斐波那契数列(Fibonacci):每一项等于前两项之和。

递推公式:
$$
F(n) = F(n-1) + F(n-2),\quad F(1) = F(2) = 1
$$

关键点:只需要保留最近两个数,用两个变量 f1f2 滚动更新即可,不需要开数组存下所有月份。

完整代码

#include <stdio.h>

int main()
{
    long f1 = 1, f2 = 1;   // 前两个月
    int i;
    for (i = 1; i <= 20; i++)
    {
        printf("%12ld %12ld", f1, f2);
        if (i % 2 == 0)
            printf("\n");      // 每行输出 4 个数(两个一组)
        f1 = f1 + f2;          // 下一组的前一个数
        f2 = f1 + f2;          // 下一组的后一个数
    }
    return 0;
}

代码讲解

  • 输出 f1 f2 后,立即算出下一组:f1 = f1 + f2(这是下一对的第一个),再 f2 = f1 + f2(下一对的第二个)。注意第二个语句里的 f1 已经是更新后的值了。
  • 为什么用 long?斐波那契数列增长极快(大约每 5 个月翻 10 倍),第 40 项就超过 10 亿,超出 int 范围。所以必须用 long

知识点

  • 斐波那契数列的递推
  • long 类型与数值范围(int 约 21 亿封顶)

数学原理

斐波那契数列在自然界随处可见(向日葵种子排列、菠萝的鳞片、兔子的繁殖模型),它与黄金分割 $\varphi = \frac{1+\sqrt5}{2} \approx 1.618$ 紧密相关:$\frac{F(n+1)}{F(n)} \to \varphi$。这是递归与递推思想的经典入口。

扩展思考(其他解法)

  • 递归写法(程序 26 会正式讲递归,这里先看思路):
    long fib(int n) {
      if (n <= 2) return 1;
      return fib(n - 1) + fib(n - 2);
    }

    代码极短但效率很低(会重复计算大量子问题),n 稍微大一点就跑不动了。所以实际计算时用循环递推更优。

  • 本解法(滚动两个变量)已经足够好,不必再强行追求别的方案。

【程序 12】判断 101~200 之间有多少个素数,并输出所有素数

题目:判断 101 到 200 之间有多少个素数,并输出所有素数。

解题思路

素数(质数):大于 1 且只能被 1 和它本身整除的自然数。比如 2、3、5、7 是素数,4、6、8 不是。

判断 $m$ 是否为素数的经典方法:用 2 到 $\sqrt{m}$ 之间的每个整数去试除 $m$,只要有一个能整除,$m$ 就不是素数;全不能整除,$m$ 就是素数。

为什么只试到 $\sqrt{m}$ 就够了? 如果 $m$ 有一个大于 $\sqrt{m}$ 的因子 $d$,那么 $m/d$ 必然小于 $\sqrt{m}$ 且也是 $m$ 的因子——所以因子总是成对出现,检查到 $\sqrt{m}$ 就够了。这能把计算量从 $m$ 次降到 $\sqrt{m}$ 次。

完整代码

#include <stdio.h>
#include <math.h>

int main()
{
    int m, i, k, count = 0;
    for (m = 101; m <= 200; m++)
    {
        int isPrime = 1;                 // 先假设 m 是素数
        k = (int)sqrt(m);
        for (i = 2; i <= k; i++)
        {
            if (m % i == 0)              // 能被整除,不是素数
            {
                isPrime = 0;
                break;                   // 提前结束内层循环
            }
        }
        if (isPrime)
        {
            printf("%-4d", m);
            count++;
            if (count % 10 == 0)
                printf("\n");            // 每行 10 个
        }
    }
    printf("\n101~200 之间共有 %d 个素数\n", count);
    return 0;
}

代码讲解

  • isPrime 是"标志变量":先置 1(假设是素数),一旦发现能被整除就置 0。
  • break 的作用是提前跳出循环:已经确定不是素数了,就没必要继续试除了,这是很常见的优化。
  • count 统计个数,每输出 10 个换一行(count % 10 == 0)。

知识点

  • 素数的判断方法(试除法,试除到 $\sqrt{m}$)
  • break 提前终止循环
  • 标志变量(flag)的用法

数学原理

"因子成对出现"是数论的基础结论。$m = a \times b$,若 $a \le b$,则必有 $a \le \sqrt{m}$,所以只需检查小于等于 $\sqrt{m}$ 的因子。这个结论后面很多题(程序 14、36、84)都会反复用到。

扩展思考(其他解法)

  • 筛法(埃拉托斯特尼筛法):程序 36 会展示。其思想是"把合数全部筛掉",一次性生成一大段范围内的素数表,批量计算时比逐个试除快得多。
  • 本题试除法已经足够清晰,不必硬凑其他方案;重点理解"为什么试到 $\sqrt{m}$"。

【程序 13】打印所有的"水仙花数"

题目:打印出所有"水仙花数"。所谓水仙花数是一个三位数,其各位数字的立方和等于该数本身。例如 153 是一个水仙花数,因为 $153 = 1^3 + 5^3 + 3^3$。

解题思路

遍历 100~999 的所有三位数,把每个数分解成百位、十位、个位,然后检验"各位立方和是否等于它自己"。核心技巧是用整除和取余分解数位

  • 百位:n / 100(除以 100 丢掉十位个位)
  • 十位:n / 10 % 10(先去掉个位,再取个位)
  • 个位:n % 10(直接取余)

完整代码

#include <stdio.h>

int main()
{
    int n, a, b, c;
    printf("水仙花数有:");
    for (n = 100; n < 1000; n++)
    {
        a = n / 100;          // 百位
        b = n / 10 % 10;      // 十位
        c = n % 10;           // 个位
        if (a * a * a + b * b * b + c * c * c == n)
            printf("%d ", n);
    }
    printf("\n");
    return 0;
}

代码讲解

  • n / 100:整数除法直接丢掉小数,比如 153/100 = 1。
  • n / 10 % 10:153/10 = 15,再 15%10 = 5。
  • n % 10:153%10 = 3。
  • 注意判断条件里是 a*a*a + b*b*b + c*c*c == n,别漏掉立方运算。

知识点

  • 数位分解(/% 的配合)——后面程序 29、30、89 都会用到
  • 枚举 + 筛选

数学原理

水仙花数属于"自幂数"(Armstrong number):$n$ 位数等于其各位数字的 $n$ 次方之和。三位的是 153、370、371、407 四个;还有四位的"玫瑰花数"等。可以试着把代码改成判断四位数,看看能得到什么。

扩展思考

  • 用循环套数位分解可以泛化到任意位数(n 位自幂数),但需要用循环求"每位数字的 n 次方",这里不展开。
  • 本题原解法已经很简洁,无需多方案。

【程序 14】将一个正整数分解质因数

题目:将一个正整数分解质因数。例如输入 90,输出 90 = 2 * 3 * 3 * 5

解题思路

质因数分解:把一个合数拆成若干个质数相乘。

经典算法(试除分解法):

  1. 从最小的质数 i = 2 开始,如果 n 能被 i 整除,就输出 i,并把 n 更新为 n / i(商),继续用 i 试除(因为可能有多个相同的质因子,如 90 = 2 × 3 × 3 × 5 里 3 出现了两次)。
  2. 如果 n 不能被 i 整除,i 加 1 继续试。
  3. 直到 n 变成 1(或最后剩下的 n 本身是质数,直接输出)。

妙处:虽然我们没判断 i 是不是质数,但任何合数因子在遇到它之前,它的质因子一定已经被除掉了。比如遇到 4 之前,2 已经把 4 分解完了,所以 i 取到 4 时 n 不可能再被 4 整除。这就是"不会输出合数"的原因。

完整代码

#include <stdio.h>

int main()
{
    int n, i;
    printf("请输入一个正整数:");
    scanf("%d", &n);
    printf("%d = ", n);
    for (i = 2; i <= n; i++)
    {
        while (n % i == 0)   // 能整除就反复除(处理重复质因子)
        {
            printf("%d", i);
            n = n / i;
            if (n != 1)
                printf(" * ");   // 后面还有因子就输出乘号
        }
    }
    printf("\n");
    return 0;
}

代码讲解

  • 外层 for 负责"换下一个试除数",内层 while 负责"把同一个质因子除干净"。
  • 例如 n=90:i=2 整除一次(输出 2,n=45);i=3 时 45%3==0(输出 3,n=15),再 15%3==0(输出 3,n=5);i=4 不整除;i=5 时输出 5,n=1。最终得到 2 3 3 * 5。
  • if (n != 1) 判断是否输出乘号,避免结尾多一个 *

知识点

  • whilefor 的嵌套
  • 质因数分解算法
  • 除法求商与取余配合使用

数学原理

算术基本定理(又称唯一分解定理):任何大于 1 的自然数都可以唯一地分解成质因数的乘积。这是数论的基础。分解质因数在密码学(大整数分解难题)中扮演着核心角色——RSA 加密算法的安全性就建立在"大数分解很难"上。

扩展思考

  • 可以优化:i 只需试到 $\sqrt{原数}$,剩下的如果大于 1 一定是质数,直接输出。不过对本题规模没必要。
  • 本题算法经典且高效,无需多方案。

【程序 15】用条件运算符输出成绩等级

题目:学习成绩 ≥ 90 分用 A 表示,60~89 分用 B 表示,60 分以下用 C 表示。

解题思路

条件运算符(三目运算符) 条件 ? 值1 : 值2if...else 的简洁写法:条件成立取"值1",否则取"值2"。可以嵌套使用,一层层判断。

完整代码

#include <stdio.h>

int main()
{
    int score;
    char grade;
    printf("请输入成绩:");
    scanf("%d", &score);
    grade = score >= 90 ? 'A' : (score >= 60 ? 'B' : 'C');
    printf("%d 分的等级是 %c\n", score, grade);
    return 0;
}

代码讲解

  • score >= 90 ? 'A' : (score >= 60 ? 'B' : 'C'):先判断是否 ≥90,是则 A;否则进入内层括号再判断是否 ≥60,是则 B,否则 C。嵌套的三目运算符就是"一层层筛选"。
  • 结果是一个字符,所以存到 char 类型变量里。

知识点

  • 三目运算符 ?:
  • char 类型与字符常量 'A'

扩展思考(其他解法)

if...else if...else 写更直观,适合逻辑复杂时使用:

if (score >= 90)      grade = 'A';
else if (score >= 60) grade = 'B';
else                  grade = 'C';

两种写法等价,三目适合简单判断,if 适合多分支。都掌握,看场景选用。


【程序 16】求最大公约数和最小公倍数(辗转相除法)

题目:输入两个正整数 m 和 n,求它们的最大公约数和最小公倍数。

解题思路

最大公约数(GCD)辗转相除法(欧几里得算法):不断用"大数除小数取余数",再用"除数"和"余数"重复,直到余数为 0,此时的除数就是最大公约数。

例如 18 和 12:18 % 12 = 6 → 12 % 6 = 0,最大公约数是 6。

最小公倍数(LCM) 有一个漂亮的性质:
$$
\text{lcm}(a, b) = \frac{a \times b}{\gcd(a, b)}
$$
先算出最大公约数,最小公倍数就顺手得出了。

完整代码

#include <stdio.h>

int main()
{
    int a, b, num1, num2, temp;
    printf("请输入两个正整数:");
    scanf("%d,%d", &num1, &num2);
    a = num1;
    b = num2;

    while (b != 0)        // 辗转相除,直到余数为 0
    {
        temp = a % b;
        a = b;
        b = temp;
    }
    printf("最大公约数:%d\n", a);
    printf("最小公倍数:%d\n", num1 * num2 / a);
    return 0;
}

代码讲解

  • 循环体三行是辗转相除的核心:temp = a % b; a = b; b = temp;。第一次循环里 a、b 分别是输入的两个数;之后 a、b 变成"除数"和"余数"。
  • 循环结束(b == 0)时,a 就是最大公约数。
  • 注意在循环前把原值存进 num1num2,因为循环会改掉 ab,而算最小公倍数还需要原数。

知识点

  • 辗转相除法(欧几里得算法)
  • 变量备份(循环会改变变量时先保存原值)

数学原理

辗转相除法的正确性依据:$\gcd(a, b) = \gcd(b, a \bmod b)$。因为 $a$ 和 $b$ 的公约数集合与 $b$ 和 $a \bmod b$ 的公约数集合完全相同。欧几里得算法是"人类已知最古老的算法之一",距今约 2300 年。而"两数乘积 = 最大公约数 × 最小公倍数"是数论的常用性质,可用于快速求最小公倍数。

扩展思考(其他解法)

  • 更相减损术(中国古代《九章算术》):while (a != b) { if (a > b) a -= b; else b -= a; },思想是"两数相减,差的公约数不变"。它和辗转相除法本质相同,但减法在数很大时慢得多。
  • 本题辗转相除法最优,不必强求别的方案。

【程序 17】统计一行字符中各类字符的个数

题目:输入一行字符,分别统计其中英文字母、空格、数字和其他字符的个数。

解题思路

getchar() 一个字符一个字符地读,读到 '\n'(换行符)为止,对每个字符分类计数。分类依据是字符的 ASCII 码区间

  • 英文字母:'A'~'Z''a'~'z'
  • 数字:'0'~'9'
  • 空格:' '
  • 其他:剩下的

完整代码

#include <stdio.h>

int main()
{
    char c;
    int letters = 0, space = 0, digit = 0, others = 0;
    printf("请输入一行字符:");
    while ((c = getchar()) != '\n')
    {
        if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))
            letters++;
        else if (c == ' ')
            space++;
        else if (c >= '0' && c <= '9')
            digit++;
        else
            others++;
    }
    printf("字母 %d 个,空格 %d 个,数字 %d 个,其他 %d 个\n",
           letters, space, digit, others);
    return 0;
}

代码讲解

  • (c = getchar()) != '\n':先把读到的字符赋给 c,再判断是不是换行符。这是 C 里非常经典的写法——把"读取"和"判断"写在一个表达式里。
  • 字符可以直接比较大小:因为字符本质上是整数(ASCII 码),c >= 'a' 就是在比较 ASCII 码。字符按 ASCII 码顺序排列,所以 c >= 'a' && c <= 'z' 就能判断 c 是否小写字母。
  • 注意逻辑优先级:&& 优先级高于 ||,所以 c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' 等价于 (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')

知识点

  • getchar() 逐个读字符
  • ASCII 码区间判断字符类别
  • 循环中的"边读边判断"

数学原理

这题用到的是 ASCII 编码的有序性:英文字母、数字在 ASCII 表中都是连续排列的('a'=97'A'=65'0'=48),所以可以用区间判断。计算机里所有"字符分类"最终都是数值比较。

扩展思考(其他解法)

  • 标准库里有 isalpha()isspace()isdigit() 等现成的字符分类函数(在 <ctype.h> 里),直接调用更简洁且不会出错:
    if (isalpha(c)) letters++;
    else if (c == ' ') space++;
    else if (isdigit(c)) digit++;

    工程实践中推荐用库函数,学习阶段自己写能加深理解。


【程序 18】求 a + aa + aaa + ... 的值

题目:求 $s = a + aa + aaa + aaaa + \dots$ 的值,其中 a 是一个数字,项数由键盘输入。例如 2 + 22 + 222 + 2222 + 22222(共 5 项)。

解题思路

找规律:后一项是前一项"后面再添一个 a"。

  • 第 1 项:a
  • 第 2 项:a*10 + a
  • 第 3 项:前一项 *10 + a

所以每项可以用递推得到:tn = tn * 10 + a,再累加进总和 sn。用一个变量存"当前项",另一个存"累计和"。

完整代码

#include <stdio.h>

int main()
{
    int a, n, count = 1;
    long sn = 0, tn = 0;    // tn: 当前项; sn: 总和
    printf("请输入数字 a 和项数 n:");
    scanf("%d,%d", &a, &n);
    while (count <= n)
    {
        tn = tn * 10 + a;   // 生成下一项:aa、aaa...
        sn = sn + tn;       // 累加
        count++;
    }
    printf("a+aa+aaa+... = %ld\n", sn);
    return 0;
}

代码讲解

  • 循环第 1 轮:tn = 0 * 10 + 2 = 2;第 2 轮:tn = 2 * 10 + 2 = 22;第 3 轮:tn = 22 * 10 + 2 = 222……每一项都从前一项递推出来。
  • snlong:n 稍大时和会很大,防止溢出。

知识点

  • 递推生成数列项
  • 累加器的模式(sn = sn + tn

数学原理

这实际上是等比数列的变形。通项 $t_k = a \times \frac{10^k - 1}{9}$,总和可以用等比数列求和公式直接算。但程序里的递推法更通用,也不需要记公式。可以把这两种方法对照理解:公式适合人算,递推适合计算机算

扩展思考

  • 若 a、n 都比较大,long 也可能溢出,可改用 long long 或高精度(大数运算)思想,超出本题范围。

【程序 19】找出 1000 以内的所有"完数"

题目:一个数如果恰好等于它的因子之和(不含它本身),这个数就称为"完数"。例如 $6 = 1 + 2 + 3$。编程找出 1000 以内的所有完数。

解题思路

对每个数 $j$(从 2 到 999),找出它所有真因子(能整除 $j$ 且小于 $j$ 的数),把因子累加,如果和等于 $j$ 本身,就是完数。判断因子:j % i == 0 说明 ij 的因子。

完整代码

#include <stdio.h>

int main()
{
    int i, j, s;
    printf("1000 以内的完数:\n");
    for (j = 2; j < 1000; j++)
    {
        s = 0;                     // 因子和清零
        for (i = 1; i < j; i++)    // 找所有真因子
        {
            if (j % i == 0)
                s = s + i;
        }
        if (s == j)                // 因子和等于自身 → 完数
        {
            printf("%d 的因子为:", j);
            for (i = 1; i < j; i++)
                if (j % i == 0)
                    printf("%d ", i);
            printf("\n");
        }
    }
    return 0;
}

代码讲解

  • 第一轮循环求因子和,若等于本身再开第二轮循环打印因子(也可以用数组先存因子,这里直接再算一遍更直观)。
  • 因子从 1 试到 j-1(不含 j 本身)。

知识点

  • 双重循环
  • 因子的判断(取余)

数学原理

完数(perfect number)与数学史上著名的完全数猜想有关。已知的前几个完数是 6、28、496、8128。欧几里得证明了:若 $2^p - 1$ 是素数(梅森素数),则 $2^{p-1}(2^p - 1)$ 是完数;欧拉后来证明了其逆定理。至今人们仍未找到"奇完数",这是数学未解之谜之一。本题的暴力枚举思路能直接算出 6、28、496 三个答案。

扩展思考(其他解法)

  • 优化:因子只需试到 $\sqrt{j}$(因子成对出现),每找到一个因子 i 就把 j/i 也加上。可以自己改写,体会优化带来的速度提升。
  • 也可以用一个数组先把因子存起来再输出,但直接再遍历一遍更直观,代码更短。

【程序 20】自由落体反弹问题

题目:一球从 100 米高度自由落下,每次落地后反跳回原高度的一半,再落下。求它在第 10 次落地时共经过多少米?第 10 次反弹多高?

解题思路

模拟过程:设总路程 sn = 100(第一次落地的路程),反弹高度 hn = 50(第一次反弹高度)。之后每次落地前都要经过"反弹上去再落下来"两段,每段都是上一次反弹高度。即第 n 次落地时,总路程增加 2 * hn,然后 hn 减半。

需要区分"反弹高度"和"落地时的总路程"这两个量:第 10 次反弹高度是在第 10 次落地后再弹起的高度,所以循环结束后 hn 已经是第 11 次反弹……要仔细看清题目问的是"第 10 次反弹多高"。按题意"第 10 次落地时共经过多少米,第 10 次反弹多高",下面是标准写法。

完整代码

#include <stdio.h>

int main()
{
    double sn = 100.0, hn = 50.0;   // 总路程、首次反弹高度
    int n;
    for (n = 2; n <= 10; n++)
    {
        sn = sn + 2 * hn;   // 第 n 次落地时,多走了"上去再下来"两段
        hn = hn / 2;        // 下一次反弹高度减半
    }
    printf("第 10 次落地时共经过 %.4f 米\n", sn);
    printf("第 10 次反弹 %.4f 米\n", hn);
    return 0;
}

代码讲解

  • hn 初始为 50(第一次反弹高度)。循环从第 2 次落地开始累计:每次多走 2 * hn
  • 循环结束后 hn 已经被减半 9 次(即 $50 \times (1/2)^9$),对应第 10 次反弹高度。
  • double:100/2 有小数,且不断除以 2,用浮点数才准确。

知识点

  • 等比数列(公比 1/2)
  • 浮点数 double 的应用

数学原理

这是等比数列求和问题。总路程
$$
S = 100 + 2 \times 50 + 2 \times 25 + \dots = 100 + 2 \times 50 \times (1 + \frac12 + \frac14 + \dots)
$$
等比数列求和公式 $S_n = \frac{a_1(1-q^n)}{1-q}$ 可以手算验证。注意"落地路程"和"反弹高度"是两组不同的量,编程时最容易搞混的就是这里。

扩展思考

可以改造成"问第 n 次落地":把 10 换成用户输入即可。原解法清晰简洁,无需多方案。

第三部分:递推与递归(程序 21 ~ 30)


【程序 21】猴子吃桃问题(逆向思维)

题目:猴子第一天摘下若干个桃子,当即吃了一半,还不过瘾,又多吃了一个。以后每天早上都吃了前一天剩下的一半零一个。到第 10 天早上想再吃时,只剩一个桃子了。求第一天共摘了多少个。

解题思路

正向想很麻烦,但倒着推极其简单。第 10 天早上剩 1 个,那么第 9 天早上吃之前有多少?设第 n 天早上有 $xn$ 个,则
$$
x
{n+1} = \frac{x_n}{2} - 1 \quad\Longrightarrow\quad xn = (x{n+1} + 1) \times 2
$$
即"第 n 天的桃子数 = (第 n+1 天的桃子数 + 1) × 2"。从第 10 天的 1 个倒推 9 次,就得到第 1 天的总数。这就是逆向思维 / 递推

完整代码

#include <stdio.h>

int main()
{
    int day = 9;      // 需要倒推 9 次(从第10天推到第1天)
    int x1, x2 = 1;   // x2 是"后一天"的桃子数,从第10天的 1 开始
    while (day > 0)
    {
        x1 = (x2 + 1) * 2;   // 前一天 = (后一天 + 1) * 2
        x2 = x1;             // 前一天变成新的"后一天",继续往前推
        day--;
    }
    printf("第一天共摘了 %d 个桃子\n", x1);
    return 0;
}

代码讲解

  • 循环 9 次(day 从 9 减到 0),每次把"后一天"的桃子数换算成"前一天"的。
  • 顺序:x2=1(第 10 天)→ 第一次循环算出第 9 天 → 第二次算出第 8 天……第九次算出第 1 天。
  • 最终 x1 就是答案 1534。

知识点

  • 逆向递推(从已知的最后状态倒推初始状态)
  • while 循环

数学原理

这其实是递推关系 $xn = 2x{n+1} + 2$,是简单的一阶线性递推,可以用通项公式解:$x_1 = 2^{10} \times 1 + 2^{10} - 2 = 1534$。程序用迭代代替公式,思路更直观。

扩展思考(其他解法)

  • 递归写法f(n) = n==10 ? 1 : 2*(f(n+1)+1),与程序 28 的递归思想一致,学到递归后再回来看这题会很有感触。
  • 本题递推已经最优,重点体会"逆向思维"。

【程序 22】乒乓球比赛对阵名单(穷举 + 筛选)

题目:甲队有 a、b、c 三人,乙队有 x、y、z 三人。已抽签决定比赛名单。a 说他与 x 不比,c 说他与 x、z 不比。请找出对阵名单。

解题思路

变量 ijk 分别表示 a、b、c 的对手(取值 x、y、z)。用三层循环穷举所有对阵组合,然后按条件筛选:

  1. a、b、c 的对手必须互不相同(i != j && i != k && j != k)。
  2. a 不与 x 比(i != 'x')。
  3. c 不与 x、z 比(k != 'x' && k != 'z')。

这是"排列 + 约束条件"问题,也就是小型搜索问题:枚举所有可能,剔除不满足约束的。约束越多,剩下的越少。

完整代码

#include <stdio.h>

int main()
{
    char i, j, k;   // i: a的对手, j: b的对手, k: c的对手
    for (i = 'x'; i <= 'z'; i++)       // a 的对手候选
        for (j = 'x'; j <= 'z'; j++)   // b 的对手候选
        {
            if (i != j)                // a、b 对手不同
                for (k = 'x'; k <= 'z'; k++)
                {
                    if (i != k && j != k &&     // c 与 a、b 对手不同
                        i != 'x' &&             // a 不与 x 比
                        k != 'x' && k != 'z')   // c 不与 x、z 比
                        printf("a--%c  b--%c  c--%c\n", i, j, k);
                }
        }
    return 0;
}

代码讲解

  • 用字符 'x''y''z' 循环,i <= 'z' 是因为字符在 ASCII 表里连续排列('x'=120,'y'=121,'z'=122)。
  • 先判断 i != j 再进入第三层循环,减少无谓的组合(这叫"剪枝"思想,虽然这里组合数很少)。
  • 所有条件同时满足才输出。

知识点

  • 三重循环穷举
  • 约束条件筛选(搜索问题雏形)

数学原理

这是简单的"受限排列"问题:总排列数 $3! = 6$ 种,加上约束条件后只剩下唯一解。可以手算验证:由 c 不与 x、z 比,c 只能对 y;a 不与 x 比,a 只能对 z(y 已被 c 占);剩下 b 对 x。

扩展思考

  • 手动推一下更快得到答案:c--y,a--z,b--x。程序的意义在于展示"让计算机替你穷举"的方法。
  • 本题解法最优,无需多方案。

【程序 23】打印菱形图案

题目:打印如下菱形(7 行):

   *
  ***
 *****
*******
 *****
  ***
   *

解题思路

把菱形分成上下两半:上半部分 4 行(星号数 1、3、5、7 递增),下半部分 3 行(星号数 5、3、1 递减)。每行结构都是"空格 + 星号":上半部分第 i 行(i 从 0 开始)有 2-i 个空格、2*i+1 个星号。

关键是找出每行空格数、星号数与行号的数学关系

完整代码

#include <stdio.h>

int main()
{
    int i, j, k;

    /* 上半部分:4 行 */
    for (i = 0; i <= 3; i++)
    {
        for (j = 0; j <= 2 - i; j++)      // 空格数递减:3,2,1,0
            printf(" ");
        for (k = 0; k <= 2 * i; k++)      // 星号数递增:1,3,5,7
            printf("*");
        printf("\n");
    }

    /* 下半部分:3 行 */
    for (i = 0; i <= 2; i++)
    {
        for (j = 0; j <= i; j++)          // 空格数递增:1,2,3
            printf(" ");
        for (k = 0; k <= 4 - 2 * i; k++)  // 星号数递减:5,3,1
            printf("*");
        printf("\n");
    }
    return 0;
}

代码讲解

  • 上半部分第 i 行(i=0,1,2,3):空格 2-i 个(3,2,1,0),星号 2i+1 个(1,3,5,7)。因为中间行是第 3 行(i=3),星号 2*3+1=7
  • 下半部分第 i 行(i=0,1,2):空格 i+1 个(1,2,3),星号 5-2i 个(5,3,1)。
  • 把行号与数量写成公式,是图形类题目的通用解法。

知识点

  • 图形题的核心:行号 → 空格数、星号数的函数关系
  • 双重/三重循环

数学原理

菱形的每行可以看作一个等差数列。星号数是等差数列 $1,3,5,7,\dots$(公差 2),即奇数序列 $2i+1$。在数学建模中,"把图形抽象成行列公式"是非常基础的一步。

扩展思考(其他解法)

  • 更统一的写法:把菱形看成坐标平面,星号满足 $|x| + |y| \le r$(曼哈顿距离),可以用一个双重循环加绝对值判断直接输出整个菱形,适合"任意大小"的菱形。作为进阶练习可以自己实现。
  • 原解法已足够好,重点是理解"行列公式"。

【程序 24】分数序列求和

题目:有一分数序列 $\frac21, \frac32, \frac53, \frac85, \frac{13}8, \frac{21}{13} \dots$,求前 20 项之和。

解题思路

观察规律:每一项的分子是上一项的分子加分母;每一项的分母是上一项的分子。也就是说,分子分母本身构成斐波那契数列的相邻两项!

用变量 a(分子)、b(分母)递推:算出 $\frac{a}{b}$ 后,下一项的分子是 a+b,分母是 a。注意更新顺序:t = a; a = a + b; b = t; 需要借助临时变量,否则 a 先被修改后 b 就拿不到原值了。

完整代码

#include <stdio.h>

int main()
{
    int n;
    float a = 2, b = 1, s = 0;   // 分子、分母、和
    for (n = 1; n <= 20; n++)
    {
        s = s + a / b;           // 累加当前项
        int t = a;
        a = a + b;               // 下一项分子 = 当前分子 + 分母
        b = t;                   // 下一项分母 = 当前分子
    }
    printf("前 20 项之和为 %.6f\n", s);
    return 0;
}

代码讲解

  • 为什么用 floata/b 是除法,结果有小数。
  • 临时变量 t 的作用:a = a + b 会覆盖 a 的旧值,但下一项的分母要的就是这个旧值,所以先存到 t 里。这就是"先备份再修改"的经典场景。

知识点

  • 斐波那契递推的变体
  • 借助临时变量的正确更新顺序

数学原理

这个数列有个有趣的性质:相邻项之比 $\frac{F_{n+1}}{F_n}$ 越来越接近黄金分割 $\varphi \approx 1.618$,而 $\frac{a}{b}$ 本身趋向 $\varphi$,所以这个级数并不收敛(每项趋于 1.618 > 0),和会越来越大。前 20 项之和约为 32.66。这题让你体会:递推公式的每一步必须保持变量的"新鲜度"

扩展思考

  • 分子分母在 20 项时已经很大,用 int 可能溢出,实际可用 long。本题规模小,float 也能算,但理解"数值范围"很重要。

【程序 25】求 1! + 2! + 3! + ... + 20! 的和

题目:求 $1! + 2! + 3! + \dots + 20!$ 的和。($n!$ 表示 $1 \times 2 \times \dots \times n$,读作"n 的阶乘")

解题思路

$n! = n \times (n-1)!$,所以可以边乘边加:用一个变量 t 保存"当前阶乘",每轮 t *= n 得到 $n!$,再累加。这样不需要每次重新从 1 乘到 n,时间复杂度是 O(n) 而不是 O(n²)。

完整代码

#include <stdio.h>

int main()
{
    int n;
    double s = 0, t = 1;   // t 存 n!,s 存和
    for (n = 1; n <= 20; n++)
    {
        t = t * n;    // 得到 n!
        s = s + t;    // 累加
    }
    printf("1!+2!+...+20! = %e\n", s);   // %e 科学计数法输出
    return 0;
}

代码讲解

  • 循环体只有两行:先乘后加。第 1 轮 t=1!,第 2 轮 t=2!,……第 20 轮 t=20!。
  • 为什么用 double?$20! \approx 2.4 \times 10^{18}$,远超 int(约 21 亿)甚至 long(约 9.2 × 10¹⁸,差一点点)的范围。double 能表示约 $10^{308}$ 的数。
  • %e 表示按科学计数法输出(如 2.561327e+18)。

知识点

  • 阶乘的递推计算
  • 数据溢出与选择合适的类型

数学原理

阶乘增长极快(比指数还快),是"组合数学"的基础运算。$n!$ 出现在排列数、组合数公式里:$P(n,k) = \frac{n!}{(n-k)!}$,$C(n,k) = \frac{n!}{k!(n-k)!}$。20! 已经有 19 位,这就是为什么不能随手用 int

扩展思考

  • 如果想得到精确整数,可用 long long(约 9.2×10¹⁸),20! = 2432902008176640000 仍在范围内。用 double 会有精度损失(浮点数的通病),可以对比一下两种输出。

【程序 26】利用递归求 5!

题目:利用递归方法求 5!。

解题思路

递归就是"函数调用自己"。求 $n!$ 可以这样看:
$$
n! = n \times (n-1)!, \qquad 0! = 1
$$
要求 fact(n),就先调用 fact(n-1) 算出 $(n-1)!$,再乘以 n。递归必须有终止条件(这里是 n == 0 返回 1),否则会无限调用自己直到栈溢出。

完整代码

#include <stdio.h>

int fact(int n)
{
    if (n == 0)
        return 1;               // 终止条件
    else
        return n * fact(n - 1); // 递归调用
}

int main()
{
    int i;
    for (i = 0; i <= 5; i++)
        printf("%d! = %d\n", i, fact(i));
    return 0;
}

代码讲解

  • fact(5) 执行时:发现 n=5 ≠ 0,于是调用 fact(4)fact(4) 调用 fact(3)……直到 fact(0) 直接返回 1。然后结果一层层"弹回来":fact(1) = 1*1 = 1fact(2) = 2*1 = 2……最终 fact(5) = 120
  • 递归分两个阶段:递推(层层深入,直到触底)和回归(逐层返回结果)。程序 28 会再讲这个概念。

知识点

  • 递归的定义:函数调用自身
  • 递归必须有终止条件
  • 调用栈的概念(每次递归调用都会占一份内存,递归太深会栈溢出)

数学原理

阶乘的递归定义 $n! = n(n-1)!$ 是数学归纳法思想的直接体现:只要基础情形($0! = 1$)成立,并且递推步骤正确,结论对所有 n 成立。递归与数学归纳法一一对应,这也是为什么学 C 语言一定会学递归。

扩展思考(其他解法)

  • 循环迭代版本见程序 25,比递归更省内存。递归的优势是代码与数学定义一致、可读性好,但深度很大时(如 fact(100000))会栈溢出,此时必须用迭代。
  • 本题递归是考点,两种写法都要会。

【程序 27】递归实现字符串逆序打印

题目:输入 5 个字符,用递归方法以相反顺序打印出来。

解题思路

核心技巧:先递归处理"后面的字符",再打印"当前字符"。这样最后打印的是第一个字符,实现了逆序。

palin(n):读一个字符存起来 → 调用 palin(n-1) 处理剩余的 → 打印存起来的字符。当 n 减到 1 时直接读、直接打印(触底)。这就是"后进先出"——最先读的字符最后打印,完全符合"递归栈"的特性。

完整代码

#include <stdio.h>

void palin(int n)
{
    char c;
    if (n <= 1)
    {
        c = getchar();
        putchar(c);        // 最后一个字符,直接打印
    }
    else
    {
        c = getchar();     // 读当前字符
        palin(n - 1);      // 先处理后面的字符
        putchar(c);        // 再打印当前字符 → 实现逆序
    }
}

int main()
{
    printf("请输入 5 个字符:");
    palin(5);
    printf("\n");
    return 0;
}

代码讲解

  • getchar() 每次读一个字符(包括回车也算字符!输入时注意别多敲)。
  • 关键在 palin(n-1)putchar(c) 的顺序:先递归,后打印。递归栈是"后进先出",所以越早读的字符越晚打印,自然就逆序了。

知识点

  • 递归与"栈"(后进先出)的关系
  • getchar / putchar

数学原理

这体现了递归的本质:把大问题分解成"一个相同结构的小问题 + 一点额外工作"。逆序打印 5 个字符 = 打印第 1 个字符 + 逆序打印后 4 个字符,而逆序打印后 4 个 = 打印第 2 个 + 逆序打印后 3 个……直到只剩 1 个字符直接打印。递归就是这种"自我相似"的分解。

扩展思考(其他解法)

  • 用数组 + 循环从后往前打印更简单,但本题的考点就是递归思想。非递归版本:
    for (i = 4; i >= 0; i--) printf("%c", str[i]);

【程序 28】递归求年龄

题目:5 个人坐在一起。第 5 个人说他比第 4 个人大 2 岁,第 4 个说比第 3 个大 2 岁……第 1 个人说他 10 岁。问第 5 个人多大?

解题思路

递归公式非常清晰:
$$
age(n) = \begin{cases} 10 & n = 1 \ age(n-1) + 2 & n > 1 \end{cases}
$$
即第 n 个人的年龄比第 n-1 个人大 2 岁。这题是程序 26 的"应用题",理解递归的回推(递推深入)与回归(逐层返回)两个阶段即可。

完整代码

#include <stdio.h>

int age(int n)
{
    if (n == 1)
        return 10;               // 第 1 个人 10 岁(终止条件)
    else
        return age(n - 1) + 2;   // 第 n 个人比第 n-1 个大 2 岁
}

int main()
{
    printf("第 5 个人的年龄是 %d 岁\n", age(5));
    return 0;
}

代码讲解

  • age(5)age(4)+2age(3)+4age(2)+6age(1)+8 = 10+8 = 18
  • 每一步"挂起"等待子调用返回,最后逐层加回 2。

知识点

  • 递归的两个阶段:回推(递推)与回归
  • 递归终止条件的重要性

扩展思考

用循环从第 1 个人往后推 4 次也一样:age = 10; for(i=1; i<5; i++) age += 2;。两种方法对比着看,能加深对递归本质的理解。


【程序 29】不多于 5 位的正整数的位数与逆序输出

题目:给一个不多于 5 位的正整数,求它是几位数,并逆序打印各位数字。

解题思路

用整除和取余把每一位分解出来:除以 10000 得万位、除以 1000 再取余得千位……然后用 if 判断最高位在哪个位置,从而确定位数,同时逆序输出(先输出个位再输出万位)。

固定分解 5 个数位再判断的写法代码冗长;更通用的写法是循环:不断 n % 10 取个位、n /= 10 去掉个位,直到 n 为 0,同时计数。

完整代码

#include <stdio.h>

int main()
{
    long n;
    int digits = 0;
    printf("请输入一个不多于 5 位的正整数:");
    scanf("%ld", &n);

    printf("逆序为:");
    while (n > 0)
    {
        printf("%ld", n % 10);   // 输出当前个位
        n = n / 10;              // 去掉个位
        digits++;                // 位数 +1
    }
    printf("\n这是一个 %d 位数\n", digits);
    return 0;
}

代码讲解

  • n % 10 取个位,n / 10 砍掉个位,循环直到 n 变成 0。这个"逐位剥离"写法是通用技巧,比固定分解 5 个数位的方法好得多(任意位数都能用)。
  • 循环次数就是位数。

知识点

  • 逐位剥离数字(%10 + /10 循环)
  • 通用算法优于针对特定长度的写法

扩展思考(其他解法)

  • "固定分解 5 位"的方法(a=x/10000 等)在"不多于 5 位"的限制下也可行,但代码长且不通用。本题循环版是最佳方案。
  • 若想保留原数(循环会破坏 n),可以先用一个变量备份。

【程序 30】判断回文数

题目:一个 5 位数,判断它是不是回文数。例如 12321 是回文数(个位与万位相同、十位与千位相同)。

解题思路

回文数就是"正着读和倒着读一样"的数。对 5 位数,只需比较"个位 == 万位"且"十位 == 千位"。分解出这四个数位后比较即可。

完整代码

#include <stdio.h>

int main()
{
    long x, wan, qian, shi, ge;
    printf("请输入一个 5 位数:");
    scanf("%ld", &x);

    wan  = x / 10000;           // 万位
    qian = x % 10000 / 1000;    // 千位
    shi  = x % 100 / 10;        // 十位
    ge   = x % 10;              // 个位

    if (ge == wan && shi == qian)
        printf("%ld 是回文数\n", x);
    else
        printf("%ld 不是回文数\n", x);
    return 0;
}

代码讲解

  • 数位分解方式同程序 29。
  • 5 位数中间那位(百位)不参与比较——回文对称轴。
  • 更通用的回文判断(任意位数)可以用程序 29 的"逐位剥离"法:把数逆序重建,若逆序后与原数相等就是回文。下面给出这种通用解法。

知识点

  • 数位分解
  • 回文判断

数学原理

回文(palindrome)概念不只在数字里:字符串("level"、"上海自来水来自海上")也是回文。数字回文的判断本质是"对称性验证"。通用思路:倒过来重建这个数,比较是否相等。

扩展思考(其他解法)

通用版本(任意位数):

long n, rev = 0, m;
scanf("%ld", &n);
m = n;
while (m > 0) {
    rev = rev * 10 + m % 10;   // 倒着重建
    m /= 10;
}
if (rev == n) printf("是回文数\n");

这个"数字反转"技巧非常常用(比如程序 89 的加密题也会涉及类似思想)。

第四部分:控制流与数组(程序 31 ~ 40)


【程序 31】按字母判断星期几

题目:输入星期几的第一个字母来判断是星期几,如果第一个字母相同,则继续判断第二个字母。

解题思路

星期一到星期日的英文是 Monday、Tuesday、Wednesday、Thursday、Friday、Saturday、Sunday。看首字母:M(Monday)、W(Wednesday)、F(Friday)是唯一的,直接判断;S 有 Saturday/Sunday 两种可能,T 有 Tuesday/Thursday 两种可能,需要再看第二个字母。

switch 处理首字母,需要时再用 getchar() 读第二个字母区分。注意要用标准的 getchar() 而不是某些编译器特有的 getch()(不回车直接读按键),这里给出标准写法。

完整代码

#include <stdio.h>

int main()
{
    char letter;
    printf("请输入星期几的首字母:");
    letter = getchar();

    switch (letter)
    {
        case 'M': printf("Monday(星期一)\n"); break;
        case 'W': printf("Wednesday(星期三)\n"); break;
        case 'F': printf("Friday(星期五)\n"); break;
        case 'S':
            printf("请输入第二个字母:");
            getchar();                // 吃掉输入缓冲里的换行符
            letter = getchar();
            if (letter == 'a') printf("Saturday(星期六)\n");
            else if (letter == 'u') printf("Sunday(星期日)\n");
            else printf("数据错误\n");
            break;
        case 'T':
            printf("请输入第二个字母:");
            getchar();                // 吃掉换行符
            letter = getchar();
            if (letter == 'u') printf("Tuesday(星期二)\n");
            else if (letter == 'h') printf("Thursday(星期四)\n");
            else printf("数据错误\n");
            break;
        default: printf("数据错误\n");
    }
    return 0;
}

代码讲解

  • 注意 getchar() 会把"回车"也当作一个字符读走,所以第二次读字符前要先用一个 getchar() 把残留的换行符"吃掉"。这是输入缓冲的经典坑(预备知识第 11 节也提到)。
  • switchST 两个 case 用嵌套 if 区分第二字母。

知识点

  • switch 多分支
  • getchar 与输入缓冲(换行符问题)

扩展思考

  • 更工程化的做法是读入整个单词再比较字符串(程序 79 会涉及字符串比较),但本题限定"按字母判断",switch 法就是正解。

【程序 32】按键切换颜色

题目:按任意键改变屏幕颜色。

解题思路

要让终端显示彩色文字/背景,需要向终端输出 ANSI 转义序列:一串以 ESC 字符(\033)开头的"控制字符",终端收到后就会改变后续文字的样式Style,而不会把这些字符显示出来。

背景色的核心格式是 \033[40m~\033[47m(黑红绿黄蓝品红青白 8 种),配合 getch() 实现"按任意键切换"。

完整代码

#include <stdio.h>
#include <conio.h>      // getch():读一个按键(不回车、不回显),Windows/MinGW 自带

int main()
{
    int color;
    for (color = 0; color < 8; color++)
    {
        printf("\033[%dm", 40 + color);     // 设置背景色:40~47 对应 8 种颜色
        printf("This is color %d\r\n", color);
        printf("Press any key to continue\r\n");
        getch();                            // 按任意键继续
    }
    printf("\033[0m");                      // 恢复默认颜色
    return 0;
}

代码讲解

  • \033[40m背景变黑,\033[41m 变红……\033[47m 变白,所以 40 + color 正好循环 8 种背景色。
  • \r\n 是"回车 + 换行"(DOS/Windows 风格),终端里与 \n 效果相近。
  • getch() 来自 <conio.h>,读键但不需要按回车,天然适合"按任意键继续"的交互。
  • 最后输出 \033[0m 重置样式,否则后续输出都会带着最后一种背景色。

知识点

  • ANSI 转义序列(CSI + SGR 参数)控制终端样式
  • 颜色代码表:前景 30~37 / 明亮 90~97,背景 40~47 / 明亮 100~107
  • conio.hgetch()(现代 Windows 编译器仍可用)

说明

  • ANSI 转义序列在 Linux/macOS 终端、Windows Terminal、VS Code 终端、PowerShell 7 中开箱即用;旧版 cmd.exe 需先启用"虚拟终端处理"(可先执行 system(""); 或改用新版终端)。
  • 程序 32(背景色)、33(清屏/定位)、35(前景色)三题合起来就是"终端控制三件套",覆盖了终端样式控制最常见的需求,跨平台通用。

【程序 33】gotoxy 与 clrscr 函数

题目:学习 gotoxy()(光标定位)与 clrscr()(清屏)函数。

解题思路

clrscr()(清屏)与 gotoxy(x, y)(光标定位)在终端中有对应的 ANSI 转义序列

  • 清屏\033[2J(清空整个屏幕);常配合 \033[H 把光标移回左上角。
  • 光标定位\033[行;列H,把光标移动到指定行列(行、列都从 1 开始数)。

完整代码

#include <stdio.h>

int main()
{
    printf("\033[2J");              // 清屏(相当于 clrscr)
    printf("\033[5;1H");            // 光标定位到第 5 行第 1 列(相当于 gotoxy(1,5))
    printf("Output at row 5 column 1\n");

    printf("\033[10;20H");          // 光标定位到第 10 行第 20 列
    printf("Output at row 10 column 20\n");

    printf("\033[0m");              // 恢复默认样式
    return 0;
}

代码讲解

  • \033[5;1H5 是行号、1 是列号,用分号分隔,末尾字母 H 表示"移动光标"。注意行、列从 1 开始
  • 清屏后光标停在原位置,通常紧接着输出 \033[H 回到左上角:\033[2J\033[H 是标准的"清屏并回到开头"组合。
  • 这段代码不需要 conio.h,ANSI 序列只是普通字符串,跨平台通用。

知识点

  • ANSI 光标控制:\033[H(回原点)、\033[2J(清屏)、\033[行;列H(定位)
  • 坐标约定从 1 开始(与 C 数组从 0 开始对比,注意区分)

扩展思考(其他解法)

Windows 下也可以 system("cls"); 清屏,但那是调用外部命令,速度慢、不可移植,教学上 ANSI 序列更优。


【程序 34】练习函数调用

题目:练习函数调用(调用自定义函数)。

解题思路

定义一个打印"Hello, world!"的函数,再定义一个调用它 3 次的函数,main 调用后者。练习的是函数定义、声明、调用的基本流程

完整代码

#include <stdio.h>

void hello_world(void)          // 无返回值、无参数
{
    printf("Hello, world!\n");
}

void three_hellos(void)
{
    int counter;
    for (counter = 1; counter <= 3; counter++)
        hello_world();          // 调用上面的函数
}

int main(void)
{
    three_hellos();             // main 调用函数
    return 0;
}

代码讲解

  • void 表示"没有返回值"(不需要 return 值)或"没有参数"。
  • 被调用的函数必须先定义(或先声明),main 才能调用它,编译器按从上到下的顺序找定义。

知识点

  • 函数的定义、调用
  • void 类型
  • 函数声明的顺序问题

扩展思考

函数的意义在于复用:把重复代码抽成函数,一处修改处处生效。后续程序 66、67、70、71 都是函数化编程的练习。


【程序 35】文本颜色设置

题目:文本颜色设置练习。

解题思路

终端可以用 ANSI 转义序列设置前景色、显示闪烁文字:

  • 前景色:\033[30m~\033[37m(8 种基础色)、\033[90m~\033[97m(8 种明亮色),或 256 色写法 \033[38;5;Nm
  • 闪烁:\033[5m;重置:\033[0m

完整代码

#include <stdio.h>

int main()
{
    int color;
    /* 256 色表中 0~15 号正好对应经典 16 色 */
    for (color = 0; color < 16; color++)
    {
        printf("\033[38;5;%dm", color);     // 设置前景色为 color 号颜色
        printf("This is color %2d\r\n", color);
    }

    printf("\033[5m");                      // 开启闪烁
    printf("This is blinking\r\n");
    printf("\033[0m");                      // 重置所有样式
    return 0;
}

代码讲解

  • \033[38;5;Nm38 表示"设置前景色",5 表示"使用 256 色模式",N 是颜色编号(0~255)。0~15 号就是经典 16 色(黑红绿黄蓝品红青白及其明亮版)。
  • 背景色把 38 换成 48\033[48;5;Nm
  • \033[5m 是闪烁属性;多个属性可以合并Merge写,如 \033[1;31m 表示"加粗 + 红色"。
  • 结束时务必 \033[0m 重置,否则样式会"污染"后续输出。

知识点

  • 256 色 ANSI 颜色(前景 38;5;N / 背景 48;5;N
  • SGR 属性可以组合(加粗 1、下划线 4、闪烁 5、反显 7 等)

说明

程序 32(背景色)、33(清屏/定位)、35(前景色)三题合起来就是"终端控制三件套",已覆盖终端样式控制最常见的需求,跨平台通用。


【程序 36】求 100 之内的素数(筛法)

题目:求 100 以内的所有素数。

解题思路

前面程序 12 用"逐个试除"判断素数。本题介绍更高效的埃拉托斯特尼筛法(Sieve of Eratosthenes)

  1. 准备一张 1~100 的"名单"(数组)。
  2. 从 2 开始:2 是素数,把名单上所有 2 的倍数划掉(置 0);找下一个没被划掉的数 3,把 3 的倍数划掉;再下一个是 5(4 已被划掉)……
  3. 划到 $\sqrt{100} = 10$ 为止,剩下没被划掉的全是素数。

筛法的精髓:每个合数只通过"它的最小质因子"被划掉一次,避免了试除法的大量重复计算。

完整代码

#include <stdio.h>
#include <math.h>

#define N 101          // 数组下标 0~100

int main()
{
    int a[N];
    int i, j;

    for (i = 2; i < N; i++)
        a[i] = i;               // 初始化名单:a[i] 存放数字 i,未被划掉

    for (i = 2; i <= sqrt(N); i++)   // 只需筛到 sqrt(N)
    {
        if (a[i] != 0)               // 当前数字还没被划掉,说明是素数
            for (j = i + i; j < N; j += i)
                a[j] = 0;            // 划掉它的所有倍数
    }

    printf("100 以内的素数:\n");
    for (i = 2; i < N; i++)
        if (a[i] != 0)
            printf("%5d", a[i]);
    printf("\n");
    return 0;
}

代码讲解

  • 数组下标就是数字本身,a[i] = 0 表示"数字 i 被划掉了"。这是"用下标做索引Index"的经典技巧。
  • 外层只到 $\sqrt{N}$:因为 $100 = 10 \times 10$,任何合数必然有一个不超过 10 的因子,超过 10 的倍数都已经被更小的素数划掉了。
  • 内层 j += i 步长是 i,正好扫过 i 的所有倍数。

知识点

  • 埃拉托斯特尼筛法
  • 数组作"标记表"的用法(用下标表示数据本身)
  • 时间复杂度:试除法 O(n√n),筛法 O(n log log n),n 越大优势越明显

数学原理

筛法的数学依据:任何合数 $m$ 都有小于 $\sqrt m$ 的质因子。所以把所有素数的倍数都划掉后,剩下的必然都是素数。这是"数论 + 数据结构"结合的第一个经典例子,也是"质数表"的标准生成方法(密码学中生成大素数也要用到类似思路)。

扩展思考

  • 程序 12 是"逐个判断",本题是"批量生成",两种思路各有适用场景:判断单个大数用试除法;需要大量素数表用筛法。
  • 可以优化:只筛奇数、只标记"奇数的倍数"等,节省一半内存,作为进阶练习。

【程序 37】对 10 个数排序(选择排序)

题目:对 10 个数进行排序(从小到大)。

解题思路

选择排序(Selection Sort),思想与程序 5 一脉相承:

  1. 第 1 轮:在全部 10 个数里找最小的,和 a[0] 交换 → a[0] 就是全局最小。
  2. 第 2 轮:在 a[1]~a[9] 里找最小的,和 a[1] 交换。
  3. ……直到最后两个数比较完。

每轮"选择"一个最小元素放到正确位置,共需 n-1 轮。用 min 记录"当前轮最小元素的下标",找到后再交换。

完整代码

#include <stdio.h>

#define N 10

int main()
{
    int a[N];
    int i, j, min, temp;

    printf("请输入 10 个数:\n");
    for (i = 0; i < N; i++)
        scanf("%d", &a[i]);

    /* 选择排序 */
    for (i = 0; i < N - 1; i++)
    {
        min = i;                       // 假设 a[i] 最小
        for (j = i + 1; j < N; j++)    // 在后面的元素中找更小的
            if (a[min] > a[j])
                min = j;               // 记录更小元素的下标
        /* 交换 a[i] 与 a[min] */
        temp = a[i];
        a[i] = a[min];
        a[min] = temp;
    }

    printf("排序后:\n");
    for (i = 0; i < N; i++)
        printf("%5d", a[i]);
    printf("\n");
    return 0;
}

代码讲解

  • 找最小的是"记录下标"而不是"记录值":因为交换时需要下标。
  • 外层循环 n-1 轮即可:前 n-1 个位置放好了,最后一个自然就位。
  • 与程序 5 对比:那里"比较 3 个数"就是选择排序处理 3 个元素的特例。

知识点

  • 选择排序算法
  • 数组下标作为"指针/索引"使用

数学原理

选择排序的时间复杂度是 O(n²)——外层 n-1 轮,内层平均 n/2 次比较。对 10 个数约 45 次比较。虽然它不是最快的排序,但思路最直观,是学习排序的第一课。更快的算法(快速排序、归并排序)在工程中更常用,但都是本思想的深化。

扩展思考(其他解法)

  • 冒泡排序:相邻元素两两比较,大的往后"冒泡",每轮把最大值送到最后。可以自己实现对比:
    for (i = 0; i < N - 1; i++)
      for (j = 0; j < N - 1 - i; j++)
          if (a[j] > a[j + 1]) { temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; }

    注意每轮能少比较 i 次(末尾已就位)。

  • 标准库还有 qsort(快速排序),工程中直接调用。学习阶段务必亲手实现选择排序和冒泡排序。

【程序 38】求 3×3 矩阵对角线元素之和

题目:求一个 3×3 矩阵对角线元素之和。

解题思路

矩阵用二维数组 a[3][3] 存储。主对角线(从左上到右下)的元素是 a[0][0]a[1][1]a[2][2]——它们的共同点是行号等于列号a[i][i])。所以累加 a[i][i] 即可。

题目只说"对角线元素之和",经典含义是主对角线;若要包含副对角线(右上到左下),还要加上 a[i][2-i],注意正中间元素别重复加。

完整代码

#include <stdio.h>

int main()
{
    float a[3][3], sum = 0;
    int i, j;
    printf("请输入 3×3 矩阵的 9 个元素:\n");
    for (i = 0; i < 3; i++)
        for (j = 0; j < 3; j++)
            scanf("%f", &a[i][j]);

    for (i = 0; i < 3; i++)
        sum = sum + a[i][i];        // 主对角线:行号 == 列号

    printf("主对角线元素之和为 %.2f\n", sum);
    return 0;
}

代码讲解

  • 二维数组的遍历:外层循环管行,内层循环管列。
  • 主对角线规律:i == j;副对角线规律:i + j == 2(n 阶矩阵是 i + j == n - 1)。

知识点

  • 二维数组的定义、输入、遍历
  • 对角线元素的坐标规律

扩展思考(其他解法)

副对角线也求和(不重复计算中心元素):

for (i = 0; i < 3; i++)
    sum += a[i][i];               // 主对角线
for (i = 0; i < 3; i++)
    if (i != 3 - 1 - i)           // 跳过中心元素(3x3 时 i==1)
        sum += a[i][2 - i];       // 副对角线

【程序 39】向有序数组中插入一个数

题目:有一个已经排好序的数组。现输入一个数,要求按原来的规律将它插入数组中。

解题思路

数组是升序的(10 个元素),要插入第 11 个位置:

  1. 先判断新数是否比最后一个元素大,是就直接放最后。
  2. 否则找到第一个比它大的位置 i,把从 i 开始的所有元素从后往前依次后移一位,腾出位置后把新数放进去。

必须从后往前移:如果从前往后移,前面的元素会覆盖后面的,数据就丢了。

完整代码

#include <stdio.h>

int main()
{
    int a[11] = {1, 4, 6, 9, 13, 16, 19, 28, 40, 100};  // 预留一个位置
    int i, j, number;

    printf("原数组:\n");
    for (i = 0; i < 10; i++)
        printf("%5d", a[i]);
    printf("\n请输入要插入的数:");
    scanf("%d", &number);

    if (number > a[9])                 // 比最后一个还大,直接放末尾
        a[10] = number;
    else
    {
        for (i = 0; i < 10; i++)
        {
            if (a[i] > number)         // 找到插入位置 i
            {
                for (j = 9; j >= i; j--)   // 从后往前依次后移
                    a[j + 1] = a[j];
                a[i] = number;             // 插入
                break;
            }
        }
    }

    printf("插入后:\n");
    for (i = 0; i < 11; i++)
        printf("%5d", a[i]);
    printf("\n");
    return 0;
}

代码讲解

  • 数组声明为 11 个元素,最后留一个空位给新数。
  • 后移的循环从 j = 9 递减到 ia[10] = a[9]a[9] = a[8]……a[i+1] = a[i]。从后往前才不会覆盖未移动的数据。
  • break 跳出查找循环。

知识点

  • 有序插入算法("从后往前后移"的细节)
  • 数组扩容的模拟(静态数组预留空间)

数学原理

插入排序(Insertion Sort)的第一步就是"把一个数插入有序序列"。完整的插入排序算法:从第 2 个元素开始,把每个元素插入前面已排序的部分。可以自己实现完整版,与选择排序、冒泡排序对照。


【程序 40】将数组逆序输出

题目:将一个数组逆序输出。

解题思路

用"首尾交换"法:a[0]a[n-1] 交换,a[1]a[n-2] 交换……只需要交换 n/2 次。注意循环上界是 n/2:交换超过 n/2 次会把数组又换回去。

完整代码

#include <stdio.h>

#define N 5

int main()
{
    int a[N] = {9, 6, 5, 4, 1};
    int i, temp;

    printf("原数组:\n");
    for (i = 0; i < N; i++)
        printf("%4d", a[i]);

    for (i = 0; i < N / 2; i++)     // 只需交换 n/2 次
    {
        temp = a[i];
        a[i] = a[N - 1 - i];        // 对称位置的元素
        a[N - 1 - i] = temp;
    }

    printf("\n逆序后:\n");
    for (i = 0; i < N; i++)
        printf("%4d", a[i]);
    printf("\n");
    return 0;
}

代码讲解

  • a[N-1-i] 是与 a[i] 对称的位置。i=0 对应 N-1,i=1 对应 N-2……
  • N 为奇数时,最中间的元素不动(i 到不了中间)。

知识点

  • 首尾交换法
  • 对称下标 N-1-i

扩展思考

  • 只想"逆序打印"不改数组的话,从 N-1 循环到 0 打印即可。
  • 字符串反转(程序 70 的进阶题)用的也是同样的首尾交换法。

第五部分:变量的存储类型与宏(程序 41 ~ 50)


【程序 41】学习 static 定义静态变量

题目:学习 static(静态变量)的用法。

解题思路

函数内普通局部变量每次调用时"重新出生"(重新分配、重新初始化);而 static 局部变量只初始化一次,函数调用结束后它的值依然保留,下次调用接着用。

用一个对比实验:普通变量 var 每次从 0 开始,静态变量 static_var 的值会一直累加。

完整代码

#include <stdio.h>

void varfunc()
{
    int var = 0;              // 普通局部变量:每次调用都重新初始化为 0
    static int static_var = 0;// 静态局部变量:只初始化一次,值会保留

    printf("var = %d, static_var = %d\n", var, static_var);
    var++;
    static_var++;
}

int main()
{
    int i;
    for (i = 0; i < 3; i++)
        varfunc();
    return 0;
}

代码讲解

  • 输出结果:
    var = 0, static_var = 0
    var = 0, static_var = 1
    var = 0, static_var = 2
  • 为什么 var 始终是 0?每次调用 varfuncvar 都重新分配内存并赋初值 0。而 static_var 存放在"静态存储区",整个程序运行期间只初始化一次。

知识点

  • 变量的存储类型auto(默认,局部)、static(静态)、register(寄存器)、extern(外部)
  • 静态变量的生命周期:从程序开始到程序结束
  • 作用域与生命周期的区别:静态变量作用域仍是所在函数,但生命周期是整个程序

扩展思考

static 在工程中还有两个重要用途:

  1. 修饰全局变量/函数:限制为"本文件可见",防止多个 .c 文件之间命名冲突。
  2. 做"计数器":比如统计函数被调用了多少次。程序 43 会再看一个例子。

【程序 42】学习 auto 定义变量

题目:学习使用 auto 定义变量。

解题思路

auto 是 C 语言"默认存储类型"关键字:普通局部变量默认就是 auto(自动分配内存,离开作用域自动释放)。写不写 auto 效果完全一样,所以实际代码很少写它。本题还演示了内层代码块(花括号包起来的局部区域)里可以重新定义同名变量,与外部变量互不影响。

完整代码

#include <stdio.h>

int main()
{
    int i, num;
    num = 2;
    for (i = 0; i < 3; i++)
    {
        printf("外层 num = %d\n", num);
        num++;
        {
            auto int num = 1;        // 内层块中新的 num(写法上 auto 可省略)
            printf("内层 num = %d\n", num);
            num++;
        }                            // 离开内层块,内层 num 销毁
        // 外层 num 不受影响
    }
    return 0;
}

代码讲解

  • 内层 {} 块内的 num 与外层 num两个不同的变量(虽然同名)。内层块结束,内层 num 就"消失"。
  • auto 关键字只是强调"这是自动变量",写不写都一样。

知识点

  • auto 存储类型(默认)
  • 变量的作用域(块作用域)与同名遮蔽(shadowing)
  • 内存分配时机:进入块时分配、离开块时释放

扩展思考

现代 C 代码基本不写 auto(C++11 中 auto 含义完全不同,是"自动推断类型",注意别混淆)。本题的价值在于理解"作用域与生命周期"。


【程序 43】static 的另一用法(块内静态变量)

题目:学习使用 static 的另一用法(块内静态变量)。

解题思路

与程序 42 对比:内层块里的 static int num = 1 也是"只初始化一次",虽然作用域只在块内,但生命周期是整个程序,所以每次进入块时不会重新初始化。

完整代码

#include <stdio.h>

int main()
{
    int i, num;
    num = 2;
    for (i = 0; i < 3; i++)
    {
        printf("外层 num = %d\n", num);
        num++;
        {
            static int num = 1;      // 静态变量:只初始化一次
            printf("内层 num = %d\n", num);
            num++;                   // 值被保留
        }
    }
    return 0;
}

代码讲解

  • 输出中内层 num 依次是 1、2、3(值被保留),而程序 42 中是 1、1、1(每次重新初始化)。
  • 与程序 42 对比,是理解 static 的最佳实验。

知识点

  • staticauto 的区别:初始化时机生命周期

【程序 44】学习 external 的用法

题目:学习使用 extern(外部变量)。

解题思路

extern 用来声明"这个变量定义在其他地方"(另一个 .c 文件或本文件后面)。全局变量默认可以在整个程序里共享。本题中 abc 是全局变量,add() 函数和 main 都能访问它们。

注意一个常见的坑:add() 里若重新定义了局部变量 int a;,会遮蔽全局 a,此时 c = a + b 用的是局部 a(未初始化!),结果不可预期。这正好是个"反面教材"——局部变量会遮蔽全局变量。下面给出修正后的代码。

完整代码

#include <stdio.h>

int a, b, c;          // 全局变量(默认值为 0)

void add()
{
    c = a + b;        // 使用全局变量 a、b
}

int main()
{
    a = b = 4;
    add();
    printf("c = %d\n", c);    // 输出 8
    return 0;
}

代码讲解

  • 全局变量定义在函数外,程序中任何地方都能访问。
  • 修正后 add() 不再定义局部 ac = a + b = 4 + 4 = 8
  • 函数内定义同名局部变量会"遮蔽"全局变量,初学者极易踩中,务必注意。

知识点

  • 全局变量与 extern
  • 同名变量的遮蔽(shadowing)问题

扩展思考

  • 多文件工程中,在一个 .c 文件里定义全局变量,另一个 .c 文件用 extern int a; 声明后即可访问。
  • 工程实践中应少用全局变量:它们让程序各部分隐式耦合,难以维护。传参是更清晰的方式。程序 87 会讲"结构体变量传递"。

【程序 45】学习 register 定义变量

题目:学习使用 register 定义变量。

解题思路

register 建议编译器把变量放在 CPU 寄存器里而不是内存中,读写更快。适合循环计数器这类"高频访问"的变量。注意它只是"建议",现代编译器优化能力很强,register 已基本多余;且寄存器变量不能取地址(&)。

完整代码

#include <stdio.h>

int main()
{
    register int i;      // 建议放入寄存器
    int sum = 0;
    for (i = 1; i <= 100; i++)
        sum += i;
    printf("1+2+...+100 = %d\n", sum);
    return 0;
}

代码讲解

  • sum += isum = sum + i 的简写。
  • 求 1~100 的和:答案 5050。这也复习了累加器模式。

知识点

  • register 存储类型(了解即可)
  • 累加求和

数学原理

1 到 n 的和有公式 $S = \frac{n(n+1)}{2}$,n=100 时是 5050。传说高斯 10 岁就用配对法(1+100、2+99…共 50 对,每对 101)瞬间算出。程序用循环"笨算",但通用性更强(任意规则的和都能算)。


【程序 46】宏 #define 命令练习(1)

题目:宏 #define 命令练习——定义常量与函数式宏。

解题思路

#define预处理指令:在编译前,把代码中所有出现"宏名"的地方原样替换成"宏体"。有两种宏:

  • 对象宏(常量):#define TRUE 1——用名字代替数字,代码可读性好。
  • 函数式宏(带参数):#define SQ(x) (x)*(x)——像函数一样用,但本质是文本替换。

⚠️ 函数式宏必须给参数加括号SQ(x) 定义为 (x)*(x) 而不是 x*x。否则 SQ(1+2) 会被替换成 1+2*1+2 = 5(错!),加括号后是 (1+2)*(1+2) = 9(对)。

完整代码

#include <stdio.h>

#define TRUE 1
#define FALSE 0
#define SQ(x) ((x) * (x))      // 宏体里多加一层括号更安全

int main()
{
    int num, again = TRUE;
    printf("输入小于 50 的数时程序结束。\n");
    while (again)
    {
        printf("请输入一个数:");
        scanf("%d", &num);
        printf("它的平方是 %d\n", SQ(num));
        if (num >= 50)
            again = TRUE;
        else
            again = FALSE;
    }
    return 0;
}

代码讲解

  • while (again)again 为 TRUE(1) 就继续循环,FALSE(0) 结束。
  • SQ(num) 在编译前被替换成 ((num) * (num)),所以即使传 num+1 也不会出错。

知识点

  • 宏定义(对象宏、函数式宏)
  • 预处理阶段(编译前的文本替换)
  • 宏的括号陷阱

扩展思考(其他解法)

  • 函数式宏与真函数的区别:宏是编译前文本替换,无类型检查、无调用开销;函数有类型检查、有调用开销。现代代码中函数式宏多被 inline 函数或 static inline 取代,但嵌入式/底层代码仍常用宏。
  • 例:SQ(3.5) 用宏没问题,若写成函数 int sq(int x) 则会丢失小数。

【程序 47】宏 #define 命令练习(2)——宏交换

题目:宏 #define 命令练习——用宏交换两个变量的值。

解题思路

宏体可以包含多条语句,用花括号包起来;跨行的宏要用续行符 \(每行末尾加反斜杠,表示"下一行仍是宏的一部分")。

完整代码

#include <stdio.h>

#define EXCHANGE(a, b)  \
    do {                \
        int t = (a);    \
        (a) = (b);      \
        (b) = t;        \
    } while (0)

int main()
{
    int x = 10, y = 20;
    printf("交换前:x=%d, y=%d\n", x, y);
    EXCHANGE(x, y);
    printf("交换后:x=%d, y=%d\n", x, y);
    return 0;
}

代码讲解

  • 每行末尾的 \ 是续行符(\ 后面不能有任何字符,包括空格)。
  • 宏体用 do { ... } while(0) 包裹是经典技巧:让宏像一个"语句"一样使用,且在 if 里使用时不会破坏分支结构(如果直接写 {...},在 if (x) EXCHANGE(a,b); else ... 场景会出问题)。
  • 注意宏内临时变量 t 用了独立名字:若参数恰好叫 t 会冲突,这是宏的固有缺点。

知识点

  • 多语句宏与续行符
  • do { } while(0) 宏封装技巧

扩展思考(其他解法)

交换两个数还有不借助临时变量的经典写法(异或法,见程序 53):

a = a ^ b;
b = a ^ b;
a = a ^ b;

这是位运算的应用,理解异或的性质后很巧妙,但可读性差,工程中一般不用。


【程序 48】宏 #define 命令练习(3)

题目:宏 #define 命令练习——用宏定义运算符。

解题思路

宏甚至可以把运算符"改名":#define LAG >。这只是一种趣味练习,实际工程不推荐(影响可读性),但能帮你理解"宏就是文本替换"的本质。

完整代码

#include <stdio.h>

#define LAG >
#define SMA <
#define EQ ==

int main()
{
    int i = 10, j = 20;
    if (i LAG j)
        printf("%d 大于 %d\n", i, j);
    else if (i EQ j)
        printf("%d 等于 %d\n", i, j);
    else if (i SMA j)
        printf("%d 小于 %d\n", i, j);
    return 0;
}

代码讲解

  • 预处理后 i LAG j 变成 i > j,程序输出"10 小于 20"。
  • 这个练习让你直观看到宏替换发生在编译之前。

知识点

  • 宏替换的本质(文本替换)
  • 预处理指令在编译流程中的位置

【程序 49】#if #ifdef 和 #ifndef 的综合应用

题目#if#ifdef#ifndef 的综合应用。

解题思路

条件编译:让编译器"有选择地编译"某些代码。常用场景:同一份代码在不同平台编译不同版本、调试版与发布版。

  • #ifdef MAX:如果宏 MAX 已定义,编译下面代码,否则编译 #else 分支。
  • #ifndef MIN:如果宏 MIN 未定义……
  • #undef MAX:取消宏定义。

完整代码

#include <stdio.h>

#define MAX
#define MAXIMUM(x, y) ((x) > (y) ? (x) : (y))
#define MINIMUM(x, y) ((x) > (y) ? (y) : (x))

int main()
{
    int a = 10, b = 20;

#ifdef MAX                       // MAX 已定义 → 编译此分支
    printf("较大的数是 %d\n", MAXIMUM(a, b));
#else
    printf("较小的数是 %d\n", MINIMUM(a, b));
#endif

#ifndef MIN                      // MIN 未定义 → 编译此分支
    printf("较小的数是 %d\n", MINIMUM(a, b));
#else
    printf("较大的数是 %d\n", MAXIMUM(a, b));
#endif

#undef MAX                       // 取消 MAX 定义

#ifdef MAX                       // MAX 现在未定义 → 走 else
    printf("较大的数是 %d\n", MAXIMUM(a, b));
#else
    printf("较小的数是 %d\n", MINIMUM(a, b));
#endif
    return 0;
}

代码讲解

  • #ifdef/#ifndef 判断的是"宏是否已定义",与宏的值无关。
  • #undef 之后宏就不存在了,后面 #ifdef 的判断结果会变化。
  • MAXIMUM(a, b) 展开为 ((a) > (b) ? (a) : (b)),是三目运算符的应用(复习程序 15)。

知识点

  • 条件编译:#ifdef#ifndef#else#endif#undef
  • 用途:头文件防重复包含(#ifndef XXX_H 包裹整个头文件,见程序 50 扩展)、平台兼容代码

数学原理

MAXIMUM 的展开用到了三目运算符,一次比较 O(1) 完成取最大值,不依赖比较次数。

扩展思考

真正工程中最常见的条件编译是"头文件守卫":

#ifndef MY_HEADER_H
#define MY_HEADER_H
/* 头文件内容 */
#endif

防止同一个头文件被多次包含导致重复定义错误。程序 50 的 #include 练习后可以回来看这个技巧。


【程序 50】#include 的应用练习

题目#include 的应用——自己写一个头文件并引用。

解题思路

#include 的作用是把另一个文件的内容原样"粘贴"到本文件。我们可以自己创建一个头文件 test.h,里面定义宏,然后在 .c 文件里 #include "test.h" 使用。

完整代码

test.h(自己创建):

/* test.h —— 自定义头文件 */
#define LAG >
#define SMA <
#define EQ ==

程序 50 主文件

#include "test.h"        // 引用自己写的头文件(用双引号)
#include <stdio.h>       // 引用标准库头文件(用尖括号)

int main()
{
    int i = 10, j = 20;
    if (i LAG j)
        printf("%d 大于 %d\n", i, j);
    else if (i EQ j)
        printf("%d 等于 %d\n", i, j);
    else
        printf("%d 小于 %d\n", i, j);
    return 0;
}

代码讲解

  • #include "文件名" 先找当前目录,再找系统目录;#include <文件名> 只找系统目录。自定义头文件用双引号。
  • 预处理时 test.h 的内容被"粘贴"进来,所以能直接用 LAG 等宏。

知识点

  • #include 的两种写法与查找规则
  • 头文件的作用:声明共享的函数、宏、类型

扩展思考

  • 结合程序 49 学到的"头文件守卫",自己写头文件时养成加守卫的习惯。
  • 大型项目里,头文件负责"声明"(如函数原型、结构体定义),.c 文件负责"实现",这是 C 语言工程组织的基础。

第六部分:位运算、图形绘制与杨辉三角(程序 51 ~ 65)


【程序 51】学习按位与 &

题目:学习使用按位与 &

解题思路

按位与对两个整数的二进制位逐位运算:只有两个位都是 1,结果位才是 1,否则为 0。

  0 1 1 1 1 1 1    (63, 即八进制 077)
& 0 0 0 0 0 1 1    (3)
= 0 0 0 0 0 1 1    (3)

按位与最常用的用途是取某些位(掩码):x & 0xFF 取 x 的最低 8 位;x & 1 判断 x 的奇偶(最低位为 1 是奇数)。程序 54 会用到。

注意区分:& 是按位与,&& 是逻辑与(结果是 0 或 1)。这两个运算符完全不同!

完整代码

#include <stdio.h>

int main()
{
    int a = 077;      // 八进制 077 = 十进制 63 = 二进制 111111
    int b = a & 3;    // 只保留最低两位
    printf("a & 3 = %d\n", b);     // 3
    b &= 7;           // b = b & 7,保留最低三位
    printf("b & 7 = %d\n", b);     // 3
    return 0;
}

代码讲解

  • C 语言里以 0 开头的整数是八进制077 = $7 \times 8 + 7 = 63$。
  • b &= 7b = b & 7 的复合赋值写法。

知识点

  • 按位与 &
  • 八进制、十六进制表示法(077 八进制,0xFF 十六进制)
  • 掩码(mask)思想

数学原理

按位与是布尔代数里"逻辑乘"在计算机上的实现:$1 \& 1 = 1$,其余为 0。用 $2^k-1$ 这样的数(二进制全 1)做掩码,可以一次取出一段连续的二进制位,这是硬件编程(寄存器操作)的日常操作。


【程序 52】学习按位或 |

题目:学习使用按位或 |

解题思路

按位或:只要两个位中有一个是 1,结果位就是 1。

  0 1 1 1 1 1 1    (63)
| 0 0 0 0 0 1 1    (3)
= 0 1 1 1 1 1 1    (63)

按位或常用于把某些位置为 1x | 0x80 把第 7 位(从 0 数)置 1,其余位不变。

完整代码

#include <stdio.h>

int main()
{
    int a = 077;      // 63
    int b = a | 3;
    printf("a | 3 = %d\n", b);    // 63
    b |= 7;           // b = b | 7
    printf("b | 7 = %d\n", b);    // 63
    return 0;
}

知识点

  • 按位或 |
  • 复合赋值 |=

数学原理

按位或是布尔代数"逻辑加":$1 | 0 = 1$。在计算机里,"把某位置 1"用或、"把某位取反"用异或(下一个题)、"取某几位"用与(上一个题)——这三个操作构成了位操作工具箱


【程序 53】学习按位异或 ^

题目:学习使用按位异或 ^

解题思路

按位异或:两个位不同则为 1,相同则为 0。

  0 1 1 1 1 1 1    (63)
^ 0 0 0 0 0 1 1    (3)
= 0 1 1 1 1 0 0    (60)

异或有三个重要性质(数学上叫"自反性"):

  1. $a \oplus a = 0$(自己异或自己得 0)
  2. $a \oplus 0 = a$(异或 0 不变)
  3. $a \oplus b \oplus b = a$(异或两次回到原值)→ 可用于加密解密

完整代码

#include <stdio.h>

int main()
{
    int a = 077;
    int b = a ^ 3;
    printf("a ^ 3 = %d\n", b);    // 60
    b ^= 7;
    printf("b ^ 7 = %d\n", b);    // 59
    return 0;
}

代码讲解

  • 63 ^ 3:63 的二进制 111111,3 的二进制 000011,逐位异或得 111100 = 60。
  • 因为 63 的低两位都是 1,与 3(低两位 11)异或后变 0。

知识点

  • 按位异或 ^
  • 异或的三大性质

数学原理

异或的"可逆性"($a \oplus b \oplus b = a$)是它最神奇的地方:把 $b$ 当作"密钥",$a \oplus b$ 就是"密文",再异或一次 $b$ 就还原。这是对称加密最朴素的模型(如简单的流密码)。程序 47 扩展里提到的"无临时变量交换"也是这个性质的应用。

扩展思考(应用)

交换两个数(不借助第三个变量):

a = a ^ b;
b = a ^ b;   // b = (a^b)^b = a
a = a ^ b;   // a = (a^b)^a = b

面试常考题,理解原理比背代码更重要。


【程序 54】取整数 a 从右端开始的 4~7 位

题目:取一个整数 a 从右端开始的第 4~7 位。

解题思路

三步走:

  1. 把 a 右移 4 位a >> 4),这样原来的第 4~7 位变成了新的第 0~3 位。
  2. 构造一个掩码:低 4 位全 1、其余全 0,即 ~(~0 << 4)
    • ~0 是全 1(所有位都是 1)
    • ~0 << 4 是"全 1 左移 4 位",低 4 位变成 0
    • 再取反,低 4 位变 1,其余变 0 → 正是掩码 0000...1111
  3. 用按位与取出来:(a >> 4) & mask

完整代码

#include <stdio.h>

int main()
{
    unsigned a, b, c, d;
    printf("请输入一个八进制数:");
    scanf("%o", &a);       // %o 按八进制读入

    b = a >> 4;            // 右移 4 位:4~7 位来到低 4 位
    c = ~(~0 << 4);        // 掩码:低 4 位全 1
    d = b & c;             // 取出低 4 位,即原数的 4~7 位

    printf("原数(八进制):%o\n", a);
    printf("第4~7位(八进制):%o\n", d);
    return 0;
}

代码讲解

  • 右移 >>:每右移一位相当于除以 2(二进制下)。
  • 左移 <<:每左移一位相当于乘以 2。
  • 掩码技巧:~(~0 << n) 生成"低 n 位全 1"的数,这是位操作经典写法。

知识点

  • 左移 <<、右移 >>、取反 ~
  • 移位 + 掩码提取指定位

数学原理

二进制下,右移 k 位 = 除以 $2^k$,左移 k 位 = 乘以 $2^k$。程序里用移位代替乘除法,在硬件层更快。掩码运算本质上是在做"向量投影":只保留感兴趣的维度,其余清零。


【程序 55】学习按位取反 ~

题目:学习使用按位取反 ~

解题思路

~ 把一个数的每一位取反(0 变 1,1 变 0)。注意它在计算机里是针对"补码"(包括符号位)取反,所以 ~a 通常得到负数。这涉及"补码表示法",是理解 C 语言整数的基础知识。

完整代码

#include <stdio.h>

int main()
{
    int a = 234;
    int b = ~a;
    printf("~a 的十进制是 %d\n", b);      // -235
    printf("~a 的十六进制是 %x\n", ~a);   // 按十六进制看更直观
    return 0;
}

代码讲解

  • 为什么 ~234 = -235?因为 234 + (~234) = -1(每位都是 0+1 或 1+0,结果全 1 表示 -1 的补码)。于是 ~234 = -1 - 234 = -235。一般地,~a = -a - 1
  • 用十六进制输出能直观看到每一位取反的效果。

知识点

  • 按位取反 ~
  • 补码表示法(负数在计算机中的存储方式)
  • %x 十六进制输出

数学原理

补码规定:$-x$ 的补码是 $x$ 各位取反再加 1(~x + 1)。这是硬件实现减法的统一方案(减法 = 加补码)。所以 ~x = -x - 1。理解补码是理解 C 语言整数溢出、位运算的钥匙。

扩展思考

  • ~0 得到全 1,即 -1(int 类型)。程序 54 用 ~(~0<<4) 生成掩码,正是取反的实际用途。

【程序 56~60、62~65】图形绘制题

题目:学习用图形库画圆、画线、画矩形、画点、画椭圆、综合绘图等。

图形库使用说明

本系列图形题使用 ACLLib(Advanced C Lab Library) 实现——一个非常轻量的 C 图形库(GitHub:wengkai/ACLLib),由上海交通大学翁恺老师的 C 语言 MOOC 课程维护,封装了 Win32 API,只有一个头文件 acllib.h 加一个源文件 acllib.c,特别适合初学者快速上手 Windows 图形编程。

ACLLib 的安装与使用(Windows + MinGW / Dev-C++ / VS Code + GCC):

  1. 从 GitHub(wengkai/ACLLib)下载源码,把 src 目录下的 acllib.hacllib.c 复制到你的项目目录。
  2. 写代码时 #include "acllib.h"
  3. 编译时需要同时编译 acllib.c 并链接系统库,命令行Command-line写法:
    gcc 程序.c acllib.c -I. -lgdi32 -lole32 -loleaut32 -luuid -lwinmm -lmsimg32 -DWINVER=0x0501 -o 程序

    (Dev-C++ 则在“项目属性 → 参数 → 链接器”里加入这些库。)

ACLLib 程序的基本结构(注意!入口不是 main):

#include "acllib.h"

int Setup()          // ACLLib 自动调用 Setup 作为程序入口
{
    initWindow("标题", DEFAULT, DEFAULT, 640, 480);  // 创建 640×480 窗口

    beginPaint();    // 开始绘制
    // ……在这里画图……
    endPaint();      // 结束绘制(把内容显示到屏幕)

    return 0;
}

常用绘图函数一览:

功能 函数
创建窗口 initWindow("标题", DEFAULT, DEFAULT, 宽, 高)
清屏 clearDevice()
画笔颜色 / 粗细 setPenColor(颜色) / setPenWidth(像素)
画刷颜色 setBrushColor(颜色)
画线 line(x1, y1, x2, y2)
画圆 / 椭圆 ellipse(左, 上, 右, 下)(外接矩形)
画矩形 rectangle(左, 上, 右, 下)
画点 putPixel(x, y, 颜色)
输出文字 setTextSize(号) + paintText(x, y, "字符串")
定时器(动画用) registerTimerEvent(回调) + startTimer(编号, 毫秒)
同时用控制台 initConsole()(之后可用 printf)

颜色:预定义常量 BLACKREDGREENBLUECYANMAGENTAYELLOWWHITE,也可以自己用 RGB(红, 绿, 蓝) 定义任意颜色。

坐标系统:原点在窗口左上角,x 向右、y 向下,单位是像素。画圆要写外接矩形:圆心 (cx, cy)、半径 r 的圆写作 ellipse(cx-r, cy-r, cx+r, cy+r)。下面每题都给出完整、可直接运行的 ACLLib 代码。


【程序 56】画圆

题目:画一系列同心圆。

解题思路:圆心固定,半径按固定步长递增,用循环画出一组同心圆。ACLLib 中画圆要写成外接矩形:圆心 (cx, cy)、半径 r 的圆 = ellipse(cx-r, cy-r, cx+r, cy+r)

完整代码

#include "acllib.h"

int Setup()
{
    int i, r;
    initWindow("同心圆", DEFAULT, DEFAULT, 640, 480);

    beginPaint();

    setPenColor(BLUE);
    setPenWidth(2);
    for (i = 0; i <= 25; i++)
    {
        r = 20 + i * 10;                        // 半径 20,30,40...270
        ellipse(320 - r, 240 - r, 320 + r, 240 + r);   // 外接矩形画圆
    }

    endPaint();
    return 0;
}

代码讲解

  • 入口是 Setup() 而不是 main:ACLLib 会自动调用它,这是它和普通 C 程序最大的区别。
  • initWindow("同心圆", DEFAULT, DEFAULT, 640, 480):标题、窗口位置(DEFAULT 表示由系统决定)、宽、高。
  • 所有绘图代码都夹在 beginPaint()endPaint() 之间;endPaint() 会把画的内容真正显示到窗口。
  • 每次循环以半径 r 画圆:外接矩形的左上角 (320-r, 240-r)、右下角 (320+r, 240+r)。

知识点:ACLLib 的 Setup 入口与 beginPaint/endPaint 流程、用外接矩形画圆、循环控制几何参数。


【程序 57】画直线

题目:学习用 line 画直线(两组放射线)。

完整代码

#include "acllib.h"

int Setup()
{
    int i;
    float x0 = 263, y0 = 263, y1 = 275, x1 = 275;

    initWindow("画直线", DEFAULT, DEFAULT, 640, 480);

    beginPaint();

    setPenColor(MAGENTA);
    /* 第一组:从左上角区域向外"生长"的线段 */
    for (i = 0; i <= 18; i++)
    {
        line(x0, y0, x0, y1);       // 画一条竖线段
        x0 = x0 - 5;  y0 = y0 - 5;  // 起点向左上移
        x1 = x1 + 5;  y1 = y1 + 5;  // 终点向右下移
    }

    /* 第二组:从左上向右下延伸的线段 */
    x0 = 263; y0 = 263; y1 = 275;
    setPenColor(YELLOW);
    for (i = 0; i <= 20; i++)
    {
        line(x0, y0, x0, y1);
        x0 = x0 + 5;  y0 = y0 + 5;  // 起点向右下移
        y1 = y1 - 5;                // 终点向上移
    }

    endPaint();
    return 0;
}

代码讲解:每次循环里"线段的两个端点按相反方向移动",线段就被不断拉长并平移,两组线段叠加出放射状图案。line(x1, y1, x2, y2) 从点 (x1,y1) 画到 (x2,y2)。注意 line 的坐标参数是 int 类型,这里用 float 存坐标是为了避免中间计算产生小数误差(传给函数时自动取整)。

知识点line 画线;用"参数每步变化"生成图案的递推思想。


【程序 58】画矩形

题目:学习用 rectangle 画方形,并配上文字。

完整代码

#include "acllib.h"

int Setup()
{
    int x0 = 263, y0 = 263, x1 = 275, y1 = 275;
    int i;

    initWindow("画矩形", DEFAULT, DEFAULT, 640, 480);

    beginPaint();

    /* 一组嵌套矩形:两个对角同时向外扩展 */
    setPenColor(BLUE);
    for (i = 0; i <= 18; i++)
    {
        rectangle(x0, y0, x1, y1);
        x0 -= 5;  y0 -= 5;          // 左上角向外
        x1 += 5;  y1 += 5;          // 右下角向外
    }

    /* 点缀:圆 + 文字 + 直线 */
    setPenColor(RED);
    ellipse(320 - 150, 240 - 150, 320 + 150, 240 + 150);   // 画圆(外接矩形)
    setTextSize(24);
    paintText(150, 40, "How beautiful it is!");
    line(130, 60, 480, 60);          // 文字下方画一条线

    endPaint();
    return 0;
}

代码讲解

  • rectangle(左, 上, 右, 下) 按两个对角点画矩形;循环里两角同时外扩,画出一层层嵌套的方框。
  • 文字用 setTextSize(24) 设置字号(数字越大字越大),paintText(x, y, "字符串") 在指定位置输出。
  • 画圆仍用外接矩形形式:ellipse(320-150, 240-150, 320+150, 240+150) 是圆心 (320,240)、半径 150 的圆。

知识点rectangle 画矩形、paintText 输出文字、用外接矩形画圆。


【程序 59】综合例子:参数方程画放射线

题目:综合画图——圆 + 用三角函数参数方程画放射线(椭圆变形)。

数学要点

  • 圆上点的参数方程:$x = x_0 + r\cos\theta$,$y = y_0 + r\sin\theta$,$\theta$ 从 0 到 $2\pi$(弧度制,$360° = 2\pi$)。
  • 把 $\sin$ 项乘以系数 $B$(如 0.809),圆就被"压扁"成椭圆——这是屏幕像素纵横比不同时的经典做法。
  • cos/sin 来自 <math.h>(int) 把浮点坐标转成像素整数。

完整代码

#include "acllib.h"
#include <math.h>

#define PI 3.1415926
#define B 0.809          // 纵轴压缩系数

int Setup()
{
    int i, x0 = 320, y0 = 240, x, y;
    double a;

    initWindow("参数方程", DEFAULT, DEFAULT, 640, 480);

    beginPaint();

    /* 三个基准圆(外接矩形画圆) */
    setPenColor(WHITE);
    ellipse(x0 - 10, y0 - 10, x0 + 10, y0 + 10);
    ellipse(x0 - 20, y0 - 20, x0 + 20, y0 + 20);
    ellipse(x0 - 50, y0 - 50, x0 + 50, y0 + 50);

    /* 把圆周分成 16 份,从圆心向每个分点画放射线 */
    setPenColor(YELLOW);
    for (i = 0; i < 16; i++)
    {
        a = (2 * PI / 16) * i;              // 16 等分圆周的角度
        x = x0 + (int)(48 * cos(a));
        y = y0 + (int)(48 * sin(a) * B);    // y 乘以 B 压成椭圆
        line(x0, y0, x, y);
    }

    setPenColor(RED);
    ellipse(x0 - 60, y0 - 60, x0 + 60, y0 + 60);

    setTextSize(16);
    paintText(10, 440, "Press a key to exit...");

    endPaint();
    return 0;
}

代码讲解a = (2*PI/16)*i 把 $2\pi$ 均分成 16 份,cos(a)sin(a) 算出第 i 个分点在单位圆上的坐标,乘以半径 48 再平移到圆心 (x0, y0),从圆心连线即得放射线。三个基准圆和放射线、外圈圆用不同颜色区分层次。

扩展思考:还可以加上旋转动画(每帧把角度整体偏移再重画)——用 ACLLib 的定时器registerTimerEvent(回调函数) 注册,startTimer(0, 30) 让回调函数每 30 毫秒被调用一次,在回调里重画即可,试试"转动的花"——这就是动画的基本框架。


【程序 60】"小球反弹"动画

题目:两个点按固定速度移动,撞到窗口边界就反向,形成弹跳动画。

解题思路:这是最简单的"游戏物理":位置随速度更新(x += dx),撞墙检测(越界就把该方向速度取反,实现反弹)。ACLLib 是事件Event驱动的:没有 while 死循环,而是用定时器让系统每隔一小段时间自动调用一次回调函数,在回调里更新位置并重画——这是图形/游戏编程的标准模式。

完整代码

#include "acllib.h"

#define LEFT 0
#define TOP 0
#define RIGHT 639
#define BOTTOM 479

int x1 = 10, y1 = 10, x2 = 10, y2 = 10;   // 两个点的坐标(全局变量,供回调函数访问)
int dx1 = 2, dy1 = 2, dx2 = 3, dy2 = 3;   // 各自的速度
ACL_Color colors[] = {RED, GREEN, BLUE, CYAN, MAGENTA, YELLOW};
int colorIdx = 0;

/* 定时器回调:每隔一段时间自动执行一次 */
void timerEvent(int tid)
{
    beginPaint();

    clearDevice();                 // 清屏:只保留当前一根线

    setPenColor(colors[colorIdx]);
    colorIdx = (colorIdx + 1) % 6; // 颜色循环变化
    line(x1, y1, x2, y2);          // 画两个点的连线

    endPaint();

    /* 位置随速度更新 */
    x1 += dx1;  y1 += dy1;
    x2 += dx2;  y2 += dy2;

    /* 撞墙反弹:越界就把该方向速度取反 */
    if (x1 <= LEFT || x1 >= RIGHT) dx1 = -dx1;
    if (y1 <= TOP  || y1 >= BOTTOM) dy1 = -dy1;
    if (x2 <= LEFT || x2 >= RIGHT) dx2 = -dx2;
    if (y2 <= TOP  || y2 >= BOTTOM) dy2 = -dy2;
}

int Setup()
{
    initWindow("弹跳动画", DEFAULT, DEFAULT, 640, 480);

    registerTimerEvent(timerEvent);  // 注册定时器回调
    startTimer(0, 20);               // 启动定时器 0:每 20 毫秒触发一次
    return 0;
}

代码讲解

  • registerTimerEvent(timerEvent) 把函数 timerEvent 注册为定时器回调;startTimer(0, 20) 启动编号 0 的定时器,每 20 毫秒调用一次回调——这就是"帧"(每秒钟约 50 帧)。
  • 坐标用全局变量保存:Setup 注册完就返回,之后的动画全靠回调函数驱动,回调里要访问这些数据。
  • 每帧先 clearDevice() 清屏再画线,就只显示"当前一根线";如果去掉清屏,线条轨迹会留在屏幕上,视觉效果更有趣。
  • ACL_Color 是颜色类型,用一个颜色数组 + 下标循环实现变色。

知识点:事件驱动编程、定时器与动画帧、碰撞检测与速度取反、全局变量在回调间的数据共享。


【程序 62】putPixel 画点

题目:用 putPixel 画点,形成网格线。

完整代码

#include "acllib.h"

int Setup()
{
    int i, j;
    initWindow("网格", DEFAULT, DEFAULT, 640, 480);

    beginPaint();

    /* 横向网格线:固定 y,x 从左到右逐点画 */
    for (j = 50; j <= 230; j += 20)
        for (i = 50; i <= 230; i++)
            putPixel(i, j, BLUE);

    /* 纵向网格线:固定 x,y 从上到下逐点画 */
    for (i = 50; i <= 230; i += 20)
        for (j = 50; j <= 230; j++)
            putPixel(i, j, BLUE);

    endPaint();
    return 0;
}

代码讲解putPixel(x, y, 颜色) 在 (x, y) 画一个像素点(注意第三个参数是颜色)。两组循环分别固定 y(画横线)和固定 x(画竖线),把点连成网格。这是理解"光栅图形"(一切图像都由像素组成)的入门。

知识点putPixel 画点、双重循环遍历坐标、像素与网格。


【程序 63】画椭圆

题目:学习用 ellipse 画椭圆(一系列从"圆"逐渐变"扁长"的椭圆)。

完整代码

#include "acllib.h"

int Setup()
{
    int i;
    int xr = 130, yr = 130;     // 横向半径、纵向半径

    initWindow("椭圆", DEFAULT, DEFAULT, 640, 480);

    beginPaint();

    setPenColor(BLUE);
    for (i = 0; i < 20; i++)
    {
        /* 外接矩形:左上角 (320-xr, 240-yr),右下角 (320+xr, 240+yr) */
        ellipse(320 - xr, 240 - yr, 320 + xr, 240 + yr);
        xr -= 5;                             // 横向半径缩小
        yr += 5;                             // 纵向半径增大
    }

    endPaint();
    return 0;
}

代码讲解:ACLLib 的 ellipse(左, 上, 右, 下)外接矩形确定椭圆,这与以"圆心 + 半径"为参数的画法不同。这里 xr、yr 分别是横、纵方向"半径"(外接矩形的一半宽、高):xr 递减、yr 递增,画出一组从圆逐渐变扁长的椭圆;当 xr == yr 时画出的就是正圆。

知识点ellipse 的外接矩形参数形式、椭圆与圆的半径关系。


【程序 64】ellipse 和 rectangle 综合画图

题目:综合使用 ellipserectangle 画图。

完整代码

#include "acllib.h"

int Setup()
{
    int i;
    int num = 15, top = 50, left = 20, right = 50;

    initWindow("综合画图", DEFAULT, DEFAULT, 640, 480);

    beginPaint();

    for (i = 0; i < num; i++)
    {
        /* 横向椭圆:左右半径 right 变大 */
        setPenColor(BLUE);
        ellipse(320 - right, 240 - left, 320 + right, 240 + left);
        /* 纵向椭圆:上下半径 top 变大 */
        setPenColor(RED);
        ellipse(320 - 20, 240 - top, 320 + 20, 240 + top);
        /* 嵌套矩形 */
        setPenColor(GREEN);
        rectangle(20 - 2 * i, 20 - 2 * i, 10 * (i + 2), 10 * (i + 2));
        right += 5;
        left += 5;
        top += 10;
    }

    endPaint();
    return 0;
}

代码讲解:三组图形(横向椭圆、纵向椭圆、嵌套矩形)的尺寸参数随循环变化,叠加在一起形成图案。rectangle 的参数可以越界(如负坐标),ACLLib 会自动裁剪,不影响程序运行。

知识点:多种绘图函数组合、参数随循环变化。


【程序 65】一个最优美的图案

题目:圆内接多边形的所有顶点两两连线(弦),形成对称的"星芒"图案。

数学原理:把圆等分成 n 个点(这里 15 个),每两个点之间连一条线段(弦),得到的图案正是数学上的"完全图" $K_{15}$。用参数方程算出各点坐标:
$$
x_i = x_c + r\cos\theta_i, \quad y_i = y_c - r\sin\theta_i, \quad \theta_i = i \cdot \frac{2\pi}{n}
$$

完整代码

#include "acllib.h"
#include <math.h>

#define MAXPTS 15
#define PI 3.1415926

int Setup()
{
    int points[MAXPTS][2];       // 15 个点的坐标
    int i, j, xc = 320, yc = 240;
    int radius = 180, angle, step;
    double rads, aspect = 0.85;

    initWindow("弦图", DEFAULT, DEFAULT, 640, 480);

    /* 用参数方程计算圆上 15 个等分点(画图前先算好坐标) */
    step = 360 / MAXPTS;          // 每个点间隔的角度
    angle = 0;
    for (i = 0; i < MAXPTS; i++)
    {
        rads = angle * PI / 180.0;              // 角度转弧度
        points[i][0] = xc + (int)(cos(rads) * radius);
        points[i][1] = yc - (int)(sin(rads) * radius * aspect);
        angle += step;
    }

    beginPaint();

    /* 画外接圆(外接矩形) */
    setPenColor(WHITE);
    ellipse(xc - radius, yc - radius, xc + radius, yc + radius);

    /* 任意两点连线:弦图 */
    setPenColor(YELLOW);
    for (i = 0; i < MAXPTS; i++)
        for (j = i; j < MAXPTS; j++)
            line(points[i][0], points[i][1], points[j][0], points[j][1]);

    endPaint();
    return 0;
}

代码讲解

  • 角度转弧度:rads = angle * PI / 180.0($360° = 2\pi$ 弧度)。
  • points[i][0]points[i][1] 分别存第 i 个点的 x、y 坐标(用二维数组存点集,复习二维数组)。坐标在 beginPaint 之前就算好,画图阶段只负责连线。
  • 双重循环 for i + for j = i 让每对点恰好连一次线,避免重复:15 个点两两连线共 $\frac{15 \times 14}{2} = 105$ 条,这正是组合数 $\binom{15}{2}$。
  • aspect = 0.85 对 y 坐标压缩,抵消屏幕像素纵横比,让外接圆视觉上接近正圆。

知识点:三角函数参数方程、角度/弧度换算、二维数组存点集、嵌套循环连线(组合数的应用)。


小结:56~65 的图形题把"循环 + 坐标公式"结合,是计算机图形学的最初级体验——把几何图形翻译成"对每根线条/每个像素做一次计算"。ACLLib 只是一个轻量入门库,如果以后想开发更复杂的图形程序,还可以学习 XEGE、SDL2 或 raylib(跨平台)。


【程序 61】打印杨辉三角形

题目:打印 10 行杨辉三角形。

解题思路

杨辉三角(帕斯卡三角)的每一行,除了两端的 1 之外,每个数等于它"左上方 + 正上方"两个数之和:
$$
a[i][j] = a[i-1][j-1] + a[i-1][j]
$$

        1
       1 1
      1 2 1
     1 3 3 1
    1 4 6 4 1
   1 5 10 10 5 1

用二维数组 a[10][10] 存储:先把每行两端的元素(a[i][0]a[i][i])设为 1,再按递推公式填中间部分,最后打印。

完整代码

#include <stdio.h>

int main()
{
    int a[10][10];
    int i, j;

    for (i = 0; i < 10; i++)
    {
        a[i][0] = 1;       // 每行第一个元素是 1
        a[i][i] = 1;       // 每行最后一个元素是 1(对角线)
    }

    for (i = 2; i < 10; i++)
        for (j = 1; j < i; j++)
            a[i][j] = a[i-1][j-1] + a[i-1][j];   // 递推公式

    for (i = 0; i < 10; i++)
    {
        for (j = 0; j <= i; j++)
            printf("%5d", a[i][j]);
        printf("\n");
    }
    return 0;
}

代码讲解

  • 注意外层从 i = 2 开始:第 0、1 行只有"1"或"1 1",两端都已是 1,中间没有需要算的格子。
  • j < i 保证不覆盖对角线元素 a[i][i]
  • 打印时 j <= i:第 i 行只有 i+1 个元素(三角形)。

知识点

  • 二维数组的初始化与递推填充
  • 递推公式建模

数学原理

杨辉三角是数学的"宝库",藏着大量规律:

  1. 第 n 行第 k 个数 = 组合数 $C(n, k) = \frac{n!}{k!(n-k)!}$(第 0 行起)。
  2. 每行之和 = $2^n$。
  3. 斜对角线和构成斐波那契数列(程序 11 的联系!)。
  4. 与二项式展开 $(a+b)^n$ 的系数完全一致。

这个三角形还出现在概率论(二项分布)、组合数学(路径计数)中。比如"从方格左上角走到右下角有多少种走法"就是组合数问题,正是杨辉三角的每个格子。

扩展思考(其他解法)

  • 不用二维数组、只用一维数组滚动更新的版本:从右往左更新 a[j] += a[j-1],可以省一半内存,作为进阶练习。
  • 若想输出"等腰"形状,可以在每行前打印空格(程序 23 学过的技巧)。

第七部分:指针入门与应用(程序 66 ~ 70)

说明:程序 62~65 的图形绘制题已在第六部分讲解,本部分从程序 66 开始讲解指针。


【程序 66】用指针方法把三个数按大小顺序输出

题目:输入 3 个数 a、b、c,按大小顺序输出(用指针实现)。

解题思路

排序逻辑和程序 5 完全一样(三次比较-交换)。区别在于:这次把"交换"写成一个函数 swap(p1, p2),参数是指针

为什么必须传指针? 因为 C 函数参数是"值传递"——如果直接传两个数,函数里交换的是参数的副本,函数外的原变量不会变。传指针(变量的地址)后,函数通过 *p1*p2 直接操作原变量的内存,才能真的交换。

完整代码

#include <stdio.h>

void swap(int *p1, int *p2)   // 参数是"指向 int 的指针"
{
    int t;
    t = *p1;      // *p1 取出指针所指变量的值
    *p1 = *p2;
    *p2 = t;
}

int main()
{
    int n1, n2, n3;
    int *p1, *p2, *p3;
    printf("请输入 3 个数:");
    scanf("%d,%d,%d", &n1, &n2, &n3);
    p1 = &n1;     // & 取地址:p1 指向 n1
    p2 = &n2;
    p3 = &n3;

    if (n1 > n2) swap(p1, p2);
    if (n1 > n3) swap(p1, p3);
    if (n2 > n3) swap(p2, p3);

    printf("从小到大:%d, %d, %d\n", n1, n2, n3);
    return 0;
}

代码讲解

  • p1 = &n1;:把 n1 的地址存进指针 p1
  • swap(p1, p2):传进去的是地址。函数里 *p1 就是 n1 本身,*p2 就是 n2 本身,交换成功。
  • 回忆预备知识第 9 节:& 取地址、* 取内容。这就是指针的两个基本操作。

知识点

  • 指针的声明、取地址、间接引用
  • 值传递 vs 传地址:想通过函数修改外部变量,必须传地址
  • 指针作为函数参数

数学原理

排序逻辑同程序 5(选择排序雏形)。本题重点是理解为什么传指针:C 参数传递是复制值,指针让我们复制"地址"从而能间接修改原变量。这是 C 语言最核心也最难的概念,值得反复琢磨。

扩展思考(其他解法)

  • 不借助指针,直接用全局变量也能让函数"修改外部变量",但全局变量坏处多(程序 44 讨论过)。传指针才是正道。
  • 传指针与传值对比如下:传值——函数内改的是副本;传指针——函数通过地址改原变量。

【程序 67】最大的与第一个交换、最小的与最后一个交换

题目:输入数组,最大的元素与第一个元素交换,最小的与最后一个元素交换,输出数组。

解题思路

用指针遍历数组找最大、最小元素的位置(记住是指针/下标,不是值),然后交换。

有一种常见的错误写法:交换时先动 array[0] 再动 array[9],导致最小值位置被覆盖。下面给出清晰正确的版本。

完整代码

#include <stdio.h>

void input(int a[], int n)
{
    int i;
    for (i = 0; i < n; i++)
        scanf("%d", &a[i]);
}

void max_min(int a[], int n)
{
    int *max = a, *min = a;    // 先都指向第一个元素
    int *p, temp;

    for (p = a + 1; p < a + n; p++)   // 指针遍历数组
    {
        if (*p > *max) max = p;       // 记录更大元素的位置
        if (*p < *min) min = p;       // 记录更小元素的位置
    }

    /* 最大的与第一个交换 */
    temp = *max; *max = a[0]; a[0] = temp;
    /* 最小的与最后一个交换 */
    temp = *min; *min = a[n-1]; a[n-1] = temp;
}

void output(int a[], int n)
{
    int i;
    for (i = 0; i < n; i++)
        printf("%d ", a[i]);
    printf("\n");
}

int main()
{
    int number[10];
    printf("请输入 10 个数:\n");
    input(number, 10);
    max_min(number, 10);
    output(number, 10);
    return 0;
}

代码讲解

  • int *max = a;:数组名本身就是"指向首元素的指针",a 等价于 &a[0]。指针 pa+1 走到 a+n-1,正好遍历所有元素(p < a + n 是结束条件)。
  • *p > *max 时更新 max = p:记录的是位置(指针),交换时才能定位。
  • 注意:若最大值恰好是最后一个元素、最小值恰好是第一个元素,先交换最大值再交换最小值时要小心错位。更稳妥的做法是用下标实现,这里给出下标版作为对照(见扩展思考)。

知识点

  • 指针遍历数组(p = a; p < a + n; p++ 模式)
  • 数组名即首地址
  • 记住"位置"而非"值"的算法思想

扩展思考(其他解法)

下标版更易读,也避免指针交换的错位问题:

int maxi = 0, mini = 0, i;
for (i = 1; i < n; i++) {
    if (a[i] > a[maxi]) maxi = i;
    if (a[i] < a[mini]) mini = i;
}
/* 先交换,再判断是否冲突 */

mini == 0 且先交换了 a[0]a[maxi],最小值位置可能被破坏——一个经典细节题,建议自己推演:最大值在最后、最小值在最前的特殊情况。


【程序 68】数组元素循环右移 m 位

题目:有 n 个整数,使其前面各数顺序向后移 m 个位置,最后 m 个数变成最前面的 m 个数。

解题思路

"循环右移 m 位":每个元素向后移 m 个位置,末尾的 m 个元素绕回开头。例如 1 2 3 4 5 右移 2 位变成 4 5 1 2 3

一种经典做法:每次把数组右移 1 位(最后元素移到最前),重复 m 次;也可以用递归实现。更高效的做法是"三次反转法"(见扩展思考)。

完整代码(单步右移 m 次)

#include <stdio.h>

void move(int a[], int n, int m)
{
    int i, last;
    while (m-- > 0)              // 右移 m 次
    {
        last = a[n - 1];         // 备份最后一个元素
        for (i = n - 1; i > 0; i--)
            a[i] = a[i - 1];     // 每个元素后移一位(从后往前!)
        a[0] = last;             // 最后一个元素移到开头
    }
}

int main()
{
    int number[20], n, m, i;
    printf("元素个数:");
    scanf("%d", &n);
    printf("右移位数:");
    scanf("%d", &m);
    printf("请输入 %d 个数:", n);
    for (i = 0; i < n; i++)
        scanf("%d", &number[i]);

    move(number, n, m % n);      // 右移 n 位等于没移,取余优化

    for (i = 0; i < n; i++)
        printf("%d ", number[i]);
    printf("\n");
    return 0;
}

代码讲解

  • 单步右移必须从后往前赋值(a[i] = a[i-1]):从前往后会覆盖还没移动的元素(和程序 39 的插入同理)。
  • m % n:右移 n 位数组回到原样,所以取余后只需移 m % n 次,这是个重要优化。
  • m > n 不取余,程序也能正确工作但多做了无用的整轮移动。

知识点

  • 数组整体移位(从后往前的赋值顺序)
  • 取余优化循环位移

扩展思考(其他解法)

三次反转法(O(n) 时间,面试常考):先整体反转,再反转前 m 个,再反转后 n-m 个。

1 2 3 4 5 → 反转整体 5 4 3 2 1 → 反转前2个 4 5 3 2 1 → 反转后3个 4 5 1 2 3 ✓

可以自己实现一个 reverse(a, from, to) 函数验证。这就是"旋转数组"问题的经典解。


【程序 69】约瑟夫环问题(报数出圈)

题目:有 n 个人围成一圈,顺序排号。从第 1 个人开始报数(从 1 数到 3),凡报到 3 的人退出圈子,问最后留下的是原来第几号的那位。

解题思路

这是经典的约瑟夫环(Josephus problem)。用数组模拟:num[i] 存第 i+1 个人的编号,被淘汰的人编号置 0。用三个变量维护状态:

  • i:当前报到第几个人(下标,循环递增,到末尾回到 0,模拟"围成一圈")
  • k:当前报数到几(1、2、3 循环)
  • m:已淘汰的人数

每轮:i 依次扫过,跳过已淘汰的人(编号 0),没淘汰的人报数 k++k 数到 3 就淘汰这个人(置 0),k 清零,m++。直到 m == n-1,剩下的就是最后一人。

完整代码

#include <stdio.h>

int main()
{
    int num[50];
    int n, i, k, m;
    printf("请输入总人数:");
    scanf("%d", &n);

    for (i = 0; i < n; i++)
        num[i] = i + 1;        // 编号 1~n

    i = 0;  k = 0;  m = 0;     // i: 当前下标; k: 报数 1~3; m: 已淘汰人数

    while (m < n - 1)          // 淘汰到只剩 1 人
    {
        if (num[i] != 0)       // 这个人还在圈里
            k++;
        if (k == 3)            // 报到 3
        {
            num[i] = 0;        // 淘汰
            k = 0;             // 重新从 1 报数
            m++;               // 淘汰人数 +1
        }
        i++;                   // 下一个人
        if (i == n)
            i = 0;             // 围成一圈:回到开头
    }

    /* 找到唯一非 0 的编号 */
    for (i = 0; i < n; i++)
        if (num[i] != 0)
            printf("最后留下的是 %d 号\n", num[i]);
    return 0;
}

代码讲解

  • "围成一圈"用下标取模模拟:if (i == n) i = 0;(也可以写 i = (i + 1) % n;,更简洁)。
  • 已淘汰的人用 0 标记,报数时跳过。
  • 循环结束时数组里只有一个非 0 元素,就是幸存者。

知识点

  • 数组模拟数据结构(环形逻辑)
  • 取模模拟环形(% n
  • 状态机思想(i、k、m 三个状态变量)

数学原理

约瑟夫环有数学递推解(不用模拟,O(n) 直接算幸存者编号):
$$
J(1) = 0, \qquad J(n) = (J(n-1) + k) \bmod n
$$
(k 为报数上限,编号从 0 开始)。当 n=41、k=3 时,这就是著名的"约瑟夫问题"历史原型——传说约瑟夫靠计算幸存位置在罗马人包围中活了下来。程序模拟虽然慢(O(nk)),但直观、易写、易扩展(比如报数到 7、每次淘汰间隔变化),是学习阶段首选。

扩展思考

  • 用链表(程序 72 之后的内容)实现更接近真实"出圈"操作。
  • 数学递推解性能最好,适合 n 特别大的场景,可作为挑战题。

【程序 70】求字符串的长度

题目:写一个函数,求一个字符串的长度(不借助 strlen),在 main 中输入字符串并输出长度。

解题思路

字符串是字符数组,以 '\0' 结尾。用指针从开头往后数,数到 '\0' 为止——这就是求长度的原理。strlen 内部干的就是这件事。

完整代码

#include <stdio.h>

int length(char *p)
{
    int n = 0;
    while (*p != '\0')   // 不是结束符就继续
    {
        n++;
        p++;             // 指针后移一位
    }
    return n;
}

int main()
{
    char str[100];
    printf("请输入一个字符串:");
    gets(str);                        // 读一整行(含空格)
    printf("该字符串的长度是 %d\n", length(str));
    return 0;
}

代码讲解

  • while (*p != '\0')\0 是字符串结束标志(ASCII 码 0)。判断、计数、移动指针三件事配合,直到遇到 \0
  • gets(str) 读入整行(包括空格);scanf("%s") 遇空格会停,不适合含空格的字符串。注意 gets 有缓冲区溢出风险,安全写法是 fgets(str, 100, stdin),正式代码建议用 fgets
  • 数组名 str 传给函数时"退化"成指向首字符的指针,所以参数写 char *p 即可。

知识点

  • 字符串的 '\0' 结尾约定
  • 指针遍历字符串
  • gets vs scanf("%s") vs fgets

扩展思考(其他解法)

  • 用下标 for (n = 0; str[n] != '\0'; n++); 也一样,本质相同。
  • 现代代码直接调用库函数 strlen(str)<string.h>),自己实现是为了理解原理。程序 86 会用到 strcat 等字符串库函数。

第八部分:结构体与链表(程序 71 ~ 80)


【程序 71】输入输出 5 个学生的数据记录(结构体数组)

题目:编写 input() 和 output() 函数,输入、输出 5 个学生的数据记录。

解题思路

结构体把每个学生的学号、姓名、3 门成绩打包成一个整体,再用结构体数组存 5 个学生。input() 负责读入,output() 负责打印。这是结构体的入门练习,也复习函数化编程。

完整代码

#include <stdio.h>

#define N 5

struct student
{
    char num[10];        // 学号
    char name[20];       // 姓名
    int score[4];        // 3 门成绩(预留 4 个位置)
};

void input(struct student stu[], int n)
{
    int i, j;
    for (i = 0; i < n; i++)
    {
        printf("请输入第 %d 个学生的学号、姓名:", i + 1);
        scanf("%s %s", stu[i].num, stu[i].name);
        for (j = 0; j < 3; j++)
        {
            printf("第 %d 门成绩:", j + 1);
            scanf("%d", &stu[i].score[j]);
        }
    }
}

void output(struct student stu[], int n)
{
    int i, j;
    printf("\n学号      姓名    成绩1 成绩2 成绩3\n");
    for (i = 0; i < n; i++)
    {
        printf("%-10s%-8s", stu[i].num, stu[i].name);
        for (j = 0; j < 3; j++)
            printf("%-6d", stu[i].score[j]);
        printf("\n");
    }
}

int main()
{
    struct student stu[N];
    input(stu, N);
    output(stu, N);
    return 0;
}

代码讲解

  • stu[i].num:用 . 访问结构体成员("stu 的第 i 个元素的 num 字段")。
  • 结构体数组作为函数参数:struct student stu[] 会退化成指针,函数内修改会反映到原数组。
  • 注意函数要传参:main 里调用 input(stu, N) 而不是 input();,否则无法访问到结构体数组。

知识点

  • 结构体定义与结构体数组
  • 成员访问运算符 .
  • 结构体数组作为函数参数

扩展思考(其他解法)

结构体指针 + 箭头运算符访问成员是工程常用写法:p->num 等价于 (*p).num。可以在 input 里改用 struct student *p; p = &stu[i]; scanf("%s", p->num); 体会一下(程序 72 起会大量使用箭头)。


【程序 72】创建一个链表

题目:创建一个链表并输出各节点数据。

解题思路

链表是动态数据结构:由一个个节点(node)用指针串联而成。每个节点包含"数据 + 指向下一个节点的指针"。最后一个节点的 next 指向 NULL(表示结束)。

创建链表三步:malloc 申请内存 → 填入数据 → 把新节点接到链上。本题先用一个"头节点"作占位,然后循环读入数据、追加节点。

完整代码

#include <stdio.h>
#include <stdlib.h>       // malloc 需要

struct list
{
    int data;
    struct list *next;
};

typedef struct list node;   // 给类型起别名 node
typedef node *link;         // node* 别名 link(指向节点的指针)

int main()
{
    link ptr, head;         // head: 头指针; ptr: 当前节点
    int num, i;

    head = (link)malloc(sizeof(node));   // 申请第一个节点
    ptr = head;

    printf("请输入 5 个数:\n");
    for (i = 0; i < 5; i++)
    {
        scanf("%d", &num);
        ptr->data = num;                 // 存数据
        ptr->next = (link)malloc(sizeof(node));  // 申请下一个节点
        if (i == 4)
            ptr->next = NULL;            // 最后一个节点指向 NULL
        else
            ptr = ptr->next;             // 移到下一个节点
    }

    /* 遍历输出 */
    ptr = head;
    while (ptr != NULL)
    {
        printf("%d ", ptr->data);
        ptr = ptr->next;
    }
    printf("\n");
    return 0;
}

代码讲解

  • malloc(sizeof(node)):向系统申请一块能放下一个 node 的内存,返回它的地址。(link) 是类型转换。
  • ptr->data-> 是"通过指针访问成员",等价于 (*ptr).data
  • 遍历链表的通用模式:while (ptr != NULL) { 处理 ptr; ptr = ptr->next; }——顺着 next 一个个走,走到 NULL 结束。
  • typedef 给类型起别名:typedef struct list node; 之后写 node 就代表 struct list,代码更简洁。

知识点

  • 链表结构:节点(数据 + next 指针)
  • malloc 动态内存分配、free 释放(本程序略,实际用完应 free)
  • typedef 类型别名
  • 链表的创建与遍历

数学原理/类比

链表像一个"寻宝游戏":你只知道自己手中这张纸条的内容和下一张纸条在哪。与数组对比:数组是一排连续的房间(知道门牌号就能直达任意房间),链表是纸条链(必须从第一张开始顺着找)。数组随机Random访问快(O(1)),链表插入删除快(O(1),不需要搬动其他元素),这是数据结构最基础的选择依据。

扩展思考

  • 用完链表要释放内存:free 逐个释放节点(先保存 next 再 free 当前节点),避免内存泄漏。
  • 工程中链表插入节点、删除节点的操作很常用,程序 74 会练习删除。

【程序 73】反向输出一个链表

题目:反向输出一个链表。

解题思路

一个思路是"头插法"建链:新节点总是插在最前面,这样建完后链表自然就是逆序的,遍历即得逆序结果。更通用的是用程序 27 的递归思想:先递归遍历到链尾,回归时打印。

完整代码(递归逆序打印——更通用)

#include <stdio.h>
#include <stdlib.h>

struct list
{
    int data;
    struct list *next;
};
typedef struct list node;
typedef node *link;

/* 递归逆序打印:先打印后面的,再打印自己 */
void print_reverse(link p)
{
    if (p == NULL)
        return;
    print_reverse(p->next);   // 先处理下一个
    printf("%d ", p->data);   // 再打印自己
}

int main()
{
    link head = NULL, ptr, temp;
    int num, i;

    printf("请输入 5 个数:\n");
    for (i = 0; i < 5; i++)
    {
        scanf("%d", &num);
        /* 头插法:新节点插到最前面 */
        ptr = (link)malloc(sizeof(node));
        ptr->data = num;
        ptr->next = head;
        head = ptr;
    }

    printf("正序:");
    for (ptr = head; ptr != NULL; ptr = ptr->next)
        printf("%d ", ptr->data);
    printf("\n逆序:");
    print_reverse(head);
    printf("\n");
    return 0;
}

代码讲解

  • 头插法核心:ptr->next = head; head = ptr;——新节点指向旧头,新节点成为新头。这样最先输入的节点被"挤"到链尾。
  • 递归逆序打印与程序 27 完全同构:先递归,后打印
  • 遍历链表的三种经典方式都可以对照:正序 for 循环、逆序递归。

知识点

  • 头插法建链
  • 递归处理链表

扩展思考(其他解法)

  • 也可以"反转链表"(就地反转指针方向)后再正序打印,这是面试经典题:
    link prev = NULL, cur = head, nxt;
    while (cur != NULL) {
      nxt = cur->next;
      cur->next = prev;
      prev = cur;
      cur = nxt;
    }
    head = prev;

    建议自己跑一遍理解指针如何"转身"。


【程序 74】连接两个链表

题目:连接两个链表。

解题思路

本题的核心操作是连接(concatenate)两个链表——找到第一个链表的末尾,把它的 next 指向第二个链表的头即可。这是链表最基础的操作之一。

完整代码

#include <stdio.h>
#include <stdlib.h>

struct list
{
    int data;
    struct list *next;
};
typedef struct list node;
typedef node *link;

link create_list(int a[], int n)     // 用数组创建链表
{
    link head, ptr, temp;
    int i;
    head = (link)malloc(sizeof(node));
    head->data = a[0];
    head->next = NULL;
    ptr = head;
    for (i = 1; i < n; i++)
    {
        temp = (link)malloc(sizeof(node));
        temp->data = a[i];
        temp->next = NULL;
        ptr->next = temp;    // 接到链尾
        ptr = temp;
    }
    return head;
}

link concatenate(link p1, link p2)   // 连接两个链表
{
    link ptr = p1;
    if (p1 == NULL) return p2;       // 第一个为空直接返回第二个
    while (ptr->next != NULL)        // 走到第一个链表的末尾
        ptr = ptr->next;
    ptr->next = p2;                  // 末尾接上第二个链表
    return p1;
}

void print_list(link head)
{
    link p;
    for (p = head; p != NULL; p = p->next)
        printf("%d ", p->data);
    printf("\n");
}

int main()
{
    int a[] = {1, 3, 5, 7};
    int b[] = {2, 4, 6};
    link la = create_list(a, 4);
    link lb = create_list(b, 3);
    printf("链表1:"); print_list(la);
    printf("链表2:"); print_list(lb);
    la = concatenate(la, lb);
    printf("连接后:"); print_list(la);
    return 0;
}

代码讲解

  • concatenate 的核心就是"找尾 + 接上":while (ptr->next != NULL) ptr = ptr->next; 走到链尾,然后 ptr->next = p2
  • 链尾的特征是 next == NULL,找链尾是链表基本功。
  • 有人会把"连接两个链表"和"选择排序、删除节点"混在一起写,使逻辑复杂化;本题只聚焦"连接",用上面的方法已经最优,不再硬凑多方案。

知识点

  • 找链表尾节点
  • 链表拼接

扩展思考

  • 合并两个有序链表(面试常考):每次取两链头中较小者接到结果链上。可以自己实现,体会"归并"思想(归并排序的雏形)。

【程序 75】放松一下,算一道简单的题目

题目:算一道简单题——综合练习 if 判断。

解题思路

这道题其实是考察"读懂代码会得到什么输出"。对 i = 1..4,依次执行几个 if,统计条件成立次数 n,当 n == 3 时输出 64 + i 对应的字符。%c 输出 65、66、67、68 对应的字符是 'A'、'B'、'C'、'D'。

完整代码(带注释帮助理解)

#include <stdio.h>

int main()
{
    int i, n;
    for (i = 1; i < 5; i++)
    {
        n = 0;
        if (i != 1)  n = n + 1;   // i=2,3,4 时成立
        if (i == 3)  n = n + 1;   // 仅 i=3 时成立
        if (i == 4)  n = n + 1;   // 仅 i=4 时成立
        if (i != 4)  n = n + 1;   // i=1,2,3 时成立
        if (n == 3)
            printf("答案是:%c\n", 64 + i);
    }
    return 0;
}

代码讲解

  • 逐个分析:i=1 时成立条件是第 4 条 → n=1;i=2 时第 1、4 条 → n=2;i=3 时第 1、2、4 条 → n=3 ✓,输出 64+3=67 即 'C';i=4 时第 1、3 条 → n=2。
  • 所以输出 'C'。这类"读代码写结果"的题是考试常见题型,训练的是逐步跟踪(trace)能力。

知识点

  • 程序跟踪/调试能力(人肉执行代码)
  • 字符与整数的转换(64 + i%c 输出)

扩展思考

练习"读代码"的正确姿势:用一张纸,把每个变量的变化一步步写下来(变量表),这是最可靠的调试方法,比瞎猜强得多。


【程序 76】指针函数:偶数求和、奇数求和的调度

题目:输入 n 为偶数时,求 1/2+1/4+...+1/n;为奇数时求 1/1+1/3+...+1/n(利用指针函数)。

解题思路

函数指针:指向函数的指针。函数在内存里也有地址,可以存在指针变量里,通过指针间接调用函数——这叫"回调"。

本题定义一个通用"调度函数" dcall(fp, n),参数 fp 是函数指针,dcall 调用 fp(n)。main 里根据 n 的奇偶选择把 pevenpodd 传给 dcall,实现"动态选择要执行的函数"。

完整代码

#include <stdio.h>

float peven(int n)          // 偶数项求和:1/2 + 1/4 + ... + 1/n
{
    float s = 0;
    int i;
    for (i = 2; i <= n; i += 2)
        s += 1.0 / i;       // 注意 1.0,避免整数除法
    return s;
}

float podd(int n)           // 奇数项求和:1/1 + 1/3 + ... + 1/n
{
    float s = 0;
    int i;
    for (i = 1; i <= n; i += 2)
        s += 1.0 / i;
    return s;
}

float dcall(float (*fp)(int), int n)   // fp 是指向函数的指针
{
    return (*fp)(n);                   // 通过指针调用函数
}

int main()
{
    float sum;
    int n;
    printf("请输入 n(>1):");
    scanf("%d", &n);

    if (n % 2 == 0)
        sum = dcall(peven, n);
    else
        sum = dcall(podd, n);

    printf("结果为 %f\n", sum);
    return 0;
}

代码讲解

  • 函数指针声明:float (*fp)(int)——fp 是一个指针,指向"参数为 int、返回 float"的函数。注意 (*fp) 的括号不能省,否则 float *fp(int) 是"返回 float* 的函数"。
  • dcall(peven, n):函数名 peven 本身可以当作函数指针传入。
  • 1.0 / i:因为 i 是 int,1 / i 会是整数除法(结果 0!),必须让分子是小数。

知识点

  • 函数指针与回调
  • 整数除法的陷阱(1 / i vs 1.0 / i

数学原理

调和级数(harmonic series)$1 + \frac12 + \frac13 + \dots$ 是发散的:虽然每项越来越小,但累加和会无限增长(增长速度和 $\ln n$ 相当,所以增长极慢)。本题的偶数项之和 $\frac12 + \frac14 + \dots$ 与奇数项之和 $1 + \frac13 + \frac15 + \dots$ 都是"调和级数的一半"(前者等于 $\frac12 \times$ 调和级数),因此也都发散,n 越大结果越大。这也提醒我们:浮点数累加级数时,n 特别大会有精度问题(浮点误差累积),这是数值计算里要注意的点。

扩展思考(其他解法)

  • 不用函数指针,main 里直接 if (n%2==0) sum = peven(n); else sum = podd(n); 也一样。函数指针的价值在于:把"选哪个函数"变成数据/参数,代码更灵活,是实现"策略模式"的基础。这在 C 语言里是高级技巧,理解即可。

【程序 77】填空练习(指向指针的指针)

题目:填空练习——char **q 指向指针的指针。

解题思路

char *s[] 是"字符串数组"(每个元素是 char*,指向一个字符串)。char **q 是"指向指针的指针":q 指向 s 的某个元素(一个 char*)。要打印第 k 个字符串,可以让 q = &s[k],然后 *q 就是 s[k](一个 char),`printf("%s", q)` 打印它。

完整代码(填空处补全)

#include <stdio.h>

int main()
{
    char *s[] = {"man", "woman", "girl", "boy", "sister"};
    char **q;
    int k;
    for (k = 0; k < 5; k++)
    {
        q = &s[k];              // ← 填空:让 q 指向 s[k]
        printf("%s\n", *q);     // *q 即 s[k],输出该字符串
    }
    return 0;
}

代码讲解

  • 这张图帮你理清三层关系:q(地址)→ s[k](char* 指针,存的是字符串首字符的地址)→ 字符('m', 'a', 'n'...)。
  • &s[k] 取 s[k] 的地址(类型是 char**),正好赋给 q
  • 为什么需要两级指针?因为 s 数组本身存的是"指针",要指向数组元素就得用"指向指针的指针"。

知识点

  • 指向指针的指针(二级指针)
  • 字符串数组(指针数组)
  • 多级间接引用的概念

数学原理/类比

指针就是"地址",二级指针是"地址的地址"。类比:变量是"房子",一级指针是"记着房子地址的纸条",二级指针是"记着纸条放在哪个抽屉的备忘录"。多级指针在二维数组、命令行参数(int main(int argc, char *argv[]))、动态分配二维结构时很常用。

扩展思考

argv 就是典型的 char **argv[0] 是程序名,argv[1] 起是命令行参数。可以写个程序 printf("%s\n", argv[1]); 试试传参运行。


【程序 78】找年龄最大的人(找出程序的问题)

题目:找到年龄最大的人并输出。请找出程序中有什么问题。

解题思路

本题是"找 bug"练习。问题代码在于:

if (m < p->age)
    q = p++;        // 错误:p++ 让 p 后移,q 指向的是"后移前"的 p
m = q->age;         // 错误:m 始终取 q 指向的人(而不是当前人)的年龄

q = p++p++ 是后置自增:q 先被赋值为当前的 p,然后 p 才后移。这导致循环里 p 每次多跳一位(跳过了第 2 个人),而且 m 的更新逻辑也乱了。下面给出修正版。

完整代码(修正版)

#include <stdio.h>

#define N 4

struct man
{
    char name[20];
    int age;
};

int main()
{
    struct man person[N] = {{"li", 18}, {"wang", 19}, {"zhang", 20}, {"sun", 22}};
    struct man *q, *p;
    int i;
    int max = 0;              // 记录最大年龄

    p = person;
    for (i = 0; i < N; i++)
    {
        if (max < p->age)
        {
            max = p->age;     // 更新最大年龄
            q = p;            // 记住这个人
        }
        p++;                  // 移到下一个人(与 if 无关!)
    }
    printf("年龄最大的是 %s,%d 岁\n", q->name, q->age);
    return 0;
}

代码讲解

  • 修正要点:p++ 必须放在 if 外面且独立执行;先更新 max 再让 q 指向当前人。
  • -> 访问结构体成员,(*q).nameq->name 等价。

知识点

  • 结构体指针遍历数组
  • 找最大值的"扫描"模式(max 记录值、q 记录位置)
  • 后缀自增 p++ 在表达式中的副作用(q = p++ 是经典陷阱)

扩展思考

找最大(最小)值的通用模板:一个变量记"当前最大"(或下标),遍历比较更新。程序 67 找最大最小也是这个模式。务必注意自增运算符在表达式里的求值顺序——q = p++ 先赋值后自增,这是很多 bug 的来源。


【程序 79】字符串排序

题目:输入三个字符串,按字典序从小到大输出。

解题思路

strcmp 比较字符串,用 strcpy 交换字符串(不能直接 = 赋值字符串!)。排序逻辑与程序 5、66 相同(三次比较-交换)。

为什么字符串不能直接比较或赋值? 字符串是字符数组,str1 == str2 比较的是两个数组的首地址而不是内容;str1 = str2 更是非法。必须用库函数 strcmp(逐字符比较)和 strcpy(逐字符复制)。

完整代码

#include <stdio.h>
#include <string.h>    // strcmp, strcpy

void swap(char *p1, char *p2)
{
    char temp[50];
    strcpy(temp, p1);
    strcpy(p1, p2);
    strcpy(p2, temp);
}

int main()
{
    char str1[50], str2[50], str3[50];
    printf("请输入三个字符串:\n");
    scanf("%s %s %s", str1, str2, str3);

    if (strcmp(str1, str2) > 0) swap(str1, str2);
    if (strcmp(str1, str3) > 0) swap(str1, str3);
    if (strcmp(str2, str3) > 0) swap(str2, str3);

    printf("排序后:\n%s\n%s\n%s\n", str1, str2, str3);
    return 0;
}

代码讲解

  • strcmp(a, b) 返回:a < b 时负数、a == b 时 0、a > b 时正数。按 ASCII 码逐字符比较("字典序")。
  • strcpy(目标, 源) 把源字符串(含 \0)复制到目标。交换字符串 = 三次 strcpy。
  • 注意 scanf("%s") 不能读含空格的字符串,若有空格需求改用 fgets

知识点

  • 字符串比较 strcmp、复制 strcpy
  • 字符串数组与指针的关系

数学原理

字典序(lexicographic order)就是把字符串当作"字母序列"按位比较,是字符编码(ASCII)有序性的直接应用。它和整数比较的唯一区别是"长度不同时"的处理(较短的是前缀时,短的更小,如 "abc" < "abcd")。

扩展思考

  • 字符串数组 char *strs[3] + 交换指针(而不是复制内容)是更高效的字符串排序方式——只换"纸条"不搬"内容"。进阶可以尝试。

【程序 80】海滩上分桃子问题

题目:海滩上有一堆桃子,五只猴子来分。第一只猴子把桃子分成五份,多了一个,扔进海里后拿走一份;第二只、第三只、第四只、第五只猴子都这么做。问海滩上原来最少有多少个桃子?

解题思路

最后一只猴子倒推:设最后一只猴子分完拿走一份后剩下 x 个,那么它分之前有 $\frac{5}{4}x + 1$ 个(因为五份中它拿走一份,剩四份,即"分之前的数 = 剩的 ÷ 4 × 5 + 1")。

倒推思路:枚举"第五只猴子分后剩下的桃子数"(必须是 4 的倍数),逐只往前推,如果每一步都能整出(每次剩下的都是 4 的倍数,才能被下一只猴子平分成 5 份),就说明成立。

枚举可以从 4 开始、步长 4(最后剩下的数必须是 4 的倍数),逐只猴子往前推。下面给出清晰的倒推版本。

完整代码(倒推法)

#include <stdio.h>

int main()
{
    int i;          // 枚举:最后剩下的桃子数(每次都被分前必须是 4 的倍数)
    int t;          // 当前"分之前"的桃子数
    int k;          // 猴子编号(5 只)
    int ok;

    for (i = 4; i < 100000; i += 4)    // 最后剩下的数必须是 4 的倍数
    {
        t = i;
        ok = 1;
        for (k = 0; k < 5; k++)        // 倒推 5 只猴子
        {
            t = t / 4 * 5 + 1;         // 分之前的数 = 剩下的 ÷4×5 + 1
            if (t % 4 != 0)            // 分之前不是 4 的倍数:前面那只猴子分不开
            {
                ok = 0;
                break;
            }
        }
        if (ok)
        {
            printf("海滩上原来最少有 %d 个桃子\n", t);
            break;
        }
    }
    return 0;
}

代码讲解

  • 倒推公式推导:分之前有 $N$ 个,猴子扔掉 1 个得 $N-1$,分成 5 份拿走 1 份剩 $\frac45 (N-1)$。设剩下 $M$ 个,则 $M = \frac45 (N-1)$,解得 $N = \frac54 M + 1$,即代码里的 t / 4 * 5 + 1
  • 条件 t % 4 != 0:前一只猴子分之前,数量必须能"先减 1 再被 4 整除"(等价于 (t-1) % 5 == 0 分后剩 4 份是整数)。用整除检查来筛选。
  • 枚举从 4 开始、每次加 4:最后剩下的数必须是 4 的倍数,缩小搜索范围。

知识点

  • 逆向思维(倒推)
  • 枚举 + 整除条件筛选

数学原理

这类"多轮取 1 再均分"的问题在数学上可以化成不定方程,用数论方法求通解;但枚举 + 验证是计算机的强项。顺带一提:这个经典问题的答案是 3121。可以自己用公式验证:$N = 5^5 - 4 = 3121$(有巧妙的数学构造,不展开)。

扩展思考

  • 把 5 只猴子改成 n 只、或每只猴子留 2 个,都是同一模型,改参数即可——这就是"模型化"思维。
  • 注意别让同一个变量既当外层循环变量又被内层逻辑修改(容易出错);本版本用 it 分离,更清晰。

第九部分:综合练习(程序 81 ~ 94)


【程序 81】809×??=800×??+9×??+1

题目:$809 \times ?!? = 800 \times ?!? + 9 \times ?!? + 1$,其中 ?? 是两位数,且 $8 \times ??$ 是两位数、$9 \times ??$ 是三位数。求 ?? 及 $809 \times ??$ 的结果。

解题思路

这道题本质是"找满足条件的两位数"。注意:$809 = 800 + 9$,所以 $809 \times ?? = 800\times ?? + 9 \times ??$ 恒成立,题目里的 "+1" 实际上是多余的(笔误)。真正有价值的约束是:$8 \times ??$ 是两位数、$9 \times ??$ 是三位数。枚举 10~99 的两位数,检查这些条件。

完整代码

#include <stdio.h>

int main()
{
    long a = 809;
    long i, b;
    for (i = 10; i < 100; i++)
    {
        b = a * i;
        if (8 * i < 100 && 9 * i >= 100)     // 8*i 是两位数、9*i 是三位数
            printf("?? = %ld, 809 * %ld = %ld\n", i, i, b);
    }
    return 0;
}

代码讲解

  • 8 * i < 100:两位数(10~99)的条件;9 * i >= 100:三位数的条件。
  • 解出 ?? 是 12(8×12=96 两位数,9×12=108 三位数),809×12 = 9708。

知识点

  • 枚举 + 条件筛选
  • 数位范围的判断

说明

这道题曾经有一种绕弯的写法(用"b/i = 809*i + b%i"这类恒等式故弄玄虚)。本题的核心价值是练习枚举筛选,直接采用简洁版。


【程序 82】八进制转换为十进制

题目:八进制转换为十进制。

解题思路

八进制数每位代表 $8^k$ 的权重。例如八进制 176 = $1 \times 8^2 + 7 \times 8^1 + 6 \times 8^0 = 64 + 56 + 6 = 126$。

程序用秦九韶式累乘n = n * 8 + (当前位数字),从左到右扫一遍。原理:先读入的位权重大,每读一位就把之前的和乘以 8 再累加。这比"先算 8 的幂"更高效,是进制转换的通用写法。

完整代码

#include <stdio.h>

int main()
{
    char s[10];
    int n = 0, i;
    printf("请输入一个八进制数:");
    scanf("%s", s);              // 按字符串读入

    for (i = 0; s[i] != '\0'; i++)
        n = n * 8 + (s[i] - '0');   // 关键:累乘 8 + 数字

    printf("十进制是 %d\n", n);
    return 0;
}

代码讲解

  • s[i] - '0':把字符 '0'~'7' 转成数字 0~7。因为字符 '0' 的 ASCII 码是 48,减去 '0' 就是数值。
  • 累乘过程:读 '1' → n=1;读 '7' → n=18+7=15;读 '6' → n=158+6=126。✓
  • 这个模式对任何进制通用:二进制就 *2,十六进制就 *16

知识点

  • 进制转换(按权展开)
  • 字符数字转数值:s[i] - '0'
  • 累乘累加(Horner 算法)

数学原理

按权展开:$N = dk 8^k + d{k-1} 8^{k-1} + \dots + d_0 8^0$。Horner(秦九韶)算法把它改写为 $N = (((dk \times 8 + d{k-1}) \times 8 + d_{k-2}) \times 8 + \dots)$,乘法次数从 $O(k^2)$ 降到 $O(k)$,这是"多项式求值"的经典优化,在数值计算中广泛使用。

扩展思考

反向转换(十进制转八进制):不断 %8 取余、/8 除,余数倒序拼接,用程序 29 的逐位剥离思路即可实现。


【程序 83】求 0~7 能组成的奇数个数

题目:求 0~7 这 8 个数字能组成多少个奇数(数字可重复使用,且一个数最高位不能是 0,但题目按 1~8 位分别统计再求和)。

解题思路

按位数分类统计(组合计数):

  • 1 位数:个位必须是奇数(1、3、5、7 四个选择)→ 4 个。
  • 2 位数:个位 4 种(奇数),十位不能为 0 且可重复 → 7 种(1~7)→ 4×7 = 28。
  • 3 位及以上:个位 4 种,最高位 7 种(不能是 0),中间位 8 种(可为 0)→ 4×7×8^(位数-2)。

递推思路:s 是当前位数的个数,从 1 位数开始,每多一位乘 7(第二位)或 8(第三位起),sum 累加。

完整代码(按组合计数递推)

#include <stdio.h>

int main()
{
    long sum = 4, s = 4;     // 1 位数:4 个(个位取奇数)
    int j;
    for (j = 2; j <= 8; j++) // 统计 2~8 位数
    {
        if (j == 2)
            s = s * 7;       // 第二位(最高位)7 种:不能为 0
        else
            s = s * 8;       // 后面的位 8 种:可以为 0
        sum += s;
        printf("%d 位数有 %ld 个\n", j, s);
    }
    printf("总共 %ld 个\n", sum);
    return 0;
}

代码讲解

  • 递推关系:s(当前位数个数)= s(上一位个数)× 新位选择数。
  • 第一位(个位)固定 4 种;第二位 7 种;第三位起 8 种。所以 2 位数 28 个、3 位数 4×7×8=224……总个数约 4 + 28 + 224 + ... 累加。

知识点

  • 计数原理(乘法原理)
  • 递推统计

数学原理

这是排列组合中的乘法原理:如果一件事分几步完成,且各步相互独立,那么总方法数 = 各步方法数相乘。注意"最高位不能为 0"这个约束,是计数题最常见的陷阱。

扩展思考

若题目改成"不能重复使用数字",则要用排列公式 $P(n,k) = \frac{n!}{(n-k)!}$ 分类讨论,更复杂。可以在学完组合数学后回头挑战。


【程序 84】验证哥德巴赫猜想(偶数 = 两素数之和)

题目:一个偶数总能表示为两个素数之和。(验证一个给定偶数)

解题思路

哥德巴赫猜想:任何一个大于 2 的偶数都可以写成两个素数之和。这是著名的数学未解之谜(至今未被完全证明,但已被计算机验证到极大的数)。

程序做法:对偶数 a,枚举可能的素数 b(从 3 开始),检查 b 和 a-b 是否都是素数(用程序 12 的试除法),是则输出一组分解。

完整代码

#include <stdio.h>
#include <math.h>

int is_prime(int x)      // 判断素数(封装成函数)
{
    int i;
    if (x < 2) return 0;
    for (i = 2; i <= sqrt(x); i++)
        if (x % i == 0)
            return 0;
    return 1;
}

int main()
{
    int a, b;
    printf("请输入一个大于 2 的偶数:");
    scanf("%d", &a);

    for (b = 3; b <= a / 2; b += 2)      // 只试奇数(素数除2外都是奇数)
    {
        if (is_prime(b) && is_prime(a - b))
            printf("%d = %d + %d\n", a, b, a - b);
    }
    return 0;
}

代码讲解

  • is_prime 是函数封装(复习函数定义),试除法判断素数(复习程序 12 的 $\sqrt{x}$ 优化)。
  • b += 2:从 3 开始只检查奇数,因为大于 2 的素数全是奇数,减半搜索量。
  • b <= a/2:只输出"前一半"的解,避免 (b, a-b) 与 (a-b, b) 重复。

知识点

  • 素数的判定(函数化)
  • 枚举分解

数学原理

哥德巴赫猜想(1742 年提出)是数论中最著名的开放问题之一:"任何大于 2 的偶数都能写成两个素数之和"。数学界已用计算机验证到 $4 \times 10^{18}$ 以内的所有偶数都成立(1978 年 Olivier Ramaré 证明了"每个充分大的偶数可表示为至多 6 个素数之和"等弱化结果),但完整证明至今未出现。程序只能"验证具体例子",无法"证明"猜想——这也是计算机与数学的边界。

扩展思考

  • 稍加改造(循环所有偶数)可以验证一大段范围内的偶数都满足猜想,这也是"计算机辅助数学实验"的入门体验。

【程序 85】判断一个素数能被几个 9 整除

题目:判断一个素数能被几个 9 整除(即求最小的 999...9 形式且能被该素数整除的数,由几个 9 组成)。

解题思路

构造 9、99、999、9999……依次检查能否被输入的素数整除。构造方法:m9 = m9 * 10 + 9(复习程序 18 的"添尾"技巧)。每构造一个,计数加 1,直到找到能被整除的。

完整代码

#include <stdio.h>

int main()
{
    long sum = 9;         // 当前构造的数:9, 99, 999...
    int zi, count = 1;
    printf("请输入一个素数:");
    scanf("%d", &zi);

    while (sum % zi != 0)
    {
        sum = sum * 10 + 9;   // 末尾再加一个 9
        count++;
    }
    printf("%ld 能被 %d 整除,由 %d 个 9 组成\n", sum, zi, count);
    return 0;
}

代码讲解

  • sum = sum * 10 + 9:9 → 99 → 999 → ……(与程序 18 生成 aa、aaa 完全同款技巧)。
  • 循环直到整除成立;用 long 防止数过大溢出。

知识点

  • 数字拼接(*10 + 数字)
  • while 循环与取余判断整除

数学原理

这类"全 9 数"(repunit 9 的变形,数学上叫 repunit,如 111...1)与数论中的"循环小数"问题相关:$\frac1p$ 的小数循环节长度往往与 $10^k \bmod p$ 有关。输入 3 得 999(3 个 9);输入 7 得 999999(6 个 9,因为 $10^6 \equiv 1 \pmod 7$)。

扩展思考

  • 大数溢出问题:若素数很大,long 不够,需要"高精度除法"(只用余数参与计算:sum = (sum * 10 + 9) % zi,判断余数是否为 0 即可,根本不用存大数!)。这是一个重要的优化技巧,值得思考为什么可行。

【程序 86】两个字符串连接程序

题目:两个字符串连接程序。

解题思路

本题做的是"按字典序归并两个字符串"(类似归并排序的合并步):比较两字符串当前字符,把较小的放入结果,直到其中一个用完,再把剩余的接上去。这比单纯拼接(strcat)更有算法价值。

完整代码

#include <stdio.h>
#include <string.h>

int main()
{
    char a[] = "acegikm";
    char b[] = "bdfhjlnpq";
    char c[80];
    int i = 0, j = 0, k = 0;

    /* 归并:每次取较小的字符 */
    while (a[i] != '\0' && b[j] != '\0')
    {
        if (a[i] < b[j])
            c[k++] = a[i++];
        else
            c[k++] = b[j++];
    }

    /* 把剩余的部分接上去 */
    while (a[i] != '\0') c[k++] = a[i++];
    while (b[j] != '\0') c[k++] = b[j++];
    c[k] = '\0';          // 手动补结束符

    printf("%s\n", c);
    return 0;
}

代码讲解

  • c[k++] = a[i++]:先赋值再自增,等价于 c[k] = a[i]; k++; i++;——这是 C 语言非常常用的紧凑写法。
  • 归并结束后,a、b 可能各自有剩余(比如一个长一个短),用两个 while 把剩余字符接上。
  • 最后必须手动写 c[k] = '\0',否则 printf("%s") 不知道在哪结束(可能输出乱码)。

知识点

  • 归并(merge)思想——归并排序的核心步骤
  • 字符串的 \0 处理
  • c[k++] = a[i++] 的惯用法

数学原理

归并:两个已排序序列合成一个排序序列,只需线性扫描(O(n+m))。这是"分治算法"(divide and conquer)的关键一步——归并排序把数组分成两半分别排序,再归并起来。可以对照程序 37 的选择排序感受"两种排序哲学"。

扩展思考(其他解法)

  • 若只是"拼接"两个字符串,用 strcat(a, b) 一行搞定(注意目标数组要够大)。本题原意是归并,比拼接更值得掌握。
  • 进阶:输入任意两个字符串而不是固定数组,把循环边界改成手动输入。

【程序 87】回答结果(结构体变量传递)

题目:读程序写结果——结构体变量作为函数参数传递。

解题思路

结构体作为函数参数是值传递:传给函数的是一份副本,函数内修改不影响函数外的原结构体。这与数组(退化为指针,函数内能改原数组)完全不同,是初学者最容易混淆的点。

完整代码

#include <stdio.h>

struct student
{
    int x;
    char c;
};

void f(struct student b)   // 值传递:b 是 a 的副本
{
    b.x = 20;
    b.c = 'y';
}

int main()
{
    struct student a;
    a.x = 3;
    a.c = 'a';
    f(a);
    printf("%d, %c\n", a.x, a.c);   // 输出 3, a(原结构体没变!)
    return 0;
}

代码讲解

  • f(a) 把 a 的内容复制一份给参数 b,函数里改的是 b,a 不受影响。
  • 所以输出是 3, a 而不是 20, y
  • 若要函数修改原结构体,必须传指针:void f(struct student *p) { p->x = 20; },调用 f(&a)

知识点

  • 结构体的值传递语义
  • 传值 vs 传指针(对比程序 66)

数学原理/类比

值传递 = "把文件复印一份给同事改,原件不动";传指针 = "告诉同事原件在哪,让他直接改原件"。C 语言里数组名是"地址"(特殊),结构体是"内容"(默认值传递),一定要分清。

扩展思考

  • 大数据量结构体传值时,复制开销大,工程上常用传指针提高效率:void f(const struct student *p)(const 表示只读不修改)。

【程序 88】读取 7 个数(1~50),打印对应个数的 *

题目:读取 7 个数(1~50 的整数),每读取一个值,程序打印出该值个数的 *

解题思路

do...while 验证输入范围(1~50),再用 for 打印对应数量的 *do...while 保证至少执行一次再判断,适合"先输入、后校验"的场景。

完整代码

#include <stdio.h>

int main()
{
    int i, a, n = 1;
    while (n <= 7)
    {
        do
        {
            printf("请输入第 %d 个数(1~50):", n);
            scanf("%d", &a);
        } while (a < 1 || a > 50);   // 输入不合法就重新输入

        for (i = 1; i <= a; i++)
            printf("*");
        printf("\n");
        n++;
    }
    return 0;
}

代码讲解

  • do { 输入 } while (输入不合法):输入校验的经典模式——"先做,不对再来"。
  • 内层 for 打印 a 个 *,正好复习"用循环画符号"。

知识点

  • do...while 循环与输入校验
  • 输入合法性检查(防呆设计)

扩展思考

  • 校验可以更友好:提示"输入错误,请重新输入"。
  • 这种"先校验再处理"的模式在真实程序中无处不在(比如银行取款金额不能为负、密码长度检查)。

【程序 89】数据加密

题目:四位整数加密:每位数字加 5,用和除以 10 的余数代替该数字,然后第一位与第四位交换、第二位与第三位交换。

解题思路

分解出四位数字存入数组 → 每位加 5 取余 → 首尾交换(程序 40 的技巧)→ 输出。整个过程就是"模运算 + 数组翻转",没有真正的高深加密,但体现了"变换"的流程化处理思想。

完整代码

#include <stdio.h>

int main()
{
    int a, i, t;
    int aa[4];
    printf("请输入一个四位整数:");
    scanf("%d", &a);

    aa[0] = a % 10;            // 个位
    aa[1] = a / 10 % 10;       // 十位
    aa[2] = a / 100 % 10;      // 百位
    aa[3] = a / 1000;          // 千位

    for (i = 0; i < 4; i++)
    {
        aa[i] = aa[i] + 5;
        aa[i] = aa[i] % 10;    // 加 5 后取个位
    }

    for (i = 0; i < 2; i++)    // 首尾交换(0↔3,1↔2)
    {
        t = aa[i];
        aa[i] = aa[3 - i];
        aa[3 - i] = t;
    }

    printf("加密后:");
    for (i = 3; i >= 0; i--)   // 按原数位顺序输出
        printf("%d", aa[i]);
    printf("\n");
    return 0;
}

代码讲解

  • 数位分解:复习程序 13、29 的 /% 技巧。
  • % 10 取个位:加 5 后可能超过 9(如 8+5=13 → 3)。
  • 交换 4 个元素只需 2 次对称交换(程序 40 的思想)。

知识点

  • 模运算取个位
  • 数组翻转
  • 简单加密变换流程

数学原理

加密本质是可逆变换。这个加密算法是可逆的吗?解密规则:交换回来 → 每个数字减 5 模 10((x + 5) % 10 的逆运算是 (x + 5) % 10 再做一次?不对——注意 (x+5)%10 的逆是 (x+5)%10 本身吗?)。可以验证:((x+5)%10 + 5)%10 = x?例如 x=3 → 8 → 3 ✓;x=7 → 2 → 7 ✓。因为"加 5 模 10"在 0~9 上是一个"对合"(两次应用回到原值,相当于每位旋转 5 格)。这个性质值得自己推敲,是理解"加密-解密配对"的好例子。

扩展思考

  • 对称加密的朴素模型:加密和解密用同一套操作(加 5 模 10 是自逆的),真实加密算法(如 AES)要复杂得多,但"变换可逆"这一核心思想是一样的。

【程序 90】读结果:数组逆置

题目:读程序写结果——用指针逆置数组。

解题思路

用两个下标/指针从两端向中间走,交换对应元素(程序 40 的升级版:用指针而不是下标)。i 从头走,j 从尾走,while (i < j) 时交换并各自向中间移动。

完整代码

#include <stdio.h>

#define M 5

int main()
{
    int a[M] = {1, 2, 3, 4, 5};
    int i = 0, j = M - 1, t;

    while (i < j)
    {
        t = a[i];
        a[i] = a[j];
        a[j] = t;
        i++;
        j--;
    }

    for (i = 0; i < M; i++)
        printf("%d ", a[i]);    // 输出 5 4 3 2 1
    printf("\n");
    return 0;
}

代码讲解

  • 指针版:int *p = a, *q = a + M - 1; while (p < q) { 交换 *p、*q; p++; q--; },与下标版完全等价。
  • 循环条件 i < j:i、j 相遇(奇数个元素时中间元素不用动)就停。

知识点

  • 双指针(从两端向中间)的经典模式
  • 数组逆置

扩展思考

"双指针相遇"模式非常常用:字符串反转(程序 70 扩展)、判断回文、二分查找(两个边界指针)都用它。这是算法基本功,务必熟练掌握。


【程序 91~93】时间函数举例

题目:时间函数举例(timectimeasctimelocaltimegmtimeclockdifftime)。

解题思路

三个题都在演示 <time.h> 的时间函数:

  • time(NULL):返回当前时间(从 1970 年 1 月 1 日 0 时起的秒数,即 Unix 时间戳)。
  • ctime():把时间戳转成易读的字符串。
  • localtime() / gmtime():转成 tm 结构(本地时间 / 格林尼治时间)。
  • clock():返回程序运行以来消耗的 CPU 时钟周期数clock() / CLOCKS_PER_SEC 得到秒数,用于测量程序耗时。
  • difftime(end, start):两个时间戳的差值(秒)。

完整代码(程序 91:显示当前时间)

#include <stdio.h>
#include <time.h>

int main()
{
    time_t lt;                        // 时间戳类型
    lt = time(NULL);                  // 取当前时间戳
    printf("当前时间:%s", ctime(&lt));   // 转成字符串
    return 0;
}

程序 93:测量代码耗时(推荐写法)

#include <stdio.h>
#include <time.h>

int main()
{
    clock_t start, end;
    int i;
    double seconds;

    start = clock();                  // 开始计时
    for (i = 0; i < 1000000; i++)
        ;                             // 空循环占时间
    end = clock();                    // 结束计时

    seconds = (double)(end - start) / CLOCKS_PER_SEC;   // 换算成秒
    printf("耗时 %.3f 秒\n", seconds);
    return 0;
}

代码讲解

  • time_t 是时间戳类型(通常是 long)。
  • ctime(&lt) 需要传指针;clock() 返回 clock_t,除以 CLOCKS_PER_SEC(每秒的时钟数)才是秒。
  • 测量代码耗时用 clock() 更准确(只算 CPU 时间);time() 的粒度是秒,太粗糙。

知识点

  • <time.h> 时间库函数
  • Unix 时间戳的概念
  • 用 clock 测量程序运行时间(性能分析入门)

数学原理

Unix 时间戳是"自 1970-01-01 00:00:00 UTC 起的秒数",全世界统一,是计算机系统的"标准时钟"。2038 年问题:32 位时间戳将在 2038 年 1 月 19 日溢出(最大值 2³¹-1 秒),现代系统已用 64 位时间戳解决——这是数据溢出问题的真实案例。

扩展思考

程序 94 的猜数游戏就用 clock() 计时测"反应速度",可以把两题连着学。


【程序 94】猜数游戏(综合应用)

题目:一个猜数游戏,判断一个人反应快慢:程序生成一个 0~99 的随机数,让你猜,提示"大了/小了",猜中后报告用了多少时间,并评价反应快慢。

解题思路

综合运用:

  1. 随机数srand(time(NULL)) 用当前时间做随机种子,rand() % 100 生成 0~99。
  2. 循环猜数while (guess != i),大了提示小一点、小了提示大一点。
  3. 计时clock() 前后各取一次,差除以 CLOCKS_PER_SEC 得秒数。
  4. 评价:if 判断耗时区间。

完整代码

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main()
{
    int guess, i;
    clock_t start, end;
    double seconds;
    char again = 'y';

    srand(time(NULL));          // 随机种子

    while (again == 'y')
    {
        i = rand() % 100;       // 0~99 的随机数
        printf("请猜一个 0~99 的数:");
        start = clock();        // 开始计时

        scanf("%d", &guess);
        while (guess != i)
        {
            if (guess > i)
                printf("太大了,再猜:");
            else
                printf("太小了,再猜:");
            scanf("%d", &guess);
        }

        end = clock();          // 猜中,停止计时
        seconds = (double)(end - start) / CLOCKS_PER_SEC;
        printf("猜对了!答案是 %d,用时 %.2f 秒\n", i, seconds);

        if (seconds < 5)
            printf("反应真快!\n");
        else if (seconds < 15)
            printf("还不错。\n");
        else
            printf("反应有点慢哦。\n");

        printf("再玩一次?(y/n):");
        getchar();              // 吃掉残留的换行符
        scanf("%c", &again);
    }
    return 0;
}

代码讲解

  • rand() 生成的数其实"有规律",所以先用 srand(time(NULL)) 按当前时间打乱起点,每次运行得到的序列不同。
  • % 100 把随机数限定在 0~99(取余限制范围)。
  • getchar() 吃掉上次 scanf 留在缓冲区的换行符(复习程序 31 的坑)。
  • 外层 while (again == 'y') 支持反复玩。

知识点

  • 随机数生成(randsrand、取余限范围)
  • 综合运用:循环、分支、计时、输入校验
  • 输入缓冲问题

数学原理

rand() % 100 的均匀性:如果 rand() 返回 0~RAND_MAX 均匀分布,取余后 0~99 近似均匀(RAND_MAX 通常 32767,不是 100 的倍数,会有轻微偏差,但对游戏无影响)。真正的加密级随机数(如 /dev/urandom)要复杂得多,这就是"伪随机 vs 真随机"的区别。

扩展思考

  • 二分查找策略:每次猜中间数(50 → 25/75 → ...),最多 7 次必然猜中(因为 $2^7 = 128 > 100$)。这个"折半"思想就是二分查找算法(在有序数据中查找的 O(log n) 方法),值得去了解。
  • 想提高难度:限定猜测次数、或把范围扩大。

第十部分:进阶挑战(程序 95 ~ 100)

前面的题目帮你打牢了基础。这一部分难度有所提升,涉及分治、回溯、栈、双指针等更经典的算法思想,是通往算法世界的敲门砖。建议每道题都先自己思考 10~20 分钟再参考答案。


【程序 95】二分查找

题目:在一个已经按升序排好的整数数组中,用二分查找判断某个数是否存在,若存在返回它的下标,否则返回 -1。

解题思路

二分查找是"折半"思想的正式化:既然数组有序,每次取中间元素和目标比较——

  • 相等:找到了,返回下标;
  • 目标比中间元素大:答案只可能在后半段,把左边界移到中间元素右边;
  • 目标比中间元素小:答案只可能在前半段,把右边界移到中间元素左边。

每比较一次搜索范围缩小一半,所以 n 个元素最多比较 $\log_2 n$ 次(这就是 $O(\log n)$ 时间复杂度的由来)。对比程序 37 的线性查找(挨个找,最坏 n 次),二分查找快得多——前提是数组必须有序

完整代码

#include <stdio.h>

/* 二分查找:返回 target 的下标,找不到返回 -1 */
int binary_search(int a[], int n, int target)
{
    int left = 0, right = n - 1;
    while (left <= right)              // 区间非空就一直找
    {
        int mid = left + (right - left) / 2;   // 防溢出的中间值写法
        if (a[mid] == target)
            return mid;
        else if (a[mid] < target)
            left = mid + 1;            // 目标在右半段
        else
            right = mid - 1;           // 目标在左半段
    }
    return -1;
}

int main()
{
    int a[10] = {1, 3, 5, 7, 9, 11, 13, 15, 17, 19};
    int x;
    printf("请输入要查找的数:");
    scanf("%d", &x);

    int pos = binary_search(a, 10, x);
    if (pos >= 0)
        printf("找到了,下标是 %d\n", pos);
    else
        printf("不存在\n");
    return 0;
}

代码讲解

  • 三个变量维护"当前搜索区间":leftright 是区间的左右边界(闭区间),循环条件是 left <= right(区间非空)。
  • mid = left + (right - left) / 2:求中间下标。写 (left + right) / 2 在理论上可能溢出(left+right 超过 int 上限),这种写法更安全,是工程上的标准姿势。
  • 每次更新边界时排除掉 mid 本身mid + 1 / mid - 1),因为 mid 已经比较过了。

知识点

  • 二分查找算法(O(log n))
  • 闭区间三变量(left / right / mid)的边界维护
  • 有序性是二分查找的前提

数学原理

为什么是 $\log_2 n$?每轮把规模为 n 的问题变成规模为 n/2 的子问题,k 轮后规模是 $n / 2^k$,当 $n / 2^k < 1$ 即 $k > \log_2 n$ 时结束。所以最多约 $\log_2 n$ 次比较。对 10 亿个元素,线性查找最坏 10 亿次,二分查找只需 30 次——这就是算法的力量。

扩展思考(其他解法)

  • 递归实现:binary_search(a, left, right, target),终止条件是 left > right 或命中。代码更简洁,但每次递归有函数调用开销。
  • 二分查找的变体很多:找"第一个 ≥ target 的位置"、找"最后一个 ≤ target 的位置"(C 标准库的 lower_bound/upper_bound 就是干这个的)。可以试着实现。

【程序 96】快速排序

题目:用快速排序算法对一个整数数组进行排序。

解题思路

快速排序(Quick Sort) 是"分治"思想的代表:

  1. 选基准(pivot):从数组中选一个元素(通常取第一个或中间一个)。
  2. 分区(partition):把数组重排成"左半部分都 ≤ pivot,右半部分都 > pivot",pivot 落到最终位置。
  3. 递归:对左右两半分别重复上述过程。

每轮分区后 pivot 就位,左右两部分互不相干,递归处理即可。关键是分区这一步:用一个指针记录"小于 pivot 的区域的边界",遍历时遇到比 pivot 小的就换到前面去。

完整代码

#include <stdio.h>

/* 分区:把 a[left..right] 以 a[right] 为基准分成两半,返回基准的最终下标 */
int partition(int a[], int left, int right)
{
    int pivot = a[right];          // 取最右元素为基准
    int i = left;                  // i 指向"小于基准区域"的下一个位置
    int j, t;

    for (j = left; j < right; j++)
    {
        if (a[j] < pivot)          // 比基准小的,换到左边区域
        {
            t = a[i]; a[i] = a[j]; a[j] = t;
            i++;
        }
    }
    /* 把基准放到 i 位置(此时 i 左边全小于 pivot) */
    t = a[i]; a[i] = a[right]; a[right] = t;
    return i;
}

void quick_sort(int a[], int left, int right)
{
    if (left >= right)             // 区间为空或只有一个元素,不用排
        return;
    int p = partition(a, left, right);
    quick_sort(a, left, p - 1);    // 排序左半
    quick_sort(a, p + 1, right);   // 排序右半
}

int main()
{
    int a[10] = {34, 7, 23, 32, 5, 62, 31, 1, 9, 12};
    int i;
    quick_sort(a, 0, 9);
    printf("排序后:");
    for (i = 0; i < 10; i++)
        printf("%d ", a[i]);
    printf("\n");
    return 0;
}

代码讲解

  • partition 用两个指针扫描:j 负责遍历,i 标记"比 pivot 小的区域"的末尾。发现 a[j] < pivot 就把 a[j] 换到 i 位置,i 后移。
  • 遍历结束后,把基准 a[right] 换到 a[i],基准就位——它的左边都比它小,右边都比它大。
  • 递归边界:left >= right(空区间或单元素)直接返回,这是递归终止条件

知识点

  • 分治算法(divide and conquer)
  • 快速排序与分区操作
  • 递归排序

数学原理

快速排序的平均时间复杂度是 $O(n \log n)$(每层分区 O(n),递归深度约 $\log n$ 层),是实践中最常用的排序算法之一。最坏情况(每次都选到最大/最小元素做基准)退化为 $O(n^2)$——可以通过随机选基准来规避。这与程序 37 的选择排序(固定 $O(n^2)$)、程序 86 铺垫的归并排序(稳定 $O(n \log n)$)形成对比:没有完美的排序,只有适合场景的排序

扩展思考(其他解法)

  • 程序 37 的选择排序、扩展里提到的冒泡排序都是 $O(n^2)$;程序 86 的"归并"思想可以扩展成归并排序(稳定、$O(n \log n)$,但需要额外空间)。建议把三种排序都写一遍对比。
  • 面试常考:快速排序的"第 k 大元素"变体——每次分区后根据 pivot 的下标判断目标在哪半部分,只递归一边,平均 $O(n)$。

【程序 97】汉诺塔

题目:有三根柱子 A、B、C,A 柱上从上到下叠着 n 个大小递增的圆盘(小的在上)。每次只能移动一个圆盘,且任何时候大盘都不能压在小盘上。请把 n 个圆盘从 A 柱全部移到 C 柱,并输出每一步的移动方案。

解题思路

这是递归的"封神"例题。把问题分解:

  1. 先把 n-1 个圆盘从 A 借助 C 移到 B(此时 A 上只剩最大的盘);
  2. 最大的盘从 A 直接移到 C;
  3. 再把 n-1 个圆盘从 B 借助 A 移到 C。

"借助"的柱子就是中转站。整个过程规模从 n 变成 n-1,触底条件是 n == 1(直接移动)。递归的美妙之处在于:我们不需要关心"怎么具体移动 n-1 个盘",只假设这个子问题已经能解决——这正是数学归纳法的思路。

完整代码

#include <stdio.h>

void hanoi(int n, char from, char helper, char to)
{
    if (n == 1)                          // 只剩一个盘,直接移动
    {
        printf("把第 1 个盘从 %c 移到 %c\n", from, to);
        return;
    }
    hanoi(n - 1, from, to, helper);      // 1. n-1 个盘:A → B(借助 C)
    printf("把第 %d 个盘从 %c 移到 %c\n", n, from, to);   // 2. 最大盘:A → C
    hanoi(n - 1, helper, from, to);      // 3. n-1 个盘:B → C(借助 A)
}

int main()
{
    int n;
    printf("请输入圆盘数:");
    scanf("%d", &n);
    hanoi(n, 'A', 'B', 'C');
    return 0;
}

代码讲解

  • 三个柱子角色动态变化:from(来源)、to(目标)、helper(辅助)。第 1 步里 C 变成"辅助",第 3 步里 A 变成"辅助"。
  • 移动次数满足递推 $T(n) = 2T(n-1) + 1$,解得 $T(n) = 2^n - 1$。n=3 时需要 7 步,n=10 需要 1023 步,n=64 需要约 1844 亿步——这就是"指数爆炸"。
  • 每一步的移动顺序:先移小盘堆,再移大盘,再移小盘堆——保证"大盘不压小盘"由递归结构天然保证。

知识点

  • 递归的经典应用(规模分解 + 终止条件)
  • 递归与数学归纳法的对应
  • 指数增长

数学原理

汉诺塔与很多数学分支相关:移动次数 $2^n - 1$ 是指数函数;它还可以用二进制解释(第 k 步移动的盘子编号恰好是 k 的二进制中最低位 1 的位置)。传说中"世界末日"与 64 层汉诺塔相关,按每秒移一次需 5845 亿年——比宇宙年龄还长,这体现了"指数爆炸"的可怕。

扩展思考

  • 试着画出 n=3 时的递归调用树,理解"递推进入、回归返回"的全过程。
  • 非递归实现:可以借助"奇偶性规律"(奇数个盘时第一步移 A→C,偶数个盘时第一步移 A→B),有兴趣可以研究。

【程序 98】最长回文子串

题目:给定一个字符串,找出其中最长的回文子串(回文即"正读倒读一样")。例如输入 babad,最长回文子串是 bab(或 aba)。

解题思路

中心扩展法:回文串有一个特性——它关于中心对称。所以可以以每个位置为中心,向两边扩展,记录能扩展出的最长回文长度。

但回文中心有两种情况:

  1. 奇数长度:中心是一个字符,如 aba 的中心是 b
  2. 偶数长度:中心在两个字符之间,如 abba 的中心是 bb 之间。

所以要对每个位置 i 分别以"i 为中心"和"(i, i+1) 之间为中心"扩展两次,取较长者。总复杂度 $O(n^2)$。

完整代码

#include <stdio.h>
#include <string.h>

/* 从 (l, r) 向两边扩展,返回能形成的最大回文长度 */
int expand(char s[], int l, int r)
{
    while (l >= 0 && s[r] != '\0' && s[l] == s[r])
    {
        l--;
        r++;
    }
    return r - l - 1;          // 回文的长度
}

int main()
{
    char s[100];
    int i, len, maxLen = 0, start = 0;

    printf("请输入字符串:");
    scanf("%s", s);
    len = strlen(s);

    for (i = 0; i < len; i++)
    {
        int odd  = expand(s, i, i);       // 奇数长度回文:中心是 s[i]
        int even = expand(s, i, i + 1);   // 偶数长度回文:中心在 s[i] 与 s[i+1] 之间
        int cur = odd > even ? odd : even;

        if (cur > maxLen)                 // 更新最长的
        {
            maxLen = cur;
            start = i - (cur - 1) / 2;    // 由长度反推回文起点
        }
    }

    printf("最长回文子串:");
    for (i = start; i < start + maxLen; i++)
        printf("%c", s[i]);
    printf("(长度 %d)\n", maxLen);
    return 0;
}

代码讲解

  • expand(s, l, r):从 (l, r) 这对中心向外扩展,s[l] == s[r] 就继续;循环结束时 r - l - 1 就是回文长度(可以自己代入 aba 验证:扩展后 l=-1、r=3,长度 3)。
  • 每个位置尝试两种中心;strlen 来自 <string.h>
  • 记录最长回文的起点下标 start长度 maxLen,最后切片输出。
  • 边界处理:l >= 0 防止数组越界到负数;s[r] != '\0' 防止越过字符串结尾。

知识点

  • 回文判断与对称性
  • 中心扩展法(O(n²))
  • 字符串边界处理

数学原理

回文串是"镜像对称"的字符串。中心扩展法把"判断任意子串是否回文"优化为"从每个中心向外看能看多远"。更快的算法是 Manacher 算法(O(n)),它利用回文的对称性避免重复比较,是算法竞赛的经典内容,学有余力可以研究。

扩展思考(其他解法)

  • 暴力法:枚举所有子串(O(n²) 个子串)再逐个判断是否回文,总复杂度 O(n³),正确但慢——正好用来体会中心扩展的优越性。
  • 程序 30 判断"一个数是否回文"是本题的退化版(只有一个中心)。

【程序 99】逆波兰表达式求值(栈的应用)

题目:输入一个后缀表达式(逆波兰表达式),例如 3 4 + 5 *(即 (3+4)×5),计算它的值。只考虑 + - * / 四种运算。

解题思路

是一种"后进先出(LIFO)"的数据结构,是逆波兰求值天然的工具:

  1. 从左到右扫描每个元素(数字或运算符);
  2. 遇到数字就压入栈;
  3. 遇到运算符就弹出栈顶两个数,做运算后把结果压回栈;
  4. 扫描完,栈顶就是最终结果。

为什么这样是对的?后缀表达式中运算符跟在操作数之后,所以"最近的两个数"就是它的操作数——正好是栈顶的两个,完美匹配"后进先出"。

完整代码

#include <stdio.h>

#define MAX 100

int main()
{
    int stack[MAX];          // 用数组模拟栈
    int top = 0;             // 栈顶指针:下一个入栈位置
    char token;
    int a, b;

    printf("请输入后缀表达式(数字与运算符之间用空格隔开,以 # 结束):\n");

    do
    {
        scanf("%c", &token);       // 读一个字符
        if (token >= '0' && token <= '9')
        {
            stack[top++] = token - '0';   // 数字(个位)压栈
        }
        else if (token == '+')
        {
            b = stack[--top];      // 先弹出的数是右操作数
            a = stack[--top];
            stack[top++] = a + b;
        }
        else if (token == '-')
        {
            b = stack[--top];
            a = stack[--top];
            stack[top++] = a - b;  // 注意顺序:a - b
        }
        else if (token == '*')
        {
            b = stack[--top];
            a = stack[--top];
            stack[top++] = a * b;
        }
        else if (token == '/')
        {
            b = stack[--top];
            a = stack[--top];
            stack[top++] = a / b;  // 注意顺序:a / b
        }
    } while (token != '#');

    printf("结果 = %d\n", stack[top - 1]);
    return 0;
}

代码讲解

  • 用数组 stack + 栈顶指针 top 模拟栈:stack[top++] = x 是压栈,x = stack[--top] 是弹栈。
  • 减法和除法的操作数顺序很重要:先弹出的是右操作数 b,后弹出的是左操作数 a,所以是 a - ba / b,写反了结果就错了。
  • 示例:3 4 + 5 * → 压 3 → 压 4 → 弹 4、3 得 7 压回 → 压 5 → 弹 5、7 得 35。
  • 本示例只支持个位数字;要支持多位数,可以按"读入空格分隔的字符串再逐个转换",思路相同。

知识点

  • 栈(后进先出)的概念与数组模拟
  • 逆波兰表达式(后缀表达式)求值
  • 运算符优先级的中缀转后缀(进阶)

数学原理

逆波兰表达式(由波兰逻辑学家 Jan Łukasiewicz 提出)的最大优点:不需要括号和优先级规则——运算符顺序天然决定了计算次序,非常适合计算机处理。编译器把人类书写的中缀表达式(如 a + b * c)先转成后缀(a b c * +),再求值。你常用的计算器、公式编辑器背后都是这套机制。

扩展思考

  • 自己实现"中缀转后缀"(需要处理运算符优先级和括号,也用栈),就能写一个完整的表达式计算器。
  • 栈还能用来做:括号匹配检查、函数调用栈模拟、迷宫寻路(回溯)。栈是数据结构的第一课,务必熟练掌握。

【程序 100】八皇后问题(回溯算法)

题目:在国际象棋棋盘(8×8)上放置 8 个皇后,要求任意两个皇后不能在同一行、同一列或同一对角线上。输出所有可行的摆放方案。

解题思路

八皇后回溯算法(backtracking) 的经典例题。核心思路:

  1. 逐行放置:每一行放一个皇后,用一维数组 col[i] 记录第 i 行皇后所在的列号。
  2. 约束检查:放第 i 行的皇后到第 j 列时,检查它与前面 i 行的皇后是否冲突:
    • 同列:col[k] == j
    • 主对角线:行差 == 列差,即 i - k == j - col[k]
    • 副对角线:行差 == -(列差),即 i - k == col[k] - j
  3. 回溯:如果第 i 行所有列都试过都冲突,就回退到上一行,把上一行的皇后换一列再试——这就是"回溯"(走不通就回头)。

完整代码

#include <stdio.h>

int col[8];          // col[i]:第 i 行皇后的列号
int total = 0;       // 方案总数

/* 检查第 row 行放在第 c 列是否与前面的皇后冲突 */
int is_safe(int row, int c)
{
    int k;
    for (k = 0; k < row; k++)
    {
        if (col[k] == c)                    // 同一列
            return 0;
        if (row - k == c - col[k])          // 主对角线(\)
            return 0;
        if (row - k == col[k] - c)          // 副对角线(/)
            return 0;
    }
    return 1;
}

void queen(int row)
{
    int c;
    if (row == 8)                    // 8 行都放好了,得到一种方案
    {
        int i;
        for (i = 0; i < 8; i++)
            printf("(%d,%d) ", i, col[i]);   // 输出每行皇后位置
        printf("\n");
        total++;
        return;
    }

    for (c = 0; c < 8; c++)          // 尝试第 row 行的每一列
    {
        if (is_safe(row, c))
        {
            col[row] = c;            // 放置
            queen(row + 1);          // 递归放下一行
            /* 回溯:col[row] 会在下一轮循环被覆盖,无需显式撤销 */
        }
    }
}

int main()
{
    queen(0);                        // 从第 0 行开始
    printf("共有 %d 种方案\n", total);
    return 0;
}

代码讲解

  • 递归函数 queen(row) 的语义:把第 row 行到最后一行的皇后安排好row == 8 表示全部完成,输出方案。
  • for (c = 0; c < 8; c++) 尝试当前行的每一列:安全就放,然后递归下一行。如果递归返回(说明后面走不通),自动尝试下一列——这正是回溯:尝试 → 深入 → 走不通就换一种尝试
  • 三个冲突判断(同列、两条对角线)合起来等价于"这 8 个皇后两两不在一条直线或斜线上"。
  • 8 皇后共有 92 种方案(不考虑旋转/镜像对称的 12 种本质解)。

知识点

  • 回溯算法(试探 + 回退)
  • 递归搜索 + 约束剪枝
  • 对角线的数学特征(行差与列差的关系)

数学原理

为什么对角线检查用 row - k == c - col[k]?设两个皇后在 (row, c) 和 (k, col[k])。它们在同一主对角线(\)上当且仅当"行差 == 列差"(即 $|row-k| = |c-col[k]|$ 且符号相同);副对角线(/)上当且仅当"行差 == -(列差)"。这正是斜率等于 ±1 的直线方程——棋盘上的对角线就是斜率 ±1 的直线,数学和编程在这里完美相遇。

扩展思考

  • N 皇后推广:把 8 改成任意 n(改数组大小和循环上界),n 皇后问题随之而来。n 增大时方案数爆炸式增长(n=8 有 92 种,n=12 有 14200 种),这正是"组合爆炸"。
  • 回溯还可以解:数独、迷宫寻路、全排列生成、图的着色问题。回溯是算法竞赛的入门必修,学完八皇后,你就跨进了"搜索算法"的大门。

结语:学习建议与进阶路线

恭喜你刷完了这 100 道题!到这里,你应该已经掌握了:

  • 基础语法:变量、数据类型、运算符、输入输出(程序 1~10)
  • 控制流:if/switch、for/while/do-while(贯穿全程)
  • 数学算法:素数、水仙花数、质因数分解、最大公约数、进制转换、斐波那契、杨辉三角(程序 11~20、30、36、61 等)
  • 数组:一维、二维数组,排序(选择/冒泡)、插入(程序 37~40)
  • 递归:阶乘、逆序、年龄问题、汉诺塔(程序 26~28、97)
  • 预处理与位运算:宏、条件编译、&、|、^、~、移位(程序 41~55)
  • 指针:指针传参、指针遍历数组、函数指针、二级指针(程序 66~70、76~77)
  • 结构体与链表:结构体数组、链表创建/遍历/拼接/反转(程序 71~74、78)
  • 字符串处理:strcmp、strcpy、字符串长度、归并、最长回文(程序 70、79、82、86、98)
  • 综合应用:约瑟夫环、哥德巴赫猜想、加密、猜数游戏(程序 69、84、89、94)
  • 进阶算法:二分查找、快速排序、逆波兰求值(栈)、八皇后(回溯)(程序 95~96、99~100)

接下来可以做什么?

  1. 自己动手重写:合上文档,把每道题独立写一遍,直到不需要看答案。这是最重要的"内化"步骤。
  2. 挑战改进:给题目增加输入校验、改成任意 n、把代码用函数重构、加注释。
  3. 深入数据结构:栈、队列、二叉树、哈希Hash表——链表学的"节点 + 指针"模式、程序 99 的栈都是这一切的基础。
  4. 学习更多算法:归并排序、广度优先搜索(BFS)、动态规划、贪心——程序 96 的分治、98 的中心扩展、100 的回溯已经为你铺好了路。
  5. 接触工程化:多文件编译、makefile、版本控制(Git)、调试器(gdb/VS Code 调试)。
  6. 更进一步:如果对 C 感兴趣,可以继续学习指针的高级用法、动态内存管理、文件操作(FILE、fopen/fclose)、结构体对齐等。

学习编程没有捷径,唯一的捷径就是多写、多错、多改。报错不可怕,可怕的是不读报错信息。祝学习顺利!


本文档共收录 100 道经典 C 语言题目,代码均采用现代 C 标准编写,力求讲解清晰、独立可读。程序 32~35 涉及终端颜色与光标控制,程序 56~65 为图形绘制题,其余题目均不依赖任何图形库。

标签: none

添加新评论

  • 上一篇: 测试
  • 下一篇: 没有了