跳转至

第三章 · 结构化程序设计

本页最后更新于 ,内容可能过时

《C语言程序设计(第3版)》第 3 章课后习题,共 24 题。

1 · 统计 0~9 各数字出现次数

编写一个程序,输入一个整数,输出 0~9 各个数字在该整数中出现的次数。

答案
#include <stdio.h>

int main(void)
{
    int n, d, cnt[10] = {0};

    printf("请输入一个整数: ");
    if (scanf("%d", &n) != 1)
        return 0;

    if (n < 0)
        n = -n;
    if (n == 0)
        cnt[0] = 1;

    while (n > 0) {
        cnt[n % 10]++;
        n /= 10;
    }

    for (d = 0; d < 10; d++)
        printf("数字 %d 出现 %d 次\n", d, cnt[d]);
    return 0;
}
解析

思路:开一个长度为 10 的数组当计数器,下标就是数字本身。n % 10 取出最低位、n /= 10 把它丢掉,循环到 n 变成 0 为止。

关键点:

  1. 下标即数字。cnt[n % 10]++ 一行就完成了"取位 + 计数",不用 switch 分十种情况写。
  2. 负号要先处理掉,否则 -1007 会一直被 % 10 取到负的下标。题干说的是"输入一个整数",没有限定正数,这一步必须加。
  3. n 本身就是 0 时循环一次都不执行,0 这个数字要单独记一次。
  4. 数组要写初值 {0}。函数里定义的局部数组不会自动清零,漏了初值计数器里就是随机值。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 1220340,输出 0 出现 2 次、1 出现 1 次、2 出现 2 次、3 出现 1 次、4 出现 1 次,5 到 9 各 0 次;输入 -1007 得 0 两次、1 一次、7 一次;输入 0 得 0 出现 1 次、其余全 0。

易错:

  1. 数组没写初值
  2. 忘了负号,n % 10 取出负下标
  3. 输入 0 时输出全 0(循环一次没跑)

2 · 英文字符与 ASCII 码值表

编写一个程序,输出所有英文字符及它们的 ASCII 码值,其中代码值分别用八进制形式、十六进制形式和十进制形式输出。

答案
#include <stdio.h>

int main(void)
{
    char c;

    for (c = 'A'; c <= 'Z'; c++)
        printf("%c  %o  %x  %d\n", c, c, c, c);
    for (c = 'a'; c <= 'z'; c++)
        printf("%c  %o  %x  %d\n", c, c, c, c);
    return 0;
}
解析

思路:英文字符就是 26 个大写加 26 个小写,两段连着的区间;每段一个循环,循环变量直接当字符用。

关键点:

  1. char 参与运算时会自动提升成 int,所以同一个 c 可以喂给 %c、%o、%x、%d 四个转换说明符,实参写四遍 c 就行。
  2. 大写区间是 'A'~'Z'(65~90),小写区间是 'a'~'z'(97~122)。'A' 到 'Z' 在 ASCII 里是连续的,所以 for (c = 'A'; c <= 'Z'; c++) 成立。
  3. 不要用 for (c = 65; c <= 90; c++) 这种码值循环。写 'A'、'Z' 一眼能看出是字符,而且换字符集也还对。
  4. 用 %o 打八进制,%x 打十六进制(小写字母)。十六进制里 10~15 会打成 a~f。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行共 52 行,每行格式为"字符 + 两个空格 + 八进制 + 两个空格 + 十六进制 + 两个空格 + 十进制"。首行 A 101 41 65,大写段末行 Z 132 5a 90,小写段首行 a 141 61 97,末行 z 172 7a 122。

易错:

  1. 只输出大写或只输出小写(题干说"所有英文字符",52 个)
  2. 十六进制用了 %X,打出来是大写 5A(本题没说大小写,两种都对,但要和格式串一致)
  3. 把 %d 当成"另一种写法"而漏掉真正的十进制输出

3 · 两种循环执行后的变量值

设有整型变量 x 和 y 的值分别为 5 和 110。试指出执行完以下循环语句后,变量 x 和 y 的值分别是多少?

(1)while(x<=y)x*=2;

(2)do{x=y/x;y=y-x;}while(y>=1);

答案
序号 答案
(1) x = 160,y = 110
(2) x = 18,y = 0
解析

两小题都从 x = 5、y = 110 出发,区别只在循环的形态。

逐题推演:

  • (1)while (x <= y) x *= 2;:循环体只有一条自乘语句,y 从头到尾没被碰过,所以答案里 y 一直是 110。x 的取值依次是 5、10、20、40、80、160;到 160 时 160 <= 110 不成立,循环退出。共执行 5 次。
  • (2)do { x = y / x; y = y - x; } while (y >= 1);:do-while 的循环体至少执行一次,而且循环体里两句是有先后的——先算出新的 x,再拿这个新的 x 去更新 y。y / x 是整型除法,除不尽的小数部分直接丢掉。逐轮如下:
轮次 进入本轮时的 x、y x = y / x 之后 y = y - x 之后 y >= 1
1 5, 110 22 88 真
2 22, 88 4 84 真
3 4, 84 21 63 真
4 21, 63 3 60 真
5 3, 60 20 40 真
6 20, 40 2 38 真
7 2, 38 19 19 真
8 19, 19 1 18 真
9 1, 18 18 0 假

第 9 轮结束时 y 变成 0,0 >= 1 为假,循环退出,此时 x = 18。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):两段代码各跑一遍,输出 (1) x=160 y=110 和 (2) x=18 y=0,与上表逐轮推出的一致。

第(2)小题里 x 的赋值顺序不能调换

循环体写成 y = y - x; x = y / x; 就是另一回事了:那样每轮先用旧的 x 减掉、再用新的 y 去除,答案会变成 x = 1、y = 0。题干给的顺序是 x = y / x 在前,y = y - x 在后,y 用的是刚更新过的 x。

易错:

  1. (2)用 while 去读,漏掉"循环体至少执行一次",少数一轮
  2. (2)里把 y - x 的 x 当成每轮开头那个值(它已经是 y / x 算完的新值)
  3. (1)把 x *= 2 的执行次数数成 6 次(160 那一次进不去循环体了)

4 · 求小于 999 的水仙花数

水仙花数是一个 n(n≥3)位数字的数,它等于每个数字的 n 次幂之和。

例如,153 是一个水仙花数,153=1³+5³+3³。试编写一个程序求小于 999 的所有水仙花数。

答案
#include <stdio.h>

int main(void)
{
    int n, a, b, c;

    for (n = 100; n < 999; n++) {
        a = n / 100;
        b = n / 10 % 10;
        c = n % 10;
        if (a * a * a + b * b * b + c * c * c == n)
            printf("%d = %d^3 + %d^3 + %d^3\n", n, a, b, c);
    }
    return 0;
}
解析

思路:n 至少 3 位、又要小于 999,那就只可能是三位数,枚举范围直接取 100~998。三位数拆位用整除和取余两下就能拿到,不用数组、不用循环。

关键点:

  1. 拆位:n / 100 是百位,n / 10 % 10 是十位,n % 10 是个位。先除以 10 把个位丢掉,再对 10 取余,剩下的就是十位。
  2. 按题干,n 就是位数。这题里 n 恒为 3,所以每个数字都是三次幂。如果题目改成"求 1000 以内的水仙花数",四位数要用四次幂,位数得现算,那时才需要另想办法求位数。
  3. 循环上界写 n < 999,对应题干"小于 999"。写成 n <= 999 也不会多打出什么(999 不满足条件),但那是碰巧,口径该跟题干对齐。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行,打出的四位是 153、370、371、407,分别对应 153 = 1^3 + 5^3 + 3^3、370 = 3^3 + 7^3 + 0^3、371 = 3^3 + 7^3 + 1^3、407 = 4^3 + 0^3 + 7^3。

易错:

  1. 循环从 1 开始(1 位、2 位数不满足题干的 n≥3,1 = 1³、153 之外的两位数都凑不出来,但范围本身就错了)
  2. 十位写成 n % 100 / 10 之外的样式记混,或直接写 n / 10(那拿到的是前两位)
  3. 三段幂和忘了乘第三下,写成 a * a + b * b + c * c

5 · 百鸡问题

编写程序解百鸡问题:鸡翁一,值钱五;鸡母一,值钱三;鸡雏三,值钱一。百钱买百鸡。问鸡翁、鸡母和鸡雏各几何?

答案
#include <stdio.h>

int main(void)
{
    int w, m, c;

    for (w = 0; w <= 20; w++)
        for (m = 0; m <= 33; m++) {
            c = 100 - w - m;
            if (c % 3 == 0 && 5 * w + 3 * m + c / 3 == 100)
                printf("鸡翁 %2d 只,鸡母 %2d 只,鸡雏 %2d 只\n", w, m, c);
        }
    return 0;
}
解析

思路:三个未知量只有两个方程,解不唯一,只能枚举。把"一共 100 只"这个条件用来消掉一个未知量,循环就从三层降到两层。

关键点:

  1. 鸡雏的只数由 c = 100 - w - m 直接减出来,不用为它再套一层循环。
  2. 枚举范围按钱数卡:鸡翁最多 20 只(20×5 = 100),鸡母最多 33 只(33×3 = 99),鸡雏的只数由前两者决定。
  3. 鸡雏三只一钱,所以 c 必须是 3 的倍数,条件里先写 c % 3 == 0。漏掉这一条,c / 3 的整型除法会把零头丢掉,凑出钱数其实不对的解。
  4. 钱数判据写成整数等式 5 * w + 3 * m + c / 3 == 100。不要写成 == 100.0 那种浮点比较,整数解本来就能精确判。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行,共打出四组解:鸡翁 0、鸡母 25、鸡雏 75;鸡翁 4、鸡母 18、鸡雏 78;鸡翁 8、鸡母 11、鸡雏 81;鸡翁 12、鸡母 4、鸡雏 84。四组都是 100 只、100 钱。

易错:

  1. 忘了判 c % 3 == 0,把不能整钱买鸡雏的情况也算成解
  2. 鸡雏那层循环也从 0 数到 100,三层循环白跑
  3. 用 c / 3.0 凑钱数,再拿浮点数判相等

6 · 逐位输出整数的十进制数值

编写一个程序,输入一个整数,逐位地输出整数的十进制数值,要求位与位之间有一个空格符分隔。

答案
#include <stdio.h>

static void print_digits(int n)
{
    if (n >= 10) {
        print_digits(n / 10);
        putchar(' ');
    }
    putchar('0' + n % 10);
}

int main(void)
{
    int n;

    printf("请输入一个整数: ");
    if (scanf("%d", &n) != 1)
        return 0;

    if (n < 0) {
        putchar('-');
        n = -n;
    }
    print_digits(n);
    putchar('\n');
    return 0;
}
解析

思路:题目要求从左到右逐位输出,而取余只能先拿到最低位。用递归把顺序倒过来:先让剩下的高位递归打完,再打自己这一位。

关键点:

  1. 直接写 while (n > 0) { printf("%d ", n % 10); n /= 10; } 打出的是逆序,12345 会变成 5 4 3 2 1。要么像上面这样递归,要么先把各位存进数组再倒着打。
  2. 空格加在"两个数字之间"。if (n >= 10) 里先递归、再补空格、最后打自己的个位;这样最高位前面和最低位后面都没有多余空格。
  3. 负号先单独打掉,剩下的按正数处理。-708 的输出是 -7 0 8。
  4. n 是 0 时递归只走一层,打出一个 0。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 12345 输出 1 2 3 4 5;输入 -708 输出 -7 0 8。

负数的取余结果是负的

-708 % 10 在 C 里是 -8 而不是 8(C 的整除向零取整,余数跟着被除数走)。如果不先把负号摘掉,拆出来的每一位都带负号,输出会变成 -7 -0 -8 这种样子。这一条和第二章题 14 讲的整型除法是同一个规则。

易错:

  1. 用取余循环直接打,得到逆序
  2. 末尾多出一个空格(先打数字再判断要不要打空格)
  3. 负号没摘掉,% 10 出来是负数

7 · 1~10 的平方与立方表

编写程序,列表输出整数 1~10 的平方和立方值。

答案
#include <stdio.h>

int main(void)
{
    int i;

    printf("%4s%8s%10s\n", "n", "n*n", "n*n*n");
    for (i = 1; i <= 10; i++)
        printf("%4d%8d%10d\n", i, i * i, i * i * i);
    return 0;
}
解析

思路:一个循环从 1 数到 10,三个量(本身、平方、立方)按固定宽度打成一列一列的表。

关键点:

  1. 表格的整齐靠字段宽度,格式串里三列写成 %4d%8d%10d。宽度取多少看到底最大的数是几位:n 最多 2 位,平方最多 3 位(100),立方最多 4 位(1000),所以 4/8/10 宽绰有余。
  2. 表头也用同样的宽度写(%4s%8s%10s),表头和数据才对齐。表头里不要塞中文再指定宽度——%8s 数的是字节数,一个汉字占 3 字节,屏幕上却是 2 列宽,算出来的对齐是错的。
  3. 平方、立方用连乘写。i * i 和 i * i * i 都是整型,值不会溢出也不会有浮点误差。

输出:

   n     n*n     n*n*n
   1       1         1
   2       4         8
   3       9        27
   4      16        64
   5      25       125
   6      36       216
   7      49       343
   8      64       512
   9      81       729
  10     100      1000

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行,输出上面这 11 行,与预期逐字相同。

易错:

  1. 三列宽度随手写成 %d,表看着是歪的
  2. 指数用 pow(i, 2),那样算出来的是 double,配 %d 打就错了
  3. 循环写成 i < 10,漏掉 10 那一行

8 · 无限循环与有符号溢出

编写一个程序,用循环不停地输出 2 的倍数,观察最终会发生什么情况。

答案
#include <stdio.h>

int main(void)
{
    int i = 2;

    while (1) {
        printf("%d\n", i);
        i += 2;
    }
    return 0;
}
解析

思路:循环条件写恒真的 1,循环体每次把 i 加 2,输出的就是 2、4、6、8……这一串 2 的倍数。

关键点:

  1. while (1) 的条件下永远为真,程序自己不会停。要停下来只能从外面动手:按 Ctrl+C,或者像核实这一步一样用 timeout 命令限时。
  2. i 每次加 2,会一直加到超过 int 能表示的上限 2147483647。取值序列是 2、4、……、2147483646,下一个数 2147483648 就装不进 int 了——这就是有符号整型溢出。
  3. 有符号整型溢出在 C 标准里是未定义行为,标准并没有规定"会翻成负数"。本机怎么走要跑了才算数。

本机的实际现象:

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):先用 timeout 3 限时跑一次,3 秒里打出了 52988463 行、474 MB,最后一行停在 105976926,进程被 timeout 杀掉(退出码 124)——3 秒连溢出点都没到。改成一直跑、只在输出里截第一个负数,第一条负数是 -2147483648,也就是第 1073741824 个数。此后 i 继续每次加 2,输出的是 -2147483648、-2147483646、……这样一串负偶数,程序仍然不停。

跑出来的数不是标准保证的结果

有符号整型溢出是未定义行为,标准只说"程序的行为没有定义",不说 i 一定变成 -2147483648。上面那个负数是本机跑出来的值,不能当标准结论。核实过程中还看到一件事:给探针程序加一句"i 变负就停下"的判断,在 -O2 下 gcc 默认有符号数不溢出,直接把它当成永假删掉了,探针于是围着 2147483640~2147483646 这一段一圈一圈地转,始终没停。同一段逻辑在 -O0 下就正常触发了。这就是未定义行为的典型表现——优化档不同,程序跑出来的东西可以完全不一样。

易错:

  1. 以为 i 到 2147483646 之后会自己停下来(循环条件里根本没有上限判断)
  2. 把 i 定义成 unsigned 再照抄一遍——无符号溢出是标准定义好的行为,会规规矩矩绕回 0,那就不叫"观察未定义行为"了
  3. 直接照着"溢出就变负数"写结论,不把跑出来的数写出来

9 · 三角形判定与面积

编写一个程序,输入 3 个实数,判断这些值是否能作为一个三角形的三条边的长,如果能构成三角形,要求输出三角形的面积。

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

int main(void)
{
    double a, b, c, s, area;

    printf("请输入三条边长: ");
    if (scanf("%lf%lf%lf", &a, &b, &c) != 3)
        return 0;

    if (a + b > c && a + c > b && b + c > a) {
        s = (a + b + c) / 2;
        area = sqrt(s * (s - a) * (s - b) * (s - c));
        printf("能构成三角形,面积 = %.6f\n", area);
    } else {
        printf("不能构成三角形\n");
    }
    return 0;
}
解析

思路:先按"任意两边之和大于第三边"判能不能围成三角形,能围的再用海伦公式算面积。

关键点:

  1. 三个不等式都要写全:a + b > c、a + c > b、b + c > a。只写一个,换个边长顺序就判错了。
  2. 海伦公式:先求半周长 s = (a + b + c) / 2,面积是 sqrt(s * (s - a) * (s - b) * (s - c))。
  3. 三个变量都用 double,scanf 里配 %lf。(a + b + c) / 2 里除号一边是 double,做的是浮点除法,不会像两个整数相除那样截断。
  4. 用了 math.h 里的 sqrt 就要链数学库,命令行里补一个 -lm。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 3 4 5,输出 能构成三角形,面积 = 6.000000;输入 3 4 6,输出面积 5.332682;输入 6 8 10,输出面积 24.000000;输入 1 2 5 和 1 2 3,都输出 不能构成三角形。

判据用的是严格大于号

三边满足"两边之和大于第三边"才算三角形。1 2 3 这种两边之和恰好等于第三边的情况是三点共线,围不出三角形,上面那次运行里它被判为不能构成。写成 >= 就把这一列也算进去了。

易错:

  1. 三个不等式只写一个
  2. 半周长写成 (a + b + c) / 2.0 之外还写 int 接收,把小数部分丢了
  3. 漏掉 -lm,链接时报 undefined reference to sqrt

10 · 三点构成三角形的判定与特征

编写程序,输入 3 对实数,若每对实数分别作为平面坐标系中点的坐标,判断这 3 个点是否能构成一个三角形。

若能构成三角形,判断这个三角形的特征:是直角三角形、等边三角形、等腰三角形还是普通三角形等。

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

static double dist(double x1, double y1, double x2, double y2)
{
    return sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
}

static int eq(double a, double b)
{
    return fabs(a - b) < 1e-9;
}

int main(void)
{
    double x1, y1, x2, y2, x3, y3, a, b, c, s, area;

    printf("请输入三对坐标: ");
    if (scanf("%lf%lf%lf%lf%lf%lf", &x1, &y1, &x2, &y2, &x3, &y3) != 6)
        return 0;

    a = dist(x2, y2, x3, y3);
    b = dist(x1, y1, x3, y3);
    c = dist(x1, y1, x2, y2);

    if (eq((x2 - x1) * (y3 - y1) - (y2 - y1) * (x3 - x1), 0.0)) {
        printf("三点共线,不能构成三角形\n");
        return 0;
    }

    s = (a + b + c) / 2;
    area = sqrt(s * (s - a) * (s - b) * (s - c));
    printf("能构成三角形,面积 = %.6f\n", area);

    if (eq(a, b) && eq(b, c))
        printf("特征: 等边三角形\n");
    else if (eq(a, b) || eq(b, c) || eq(a, c))
        printf("特征: 等腰三角形\n");
    else if (eq(a * a + b * b, c * c) || eq(a * a + c * c, b * b) ||
             eq(b * b + c * c, a * a))
        printf("特征: 直角三角形\n");
    else
        printf("特征: 普通三角形\n");
    return 0;
}
解析

思路:先按距离公式把三对坐标化成三条边长,后面就回到上一题的做法。能不能构成三角形用"三点不共线"来判,特征按从特殊到一般的顺序分层判。

关键点:

  1. 三点共线的判据是叉积为零:(x2-x1)*(y3-y1) - (y2-y1)*(x3-x1)。它只用加减乘,比"两边之和大于第三边"稳——三点本来就共线时,浮点误差很容易让边长比较的结果摇摆。
  2. 特征判定有先后:先等边(三边全等)、再等腰(有两边相等)、再直角、最后普通。等腰直角三角形两类都占,题干把四类并列,按这个顺序它会归到等腰那一类,不会两个都打。
  3. 直角判定要把三组勾股关系都试一遍,因为不知道哪条边是斜边。
  4. 浮点数不能用 == 直接比。距离里有开方,等边的三条边可能差出最后几位,所以统一走 fabs(a - b) < 1e-9 这种带容差的比较。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 0 0 4 0 0 3(三边 3、4、5)输出面积 6.000000、特征 直角三角形;输入 0 0 4 0 2 3.464101615(三边 4、4、4)输出面积 6.928203、特征 等边三角形;输入 0 0 4 0 1 1 输出面积 2.000000、特征 普通三角形;输入 0 0 4 0 2 0 输出 三点共线,不能构成三角形。

易错:

  1. 用 a == b 判等腰,开方之后的浮点误差让本该判等的两条边判成不等
  2. 判定顺序倒过来,等腰直角三角形的两个特征都打
  3. 直角判定只试 a*a + b*b == c*c 一组,换个输入顺序就漏

11 · 星号直角三角形图案

输入正整数 n,输出由 n 行 n 列星号字符组成的三角形图案。右边是 n 等于 4 的图案。

      *
    * *
  * * *
* * * *
答案
#include <stdio.h>

int main(void)
{
    int n, i, j;

    printf("请输入行数 n: ");
    if (scanf("%d", &n) != 1 || n <= 0)
        return 0;

    for (i = 1; i <= n; i++) {
        for (j = 1; j <= n - i; j++)
            printf("  ");
        for (j = 1; j <= i; j++)
            printf(j == 1 ? "*" : " *");
        printf("\n");
    }
    return 0;
}

n 等于 4 时的输出:

      *
    * *
  * * *
* * * *
解析

思路:第 i 行由两段拼成——左边一段缩进,右边 i 个星号。星号之间隔一个空格,所以每往下一行,缩进就少 2 个空格。

关键点:

  1. 第 i 行的缩进是 2 * (n - i) 个空格。乘 2 是因为星号之间隔了一个空格,一个星号在屏幕上占 2 列。
  2. 打星号那层用 j == 1 ? "*" : " *":第一个星号前面不加空格,后面的都带上自己的前导空格。一行打完正好是 * * * * 这个样子,行尾不留空白。
  3. 读入 n 之后顺手判一下 n <= 0。缩进量是拿 n - i 算的,n 是 0 或负数时循环根本进不去,什么也打不出来。
  4. 题干样图里最上面几行的行首空格是图案的一部分,不是排版缩进,比对的时候不能压掉。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 4,输出的 4 行与题干样图逐行相同(按判题同款口径 normalize() 比较,行首空格保留、行尾空白剥掉);输入 5 得到 5 行,最上面一行是 *,往下每行左移 2 格。

易错:

  1. 缩进写成 n - i 个空格,图案的斜率变成 1 个字符,三角形被压瘦一半
  2. 每个星号后面都补一个空格,行尾多出空白
  3. 把题干样图的行首空格当成 markdown 的缩进压掉了,比对时被判成图案不同

12 · 空心三角形图案

输入正整数 n,输出 n 行 2×n-1 列的空心三角形图案。右边是 n 等于 4 的图案。

* * * * * * *
  *       *
    *   *
      *
答案
#include <stdio.h>

int main(void)
{
    int n, i, j;

    printf("请输入行数 n: ");
    if (scanf("%d", &n) != 1 || n <= 0)
        return 0;

    for (i = 1; i <= n; i++) {
        for (j = 1; j <= 2 * (i - 1); j++)
            putchar(' ');

        if (i == 1) {
            for (j = 1; j <= 2 * n - 1; j++)
                printf(j == 1 ? "*" : " *");
        } else if (i == n) {
            putchar('*');
        } else {
            putchar('*');
            for (j = 1; j <= 4 * (n - i) - 1; j++)
                putchar(' ');
            putchar('*');
        }
        putchar('\n');
    }
    return 0;
}

n 等于 4 时的输出:

* * * * * * *
  *       *
    *   *
      *
解析

思路:题干说这个图案是 n 行、2n-1 列,而且星号之间还空一格,所以一个"星号位"实际占 2 列,一行最宽 2×(2n-1)-1 个字符。

第 i 行的左边界在第 2*(i-1) 列、右边界在第 2*(2n-i-1) 列,两条边每往下一行各收 2 列。

关键点:

  1. 左星号的位置直接就是 2 * (i - 1) 列,也就是每行的缩进量——这和上一题一样。
  2. 右星号在第 2 * (2n - i - 1) 列,减去左星号的位置再减去左星号本身占的 1 列,中间的空格数是 4 * (n - i) - 1。四个待填的中间行分别是 7、3 个空格,正好对上样图。
  3. 顶边(i = 1)是实心的,2n-1 个星号之间照样隔一个空格;底边(i = n)只有一个星号;中间各行只有左右两个星号。
  4. 中间行打完右边那个星号就换行,不要为了凑宽度再补空格。行尾有没有空白,比对时是要剥掉的。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 4,输出的 4 行与题干样图逐行相同(按判题同款口径 normalize() 比较,行首空格保留、行尾空白剥掉);输入 5 得到 5 行,顶行 9 个星号,往下每行左右两侧各收 2 列,末行是缩进 8 格的一个星号。

易错:

  1. 中间行的空格数按 2 * (n - i) - 1 算(那是"星号位"的个数,每格 2 列,少算了一半)
  2. 顶边把 2n-1 个星号连着打,星号之间漏了空格
  3. 底边的星号忘了缩进,跑到行首去了
  4. 中间行右侧的星号忘了打,只剩左边一条边

13 · 读程序写输出:差一错误

读程序,试指出程序的输出结果。

#include<stdio.h>
void main()
{  int k,m,j;
   k=15;
   for(m=2;m<=k;m++)
   {  for(j=2;j*j<=m;j++)
         if(m%j==0) break;
      if(m%j!=0) printf("%4d",m);
   }
   printf("\n");
}
答案
   3   5   7  11  13
解析

这段程序想打出 2 到 15 之间的素数:外层 m 逐个走,内层用试除法找 m 的最小约数。判断"是素数"的那一句是 if(m%j!=0),而它判的并不是"内层有没有找到约数",是"退出内层时 m % j 是不是 0"。

差一错误就出在这里。

内层的 for(j=2;j*j<=m;j++) 有两种退出方式:

  • 撞上 break:j 就是找到的那个约数,此时 m % j == 0。
  • 循环条件不成立、自然退出:j 是第一个满足 j*j > m 的数。

作者想要的是第二种(自然退出就说明是素数)。写成 m % j != 0 恰好也能认出自然退出——自然退出时 j*j > m,而 m 是素数,j 取不到 m 这个值,所以 m % j 必然非 0。

这个巧合在 m = 2 时断掉了。逐轮把 m、退出时的 j 和 m % j 打出来看:

m 退出内层时的 j j*j m % j 是否打印
2 2 4 0 跳过
3 2 4 1 打印
4 2 4 0 跳过
5 3 9 2 打印
6 2 4 0 跳过
7 3 9 1 打印
8 2 4 0 跳过
9 3 9 0 跳过
10 2 4 0 跳过
11 4 16 3 打印
12 2 4 0 跳过
13 4 16 1 打印
14 2 4 0 跳过
15 3 9 0 跳过

m = 2 那一行就是问题所在:j 从 2 起步,循环条件 j*j <= m 是 4 <= 2,一开始就不成立,循环体一次都没执行,j 停在初值 2。而 j 这时正好等于 m,于是 2 % 2 == 0,if(m%j!=0) 判为假,2 这个素数被当成合数跳过了。

其余的素数(3、5、7、11、13)内层都能跑到 j*j > m 才退出,j 比 m 小,m % j 不为 0,正常打印。

合数全都在某个约数上 break 出来,退出时 m % j == 0,也都正确跳过。

正确的判据应该直接问"内层是不是自然退出的",也就是 j*j > m:自然退出意味着 2 到 √m 之间的数都试过了、一个约数都没找到。把 if(m%j!=0) 换成 if(j*j>m),输出就变成 2 3 5 7 11 13。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行,输出就是 3 5 7 11 13,2 不在里面。每个数占 4 列(%4d 右对齐),一位数前面 3 个空格,两位数前面 2 个空格。

题干里的 void main()

题干写的是 void main()。标准从来只认 int main,gcc 15.2.0 在 -Wmain 下会给一条警告 warning: return type of 'main' is not 'int' [-Wmain](只是警告,不是硬错误)。核实输出时只做了两处最小修改:

  1. void main() 改成 int main(void)
  2. 末尾补一句 return 0;

其余一字未动,改完那份的输出与按原样编译的那份逐字节相同。

易错:

  1. 按"素数就是除 1 和自身外没有别的约数"心算,把 2 也算进去,答成 2 3 5 7 11 13
  2. 以为 if(m%j!=0) 判的是"内层没找到约数"
  3. 忘了 %4d 会占满 4 列,把答案写成 3 5 7 11 13

14 · 用级数计算自然对数底 e

编写程序,按下面的公式计算自然对数底 e 的值。

e=1+1/1!+1/2!+1/3!+1/4!+⋯

答案
#include <stdio.h>

int main(void)
{
    double e = 1.0, term = 1.0;
    int i;

    for (i = 1; i <= 20; i++) {
        term /= i;
        e += term;
    }
    printf("e = %.10f\n", e);
    return 0;
}
解析

思路:级数每一项都是前一项再除以一个递增的整数——1/i! = (1/(i-1)!) / i。照这个递推关系写,循环体里只有一句除法。

关键点:

  1. 不要每次重新算阶乘。term = 1.0 / fact(i) 这种写法既慢,fact(i) 还会在 i 稍大时整型溢出。term /= i 一步到位,而且每一项都是浮点数。
  2. i 是 int、term 是 double,term /= i 做的是浮点除法,不会像两个整数相除那样截断。
  3. 累加从 e = 1.0 起(级数的第一项就是常数 1),term 也从 1.0 起,这样第一轮循环算出来的正好是 1/1!。
  4. 算多少项够?1/20! 已经是 4×10⁻¹⁹ 量级,而 double 在 2.718 附近能分辨的精度只到约 10⁻¹⁶,再多加几项也不会让结果动一位。20 项足够打印 10 位小数。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行,输出 e = 2.7182818285。

别用 float 存

float 只有大约 7 位有效十进制数字,用它累加这个级数,打到第 8 位小数就开始是乱的。这类累加型的计算统一用 double。

易错:

  1. 每次循环重新求阶乘,int 到 13! 就溢出了
  2. 累加从 0 或从 term 起,把第一项常数 1 漏掉或算重
  3. 用 float 存结果,%.10f 打出来的后几位不可信

15 · 用级数计算 e 的 x 次幂

编写程序,按下面的公式计算 eˣ 的值。

eˣ=1+x/1!+x²/2!+x³/3!+x⁴/4!+⋯

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

int main(void)
{
    double x, sum = 1.0, term = 1.0;
    int i;

    printf("请输入 x: ");
    if (scanf("%lf", &x) != 1)
        return 0;

    for (i = 1; i <= 100; i++) {
        term *= x / i;
        sum += term;
        if (fabs(term) < 1e-12)
            break;
    }
    printf("e^%g = %.10f\n", x, sum);
    return 0;
}
解析

思路:和上一题同一个做法,只是每一项再多乘一个 x——第 i 项是 x^i / i!,也就是前一项乘 x 再除以 i。

关键点:

  1. 递推关系 term *= x / i。x / i 里一边是 double,做的是浮点除法。
  2. 上一题固定跑 20 次就够,这一题不行:x 越大收敛越慢,x = 20 时前几项还在猛涨。所以改用"当前项的绝对值小于 1e-12 就收尾",同时把循环上限写成 100,防止某项一直不衰减时死循环。
  3. 终止判据必须带 fabs。x 是负数时各项正负交替,只看 term < 1e-12 会在第一个负数项上立刻判停,结果是错的。这一条容易漏。
  4. 打印时 x 用 %g,0.5 打成 0.5、2 打成 2,不会拖出一串没用的 0;结果用 %.10f。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 1 输出 e^1 = 2.7182818285;输入 2 输出 e^2 = 7.3890560989;输入 -1 输出 e^-1 = 0.3678794412;输入 0.5 输出 e^0.5 = 1.6487212707。

易错:

  1. 终止判据漏掉 fabs,x 为负数时第一项就判停
  2. 只在循环上限上写死次数,x 大时项数不够
  3. 递推写成 term *= x / i 之外还额外乘了 x,变成 x^(i+1)/i!

16 · 莱布尼茨级数计算圆周率

编写程序,用如下公式计算圆周率 π 的近似值。

π=4 - 4/3 + 4/5 - 4/7 + 4/9 - 4/11+⋯

回答程序要计算多少项才能得到数值 3.14、3.141、3.141 5、3.141 59。

答案
目标值 需要的项数
3.14 152
3.141 916
3.1415 7010
3.14159 130658
#include <stdio.h>
#include <math.h>

int main(void)
{
    double target[4] = {3.14, 3.141, 3.1415, 3.14159};
    int digits[4] = {2, 3, 4, 5};
    int done[4] = {0};
    double pi = 0.0, term, scale;
    int n, i, k;

    for (n = 1; n <= 200000; n++) {
        term = 4.0 / (2 * n - 1);
        pi += (n % 2 == 1) ? term : -term;

        for (k = 0; k < 4; k++) {
            if (done[k])
                continue;
            scale = 1.0;
            for (i = 0; i < digits[k]; i++)
                scale *= 10.0;
            if (fabs(round(pi * scale) / scale - target[k]) < 1e-12) {
                printf("达到 %.5f 需要 %d 项\n", target[k], n);
                done[k] = 1;
            }
        }
    }
    return 0;
}
解析

思路:第 n 项是 4/(2n-1),符号正负交替;边累加边拿部分和去和四个目标值比,谁先对上就把项数记下来。done 数组用来标记已经命中的目标,避免后面每一轮重复打。

关键点:

  1. 第 n 项的分母是 2n-1(1、3、5、7……),奇数项加、偶数项减,写成 (n % 2 == 1) ? term : -term。
  2. "得到数值 3.14"按打印两位小数的口径来,也就是把部分和四舍五入到 2 位小数后等于 3.14。题干的 3.141 5、3.141 59 就是 3.1415、3.14159,分别对应 4 位和 5 位小数。
  3. 这个级数收敛极慢:误差大致按 1/n 衰减,所以要到 3.14159 就得算 13 万多项。不是程序写得慢,是这个公式本身慢。
  4. 循环上限写成 200000 是因为已经知道最大那个目标要 13 万项;写小了会提前退出去,后一个目标永远打不出来。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行,依次输出 达到 3.14000 需要 152 项、达到 3.14100 需要 916 项、达到 3.14150 需要 7010 项、达到 3.14159 需要 130658 项。

两种理解口径差得很远

题干没写清"得到数值 3.14"是打印两位小数(四舍五入)还是截断到两位。上面按 printf("%.2f") 的口径给,四个答案是 152、916、7010、130658;按截断口径算,则是 119、1688、10794、136121。

差这么多是因为部分和沿着 π 两侧来回摆:奇数项从上面靠近、偶数项从下面靠近。第 119 项的部分和是 3.149 995 87,擦着 3.15 的下沿落进了"截断成 3.14"的范围,却因为超过 3.145 而四舍五入成 3.15;一直要等到第 152 项,部分和 3.135 013 78 才第一次落进四舍五入的 3.14 区间。

两种口径都说得通,关键是程序里的判据和说法要对上。

易错:

  1. 用"部分和与 π 的差小于某个 ε"当判据,却把 ε 取成 0.005、0.0005 这种一半的值(那对应的是另一种口径)
  2. 拿浮点数直接写 pi == 3.14,几乎永远不成立
  3. 循环上限写得太小,后面几个目标根本轮不到

17 · 用循环改写 goto 代码

用结构化的代码改写以下代码:

loop:if(x<y)goto next;
   x-=y;
   goto loop;
next:printf("x=%lf\n",x);
答案
while (x >= y)
    x -= y;

printf("x=%lf\n", x);

补全成一个能编译的程序:

#include <stdio.h>

int main(void)
{
    double x, y;

    if (scanf("%lf%lf", &x, &y) != 2)
        return 0;

    while (x >= y)
        x -= y;

    printf("x=%lf\n", x);
    return 0;
}
解析

思路:题干这三行是拿 goto 手写的一个循环,逐句换掉就成结构化的代码。

  • loop: 是循环的入口标号。
  • if(x<y) goto next; 是"条件不满足就跳出循环",对应 while 的条件要取反,写成 x >= y。
  • x -= y; 在两个标号之间,是循环体。
  • goto loop; 是回到入口,这一句在改成 while 后自然消失。
  • next: 之后的 printf 是循环后面的第一条语句,照原样接在 while 后面,缩进退回到顶层。

关键点:

  1. 条件必须取反。goto 版的写法是"满足某条件就跳出去",while 版要写成它的反面。
  2. printf 不能进循环体。它在 next: 标号之后,是"循环结束才执行一次",塞进循环会每轮打一行。
  3. 题干的片段能看出 x 是 double(打的是 %lf),y 的类型从 x >= y 的比较和 x -= y 推断也是 double,补全时两个都定义成 double。
  4. 循环条件用的是 >= 而不是 >:原码在 x == y 时还会再减一次(x < y 才跳出),x -= y 之后 x 正好等于 0。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):补全的程序送 -fsyntax-only 通过、无警告;跑一遍,输入 17.5 5 输出 x=2.500000(减 3 次:17.5→12.5→7.5→2.5),输入 3 5 输出 x=3.000000(一开始 3 >= 5 就不成立,一次都没进循环)。

易错:

  1. 条件忘了取反,写成 while (x < y)(那样循环体成了"不满足才减",逻辑整个反过来)
  2. 把 printf 也放进循环体
  3. 只改掉 goto loop;,留下 goto next; 或那个跳出用的 if

18 · 两种方法求 Fibonacci 数的差异

以下给出计算 Fibonacci 数列的两种不同方法:

(1)递推计算:f₀=0,f₁=1;fᵢ=fᵢ₋₁+fᵢ₋₂(i>1)。

(2)用公式计算:

fᵢ=(int)(cⁱ/sqrt(5)+0.5) /0.5 是补偿计算误差/

其中,c=(1.0+sqrt(5))/2.0。试编写程序找出以上两种计算方法求得的 fᵢ 不相等的 i。

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

int main(void)
{
    long f[93];
    double c = (1.0 + sqrt(5)) / 2.0;
    int i;

    f[0] = 0;
    f[1] = 1;
    for (i = 2; i <= 92; i++)
        f[i] = f[i - 1] + f[i - 2];

    for (i = 0; i <= 92; i++) {
        long g = (long)(pow(c, i) / sqrt(5) + 0.5);
        if (g != f[i])
            printf("i=%d: 递推=%ld 公式=%ld\n", i, f[i], g);
    }
    return 0;
}

i 从 71 开始两种方法不再相等,并且此后一路不相等。

解析

思路:递推法按定义一项一项加起来,结果是精确的整数;公式法用 double 做幂运算,只能算近似值。把两个序列逐项对一遍,第一个对不上位置就是答案。

关键点:

  1. c = (1+√5)/2 和 sqrt(5) 都是无理数,double 只能存近似值。pow(c, i) 每算一次,c 的相对误差就被放大 i 倍,乘上 c^i 这个量级之后变成绝对误差。
  2. 公式里的 +0.5 是把"四舍五入"用截断实现的,它能吸收的误差上限就是 0.5。误差一旦涨到 1 以上,这个补偿就完全失效。
  3. i = 71 时 f₇₁ = 308061521170129,约 3×10¹⁴。double 在这个量级上相邻两个可表示数之间已经隔着 0.03 左右,再加上 c 本身的误差被放大了 71 倍,累计误差刚好越过 1。
  4. 数组开 93 是因为 64 位 long 的上限是 9223372036854775807,f₉₂ = 7540113804746346429 还在范围内,f₉₃ 就超了。再往后递推法自己也在溢出,两边都没意义,比较到此为止。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行,i 在 0~70 之间两种方法的结果完全一致;从 i = 71 起不相等(递推 308061521170129、公式 308061521170130),此后一路不相等,到 92 为止共 22 个。

71 这个数取决于浮点精度,不是数学结论

double 的位数由 IEEE 754 定死,所以 71 在常见平台上是稳定的。但它是精度的结果,不是 Fibonacci 数列的性质:把 c、sqrt、pow 换成 long double 版本(x86 上尾数 64 位),第一个不相等的 i 就推迟到 90。题目要的是"把不相等的找出来",程序写对就行,具体从哪一项开始取决于用的是什么浮点类型。

易错:

  1. 只比前几项就下结论(前 70 项全都相等)
  2. 公式里忘了 +0.5,从 i = 3 起就对不上(截断比四舍五入小 1)
  3. 数组开到几百,后面那些项两边都在溢出,比出来的差异没有意义

19 · 求平方不超过 x 的最大整数

编写输入正实数 x,求平方不超过 x 的最大整数 n,并输出。

答案
#include <stdio.h>

int main(void)
{
    double x;
    int n = 0;

    printf("请输入正实数 x: ");
    if (scanf("%lf", &x) != 1 || x < 0)
        return 0;

    while ((double)(n + 1) * (n + 1) <= x)
        n++;

    printf("平方不超过 %g 的最大整数 n = %d\n", x, n);
    return 0;
}
解析

思路:要求的 n 就是 √x 向下取整。不开方也行——从 0 开始,一个一个试下一个整数的平方有没有超过 x,没超过就把 n 往前推一格。

关键点:

  1. 判据 (n + 1) * (n + 1) <= x 里,乘法是整数乘法,再把结果转成 double 和 x 比。整数不超过 2⁵³ 时 double 能精确表示,所以这个比较不会踩浮点误差,"不超过"就是真的不超过。
  2. 直接用 (int)sqrt(x) 要留神开方函数往哪边偏。sqrt 的返回值是舍入过的,遇到完全平方数时可能比真值小一点点,(int) 一截就少 1。用循环试乘法就没这个问题。
  3. 题干说的是"正实数",x 不必是整数,也不必大于 1:x = 2.25 时答案是 1,x = 0.5 时答案是 0(0 * 0 = 0 <= 0.5,但 1 * 1 = 1 > 0.5)。
  4. 循环次数是 √x 量级。x 特别大时这个写法会慢,那时改用二分查找;本题的规模不必。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 10 得 n = 3;输入 16 得 n = 4(完全平方数,答案正好是 4);输入 15.9 得 n = 3;输入 2.25 得 n = 1;输入 0.5 得 n = 0。

易错:

  1. 写成 (int)sqrt(x),遇到完全平方数偶发少 1
  2. 比较用 < x 而不是 <= x,x = 16 时会得 3
  3. n 定义成 double(它要当整数用,%d 打不出来)

20 · 回文整数的判断

回文整数是指正读和反读相同的整数,编写一个程序,输入一个整数,判断它是否是回文整数。

答案
#include <stdio.h>

int main(void)
{
    int n, m, rev = 0;

    printf("请输入一个整数: ");
    if (scanf("%d", &n) != 1)
        return 0;

    m = n;
    while (m > 0) {
        rev = rev * 10 + m % 10;
        m /= 10;
    }

    if (rev == n)
        printf("%d 是回文整数\n", n);
    else
        printf("%d 不是回文整数\n", n);
    return 0;
}
解析

思路:把整数倒过来写一遍,再和原数比。倒写的过程和上一章逐位输出是同一套拆位动作,只是这次把取出的数字一位一位接到结果的末尾。

关键点:

  1. 拆位要用副本 m。直接在 n 上拆,循环结束时 n 已经变成 0,就没东西可比了。
  2. rev = rev * 10 + m % 10 分三步看:m % 10 取出最低位,rev * 10 把已有结果整体左移一位,加号把它放到新的个位。
  3. 负数的 m > 0 一开始就不成立,rev 保持 0,会被判成不是回文。题干说"输入一个整数",通常按正整数理解;真要为负号补充处理,也得先想清楚 -121 算不算回文。
  4. 末尾是 0 的数(比如 120)翻转后是 021,也就是 21,不等于原数,会被正确判为不是回文。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 12321 输出 12321 是回文整数;输入 12345 输出 12345 不是回文整数;输入 1000000001 输出 1000000001 是回文整数。

翻转过程中会溢出

rev = rev * 10 + ... 每一步都在放大,int 最多装 10 位数。输入 1999999999 时翻转到最后一步要得到 9999999991,早就超出了 int 的上限 2147483647,这一步是有符号整型溢出(未定义行为)。这次运行最终打出"不是回文整数",结论碰巧是对的,但过程不能依赖。真要处理这么大的输入,把 rev 定义成 long long,或者翻到一半位数就停下来比较。

易错:

  1. 在 n 上直接拆位,最后 n 变成 0,跟谁比都不相等(所有数都判成不是回文)
  2. 翻转时写成 rev = rev * 10 + m / 10(取的是高位不是低位)
  3. 用字符串逆序当回文判据,还得额外处理前导零和负号

21 · 1~256 的三种进制数值表

编写一个程序,输出 1~256 十进制数等价的二进制、八进制和十六进制数值表。

答案
#include <stdio.h>

int main(void)
{
    int i, k;

    printf("十进制  二进制          八进制  十六进制\n");
    for (i = 1; i <= 256; i++) {
        printf("%6d  ", i);
        for (k = 15; k >= 0; k--)
            putchar('0' + (i >> k & 1));
        printf("  %6o  %6X\n", i, i);
    }
    return 0;
}
解析

思路:一行一个十进制数,后面跟它的三种进制。八进制和十六进制直接交给 %o、%X;二进制 printf 没有对应的转换说明符,得自己一位一位拼出来。

关键点:

  1. 取二进制的某一位用"右移 + 按位与":i >> k & 1 把第 k 位挪到最低位,再用 & 1 把它单独留下来。k 从 15 数到 0,打出来就是从高位到低位。
  2. putchar('0' + 该位) 把数字 0/1 变成字符 '0'/'1'。字符 '0' 的码值是 48,加 0 还是 '0',加 1 就是 '1'。
  3. 二进制统一按 16 位打(左边补 0)。表里最大的是 256,二进制是 9 位,16 位的宽度留给它足够余量,各行左边缘也能对齐。只打有效位数也行,那样每行长短不一,表就散了。
  4. %o 是小写八进制、%x 是小写十六进制,%X 换成大写字母。上面用 %X,所以 254 那一行打的是 FE。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行,输出共 257 行(表头 1 行加 1~256 各一行)。第 1 行是 1 0000000000000001 1 1,末尾三行是 254 0000000011111110 376 FE、255 0000000011111111 377 FF、256 0000000100000000 400 100。

表格的首尾几行长这样:

十进制  二进制          八进制  十六进制
     1  0000000000000001       1       1
     2  0000000000000010       2       2
     3  0000000000000011       3       3
   ...
   254  0000000011111110     376      FE
   255  0000000011111111     377      FF
   256  0000000100000000     400     100

易错:

  1. 只用 printf,找不到打二进制的格式符,卡在那里(必须自己拼)
  2. 取位写成 i >> k & 1 之外的 i & (1 << k) 时忘了归一化,打出来是 0 或 256 而不是 0 或 1
  3. 十进制那列用 %d 而不用 %6d,位数一变表就歪

22 · 猴子吃野果的倒推

草地上有一堆野果,有一只猴子每天去吃掉这堆野果的一半又一个,5 天后刚好吃完这堆野果。

求这堆野果原来共有多少个?猴子每天吃多少个野果?

答案
#include <stdio.h>

int main(void)
{
    int x = 0, day, before[6];

    for (day = 5; day >= 1; day--) {
        x = 2 * (x + 1);
        before[day] = x;
    }

    printf("原来共有 %d 个野果\n", before[1]);
    for (day = 1; day <= 5; day++) {
        int eaten = before[day] / 2 + 1;
        printf("第 %d 天吃掉 %2d 个,剩 %2d 个\n", day, eaten, before[day] - eaten);
    }
    return 0;
}

原来共有 62 个;第 1 天到第 5 天依次吃掉 32、16、8、4、2 个。

解析

思路:正着算不知道起点是多少,倒着推更直接。第 5 天刚好吃完,那就是第 5 天开始时有 2 个;把"吃掉一半又一个"反过来,每天开始时的数量就是"当天剩下的数量加 1 再乘 2"。

关键点:

  1. 倒推公式的来历:设某天开始有 x 个,吃掉 x / 2 + 1 个,剩下 x - x / 2 - 1 = x / 2 - 1 个。把剩下的记作 r,就有 x = 2 * (r + 1)。
  2. 倒推的起点是第 5 天结束时的 0 个,往前推 5 步。把每天开始时的数量存进数组,正着打一遍每天的吃法就成了。
  3. "一半"在这里永远是整数:倒推出来的数量都是偶数(2、6、14、30、62),正着吃的时候不会出现半个野果,整数除法不会丢东西。
  4. 正着验算一遍:62 吃 32 剩 30;30 吃 16 剩 14;14 吃 8 剩 6;6 吃 4 剩 2;2 吃 2 剩 0。五天正好吃完。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行,输出 原来共有 62 个野果,随后五行依次是 第 1 天吃掉 32 个,剩 30 个、第 2 天吃掉 16 个,剩 14 个、第 3 天吃掉 8 个,剩 6 个、第 4 天吃掉 4 个,剩 2 个、第 5 天吃掉 2 个,剩 0 个。

易错:

  1. 正着穷举起点,没想到倒推一步就够
  2. 倒推公式写成 x = 2 * r + 1(应该是 2 * (r + 1),多加的那个 1 是当天吃掉的多余一个)
  3. 只算出总数 62,忘了题干还问"猴子每天吃多少个"

23 · 质因子分解式

输入自然数 n(n>1),输出该数的质因子分解式。例如,n=30,程序输出

30=2*3*5

答案
#include <stdio.h>

int main(void)
{
    int n, m, p, first = 1;

    printf("请输入自然数 n(n>1): ");
    if (scanf("%d", &n) != 1 || n <= 1)
        return 0;

    printf("%d=", n);
    m = n;
    for (p = 2; p * p <= m; p++)
        while (m % p == 0) {
            printf(first ? "%d" : "*%d", p);
            first = 0;
            m /= p;
        }
    if (m > 1)
        printf(first ? "%d" : "*%d", m);
    printf("\n");
    return 0;
}
解析

思路:拿 p 从 2 开始逐个试除。试到一个能整除的 p 就反复除,直到除不动为止,再换下一个 p。这样找到的因子天然都是质数——合数因子早就被它的小质因子除干净了。

关键点:

  1. 内层必须是 while 不是 if。360 = 2×2×2×3×3×5 里有三个 2,只除一次的话剩下的 45 再也没机会被 2 整除,最后会打出 360=2*45 这种没分解完的式子。
  2. 试除的上界跟着 m 走:条件是 p * p <= m。每除掉一个因子 m 就变小,能省掉大量无用的试除。
  3. 循环结束后若 m > 1,剩下这个 m 就是最后一个质因子。它可能大于原数的平方根,试除循环够不到它,必须单独补一句打出来——97 这类素数就是靠这一句打出 97=97 的。
  4. 星号要放在因子之间,第一个因子前面不打。用 first 标志控制,就不会出现 30=*2*3*5。
  5. 打等号左边用的是原数 n,不是循环里一直在变的 m。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 30 输出 30=2*3*5;输入 360 输出 360=2*2*2*3*3*5;输入 1024 输出 1024=2*2*2*2*2*2*2*2*2*2;输入 97 和 999983(都是素数)分别输出 97=97、999983=999983。

易错:

  1. 内层用 if,重复的质因子只打一次
  2. 忘了循环结束后补打 m,素数的输出只剩下一个 97=
  3. 星号一律打在因子前面,输出成了 30=*2*3*5
  4. 等号左边写成 m,那已经是除完的 1 了

24 · 二分法求方程的根

已知自变量 x 在区间 [0,3] 上,函数 f(x)=x³-x²-1 有一个实根,试用二分法求该函数的根。

提示:二分法求根方法是一个非常实用的求根方法,二分法求根的基本思想是,设连续函数 f(x) 在区间 [low,high] 上有定义,且 f(low) 与 f(high) 的值的符号不同。

循环做以下的工作,测试区间中点 m(m=(low+high)/2)的值 f(m),并用 f(m) 值的符号与 f(low) 和 f(high) 的符号比较,如果 f(m) 的符号与 f(low) 的符号相同,则用 m 更新 low,否则用 m 更新 high。

循环直至 | high-low | 小于指定的精度要求。

答案
#include <stdio.h>

static double f(double x)
{
    return x * x * x - x * x - 1;
}

int main(void)
{
    double low = 0.0, high = 3.0, mid, eps = 1e-6;

    if (f(low) * f(high) >= 0) {
        printf("区间端点同号,二分法不适用\n");
        return 0;
    }

    while (high - low >= eps) {
        mid = (low + high) / 2;
        if (f(mid) * f(low) > 0)
            low = mid;
        else
            high = mid;
    }
    printf("方程的根 ≈ %.6f\n", (low + high) / 2);
    return 0;
}
解析

思路:就按题干给的算法写。区间 [low, high] 两端函数值异号,根一定落在里面;每轮取中点,看中点和哪个端点同号,就把那个端点搬过来,区间每次缩短一半。

关键点:

  1. 先验端点异号。f(0) = -1、f(3) = 27 - 9 - 1 = 17,乘积为负,可以用二分。这一句顺手也把"区间给错了"挡在外面。
  2. 更新规则和题干提示一一对应:f(mid) 与 f(low) 同号(乘积大于 0)说明两端同号的那一半不含根,根在 [mid, high] 里,于是 low = mid;否则根在 [low, mid] 里,high = mid。
  3. 判同号用乘积的符号,不要用 f(mid) * f(low) > 0 之外的花哨写法。乘积写法很直白,本题的数值范围也不会溢出。
  4. 终止条件是 high - low < eps。区间每轮减半,从初始长度 3 缩到 1e-6 大约要 22 轮。
  5. 最后取 (low + high) / 2 当结果。区间两端到根的距离都不超过 eps,取中点误差最小。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):程序原样编译运行,输出 方程的根 ≈ 1.465572,迭代了 22 轮。把精度换成 1e-12 再跑,迭代 42 轮,得 1.465571231877。

精度不是越高越准

double 只有约 16 位有效数字,精度一路收紧到 1e-15 以下时,low 和 high 的差值已经落在浮点分辨率以下,high - low 可能永远降不到 eps,循环就停不下来。本题取 1e-6 到 1e-12 都合适。

易错:

  1. 更新方向搞反(f(mid) 与 f(low) 同号时去改 high),区间会朝着没有根的那一半收缩,结果是错的
  2. 判同号时把 f(low) 写成了 f(high)
  3. 循环条件写成 high - low > 0,浮点数永远等不到相等,死循环
  4. 忘了 f(x) 里的 x * x * x,把 x³ 写成 x * 3

习题自测登记:完成本章习题后在此登记