UCSC-C-语言结构化编程笔记-全-
UCSC C 语言结构化编程笔记(全)
001:本课程的先决条件

在本节课中,我们将了解学习本课程所需具备的基础知识。这些知识是理解后续更高级概念,如数据结构与程序结构化的基石。
上一节我们介绍了课程概况,本节中我们来看看学习本课程前你需要掌握哪些核心技能。
背景知识要求
你可以通过多种途径达到本课程的起点水平。
- 你可能学习过C语言基础,例如本系列的前置课程《C语言程序设计基础》。
- 你也可能具备其他编程语言(如Java或Python)的基础,这同样可以为你学习C语言提供帮助。
如果你对C语言不熟悉,但已有其他语言的编程经验,可以通过快速复习C语言基础来跟上进度。
必备核心概念
以下是进入本课程前必须掌握的几个核心编程概念。
开发环境
你需要知道如何使用编译器和编辑器。本系列课程中使用的例子是GNU C编译器和VI/VIM编辑器,但你也可以选择其他工具。
语言基本元素
你需要了解构成C程序的基本组件,它们被称为词法元素。
- 标识符:用于命名变量、函数等。
- 常量:固定的值,如数字或字符。
- 运算符:用于执行运算的符号,如
+、-、*、/。 - 标点符号:如分号
;、花括号{}。 - 关键字:语言保留的具有特殊含义的单词,如
int、if、return。 - 注释:用于解释代码,不会被编译执行。
数据类型
程序需要处理数据,因此你需要了解基本的数据类型。C语言内置了多种原生类型。
- 整型:用于表示整数,如
int。 - 浮点型:用于表示实数,如
float和double。 - 字符型:用于表示单个字符,即
char。
流程控制
为了让程序执行有趣的任务,你需要控制代码的执行顺序。
- 复合语句:将多条语句组合成一个逻辑块,用花括号
{}包裹。 - 循环:重复执行代码块,包括
while循环和for循环。 - 条件语句:根据条件决定执行路径,主要是
if语句和switch语句。
函数
函数是C语言中组织代码的核心概念。
- 每个C程序都从
main函数开始执行。 - 我们可以编写子函数(或称为子例程)来分解复杂任务,这有助于代码的组织和调试。
- 函数通过传值调用的方式接收参数,理解这一点至关重要。
- 函数需要先声明(或称为原型声明)后定义。
- 理解存储类别(如
static)对函数内变量的影响。
数组、指针与字符串
这是基础课程中涵盖的最后一部分重要内容。
- 数组:用于处理大量同类数据。数组通过索引访问元素,索引与指针类型有重要关联。
- 指针:存储内存地址的变量。指针允许我们模拟传引用调用,这对于
scanf等输入输出函数非常重要。 - 字符串:C语言中没有原生的字符串类型。字符串被当作字符数组来处理,并有一系列库函数支持其操作。
应具备的能力
在掌握了上述概念后,你应该能够运用数组和循环迭代,实现一些经典的排序算法,例如归并排序、冒泡排序或快速排序。这证明了你对核心概念的掌握程度。
课程展望
现在,我们已经回顾了学习本课程所需的基础知识。准备好后,我们将进入更高级的主题。
在接下来的课程中,我们将重点学习如何更精巧地组织数据——事实上,我们将能够创建自己的新数据类型,以及如何通过结构化程序设计的方法来编写更大型、更复杂的程序。

本节课中,我们一起学习了开始《C语言结构化程序设计》课程前需要掌握的先决条件,包括开发环境、语言基础、数据类型、流程控制、函数以及数组、指针和字符串。这些是构建后续高级知识的坚实基础。
002:枚举作为抽象数据类型

概述
在本节课中,我们将要学习C语言中的用户自定义类型,特别是最简单的形式——枚举类型。我们将了解枚举类型的定义、用途以及它如何通过类型检查来增强程序的健壮性和可读性。
理解用户自定义类型
我们的第一个主题是理解什么是用户自定义类型。用户自定义类型最简单的形式就是枚举类型。
如果你正在阅读C语言的相关书籍,这部分内容通常位于第7.5节。
对于一个编程语言及其能轻松解决的问题而言,其原生类型至关重要。C语言的原生类型包括 int、float、double、char、short、long 和 指针。这些类型在不同类型的问题中各有其用:数学问题会用到整数和双精度类型;数据处理和字符串处理问题则会大量使用字符和字符指针类型。
然而,一门语言不可能为每个特定问题领域都内置所有需要的类型。化学家、物理学家、生物学家都有他们各自描述问题的方式和类型。因此,大多数现代语言,如Java和C++,都拥有非常强大的类型扩展能力。
C语言是一门更原始的语言,它最初被设计为系统实现语言,其内置类型足以完成那些任务。但随着它演变为一门通用语言,人们发现它非常高效和便捷。因此,即使在C语言中,也有进行类型扩展的方法。本视频将要讨论的方法就是枚举。
枚举类型的用途与优势
设想一个场景,你需要对日历进行计算,并讨论星期几。拥有一个专门的类型,可以让编译器进行类型检查,从而让调试变得更加容易。
即使在像C这样的弱类型语言中(与Java这样的强类型语言相对),类型安全仍然极其有用。C语言之所以被认为是弱类型,是因为它允许很多“有趣”的操作。例如,在C语言中,if (a = b) 这样的语句是合法的,因为C语言没有真正的布尔类型,它会将赋值表达式的结果(即赋值给a的值)解释为0或非0来判断真假。虽然大多数编译器会对这种常见错误发出警告,但类型安全能帮助我们避免更多其他难以被编译器自动发现的错误。
例如,你可能写出 a / b 这样的表达式,如果a和b是整数,1 / 2的结果会是0,而不是你期望的0.5。虽然你可以通过强制类型转换来得到正确结果,但在匆忙编写代码时很容易忘记。这说明了自动类型转换虽然方便,但也可能影响程序的正确性。
因此,让我们转向如何创建用户自定义类型,特别是枚举类型。
如何定义枚举类型
以下是如何定义一个名为 day 的枚举类型:
enum day {sun, mon, tue, wed, thu, fri, sat};
enum day 向编译器声明这是一个枚举类型,其标签名是 day。现在,enum day 就成为一个新的类型。
它的值域是什么?它的值就是这些枚举常量:sun, mon, tue, wed, thu, fri, sat。我们也可以将它们全部写出来。有些人习惯将枚举常量大写。
为什么建议大写?因为从本质上讲,这些枚举常量被赋予了整数值。第一个常量(sun)被隐式赋值为0,后续的依次为1, 2, 3, 4, 5, 6。所以,它们实际上是小整数常量。
现在,我们可以声明一个该类型的变量:
enum day today = fri;
当然,在底层,fri 对应的值是5。现在我们有了一个这种类型的变量,编译器可以判断你是否在正确地操作类型和表达式。同时,这也增加了程序的清晰度。
是的,你完全可以使用普通整数来完成所有这些操作,但枚举类型是一个非常优雅的扩展,它使你的程序更具可读性,也提供了更清晰的表达方式。
枚举值的自定义
默认的赋值顺序是可以改变的。在枚举列表中,你可以显式地为某个常量赋值,这会打断隐式的递增序列,后续的常量会从你显式赋值的下一个整数开始继续递增。
例如:
enum day {sun = 1, mon, tue, wed, thu, fri, sat};
这里,sun 被赋值为1,mon 会自动变成2,依此类推。
实际上,C语言中的枚举类型是整数类型的一种受限形式。我们将在下一个视频中编写一些代码来验证这个概念。

总结
本节课我们一起学习了C语言中的枚举类型。我们了解到枚举是一种用户自定义类型,它通过为一系列相关的命名常量赋予类型,提高了代码的可读性和类型安全性。虽然枚举在底层是整数,但它为程序员提供了一种更清晰、更不易出错的方式来处理一组固定的选项。在下一节中,我们将通过实际代码来探索枚举的更多细节。
003:枚举类型代码示例

在本节课中,我们将学习如何在实际代码中定义和使用枚举类型。我们将通过一个具体的例子,了解如何声明枚举类型、为其编写操作函数,以及如何使用 typedef 关键字来提升代码的清晰度。
枚举类型的声明与定义
上一节我们介绍了枚举类型的基本概念,本节中我们来看看如何在代码中具体实现它。
以下是如何声明一个名为 enum day 的枚举类型,它代表一周的七天:
enum day {Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday};
在这个声明中:
enum是定义枚举类型的关键字。day是这个类型的标签名。- 花括号
{}内的是枚举常量,也称为枚举器,它们分别是Sunday,Monday, ...,Saturday。
这个声明创建了一个新的类型。本质上,它是一个受限制的整数类型,其中的枚举常量在底层被赋予了整数值。默认情况下,列表中的第一个常量(Sunday)值为0,后续常量依次递增(Monday为1,Tuesday为2,依此类推)。这些常量是不可修改的。虽然可以通过显式初始化来改变这些默认值(例如 Sunday=1),但在大多数情况下,使用默认值就足够了。
为枚举类型定义操作
当我们声明一个类型时,不仅定义了一组值,还需要定义在其上进行的操作。就像浮点数有除法操作一样,我们需要为自定义类型提供有意义的操作,使其变得实用。
一个常见的需求是能够打印枚举值。由于无法扩展 printf 函数来直接支持新的枚举类型格式(如果使用 %d 只会打印出底层整数),我们需要自己编写打印函数。
以下是打印枚举类型值的函数 print_day:
void print_day(enum day d) {
switch (d) {
case Sunday: printf("Sunday"); break;
case Monday: printf("Monday"); break;
case Tuesday: printf("Tuesday"); break;
case Wednesday: printf("Wednesday"); break;
case Thursday: printf("Thursday"); break;
case Friday: printf("Friday"); break;
case Saturday: printf("Saturday"); break;
default: printf("%d is not a valid day!", d);
}
}
这个函数使用 switch 语句来根据传入的枚举值执行不同的操作。这是一种处理枚举器的常用模式。每个 case 对应一个枚举常量,并执行相应的打印动作。break 语句至关重要,它防止了“贯穿”现象,确保只执行匹配的那个分支。如果传入的值不在定义的枚举常量范围内(例如一个无效的整数值),则会执行 default 分支,打印错误信息。
另一个有用的操作是获取“下一天”。例如,星期五的下一天是星期六。
以下是实现“下一天”功能的函数 next_day:
enum day next_day(enum day d) {
return ( (d + 1) % 7 );
}
该函数接收一个枚举值,返回它的下一天。这里巧妙地使用了取模运算符 %。对于 Sunday (0) 到 Friday (5),加1即可得到下一天。对于 Saturday (6),加1会得到7,但7超出了枚举范围。通过 (6 + 1) % 7 计算,结果为0,即 Sunday,从而实现了星期的循环。
主函数演示
现在,让我们在 main 函数中看看这些代码如何运行。
int main() {
enum day today = Friday;
print_day(today);
printf("\n");
today = 7; // 赋值一个超出范围的值
print_day(today);
printf("\n");
today = Friday;
enum day tomorrow = next_day(today);
print_day(tomorrow);
printf("\n");
return 0;
}
程序运行结果如下:
Friday
7 is not a valid day!
Saturday
在主函数中:
- 我们声明了一个
enum day类型的变量today并初始化为Friday。print_day函数正确地将其打印为“Friday”,尽管其底层值是6。 - 接着,我们给
today赋了一个整数值7。这展示了C语言作为弱类型语言的特性:它允许这种隐式转换,而某些强类型语言则会将其视为错误。我们的print_day函数通过default分支捕获了这个错误,并打印了相应信息。 - 最后,我们将
today重新设为Friday,调用next_day函数,并成功打印出了下一天“Saturday”。
你可以尝试为这个枚举类型添加更多操作,例如编写 yesterday(前一天)函数。
使用typedef提升代码清晰度
最后,我们来看一个能提升代码可读性的小技巧:typedef 关键字。
以下代码与之前的功能完全相同,但增加了一个 typedef 声明:
typedef enum day {Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday} day;
typedef 关键字的作用是为一个已有的类型创建一个新的别名。这里,它将 enum day 这个类型与一个更简单的名字 day 关联起来。
之后,在所有需要类型名的地方,我们都可以直接使用 day 来代替 enum day:
void print_day(day d) {
// ... 函数体不变
}
day next_day(day d) {
// ... 函数体不变
}
int main() {
day today = Friday;
// ... 其余代码不变
}
使用 typedef 可以简化复杂的类型声明,使代码更加清晰易读,这是在处理更复杂类型时的一种常见做法。

本节课中我们一起学习了枚举类型的具体代码实现。我们掌握了如何声明枚举类型,如何为其编写实用的操作函数(如打印和计算下一天),并了解了C语言中枚举的弱类型特性。最后,我们还学习了使用 typedef 来简化类型名,以提高代码的清晰度。
004:C预处理器

概述
在本节课中,我们将要学习C语言中一个非常重要的组成部分——预处理器。预处理器在代码被实际编译之前执行,负责处理诸如文件包含和宏展开等任务。理解预处理器对于阅读旧代码和掌握C语言底层机制至关重要。
预处理器简介
上一节我们介绍了C语言的基本结构,本节中我们来看看预处理器。C语言诞生于1972年,其设计严重依赖预处理器来完成编译前的准备工作。在更现代的语言中,这些功能通常直接集成在编译器内部,但在C语言中,预处理器是一个独立的阶段。
预处理器指令以井号 # 开头,并且必须位于第一列。到目前为止,我们最常用的指令是 #include,用于包含头文件。
#include 指令
#include 指令用于将其他文件的内容插入到当前源文件中。这使我们能够使用标准库或自定义的代码库。
以下是 #include 的两种主要用法:
-
包含标准库文件:使用尖括号
<>,编译器会在标准系统目录中查找文件。#include <stdio.h> #include <math.h>例如,
stdio.h中包含了printf和scanf等函数的声明。math.h则提供了数学常数(如M_PI)和函数(如sin,sqrt)。 -
包含自定义文件:使用双引号
"",编译器会优先在当前目录中查找文件。#include "my_header.h"在大型项目中,开发者常将自定义的函数声明、常量定义等放在头文件中,以便多个源文件共享。
对于希望深入理解或成为专业开发者的学生,建议查看标准头文件的内容。它们通常位于类似 /usr/include/ 的目录中。
#define 指令(宏)
预处理器的另一个核心功能是 #define 指令,用于定义宏。宏是一种文本替换机制,虽然强大,但在现代C++或Java等语言中已较少使用。使用宏时需要谨慎,因为它不进行类型检查,可能引入难以发现的错误。
定义常量
一个简单的用法是定义常量,这有助于提高代码的可读性和可维护性。在C语言社区中,常量名通常使用大写字母。
#define SPEED_OF_LIGHT 299792 // 光速,单位:千米/秒
#define PI 3.14159
#define MAX_INT 2147483647
当预处理器遇到 SPEED_OF_LIGHT 时,会将其直接替换为 299792。
定义带参数的宏(函数式宏)
宏可以带参数,实现类似函数的功能,但这存在风险。
#define SQUARE(x) ((x) * (x))
这个宏用于计算平方。注意,参数 x 和整个表达式都被括号包围,这至关重要。
让我们看看如何使用它:
int result = SQUARE(5); // 展开为:((5) * (5)),结果是25
如果宏定义中没有足够的括号,就会出现问题。例如,错误定义:
#define SQUARE_BAD(x) x * x
使用它时:
int y = 3;
int bad_result = SQUARE_BAD(y + 1); // 展开为:y + 1 * y + 1
由于乘法运算符 * 的优先级高于加法,实际计算是 y + (1 * y) + 1,即 3 + 3 + 1 = 7,而不是期望的 (3+1)*(3+1)=16。因此,为宏表达式和每个参数都加上括号是一个好习惯。
宏作为语法糖
宏有时被用作“语法糖”,让代码看起来更符合个人习惯或更清晰,但这可能降低代码的可移植性。
#define EQ ==
之后可以这样写:
if (a EQ b) { ... } // 预处理器会将其展开为 if (a == b) { ... }
有人认为这可以避免将 ==(比较)误写为 =(赋值)的常见错误,但现代编译器和IDE通常能直接警告此类错误,因此这种做法并非必需。
查看宏展开结果
如果你想查看预处理器处理后的代码(即宏展开后的结果),可以使用编译器的 -E 选项。
gcc -E your_program.c
执行此命令后,编译器会输出经过预处理(包含文件展开、宏替换)后的C代码,而不会进行编译和链接。

总结
本节课中我们一起学习了C语言预处理器的核心功能。我们介绍了 #include 指令的两种用法,用于包含系统头文件和自定义文件。我们重点探讨了 #define 指令,学习了如何用它定义常量和带参数的宏,并强调了正确使用括号以避免运算符优先级问题的重要性。最后,我们了解了如何使用编译器选项查看宏展开的结果。虽然宏功能强大,但在现代C语言编程中应谨慎使用,优先考虑使用语言本身的特性(如 const 变量和函数)来保证代码的安全性和可读性。
005:预处理器代码示例

在本节课中,我们将通过一个具体的代码示例,来演示C语言预处理器的实际应用。我们将学习如何使用宏定义常量、生成随机数,并理解宏在代码生成和简化中的作用。
概述
上一节我们介绍了预处理器的基本概念。本节中,我们来看看一个使用预处理器宏来生成随机数据的完整程序。该程序将模拟生成1000只雄性南象海豹的随机体重数据。
代码解析
以下是演示预处理器用法的核心代码。我们将逐步分析其组成部分。
1. 包含头文件与常量定义
程序首先包含了必要的标准库头文件,并定义了两个常量,分别表示雄性南象海豹体重的最大值和最小值。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define MAX_ELEPHANT_SEAL_WEIGHT_MALE 8800
#define MIN_ELEPHANT_SEAL_WEIGHT_MALE 4400
2. 定义计算宏
接下来,我们定义了一些更复杂的宏。RANGE 宏计算了体重的可变范围。WEIGHT_OVER 宏用于生成一个0到该范围内的随机数。WEIGHT 宏则利用前一个宏的结果,加上最小体重,以生成符合实际范围的体重值。
#define RANGE 4400
#define POPULATION 1000
#define WEIGHT_OVER (rand() % RANGE)
#define WEIGHT WEIGHT_OVER + MIN_ELEPHANT_SEAL_WEIGHT_MALE
3. 定义填充数据的宏
然后,我们定义了一个多行宏 FILL。这个宏使用一个循环,为数组中的每个元素调用 WEIGHT 宏来赋值。反斜杠 \ 用于表示宏定义延续到下一行。
#define FILL \
for (i = 0; i < POPULATION; i++) \
data[i] = WEIGHT
4. 打印数据函数
我们定义了一个标准的函数来打印生成的数组数据。该函数以制表符分隔数值,并且每打印10个数值就换行。
void print_data(int d[], int size)
{
int i;
printf("\n");
for (i = 0; i < size; i++)
{
printf("%d\t", d[i]);
if ((i + 1) % 10 == 0)
printf("\n");
}
}
5. 主函数
在主函数中,我们声明了数组,并使用系统时间初始化随机数种子,以确保每次运行程序都能获得不同的随机数序列。然后,我们调用 FILL 宏来填充数组,并最终打印数据。
int main()
{
int i;
int data[POPULATION];
srand(time(NULL));
FILL;
print_data(data, POPULATION);
return 0;
}
执行与结果
编译并运行此程序,将输出1000个介于4400到8800之间的随机整数,模拟了雄性南象海豹的体重数据。通过扫描这些数字,可以验证它们确实在定义的范围内。
关于宏的注意事项
宏功能强大,可以用于代码生成和简化。然而,在现代C/C++编程社区中,对于包含代码段的宏的依赖已大大减少。通常,我们更倾向于让编译器进行优化,或者使用C++中的内联函数(某些C编译器也支持),这能产生更安全、更易于检查的代码。
总结

本节课中我们一起学习了如何利用C语言的预处理器来定义常量、创建功能宏以生成随机数据,并构建了一个完整的示例程序。我们看到了宏如何使代码更简洁,同时也了解了其潜在的局限性以及现代编程中的最佳实践。
006:使用断言保证程序正确性

概述
在本节课中,我们将学习C语言预处理阶段中一些非常有用的宏,特别是assert宏。我们将了解如何利用这些宏来检查字符、处理输入输出,并重点学习如何使用断言来验证程序在运行时的状态是否符合预期,从而帮助我们发现错误并增强代码的可靠性。
标准库中的实用宏
现在,我们来看看一些可以在各种标准库中找到的实用宏,包括stdio.h和ctype.h。我们将重点介绍一个通过#define指令定义的宏,即所谓的assert宏。
字符输入输出宏
首先,我们介绍用于字符预处理的实用宏。对于char *(即指向字符的指针,我们用它来表示字符串),标准库中包含了类似#define getchar() getc(stdin)的定义。
stdin是分配给输入的名称,通常初始指向键盘。因此,如果你想从键盘获取一个字符,可以使用getchar(),或者也可以使用getc(stdin)。但getchar更简洁。这是一个存在于标准I/O库中的宏,它有助于从输入中获取字符。
有一个等效的输出宏,叫做putchar(c),意思是将字符c放入stdout。同样,stdout预定义为你的屏幕。
这些简单的宏允许你进行逐个字符的处理,这包括处理空白字符,其行为与使用printf和scanf时略有不同,后者中的空白字符可能会引起问题。
字符类型判断宏
在ctype.h中,我们还有用于字符判断的宏。你应该已经能够使用在线查找等参考资料,包括维基百科上关于语言及其标准库的资料。将所有信息都记在脑子里是不现实的,通常从互联网上获取这些信息很方便。
以下是ctype.h中的一些宏示例:
isalpha(c):判断该字符是否为字母(A到Z,大小写均可)。函数名中的is反映了我们期望一个布尔值。在C语言中,0表示假,非零(本例中返回1)表示真。因此,如果看到字母字符,它将返回1;如果看到非字母字符或空白字符,它将返回0。isspace(c):检查是否为空白字符。isdigit(c):检查是否为数字字符。islower(c):检查字符是否为小写。例如,如果你想将所有字符转为大写,你可以先检查它是否为小写字符,然后使用一个操作将其转为大写。toupper(c):这个操作可以将小写字符转为大写。
断言(Assert)宏
现在,让我们转向一个非常重要的话题,它也存在于预处理器的标准库中,那就是使用断言(assert)。
这些断言可以通过一个名为NDEBUG的标志来开启或关闭。
它的工作原理是:你断言(assert)一个表达式(通常应被视为逻辑表达式)。如果该表达式为真,程序继续运行,这意味着此时程序的状态符合你的预期。但如果由于某些原因它不符合预期,那么它会调用abort函数并输出相应的错误信息,我们将在下一节的代码中看到这一点。
断言可以被插入到常规代码中进行测试。它们可以用来证明程序的正确性。从某种意义上说,最初一些著名的计算机科学家(如图灵奖得主Dijkstra等人)曾尝试用数学方法证明他们的程序是正确的。但事实证明,实际上没有多少人能做到这一点,即使是伟大的天才也很难为大量代码做到这一点,尽管有些人设想最终能够改进程序并证明代码的正确性。幸运的是,对于人类劳动而言,这尚未实现。
因此,我们可以插入这些断言。它们实际上也充当了注释的角色,说明在程序的这一点我们期望什么是真的。它们可以被保留并交付。然后,当我们确信程序正确时,可以使用NDEBUG标志来控制是否测试这些断言。
我们将在下一个视频的代码中使用所有这些内容。

总结
本节课我们一起学习了C语言预处理中的实用宏。我们了解了用于字符输入输出的getchar和putchar宏,以及用于字符类型判断的isalpha、isspace等宏。更重要的是,我们深入探讨了assert宏的用途和工作原理,它通过在代码中插入逻辑检查点,帮助我们在开发阶段验证程序假设,及时发现错误,是提升代码健壮性的重要工具。在下一节中,我们将通过实际代码来演示这些概念的应用。
007:断言(assert)的使用

在本节课中,我们将学习C语言中一个非常有用的调试工具——断言(assert)。我们将通过几个简单的示例程序,了解断言如何工作,以及如何使用编译标志来控制它的开启与关闭。
概述
断言是一种在程序中嵌入检查点的方法,用于验证程序在运行时的状态是否符合预期。如果断言的条件为假(false),程序将终止并输出错误信息,这有助于开发者快速定位逻辑错误。
断言基础
上一节我们介绍了断言的概念,本节中我们来看看它的具体用法。要使用断言,首先需要包含头文件 assert.h。
#include <assert.h>
断言的基本语法是一个宏,其形式为 assert(expression)。如果表达式 expression 的值为0(即假),则断言失败,程序会中止执行。
第一个示例:简单的断言失败
让我们看一个非常简单的测试程序。这个程序仅仅包含一个断言 assert(0)。记住,0在C语言中代表假(false)。
#include <assert.h>
int main() {
assert(0);
return 0;
}
这个程序非常简单,它基本上就是在说“这个断言会失败”。当我们编译并运行它时,程序会失败。断言失败时,你会看到类似 assert failed function main, file assert_test1.c, line 16 的信息,然后程序会中止(abort)。断言的作用是指出程序的某个前置条件在该点不成立,并且它会告诉你失败发生的位置。
第二个示例:使用NDEBUG宏控制断言
现在,让我们看第二个测试程序。这个程序与之前的区别在于,我们定义了一个 NDEBUG 宏。
#define NDEBUG
#include <assert.h>
int main() {
assert(0);
return 0;
}
NDEBUG 宏的作用是在编译时关闭所有断言。所以,现在这个程序(除了添加了 NDEBUG 宏之外,与前一个相同)会像根本没有 assert(0) 语句一样运行。从某种意义上说,NDEBUG 标志允许我们使用断言进行调试,然后在确认程序运行正确后,可以通过定义这个宏来“移除”它们。这是一种开发代码的方式:插入断言作为动态正确性证明,在你确信所有断言都不再需要后,可以暂时关闭它们。当然,如果你基于该源代码进一步开发,这些断言仍然存在,并且任何时候你没有定义 NDEBUG 宏,断言都会被重新启用。
第三个示例:在数学程序中使用断言
让我们再看一个使用断言的程序,这是一个简单的数学程序。
#include <assert.h>
#include <stdio.h>
int main() {
double x, y;
while (1) { // 无限循环
printf("Enter two floats: ");
scanf("%lf %lf", &x, &y);
assert(y != 0.0); // 断言y不等于0
printf("%g divided by %g is %g\n", x, y, x / y);
}
return 0;
}
在这个程序中,我们断言了什么?我们有两个双精度浮点数 x 和 y。程序运行一个无限循环,要求输入两个浮点数,然后断言 y 不等于零。为什么?因为我们不想在这里除以零。否则,我们就打印 x / y 的结果。
如果我们运行这段代码,输入一些数字,比如4.54除以5.6,结果是0.714。再输入5除以3,结果是0.6。我们也可以输入0除以4.5,结果是0。程序在无限循环中。
但是,现在让我们尝试输入5除以0,看看会发生什么。这时断言会失败。所以,断言防止了我们进行除以零的操作。通常,我们会用某种方式(例如读取一个终止标记,或在大多数Unix操作系统中使用Ctrl+C)来退出这种无限循环。
关闭断言后的行为
最后,如果我们使用 -DNDEBUG 标志编译这个程序(即关闭断言),会发生什么?我们可以用GCC编译器这样操作:gcc -DNDEBUG program.c。
现在运行相同的程序,但断言已被关闭。我们可以输入3和4,结果是0.75。输入2和1,结果是2。但是,当我们输入7除以0时,我们不会得到断言错误,而是会得到 x / y is INF,这是系统提供的“无穷大”表示。
这很有趣。如果你输入0除以0呢?它也不会中止,而是会输出“not a number”(NaN)。这是当我们进行某些无法在常规实数集中解释的数学运算时,会出现的两个有趣的结构。现在,因为我们处于无限循环中,它正在等待输入浮点数,而我们无法像之前那样输入0来触发断言失败,我们只能使用Ctrl+C来退出。
总结

本节课中我们一起学习了C语言中的断言(assert)。我们看到了它在代码中的实际应用,它非常有用,尤其是如果你想成为一个严谨的程序员。它有助于你在逻辑上思考,将断言作为前置条件和后置条件来检查你的代码,从而提升代码的健壮性。
008:结构体介绍——更高级的抽象数据类型

在本节课中,我们将要学习C语言中一个非常重要的概念:结构体。结构体允许我们创建自定义的、更复杂的数据类型,这对于解决现实世界中的问题至关重要,尤其是在处理像游戏卡牌这类由多个属性组成的对象时。
什么是抽象数据类型?
上一节我们介绍了抽象数据类型的概念,它是一种用户自定义的类型。我们已经通过枚举类型见过它。类型对于问题解决方案非常关键。C语言有原生类型,如 int、char、float 等,包括各种指针类型。这些类型可以自然地用于解决许多问题,尤其是文本和数学问题。
但是,如果你需要在游戏开发中处理某些对象,例如想要创建一个表示“扑克牌”的数据类型,该怎么办呢?
为什么需要结构体?
想象一个策略游戏,你需要与一群其他玩家玩扑克牌。在这样的游戏中,我们希望能够计算五张手牌的概率。例如,计算你抽到的五张牌恰好是“顺子”(即五张连续点数的牌,如7、8、9、10、J)的概率。
为了进行这种计算,我们必须有一种表示单张牌的方式。一张牌通常有两个属性:点数和花色。
- 点数:我们用1到13的整数表示,1代表A,2到10代表对应数字,11代表J,12代表Q,13代表K。A在某些情况下(如顺子)既可以作为1(低点)也可以作为14(高点),这会增加计算的复杂性。
- 花色:花色可以是梅花(Clubs)、方块(Diamonds)、红心(Hearts)或黑桃(Spades)。
我们将使用结构体机制来构建我们自己的“卡牌”数据类型。
如何定义结构体?
以下是定义结构体的语法:
struct card {
int pips; // 点数,1-13
char suit; // 花色,'C', 'D', 'H', 'S'
};
让我们分解这个语法:
struct是关键字。card是标签名,它标识了这个结构体类型。- 大括号
{}内是声明列表,包含用分号分隔的成员变量声明。 - 每个成员声明就像普通的变量声明,例如
int pips;。
定义了 struct card 之后,我们就可以用它来声明变量了。例如,我们可以声明一个代表整副扑克牌的数组:
struct card deck[52]; // 一个包含52张牌的数组
使用typedef简化
每次声明变量都要写 struct card 可能有些繁琐。C语言提供了 typedef 关键字来为类型创建别名,从而简化声明。
我们可以这样使用 typedef:
typedef struct {
float real; // 实部
float imag; // 虚部
} complex;
在这个例子中:
- 结构体本身没有标签名。
typedef将整个结构体类型定义了一个新名字complex。- 现在,我们可以直接使用
complex来代替struct {...}。
我们可以对卡牌做同样的事情:
typedef struct {
int pips;
char suit;
} card;
card deck[52]; // 现在声明变得非常简洁直观
通过 typedef,我们创建了一个更简单、更直观的抽象数据类型名称 card。
结构体的优势
使用结构体将相关的数据组合在一起,有以下好处:
- 组织性:代码更清晰,逻辑上相关的数据被封装在一起。
- 可读性:
card myCard;比单独使用int myCardPips; char myCardSuit;更能表达意图。 - 可维护性:如果需要为卡牌增加新属性(如是否正面朝上),只需修改结构体定义,而不是散落在各处的多个变量。
总结

本节课中我们一起学习了C语言的结构体。我们了解到结构体是一种强大的工具,它允许我们创建自定义的抽象数据类型,将多个相关的数据成员组合成一个单一的单元。我们学习了如何定义结构体,如何声明结构体变量,以及如何使用 typedef 关键字来简化类型名称,使代码更加清晰和易于管理。通过以“扑克牌”为例,我们看到了结构体如何帮助我们更自然地建模现实世界中的复杂对象。在接下来的课程中,我们将学习如何操作和使用结构体变量。
009:如何访问结构体成员

在本节课中,我们将学习如何访问和操作结构体类型的成员。上一节我们介绍了如何使用 struct 机制构建新的用户自定义类型,并以创建卡牌类型为例进行了说明。本节中,我们来看看如何操作这种类型。
操作结构体类型的关键在于,我们必须有能力为结构体的各个成员赋值。回顾一下,我们的卡牌结构体包含 pips(点数)和 suit(花色)两个成员。因此,我们需要有访问这些成员的方法。
我们将介绍两种运算符来实现成员访问。第一种是点运算符(.),这种方式更直接、更简单。第二种是成员访问运算符(->),它涉及到指针的使用。
使用点运算符(.)访问成员
以下是使用点运算符的示例。我们首先声明一个 struct card 类型,然后声明三个该类型的变量 c1、c2 和 c3。
struct card {
int pips;
char suit;
};
struct card c1, c2, c3;
要访问成员,我们使用变量名,后接点运算符,再接成员名。之后,就可以像操作普通变量一样对其进行赋值或读取。
c1.pips = 3; // 将 c1 的点数赋值为 3
c1.suit = 'H'; // 将 c1 的花色赋值为 'H'(红心)
这样,c1 就代表了一张红心3。这种方式非常直观:变量名 + . + 成员名,构成了对结构体内部字段的命名约定。
使用箭头运算符(->)访问成员
第二种方法涉及指针。我们声明一个指向 struct card 的指针,并让它指向变量 c1 的地址。
struct card *pointer_to_card;
pointer_to_card = &c1; // pointer_to_card 现在存储着 c1 的地址
现在,pointer_to_card 提供了访问 c1 的另一种方式。要访问成员,我们使用箭头运算符(->)。
pointer_to_card->pips = 5; // 通过指针将 c1 的点数设置为 5
pointer_to_card->suit = 'S'; // 通过指针将 c1 的花色设置为 'S'(黑桃)
这样,c1 就变成了一张黑桃5。虽然指针的使用稍显复杂,但这是掌握C语言必须习惯的内容。
运算符优先级与组合访问
在总结两种方法之前,理解运算符优先级非常重要。在C语言的运算符优先级表中,点运算符(.)和箭头运算符(->)具有最高的优先级,与后缀自增、函数调用和索引操作同级。
这意味着,在表达式中,访问成员的操作会优先于大多数其他运算(如数学运算、关系比较和赋值)。了解这一点有助于避免因优先级混淆而导致的错误。
此外,我们可以组合使用这两种方法。既然 pointer_to_card 指向 c1,那么对指针解引用(*)就能得到 c1 本身,然后就可以使用点运算符。
(*pointer_to_card).suit = 'D'; // 解引用指针后使用点运算符,将花色改为 'D'(方块)
请注意,由于点运算符优先级很高,解引用操作必须用括号括起来,以确保先进行解引用。
核心概念总结
以下是访问结构体成员的两种核心方式:
- 点运算符(
.):用于结构体变量本身。- 公式:
结构体变量名.成员名
- 公式:
- 箭头运算符(
->):用于指向结构体的指针。- 公式:
结构体指针名->成员名 - 这等价于:
(*结构体指针名).成员名
- 公式:
本节总结

本节课中,我们一起学习了如何访问结构体成员。我们介绍了两种方法:直接对结构体变量使用点运算符(.),以及对指向结构体的指针使用箭头运算符(->)。我们还了解了这些运算符具有很高的优先级,并演示了如何通过解引用指针来组合使用这两种访问方式。掌握这些内容是有效操作自定义数据类型的基石。
010:栈抽象数据类型介绍

概述
在本节课中,我们将要学习一种标志性的数据结构——栈抽象数据类型。我们将了解栈的基本概念、工作原理、常见操作,并初步探讨如何在C语言中实现它。
栈的基本概念与生活实例
上一节我们介绍了数据结构的重要性,本节中我们来看看一个经典的数据结构:栈。
栈是一种抽象数据类型。回想一下高中时去食堂的情景,那里会有一叠托盘放在弹簧上。你总是拿走最上面的托盘。如果你遵守规则,也会把托盘放回最上面。这被称为“后进先出”,因为最后放上去的托盘会最先被拿走。我们称之为LIFO。
这是一种典型的、非平凡的数据结构,在计算机算法乃至计算机体系结构中都非常常见,非常有用。
栈的操作(动词)
与数据结构相伴的是一组可以对其执行的操作。
以下是栈的常见操作:
- Push(压栈):将元素放入栈顶。
- Pop(出栈):从栈顶移除元素。Push和Pop是互逆的操作。
- Peek/Top(查看栈顶):查看栈顶元素但不移除它。
- IsEmpty(判空):检查栈是否为空。
- IsFull(判满):检查栈是否已满(对于有容量限制的栈)。
- Reset(重置):清空栈。
这些将是我们构建完整数据结构时要实现的操作。
栈的直观理解
我们可以看一些栈的例子。这是一摞书。同样,你不会从中间抽出一本书,你总是从顶部拿走或放回。这是一堆积木。你不会在中间连接,总是在顶部添加或移除。
从概念上看,栈是这样的:假设栈初始为 [1]。
- 元素2被压入栈,栈变为
[2, 1]。 - 元素3被压入栈,栈变为
[3, 2, 1]。 - 继续压入4,5,6,栈变为
[6, 5, 4, 3, 2, 1]。
现在执行出栈操作。注意,当你按顺序 2, 3, 4, 5, 6 压入时,出栈的顺序是相反的:6, 5, 4, 3, 2。
我们马上会看到,如果你想反转某个列表、字符串或任何序列,栈是一个非常方便的抽象数据类型。你把元素依次压入,然后再依次弹出,就能得到它们的逆序。
在C语言中实现栈
那么如何在C语言中实现栈呢?
我们将回到使用 typedef struct 来定义。我们将把它构建成一个最大长度为 MAXLEN 的数组。我们将有一个元素数组。在这个例子中,我们放入字符 char。通常,栈中可以存放任意类型的元素。因此,对于每种元素类型,你都需要一个不同的栈实现。在一些非常高级的实现中,有处理泛型元素的方法,但我们暂不讨论。
然后,栈顶指针 top 将指向当前栈顶的下一个位置。
这就是我们的栈数据结构。其底层表示是一个数组。更高级的课程会介绍实现栈的其他方式。
我们的数据结构定义如下:
typedef struct {
char elements[MAXLEN]; // 存储栈元素的数组
int top; // 栈顶指针(指向下一个空闲位置)
} Stack;
压栈操作详解
让我们看看如何执行压栈操作。将某物放入栈中需要一个参数,即要压入的字符 c。为了修改栈,我们将通过指针来访问它,因为栈的内容会被改变。栈本质上是一个数组。
压栈操作 push 可以这样实现:
void push(Stack *stack, char c) {
stack->elements[stack->top] = c; // 将字符c放入当前栈顶位置
stack->top++; // 栈顶指针加1,指向新的下一个空闲位置
}
代码解释:stack->top 指向当前栈顶(下一个空闲位置)。我们先将元素 c 存入该位置,然后将 top 索引加1,指向新的栈顶。
总结与预告
本节课中我们一起学习了栈抽象数据类型的基本思想,这是一个非平凡的抽象数据类型。我们看到了更多高级数据类型的雏形。

在下一节中,我将开发完整的代码库,实现包含所有不同操作(压栈、出栈、重置等)的栈的完整表示。
011:使用栈反转字符串

在本节课中,我们将学习如何使用栈(Stack)这一数据结构来反转一个字符串。我们将通过具体的代码实现,了解栈的基本操作,并应用这些操作完成字符串反转的任务。
🎼 栈的基本实现
上一节我们介绍了栈的抽象概念,本节中我们来看看如何在C语言中实现一个基本的栈。
首先,我们定义栈的最大容量和一些状态常量。
#define MAX_LEN 1000
#define EMPTY -1
#define FULL (MAX_LEN - 1)
接下来,我们定义栈的数据类型。这里我们实现一个存储字符的栈。
typedef struct stack {
char s[MAX_LEN];
int top;
} stack;
以下是栈的核心操作函数:
1. 重置栈
此操作将栈顶指针设置为EMPTY,表示栈为空。
void reset(stack *stk) {
stk->top = EMPTY;
}
2. 压入元素
此操作将一个字符放入栈顶,并更新栈顶指针。
void push(char c, stack *stk) {
stk->top++;
stk->s[stk->top] = c;
}
3. 弹出元素
此操作从栈顶取出一个字符,并更新栈顶指针。
char pop(stack *stk) {
return (stk->s[stk->top--]);
}
4. 查看栈顶元素
此操作仅查看栈顶元素,而不改变栈的状态。
char top(const stack *stk) {
return (stk->s[stk->top]);
}
5. 检查栈是否为空
此操作检查栈顶指针是否等于EMPTY。
int is_empty(const stack *stk) {
return (stk->top == EMPTY);
}
6. 检查栈是否已满
此操作检查栈顶指针是否等于FULL。
int is_full(const stack *stk) {
return (stk->top == FULL);
}
🔄 使用栈反转字符串
现在,我们将使用上面实现的栈来反转一个字符串。算法思路是:先将字符串的每个字符依次压入栈中,然后再依次弹出,由于栈“后进先出”的特性,弹出的顺序就是原字符串的逆序。
以下是实现此功能的主要代码步骤:
-
初始化栈和字符串
我们声明一个栈和一个待反转的字符串。stack s; char str[] = "I am O am I"; char str_back[20]; -
打印原字符串并压入栈中
我们遍历原字符串,打印每个字符并将其压入栈中。printf("Original string is: %s\n", str); printf("Individual letters pushed onto the stack: "); for (int i = 0; str[i] != '\0'; i++) { printf("%c ", str[i]); push(str[i], &s); } printf("\n"); -
弹出栈中元素以反转字符串
当栈不为空时,我们不断弹出栈顶元素,并将其存入新的字符数组中,从而得到反转后的字符串。int i = 0; while (!is_empty(&s)) { str_back[i++] = pop(&s); } str_back[i] = '\0'; // 为反转后的字符串添加结束符 -
输出结果
最后,我们打印出反转后的字符串。printf("The reversed string is: %s\n", str_back);
运行此程序,输出结果如下:
Original string is: I am O am I
Individual letters pushed onto the stack: I a m O a m I
The reversed string is: I ma O ma I
可以看到,字符串 "I am O am I" 被成功反转为 "I ma O ma I"。
📝 总结

本节课中我们一起学习了栈数据结构的C语言实现,并应用它完成了一个经典的算法任务——字符串反转。我们实现了栈的重置、压入、弹出、查看栈顶、判空和判满等基本操作。通过将字符串字符依次压栈再弹出的过程,我们直观地体会了栈“后进先出”的特性。栈是计算机科学中非常重要且广泛应用的非平凡数据结构,掌握其原理和实现是编程学习中的关键一步。
012:列表抽象数据类型介绍

概述
在本节课中,我们将学习如何在C语言中构建抽象数据类型,特别是列表。我们将重点介绍结构体和指针的使用,并解释如何创建和管理一个单链表。
构建抽象数据类型
上一节我们介绍了抽象数据类型的基本概念。本节中,我们来看看如何利用C语言的结构体和指针来构建一个具体的抽象数据类型——列表。
列表是计算机科学中一种非常典型的数据结构,被称为自引用类型。这意味着在定义列表时,其定义中包含了对其自身类型的引用,具有一定的递归特性。
列表的结构
一个列表通常由一个头指针开始。这个头指针指向列表的第一个元素。每个元素包含两部分:一部分是存储的数据,另一部分是指向下一个元素的指针。
列表的末尾是一个特殊的标记,通常用零或预定义的NULL来表示,这与我们用来终止字符串的哨兵值类似。
以下是一个单链表元素的C语言结构体定义:
struct list {
int data;
struct list* next;
};
每个这样的结构体实例就是链表中的一个节点,包含data(数据)和next(指向下一个节点的指针)。
头指针则简单地定义为指向这种结构体的指针:
typedef struct list* list;
创建列表
创建列表需要动态分配内存。我们使用标准库中的malloc函数,它从称为“堆”的动态存储区域中分配指定大小的内存。
以下是创建列表头节点的一个示例方法:
list head = malloc(sizeof(struct list));
malloc(sizeof(struct list))请求分配一个足以存放struct list的内存块,并返回指向该内存块的指针。sizeof是一个关键字,用于计算特定类型或对象所占用的字节数。
内存管理
当我们使用malloc动态分配内存时,作为一个良好的编程习惯,在内存不再需要时应将其释放。虽然程序结束时系统会自动回收内存,但如果程序会大量使用动态内存,及时释放可以避免内存耗尽。
与malloc对应的是free函数,用于将指针指向的内存返还给系统以供重用。

总结
本节课中我们一起学习了列表抽象数据类型的基本概念。我们了解了如何使用C语言的结构体和指针来定义和构建一个单链表,介绍了通过malloc进行动态内存分配以及使用free进行内存释放的重要性。在接下来的视频中,我们将通过代码示例进一步巩固这些概念。
013:单个元素的列表代码实现

概述
在本节课中,我们将学习如何用C语言代码实现一个最简单的单链表。我们将重点分析链表的核心数据结构定义、基本操作(如判断链表是否为空、遍历打印链表),并观察一个仅包含单个元素的链表的创建与展示过程。通过这个简单的例子,你将掌握链表处理中最关键的模式。
链表数据结构定义
上一节我们介绍了链表的概念,本节中我们来看看其核心的数据结构在代码中如何定义。
typedef struct list {
int data;
struct list *next;
} list;
在这个typedef定义中,data用于存储节点数据,本例中为int类型。next是一个指向struct list的指针,用于链接到下一个节点。data可以是任何类型,甚至是更复杂的结构体。
链表基本操作
定义了数据结构后,我们需要一些操作来使用它。以下是两个基础操作。
判断链表是否为空
对于任何抽象数据类型,我们都需要一些操作。这里是一个判断ADT链表是否为空的函数。
int is_empty(const list *l) {
return (l == NULL);
}
一个空链表的头指针指向NULL。这个函数通过测试传入的链表指针l是否为NULL来判断链表是否为空。它也可以用来判断是否到达链表尾部。
遍历并打印链表
接下来,我们看看如何迭代地遍历链表并打印其内容。
void print_list(const list *h, char *title) {
printf("%s", title);
while (h != NULL) {
printf("%d:", h->data);
h = h->next;
}
printf("\n");
}
这个函数接收链表头指针h和一个标题字符串title。首先打印标题。然后进入循环,只要h不为NULL(即未到达链表尾部),就打印当前节点的data值,并将指针h前进到下一个节点(h = h->next)。这是一个需要牢记的关键模式。
以下是这个模式的核心要点:
- 哨兵测试:
while (h != NULL)是循环继续的条件。 - 数据处理:在循环体内对节点数据执行操作(本例为
printf)。 - 指针前进:通过
h = h->next移动到下一个节点。
尝试将此迭代版本改写为递归函数,将是一个很好的练习。递归的基准情况同样是测试指针是否为NULL。
创建单元素链表实例
理解了基本操作后,我们来看一个创建具体链表实例的main函数。
int main() {
list *head = NULL;
printf("size of list is %lu\n", (long unsigned)sizeof(list));
head = malloc(sizeof(list));
head->data = 5;
head->next = NULL;
print_list(head, "single element list: ");
return 0;
}
程序首先声明一个指向list的指针head,并将其初始化为NULL,这表示一个空链表。malloc函数从堆中分配一块大小为sizeof(list)的内存。创建链表时,一个常见的错误是忘记正确设置尾节点的next指针为NULL。这里我们将data设置为5,next设置为NULL,从而创建了一个单元素链表。最后调用print_list函数打印这个链表。
程序运行与输出
让我们编译并运行这段代码,验证其是否按预期工作。
使用命令 gcc -o list_demo list_demo.c 进行编译,然后执行 ./list_demo。
程序输出如下:
size of list is 16
single element list: 5:
输出显示,list结构体的大小是16字节,这正是malloc需要分配的大小。创建的链表被成功打印为“single element list: 5:”。打印中的冒号是为了在后续的多元素链表程序中分隔各个数据项。

总结
本节课中我们一起学习了用C语言实现单链表的基础代码。我们分析了链表节点的结构定义,实现了判断链表为空和遍历打印链表的核心函数,并完成了一个单元素链表的创建与展示。其中,while (h != NULL) { ... h = h->next; } 是链表处理中至关重要的模式,理解它就能掌握大部分链表操作的核心逻辑。请仔细研究这段代码,它为理解更复杂的链表处理打下了坚实基础。
014:列表的完整实现与操作

在本节课中,我们将深入学习C语言中链表(List)的完整实现与基本操作。链表是理解指针和动态内存分配的关键数据结构。我们将通过具体的代码示例,详细讲解如何创建链表、向链表添加元素、将数组转换为链表以及如何遍历打印链表。
链表的基本结构
上一节我们介绍了链表的概念,本节中我们来看看如何用C语言代码定义链表的结构。
链表的核心是一个自引用的结构体。这意味着结构体中包含一个指向同类型结构体的指针。其定义如下:
typedef struct list {
int data; // 存储的数据
struct list *next; // 指向下一个节点的指针
} list;
这个结构体包含两个部分:data 用于存储整型数据,next 是一个指针,指向下一个 list 类型的节点。当 next 指针为 NULL 时,表示这是链表的末尾。
判断链表是否为空
在操作链表之前,一个常见的需求是判断链表是否为空。以下是判断方法:
判断链表是否为空非常简单。我们只需要检查指向链表头部的指针是否为 NULL。
int is_empty(list *head) {
return head == NULL;
}
如果 head 指针为 NULL,则链表为空,函数返回 1(真);否则返回 0(假)。
创建链表节点
要使用链表,首先需要能够创建单个的链表节点。以下是创建单个链表节点的函数:
这个函数接收一个数据值,动态分配内存来创建一个新的链表节点,并对其进行初始化。
list *create_list(int d) {
list *head = malloc(sizeof(list)); // 从堆中分配内存
head->data = d; // 存储数据
head->next = NULL; // 将next指针设为NULL
return head; // 返回新创建的节点
}
函数 create_list 执行以下步骤:
- 使用
malloc函数在堆内存中分配一个list结构体大小的空间。 - 将传入的数据
d存储到新节点的data字段中。 - 将新节点的
next指针设置为NULL,表示它是链表的最后一个节点。 - 返回指向这个新节点的指针。
向链表头部添加元素
创建了节点之后,我们需要将它们连接起来形成链表。一种常见的方式是在链表头部添加新元素。
add_to_front 函数接收当前链表的头指针和新数据,将新数据创建为节点并置于链表的最前端。
list *add_to_front(list *h, int d) {
list *head = create_list(d); // 创建新节点
head->next = h; // 新节点的next指向原链表头
return head; // 新节点成为新的链表头
}
该函数的工作流程如下:
- 调用
create_list(d)创建一个包含数据d的新节点。 - 将这个新节点的
next指针指向原来的链表头h。 - 返回这个新节点,它现在成为了链表的新头部。
通过这种方式,链表像一条链条,新元素总是被添加到链条的开头。
将数组转换为链表
在实际编程中,我们常常需要将其他数据结构(如数组)转换为链表。以下是将数组转换为链表的函数:
array_to_list 函数接收一个整型数组及其大小,并返回一个由数组元素构成的链表。
list *array_to_list(int d[], int size) {
list *head = create_list(d[0]); // 用数组第一个元素创建链表头
for (int i = 1; i < size; i++) {
head = add_to_front(head, d[i]); // 循环将后续元素添加到链表头部
}
return head; // 返回最终链表的头指针
}
转换过程分为两步:
- 使用数组的第一个元素
d[0]调用create_list函数,创建链表的初始头节点。 - 使用一个循环,从数组的第二个元素开始,依次调用
add_to_front函数,将每个元素添加到链表的头部。
需要注意的是,由于我们总是向“前端”添加,最终生成的链表顺序将与数组顺序相反。
遍历并打印链表
创建链表后,我们需要能够查看其中的内容。以下是遍历并打印链表所有元素的函数:
print_list 函数接收链表的头指针,并从头到尾依次打印每个节点的数据。
void print_list(list *h) {
while (h != NULL) { // 当指针不为NULL时继续循环
printf("%d ", h->data); // 打印当前节点的数据
h = h->next; // 将指针移动到下一个节点
}
printf("\n");
}
这是一个遍历链表的经典模式:
- 使用
while循环,条件是指向当前节点的指针h不为NULL。 - 在循环体内,访问并打印当前节点
h的data字段。 - 将指针
h更新为h->next,即指向链表中的下一个节点。 - 当
h变为NULL时,表示已到达链表末尾,循环结束。
完整示例程序
现在,让我们将这些部分组合起来,看一个完整的示例程序,它演示了如何将数组转换为链表并打印出来。
以下程序定义了一个包含6个元素的数组,将其转换为链表,然后打印链表的内容。
#include <stdio.h>
#include <stdlib.h>
typedef struct list {
int data;
struct list *next;
} list;
// 此处插入之前定义的所有函数:is_empty, create_list, add_to_front, array_to_list, print_list
int main() {
int data_array[6] = {2, 3, 5, 7, 8, 9};
int size = 6;
list *head = array_to_list(data_array, size); // 将数组转换为链表
printf("Data of six made into a six element list: ");
print_list(head); // 打印链表
// ... 后续应释放链表内存 (为简洁起见,此处省略)
return 0;
}
程序执行流程:
- 定义一个包含
{2, 3, 5, 7, 8, 9}的数组。 - 调用
array_to_list函数将该数组转换为链表。由于是向前端添加,转换过程如下:- 初始头节点为
2。 - 添加
3,新链表为3 -> 2。 - 添加
5,新链表为5 -> 3 -> 2。 - ... 以此类推。
- 最终链表顺序为
9 -> 8 -> 7 -> 5 -> 3 -> 2。
- 初始头节点为
- 调用
print_list函数打印链表,输出结果为9 8 7 5 3 2。
总结

本节课中我们一起学习了C语言链表的核心实现与操作。我们首先定义了链表的自引用结构体,然后逐步实现了创建节点、判断空链表、向头部添加元素、将数组转换为链表以及遍历打印链表的功能。这些代码模式是处理链表和更复杂指针结构的基础。请务必理解 malloc 如何分配内存、next 指针如何连接节点形成链式结构,并通过反复练习来掌握这些关键概念。
015:列表处理细节

在本节课中,我们将深入学习线性链表的处理细节,并介绍一系列基础且重要的操作。我们将通过递归和指针操作来实现这些功能。
概述
本节我们将探讨线性链表的四个核心操作:计数、连接、插入和删除。这些操作是理解和使用链表数据结构的基础,能帮助我们更好地掌握指针、递归以及动态内存分配(如malloc)的运用。
计数操作
首先,我们来看如何计算链表中元素的数量。这个操作非常简单,其核心思想是遍历链表,每经过一个节点就将计数器加一,直到遇到表示链表结束的哨兵指针(NULL)。
我们可以通过递归或迭代(例如使用while循环)来实现。这里我们将展示递归的实现方式。
递归实现思路:
- 基线条件:如果当前节点是
NULL(即空链表或已到达链表末尾),则返回0。 - 递归步骤:如果当前节点不为
NULL,则返回1 + count(下一个节点)。
以下是该逻辑的伪代码描述:
int count(Node* head) {
if (head == NULL) {
return 0; // 基线条件:空链表
} else {
return 1 + count(head->next); // 递归步骤
}
}
通过这种方式,递归调用会遍历整个链表,并在返回过程中累加出总节点数。
连接操作
上一节我们介绍了如何遍历和计数链表。本节中,我们来看看如何将两个链表连接(拼接)在一起。连接操作是指将第一个链表(H1)的末尾与第二个链表(H2)的开头链接起来。
连接操作的前提是第一个链表H1非空。其递归实现思路如下:
- 我们沿着H1链表递归前进,直到找到其最后一个节点(该节点的
next指针为NULL)。 - 然后,将这个最后一个节点的
next指针指向第二个链表H2的头节点。
以下是该过程的图示说明(请根据描述想象或绘制):
H1: [A] -> [B] -> [C] -> NULL
H2: [X] -> [Y] -> [Z] -> NULL
连接后:
[A] -> [B] -> [C] -> [X] -> [Y] -> [Z] -> NULL
在代码中,我们通常会使用assert宏来确保H1非空(这是一个前置条件检查)。作为练习,你可以尝试修改代码,使其在H1为空链表时也能正确工作(此时应直接返回H2作为新链表的头)。
插入操作
连接操作是将整个链表拼接起来,而插入操作则是在链表的特定位置加入一个单独的节点。与数组的插入相比,链表的插入通常更高效,因为它不需要移动大量元素。
假设我们有一个由节点P1和P2组成的相邻对(即P1->next == P2),我们希望在它们之间插入一个新节点Q。
插入操作的步骤如下,这些步骤保证了操作的原子性和正确性:
- 首先,确保P1和P2确实是相邻节点(这是一个前提条件)。
- 然后,将新节点Q的
next指针指向P2。 - 最后,将P1的
next指针指向Q。
用代码逻辑表示如下:
assert(P1->next == P2); // 验证P1和P2相邻
Q->next = P2; // 步骤2
P1->next = Q; // 步骤3
完成这三步后,节点Q就成功地被插入到了P1和P2之间。
总结
本节课中我们一起学习了线性链表的四个基本操作。
- 我们使用递归实现了计数功能,以确定链表的长度。
- 我们探讨了如何将两个链表连接成一个。
- 我们详细分析了如何在链表中插入一个新节点,并理解了其相对于数组操作的优势。

这些操作是构建更复杂链表功能(如排序、查找)的基石。在接下来的课程中,我们将继续探讨删除操作的实现细节。
016:二叉树介绍

概述
在本节课中,我们将要学习一种重要的抽象数据结构——树,并重点介绍其特例:二叉树。我们将了解二叉树的基本结构、特性及其在高效存储和检索数据方面的应用。
树的基本概念
上一节我们介绍了数据结构的重要性,本节中我们来看看树这种结构。树是一种抽象数据结构,它包含一个根节点、若干分支以及叶子节点。这种结构类似于自然界中的树,有助于我们形象化地理解数据组织方式。
二叉树简介
在众多树结构中,二叉树是最重要且应用最广泛的一种。顾名思义,二叉树中的每个节点最多有两个后代,分别称为左后代和右后代。
以下是二叉树节点的典型C语言结构体表示:
struct TreeNode {
int data; // 节点存储的数据
struct TreeNode* left; // 指向左子节点的指针
struct TreeNode* right; // 指向右子节点的指针
};
二叉树的结构与特性
让我们更具体地观察二叉树。一个完整的二叉树意味着每个内部节点都有两个后代。
考虑一个具有两层的完整二叉树:
- 根节点为10。
- 其左后代为6,右后代为18。
- 6和18作为叶子节点,没有后代。
这样的树总共有 1(根)+ 2(第一层)+ 4(第二层)= 7 个节点。更一般地,对于一个有 L 层的完整二叉树,其节点总数可以通过以下公式计算:
总节点数 = 2^0 + 2^1 + ... + 2^(L-1) = 2^L - 1
由此可见,二叉树的规模随着层数呈指数级增长。
二叉树的排序特性与效率
二叉树的一个关键特性是可以在节点中存储值,并遵循特定的排序规则。例如,在上图的树中,左后代(6)的值小于其父节点(10),而右后代(18)的值大于父节点。
这种排序特性使得二叉树在存储和检索数据时非常高效。对于包含 N 个数据元素的排序二叉树,许多操作(如查找、插入)的时间复杂度可以达到 O(log₂ N)。这意味着随着数据量 N 的增大,所需操作步骤的增长速度远慢于线性增长,从而实现了高性能的数据管理。

总结
本节课中我们一起学习了二叉树的基本概念。我们了解到二叉树是一种每个节点最多有两个后代的树结构,常用结构体与指针实现。完整的二叉树其节点数随层数指数增长。当二叉树节点数据遵循排序规则时,它能以对数时间复杂度高效地进行数据操作,这是其在计算机科学中备受重视的原因。对于希望深入学习C++或从事专业开发的学者而言,掌握二叉树至关重要。
017:二叉树代码详解

概述
在本节课中,我们将学习一种非常重要的数据结构——二叉树。我们将通过详细的C语言代码,了解如何定义二叉树节点、如何遍历二叉树,以及如何从数组数据递归地构建一棵二叉树。本课程内容面向希望深入学习计算机科学或C++序列的同学,其中会大量运用递归和指针的概念。
二叉树节点定义与类型别名
上一节我们介绍了二叉树的重要性,本节中我们来看看如何在C语言中定义它的基本结构。
为了简化代码并提高可读性,我们使用 typedef 来创建类型别名。首先,我们定义一个字符类型作为节点存储的数据。在实际应用中,数据可以很复杂,但在这个简单的例子中,我们只在节点中存储一个字符。
typedef char DataType;
接下来,我们定义二叉树节点的结构。它类似于链表节点,但关键区别在于每个节点包含两个指针,分别指向左子树和右子树。
struct node {
DataType data;
struct node *left;
struct node *right;
};
为了使代码更简洁,我们为这个结构体及其指针创建类型别名。
typedef struct node Node;
typedef Node* BTree;
这里,BTree 被定义为一个指向 Node 的指针。在C语言中,我们通常用一个指向树根节点的指针来代表整棵二叉树。
二叉树的遍历:中序遍历
定义了树的结构后,我们需要一种方法来访问树中的所有节点。以下是遍历二叉树的一种常见方式——中序遍历。
中序遍历遵循“左-根-右”的顺序。其递归逻辑是:如果当前节点为空(NULL),则返回;否则,先递归遍历左子树,然后访问当前节点的数据,最后递归遍历右子树。
void inorderTraversal(BTree root) {
if (root == NULL) {
return;
}
inorderTraversal(root->left); // 遍历左子树
printf("%c ", root->data); // 访问根节点数据
inorderTraversal(root->right); // 遍历右子树
}
通过这种方式,所有左子树的节点都会在右子树节点之前被访问。
构建二叉树:节点创建与初始化
要使用二叉树,我们首先需要能够创建节点。这涉及到从堆内存中动态分配空间。
我们使用 malloc 函数来为一个新节点分配内存。sizeof(Node) 运算符能确保我们获得足够存储一个 Node 结构体的内存空间。
BTree createNode() {
BTree newNode = (BTree)malloc(sizeof(Node));
return newNode;
}
分配内存后,我们需要一个函数来初始化这个新节点的各个字段:数据、左指针和右指针。
BTree initNode(DataType value, BTree leftChild, BTree rightChild) {
BTree node = createNode();
node->data = value;
node->left = leftChild;
node->right = rightChild;
return node;
}
这个函数接收数据值和左右子树的指针作为参数,填充新节点的内容,并返回指向该节点的指针。
从数组递归构建完整二叉树
现在,我们将学习如何利用数组中的数据递归地构建一整棵二叉树。这是一种将线性数据转换为树形结构的有效方法。
createTree 函数递归地将数组元素构建成二叉树。其核心思想是利用完全二叉树的性质:对于数组中索引为 i 的元素,其左子节点在数组中的索引是 2*i + 1,右子节点索引是 2*i + 2。
以下是该函数的实现步骤:
- 检查递归基线条件:如果当前索引
i超出了数组大小size,则返回NULL。 - 否则,为当前数组元素
data[i]创建一个新节点。 - 递归地为左子节点(索引
2*i+1)和右子节点(索引2*i+2)调用createTree函数。 - 将递归调用返回的子树指针赋值给当前节点的
left和right成员。 - 返回当前构建好的节点指针。
BTree createTree(DataType data[], int i, int size) {
if (i >= size) {
return NULL; // 基线条件:超出数组范围
}
// 为当前数据创建节点,并递归创建其左右子树
BTree leftChild = createTree(data, 2*i + 1, size);
BTree rightChild = createTree(data, 2*i + 2, size);
return initNode(data[i], leftChild, rightChild);
}
代码演示与执行结果
让我们通过一个具体的例子,将以上所有概念串联起来,看看它们是如何协同工作的。
在 main 函数中,我们首先创建一个包含5个字符的数组。
int main() {
DataType data[] = {'A', 'B', 'C', 'D', 'E'};
int size = 5;
接着,我们调用 createTree 函数,从数组索引0开始构建二叉树。
BTree myTree = createTree(data, 0, size);
树构建完成后,我们使用 inorderTraversal 函数对这棵树进行中序遍历并打印结果。
printf("Inorder traversal: ");
inorderTraversal(myTree);
printf("\n");
return 0;
}
执行结果:
当运行这段代码时,控制台会输出中序遍历的结果:D B E A C。如果你手动绘制并遍历这棵根据数组 [A, B, C, D, E] 构建的二叉树,会得到相同的顺序,这验证了我们代码的正确性。
总结
本节课中我们一起学习了二叉树在C语言中的实现。我们从定义节点结构和类型别名开始,讲解了中序遍历的递归方法。然后,我们深入探讨了如何使用 malloc 动态创建节点,以及如何从数组数据出发,递归地构建一整棵二叉树。最后,通过一个完整的代码示例,我们看到了这些部分如何组合在一起工作。

二叉树是一种极其重要的数据结构,因为它能使某些操作(如搜索)在对数时间复杂度内完成,相比之下,在普通数组中执行相同操作可能需要线性时间复杂度。因此,对于处理大量数据且需要高效查询的问题,二叉树是非常理想的选择。理解这些基础实现,将为学习更高级的数据结构和算法(例如在C++标准模板库中提供的树结构)打下坚实的基础。
018:更高级的输入输出

概述
在本节课中,我们将学习C语言中更高级的输入输出功能,特别是printf函数的格式化输出。我们将超越基础的屏幕和键盘输入输出,探索如何通过格式说明符和修饰符来控制输出的外观,并理解其与操作系统文件I/O的联系。
从基础到进阶
上一节我们介绍了基础的printf和scanf函数。本节中,我们将深入了解printf的格式化能力。
printf函数总是包含一个控制字符串。这个字符串用引号括起,它既可以直接指定屏幕上打印的文本,也可以包含带有百分号%的转换说明符,用于转换后续逗号分隔的参数列表中的值。
一个典型的例子是:
printf("sum of x and y is %d and %d equals %d", x, y, x+y);
如果x是3,y是5,那么屏幕上将显示:sum of x and y is 3 and 5 equals 8。其中,%d用于将整数参数转换为十进制格式输出。
格式说明符与修饰符
除了我们常用的%d(整数)、%f(浮点数)、%c(字符)和%s(字符串)之外,printf还支持更多转换说明符和修饰符,以提供更精细的输出控制。
例如,%%用于打印一个百分号字符本身,而不是进行转换。
以下是格式说明符修饰符和控制代码的一些示例,这些概念能极大地增强输出的可读性和格式。
宽度与对齐修饰符
宽度修饰符可以控制输出字段的最小宽度。如果输出内容短于指定宽度,则会用空格(或指定的填充字符)填充。
%2c:打印一个字符,并确保其占据至少2个字符的宽度。如果字符是'A',输出为" A"(右对齐,左侧填充空格)。%-2c:同样打印字符并确保2字符宽度,但使用-表示左对齐。对于'A',输出为"A "。
整数填充与浮点数格式
修饰符也可以用于改变数字的呈现方式。
%5d:打印整数,并确保其占据至少5个字符的宽度。对于数字123,输出为" 123"。%05d:打印整数,并确保其占据至少5个字符的宽度,不足部分用前导零填充。对于数字123,输出为"00123"。%12.5e:以指数形式打印浮点数。字段总宽度为12个字符,其中小数部分保留5位精度。对于数字0.123456789,输出可能类似于" 1.23457e-01"(注意前导空格以满足宽度要求)。
清晰、格式良好的输出是高质量代码的标志。理解这些修饰符的最佳方式是通过实践。
代码示例与实践
让我们通过一个具体的程序来演示上述概念。
#include <stdio.h>
int main() {
double x = 0.123456789;
printf("General printing ideas:\n");
printf("x with %%12.5e format: %12.5e\n", x);
printf("x with %%e format: %e\n", x);
printf("x with %%10.5f format: %10.5f\n", x);
printf("x with %%d (incorrect!): %d\n", x); // 错误用法
return 0;
}
运行此程序,观察不同格式说明符下的输出:
%12.5e:产生格式化的指数输出,宽度为12,精度为5。%e:产生默认的指数输出,通常具有更高的精度。%10.5f:产生固定小数点的浮点数输出,宽度10,小数点后5位。%d:这是一个错误用法,试图用整数格式打印浮点数。这会导致不可预测的输出(通常是无意义的数字),因为它错误地解释了浮点数在内存中的二进制表示。
修正类型不匹配的错误
要正确地将浮点数作为整数打印,需要先进行显式类型转换。
printf("x cast to int with %%d: %d\n", (int)x); // 正确用法
这样修改后,程序将输出0,因为(int)0.123456789的结果是0。

总结
本节课中我们一起学习了printf函数更高级的格式化输出功能。我们探讨了如何使用各种格式说明符(如%d, %f, %e)以及宽度、精度、对齐等修饰符来控制输出的布局和外观。我们还通过实例看到了错误使用格式说明符(如用%d打印浮点数)会导致的问题,并学会了通过类型转换来修正它。掌握这些技巧将使你能够生成更清晰、更专业的数据输出。
019:文件输入输出介绍

概述
在本节课中,我们将要学习C语言中一个非常实用且重要的主题:文件输入输出。通过文件IO,你的程序能够与计算机的文件系统进行交互,从而读取或存储大量数据,这极大地扩展了程序的实用性。
文件IO的基本概念
上一节我们介绍了数组和循环等基本数据处理方法。本节中我们来看看如何从外部文件获取数据,而不是依赖键盘输入。
假设你有一个存储了作业分数的数据文件。这个文件可能由你使用文本编辑器创建。计算机可以电子化地存储大量数据。现在,你想计算这些分数的平均值,甚至找出最大值和最小值。
如果数据已经存在于一个C语言数组中,我们知道如何使用循环来处理它并计算平均值。但现在的目标是从文件中将这些数据读入程序。
核心函数:fscanf
从文件读取数据的关键是使用标准IO库中的 fscanf 函数。
我们通常使用 scanf 从键盘读取数据。fscanf 与之类似,但它需要一个额外的参数来指定数据来源——一个文件指针。
fscanf 的函数原型如下:
int fscanf(FILE *stream, const char *format, ...);
第一个参数 stream 是文件指针。第二和第三个参数与 scanf 相同,分别是格式字符串和变量地址列表。scanf 默认从标准输入读取,而 fscanf 功能更通用,可以从任何已打开的文件读取。
文件的打开与关闭
在使用文件之前,必须首先打开它。系统可能对文件有访问权限限制,因此我们需要指定打开模式。
以下是打开文件的核心步骤:
- 声明文件指针:例如
FILE *ifp;。 - 使用
fopen函数打开文件:该函数需要两个参数:文件名和打开模式。 - 检查打开是否成功:
fopen在失败时会返回NULL。
fopen 函数的基本用法如下:
FILE *ifp;
ifp = fopen("my_homework_scores.txt", "r");
其中,"r" 表示以只读模式打开文件。其他常用模式包括:
"w":写入模式(会创建新文件或清空已有文件)。"a":追加模式(在文件末尾添加数据)。
系统对一个用户同时打开的文件数量有限制(通常在20个左右)。因此,良好的编程习惯是在文件使用完毕后将其关闭,以释放系统资源。
关闭文件使用 fclose 函数:
fclose(ifp);
即使程序结束时未显式关闭,系统通常也会关闭所有打开的文件,但主动关闭是一个好习惯。
实践流程总结
本节课中我们一起学习了C语言文件输入输出的基础知识。核心流程可以概括为以下几步:
- 使用
fopen函数并指定正确模式来打开文件。 - 使用
fscanf函数从打开的文件指针中读取数据。 - 像处理普通变量一样,在程序中使用读取到的数据。
- 使用
fclose函数关闭文件。

在接下来的课程中,我们将通过实际编写代码来演示这一完整流程。通过动手修改和运行示例代码,你将掌握文件IO所需的大部分核心知识。
020:基本文件输入输出代码

概述
在本节课中,我们将学习如何使用C语言进行基本的文件输入输出操作。我们将通过一个具体的示例程序,演示如何从文件中读取数据(例如家庭作业成绩),将其存储到数组中,并计算平均值。核心内容包括使用 fopen 打开文件、使用 fscanf 读取数据,以及如何组织代码以实现模块化。
代码结构与准备工作
上一节我们介绍了文件操作的基本概念,本节中我们来看看如何将这些概念应用到实际代码中。
首先,我们假设有一个名为 myHW 的文件,其中包含一系列家庭作业成绩。我们的程序将读取这些成绩,计算平均值,并输出结果。程序被设计为最多处理20个成绩。
以下是程序的主要组成部分:
- 一个用于读取数据的函数。
- 一个用于打印数据的函数。
- 一个用于计算平均值的函数。
主程序分析
让我们先查看主函数 main 的职责。它负责协调整个程序的流程。
主函数中定义了以下变量:
- 一个循环索引
i。 - 一个整数
size,初始值设为20,代表数组的最大容量。 - 一个数组
data,用于存储从文件读取的成绩。 - 一个文件指针
ifp,用于指向我们要操作的文件。
程序的第一步是打开文件。代码如下:
ifp = fopen("myHW", "r");
这行代码尝试打开当前目录下名为 myHW 的文件,模式 "r" 表示以只读方式打开。如果文件打开成功,ifp 将指向该文件。
接着,程序调用 read_data 函数,将文件指针 ifp、数组 data 和变量 size 的地址传递给它。该函数将读取文件内容,填充数组,并通过 size 返回实际读取的成绩数量。
然后,程序调用 print_data 函数来显示读取到的所有成绩。
最后,程序调用 compute_average 函数来计算并打印平均成绩。作为良好的编程习惯,程序在结束前使用 fclose(ifp) 关闭了文件。
核心函数详解
计算平均值函数
compute_average 函数的逻辑很直接。它接收一个数组和其大小作为参数。
其工作原理是:
- 初始化一个
double类型的变量avg为0。 - 遍历数组,将所有元素累加到
avg中。 - 将累加和除以元素数量,得到平均值。
公式表示为:avg = (Σ data[i]) / size
打印数据函数
print_data 函数负责格式化输出数组内容。
其逻辑是:
- 遍历数组,依次打印每个元素。
- 为了保持输出整洁,每打印10个成绩就换一次行。
读取数据函数
read_data 函数是本教程的核心,它演示了如何使用 fscanf 从文件读取数据。
函数接收一个文件指针 ptr、一个数组 d 和一个指向整数(size)的指针。size 需要以指针形式传递,以便函数能修改其值,向调用者返回实际读取的数据量。
函数内部,先将 *size 设为0。然后进入一个循环,在循环中调用:
fscanf(ptr, "%d", &d[*size])
fscanf 的工作方式与 scanf 类似,但第一个参数指定了从哪个文件读取。"%d" 指示读取一个整数,&d[*size] 提供了存储该整数的内存地址。
fscanf 的返回值很重要:成功读取并转换一个数据时返回1,失败(如遇到文件结尾)时返回0。因此,循环条件 while (fscanf(...) == 1) 确保我们能持续读取数据,直到文件结束。
每成功读取一个数据,就将 (*size) 的值增加1。循环结束后,*size 的值就是文件中成绩的总数。
程序运行演示
现在,让我们在终端中编译并运行这个程序。假设 myHW 文件内容为:98, 67, 89, 56, 73, 29, 4。
程序运行后,输出结果如下:
我的7个家庭作业成绩是:98 67 89 56 73 29 4
平均成绩是:79.857143
输出显示,程序正确地读取了全部七个成绩,并计算出了精确的平均值。
总结
本节课中我们一起学习了C语言文件输入输出的基本操作。我们重点掌握了:
- 使用
fopen函数打开文件。 - 使用
fscanf函数从指定文件中读取格式化数据。 - 通过函数参数传递文件指针和数组,实现模块化的数据读取、处理和输出。
- 使用
fclose关闭文件以释放资源。

这个程序框架具有很强的扩展性。基于它,你可以轻松地添加新功能,例如查找最高分或最低分,而这些操作不再涉及文件I/O,只需对内存中的数组进行处理即可。主要的收获在于理解如何利用 fopen 和 fscanf 将文件数据方便地读入程序,从而替代标准输入(scanf),实现数据的持久化处理。
021:对文件进行双倍行距处理

在本节课中,我们将详细分析一段实现文件双倍行距处理的代码。我们将看到一些之前未涉及的元素,并学习如何通过命令行参数操作文件。
概述
我们将编写一个程序,读取一个输入文件,将其内容复制到输出文件,并在每个换行符后添加一个额外的换行符,从而实现双倍行距的效果。我们将学习使用标准库函数、处理命令行参数以及进行基本的文件输入输出操作。
代码详解
上一节我们介绍了双倍行距的基本概念,本节中我们来看看具体的实现代码。
首先,我们需要包含标准库,因为我们将使用一个名为 exit 的函数来退出程序。
#include <stdlib.h>
一个简单的文件打印程序
以下是打印文件内容的程序,它展示了逐字符处理文件的标准模式。
void printFile(FILE *fp) {
rewind(fp); // 确保从文件开头开始
int c;
while ((c = getc(fp)) != EOF) {
putchar(c); // 将字符输出到屏幕(标准输出)
}
}
这个 while 循环非常标准,你应该习惯编写它。它允许你一次处理一个字符,这对于处理文本文件尤其有用。
双倍行距处理程序
现在,我们来看用于处理输入文件并生成输出文件的核心程序。其逻辑非常简单:复制每个字符,并在遇到换行符时额外输出一个换行符。
void doubleSpaceFile(FILE *in, FILE *out) {
rewind(in); // 确保从输入文件开头开始处理
int c;
while ((c = getc(in)) != EOF) {
putc(c, out); // 将字符写入输出文件
// 如果当前字符是换行符,则再写入一个换行符
if (c == '\n') {
putc('\n', out);
}
}
}
这就是实现双倍行距的全部逻辑。对于输入文件中的每一个换行符,我们在输出文件中写入第二个换行符。
处理命令行参数
我们的程序需要从命令行接收输入和输出文件名。以下是处理命令行参数的部分。
int main(int argc, char *argv[]) {
// 检查参数数量:程序名、输入文件名、输出文件名
if (argc != 3) {
printf("用法:需要可执行程序名、输入文件和输出文件。\n");
exit(1);
}
// 打开输入文件用于读写("r+"模式)
FILE *inFile = fopen(argv[1], "r+");
// 打开输出文件用于读写("w+"模式)
FILE *outFile = fopen(argv[2], "w+");
// 打印输入文件内容(标题)
printf("输入文件 %s 的内容:\n", argv[1]);
printFile(inFile);
printf("\n\n"); // 打印双倍空行分隔
// 进行双倍行距处理
doubleSpaceFile(inFile, outFile);
// 打印输出文件内容(标题)
printf("输出文件 %s 的内容(双倍行距):\n", argv[2]);
rewind(outFile); // 将输出文件指针重置到开头
printFile(outFile);
// 关闭文件,释放资源
fclose(inFile);
fclose(outFile);
return 0;
}
以下是关于命令行参数和文件模式的关键点:
argc表示参数数量,argv是一个字符串指针数组。- 文件模式
"r+"允许读取和写入文件。 - 文件模式
"w+"会创建新文件(或清空已有文件)并允许读取和写入。
运行示例
程序编译后,可以通过命令行运行。例如:
./a.out homework.c homework_double_spaced.c
程序会先显示原始文件内容,然后显示经过双倍行距处理后的新文件内容。你可以使用 cat 命令(Unix/Linux系统)来验证输出文件确实包含了双倍行距的文本。
总结
本节课中我们一起学习了如何用C语言实现文件的双倍行距处理。我们掌握了以下核心技能:
- 使用
getc和putc进行逐字符的文件读写。 - 利用标准的
while ((c = getc(fp)) != EOF)循环遍历文件。 - 通过
argc和argv处理命令行参数。 - 使用
fopen配合不同模式(如"r+","w+")打开文件。 - 在程序结束时使用
fclose关闭文件。

你所看到的这些代码模式可以反复使用。这个应用虽然简单,但其中包含了文件输入输出90%的核心操作。一旦掌握了这些,你就具备了进行主要文件I/O操作所需的所有知识。
022:main、argc、argv的使用

概述
在本节课中,我们将要学习C语言中main函数的两个特殊参数argc和argv的用法。它们是处理命令行参数的关键,使我们能够从命令行向程序传递信息,例如输入和输出文件的名称。
文件I/O的重要性
文件输入/输出操作非常重要。大多数严肃的数据处理都涉及大量数据,这些数据通常存储在文件中。同样,程序的输出结果也常常需要保存到文件中,而不是打印在纸上。
通常的操作模式是:读取一个输入文件,对其进行处理,然后生成一个输出文件。例如,你可能有一组记录,需要根据某个字段按字母顺序排序。这时,你需要读取一个文件,处理数据,并输出排序后的结果。
命令行参数的工作原理
让我们看看这样的代码如何工作。通常,我们希望程序能像这样运行:处理一个输入文件,得到一个输出文件。我们希望输入文件和输出文件的名称可以作为命令行参数传递。
例如,我们以实现“文件双倍行距”功能为例。我们希望这样调用程序:double_space 输入文件名 输出文件名。这就是我们的命令行指令。
因此,我们需要一种方法,让编译后的C代码能够接收并执行命令行上的参数。C语言确实提供了这种功能。
main函数的参数
通常,我们使用的是int main(void)或空的main()函数,它们不接受参数。但main函数实际上可以接受两个特殊参数:
- 一个整数参数
argc(参数计数) - 一个字符串数组
argv(参数向量)
argc用于统计在命令行中看到了多少个参数项。其中,第一个参数项通常是可执行程序本身的名称。因此,我们最终会有一个类似double_space的可执行文件,它将处理一个需要打开用于读取的文件(例如file1),并生成第二个文件(例如file2)。输出文件可能已经存在,或者会在默认的本地文件系统中被创建。
在代码中的实现
在代码内部,我们将使用fopen函数来打开文件:
- 输入文件:
FILE *fp_in = fopen(argv[1], "r"); - 输出文件:
FILE *fp_out = fopen(argv[2], "w");
这是最基本的操作。编译后,我们就能处理输入并生成输出。
其他细节
在实现过程中,我们还会用到一些其他的文件函数。例如,rewind函数允许我们返回到文件的开头。有时在处理文件的过程中,我们可能会移动到文件的不同位置,这时就需要使用rewind来重置文件指针。
当然,对于每一个fopen操作,我们都应该有对应的fclose操作,以确保良好的文件操作习惯,防止资源泄漏。

总结
本节课中,我们一起学习了C语言main函数的参数argc和argv。我们了解了它们如何用于从命令行接收参数,特别是输入和输出文件的路径。我们还简要介绍了在文件处理中会用到的基本函数,如fopen、fclose和rewind。掌握这些知识是进行文件操作和编写实用命令行程序的基础。
023:荣誉课程 - 带删除功能的列表处理代码

在本节课中,我们将学习一个综合性的链表处理代码示例。该示例整合了之前课程中介绍的所有核心概念,包括链表的创建、遍历、转换、打印以及删除操作。本节内容属于荣誉课程部分,旨在为有兴趣深入学习计算机科学和后续C++课程的同学提供更高级的编程视角。对于初学者而言,理解其基本思想即可,掌握数组的使用仍然是当前阶段的首要目标。
链表基础结构定义
首先,我们定义链表的基本结构。链表中的每个元素包含一个数据域和一个指向下一个元素的指针。
typedef struct node {
int data;
struct node* next;
} Node;
判断链表是否为空
判断链表是否为空是一个基础操作。我们通过检查指向链表头部的指针是否为 NULL 来实现。
int is_empty(Node* head) {
return head == NULL;
}
创建链表元素
要创建一个链表元素或一个单元素链表,我们需要使用 malloc 函数从堆中分配内存。
Node* create_node(int data) {
Node* new_node = (Node*)malloc(sizeof(Node));
if (new_node != NULL) {
new_node->data = data;
new_node->next = NULL;
}
return new_node;
}
在链表头部添加元素
在链表头部添加元素涉及创建一个新节点,并将其链接到原链表的头部。
Node* add_to_front(Node* head, int data) {
Node* new_node = create_node(data);
if (new_node != NULL) {
new_node->next = head;
head = new_node;
}
return head;
}
将数组转换为链表
将数组转换为链表是一个常见操作。其基本思想是遍历数组,并依次将每个元素添加到链表的头部。
Node* array_to_list(int arr[], int size) {
Node* head = NULL;
for (int i = size - 1; i >= 0; i--) {
head = add_to_front(head, arr[i]);
}
return head;
}
打印链表内容
打印链表内容的标准方法是遍历链表,直到遇到 NULL 指针为止。
void print_list(Node* head) {
Node* current = head;
while (current != NULL) {
printf("%d -> ", current->data);
current = current->next;
}
printf("NULL\n");
}
从链表中删除元素
删除链表中的元素是本教程中最复杂的部分。它需要处理两种可能的情况:删除头节点或删除内部节点。为了能够修改调用函数中的头指针,我们需要使用指向指针的指针。
void delete_node(Node** head_ref, Node* node_to_delete) {
// 处理空链表或空节点的情况
if (*head_ref == NULL || node_to_delete == NULL) {
return;
}
// 如果要删除的节点是头节点
if (*head_ref == node_to_delete) {
*head_ref = node_to_delete->next;
free(node_to_delete);
return;
}
// 如果要删除的节点是内部节点,需要找到其前驱节点
Node* current = *head_ref;
while (current != NULL && current->next != node_to_delete) {
current = current->next;
}
// 如果找到了前驱节点,则重新链接链表
if (current != NULL) {
current->next = node_to_delete->next;
free(node_to_delete);
}
}
递归删除整个链表
我们可以使用递归的方式优雅地删除整个链表。
void delete_list(Node* head) {
if (head == NULL) {
return;
}
delete_list(head->next); // 递归删除剩余部分
free(head); // 释放当前节点
}
链表拼接
链表拼接操作将第二个链表连接到第一个链表的末尾。
Node* concatenate(Node* list1, Node* list2) {
if (list1 == NULL) {
return list2;
}
Node* current = list1;
while (current->next != NULL) {
current = current->next;
}
current->next = list2;
return list1;
}
测试代码
以下是如何使用上述函数进行测试的示例。
int main() {
int data1[] = {2, 3, 5, 7, 8, 9};
int data2[] = {176, 99}; // 编译器会自动推断数组大小为2
Node* head1 = array_to_list(data1, 6);
Node* head2 = array_to_list(data2, 2);
printf("List 1: ");
print_list(head1);
printf("List 2: ");
print_list(head2);
head1 = concatenate(head1, head2);
printf("Concatenated List: ");
print_list(head1);
// 删除头节点
delete_node(&head1, head1);
printf("After deleting head: ");
print_list(head1);
// 删除内部节点(例如第二个节点)
if (head1 != NULL && head1->next != NULL) {
delete_node(&head1, head1->next);
printf("After deleting second node: ");
print_list(head1);
}
delete_list(head1); // 清理内存
return 0;
}
总结

本节课我们一起学习了链表数据结构的综合操作。我们从基础的结构定义开始,逐步实现了创建、遍历、转换、打印以及删除等核心功能。重点和难点在于理解并使用指向指针的指针来修改链表头,以及处理删除节点时的边界条件。虽然这部分内容属于荣誉课程,较为深入,但它揭示了底层数据管理的核心思想。对于日常编程,许多现代语言已经提供了封装好的链表实现。掌握这些原理将为你未来学习更高级的数据结构和算法打下坚实的基础。
024:C++介绍

概述
在本节课程中,我们将初步了解C++语言。我们将探讨C++与C语言的关系,介绍C++的一些基本优势,并简要回顾其历史背景。通过学习,你将理解为何C++被视为C语言的现代扩展,以及它如何使编程变得更加容易。
C++与C语言的关系
上一节我们介绍了C语言的核心概念,本节中我们来看看C++。在某种程度上,C++可以被视为一种现代化的C语言。将代码从C迁移到C++有许多好处。
首先,C++兼容C语言。这意味着你无需进行大量修改,就能享受到现代语言特性带来的便利,这些特性提供了更高层次的编程视角,从而简化了编码过程。
其次,许多同学可能对后续课程“面向C程序员的C++”感兴趣。学习C++有两个主要好处:一是能快速掌握C++的一些优秀特性;二是有助于顺利过渡到我在Coursera上提供的后续系列课程。
转向C++的优势
那么,转向C++有哪些具体的好处呢?
好消息是,C语言本质上是C++的一个子集。虽然这不完全准确(在C++模式下运行某些C代码可能无法工作或语义不同),但大体上,绝大多数内容是相同的。C++提供了许多使编程更简单的内存管理改进。
C++有时名声不佳,原因在于它极其复杂。当你包含所有库和模板等结构时,它是一个非常庞大的语言。然而,在大多数情况下,你并不需要那么多复杂的功能,或者可以轻松使用其非常基础的部分,这仍然能使你的编码更容易。我们将在本介绍中看到这一点。
第三点是,C++编译器通常与C编译器捆绑在一起。例如,在GNU编译器系统中,你可以使用G++而不是GCC来运行你的代码,就像它是C++代码一样。
更简单的输入输出
接下来,我们来看看C++如何使输入输出比C语言更简单。
C++的输出可以这样写:
cout << "C++ is an improved C";
这行代码将字符串“C++ is an improved C”发送到屏幕。注意,这里没有使用printf函数。从直观上看,它的意思是“取这个并把它移到那里”,cout代表标准输出。从技术上讲,这个移位操作被“重载”以提供输出行为。
类似地,输入也更简单:
cin >> miles;
这表示从标准输入(你的键盘)获取数据。如果你输入一个值,它将被适当地转换为miles变量。同样,这里不需要格式说明符或取地址符&。这种方式非常直观。我们将在后续代码中演示这一点。
C++的起源与发展
在深入代码之前,让我们先向C++的发明致敬。
简要的行业历史是,C++诞生于贝尔实验室,由计算机科学家Bjarne Stroustrup在1985年使其成熟。他最初称之为“带类的C”。作为一名斯堪的纳维亚的学生,他接触过一种名为Simula的语言,这是第一种处理抽象数据类型(即类结构)的原始语言。请记住,我们编程的很多工作就是为代码提供结构,而结构有助于封装、防止低级错误。
Simula是第一种真正拥有类的语言,Stroustrup对此非常着迷。但当时他是贝尔实验室的员工,而贝尔实验室的一切基本上都是用C语言完成的,因此他将类嵌入到C中,将它们结合起来。
这样做的好处是免费的。贝尔实验室当时作为一家电话公司(在当时是垄断企业),免费提供了C和Unix系统等资源,这是它回馈社会的一种方式。C++基于C,因此已经了解C的人几乎可以立即添加他们认为合适的功能。同时,它效率很高。当时另一个拥有类的主要竞争语言是Smalltalk,但Smalltalk运行成本非常高,非常耗时。C直接编译为本地代码,效率一直很高,而Smalltalk则编译为解释性代码。
此后,C++不断演进。目前最稳定、大多数人使用的是C++11或更高级的版本。这也将是我们在“面向C程序员的C++”课程中讨论的语言形式。

总结
本节课中,我们一起学习了C++的基本介绍。我们了解了C++与C语言的紧密联系及其作为现代C语言扩展的地位,认识了C++在输入输出方面的语法简化,并回顾了其诞生于贝尔实验室的历史。核心要点在于,C++在保持C语言高效性的同时,通过引入更高层次的抽象(如类)和更简洁的语法(如cout/cin),使得编程变得更加容易和安全,为后续深入学习面向对象编程奠定了良好的基础。
025:第一个C++程序示例

概述
在本节课中,我们将学习如何编写一个简单的C++程序。我们将通过一个将英里转换为公里的示例程序,了解C++与C语言的一些基本区别,包括注释、头文件、命名空间、常量以及输入输出流的使用。
C++代码示例与解析
上一节我们介绍了C++的背景,本节中我们来看看一个具体的C++程序示例,并解析其关键组成部分。
注释
C++引入了单行注释,这种形式现在在ANSI C中也很常见。与C语言中的/* ... */块注释相比,单行注释使用//,它更简洁,并且避免了因忘记关闭*/而导致的错误。
头文件包含
在C++中,我们使用不同的方式包含头文件。例如,我们使用#include <iostream>,并且通常不添加.h后缀。C++标准库中的头文件通常没有.h。虽然也存在带.h的版本,但<iostream>是C++标准库中用于输入输出的部分。
你仍然可以包含C标准库的头文件,例如#include <cstdio>,然后使用printf和scanf。但在C++社区中,这通常不被认为是良好的编程风格。
命名空间
C++引入了命名空间的概念,这是一种封装标识符名称的方式。使用using namespace std;可以让我们省略标准库中标识符前面的std::前缀。
例如,我们将使用cout和cin,它们实际上来自标准库。如果不使用using namespace std;,我们就必须写成std::cout和std::cin。命名空间允许不同库(例如来自IBM的库)协同工作,而不会发生标识符名称冲突。
常量与内联函数
C++中使用了const关键字来声明常量,这意味着该标识符的值在程序中不能被改变。这有助于编译器进行静态类型检查,防止错误。
另一个有趣的改进是使用inline关键字。它告诉编译器尝试优化一个函数。当调用一个函数时,会有一些开销。inline建议编译器将函数代码直接插入到调用处,从而节省函数调用的开销。
最初,C++引入inline是为了避免使用C语言中的#define宏,因为宏是预处理器的概念,容易出错。内联函数可以进行语法检查,并且在调用时可能发生类型转换,而纯宏则不会。不过,现在许多编译器都有优化选项,可以自动决定是否内联,因此inline关键字的使用不再像以前那样必要。
主函数与变量声明
和C语言一样,我们使用int main(void)作为程序入口。在C++中,变量的声明不必一定放在代码块的开始处。你可以将声明放在最接近使用该变量的地方,这可以提高代码的可读性。
输入输出流
C++使用cin和cout进行输入输出,这比C语言的printf和scanf更简洁直观。endl不仅输出换行符,还会刷新输出缓冲区。你也可以使用\n来换行。
程序代码与运行
以下是完整的C++程序代码,它将英里数转换为公里数:
#include <iostream>
using namespace std;
const double KM_PER_MILE = 1.60934;
inline double milesToKm(double miles) {
return miles * KM_PER_MILE;
}
int main(void) {
int miles = 1;
while (miles != 0) {
cout << "Distance in miles (0 to terminate): ";
cin >> miles;
double km = milesToKm(miles);
cout << "Distance is " << km << " kilometers." << endl;
}
return 0;
}
编译与运行:
- 使用
g++编译器进行编译:g++ -o miles_to_km miles_to_km.cpp - 运行生成的可执行文件:
./miles_to_km
程序运行示例:
- 输入3英里,输出约为4.828公里。
- 输入6英里,输出约为9.654公里。
- 输入26英里(接近马拉松距离),输出约为41.842公里。
- 输入0即可终止程序。
总结
本节课中我们一起学习了如何编写第一个C++程序。我们了解了C++在注释、头文件、命名空间、常量声明、变量作用域以及输入输出流方面与C语言的区别。这个简单的英里转换程序展示了C++语法的一些基本优势,例如更清晰的I/O操作和更灵活的变量声明位置。

建议你尝试将自己编写的一个简单C语言程序,使用本节课介绍的C++特性进行改写,以加深理解。
026:C++中易于掌握的特性

概述
在本节课中,我们将学习C++语言中一些相较于C语言更易于掌握的特性。这些特性包括输入输出流的简化、标准库的扩展、更灵活的变量声明方式以及强大的标准模板库(STL)。掌握这些内容将帮助你更高效地编写C++程序。
输入输出与标准库的简化
上一节我们介绍了C语言的基础结构,本节中我们来看看C++如何简化输入输出操作并扩展标准库。
C++使用IO流(如cout和cin)进行输入输出,这比C语言的printf和scanf更直观。例如,C++中无需像scanf那样使用&符号来获取变量的地址。
此外,C++标准库包含了旧的C语言库,但通常以c为前缀命名,例如cmath或cstdio。
以下是C++输入输出的简单示例:
#include <iostream>
using namespace std;
int main() {
int x;
cout << "请输入一个数字: ";
cin >> x; // 无需使用&x
cout << "你输入的数字是: " << x << endl;
return 0;
}
更灵活的代码结构与声明
C++在代码结构和变量声明上提供了更大的灵活性。
首先,C++支持单行注释(//),这一特性现在也被大多数C编译器采纳。
其次,C++允许在for循环中直接声明循环变量,该变量的作用域仅限于循环内部。
更重要的是,变量声明可以出现在代码的任何位置,而不仅限于块的开头。这有助于提高代码的可读性,因为变量可以在靠近其使用位置的地方声明。
以下是相关特性的示例:
for (int i = 0; i < n; i++) { // i在此声明,作用域仅限于循环
// 使用i
}
// i在此处不可用
// 变量可以在需要时声明
int a = 10;
// ... 一些代码 ...
int b = 20; // 在靠近使用处声明
int sum = a + b;
类型推断与auto关键字
C++引入了类型推断机制,通过auto关键字,编译器可以自动推断变量的类型。
这类似于Python等语言中的“鸭子类型”。虽然对于简单类型,显式声明类型通常更清晰,但在处理C++中复杂的类型装饰时,使用auto让编译器推断类型会非常方便。
以下是使用auto的示例:
auto i = 3; // 编译器推断i为int类型
auto x = 3.0; // 编译器推断x为double类型
标准模板库(STL)与容器
C++一个重要的优势是其强大的标准模板库(STL)。STL提供了许多现成的容器类和高效算法。
STL中的容器类包括列表(list)、栈(stack)等。其中,向量(vector) 是最常用且功能强大的容器之一,它可以看作是数组的泛化和扩展。
向量支持随机访问(像数组一样通过索引访问),同时也提供了更安全、更简便的方式来遍历元素,有助于避免常见的“差一错误”。
以下是使用向量的示例:
#include <vector>
using namespace std;
// 声明一个存储整数的向量,并初始化为含有5个元素
vector<int> myVector(5);
// 像数组一样使用索引访问
myVector[0] = 10;
// 使用更安全的方式添加元素
myVector.push_back(20); // 在末尾添加元素20
向量比C语言中的原生数组更灵活、更安全,我们将在后续的代码演示中看到其具体优势。
总结

本节课中我们一起学习了C++语言中一些使编程更简单的特性。我们了解了更直观的IO流、更灵活的变量声明位置、用于类型推断的auto关键字,以及功能强大的标准模板库(STL),特别是向量(vector) 容器。这些特性旨在提高代码的可读性、安全性和开发效率。
027:使用新特性的C++程序

概述
在本节中,我们将学习如何利用C++语言中一些易于使用的新特性来编写程序。我们将重点关注auto关键字、内部声明以及在for循环中局部声明迭代变量的功能。通过一个计算阶乘的示例程序,我们将看到这些特性如何使代码更简洁、更易读。
利用C++新特性的代码示例
上一节我们介绍了C++相较于C语言的一些新概念,本节中我们来看看如何在实际代码中应用这些特性。
我们编写一个函数来计算阶乘。请注意,这里我们使用了long long类型。在C++中,除了普通的int和C语言中引入的long int之外,还有更长的整数类型。这对于阶乘计算尤为重要,因为使用普通int类型计算到13的阶乘时,就会发生整数溢出。使用long long类型可以确保我们有足够的字节来表示这么大的整数。
我们使用了IO流库(iostream),而不是C语言的标准I/O。同时,我们使用了using namespace std;指令,这样就不必在每个标准库对象前加上std::前缀,使代码更加简洁。当然,你也可以不使用这个指令,而是显式地写成std::cout和std::endl。
这个程序使用了一些C89标准(即广泛使用的C语言标准)中没有的特性。
以下是程序的核心代码部分:
for (int i = 0; i < limit; ++i) {
// 计算并输出 i 的阶乘
}
在这个for循环中,我们将变量i声明在循环内部。这意味着i的作用域仅限于这个循环,循环结束后无法再使用它。
接下来,我们看另一个使用了auto关键字的循环:
for (auto i = 0; i < limit; ++i) {
// 使用 auto 自动推断 i 的类型为 int
}
这里我使用了auto,虽然并非必需(我本可以明确写成int i),但这是为了演示自动类型推导的功能。编译器会推断出i是一个int类型,并且知道它的作用域仅限于这个循环内部。
然后,程序会打印出一系列阶乘值。我们使用了非常直观的I/O形式:
std::cout << "factorial of " << i << " is " << factorial(i) << std::endl;
这行代码的意思是:输出字符串“factorial of”,接着输出变量i的值,再输出字符串“is”,然后输出factorial(i)的计算结果,最后换行。
程序执行完毕后就会结束。
新特性的优势
通过以上代码,我们使用了非常方便的特性。这些特性并没有改变你对C语言核心的理解,但它们确实有助于更轻松地编写正确的代码。
我已经编译并运行了这个程序。例如,计算12的阶乘结果是4.79亿,这还在int类型的正常范围内。但计算13的阶乘时,结果达到了60亿,这已经超过了标准int类型能表示的范围。如果使用int类型,这个值会被错误地解释。你可以尝试用int类型计算13的阶乘,会发现结果是不正确的。对于14和15的阶乘也是如此,尽管这些数字非常大,超出了普通单精度整型的表示范围。
后续内容预告
在下一个视频中,我将向你展示这些特性带来的更多便利。我将重点介绍如何使用标准库中的容器类,特别是vector容器类。这对于那些希望学习荣誉课程内容、提前尝试更高级材料的同学尤为重要。如果你只是在本课程中学习基础编程,这部分内容可能不那么重要,但它仍然是C++中一个非常强大和常用的工具。

总结
本节课中,我们一起学习了如何在C++程序中使用auto关键字、内部声明和局部循环变量等新特性。通过一个阶乘计算的实例,我们看到了这些特性如何提升代码的简洁性和可读性,并理解了使用足够大的数据类型(如long long)来防止计算溢出的重要性。这些知识为编写更健壮、更现代的C++代码奠定了基础。
028:C++抽象数据类型作为类

概述
在本节课中,我们将学习C++语言中一个核心的现代特性:类。我们将了解类如何作为抽象数据类型的封装机制,并比较它与C语言中结构体的区别。
基本概念
C++之所以成为一种现代语言,而C语言被视为一种1970年代风格的系统实现语言,其核心理念在于C++引入了类。
类是封装抽象数据类型的一种方式。
我们一直在讨论C语言中的抽象数据类型,通常使用结构体来创建一种新的数据类型效果。
但结构体本身不包含操作,并且结构体本身是公开的,因此可以被随意修改。现代编程中一个重要的概念是封装。
封装允许你隐藏内部细节。可以想象一下修理汽车,引擎中的某些部件只有授权的经销商才能接触,否则不应被随意改动。你可以将其视为一个黑盒,只要知道如何操作,你仍然可以使用汽车。类的概念也是如此。
因此,C++采用了结构体的概念,并为其添加了实现进一步封装的理念。
类的应用实例
现在,设想我们需要一种新类型,称之为shape(形状)。shape是一个名词。在传统的C语言中,我们会用某种结构体来表示它,然后编写各种函数来对其执行操作,这些函数通常会通过指向结构体的指针来调用。
我们可以思考一个形状,计算它的面积,在屏幕上绘制它,或者找到它在平面上的位置。这些都是我们为shape这个类型设想的各种操作。
可以想象,你在一家想要开发(例如)建筑图纸软件的公司,他们希望有一种复杂的方法来自动绘制这些图纸以辅助人类建筑师。这样的软件包是存在的。如果我们有shape,我们会有不同种类的形状,它们会有不同的描述方式。然后,正如之前所说,我们会对它们执行操作。
C++中的实现
让我们简单看一下在C++中如何实现。假设我们有一个矩形形状。
我们使用class关键字,它类似于struct。虽然这里也可以使用struct,但有一个细微差别。在现代C++中,你使用class关键字。
然后你命名这个新类型,我们称之为rectangle。接着你有一个左花括号,就像结构体一样,但之后你有一个表示访问权限的关键字,这里是public,意味着所有人都可以使用它。
然后我们有一个构造函数。这是一种特殊的函数,允许你构建这类对象。其背后的理念是,int是一种原生类型,编译器知道如何构建一个int(通常是在内存中表示为4字节)。而rectangle允许你使用先前定义的类型来构建这个新类型。
这里还有一个对该类型的操作,称为area(面积)。area函数返回height * width。
此外,通常在现代语言(包括Java和Python)中,你会隐藏内部的表示。在这个例子中,我们隐藏了height和width。如果我们进一步完善这个类,还可以考虑其他操作,比如绘制操作。
代码示例
现在,在我们的主程序中,我们可能会声明一个rectangle对象r,并用值初始化它(例如2.5和2.0)。我们可能有一个变量ar用于计算面积,然后我们可以说ar = r.area()。这里使用了点表示法,这与C语言中结构体的用法相同。
现在发生的情况是,在这个矩形r上,area函数被调用,它秘密地使用高度2.5和宽度2.0进行计算。所以ar应该得到双精度值5.0。
核心特性总结
这就是Bjarne Stroustrup为C语言添加的核心思想,将其转变为一种现代语言。在这种语言中,你可以通过使用类的概念以及拥有成员方法(或称成员函数)的概念来轻松扩展数据类型。
因此,与普通的C语言结构体只有成员数据字段不同,现在你还有了成员函数(在现代面向对象术语中,有时称为方法)。

总结
本节课中,我们一起学习了C++中类的基本概念。我们了解到类是对抽象数据类型的封装,它通过public和private等访问控制关键字实现了信息的隐藏,并通过成员函数(如构造函数和area方法)为数据类型添加了操作。这使得C++能够更好地支持现代软件工程中的封装和模块化原则。

浙公网安备 33010602011771号