跳转至

第四章 · 数组

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

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

1 · 数组遍历、查找与逆置

用 C 代码描述以下计算要求:

(1)输出一维数组中下标是 4 的倍数的元素。

(2)自左至右在一维数组中找第一个值为 key 的元素的下标。

(3)将一维数组中的元素按与原先存储顺序相反的顺序重新存储。

答案
#include <stdio.h>

/* 三段合起来放进一个函数里,a 是长度为 n 的整型数组 */
void demo(int a[], int n, int key)
{
    int i, t;

    /* (1)输出下标是 4 的倍数的元素 */
    for (i = 0; i < n; i += 4)
        printf("%d ", a[i]);

    /* (2)自左至右找第一个值为 key 的元素的下标 */
    for (i = 0; i < n && a[i] != key; i++)
        ;
    /* 循环退出时:i < n 就是那个下标,i == n 说明数组里没有 key */

    /* (3)按相反的顺序重新存储 */
    for (i = 0; i < n / 2; i++) {
        t = a[i];
        a[i] = a[n - 1 - i];
        a[n - 1 - i] = t;
    }
}
解析

三个要求都是"从头到尾走一遍数组",区别只在每一步做什么:一个按固定步长跳着走,一个边走边比、比中就停,一个两头对着换。

关键点:

  1. 第(1)条的下标本身就是 4 的倍数,循环变量从 0 起步、每次加 4 就够,不必写 if (i % 4 == 0) 再判断一回。
  2. 第(2)条要的是"第一个",找到就得停。若把 for 循环写全、中途不退出,留下的是最后一个匹配元素的下标;另外"数组里没有 key"这种情况必须有明确的表示,这里用 i == n 表示,用 break 写法时也一样要判。
  3. 第(3)条只走一半(i < n / 2)。上界写成 n 会把刚换过去的元素又换回来,等于没做。交换要借一个临时变量,直接 a[i] = a[n-1-i]; a[n-1-i] = a[i]; 会丢掉一个值。
  4. 三段代码的共同边界是 n == 0:循环体一次也不执行,不会越界。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案块这段代码补上 main 跑过一遍,取 a[10] = {10,11,…,19}:第(1)条输出 10 14 18,第(2)条 key = 15 得下标 5、key = 99 得 -1,第(3)条把数组变成 19 18 17 16 15 14 13 12 11 10;编译 0 警告。

易错:

  1. 第(3)条的循环上界写成 n
  2. 第(2)条没找到时返回 0——下标 0 是合法下标,用它表示"没找到"会和"第一个元素正好是 key"混在一起
  3. 第(2)条的循环写全、中途不退出,得到的是最后一个匹配元素的下标

2 · 统计各英文字母出现的次数

输入一行字符,分别统计其中各英文字母出现的次数(不区分大小写)。

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

int main(void)
{
    int count[26] = {0};
    int c, i;

    printf("请输入一行字符: ");
    while ((c = getchar()) != '\n' && c != EOF) {
        if (isalpha((unsigned char)c))
            count[toupper((unsigned char)c) - 'A']++;
    }

    for (i = 0; i < 26; i++)
        if (count[i] != 0)
            printf("%c: %d\n", 'A' + i, count[i]);
    return 0;
}
解析

思路:开 26 个计数器,把每个字母折算成 0~25 的下标再累加,最后按字母顺序打表。

关键点:

  1. toupper(c) - 'A' 一步就把大小写并到同一个格子里,这就是"不区分大小写"的落点。若分成两组计数,最后还得再合并一次。
  2. 逐字符输入用 getchar()。scanf("%s") 遇到空白就停,一行里只有第一个单词会被统计到。
  3. isalpha、toupper 的实参先转成 unsigned char。char 在多数平台是带符号的,读到非 ASCII 字节时是负值,直接传给 ctype.h 的函数是未定义行为——标准要求实参必须是 unsigned char 能表示的值或 EOF。
  4. 输出时跳过计数为 0 的字母,不然 26 行里大半是 0。
  5. 循环同时判 '\n' 和 EOF:真正的"一行"以换行结束,输入流提前结束(比如重定向)时也要能收尾。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 Hello World 123 abc,输出 10 行,依次为 A: 1、B: 1、C: 1、D: 1、E: 1、H: 1、L: 3、O: 2、R: 1、W: 1;数字和空格没有进任何一格。

数组下标本身就是一次映射

题目要的是"每个字母各有一个计数器"。把这 26 个计数器排成一个 count[26],字母到计数器由 toupper(c) - 'A' 对应,省掉了 26 个变量和一圈查找。凡是"按取值分类计数"的题都可以这么开数组,下标的转换式就是分类规则本身。

易错:

  1. 用 count[c] 直接当下标('a' 是 97,数组得开 256 个,而且大小写还会分成两格)
  2. 用 scanf("%s") 读输入
  3. 只统计大写或只统计小写
  4. 忘了把 getchar() 返回的 EOF 排除在外

3 · 圆括号与花括号的配对检查

输入 C 程序源程序正文,找出可能存在的圆括号和花括号不匹配的错误。

答案
#include <stdio.h>

#define MAX 256

int main(void)
{
    char stack[MAX];
    char line[256];
    int top = 0, lineno = 0, ok = 1;

    printf("请输入 C 程序源程序正文(空行结束):\n");
    while (fgets(line, sizeof line, stdin) != NULL) {
        int i;

        if (line[0] == '\n')
            break;
        lineno++;
        for (i = 0; line[i] != '\0'; i++) {
            char c = line[i];

            if (c == '(' || c == '{') {
                stack[top++] = c;
            } else if (c == ')' || c == '}') {
                char want = (c == ')') ? '(' : '{';

                if (top == 0) {
                    printf("第 %d 行: 多余的 '%c'\n", lineno, c);
                    ok = 0;
                } else if (stack[--top] != want) {
                    printf("第 %d 行: '%c' 未能与 '%c' 匹配\n",
                           lineno, stack[top], c);
                    ok = 0;
                }
            }
        }
    }
    while (top > 0) {
        printf("未闭合的 '%c'\n", stack[--top]);
        ok = 0;
    }
    if (ok)
        printf("圆括号、花括号均匹配\n");
    return 0;
}
解析

思路:括号配对天然是栈的题目——左括号先"记下来",遇到右括号就去核对最近一个还没配对的左括号。

关键点:

  1. 左括号进栈;右括号看栈顶:栈空说明这个右括号多余,栈顶类型不符说明交叉不匹配(比如 (a}),类型相符就弹出栈顶。
  2. 圆括号和花括号放在同一个栈里。若分成两个计数器,只能查出括号的个数对不对,查不出 (a} 这种错配。
  3. 扫描结束后栈里还有剩,剩下的就是没闭合的左括号,逐个报出来。
  4. 本题按题面只处理正文里出现的括号,不再区分它是否落在字符串常量或注释内部——那需要另写一套词法判断,不是本章的考点。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):两份输入各跑一次。一份括号全配对的 main,输出 圆括号、花括号均匹配;另一份把第 3 行写成 if ((a > 0) {(少了一个右括号),输出 第 7 行: '(' 未能与 '}' 匹配,接着是 未闭合的 '{'——多出来的那个左圆括号把后面的 } 顶掉了,于是右花括号配不上、左花括号没了归宿,两处错误都被报了出来。

易错:

  1. 只数括号的个数,不检查类型是否配对
  2. 只检查圆括号或只检查花括号
  3. 扫完就结束,忘了再看一眼栈里有没有剩下的左括号

4 · 复制数组并去掉重复值

编写将已知数组内容复制到另一个新数组,使复制产生的新数组包含已知数组全部出现过的值,而又不重复。

答案
#include <stdio.h>

#define MAX 100

/* 把 src 中 n 个元素复制到 dst,重复出现的值只保留第一次,
   返回新数组的元素个数 */
int dedup(int dst[], const int src[], int n)
{
    int m = 0, i, j;

    for (i = 0; i < n; i++) {
        for (j = 0; j < m; j++)
            if (dst[j] == src[i])
                break;
        if (j == m)
            dst[m++] = src[i];
    }
    return m;
}

int main(void)
{
    int src[] = {3, 5, 3, 1, 5, 7, 1, 3, 9};
    int dst[MAX];
    int n = (int)(sizeof src / sizeof src[0]);
    int m, i;

    m = dedup(dst, src, n);

    printf("已知数组: ");
    for (i = 0; i < n; i++)
        printf("%d ", src[i]);
    printf("\n新数组:   ");
    for (i = 0; i < m; i++)
        printf("%d ", dst[i]);
    printf("\n新数组共 %d 个元素\n", m);
    return 0;
}
解析

思路:新数组一边生成一边查重。每取源数组的一个元素,先在已经生成的那部分里找一遍,没有出现过才追加到末尾。

关键点:

  1. 内层 for 循环结束后,j == m 表示"新数组里前 m 个都没有这个值",这正是可以追加的条件;j < m 则说明第 j 个就是重复的。用循环变量的越界值表示"没找到",这个写法在数组题里反复出现。
  2. 保持元素第一次出现的顺序。只追加没见过的值,得到的次序天然就是首次出现的次序。
  3. 元素个数由函数返回,不能沿用原来的 n——新数组的长度是 m,用 n 打表会把没写过的位置一起打出来。
  4. 时间复杂度是 O(n·m)。若元素取值范围很小,可以另开一个标记数组把每个值的"见过没有"记下来,做到 O(n);本章还没讲这种以值域换时间的做法,这里就不展开了。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):源数组取 {3,5,3,1,5,7,1,3,9},输出 3 5 1 7 9 共 5 个元素——两次出现的 3、两次出现的 5、两次出现的 1 都只留了第一次。

易错:

  1. 内层循环的下标写混,拿 src[i] 去比 src[j]
  2. 追加时边界用了 n 而不是 m
  3. 想先排序再去重——虽然也不留重复值,却把元素的相对顺序改掉了,与"复制"这个要求不符

5 · 最长平台的元素个数

设有序数组有 n 个元素,数组中连续相等的元素段称为数组的平台。

试找出数组最长平台的元素个数。规定若数组的元素互不相等,则它的最长平台长为 1。

答案
#include <stdio.h>

/* 最长平台上元素的个数;元素互不相等时返回 1,空数组返回 0 */
int longest_plateau(const int a[], int n)
{
    int best = 1, cur = 1, i;

    if (n == 0)
        return 0;
    for (i = 1; i < n; i++) {
        if (a[i] == a[i - 1])
            cur++;
        else
            cur = 1;
        if (cur > best)
            best = cur;
    }
    return best;
}

#define COUNT(a) ((int)(sizeof (a) / sizeof (a)[0]))

int main(void)
{
    int t1[] = {1, 1, 2, 2, 2, 3, 3};
    int t2[] = {1, 2, 3, 4, 5};
    int t3[] = {7, 7, 7, 7};
    int t4[] = {0, 0, 1, 1, 1, 1, 2};
    int t5[] = {-3, -3, -3, 0};
    int t6[] = {4};

    printf("t1 -> %d\n", longest_plateau(t1, COUNT(t1)));
    printf("t2 -> %d\n", longest_plateau(t2, COUNT(t2)));
    printf("t3 -> %d\n", longest_plateau(t3, COUNT(t3)));
    printf("t4 -> %d\n", longest_plateau(t4, COUNT(t4)));
    printf("t5 -> %d\n", longest_plateau(t5, COUNT(t5)));
    printf("t6 -> %d\n", longest_plateau(t6, COUNT(t6)));
    return 0;
}
解析

思路:一次遍历同时维护两个量——cur 是"当前这一段平台有多长",best 是"到目前为止见到的最长平台"。每走一步就试着刷新 best。

关键点:

  1. 相邻两个元素相等,cur 加 1;不相等,说明上一段平台到此为止,cur 从 1 重新数起。
  2. best 的初值取 1,正好落在题目"若数组的元素互不相等,则它的最长平台长为 1"这条规定上——一个元素自成一个长度为 1 的平台。只有空数组才需要另外处理,函数开头把它单独挡掉。
  3. 刷新 best 的动作每轮都做,不能只在"遇到不相等"时做:要是最长的那一段恰好顶在数组末尾,只在不相等时才更新就会漏掉它。
  4. 题目说数组是有序的,但"连续相等"这个判据本身不依赖有序性,所以同一份代码对无序数组同样成立。
  5. 空数组要单独挡掉:长度为 0 的数组没有元素,谈不上平台,直接返回 best 的初值 1 就错了。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):六组数据分别跑出 t1 -> 3、t2 -> 1、t3 -> 4、t4 -> 4、t5 -> 3、t6 -> 1。其中 t1 是 {1,1,2,2,2,3,3}(最长平台是三个 2),t2 是 {1,2,3,4,5}(互不相等,按规定得 1),t3 是 {7,7,7,7}(整组就是一个平台),t6 只有 {4} 一个元素。

易错:

  1. best 的初值写成 0
  2. 只在"遇到不相等"时更新 best,漏掉顶在末尾的那一段
  3. 把题目读成"数一数数组里有多少个不同的值"(那是去重,不是平台)
  4. 空数组照样返回 1

6 · 多项式求和与手写格式输出

输入两个多项式的各项系数和指数,编写程序求出它们的和,并要求与手写习惯相同的格式输出。

规定:一个多项式的输入以输入指数为负数结束。多项式的每一项 axᵇ 用 ax^b 格式输出。

答案
#include <stdio.h>

#define MAX 100

/* 读一个多项式:每项输入"系数 指数",指数为负数时结束 */
static int read_poly(int coef[], int exp[])
{
    int n = 0, c, e;

    while (scanf("%d%d", &c, &e) == 2 && e >= 0) {
        coef[n] = c;
        exp[n] = e;
        n++;
    }
    return n;
}

/* 按手写习惯输出:系数 1 和 -1 省略、指数 1 不写 ^1、指数 0 只写系数 */
static void print_poly(const int coef[], const int exp[], int n)
{
    int i, printed = 0;

    for (i = 0; i < n; i++) {
        int c = coef[i];

        if (c == 0)
            continue;
        if (printed)
            putchar(c > 0 ? '+' : '-');
        else if (c < 0)
            putchar('-');
        printed = 1;

        if (exp[i] == 0)
            printf("%d", c > 0 ? c : -c);
        else {
            if (c != 1 && c != -1)
                printf("%d", c > 0 ? c : -c);
            putchar('x');
            if (exp[i] != 1)
                printf("^%d", exp[i]);
        }
    }
    if (!printed)
        putchar('0');
    putchar('\n');
}

int main(void)
{
    int c1[MAX], e1[MAX], c2[MAX], e2[MAX];
    int rc[MAX * 2], re[MAX * 2];
    int n1, n2, n = 0, i, j;

    printf("输入第一个多项式的各项(系数 指数,指数为负数结束):\n");
    n1 = read_poly(c1, e1);
    printf("输入第二个多项式的各项(系数 指数,指数为负数结束):\n");
    n2 = read_poly(c2, e2);

    for (i = 0; i < n1; i++) {
        rc[n] = c1[i];
        re[n] = e1[i];
        n++;
    }
    for (i = 0; i < n2; i++) {
        for (j = 0; j < n; j++)
            if (re[j] == e2[i])
                break;
        if (j < n)
            rc[j] += c2[i];
        else {
            rc[n] = c2[i];
            re[n] = e2[i];
            n++;
        }
    }

    /* 按指数从大到小排列,输出才像手写的样子 */
    for (i = 0; i < n - 1; i++)
        for (j = i + 1; j < n; j++)
            if (re[j] > re[i]) {
                int t;

                t = re[i]; re[i] = re[j]; re[j] = t;
                t = rc[i]; rc[i] = rc[j]; rc[j] = t;
            }

    printf("和: ");
    print_poly(rc, re, n);
    return 0;
}
解析

思路:一个多项式用两个平行数组存——coef[i] 是第 i 项的系数,exp[i] 是它的指数。

先把第一个多项式整份抄进结果,再把第二个多项式的每一项并进去:指数已经在结果里出现过的,就把系数加上去;没出现过的,追加成新的一项。最后按指数从大到小排一遍,输出才像手写的样子。

关键点:

  1. 输入以"指数为负数"结束,所以结束标志也得写成一个完整的"系数 指数"对(例如 0 -1)。只输入一个 -1 的话,scanf("%d%d") 会把它当系数、再往后读一个数当指数,第一个多项式就会多出一项,后面的输入全部错位。
  2. 判断"是不是同类项"要看指数,不能看系数——指数才是项的标识。
  3. 合并之后系数可能变成 0(正负相消),这种项跳过不输出。
  4. 手写格式的几条细节都要单独处理:系数是 1 或 -1 时省略数字只写 x;指数是 1 时不写 ^1;指数是 0 时只写系数;只有第一项带前导负号,其余各项由 +/- 连接。
  5. 两个多项式完全抵消时结果为空,此时输出一个 0,不能输出空行。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):三组数据分别跑出 和: 4x^2+6、和: 2、和: 0。第一组是 3x^2+2x+1 加 x^2-2x+5(一次项相消,得 4x^2+6);第二组是 x^2+1 加 -x^2+1(二次项相消,只剩常数 2);第三组两项全部相消,输出 0。

系数和指数用两个平行数组绑在一起

同一项的两部分靠下标对齐:coef[i] 与 exp[i] 属于第 i 项。本章还没讲结构体,凡是"一个对象有两三个字段"的场合,教材都用这种平行数组来存。

易错:

  1. 结束标志只输一个负数,把后面一个多项式的首项当作指数读入
  2. 拿系数去配同类项
  3. 系数为 0 的项照样打印(会输出 +0x^3 这种)
  4. 系数 1 打成 1x^2、指数 1 打成 x^1(手写习惯都不这么写)
  5. 结果为空时什么都没输出

7 · 筛选法求质数

采用筛选法求质数。算法思想简述如下:

(1)将数组中下标为 0 和 1 的元素设置为 0,下标为 2~N 的元素都设置为 1。

(2)从下标为 2 的元素开始考查,当发现当前位置的数组元素值为 1 时,将下标是当前下标 2 倍、3 倍、……的那些元素全部置 0。

(3)重复步骤(2),直至考查了数组的全部元素,那些值依旧为 1 的元素的下标都是质数。

答案
#include <stdio.h>

#define N 50

int main(void)
{
    int a[N + 1];
    int i, k, count = 0;

    a[0] = a[1] = 0;
    for (i = 2; i <= N; i++)
        a[i] = 1;

    for (i = 2; i <= N; i++)
        if (a[i] == 1)
            for (k = 2 * i; k <= N; k += i)
                a[k] = 0;

    printf("N = %d 以内的质数:\n", N);
    for (i = 2; i <= N; i++)
        if (a[i] == 1) {
            printf("%4d", i);
            if (++count % 10 == 0)
                putchar('\n');
        }
    if (count % 10 != 0)
        putchar('\n');
    printf("共 %d 个\n", count);
    return 0;
}
解析

思路:完全按题干的三个步骤。数组元素只当标记用——a[i] 记住"下标 i 是不是质数",数组的下标本身就是要判断的那个数。

关键点:

  1. 初始时把 0 和 1 划掉,2~N 全标 1(先都当成质数,再逐个划掉合数)。
  2. 内层从 2 * i 起步、步长 i,就是把 i 的 2 倍、3 倍、……全部置 0,与题干一致。
  3. 判定用 a[i] == 1 而不是 a[i] != 0。若用后者,已经划掉的格子也会进去跑一圈内层循环,白做。
  4. 数组要开到 N + 1:题目要用到下标 N,开 a[N] 就少一格。
  5. 打表时 ++count % 10 == 0 每行放 10 个,收尾时再判断要不要补一个换行,避免多出一个空行。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):取 N = 50,输出两行数字 2 3 5 7 11 13 17 19 23 29 和 31 37 41 43 47,共 15 个,与 50 以内的质数表逐项吻合。

易错:

  1. 内层从 i 或 i + 1 起步,把质数自己划掉了
  2. 数组只开到 a[N],访问 a[N] 越界
  3. 判定条件写成 a[i] != 0,多跑一半无用的循环
  4. 把 a[0] = a[1] = 0 漏掉,于是 0 和 1 也被当成质数打出来

8 · 试除法求前 n 个质数

求前 n 个质数。要求确定数 m 是否是质数,用 m 被已求得的质数的整除性来确定。

答案
#include <stdio.h>

#define MAX 1000

int main(void)
{
    int primes[MAX];
    int n, k = 0, m, i;

    printf("求前 n 个质数,请输入 n: ");
    if (scanf("%d", &n) != 1 || n <= 0 || n > MAX)
        return 1;

    for (m = 2; k < n; m++) {
        int is_prime = 1;

        for (i = 0; i < k; i++)
            if (m % primes[i] == 0) {
                is_prime = 0;
                break;
            }
        if (is_prime)
            primes[k++] = m;
    }

    for (i = 0; i < n; i++) {
        printf("%5d", primes[i]);
        if ((i + 1) % 10 == 0)
            putchar('\n');
    }
    if (n % 10 != 0)
        putchar('\n');
    return 0;
}
解析

思路:从小到大逐个考察整数 m,用已经求出来的质数去试除它。一个都除不尽,m 就是新的质数,立刻存进 primes 数组,成为后面所有数的判据。

关键点:

  1. 外层循环的条件是 k < n——"还没凑够 n 个就接着找",而不是给 m 定一个上界。第 n 个质数有多大是算出来的,事先不知道。
  2. 试除只用已求得的质数,不用 4、6、8 这些合数,也不必试到 m-1:题目的这一句要求正好把判据缩到了 primes[0..k-1]。
  3. 只要有一个质数能整除 m,m 就是合数,立刻结束内层循环。用 is_prime 这个标志把"内层是怎么结束的"记下来,比事后判断 i 停在哪儿更清楚。
  4. 第一个被考察的数 2 一定被收下:此时 k == 0,内层循环一次也不执行,is_prime 保持为 1。
  5. primes 的大小是"最多要存多少个质数",与质数本身的大小无关。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 n = 20,输出两行 2 3 5 7 11 13 17 19 23 29 和 31 37 41 43 47 53 59 61 67 71,与质数表逐项吻合。

其实不必试到 m - 1

若 m 是合数,写成 m = a × b 时总有一个因子 a 不超过 m 的平方根:一个都不超过平方根,两个因子就都超过平方根,乘积必然大于 m。

所以内层循环只需要试到 primes[i] * primes[i] > m 就可以提前收手,结果不变。题干只要求"用 m 被已求得的质数的整除性来确定",答案就按题面写;知道这一步可以省就行。

易错:

  1. 拿 2、3、4、5……所有整数去试除(那就不叫"用已求得的质数"了)
  2. 内层循环写成 i <= k,读到还没赋值的 primes[k]
  3. 忘了 2 也是质数,从 3 开始考察
  4. 外层用 for (m = 2; m <= 某个数; m++),把范围写死,n 一大就不够用

9 · 按给定公式输出年历

编写输入年份,输出该年年历的程序。

提示:已知日期,按以下公式计算该日期的 N 值:

N=1461*f(年、月)/4+153*g(月)/5+日;

其中函数 f() 和 g() 的计算公式为:

f(年,月)=年-1,如月<3;否则,f(年,月)=年。
g(月)=月+13,如月<3;否则,g(月)=月+1。

然后计算:

d=(N-621049)%7

d 值为 0~6 中的某个整数,代表一周中星期日~星期六的某一天。

答案
#include <stdio.h>

/* 题给公式:0~6 分别表示星期日~星期六 */
static int weekday(int year, int month, int day)
{
    int y = (month < 3) ? year - 1 : year;
    int m = (month < 3) ? month + 13 : month + 1;
    long n = 1461L * y / 4 + 153L * m / 5 + day;

    return (int)((n - 621049L) % 7);
}

static int leap(int year)
{
    return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
}

static void print_month(int year, int month)
{
    static const char *wname[7] = {"日", "一", "二", "三", "四", "五", "六"};
    static const int mdays[12] = {31, 28, 31, 30, 31, 30,
                                  31, 31, 30, 31, 30, 31};
    int days = mdays[month - 1];
    int start = weekday(year, month, 1);
    int d, w;

    if (month == 2 && leap(year))
        days = 29;

    printf("        %d 年 %d 月\n", year, month);
    for (w = 0; w < 7; w++)
        printf("%s ", wname[w]);
    putchar('\n');

    for (w = 0; w < start; w++)
        printf("   ");
    for (d = 1; d <= days; d++) {
        printf("%2d ", d);
        if (++w % 7 == 0)
            putchar('\n');
    }
    if (w % 7 != 0)
        putchar('\n');
    putchar('\n');
}

int main(void)
{
    int year, month;

    printf("请输入年份: ");
    if (scanf("%d", &year) != 1)
        return 1;

    for (month = 1; month <= 12; month++)
        print_month(year, month);
    return 0;
}
解析

思路:把题给的公式原样写成一个函数,用它算出"该月 1 日是星期几",这个值决定该月的排版从哪一列起头;再按平年/闰年的天数把日期一格一格填进去。

关键点:

  1. 公式里 1461 * f / 4 和 153 * g / 5 都是先乘后除、走整数除法,运算顺序不能动。f、g 按"月份是否小于 3"分别取值——一月、二月被归到上一年的第 13、14 月,这正是公式能统一处理闰年的地方。
  2. d 的值 0~6 依次对应星期日到星期六,所以表头从左到右就是"日 一 二 三 四 五 六"。
  3. 排版分三步:打表头;按 d 的个数先打空位,把 1 号推到正确的列;从 1 号起每打 7 天换一行。
  4. 闰年按公历规则判断:能被 4 整除且不能被 100 整除,或者能被 400 整除。2000 年是闰年,1900 年不是。
  5. 表头一格是"一个汉字加一个空格"(占 3 个字符位),日期用 %2d 也是 3 个字符位,两者才对得齐。用 %4s 去打汉字对不齐:printf 按字节数补空格,不按显示宽度算。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):输入 2024,1 月 1 日落在"一"列、1 月 31 天,2 月 1 日落在"四"列、2 月 29 天;换成 2000 和 2023 各跑一次,2 月分别是 29 天和 28 天,各月 1 日的星期也与真实日历一致。

题给公式只在 1901~2099 年对得上真实日历

公式里的 1461 * f / 4 相当于"每 4 年一闰",没有公历"100 年不闰、400 年再闰"那一层修正。逐月对照真实公历的结果是:1901~2099 年这 199 年完全吻合;1900 年 1、2 月差 1 天,2100 年 3 月以后差 1 天,1800 年差 2 天,越往前的世纪差得越多;到 1700 年 N - 621049 已经为负,C 的 % 按截断取余会得到负数,算出来的 d 直接不可用。

答案完全按题干给的公式实现,这个适用区间一并说明。

易错:

  1. 把 1461 * f(年,月) / 4 写成 1461 * (f(年,月) / 4)(取 f = 2023 时前者是 738900,后者只剩 737805)
  2. 闰年只判"能被 4 整除",把 1900 年当成闰年
  3. 表头用 %4s 对齐汉字
  4. 打完最后一天不检查是不是正好到行尾,多补一个空行

10 · 求下一个更大的排列

设数组的每个元素只存储 0~9 的数,把该数组的前 n 个整数的排列看做是一个 n 位的长整数的一种表示。

现要求编写程序,对数组中的元素做调整,产生一个新的排列,使新排列表示的长整数比调整前的长整数大(如果可能),但又是所有更大的表示中最小的。

例如,a[]={3,2,6,5,4,1},则更大又是最小的排列为 {3,4,1,2,5,6}。

答案
#include <stdio.h>

static void reverse(int a[], int lo, int hi)
{
    while (lo < hi) {
        int t = a[lo];

        a[lo++] = a[hi];
        a[hi--] = t;
    }
}

/* 把数组前 n 位看成一个长整数,改成"比它大又是所有更大者中最小的"排列。
   已经是最大排列时返回 0,数组不动。 */
int next_bigger(int a[], int n)
{
    int i = n - 2, j, t;

    while (i >= 0 && a[i] >= a[i + 1])
        i--;
    if (i < 0)
        return 0;

    j = n - 1;
    while (a[j] <= a[i])
        j--;
    t = a[i];
    a[i] = a[j];
    a[j] = t;

    reverse(a, i + 1, n - 1);
    return 1;
}

static void show(const char *tag, const int a[], int n)
{
    int i;

    printf("%s = {", tag);
    for (i = 0; i < n; i++)
        printf("%d%s", a[i], i < n - 1 ? "," : "");
    printf("}\n");
}

int main(void)
{
    int a[] = {3, 2, 6, 5, 4, 1};
    int b[] = {1, 2, 3, 4, 5, 6};
    int c[] = {9, 8, 7};
    int n = (int)(sizeof a / sizeof a[0]);
    int m = (int)(sizeof b / sizeof b[0]);
    int k = (int)(sizeof c / sizeof c[0]);

    show("a", a, n);
    printf("--> %d\n", next_bigger(a, n));
    show("a", a, n);

    show("b", b, m);
    printf("--> %d\n", next_bigger(b, m));
    show("b", b, m);
    show("b", b, m);
    printf("--> %d\n", next_bigger(b, m));
    show("b", b, m);

    show("c", c, k);
    printf("--> %d\n", next_bigger(c, k));
    show("c", c, k);
    return 0;
}
解析

思路:从右往左找"还能往大变"的那一位,把它换成右边比它大的最小的数,再把这一位后面那段排成升序——得到的就正好是比原数大又是最小的排列。

关键点:

  1. 从右往左找第一个满足 a[i] < a[i+1] 的位置 i。找不到这样的位置,说明整个数组从左到右是递减的,它已经是最大的排列,直接返回 0。注意判据是"小于":要找的是还能往大写的那一位。
  2. 在 i 右边从右往左找第一个大于 a[i] 的 a[j],交换两者。因为 i 右边那段本身是从右往左递增的,从右往左碰到的第一个大于 a[i] 的数,正好就是"比 a[i] 大的里面最小的那个"。
  3. 交换之后 i 右边那段仍然是递减的。要让它表示的数最小,就得把它反过来排成升序,所以反转 a[i+1] 到 a[n-1]。
  4. 反转不需要额外数组,两头对换即可。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):{3,2,6,5,4,1} 得到 {3,4,1,2,5,6},与题干给的例子一致;{1,2,3,4,5,6} 连做两次,依次得到 {1,2,3,4,6,5} 和 {1,2,3,5,4,6};{9,8,7} 返回 0,数组保持原样。

这其实就是字典序里的下一个排列

把数组前 n 位当成一个长整数,与把它当成一串数字,要找的是同一个东西:字典序里的下一个排列。题目限定每个元素只能是 0~9 的一位数字,正是为了让它能拼成一个"长整数"——每个位置上都不进位、不溢出,交换和反转都不会把某一位弄出界。

易错:

  1. 第 1 步的判据方向写反,写成 a[i] > a[i+1],找到的是"还能变小"的那一位
  2. 第 2 步找的是最右边第一个大于 a[i] 的数,不是最左边那个
  3. 第 3 步忘了反转后半段,只交换一次的话得到的不一定是最小的那个更大者
  4. 反转范围写成 i 到 n-1,把 a[i] 自己也卷进去

11 · 1/n 的循环小数表示

对于 n=2,3,4,...,50,输出 1/n 的十进制表示的字符串。要求每当十进制小数的第一个循环周期输出后,就结束该数的输出。

以下是程序部分输出结果的样板:

1/2=0.50
1/3=0.3
1/4=0.250
1/5=0.20
1/6=0.16
1/7=0.142857
答案
#include <stdio.h>

#define MAXN 50

int main(void)
{
    int n;

    for (n = 2; n <= MAXN; n++) {
        int seen[MAXN] = {0};
        int digit[MAXN * 2];
        int len = 0, r = 1 % n, i;

        printf("1/%d=0.", n);
        seen[r] = 1;
        for (;;) {
            r *= 10;
            digit[len++] = r / n;
            r %= n;
            if (seen[r])
                break;
            seen[r] = 1;
        }
        for (i = 0; i < len; i++)
            putchar('0' + digit[i]);
        putchar('\n');
    }
    return 0;
}
解析

思路:一个分数除不除得尽、循环节有多长,全看余数。按长除法一位一位往下算(余数乘 10 再除以 n,商是这一位的数字),同时把出现过的余数记下来;哪一步算出的余数以前见过,就说明小数从这里开始循环,第一个循环节已经输出完了。

关键点:

  1. 全程只用整数。1/49 的循环节有 42 位,用 double 根本存不下,只能靠长除法一位一位算出来。
  2. seen[r] 记录"余数 r 出现过"。初始余数 1 % n 也要先记上:1/3 的第一位算出 3、余数又回到 1,正因为 1 已经在 seen 里,循环才在这里停下,得到 1/3=0.3 而不是 0.33。
  3. 余数 0 同样是一个要被记录的状态。1/2 先算出数字 5、余数变成 0,此时 0 还没出现过,于是再算一位;这一位算出数字 0、余数仍是 0,这才发现循环。所以输出是 1/2=0.50——样张里的 1/4=0.250、1/5=0.20 也是同一个道理:除得尽的小数,看成循环节为 0 的循环小数,第一个循环节连同前面的数字一起打出来。
  4. 循环节的长度不会超过 n-1(余数只有 n 种取值),所以数字数组开到 2n 绰绰有余。
  5. 数字用 '0' + digit[i] 逐位输出,不走浮点格式串,省掉精度和小数位数的纠缠。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):n 从 2 到 50 共 49 行,前 6 行与题干样张逐行一字不差(按判题口径 normalize() 剥掉行尾空白后比对)。另外几条:1/8=0.1250、1/13=0.076923、1/49=0.020408163265306122448979591836734693877551(42 位循环节)。

易错:

  1. 用 1.0 / n 直接打印(浮点表示不出 1/49 这种循环小数)
  2. 忘了把初始余数记进 seen,1/3 会打成 0.33
  3. 余数 0 不进 seen,除得尽的小数就少了样张里的那个尾数 0
  4. 每位都去试"余数是否等于某个固定值",而不是记录全部出现过的余数

12 · 不借工作数组的两段互换

编写将数组的前 n 个元素中,前端的 m 个元素和随后的 n-m 个元素互换的程序。要求程序不另用其他工作数组,如 a[]={1,2,3,4,5,6,7,8,9,0},设 n=10,m=3。

交换后有:

a[]={4,5,6,7,8,9,0,1,2,3}
答案
#include <stdio.h>

#define N 10

static void reverse(int a[], int lo, int hi)
{
    while (lo < hi) {
        int t = a[lo];

        a[lo++] = a[hi];
        a[hi--] = t;
    }
}

/* 把前 m 个元素与随后的 n-m 个元素互换,不用工作数组 */
void swap_blocks(int a[], int n, int m)
{
    reverse(a, 0, n - 1);
    reverse(a, 0, n - m - 1);
    reverse(a, n - m, n - 1);
}

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

    printf("交换前: ");
    for (i = 0; i < N; i++)
        printf("%d%s", a[i], i < N - 1 ? "," : "");
    putchar('\n');

    swap_blocks(a, N, 3);

    printf("交换后: ");
    for (i = 0; i < N; i++)
        printf("%d%s", a[i], i < N - 1 ? "," : "");
    putchar('\n');
    return 0;
}
解析

思路:三次逆置。把原数组看成"前段 A 接后段 B"两块,先把整体反过来(得到 B 的倒序接 A 的倒序),再把前 n-m 个反转回来、后 m 个反转回来,两段就换了个位置。

关键点:

  1. 记作 AB,整体反转得 B' A'(撇号表示这一段内部的次序倒过来了);把前 n-m 个反转回来得 B A';再把后 m 个反转回来得 BA,正是"前后两段互换"。
  2. 三次逆置的下标区间依次是 0 ~ n-1、0 ~ n-m-1、n-m ~ n-1。后两段的长度分别对应交换之后的后段和前段。
  3. reverse 里只借一个临时变量做交换,没有第二个数组,符合题目的要求。
  4. m 取 0 或 n 也不会出事:三次调用里有两次退化成空区间或整段,合起来仍是原样。m 大于 n 时才需要另行判断,题目没有这种输入。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):{1,2,3,4,5,6,7,8,9,0} 取 n = 10、m = 3,交换后输出 4,5,6,7,8,9,0,1,2,3,与题干给的样板一致。

另一种不用工作数组的写法:整体平移

把前 m 个元素看成一叠要搬到末尾的东西:每次把最前面的元素取出来存在一个变量里,其余元素整体左移一格,再把这个变量放到末尾,重复 m 次。同样不借工作数组,但一共要搬动 m × (n-1) 个元素;三次逆置合计只做 n 次左右的交换。

两种写法都满足题目的要求,逆置法更常用。

易错:

  1. 三次逆置的顺序写反(先分段反转、再整体反转,得到的是另一组排列)
  2. 后两次的下标区间照抄原来的分段(0 ~ m-1 和 m ~ n-1),那两段已经不是原来的位置了
  3. 交换时不借临时变量,写成 a[i] = a[j]; a[j] = a[i];,丢掉了一个值

13 · 方阵原地旋转 90°

不用工作数组,分别编写实现将已知方阵旋转 90° 的函数。

提示:旋转可从外到内分层,逐层完成旋转。

答案
#include <stdio.h>

/* 顺时针旋转 90°:从外到内逐层做,每层只借一个临时变量 */
void rotate90(int n, int a[n][n])
{
    int layer;

    for (layer = 0; layer < n / 2; layer++) {
        int first = layer, last = n - 1 - layer;
        int i;

        for (i = first; i < last; i++) {
            int offset = i - first;
            int t = a[first][i];

            a[first][i] = a[last - offset][first];
            a[last - offset][first] = a[last][last - offset];
            a[last][last - offset] = a[i][last];
            a[i][last] = t;
        }
    }
}

/* 逆时针旋转 90°:四步赋值反过来绕一圈 */
void rotate270(int n, int a[n][n])
{
    int layer;

    for (layer = 0; layer < n / 2; layer++) {
        int first = layer, last = n - 1 - layer;
        int i;

        for (i = first; i < last; i++) {
            int offset = i - first;
            int t = a[first][i];

            a[first][i] = a[i][last];
            a[i][last] = a[last][last - offset];
            a[last][last - offset] = a[last - offset][first];
            a[last - offset][first] = t;
        }
    }
}

static void show(int n, int a[n][n])
{
    int i, j;

    for (i = 0; i < n; i++) {
        for (j = 0; j < n; j++)
            printf("%3d", a[i][j]);
        putchar('\n');
    }
}

int main(void)
{
    int a[3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
    int b[4][4] = {{1, 2, 3, 4}, {5, 6, 7, 8},
                   {9, 10, 11, 12}, {13, 14, 15, 16}};
    int c[3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};

    printf("3 阶方阵旋转前:\n");
    show(3, a);
    rotate90(3, a);
    printf("3 阶方阵顺时针旋转后:\n");
    show(3, a);

    printf("4 阶方阵旋转前:\n");
    show(4, b);
    rotate90(4, b);
    printf("4 阶方阵顺时针旋转后:\n");
    show(4, b);

    printf("3 阶方阵逆时针旋转后:\n");
    rotate270(3, c);
    show(3, c);
    return 0;
}
解析

思路:按提示从外到内分层。

每一层是一个"方框",旋转就是把方框上边那一段搬到右边、右边那一段搬到下边、下边那一段搬到左边、左边那一段搬回上边。四段正好绕一圈,用一次赋值完成,只需要一个临时变量。

关键点:

  1. 第 layer 层的上边是第 first 行、下边是第 last 行,左列是第 first 列、右列是第 last 列,其中 last = n - 1 - layer。一圈走完,layer 加 1 往里收一层。
  2. 层内用 offset = i - first 记"这是这一层里的第几个格子",四条边上的位置靠它对齐。顺时针方向上,代码里的四步依次是"左列搬上边、下边搬左列、右列搬下边、上边搬右列",绕的方向一致。
  3. 内层循环的上界是 last 而不是 last + 1。一层的每条边只有 last - first 个格子(比边长少 1),四个角归相邻的那条边管;写成 i <= last 就会把角上的格子搬两遍。
  4. 全程原地交换,没有第二个数组。把四步赋值的顺序反过来(先把上边存进临时变量、最后写回左边那一列)就是逆时针旋转,两个方向各写一个函数即"分别"。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):3 阶方阵 {1,2,3 / 4,5,6 / 7,8,9} 顺时针旋转后是 {7,4,1 / 8,5,2 / 9,6,3},逆时针旋转后是 {3,6,9 / 2,5,8 / 1,4,7};4 阶方阵 1~16 顺时针旋转后第一行是 13 9 5 1、最后一行是 16 12 8 4。旋转前后元素 1~N² 各出现一次,没有丢也没有重。

易错:

  1. 内层循环写成 i <= last,把四个角上的元素搬了两次
  2. last 忘了减去 layer(写成 n - 1,每一层都在操作最外面那一圈)
  3. 四步赋值的顺序乱掉,中间某一格被覆盖两次,另一格没被写到
  4. 借了一个和方阵同样大的数组当中转——那就不是"不用工作数组"了

14 · 反对角线与螺旋两种方阵

分别生成如下示例所示,由自然数 1~N² 组成的 N 阶方阵,并输出。

 1   3   4  10         1   2   3   4
 2   5   9  11        12  13  14   5
 6   8  12  15        11  16  15   6
 7  13  14  16        10   9   8   7
       (1)                   (2)
答案
#include <stdio.h>

#define N 4

/* (1)沿反对角线来回填:s 为奇数时自左下向右上,s 为偶数时自右上向左下 */
static void zigzag(int a[N][N])
{
    int k = 1, s;

    for (s = 0; s <= 2 * (N - 1); s++) {
        int lo = (s - (N - 1) > 0) ? s - (N - 1) : 0;
        int hi = (s < N - 1) ? s : N - 1;
        int i;

        if (s % 2 == 1) {
            for (i = hi; i >= lo; i--)
                a[i][s - i] = k++;
        } else {
            for (i = lo; i <= hi; i++)
                a[i][s - i] = k++;
        }
    }
}

/* (2)由外到内的顺时针螺旋 */
static void spiral(int a[N][N])
{
    int top = 0, bottom = N - 1, left = 0, right = N - 1;
    int k = 1, i, j;

    while (top <= bottom && left <= right) {
        for (j = left; j <= right; j++)
            a[top][j] = k++;
        top++;

        for (i = top; i <= bottom; i++)
            a[i][right] = k++;
        right--;

        if (top <= bottom) {
            for (j = right; j >= left; j--)
                a[bottom][j] = k++;
            bottom--;
        }
        if (left <= right) {
            for (i = bottom; i >= top; i--)
                a[i][left] = k++;
            left++;
        }
    }
}

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

    zigzag(a);
    spiral(b);

    for (i = 0; i < N; i++) {
        for (j = 0; j < N; j++)
            printf("%4d", a[i][j]);
        printf("        ");
        for (j = 0; j < N; j++)
            printf("%4d", b[i][j]);
        putchar('\n');
    }
    return 0;
}
   1   3   4  10           1   2   3   4
   2   5   9  11          12  13  14   5
   6   8  12  15          11  16  15   6
   7  13  14  16          10   9   8   7
解析

两个小题是两种不同的填数路线,要解决的问题一样:把 1 到 N² 依次放进正确的格子,每个数用一次。

第(1)个方阵沿反对角线来回填。下标之和 i + j 相等的格子排在一条反对角线上:第一条只有左上角一个格子,第二条两个,逐渐加到 N 个,之后再逐条减少。

填的方向一条一变——奇数条自左下向右上(1 在左上角,2 在它下面、3 在它右面,正是第二条从下往上),偶数条自右上向左下(4、5、6 是第三条从上往下)。样张里每一段的数字都是连号的,就是这个来回走的顺序。

第(2)个方阵是由外到内的顺时针螺旋。用四个边界 top、bottom、left、right 圈住还没填的内部区域:先填上边一行从左到右,top 下移一格;再填右边一列从上到下,right 左移一格;再填下边一行从右到左,bottom 上移一格;再填左边一列从下到上,left 右移一格。

四条边走完算一圈,重复到边界交错为止。

关键点:

  1. 第(1)个方阵里,第 s 条反对角线上的每个格子都满足 j = s - i,所以内层循环只管走 i、j 由 s 减出来。i 的取值要夹在 max(0, s-(N-1)) 和 min(s, N-1) 之间,越出这个范围算出来的 j 会跑到数组外面。
  2. 方向由 s 的奇偶决定,一条一变,这就是"来回填"的落点。
  3. 第(2)个方阵里,走完一圈四条边界各缩一格。单数阶方阵最后会剩中间一格,所以填下边和左边之前要再判一次边界,不然最里面那一圈会重复填或者越格。
  4. 两个方阵的元素个数都是 N²,填完后 1~N² 各出现一次,可以用这个来做自检。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):取 N = 4 跑一次,两个方阵的每一格与题干转写里的数字逐格一致——左矩阵逐行是 1 3 4 10、2 5 9 11、6 8 12 15、7 13 14 16,右矩阵逐行是 1 2 3 4、12 13 14 5、11 16 15 6、10 9 8 7。

答案块里的列宽和题干转写对不齐

程序用 %4d 打每一列,每个数字占 4 个字符位、右对齐,两个方阵之间空 8 个字符位:左矩阵的右边缘固定在第 16 个字符位,右矩阵从第 25 个字符位起。

题干转写里左矩阵的右边缘在第 13 个字符位(第 1 行的 10)和第 14 个字符位(第 4 行的 16)之间参差着,右矩阵从第 23 个字符位起——原书这两列数字是均匀右对齐的,转写时压过列宽才留下这点不齐。

两边的数字完全一样,只是列的宽度与整体缩进对不上。题目示例下面那行 (1)、(2) 是给两个方阵标的号,不是程序的输出,答案块里没有打。

易错:

  1. 第(1)个方阵里 i 的取值范围不夹住,算出的 j 越界
  2. 方向不随条数交替,两个方向都朝同一头填
  3. 第(2)个方阵里走完一圈少缩了某条边界,下一圈会重复填上一圈的格子
  4. 两个方阵都用"从 1 开始每次加 1"填,忘了编号是沿着某条路线走的,而不是逐行逐列走的

15 · 字符序列与字符串

字符序列与字符串有何区别?表示字符串有哪些实用的方法?

答案

区别:

  • 字符序列是一串按次序排在一起的字符,规定了"有哪几个字符、谁在谁前面",但没有结尾标志,长度必须另外记。
  • 字符串是末尾带一个空字符 '\0'(码值 0)的字符序列。'\0' 只用来标记字符串到此结束,本身不算字符串的内容。
  • C 语言没有单独的字符串类型,字符串靠字符数组或字符指针表示。所以字符串一定是字符序列,字符序列不一定是字符串。

表示字符串的实用方法:

  1. 字符数组存一份带 '\0' 的副本:char s[6] = "hello";。内容可以改写,长度是字符个数加 1。
  2. 字符指针指向字符串字面量:char *p = "hello";。不用自己数字符,但字面量的内容不能改写,改它是未定义行为。
  3. 字符指针数组表示一组字符串:char *week[] = {"日", "一", "二", "三", "四", "五", "六"};。各条串长短不一也不浪费空间,按字典序排序时只交换指针,不搬字符。
  4. 用带长度字段的结构表示:struct { char s[100]; int len; };。取长度不花时间,但 C 的库函数都不认这种表示,得自己写一套。
  5. 长度要到运行时才知道时动态分配:char *p = malloc(n + 1);,末尾照样得留出 '\0' 的位置。
解析

区别的根子在"要不要结尾标志"这一条上。字符串处理函数(strlen、strcpy、printf("%s"))都没有"长度"这个参数,它们全部靠 '\0' 判断什么时候停下来;字符序列少了这个标记,函数就不知道边界在哪里。

边界情况:

  • char s[5] = "hello"; 能通过编译,但数组里没有位置放 '\0',s 就不是字符串。strlen(s) 会一直往后读,结果取决于数组后面那片内存里碰巧是什么——这是未定义行为,不是"长度正好是 5"。
  • sizeof 和 strlen 不是一回事:sizeof s 是数组占的字节数(含 '\0'),strlen(s) 是 '\0' 之前的字符个数;对指针用 sizeof p 得到的是指针自身的大小,与它指向的串有多长无关。
  • 字符串字面量 "hello" 本身是一个末尾自带 '\0' 的 char[6] 数组。
  • char seq[5] = {'h','e','l','l','o'}; 是字符序列但不是字符串,只能按已知长度打印,比如 printf("%.5s", seq);。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):char s[6] = "hello"; 得 sizeof s = 6、strlen(s) = 5;char *p = "hello"; 得 sizeof p = 8(本机指针 8 字节)、strlen(p) = 5;char seq[5] = {'h','e','l','l','o'}; 得 sizeof seq = 5,用 %.5s 限长打印出 hello。

易错:

  1. 以为 sizeof 就是串长(它是数组占的字节数,还要算上 '\0')
  2. char s[5] = "hello"; 照样当字符串去 strlen
  3. 改了字面量的内容(char *p = "hello"; p[0] = 'H'; 是未定义行为,有些平台会直接崩溃)
  4. 用 char 指针数组存字符串时忘了它是指针,对 week 用 sizeof 得到的是整个指针数组的大小

16 · 整理字符串中的空白符

整理字符串,将字符串的前导空白符和后随空白符删除,并将字符串中非空白字符串之间的连续的多个空白符只保留一个,而去掉多余的空白符。

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

/* 去掉前导、后随的空白符,中间的连续空白只留一个 */
void squeeze(char s[])
{
    int i = 0, j = 0;

    while (isspace((unsigned char)s[i]))
        i++;
    while (s[i] != '\0') {
        if (isspace((unsigned char)s[i])) {
            s[j++] = ' ';
            while (isspace((unsigned char)s[i]))
                i++;
        } else {
            s[j++] = s[i++];
        }
    }
    if (j > 0 && s[j - 1] == ' ')
        j--;
    s[j] = '\0';
}

int main(void)
{
    char t1[] = "  hello \t  world  \n";
    char t2[] = " \t\n ";
    char t3[] = "abc";
    char t4[] = "  a  ";

    squeeze(t1);
    printf("[%s]\n", t1);
    squeeze(t2);
    printf("[%s]\n", t2);
    squeeze(t3);
    printf("[%s]\n", t3);
    squeeze(t4);
    printf("[%s]\n", t4);
    return 0;
}
解析

思路:用两个下标在同一个字符数组上走,i 负责读、j 负责写,边读边决定这个字符要不要留下、留在哪儿。

关键点:

  1. 先跳前导空白:只让读下标 i 往前走,写下标 j 停在 0。这一步单独做,"前导"和"中间的连续空白"才好分别处理。
  2. 中间部分:读到非空白字符就原样搬到 s[j];读到空白就只写一个空格,然后把跟着的一整串空白全部跳掉。不管中间夹了几个空白,写出去永远只有一个空格。
  3. 后随空白要最后处理:如果写下的最后一个字符是空格,就把写下标退一格,再补 '\0'。扫描过程中没法判断某个空白是不是末尾的——那时还不知道后面有没有非空白字符。
  4. 空白判定用 isspace。它认空格、制表符、换行、回车、换页、垂直制表符六种;只写 ' ' 的话,制表符和换行就漏掉了。
  5. 全程原地改写,字符串只会变短,写成的位置永远不晚于读到的位置,不会覆盖还没读到的字符。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):四组输入各跑一次。" hello \t world \n" 整理后是 hello world(前导两个空格、中间一个制表符加两个空格、末尾两个空格加换行全部处理掉);全是空白符的 " \t\n " 整理后是空串;本来就干净的 "abc" 原样不变;" a " 得到 a。

易错:

  1. 只删前导和后随,忘了把中间连续的空格压成一个
  2. 空白判定只写 ' ',制表符和换行漏掉
  3. 结尾那个空格忘了退回去,字符串变成 hello world(末尾多一个空格)
  4. 另开一个数组接结果,把整理过的内容放到别处去(题目要的是就地把这个字符串整理好)

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