跳转至

第五章 · 函数

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

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

1 · 星号空心三角形

编写一个函数,已知参数 n,在屏幕的中间显示一个用星号字符绘制的空心的三角形图案。

例如,n=4,函数显示的图案为:

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

/* 显示 n 行空心三角形;每行前面先输出 start 个空格,用于在屏幕中间显示 */
void triangle(int n, int start)
{
    int i, j;

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

int main(void)
{
    triangle(4, 0);
    return 0;
}

n = 4、start = 0 时的输出:

*
* *
*   *
* * * *
解析

三角形的每一行都由"若干空格 + 若干星号"拼成。样张里两个星号之间固定隔 1 个空格,所以每一列折合 2 个字符宽(一个 * 加一个空格);底边有 n 个星号。

关键点:

  1. 中间行的两个星号落在第 0 列和第 2 * (i - 1) 列上,之间要空 2 * (i - 1) - 1 格。i = 2 空 1 格得 * *,i = 3 空 3 格得 * *。
  2. 第 1 行和底边是整行星号,其余行是空心的,判据是 i == 1 || i == n。底边星号的列号是 0, 2, 4, …,正好是"打一个 * 再打一个空格"重复 n 次,所以照这个循环写就行。
  3. 题干要求"在屏幕的中间显示",每行前面要留一段空白。按 80 列宽算,底边宽 2 * n - 1(n = 4 时是 7),左边留白 (80 - 7) / 2 = 36 格。函数把留白数当形参 start 传进来,图案本身和它的位置就分开了。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序输出 4 行,与题干样张逐行一致;把调用换成 start = 36 再跑一次,4 行都是第 37 列才出现星号,即每行前面 36 个空格。

样张画的是图案本身

题干样张第一行的 * 顶在最左边,没有画居中留白。要落在屏幕中间就按上面第 3 条算留白;不需要居中时把 start 传 0 即可。

易错:

  1. 星号之间多打了空格(写成 2 个以上),画出来比样张宽
  2. 中间行的空格数写成 2 * (i - 1),多打一格,右边的星号偏出去
  3. 漏掉整行都是星号的底边,或者把第一行也当成空心行

2 · 随机出题的乘法测验

为低年级小学生编写一个两个整数乘法的测验程序。程序利用随机函数产生两个整数,并给出算式请小学生输入解答。程序对正确的解答给予鼓励;对不正确的解答给出正确的答案。

另外,为了让程序更有实用性,输入整数的范围也可由用户指定,如一位数乘法、两位数乘法等。使用随机函数的程序有以下要求:

(1)在程序前面包含以下代码:

#include<stdlib.h>
#include<time.h>  /*有关时间库函数*/

(2)主函数先用以下代码为随机函数初始化:

srand(time(NULL));

(3)用以下代码随机取 1~9 的整数 k:

k=1+rand()%9;

为了提高小学生的学习兴趣,程序的回答也希望有所改变,如对正确的回答可以有多种选择,如“Very good!”、“Excellent!”、“Keep up the good work!” 等。

同样,对于错误的响应也可以有多种选择,如“No. Please try again.”、“Wrong. Try once more.”、“No. Keep trying.” 等。

利用随机函数选择一种回答。

答案
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>

/* 随机挑一句鼓励的话 */
void good(void)
{
    switch (1 + rand() % 3) {
    case 1:  printf("Very good!\n");              break;
    case 2:  printf("Excellent!\n");              break;
    default: printf("Keep up the good work!\n");  break;
    }
}

/* 随机挑一句"再试一次"的话,并把正确答案告诉学生 */
void bad(int right)
{
    switch (1 + rand() % 3) {
    case 1:  printf("No. Please try again.\n");   break;
    case 2:  printf("Wrong. Try once more.\n");   break;
    default: printf("No. Keep trying.\n");        break;
    }
    printf("正确的答案是 %d。\n", right);
}

int main(void)
{
    int i, u, v, ans, max;

    srand(time(NULL));

    printf("请输入乘数上限(如 9 表示一位数乘法、99 表示两位数乘法):");
    if (scanf("%d", &max) != 1 || max < 1)
        return 0;

    for (i = 0; i < 3; i++) {
        u = 1 + rand() % max;
        v = 1 + rand() % max;
        printf("%d * %d = ? ", u, v);
        if (scanf("%d", &ans) != 1)
            return 0;
        if (ans == u * v)
            good();
        else
            bad(u * v);
    }
    return 0;
}
解析

题目给的三段代码分别解决三件事:取随机数要包含的头文件、给随机数发生器播种、在一段范围里取一个整数。程序把这三件事串起来,就是"出题 → 收答案 → 给回答"的循环。

关键点:

  1. srand(time(NULL)); 是播种。不播种的话 rand() 每次都从同一串数开始,出的题每回一样,测验就没有意义了。time(NULL) 取当前时间(秒),每次运行都不同,所以种子也就不同。
  2. 在 1 ~ max 里取随机整数写成 1 + rand() % max。rand() % max 落在 0 ~ max - 1,加 1 之后就是 1 ~ max。上限由学生输入:输 9 出一道一位数乘法,输 99 出一道两位数乘法。题目里给的 k = 1 + rand() % 9 就是这个写法在 max = 9 时的样子。
  3. 回答也用随机数挑:1 + rand() % 3 得到 1、2、3,对应 switch 的三个分支。鼓励的话和"再试一次"的话各挑各的,两边都随机。
  4. 答错时要把正确答案打出来,所以 bad() 带一个形参 right。这里正好用上"实参把值传给形参"这件事。
  5. scanf 的返回值是成功读到的项数,判一下可以挡住空输入和非法输入。

把 srand(time(NULL)) 换成固定种子,题目和回答就都能复现:三个算式依次是 2 * 1、7 * 2、3 * 3;第 1、3 题答对,走的是鼓励分支(这次挑到的是 Excellent!),第 2 题答 12 与正确答案 14 不符,走的是"给出正确答案"分支(这次挑到的是 No. Keep trying.)。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序在 -fsyntax-only 下通过、0 条警告;把种子固定成 srand(2026) 后喂入 9 2 12 9,输出依次是 2 * 1 = ? Excellent!、7 * 2 = ? No. Keep trying. 加 正确的答案是 14。、3 * 3 = ? Excellent!。

一位数、两位数的范围怎么写

上限是"乘数的最大值",不是位数。一位数乘法传 9(得 1 ~ 9),两位数乘法传 99(得 1 ~ 99)。

这个范围内也会出现一位数,属于正常现象;非要保证两位数,就把两个乘数换成 10 + rand() % 90。

易错:

  1. 忘了 srand(),每次运行都出同一套题
  2. 取范围时漏掉 + 1,rand() % max 会取到 0,出现 0 * 5 = ? 这样的算式
  3. 回答只在"对"或"错"里各写死一句话,没有随机挑选
  4. 答错时只打"错了",没有把正确答案告诉学生

3 · 猜数游戏

编写一个玩猜数游戏的程序。程序随机产生某个范围内的正整数,让人猜这个整数。

当猜中时输出鼓励信息;当未猜中时给出猜得太小或太大的提示信息。

答案
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void)
{
    int secret, guess;

    srand(time(NULL));
    secret = 1 + rand() % 100;

    printf("我已经想好了一个 1 到 100 之间的正整数,请你猜:\n");
    for (;;) {
        printf("请输入你的猜测:");
        if (scanf("%d", &guess) != 1)
            break;
        if (guess == secret) {
            printf("猜对了,真棒!\n");
            break;
        } else if (guess < secret) {
            printf("太小了,再试一次。\n");
        } else {
            printf("太大了,再试一次。\n");
        }
    }
    return 0;
}
解析

程序分成两半:先随机定一个答案,再反复读猜测、按大小给提示,直到猜中为止。

关键点:

  1. 答案用 1 + rand() % 100 取,范围是 1~100;同题 2 一样,先用 srand(time(NULL)) 播种。
  2. 循环写成 for (;;),出口有两个:猜中时 break,输入读不到数时也 break(否则 scanf 一直失败会变成死循环)。
  3. 三种情况互斥,用 if / else if / else 串起来最清楚:相等就是猜中,小于答案提示"太小",剩下的必然是太大。
  4. 提示信息要说清方向,学生才知道下一次往哪边猜。

把随机答案固定成 42 再跑,输入依次是 50、30、42:第一次提示"太大了,再试一次。",第二次提示"太小了,再试一次。",第三次输出"猜对了,真棒!"并结束。

中途那两条提示正好把答案夹在 30 到 50 之间。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序在 -fsyntax-only 下通过、0 条警告;把 secret = 1 + rand() % 100; 换成 int secret = 42;、喂入 50 30 42 后运行,三行提示依次是"太大了,再试一次。""太小了,再试一次。""猜对了,真棒!"。

易错:

  1. 用 = 代替 == 比较(guess == secret 才是比较)
  2. 只写"猜错了",不说太大还是太小
  3. 循环没有出口,猜中之后还在继续要输入
  4. 忘了 srand(),每次运行都是同一个答案

4 · 两个递归程序的功能

读程序,指出以下程序的功能。

程序 1:

#include<stdio.h>
void main()
{  int c;
   if((c=getchar())!='\n')
   {  main();   printf("%c",c);
   } else printf("\n");
}

程序 2:

#include<stdio.h>
int mul(int,int);
void main()
{  int u,v,w;
   printf("Enter two integers!\n");  scanf("%d%d",&u,&v);
   if(v>=0)  w=mul(u,v);
   else w=-mul(u,-v);
   printf("The result is %d\n",w);
}
int mul(int a,int b)
{  if(a==0||b==0)  return 0;
   if(b==1)  return a;
   return a+mul(a,b-1);
}
答案

程序 1 的功能:把从键盘输入的一行字符按相反的顺序输出。读到换行符为止,换行符本身不打印,而是在最内层先打一个换行;也就是说,这一行字符被"倒着"打了一遍。

这里是用 main 的递归调用代替了循环。

程序 2 的功能:读入两个整数 u 和 v,用递归函数 mul() 算出它们的乘积并输出。mul() 没有用 *,而是把乘法拆成加法:mul(a, b) 等于 b 个 a 相加。

v 为负时先取绝对值算,再补上负号。

解析

两个程序都是"递归代替循环"的例子,而递归的位置决定了输出顺序。

程序 1 的执行过程(输入 abc 加回车):

  • 第一次调用 getchar() 读到 a,不是换行,于是先把递归调用 main() 做掉,而把 printf("%c", c) 留到后面再说。
  • 第二、三次调用读到 b、c,同样先往下递归。
  • 第四次调用读到换行符,走 else 分支打一个换行,然后返回。
  • 返回的路上才轮到前面几次调用留下的 printf,它们按"后进先出"的顺序打印,于是打出的字符依次是 c、b、a。

所以输出是"先一个换行、再 cba",末尾没有再打换行。输入别的内容时读法一样,效果就是整行逆序。

这也解释了为什么这个程序必须靠递归、不能改成顺序语句:要是把 printf 挪到递归调用前面,打出来的就是原顺序了。

程序 2 的核心是 mul() 的递归定义:

  • mul(a, 0) 和 mul(0, b) 返回 0;
  • mul(a, 1) 返回 a;
  • 其余情况返回 a + mul(a, b - 1),也就是把 a 一个一个加上去,加了 b 次。

取 u = 5, v = 3 时,mul(5, 3) = 5 + mul(5, 2) = 5 + 5 + mul(5, 1) = 5 + 5 + 5 = 15。main() 里那句 if (v >= 0) … else w = -mul(u, -v); 是在处理负乘数:v 为负时先用 -v 当正数算,最后再取负号,所以 -5 乘 3 与 5 乘 -3 都得到 -15。

两个程序按题干的写法都能编过,问题出在 void main() 上:gcc 15.2.0(-O2 -std=gnu23 -Wall)会给出警告。把两个程序分别存成 q4p1_orig.c、q4p2_orig.c 送编译,警告原文是:

q4p1_orig.c:2:6: warning: return type of ‘main’ is not ‘int’ [-Wmain]
    2 | void main()
      |      ^~~~
q4p2_orig.c:3:6: warning: return type of ‘main’ is not ‘int’ [-Wmain]
q4p2_orig.c:5:38: warning: ignoring return value of ‘scanf’ declared with attribute ‘warn_unused_result’ [-Wunused-result]

核实输出时只做了两处最小修改,其余一字未动:

  1. 程序 1 的 void main() 改成 int main(void)
  2. 程序 2 的 void main() 改成 int main(void)

程序 2 里那句 scanf 的返回值仍然没被使用,所以修改版还是会带一条 -Wunused-result 警告,这是题干写法本身带的,没有动它。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):最小修改后的程序 1 喂入 abc 加回车,输出的 4 个字节是"换行、c、b、a",末尾不带换行;程序 2 喂入 5 3 输出 The result is 15,喂入 -5 3 输出 The result is -15,喂入 7 0 输出 The result is 0。

void main() 是教材年代的写法

标准里 main 的返回类型只能是 int,gcc 在 -Wmain 下警告。2013 年的教材里 void main() 很常见,照题干读程序没问题,但自己写程序要写 int main(void)。

另外,void main 没有返回值,进程的退出状态是寄存器里的残留值(这次跑程序 1 得到的是 97),改成 int main 之后就是规范里的 0。

程序 2 的 mul() 只对非负的第二个实参收敛

mul(a, b) 每递归一次 b 减 1,直到 b 等于 1 或 0 才停。要是直接拿一个负的 b 进来,b 只会越减越小,递归永远不结束。main() 里先判 v >= 0、负数走 -mul(u, -v),正是为了不让负乘数进到 mul() 里。

易错:

  1. 把程序 1 的功能说成"输入一行再输出一行"(它输出的是逆序)
  2. 以为程序 1 的输出末尾有换行(换行在最前面,最后打印的是第一个字符)
  3. 把 mul() 读成"用乘法求积",没看出它是靠加法递归实现的
  4. 觉得 -mul(u, -v) 里的两个负号多余

5 · 时分秒与当天秒数

编写一个函数,用时间时、分和秒作为函数的 3 个形参,函数返回自 0 点钟到指定时间的秒数,并用这个函数计算同一天两个时间之间的秒数。

答案
#include <stdio.h>

/* 返回从 0 点钟到 h 时 m 分 s 秒的秒数 */
int seconds(int h, int m, int s)
{
    return h * 3600 + m * 60 + s;
}

/* 同一天两个时间之间的秒数 */
int diff(int h1, int m1, int s1, int h2, int m2, int s2)
{
    return seconds(h2, m2, s2) - seconds(h1, m1, s1);
}

int main(void)
{
    printf("%d\n", seconds(0, 0, 1));            /* 0 点过 1 秒 */
    printf("%d\n", seconds(12, 0, 0));           /* 中午 12 点 */
    printf("%d\n", seconds(23, 59, 59));         /* 一天里的最后一秒 */
    printf("%d\n", diff(8, 30, 0, 12, 45, 30));  /* 8:30:00 到 12:45:30 */
    printf("%d\n", diff(12, 45, 30, 8, 30, 0));  /* 反过来减 */
    return 0;
}
解析

思路:先把"时、分、秒"按 1 小时 3600 秒、1 分钟 60 秒折成秒数相加,这就是本题要写的第一个函数;两个时间的差直接调用它两次相减,不必重新写一遍换算。

关键点:

  1. h * 3600 + m * 60 + s 是换算的全部内容。三个形参的类型都是 int,返回类型也是 int——最大的秒数是一天的 86 399,不会溢出。
  2. 第二个函数 diff() 把 6 个形参分成两组,各调用一次 seconds() 再相减。题干说"用这个函数计算两个时间之间的秒数",指的就是这一层复用。
  3. 相减的顺序决定正负:前一个时间在后一个之前,差是正数;反过来减就是负数。题目没有限定先后,两种都算对,写清哪个减哪个就行。
  4. 形参的顺序按"时、分、秒"排,调用时别把分和秒的位置写反(diff(8, 30, 0, 12, 45, 30) 里 30 是分、45 是分)。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序输出 5 行,依次是 1、43200、86399、15330、-15330。其中 15330 秒就是 4 小时 15 分 30 秒,正是 8:30:00 到 12:45:30 的间隔。

为什么 12 点是 43200 秒

12 × 3600 = 43200,正好是 86400 的一半;23:59:59 是 86399,即一天的最后一秒。这两个值可以拿来核对换算有没有写错。

易错:

  1. h * 3600 写成 h * 60,把小时和分钟按同一个数折算了
  2. 求差时只算秒数不换算,比如拿 30 减 0 当结果(正确做法是两个时间各自折成秒数再减)
  3. 形参顺序写乱,把 diff(8, 30, 0, …) 里的分和秒对调,算出来的差就不是 15330 秒了

6 · 完全数的判断与列举

如果一个整数(整数>1)的各因子(包括 1,但不包括整数自身)之和等于该整数,称这样的整数为完全数。

例如,因为 6=1+2+3,所以 6 是完全数。

编写一个已知整数判断其是否是完全数的函数,并用该函数输出 1 000 之内的所有完全数。

答案
#include <stdio.h>

/* 判断 n 是否是完全数:各因子(包括 1,不包括自身)之和等于 n */
int isPerfect(int n)
{
    int i, sum = 0;

    for (i = 1; i <= n / 2; i++)
        if (n % i == 0)
            sum += i;
    return sum == n;
}

int main(void)
{
    int n;

    for (n = 2; n <= 1000; n++)
        if (isPerfect(n))
            printf("%d ", n);
    printf("\n");
    return 0;
}
解析

思路:判断一个数是不是完全数,只能把它所有真因子加起来跟它自己比。所以先写一个"求和再比较"的函数,主函数只负责在 2 到 1000 之间挨个调用它。

关键点:

  1. 因子从 1 开始试,所以循环变量初值是 1——1 也是因子,漏掉它就把 6 也判掉了。
  2. 循环上界取 n / 2:一个整数的真因子不可能超过它的一半(n 自己不算)。试到 n / 2 就够,写 i < n 多跑一倍并且要额外把 n 自己排除掉。
  3. 判据是 sum == n 而不是 sum >= n。函数的返回类型写成 int,用 0 和 1 表示真假即可。
  4. 主函数从 2 开始(题干说明整数大于 1),一个数一个数地过。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序在 1 000 之内筛出三个完全数,依次是 6、28、496。手工核对:28 的真因子 1、2、4、7、14 相加得 28;496 的真因子相加也正好得 496。

1000 之内的完全数只有三个

6、28、496 之后的下一个是 8 128,已经超过 1 000。所以本题的输出就是这三个数,多一个少一个都说明筛法有问题。

易错:

  1. 循环从 i = 2 开始,漏掉因子 1(6 会被判成不是完全数)
  2. 循环写成 i <= n,把 n 自己也算进因子和里,sum 至少是 n + 1,结果一个完全数都筛不出来
  3. 判据用 sum > n 或 sum >= n
  4. 只试到 sqrt(n)(那样会漏掉大于平方根的那些因子)

7 · 质数的判断与列举

编写判断已知正整数是否是质数的函数,并利用该函数输出 1 000 之内的所有质数。

答案
#include <stdio.h>

/* 判断 n 是否是质数:小于 2 不是;否则试除到 sqrt(n) */
int isPrime(int n)
{
    int i;

    if (n < 2)
        return 0;
    for (i = 2; i * i <= n; i++)
        if (n % i == 0)
            return 0;
    return 1;
}

int main(void)
{
    int n, k = 0;

    for (n = 2; n <= 1000; n++)
        if (isPrime(n)) {
            printf("%4d", n);
            if (++k % 10 == 0)
                printf("\n");
        }
    if (k % 10 != 0)
        printf("\n");
    printf("共 %d 个\n", k);
    return 0;
}
解析

思路:质数是"除了 1 和它本身,没有别的因子"的正整数。判断的办法是拿 2、3、4……挨个去除,只要除得尽就不是质数;除到平方根就够了,再往后的因子一定和前面某个因子配对。

关键点:

  1. 先挡掉 n < 2 的情况:1 不是质数,0 和负数也不是。这一步漏了,1 会被当成质数打出来。
  2. 循环条件写 i * i <= n,等价于"试除到 sqrt(n)",但不用 #include <math.h>,也不会有浮点误差。写成 i <= n / 2 结果一样,只是多跑很多次;写成 i < n 也行,慢一些。
  3. 主函数边筛边排版:每打满 10 个数换一行(%4d 让每列对齐)。最后那个 if (k % 10 != 0) 是给不满一行的末尾补一个换行。
  4. 顺带把个数统计出来,便于核对。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序输出 17 行、每行 10 个数(末行 8 个),共 168 个质数,从 2 起、到 997 止。

平方根上界为什么够

如果 n = a * b 且 a <= b,那么 a <= sqrt(n)。也就是说,只要 n 有真因子,其中必有一个不超过 sqrt(n),试到 sqrt(n) 就能把它抓住;一个都没抓到,n 就是质数。

易错:

  1. 忘了挡 1,把 1 打进质数表
  2. 循环条件写成 i <= n,n % n == 0 恒成立,每个数都会被判成合数,连 2 都筛不出来
  3. 用 #include <math.h> 的 sqrt() 又忘了 -lm,链接不过
  4. 输出时忘了换行处理,168 个数全挤在一行

8 · 递归求最大公约数

试按下面的定义编写求两整数最大公约数的递归函数 gcd():

            ⎧ a              , a=b
gcd(a, b) = ⎨ gcd(a-b, b)    , a > b
            ⎩ gcd(a, b-a)    , a < b

要求函数 gcd() 能输出形参值 a 和 b,以便能观察函数 gcd() 的递归计算过程。

答案
#include <stdio.h>

/* 按 gcd(a,b) = a(a=b)、gcd(a-b,b)(a>b)、gcd(a,b-a)(a<b)的递归定义求最大公约数 */
int gcd(int a, int b)
{
    printf("gcd(%d, %d)\n", a, b);   /* 每次进入函数先报一次形参值 */
    if (a == b)
        return a;
    if (a > b)
        return gcd(a - b, b);
    return gcd(a, b - a);
}

int main(void)
{
    printf("result = %d\n", gcd(12, 8));
    return 0;
}

求 gcd(12, 8) 时的输出:

gcd(12, 8)
gcd(4, 8)
gcd(4, 4)
result = 4
解析

思路:题干给的三行定义就是辗转相减法。递归分三层:两个数相等时已经到出口,直接返回它俩;前一个大就把"大减小"的结果接着递归;后一个大就反着减。

题目额外要求打印形参,所以进函数第一件事就是打印。

关键点:

  1. 打印语句要放在递归调用之前(也就是函数一进来就打)。放在后面的话,打印顺序就变成从出口往回退的顺序,看不到"一路减下去"的过程。
  2. 出口是 a == b,返回 a。因为每一步都在保持"两个数的最大公约数不变"这个性质,减到最后相等时那个相等的值就是最大公约数。
  3. 两次递归调用各有各的条件:a > b 走 gcd(a - b, b),剩下的 a < b 走 gcd(a, b - a)。第二种写法不能省掉 return。
  4. 两个形参都必须是正整数。若其中一个为 0,比如 gcd(0, 5):a < b 成立,接下来还是 gcd(0, 5),永远出不去。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面这份程序打印 3 行 gcd(...) 再打印结果:gcd(12, 8) → gcd(4, 8) → gcd(4, 4),最后 result = 4。第一次 12 减 8 得 4,第二次 4 小于 8 改成用 8 减 4 得 4,第三次两数相等,返回 4——和手工算的 12 与 8 的最大公约数一致。

减法的递归深度比取余大得多

每递归一次,两个数里较大的那个只减少一份小的那个。求 gcd(1000000, 1) 要递归一百万次,栈很可能撑不住。

这一定义是教材用来演示递归的,实际要用还是辗转相除(用 %)快。不过本题要求按给的定义写,就不换成取余了。

易错:

  1. 把 printf 写在递归调用之后,打印出来的顺序是反的
  2. 出口写成 a == 0 || b == 0 时返回 0(那样 gcd(12, 8) 会一路减到 gcd(4, 0) 返回 0)
  3. 两次递归调用的条件写成一样的(a > b 和 a < b 要分清楚,前者减 a,后者减 b)
  4. 第一次调用就传了 0 或负数进去

9 · 递归逆置整数

试用递归函数,返回与所给十进制整数相反顺序的整数,如已知整数是 1 234,函数返回值是 4 321。

答案
#include <stdio.h>

/* 辅助递归:把 n 的末位不断接到 acc 的后面 */
static int rev(int n, int acc)
{
    if (n == 0)
        return acc;
    return rev(n / 10, acc * 10 + n % 10);
}

/* 返回与 n 相反顺序的整数,如 1234 返回 4321 */
int reverse(int n)
{
    return rev(n, 0);
}

int main(void)
{
    printf("%d\n", reverse(1234));
    printf("%d\n", reverse(7));
    printf("%d\n", reverse(1200));
    printf("%d\n", reverse(1234567891));
    return 0;
}
解析

思路:一个整数的末位是 n % 10,去掉末位是 n / 10。"逆序"就是把每次取下来的末位依次接到结果的右边——先取下来的 1 最后要落在最高位上。

所以递归里带一个形参 acc 记住"已经攒出来的那部分",每往下递归一层就先把它左移一位(acc * 10),再把当前的末位加上去。

以 reverse(1234) 为例,递推过程是:

  • rev(1234, 0) → rev(123, 4)
  • rev(123, 4) → rev(12, 43)
  • rev(12, 43) → rev(1, 432)
  • rev(1, 432) → rev(0, 4321)
  • n 变成 0,到出口,返回 acc,得 4321。

关键点:

  1. 出口是 n == 0。写 n < 10 也可以(那时 acc * 10 + n 一步算完),但 n == 0 能把 0 本身也处理掉。
  2. 带一个形参 acc 存中间结果,就不需要事先知道这个数有几位;不用 pow() 或 log10() 去算位数,也就没有浮点误差的麻烦。
  3. 接位的动作是 acc * 10 + n % 10,那个乘 10 一步都不能省——省掉就成了把各位数字加起来。
  4. 函数 rev() 只给本文件用,加上 static 修饰;reverse() 只当入口,把初值 0 传进去。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面这份程序依次输出 4321、7、21、1987654321。另外单独试过 reverse(0) 得 0、reverse(-123) 得 -321。

末位的 0 会消失

reverse(1200) 得 21 而不是 0021——0021 这个写法在整数里就是 21。这不是算错了,是"相反顺序的整数"这个说法本身丢掉了前导零。

要是想保留位数,就得按字符串或数组来存,不能返回整数。

易错:

  1. 接位时漏掉 acc * 10
  2. 用 pow(10, 位数 - 1) 算每一位的权重,位数用 log10() 数出来,浮点误差会让大数算错
  3. 返回类型用 int 却拿它逆置一个接近十位的数(1234567891 逆过来是 1987654321,还在 int 范围内,再大就溢出了)
  4. 递归里忘了写 return,函数没有返回值

10 · 记录函数被调用的次数

试给出一种解决方案,能让函数知道自己被别的函数调用多少次。

答案

用静态局部变量。在函数里声明一个带 static 的计数器,它只在第一次调用时初始化,函数返回后这个变量仍然存在,下一次进来接着加。

#include <stdio.h>

/* 静态局部变量:只在第一次调用时初始化,函数返回后它的值仍然保留 */
int counter(void)
{
    static int count = 0;

    return ++count;
}

int main(void)
{
    int i;

    for (i = 0; i < 3; i++)
        printf("%d ", counter());
    printf("\n");
    return 0;
}

连续调用 counter() 依次得到 1、2、3。

解析

这题的关键是找出一个"满足两个条件的变量":既只归这一个函数管,又要在函数返回后还活着。自己数一数 C 里的几种变量,符合条件的只有静态局部变量。

为什么普通局部变量不行:它在函数被调用时才建立、返回时就销毁,每次进来都重新初始化一遍。所以每调用一次都是 1,永远数不上去。

三种可行的方案:

  1. 静态局部变量(首选):static int count = 0; 写在函数里,作用域还是本函数,别的函数看不见;但存储位置在静态存储区,生存期是整个程序运行期间,初始化只做一次。
  2. 文件作用域的外部变量:在文件开头写一个 int count = 0;,函数里加。能实现,但别的函数也能随手改它,函数不再"自己知道"——共享面太大。
  3. 由调用者把计数器传进来:函数的形参多一个 int *count,每次调用时 (*count)++;。计数存在调用者那边,函数自己不保存状态。

关键点:

  1. static 修饰局部变量只改变生存期,不改变作用域——它仍然只能在声明它的那个函数里用。
  2. 初始化只发生一次。static int count = 0; 这句话在后面的调用里会被跳过,所以计数不会被清零。
  3. 顺序上要"先加再返回"(return ++count;)才能得到 1、2、3;写 return count++; 得到的是 0、1、2。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面这份程序输出 1 2 3;把 static 去掉、写成普通局部变量的对照程序输出 1 1 1。

别在同一个 printf 里连写几次调用

printf("%d %d %d\n", counter(), counter(), counter()); 这种写法,三个实参的求值顺序标准没有规定(gcc 实际上是从右往左算),打出来是倒过来的 3 2 1。计数并没有错,是"谁先算"没被规定。

要按顺序看,就一次调用一条语句。

易错:

  1. 用普通局部变量做计数器,每次都从 0 开始
  2. 看到 3 2 1 不是计数反了,是实参求值顺序的问题
  3. 把 static 写成全局变量,虽然能数,但解答偏离了"函数自己知道"这个要求
  4. 初始化语句写成 count = 0;(那每次调用都会清零),必须是 static int count = 0; 带着初始化

11 · 四个变量的存储类别

试分别指出以下数据对象的存储类别。

(1)x 是函数的整型形参。

(2)y 是某函数专用,并能保留函数前次调用结果的变量。

(3)z 只能让同一源程序文件中的函数共享的变量。

(4)d 是能让程序中许多函数共享的变量。

答案
序号 答案
(1) auto,自动存储类别(形参)
(2) static,静态局部变量
(3) static,静态外部变量(文件作用域)
(4) 外部变量(其他文件用 extern 声明即可共享)
解析

四小题对应存储类别的四种基本情况,判据只有两条:变量在哪个范围里可见(作用域),以及它活多久(生存期)。

逐项说明:

  • (1)形参的存储类别是自动的(auto)。函数被调用时给它分配空间,函数返回时随栈一起释放,所以每次调用都要重新传值。C 里能写在形参上的存储类说明符只有 register 一个,它也只是给编译器的优化建议,平时一个字都不用写。
  • (2)"某函数专用"说的是作用域限于本函数,"保留前次调用结果"说的是生存期不止一次调用——两条合起来只有静态局部变量,写法是 static int y; 写在函数体内。
  • (3)"只能让同一源程序文件中的函数共享"说的是文件作用域加内部链接,也就是在文件开头写 static int z;,这叫静态外部变量。它跟普通全局变量的区别只在链接:别的源文件里写 extern int z; 也引用不到它。
  • (4)"让程序中许多函数共享"就是外部变量(全局变量),在文件开头(所有函数之外)写 int d;。它在整个程序里都有外部链接,别的源文件写一句 extern int d; 就能用同一个变量。

编译、链接探测的结果和上面的判断一致:

  • 一个源文件里写 static int z;、另一个源文件里写 extern int z;,两个文件一起链接时报 undefined reference to ‘z’,也就是文件外确实看不见它。
  • 一个源文件里写 int d = 10;、另一个文件里 extern int d;,链接通过;第二个文件把 d 改成 20 之后,第一个文件里读到的就是 20(输出 a=10 d=10、a=20 d=20),说明许多函数能共享同一个外部变量。
  • 静态局部变量确实能保留上一次的值:同一个函数连着调用 5 次,依次返回 1、2、3、4、5,没有哪一次回到初值。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面三条探测各自跑过,报错原文与输出就是正文里列的这些;另外 auto int x = 3; 这种写法在 -std=gnu23 和 -std=c89 下都能编译。

C23 给 auto 添了新用法

C23 起 auto 还能当类型说明符用,写 auto x = 3; 是"由初值推导类型"的意思,x 是 int(本机在 -std=gnu23 下编译运行,输出 3)。本题问的是形参的存储类别,答案仍是自动存储类别 auto,和教材年代一致;新用法只是提醒一下 auto 这个关键字如今有两种身份。

易错:

  1. (2)答成 auto 或 register(都保不住上一次的值)
  2. (3)答成外部变量(那样别的源文件也能用,与"只能让同一源文件共享"不符)
  3. (4)答成 static(加 static 就成了文件私有的)
  4. 把"存储类别"理解成"数据类型"(int、float 是类型,不是存储类别)

12 · TwoRound 宏的意义

试简要叙述以下宏定义的意义。

#define TwoRound(x) (int)((x)*100+0.5)/100.0
答案

这个宏的意义是:把 x 按两位小数做四舍五入,返回处理后的值。名字里的 Two 指的就是两位小数。

把它当成一个语句看,四步依次是:

  1. (x)*100:小数点右移两位。原来小数点后第三位被顶到了个位上,整数部分则退到百位以上。
  2. +0.5:给这个数加半个 1。个位上的值原本就是"第三位小数",加上 0.5 之后,只要它不小于 5 就会进到十位上去。
  3. (int):截掉小数部分。到这一步,得到的正是"乘 100 之后四舍五入"的整数。
  4. /100.0:除回 100,变回小数,也就是保留两位小数的结果。

例如 TwoRound(3.14159) 是 3.14,TwoRound(2.5) 是 2.50,TwoRound(2) 得 2.00。

解析

宏展开是纯文本替换,TwoRound(3.14159) 展开之后就是:

(int)((3.14159)*100+0.5)/100.0

实参 x 在宏体里只出现一次,而且写成了 (x),所以传 1 + 2 这样的表达式进来也不会被运算符优先级拆开——这两点比一般函数式宏安全。

可它仍然是个宏,不是函数,两处地方要留神。

第一处是小数本身的二进制表示。浮点数存不进精确的十进制小数,1.005 和 2.675 在 double 里分别是:

1.005 = 1.00499999999999989342
2.675 = 2.67499999999999982236

两个都略小于字面值,可它们乘 100 之后的表现却不一样:

  • 1.005 * 100 得 100.49999999999998578915,加上 0.5 是 100.99999999999998578915,(int) 截断得 100,所以 TwoRound(1.005) 是 1.00,不是想当然的 1.01。
  • 2.675 * 100 按双精度舍入,正好落在 267.5 上,加上 0.5 得 268.0,截断得 268,所以 TwoRound(2.675) 是 2.68。

两次的差别全在"乘 100 那一步舍入到了哪一边",跟这个宏的写法无关;换成别的两位小数,只要第三位是 5,就有踩中的可能。

第二处是 (int) 的截断方向。C 里整型转换是向零截断的,负数上就表现为"绝对值被缩小":TwoRound(-1.005) 先算 -100.4999… + 0.5 = -99.9999…,截断得 -99,结果 -0.99。

要在负数上也做四舍五入,得按符号减 0.5。

宏体还有个括号问题:整体没有括号,100 / TwoRound(2.5) 展开成 100 / (int)(2.5*100+0.5) / 100.0,那个 100 被卷进了整数除法里,算出来是 0.000000。

要给别人用,宏体应该写成 ((int)((x)*100+0.5)/100.0)。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):TwoRound 取 1.005、2.675、3.14159、2.5、0.125、-1.005、2 时依次得 1.00、2.68、3.14、2.50、0.13、-0.99、2.00;printf("%f\n", 100 / TwoRound(2.5)) 输出 0.000000。

名字与 0.5 的分工

+0.5 负责"入",(int) 负责"舍":加 0.5 之后凡是够到下一个整数的就进上去,不够的被截掉。/100.0 里的 .0 也不能省,写成 /100 就是整数除法,结果只剩整数部分。

易错:

  1. 说成"保留两位有效数字"(是两位小数)
  2. 认为 TwoRound(1.005) 得 1.01(浮点表示决定它得 1.00)
  3. 拿它当函数,写在 100 / TwoRound(x) 这种位置
  4. 以为 (int) 本身是四舍五入(它只是截断,四舍五入是靠 +0.5 实现的)
  5. 把 /100.0 写成 /100

13 · 判断闰年的宏

写出判断某年为闰年的宏定义。

答案
#define LEAP(y) (((y) % 4 == 0 && (y) % 100 != 0) || (y) % 400 == 0)

宏展开后是个值为 0 或 1 的表达式,可以直接当条件用:if (LEAP(2024)) …。

解析

闰年的规则是两句:能被 4 整除的是闰年;但整百年份要能被 400 整除才算(所以 1900 年不是闰年,2000 年是)。把两句合成一个表达式,就是"% 4 == 0 而且不是 % 100 == 0,或者干脆 % 400 == 0"。

关键点:

  1. y 在宏体里出现 4 次,每次都要写成 (y)。少了括号,实参是个表达式时就会被优先级拆开:写 #define BAD2(y) ((y % 4 == 0 && y % 100 != 0) || y % 400 == 0),拿 BAD2(1996 + 4) 去问 2000 年,算的是 1996 + 4 % 4,得 0,把闰年判成了平年(本机编译运行验证过)。
  2. 整个表达式外面还要加一层括号。少了它,!LEAP(y) 展开后 ! 会先跟 % 结合:换成 #define BAD(y) (y) % 4 == 0 && (y) % 100 != 0 || (y) % 400 == 0 送编译,BAD(2024) 得 1、!BAD(2024) 也得 1,自相矛盾;gcc 对这种写法还会给出 -Wparentheses 警告:suggest parentheses around ‘&&’ within ‘||’。
  3. 两个条件的位置不能颠倒。写成 ((y) % 4 == 0 || (y) % 400 == 0) 会把 1900 判成闰年;只写 (y) % 400 == 0 又会漏掉 2024 这种普通闰年。
  4. 反过来写也行:((y) % 4 == 0 && ((y) % 100 != 0 || (y) % 400 == 0))。% 4 != 0 时后半截本来就不会算,两种写法的结果一样。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案的宏取 1900 得 0、2000 得 1、2024 得 1、2023 得 0、2026 得 0;拿它筛 1896 到 2096 之间每 4 年一个的年份,1900 被挡在外面、2000 在名单里。

宏的参数会被求值多次

LEAP(y) 展开后 y 出现 4 次,所以实参不能带副作用:写 LEAP(year++) 会让 year 自增 4 次。要反复用的判闰年逻辑,写成 static inline 函数更稳(参数只求值一次,也有类型检查)。

易错:

  1. 只写 (y) % 4 == 0,把 1900 判成闰年
  2. y 不加括号,实参写成表达式时算错
  3. 整体不加括号,宏嵌进 ! 或 && 里结果就不对
  4. 把 % 100 != 0 写成 % 100 == 0(条件反了)

14 · 三整数取最大的宏

写出从 3 个整数中找出最大数的宏。

答案
#define MAX3(a, b, c) ((a) > (b) ? ((a) > (c) ? (a) : (c)) : ((b) > (c) ? (b) : (c)))
解析

思路:先在两数之间取大,再拿这个结果和第三个数比。用嵌套的条件运算符(?:)一层就写完,不需要中间变量。

关键点:

  1. 外层先判 a > b:成立就在 a 和 c 之间取大,不成立就在 b 和 c 之间取大。三条分支各自返回一个数,没有漏洞。
  2. 三个参数每次都写成 (a)、(b)、(c),整体再套一层括号。这是函数式宏的两条基本规矩:参数不加括号会被优先级拆开,整体不加括号会被外层运算符抢走优先级。
  3. 更一般的写法和它等价:先 #define MAX2(a, b) ((a) > (b) ? (a) : (b)),再 #define MAX3(a, b, c) MAX2(MAX2(a, b), c)。两层括号都在,展开后同样安全。
  4. 宏没有类型,什么类型都能比:传 int、char、double 都行,比较按各自的类型规则走。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):MAX3(3, 7, 5) 得 7,MAX3(9, 7, 5) 得 9,MAX3(3, 7, 15) 得 15,MAX3(-3, -7, -5) 得 -3,MAX3('a', 'B', 'c') 得 99('c' 的码值)。

参数带副作用时不能用

宏体里每个参数都出现两次,实参带副作用就会被算两次。拿 MAX3(i++, -5, 3)(i 初值 0)试一下:两次比较都成立,i++ 真的执行了两次,i 变成 2。

要是写成 printf("%d %d\n", MAX3(i++, 5, 3), i);,gcc 还会报 warning: operation on 'i' may be undefined [-Wsequence-point]——同一个表达式里既改 i 又读 i,属于未定义行为。要多次求值安全的版本,写成函数。

易错:

  1. 参数或整体漏括号,宏嵌进别的表达式里算错
  2. 只比较了两个数(比如只写 (a) > (b) ? (a) : (b),漏掉 c)
  3. 拿带 ++、-- 的变量当实参
  4. 把 ?: 的嵌套层次写错,a 比 b 大却在 b、c 之间取大

15 · 输出整数数组的宏

写出输出整数数组的宏定义,要求数组元素个数和一行输出多少个整数作为宏的形参。

答案
/* a 是数组名,n 是元素个数,per 是一行输出多少个 */
#define PRINT_ARRAY(a, n, per)                        \
    do {                                              \
        int _i;                                       \
        for (_i = 0; _i < (n); _i++) {                \
            printf("%d ", (a)[_i]);                   \
            if ((_i + 1) % (per) == 0)                \
                printf("\n");                         \
        }                                             \
        if ((n) % (per) != 0)                         \
            printf("\n");                             \
    } while (0)

用法:PRINT_ARRAY(a, 7, 3); 把 a 的前 7 个元素按每行 3 个打出来。

解析

思路:把"遍历数组"和"每打满 per 个换一行"两件事合在一个宏里。元素个数 n 和每行个数 per 都是宏的形参,换数组、换排版都只改实参。

关键点:

  1. (a)[_i] 里的括号不能省。实参如果是 b + 1 这样的表达式,写成 a[_i] 展开后成了 b + 1[_i],意思全变。
  2. (n)、(per) 也都要加括号,这两处都要参与比较和取余。
  3. 换行的判据是 (_i + 1) % (per) == 0:下标从 0 起,所以要用"第几个"(_i + 1)去取余,不能用下标本身。
  4. 循环之后补一句 if ((n) % (per) != 0) printf("\n");。最后一行的元素个数不满 per 时,循环里那个判据不会触发,末尾就没换行——补一句让输出总以换行收尾。
  5. 整个宏用 do { … } while (0) 包住。这样它在语法上就是一条完整的语句:写 PRINT_ARRAY(a, 7, 3); 带分号没问题,放在 if (…) PRINT_ARRAY(…); else … 里也不会把 else 挂错。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):数组 {1, 2, 3, 4, 5, 6, 7} 配 per = 3,输出三行 1 2 3、4 5 6、7;配 per = 10,输出一行 1 2 3 4 5 6 7,末尾有换行。

数字后面跟着一个空格

每次打一个元素,宏里跟的都是 printf("%d ", (a)[_i]);,所以每行行尾会多出一个空格(上面的输出按判题同款口径剥掉了行尾空白)。要整齐也可以在循环里改判据、只在元素之间补空格,但那样打出来的就不是上面这一段文本了。

易错:

  1. 数组名不加括号,传表达式进来就出错
  2. 用下标 _i % per 判换行,第一行就提前换了
  3. 忘了最后那个补换行的 if,末行不满时输出没有以换行结尾
  4. 不用 do { } while (0) 包住,宏展开成一段裸语句,放进 if / else 里出错

16 · 三个输出宏与头文件

分别写出一行输出 1 个数据项、一行输出 2 个数据项和一行输出 3 个数据项的宏定义,然后将这些定义的正文组织成一个头文件,并要求该头文件具有不会让同一个源程序文件重复包含的能力。

答案

头文件(例如存成 print_items.h):

/* 数组输出宏:一行分别输出 1 个、2 个、3 个数据项 */
#ifndef PRINT_ITEMS_H
#define PRINT_ITEMS_H

#include <stdio.h>

/* 通用宏:一行输出 k 个数据项 */
#define PRINT_ITEMS(a, n, k)                          \
    do {                                              \
        int _i;                                       \
        for (_i = 0; _i < (n); _i++) {                \
            printf("%d ", (a)[_i]);                   \
            if ((_i + 1) % (k) == 0)                  \
                printf("\n");                         \
        }                                             \
        if ((n) % (k) != 0)                           \
            printf("\n");                             \
    } while (0)

/* 三个宏只是把 k 固定成 1、2、3 */
#define PRINT_1(a, n) PRINT_ITEMS(a, n, 1)
#define PRINT_2(a, n) PRINT_ITEMS(a, n, 2)
#define PRINT_3(a, n) PRINT_ITEMS(a, n, 3)

#endif

用的时候:

#include "print_items.h"

int main(void)
{
    int a[] = {1, 2, 3, 4, 5, 6, 7};

    PRINT_1(a, 7);
    PRINT_2(a, 7);
    PRINT_3(a, 7);
    return 0;
}
解析

三个宏的正文只差"一行几个数"这一个参数,所以先写一个带 k 的通用宏,再用三个短宏把 k 固定下来。这样后面要改输出格式,只改一处;要加一个"一行 4 个",再加一行 #define 就行。

头文件卫士是 #ifndef / #define / #endif 三句:

  1. #ifndef PRINT_ITEMS_H:如果这个名字还没被定义过,就往下走。
  2. #define PRINT_ITEMS_H:一进来就把它定义上,这样第二次包含时上面那个 #ifndef 就不成立,整段被跳过。
  3. #endif:把整段收住。PRINT_ITEMS_H 这个名字习惯按文件名加下划线大写来写,重名概率很低。

注意卫士只包住头文件自己的内容,别把 #include <stdio.h> 也漏在外面——宏体里用到了 printf,所以这一句要在头文件里带上(有卫士挡着,重复包含时不会被插进来第二遍)。

本机验证:把上面这个头文件连着 #include 两次,编译通过、0 条警告;换成一份没有卫士的头文件、里面写了一句 int arr_size = 7;,同样包含两次,编译直接报 error: redefinition of ‘arr_size’,并指出前面那次定义在哪儿。可见卫士的作用是让"重复包含"对头文件里定义的任何东西都不产生第二次定义。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):数组 {1, 2, 3, 4, 5, 6, 7} 分别送进三个宏,PRINT_1 打出 7 行、每行 1 个数,PRINT_2 打出 4 行(末行 1 个),PRINT_3 打出 3 行(末行 1 个);每个数字后面跟一个空格。

宏定义重复出现并不报错

单单重复 #define 同一个宏(内容一样)是允许的,所以只放宏定义的头文件重复包含往往看不出问题。麻烦出在头文件里有变量定义、结构体定义的时候,那时第二次定义就是硬错误。卫士是提前把这类问题挡掉,代价只有三行。

还有一种写法是 #pragma once,gcc 支持,但它不在 C 标准里。

易错:

  1. 头文件里忘了 #include <stdio.h>,宏体用到的 printf 靠包含它的源文件去提供
  2. 卫士的宏名和别处撞了(比如用了太普通的 ITEMS)
  3. #endif 后面的注释写不写都行,但 #ifndef 与 #endif 必须配成对
  4. 三个宏各抄一遍正文,改一处要改三处

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