多伦多大学-APS105-C-语言编程笔记-全-

多伦多大学 APS105 C 语言编程笔记(全)

001:导论 🎬

在本节课中,我们将要学习编程的基本概念,了解课程结构,并编写我们的第一个C语言程序。

课程结构与建议 📅

上一节我们介绍了课程的基本信息,本节中我们来看看课程的具体安排和学习建议。

课程时间表包含四个部分:

  • 讲座:学习所有核心概念,并向讲师提问。
  • 辅导课:助教将带领大家练习与考试题目非常接近的问题。
  • 实验预备讲座:助教主讲,内容与实验材料直接相关,提供额外练习。
  • 实验课:获得助教的现场帮助,完成软件编写。

以下是关于实验安排的建议:

  • 实验材料会在截止日期前两周发布。
  • 建议在材料发布后立即查看。
  • 实验所需的所有内容会在截止日期前一周的讲座中覆盖。
  • 你应该在材料覆盖后立即开始实验。
  • 实验截止时间为周六午夜。

学术诚信 ⚖️

上一节我们讨论了学习节奏,本节中我们必须谈谈学术诚信这个严肃的话题。

规则是你可以讨论课程内容,但不能互相抄袭。抄袭代码会损害你自己的学习,因为本课程最好的学习方式就是实践。此外,抄袭代码比抄袭其他作业更容易被检测到。

评分与资源 📊

现在我们来谈谈评分标准和可用资源。

你的总成绩中,考试占70%(期中30%,期末40%),实验占30%。实验分为9个,权重逐渐增加:

  • 实验0(设置实验):权重0%。
  • 实验1-3:每个占2%,共6%。
  • 实验4-6:每个占3%,共9%。
  • 实验7-9:每个占5%,共15%。

本课程的官方资源是课程网站和在线教材。此外,还有一些额外资源:

  • 讲座会进行直播并录制上传至YouTube。
  • 讲义幻灯片发布在讲师个人网站。
  • 有一个非官方的Discord社区供大家交流。

编程基础概念 💻

在了解了课程框架后,我们现在深入探讨编程的核心概念。

程序是计算机可以执行(或运行)的东西。编写程序的艺术称为编程

人类使用编程语言来编写程序,C语言就是其中一种。我们选择C语言是因为它是一门小巧的语言,特性不多,并且更接近计算机的工作原理。

计算机由两部分组成:硬件(你可以触摸的物理部件)和软件(计算机运行所需的信息)。硬件主要包括:

  • CPU:中央处理器,进行计算。
  • RAM:随机存取存储器,短期记忆,CPU直接与之交互。
  • 存储设备:如SSD,长期记忆,用于存储文件。

我们将计算机建模为一个黑箱,它接收输入(如键盘、鼠标),经过内部处理(计算、内存、存储),产生输出(如显示器上的图像)。

计算机如何表示信息 🔢

上一节我们了解了计算机的组成,本节中我们来看看计算机内部如何表示信息。

计算机使用一系列数字来存储所有信息。在最基本的层面上,计算机只能存储 10,这称为一个

我们通常用十进制表示数字,而计算机使用二进制。二进制是一种只使用位(0和1)的数字系统。

一个字节由8个位组成。由于每个位有2种可能,一个字节可以表示 2^8 = 256 种不同的东西。

在编程语言中,我们通常直接写十进制数字。如果要写二进制数字,会以前缀 0b 开头,例如 0b101 表示十进制的5。

计算机使用数字来表示键盘上的字符。例如,大写字母‘A’在计算机内部被表示为十进制数字 65

软件开发工具链 ⚙️

理解了信息表示后,我们来看看如何将我们的想法变成计算机可以运行的程序。

代码是用编程语言编写的文本。特定软件的代码称为源代码

我们使用编译器这个程序将源代码转换为操作系统可以运行的程序。这个过程称为编译。编译器输出的机器码是CPU能够理解的二进制指令。

我们还需要处理输入和输出。在本课程中,我们将主要使用终端,它是一个文本界面,使用键盘进行输入,在屏幕上显示文本作为输出。

第一个C程序 👋

最后,让我们将所有概念结合起来,编写并运行我们的第一个C程序。

以下是一个典型的第一个程序,它会在屏幕上输出“Hello World”:

#include <stdio.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/e57a1cf804bb43d7092be5d08050f562_3.png)

int main(void)
{
    printf("Hello World\n");
    return 0;
}

代码解析:

  1. #include <stdio.h>:告诉编译器包含标准输入输出库的文件,以便使用 printf 函数。
  2. int main(void):定义了一个名为 main 的函数,它是每个C程序的起点。int 表示这个函数执行完毕后会返回一个整数。
  3. printf("Hello World\n");:调用 printf 函数,输出字符串“Hello World”。\n 是一个特殊字符,表示换行。
  4. return 0;:结束 main 函数的执行,并向操作系统返回值0,通常表示程序成功运行,没有错误。

要运行这个程序,你需要:

  1. 将代码保存为 .c 文件(如 hello.c)。
  2. 使用编译器(如GCC)进行编译:gcc hello.c -o hello
  3. 运行生成的可执行文件:./hello

总结 🎯

本节课中我们一起学习了APS105课程的结构与学习建议,理解了程序、编程语言、计算机硬件与软件的基本概念,知道了计算机如何使用二进制表示信息,认识了编译器的作用,并成功编写和运行了我们的第一个C语言程序——“Hello World”。

002:变量 🧠

在本节课中,我们将要学习C语言中一个非常核心的概念:变量。我们将从计算机如何存储信息的基础知识开始,逐步理解变量是什么、如何声明它们,以及C语言中几种基本的数据类型。


计算机内存基础

上一节我们介绍了程序的基本结构,本节中我们来看看计算机是如何存储和访问信息的。

程序总是在访问内存。这里所说的“内存”,指的是RAM(随机存取存储器),也称为主内存。计算机通过一种叫做字节的单位来访问内存。

一个字节由8个比特组成。由于每个比特可以是0或1,所以一个字节可以表示 2^8 = 256 种不同的可能性。

在内存中,每个字节都有一个唯一的地址,就像房子的门牌号一样。内存是字节可寻址的,这意味着最小的可访问单元是一个字节,而不是单个比特。

CPU有专门的指令来读写内存中的字节。不过,在C语言中,这些底层细节被语言本身处理了,我们无需直接操作。

计算机能访问的地址数量取决于CPU的“位宽”。例如,一个64位CPU可以寻址高达 2^64 个字节,这是一个非常巨大的数字。为了便于理解,我们使用千字节(KB)、兆字节(MB)、吉字节(GB)等单位。需要注意的是,在计算机中:

  • 1 KB = 1024 字节 (2^10)
  • 1 MB = 1024 KB (2^20)
  • 1 GB = 1024 MB (2^30)

C语言中的类型

我们已经知道内存存储的是字节,但如何区分一个字节是代表字母‘A’还是数字65呢?这就要靠类型来告诉计算机如何解释这些字节。

一个类型主要告诉我们两件事:

  1. 该值代表什么(例如,是整数还是字符)。
  2. 它需要占用多少内存

以下是本课程中将使用的基本类型:

整数类型:int

int 类型代表整数(没有小数点的数字)。在C语言中,一个 int 通常占用 4个字节(32位)。

由于需要表示正数和负数,最高位(第31位)被用作符号位:0代表正数,1代表负数。因此,int 可以表示的范围是:
-2^312^31 - 1

字符类型:char

char 类型代表单个字符,例如字母、数字、标点符号或控制字符(如换行符)。一个 char 总是占用 1个字节

在代码中,字符值用单引号定义:

char c = 'A';

对于无法直接输入的字符(如换行、制表符),需要使用转义字符 \

  • 换行符:\n
  • 制表符:\t
  • 反斜杠本身:\\
  • 单引号:\'

字符在内存中实际上存储为一个数字(0-255),其对应关系由 ASCII 标准定义。

浮点数类型:double

double 类型代表带有小数点的数字。它占用 8个字节,提供了较高的精度。在C语言中,只要需要小数,就应该使用 double

double pi = 3.141592;

布尔类型:bool

bool 类型代表逻辑值,只有两种可能:true(真)或 false(假)。虽然理论上只需1比特,但实际上它占用 1个字节

要使用 bool 类型,需要在代码开头添加:

#include <stdbool.h>

然后就可以声明布尔变量:

bool isRaining = true;

声明与使用变量

理解了类型之后,我们就可以在程序中创建和使用变量了。

变量的声明

在C语言中声明变量的语法是:
类型 变量名;

例如,声明一个整数变量 x

int x;

执行这行代码后,C语言会在内存中为 x 预留4个字节的空间。但此时 x 的值是未定义的(可以理解为随机值)。

变量的赋值

我们可以使用赋值语句来改变变量的值,语法是:
变量名 = 值;

需要注意的是,编程中的等号 =赋值操作,意味着“将右边的值存入左边的变量”,而不是数学中的恒等关系。

x = 1; // 现在x的值是1

变量的初始化

为了避免使用未定义的随机值,最佳实践是在声明变量的同时就给它一个初始值,这称为初始化

int x = 1; // 声明并初始化x为1

这行代码等价于先声明 int x; 再赋值 x = 1;


程序开发流程与错误

在编写软件时,我们通常遵循一个开发周期:

  1. 编辑代码:编写或修改你的C程序。
  2. 编译代码:使用编译器将源代码转换为可执行程序。
  3. 判断:编译是否成功?
    • 如果失败,会产生编译时错误。这意味着代码语法或规则有问题,编译器无法生成程序。你需要返回第1步修改代码。
    • 如果成功,则可以运行程序。
  4. 运行与测试:运行程序,检查输出是否符合预期。
    • 如果不符合,会产生运行时错误或逻辑错误。这意味着程序能运行,但行为不对。你需要返回第1步修改代码逻辑。

一个常见的编译时错误是使用了未声明的变量。例如,如果直接写 x = 1; 而没有先声明 int x;,编译器会报错:
error: use of undeclared identifier 'x'
这表示标识符(变量名)‘x’尚未声明。


总结

本节课中我们一起学习了:

  • 计算机内存以字节为单位组织,每个字节有唯一地址。
  • 类型决定了数据如何被解释以及占用多少内存。
  • C语言的基本类型:表示整数的 int,表示字符的 char,表示小数的 double,以及表示真/假的 bool
  • 如何通过 类型 变量名; 的语法来声明变量。
  • 如何使用 变量名 = 值; 的语法来赋值,以及更安全的初始化方式 类型 变量名 = 值;
  • 程序开发的基本流程和两种主要错误:编译时错误运行时错误

变量是存储和操作数据的基石,理解它们是迈向有效编程的关键一步。下一讲,我们将学习如何让程序与用户交互——输出信息。

003:输入与输出 📝

在本节课中,我们将学习C语言中两个核心功能:如何从用户那里获取输入,以及如何将程序的输出显示给用户。我们将通过printfscanf这两个函数来实现输入与输出,并理解格式字符串、变量地址等关键概念。


回顾“Hello World”程序

上一节我们介绍了数据类型。现在,让我们重新审视第一个“Hello World”程序,以便更好地理解输出是如何工作的。

程序的第一行 #include <stdio.h> 是“标准输入输出”的缩写,它包含了我们今天要学习的函数的声明。printf函数的声明就在其中,它告诉编译器printf需要什么输入以及会产生什么输出。

main函数中,我们使用了printf函数。它的作用是输出一个字符串。字符串就是一系列字符的序列。就像发短信需要在结尾按“发送”或“回车”一样,字符串通常以换行符\n结束,这是一个我们之前学过的控制字符。

如何创建一个字符串?
字符串以双引号"开始,后面可以跟任意数量的字符,直到遇到另一个双引号"结束。打印时,这些引号本身不会显示出来。

main函数本身也是一个函数。int main(void)中的void表示它不接受任何输入,而开头的int表示它的输出是一个整数值。目前,我们总是返回0,表示程序运行正常结束。


理解函数调用与参数

当我们使用一个函数时,例如printf("Hello World\n");,我们称之为函数调用。函数的输入被称为参数。如果函数有多个参数,它们之间用逗号,分隔。

printf函数至少需要一个字符串参数。它也会返回一个整数值(表示打印了多少个字符),但这个返回值通常被忽略。

以下是printf可以接受多个参数的例子:

printf("Hello World\n", 1);

在这个例子中,第二个参数是整数1,但目前printf还无法使用它,所以程序仍然只会输出“Hello World”。


使用格式字符串打印变量值

如果我们想用printf打印变量的值,就需要使用格式字符串

在格式字符串中,我们使用格式说明符来告诉printf如何显示数据。格式说明符以百分号%开头。

  • %d:用于输出整数。
  • %lf:用于输出双精度浮点数(double)。
  • %c:用于输出字符。

%lf中的lf代表“long float”,这是历史原因造成的命名。

如何使用?
格式字符串中的第一个格式说明符对应printf的第一个额外参数,第二个对应第二个,依此类推。

int x = 1;
int y = 2;
printf("Point %d, %d\n", x, y);

运行这段代码,将输出:Point 1, 2

如何打印一个真正的百分号?
就像用\\表示一个反斜杠一样,在格式字符串中,用%%来表示一个百分号%


字符与整数的关系

计算机底层一切皆数字。字符在内存中也是以数字形式存储的(例如ASCII码)。我们可以通过格式说明符来查看这种关系。

printf("The integer value of 'A' is %d\n", 'A'); // 输出:65
printf("The character value of 65 is %c\n", 65); // 输出:A

字符'A'对应的整数值是65。通过改变格式说明符,我们可以让同一个值以整数或字符的形式显示。


使用scanf获取用户输入

printf用于输出,scanf则用于从终端获取输入。它同样使用格式字符串。

关键区别:scanf需要变量的地址
为了让scanf能够修改我们变量的值(即把用户输入存进去),它需要知道这个变量在内存中的起始地址,而不仅仅是变量的值。

如何获取一个变量的地址?使用取地址运算符&

int x;
scanf("%d", &x); // &x 获取了变量x在内存中的地址

&x的结果类型是int*(指向整数的指针),我们将在课程后半部分详细讨论。现在只需记住,scanf的参数需要是变量的地址。


一个完整的输入输出示例

以下是一个简单的程序,它提示用户输入一个数字,然后将其打印出来。

#include <stdio.h>
int main(void) {
    int x;
    printf("Input x: ");
    scanf("%d", &x);
    printf("You entered: %d\n", x);
    return 0;
}

程序运行流程:

  1. 打印提示信息 "Input x: "
  2. 等待用户输入一个整数(例如14)并按下回车。
  3. scanf将输入的数字转换为整数值,并存储到变量x的地址中。
  4. printf将变量x的值打印出来。

注意事项:

  • 格式说明符(如%d)必须与要读取的变量类型(如int)匹配,否则会导致不可预测的结果。
  • 目前我们假设用户总是输入有效数据。在实际程序中,需要检查scanf的返回值(成功读取的项目数)来处理错误输入。

常量与命名规范

有时我们需要创建值永不改变的变量,这称为常量。在C语言中,使用const关键字来声明常量。

const int InchesPerCm = 2.54; // 这是一个常量,值不能被修改

尝试给InchesPerCm重新赋值会导致编译错误。使用常量可以防止意外修改,使代码更安全。

命名规范(约定俗成):

  • 普通变量:以小写字母开头,如果包含多个单词,后续单词首字母大写(驼峰命名法),例如userAge
  • 常量:以大写字母开头,单词间用下划线_连接,例如Inches_Per_Cm。全大写的常量(如EXIT_SUCCESS)通常表示特殊的预定义值。

保持一致的代码风格非常重要,它能提高代码的可读性。


实践:英寸转厘米程序

现在,让我们综合运用所学知识,编写一个将英寸转换为厘米的程序。

程序思路:

  1. 定义一个常量存储转换率(英寸/厘米)。
  2. 提示用户输入英寸值。
  3. 使用scanf读取用户输入。
  4. 进行计算(厘米 = 英寸 * 转换率)。
  5. 使用printf输出结果。

以下是程序代码:

#include <stdio.h>
#include <stdlib.h> // 为了使用 EXIT_SUCCESS

int main(void) {
    const double InchesPerCm = 2.54;

    printf("Enter number of inches: ");
    double inches = 0.0;
    scanf("%lf", &inches);

    double centimeters = inches * InchesPerCm;

    printf("Measurement in CM is: %.2lf\n", centimeters);

    return EXIT_SUCCESS; // 比直接返回0更具表达性
}

代码说明:

  • 第10行:%.2lf是格式说明符的子说明符.2表示只打印小数点后两位,并进行四舍五入。这不会改变变量centimeters的实际值,只影响显示方式。
  • 第12行:使用EXIT_SUCCESS(定义在stdlib.h中)代替0,能更清晰地表达“程序成功退出”的意图。

总结与注意事项

本节课我们一起学习了C语言的输入与输出。

  • 我们使用printf和格式字符串(如%d, %lf, %c)来输出数据和变量。
  • 我们使用scanf和取地址运算符&来获取用户输入并存储到变量中。
  • 我们了解了常量(const)的用法和基本的命名规范。
  • 我们完成了一个完整的单位转换程序。

重要提醒:
计算机的存储空间是有限的。例如,int类型有最大值(约21亿)。如果尝试存储超过这个范围的数字,会发生“溢出”,导致结果回绕到最小值,从而产生错误。在本课程中,我们会确保输入数据在合理范围内,但了解这一限制对未来的编程工作至关重要。

现在,你应该能够完成实验一的第一部分,并尝试第二部分了。如果遇到困难,下一讲的内容会提供更多帮助。

004:算术运算 🧮

在本节课中,我们将要学习C语言中的算术运算。我们将了解什么是表达式,学习C语言中运算符的优先级和结合性,并探讨整数除法的特殊规则、类型转换以及一些实用的快捷运算符。理解这些概念是编写正确数学运算代码的基础。

什么是表达式?

上一节我们介绍了课程背景,本节中我们来看看算术运算的基础——表达式。

表达式是操作数和运算符的组合,其计算结果是一个单一的值。例如,1 + 2 就是一个表达式,其结果是 3。即使是单个值,如 3,本身也是一个表达式。

运算符与运算顺序

C语言包含所有你期望的运算符:加(+)、减(-)、乘(*)、除(/)。和我们熟悉的数学规则一样,C语言中的运算遵循特定的顺序,即运算优先级。你可能学过 BEDMAS 或 PEMDAS 规则,它们是相同的概念,只是名称不同。

以下是C语言中的基本运算顺序:

  1. 括号 () 内的运算最先进行。
  2. 接着是指数运算(本课暂不涉及)。
  3. 然后是同优先级的乘(*)、除(/)运算。
  4. 最后是同优先级的加(+)、减(-)运算。
  5. 对于相同优先级的运算符,计算顺序是从左到右,这被称为左结合性

理解运算顺序至关重要。例如,计算 1 + 2 * 3。因为乘法优先级高于加法,所以先计算 2 * 3 得到 6,再计算 1 + 6,最终结果是 7。如果没有优先级规则,从左到右计算会得到 (1 + 2) * 3 = 9,这将导致错误的结果。

整数除法与取模运算

在C语言中,除法运算的行为可能与你预期的不同。当两个操作数都是整数(int)时,进行的是整数除法,结果也是一个整数。

整数除法的规则是截断(直接舍弃小数部分),而不是四舍五入。例如,5 / 2 的结果是 2,而不是 2.5

如果你想得到除法运算的余数,需要使用取模运算符%)。它的优先级与乘除法相同。例如,5 % 2 的结果是 1

取模运算必须用于两个整数之间,不能用于浮点数(double)。C语言确保以下等式始终成立:
(a / b) * b + (a % b) == a
其中 ab 是整数,a / b 进行整数除法。

需要注意的是,当被除数(a)为负数时,余数也可能是负数。例如,-5 % 2 的结果是 -1。在本课程中,我们通常假设操作数为正数以简化问题。

除以零与未定义行为

在数学和C语言中,除以零都是没有意义的。如果你在代码中写入 1 / 0,编译器可能会发出警告,但程序仍可编译。

然而,运行这样的程序会导致未定义行为。这意味着程序可能输出任意随机值,每次运行的结果都可能不同,在不同的计算机上表现也可能不同。依赖未定义行为是严重的错误来源,必须避免。因此,在编写除法代码时,务必确保除数不为零。

混合类型运算与类型转换

当表达式中混合了整数(int)和浮点数(double)时,C语言会自动进行隐式类型转换

转换规则如下:

  • 如果两个操作数都是 int,则结果也是 int(取模运算要求如此)。
  • 如果至少有一个操作数是 double,那么另一个操作数会被转换为 double,然后进行计算,结果为 double

int 转换为 double 很简单,直接添加 .0,例如 2 变成 2.0。将 double 转换为 int 则会进行截断,例如 2.9 变成 2

除了隐式转换,你也可以使用显式类型转换(或称强制类型转换)。它使用类型转换运算符,格式为 (类型)值。这是一个一元运算符,具有很高的优先级。

例如:

  • (int)2.5 的结果是整数 2
  • (double)5 / 2 的计算过程是:先将整数 5 转换为 5.0,然后 2 被隐式转换为 2.0,最终 5.0 / 2.0 的结果是 2.5

赋值运算符及其快捷方式

赋值(=)本身也是一个运算符,它是右结合的,并且优先级非常低。赋值表达式的结果是被赋予的值。例如,x = 3 这个表达式的结果是 3。这意味着你可以写 y = x = 3,但这会使代码难以阅读。建议初学者始终将赋值语句单独成行

对于常见的运算后赋值操作,C语言提供了复合赋值运算符作为快捷方式:

  • x = x + 2; 可以简写为 x += 2;
  • 同样,还有 -=*=/=%=

这些快捷方式让代码更简洁。

自增与自减运算符

将变量加1(递增)或减1(递减)是非常常见的操作。C语言为此提供了专门的运算符:

  • 递增:++
  • 递减:--

它们有两种形式:

  • 前缀形式(如 ++x):先增加变量的值,然后返回增加后的值。
  • 后缀形式(如 x++):先返回变量当前的值,然后再增加变量的值。

建议始终使用前缀形式++x),除非你有非常明确的理由需要使用后缀形式。前缀形式更直观,且通常效率更高。

sizeof 运算符

sizeof 是一个一元运算符,用于获取一个类型或变量所占用的内存字节数,结果是一个整数。

例如:

  • sizeof(int) 通常返回 4
  • sizeof(double) 通常返回 8
  • sizeof(char) 返回 1

你也可以对变量使用它,如 double x; sizeof(x); 同样返回 8

代码注释

为了让自己和他人更容易理解代码,可以添加注释。编译器会完全忽略注释内容。

C语言有两种注释方式:

  1. 多行注释:介于 /**/ 之间的所有内容。
    /* 这是一个多行注释,
       可以跨越多行。 */
    
  2. 单行注释:从 // 开始到行尾的内容。
    // 这是一个单行注释
    #include <stdlib.h> // 需要这个头文件来使用 exit(EXIT_SUCCESS)
    

虽然本课程的实验作业不根据注释评分,但良好的注释习惯对编写和维护代码非常有帮助。

一个常见的陷阱

最后,我们来看一个初学者常犯的错误,它综合体现了整数除法的特性。

请问表达式 1 / 2 + 1 / 2 在C语言中的结果是多少?

根据整数除法规则,每个 1 / 2 的结果都是 0(因为 0.5 被截断)。所以整个表达式是 0 + 0,最终结果为 0,而不是数学上正确的 1

要得到正确结果,需要确保至少有一个操作数是浮点数,例如写成 1.0 / 2 + 1.0 / 2


本节课中我们一起学习了C语言算术运算的核心知识:表达式与运算符优先级、整数除法与取模运算、混合类型转换、以及自增、赋值等快捷运算符。记住整数除法的截断规则和避免除以零是写出正确数学运算代码的关键。下一讲,我们将开始学习如何让程序根据条件做出判断。

005:数学运算与随机数 🧮🎲

在本节课中,我们将学习C语言中的数学运算和随机数生成。我们将探讨如何使用数学库进行高级计算,以及如何生成和控制随机数。这些知识对于完成实验一和实验二至关重要。

数学库与函数原型

上一节我们介绍了基本的算术运算。本节中,我们来看看如何使用C语言的标准数学库进行更复杂的计算。

要使用数学库,我们需要在代码开头包含其头文件。这会将库中所有函数的原型添加到我们的程序中。

函数原型描述了函数的输入和输出。其基本形式如下:

输出类型 函数名(输入类型1, 输入类型2, ...);

例如,int main(void) 表示 main 函数没有输入,输出一个整数。

以下是几个函数原型的例子:

  • double foo(double);:函数名为 foo,接受一个 double 类型输入,返回一个 double 类型输出。
  • double foo(double, double);:函数 foo 接受两个 double 类型输入,返回一个 double 类型输出。
  • void f(double);:函数 f 接受一个 double 类型输入,没有输出。
  • double foo(void);:函数 foo 没有输入,返回一个 double 类型输出。

头文件(如 math.h)本质上就是包含了许多这样的函数原型。预处理器会在编译前将 #include 指令替换为对应文件的内容。

常用数学函数

了解了如何引入数学库后,现在我们来具体看看其中一些有用的函数。

平方根:计算平方根的函数是 sqrt。它接受一个 double 参数,并返回其平方根。

double result = sqrt(4.0); // result 的值为 2.0

幂运算:计算幂的函数是 pow。它接受两个 double 参数(底数和指数),并返回结果。

double result = pow(2.0, 8.0); // result 的值为 256.0

三角函数:例如 sincos,它们使用弧度制。数学库中定义了常量 M_PI 来表示 π 的值。

double sine_value = sin(M_PI / 2.0); // 计算 sin(90°),结果为 1.0

对数函数

  • log(x) 计算自然对数(以 e 为底)。
  • log10(x) 计算常用对数(以 10 为底)。
    常量 M_E 表示自然对数的底数 e。

浮点数取余:对于浮点数,不能使用 % 运算符。需要使用 fmod 函数。

double remainder = fmod(3.5, 2.0); // remainder 的值为 1.5

最值函数

  • fmin(a, b) 返回 ab 中较小的值。
  • fmax(a, b) 返回 ab 中较大的值。

浮点数舍入

处理浮点数时,经常需要舍入。C语言提供了多种舍入函数,它们的行为略有不同。

以下是主要的舍入方式:

  • round(x):四舍五入到最接近的整数。在恰好为0.5的情况下,总是远离零方向舍入(例如,1.5 -> 2, -1.5 -> -2)。
  • rint(x):四舍五入到最接近的整数。在恰好为0.5的情况下,默认采用“银行家舍入法”,即舍入到最近的偶数(例如,1.5 -> 2, 2.5 -> 2)。
  • ceil(x):向上取整,返回不小于 x 的最小整数(例如,ceil(1.1) -> 2, ceil(-1.9) -> -1)。
  • floor(x):向下取整,返回不大于 x 的最大整数(例如,floor(1.9) -> 1, floor(-1.1) -> -2)。
  • 截断:强制转换为 int 类型会直接丢弃小数部分。对于正数,效果同 floor;对于负数,效果同 ceil

重要提示printf 中的格式说明符(如 %.1lf)只影响显示的舍入,不会改变变量实际存储的值。若需真正修改变量的值,必须使用上述舍入函数并重新赋值。

若要舍入到指定小数位,需要先缩放数值,舍入后再缩放回来。例如,将 2.55 舍入到一位小数:

double value = 2.55;
double rounded_value = rint(value * 10.0) / 10.0; // 结果为 2.6

实际问题:凑整到5分币

现在,我们应用所学知识解决一个实际问题:由于1分币已淘汰,需要将金额舍入到最接近的5分币(0.05元)。

思路是:将金额除以镍币的面值(0.05),得到镍币的数量(一个浮点数)。将这个数量四舍五入到最接近的整数,再乘以面值,就得到了舍入后的金额。

double amount = 42.42;
double rounded_amount = rint(amount / 0.05) * 0.05;
// rounded_amount 为 42.40

随机数生成

最后,我们来看看如何在C语言中生成随机数。随机数由 rand 函数生成,其原型定义在 stdlib.h 中。

rand() 函数返回一个介于 0RAND_MAX(一个很大的整数)之间的伪随机整数。伪随机意味着数字序列由数学公式生成,是确定性的。

为了获得不同的随机序列,可以使用 srand 函数设置随机数种子。种子值通常取自当前时间。

srand((unsigned int)time(NULL)); // 使用当前时间作为种子
int random_num = rand();

要生成特定范围内的随机数(如1到10),可以结合使用取模运算符 % 和加法:

int random_in_range = (rand() % 10) + 1; // 生成 1 到 10 之间的随机数

更通用的公式是,生成 [A, B] 区间内的随机数:

int random_num = (rand() % (B - A + 1)) + A;

本节课中我们一起学习了C语言数学库的使用,包括各种数学函数和舍入方法,并通过实例解决了凑整问题。最后,我们还掌握了生成和控制伪随机数的技巧。这些内容是进行科学计算和模拟程序开发的基础。

006:If语句 🎯

在本节课中,我们将要学习C语言中一个非常核心的概念——if语句。通过if语句,我们的程序将不再只是简单地按顺序执行每一行代码,而是能够根据条件做出判断,从而执行不同的代码块。这是让程序看起来“智能”的第一步。

什么是If语句? 🤔

到目前为止,我们的程序总是做同样的事情。运行main函数时,它只是按顺序一行一行地执行,直到遇到return语句结束。虽然输入可能不同(例如,我们可以输入不同的整数),但这远不足以完成稍微复杂的任务。

if语句为我们提供了一种有条件地运行代码的方式,即只在某些情况下运行代码,而不是每次都运行。它本质上使用一个布尔值(真或假)来决定是否执行某段代码。

If语句的语法 📝

C语言的语法规则规定了if语句的写法。其基本结构如下:

if (表达式) {
    // 如果表达式为真,则执行这里的语句
}
  • if是一个保留关键字,不能用作变量名。
  • 表达式是任何能计算出值的组合,在这个上下文中,它应该能得出一个布尔值。
  • 如果表达式结果为,则执行大括号 {} 内的语句。
  • 如果表达式结果为,则直接跳过这些语句,继续执行大括号之后的代码。

重要提示:虽然从语法上讲,如果if后面只有一条语句,可以省略大括号,但强烈建议始终使用大括号。省略大括号是许多难以调试的错误(甚至是一些重大安全漏洞)的根源。使用大括号能让代码结构更清晰,避免歧义。

关系运算符 🔗

if语句中,我们需要一个能产生布尔值的表达式。到目前为止,我们见过的运算符(如加、减、乘、除、取模)都产生数字结果。为了进行比较,我们需要关系运算符

以下是C语言中常用的关系运算符,它们接受数字参数并返回布尔值:

  • ==:等于(注意:在C语言中,单个=是赋值,==才是比较)
  • !=:不等于
  • <:小于
  • <=:小于或等于
  • >:大于
  • >=:大于或等于

示例

  • 2 == 2 结果为 真 (1)
  • 3 < 2 结果为 假 (0)
  • 3 >= 3 结果为 真 (1)

关系运算符遵循与算术运算类似的类型转换规则:如果两个操作数都是int,则用整数比较;如果至少有一个是double,则另一个会被隐式转换为double,然后进行浮点数比较。注意:比较浮点数时,通常不建议直接使用==来判断相等,因为可能存在精度问题。

逻辑运算符 🧠

有时我们需要组合多个条件。这时就需要逻辑运算符,它们只对布尔值进行操作,并返回一个新的布尔值。

以下是主要的逻辑运算符:

  • &&:逻辑与。只有当两边条件都为真时,整个表达式才为真。
    • 示例:(1 && 1) 为真,(0 && 1) 为假。
  • ||:逻辑或。只要有一边条件为真,整个表达式就为真。
    • 示例:(1 || 0) 为真,(0 || 0) 为假。
  • !:逻辑非。这是一个一元运算符,用于取反。
    • 示例:!1 为假,!0 为真。

运算符优先级:在复杂的表达式中,运算顺序很重要。算术运算符(*, /, %, +, -)优先级最高,其次是关系运算符(<, <=, >, >=, ==, !=),最后是逻辑运算符(其中&&优先级高于||)。最佳实践:如果不确定优先级,就使用括号()来明确指定运算顺序,这能让代码更清晰、更安全。

第一个If语句程序 🚀

现在,让我们编写第一个使用if语句的程序。这个程序将判断用户输入的数字是奇数还是偶数。

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

int main(void) {
    int num = 0;
    printf("请输入一个整数:");
    scanf("%d", &num);

    if (num % 2 == 0) {
        printf("这个数是偶数。\n");
    }

    return EXIT_SUCCESS;
}

程序流程解析

  1. 程序从main开始,打印提示信息。
  2. 使用scanf读取用户输入的整数并存入变量num
  3. 计算if语句中的表达式:num % 2 == 0
    • 如果num除以2的余数为0(即为偶数),表达式结果为,程序进入if代码块,执行printf打印“这个数是偶数”。
    • 如果表达式结果为(即为奇数),程序直接跳过if代码块,执行后面的return语句。
  4. 程序结束。

Else语句:处理另一种情况 🔄

上一节我们介绍了如何条件性地执行代码。本节中,我们来看看当条件不满足时,如何执行另一段不同的代码。这就要用到else语句。

if-else语句的语法如下:

if (表达式) {
    // 表达式为真时执行的语句
} else {
    // 表达式为假时执行的语句
}

每次执行时,程序只会执行if块或else块中的一个,绝不会同时执行两者。执行完毕后,程序会继续执行整个if-else结构之后的代码。

让我们改进之前的奇偶数判断程序:

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

int main(void) {
    int num = 0;
    printf("请输入一个整数:");
    scanf("%d", &num);

    if (num % 2 == 0) {
        printf("这个数是偶数。\n");
    } else {
        printf("这个数是奇数。\n");
    }

    return EXIT_SUCCESS;
}

现在,无论输入什么整数,程序都会给出明确的“奇数”或“偶数”反馈。

关于取模运算的注意事项:判断奇偶数时,将取模结果与0比较是安全的做法。如果与1比较(例如num % 2 == 1),当num为负数时(如-7 % 2在C语言中结果为-1),判断会出错。因此,对于判断倍数关系,总是与0比较是更稳妥的选择。

多个条件与嵌套If语句 🧩

程序通常需要处理更复杂的逻辑,这就需要组合多个if语句,甚至进行嵌套。

以下是一个包含多个条件的例子:

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

int main(void) {
    int num = 0;
    printf("请输入一个整数:");
    scanf("%d", &num);

    if (num % 2 == 0) {
        printf("这个数是偶数。\n");
    }

    if (num >= 10) {
        printf("这个数大于等于10。\n");
    } else {
        printf("这个数小于10。\n");
    }

    return EXIT_SUCCESS;
}

悬空Else问题:当if语句嵌套时,else与哪个if配对可能产生歧义。C语言规定:else最近的、尚未配对的if配对。清晰的缩进和使用大括号可以避免混淆。

重要提示:C编译器不关心代码的缩进,缩进只是为了方便人类阅读。代码的结构完全由大括号{}决定。

综合实践:一个简单的骰子游戏 🎲

最后,让我们运用所学知识,创建一个简单的猜奇偶骰子游戏,将if语句、随机数和用户输入结合起来。

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

int main(void) {
    // 设置随机数种子,确保每次运行结果不同
    srand(time(NULL));

    int bet = 0;
    printf("请下注(0代表偶数,1代表奇数):");
    scanf("%d", &bet);

    // 生成1到6的随机数,模拟掷骰子
    int die_roll = (rand() % 6) + 1;
    printf("你掷出了:%d\n", die_roll);

    // 判断输赢
    if (die_roll % 2 == bet) {
        printf("你赢了!\n");
    } else {
        printf("你输了。\n");
    }

    return EXIT_SUCCESS;
}

游戏逻辑解析

  1. 使用srand(time(NULL))初始化随机数生成器,使每次游戏结果不同。
  2. 提示用户下注(0=偶数,1=奇数)。
  3. 生成一个1到6的随机整数作为骰子点数。
  4. 判断输赢:如果骰子点数除以2的余数等于用户的下注数字,则用户猜中了奇偶性,宣布获胜;否则宣布失败。

本节课中我们一起学习了C语言中至关重要的if语句。我们掌握了如何使用关系运算符(如==, <, >)创建条件表达式,以及如何使用逻辑运算符(&&, ||, !)组合多个条件。我们学习了ifif-else的基本语法与执行流程,并了解了通过嵌套和组合可以构建更复杂的程序逻辑。最后,我们通过一个有趣的骰子游戏将理论付诸实践。if语句是控制程序流程的基石,理解它对于编写任何有意义的程序都至关重要。

007:决策判断 👨‍💻

在本节课中,我们将继续学习如何让计算机做出决策,而不仅仅是按顺序执行语句。我们将深入探讨关系运算符、逻辑运算符,并学习如何编写更复杂的条件判断程序。


字符的比较与运算

上一节我们介绍了关系运算符和逻辑运算符。本节中我们来看看如何将这些概念应用到字符上。

计算机将字符编码为数字(ASCII码)。虽然我们无需记住每个字符的具体编码,但了解它们的顺序很重要,因为我们可以比较字符。

以下是字符顺序的核心规则:

  • 数字字符 ‘0’‘9’ 是连续且按顺序排列的。
  • 大写字母 ‘A’‘Z’ 是连续且按顺序排列的。
  • 小写字母 ‘a’‘z’ 是连续且按顺序排列的。
  • 数字字符的值小于大写字母,大写字母的值小于小写字母。

由于字符本质上是数字,我们可以对它们进行算术运算。例如:

  • ‘0’ + 2 的结果是字符 ‘2’
  • ‘A’ + 2 的结果是字符 ‘C’
  • ‘o’ - 1 的结果是字符 ‘n’

实践:判断输入是否为字母

现在,让我们编写一个程序来判断用户输入的是否为字母。

程序的核心逻辑是:我们需要检查输入的字符是否落在大写字母或小写字母的范围内。

如果我们直接用数学逻辑描述,可能是:“字符 >= ‘A’ 且 字符 <= ‘Z’”。但在C语言中,我们不能直接写成 ‘A’ <= c <= ‘Z’,因为C语言会从左到右依次计算。它会先计算 ‘A’ <= c,得到一个布尔值(真或假),然后再用这个布尔值与 ‘Z’ 比较,这并非我们想要的结果。

正确的做法是使用逻辑运算符 && (与) 来连接两个条件。

以下是判断字符是否为字母的步骤:

  1. 提示用户输入一个字符。
  2. 读取用户输入的字符。
  3. 使用 if 语句进行判断。
  4. 条件应为:(c >= ‘A’ && c <= ‘Z’) || (c >= ‘a’ && c <= ‘z’)
  5. 根据条件结果输出相应信息。

初始代码可能如下所示,但它只检查了大写字母:

#include <stdio.h>
int main() {
    char c = ‘\0’; // 初始化为空字符
    printf(“Enter a character: “);
    scanf(“%c”, &c);
    if (c >= ‘A’ && c <= ‘Z’) {
        printf(“You entered a letter.\n”);
    } else {
        printf(“You did not enter a letter.\n”);
    }
    return 0;
}

为了同时检查小写字母,我们需要使用逻辑运算符 || (或) 来组合两个条件。完整的条件语句如下:

if ( (c >= ‘A’ && c <= ‘Z’) || (c >= ‘a’ && c <= ‘z’) ) {
    printf(“You entered a letter.\n”);
} else {
    printf(“You did not enter a letter.\n”);
}

提升代码可读性:使用布尔变量

当条件判断变得复杂时,代码会难以阅读。一个提高可读性的好方法是使用布尔变量为条件命名。

我们可以创建两个布尔变量来清晰地表达我们的意图:

  • is_uppercase:检查字符是否为大写字母。
  • is_lowercase:检查字符是否为小写字母。

改进后的代码片段如下:

int is_uppercase = (c >= ‘A’ && c <= ‘Z’);
int is_lowercase = (c >= ‘a’ && c <= ‘z’);
if (is_uppercase || is_lowercase) {
    printf(“You entered a letter.\n”);
}

这种方式让 if 语句的意图一目了然,即使几个月后回看代码,也能立刻理解其逻辑。


逻辑运算的“短路”求值

C语言在计算逻辑表达式时采用“短路”求值(Lazy Evaluation),这会影响程序的执行效率,有时甚至影响结果。

对于 || (或) 运算:
如果第一个条件为真,整个表达式已经确定为真,编译器将不会计算第二个条件。
例如,在 is_uppercase || is_lowercase 中,如果字符是大写字母,程序就不会再去检查它是否是小写字母。

对于 && (与) 运算:
如果第一个条件为假,整个表达式已经确定为假,编译器将不会计算第二个条件。
例如,在 (c >= ‘A’) && (c <= ‘Z’) 中,如果字符小于 ‘A’,程序就不会再去检查它是否小于等于 ‘Z’

了解短路求值有助于我们编写更高效的代码,例如将更可能使表达式短路(为真或为假)的条件放在前面。


德摩根定律与条件改写

有时,复杂的条件判断可以通过德摩根定律进行改写,使其更清晰。德摩根定律是逻辑学中的基本定律,在编程中非常实用。

定律如下:

  • !(A || B) 等价于 !A && !B
  • !(A && B) 等价于 !A || !B

应用实例:如果我们想判断一个字符“不是字母”,原始写法是 if ( !(is_uppercase || is_lowercase) )。应用德摩根定律后,可以改写为 if ( !is_uppercase && !is_lowercase )。后者有时更易于理解。

重要提示: 改写时必须注意运算符优先级并使用括号。随意移除括号可能导致逻辑错误。例如,!is_uppercase || is_lowercase!(is_uppercase || is_lowercase) 的含义完全不同。


if 语句的常见陷阱

在使用 if 语句时,有几个常见的错误需要避免。

陷阱一:多余的分号
if 条件后直接跟一个分号 ;,会被视为一个空语句。这意味着无论条件是否成立,后面的代码块都会执行。

if (condition); // 错误!这个分号是一个空语句
{
    printf(“This always prints!\n”);
}

正确做法是 在条件后直接使用花括号 {}

陷阱二:复杂的嵌套
过度嵌套 if 语句会使代码难以阅读和维护。应优先考虑使用逻辑运算符组合条件。

// 不推荐:嵌套过深
if (a) {
    if (b) {
        // 执行操作
    }
}
// 推荐:使用逻辑运算符
if (a && b) {
    // 执行操作
}

多分支判断:else if

当需要从多个互斥的条件中选择一个执行时,可以使用 else if 语句链。

其结构如下:

if (condition1) {
    // 条件1为真时执行
} else if (condition2) {
    // 条件1为假且条件2为真时执行
} else if (condition3) {
    // 条件1、2为假且条件3为真时执行
} else {
    // 所有条件都为假时执行
}

程序会从上到下依次检查条件,一旦某个条件为真,就执行对应的代码块,然后跳过整个 else if 链。


综合练习:求三个整数的最大值

让我们运用所学知识,编写一个程序,找出用户输入的三个整数中的最大值。

程序思路:

  1. 读取三个整数 x, y, z
  2. 使用条件判断找出最大值。
  3. 我们可以分情况讨论:最大值可能是 x,可能是 y,否则就是 z

一种清晰的实现方式是直接比较:

  • 如果 x 大于等于 y 并且 x 大于等于 z,那么最大值是 x
  • 否则,如果 y 大于等于 x 并且 y 大于等于 z,那么最大值是 y
  • 否则,最大值就是 z

示例代码:

#include <stdio.h>
int main() {
    int x, y, z, max;
    printf(“Enter three integers: “);
    scanf(“%d %d %d”, &x, &y, &z);
    if (x >= y && x >= z) {
        max = x;
    } else if (y >= x && y >= z) {
        max = y;
    } else {
        max = z;
    }
    printf(“The maximum is %d.\n”, max);
    return 0;
}

另一种思路是先将 max 初始化为 z,然后只检查 xy 是否比它大,这样代码更简洁。


总结 🎯

本节课中我们一起学习了C语言中决策判断的核心知识:

  1. 字符运算:理解了字符基于ASCII码的顺序,并可以对其进行算术和关系运算。
  2. 逻辑组合:熟练使用 && (与)、|| (或) 来构建复杂的条件表达式。
  3. 代码可读性:通过使用有意义的布尔变量名来提升代码清晰度。
  4. 短路求值:了解了C语言逻辑运算的执行特点,可以借此编写更高效的代码。
  5. 德摩根定律:掌握了改写复杂逻辑条件的有效工具。
  6. 避免陷阱:认识了 if 语句后误加分号等常见错误。
  7. 多分支结构:学会了使用 if-else if-else 链来处理多个互斥条件。
  8. 综合应用:通过“求三个数最大值”的练习,将以上知识点融会贯通。

记住,编写清晰、易读的代码与编写正确的代码同等重要。不断练习,你就能熟练地让程序做出智能的决策。

008:While循环 🌀

在本节课中,我们将学习C语言中的while循环。上一节我们介绍了if语句,它允许我们根据条件向前跳转执行代码。本节中,我们将学习如何让代码向后跳转,从而实现重复执行特定代码块的功能。这正是while循环的核心作用。

什么是While循环?

while循环允许我们在某个条件为真时,重复执行一段代码。这使得计算机能够高效地执行重复性任务。

其基本语法如下:

while (表达式) {
    // 要重复执行的代码
}

其中,表达式 是一个布尔表达式,其结果为真(非零)或假(零)。只要表达式为真,花括号 {} 内的代码就会不断重复执行。

While循环的执行流程

为了更好地理解,我们可以将其与if语句对比:

  • if语句:检查条件,如果为真则执行一次代码块,然后继续执行后面的语句。
  • while循环:检查条件,如果为真则执行代码块,执行完毕后返回再次检查条件。只要条件仍为真,就继续重复执行代码块,直到条件变为假,程序才会跳出循环,继续执行循环之后的代码。

因此,要避免程序陷入无限循环,必须在循环体内有能够改变条件状态的代码,使其最终变为假。

实践:倒计时程序

让我们通过一个简单的倒计时程序来理解while循环。

目标:编写一个程序,从10倒数到1,然后打印“发射!”。

不使用循环的笨办法是写11条printf语句。而使用while循环,我们可以更简洁地实现:

#include <stdio.h>

int main() {
    int count = 10; // 初始化计数器

    while (count > 0) { // 当count大于0时执行循环
        printf("倒计时 %02d\n", count); // 打印当前计数,%02d确保两位数显示,不足补零
        count--; // 计数器减1,这是改变循环条件的关键步骤
    }

    printf("发射!\n");
    return 0;
}

代码解析

  1. 我们创建了一个变量 count 并初始化为10。
  2. while (count > 0) 是循环条件。只要 count 的值大于0,循环就会继续。
  3. 在循环体内,我们打印当前的 count 值,然后使用 count-- 将其减1。
  4. count 减到0时,条件 count > 0 变为假,循环结束,接着执行最后的 printf(“发射!\n”)

格式说明符小技巧%02d 中的 02 表示输出的整数至少占2位宽度,不足2位时在前面用0填充。这确保了输出格式整齐(如 10, 09, 08…)。

实践:计算整数位数

接下来,我们看一个更实用的例子:计算用户输入的一个正整数的位数。

思路:我们可以利用整数除法的特性(结果向下取整)。例如,123 / 10 = 12,相当于去掉了最后一位。重复这个过程,直到数字变为0,重复的次数就是数字的位数。

以下是实现代码:

#include <stdio.h>

int main() {
    int input;
    int num_digits = 0;

    printf("请输入一个正整数:");
    scanf("%d", &input);

    while (input != 0) {
        input /= 10; // 等价于 input = input / 10; 去掉最后一位
        ++num_digits; // 位数加1
    }

    printf("这个数有 %d 位。\n", num_digits);
    return 0;
}

执行示例

  • 输入 123123 -> 12 (1次) -> 1 (2次) -> 0 (3次),输出“这个数有 3 位。”
  • 输入 55 -> 0 (1次),输出“这个数有 1 位。”

关于自增运算符:代码中使用了前缀自增 ++num_digits。虽然在此处与后缀自增 num_digits++ 效果相同,但在C语言中,涉及表达式求值时两者有区别。作为良好实践,在单独进行自增操作时,推荐使用前缀形式以避免潜在的混淆。

Do-While循环

除了标准的while循环,C语言还提供了do-while循环。两者的关键区别在于条件检查的时机

  • while循环:先检查条件,再执行循环体(可能一次都不执行)。
  • do-while循环:先执行一次循环体,再检查条件(至少执行一次)。

其语法如下:

do {
    // 要重复执行的代码
} while (表达式);

实践:输入验证

do-while循环非常适合用于输入验证,确保用户输入符合要求。例如,强制用户输入一个正数:

#include <stdio.h>

int main() {
    int input;

    do {
        printf("请输入一个正整数:");
        scanf("%d", &input);
        // 如果输入小于等于0,条件为真,循环继续,要求重新输入
    } while (input <= 0);

    printf("你输入了:%d\n", input);
    return 0;
}

在这个程序中,即使用户一开始输入负数或零,printfscanf也会至少执行一次,提示用户输入。直到用户输入一个大于0的数,循环条件 input <= 0 才为假,循环终止。

综合实践:正整数求和程序

现在,我们结合所学,编写一个程序,持续读取用户输入的正整数并将其累加,直到用户输入0为止,然后输出总和。

#include <stdio.h>

int main() {
    int sum = 0; // 总和初始化为0
    int input;

    printf("请输入正整数(输入0结束):\n");

    // 先读取第一个数
    scanf("%d", &input);

    while (input != 0) { // 当输入不是0时继续循环
        sum = sum + input; // 累加到总和
        // 提示并读取下一个数
        printf("请输入下一个正整数(输入0结束):\n");
        scanf("%d", &input);
    }

    printf("所有数的总和是:%d\n", sum);
    return 0;
}

重要提示:代码顺序:在循环中,语句的顺序至关重要。如果将循环内的 scanf 移到循环条件检查之后,就会导致第一个输入的数字被“跳过”而无法加入总和。计算机严格按顺序执行指令,理解这一点对调试程序非常重要。

总结 🎯

本节课中我们一起学习了C语言中实现重复执行的关键结构——循环。

  • 我们首先介绍了 while循环 的基本概念和语法,理解了其“先判断,后执行”的流程,并通过倒计时程序加深了理解。
  • 接着,我们应用while循环解决了计算整数位数的问题。
  • 然后,我们学习了 do-while循环,它适用于至少需要执行一次的场景,并演示了如何用它进行输入验证
  • 最后,我们完成了一个正整数累加的综合练习,巩固了对循环控制、条件判断和变量状态变化的理解。

记住,编写循环时,务必确保循环体内的操作能影响循环条件,使其最终变为假,从而避免程序陷入无限循环。

009:For循环 🔄

在本节课中,我们将要学习C语言中的for循环。for循环是一种用于实现有界重复的控制结构,特别适用于我们预先知道循环需要执行多少次的情况。它与我们之前学过的while循环功能相似,但语法更紧凑,通常更易于阅读。

语法与执行流程

for循环的语法初看可能有些复杂,但其核心逻辑与while循环一致。其基本结构如下:

for (初始化语句; 条件表达式; 增量表达式) {
    // 循环体:每次迭代要执行的语句
}
  • 初始化语句:在循环开始前执行一次,通常用于初始化一个循环计数器。
  • 条件表达式:在每次迭代开始前检查。如果为真(非零),则执行循环体;如果为假(零),则退出循环。
  • 增量表达式:在每次迭代结束后执行,通常用于更新循环计数器。

其执行流程可以概括为:

  1. 执行初始化语句(仅一次)。
  2. 检查条件表达式
  3. 若条件为真,则执行循环体内的语句。
  4. 执行增量表达式
  5. 返回步骤2,再次检查条件。

与While循环的对比

上一节我们介绍了while循环,本节中我们来看看for循环如何简化某些场景下的代码。

例如,使用while循环打印数字0到9:

int count = 0;
while (count < 10) {
    printf("%d\n", count);
    count = count + 1; // 或 count++
}

使用for循环实现相同功能:

for (int count = 0; count < 10; count++) {
    printf("%d\n", count);
}

可以看到,for循环将循环计数器count的初始化、条件检查和更新都集中在一行内,使得代码意图(循环10次)一目了然,减少了在代码不同位置寻找相关语句的麻烦。

基础应用示例

以下是for循环的一些基础应用。

打印一行星号

假设我们需要打印15个星号在一行上。

for (int i = 0; i < 15; i++) {
    printf("*");
}
printf("\n"); // 在最后换行

打印星号三角形

现在,我们来完成一个更复杂的任务:打印一个由星号组成的三角形。这需要用到嵌套循环

思路分析:

  1. 外层循环控制行数(例如5行)。
  2. 对于每一行,内层循环控制打印的星号数量,其数量等于当前行号。
int total_rows = 5;
for (int row = 1; row <= total_rows; row++) {
    // 内层循环:打印当前行所需的星号
    for (int star = 1; star <= row; star++) {
        printf("*");
    }
    printf("\n"); // 每打印完一行后换行
}

运行此代码,将输出一个5行的星号三角形。如果想改变三角形的大小,只需修改total_rows变量的值即可。

进阶示例:打印数字网格

为了展示从0开始计数的优势,我们尝试打印一个10x10的数字网格(0到99)。

for (int row = 0; row < 10; row++) {
    for (int col = 0; col < 10; col++) {
        // 计算当前应打印的数字:行号*10 + 列号
        int number = row * 10 + col;
        printf("%2d ", number); // %2d确保每个数字占2位宽度,使网格对齐
    }
    printf("\n"); // 每打印完10个数字后换行
}

这个例子清晰地展示了如何通过行索引和列索引的组合来生成连续的数字序列。

注意事项与不推荐写法

在编写for循环时,有一些做法需要避免,以保持代码的清晰和可维护性。

  • 避免在条件或增量表达式中进行复杂操作:例如,for (int i=0; i++ < 10; ) 这种写法容易导致差一错误(off-by-one error),不推荐初学者使用。
  • 避免使用逗号运算符将多个操作塞进一个表达式:虽然语法允许,如 for (int i=0; i<10; printf("*"), i++),但这会严重降低代码可读性,在实际编程中应杜绝。
  • 关于breakcontinue:这两个关键字可以用于更精细地控制循环(break立即终止整个循环,continue跳过当前迭代的剩余部分)。在本课程中,通常不鼓励使用它们,但了解其含义是必要的。

总结

本节课中我们一起学习了C语言的for循环。我们了解了它的语法结构、执行流程,并通过与while循环的对比,理解了它特别适合处理有界重复的场景。我们练习了从打印单行字符到构建星号三角形和数字网格等例子,掌握了嵌套循环的使用。最后,我们强调了一些需要避免的编码习惯,以写出更清晰、健壮的代码。记住,多练习是掌握循环的关键。

010:函数 🧩

在本节课中,我们将要学习C语言中一个非常重要的概念:函数。函数可以将复杂的程序分解成更小、更易于理解和管理的部分,使代码更清晰、更易于复用。

函数基础

上一节我们介绍了程序的基本结构,本节中我们来看看如何创建和使用函数。

函数通过函数原型来声明。原型告诉编译器函数的名称、输入参数的类型以及返回值的类型。例如,一个名为 add2 的函数,它接收一个整数并返回一个整数,其原型如下:

int add2(int x);

要定义这个函数(即告诉C语言函数具体做什么),我们需要在原型后使用花括号 {},并在其中编写代码。函数内部使用 return 语句来返回一个值并结束函数的执行。

int add2(int x) {
    return x + 2;
}

函数的顺序与声明

编译器按从上到下的顺序读取代码。然而,程序执行总是从 main 函数开始。因此,如果一个函数在 main 函数之后定义,但在 main 中被调用,编译器在读取到调用语句时还不知道这个函数的存在,会导致编译错误。

以下是解决此问题的两种方法:

方法一:将函数定义放在 main 函数之前。
这样,编译器在遇到 main 函数中的调用时,已经知道了该函数的定义。

方法二:使用函数原型提前声明。
main 函数之前,只写出函数的原型(以分号结尾),而将完整的函数定义放在后面。这相当于告诉编译器:“请相信我,稍后我会定义这个函数。”

// 函数原型声明
int add2(int x);

int main() {
    // 使用函数
    printf("%d", add2(4));
    return 0;
}

// 函数定义
int add2(int x) {
    return x + 2;
}

函数的返回类型:void

函数可以没有返回值,这时其返回类型应声明为 voidvoid 函数内部可以使用 return; 语句提前结束,也可以不写 return,函数在执行完最后一条语句后会自动返回。

void printMessage() {
    printf("Hello, World!\n");
    // 可以没有return语句
}

注意,void 函数不能返回一个值,也不能将其调用结果用作需要值的表达式的一部分。

实践:打印星号三角形

为了理解函数的实际应用,让我们尝试编写一个程序来打印一个右对齐的星号三角形。例如,输入5,输出如下:

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

我们可以将问题分解。首先,思考如何打印单行星号。这可以写成一个函数 printRow,它接收一个整数参数 row,表示该行应打印的星号数量。

void printRow(int row) {
    for (int count = 1; count <= row; count++) {
        printf("*");
    }
    printf("\n"); // 每行结束后换行
}

接着,我们编写另一个函数 printTriangle,它接收一个整数 maxRow,表示三角形的总行数。这个函数循环调用 printRow 来打印每一行。

void printTriangle(int maxRow) {
    for (int row = 1; row <= maxRow; row++) {
        printRow(row);
    }
}

最后,在 main 函数中获取用户输入,并调用 printTriangle

int main() {
    int n = 0;
    printf("Enter number of rows: ");
    scanf("%d", &n);
    printTriangle(n);
    return 0;
}

通过这种方式,我们将一个复杂问题分解成了 mainprintTriangleprintRow 三个逻辑清晰的部分。如果打印单行出错,我们只需检查 printRow 函数;如果三角形行数不对,则检查 printTriangle 函数。

参数传递:按值传递

在C语言中,当我们将一个变量作为参数传递给函数时,传递的是该变量值的副本,而不是变量本身。这被称为“按值传递”。

请看以下示例:

int add2(int a) {
    int result = a + 2;
    a = 42; // 修改函数内部的副本
    return result;
}

int main() {
    int x = 4;
    printf("x: %d\n", x); // 输出 x: 4
    int sum = add2(x);    // 传递x的值(4)的副本给函数
    printf("Result: %d\n", sum); // 输出 Result: 6
    printf("x: %d\n", x); // 输出 x: 4 (main中的x未改变)
    return 0;
}

即使函数 add2 内部将参数 a 改为了42,main 函数中的变量 x 依然保持为4。这是因为函数接收到的只是 x 的值(4)的一个独立副本,对副本的任何修改都不会影响原始的 x


本节课中我们一起学习了C语言函数的核心概念:如何声明和定义函数,void 返回类型的作用,以及通过分解“打印三角形”问题来实践函数的使用。我们还初步了解了C语言中按值传递的参数机制,这是理解函数如何与数据交互的关键。在下一讲中,我们将更深入地探讨函数参数和作用域的细节。

011:作用域

在本节课中,我们将要学习C语言中一个非常重要的概念——作用域。作用域决定了程序中变量的有效使用范围。理解作用域对于编写正确、清晰的代码至关重要。

函数调用术语

上一节我们介绍了函数的基本用法,本节中我们来看看描述函数调用的一些专业术语。

  • 调用者:发起函数调用的函数。例如,main函数调用print_triangle函数,那么main就是调用者。
  • 被调用者:被调用的函数。在上面的例子中,print_triangle就是被调用者。
  • 函数调用:程序执行流程跳转到被调用函数的过程。调用时,参数值会被复制给被调用函数。
  • 返回语句return语句会终止被调用者的执行,并将控制权交还给调用者。如果函数有返回值,调用者会获得该值的一个副本。

什么是作用域?

作用域指的是程序中可以合法使用某个变量的区域。通常,变量的作用域从其声明处开始,到其所在的最近的一对花括号 {} 结束。

变量总是存在于某个函数内部。例如,在main函数中声明的变量,其作用域覆盖整个main函数体。

int main() {
    int i = 42; // i的作用域开始
    printf("%d\n", i); // 可以合法使用i
    return 0; // i的作用域结束
}

循环与作用域

对于for循环,其初始化语句中声明的变量,其作用域仅限于该循环体内部的花括号。

以下是for循环中变量作用域的示例:

int main() {
    int i = 42; // i的作用域开始
    for (int j = 0; j < 1; j++) { // j的作用域开始
        printf("j = %d, i = %d\n", j, i); // 此处i和j都有效
    } // j的作用域结束
    // printf("%d\n", j); // 错误!j已不在作用域内
    return 0; // i的作用域结束
}

任意代码块与作用域

你可以在任何地方创建新的花括号代码块,这也会创建一个新的作用域。

以下是在任意位置创建新作用域的示例:

int main() {
    int i = 42;
    { // 新的作用域开始
        int j = 4;
        printf("%d\n", j); // 合法
    } // j的作用域结束
    // printf("%d\n", j); // 错误!j已不在作用域内
    return 0;
}

变量遮蔽

如果在内层作用域声明了一个与外层作用域同名的变量,内层的变量会“遮蔽”外层的变量。C语言会使用最近声明的那个变量。

int main() {
    int i = 42; // 外层i
    for (int i = 0; i < 1; i++) { // 内层i,遮蔽了外层i
        printf("inner i = %d\n", i); // 输出 0 (内层i)
    }
    printf("outer i = %d\n", i); // 输出 42 (外层i)
    return 0;
}

注意:变量遮蔽会使代码难以理解,应尽量避免。

函数与作用域

每个函数都有自己独立的作用域。一个函数不能直接访问另一个函数内部声明的变量。参数传递是通过传值进行的,即被调用函数获得的是参数值的一个副本。

#include <stdbool.h>
#include <stdio.h>

bool is_digit(char c) {
    // c是main函数中c1或c2的值的副本
    return (c >= '0') && (c <= '9');
}

int main() {
    char c1, c2;
    printf("Input two characters: ");
    scanf(" %c %c", &c1, &c2); // 注意%c前的空格

    if (is_digit(c1)) {
        printf("%c", c1);
    }
    if (is_digit(c2)) {
        printf("%c", c2);
    }
    printf("\n");
    return 0;
}

关于scanf%c的警告

使用scanf读取字符(%c)时需要特别注意空格。

  • 规则1:在%c格式说明符加一个空格(如" %c"),可以忽略输入流中任意数量的空白字符(空格、制表符、换行符),直到遇到第一个非空白字符。
  • 规则2不要在格式字符串的末尾添加空格,否则scanf会等待额外的非空白输入,导致程序看似卡住。
// 正确做法:忽略前导空白,读取两个字符
scanf(" %c %c", &c1, &c2);

// 危险做法1:可能意外读入空格
scanf("%c%c", &c1, &c2); // 输入" A B",c1会是空格

// 危险做法2:程序会等待额外输入
scanf("%c %c ", &c1, &c2); // 末尾的空格是陷阱!

scanf在处理用户输入时规则较为复杂,在实际开发中通常有更好的替代方案。

逗号运算符

逗号,在C语言中可以作为运算符使用。在表达式中,它依次执行其左右两边的子表达式,并丢弃左边表达式的结果,整个逗号表达式的结果是右边表达式的结果

for循环中,常用逗号来同时初始化和更新多个变量。

for (int x = 0, y = 10; x < y; x++, y--) {
    // 循环体
}
// 初始化: int x = 0, y = 10
// 条件: x < y
// 更新: x++, y-- (先执行x++,结果丢弃;再执行y--,结果作为整个表达式的结果,但循环不关心这个结果)

为什么交换函数不起作用?

尝试编写一个交换两个变量值的函数时,初学者常会遇到问题。

void swap(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
}

int main() {
    int a = 1, b = 2;
    swap(a, b);
    printf("a=%d, b=%d\n", a, b); // 输出仍然是 a=1, b=2!
    return 0;
}

原因:C语言函数参数是传值调用swap函数中的abmain函数中ab副本。交换操作只发生在副本上,原变量的值并未改变。

(不推荐的)解决方案:全局变量

一种绕过作用域限制(但不推荐)的方法是使用全局变量。全局变量在所有函数之外声明,其作用域从声明处开始直到文件结束,可以被任何函数访问和修改。

#include <stdio.h>

static int a, b; // 全局变量

void swap(void) {
    int temp = a;
    a = b;
    b = temp;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/f322d9aed16b448fd9f16ac12d2c0431_2.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/f322d9aed16b448fd9f16ac12d2c0431_4.png)

int main() {
    a = 1; b = 2;
    swap();
    printf("a=%d, b=%d\n", a, b); // 输出 a=2, b=1
    return 0;
}

警告:过度使用全局变量会使程序状态难以追踪,容易引发错误,在本课程中应尽量避免。

预览:真正的交换函数

要实现真正交换两个变量的值,需要用到指针的概念,这将是下一节课的重点。其核心思想是传递变量的地址,而非值的副本。

void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int x = 1, y = 2;
    swap(&x, &y); // 传递x和y的地址
    printf("x=%d, y=%d\n", x, y); // 输出 x=2, y=1
    return 0;
}

本节课中我们一起学习了作用域的概念。我们了解了变量在代码块、循环和函数中的有效范围,认识了变量遮蔽现象及其危害,并理解了函数参数传值调用的特性,这解释了为何简单的交换函数无法工作。我们还简要了解了逗号运算符的用法,并收到了关于scanf读取字符的重要警告。最后,我们预览了通过指针解决变量交换问题的方向。掌握作用域是写出结构良好、逻辑清晰程序的基础。

012:指针 🧭

在本节课中,我们将要学习C语言中一个核心且强大的概念——指针。指针赋予程序员对内存的直接控制能力,但同时也是许多错误的来源。我们将从计算机如何存储数据的基础知识开始,逐步理解指针的概念、语法和实际应用。

计算机内存与十六进制

上一节我们回顾了计算机存储数据的基本原理。本节中,我们来看看一种更便于人类理解计算机内存的表示方法——十六进制。

计算机以二进制(0和1)存储所有数据。一个64位的数字由64个二进制位组成,直接读写非常不便。十六进制(基数为16)系统则更为方便,因为它与二进制有天然的对应关系。

以下是十六进制数字系统的规则:

  • 使用16个字符表示数字:0-9 代表数值0-9,A-F 代表数值10-15。
  • 在C语言中,十六进制数以 0x 开头,例如 0xF4
  • 每个十六进制位(0-F)对应4个二进制位。
  • 两个十六进制位(例如 0xFF)正好对应一个字节(8位)。

将十六进制数 0xF4 转换为十进制的方法是:F(15)乘以16,加上 4 乘以1,结果为 244

内存地址与指针基础

理解了内存的表示方式后,我们来看看内存地址和指针的基本概念。

计算机内存中的每个字节都有一个唯一的地址,就像街道上的门牌号。在C语言中,当我们创建一个变量(例如 int x;),系统会在内存中为它分配空间。一个 int 类型通常占用4个字节。

指针就是一个变量,它存储的是另一个变量在内存中的起始地址。我们可以使用取地址运算符 & 来获取一个变量的地址。

int x = 1;        // 声明一个整型变量x
int *z = &x;      // 声明一个指向整型的指针z,并将其初始化为x的地址

这里,z 的类型是 int *,读作“指向整型的指针”。z 中存储的值就是变量 x 在内存中第一个字节的地址。

重要限制:只能对变量使用 & 运算符,不能对字面量(如 &4)或表达式结果使用,否则编译器会报错。

解引用与通过指针修改变量

知道了如何获取地址,接下来我们学习如何通过地址来访问或修改其指向的值。

为了通过指针访问它指向的内存中的值,我们使用解引用运算符 *

int x = 1;
int *z = &x; // z 存储了 x 的地址

int y = *z;  // 解引用z:获取z所指向地址的值(即x的值),并赋给y。现在 y = 1。
*z = 3;      // 解引用z:将z所指向地址的值(即x)修改为3。现在 x = 3。

每次使用 * 运算符,都会从指针类型中“移除”一个 *。例如,如果 z 的类型是 int **(指向指针的指针),那么 *z 的结果类型就是 int *

危险操作:切勿将指针随意设置为一个硬编码的地址(如 int *z = 5;),然后尝试解引用它。这很可能访问到非法内存区域,导致程序崩溃(段错误)。

指针在函数中的应用

之前我们介绍了函数参数按值传递的规则。本节中我们来看看如何利用指针让函数修改其外部变量的值。

这是指针最重要的用途之一。回想一下 scanf 函数,我们需要传递变量的地址(&x)而不是变量本身,这样 scanf 才能将读取的值写入我们指定的内存位置。

以下是一个自定义函数通过指针修改外部变量的例子:

void set_to_3(int *p) {
    *p = 3; // 修改p所指向的变量值为3
}

int main(void) {
    int x = 1;
    set_to_3(&x); // 传递x的地址
    printf("%d\n", x); // 输出:3
    return 0;
}

函数 set_to_3 接收一个 int 指针 p。当在 main 中调用 set_to_3(&x) 时,p 获得了 x 地址的一个副本。通过解引用 p*p),函数就能访问并修改 main 函数作用域内的变量 x

实战示例:交换函数

让我们通过一个经典的例子——交换两个变量的值——来巩固对指针的理解。

如果不使用指针,仅靠按值传递的参数无法实现真正的交换。以下是使用指针的正确实现:

void swap(int *a, int *b) {
    int temp = *a; // 步骤1:取出a指向的值
    *a = *b;       // 步骤2:将b指向的值赋给a指向的地址
    *b = temp;     // 步骤3:将暂存的值赋给b指向的地址
}

int main(void) {
    int x = 1, y = 2;
    printf("Before: x=%d, y=%d\n", x, y); // 输出: 1, 2
    swap(&x, &y); // 传递x和y的地址
    printf("After: x=%d, y=%d\n", x, y);  // 输出: 2, 1
    return 0;
}
  1. temp = *a:将 a 指针指向的值(即 main 中的 x,值为1)保存到临时变量 temp
  2. *a = *b:将 b 指针指向的值(即 main 中的 y,值为2)写入 a 指针指向的地址(即 x 的内存)。现在 x 变为2。
  3. *b = temp:将 temp 中保存的原始值(1)写入 b 指针指向的地址(即 y 的内存)。现在 y 变为1。

这样,通过操作内存地址,我们成功地在函数内部交换了外部两个变量的值。

打印指针地址

最后,我们学习如何查看指针本身存储的地址值。

C语言提供了 %p 格式说明符来打印指针(地址)值。它期望的参数类型是 void *(通用指针类型)。通常我们需要进行强制类型转换。

int x = 10;
int *p = &x;

printf("The address of x is: %p\n", (void *)&x);
printf("The value stored in p is: %p\n", (void *)p);
printf("The value p points to is: %d\n", *p);

输出将是类似 0x7ffee3a5a8bc 的十六进制数,这就是变量 x 在内存中的地址。p 中存储的值与 &x 相同。


本节课中我们一起学习了C语言指针的核心知识。我们了解了内存地址的十六进制表示,掌握了指针变量(int *p)的声明、取地址(&)与解引用(*)操作。最关键的是,我们明白了如何利用指针让函数修改调用者的变量,并通过 swap 函数加深了理解。记住,指针是强大的工具,但使用不当(如访问非法地址)会导致程序崩溃。始终确保你操作的指针指向有效的内存区域。

013:数组 🧮

在本节课中,我们将要学习C语言中一个非常重要的概念——数组。数组允许我们一次性声明和管理一组相关的数据,这能极大地简化我们的代码,尤其是在处理多个同类型变量时。

概述:为什么需要数组?

上一节我们介绍了循环,它帮助我们高效地处理重复性操作。但如果我们有多个相关的变量需要处理呢?例如,计算五个成绩的平均分。使用我们目前的知识,需要声明五个独立的变量,然后手动将它们相加并除以5。这不仅繁琐,而且在需要添加新成绩时,代码会变得难以维护。本节中,我们来看看如何使用数组来解决这个问题。

数组的声明与访问

数组是一组相关值的集合。我们可以一次性声明多个相同类型的变量。

声明数组的语法如下:

类型 数组名[数组大小];
  • 类型:数组中每个元素的类型(如 int)。
  • 数组名:这组数据的名称。
  • 数组大小:一个整数,表示要创建多少个该类型的值。

访问数组元素的语法如下:

数组名[索引]
  • 索引:一个整数,用于选择数组中的特定元素。

需要注意的是,C语言中的数组是零索引的,这意味着第一个元素的索引是0,第二个是1,依此类推。对于一个大小为 N 的数组,有效的索引范围是 0N-1

使用数组改进平均分计算

让我们用数组来重写计算五个成绩平均分的例子。

以下是使用数组的代码:

#include <stdio.h>

int main(void) {
    int grades[5];
    grades[0] = 75;
    grades[1] = 85;
    grades[2] = 99;
    grades[3] = 64;
    grades[4] = 72;

    int sum = 0;
    for (int index = 0; index < 5; index++) {
        sum += grades[index];
    }
    int average = sum / 5;
    printf("Average: %d\n", average);
    return 0;
}

现在,我们不再需要声明五个独立的变量,而是声明了一个包含五个整数的数组。这为后续的优化奠定了基础。

结合循环与数组

观察上面的代码,我们发现计算总和时,我们重复地写了 grades[0]grades[1]... 这正是循环可以发挥作用的地方。我们可以使用一个 for 循环来遍历数组的所有元素。

以下是结合循环遍历数组的代码:

int sum = 0;
for (int index = 0; index < 5; index++) {
    sum = sum + grades[index]; // 或者 sum += grades[index];
}
int average = sum / 5;

通过循环,我们消除了重复的代码。现在,无论数组中有多少个成绩,计算总和的逻辑都保持不变。

消除“魔法数字”并使用 #define

在上面的代码中,数字 5 出现了多次(数组大小、循环边界、除数)。这种直接出现在代码中的字面值常被称为“魔法数字”,它们使得代码难以理解和维护。如果我们要添加一个成绩,需要修改多处。

我们可以使用 #define 预处理器指令来给这个“魔法数字”起一个名字。

以下是使用 #define 的示例:

#include <stdio.h>
#define GRADES_LENGTH 5

int main(void) {
    int grades[GRADES_LENGTH];
    // ... 初始化成绩 ...
    int sum = 0;
    for (int index = 0; index < GRADES_LENGTH; index++) {
        sum += grades[index];
    }
    int average = sum / GRADES_LENGTH;
    // ...
    return 0;
}

现在,如果需要修改成绩的数量,我们只需更改 #define GRADES_LENGTH 这一处的值即可。#define 会在编译前进行简单的文本替换,将代码中所有的 GRADES_LENGTH 替换为定义的值。

数组的初始化与自动长度计算

在声明数组的同时,我们可以直接初始化它。

以下是声明时初始化数组的语法:

类型 数组名[大小] = {值1, 值2, 值3, ...};

更棒的是,如果我们提供了初始值列表,C语言可以自动推断数组的大小,此时可以省略方括号中的大小。

以下是自动推断数组大小的示例:

int grades[] = {75, 85, 99, 64, 72}; // C会自动计算数组长度为5

为了在代码中动态获取这个长度,我们可以使用一个宏(一种高级的 #define):

#define ARRAY_LENGTH(arr) (sizeof(arr) / sizeof(arr[0]))

这个宏的原理是:用整个数组占用的总字节数 sizeof(arr),除以单个元素占用的字节数 sizeof(arr[0]),结果就是数组中元素的个数。

以下是使用自动长度计算的完整示例:

#include <stdio.h>
#define ARRAY_LENGTH(arr) (sizeof(arr) / sizeof(arr[0]))

int main(void) {
    int grades[] = {75, 85, 99, 64, 72}; // 自动推断长度为5
    int sum = 0;
    for (int index = 0; index < ARRAY_LENGTH(grades); index++) {
        sum += grades[index];
    }
    int average = sum / ARRAY_LENGTH(grades);
    printf("Average: %d\n", average);
    return 0;
}

现在,添加新成绩变得极其简单:只需在初始化列表中加入一个新值,其他所有部分(循环边界、平均值计算)都会自动适应新的数组长度。

数组的边界与内存安全 ⚠️

我们必须确保只访问数组的有效索引(0 到 长度-1)。访问数组边界之外的内存是未定义行为,会导致不可预测的结果,程序可能崩溃、输出错误数据,或者看似正常地运行(但潜藏危机)。

以下是一个演示越界访问危险的例子:

#include <stdio.h>
int main(void) {
    int x = 1;
    int grades[5] = {0};
    grades[6] = 42; // 错误!访问了第7个元素,但数组只有5个。
    printf("x = %d\n", x); // x的值可能被意外修改!
    return 0;
}

在上面的代码中,grades[6] 的赋值可能恰好覆盖了变量 x 在内存中的位置,导致 x 的值被意外更改。编译器可能不会阻止这类错误,因此程序员必须格外小心。

总结

本节课中我们一起学习了C语言中数组的核心概念:

  1. 数组的声明与初始化:用于管理一组相同类型的数据。
  2. 零索引与元素访问:数组索引从0开始,通过 数组名[索引] 访问元素。
  3. 数组与循环的结合:使用 for 循环可以高效地遍历和处理数组中的所有元素。
  4. 使用 #define 消除魔法数字:提高代码的可读性和可维护性。
  5. 数组的自动长度计算:通过初始化列表和 ARRAY_LENGTH 宏,可以写出更灵活、更安全的代码。
  6. 数组的边界安全:必须始终确保数组访问在有效索引范围内,否则会导致未定义行为。

掌握数组是理解更复杂数据结构(如字符串、多维数组)的基础。在下一讲中,我们将探讨数组在内存中的布局,这有助于理解为什么越界访问会导致如此奇怪的问题。

014:指针算术运算

在本节课中,我们将深入学习指针,特别是指针的算术运算。我们将通过一系列示例和“反面教材”来理解指针如何工作,以及在使用指针时必须遵守的规则和需要避免的陷阱。

概述:从数组求和函数说起

上一节我们介绍了指针的基本概念。本节中,我们来看看如何将数组操作封装成函数,并在这个过程中揭示一个关于数组和指针的重要特性。

首先,我们有一个计算成绩平均值的程序。为了代码复用,我们尝试将计算数组和的循环提取到一个独立的 sum 函数中。

// 最初的尝试:一个看似合理的sum函数
int sum(int array[]) {
    int acc = 0;
    for (int i = 0; i < sizeof(array) / sizeof(array[0]); i++) {
        acc += array[i];
    }
    return acc;
}

然而,当我们调用这个函数时,结果却出错了。平均值从预期的78变成了31。调试发现,函数内部 sizeof(array) 的值是8(一个指针的大小),而不是整个数组的大小(20字节)。这意味着循环只处理了前两个元素。

数组在内存中的布局与“退化”

为什么会出现这种情况?要理解这一点,我们需要知道C语言中数组在内存中是如何存储的。

  • 数组是连续的:数组的所有元素在内存中是连续存放的,中间没有空隙。
  • 数组名是指针:在大多数上下文中,数组名会被“退化”为一个指向其第一个元素的指针。它只保存数组的起始地址。

因此,当我们将数组作为参数传递给函数时,传递的并不是整个数组的副本,而是数组起始地址的一个副本。函数接收到的只是一个指针(例如 int*),它丢失了原始数组长度的信息。

所以,我们之前写的 sum 函数中,参数 int array[] 实际上等价于 int* arraysizeof(array) 计算的是指针变量本身的大小(在64位系统上通常是8字节),而不是整个数组的大小。

指针算术运算

既然数组在函数中退化为指针,我们如何通过这个指针访问数组的其他元素呢?这就要用到指针算术运算。

指针算术运算的规则是:对指针进行加/减整数运算时,移动的字节数会自动乘以指针所指向类型的大小

以下是核心公式:
address = pointer + (index * sizeof(type))

具体示例如下:

  • int* a; a + i 的结果地址是 a + i * sizeof(int) (即 a + i * 4)。
  • double* b; b + i 的结果地址是 b + i * sizeof(double) (即 b + i * 8)。
  • char* c; c + i 的结果地址是 c + i * sizeof(char) (即 c + i * 1)。

重要提示:指针只能进行加法和减法运算,乘法和除法是非法的。

数组下标与指针运算的等价性

理解了指针算术后,我们会发现一个关键点:数组下标访问 array[index] 在底层完全等价于 *(array + index)

例如,grades[1]*(grades + 1) 访问的是同一个内存位置。数组下标语法只是为了方便而提供的语法糖,其本质就是指针算术运算和间接寻址。

核心建议:在代码中,你应该始终使用直观的数组下标语法 [] 来访问元素。如果你发现自己正在手动进行指针算术运算(例如 *(ptr + i)),那么很可能你做错了,或者正在引入不必要的复杂性和风险。

修正函数:传递数组长度

现在我们可以修正之前的 sum 函数了。由于函数无法通过指针得知数组长度,我们必须将长度作为一个额外的参数显式传递进去。

// 正确的sum函数
int sum(int array[], int array_length) {
    int acc = 0;
    for (int i = 0; i < array_length; i++) {
        acc += array[i];
    }
    return acc;
}

// 调用时
int total = sum(grades, GRADES_LENGTH);

这是一个重要的编程原则:任何接收数组作为参数的函数,通常也需要接收该数组的长度作为参数

危险的演示:指针算术的滥用

为了更深入地理解内存布局,我们来看一个危险且不推荐在实际编程中使用的例子。这个例子展示了如果变量在内存中恰好连续排列,通过指针算术可能意外修改其他变量。

int x = 1; // 假设地址为 0x1FFC
int y = 2; // 地址为 0x1FF8 (比x低4字节)
int z = 3; // 地址为 0x1FF4 (比y低4字节)
int* p = &z; // p 指向 z

// 危险操作:p + 2 将向前移动 2 * sizeof(int) = 8 字节
// 如果变量连续,这将指向 x 的地址
*(p + 2) = 4; // 这实际上修改了 x 的值!

printf("%d, %d, %d\n", x, y, z); // 可能输出 4, 2, 3

关键点:这种行为是未定义的。变量在内存中的排列方式(是否连续、是否有间隙)取决于编译器、系统架构和优化设置。这段代码在一台机器上可能“碰巧”工作,在另一台机器上可能导致程序崩溃或产生随机结果。绝对不要编写依赖此类内存布局的代码

指针的初始化与NULL值

未初始化的指针包含随机地址,解引用它们会导致不可预测的行为。良好的习惯是总是初始化指针。

如果你暂时不知道指针应该指向哪里,可以将其初始化为 NULLNULL 是一个特殊的指针值,表示“不指向任何地方”(通常是地址0)。

int* p = NULL;

解引用一个 NULL 指针是明确无效的操作,通常会导致程序立即崩溃(段错误)。这听起来很糟糕,但实际上比悄无声息地访问随机内存要好,因为它能立即暴露问题,便于调试。

int* p = NULL;
printf("%d\n", *p); // 几乎肯定会导致 Segmentation fault

返回指向局部变量的指针

这是一个常见的错误。函数内的局部变量在其作用域(即函数执行期间)内有效。一旦函数返回,这些变量的内存可能被回收并作他用。

int* bad_function() {
    int x = 10;
    return &x; // 错误:返回了局部变量 x 的地址
}

int main() {
    int* ptr = bad_function();
    printf("%d\n", *ptr); // 未定义行为!可能打印10,可能打印垃圾值,也可能崩溃。
    return 0;
}

ptr 现在是一个“悬垂指针”,它指向的内存已经不再有效。访问它的结果是未定义的,完全不可靠。

但是,返回指向调用者有效内存的指针是安全的,例如:

int* max_pointer(int* a, int* b) {
    if (*a >= *b) {
        return a; // 返回指向main函数中变量的指针
    } else {
        return b;
    }
}

int main() {
    int x = 1, y = 2;
    int* p = max_pointer(&x, &y); // p 指向 y,而 y 在 main 中有效
    printf("max is %d\n", *p); // 安全,输出 2
    return 0;
}

其他注意事项

以下是使用指针时需要注意的其他细节:

  • 函数参数中的 int array[]int* array:在函数参数列表中,两者完全等价。但使用 int array[] 能更清晰地表达“这是一个指向数组(多个元素)的指针”的意图,而 int* array 可能仅表示指向单个整数的指针。建议使用数组语法以增强可读性。
  • 变量声明中的星号*:在一条语句中声明多个指针时要小心。int* x, y; 声明了 x 是一个 int 指针,但 y 只是一个普通的 int。星号 * 只修饰紧随其后的变量名。为了避免混淆,最安全的做法是每行只声明一个变量。

总结

本节课中我们一起学习了指针算术运算以及相关的核心概念和陷阱。

我们了解到:

  1. 数组在传递给函数时会退化为指向其首元素的指针,同时丢失长度信息。因此,操作数组的函数通常需要显式接收数组长度参数。
  2. 指针算术运算会根据指针类型自动缩放,array[i] 等价于 *(array + i)。在实际编程中应优先使用数组下标语法。
  3. 绝对不要编写依赖特定内存布局(如变量连续存放)的代码,因为这是未定义行为,不可移植且极其危险。
  4. 始终初始化指针,对于暂无指向的指针,使用 NULL 进行初始化。
  5. 切勿返回指向局部变量的指针,这将产生悬垂指针,导致未定义行为。
  6. 指针是强大的工具,但也容易出错。一个重要的原则是:如果可能,尽量避免使用指针;如果必须使用,务必格外小心。

记住,谨慎使用指针,你的程序将会更加健壮和可靠。

015:用C语言实现哥德巴赫猜想

在本节课中,我们将学习如何编写一个完整的C语言程序来验证著名的哥德巴赫猜想。我们将从零开始,逐步构建程序,涵盖用户输入、函数设计、循环逻辑以及指针的应用。通过这个过程,你将学会如何将一个复杂问题分解为多个小步骤,并用代码实现。

概述

哥德巴赫猜想是一个尚未被证明的数学命题,它指出:每一个大于2的偶数都可以表示为两个质数之和。本节课的目标是编写一个C语言程序,来验证对于用户输入的偶数,这个猜想是否成立。我们将通过构建几个核心函数来完成这个任务。

用户输入函数

首先,我们需要一个函数来获取用户的输入。这个输入必须是一个大于2的偶数。

int get_user_input(void) {
    int input = 0;
    printf("Enter an even integer greater than two: ");
    scanf("%d", &input);

    while (input <= 2 || input % 2 != 0) {
        printf("Invalid input. Enter an even integer greater than two: ");
        scanf("%d", &input);
    }
    return input;
}

这个函数使用一个while循环来确保用户输入的数字符合要求。如果输入无效,程序会提示用户重新输入。

判断质数函数

接下来,我们需要一个函数来判断一个给定的整数是否是质数。质数是只能被1和自身整除的大于1的自然数。

bool is_prime(int x) {
    if (x == 1) {
        return false;
    }
    for (int divisor = 2; divisor < x; divisor++) {
        if (x % divisor == 0) {
            return false;
        }
    }
    return true;
}

函数从2开始,检查x是否能被任何小于它的数整除。如果能,则返回false;如果循环结束都没有找到能整除的数,则返回true。我们特别处理了数字1,因为它不是质数。

寻找下一个质数函数

为了验证猜想,我们需要从一个质数开始,寻找下一个质数。这个函数接收一个(假设是质数的)整数,并返回比它大的下一个质数。

int next_prime(int x) {
    do {
        x++;
    } while (!is_prime(x));
    return x;
}

函数使用一个do-while循环,不断将输入值加1,直到is_prime函数确认它是一个质数为止。

验证猜想的核心逻辑

现在,我们可以编写核心函数来验证哥德巴赫猜想。这个函数接收一个偶数num,并尝试找到两个质数使其和等于num

bool conjecture_holds(int num, int *first, int *second) {
    *first = 2;
    *second = num - *first;

    while (*first <= *second) {
        if (is_prime(*second)) {
            return true;
        }
        *first = next_prime(*first);
        *second = num - *first;
    }
    return false;
}

函数从最小的质数2开始,计算second = num - first。然后检查second是否是质数。如果是,猜想成立。如果不是,则将first更新为下一个质数,重新计算second,并继续循环。当first大于second时,意味着已经尝试了所有可能的组合仍未找到,则返回false。注意,这里使用了指针*first*second来将找到的两个质数传递回主调函数。

整合与输出

最后,我们创建一个输出函数来展示结果,并在main函数中整合所有部分。

void print_conjecture_result(int num) {
    int first, second;
    if (conjecture_holds(num, &first, &second)) {
        printf("Conjecture holds for %d. Numbers are %d and %d.\n", num, first, second);
    } else {
        printf("Conjecture does NOT hold for %d. Math is broken!\n", num);
    }
}

int main(void) {
    // 测试单个输入
    int number = get_user_input();
    print_conjecture_result(number);

    // 批量测试一段范围内的偶数
    for (int x = 4; x < 1000; x += 2) {
        print_conjecture_result(x);
    }
    return 0;
}

main函数中,我们首先测试用户输入的单个数字。然后,我们使用一个for循环来批量验证从4到1000的所有偶数,以更全面地观察猜想是否成立。

总结

本节课中,我们一起学习了如何用C语言实现哥德巴赫猜想的验证程序。我们从问题分析开始,将其分解为获取输入、判断质数、寻找下一个质数以及验证猜想等几个步骤,并分别用函数实现。我们复习了循环、条件判断、函数定义以及指针的用法。最终,我们整合这些函数,构建了一个可以验证猜想并输出结果的完整程序。通过这个练习,你不仅加深了对C语法的理解,也锻炼了解决复杂编程问题的结构化思维能力。

016:期中复习课 Part 1

在本节课中,我们将一起复习C语言编程的一些核心概念,包括基本语句、布尔逻辑、指针操作、数组遍历以及函数实现。我们将通过分析一份期中考试样题来巩固这些知识。

基本语句与运算

首先,我们来看一个简单的题目:编写一条C语句,计算一个两位数中每个数字的平方根之和,并将结果赋值给一个双精度浮点数变量。

以下是解题步骤:

  • 使用 num / 10 获取十位数。
  • 使用 num % 10 获取个位数。
  • 调用 sqrt() 函数计算平方根并求和。

代码示例:

double square = sqrt(num / 10) + sqrt(num % 10);

布尔逻辑与条件判断

上一节我们处理了数值运算,本节中我们来看看如何用一条语句声明并初始化一个布尔变量,用于判断一个数是否为正奇数。

以下是解题步骤:

  • 检查数字是否大于0,以确定其正负。
  • 检查数字除以2的余数是否为1,以确定其奇偶性。
  • 使用逻辑与运算符 && 连接两个条件。

代码示例:

bool positive_odd = (num > 0) && (num % 2 == 1);

指针与数组操作

接下来,我们分析一段包含指针和数组操作的代码,并找出其中的错误。

以下是代码中存在的错误:

  • 第7行array = 8; 错误。数组名是一个常量指针,不能被重新赋值。
  • 第9行k = array[p]; 错误。数组索引必须是整数,而 p 是一个指针。
  • 第10行array[9] = 0; 错误。数组越界访问。该数组的有效索引范围是0到8。

指针算术与循环

现在,我们来看一个使用指针算术遍历数组的程序,并推断其输出。

程序的核心是一个 for 循环,它使用指针 p 遍历数组 my_array。在循环体内,它检查 p 指向的值是否为偶数。如果是,则计算当前指针 p 与数组起始地址之间的偏移量(以元素个数计),并将该值作为索引,在 another 数组中存储当前偶数值,最后打印出来。

根据给定的数组 {2, 4, 5, 7, 8},程序的输出将是:

another[0] = 2
another[1] = 4
another[4] = 8

函数调试与修正

本节我们将调试一个存在错误的函数。该函数旨在找出一个整数中最大的数字。

原函数存在以下问题:

  1. 第4行:循环条件 while (num < 0) 错误。这会导致循环仅在输入为负数时执行。应改为 while (num > 0)
  2. 第6行:变量 largest_digit 被初始化为 9。这会导致函数总是返回9。应初始化为 0
  3. 第9行:比较语句 if (num > largest_digit) 错误。这里应该比较当前提取出的数字 digit,而不是原始数字 num。应改为 if (digit > largest_digit)

修正后的函数能正确遍历数字的每一位,并记录遇到的最大值。

指针作为函数参数

这是一个综合性的题目,涉及指针作为函数参数、按值传递以及作用域。我们需要逐步跟踪程序的执行,以确定其输出。

关键点在于理解 modify 函数接收的是 main 函数中变量地址的副本。在函数内部:

  • 通过指针可以修改 main 函数中变量的值。
  • 指针变量本身(存储的地址)也可以被修改。
  • 对函数内局部变量的修改不会影响 main 函数。

经过逐步分析,程序的输出为:

In modify: *a = -5, *b = 7, c = 3
In main: a = -5, b = 12, c = 3

数组遍历与点积计算

最后,我们实现一个计算两个向量点积的函数。向量的数组表示以 -1 作为结束标记。

解题思路是同时遍历两个数组。我们使用一个 while 循环,只要两个数组在当前索引处的值都不等于 -1,就继续循环。在循环中,我们累加对应元素的乘积。当循环退出后,检查两个数组在当前索引处的值是否都等于 -1。如果是,说明两个向量长度相同,返回累加结果;否则,返回 -1

代码示例:

int dot_product(int arr1[], int arr2[]) {
    int i = 0;
    int result = 0;
    while (arr1[i] != -1 && arr2[i] != -1) {
        result += arr1[i] * arr2[i];
        i++;
    }
    if (arr1[i] == -1 && arr2[i] == -1) {
        return result;
    } else {
        return -1;
    }
}

总结

本节课中我们一起学习了C语言期中考试涉及的多个核心主题。我们练习了编写基础运算和逻辑判断语句,分析了指针和数组操作中的常见错误,跟踪了指针在函数调用中的行为,并实现了一个处理数组的实用函数。掌握这些概念对于理解和编写更复杂的C程序至关重要。

017:期中复习课 Part 2

在本节课中,我们将继续复习期中考试内容,通过分析2023年考试中的编程题和概念题,来巩固对C语言核心知识的理解。我们将重点关注函数编写、循环控制、条件判断以及指针基础。

通用产品代码校验位计算

上一节我们回顾了基础概念,本节中我们来看看一个具体的编程问题:计算通用产品代码的校验位。

题目要求编写一个函数,接收一个11位的整数条形码,并根据特定规则计算出第12位校验位。规则如下:

  1. 将所有奇数位上的数字相加,将结果乘以3。
  2. 将所有偶数位上的数字相加。
  3. 将步骤1和步骤2的结果相加。
  4. 计算步骤3结果对10取模的值,记为 M
  5. 如果 M 为0,则第12位为0;否则,第12位为 10 - M

以下是解决此问题的一种方法,使用循环遍历每一位数字:

int barcode_digit(int barcode) {
    int odd_sum = 0;
    int even_sum = 0;
    bool is_odd = true; // 从第一个(奇数)位开始

    for (int i = 0; i < 11; i++) {
        int digit = barcode % 10; // 获取最后一位数字
        barcode = barcode / 10;   // 移除最后一位数字

        if (is_odd) {
            odd_sum += digit;
            is_odd = false;
        } else {
            even_sum += digit;
            is_odd = true;
        }
    }

    int value = (odd_sum * 3) + even_sum;
    int M = value % 10;

    if (M == 0) {
        return 0;
    } else {
        return 10 - M;
    }
}

另一种更简洁的方法是使用 while 循环,直接交替处理奇偶位:

int barcode_digit(int barcode) {
    int odd_sum = 0;
    int even_sum = 0;
    bool is_odd = true;

    while (barcode > 0) {
        int digit = barcode % 10;
        barcode /= 10;

        if (is_odd) {
            odd_sum += digit;
        } else {
            even_sum += digit;
        }
        is_odd = !is_odd; // 切换奇偶状态
    }
    // ... 后续计算M和返回值的代码同上
}

打印特定图案

接下来,我们分析一个需要打印复杂图案的程序。用户输入行数,程序需要打印出一个由星号(*)和空格组成的菱形边框图案。

首先,我们需要观察图案的行列关系。对于输入行数 n,总列数为 2 * n - 1。图案由顶部边框、底部边框、左右边框以及两条对角线组成。

解决思路是使用嵌套循环遍历每一个“单元格”(行和列的位置),并根据其位置决定打印星号还是空格。

以下是打印该图案的核心逻辑:

#include <stdio.h>

int main() {
    int num_rows;
    printf("Enter number of rows: ");
    scanf("%d", &num_rows);

    int num_cols = 2 * num_rows - 1;

    for (int row = 1; row <= num_rows; row++) {
        for (int col = 1; col <= num_cols; col++) {
            // 判断是否打印星号的条件
            if (row == 1 ||                     // 顶部边框
                row == num_rows ||              // 底部边框
                col == 1 ||                     // 左边框
                col == num_cols ||              // 右边框
                row + col == num_rows + 1 ||    // 左上到右下的对角线
                col - row == num_rows - 1) {    // 右上到左下的对角线
                printf("*");
            } else {
                printf(" ");
            }
        }
        printf("\n"); // 每行结束换行
    }
    return 0;
}

对于初学者,如果时间有限,可以先实现顶部、底部和两侧的边框,这已经能获得大部分分数。

理解程序输出与常见陷阱

本节我们来看看一些考察C语言细节的题目,帮助大家避开常见陷阱。

题目1:无限循环

int n = 10;
while (n != 0) {
    printf("%d\n", n);
    n -= 2;
}

这段代码意图是打印 10, 8, 6...,但 n2 减到 0 后变为 -2,永远不会等于 0,因此是一个无限循环。在考试中,遇到无限循环可以写几项后用 ... 表示。

题目2:指针与类型

int use_count;
int *x, *y;

判断以下语句:

  1. *use_count; // 错误use_count 是整型变量,不能解引用。
  2. int *p = x; // 正确:将指针 x 赋值给同类型指针 p
  3. int **q = &y; // 警告&y 的类型是 int **,可以赋值给 int ** 类型的 q,但通常意味着复杂的指针操作,编译器会给出类型不匹配的警告。
  4. x = y = &use_count; // 正确:多重赋值,将 use_count 的地址赋给 yx

题目3:运算符与作用域
考虑以下代码片段:

int i = 3, j = 6;
// 片段1: printf("%d", i++); // 输出 3,然后 i 变为 4
// 片段2: printf("%d", --i); // 输出 2,i 先减为2再打印
// 片段3: i = j / 10; // i 变为 0 (整数除法)
// 片段4: i = (int) 9.99; // i 变为 9 (强制类型转换截断)
// 片段5: if (i = 6) { ... } // i 被赋值为6,条件为真,但问的是i的值,答案是6
// 片段6: for (int i = 0; i < j; i++) { } // 循环后,外层的 i 仍然是3 (变量遮蔽)

特别注意片段5中的赋值运算符 = 与相等运算符 == 的区别,以及片段6中 for 循环内声明的 i 会遮蔽外层的 i

综合程序阅读

最后,我们快速分析一个综合性的程序阅读题,它涉及函数调用和循环嵌套。

程序大致功能是打印一个圣诞树形状的图案。核心在于理解 skip_space(n) 函数打印 n 个空格,print_left(n) 打印 n/print_right(n) 打印 n\

通过跟踪主循环中变量 i 的变化,可以逐步推导出每一行打印的空格、斜杠和反斜杠的数量,最终组合成图案。这类题目考查的是耐心和细心。

本节课中我们一起学习了如何计算UPC校验位、如何使用循环和条件语句打印复杂图案,并分析了C语言中容易出错的细节,如无限循环、指针类型、运算符优先级和作用域。持续练习是掌握编程的关键,祝大家复习顺利!

018:期中复习课 Part 3

在本节课中,我们将继续复习期中考试可能遇到的编程题目。我们将通过分析几道典型的考题,学习如何将问题分解、设计算法并使用C语言实现。这些题目涵盖了循环、数组、函数和位运算等多个核心概念。

问题一:计算数学常数e的近似值

数学常数e可以用无穷级数表示:e = 1 + 1/1! + 1/2! + 1/3! + ...。我们的任务是编写一个C程序来近似计算e的值,当级数项的值小于0.01时停止累加。程序需要输出e的近似值以及用于计算该近似值的项数。

首先,我们需要一个计算阶乘的函数。阶乘的公式是 n! = n * (n-1) * ... * 2 * 1

double factorial(int n) {
    double product = 1.0;
    for (int x = 2; x <= n; ++x) {
        product *= x;
    }
    return product;
}

接下来,在主函数中,我们使用循环来累加级数项,直到当前项的值小于0.01。

int main(void) {
    double e = 0.0;
    double term = 1.0; // 第一项 1/0! = 1
    int n = 0;
    int num_terms = 0;

    while (term >= 0.01) {
        e += term;          // 累加当前项
        num_terms++;        // 项数加一
        n++;                // 准备计算下一项
        term = 1.0 / factorial(n); // 计算新的项 1/n!
    }

    printf("e的近似值: %f\n", e);
    printf("使用的项数: %d\n", num_terms);
    return 0;
}

核心思路:初始化e和第一项,在循环中累加,每次循环后计算下一项(1/n!),直到项值小于阈值。


上一节我们介绍了如何通过循环和函数求解数学问题,本节中我们来看看一个关于计算机底层表示的有趣问题。

问题二:不使用sizeof确定int类型的位数

题目要求编写程序,在不使用sizeof运算符的情况下,确定当前计算机上int类型使用的比特位数。例如,在32位系统上,程序应输出n = 32

思路是利用整数的二进制表示特性。对于一个有符号整数,其最高位(第n-1位)是符号位。我们可以从数值1(二进制...0001)开始,不断将其左移(乘以2),直到这个数变为负数,此时左移的次数就等于整数位数减一。

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

    // 当x为正数时,持续左移
    while (x > 0) {
        x *= 2; // 等价于 x = x << 1, 将比特左移一位
        n++;
    }

    // 循环结束时,x因溢出变为负数,n即为比特位数
    printf("n = %d\n", n);
    return 0;
}

核心思路:从1开始,通过不断乘以2(左移)来“推动”那个唯一的1比特向高位移动。当它移动到符号位时,数值由正变负,移动的次数即为总位数。


理解了底层表示后,我们回到数组操作。下面这个问题考察我们如何根据特定规则处理和排序数组元素。

问题三:按右起第二位数字排序并打印数组

给定一个包含6个整数的数组,编写一个函数,按照每个数字的右起第二位数字(十位数)的升序来打印它们,并按要求用逗号和空格分隔。

例如,数组 {269, 321, 426, 500, 812, 943},其十位数分别是6, 2, 6, 0, 1, 3。按升序打印结果为 500, 812, 321, 943, 269, 426

以下是解决此问题的一种方法:

void printBySecondDigit(int arr[6]) {
    int firstPrinted = 1; // 标记是否是第一个打印的数字

    // 外层循环:遍历所有可能的十位数(0到9)
    for (int digit = 0; digit <= 9; ++digit) {
        // 内层循环:遍历数组中的每个元素
        for (int i = 0; i < 6; ++i) {
            // 提取当前数字的十位数
            int secondDigit = (arr[i] / 10) % 10;

            // 如果十位数匹配当前正在查找的digit
            if (secondDigit == digit) {
                if (firstPrinted) {
                    // 打印第一个数字,前面不加逗号
                    printf("%d", arr[i]);
                    firstPrinted = 0;
                } else {
                    // 打印后续数字,前面加逗号和空格
                    printf(", %d", arr[i]);
                }
            }
        }
    }
    printf("\n"); // 打印换行
}

核心思路:我们不是直接对数组排序,而是利用十位数只有0-9这10种可能。外层循环按顺序(0,1,2...9)查找十位数,内层循环检查数组中的每个数字。当找到匹配的数字时,就按照格式要求打印它。使用firstPrinted标志来控制是否打印前置的逗号。


处理完数组排序,我们来看一个涉及数学计算和条件判断的函数实现。

问题四:寻找毕达哥拉斯三元组

毕达哥拉斯三元组是满足 x² + y² = z² 的三个正整数。编写一个函数,接受一个正整数 x,寻找满足以下条件的 yz

  1. y 是1到100之间的整数。
  2. z 是正整数。
  3. 如果找到,打印 x, y, z;否则打印“No solution exists”。
void findPythagoreanTriple(int x) {
    for (int y = 1; y <= 100; ++y) {
        // 计算 z = sqrt(x*x + y*y)
        double z_double = sqrt(x * x + y * y);
        int z_int = (int)z_double; // 将double强制转换为int,会截断小数部分

        // 检查z_int的平方是否严格等于 x² + y²
        if (z_int * z_int == x * x + y * y) {
            // 确保z是正整数(条件已满足)
            printf("%d %d %d\n", x, y, z_int);
            return; // 找到一组解即返回
        }
    }
    // 循环结束未找到解
    printf("No solution exists\n");
}

核心思路:在给定xy范围(1-100)内进行遍历。对每一对(x, y),计算z = sqrt(x² + y²)。通过将z强制转换为整数再平方,与原值比较,可以判断z是否为整数解。找到第一组解后立即打印并返回。


最后,我们挑战一个更复杂的算法问题,它要求我们找出数组中具有最大和的连续子数组。

问题五:寻找最大子数组和

编写一个函数,接收一个整数数组和其长度,找出该数组中和最大的连续子数组(至少包含一个元素),并返回这个最大和。

例如,数组 [ -2, 1, -3, 4, -1, 2, 1, -5, 4 ] 中,和最大的连续子数组是 [4, -1, 2, 1],其和为6。

最直观的方法是暴力枚举所有可能的子数组:

int largest_sum(int list[], int count) {
    int largest = -1000000; // 初始化为一个非常小的数

    // 外层循环:子数组的长度,从1到整个数组长度count
    for (int length = 1; length <= count; ++length) {
        // 中层循环:子数组的起始索引
        for (int start = 0; start <= count - length; ++start) {
            int sum = 0;
            // 内层循环:计算从start开始,长度为length的子数组的和
            for (int i = 0; i < length; ++i) {
                sum += list[start + i];
            }
            // 更新遇到的最大和
            if (sum > largest) {
                largest = sum;
            }
        }
    }
    return largest;
}

核心思路:使用三层循环。

  1. 第一层循环遍历所有可能的子数组长度(1到count)。
  2. 第二层循环遍历给定长度下,所有可能的起始位置(0到count - length)。
  3. 第三层循环计算从当前起始位置开始、指定长度的子数组的元素和。
  4. 在计算每个子数组和之后,与当前记录的最大值largest比较并更新。

这种方法枚举了所有子数组,确保了正确性,但对于大的数组可能效率较低。在考试中,清晰正确的逻辑比极致优化更重要。


本节课中我们一起学习了五道典型的C语言编程考题。我们从计算数学常数e的近似值开始,练习了循环和函数的使用。接着,我们探索了如何在不使用sizeof的情况下确定int的位数,理解了整数的底层表示。然后,我们解决了按特定规则(十位数)打印数组的问题,巩固了嵌套循环和条件判断。之后,我们通过寻找毕达哥拉斯三元组,结合了循环、数学计算和类型转换。最后,我们挑战了寻找最大子数组和的经典问题,实践了多层循环和算法设计。

应对编程考题的关键在于:仔细阅读题目、将复杂问题分解为小步骤、先规划再编码,并优先保证代码的正确性和清晰度。祝你复习顺利!

019:期中复习课 Part 4

在本节课中,我们将继续复习期中考试可能涉及的核心概念,通过分析往届考题来巩固对C语言基础、逻辑运算、函数、指针和数组的理解。我们将逐一解析题目,并提供清晰的解题思路和代码示例。


概述

本节复习课将涵盖多种题型,包括编写单条C语句、简化布尔表达式、分析程序输出、重构函数以及解决涉及指针和数组的编程问题。我们将通过具体的例题,帮助你掌握解题技巧,为考试做好准备。

编写单条C语句

首先,我们来看如何编写单条C语句来满足特定要求。这类题目通常要求声明变量并进行计算。

题目:编写一条C语句,声明一个名为 numberdouble 类型变量,并将其初始化为 66.699.9 之间的一个随机数(包含两端)。

解题思路:标准库函数 rand() 返回一个整数。要生成指定范围内的随机浮点数,可以先生成一个整数范围内的随机数,然后通过除法转换为浮点数。

核心公式:生成 [a, b] 范围内随机整数的公式为:

rand() % (b - a + 1) + a

对于浮点数,可以先在放大的整数范围(如 [666, 999])内生成随机数,再除以 10.0 进行缩放。

代码示例

double number = (rand() % 334 + 666) / 10.0;

或者:

double number = rand() % 334 / 10.0 + 66.6;

简化布尔表达式

上一节我们介绍了如何编写语句,本节中我们来看看如何运用德摩根定律来简化复杂的布尔表达式。

题目:假设已有变量声明,请编写一条更简单的C语句(使用更少的布尔条件和关系运算符),使其与以下语句等价:

!(x <= y && y <= z)

解题思路:应用德摩根定律,将逻辑非分配到每个条件上,并改变逻辑运算符。

德摩根定律

!(A && B) 等价于 !A || !B

将定律应用于题目:

  1. 首先应用定律:!(x <= y) || !(y <= z)
  2. 然后简化每个条件:(x > y) || (y > z)

最终简化语句

(x > y) || (y > z)

分析程序输出

接下来,我们通过分析一个函数调用的过程,来理解程序执行的流程和变量的变化。

题目:以下C程序会打印什么?
(程序涉及一个检查阿姆斯特朗数的函数 isArmstrongNumber 和多次调用。)

解题思路:需要手动模拟程序执行,跟踪 main 函数和 isArmstrongNumber 函数中变量的值。关键在于理解 while 循环如何分解数字并计算各位数字的立方和。

模拟过程要点

  1. isArmstrongNumber(152):计算 1^3 + 5^3 + 2^3 = 1 + 125 + 8 = 134,不等于152,返回 false
  2. isArmstrongNumber(413):计算 4^3 + 1^3 + 3^3 = 64 + 1 + 27 = 92,不等于413,返回 false
  3. 由于两个函数调用都返回 false,程序执行 else 分支。

程序输出

152 and 413 are not Armstrong numbers.

重构函数

现在,我们来看如何重构一个函数,以减少其返回语句的数量。

题目:以下函数接收三个不同的字符,返回中间的那个字符(按ASCII码顺序)。请重写该函数,使其只使用一个 return 语句和一个 if 语句(可包含 else ifelse)。

char middle(char a, char b, char c) {
    if ((a < b && b < c) || (c < b && b < a)) return b;
    if ((b < a && a < c) || (c < a && a < b)) return a;
    return c;
}

解题思路:我们可以引入一个临时变量 mid 来存储结果,通过一系列条件判断为其赋值,最后统一返回这个变量。

重构后的代码

char middle(char a, char b, char c) {
    char mid;
    if ((a < b && b < c) || (c < b && b < a)) {
        mid = b;
    } else if ((b < a && a < c) || (c < a && a < b)) {
        mid = a;
    } else {
        mid = c;
    }
    return mid;
}

指针与数组操作

指针是C语言中的难点。本节我们将通过一个综合例题,学习如何跟踪指针操作对内存中数据的影响。

题目:分析以下程序段,指出其打印的所有内容。
(程序涉及整数变量、数组、指针声明、指针运算、解引用和赋值。)

解题步骤

  1. 绘制内存状态图:列出所有变量及其初始值。
    • first = 1, second = 2
    • data = {10, 20, 30, 0}
    • third = &second (指向 second)
    • fourth = &first (指向 first)
    • fifth = data + first => data + 1 (指向 data[1],即20)
  2. 逐步执行语句
    • (*third)++second 从2变为3。
    • (*fourth)++first 从1变为2。
    • data[second] = *fifth + first + *third + *fourth
      • second 现在是3,所以是 data[3]
      • *fifth 是20。
      • first 是2,*third 是3,*fourth 是2。
      • 计算:20 + 2 + 3 + 2 = 27,因此 data[3] 从0变为27。
  3. 确定打印内容
    • printf 打印变量:2 3 3 2 20
    • 循环打印数组:10 20 30 27

最终输出

2 3 3 2 20
10 20 30 27

编程问题:判断回文

考试中常包含小型编程题。以下是判断字符数组是否为回文的经典问题。

题目:完成函数 isPalindrome 的定义。该函数接收一个字符数组 sequence 及其大小 size,如果该序列是回文(正读反读都一样)则返回 true,否则返回 false。假设数组至少有一个元素。

解题思路:只需比较数组前半部分和后半部分对称位置的字符是否相等。如果发现任何一对不相等,则不是回文。

代码实现

bool isPalindrome(const char sequence[], int size) {
    for (int i = 0; i < size / 2; i++) {
        if (sequence[i] != sequence[size - i - 1]) {
            return false; // 发现不匹配字符,不是回文
        }
    }
    return true; // 所有对称字符都匹配,是回文
}

说明:循环只运行到 size / 2。对于奇数长度,中间字符无需比较;对于单元素数组,循环条件 0 < 0.5 为假,直接返回 true


编程问题:比较数组集合

最后,我们看一个更复杂的编程问题,它涉及遍历以特定标记结尾的数组。

题目:完成函数 hasSameNumbers。它接收两个数组的地址,每个数组的末尾由一个负数标记,其余元素为正整数。如果两个数组包含完全相同的整数集合(顺序可以任意,每个数组内无重复),则返回 true,否则返回 false

解题思路:遍历第一个数组的每个有效元素(直到遇到负数),对于每个元素,在第二个数组中查找是否存在。如果找不到,则集合不同。由于数组内无重复,此方法有效。

代码实现

bool hasSameNumbers(const int a[], const int b[]) {
    int i = 0;
    // 遍历数组a的所有有效元素
    while (a[i] >= 0) {
        int found = 0; // 标记是否在b中找到当前元素
        int j = 0;
        // 在数组b中查找a[i]
        while (b[j] >= 0) {
            if (a[i] == b[j]) {
                found = 1;
                break; // 找到即可跳出内层循环
            }
            j++;
        }
        if (!found) { // 如果在b中没找到a[i]
            return false;
        }
        i++;
    }
    // 还需要检查b中是否有a中没有的元素?由于无重复且集合相同,若a遍历完,b中剩余元素必然在a中已检查过或为负数标记。
    // 但严谨起见,可以同样遍历b检查a,或者检查两数组有效长度是否相等。简单实现可假设题目暗示两数组若集合相同则有效长度相等。
    return true;
}

关键点:使用嵌套循环进行查找。外层遍历数组a,内层在数组b中线性查找。使用 found 标志记录查找结果。


总结

本节课中我们一起学习了多种期中考试常见题型的解法:

  1. 编写单条C语句:运用公式和类型转换实现特定功能。
  2. 简化布尔表达式:应用德摩根定律来优化逻辑判断。
  3. 分析程序输出:通过细致地手动模拟执行过程来理解代码行为。
  4. 重构函数:使用临时变量和条件链来合并返回点。
  5. 理解指针操作:通过画图跟踪变量和指针状态的变化。
  6. 实现算法函数:例如判断回文和比较无序集合,重点在于掌握循环和条件判断的运用。

希望这些例题和解析能帮助你巩固知识,建立信心。祝你在考试中取得好成绩!

020:动态内存 🧠

在本节课中,我们将学习C语言中一个强大但需要谨慎使用的特性:动态内存管理。我们将了解如何手动请求和释放内存,以及如何避免由此带来的常见陷阱。


概述

到目前为止,我们使用的变量(局部变量)都存在于一个称为“栈”的内存区域中。这些变量在函数运行时被创建,在函数结束时自动销毁。然而,有时我们需要创建在函数结束后依然存在的内存,或者需要根据运行时信息(如用户输入)来决定分配多少内存。这时,我们就需要使用“堆”内存,并通过 mallocfree 函数来手动管理它。

上一节我们介绍了指针和函数返回地址时可能遇到的问题,本节中我们来看看如何通过动态内存分配来解决这些问题。

栈内存的局限性

首先,回顾一下我们之前遇到的问题。考虑以下代码:

int* foo() {
    int x = 1;
    return &x; // 返回局部变量x的地址
}

int main() {
    int* p = foo();
    printf("%d\n", *p); // 危险!x已不存在
    return 0;
}

在这段代码中,函数 foo 返回了局部变量 x 的地址。当 foo 执行完毕后,x 所占用的栈内存就被释放了。此时,main 函数中的指针 p 指向的是一块无效的内存区域。尝试访问这块内存(如 *p)会导致未定义行为,通常是程序崩溃(段错误)。

核心问题:我们无法安全地从函数返回一个指向其局部变量的指针。

引入堆内存:malloc

为了解决这个问题,C语言提供了另一种内存区域:。堆内存的分配和释放不由函数调用栈控制,而是由程序员手动管理。

我们使用 malloc 函数从堆中请求内存。它的函数原型如下(定义在 <stdlib.h> 中):

void* malloc(size_t size);
  • size_t size:需要分配的连续字节数size_t 是一个表示大小的无符号整数类型。
  • 返回值:一个指向所分配内存起始地址的 void* 类型指针。如果内存不足,则返回 NULL

malloc 只是分配了原始内存,并不初始化其内容,里面的值是未定义的。

分配一个整数

如果我们想分配一个整数的空间,可以这样做:

int* p = malloc(sizeof(int)); // 请求足够存放一个int的内存(通常是4字节)
if (p != NULL) { // 必须检查malloc是否成功
    *p = 42; // 初始化这块内存
    printf("%d\n", *p); // 使用它
}

现在,我们可以重写之前的 foo 函数,使其安全地返回一个指针:

int* foo() {
    int* p = malloc(sizeof(int)); // 在堆上分配内存
    if (p != NULL) {
        *p = 1; // 初始化值
    }
    return p; // 返回堆内存的地址,该内存在函数返回后依然有效
}

int main() {
    int* p = foo();
    if (p != NULL) {
        printf("%d\n", *p); // 安全地访问
    }
    return 0;
}

这样,p 指向的是堆上的内存,即使 foo 函数结束,这块内存依然存在,因此访问是安全的。

释放内存:free

有借有还。既然我们手动请求了内存,就必须在不再需要时手动归还,否则会导致内存泄漏。归还内存使用 free 函数:

void free(void* ptr);
  • void* ptr:必须是之前由 malloc(或相关函数)返回的指针。
  • 调用 free(ptr) 后,ptr 指向的内存被释放,交还给系统。
  • 释放后,绝对不能再通过 ptr 访问该内存。
  • 如果 ptrNULLfree 函数什么也不做,这是安全的。

一个良好的习惯是在 free 之后,立即将指针设为 NULL,以防止后续误用。

int* p = malloc(sizeof(int));
// ... 使用 p ...
free(p);
p = NULL; // 好习惯,防止“悬空指针”

内存泄漏

如果只分配而不释放,程序占用的内存会越来越多,就像某些网页浏览器一样。这被称为内存泄漏。对于长时间运行的程序,这是严重的问题。

使用已释放的内存

free 之后再次使用指针,称为 Use-After-Free。这是未定义行为,可能导致程序崩溃或读取到垃圾数据。

int* p = malloc(sizeof(int));
*p = 14;
free(p);
printf("%d\n", *p); // 错误!使用了已释放的内存

重复释放

对同一个指针调用两次 free,称为 Double Free,同样会导致运行时错误。

int* p = malloc(sizeof(int));
free(p);
free(p); // 错误!重复释放

动态分配数组

malloc 更强大的用途是动态创建数组。我们可以在程序运行时决定数组的大小。

以下是动态创建和操作数组的步骤:

  1. 计算总字节数:数组长度 × 每个元素的大小。
  2. 调用 malloc 分配内存。
  3. 检查返回值是否为 NULL
  4. 将返回的指针当作数组名使用
  5. 使用完毕后,调用 free 释放内存
#include <stdio.h>
#include <stdlib.h>

int main() {
    int arrayLength;
    printf("Enter the array length: ");
    scanf("%d", &arrayLength);

    // 1. 动态分配数组
    int* dynamicArray = malloc(arrayLength * sizeof(int));

    // 2. 检查分配是否成功
    if (dynamicArray == NULL) {
        printf("Memory allocation failed!\n");
        return 1; // 或 exit(EXIT_FAILURE);
    }

    // 3. 像使用普通数组一样使用它
    for (int i = 0; i < arrayLength; i++) {
        dynamicArray[i] = i * 10;
    }

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

    // 4. 释放内存
    free(dynamicArray);
    dynamicArray = NULL;

    return 0;
}

核心概念malloc 分配的是一块连续的字节区域,这与数组在内存中的布局完全相同,因此我们可以将返回的指针当作数组来使用。

调试工具:Valgrind

动态内存错误很难仅通过观察代码发现。幸运的是,有工具可以帮助我们。Valgrind 是一个强大的内存调试工具。

在命令行中,像这样运行你的程序:

valgrind ./your_program_name

Valgrind 会监控程序的内存操作,并报告如下问题:

  • 内存泄漏:哪些内存被分配了但没有释放。
  • Use-After-Free:在释放后使用了内存。
  • Double Free:重复释放。

为了获得更详细的泄漏信息,可以运行:

valgrind --leak-check=full ./your_program_name

Valgrind 的输出会指出问题发生的代码行号,是调试动态内存问题的利器。

错误处理:exit 函数

malloc 返回 NULL(内存不足)时,我们的程序可能无法继续。与其让程序后续崩溃,不如主动、体面地结束它。我们可以使用 exit 函数。

#include <stdlib.h>
// ...
int* p = malloc(sizeof(int));
if (p == NULL) {
    // 内存分配失败,退出程序
    exit(EXIT_FAILURE); // EXIT_FAILURE 是一个表示失败的非零值
}
// EXIT_SUCCESS 表示成功

exit 函数会立即终止程序,其参数会返回给操作系统,类似于 main 函数的返回值。

总结

本节课中我们一起学习了C语言的动态内存管理:

  1. 为什么需要动态内存:为了创建在函数作用域之外存活的内存,或创建大小在编译时未知的数据结构(如数组)。
  2. 如何分配内存:使用 malloc(size) 函数从堆上请求指定字节数的连续内存。必须使用 sizeof 运算符来计算所需大小。
  3. 如何释放内存:使用 free(ptr) 函数释放之前分配的内存。这是防止内存泄漏的关键。
  4. 必须遵守的规则
    • 检查 malloc 的返回值是否为 NULL
    • 不要使用已释放的内存(Use-After-Free)。
    • 不要对同一块内存释放两次(Double Free)。
    • 释放后可将指针设为 NULL 以防误用。
  5. 动态数组:通过 malloc(n * sizeof(type)) 可以创建动态大小的数组。
  6. 辅助工具:使用 Valgrind 来检测内存泄漏和其他内存错误。
  7. 核心建议:动态内存功能强大但危险,仅在必要时使用。如果可以使用栈内存(局部变量)或全局变量解决问题,就优先使用它们。

动态内存管理是C编程中的难点,也是体现程序员功力的地方。理解并正确应用这些概念,是编写健壮、高效C程序的基础。

021:二维数组 🧮

在本节课中,我们将要学习C语言中的二维数组。我们将从一维数组的回顾开始,逐步介绍二维数组的声明、初始化、内存布局以及如何动态创建和使用它们。课程最后,我们会通过一个简单的井字棋游戏示例来巩固所学知识。


回顾:一维数组

上一节我们介绍了一维数组,本节中我们来看看二维数组。首先,让我们快速回顾一下一维数组。

声明一个一维数组的语法如下:

int array_name[array_size];

或者,我们可以在声明时直接初始化它:

int array_name[] = {value1, value2, value3};

我们还可以使用一个宏来计算数组的大小。


二维数组基础

二维数组,顾名思义,就是在两个维度上存储数据的数组。它的声明语法与一维数组非常相似,只是多了一对方括号。

声明一个二维数组的语法如下:

type array_name[rows][columns];

其中:

  • type 是数组中每个元素的类型。
  • array_name 是数组的名称。
  • rows 是数组的行数。
  • columns 是数组的列数。

我们可以把二维数组想象成一个表格,有行和列。和所有C语言数组一样,索引是从0开始的。例如,要访问“第1行,第2列”(人类计数法)的元素,我们需要使用索引 [0][1]


初始化二维数组

与一维数组类似,我们可以在声明时初始化二维数组。但需要注意的是,我们需要为每一行使用一组独立的花括号。

以下是初始化一个2行3列的二维数组的方法:

int table[2][3] = {
    {1, 2, 3}, // 第一行
    {4, 5, 6}  // 第二行
};

我们也可以省略内层的花括号,C语言会按顺序填充所有值。但为了清晰起见,建议使用内层花括号。


内存布局:行主序

在内存中,二维数组是以行主序的方式连续存储的。这意味着同一行的所有元素在内存中是相邻的,存储完第一行后,紧接着存储第二行,依此类推。

正因为这种存储方式,我们可以用一个一维数组的索引公式来访问二维数组中的元素:

1D_index = (row_index * number_of_columns) + column_index

例如,对于一个 table[2][3],元素 table[1][2] 在一维表示中的索引是 (1 * 3) + 2 = 5


动态二维数组与省略维度

在声明二维数组时,我们可以省略第一个维度(行数),但必须指定第二个维度(列数)。C语言编译器可以根据初始化的数据量自动计算出行数。

以下是合法的声明:

int table[][3] = {{1,2,3}, {4,5,6}}; // C会计算出有2行

但是,不能同时省略两个维度,因为C语言无法推断出数据的组织方式。


部分初始化与零填充

如果我们为二维数组指定了大小,但在初始化时提供的值少于元素总数,C语言会自动将剩余的元素初始化为0。

例如:

int table[2][3] = {{1}, {4,5}};

这个数组将被初始化为:

  • 第一行: {1, 0, 0}
  • 第二行: {4, 5, 0}

这个规则同样适用于一维数组。


运行时确定大小的数组(VLA)

在现代C语言标准中,我们可以使用变量来指定数组的大小,这被称为可变长度数组。但请注意,这样创建的数组位于栈上,有其作用域限制。

以下是创建可变长度一维数组的示例:

int length;
printf("Enter array length: ");
scanf("%d", &length);
int dynamic_array[length]; // 大小在运行时确定

对于二维数组也同样适用:

int rows, cols;
printf("Enter rows and columns: ");
scanf("%d %d", &rows, &cols);
int dynamic_2d_array[rows][cols];

但是,以这种方式声明的数组不能在声明时进行初始化。


将二维数组传递给函数

将二维数组传递给函数时,函数原型中必须至少指定列数。行数可以被省略,或者作为一个单独的参数传递。

以下是一个函数声明的例子:

void process_array(int arr[][COLS], int rows);
// 或者,如果COLS也是一个变量,需要先声明它
void process_array(int cols, int rows, int arr[][cols]);

关键点是,列数参数必须在数组参数之前声明,这样编译器才知道如何进行内存地址计算。


创建不等长列的“二维”数组

标准的二维数组要求每一行的列数相同。如果我们想要一个每行列数都不同的结构(类似于“锯齿数组”),就需要使用指针数组和动态内存分配。

其核心思想是:

  1. 创建一个指针数组,每个指针将指向一行。
  2. 为每一行单独使用 malloc 分配所需数量的内存。

以下是实现步骤的分解:

首先,声明一个指针数组(即“行”数组):

int num_rows;
printf("Enter number of rows: ");
scanf("%d", &num_rows);
int *table[num_rows]; // 一个包含`num_rows`个整型指针的数组

然后,为每一行动态分配内存(即“列”数组):

for (int i = 0; i < num_rows; i++) {
    int num_cols;
    printf("Enter columns for row %d: ", i+1);
    scanf("%d", &num_cols);

    // 为`num_cols`个整数+1个哨兵值(如-1)分配空间
    table[i] = malloc(sizeof(int) * (num_cols + 1));

    // 填充数据...
    for (int j = 0; j < num_cols; j++) {
        table[i][j] = rand() % 100; // 示例:填充随机数
    }
    // 在末尾放置哨兵值,标记行结束
    table[i][num_cols] = -1;
}

通过这种方式,table[i] 是一个指向整型的指针,我们可以像使用一维数组一样使用它 table[i][j]。但请注意,不同行分配的内存块在堆上不一定是连续的。

这种结构的类型实际上是 int **table(指向整型指针的指针),这是我们课程中首次遇到的双重指针。


应用示例:简易井字棋检查器

最后,让我们通过一个简单的井字棋游戏胜负检查器来应用二维数组。假设我们有一个 SIZE x SIZE 的字符数组代表棋盘,‘X’和‘O’代表玩家,‘.’代表空位。

以下是核心的检查逻辑概述(检查行、列和对角线):

#define SIZE 3

char checkWinner(char board[SIZE][SIZE]) {
    // 检查行和列
    for (int i = 0; i < SIZE; i++) {
        if (board[i][0] != '.' && board[i][0] == board[i][1] && board[i][1] == board[i][2]) {
            return board[i][0]; // 行获胜
        }
        if (board[0][i] != '.' && board[0][i] == board[1][i] && board[1][i] == board[2][i]) {
            return board[0][i]; // 列获胜
        }
    }
    // 检查对角线
    if (board[0][0] != '.' && board[0][0] == board[1][1] && board[1][1] == board[2][2]) {
        return board[0][0];
    }
    if (board[0][2] != '.' && board[0][2] == board[1][1] && board[1][1] == board[2][0]) {
        return board[0][2];
    }
    return '.'; // 暂无胜者
}

这个例子展示了如何遍历和操作二维数组,其思想与实验6(单词搜索)类似,只是检查的方向和规则不同。


总结 🎯

本节课中我们一起学习了C语言中二维数组的方方面面。

  • 我们回顾了一维数组,并扩展到二维数组的声明和初始化。
  • 理解了二维数组在内存中以行主序连续存储,并掌握了相应的索引计算。
  • 学习了如何声明可变长度数组以及在函数中传递二维数组的规则。
  • 深入探讨了如何通过指针数组和动态内存分配来创建列数不等的“二维”结构,这引入了 int ** 类型的双重指针概念。
  • 最后,通过一个井字棋检查器的例子,实践了二维数组的遍历和逻辑应用。

掌握二维数组是处理矩阵、网格、游戏棋盘等数据的基础,也为后续学习更复杂的数据结构做好了准备。

022:字符串

在本节课中,我们将要学习C语言中的字符串。字符串是字符数组,并以一个特殊的零字节(\0)标记结尾。理解字符串的存储、操作以及安全地处理用户输入至关重要。

字符串基础

上一节我们介绍了数组,本节中我们来看看一种特殊的数组——字符串。

字符串本质上是一个字符数组,其末尾有一个特殊的零字节(\0)作为终止符。这个零字节告诉程序字符串在哪里结束。

核心概念:字符串 = 字符数组 + 终止符 \0
代码示例char str[] = "Hello"; // 实际存储: 'H','e','l','l','o','\0'

字符串字面量

字符串字面量是用双引号括起来的字符序列。例如,"Hello world"

重要规则

  • 字符串字面量存储在只读内存中,这意味着你不能修改它们的内容。
  • 字符串字面量的类型是 const char*,它是一个指向常量字符的指针。
  • 使用 const char* 类型来声明指向字符串字面量的指针,可以防止你意外修改只读内存,从而避免程序崩溃(段错误)。

代码示例

const char *s = "Hello"; // 正确:指向只读字符串
// s[0] = 'h'; // 错误:尝试修改只读内存,会导致未定义行为(如段错误)

可修改的字符串数组

如果你想创建一个可以修改的字符串,应该在栈上定义一个字符数组。

以下是创建可修改字符串的方法:

  • 方法一:显式初始化数组并包含终止符。
    char s1[] = {'H', 'e', 'l', 'l', 'o', '\0'};
    
  • 方法二:使用字符串字面量初始化数组。C语言会自动将字符串(包括终止符)复制到栈上的数组中。
    char s2[] = "Hello"; // 数组s2在栈上,内容可修改
    s2[0] = 'h'; // 正确:可以修改
    

使用 sizeof 运算符获取此类数组的大小时,会包含末尾的终止符。

字符串操作与常见陷阱

理解了字符串的存储方式后,我们来看看如何操作它们以及需要注意的问题。

打印字符串

使用 printf 打印字符串时,格式说明符是 %s。它会从给定的地址开始,逐个打印字符,直到遇到终止符 \0 为止。

代码示例

char str[] = "Hello";
printf("%s\n", str); // 输出: Hello
printf("%.3s\n", str); // 输出: Hel (限制打印前3个字符)
printf("%s\n", str + 2); // 输出: llo (从第3个字符开始打印)

还有一个专用的函数 puts,用于输出字符串并自动添加换行符。

puts(str); // 等同于 printf("%s\n", str);

字符串长度

由于字符串以 \0 结尾,我们可以通过遍历数组来计算其长度(不包含终止符)。

代码示例:实现 strlen 函数的功能

int string_length(const char *s) {
    int i = 0;
    while (s[i] != '\0') { // 遇到终止符时停止
        i++;
    }
    return i; // 返回字符数量
}

缓冲区溢出

这是处理字符串时最危险的问题之一。如果为字符串分配的空间不足以容纳用户输入的内容及终止符,就会发生缓冲区溢出,导致程序写入非法内存,可能破坏其他数据或引发安全漏洞。

错误示例

char buffer[4]; // 只能容纳3个字符+1个终止符
scanf("%s", buffer); // 如果用户输入"Hello",将发生缓冲区溢出!

scanf 不知道 buffer 的大小,它会一直写入,覆盖 buffer 之后的内存。

安全的字符串输入

鉴于 scanf%s 配合使用极其危险,我们必须寻找更安全的方法来获取用户输入。

使用 fgets

fgets 函数允许你指定缓冲区的大小,从而防止溢出。

函数原型char *fgets(char *str, int n, FILE *stream);

  • str:指向存储输入字符串的缓冲区。
  • n:最大读取字符数(包括终止符 \0)。实际最多读取 n-1 个字符。
  • stream:输入流,通常使用 stdin 表示标准输入(键盘)。

代码示例

#define BUFFER_SIZE 10
char buffer[BUFFER_SIZE];
printf("Enter your name: ");
fgets(buffer, BUFFER_SIZE, stdin);
// 如果输入超过9个字符,fgets只会读取前9个并添加'\0',多余部分留在输入流中。

使用 getline(推荐)

getline 函数更加强大和方便。它会自动分配足够的内存来容纳整行输入(包括换行符),你无需猜测缓冲区大小。

函数原型ssize_t getline(char **lineptr, size_t *n, FILE *stream);

  • lineptr:指向字符指针的指针。函数会通过 malloc 分配内存,并修改这个指针指向新内存。
  • n:指向一个变量的指针,该变量保存了已分配缓冲区的大小。函数可能会调整它。
  • stream:输入流,如 stdin
  • 返回值:成功读取的字符数(包括换行符,但不包括终止符),失败返回-1。

代码示例

char *line = NULL; // 初始化为NULL,getline会分配内存
size_t len = 0;    // 缓冲区大小初始为0
ssize_t read;

printf("Enter a line: ");
read = getline(&line, &len, stdin); // 注意传递指针的地址

if (read != -1) {
    // 可选:去掉末尾的换行符
    if (read > 0 && line[read - 1] == '\n') {
        line[read - 1] = '\0';
    }
    printf("You entered: %s\n", line);
    printf("Buffer size allocated: %zu\n", len);
}

free(line); // 必须释放getline分配的内存

getline 会处理所有内存管理问题,是处理不定长用户输入最安全、最省心的方式。

总结

本节课中我们一起学习了C语言字符串的核心知识:

  1. 本质:字符串是以空字符 \0 结尾的字符数组。
  2. 字面量:字符串字面量(如"Hello")存储在只读内存,应使用 const char* 指针指向它们。
  3. 可修改字符串:在函数内定义字符数组(如 char s[] = "Hello")可以创建栈上的、可修改的字符串副本。
  4. 操作与陷阱:使用 %s 格式说明符进行输出,但切勿scanf%s 进行输入,这会导致无法防范的缓冲区溢出。
  5. 安全输入:使用 fgets 并指定缓冲区大小来防止溢出,或者使用更强大的 getline 函数,它能自动管理内存以适应任意长度的输入。

牢记始终为终止符预留空间,并选择安全的输入函数,是编写健壮C语言程序的关键。

023:字符串函数 📚

在本节课中,我们将学习C标准库中提供的一系列字符串处理函数。这些函数能帮助我们更高效地操作字符串,而无需自己从头实现所有功能。要使用这些函数,只需在代码文件顶部包含 #include <string.h> 头文件即可。


字符串基础回顾 🔍

上一节我们介绍了字符串的基本概念。字符串本质上是一个以空字符(\0)结尾的字符数组。它可以分配在栈上或堆上。请记住,存储一个字符串需要字符串实际字符数 + 1个字节,这额外的1个字节就是用来存放结尾的空字符。忘记这一点是许多错误的根源。


获取字符串长度 📏

我们之前自己编写过获取字符串长度的函数。实际上,标准库已经提供了这个功能。

strlen 函数

strlen 函数用于计算一个以空字符结尾的字符串的长度。它接受一个指向字符数组的指针作为参数,并返回一个 size_t 类型的值,表示字符串中空字符前的字符数量。

函数原型

size_t strlen(const char *s);

示例

char *s = "hello world";
int s_len = (int)strlen(s); // s_len 的值为 11

字符串 "hello world" 包含11个字符,但存储它需要12个字节(11个字符 + 1个空字符)。

strnlen 函数

有时我们不确定一个字符串是否正确地以空字符结尾。strnlen 函数允许我们指定一个最大长度限制,避免访问无效内存。

函数原型

size_t strnlen(const char *s, size_t maxlen);

这个函数最多检查 maxlen 个字节。如果在达到 maxlen 之前找到了空字符,则返回实际长度;否则,返回 maxlen

示例

char *s = "hello world";
size_t len = strnlen(s, 5); // len 的值为 5

即使字符串更长,它也只会检查前5个字节。


复制字符串 📝

如果我们想修改一个字符串,通常需要将其复制到我们可以写入的内存区域(例如通过 malloc 分配的内存)。strcpy 函数就是用于此目的。

strcpy 函数

strcpy 函数将一个源字符串(包括结尾的空字符)复制到目标内存区域。

函数原型

char *strcpy(char *dest, const char *src);
  • dest:指向目标内存区域的指针,必须有足够的空间容纳源字符串。
  • src:指向要复制的源字符串的指针。

重要:你必须确保 dest 指向的内存至少能容纳 strlen(src) + 1 个字节。

示例

char *src = "hello world";
char *dest = malloc(strlen(src) + 1); // 分配足够的内存
if (dest != NULL) {
    strcpy(dest, src); // 复制字符串
    // 现在可以安全地修改 dest
    free(dest); // 使用完毕后释放内存
}

strncpy 函数

strncpystrcpy 的安全版本,它允许你指定最多复制多少个字符到目标缓冲区。

函数原型

char *strncpy(char *dest, const char *src, size_t n);
  • n:最多复制的字符数。

行为

  • 如果 src 的长度(包括空字符)小于 n,它会将剩余的空间用空字符(\0)填充。
  • 如果 src 的长度大于或等于 n,它只会复制 n 个字符,并且不会在目标字符串的末尾添加空字符!这可能导致字符串未正确终止。

示例

char src[] = "hello";
char dest[6];
strncpy(dest, src, 5); // 只复制了5个字符,dest 可能没有空字符结尾
dest[5] = '\0'; // 手动添加空字符以确保安全

使用 strncpy 时必须小心,并经常需要手动确保字符串的终止。


连接字符串 🔗

strcat 函数用于将一个字符串追加到另一个字符串的末尾。

strcat 函数

strcat 将源字符串的内容复制到目标字符串的末尾(覆盖目标字符串原有的空字符),并在最后添加一个新的空字符。

函数原型

char *strcat(char *dest, const char *src);

重要:你必须确保 dest 指向的内存有足够的剩余空间来容纳 src 的所有字符加上一个新的空字符。

示例

char dest[20] = "hello";
char *src = " world";
strcat(dest, src); // dest 现在变成 "hello world"

strncat 函数

strncpy 类似,strncat 允许限制从源字符串追加的字符数量,并且它总是会在结果字符串的末尾添加一个空字符。

函数原型

char *strncat(char *dest, const char *src, size_t n);

重要:即使使用 strncat,你仍然需要确保目标缓冲区有足够的空间。安全的使用方式是计算剩余空间:剩余空间 = 缓冲区总大小 - 当前字符串长度 - 1

示例

char dest[10] = "hello";
char src[] = " world";
size_t dest_size = 10;
size_t chars_to_copy = dest_size - strlen(dest) - 1; // 计算还能安全添加多少字符
strncat(dest, src, chars_to_copy); // 安全地追加

比较字符串 ⚖️

要比较两个字符串的内容,不能直接使用 == 运算符(那比较的是指针地址),而应使用 strcmp 函数。

strcmp 函数

strcmp 逐个字符地比较两个字符串,直到遇到不同的字符或空字符。

函数原型

int strcmp(const char *s1, const char *s2);

返回值

  • < 0s1 小于 s2(按字典序)。
  • = 0s1 等于 s2
  • > 0s1 大于 s2

示例

if (strcmp("apple", "banana") < 0) {
    // 会执行到这里,因为 "apple" < "banana"
}

strncmp 函数

strncmp 只比较两个字符串的前 n 个字符。

函数原型

int strncmp(const char *s1, const char *s2, size_t n);

示例

if (strncmp("Jonathan", "John", 3) == 0) {
    // 会执行到这里,因为前3个字符都是 "Joh"
}

在字符串中搜索 🔎

strstr 函数

strstr 用于在一个字符串(“干草堆”)中查找另一个子字符串(“针”)首次出现的位置。

函数原型

char *strstr(const char *haystack, const char *needle);
  • 如果找到,返回指向 haystack 中匹配子串起始位置的指针。
  • 如果未找到,返回 NULL

示例

char *text = "This is a long string";
char *found = strstr(text, "long");
if (found != NULL) {
    printf("Found at: %s\n", found); // 输出: "long string"
}

strchr 函数

strchr 用于在字符串中查找特定字符首次出现的位置。

函数原型

char *strchr(const char *s, int c);
  • 如果找到,返回指向该字符的指针。
  • 如果未找到,返回 NULL

示例

char *s = "hello world";
char *found = strchr(s, 'w');
if (found != NULL) {
    printf("Found: %s\n", found); // 输出: "world"
}

字符串与数值转换 🔢

以下函数位于 <stdlib.h> 中,用于将字符串转换为数值。

atoiatof 函数

  • atoi:将字符串转换为整数。
  • atof:将字符串转换为浮点数(double)。

函数原型

int atoi(const char *str);
double atof(const char *str);

示例

int num = atoi("1234");       // num = 1234
double val = atof("12.34");   // val = 12.34

注意:如果字符串格式无效,这些函数的行为是未定义的或返回0。在实际项目中,更推荐使用 strtolstrtod 等具有错误检查功能的函数。


总结 📋

本节课我们一起学习了C语言标准库中常用的字符串处理函数。我们涵盖了:

  1. 测量长度strlen, strnlen
  2. 复制字符串strcpy, strncpy
  3. 连接字符串strcat, strncat
  4. 比较字符串strcmp, strncmp
  5. 搜索内容strstr, strchr
  6. 类型转换atoi, atof

使用这些函数时,最关键的是要时刻注意内存安全空字符终止。许多函数都有带 n 的“安全”版本,允许你指定操作的上限,这是防止缓冲区溢出的重要手段。请务必查阅手册以了解每个函数的详细行为和注意事项。

024:字符串练习 🧵

在本节课中,我们将通过两个典型的考试题目来练习字符串操作,并学习一个实用的编程技巧:如何编写一个可以接收命令行参数的程序。我们将从字符串的基础概念开始,逐步深入到具体的函数实现。

字符串基础回顾

上一节我们介绍了字符串的基本概念。本节中我们来看看如何应用这些知识解决实际问题。

C语言中的字符串本质上是一个字符数组,其特殊之处在于它以空字符(\0)结尾。这个空字符是字符串结束的标志,也是我们操作字符串的关键。

练习一:去除前导零

首先,我们来看一个去除字符串前导零的练习。题目要求我们完成一个函数,该函数会修改一个表示数字的字符串,去掉其开头的所有‘0’字符。

以下是题目给出的代码框架和我们的任务:

void printnum(char *string) {
    int i = 0;
    while (string[i] == ‘0’) {
        i++;
    }
    // 我们需要在此处添加一行代码
    printf(“%s\n”, string);
}

我们的目标是在注释处添加代码,使得函数能正确打印出不带前导零的数字。例如,输入字符串 “000089876”,应输出 “89876”

解题思路

  1. 变量 iwhile 循环结束后,其值等于前导零的个数。
  2. 为了从第一个非零字符开始打印,我们需要将字符串指针向前移动 i 个位置。
  3. 这可以通过指针运算实现:string += i;。这行代码将指针 string 重新指向原字符串的第 i 个字符(即第一个非‘0’字符)。

因此,我们添加的代码就是 string += i;。这样,printf 函数就会从新的起始位置开始打印,直到遇到空字符为止。

练习二:查找子字符串的最后一次出现

接下来是一个更复杂的练习:编写一个函数 last_string_in_string,用于查找字符串 s1 在字符串 s2 中最后一次出现的位置。

函数原型如下:

char *last_string_in_string(const char *s1, const char *s2);

如果找到,则返回指向 s2 中该次匹配起始位置的指针;如果未找到,则返回 NULL。题目要求我们不能使用 strstr 函数。

解题思路
我们可以从 s2 的末尾开始,向前逐段比较与 s1 长度相同的子串。以下是实现步骤:

  1. 获取 s1s2 的长度。
  2. s2(长度_s2 - 长度_s1) 位置开始向前遍历。这是为了确保每次比较的子串都有足够的长度。
  3. 在循环中,使用 strncmp 函数比较 s2 中从当前位置开始的、长度为 长度_s1 的子串是否与 s1 完全相等。
  4. 如果相等,则立即返回指向该位置的指针。
  5. 如果遍历完所有可能位置都未找到,则返回 NULL

核心代码逻辑如下:

int len1 = strlen(s1);
int len2 = strlen(s2);
for (int i = len2 - len1; i >= 0; i--) {
    if (strncmp(s2 + i, s1, len1) == 0) {
        return (char *)(s2 + i); // 找到匹配
    }
}
return NULL; // 未找到匹配

实用技巧:使用命令行参数

前面的练习都是标准的函数实现。现在,我们来看看一个更贴近实际应用的编程方法:让 main 函数接收命令行参数。

main 函数其实有另一种形式:

int main(int argc, char *argv[])
  • argc (argument count):表示程序运行时传入的参数个数。
  • argv (argument vector):是一个指向字符串数组的指针,每个字符串都是一个参数。

参数规则

  • argv[0] 通常是程序自身的名称。
  • argv[1]argv[2]... 是用户输入的其他参数。
  • 操作系统会自动处理空格,将输入分割成独立的字符串参数。

示例:十进制转十六进制程序

让我们编写一个实用的程序 dectohex,它从命令行接收一个十进制数字,并将其转换为十六进制输出。

程序用法示例:在终端中输入 ./dectohex 255,程序输出 FF

实现步骤

  1. 参数检查:首先检查 argc 是否为2(程序名 + 一个数字参数)。如果不是,则打印错误信息并退出。
  2. 字符串转整数:我们需要将 argv[1] 这个字符串转换为整数。为了更好地处理错误输入(如包含字母),我们编写自己的转换函数,而不是直接用 atoi
    • 遍历字符串的每个字符。
    • 检查字符是否在 ‘0’‘9’ 之间,否则报错。
    • 使用公式 value = value * 10 + (current_char - ‘0’) 来累加计算整数值。
  3. 整数转十六进制字符串
    • 通过不断除以16并取余数,计算出十六进制数的每一位(逆序)。
    • 根据余数大小(0-9 或 10-15),将其转换为对应的字符(‘0’-‘9’‘A’-‘F’)。
    • 由于计算是逆序的,我们需要将字符从后往前填入新申请的字符串内存中。
    • 最后,不要忘记在字符串末尾添加空字符 ‘\0’
  4. 输出与清理:打印得到的十六进制字符串,并释放之前动态申请的内存。

这个程序展示了如何编写一个完整、健壮的命令行工具,它比使用 scanf 等待用户交互更为常见和实用。

总结

本节课中我们一起学习了:

  1. 字符串操作练习:通过“去除前导零”和“查找子串最后出现位置”两个题目,巩固了指针运算和字符串比较函数(strncmp)的用法。
  2. 命令行参数:学习了 main(int argc, char *argv[]) 的用法,了解了如何让程序从命令行接收输入参数。
  3. 综合应用:实现了一个完整的“十进制转十六进制”命令行程序,涵盖了参数校验、字符串与整数的相互转换、内存动态管理等多个知识点。

掌握这些技能,你将能够编写出更接近真实世界应用场景的C语言程序。

025:递归 🌀

在本节课中,我们将要学习递归的概念。递归是编程中一个强大但有时令人困惑的工具,它允许函数调用自身来解决问题。我们将通过几个例子来理解递归的基本原理,包括如何定义递归函数以及如何避免常见的陷阱。

什么是递归?

递归函数是指调用自身的函数。在数学中,你可能见过类似的概念,称为递推关系。为了有效地使用递归解决问题,我们需要两个关键部分:基础情况递归步骤

基础情况是一个简单且已知的解,它可以直接返回结果,无需进一步递归。递归步骤则是将问题分解为一个更小的、相同类型的子问题,并假设我们已经知道如何解决这个子问题。

递归的示例:指数计算

上一节我们介绍了递归的基本概念,本节中我们来看看如何用递归计算指数。

假设我们要计算 Bn 次方(B^n),其中 n 是非负整数。我们可以这样定义递归函数:

  • 基础情况:当指数 n 等于 0 时,任何数的 0 次方都是 1。因此,我们直接返回 1。
  • 递归步骤:对于 n > 0 的情况,B^n 可以表示为 B * B^(n-1)。这里,B^(n-1) 是原问题的一个更小版本。

以下是该函数的C语言实现:

int exponent(int B, int n) {
    // 基础情况
    if (n == 0) {
        return 1;
    }
    // 递归步骤
    else {
        return B * exponent(B, n - 1);
    }
}

当我们调用 exponent(2, 4) 时,计算过程如下:

  1. exponent(2, 4) 调用 exponent(2, 3)
  2. exponent(2, 3) 调用 exponent(2, 2)
  3. exponent(2, 2) 调用 exponent(2, 1)
  4. exponent(2, 1) 调用 exponent(2, 0)
  5. exponent(2, 0) 遇到基础情况,返回 1
  6. 然后结果层层返回:1*2=2 -> 2*2=4 -> 4*2=8 -> 8*2=16
    最终得到结果 16。

栈溢出与无限递归

在理解了基本递归后,我们需要认识一个重要的潜在问题:栈溢出。

每次函数调用时,计算机会在称为“栈”的内存区域中为该调用分配空间,用于存储局部变量等信息。如果递归调用过深(例如,计算 exponent(2, 1000000)),栈空间可能会被耗尽,导致程序崩溃,这就是栈溢出

栈溢出最常见的原因是无限递归,即函数没有正确的基础情况来终止递归,导致它无限地调用自身。这与无限循环类似,但会更快地消耗内存。

递归的优化:尾递归

有些递归函数可以被编译器优化,以减少内存使用。一种形式称为尾递归

尾递归函数的特点是,递归调用是函数体中最后执行的操作(即在 return 语句中直接调用自身,没有其他运算)。例如,我们可以重写指数函数为尾递归形式:

int exponent_tail(int B, int n, int accumulator) {
    if (n == 0) {
        return accumulator;
    } else {
        return exponent_tail(B, n - 1, B * accumulator);
    }
}

// 包装函数,保持接口不变
int exponent(int B, int n) {
    return exponent_tail(B, n, 1);
}

许多编译器能够将尾递归函数优化为等价的循环代码,从而避免额外的栈开销和栈溢出风险。不过,对于本课程,了解这个概念即可,无需强制使用。

更复杂的递归:斐波那契数列 🐚

现在,让我们看一个更复杂的递归例子:计算斐波那契数列。

斐波那契数列的定义如下:

  • F(0) = 0
  • F(1) = 1
  • F(n) = F(n-1) + F(n-2),当 n > 1 时

这是一个典型的递归定义。我们可以将其直接翻译成C语言函数:

int fib(int n) {
    // 基础情况
    if (n < 2) {
        return n;
    }
    // 递归步骤
    else {
        return fib(n - 1) + fib(n - 2);
    }
}

然而,这个实现存在效率问题。以计算 fib(4) 为例,其调用过程会涉及大量重复计算(例如 fib(2) 被计算了多次)。随着 n 增大,计算时间会呈指数级增长。

提升递归效率:记忆化

为了解决重复计算的问题,我们可以使用一种称为记忆化的技术。记忆化意味着“缓存”或存储已经计算过的结果,以便后续直接使用,避免重复计算。

虽然本课程不要求实现记忆化,但了解这个概念很有用。基本思路是使用一个数组来存储已经计算出的斐波那契数值。在递归函数中,先检查数组中是否已有结果,如果有则直接返回,否则进行计算并将结果存入数组。这能显著提升性能。

递归实践:阶乘计算

为了巩固对递归的理解,让我们尝试用递归计算阶乘。

阶乘 n! 定义为 n * (n-1) * ... * 1,且 0! = 1

以下是递归解决方案的思考过程:

  • 基础情况0!1! 等于 1。
  • 递归步骤n! 可以表示为 n * (n-1)!

对应的C语言函数如下:

int factorial(int n) {
    // 基础情况
    if (n <= 1) {
        return 1;
    }
    // 递归步骤
    else {
        return n * factorial(n - 1);
    }
}

对于许多问题,递归解法比循环解法在思维上更直观,尤其是当问题自然具有自相似结构时。

总结

本节课中我们一起学习了递归的核心概念。我们了解到,一个递归函数需要明确的基础情况和递归步骤。我们通过计算指数、斐波那契数和阶乘的例子实践了递归的编写。同时,我们也认识了递归可能带来的问题,如栈溢出和效率低下,并简要了解了尾递归优化和记忆化技术。递归是解决某些类型问题的强大工具,掌握它需要练习,但能极大地提升你解决复杂问题的能力。

026:进阶递归

在本节课中,我们将深入学习递归的进阶应用。我们将通过几个具体的编程问题,包括计算最大公约数、递归计数、数组求和以及著名的汉诺塔问题,来巩固对递归概念的理解。我们将遵循递归的两个核心规则:基础情况递归步骤,并学习如何将它们应用到更复杂的场景中。


计算最大公约数 (GCD)

上一节我们回顾了递归的基本概念。本节中,我们来看看如何利用递归解决一个经典的数学问题:计算两个整数的最大公约数。

最大公约数 (GCD) 是能同时整除两个给定正整数的最大整数。我们可以使用欧几里得算法来递归地解决这个问题。

该算法的核心思想是:对于两个整数 ab(假设 a >= b),gcd(a, b) 等于 gcd(b, a % b)。当 b 变为 0 时,a 就是最大公约数。

以下是递归实现的代码:

int gcd(int a, int b) {
    // 基础情况:当 b 为 0 时,a 就是最大公约数
    if (b == 0) {
        return a;
    }
    // 如果 a 小于 b,交换参数以确保 a >= b
    else if (a < b) {
        return gcd(b, a);
    }
    // 递归步骤:gcd(a, b) = gcd(b, a % b)
    else {
        return gcd(b, a % b);
    }
}

例如,计算 gcd(20, 8) 的过程如下:

  1. gcd(20, 8) 因为 20 >= 8,进入递归步骤:gcd(8, 20 % 8)gcd(8, 4)
  2. gcd(8, 4) 进入递归步骤:gcd(4, 8 % 4)gcd(4, 0)
  3. gcd(4, 0) 触发基础情况,返回 4。

因此,gcd(20, 8) 的结果是 4。


递归计数

理解了如何解决数学问题后,我们来看看一个更简单的任务:递归地计数。这有助于我们理解递归调用顺序对结果的影响。

我们的目标是编写一个函数,给定一个数字 n,它能打印出从 1 到 n 或从 n 到 1 的数字。关键在于打印语句的位置。

以下是两种实现方式:

// 版本一:先递归,后打印。结果是从 1 打印到 n。
void countUp(int n) {
    if (n == 0) {
        return; // 基础情况:什么都不做
    }
    countUp(n - 1); // 递归步骤:先解决更小的问题 (n-1)
    printf("%d\n", n); // 然后再打印当前数字
}

// 版本二:先打印,后递归。结果是从 n 打印到 1。
void countDown(int n) {
    if (n == 0) {
        return; // 基础情况
    }
    printf("%d\n", n); // 先打印当前数字
    countDown(n - 1); // 再解决更小的问题
}

调用 countUp(5) 会输出 1 2 3 4 5,而 countDown(5) 会输出 5 4 3 2 1。这清晰地展示了递归调用和操作执行的先后顺序。


递归计算数组和

现在,让我们将递归思想应用到数据结构上:计算一个整数数组所有元素的总和。

思路是:数组的和等于第一个元素加上剩余元素组成的子数组的和。这自然形成了一个递归定义。

以下是递归求和的实现:

int sumArray(int arr[], int length) {
    // 基础情况1:如果数组为空,和为0
    if (length == 0) {
        return 0;
    }
    // 基础情况2:如果数组只有一个元素,和就是这个元素
    if (length == 1) {
        return arr[0];
    }
    // 递归步骤:总和 = 第一个元素 + 剩余子数组的和
    // arr + 1 是指针运算,指向下一个元素,length - 1 是剩余长度
    return arr[0] + sumArray(arr + 1, length - 1);
}

例如,对于数组 {1, 2, 3, 4, 5}

  1. sum({1,2,3,4,5}) = 1 + sum({2,3,4,5})
  2. sum({2,3,4,5}) = 2 + sum({3,4,5})
  3. 以此类推,直到触发基础情况。

最终结果是 15。

有时你可能会想到一个需要额外参数的“尾递归”版本。为了让主函数更简洁,通常会定义一个辅助函数:

// 辅助函数,携带当前和作为参数
int sumHelper(int arr[], int length, int currentSum) {
    if (length == 0) {
        return currentSum; // 基础情况:返回当前累加和
    }
    // 递归步骤:将当前元素加入和,并处理剩余数组
    return sumHelper(arr + 1, length - 1, currentSum + arr[0]);
}

// 主函数,对用户隐藏辅助参数
int sumArrayNice(int arr[], int length) {
    return sumHelper(arr, length, 0); // 初始当前和为0
}

汉诺塔问题

最后,我们探讨一个经典的递归问题:汉诺塔。它完美体现了“将大问题分解为小问题”的递归思想。

问题描述:有三根柱子(编号1,2,3)。开始时,所有大小不同的圆盘按从大到小的顺序套在柱子1上。目标是将所有圆盘移动到柱子3上。
规则

  1. 每次只能移动一个圆盘。
  2. 任何时候,都不能将较大的圆盘放在较小的圆盘之上。

递归思路
要将 n 个圆盘从“源”柱子(from)移动到“目标”柱子(to),并借助“备用”柱子(spare),可以分解为三个步骤:

  1. 将上面的 n-1 个圆盘从 from 移动到 spare(借助 to)。
  2. 将最大的第 n 个圆盘直接从 from 移动到 to
  3. spare 上的 n-1 个圆盘移动到 to(借助 from)。

n 为 0 时,不需要移动,这是基础情况。

以下是计算移动步骤并打印移动方法的递归实现:

int hanoi(int disks, int from, int to, int spare) {
    int steps = 0;
    // 基础情况:没有圆盘需要移动
    if (disks == 0) {
        return 0;
    }

    // 步骤1:移动上面 n-1 个圆盘到备用柱子
    steps += hanoi(disks - 1, from, spare, to);

    // 步骤2:移动最大的圆盘到目标柱子
    printf("Move disk %d from rod %d to rod %d\n", disks, from, to);
    steps += 1; // 计入一步

    // 步骤3:将 n-1 个圆盘从备用柱子移动到目标柱子
    steps += hanoi(disks - 1, spare, to, from);

    return steps;
}

调用 hanoi(3, 1, 3, 2) 会输出将 3 个圆盘从柱子1移到柱子3的详细步骤,并返回总步数 7。
移动 n 个圆盘所需的最少步数是 2^n - 1。例如,8个圆盘需要 255 步,这使得汉诺塔成为一个非常耗时的游戏。


总结

本节课中我们一起学习了递归的进阶应用。我们通过四个例子巩固了递归思维:

  1. 最大公约数:使用欧几里得算法,将问题不断化简直到基础情况。
  2. 递归计数:理解了递归调用与操作执行的顺序关系。
  3. 数组求和:学会了将问题分解为“首元素”与“子数组”的和。
  4. 汉诺塔问题:体验了如何将复杂问题(移动n个圆盘)递归地分解为更小的相同问题(移动n-1个圆盘)。

递归的核心始终是定义清晰的基础情况向基础情况推进的递归步骤。掌握这种思维方式,是解决许多复杂编程问题的关键。

027:字符串递归练习

在本节课中,我们将学习如何对字符串进行递归操作。我们将通过实现一个检查回文串的函数来深入理解递归在字符串处理中的应用。此外,我们还将简要介绍一些C语言的高级特性,如三元运算符、枚举(enum)、switch语句和类型定义(typedef),虽然这些内容在本课程中不要求使用,但了解它们有助于阅读未来的代码。


递归基础回顾

上一节我们介绍了递归的基本概念。递归函数需要两个关键部分:基础情况(一个已知的简单解)和递归步骤(将问题简化为更小的自身版本)。

对于字符串的递归,通常有三种主要思路:

  1. 将问题视为“一个字符 + 更小的字符串”。
  2. 将问题视为“更小的字符串 + 一个字符”(即从末尾开始处理)。
  3. 将问题视为“首尾两个字符 + 中间更小的字符串”。

实践:递归判断回文串

我们的目标是编写一个函数 is_palindrome,它能递归地判断一个字符串是否是回文串(即正读反读都相同,例如 “racecar”)。

直接使用单个字符串参数进行递归比较困难,因为我们需要同时跟踪首尾字符的位置。因此,我们需要一个辅助函数来接收额外的索引参数。

以下是实现思路:

  1. 基础情况
    • 如果首字符索引 first 大于或等于尾字符索引 last,说明我们已经检查完所有字符对或只剩下一个字符,此时字符串是回文,返回 true
    • 如果 first 位置的字符不等于 last 位置的字符,则肯定不是回文,返回 false
  2. 递归步骤
    • 如果首尾字符相等,则问题简化为检查去掉首尾字符后的子串。即,使用 first + 1last - 1 作为新的索引调用辅助函数。

根据以上思路,我们可以编写如下代码:

#include <stdio.h>
#include <string.h>
#include <stdbool.h>

bool is_palindrome_helper(const char *s, int first, int last) {
    // 基础情况1: 字符不匹配
    if (s[first] != s[last]) {
        return false;
    }
    // 基础情况2: 检查完毕或只剩中心字符
    if (first >= last) {
        return true;
    }
    // 递归步骤: 检查内部子串
    return is_palindrome_helper(s, first + 1, last - 1);
}

bool is_palindrome(const char *s) {
    int len = strlen(s);
    // 处理空字符串的情况(空串被视为回文)
    if (len == 0) {
        return true;
    }
    return is_palindrome_helper(s, 0, len - 1);
}

在主函数中,我们可以这样测试:

int main(int argc, char *argv[]) {
    if (argc != 2) {
        printf("Usage: %s <string>\n", argv[0]);
        return 1;
    }
    if (is_palindrome(argv[1])) {
        printf("\"%s\" is a palindrome.\n", argv[1]);
    } else {
        printf("\"%s\" is NOT a palindrome.\n", argv[1]);
    }
    return 0;
}

C语言特性简介(了解即可)

以下是一些C语言的其他特性。虽然在本课程中不要求使用,但了解它们有助于你阅读和理解更多的代码。

三元运算符 ? :

三元运算符是一种简洁的条件表达式。
语法条件 ? 表达式1 : 表达式2
如果条件为真,整个表达式的值为表达式1,否则为表达式2

int x = (5 > 3) ? 10 : 20; // x 的值为 10

它相当于一个简写的if-else语句,但过度使用会降低代码可读性。

枚举

枚举(enum)允许你为整数值创建有意义的名称,使代码更易读。
语法enum 枚举类型名 { 名称1, 名称2, ... };

enum Month { JAN=1, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC };
enum Month current_month = MAR; // current_month 的值为 3

未显式赋值的名称会自动获得递增的整数值(从上一个值+1开始,首个默认为0)。

Switch 语句

switch 语句根据一个变量的值跳转到不同的代码块执行。它通常与 enum 搭配使用。
语法

switch (变量) {
    case 值1:
        // 代码块1
        break; // 跳出switch
    case 值2:
        // 代码块2
        break;
    default:
        // 默认代码块(可选)
}

注意:每个 case 末尾的 break 至关重要,否则代码会继续执行下一个 case 的语句(这称为“穿透”)。

类型定义

typedef 用于为现有类型创建别名。
语法typedef 原类型 新类型名;

一个常见的用法是简化 enum 类型的声明:

typedef enum { NORTH, EAST, SOUTH, WEST } DirectionT;
DirectionT dir = NORTH; // 声明变量时无需再写 ‘enum’

这使类型声明更加简洁。通常在新类型名后加 _T_t 来表示这是一个类型别名。


额外练习:递归实现 strchr

作为额外的练习,我们可以尝试递归实现标准库函数 strchr,其功能是在字符串中查找指定字符并返回首次出现位置的指针。

以下是实现思路:

  1. 基础情况1:如果当前字符是字符串结束符 \0,说明未找到,返回 NULL
  2. 基础情况2:如果当前字符等于要查找的字符,返回指向当前字符的指针。
  3. 递归步骤:否则,在字符串的剩余部分(指针+1)中继续查找。

代码实现如下:

char* my_strchr(const char *s, char c) {
    if (*s == '\0') {
        return NULL; // 未找到
    }
    if (*s == c) {
        return (char*)s; // 找到,返回指针
    }
    return my_strchr(s + 1, c); // 在剩余部分递归查找
}

总结

本节课中我们一起学习了:

  1. 字符串递归:通过实现回文串判断函数,掌握了如何设计递归函数的基础情况与递归步骤,并学会了使用辅助函数来处理需要额外参数的递归问题。
  2. C语言特性:简要了解了三元运算符、枚举、switch语句和类型定义。请记住,这些特性在本课程中不要求使用,但认识它们对未来的编程学习有益。

递归是解决许多字符串和数据结构问题的强大工具,核心在于将大问题分解为相似的小问题。多加练习是掌握它的关键。

028:结构体 🏗️

在本节课中,我们将要学习C语言中一个非常重要的概念——结构体。结构体允许我们将多个不同类型的变量组合在一起,形成一个新的复合数据类型。这对于组织和管理复杂数据非常有用。


什么是结构体?

在C语言中,我们可以将多个变量组合在一个称为“结构体”的单元中。它是一种新的数据类型。C语言使用关键字 struct 来定义结构体。

定义结构体的语法是:首先输入关键字 struct,然后为这个变量组起一个名字,接着在大括号 {} 内放入任意数量的变量声明。

与枚举类型类似,结构体通常定义在 #include 指令之后,并且不定义在函数内部。


一个简单的例子:计算两点距离

让我们从一个简单的任务开始:计算两点之间的距离。一个点由x坐标和y坐标组成。

如果不使用结构体,我们可能会这样写函数:

double distance(double x1, double y1, double x2, double y2) {
    return sqrt(pow(x2 - x1, 2.0) + pow(y2 - y1, 2.0));
}

main 函数中,我们需要分别定义四个变量:

int main() {
    double x1 = 1, y1 = 2;
    double x2 = 4, y2 = 6;
    double dist = distance(x1, y1, x2, y2);
    printf("Distance: %.1f\n", dist); // 输出 5.0
    return 0;
}

这种方法虽然可行,但当需要处理多个点时,管理 x1, y1, x2, y2 这样的变量名很容易出错,例如可能不小心将不同点的坐标混用。


使用结构体改进

既然我们学习了结构体,就可以创建一个代表“点”的结构体。我们知道一个点在逻辑上是一个整体。

我们可以这样定义一个结构体:

struct point {
    double x;
    double y;
};

这样就创建了一个名为 struct point 的新类型。我们可以像使用 intdouble 一样使用它来声明变量。

要访问结构体内部的变量(通常称为“字段”或“成员”),可以使用点运算符 .

struct point p1;
p1.x = 1;
p1.y = 2;

使用 typedef 简化

为了使代码更易读,我们常使用 typedef 为结构体类型创建一个别名。

typedef struct point {
    double x;
    double y;
} point_t;

现在,我们可以直接使用 point_t 来声明变量:

point_t p1;
p1.x = 1;
p1.y = 2;

我们还可以在声明时直接初始化结构体,类似于数组:

point_t p1 = {1, 2}; // 按结构体字段定义的顺序赋值
point_t p2 = {.y = 6, .x = 4}; // 也可以指定字段名赋值

用结构体重写函数

现在,我们可以用结构体重写之前的函数,使逻辑更清晰。

距离函数可以改为接受两个 point_t 结构体:

double distance(point_t p1, point_t p2) {
    return sqrt(pow(p2.x - p1.x, 2.0) + pow(p2.y - p1.y, 2.0));
}

打印点的函数也可以简化:

void print_point(point_t p) {
    printf("(%.1f, %.1f)\n", p.x, p.y);
}

main 函数中,代码变得更简洁:

int main() {
    point_t p1 = {1, 2};
    point_t p2 = {4, 6};
    print_point(p1);
    print_point(p2);
    printf("Distance: %.1f\n", distance(p1, p2));
    return 0;
}

通过结构体,我们将逻辑上相关的数据(x和y坐标)组合在了一起,减少了出错的可能性。


使用结构体指针

上一节我们介绍了如何定义和使用结构体。本节中我们来看看为什么以及如何使用结构体指针。

在C语言中,函数参数是“按值传递”的。这意味着当我们将一个结构体传递给函数时,函数会获得该结构体的一个完整副本。如果结构体很大,复制它会消耗时间和内存。

更常见的做法是传递结构体的指针。这样,函数操作的是原始数据,效率更高,并且函数内部对结构的修改也能影响到函数外部。

首先,我们将函数参数改为指针:

double distance(point_t *p1, point_t *p2) {
    // 需要解引用指针来访问字段
}

main 函数中调用时,需要传递地址:

distance(&p1, &p2);

箭头运算符 ->

当通过指针访问结构体字段时,不能直接使用点运算符 .。我们需要先解引用指针,这会导致代码冗长:

double dx = (*p2).x - (*p1).x; // 必须使用括号

为了简化,C语言提供了箭头运算符 ->,它结合了解引用和字段访问。

使用箭头运算符,代码变得清晰:

double distance(point_t *p1, point_t *p2) {
    return sqrt(pow(p2->x - p1->x, 2.0) + pow(p2->y - p1->y, 2.0));
}

同样,打印函数也应改为使用指针和箭头运算符:

void print_point(point_t *p) {
    printf("(%.1f, %.1f)\n", p->x, p->y);
}

这是处理结构体的首选方式。


动态创建结构体

有时我们需要动态地在堆上创建结构体。这时可以使用 malloc 函数。

我们可以创建一个专门的函数来动态创建并初始化一个点:

point_t* point_create(double x, double y) {
    point_t *p = malloc(sizeof(point_t));
    if (p != NULL) {
        p->x = x;
        p->y = y;
    }
    return p;
}

main 函数中使用:

point_t *p1 = point_create(1, 2);
point_t *p2 = point_create(4, 6);
// ... 使用 p1 和 p2 ...
free(p1); // 记得释放内存
free(p2);

这类似于其他语言中的“构造函数”。


组织代码:头文件和源文件

随着程序变大,将代码分割到多个文件中是很好的实践。通常,我们将与特定结构体相关的所有函数放在一个 .c 文件中,并创建一个对应的 .h 头文件来声明这些函数。

以下是组织代码的常见方式:

point.h (头文件)

#ifndef POINT_H // 防止头文件被重复包含
#define POINT_H

typedef struct point point_t; // 前向声明,隐藏结构体细节

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/74545d798592828a5ec93a4607a17e44_3.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/74545d798592828a5ec93a4607a17e44_5.png)

// 函数原型
point_t* point_create(double x, double y);
double point_distance(point_t *p1, point_t *p2);
void point_print(point_t *p);
double point_get_x(point_t *p);
void point_set_x(point_t *p, double x);
// ... 其他 getter 和 setter 函数

#endif

point.c (源文件)

#include "point.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>

// 实际定义结构体
struct point {
    double x;
    double y;
};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/74545d798592828a5ec93a4607a17e44_7.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/74545d798592828a5ec93a4607a17e44_8.png)

// 函数定义
point_t* point_create(double x, double y) {
    point_t *p = malloc(sizeof(point_t));
    if (p != NULL) {
        p->x = x;
        p->y = y;
    }
    return p;
}

double point_distance(point_t *p1, point_t *p2) {
    return sqrt(pow(p2->x - p1->x, 2.0) + pow(p2->y - p1->y, 2.0));
}
// ... 其他函数定义

main.c (主程序)

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

int main() {
    point_t *p1 = point_create(1, 2);
    point_t *p2 = point_create(4, 6);

    point_print(p1);
    point_print(p2);
    printf("Distance: %.1f\n", point_distance(p1, p2));

    free(p1);
    free(p2);
    return 0;
}

编译时,需要将所有源文件一起编译:

gcc point.c main.c -o main -lm

总结

本节课中我们一起学习了C语言的结构体。我们从为什么需要结构体开始,学习了如何定义结构体、使用 typedef 创建别名、初始化结构体。接着,我们探讨了通过指针传递结构体的优势,并介绍了箭头运算符 -> 的用法。最后,我们了解了如何动态创建结构体以及如何通过头文件和源文件来组织更大型的代码,实现信息隐藏和模块化。

结构体是C语言中构建复杂数据模型的基石,理解它们对编写清晰、高效的代码至关重要。

029:链表 🧩

在本节课中,我们将要学习一种新的数据结构——链表。我们将了解为什么需要链表,它与数组的区别,以及如何在C语言中实现和使用链表。


为什么需要链表?🤔

上一节我们介绍了数组,它虽然有用,但并非完美。数组存在一些缺点。例如,假设我们有一个数组来表示排队的人。如果我们想把队首的人移到队尾,就需要进行大量的赋值操作。

以下是一个移动数组元素的函数示例:

void move_array(int arr[], int length) {
    if (length <= 1) {
        return;
    }
    int first = arr[0];
    for (int i = 0; i < length - 1; i++) {
        arr[i] = arr[i + 1];
    }
    arr[length - 1] = first;
}

这个函数需要进行 n+1 次赋值(n 是数组长度)。随着数组变大,操作会越来越慢。在计算中,我们通常希望操作效率尽可能高,不随数据规模线性增长。

我们可能会注意到,在这个操作中,除了被移动的元素,其他元素的相对顺序并没有改变。这正是链表的核心思想。


什么是链表?🔗

链表的基本思想是,每个元素(称为“节点”)不仅存储数据,还存储指向下一个元素的“指针”。这样,改变顺序就只需要修改几个指针,而不需要移动大量数据。

对于之前的排队例子,链表可以这样表示:节点1知道下一个是节点2,节点2知道下一个是节点3,依此类推。要把节点1移到队尾,只需要:

  1. 让队首指针指向节点2。
  2. 让节点4的“下一个”指针指向节点1。
  3. 让节点1的“下一个”指针指向 NULL(表示队尾)。

无论链表多长,都只需要修改三个指针,这是一个固定数量的操作。


在C语言中实现链表 ⚙️

本节中我们来看看如何在C语言中具体实现链表。我们需要定义节点的结构。

定义节点结构

我们不能在结构体内部直接包含一个同类型的结构体变量,否则会导致无限递归。因此,我们必须使用指针。

typedef struct node {
    int value;           // 节点存储的数据
    struct node* next;   // 指向下一个节点的指针
} node_t;
  • value 存储节点的数据(这里用整数示例)。
  • next 是一个指针,指向下一个 node_t 类型的节点。如果这是最后一个节点,则 next 应设置为 NULL

为了管理整个链表,我们通常还会定义一个表示链表本身的结构体,它只包含一个指向第一个节点(头节点)的指针。

typedef struct list {
    node_t* head; // 指向链表第一个节点的指针
} list_t;

创建链表和节点 🛠️

上一节我们定义了结构,本节中我们来看看如何动态创建它们。我们希望链表在函数调用后依然存在,因此要使用动态内存分配。

以下是创建链表和节点的辅助函数:

// 创建一个新的链表(初始为空)
list_t* create_list() {
    list_t* list = (list_t*)malloc(sizeof(list_t));
    if (list == NULL) {
        exit(EXIT_FAILURE); // 内存分配失败
    }
    list->head = NULL; // 空链表
    return list;
}

// 创建一个新节点
node_t* create_node(int value) {
    node_t* node = (node_t*)malloc(sizeof(node_t));
    if (node == NULL) {
        exit(EXIT_FAILURE);
    }
    node->value = value;
    node->next = NULL;
    return node;
}

遍历和打印链表 📝

现在我们已经有了链表,我们需要一种方法来查看其中的内容。以下是遍历链表并打印所有值的两种方法。

迭代方法(使用循环)

void print_list(list_t* list) {
    node_t* current = list->head; // 从头部开始
    while (current != NULL) {
        printf("%d ", current->value);
        current = current->next; // 移动到下一个节点
    }
    printf("\n");
}

循环持续执行,直到 current 指针变为 NULL,表示已到达链表末尾。

递归方法

链表的结构天然适合递归。

void print_list_recursive(node_t* node) {
    if (node == NULL) { // 基础情况:空链表
        printf("\n");
        return;
    }
    printf("%d ", node->value); // 处理当前节点
    print_list_recursive(node->next); // 递归处理剩余部分
}
// 需要一个包装函数来传入链表头
void print_list_wrapper(list_t* list) {
    print_list_recursive(list->head);
}

向链表头部插入节点 📌

一个常见的操作是在链表头部添加新节点。以下是实现此功能的步骤和函数。

向链表头部插入节点的步骤:

  1. 创建新节点。
  2. 让新节点的 next 指针指向当前的头节点。
  3. 更新链表的 head 指针,使其指向新节点。
node_t* insert_front(list_t* list, int value) {
    node_t* new_node = create_node(value); // 步骤1
    new_node->next = list->head;           // 步骤2
    list->head = new_node;                 // 步骤3
    return new_node;
}

重要提示:步骤2和步骤3的顺序至关重要。如果先执行步骤3(更新list->head),再执行步骤2,新节点的next就会指向自己,造成循环引用和内存泄漏。


核心概念总结 📚

本节课中我们一起学习了链表这一重要的数据结构。

  • 链表是什么:链表是由一系列节点组成的数据结构,每个节点包含数据和指向下一个节点的指针。
  • 为何使用链表:与数组相比,链表在插入、删除元素(尤其是在头部)时更高效,通常只需要修改少量指针,而无需移动大量数据。
  • 如何实现:在C语言中,我们使用结构体定义节点,并用指针连接它们。通常使用动态内存分配(malloc)来创建节点。
  • 关键操作:我们学习了创建链表、遍历打印链表以及向链表头部插入节点。操作指针时必须格外小心顺序,否则会导致错误或内存泄漏。

链表是理解指针和动态内存管理的绝佳实践,也是后续学习更复杂数据结构的基础。请务必动手实践代码,加深理解。

030:链表实现 🧩

在本节课中,我们将深入学习链表的具体实现。我们将探讨如何正确地释放链表内存、如何从链表中移除节点,以及如何在链表中插入节点。通过编写一系列实用函数,你将掌握操作链表的核心技巧。

释放链表内存 🧹

上一节我们介绍了链表的基本概念,本节中我们来看看如何安全地释放整个链表占用的内存。直接逐个释放节点容易出错,例如忘记释放某个节点或造成“释放后使用”的错误。

以下是安全释放链表的步骤:

  1. 遍历链表,使用一个临时指针保存下一个节点的地址。
  2. 释放当前节点。
  3. 将当前指针移动到之前保存的下一个节点。
  4. 重复此过程直到链表末尾。

对应的代码实现如下:

void free_linked_list(LinkedList *list) {
    Node *current = list->head;
    Node *next;

    while (current != NULL) {
        next = current->next; // 保存下一个节点
        free(current);        // 释放当前节点
        current = next;       // 移动到下一个节点
    }
    // 可选:释放链表结构本身
    free(list);
}

使用这个函数可以确保释放所有节点,避免内存泄漏。

从链表中移除节点 🔗

现在,我们来看看如何从链表中移除一个指定的节点。核心思路是找到目标节点的前一个节点,然后修改其 next 指针,使其跳过目标节点,指向目标节点的下一个节点。

实现时需要考虑几个边界情况:

  • 链表为空。
  • 要移除的节点是链表的头节点(即没有前一个节点)。
  • 要移除的节点不在链表中。

以下是处理这些情况的移除函数:

void remove_node(LinkedList *list, Node *node_to_remove) {
    if (list->head == NULL || node_to_remove == NULL) {
        return; // 链表为空或节点无效,直接返回
    }

    Node *current = list->head;
    Node *previous = NULL;

    // 遍历寻找目标节点
    while (current != NULL && current != node_to_remove) {
        previous = current;
        current = current->next;
    }

    // 如果没找到节点,直接返回
    if (current == NULL) {
        return;
    }

    // 如果找到的是头节点
    if (previous == NULL) {
        list->head = node_to_remove->next;
    } else {
        // 如果是中间或尾部节点
        previous->next = node_to_remove->next;
    }

    // 可选:将移除节点的next置为NULL,使其完全脱离链表
    node_to_remove->next = NULL;
}

还有一种更简洁但理解起来略有难度的“间接寻址”方法,它通过操作指向指针的指针来统一处理头节点和中间节点的情况。

在链表中插入节点 ➕

接下来,我们学习如何在链表中插入节点。我们先看相对简单的“在某个节点之后插入”。

假设要在节点 after 之后插入新节点 new_node,需要两步操作:

  1. 让新节点的 next 指向 after 节点的原下一个节点。
  2. after 节点的 next 指向新节点。

注意: 这两步的顺序不能颠倒,否则会丢失原下一个节点的引用。

void insert_after(Node *after, Node *new_node) {
    if (after == NULL || new_node == NULL) {
        return;
    }
    new_node->next = after->next;
    after->next = new_node;
}

“在某个节点之前插入”则稍微复杂一些,因为它需要找到目标节点的前驱节点,其逻辑与 remove_node 函数类似。

其他实用链表函数 🛠️

掌握了核心操作后,我们可以编写一些辅助函数来更方便地使用链表。

以下是几个常用函数:

  • 判断链表是否为空: 检查头指针是否为 NULL
    bool is_empty(LinkedList *list) {
        return list->head == NULL;
    }
    
  • 计算链表长度: 遍历链表并计数。
    int length(LinkedList *list) {
        int count = 0;
        Node *current = list->head;
        while (current != NULL) {
            count++;
            current = current->next;
        }
        return count;
    }
    
  • 在链表尾部插入节点: 遍历到链表末尾,然后进行插入。
    void insert_at_end(LinkedList *list, Node *new_node) {
        if (list->head == NULL) {
            list->head = new_node;
            return;
        }
        Node *current = list->head;
        while (current->next != NULL) {
            current = current->next;
        }
        current->next = new_node;
        new_node->next = NULL;
    }
    
  • 移除并返回头节点: 将头指针指向第二个节点,并返回原头节点。
    Node* remove_front(LinkedList *list) {
        if (list->head == NULL) {
            return NULL;
        }
        Node *removed_node = list->head;
        list->head = list->head->next;
        removed_node->next = NULL; // 使其脱离链表
        return removed_node;
    }
    

总结 📚

本节课中我们一起学习了链表的详细实现。我们掌握了如何安全地释放链表内存,如何实现节点的移除与插入操作,并编写了一系列实用的链表工具函数。链表操作的核心在于谨慎地管理指针,理解节点之间的连接关系,并妥善处理各种边界情况。虽然涉及许多指针操作,但通过反复练习和理解这些基本模式,你将能够熟练地运用这一强大的数据结构。

031:链表练习与动态数组

在本节课中,我们将继续练习链表操作,并学习如何动态调整数组大小。我们将编写几个实用的链表函数,并了解C语言中用于内存管理的其他工具。

链表函数库

上一节我们介绍了链表的基本操作。本节中,我们将封装这些操作为一个函数库,让用户无需直接操作节点结构,使接口更接近Python等高级语言。

我们之前已经编写了以下函数:

  • linklist_create:初始化链表,将头指针设为 NULL
  • linklist_free:释放链表所有节点,清理内存。
  • linklist_empty / linklist_length:检查链表是否为空并获取其长度。
  • linklist_insert_front:在链表头部插入一个新值。

我们还编写了从链表头部移除节点的函数 linklist_remove_front。今天,我们将编写几个新的函数。

以下是今天将要实现的新功能:

  • linklist_contains:检查链表中是否包含某个特定值。
  • linklist_remove_first:移除链表中第一个匹配的节点。
  • linklist_remove_all:移除链表中所有匹配的节点。

检查值是否存在

首先,我们编写函数检查一个值是否存在于链表中。该函数遍历链表,若找到匹配值则立即返回 true

bool linklist_contains(LinkList *list, int value) {
    Node *current = list->head;
    while (current != NULL) {
        if (current->value == value) {
            return true;
        }
        current = current->next;
    }
    return false;
}

移除第一个匹配值

接下来,我们实现移除第一个匹配值的函数。这需要小心处理指针,以确保链表不会断裂。

bool linklist_remove_first(LinkList *list, int value) {
    Node *current = list->head;
    Node *previous = NULL;

    while (current != NULL) {
        if (current->value == value) {
            Node *next = current->next; // 保存下一个节点
            free(current); // 释放当前节点

            // 更新指针
            if (previous == NULL) {
                // 要删除的是头节点
                list->head = next;
            } else {
                // 要删除的是中间或尾部节点
                previous->next = next;
            }
            return true; // 成功移除
        }
        // 继续遍历
        previous = current;
        current = current->next;
    }
    return false; // 未找到该值
}

移除所有匹配值

最后,我们可以利用 linklist_remove_first 函数轻松实现移除所有匹配值的功能。

int linklist_remove_all(LinkList *list, int value) {
    int removed_count = 0;
    while (linklist_remove_first(list, value)) {
        removed_count++;
    }
    return removed_count;
}

链表的应用:计算总和

了解了链表的基本操作后,我们来看看一个有趣的应用:如何仅通过“从头部移除两个数,将其和放回头部”这一操作,来计算链表中所有数字的总和。

其核心思想是循环执行以下步骤,直到链表中只剩一个节点:

  1. 从链表头部移除两个节点,获取其值 ab
  2. 计算 sum = a + b
  3. sum 作为新节点插入链表头部。

最终,链表中剩下的唯一节点值就是总和。这个过程模拟了计算机逐对相加的工作方式,也体现了“栈”(后进先出)数据结构的特点。

动态数组与内存管理

尽管链表在某些场景下有用,但在大多数情况下,数组因其内存连续性和缓存友好性而速度更快。C语言提供了动态调整数组大小的功能。

手动调整数组大小

在只知道 mallocfree 的情况下,调整数组大小需要手动分配新内存、复制数据并释放旧内存。

int *array = malloc(4 * sizeof(int));
// ... 使用数组
int new_length = 7;
int *new_array = malloc(new_length * sizeof(int));
// 检查 malloc 是否成功...
// 复制旧数据
for (int i = 0; i < (4 < new_length ? 4 : new_length); i++) {
    new_array[i] = array[i];
}
free(array); // 释放旧内存
array = new_array; // 指向新内存
length = new_length;
// 初始化新增部分
for (int i = 4; i < new_length; i++) {
    array[i] = 0;
}

使用 realloc 函数

C标准库提供了 realloc 函数来简化这个过程。它可以尝试调整已有内存块的大小,并在需要时自动复制数据。

int *new_array = realloc(array, new_length * sizeof(int));
if (new_array == NULL) {
    // 处理错误:内存不足
    free(array);
    exit(EXIT_FAILURE);
}
array = new_array;
length = new_length;

realloc 的第一个参数如果是 NULL,则其行为等同于 malloc

使用 calloc 函数初始化内存

另一个有用的函数是 calloc,它在分配内存的同时将其初始化为0。这可以避免使用未初始化内存导致的随机错误。

// malloc 分配的内存内容是未定义的(可能是随机值)
int *arr1 = malloc(10 * sizeof(int));

// calloc 分配的内存内容保证为0
int *arr2 = calloc(10, sizeof(int));

封装动态数组:向量(Vector)

为了方便管理动态数组及其容量,我们可以创建一个“向量”结构体,将数据指针、当前长度和总容量打包在一起。

typedef struct {
    int *data;
    int length;
    int capacity;
} Vector;

Vector *vector_create(int initial_length) {
    Vector *vec = malloc(sizeof(Vector));
    vec->data = calloc(initial_length, sizeof(int)); // 使用 calloc 初始化
    // 错误检查...
    vec->length = initial_length;
    vec->capacity = initial_length;
    return vec;
}

void vector_resize(Vector *vec, int new_length) {
    if (new_length <= vec->capacity) {
        // 缩小:仅更新长度,保留容量以便后续扩展
        vec->length = new_length;
    } else {
        // 扩大:需要重新分配内存
        int *new_data = realloc(vec->data, new_length * sizeof(int));
        // 错误检查...
        vec->data = new_data;
        vec->capacity = new_length;
        vec->length = new_length;
    }
}

使用向量结构可以让代码更清晰,并减少因忘记关联数组长度和指针而导致的错误。

链表综合练习:重排序节点

最后,我们通过一个来自期末考试的题目来综合练习链表操作。题目要求编写函数 reorder,重新排列链表节点,使所有值为0的节点出现在链表前端,非零节点保持原有顺序出现在后端,且不能修改节点的数据域或创建新节点。

解题思路:遍历链表。当遇到值为0且不是头节点的节点时,将其移动到链表头部。我们需要仔细更新相关节点的 next 指针以及链表的 head 指针,并注意更新遍历指针以避免死循环。

void reorder(LinkList *list) {
    Node *current = list->head;
    Node *previous = NULL;

    while (current != NULL) {
        if (current->value == 0 && previous != NULL) {
            // 将 current 节点移动到头部
            Node *next = current->next; // 保存下一个节点
            previous->next = next; // 前驱节点跳过 current

            current->next = list->head; // current 指向原头部
            list->head = current; // 更新头指针为 current

            current = next; // 当前节点更新为保存的下一个节点
            continue; // 跳过本次循环的指针后移操作
        }
        // 正常遍历后移指针
        previous = current;
        current = current->next;
    }
}

总结

本节课中我们一起学习了:

  1. 实现了更复杂的链表操作函数,包括查找、移除首个匹配值和移除所有匹配值。
  2. 探索了链表的一个应用场景,模拟了加法运算的过程。
  3. 学习了C语言中动态管理数组内存的工具:realloc 用于调整大小,calloc 用于分配并初始化内存。
  4. 介绍了将动态数组封装成“向量”结构体的思想,以提高代码的模块化和安全性。
  5. 通过一个重排序链表节点的综合题目,巩固了链表指针操作的技巧。

记住,虽然链表是重要的数据结构,但在追求性能时,连续内存的数组通常是更优的选择。根据具体问题灵活选用合适的数据结构是关键。

032:排序算法入门 🧮

在本节课中,我们将学习排序算法。排序是编程中最常见的问题之一。虽然你通常不需要自己实现排序算法,但了解其工作原理非常重要,因为你会经常用到它。计算机处理数据速度很快,但在向人类(比如你自己)展示数据时,通常需要将数据按某种顺序排列,以便于阅读。今天,我们将讨论几种排序算法。算法,就是解决问题的一系列步骤,通常可以用类似C语言这样的编程语言来描述。

问题定义

我们面临的问题是:需要对一个数组进行排序。例如,给定一个包含四个元素的数组 [2, 5, 3, 1]。我们希望编写一个函数,将数组按升序排列。升序意味着最小的数字排在最前面,然后是次小的,依此类推,最大的数字排在最后。排序后,数组元素应为 [1, 2, 3, 5]

计算机的能力有限,它一次只能比较两个元素,判断哪个更大或更小。排序所需的另一个基本操作是交换数组中两个元素的位置,这个功能我们之前已经实现过。

冒泡排序 🫧

上一节我们定义了排序问题,本节中我们来看看第一种解决方案。想象一下如何向一个两岁的孩子(或者像C语言这样“笨拙”的计算机)解释排序。一个直观的想法是:从数组开头开始,比较相邻的两个元素,如果顺序不对就交换它们,然后继续比较下一对。这样扫描一遍数组后,最大的元素就会“冒泡”到数组末尾。然后重复这个过程,直到某次扫描中没有发生任何交换,此时数组就已排序完成。这种方法被称为冒泡排序

以下是冒泡排序的步骤:

  1. 比较数组中索引为0和1的元素。
  2. 如果第一个元素(索引0)大于第二个元素(索引1),则交换它们。
  3. 移动到下一对元素(索引1和2),重复比较和交换。
  4. 继续此过程,直到到达数组末尾。
  5. 完成一遍扫描后,最大的元素已位于正确位置。
  6. 重复步骤1-5,直到某次完整扫描中没有发生任何交换。

让我们用数组 [2, 5, 3, 1] 来演示:

  • 第一遍扫描:比较 (2,5) -> 顺序正确;比较 (5,3) -> 交换 -> [2, 3, 5, 1];比较 (5,1) -> 交换 -> [2, 3, 1, 5]
  • 第二遍扫描:比较 (2,3) -> 顺序正确;比较 (3,1) -> 交换 -> [2, 1, 3, 5];比较 (3,5) -> 顺序正确。
  • 第三遍扫描:比较 (2,1) -> 交换 -> [1, 2, 3, 5];后续比较均无交换。
  • 第四遍扫描:无任何交换,排序完成。

代码实现

以下是冒泡排序的C语言实现:

void bubbleSort(int array[], int length) {
    int swapped;
    do {
        swapped = 0; // 标记本轮是否发生交换
        for (int i = 0; i < length - 1; i++) {
            if (array[i] > array[i + 1]) { // 如果顺序错误
                // 交换元素
                int temp = array[i];
                array[i] = array[i + 1];
                array[i + 1] = temp;
                swapped = 1; // 标记发生了交换
            }
        }
    } while (swapped); // 如果本轮发生过交换,则继续循环
}

选择排序 🎯

我们介绍了冒泡排序,它通过不断交换相邻元素来排序。本节中我们来看看另一种思路:选择排序。其核心思想是:在未排序部分中找到最小的元素,并将其放到已排序部分的末尾(即数组开头)。你可以将其视为一种递归思维:将最小元素放到第一位后,剩下的任务就是对数组的其余部分进行排序。

以下是选择排序的步骤:

  1. 从数组的第一个位置(索引0)开始,假设该位置的元素是最小的。
  2. 遍历数组中剩余的元素(从索引1到末尾),寻找比当前最小元素更小的值。
  3. 如果找到更小的元素,则更新最小元素的索引。
  4. 遍历完成后,将找到的最小元素与数组第一个位置的元素交换。
  5. 现在,第一个元素已排序。对剩余的未排序部分(从索引1开始)重复步骤1-4。

让我们用数组 [9, 5, 18, 8, 5, 2] 来演示:

  • 第一轮:找到最小元素2(索引5),与索引0的元素9交换 -> [2, 5, 18, 8, 5, 9]
  • 第二轮:在剩余部分 [5, 18, 8, 5, 9] 中找到最小元素5(索引1),已在正确位置,无需交换。
  • 第三轮:在剩余部分 [18, 8, 5, 9] 中找到最小元素5(索引4),与索引2的元素18交换 -> [2, 5, 5, 8, 18, 9]
  • 第四轮:在剩余部分 [8, 18, 9] 中找到最小元素8(索引3),已在正确位置。
  • 第五轮:在剩余部分 [18, 9] 中找到最小元素9(索引5),与索引4的元素18交换 -> [2, 5, 5, 8, 9, 18]。排序完成。

代码实现

以下是选择排序的C语言实现:

void selectionSort(int array[], int length) {
    for (int i = 0; i < length - 1; i++) {
        int minIndex = i; // 假设当前位置元素最小
        // 在剩余部分中寻找更小的元素
        for (int j = i + 1; j < length; j++) {
            if (array[j] < array[minIndex]) {
                minIndex = j; // 更新最小元素索引
            }
        }
        // 如果找到的最小元素不在当前位置,则交换
        if (minIndex != i) {
            int temp = array[i];
            array[i] = array[minIndex];
            array[minIndex] = temp;
        }
    }
}

插入排序 📥

我们了解了选择排序,它通过选择最小元素来构建有序序列。本节中我们来看看最后一种算法:插入排序。想象你手中有一副已排序的扑克牌,现在拿到一张新牌。你会怎么做?你会从右向左扫描手中的牌,为新牌找到正确的插入位置,然后将其插入。插入排序正是基于这种思想:将数组分为已排序和未排序两部分,逐个将未排序部分的元素插入到已排序部分的正确位置。

以下是插入排序的步骤:

  1. 假设数组的第一个元素(索引0)是已排序部分。
  2. 从第二个元素(索引1)开始,将其视为待插入的“新牌”。
  3. 将这张“新牌”与已排序部分的元素从右向左依次比较。
  4. 如果“新牌”比当前比较的已排序元素小,则将那个已排序元素向右移动一位,为新元素腾出空间。
  5. 继续向左比较,直到找到一个不大于“新牌”的元素,或者到达数组开头。
  6. 将“新牌”插入到腾出的空位。
  7. 对未排序部分中的每个元素重复步骤2-6。

让我们用数组 [9, 2, 6, 5, 1, 7] 来演示:

  • 初始[9] 已排序。
  • 插入2:比较9,9>2,移动9 -> [9, 9, 6, 5, 1, 7],到达开头,插入2 -> [2, 9, 6, 5, 1, 7]
  • 插入6:比较9,9>6,移动9 -> [2, 9, 9, 5, 1, 7];比较2,2<6,停止,插入6 -> [2, 6, 9, 5, 1, 7]
  • 插入5:比较9,9>5,移动9 -> [2, 6, 9, 9, 1, 7];比较6,6>5,移动6 -> [2, 6, 6, 9, 1, 7];比较2,2<5,停止,插入5 -> [2, 5, 6, 9, 1, 7]
  • 插入1:类似地,将9,6,5,2依次右移,最后在开头插入1 -> [1, 2, 5, 6, 9, 7]
  • 插入7:比较9,9>7,移动9 -> [1, 2, 5, 6, 9, 9];比较6,6<7,停止,插入7 -> [1, 2, 5, 6, 7, 9]。排序完成。

代码实现

以下是插入排序的C语言实现:

void insertionSort(int array[], int length) {
    for (int i = 1; i < length; i++) { // 从第二个元素开始
        int element = array[i]; // 待插入的“新牌”
        int j = i; // j用于在已排序部分中从右向左扫描
        // 寻找插入位置,并向右移动元素
        while (j > 0 && array[j - 1] > element) {
            array[j] = array[j - 1]; // 将大于element的元素右移
            j--;
        }
        array[j] = element; // 插入元素到正确位置
    }
}

算法效率简析 ⚡

我们实现了三种排序算法。从计算机科学的角度,我们常用大O表示法来粗略衡量算法的效率,它描述了算法所需操作次数与输入数据规模(通常用 n 表示)之间的关系。对于大规模问题,常数因子通常被忽略,我们关注的是增长趋势。

今天我们学习的三种算法——冒泡排序、选择排序和插入排序——在最坏和平均情况下的时间复杂度都是 O(n²)。这是因为它们都包含嵌套循环:外层循环运行大约 n 次,内层循环在每次外层循环中也运行大约 n 次,所以总操作次数与 成正比。

n 较小时(例如本课程中的问题规模),O(n²) 的性能差异并不明显。然而,随着 n 增大(例如处理数百万数据时),O(n²) 算法的耗时将急剧增加,远不如 O(n log n) 或 O(n) 的算法高效。因此,在处理大规模数据时,我们需要更高效的算法。

总结 📝

本节课中我们一起学习了三种基础的排序算法:

  1. 冒泡排序:通过重复比较和交换相邻元素来排序,实现简单但效率较低。
  2. 选择排序:通过在未排序部分中反复选择最小元素来构建有序序列。
  3. 插入排序:通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

这三种算法的时间复杂度均为 O(n²),适用于小规模数据或作为学习排序思想的入门。在下一讲中,我们将探索效率更高的排序算法。

033:进阶排序

在本节课中,我们将学习几种进阶的排序算法。首先,我们会深入探讨一个高效且实用的算法——快速排序。然后,为了增加趣味性,我们还会了解几种理论上存在但绝不推荐在实际中使用的“玩笑”排序算法。最后,我们将学习如何使用C标准库中内置的快速排序函数 qsort

🚀 快速排序算法

上一节我们介绍了时间复杂度为 O(n²) 的排序算法。本节中,我们来看看一个更高效的算法——快速排序,其平均时间复杂度为 O(n log n)。

快速排序的核心思想是 分治。其工作原理如下:

  1. 选择基准:从数组中选择一个元素作为“基准”。
  2. 分区:重新排列数组,使得所有小于基准的元素都移到基准的左边,所有大于基准的元素都移到基准的右边。分区操作完成后,基准元素就处于其最终的正确位置。
  3. 递归:递归地将上述过程应用于基准左侧和右侧的两个子数组。

分区过程详解

让我们通过一个例子来理解分区操作。假设我们有以下数组:
[10, 14, 8, 13, 2, 3, 6, 9]

我们选择最后一个元素 9 作为基准。目标是让所有小于9的数到左边,大于9的数到右边。

我们使用一个“绿色箭头”来标记基准最终应该放置的位置,初始时指向数组开头(索引0)。

以下是分区的逐步过程:

  • 步骤1:比较 10910 > 9,不交换,绿色箭头不动。
  • 步骤2:比较 14914 > 9,不交换,绿色箭头不动。
  • 步骤3:比较 898 < 9,将 8 与绿色箭头当前位置(索引0)的元素 10 交换。交换后数组为 [8, 14, 10, 13, 2, 3, 6, 9]。然后将绿色箭头右移一位(指向索引1)。
  • 步骤4:比较 13913 > 9,不交换。
  • 步骤5:比较 292 < 9,将 2 与绿色箭头当前位置(索引1)的元素 14 交换。数组变为 [8, 2, 10, 13, 14, 3, 6, 9]。绿色箭头右移(指向索引2)。
  • 步骤6:比较 393 < 9,将 3 与绿色箭头当前位置(索引2)的元素 10 交换。数组变为 [8, 2, 3, 13, 14, 10, 6, 9]。绿色箭头右移(指向索引3)。
  • 步骤7:比较 696 < 9,将 6 与绿色箭头当前位置(索引3)的元素 13 交换。数组变为 [8, 2, 3, 6, 14, 10, 13, 9]。绿色箭头右移(指向索引4)。
  • 步骤8:所有元素(除基准外)已比较完毕。现在,将基准 9 与绿色箭头当前位置(索引4)的元素 14 交换。最终数组为 [8, 2, 3, 6, 9, 10, 13, 14]

此时,分区完成。基准 9 位于索引4,其左侧所有元素 [8, 2, 3, 6] 均小于9,右侧所有元素 [10, 13, 14] 均大于9。

递归排序

分区之后,问题被分解为两个更小的子问题:排序左子数组 [8, 2, 3, 6] 和右子数组 [10, 13, 14]。对这两个子数组递归地应用相同的快速排序过程,直到子数组的长度为0或1(递归的基本情况),整个数组即告排序完成。

快速排序的函数框架如下:

void quicksort(int arr[], int low, int high) {
    if (low < high) {
        // 对数组进行分区,并获取基准的最终位置
        int pivot_index = partition(arr, low, high);
        // 递归排序左半部分
        quicksort(arr, low, pivot_index - 1);
        // 递归排序右半部分
        quicksort(arr, pivot_index + 1, high);
    }
}

其中 partition 函数实现了上述的分区逻辑。

🤪 玩笑排序算法

在了解了高效的快速排序后,我们来看看几个“著名”的低效排序算法。请注意,这些算法仅用于教学娱乐,绝对不要在任何实际场景或考试中使用。

以下是几种玩笑排序算法:

  • Bogo排序:算法不断随机打乱整个数组,然后检查数组是否已有序。如果未有序,则重复此过程。其平均时间复杂度是 O(n!),效率极低。
  • 奇迹排序:算法检查数组是否有序。如果未有序,则什么也不做,只是再次检查,寄希望于一个“奇迹”发生(比如宇宙射线翻转了内存位)。其运行时间可能是无限的。
  • Bozo排序:比Bogo排序“聪明”一点。它随机选择数组中的两个元素进行交换,然后检查数组是否有序。如果未有序,则重复此过程。

Bozo排序示例代码

以下是一个Bozo排序的简单实现,用于展示其思想:

#include <stdbool.h>
#include <stdlib.h>
#include <time.h>

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/8cb3def1e3cf85963e7df78762b35dbf_2.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/8cb3def1e3cf85963e7df78762b35dbf_3.png)

// 检查数组是否已排序
bool in_order(int arr[], int len) {
    for (int i = 1; i < len; i++) {
        if (arr[i-1] > arr[i]) {
            return false;
        }
    }
    return true;
}

// Bozo排序
void bozo_sort(int arr[], int len) {
    srand(time(NULL)); // 设置随机种子
    while (!in_order(arr, len)) {
        int i = rand() % len; // 随机选择第一个索引
        int j = rand() % len; // 随机选择第二个索引
        // 确保两个索引不同
        if (j >= i) j++;
        if (j == len) j = 0; // 处理边界情况
        // 交换两个元素
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }
}

尽管对于极小规模的数组(例如少于12个元素),Bozo排序可能偶然快速完成,但随着数组增大,其完成时间将变得不可预测且极其漫长。

📚 使用C标准库的qsort

理解了快速排序的原理后,在实际编程中,我们通常直接使用C标准库 <stdlib.h> 中提供的 qsort 函数,它实现了快速排序算法。

qsort 函数的原型如下:

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

参数说明:

  • base: 指向要排序的数组的起始地址的指针。
  • nmemb: 数组中元素的数量。
  • size: 每个元素的大小(以字节为单位)。
  • compar: 指向比较函数的指针。该函数决定了排序的顺序。

比较函数

比较函数 compar 接收两个指向待比较元素的 const void * 类型指针。它需要返回一个整数:

  • 如果第一个参数“小于”第二个,返回 负整数
  • 如果两个参数“相等”,返回 0
  • 如果第一个参数“大于”第二个,返回 正整数

关键在于:在比较函数内部,你需要先将 void * 指针转换(强制类型转换)为实际数据类型的指针,然后再进行比较。

示例1:排序整数数组

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

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/8cb3def1e3cf85963e7df78762b35dbf_5.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-aps105-c-prog/img/8cb3def1e3cf85963e7df78762b35dbf_7.png)

// 比较函数,用于升序排序整数
int compare_ints(const void *a, const void *b) {
    // 将void指针转换为int指针,再解引用获取值
    int x = *(const int *)a;
    int y = *(const int *)b;
    if (x < y) return -1;
    if (x > y) return 1;
    return 0;
    // 更简洁的写法:return (*(int*)a - *(int*)b);
}

int main() {
    int arr[] = {10, 14, 8, 13, 2, 3, 6, 9};
    int n = sizeof(arr) / sizeof(arr[0]);

    qsort(arr, n, sizeof(int), compare_ints);

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

要改为降序排序,只需修改比较函数,例如 return (*(int*)b - *(int*)a);

示例2:排序字符串数组(命令行参数)

排序字符串数组时,需要特别注意指针的层级。argv 是一个 char* [] 类型的数组,即元素是 char*(字符串指针)。

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

// 比较函数,用于排序字符串(char* 数组)
int compare_strings(const void *a, const void *b) {
    // a和b是指向数组元素的指针,而数组元素是char*。
    // 所以需要先将void*转换为char**,再解引用得到char*(即字符串)。
    const char **str_a = (const char **)a;
    const char **str_b = (const char **)b;
    return strcmp(*str_a, *str_b);
}

int main(int argc, char *argv[]) {
    // 跳过第一个参数(程序名),对剩余参数排序
    qsort(argv + 1, argc - 1, sizeof(char *), compare_strings);

    for (int i = 1; i < argc; i++) {
        printf("%s\n", argv[i]);
    }
    return 0;
}

关键点compar 函数接收的是指向数组元素的指针。我们的数组元素是 char*,所以传入 compar 的是 char** 类型。因此,在函数内部需要先转换为 char**,再解引用得到 char* 才能用 strcmp 比较。


本节课中我们一起学习了快速排序算法的分治思想,见识了几种低效的玩笑排序算法作为反面教材,并重点掌握了如何使用C标准库中的 qsort 函数来对各种类型的数据(如整数、字符串)进行排序。虽然 qsort 的使用涉及指针和函数指针,稍显复杂,但它是C语言中高效、通用的排序工具,理解其用法对深入掌握C语言编程至关重要。

034:查找算法

在本节课中,我们将学习如何在数组中查找特定元素。我们将从最简单的线性查找开始,然后探讨一种更高效的方法——二分查找,特别是当数组已经排序时。最后,我们会讨论如何修改二分查找来找到重复元素的第一个或最后一个出现位置。

线性查找

上一节我们介绍了查找的基本概念。本节中我们来看看最简单的查找方法:线性查找。线性查找的原理是遍历数组中的每一个元素,直到找到目标值或遍历完整个数组。

以下是线性查找的实现步骤:

  1. 从数组的第一个元素开始。
  2. 将当前元素与目标值进行比较。
  3. 如果相等,则返回当前元素的索引。
  4. 如果不相等,则移动到下一个元素。
  5. 如果遍历完所有元素仍未找到,则返回 -1。

其核心代码可以表示为:

int linearSearch(int array[], int length, int value) {
    for (int i = 0; i < length; i++) {
        if (array[i] == value) {
            return i;
        }
    }
    return -1;
}

这种方法简单直接,但效率不高,在最坏情况下需要检查数组中的每一个元素。

二分查找

如果数组是有序的,我们可以使用一种更聪明的查找方法:二分查找。它的核心思想是每次都将搜索范围缩小一半。

想象一下在一本按字母顺序排列的电话簿中找名字。你不会从第一页开始一页一页地翻,而是会先翻到中间,根据名字是在这一页之前还是之后,决定接下来翻查前半部分还是后半部分。二分查找正是基于这个原理。

以下是二分查找的算法逻辑:

  1. 设定搜索范围的起始索引 low 为 0,结束索引 high 为数组长度减 1。
  2. 只要 low 小于等于 high,就继续搜索。
  3. 计算中间索引 mid,公式为 mid = (low + high) / 2
  4. 比较中间元素 array[mid] 与目标值 value
    • 如果 array[mid] == value,查找成功,返回 mid
    • 如果 array[mid] < value,说明目标值在右半部分,将 low 更新为 mid + 1
    • 如果 array[mid] > value,说明目标值在左半部分,将 high 更新为 mid - 1
  5. 如果循环结束仍未找到,则返回 -1。

以下是迭代实现的代码:

int binarySearch(int array[], int length, int value) {
    int low = 0;
    int high = length - 1;

    while (low <= high) {
        int mid = (low + high) / 2;

        if (array[mid] == value) {
            return mid;
        } else if (array[mid] < value) {
            low = mid + 1;
        } else { // array[mid] > value
            high = mid - 1;
        }
    }
    return -1;
}

二分查找的效率远高于线性查找,其时间复杂度为 O(log n)

递归实现二分查找

二分查找的逻辑天然适合用递归来描述。我们可以将“在某个范围内查找”看作一个子问题。

递归实现的思路如下:

  1. 基准情况1:如果搜索范围无效(low > high),返回 -1。
  2. 基准情况2:如果中间元素等于目标值,返回中间索引。
  3. 递归情况:根据中间元素与目标值的大小关系,在左半部分或右半部分递归调用查找函数。

以下是递归实现的代码:

int binarySearchRecursiveHelper(int array[], int low, int high, int value) {
    if (low > high) {
        return -1; // 基准情况1:范围无效
    }

    int mid = (low + high) / 2;

    if (array[mid] == value) {
        return mid; // 基准情况2:找到目标
    } else if (array[mid] < value) {
        // 递归情况:搜索右半部分
        return binarySearchRecursiveHelper(array, mid + 1, high, value);
    } else { // array[mid] > value
        // 递归情况:搜索左半部分
        return binarySearchRecursiveHelper(array, low, mid - 1, value);
    }
}

// 包装函数,提供简洁的接口
int binarySearchRecursive(int array[], int length, int value) {
    return binarySearchRecursiveHelper(array, 0, length - 1, value);
}

查找第一个和最后一个匹配项

标准的二分查找在找到目标值后就立即返回,如果数组中有重复元素,它返回的索引可能是其中任意一个。有时我们需要找到第一个或最后一个出现的位置。

我们可以修改二分查找算法来实现这一点,而无需退化成线性查找。

查找第一个匹配项的思路是:即使找到了一个匹配项(array[mid] == value),我们也不立即返回,而是继续在左半部分high = mid - 1)搜索,看看前面是否还有相同的值。我们需要记录下当前找到的这个匹配索引。当搜索范围无效时,最后记录的索引就是第一个匹配项的位置。

以下是查找第一个匹配项的修改版二分查找:

int binarySearchFirst(int array[], int length, int value) {
    int low = 0;
    int high = length - 1;
    int result = -1; // 用于记录找到的索引

    while (low <= high) {
        int mid = (low + high) / 2;

        if (array[mid] == value) {
            result = mid; // 记录找到的位置
            high = mid - 1; // 继续在左侧寻找更早的匹配项
        } else if (array[mid] < value) {
            low = mid + 1;
        } else { // array[mid] > value
            high = mid - 1;
        }
    }
    return result; // 返回第一个匹配项的索引,未找到则返回-1
}

查找最后一个匹配项的思路与之对称:找到匹配项后,继续在右半部分low = mid + 1)搜索。

结合这两个函数,我们可以高效地计算某个值在数组中出现的次数:count = lastIndex - firstIndex + 1

总结

本节课中我们一起学习了数组的查找算法。

  • 对于未排序的数组,线性查找是唯一的选择,它逐个检查元素,时间复杂度为 O(n)。
  • 对于已排序的数组,二分查找是更优的选择,它通过不断将搜索范围减半来快速定位元素,时间复杂度为 O(log n)。
  • 我们学习了二分查找的迭代递归两种实现方式。
  • 我们还探讨了如何修改二分查找来找到重复元素的第一个最后一个出现位置,从而能高效地计算元素出现的次数,而无需进行线性扫描。

掌握这些查找算法是处理数据的基础技能,它们在不同的场景下各有优势。

035:文件操作与成绩计算器

在本节课中,我们将学习如何使用C语言进行文件操作,并综合运用课程中学到的知识,开发一个实用的成绩计算器程序。我们将通过读取文件来获取输入数据,从而避免每次运行程序时都需要手动输入大量信息。


概述:文件操作基础

上一节我们介绍了从终端获取用户输入的方法。本节中,我们来看看如何从文件中读取数据,这对于处理大量输入非常有用。

在C语言中,操作文件主要涉及三个步骤:打开文件、读取/写入数据、关闭文件。文件操作不会在考试中涉及,但除了打开和关闭文件的特定函数外,其核心概念与我们之前学习的字符串处理等内容紧密相关。

打开与关闭文件

要使用一个文件,首先需要打开它。我们使用 fopen 函数,它需要两个参数:

  • 第一个参数:一个表示文件路径的字符串(例如 "grades.txt")。
  • 第二个参数:一个表示打开模式的字符串。例如,"r" 表示以只读模式打开文件。

fopen 函数会返回一个指向 FILE 结构体的指针。如果打开失败(例如文件不存在),它会返回 NULL

使用完文件后,必须使用 fclose 函数将其关闭,并传入从 fopen 获得的文件指针。这类似于使用 malloc 分配内存后需要 free 释放。

以下是打开和关闭文件的基本代码框架:

FILE *file_pointer = fopen("filename.txt", "r");
if (file_pointer == NULL) {
    // 处理错误,例如打印错误信息并退出
    exit(1);
}
// ... 在这里对文件进行读写操作 ...
fclose(file_pointer);

从文件中读取行数据

要从文件中逐行读取数据,我们可以使用之前学过的 getline 函数。之前我们将其与 stdin(标准输入,即终端)配合使用,现在我们可以将文件指针传递给它。

getline 函数会动态分配内存来存储读取的行,并返回该行的字符数(不包括结尾的空字符 \0)。当读取到文件末尾时,它会返回 -1

以下是从文件中读取所有行的典型循环结构:

char *line = NULL;
size_t size = 0;
ssize_t length;

while ((length = getline(&line, &size, file_pointer)) != -1) {
    // 处理读取到的一行数据,它存储在 `line` 变量中
    printf("Read line: %s", line);
}
free(line); // 释放 getline 分配的内存
fclose(file_pointer); // 关闭文件

项目实践:构建成绩计算器

掌握了文件读取的基础后,我们将综合运用结构体、数组、循环和函数等知识,开发一个成绩计算器。这个程序将从一个名为 grades.txt 的文本文件中读取成绩数据,然后计算当前课程成绩、可能的最低和最高成绩,并分析为了达到目标成绩需要在期末考试中取得多少分。

第一步:定义数据结构

首先,我们定义一个结构体来统一管理每项考核(如实验、期中考试)的信息。这比使用多个独立的数组更清晰、更易于维护。

typedef struct {
    double weight;   // 该项考核的权重(百分比)
    double grade;    // 该项考核获得的分数
    double total;    // 该项考核的总分
} assessment_t;

第二步:从文件读取成绩数据

我们将编写一个函数 read_grades 来读取文件。假设 grades.txt 的前9行是9次实验的成绩(满分10分),最后一行是期中考试成绩(满分75分)。

以下是该函数的核心逻辑:

  1. 使用 fopen 打开文件。
  2. 使用 getline 循环读取每一行。
  3. 使用 atof 函数将读取的字符串转换为浮点数 double
  4. 根据行号,将成绩赋值给对应的 assessment_t 结构体。
  5. 读取完成后,关闭文件并释放 getline 使用的缓冲区。

第三步:初始化考核权重与总分

main 函数中,我们需要创建并初始化所有考核项目的数组。对于实验,根据课程大纲,前三次实验权重为2%,中间三次为3%,最后三次为5%,满分均为10分。期中考试权重为30%,满分为75分。

assessment_t labs[LABS_LENGTH];
assessment_t midterm;

// 初始化实验的权重和总分
for (int i = 0; i < LABS_LENGTH; i++) {
    if (i < 3) labs[i].weight = 2.0;
    else if (i < 6) labs[i].weight = 3.0;
    else labs[i].weight = 5.0;
    labs[i].total = 10.0;
}
// 初始化期中考试
midterm.weight = 30.0;
midterm.total = 75.0;

第四步:计算当前课程成绩

当前课程成绩是已完成的各项考核的加权和。计算公式为:
课程成绩贡献 = (获得的分数 / 总分) * 权重

我们需要遍历所有考核项进行累加。注意,如果某项成绩为负数(例如用-1表示申请了延期),则其权重应转移到期末考试上,本次不计入当前成绩。

double course_grade = 0.0;
double final_weight = 40.0; // 期末考试基础权重

for (int i = 0; i < LABS_LENGTH; i++) {
    if (labs[i].grade < 0) {
        // 成绩为负,权重转移至期末
        final_weight += labs[i].weight;
    } else {
        // 正常计算该项贡献
        course_grade += (labs[i].grade / labs[i].total) * labs[i].weight;
    }
}
// 加上期中考试贡献
course_grade += (midterm.grade / midterm.total) * midterm.weight;

第五步:计算可能的最低与最高成绩

  • 最低可能成绩:假设期末考试得0分,那么最终成绩就是当前的 course_grade
  • 最高可能成绩:假设期末考试得满分(100%),那么最终成绩是 course_grade + final_weight

第六步:计算达成目标成绩所需的期末分数

用户可能想了解为了达到某个总评目标(例如75分或85分),需要在期末考试中获得多少分。

计算步骤如下:

  1. 计算需要从期末考试中获得的加权贡献:needed_from_final = target_grade - course_grade
  2. 将所需的加权贡献转换为期末考试卷面百分比分数:required_final_percent = (needed_from_final / final_weight) * 100

然后,我们需要处理一些边界情况:

  • 如果 needed_from_final <= 0,说明已经达到或超过目标,可以输出“无需参加考试”或类似信息。
  • 如果 needed_from_final > final_weight,说明即使期末考试得满分也无法达到目标,应输出“无法达成”。
  • 否则,输出计算得到的 required_final_percent

总结

本节课中我们一起学习了C语言的文件操作,并完成了一个综合性的实践项目——成绩计算器。

我们回顾并应用了以下核心知识:

  • 文件操作:使用 fopenfclose 打开和关闭文件。
  • 数据读取:使用 getline 从文件中逐行读取数据,并使用 atof 进行类型转换。
  • 结构体应用:使用 typedef struct 创建 assessment_t 类型来有效组织和管理相关数据。
  • 流程控制与计算:通过循环遍历数组,使用条件判断处理特殊情况(如成绩延期),并实现加权平均等数学计算。

通过这个项目,你将看到如何将分散的知识点组合起来,解决一个实际的、有用的编程问题。文件操作使得程序能够方便地处理外部数据,这是大多数真实软件的基础。虽然本课程不考核文件操作的具体API,但理解其背后的思想(输入/输出、资源管理)和熟练运用其他已学概念(字符串、内存、结构体)至关重要。

036:课程回顾 🎓

在本节课中,我们将对C语言编程课程的全部内容进行一次全面的回顾。我们将梳理从基础语法到高级概念的所有核心知识点,帮助你为期末考试做好准备。


概述 📋

本课程从零开始,带领我们学习了如何使用C语言编写程序。我们首先了解了如何编写源代码,然后使用C编译器将其转换为可执行的机器码。接下来,我们将按学习顺序,回顾所有关键概念。

1. 变量与基本类型

我们首先学习了如何声明和初始化变量。声明一个变量的语法是:指定类型,给出变量名,并可选地赋予一个初始值。

代码示例:

int score = 100; // 声明并初始化一个整型变量

以下是本课程中需要掌握的基本数据类型:

  • int: 用于表示整数(无小数位)。
  • double: 用于表示带小数点的数字。
  • char: 用于表示单个字符(如键盘上的字母)。
  • bool: 用于表示布尔值(truefalse),使用前需包含 <stdbool.h> 头文件。
  • void: 表示“无类型”,用于函数不返回值或无参数的情况。

关于字符,我们还需要了解转义字符,例如换行符 \n 和制表符 \t

2. 输入与输出

为了让程序能与用户交互,我们学习了输入输出函数。其中,printfscanf 是最重要的两个。

  • printf: 用于向终端输出信息。我们可以使用格式说明符来输出变量的值。
  • scanf: 用于从终端读取输入。同样使用格式说明符,但需要注意,我们必须传递变量的地址scanf,它才能修改变量的值。

重要格式说明符:

  • %d: 用于 int
  • %lf: 用于 double
  • %c: 用于 char
  • %s: 用于字符串。
  • %p: 用于指针(考试中较少使用)。

3. 运算符

C语言提供了丰富的运算符来操作数据。

需要掌握的运算符包括:

  • 自增/自减++--(前缀与后缀形式)。
  • 取地址&,用于获取变量的内存地址。
  • sizeof: 用于获取数据类型或变量所占的字节大小。
  • 算术运算符+, -, *, /, %(取模,注意对负数的特殊规则)。
  • 赋值运算符= 用于赋值,以及 +=, -=, *=, /=, %= 等复合赋值运算符。

数学函数(如 rand())可能出现在考试中,但其他复杂数学函数通常不需要记忆。

4. 控制流:条件与循环

为了让程序能够做出决策和重复执行任务,我们引入了控制流语句。

上一节我们介绍了基本的运算,本节中我们来看看如何控制程序的执行路径。

条件语句 (if, else if, else)

if 语句允许我们根据条件表达式的真假来决定是否执行某段代码。我们可以使用 else ifelse 来构建多分支判断。

用于比较的运算符(结果为布尔值):

  • == (等于)
  • != (不等于)
  • >, <, >=, <= (大于,小于,大于等于,小于等于)

逻辑运算符(操作布尔值):

  • ! (逻辑非)
  • && (逻辑与)
  • || (逻辑或)

循环语句

循环使我们能够重复执行代码块。

  • while 循环: 先检查条件,条件为真则执行循环体。
  • do...while 循环: 先执行一次循环体,再检查条件。保证循环体至少执行一次。
  • for 循环: 常用于有明确次数的循环。结构为 for (初始化; 条件; 增量) { 语句 }。初始化语句在循环开始前执行一次;每次迭代前检查条件;每次迭代后执行增量表达式。

5. 函数

为了组织代码和实现复用,我们学习了如何创建函数。

函数定义:

返回类型 函数名(参数列表) {
    // 函数体
    return 返回值; // 如果返回类型不是void
}

函数原型: 如果函数定义在 main 函数之后,需要在文件顶部或 main 之前声明函数原型,以便编译器识别。

返回类型 函数名(参数列表); // 函数原型

重要概念:

  • 作用域: 变量只在定义它的花括号 {} 内有效。
  • 按值传递: C语言中,函数参数传递的是值的副本。因此,在函数内部修改参数不会影响外部的原始变量。
  • 指针参数: 若想通过函数修改外部变量,需要传递该变量的指针(地址)。

6. 指针

指针是C语言的核心概念,它存储的是内存地址。

  • 声明指针类型 *指针变量名;
  • 取地址运算符&变量 获取变量的地址。
  • 解引用运算符*指针变量 访问指针所指向地址的值。

指针与类型:

  • 对变量使用 &,其类型变为“指向该类型的指针”。
  • 对指针使用 *,其类型变回“指向的类型”。

理解不同数据类型的大小(如 int 通常为4字节,double 为8字节)有助于理解指针运算。

7. 数组

数组是一块连续的内存空间,用于存储多个相同类型的值。

声明与初始化:

int grades[5]; // 声明一个大小为5的整型数组
int values[] = {1, 2, 3}; // 声明并初始化,编译器自动推断大小为3

数组名在大多数情况下会“退化”为指向其第一个元素的指针。因此,grades[i] 等价于 *(grades + i)

指针算术:

  • 指针 + 整数 = 指针(指向更后面的元素)。
  • 指针 - 指针 = 整数(两个指针之间相隔的元素个数)。

8. 动态内存分配

当我们需要在运行时决定内存大小,或者希望内存的生命周期超越函数时,需要使用动态内存分配。

  • malloc: 申请指定字节数的内存,返回指向该内存块的指针。如果失败则返回 NULL
    int *arr = (int*)malloc(10 * sizeof(int)); // 分配可存放10个int的空间
    
  • free: 释放之前由 malloc 分配的内存。必须只能释放一次,释放后不应再访问该内存。
    free(arr);
    

9. 二维数组

二维数组可以看作是“数组的数组”。

声明与初始化:

int matrix[2][3] = { {1, 2, 3}, {4, 5, 6} };

在函数中使用二维数组: 作为参数时,必须指定列数(第二维的大小)。

void printMatrix(int table[][3], int numRows);

动态分配二维数组通常使用“指针的指针”(int**)来实现,即先分配一个指针数组,再为每个指针分配一个一维数组。

10. 字符串

在C语言中,字符串是以空字符 \0 结尾的字符数组。

重要注意事项:

  • 使用 %s 格式说明符进行输入输出。
  • 绝对避免使用 scanf(“%s”, str) 读取字符串,因为它无法防止缓冲区溢出。应使用 fgetsgets_s
  • 常用字符串函数(需包含 <string.h>):
    • strlen: 获取字符串长度(不包括 \0)。
    • strcpy: 复制字符串。
    • strcmp: 比较字符串。
    • strcat: 连接字符串。

11. 结构体

结构体允许我们将多个不同类型的变量组合成一个新的复合数据类型。

定义与使用:

typedef struct {
    double x;
    double y;
} Point;

Point p1 = {1.0, 2.0}; // 初始化
p1.x = 3.0; // 使用 . 运算符访问成员

Point *ptr = &p1;
ptr->y = 4.0; // 使用 -> 运算符通过指针访问成员

12. 链表

链表是一种动态数据结构,由一系列节点组成,每个节点包含数据和指向下一个节点的指针。

核心操作:

  • 创建节点。
  • 在链表头部或尾部插入节点。
  • 遍历链表。
  • 搜索并删除节点。

理解链表的关键在于绘制节点和指针的示意图,并注意更新头指针。

13. 排序与搜索算法

最后,我们探讨了如何组织数据。

排序算法(需掌握):

  • 冒泡排序: 重复遍历列表,比较相邻元素并交换。
  • 选择排序: 每次从未排序部分选择最小(或最大)元素放到已排序部分的末尾。

搜索算法:

  • 线性搜索: 适用于未排序数组,逐个元素检查。
  • 二分搜索: 仅适用于已排序数组。每次与中间元素比较,将搜索范围缩小一半,效率远高于线性搜索。

总结 🏁

本节课中我们一起回顾了整个C语言编程课程的知识体系。我们从最基础的变量和输入输出开始,逐步深入到函数、指针、内存管理、数据结构(数组、字符串、结构体、链表)以及基本算法(排序和搜索)。掌握这些内容是顺利通过期末考试的基础。请务必通过练习过去的考题来巩固理解。

祝大家在期末考试中取得优异成绩!我们更高年级的课程中再见!✨

037:期末复习课

概述

在本节期末复习课中,我们将回顾C语言课程的核心概念,包括链表操作、递归、动态内存管理、字符串处理以及排序算法。课程将通过分析历年考题来巩固这些知识点,确保大家为期末考试做好充分准备。


链表排序:冒泡排序算法

上一节我们介绍了链表的基本操作,本节中我们来看看如何对链表进行排序。冒泡排序算法可以应用于链表,其核心思想是反复比较并交换相邻元素,直到链表完全有序。

以下是实现链表冒泡排序的关键步骤:

  1. 使用一个布尔变量 swapped 来跟踪在一轮遍历中是否发生了交换。
  2. 使用 do-while 循环,只要上一轮发生了交换,就继续下一轮遍历。
  3. 在每一轮遍历中,使用指针 current 迭代链表。
  4. 对于每一对相邻节点(currentcurrent->next),如果它们的值顺序错误(例如 current->data > current->next->data),则交换这两个节点的数据值。
  5. 注意处理链表末尾(current->nextNULL)的情况。

代码示例:链表冒泡排序

void bubble_sort_linked_list(Node** list) {
    int swapped;
    Node* current;
    if (*list == NULL) {
        return;
    }
    do {
        swapped = 0;
        current = *list;
        while (current->next != NULL) {
            if (current->data > current->next->data) {
                // 交换相邻节点的数据
                int temp = current->data;
                current->data = current->next->data;
                current->next->data = temp;
                swapped = 1;
            }
            current = current->next;
        }
    } while (swapped);
}

动态内存管理:字符串数组的常见错误

理解了链表操作后,我们来看看动态内存管理中的一个典型问题。在处理结构体数组(特别是包含指针的成员)时,初学者常犯的错误是让多个指针指向同一块内存。

问题场景:
一个 Student 结构体包含一个 char* last_name 指针。在 get_names 函数中,如果直接将用户输入的字符串地址(一个局部字符数组)赋给每个学生的 last_name,那么所有学生的 last_name 指针都将指向同一个数组。后续的输入会覆盖这个数组,导致所有学生共享最后一个输入的名字。

解决方案:
为每个学生的 last_name 动态分配独立的内存空间。

修正后的代码逻辑:

void get_names(Student students[], int num_students) {
    for (int i = 0; i < num_students; i++) {
        // 为每个学生的姓氏动态分配内存
        students[i].last_name = (char*)malloc(MAX_NAME_LENGTH * sizeof(char));
        if (students[i].last_name == NULL) {
            // 处理内存分配失败
            exit(1);
        }
        printf("Enter last name: ");
        // 直接将输入读入新分配的内存
        scanf("%s", students[i].last_name);
    }
}
// 注意:在主函数使用完这些名字后,需要遍历数组并 free 每个 students[i].last_name

递归应用:反向打印数字序列

动态内存管理让我们关注数据的生命周期,而递归则是一种强大的问题解决范式。我们来看一个经典的递归问题:读取一系列正整数,并以相反的顺序打印它们。

问题描述:
编写递归函数 print_reverse,持续读取用户输入的正整数,当输入0时停止,然后以相反顺序打印所有输入的数字。

递归思路:

  1. 基线条件: 如果读取到的数字是0,则打印“反向序列”提示并返回。
  2. 递归步骤: 如果读取到的数字不是0,则先递归调用 print_reverse 处理后续的数字,然后再打印当前数字。这样就能保证后输入的数字先被打印。

代码实现:

void print_reverse() {
    int num;
    printf("Enter number: ");
    scanf("%d", &num);

    if (num == 0) {
        printf("Reversed sequence: ");
        return;
    }
    // 先递归处理后面的数字
    print_reverse();
    // 递归返回后,再打印当前数字
    printf("%d ", num);
}

字符串基础:字面量与字符数组

在递归中我们处理了数字,现在回到字符数据。理解字符串字面量和字符数组的区别至关重要,这关系到能否修改字符串内容。

核心区别:

  • 字符串字面量: 例如 char *str = "Hello";"Hello" 存储在内存的只读区域。指针 str 指向它,但你不能通过 str 修改其内容(例如 str[0] = 'h'; 会导致未定义行为)。
  • 字符数组: 例如 char arr[] = "Hello";。这会在栈上创建一个数组,并将字符串 "Hello"(包括结尾的 \0)复制到其中。你可以自由修改数组的内容(例如 arr[0] = 'h'; 是合法的)。

重要规则:

  • 你可以读取字符串字面量的内容(char c = str[1];)。
  • 你不能修改字符串字面量的内容。
  • 字符数组在传递给函数时,会退化为指向其首元素的指针(char*)。

递归与字符串:查找字符索引

结合字符串和递归,我们来看另一个问题:递归地查找一个字符在字符串中第一次出现的索引。

问题描述:
编写递归函数 recursive_find_index,返回字符 c 在字符串 string 中首次出现的索引,如果未找到则返回 -1。

递归思路(使用辅助函数):
由于需要跟踪当前查找的索引位置,而题目要求的函数参数只有 stringc,因此通常需要一个辅助函数来接收额外的索引参数。

代码实现:

// 辅助函数
int find_char_helper(const char* string, char c, int index) {
    if (string[index] == '\0') { // 基线条件:到达字符串末尾
        return -1;
    }
    if (string[index] == c) { // 基线条件:找到字符
        return index;
    }
    // 递归步骤:检查字符串的下一个位置
    return find_char_helper(string, c, index + 1);
}

// 题目要求的函数
int recursive_find_index(const char* string, char c) {
    return find_char_helper(string, c, 0);
}


二维数组操作:计算矩阵对角线之和

从字符串回到数值计算,我们看看如何处理二维数组。一个常见的问题是计算方阵两条对角线上所有元素的和。

高效解法:
只需一次循环即可完成。主对角线元素为 array[i][i],副对角线元素为 array[i][n-1-i]。注意当 n 为奇数时,中心元素 array[n/2][n/2] 会被计算两次,这正好符合题目要求。

代码实现:

int diagonal_sum(int square[][N], int n) {
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += square[i][i];          // 主对角线
        sum += square[i][n - 1 - i];  // 副对角线
    }
    return sum;
}

关键函数与考试建议

在课程的最后,我们总结一些必须掌握的标准库函数和备考建议。

必须掌握的核心函数:

  • 输入/输出: printf, scanf
  • 内存管理: malloc, free
  • 字符串操作: strlen, strcpy, strcmp
  • 随机数: rand (注意配合 % 运算符生成指定范围随机数)

期末考试重点与建议:

  1. 重点主题: 链表递归是重中之重,题目占比会很高。
  2. 排序算法: 掌握冒泡排序选择排序的思想与实现,不需要记忆插入排序或快速排序。
  3. 不考内容: 二叉树插入排序快速排序不在考试范围内。
  4. 练习方法: 多做历年试题,尤其是链表和递归相关的题目。尝试在不看答案的情况下实现链表的各种操作(插入、删除、遍历、排序)。
  5. 应试技巧:
    • 如果题目没有明确要求,可以使用辅助函数。
    • 注意处理边界情况(如空链表、空字符串)。
    • 动态内存分配题中,如果题目要求“正确释放内存”,请务必写出 free 语句。

总结

本节课中我们一起回顾了C语言期末考试的多个核心知识点。我们从链表的冒泡排序开始,探讨了动态内存管理的常见陷阱,练习了递归在反向打印和字符串查找中的应用,厘清了字符串字面量与字符数组的关键区别,并学习了高效处理二维数组的技巧。最后,我们梳理了必备的函数库和备考策略。请务必加强对链表和递归的练习,这是考试成功的关键。祝大家期末考试顺利!

posted @ 2026-03-29 09:35  布客飞龙I  阅读(13)  评论(0)    收藏  举报