杜克大学-C-语言入门笔记-全-

杜克大学 C 语言入门笔记(全)

001:为什么你应该学习编程 🚀

在本节课中,我们将探讨学习编程的原因、本课程的教学方法以及整个专项课程的学习路线图。

大家好,欢迎来到《编程基础》,这是C语言入门专项课程的第一门课。我是Drew Hilton,希望你已经准备好学习大量编程知识。我是Anne Bracy,同样欢迎你加入本课程和专项课程。我很高兴你有机会和我们一起学习编程。我是Genevieve Lipp,我通过Drew和Anne的书籍学习了编程,本专项课程正是基于此书,我可以告诉你,这是一种极佳的学习方式。你不需要任何编程背景知识,只需要学习的渴望和思考问题的热情。

正如Genevieve刚才提到的,如果你编程是新手,这个专项课程非常适合你。你可能出于多种多样的原因考虑学习编程。也许你想成为一名专业的软件开发人员。如果是这样,这是一个很好的起点。我们将建立扎实的基础,这对你学习C语言或任何其他语言都大有裨益。或者,你可能希望掌握编程技能以应用于其他学科。我认识许多社会科学家和自然科学家,他们发现编程对于分析数据、探索其领域内的问题是必要的。你们中的一些人可能已经上过入门课程,并希望扩展技能。这可以成为一个绝佳的机会,既能从扎实的基础开始构建编程技能,又能学习一门新语言。参加这个专项课程的另一个重要原因是,如果你正在学习计算机组成课程(无论是在Coursera上还是在学校里),但缺乏出色的C语言编程背景。这个专项课程是快速掌握这些主题的好方法。

那么,我们的教学方式有何特别之处?我们将从最基础的部分开始。许多编程课程假设你看几个例子就能学会写代码。我们不会这样做。相反,我们将教你一种循序渐进的方法来解决编程问题。这个七步法是处理任何编程问题的好方法,从本课程中用作练习的小问题,到现实生活中会遇到的复杂大问题。我们不仅要教你如何写代码,还要教你如何读代码。毕竟,如果不会读,又怎么会写呢?对于我们教给你的每一段语法,我们也会教你其语义,即代码具体做什么。对于你写的任何代码,你都将能够手动执行它,准确说出每一行的作用。这与“无魔法”原则相辅相成——我们永远不会仅仅因为“就是这样”而让你写或做某件事。相反,我们希望你知道,编程中的一切都关乎明确定义的规则,你可以理解、遵循甚至自己执行。

那么,接下来我们要做什么呢?本课程的其余部分将全部围绕计算思维展开。你将学习许多重要概念:如何设计算法来解决问题,如何手动执行一段代码,以及“万物皆数”的原则。这些将为你从第二门课程开始用C语言编写程序奠定基础。说到第二门课程,在那里你将学习使用各种对开发代码至关重要的工具。你将学习如何将代码转换为计算机可以运行的实际程序(这称为编译)。你还将学习测试和调试,即发现并修复程序中的错误。在第三门课程中,你将学习使用指针、数组和字符串来存储更复杂数据的方法。如果你现在不知道这些是什么,没关系,我们到时候会解释。在第四门课程中,我们将学习如何与用户和系统交互,以及如何在事先不知道要处理多少数据的情况下动态分配内存。

那么,让我们开始吧。


本节课总结:在本节课中,我们一起了解了学习编程的多种动机,认识了本课程的教学团队,并明确了本专项课程“从零开始、循序渐进、强调理解、读写并重”的教学特色。我们还预览了整个四门课程的学习路径,从计算思维基础到C语言编程实践,再到高级数据结构和系统交互,为后续的学习做好了准备。

002:逐步执行算法 🧮

在本节课中,我们将学习如何手动逐步执行一个简单的算法。我们将通过一个具体的例子,一步步跟踪算法的执行过程,理解其逻辑和输出结果。即使没有任何编程经验,你也能通过基本的数学运算和仔细的记录来完成这个过程。


算法概述

这个算法接受一个非负整数 n 作为输入。它并不完成一个特别有用的任务,但为我们提供了一个简单的起点来理解算法的执行流程。算法的核心是进行一系列计算,并在过程中记录输出值。

为了执行算法,我们需要一个具体的 n 值。这里我们选择 n = 2

我们还需要一个“输出框”来记录算法要求我们写下的所有内容。同时,我们将使用一个绿色箭头来跟踪当前正在执行的步骤,确保我们不会在流程中迷失。


逐步执行过程

现在,让我们开始一步步执行这个算法。我们将从第一步开始,并严格按照指示操作。

初始化变量

首先,算法要求我们创建一个名为 x 的变量,并将其值设置为 n + 2

  • 计算:由于 n = 2,所以 x = 2 + 2 = 4。
  • 记录:我们记下,变量 x 的初始值是 4。

开始循环计数

接下来,算法要求我们从 0 计数到 n(包括 n 本身)。我们需要为计数的数字起一个名字,以便在后续步骤中使用,这里我们称它为 i

在计数过程中,我们将重复执行一组步骤。这些步骤通过缩进表示,并且算法明确说明了在计数完成后需要执行的步骤。

以下是循环中需要重复执行的步骤列表:

  1. 写入输出:写下 x * i 的值。
  2. 更新变量:将 x 的值更新为 x + i * n

现在,我们开始第一次循环,此时 i = 0

  • 执行步骤 1:计算 x * i = 4 * 0 = 0。我们将 0 写入输出框。
  • 执行步骤 2:计算 x + i * n = 4 + 0 * 2 = 4。因此,x 的新值仍然是 4

我们已经完成了第一轮循环的步骤。现在,我们需要回到循环开始处,计数下一个数字。

第二次循环

我们将 i 的值更新为下一个要计数的数字,即 i = 1,然后再次执行循环内的步骤。

  • 执行步骤 1:计算 x * i = 4 * 1 = 4。我们将 4 写入输出框。
  • 执行步骤 2:计算 x + i * n = 4 + 1 * 2 = 6。因此,我们将 x 的值更新为 6

第二轮循环结束。我们再次回到循环开始处,准备进行最后一次循环。

第三次循环

我们将 i 的值更新为 i = 2(因为 n=2,我们需要计数到 2)。然后执行循环内的步骤。

  • 执行步骤 1:计算 x * i = 6 * 2 = 12。我们将 12 写入输出框。
  • 执行步骤 2:计算 x + i * n = 6 + 2 * 2 = 10。因此,我们将 x 的值更新为 10

现在,我们已经计数了 0, 1, 2 这三个数字,循环结束。

循环后的步骤

循环结束后,算法还有最后一步需要执行:写下变量 x 的当前值。

  • 执行最终步骤:x 的当前值是 10。我们将 10 写入输出框。

至此,所有步骤执行完毕。我们的输出框中记录了序列:0, 4, 12, 10


总结

本节课中,我们一起手动逐步执行了一个简单的算法。我们学习了如何:

  1. 初始化参数:为算法设定输入值(n=2)并初始化变量(x=4)。
  2. 跟踪循环:理解并执行一个从 0 到 n 的计数循环,在每次循环中重复执行特定的计算和更新步骤。
  3. 记录输出:在指定的步骤中将计算结果记录到输出中。
  4. 完成算法:执行循环结束后的最终步骤,并得到完整的输出序列。

通过这个练习,我们掌握了逐步跟踪算法执行的基本方法,这是理解更复杂编程逻辑的重要基础。最终,对于输入 n=2,该算法生成的数字序列是 0, 4, 12, 10

C语言入门:p03:03_01_05:测试数值序列算法 🧪

在本节中,我们将通过一个具体的例子,手动测试之前编写的算法。我们将逐步跟踪变量的变化,并验证算法的输出是否符合预期。

上一节我们介绍了算法的设计思路,本节中我们来看看如何通过“模拟执行”来验证算法的正确性。

我们将测试一个从0计数到n的算法。变量n的值是5。为了清晰地跟踪执行过程,我们会为每个变量绘制一个“盒子”,用来记录其当前值。同时,我们用一个标记为“输出”的盒子来记录程序的输出结果。每一步计算出的数字都会被写入这个输出盒。最后,我们使用一个绿色箭头来指示当前执行到了算法的哪一行代码。

现在,我们可以开始逐步执行算法了。

首先,算法要求我们从0计数到n(包括n),并将每次计数的数字称为i

由于i是一个变量,并且初始值为0,我们为i创建一个盒子,并填入初始值0

接下来,我们进入算法的核心步骤。

以下是每一步需要执行的操作:

  1. 计算表达式 n² - 2 * i 的值。
  2. 将这个值写入“输出”盒子。

现在,我们需要进行一些数学计算。此时n的值为5,i的值为0。因此,计算过程为:
5² - 2 * 0 = 25 - 0 = 25
按照算法要求,我们将数字25写入输出盒。

这个过程将不断重复。

现在,i的值变为1。我们再次计算 n² - 2 * i。这次i的值为1,所以计算为:
25 - 2 = 23
我们将23写入输出盒。

至此,我们完成了i=0i=1的情况。我们的目标是处理到i=5

现在处理i等于2的情况。再次计算:
25 - 4 = 21
21写入输出盒。

接下来处理i等于3的情况。计算:
25 - 6 = 19
19写入输出盒。

我们继续这个过程。

以下是后续步骤的计算结果列表:

  • i的值为4时,输出为17
  • i的值为5时,输出为15

每次执行算法,i的值都会递增。然而,当i等于6时,我们会停止,因为我们只计数到n=5

此时,整个算法执行完毕。好消息是,我们的输出结果与预期完全一致。生成的数字序列正是我们希望得到的。

本节课中我们一起学习了如何通过手动模拟执行来测试一个简单的循环算法。我们通过跟踪变量i的变化,逐步计算并收集输出结果,最终验证了算法的正确性。这种“逐步跟踪”的方法是理解和调试程序逻辑的基础技能。

004:正方形模式算法推导 🧩

在本节课中,我们将学习如何为一个特定的网格红蓝方块图案开发算法。我们将通过一个具体的例子,逐步推导出通用算法,并最终确定方块颜色的判断规则。

概述

我们将遵循一个四步编程过程来解决问题:

  1. 手动完成一个具体实例。
  2. 精确记录每一步操作。
  3. 从具体步骤中归纳出通用算法。
  4. 测试算法。

下面,我们以 n = 3 为例开始第一步。

第一步:手动完成实例

首先,我们不考虑背后的逻辑,直接在网格上手动放置方块。对于 n = 3 的情况,我们依次放置了以下方块:

  • 在坐标 (0,0) 放置蓝色方块。
  • 在坐标 (0,1) 放置红色方块。
  • 在坐标 (0,2) 放置红色方块。
  • 在坐标 (0,3) 放置蓝色方块。
  • 在坐标 (1,1) 放置红色方块。
  • 在坐标 (1,2) 放置蓝色方块。
  • 在坐标 (1,3) 放置红色方块。
  • 在坐标 (2,2) 放置红色方块。
  • 在坐标 (2,3) 放置红色方块。
  • 在坐标 (3,3) 放置蓝色方块。

第二步:记录步骤

现在,我们将第一步中的操作精确地、一步一步地记录下来。以下是完整的步骤列表:

  1. 在 (0,0) 放置蓝色方块。
  2. 在 (0,1) 放置红色方块。
  3. 在 (0,2) 放置红色方块。
  4. 在 (0,3) 放置蓝色方块。
  5. 在 (1,1) 放置红色方块。
  6. 在 (1,2) 放置蓝色方块。
  7. 在 (1,3) 放置红色方块。
  8. 在 (2,2) 放置红色方块。
  9. 在 (2,3) 放置红色方块。
  10. 在 (3,3) 放置蓝色方块。

第三步:归纳通用算法

上一步我们记录了具体步骤,本节中我们来看看如何从中发现规律并归纳出算法。

观察记录下的步骤,我们可以发现一些重复的计数行为。前4步对应 x = 0,接着3步对应 x = 1,然后2步对应 x = 2,最后1步对应 x = 3。这表明随着 x 从0计数到3,我们在重复类似的操作,但具体细节(如颜色和 y 坐标的范围)有所不同。

我们先分析 y 坐标的规律。以下是每个 x 值对应的 y 坐标范围:

  • x = 0 时,y 从 0 到 3。
  • x = 1 时,y 从 1 到 3。
  • x = 2 时,y 从 2 到 3。
  • x = 3 时,y 从 3 到 3。

由此可以归纳出:对于每个 xy 的计数范围是从 xn(本例中 n=3)。

暂时忽略颜色,我们可以将算法初步描述为:

对于 x 从 0 到 n:
    对于 y 从 x 到 n:
        在坐标 (x, y) 放置一个(待定颜色的)方块

然而,这个算法仍然依赖于具体的 n=3。为了使其通用,我们需要让算法适用于任何 n。如果我们对 n=1 重复第一步和第二步,会得到类似的步骤模式,只是计数的上限变成了1。这证实了我们的归纳方向是正确的。

因此,更通用的算法框架是:

对于 x 从 0 到 n:
    对于 y 从 x 到 n:
        在坐标 (x, y) 放置一个(颜色?)方块

接下来,我们需要确定颜色规则。

确定颜色规则

上一节我们得到了算法的骨架,本节中我们来看看如何确定方块的填充颜色。

回顾 n=3 的例子,蓝色方块出现在坐标 (0,0), (0,3), (1,2), (3,3)。为了找出规律,我们计算这些坐标的 xy 之和:

  • (0,0): 0 + 0 = 0
  • (0,3): 0 + 3 = 3
  • (1,2): 1 + 2 = 3
  • (3,3): 3 + 3 = 6

观察这些和:0, 3, 3, 6。它们都是3的倍数。为了验证这个规律,我们可以查看 n=5 时蓝色方块的位置(其坐标和分别为 0, 3, 6, 9等),同样符合“坐标和为3的倍数”这一规律。

因此,我们可以完善颜色判断规则:当且仅当 x + y 是3的倍数时,放置蓝色方块,否则放置红色方块

现在,我们可以写出完整的算法:

对于 x 从 0 到 n:
    对于 y 从 x 到 n:
        如果 (x + y) % 3 == 0:
            在坐标 (x, y) 放置蓝色方块
        否则:
            在坐标 (x, y) 放置红色方块

总结

本节课中我们一起学习了算法开发的完整过程。我们从手动解决一个具体实例(n=3)开始,然后精确记录步骤。接着,我们通过观察步骤中的模式,归纳出了绘制方格的通用循环结构。最后,我们通过分析蓝色方块的位置,发现了颜色判断的关键规则:(x + y) % 3 == 0。这样就得到了一个可以适用于不同 n 值的完整算法。下一步将是测试这个算法,我们将在后续课程中进行。

005:测试正方形模式算法 🧪

在本节课中,我们将测试一个用于在网格上绘制蓝红正方形图案的算法。测试能增强我们对算法的信心。由于泛化是编程过程中较困难且易出错的部分(例如,可能将本应作为变量的值误设为常量),因此测试尤为重要。

准备工作 🛠️

我们需要一个用于绘制的网格、一个箭头来跟踪算法执行位置,以及一个列出变量的方框。我们设定 n 的值为 2

算法执行步骤 📝

以下是算法的逐步执行过程。

首先,我们为变量 x 创建一个方框,并从 0 开始计数。接着,为变量 y 创建一个方框,并从 x 的当前值(此处为 0)开始计数。

现在,我们需要判断 x + y 是否是 3 的倍数。此时 0 + 0 = 0,是 3 的倍数。

因此,我们进入 if 语句,在坐标 (0, 0) 处放置一个蓝色正方形。至此,y 的当前迭代步骤完成。

我们继续计数,y 变为 1。计算 0 + 1 = 1,不是 3 的倍数。

于是,我们进入 otherwise 子句,在坐标 (0, 1) 处放置一个红色正方形。y 的当前步骤完成。

继续计数,y 变为 2。计算 0 + 2 = 2,不是 3 的倍数。

我们在坐标 (0, 2) 处放置一个红色正方形。继续计数,y 变为 3

由于我们只从 x 计数到 n(即 yxn),当 y = 3 时,已超出范围,因此不执行任何步骤。

继续外层循环 🔄

上一节我们完成了 x = 0 时的内层循环。本节中,我们继续为 x 计数,现在 x 变为 1

我们将从 y = 1 计数到 n(即 y12)。首先,y = 1

计算 1 + 1 = 2,不是 3 的倍数。

我们在坐标 (1, 1) 处放置一个红色正方形。然后递增 yy 变为 2

计算 1 + 2 = 3,是 3 的倍数。

我们在坐标 (1, 2) 处放置一个蓝色正方形。至此,y 的计数完成。

接下来,我们计数下一个 xx 变为 2。此时,y2 计数到 2

计算 2 + 2 = 4,不是 3 的倍数。

我们在坐标 (2, 2) 处放置一个红色正方形。y 的计数完成。

接着,我们计数下一个 x。由于我们只计数到 n(即 x02),当 x 尝试变为 3 时,已超出范围。

因此,计数过程结束。

算法验证 ✅

算法执行完毕。我们得到了预期的结果图案。可以说,我们的算法至少在 n = 2 的情况下是有效的。

通过测试一个并非用于推导算法的特定 N 值,我们对算法的正确性增加了信心。当然,若想获得更高置信度,需要进行更全面的测试。但需谨记,测试本身无法证明算法绝对正确,它只能让我们对其正确性越来越有信心。

总结 📚

本节课中,我们一起学习了如何通过手动模拟执行来测试一个网格着色算法。我们使用 n = 2 作为测试用例,逐步跟踪了变量 xy 的变化,并根据条件 (x + y) % 3 == 0 放置了相应颜色的正方形。这个过程验证了算法在特定情况下的正确性,并加深了我们对循环和条件判断执行流程的理解。记住,测试是编程中不可或缺的一环,它能帮助我们发现潜在的错误并提升代码质量。

006:绘制矩形算法设计

在本节课中,我们将学习如何为一个具体问题设计算法:在16x16的网格上,根据给定的起始坐标、宽度和高度,绘制一个蓝色矩形。

问题概述

我们有一个16x16的网格。目标是绘制一个蓝色矩形,该矩形具有特定的高度和宽度,并位于一个特定的起始坐标(X, Y)处。

具体实例分析

首先,我们通过一个具体实例来理解问题。假设起始坐标为(7, 9),矩形宽度为8,高度为4。

要解决这个具体实例,我们从起始点开始,用蓝色画笔绘制这个矩形。

现在,我们退一步,写下刚才绘制矩形的步骤。

  1. 从坐标(7, 9)开始,绘制一条长度为8的水平线。
  2. 从坐标(7, 10)开始,绘制一条长度为8的水平线。
  3. 从坐标(7, 11)开始,绘制一条长度为8的水平线。
  4. 从坐标(7, 12)开始,绘制一条长度为8的水平线。

让我们暂时离开绘图,审视一下写下的这四行描述。你会发现这是一个非常重复的过程。

这四步的唯一区别是Y坐标。Y坐标从9开始,到12结束。我们从9开始,因为这是矩形起始的Y坐标。我们在12结束,因为我们要绘制一个高度为4的矩形,所以需要绘制四条水平线,停止在 Y + 高度 - 1 的位置。

归纳通用算法

检查完这四步后,让我们尝试编写一个能将其推广的算法。

回顾一下,我们是从Y开始计数,直到 Y + 高度 这个值。注意,这个范围是不包含 Y + 高度 本身的,因为我们数了9, 10, 11, 12,并没有数到13(即 Y + 高度)。我们将这个循环变量称为 i

对于 i 的每一个值,我们都要从坐标(7, i)开始,绘制一条长度为8的水平线。

为什么长度是8?因为这是矩形的宽度,所以我们可以将其称为 宽度。这样,我们的算法就能适用于不同大小的矩形。

为什么X坐标从7开始?因为这是起始的X坐标,我们可以将其称为 X。这样,我们的算法就能适应所有X值。

现在,这一步“从坐标(X, i)开始绘制一条长度为 宽度 的水平线”看起来有点复杂。这没关系,我们可以将其视为另一个独立的编程问题。我们假设已经知道如何完成这一步,或者可以将其作为一个新问题,从头开始用步骤1到4的方法来解决。在本练习中,我们假设我们知道如何执行这一步。

最后,我们需要增加一点精度,因为我们想要的是蓝色线条。所以算法需要明确指出:绘制一条蓝色的、长度为 宽度 的水平线,起始位置为(X, i)。

以下是完整的算法描述:

对于 iYY + 高度 - 1(包含):

  • 从坐标(X, i)开始,绘制一条蓝色的、长度为 宽度 的水平线。

你可以用自己设定的X、Y、宽度和高度值来测试这个算法,看看它是否有效。

边界情况说明

最后需要注意,某些X、Y、宽度和高度的组合会导致绘图超出网格边界。在本例中,我们假设发生这种情况时无需特殊处理,不做任何响应就是期望的行为。后续课程中,我们将学习更复杂的方式来处理错误和边界情况。

本节总结

本节课中,我们一起学习了如何为一个绘制矩形的问题设计算法。我们从分析一个具体实例入手,识别出重复的步骤模式,然后将其归纳为一个通用的循环算法。该算法使用起始坐标(X, Y)、宽度和高度作为参数,通过循环绘制一系列水平线来形成矩形。我们还简单讨论了算法边界情况的处理方式。

007:最近点问题

概述

在本节课中,我们将学习如何从一组点中找到距离给定点最近的点。我们将通过一个具体的例子,逐步推导出解决此问题的通用算法。


07_01:手动求解实例 🧮

上一节我们介绍了课程目标,本节中我们通过一个具体例子来手动寻找最近点。

我们有一个笛卡尔坐标系,其中定义了一个点集 S 和另一个给定点 P。点 P 的坐标是 (1, -1)。我们的目标是找到 S 中距离 P 最近的点。

首先,我们需要计算两点间距离的数学定义。两点 (x1, y1)(x2, y2) 之间的距离公式为:

距离 = √[(x2 - x1)² + (y2 - y1)²]

以下是手动计算和比较每个点与点 P 距离的步骤:

  1. 计算 P(1, -1) 与点 (2, 7) 的距离:√[(1-2)² + (-1-7)²] = √(1 + 64) ≈ 8.06。
  2. 计算 P 与点 (10, 5) 的距离:√[(1-10)² + (-1-5)²] = √(81 + 36) ≈ 10.82。比较 10.82 与 8.06,8.06 更小,因此当前最近点仍是 (2, 7)
  3. 计算 P 与点 (8, -2) 的距离:√[(1-8)² + (-1-(-2))²] = √(49 + 1) ≈ 7.07。比较 7.07 与 8.06,7.07 更小,因此更新当前最近点为 (8, -2)
  4. 计算 P 与点 (6, -5) 的距离:√[(1-6)² + (-1-(-5))²] = √(25 + 16) ≈ 7.81。比较 7.81 与 7.07,7.07 更小,因此当前最近点保持为 (8, -2)
  5. 计算 P 与点 (-3, -5) 的距离:√[(1-(-3))² + (-1-(-5))²] = √(16 + 16) ≈ 5.66。比较 5.66 与 7.07,5.66 更小,因此更新当前最近点为 (-3, -5)
  6. 计算 P 与点 (-4, 3) 的距离:√[(1-(-4))² + (-1-3)²] = √(25 + 16) ≈ 9.06。比较 9.06 与 5.66,5.66 更小,因此当前最近点保持为 (-3, -5)
  7. 计算 P 与点 (-5, 2) 的距离:√[(1-(-5))² + (-1-2)²] = √(36 + 9) ≈ 9.22。比较 9.22 与 5.66,5.66 更小,因此当前最近点保持为 (-3, -5)

所有点测试完毕,最终答案为 (-3, -5)。通过视觉检查,该点看起来确实是距离 P 最近的点。


07_02:步骤分析与关键发现 🔍

上一节我们完成了手动计算,本节中我们来仔细分析并记录下这些步骤。

我们将上述计算过程整理为更清晰的步骤列表:

以下是记录每一步计算和比较的详细过程:

  • 计算 √(1² + 8²) ≈ 8.06。
  • 计算 √(9² + 6²) ≈ 10.82,比较 10.82 与 8.06,8.06 更小。
  • 计算 √(7² + (-1)²) ≈ 7.07,比较 7.07 与 8.06,7.07 更小,因此这是一个更好的选择。
  • 计算 √(6² + (-5)²) ≈ 7.81,比较 7.81 与 7.07,7.07 更小。
  • ... 以此类推。
  • 最终给出答案 (-3, -5)

这些步骤看起来合理,但我们在记录过程中忽略了一个关键点。仔细看最后一步“给出答案 (-3, -5)”。当我们尝试将这个过程推广为通用算法时,我们会问:为什么答案是 (-3, -5)?我们发现,答案 (-3, -5) 在之前的步骤描述中并没有明确出现。

这个现象表明我们的步骤记录有遗漏。我们在思考时下意识地做了某件事,但没有把它写下来。在推广算法之前,必须先修正这一点。

我们为什么选择 (-3, -5) 作为答案?回顾最后几步,长度为 5.66 的虚线是我们跟踪的“当前最近距离”。我们最初以 (2, 7) 作为起点,之后每次发现更短距离时就更新这个“当前最近点”。然而,在我们写下的步骤里,从未提及与这个“当前最短距离”相关联的是哪个点。

我们需要修正步骤,加入对“当前最近点”的跟踪:

  1. 初始最佳选择为 (2, 7)
  2. 当计算到点 (8, -2) 并判断 7.07 更小时,我们将最近点更新为 (8, -2)
  3. 当计算到点 (-3, -5) 并判断 5.66 更小时,我们将最近点更新为 (-3, -5)

现在,最终的答案就清晰了:它就是最后一次更新后保留的“最佳选择”。


总结

本节课中,我们一起学习了“最近点”问题。我们首先通过一个具体实例手动计算,然后仔细分析了计算步骤,并发现了一个关键遗漏——需要持续跟踪“当前最近点”而不仅仅是距离。修正后的完整步骤为我们下一节将算法推广到通用情况打下了坚实基础。

008:泛化最近点问题

在本节课中,我们将学习如何将解决特定问题的具体步骤,抽象和泛化为一个通用的算法。我们将以“寻找距离给定点P最近的点”为例,展示从具体计算到通用算法的思维过程。

从具体步骤到通用模式

上一节我们通过一个具体案例,手动计算了距离点P最近的点。本节中,我们来看看如何将这些具体步骤抽象成一个适用于任何点集的通用算法。

观察我们的步骤,其中一些是相似的,但我们需要让它们完全对应起来。每个彩色框都包含相似的操作:先进行一次计算,然后进行一次比较。

我们还有一些步骤只在特定条件下执行,需要明确这些条件。此外,开始和结束的步骤也需要被泛化,其中的具体数字需要被替换。

统一计算步骤

首先,让我们统一这些相似的计算步骤。

为什么这里计算的是 9² + 6²,下一个计算的是 7² + (-1)²?观察图片,数字9来源于Delta x,6来源于Delta y,即点S(10,5)与点P(1,-1)在x和y方向上的差值。其他数字同理,都是点S与点P的坐标差。

因此,我们可以重写所有这些步骤。将点命名为S1, S2, ..., S6。在起始步骤中,我们将第一个点称为S0(程序员通常喜欢从0开始计数)。

第一个计算可以改写为:

current_distance = sqrt( (S1.x - P.x)² + (S1.y - P.y)² )

现在,这个计算结果不总是10.82或7.07,所以我们应该给它一个名字,比如current_distance。但这样做之后,我们需要在每次使用这个数字时都更新它。例如,如果我们把第一个10.82替换为current_distance,那么下一行就需要用current_distance与8.06进行比较。

引入“最佳距离”变量

有些数字在其他地方被使用,因此它们需要不同的名字。我们将每次计算的结果都替换为current_distance

注意,我们只在current_distance更小时才更新“最佳选择”。这意味着我们必须记录目前为止的“最佳距离”,并且也需要更新它。现在,当current_distance更小时,我们更新best_distance,并且可以用best_distance的值与后续的计算结果进行比较。

让我们看看第一个计算的8.06,并考虑它出现的其他地方。这个计算可以像其他计算一样,使用S0来完成。由于这是我们的第一次计算,它自然就是目前的最佳值。然后我们可以将其他出现的8.06替换为best_distance,使步骤统一。

泛化“最佳选择”点

继续泛化,看看“最佳选择”对应的点:(2,7), (8,-2), (-3,-5)。这些点就是我们正在查看的特定点,因此我们可以将它们替换为S0, S2, S4。

回想一下,我们只在current_distance更小时才更新“最佳选择”。我们可以通过每次都比较current_distancebest_distance来使每个步骤相同,但仅在current_distance更小时才更新best_distance

构建循环结构

现在,我们对每个点执行相同的步骤。每个彩色框的内容都相同,除了我们正在查看的点是S1, S2, ..., S6。

这意味着我们可以将其表达为一个重复过程,从1计数到点集S中的点数(不包含S0)。我们将计数的数字称为i,并计算S中第i个点到P的距离。

以下是算法的核心循环逻辑:

for i from 1 to (number of points in S - 1):
    current_distance = sqrt( (S[i].x - P.x)² + (S[i].y - P.y)² )
    if current_distance < best_distance:
        best_choice = S[i]
        best_distance = current_distance

处理起始、结束与边界情况

我们还没有改变开始和结束的步骤,但需要泛化我们给出的答案。答案并不总是(-3, -5)。为什么我们这么说?因为答案是我们完成计数时的best_choice。所以,通常我们的答案是best_choice

我们还需要考虑一个边界情况:如果点集S中没有任何点怎么办?在这种情况下,我们应返回“无答案”。这个边界情况在我们刚刚处理的例子中没有出现,但会在测试时(例如处理一个空点集)暴露出来。在计算的第一步就会遇到问题,因此我们报告“无答案存在”并跳过所有其他步骤。

完整算法

以下是完整的算法描述,稍后我们将学习如何将其转化为代码:

  1. 如果点集S为空,则返回“无答案存在”。
  2. 计算S[0]P的距离,将其设为best_distance,将S[0]设为best_choice
  3. 对于i从1到(S中的点数 - 1):
    • 计算S[i]P的距离,存入current_distance
    • 如果current_distance < best_distance
      • best_choice更新为S[i]
      • best_distance更新为current_distance
  4. 返回best_choice作为答案。

本节课中,我们一起学习了如何将一个具体的手动计算过程,通过识别模式、统一变量、引入循环和考虑边界情况,逐步抽象成一个清晰、通用的算法。这是将问题解决思路转化为可执行代码的关键一步。

009:为什么你应该学习阅读代码 📖

在本节课中,我们将要学习一个至关重要的编程技能:如何阅读代码并手动执行它。掌握这项技能是理解程序运行机制、编写高质量代码和高效调试的基础。

概述

学习编程时,许多人倾向于直接开始编写代码。然而,这类似于在学习写作之前没有学会阅读。阅读和理解代码,比从零开始构思和编写代码要容易得多。本节将阐述学习阅读代码的重要性,并介绍你将在此模块中学到的核心技能。

阅读先于写作

回想一下你最初学习书面语言的时候。你很可能是在学会阅读之后,才学会写作的。阅读一个单词或句子并理解其含义,比构思并写出自己的句子要容易。你可能遇到过这样的情况:你能轻松读懂一个表达,但当自己书写时却会出现拼写错误或语法错误。

编程与此非常相似。在你能够编写出优秀的代码之前,你需要通过阅读来理解,并确切地知道代码是如何被执行的。这不仅更容易,还能帮助你在编程过程中减少错误,并在代码行为不符合预期时,有效地进行故障排查。

从个人经验看手动执行代码的重要性

我的背景是机械工程。在本科阶段,我们使用Matlab进行计算任务。尽管有优秀的指导和精心设计的练习,包括我在内的许多学生仍然会花费数小时来调试代码,方法大多是猜测和检验。我从未尝试过画出我认为正在发生的事情,甚至没有手动执行过一行代码。当时我可能也做不到,因为我对一些概念的理解并不完全。

在教学过程中,我看到学生们犯着类似的错误。寻找错误信息的原因就像一场寻宝游戏,这个过程所花费的时间,可能比最初编写代码的时间还要长得多。

理解代码的实际行为

通常,我们知道我们希望一段代码做什么,甚至认为我们知道它在做什么。但除非你能确定,否则你无法对你的结果有信心,并且你可能会在调试上花费比预期更多的时间。

以下是一个我们将在课程后面讨论的函数的算法。

算法步骤:
1. 初始化变量。
2. 进入循环。
3. 在循环内更新状态。
4. 检查条件。
5. 返回结果。

你可以看到,算法的每一步都在改变程序的某种状态。

以下是代码中实现的相同步骤。如果你现在还看不懂这段代码,请不要担心。

int exampleFunction(int n) {
    int state = 0;
    for (int i = 0; i < n; i++) {
        state += i * 2;
    }
    return state;
}

关键在于,代码需要以与你的算法完全相同的方式来改变程序。如果代码的行为有所不同,我们就需要修改它以匹配算法。这就是为什么学习阅读代码对于编写代码如此重要。

本模块你将学到的技能

在本模块中,你将学习理解语句的作用。这包括语言的语法(即文法规则)和语句的语义(即其含义)。

你还将学习如何跟踪程序的状态——即程序执行到何处,以及哪些函数可以访问哪些变量。

为了做到这一点,你将学习如何根据一套规则,精确地画出正在发生的事情的图示。一旦你掌握了这些技能,编写代码将会变得更容易。当你的代码行为不符合预期时,你将拥有一套工具来调查你的程序,看看它到底在做什么。

总结

本节课中,我们一起学习了阅读和手动执行代码的重要性。我们了解到,这项技能是理解程序逻辑、减少错误和高效调试的基石。通过先学会“阅读”程序,我们为后续“写作”出正确、高效的代码打下了坚实的基础。在接下来的课程中,我们将具体学习跟踪程序状态和绘制执行过程图的方法。

010:变量声明与赋值 📝

在本节课中,我们将学习C语言中最基础的语句——变量声明与赋值。我们将从最简单的代码执行开始,逐步构建对更复杂语句的理解。

概述

我们将首先了解如何声明变量,然后学习如何为变量赋值,最后探讨如何将声明与赋值结合在一行代码中完成。理解这些基础概念是编写任何C程序的第一步。

变量声明

在C语言中,使用变量前必须先声明它。声明语句告诉编译器变量的名称和类型。

以下是声明变量的基本语法:

int x;

执行这条语句会在内存中创建一个标记为 x 的“盒子”。由于变量 x 未被初始化,这个盒子里的值是未知的。

我们可以声明多个变量:

int x;
int y;

执行这两条语句会创建两个独立的“盒子”,分别标记为 xy。它们都未被初始化。

重要提示:使用未初始化的变量是危险的,这会导致程序中出现难以发现和修复的错误(bug)。

变量赋值

声明变量后,我们可以使用赋值语句为其赋予一个具体的值。

赋值语句的基本语法是:

变量名 = 值;

例如,在声明 int x; 之后,我们可以写:

x = 4;

执行这条赋值语句会将数字 4 放入标记为 x 的盒子中。此时,变量 x 就被初始化了,其值为 4

声明时初始化

为了代码的简洁和安全,我们经常在声明变量的同时就为其赋值。这被称为初始化。

声明时初始化的语法是:

int 变量名 = 值;

例如:

int y = 6;

执行这条语句会一次性完成两件事:1) 创建一个标记为 y 的盒子;2) 将数字 6 放入这个盒子中。

使用变量进行计算

变量的真正价值在于存储和操作数据。我们可以在赋值语句的右侧使用表达式,其中包含其他变量。

让我们通过一个例子来理解:

假设我们有以下代码:

int x = 4;
int y = x + 2;
int z = y - x;

执行过程分析

  1. 执行 int x = 4;:创建变量 x 并将其初始化为 4

  2. 执行 int y = x + 2;

    • 首先,计算等号右侧的表达式 x + 2。此时 x 的值是 4,所以 4 + 2 等于 6
    • 然后,创建变量 y 并将其初始化为计算结果 6

  1. 执行 int z = y - x;
    • 计算表达式 y - xy 的值是 6x 的值是 4,所以 6 - 4 等于 2
    • 创建变量 z 并将其初始化为 2

通过这个过程,变量 xyz 最终分别存储了值 462

总结

本节课我们一起学习了C语言中变量声明与赋值的基础知识。

我们了解到:

  • 变量声明(如 int x;)的作用是在内存中为指定类型的变量预留空间。
  • 变量赋值(如 x = 4;)的作用是将一个值存储到已声明的变量中。
  • 声明时初始化(如 int y = 6;)是将声明和赋值合并的高效且安全的写法。
  • 可以在赋值语句中使用包含其他变量的表达式(如 y = x + 2;),程序会先计算表达式的结果,再将结果赋给变量。

掌握这些概念是理解后续更复杂的控制流、函数和数据结构的关键。现在,你已经可以开始编写简单的C语言程序来执行基础计算了。

011:表达式示例

在本节课中,我们将通过具体的代码示例,学习如何分析和计算C语言中的表达式。我们将逐步解析代码片段,理解变量声明、初始化以及表达式求值的过程。

概述

我们已经了解了表达式的基本概念。本节将通过两个具体的代码示例,演示如何根据运算符优先级和结合性规则,一步步计算表达式的值,并更新变量的内容。

示例一:基础运算

首先,我们来看第一个代码示例。它演示了变量声明、初始化和赋值语句中表达式的计算过程。

以下是代码片段的逐步分析:

  1. 声明变量 x:代码首先声明了一个名为 x 的整型变量。

    int x;
    
  2. 初始化变量 x:将 x 初始化为表达式 4 + 3 * 2 的结果。

    • 根据数学规则,乘法 (*) 的优先级高于加法 (+)。
    • 因此,先计算 3 * 2,得到 6
    • 再计算 4 + 6,得到 10
    • 最终,将 10 存入变量 x 对应的“盒子”中。
    x = 4 + 3 * 2; // x 的值为 10
    
  3. 声明并初始化变量 y:声明另一个整型变量 y,并将其初始化为 x - 6

    • 此时 x 的值为 10
    • 计算 10 - 6,得到 4
    • 4 存入变量 y 的“盒子”中。
    int y = x - 6; // y 的值为 4
    
  4. 执行赋值语句 x = x * y:这条语句可能让初学者困惑,但它并非代数方程。

    • 首先,计算右侧表达式 x * y 的值。此时 x10y4,乘积为 40
    • 然后,将这个结果 40 存入左侧变量 x 的“盒子”中,覆盖原来的值 10
    x = x * y; // x 的新值为 40
    

执行完以上代码后,变量 x 的最终值是 40,变量 y 的值是 4

示例二:包含括号和取模运算

在理解了第一个例子后,我们来看一个稍复杂的例子。这个例子引入了括号和取模运算符 (%)。

在逐步分析之前,请先尝试自己推断代码执行后变量 x, y, z 的最终值。

以下是代码的详细执行步骤:

  1. 声明并初始化变量 x:将整型变量 x 初始化为 2
    int x = 2;
    

  1. 声明并初始化变量 y:将整型变量 y 初始化为 x * 3

    • 此时 x2,计算 2 * 3 得到 6
    • 6 存入变量 y
    int y = x * 3; // y 的值为 6
    
  2. 声明并初始化变量 z:将整型变量 z 初始化为 y / 2

    • 此时 y6,计算 6 / 2 得到 3
    • 3 存入变量 z。注意,这里是整数除法。
    int z = y / 2; // z 的值为 3
    

  1. 执行赋值语句 x = (2 + z) % 2:这是最关键的一步。
    • 括号优先:首先计算括号内的表达式 (2 + z)z 的值为 3,所以 2 + 3 等于 5
    • 计算取模:接着计算 5 % 2。取模运算 (%) 是求余数,即 5 除以 2,商为 2,余数为 1
    • 更新变量:将计算结果 1 赋值给变量 x,覆盖其原有的值 2
    x = (2 + z) % 2; // x 的新值为 1
    

执行完以上代码后,变量的最终值为:x = 1y = 6z = 3

总结

本节课中,我们一起学习了如何分析C语言中的表达式。

  • 我们回顾了运算符的优先级规则,例如乘法优先于加法。
  • 我们通过示例看到,赋值语句 x = x * y 是计算右侧表达式并更新左侧变量,而非求解方程。
  • 我们练习了包含括号和取模运算符 (%) 的复杂表达式求值,理解了括号可以改变运算顺序,以及取模运算是求除法后的余数。

通过这两个循序渐进的例子,你现在应该能够分析和计算涉及各种数学表达式的C语言代码片段了。掌握表达式求值是理解程序逻辑流的基础。

012:使用函数进行抽象 🧩

在本节课中,我们将要学习C语言中一个极其重要的概念:函数。我们将通过一个具体的例子——计算最近点对的算法——来理解为什么函数如此关键,以及它们如何帮助我们避免代码重复、减少错误,并构建更清晰、更易维护的程序。

为什么需要函数?🤔

上一节我们介绍了最近点对算法的基本思路。现在,让我们仔细审视这个算法。你会发现,在算法中,有两处地方需要计算两点之间的距离。

为什么这一点很重要?因为这意味着我们需要执行两次完全相同的计算。如果不使用函数,我们就不得不将相同的代码编写两次,而这仅仅是在这一个问题中。试想一下,如果我们在解决其他问题时也需要计算两点之间的距离呢?

以下是几个同样需要计算两点距离的问题示例:

  • 在地图上寻找最近的商店。
  • 在游戏中计算两个角色之间的距离。
  • 在物理模拟中计算粒子间的距离。

当然,还有很多其他例子。我们当然可以每次都重写计算距离的代码。对于像计算距离这样的小任务,这看起来似乎没什么大不了的。但是,我们应该尽可能地避免重复代码。

每次重写代码,我们都冒着犯错的风险,可能会将错误引入我们的程序。此外,一遍又一遍地重写相同的代码也非常枯燥。

因此,更好的方法是:将计算距离的过程抽象出来,变成一个独立的函数

函数如何工作?⚙️

distance(距离)做成一个独立的函数,意味着我们可以在任何需要的地方重用这个算法,而无需重写它。

具体是如何工作的呢?我们的closest_point(最近点)函数会在其代码中调用distance函数。也就是说,它会请求distance函数来执行计算。

以下是调用过程的步骤:

  1. 传递参数closest_point函数首先会传入参数值,指定distance函数应该计算哪两个点之间的距离。
  2. 执行函数distance函数开始执行其内部的代码,按照你学到的规则运行其中的语句。
  3. 返回结果:当distance函数计算出答案后,它会将这个结果返回给调用它的函数(即closest_point)。
  4. 继续执行:返回后,distance函数的任务就完成了。调用函数(closest_point)则继续它自己的执行,并利用从被调用函数那里得到的答案。

函数的好处 ✨

那么,这样做有什么帮助呢?

首先,代码重用。我们可以在任何需要的时候重用这个距离计算,无需重写。无论是在closest_point函数中的不同位置,还是在解决其他任何问题时,我们只需要简单地调用distance函数即可。

其次,也是函数更重要的好处:抽象

抽象是指将接口(某物做什么)与其实现(某物如何做)分离开来。一旦我们编写好了distance函数,我们就可以直接使用它,而无需再考虑其内部是如何工作的。随着你构建的程序越来越大、越来越复杂,抽象的重要性也会日益凸显。

构建函数调用链 🔗

你并不局限于只使用两个函数。一个程序可以拥有许多函数,这些函数可以根据需要调用任意多个其他函数。

例如,假设我们的closest_point函数是一个更大程序的一部分。这个程序拥有各种类型地点的信息,并利用这些信息来找出离我们最近的特定类型的地点。

你最终可能会得到类似下面的调用链:

  1. main函数:所有C程序的起点。它可能调用一个find_nearest_store(寻找最近商店)函数,并传入参数来指定位置信息(例如坐标(4, 217))。
  2. find_nearest函数:这个函数可能接着调用另一个函数get_locations_by_type(按类型获取地点),来获取所有类型为“商店”的地点列表。
  3. get_locations_by_type函数:返回商店地点列表给find_nearest
  4. 再次调用find_nearest函数完全可以再调用另一个函数,比如我们的closest_point函数,来找出离目标位置最近的那个点。
  5. closest_point函数:正如我们之前讨论的,closest_point可以调用distance函数来计算两点之间的距离,并获得返回的计算结果。当然,closest_point可以根据需要多次调用distance函数。
  6. 结果返回:当closest_point完成计算并得出答案后,它将该值返回给调用它的函数(find_nearest)。find_nearest接着完成自己的计算,并将其答案返回给调用它的函数(main),依此类推。

总结 📝

本节课中,我们一起学习了C语言中函数的高级概念。我们了解到,函数通过封装特定功能(如计算距离)来促进代码重用,避免重复劳动和潜在错误。更重要的是,函数提供了抽象能力,让我们能够关注“做什么”而非“怎么做”,这是构建复杂程序的基石。最后,我们看到函数可以相互调用,形成清晰的调用链,从而构建出模块化、易于理解和维护的程序结构。

现在你已经掌握了函数的核心思想,接下来让我们深入C语言,具体学习函数的语法和语义。

013:函数调用执行过程详解 🧠

在本节课中,我们将深入探讨C语言中函数调用的执行过程。我们将通过一个具体的代码示例,一步步跟踪程序的执行流程,理解函数调用时内存(栈帧)如何创建、参数如何传递、返回值如何计算以及控制流如何转移。这对于理解程序运行机制至关重要。


概述

我们将分析一个包含三个函数(mainmy_functionf)的程序。通过跟踪其执行,我们将清晰地看到:

  1. 程序从 main 函数开始执行。
  2. 函数调用时,会为其创建独立的栈帧(frame),用于存储参数和局部变量。
  3. 参数通过值传递(复制)的方式初始化。
  4. 执行流程通过“调用点”(call site)记录和返回。
  5. 函数返回时,其栈帧被销毁,控制权交回调用者。

现在,让我们进入详细的步骤分析。


执行步骤分解

第一步:进入 main 函数并声明变量

程序从 main 函数开始执行。首先,我们为 main 函数创建一个栈帧。执行第一条语句,声明变量 a。我们在 main 的栈帧中为 a 分配一个存储空间(“盒子”)。

int a;

第二步:调用 my_function 函数

接下来,执行 a = my_function(3, 7);。为了计算这个表达式,我们需要调用 my_function

以下是调用 my_function 的完整过程:

  1. 创建栈帧:为 my_function 创建一个新的栈帧。
  2. 传递参数:根据函数声明 my_function(int x, int y),在栈帧中创建参数 xy 的“盒子”。将调用时传入的值(37)复制到这两个盒子中。
    • x = 3
    • y = 7
  3. 记录返回地址:在代码中标记出调用点(Call Site 1),并将这个标记(1)记录在 my_function 栈帧的角落。这指明了函数执行完毕后应返回的位置。
  4. 转移执行控制:将“执行箭头”移动到 my_function 函数内部,开始执行其中的代码。

第三步:执行 my_function 函数

现在,我们在 my_function 的栈帧中执行代码。

  1. 声明并初始化变量 z:计算表达式 2 * x - y
    • 从栈帧中取得 x=3, y=7
    • 计算:2 * 3 - 7 = -1
    • 因此,z = -1
  2. 遇到 return 语句:return z * x;。这表示要结束当前函数并返回一个值。
    • 计算返回值:计算表达式 z * x,即 -1 * 3 = -3
    • 定位返回点:查看栈帧中记录的调用点标记(1)。
    • 传递返回值:将计算得到的返回值 -3 复制回调用点(即 main 函数中 a = my_function(...) 这个位置)。
    • 销毁栈帧并返回:销毁 my_function 的栈帧,将执行箭头移回调用点(标记1处)。

第四步:回到 main 函数并赋值

此时,我们回到了 main 函数。函数调用 my_function(3, 7) 的值已经计算出来,是 -3。因此,这条语句等价于 a = -3;。我们将 -3 存入变量 a 的盒子中。

第五步:调用 f 函数

执行下一条语句 int b = f(a * a);。首先为 b 创建存储空间,然后计算 f(9)(因为 a * a(-3) * (-3) = 9)。

调用 f 函数的过程与之前类似:

  1. f 创建栈帧。
  2. 传递参数:根据 f(int n),创建参数 n 并赋值为 9
  3. 记录返回地址(标记为 Call Site 2)。
  4. 跳转到 f 函数内部执行。

第六步:执行 f 函数

f 函数中,我们遇到语句 return 3 + my_function(n, n + 1);。在返回之前,需要先计算 my_function(n, n + 1) 的值。

因此,我们再次调用 my_function

  1. 为这次调用创建一个新的 my_function 栈帧(与第一次调用互不影响)。
  2. 传递参数:x = n = 9y = n + 1 = 10
  3. 记录返回地址(标记为 Call Site 3,位于 f 函数内部)。
  4. 跳转到 my_function 执行。

第七步:第二次执行 my_function

在新的栈帧中执行 my_function(9, 10)

  1. 计算 z = 2 * x - y = 2*9 - 10 = 8
  2. 遇到 return z * x;
    • 计算返回值:8 * 9 = 72
    • 根据栈帧中的标记(3)返回到 f 函数内的调用点。
    • 销毁本次 my_function 的栈帧。

第八步:完成 f 函数的执行

现在回到了 f 函数,我们得到了 my_function(9, 10) 的返回值 72。接着计算 return 3 + 72;,得到 75。这就是 f 函数的返回值。

  1. 根据 f 栈帧中的标记(2)返回到 main 函数中的调用点。
  2. 销毁 f 函数的栈帧。

第九步:完成 main 函数的执行

我们回到了 main 函数,f(a*a) 的调用结果为 75。因此,b 被初始化为 75

最后,main 函数执行 return 0;。当从 main 函数返回时,整个程序结束。


核心概念总结

本节课我们一起学习了C语言函数调用的完整执行过程,其核心机制可以概括为以下公式和步骤:

核心机制:栈帧管理
每次函数调用都会在内存的调用栈上创建一个独立的栈帧,用于隔离该函数的执行环境。

调用过程公式化描述:

1. 调用者(caller)执行 `result = callee(arg1, arg2, ...);`
2. 系统为被调用者(callee)创建新栈帧
3. 参数值被复制到新栈帧的形参变量中
4. 记录下一条指令地址(返回地址)到栈帧
5. 跳转到被调用者函数体开始执行
6. 被调用者使用自己的栈帧进行计算
7. 遇到return语句,计算返回值R
8. 根据栈帧记录的地址跳回调用者
9. 销毁被调用者的栈帧
10. 返回值R被用于调用者的表达式(赋给result)

关键规则:

  • 值传递:参数是原始值的副本,修改形参不影响实参。
  • 后进先出 (LIFO):函数调用和返回遵循栈的顺序,最后被调用的函数最先返回。
  • 局部性:函数只能直接访问自己栈帧内的变量。

通过这种按步骤的跟踪分析,你应该对程序运行时函数如何交互、内存如何分配与清理有了直观的认识。这是理解更复杂编程概念(如递归、指针)的重要基础。

014:使用printf函数打印输出 📝

在本节课中,我们将学习如何使用C语言中的printf函数向终端打印输出。我们将通过几个具体的例子,了解如何打印字符串、变量值以及如何使用格式说明符和转义序列来控制输出格式。


打印字符串示例

上一节我们介绍了程序的基本框架,本节中我们来看看如何使用printf打印简单的字符串。

我们有一个标准的main函数框架和一个输出框。执行箭头位于main函数的开始处。

首先,我们声明并初始化一个整数变量a,其值为42

int a = 42;

然后,我们遇到第一个printf调用。

printf("Hello world");

这个调用将直接打印字符串“Hello world”。字符串中没有任何格式说明符。因此,我们的输出是:

Hello world

接下来是第二个printf调用。

printf("A");

请注意,这里打印的是字面意义上的字母“A”,因为它被引号包围,是一个字符串字面量。这与变量a无关。所以输出是:

A

最后,我们返回0并退出main函数。


使用格式说明符打印变量值

如果你想打印变量a的值,应该怎么做呢?printf中的“f”代表“格式化”(formatted),你可以使用多种格式说明符来格式化输出。

在这个例子中,我们首先声明并初始化两个整数。

int a = 42;
int b = 7;

然后,我们遇到一个带有格式说明符的printf调用。

printf("%d", a);

在这里,%d将整数格式化为十进制数字。%d意味着printf将查看对应的参数(这里是变量a),并将其作为十进制数字打印出来。因此,它会在字符串中%d的位置打印数字42的十进制表示。
输出为:

42

下一个printf调用包含两个%d

printf("b is %d and a+b is %d", b, a+b);

第一个%d对应字符串后的第一个参数b(值为7)。第二个%d对应表达式a+b。这个表达式会像其他表达式一样被求值,即42 + 7 = 49
因此,输出是:

b is 7 and a+b is 49

最后,我们返回0并退出main函数。


使用转义序列控制格式

关于printf,另一个需要了解的重要概念是转义序列。转义序列以反斜杠\开头。

最常见的转义序列是\n,代表换行。

printf("Hello world\n\n");

这个printf调用包含两个\n,因此它会在“Hello world”之后打印两个空行。

接下来,我们声明并初始化变量b

int b = 7;

这个printf调用中包含\t,代表制表符,对于对齐输出非常有用。

printf("My answer is\t%d", b);

输出为:

My answer is    7

最后,我们返回并退出main函数。


本节课中我们一起学习了printf函数的基本用法。我们看到了如何打印简单的字符串,如何使用%d这样的格式说明符来打印整型变量的值,以及如何使用\n\t这样的转义序列来控制输出的换行与对齐。这些是C语言中输出信息的基础,在后续课程中你还会看到更多printf的高级用法。

015:if-else语句执行过程详解 🧭

在本节课中,我们将学习C语言中if-else条件语句的具体执行过程。我们将通过一个具体的函数示例,一步步跟踪程序的执行流程,理解条件判断如何引导程序走向不同的代码分支。

概述

我们将分析一个包含ifif-else语句的函数F。通过模拟程序在内存中的执行步骤,包括变量声明、函数调用、条件判断和返回值处理,来直观地理解控制流的运作机制。

执行过程逐步解析

程序从main函数的起始处开始执行。

第一步:声明变量并调用函数

第一条语句声明了一个变量A,我们为其在内存中创建一个“盒子”。尽管这条语句会初始化A,但我们需要先计算f(34)的值,因此暂时将A的值标记为问号。

为了计算对函数F的调用,我们创建一个栈帧,并传入参数值。我们记录下返回位置,然后将执行箭头移动到函数F的第一行。

第二步:执行第一个if语句

下一行代码是一个if语句,其条件表达式为x < y。我们计算该表达式,发现3 < 4为真。

因此,我们将执行箭头移入该if语句的then子句中继续执行。

以下是then子句中的操作:

  1. 执行printf语句,输出“x less than y”。
  2. 执行return y + x;语句。我们计算表达式得到返回值7。这就是函数调用的结果。

现在,我们返回到调用者(main函数),并销毁F函数的栈帧。此时我们知道了F(3,4)的调用结果为7,因此完成变量A的初始化,A的值现在为7

第三步:处理main函数中的第二个调用

我们准备好执行main函数中的下一行代码。为变量B创建一个盒子,并调用F(7, 5)

我们再次计算条件x < y。此时x7y5,因此7 < 5为假。

我们找到这个if语句的闭合花括号,并看到该语句有一个else子句。因此,我们将执行箭头移入else子句的开头,并从那里继续执行。

第四步:执行else子句及嵌套的if

首先,执行else子句中的printf语句。

然后,我们遇到另一个if语句。这个if语句嵌套在else内部,但这不影响我们评估它的规则。我们看到其条件表达式为假,因此我们寻找该if语句的闭合花括号。

由于没有else子句,我们立即将执行箭头移过闭合花括号,并继续执行。

else子句内部没有更多语句,因此我们将执行箭头移出else子句并继续。

第五步:函数返回并结束

现在我们遇到一个return语句。我们计算表达式x - 2,得到返回值5,该值将返回到我们记录的位置。

我们返回到main函数,并销毁F函数的栈帧。然后完成对变量B的赋值语句。

最后,我们到达main函数的return语句,执行它并退出程序。

总结

本节课中,我们一起学习了if-else语句在C语言中的执行过程。我们跟踪了一个具体示例,观察了程序如何根据条件表达式的真假值选择不同的执行路径,包括处理嵌套的条件语句。理解这个流程对于编写和调试依赖条件逻辑的程序至关重要。

016:switch-case语句执行过程详解 🧭

在本节课中,我们将通过一个具体的函数 G 来学习 switch-case 语句的执行过程。我们将一步步跟踪代码的执行,观察变量如何变化,以及程序的控制流如何根据不同的条件进行跳转。


概述

我们将分析一个包含 switch-case 语句的函数 G 的调用过程。通过三次不同的函数调用,我们将看到:

  1. 如何根据 switch 表达式的值匹配对应的 case 标签。
  2. break 语句如何影响执行流程。
  3. 当没有匹配的 case 时,如何执行 default 分支。
  4. 函数调用栈帧的创建与销毁过程。

理解这些细节对于掌握C语言中条件分支的逻辑至关重要。


初始状态与第一次调用

程序从 main 函数开始执行。第一条语句是 int a = G(10, 4);

首先,我们为变量 a 创建一个存储框。接着,为函数 G 的调用创建一个栈帧,并将实参 104 分别赋给形参 nx。我们记录下在 main 函数中的当前位置,然后进入函数 G 执行。

在函数 G 内部,我们遇到 switch 语句。其表达式为 x + n,在当前上下文中计算为 4 + 10,结果是 14

程序查找与值 14 匹配的 case 标签,并找到 case 14:。执行箭头跳转到该 case 内部,开始执行其中的语句。

我们遇到的第一条语句是 return n - x;。计算 10 - 4 得到 6。当遇到 return 语句时,我们记下返回值 6,然后退出函数 G,返回到调用它的 main 函数,同时销毁 G 的栈帧。

回到 main 函数后,我们完成赋值语句,将 6 赋给变量 a


第二次调用与流程控制

接下来,我们执行第二条语句:int b = G(a, 2);

我们为变量 b 创建存储框。再次为函数 G 绘制栈帧,传入实参 a (值为6) 和 2。记录位置后,进入函数 G

此时,switch 的表达式 x + n 计算为 2 + 6,结果是 8。程序跳转到 case 8: 并开始执行其中的语句。

我们遇到的第一条语句是 x = x + 1;。因此,我们将 x 的值从 2 更新为 3

这里有一个关键点: 由于这个 case 块末尾没有 break 语句,程序会“贯穿”到下一个 case 继续执行。我们无需关心下一个 case 标签的值,只需继续执行语句,直到遇到 break

接下来,我们执行语句 n = n - 1;,将 n 的值从 6 更新为 5

现在,我们遇到了一个 break; 语句。这个 break 语句会使程序跳出其所在的最内层 switch 语句(其边界如图所示)。稍后我们会看到,break 也可以用于跳出其他结构,但在此处,它用于跳出包围它的 switch 语句。

跳出 switch 后,我们开始执行其后的代码。下一条语句是 return x * n;,计算 3 * 5 得到 15。我们返回 15,回到 main 函数,销毁 G 的栈帧,并将 15 赋值给变量 b


第三次调用与默认分支

最后,我们执行第三条语句:int c = G(9, b);

我们为变量 c 创建存储框。为函数 G 创建栈帧,传入实参 9b (值为15)。记录位置后,进入函数 G

计算 switch 表达式 x + n,得到 15 + 9 = 24。查看所有的 case 标签:8014。没有任何一个匹配 24

因此,程序执行 default 分支。default 标签匹配所有未被其他 case 显式命中的值。我们跳转到 default 内部并开始执行语句。

我们执行 x = n;,将 n 的值 9 赋给 x。接着遇到 break; 语句,它使我们跳出 switch 语句。

然后,我们执行 return x * n;,计算 9 * 9 得到 81,并将其返回给 main 函数。

main 函数中,我们完成对 c 的赋值。最后,main 函数执行 return 0;,程序正常退出。


总结

本节课中,我们一起学习了 switch-case 语句的详细执行过程。我们通过跟踪三次函数调用,明确了以下核心概念:

  • 匹配与跳转switch 根据表达式的值跳转到匹配的 case 标签处执行。
  • 贯穿现象:如果 case 块末尾没有 break,程序会继续执行下一个 case 中的语句,直到遇到 breakswitch 结束。
  • 默认分支default 用于处理所有未被特定 case 覆盖的情况。
  • 控制流break 语句用于立即终止 switch 语句的执行。
  • 函数调用机制:我们回顾了函数调用时栈帧的创建、参数传递、返回值的处理以及栈帧的销毁这一完整过程。

理解这些流程对于编写正确且高效的C语言程序至关重要。

017:while循环执行过程详解 🔄

在本节课中,我们将通过一个具体的代码示例,详细演示while循环的执行过程。我们将一步步跟踪程序的执行流程,理解循环条件如何被评估、循环体如何执行,以及循环何时终止。


程序初始化

首先,程序从main函数开始执行。第一条语句声明了一个变量x,并通过调用函数g(2, 9)来初始化它。

int x = g(2, 9);

进入函数g

执行流程进入函数g。在g函数内部,首先声明变量total并将其初始化为0。

int total = 0;

while循环的首次评估

接下来,我们遇到一个while循环。循环的条件表达式是a < b。在本次调用中,a为2,b为9,因此条件2 < 9。程序进入循环体。


循环体第一次执行

以下是循环体内的语句执行顺序:

  1. 更新total:执行total += a;。这是total = total + a;的简写。此时total为0,a为2,所以total被赋值为2。
  2. 打印变量:执行printf(“a = %d, b = %d\n”, a, b);。这将输出:a = 2, b = 9
  3. 更新a和b
    • a++;a = a + 1; 的简写。a的值变为3。
    • b--;b = b - 1; 的简写。b的值变为8。

执行完循环体的最后一个花括号}后,执行流程会跳回while循环的顶部,重新评估条件表达式。


循环的后续迭代

现在,让我们看看循环如何继续进行:

  • 第二次迭代:条件3 < 8为真。进入循环体。
    • total += a; -> total = 2 + 3 = 5
    • printf 输出:a = 3, b = 8
    • a++ -> a = 4b-- -> b = 7
  • 第三次迭代:条件4 < 7为真。进入循环体。
    • total += a; -> total = 5 + 4 = 9
    • printf 输出:a = 4, b = 7
    • a++ -> a = 5b-- -> b = 6
  • 第四次迭代:条件5 < 6为真。进入循环体。
    • total += a; -> total = 9 + 5 = 14
    • printf 输出:a = 5, b = 6
    • a++ -> a = 6b-- -> b = 5

循环终止

第四次迭代结束后,执行流程再次跳回循环顶部,评估条件6 < 5。此时条件为

因此,执行流程会跳过整个循环体,直接开始执行循环后面的语句。


函数返回与程序结束

循环后面的语句是return total;。此时total的值为14,因此函数g返回值14。

执行流程返回到main函数,变量x的初始化完成:x = 14

main函数继续执行下一条语句printf(“x = %d\n”, x);,输出:x = 14

最后,main函数执行return 0;,程序正常退出。


总结 🎯

本节课中,我们一起学习了while循环的完整执行过程。我们跟踪了代码,观察了循环条件a < b如何从真变为假,循环体如何重复执行,以及变量totalab在每次迭代中的变化。关键点在于:只要循环条件为真,就执行循环体;条件为假时,则跳过循环体继续执行。通过这个详细的演练,你应该对while循环的工作原理有了清晰的认识。

C语言入门:P18:for与while循环的等价性 🔄

在本节中,我们将学习for循环与while循环在功能上的等价性。我们将通过分析两种循环的结构和执行流程,来理解它们如何实现相同的逻辑。


for循环有时被称为“语法糖”,因为它允许你以一种更紧凑的形式来编写程序中常见的计数模式。让我们来对比一下它们的结构。

以下是for循环与while循环的对应组成部分:

  • 初始化语句:在for循环中,它紧跟在for关键字之后。在等价的while循环中,它位于while循环开始之前。
  • 条件表达式:在for循环中,它是for关键字后的第二部分。在while循环中,它是while关键字后括号内的部分。
  • 循环体语句:任何在for循环体内执行的语句,在while循环中同样可以在其循环体内执行(通常在增量语句之前)。
  • 增量语句:在for循环中,它是for关键字后的第三部分。在等价的while循环中,增量操作发生在循环体内部,通常就在右花括号}之前。

上一节我们介绍了两种循环的结构对应关系,本节中我们来看看它们在实际执行中是如何等价的。

我们通过一个具体的代码示例来观察这两个等价循环的执行过程。假设我们有以下代码片段:

// for循环版本
int y = 1;
int n = 3;
for (int i = 1; i < n; i++) {
    y = y * i;
}

// while循环版本
int y = 1;
int n = 3;
{
    int i = 1;
    while (i < n) {
        y = y * i;
        i++;
    }
}

执行总是从main函数内部开始。首先,我们初始化y为1,n为3。

现在,for循环的语法要求我们在for关键字后立即初始化int i = 1。因此,我们将执行指针暂停在此处。与此同时,右侧while循环的执行指针进入花括号,并以同样的方式初始化i,改变了程序的状态。

这里花括号的作用很微妙。由于变量i的作用域被限制在for循环内,这里的while循环也使用花括号将变量i的作用域限制在这个“盒子”里。否则,它的作用域将是整个main函数,这就与for循环版本不等价了。

查看条件表达式i < n,此时1 < 3为真,因此我们进入循环体开始执行语句。计算y * i,即1 * 1,结果为1,赋值给y

接着,在for循环中,我们会将i递增到2,然后返回到循环顶部。在while循环中,我们在循环体末尾执行i++,将i递增到2,然后返回到while条件判断处。

再次检查条件表达式2 < 3,结果为真。我们再次进入循环体,计算y * i,即1 * 2,将结果2赋值给y

然后,i被递增到3,并再次返回到循环顶部。此时条件3 < 3为假。

因此,我们将执行指针移动到循环之后。对于for循环,我们尊重i的作用域。对于while循环,我们退出其后的花括号,由于i现在已超出作用域,这个“盒子”被销毁。

最后,程序return 0;并退出main函数。


本节课中我们一起学习了for循环与while循环的等价性。我们对比了它们的结构,并通过一步步跟踪代码执行过程,验证了它们在逻辑上可以完成完全相同的任务。理解这种等价性有助于我们根据代码的清晰度和简洁性,灵活选择最合适的循环结构。

019:嵌套循环执行过程详解 🌀

在本节课中,我们将通过一个具体的代码示例,详细学习C语言中嵌套循环的执行过程。我们将分析一个包含while循环、if条件语句和for循环的复杂结构,并一步步跟踪程序的执行流程。


概述

我们首先来看一个函数F,其内部包含一个while循环。在这个while循环中,又嵌套了一个if条件语句。而在这个if语句内部,则包含了一个for循环。理解嵌套结构的关键在于,它并没有引入新的规则,我们只需遵循之前学过的、从外到内逐层执行的顺序即可。

代码执行流程分析

程序从main函数开始执行。首先,我们调用函数F,并传入参数a=3b=8

调用函数F时,会创建一个新的栈帧。程序会记录返回地址,然后开始执行F函数内部的代码。

进入while循环

由于条件a < b(即3 < 8)为真,程序进入while循环体。
首先,执行printf语句,打印出a=3, b=8
接着,遇到一个if语句,其条件是判断a % 2 == 0。这里,%是取模运算符,用于计算除法后的余数。
计算3 % 2,得到余数1。因此,条件为假,程序跳过整个if语句块。
然后,执行a++,将a的值增加为4;执行b--,将b的值减少为7
至此,while循环体第一次执行完毕。

循环的继续与进入if语句

程序跳回while循环的起始处,再次检查条件a < b4 < 7)。条件为真,因此再次进入循环体。
打印a=4, b=7
再次检查if条件a % 2 == 0。计算4 % 2,得到余数0,条件为真。因此,程序这次进入了if语句块。

执行嵌套的for循环

if语句块内部,是一个for循环。

  1. 初始化:声明整型变量i,并将其初始化为a的当前值,即i = 4
  2. 条件检查:检查循环条件i < b4 < 7)。条件为真,进入for循环体。
  3. 循环体执行:打印i=4
  4. 更新:执行i++,将i的值更新为5
  5. 返回步骤2(条件检查)。

以下是for循环的完整迭代过程:

  • 第一次迭代i=4,条件4<7为真,打印i=4i自增为5
  • 第二次迭代i=5,条件5<7为真,打印i=5i自增为6
  • 第三次迭代i=6,条件6<7为真,打印i=6i自增为7
  • 第四次条件检查i=7,条件7<7为假。循环终止,程序跳出for循环。

完成if与while循环

跳出for循环后,我们位于if语句块的末尾。程序继续执行if语句之后的代码。
执行a++a变为5;执行b--b变为6
到达while循环体的末尾,再次跳回循环开始处。

循环的最后一次迭代

检查条件a < b5 < 6),为真,进入循环体。
打印a=5, b=6
检查if条件a % 2 == 05 % 2 = 1),为假,跳过if语句块。
执行a++a变为6;执行b--b变为5
再次回到while循环开始。

循环结束与函数返回

检查条件a < b6 < 5),此时为假。因此,跳过整个while循环体,直接执行到函数F的末尾。
函数F执行完毕,返回main函数。
main函数中只剩下return 0;语句,执行后程序正常退出。


总结

本节课中,我们一起学习了嵌套循环的执行过程。通过跟踪一个具体的例子,我们明确了以下几点:

  1. 嵌套结构遵循从外到内、逐层执行的基本规则。
  2. 外层循环(如while)的每次迭代,都可能完整地执行其内部嵌套的整个结构(如if和内部的for循环)。
  3. 理解执行流程的关键是严格跟踪变量的值变化条件表达式的真假判断
    掌握这些步骤,你就能清晰地分析任何复杂的嵌套控制流结构。

020:continue语句执行过程详解 🚀

在本节课中,我们将学习continue语句在循环中的执行过程。我们将通过一个具体的代码示例,逐步分析当程序遇到continue时,控制流是如何跳转的,以及循环变量是如何更新的。


for循环到while循环的转换 🔄

为了更清晰地理解continue的行为,我们首先将示例中的for循环转换为功能等效的while循环。

原始for循环结构如下:

for (int i = low; i < high; i++) {
    // 循环体
}

转换的第一步,是将循环变量的声明移到循环外部:

int i = low;
for (; i < high; i++) {
    // 循环体
}

接着,为了更接近while循环的形式,我们将增量操作i++移到循环体的末尾。但需要注意的是,在continue语句之前,我们也放置了一个i++

int i = low;
while (i < high) {
    // 循环体
    i++;
}

现在,我们已经成功地将for循环转换成了等价的while循环。由于变量i在函数结束时都会超出作用域,我们可以移除之前为了转换而添加的多余花括号,这不会改变代码的行为。


逐步执行过程 🧭

现在,我们开始逐步执行代码。我们首先调用print_remainder函数,传入参数-245

  1. 初始化:声明变量i并将其初始化为low(即-2),然后进入while循环。
  2. 第一次迭代
    • 检查条件i < high(-2 < 4)为真,进入循环体。
    • 检查i == 0为假,跳过if语句。
    • 执行printf,打印“5 mod -2 is 1”。
    • 执行i++i变为-1。
    • 到达循环体末尾,跳转回循环条件检查处。
  3. 第二次迭代
    • 条件i < high(-1 < 4)为真,进入循环体。
    • i(-1)不为0,跳过if语句。
    • 打印“5 mod -1 is 0”。
    • i++i变为0。
    • 返回循环顶部。
  4. 第三次迭代(遇到continue
    • 条件i < high(0 < 4)为真,进入循环体。
    • 此时i等于0,条件i == 0为真,因此进入if语句块。
    • 打印“Cannot divide by zero.”。
    • 执行i++i变为1。
    • 遇到continue语句continue语句会立即终止本次循环的剩余部分,并直接跳转到循环条件检查处(即while(i < high)),开始下一次迭代。它不会执行循环体中continue之后的任何代码。
  5. 第四次迭代
    • 从循环条件检查开始:i为1,条件i < high为真。
    • i不为0,跳过if,打印“5 mod 1 is 0”。
    • i++i变为2。
  6. 第五次迭代
    • i为2,条件为真,进入循环。
    • 打印“5 mod 2 is 1”。
    • i++i变为3。
  7. 第六次迭代
    • i为3,条件为真,进入循环。
    • 打印“5 mod 3 is 2”。
    • i++i变为4。
  8. 循环结束
    • 再次检查条件i < high(4 < 4)为
    • 跳过循环体,退出while循环。
    • print_remainder函数返回。
  9. 最后,从main函数返回,程序结束。

核心要点总结 📝

本节课中,我们一起深入分析了continue语句的执行过程:

  1. 作用continue语句用于跳过当前循环迭代中剩余的代码,直接进入下一次循环的条件判断。
  2. for循环中的行为:在for (初始化; 条件; 增量)循环中,执行continue后,程序会先执行“增量”表达式(例如i++),然后再进行“条件”判断。这是我们通过转换为while循环来揭示的关键细节。
  3. break的区别break是立即终止整个循环,而continue只是终止当前这一次迭代,循环本身还会继续(只要条件满足)。

理解continue的精确跳转逻辑,对于编写正确的循环和控制程序流程至关重要。

021:类型系统简介

在本节课中,我们将要学习C语言中一个核心概念——类型系统。到目前为止,我们只接触过 int 类型,但类型远不止于此。理解类型是理解计算机如何处理数据的关键。

概述:一切皆数字

上一节我们介绍了C语言的基本语法和手动执行语义。本节中我们来看看计算机科学的一个关键原则:一切皆数字

计算机只能对数字进行操作。因此,所有数据都必须以数字形式表示。类型的作用就是告诉计算机如何解释和操作这些数字表示。

类型的作用

类型系统是编程语言的基石。它定义了数据的种类、可执行的操作以及数据在内存中的表示方式。

以下是类型系统的主要功能:

  1. 解释数据:告诉计算机内存中的一串二进制数字代表什么(例如,是整数、字符还是浮点数)。
  2. 定义操作:规定可以对某种类型的数据执行哪些运算(例如,整数可以加减,字符可以比较)。
  3. 管理内存:决定为数据分配多少内存空间。

课程内容预告

随着本课程的深入,你将学习到以下关于类型的知识:

  • 其他内置类型:除了 int,还将学习如 charfloatdouble 等其他基本类型。
  • 类型转换
    • 隐式类型转换:在某些运算中,编译器自动进行的类型转换。
    • 显式类型转换:通过类型转换操作符,由程序员明确指定的类型转换,例如 (float) x
  • 自定义类型:学习如何使用 structuniontypedef 等工具创建自己的类型,以表示你需要处理的任何数据。

总结

本节课中我们一起学习了类型系统的基本概念。我们了解到,计算机将所有数据视为数字,而类型是解释和操作这些数字的规则。掌握类型系统对于编写正确、高效的C语言程序至关重要。在接下来的课程中,我们将逐一探索各种内置类型、类型转换的机制以及创建自定义类型的方法。

022:类型与格式化输出 📝

在本节课中,我们将学习如何使用printf函数和各种格式说明符来打印不同类型的数据。通过具体的代码示例,你将看到如何打印字符、整数(包括有符号、无符号、八进制和十六进制)以及浮点数。


初始化变量

首先,我们初始化几个不同类型的变量。

char letter = 'G';
int neg_number = -1;
unsigned int age = 33;
float p1 = 3.141592;
double p2 = 3.141592653589793;
  • letter 是一个字符变量,值为 'G'
  • neg_number 是一个有符号整数,值为 -1
  • age 是一个无符号整数,值为 33
  • p1 是一个单精度浮点数。
  • p2 是一个双精度浮点数。

打印整数类型

上一节我们初始化了变量,本节中我们来看看如何使用printf和不同的格式说明符来打印它们。

以下是使用%c%d%u%o%x格式说明符的示例:

printf("My name begins with %c.\n", letter); // 打印字符
printf("neg_number as decimal: %d\n", neg_number); // 打印有符号十进制整数
printf("age as decimal: %d\n", age); // 打印无符号整数(用%d)
printf("age as octal: %o\n", age); // 打印八进制数
printf("age as hexadecimal: %x\n", age); // 打印十六进制数
  • %c:将参数格式化为一个字符并打印。'G'的数值是71,但%c会将其显示为字符G
  • %d:将参数格式化为有符号十进制整数并打印。
  • %o:将参数格式化为八进制(基数为8)数并打印。
  • %x:将参数格式化为十六进制(基数为16)数并打印。

格式说明符与类型不匹配

如果我们将格式说明符用于不匹配的数据类型,会发生什么?计算机将所有数据都视为数字(比特位),printf只是按照给定的格式说明符来解释这些比特位。

以下是几个类型不匹配的打印示例:

printf("letter as int (%%d): %d\n", letter); // 用%d打印字符
printf("neg_number as hex (%%x): %x\n", neg_number); // 用%x打印负数
printf("neg_number as unsigned (%%u): %u\n", neg_number); // 用%u打印负数
printf("age as char (%%c): %c\n", age); // 用%c打印整数
  • printf("%d", letter):字符'G'的数值是71。printf接收到数字71,并按%d将其打印为十进制整数71
  • printf("%x", neg_number)-1在32位有符号整数中的二进制表示是全1(1111...1111),其十六进制表示为FFFFFFFF
  • printf("%u", neg_number)printf-1的比特位(全1)解释为一个无符号32位整数,其值是最大的32位无符号整数,约42亿。
  • printf("%c", age):整数33对应的ASCII字符是感叹号!printf会打印出这个字符。

打印浮点数类型

现在,让我们把注意力转向浮点数。我们将使用floatdouble类型,并探索不同的格式说明符来控制它们的显示方式。

以下是打印浮点数的几种格式说明符:

printf("p1 as float (%%f): %f\n", p1); // 默认打印浮点数
printf("p1 in scientific notation (%%e): %e\n", p1); // 科学计数法
printf("p1 with 10 decimal places (%%.10f): %.10f\n", p1); // 指定小数位数
printf("p2 as double (%%f): %f\n", p2); // 默认打印双精度数
printf("p2 with 10 decimal places (%%.10f): %.10f\n", p2); // 指定小数位数
  • %f:将参数格式化为浮点数,默认打印6位小数。
  • %e:将参数格式化为科学计数法(例如 3.141592e+00)。e+00表示乘以10的0次方(即乘以1)。
  • %.10f:打印10位小数。点号(.)后的数字指定了精度。

注意浮点数的精度问题:当你打印p1的10位小数(3.1415920258)时,末尾出现了0258,而非预期的2653。这是因为浮点数在内存中无法精确表示所有十进制数。在初始化p1 = 3.141592时,C语言将其舍入到了最接近的可表示的浮点数值。打印时,这个内部存储的值被转换回十进制,从而显示了微小的误差。

相比之下,double类型(p2)具有更高的精度,能够更准确地表示更多数字。因此,在打印p2的10位小数时,我们得到了预期的结果3.1415926536。在编写涉及浮点数的实际程序时,理解这些表示误差对于避免隐蔽而危险的问题至关重要。


总结

本节课中我们一起学习了C语言中printf函数的格式化输出。

  • 我们使用了%c%d%u%o%x来打印字符和各种进制的整数。
  • 我们了解到,当格式说明符与参数类型不匹配时,printf会直接按照说明符解释传入的比特位,这可能导致意外的输出。
  • 我们探索了%f%e%.nf来打印浮点数,并理解了浮点数在内存中表示可能存在的精度限制,尤其是floatdouble之间的精度差异。

掌握这些格式说明符是有效调试和展示程序数据的基础。

023:类型转换

在本节课中,我们将学习C语言中的类型转换,包括隐式转换和显式转换。我们将通过一个具体的代码示例来理解它们如何发生,以及为什么需要谨慎处理数据类型。

概述

类型转换是C语言中一个重要的概念,它允许我们将一种数据类型的值转换为另一种数据类型。理解类型转换有助于我们避免计算错误,并编写出更精确的代码。

隐式转换示例

让我们通过一段示例代码来看看隐式转换何时发生,以及为什么需要关注类型。

首先,我们创建一个名为 n_hours 的整型变量,并将其初始化为40。

int n_hours = 40;

接下来,我们创建另一个整型变量 n_days,并将其初始化为7。

int n_days = 7;

然后,我们创建一个浮点型变量 average,并将其初始化为 n_hours 除以 n_days 的结果。

float average = n_hours / n_days;

由于 n_hoursn_days 都是整型,这里进行的是整数除法。40除以7在整数除法中的结果是5。

现在,由于我们将一个整型值赋值给一个浮点型变量,编译器会在除法运算之后,隐式地将整数结果转换为浮点数。因此,average 被初始化为5.0。

当我们打印结果时,会得到“40小时在7天中是每天5.0小时”的输出,这显然是不正确的。

显式转换(强制类型转换)

为了修正这个错误,我们需要对代码进行一个小小的改动:在进行除法运算之前,显式地将 n_days 转换为浮点型。

以下是修改后的代码:

float average = n_hours / (float)n_days;

我们以同样的方式开始:创建并初始化 n_hours 为40,n_days 为7。

然而,现在情况有所不同。除法表达式中的除数现在是转换为浮点数的7。因此,我们需要计算整数40除以浮点数7.0。

计算机执行整数除以整数,或浮点数除以浮点数的运算。所以,编译器必须在进行除法之前,隐式地将40转换为浮点数。

现在,我们进行的是浮点数除法:40.0除以7.0等于约5.71。

我们用这个值初始化 average,这意味着当我们打印结果时,会得到正确的答案。

总结

在本节课中,我们一起学习了C语言中的类型转换。我们看到了隐式转换如何自动发生,以及如何使用显式转换(强制类型转换)来确保运算的正确性。

记住,虽然类型转换是一个有用的工具,但应该谨慎使用。只有在仔细思考了转换的原因和目的之后,才应该使用它。

024:万物皆数字

在本节课中,我们将探讨计算机科学中的一个核心原则:万物皆数字。我们将看到,无论是文本、图像还是声音,在计算机内部最终都被表示为数字。理解这一点是学习编程和计算机如何工作的基础。

字符串是数字

上一节我们介绍了计算机处理数字的基本方式,本节中我们来看看这个原则如何应用于看似非数字的事物。

字符串就是数字。你之前见过字符的字面序列,例如 "hello world"。但你是否想过计算机如何将它们作为数字存储?每个字符都占据计算机内存的一部分。我们稍后会详细讨论具体占多少,现在你可以概念性地将每个字符想象成放在一个盒子里。

在硬件层面,计算机将每个字符存储为一个数字。你可以在ASCII表中查找不同字符对应的十进制或十六进制值。例如,字符 'h' 的十进制值是 104,字符 'e'101。当然,计算机最终以二进制形式存储它们。

计算机将字母存储为数字的一个有趣结果是,我们可以对它们进行数学运算。这个原理构成了大多数现代加密方法的基础。

图像也是数字

另一个初看不像数字的东西是图像,比如这张花的图片。这张图像由大约一百万个像素组成,每个像素都用数字来代表图像中的颜色。

为了看清这些像素,让我们放大图像的这一小部分。在这里,你可以看到图像被放大了数倍。你可以看到图像看起来是块状的或像素化的。如果你能看得那么近,图像实际上就是这样的。

请注意,如果你自己尝试,可能会得到看起来平滑得多的结果。大多数图像软件在放大图像时会应用算法来平滑像素化效果。你必须关闭这个功能才能看到原始像素。

让我们回到放大的图像,它能更清晰地显示原始像素。如果我们只看图像的一个子集并进一步放大,就能更容易地看到和讨论单个像素。

以下是原始图像中的16个像素(原始图像有约一百万个像素,这让你了解我们放大了多少倍)。

以下是这些像素的RGB值示例:

  • 这个深紫色像素的红色值为 55,绿色值为 27,蓝色值为 75
  • 同时,这个金色像素的RGB值则截然不同,分别为 16613641,这赋予了它完全不同的颜色。

声音同样是数字

声音是计算机处理的另一个重要事物。在我说话时,你可以看到我说的话的波形。计算机将这些波形编码为数字,并将它们发送到声音硬件。声音硬件利用这些数字来决定如何移动扬声器,从而产生所需的声音。

总结与展望

本节课中我们一起学习了“万物皆数字”这一核心概念。我们看到,文本(字符串)、图像(像素)和声音(波形)在计算机内部都被表示为数字序列。

除了这些,计算机中还有许多其他类型的数据需要表示。你将在课程3中学习由序列构成的数据(如数组和链表)。现在,你将开始学习 structtypedef,它们是C语言中用来创建自定义复合数据类型的关键工具,能帮助你更有效地组织和表示复杂信息。

C语言入门:p25:矩形结构体定义与使用

在本节课中,我们将学习C语言中结构体的基本概念,并通过一个具体的例子——定义一个表示矩形的结构体,来理解如何声明结构体变量、访问其成员以及进行赋值操作。


上一节我们介绍了结构体的基本概念,本节中我们来看看一个具体的结构体定义和使用实例。

在这个例子中,我们将看到一小段代码的语义,这段代码使用了一个名为 struct rect_t 的结构体。这个结构体表示一个具有四个成员(leftbottomrighttop)的矩形。

以下是声明和使用 struct rect_t 的一些代码。

与往常一样,我们从 main 函数的开头开始执行。第一条语句声明了一个类型为 struct rect_t 的变量,名为 my_rec

我们将在 main 函数的栈帧中为这个变量画一个方框,并标记为 my_rec。然而,与我们之前画过的其他方框不同,这个方框内部还有另外四个小方框,分别对应结构体的每个成员:leftbottomrighttop

由于我们尚未给这些成员赋值,这些方框是未初始化的,因此我们在其中放置了问号。

下一行代码是 my_rec.left = -4;。这是一个赋值语句,因此我们必须找到等号左边命名的方框。这行的第一部分是 my_rec,它指的是整个大方框。然而,它后面跟着 .left,这指的是 my_rec 方框内标记为 left 的小方框。请记住,点号 . 表示“在...内部”。

所以,现在我们将 -4 放入这个方框。下一行的行为类似,只不过我们将 1 放入 my_rec 方框内的 bottom 方框。同样地,我们将 8 赋值给 my_rec.right,将 6 赋值给 my_rec.top

现在,我们将再次打印关于这个矩形的一些信息。my_rec.left 指的是 my_rec 方框内的 left 方框,因此我们将 -4 传递给 printf 的第一个 %d 格式符。我们将 1 传递给 printf 的第二个 %d 格式符。

所以,我们将打印出“bottom left equals -4 1”。然后,我们将对右上角坐标进行类似的操作。

至此,你已经看到了声明和使用结构体的语义。


本节课中,我们一起学习了如何定义一个矩形结构体、声明其变量、访问结构体成员以及进行赋值。通过这个简单的例子,我们理解了结构体如何将多个相关的数据项组合成一个单一的复合类型,并通过点操作符 . 来访问其内部成员。这是组织和管理复杂数据的基础。

C语言入门:26:typedef的用途详解

在本节课中,我们将要学习C语言中typedef关键字的核心用途。typedef用于为已有的数据类型创建新的别名,这不仅能简化代码书写,还能提升代码的可读性和可维护性。我们将通过结构体和基本数据类型的例子来具体说明。


简化结构体类型声明

上一节我们介绍了结构体的基本概念,本节中我们来看看如何使用typedef来简化结构体类型的声明。

当我们声明一个结构体时,结构体标签本身并不是一个完整的类型名。例如,这里有一个矩形的结构体声明:

struct rect_tag {
    int length;
    int width;
};

此时,struct rect_tag标识了结构体类型,但单独使用rect_tag并不是类型名。当你想要使用这个结构体类型时,必须在前面加上struct关键字,如下所示:

struct rect_tag myRect;

许多程序员认为到处书写struct很繁琐。因此,他们使用typedef来为这个结构体类型定义一个新的类型名。

以下是使用typedef为我们的矩形结构体定义新类型名的一种方法:

typedef struct rect_tag RectT;

typedef关键字表明我们将为一个已存在的类型创建一个新名字。在这个例子中,新名字RectT出现在声明的最后,而现有的类型名struct rect_tag位于它们之间。

现在,我们可以直接使用RectT作为一个类型名。它是struct rect_tag的一个别名。


typedef与结构体声明的其他组合方式

为了确保你在阅读他人代码时不会感到困惑,我们还需要了解实现同一目标的另外两种常见方式。

第一种方式:合并声明
我们可以将结构体声明和typedef合并到一条语句中。

typedef struct rect_tag {
    int length;
    int width;
} RectT;

这遵循我们刚才讨论的相同规则:新名字RectT位于typedef语句的末尾,而现有的类型(在这里被声明)位于新名字和typedef关键字之间。

第二种方式:省略结构体标签
第三种方式与上一种类似,只是省略了结构体标签。

typedef struct {
    int length;
    int width;
} RectT;

这会创建一个没有标签的结构体,并立即将其别名定义为RectT


提升代码可维护性与可读性

typedef的用途远不止于结构体。它还能显著提升代码的可维护性和可读性。

假设我们正在编写处理像素RGB值的代码。在最初的设想中,我们使用unsigned int来表示每个像素的红、绿、蓝分量。

unsigned int red, green, blue;

但如果后来我们发现,由于RGB值只能在0到255之间,使用unsigned char会更节省内存,那该怎么办?

按照最初的写法,我们必须找到每一处使用unsigned int来表示RGB值的地方并进行修改。我们甚至不能简单地使用编辑器的搜索替换功能,因为程序中可能还有其他不表示RGB值的unsigned int变量,我们并不想改动它们。这样的修改既繁琐又容易出错。

事实上,编程的一个重要原则是:编写代码时,应确保如果需要修改某处,你只需在一个地方进行改动

现在,假设我们最初是这样编写代码的:

typedef unsigned int RGBT;
RGBT red, green, blue;

这里,我们使用typedefRGBT成为unsigned int的别名,然后在所有需要表示RGB值的地方都使用RGBT作为类型名。

采用这种写法后,如果我们想改变用于RGB值的类型,只需修改typedef语句一处,所有相关代码都会自动正确地更新。此外,这还带来了一个额外的好处:它提高了代码的可读性。任何阅读代码的人都能通过类型RGBT立刻识别出某个变量、参数或返回值代表的是一个RGB值。


总结

本节课中我们一起学习了typedef关键字在C语言中的核心用途。我们了解到:

  1. typedef可以为复杂的数据类型(如结构体)创建简洁的别名,简化代码书写。
  2. 它可以与结构体声明以多种方式结合使用。
  3. 更重要的是,typedef通过将数据类型抽象化,将“是什么”与“如何实现”分离,极大地增强了代码的可维护性(只需修改一处定义)和可读性(类型名具有明确的语义)。

合理使用typedef是编写清晰、健壮且易于维护的C语言代码的重要技巧之一。

027:枚举类型详解 🧩

在本节课中,我们将学习如何在C语言中定义和使用枚举类型。枚举类型是一种用户自定义的数据类型,它允许我们为整数值分配有意义的名称,从而使代码更易读、更易维护。

概述

我们将通过一个模拟安全系统威胁等级的例子来讲解枚举类型。系统有五个威胁等级:低、警戒、升高、高和严重。我们将这些等级编码为一个枚举类型,并展示如何在代码中使用它们。

定义枚举类型

首先,我们来看如何定义一个枚举类型。在代码顶部,我们使用 enum 关键字来创建。

enum threat_level_t {
    LOW,
    GUARDED,
    ELEVATED,
    HIGH,
    SEVERE
};

C语言会自动为枚举列表中的每个名称分配一个整数值,从0开始递增。因此,LOW 对应值0,GUARDED 对应值1,依此类推,SEVERE 对应值4。

使用枚举类型

定义好枚举类型后,我们就可以像使用其他数据类型(如 int)一样使用它。我们可以声明枚举类型的变量,并将其初始化为某个枚举常量。

enum threat_level_t my_threat = HIGH; // my_threat 的值为 3

辅助函数示例

为了展示枚举类型的实用性,我们定义了两个辅助函数。第一个函数 print_threat 用于打印与特定威胁等级对应的字符串。

以下是 print_threat 函数的实现,它使用 switch 语句来根据枚举值执行不同的操作:

void print_threat(enum threat_level_t threat) {
    switch(threat) {
        case LOW: printf("Green/Low\n"); break;
        case GUARDED: printf("Blue/Guarded\n"); break;
        case ELEVATED: printf("Yellow/Elevated\n"); break;
        case HIGH: printf("Orange/High\n"); break;
        case SEVERE: printf("Red/Severe\n"); break;
    }
}

第二个函数 print_shoes 根据当前威胁等级判断是否需要脱鞋。

以下是 print_shoes 函数的实现,它使用 if-else 语句进行逻辑判断:

void print_shoes(enum threat_level_t cur_threat) {
    if (cur_threat >= ELEVATED) { // ELEVATED 的值为 2
        printf("Please take off your shoes.\n");
    } else {
        printf("Shoes may be worn.\n");
    }
}

主函数流程

现在,让我们看看 main 函数如何将这些部分组合起来。

  1. 首先,声明并初始化一个枚举变量 my_threat
  2. 然后,调用 print_threat 函数打印当前威胁等级。
  3. 接着,调用 print_shoes 函数给出相应指示。
int main() {
    enum threat_level_t my_threat = HIGH;
    print_threat(my_threat);
    print_shoes(my_threat);
    return 0;
}

当程序运行时,my_threat 的值(HIGH,即3)作为参数传递给 print_threat 函数。函数内部,switch 语句跳转到 case HIGH: 分支,打印出“Orange/High”。函数返回后,其栈帧被销毁。

随后,程序调用 print_shoes 函数。函数内部判断 cur_threat(值为3)是否大于等于 ELEVATED(值为2)。条件为真,因此执行 then 子句,打印“Please take off your shoes.”。函数返回后,其栈帧也被销毁。

最后,main 函数执行完毕,程序结束。

枚举类型的优势

通过这个例子,我们可以清楚地看到使用枚举类型的优势。如果仅使用整数0到4来表示威胁等级,代码中会充斥大量“魔法数字”,其含义对阅读者来说不明确。

例如,if (threat_level >= 2) 就不如 if (cur_threat >= ELEVATED) 直观。枚举类型使代码的意图更加清晰,从而更容易阅读、编写和修改。

总结

本节课我们一起学习了C语言中枚举类型的核心知识。我们了解了如何定义枚举类型,它本质上是为一系列整数常量提供了有意义的名称。我们通过安全威胁等级的例子,实践了如何声明枚举变量、如何使用枚举值,并看到了在函数(如 switchif 语句中)使用枚举如何极大地提升代码的可读性和可维护性。记住,用 ELEVATED 这样的名字代替数字2,是编写清晰、专业代码的重要一步。

028:规划与协作的重要性 🎯

在本节课中,我们将跟随杜克大学软件工程硕士生Evie的分享,探讨编程学习中规划与团队协作的重要性,并了解如何为未来的职业生涯做好准备。


我叫Evie,在杜克大学攻读工程硕士项目,专业是电气与计算机工程。作为这所大学和这个项目的一名学生,这是一段非常棒的经历,我在这里学到了很多。

对于编程新手来说,可能会认为编程完全是魔法。但实际上并非如此,在编程世界里,一切都遵循逻辑,必须言之成理。因此,你首先需要信任逻辑,而不是随意空想。此外,规划是编程中必须认真考虑的事情。与其只是胡乱地坐在屏幕前假装工作、敲击键盘,你实际上需要先制定计划、绘制图表,并认真思考你需要做什么,然后再将想法在编程世界中实现出来。

在杜克大学,尤其是教授们和所有课程都经过了精心的规划和设计。因此,请信任所有你必须完成的课程项目,并一步一步地执行。最终,你将成为一名出色的程序员。


上一节我们讨论了规划的重要性,本节中我们来看看一个具体的项目案例,它展示了团队协作的核心价值。

在整个硕士项目中,我完成了许多项目。我特别想谈谈在服务器软件课程中完成的UPS和亚马逊项目。这个项目的基本情况是:一个团队负责UPS系统,另一个团队负责亚马逊系统,我们需要共同设计通信协议,以使整个系统正常运行。

这个项目对我来说非常特别,因为它实际上模拟了工业界的真实工作场景。因为在工业界,你不可能独自一人甚至一个团队就完成整个系统。因此,学会如何与其他团队沟通并共同设计协议至关重要。在整个开发过程中,我们实际上需要逐步修改我们的协议,最终才能达到完美的状态。这个过程有些痛苦,但最终,当我们把所有部分整合在一起,发现整个系统完美运行时,那种成就感和内心的平静与快乐是无与伦比的。


从项目经验过渡到职业准备,对于正在努力找工作或准备面试的学生,我完全理解你们现在的感受。我知道这很难,但请相信我,一切都会好起来的。

我认为找工作最重要的事情不是“我如何通过这场面试”,而是“我如何成为一名优秀的软件工程师”。为了实现这个目标,你需要做的就是练习、练习、再练习。遵循所有课程项目的指引和教授们的建议,因为他们拥有丰富的经验,并且了解行业需要什么。一旦你越来越能胜任软件工程师的工作,工作机会自然会找到你。


本节课总结

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

  1. 编程基于逻辑:编程并非魔法,一切都需要遵循清晰的逻辑。
  2. 规划先行:动手编码前,充分的规划和设计至关重要。
  3. 团队协作的价值:通过真实的项目案例,我们看到了沟通与协议设计在大型项目中的核心作用。
  4. 职业发展的核心:专注于提升自身能力,成为一名优秀的工程师,是获得理想工作的根本途径。

记住,成为一名出色的程序员是一个循序渐进、持续学习和实践的过程。

029:编写具体算法的重要性 🎯

在本节课中,我们将探讨在编写算法时保持具体和明确的重要性。我们已经学习了通过七个步骤思考算法、C语言的语法和语义,以及“万物皆数字”原则及其与类型的关系。现在,我们将强调在编写算法时具体化的重要性。

为什么需要具体化? 🤔

人类通常能理解模糊的指令,因为我们具备常识。然而,计算机没有常识,只会严格按照你的指令执行。因此,在编写算法时,你必须仔细思考每一步的具体操作,并非常明确地将其记录下来。

一个模糊算法的示例 🥪

为了强调精确性的重要性,我们将通过一个有趣的小演示来展示一个模糊算法的问题。假设吉纳维芙(Genevieve)给我写了一个制作花生酱果酱三明治的算法,而我暂时“忘记”了所有常识,只会严格按照她说的做。

以下是吉纳维芙的算法步骤:

  1. 拿出一片面包。
  2. 用刀把花生酱涂在上面。
  3. 拿出另一片面包。
  4. 用刀把果酱涂上。
  5. 把两片面包合在一起。

执行这个算法时,问题立刻出现了。第二步“涂在上面”没有指定涂在哪片面包上。第四步“涂上”更是模糊,没有说明把果酱涂在哪里。结果,制作过程混乱,最终的三明治可能无法食用。

这个演示清楚地表明,即使对于看似简单的任务,模糊的指令也会导致错误或无法预料的结果。

如何编写具体的算法 ✍️

上一节我们看到了模糊指令带来的问题,本节中我们来看看如何编写具体、明确的算法。关键在于从计算机的角度思考,它没有任何背景知识。

以下是编写具体算法的一些核心要点:

  • 明确操作对象:明确指出每一步操作的对象是什么。例如,是“第一片面包”还是“第二片面包”?
  • 定义动作细节:详细描述动作本身。例如,“涂抹”应说明使用什么工具(如“用黄油刀”),以及涂抹的范围(如“均匀地涂抹在面包片的一面”)。
  • 规定顺序和条件:清晰说明步骤的执行顺序,以及任何可能的分支条件。
  • 避免歧义:不使用可能产生多种理解的词汇。例如,“处理数据”不如“将数组中的每个元素乘以2”明确。

在编程中,我们可以用伪代码注释来清晰地规划算法。例如,一个更具体的三明治制作算法片段可能是:

// 步骤1:取出第一片面包,放在盘子上。
// 步骤2:用黄油刀从罐中取出适量花生酱,均匀涂抹在第一片面包的上表面。
// 步骤3:取出第二片面包。
// 步骤4:用同一把刀(需清洁或使用另一把)从罐中取出适量果酱,均匀涂抹在第二片面包的上表面。
// 步骤5:将第二片面包涂有果酱的一面,盖在第一片面包涂有花生酱的面上,对齐。

从算法到代码 🚀

在接下来的课程中,我们将深入探讨如何将你的算法转化为代码,以及如何编译、运行和测试代码,以增强对其正确性的信心。当出现问题时,我们还将学习如何调试代码。

然而,在此之前,我们将以一项实践任务来结束本课程:你需要用英语编写一个算法,并让一位课程同伴来执行它。当然,你也需要执行别人的算法并 peer review 他们的工作。在这个过程中,你必须做到具体和清晰,以确保执行者能准确理解该做什么。

总结 📝

本节课中,我们一起学习了编写具体算法的重要性。计算机缺乏常识,因此算法中的任何模糊之处都可能导致错误。我们通过一个制作三明治的生动例子,看到了模糊指令如何使简单任务失败。记住,优秀的算法就像给一个完全没有背景知识的人的精确说明书。在将想法转化为代码之前,花时间仔细推敲并具体化你的算法步骤,是确保程序正确运行的关键第一步。

030:排序算法简介

在本节课中,我们将学习排序算法的基本概念,了解其重要性,并开始思考如何设计一个排序算法。

排序是计算机科学中一项重要且无处不在的任务。例如,电子邮件客户端允许用户按主题、日期等标准对邮件进行排序,这使查找所需信息变得更加容易。在社交媒体上,热门故事列表通常是通过对所有故事按受欢迎程度排序后,取列表顶部的项目生成的。同样,搜索引擎会对匹配查询的众多结果进行排序,将最可能优质的结果显示在顶部。这些例子都说明了排序在数据处理中的核心作用。

本课程的最终项目是编写一个排序算法。该算法接收一个数字序列作为输入,并将它们按升序(从小到大)排列。例如,对于给定的输入数据,算法需要将其重新排列为有序序列。当然,这里展示的是一次性重排,这种方式难以推广。我们需要思考一种更逐步的方法,以便对任何数字序列进行排序。

我们选择排序作为项目主题,还有另外几个原因。首先,许多问题都存在多种不同的正确解决方案,了解这一点很有益处。在同行评审他人的解决方案时,你可能会看到与自己想法不同的方法。其次,排序算法的结果很容易验证。在评审他人的算法时,你可以通过检查输入数据是否被正确排序,来轻松判断其答案是否正确。

现在,让我们开始运用所学知识,着手编写一个排序算法。


本节课中,我们一起学习了排序算法的普遍重要性及其应用场景,明确了本课程的最终项目目标,并理解了从简单案例出发、逐步设计通用算法的重要性。

031:编程入门 🚀

在本课程中,我们将学习如何将一个算法转化为一个可运行的C语言程序。我们将涵盖从编写代码、编译、运行到测试和调试的完整流程,并介绍一些专业的开发工具。


从算法到代码 ✍️

上一节我们介绍了课程的整体目标,本节中我们来看看如何将算法转化为C语言代码。

算法是解决问题的步骤描述。编程的核心就是将这个描述用C语言的语法(规则)和语义(含义)表达出来。这要求你熟悉C语言的基本结构,例如变量、循环和条件判断。


编辑代码:使用Vim编辑器 💻

将算法转化为代码后,下一步是将其输入计算机。我们将使用Vim编辑器。

Vim是一款面向专业程序员的文本编辑器。它功能强大,但学习曲线较陡峭。以下是选择Vim的几个关键原因:

  • 长期收益高:虽然初期学习需要投入时间,但熟练掌握后,其效率远超简易编辑器。
  • 专业信号:使用专业工具能向他人(如潜在雇主)展示你的专业素养和投入程度。
  • 避免平台期:简易工具上手快,但功能有限,很快会遇到瓶颈。专业工具则能伴随你应对更复杂的项目。

编译代码:使用GCC编译器 ⚙️

代码编写完成后,计算机并不能直接理解。我们需要使用编译器将其转换为机器可执行的格式。

我们将使用GCC(GNU Compiler Collection)编译器。它的基本用法是在终端中输入命令:

gcc your_program.c -o your_program

这条命令会将 your_program.c 这个源代码文件编译成名为 your_program 的可执行文件。


运行与调试代码 🐞

编译成功后,就可以运行程序了。但程序第一次运行往往不会完美。

测试是运行程序并检查其行为是否符合预期的过程。
调试则是找出代码中导致错误(Bug)的原因并修复它的过程。

我们将介绍两个强大的调试工具:

  1. GDB: GNU调试器,用于逐行执行程序、检查变量状态。
  2. Valgrind: 主要用于检测内存管理错误,如内存泄漏。

这些是专家级工具,掌握它们对写出健壮、高效的代码至关重要。


课程项目预览:扑克手牌胜率计算器 🃏

在本课程以及后续课程中,我们将共同完成一个贯穿性的项目。

你将编写一个程序,该程序能够接收一手扑克牌的描述,并计算这手牌赢得比赛的各项概率。这个项目将综合运用我们学到的所有核心概念。


本节课中,我们一起学习了C语言编程的完整工作流:从算法构思、代码编辑(Vim)、编译(GCC)到运行与调试(GDB, Valgrind)。我们还了解了选择专业工具的重要性,并预览了即将开始的实战项目。

032:两个矩形的交集 📐

在本节课中,我们将学习如何运用“七步问题解决法”来解决一个具体问题:计算两个矩形的交集。我们将专注于处理边平行于坐标轴的矩形,这会使问题大大简化。

要解决这个问题,你需要一些预备知识,例如理解什么是矩形。你可能还记得,矩形是一个四边图形,其相邻边彼此成直角。同时,你需要理解“两个矩形的交集”意味着什么,即找出同时位于两个矩形内部的区域。


第一步:分析具体实例

第一步是分析一个具体的问题实例。我们从一张空白的笛卡尔坐标系开始,以便精确地绘制矩形。

首先,我选取了第一个矩形,其范围是从 (-4, 1) 到 (8, 6)。接着,我选取了第二个矩形,其范围是从 (1, -1) 到 (4, 7)。这两个矩形的交集区域已用绿色高亮标出。

我们可以看到,交集区域的范围是从 (1, 1) 到 (4, 6)。我们希望准确地写下我们是如何得出这个结果的。但我们的直觉可能只是说“我们看了图就知道了答案”。这种情况很常见:你只是知道自己做了什么,但很难一步步地思考清楚。

当这种情况发生时,一个好办法是分析另一个实例,并尝试思考你正在做什么。


第二步:分析第二个实例

这里我们移除了网格线,这样我们就不能直接从最终绘图中读取坐标了。

对于第一个矩形,我选择了 (-2, 1) 到 (6, 3)。对于第二个矩形,我选择了 (1, -1) 到 (4, 4)。交集区域同样用绿色标出,但它的角点坐标是多少呢?

这个矩形是从 (-1, 1) 到 (4, 3)。现在,让我们准确地写下我们刚才做了什么。

我们首先要回答的问题是:如何用数字表示一个矩形?

答案是:一个矩形由四个整数表示:左边界 (left)、底边界 (bottom)、右边界 (right)、顶边界 (top)

接下来,我们需要思考我们采取了哪些步骤来得出这个答案。

  1. 我首先确定交集矩形的左边界 x 坐标为 -1。
  2. 接着,我确定底边界 y 坐标为 1。
  3. 然后,我确定右边界 x 坐标为 4。
  4. 最后,我确定顶边界 y 坐标为 3。

我的答案是矩形 (-1, 1, 4, 3)。


第三步:将步骤推广到一般情况

现在,我们准备将这些步骤推广到任意一对矩形。

  • 为什么我们选择 -1 作为答案的左边界? 我们可能认为这是因为它是第二个矩形 (R2) 的左边界。但它总是 R2 的左边界吗?不,这次只是巧合,因为 R2 的左边界大于第一个矩形 (R1) 的左边界。更一般地说,答案的左边界应该是 R1 左边界和 R2 左边界的最大值
  • 如果我们现在没有发现这个细微差别怎么办? 我们应该在后续步骤中发现它,要么是在第四步手动测试算法时,要么是在第六步运行代码测试用例时。如果之后发现了,我们会回到第三步,修正算法,并重做后续步骤。
  • 那么底边界呢?为什么是 1? 同样,我们可能认为是因为它是 R1 的底边界,但这会遇到我们刚才讨论过的相同陷阱。实际上,它是 R1 底边界和 R2 底边界的最大值
  • 接着,我们对右边界进行类似的思考,但这次是 两个输入矩形右边界的较小值(最小值)
  • 最后,顶边界是 两个输入矩形顶边界的较小值(最小值)

让我们把这些步骤更明确地写成指令:

我们将创建一个矩形,称之为 ans(答案)。它的四条边由输入矩形对应边的最大值或最小值决定,正如我们刚才讨论的那样。然后,我们刚刚创建的矩形 ans 就是我们的答案。


第四步:测试算法

接下来,我们应该测试我们的算法。它看起来相当直接,那么可能会出什么问题呢?

首先,我们用来构建算法的唯一输入具有相同的总体形状:R1 比 R2 宽,而 R2 比 R1 高。如果我们在第三步中犯了任何之前讨论过的错误,我们可能还没有发现它们。

存在一个我们尚未考虑的、更微妙的情况:矩形可能不相交

对于这种情况,算法会产生什么结果?它应该产生什么结果?实际上,它应该产生“无解”。因此,我们需要某种方式来表示这种情况。


总结

本节课中,我们一起学习了如何运用“七步法”解决“求两个矩形交集”的问题。我们首先通过具体实例理解问题,然后抽象出用四个整数(左、底、右、顶)表示矩形的方法。接着,我们推导出计算交集矩形的通用规则:左边界和底边界取两个矩形的最大值,右边界和顶边界取两个矩形的最小值。最后,我们意识到算法需要处理矩形不相交的特殊情况,这为后续的代码实现和测试指明了方向。

033:将交集算法转化为代码 🧩

在本节课中,我们将学习如何将一个描述清晰的算法,逐步转化为可以运行的C语言代码。我们将以计算两个矩形交集的算法为例,展示从算法构思到代码实现的全过程。


从算法到代码

上一节我们讨论了计算矩形交集的算法。本节中,我们来看看如何将这个算法转化为实际的C语言代码。

你可以用纸笔或直接在电脑的代码编辑器中完成这个转化过程。虽然稍后你会学习如何在电脑上编辑和运行程序,但首先观察算法到代码的翻译过程非常有启发性。

第一步:编写函数声明

首先要做的是写出函数声明。你之前已经学过函数声明的语法:返回类型,后跟函数名,然后是参数列表。函数体用花括号 {} 包裹。

return_type function_name(parameter_list) {
    // 函数体
}

写好声明后,一个好习惯是将你的算法步骤作为注释写在代码中。记住,注释会被计算机忽略,仅供人类阅读。

定义矩形结构体

对于我们正在开发的算法,首先需要一个表示矩形类型的结构体(struct)。你之前见过结构体,所以这里没有新内容。

一个需要考虑的问题是,坐标应该用整数 int 还是浮点数 float?这里,我们决定使用 float,以便处理具有分数坐标的矩形。

struct rectangle {
    float left;
    float bottom;
    float top;
    float right;
};

编写交集函数框架

接下来,我们写出函数声明,并将算法步骤写成注释。这个函数名为 intersection,接收两个矩形 R1R2 作为参数,并返回一个矩形作为答案。

struct rectangle intersection(struct rectangle R1, struct rectangle R2) {
    // 步骤1: 创建一个名为ans的矩形
    // 步骤2: 令ans.left = R1.left和R2.left中的较大值
    // 步骤3: 令ans.bottom = R1.bottom和R2.bottom中的较大值
    // 步骤4: 令ans.top = R1.top和R2.top中的较小值
    // 步骤5: 令ans.right = R1.right和R2.right中的较小值
    // 步骤6: 矩形ans就是我们的答案
}

实现算法步骤

现在,我们开始将每个注释步骤转化为代码。

步骤1:创建矩形变量

我们需要声明一个变量来存储结果。

struct rectangle ans;

步骤2:计算左边界

我们需要将 ans.left 设置为 R1.leftR2.left 中的较大值。这需要一个赋值语句。

计算两个值的最大值似乎不止一个简单表达式,因此我们应该将“求最大值”这个计算抽象成一个独立的函数。我们在这里先调用这个函数,稍后再去编写它。

这个函数应该叫 max,接收两个 float 参数,并返回其中较大的那个 float。它的声明如下:

float max(float a, float b);

因此,这一行代码可以写成:

ans.left = max(R1.left, R2.left);

步骤3:计算底边界

这一步与上一步类似,我们可以复用即将编写的 max 函数。

ans.bottom = max(R1.bottom, R2.bottom);

步骤4:计算上边界

我们需要将 ans.top 设置为 R1.topR2.top 中的较小值。同样,我们将“求最小值”抽象成一个 min 函数。

ans.top = min(R1.top, R2.top);

步骤5:计算右边界

这一步与计算上边界非常相似。

ans.right = min(R1.right, R2.right);

步骤6:返回结果

如何让函数给出答案?我们使用 return 语句。

return ans;

补全辅助函数

我们还有几件事没完成。首先,需要编写之前承诺的 maxmin 函数。其次,在认为代码完成之前,必须对其进行测试(测试部分将很快学习)。现在我们先编写 maxmin 函数。

以下是 max 函数的声明,其算法步骤也已作为注释写在里面:

float max(float f1, float f2) {
    // 步骤1: 检查f1是否大于f2
    // 如果是,则f1是答案
    // 如果不是,则f2是答案
}

算法首先检查 f1 是否大于 f2,根据结果执行不同操作。这可以转化为 if-else 语句。

float max(float f1, float f2) {
    if (f1 > f2) {
        return f1; // then子句:f1是答案
    } else {
        return f2; // else子句:f2是答案
    }
}

对于 min 函数,算法非常相似,唯一区别是判断 f1 是否小于 f2

float min(float f1, float f2) {
    if (f1 < f2) {
        return f1;
    } else {
        return f2;
    }
}

总结

本节课中,我们一起学习了将算法转化为C语言代码的完整流程。我们从编写函数声明和结构体开始,将算法步骤作为注释,然后逐步用代码实现每个步骤,并在过程中合理地使用函数来抽象通用操作(如求最大值、最小值)。最后,我们补全了所需的辅助函数。现在,计算矩形交集的算法已经成功翻译成了可执行的代码框架。

034:编程环境介绍 🖥️

在本节课中,我们将学习如何在Unix环境中开始编写代码。我们将介绍编程环境的基本使用方法,包括如何编辑文件、使用Git以及其他编程工具。


概述

现在,你已经准备好在一个Unix环境中开始实际编写代码了。我们将花一些时间介绍如何使用这个环境,包括如何编辑文件、使用Git以及你在编程过程中会用到的其他各种工具。


进入编程环境

首先,我登录了Coursera平台,打开了第一项作业。这项作业实际上是一个环境使用指南,它会引导你熟悉整个操作流程。你可以跟着这个指南一起操作。

我确认遵守了Coursera的荣誉准则,并承诺独立完成作业。

点击“打开工具”后,会出现一个窗口,这本质上是一个Unix终端。这个工具叫做Xterm JS,它在你的网页浏览器中提供了一个标准的Unix终端。

为了让操作空间更大,我将终端窗口调整为140列宽和36行高。这样就有更多空间来输入命令了。

终端会提示这是你第一次使用,并正在为你设置环境。接着,它会欢迎你进入实践编程环境。


熟悉Unix命令行

此时,你可能对Unix和命令行还不太熟悉。别担心,后续会有相关的阅读材料,并且你会有大量的练习机会。

首先,我输入了 ls 命令。这个命令的作用是列出当前目录下的所有内容。

ls

执行后,我们看到当前目录下有一个 readme 文件和一个名为 learn-to-program 的文件夹。这个 learn-to-program 文件夹将存放你所有的作业。

第一个作业是“00_hello”,也就是第0号作业。我们的第一个任务是创建一个名为 hello.txt 的文件。这虽然不是一个真正的编程活动,但能让你在开始写代码前,先熟悉这些基本操作。


使用Emacs编辑器

接下来,我使用 emacs 编辑器打开了 readme 文件。emacs 是你将要学习使用的编辑器。

emacs readme

文件打开需要一点时间。打开后,你可以看到里面有很多说明文字,可以滚动阅读。

正如说明中所说,我们的目标是创建一个名为 hello.txt 的文件,里面只包含一行文字“hello”。说明中也给出了具体的操作步骤。

我们需要用Emacs打开并创建一个名为 hello.txt 的新文件。说明中提到要输入 C-x C-f。在Emacs的术语中,这代表同时按下 Ctrl 键和 X 键,然后松开,再同时按下 Ctrl 键和 F 键。

我按下 Ctrl+X,然后松开,再按下 Ctrl+F。这时,在编辑器底部会看到提示“Find file:”,并等待我输入文件名。它显示了当前所在的目录路径,我只需输入 hello.txt 然后按回车即可。

编辑器底部会显示“New file”,并且文件名栏会变成 hello.txt。但这时,我暂时看不到操作说明了,因为它们还在另一个文件里。

Emacs有一个非常棒的功能,可以让你同时查看两个文件。我按下 Ctrl+X 然后按 2,窗口就会被分成上下两半。现在我有两个缓冲区,一个在上,一个在下,但此时它们显示的都是 hello.txt 文件的不同部分。

同时查看一个文件的两个不同位置很有用,但我现在想把顶部的缓冲区换成 readme 文件。我按下 Ctrl+X 然后按 B,它会询问我想切换到哪个缓冲区。默认就是 readme,所以我直接按回车。

现在,顶部是 readme 文件,底部是 hello.txt 文件。我可以看到操作说明就在 readme 文件里。如果想在两个缓冲区之间切换光标,可以按 Ctrl+X 然后按 O。你会看到光标随着按键在两个窗口间跳转。


编辑并保存文件

现在,我进入底部的 hello.txt 文件,输入单词“hello”,然后按回车键换行。通常,文件都以一个新行结束。

接着,我按下 Ctrl+X 然后按 Ctrl+S 来保存文件。编辑器底部会提示文件已写入并保存。


提交作业

现在,我想给这个作业评分。首先,我需要暂时挂起Emacs编辑器。我按下 Ctrl+Z,终端会显示“Stopped (emacs readme)”。Emacs并没有关闭,只是被暂时冻结在后台了。如果我因为忘记看接下来的说明而需要把它调回来,可以输入 fg 命令。

fg

说明中提示我要运行一些Git命令。Git是一个非常流行的版本控制系统,被许多专业开发者使用,你会在后续课程中深入学习它。

目前,我们需要做的是“添加”(add)、“提交”(commit)和“推送”(push)这个文件。这会将文件放入Git仓库,并将我们的作业提交给评分系统。

首先,我输入命令将 hello.txt 文件添加到Git的暂存区:

git add hello.txt

如果命令执行成功,通常不会有任何输出,这表示一切正常。

接着,我提交这个更改,并附上一条提交信息:

git commit -m "Did assignment 0."

这里的 -m 选项表示直接在命令行上提供提交信息,这是一条关于当前操作的简短说明。终端会显示提交成功的信息。

最后,我将本地的提交推送到远程仓库(通常是Coursera的服务器),以便评分系统能够获取:

git push

这个命令会将文件发送到与评分器共享的位置。


获取评分结果

提交完成后,下一步就是给作业评分。我输入 grade 命令:

grade

这个命令会检查一些内容,确保我已经提交了所有必需的文件,然后运行评分器。结果显示我通过了这个作业。

评分器还会提示我,它已经发布了下一个作业,并且我应该继续观看视频,直到看完第一周关于评分的视频,那里包含了完成下个作业所需的材料。

同时,它还提示如果我运行 git pull 命令,就能获取我的评分报告和下一个作业。

git pull

git pullgit push 相反,它用于从远程仓库获取更新。在专业软件开发中,你会用它来获取合作者编写的代码。

执行后,我看到多了一个名为 grade.txt 的文件。在这个简单的例子里,它只是说我的文件与预期输出匹配,因为这次作业没什么可评分的。但在一般情况下,这个文件会告诉你通过了哪些测试用例,失败了哪些,以及你的得分情况。

这样,我们就完成了当前作业,并且下一个作业也已经准备就绪,等你观看更多视频后就可以回来继续完成了。


总结

本节课中,我们一起学习了在Unix编程环境中的基本操作流程。我们了解了如何:

  1. 使用 ls 命令查看目录内容。
  2. 使用 emacs 编辑器创建和编辑文件。
  3. 在Emacs中进行多窗口操作和文件切换。
  4. 使用Git进行版本控制的基本操作:addcommitpush
  5. 使用 grade 命令提交作业并获取评分结果。
  6. 使用 git pull 获取更新和新的作业。

这些是后续编程学习的基础,熟练掌握它们将使你的学习过程更加顺畅。

035:使用Emacs编辑文件 📝

在本节课中,我们将学习如何使用Emacs编辑器的一些核心功能,包括搜索、撤销、区域操作以及键盘宏。这些技巧将帮助你更高效地编写和编辑代码。

你已经看过一个视频,演示了如何使用Emacs编写一个简单的文件,保存它,将其添加到Git并提交到评分系统。Emacs是一个非常强大的编辑器,拥有众多功能。我们将向你展示一些简单的功能,如搜索、撤销,以及Emacs撤销功能的一些很酷的特性,还有像键盘宏这样稍微高级一点的功能。显然,我们无法在这个视频中涵盖Emacs的所有内容,但我们会展示一些更有用的功能,然后你可以随着使用学习更多。

如果我看一下这个目录,我有一个文本文件和一个C文件可以操作。我将打开这个文本文件并开始操作。它只是《C语言程序设计》第二章的一些文本,你可能在课程一的阅读材料中已经读过大部分。

搜索功能 🔍

上一节我们提到了Emacs的基本使用,本节中我们来看看如何进行文本搜索。

我可能想做的一件事是搜索。如果我按下 Ctrl+S,你会看到我的Xterm底部显示“I-search”。Emacs具有增量搜索功能,这意味着当我输入搜索词时,它会开始搜索我目前输入的任何内容。例如,如果我输入“I”,它会跳转到第一个“I”的实例,并高亮显示所有其他“I”。如果我之后输入“M”,它会细化搜索,开始显示像“important”和“sometimes”和“simple”这样的词。如果我输入“E”,它会跳到这里找到“sometimes”。这在编程时非常有用,因为你想搜索一个函数名或其他东西,按下 Ctrl+S,然后开始输入一点,你就能快速找到你想要的东西,通常不需要输入完整的单词。

如果我按退格键,它会回到“I am”。我可能想要“important”但不是这个,所以如果我再次按 Ctrl+S,它会向前搜索下一个“I am”的实例,我可以继续这样做。此时它需要向下滚动。在普通的Xterm中,这不会发生。Xterm JS有一个小bug,它并不总是正确重绘屏幕。如果发生这种情况,只需按 Ctrl+L,这会让Emacs完全重绘屏幕。然后我就可以通过再次按 Ctrl+S 来继续搜索更多内容。这个bug有点烦人,但Xterm JS不是我的东西。

好的,这就是搜索。你也可以向后搜索。如果我按 Ctrl+R,它会说“I-search backward”,我可以向后搜索,它会向后移动。我可以通过 Ctrl+SCtrl+R 在这些搜索之间切换,向前和向后移动。

撤销与区域操作 ↩️

好的,这就是搜索。在我们的编辑器中,我们可能还想做其他事情。我们可能想撤销一些操作。例如,如果我输入一些东西,然后意识到我不想要它,我可以按 Ctrl+X U,它会撤销那个更改。

现在,这很正常。让我们假设一下,我做了很多更改。我在这里做了一些更改。“Blah, blah, blah, some other stuff.” 然后我去做一些非常重要的事情。“really important, complicated stuff I want to keep.” 现在我意识到我刚才做的所有其他事情,我想撤销它。这在编程中可能会发生,我可能在一个函数中做了一些更改,然后去处理另一段代码,然后以某种方式意识到我把那个函数搞乱了。

如果我按 Ctrl+空格键,它会说“Mark set”并开始高亮显示一个区域。一旦我高亮显示并选择了一个区域,我可以在该区域内撤销。所以如果我撤销 Ctrl+X U,它将在区域内撤销,并且只撤销该文件区域内的更改,而不是我稍后在其他地方所做的更改。这在编程或甚至编辑其他内容时非常有用。

我将来到这里,所以有时我想移动到行首,Ctrl+A 会做到这一点。Ctrl+E 会移动到行尾。我将删除这行文本。所以 Ctrl+K 剪切整行。如果我想把它粘贴到其他地方,Ctrl+Y 粘贴我上次剪切或复制的东西。如果我想复制或剪切一个区域,我可以选择它。Ctrl+空格键 开始选择,然后 Ctrl+W 将剪切。Ctrl+Y 将粘贴。如果我想复制而不是剪切,我可以按 Esc+W,然后粘贴。

现在,Emacs另一个非常酷的功能是,粘贴后,你可以更改回之前粘贴过的内容。所以粘贴后立即按 Esc+Y,它会取消粘贴我刚刚粘贴的内容,并粘贴前一个东西,我可以再次这样做,回到我之前复制和粘贴的更早的内容。所以有时你复制和粘贴东西,你想粘贴的不是最近的东西。这真的很酷。

好的,我直接恢复吧,我只是要保存它,这没关系。

键盘宏 🎹

本视频中我要展示的另一个功能,我认为非常酷。我将打开这个枚举示例,我在里面写了很多枚举项。它包含了所有这些内容。😊,我想做的是编写一个函数,接收这个枚举并打印出它是什么。

现在。我刚刚告诉过你如何复制和粘贴。所以我要复制。在复制时,我实际上可以搜索花括号,然后直接跳到这里,而不需要滚动整个内容。所以我要复制所有这些。我要写一个函数 print_thing。它接收枚举。

那个名字真的很长。我有点懒,所以我直接按 Esc+/,它会为我自动补全那个名字。哎呀,我需要给它一个名字。T,switch (t)。现在,刚刚粘贴了所有那些东西。Emacs告诉我我的花括号匹配了什么,即使它在屏幕外,并且没有对齐,因为我在那里没有任何分号,所以Emacs知道这个语法有问题。这告诉我我匹配了什么。另外,如果我将鼠标悬停在某个东西上,如果它们在屏幕上,它会匹配。

我只是要向后搜索这个。现在,我想做的是,我想把我刚刚粘贴在这里的每个枚举案例的名称,改成类似 case 那个东西:printf 那个东西 break 的样子。

现在,在大多数编辑器中,你可能需要枯燥地花15分钟复制、粘贴和修改,这真的很无聊。但Emacs有一个非常酷的功能叫做键盘宏。基本上,我想对每一行做同样的事情。所以我要这样做。

我按 Ctrl+X (,它说“Defining keyboard macro”。现在Emacs将记住我执行的一系列命令,并让我重放那系列命令。所以我想做的是:向前移动两个单词。我想写单词“case”。我想开始高亮显示一个区域来复制,所以我按 Ctrl+空格键。我想向前搜索一个逗号。然后我想向后移动。所以我按左箭头。然后我想复制。所以我要按 Esc+W。现在,我想过来这里,用冒号替换它。输入 printf。我要粘贴那个东西,所以我按 Ctrl+Y。假设我想要一个新行。和 break。然后我想向下移动一行,并用 Ctrl+A 移动到行首。然后我按 Ctrl+X ),它说“Keyboard macro defined”。

所以我对那一行执行的那一系列命令,我可以按 Ctrl+X E,它会对这一行再次执行。在我这样做之后,它说“Type E to repeat macro”。我可以按住 E。嗯,在这个上面,它让我重复它。但在大多数编辑器、大多数终端上,你可以直接按住它。然后这一个不会完全工作,因为它没有逗号,但如果我只是在那里放一个逗号,让它看起来像其他的,砰!

现在这在语法上是合法的,我可能会放像 default: printf("Invalid thing"); break;

但我让Emacs通过键盘宏为我完成了所有这些工作。所以这是一些很酷的功能。你会在阅读材料中读到更多关于它们的内容,并且通过练习你会学到很多。😊


本节课中我们一起学习了Emacs编辑器的几个高效功能:使用 Ctrl+SCtrl+R 进行增量搜索;使用 Ctrl+X U 进行撤销,并学习了如何通过设置标记(Ctrl+空格键)在特定区域内进行选择性撤销;掌握了基本的文本操作快捷键,如 Ctrl+A(行首)、Ctrl+E(行尾)、Ctrl+K(剪切行)、Ctrl+Y(粘贴)以及 Esc+W(复制区域)。最后,我们探索了强大的键盘宏功能,通过 Ctrl+X ( 开始录制,执行一系列操作,Ctrl+X ) 结束录制,然后使用 Ctrl+X E 重复执行,这能极大地自动化重复性编辑任务。

036:更多关于Git的内容

概述

在本节课中,我们将学习Git版本控制系统如何支持多人协作开发。我们将了解远程仓库的概念,以及如何使用git pushgit pull命令与远程仓库同步代码。

Git在专业软件开发人员中广受欢迎,一个重要原因是它为大型项目的多人协作提供了强大的功能。

远程仓库与协作

假设我有一个本地的Git仓库,并希望与另一位开发者Genevieve协作。

我不仅需要获取Genevieve的代码副本,我们双方还需要能够轻松地修改代码并将这些更改合并到一起。

她的代码在她的笔记本电脑上,而我想在我的电脑上工作。由于我们的笔记本电脑并非总是可用,我们会在一个双方都能访问的服务器上创建另一个仓库。

其中一人在服务器上运行Git命令来建立一个空仓库。

配置远程仓库

接下来,我需要告诉本地电脑上的Git,我希望它与一个远程仓库协作。

你可以为远程仓库起任何名字。这里我将其命名为Xyz,因为我们的假设服务器是xyz.duke.edu。你需要提供远程仓库的位置。

以下命令仅用于建立与远程位置的关联:

git remote add Xyz <远程仓库地址>

推送代码到远程仓库

现在,我运行git push命令,指定远程仓库的名称(本例中是Xyz)以及我想要推送的分支(默认称为master分支)。Git仓库的主分支通常叫做master

git push -u Xyz master

-u--set-upstream选项将此远程分支设置为默认的推送和拉取位置。

我们不会深入讲解分支,但它是Git一个极其有用的功能。

现在,Git不仅会复制我所有的文件,还会将我整个修订历史(包括我所有的提交记录和附带的日志信息)推送到远程仓库。

克隆远程仓库

现在,Genevieve想要获取远程仓库的副本。她将使用git clone命令。

她指定克隆的来源位置(与Drew配置远程时使用的位置相同,但使用她自己的用户名进行身份验证)。

git clone <远程仓库地址>

这将在她的笔记本电脑上创建一个新的本地仓库,并自动创建一个名为origin的远程关联,origin被自动设置为默认的推送和拉取位置。

然后,Git会从远程仓库复制所有的提交记录到她的本地。

并行开发与同步冲突

现在,Genevieve编写了一些新代码,进行了测试,并使用git addgit commit命令创建了新的提交。此时,她的本地仓库包含了这些更改,但其他人都没有,包括Drew的仓库。

与此同时,Drew也在同一个项目上编写了自己的代码,并在自己的本地仓库中创建了提交。

Drew希望将自己的更改分享给Genevieve,于是他运行git push。然而,他遇到了问题。

当他执行git push时,收到了错误信息。错误信息指出,远程仓库包含本地尚未拥有的工作(即Genevieve的提交)。Git建议他先使用git pull获取Genevieve的工作。

拉取与合并更改

如果Drew运行git pull,Git会将Genevieve的提交复制到他的电脑,然后尝试合并他的工作和她的工作。

  • 如果他们修改了不同的文件,或者同一文件的不同部分,Git会自动处理合并过程。
  • 如果他们在同一区域做了不同的修改,Git会告知他需要手动解决冲突。

接着,Git会将Genevieve的提交以及一个代表合并结果的新提交放入Drew的本地仓库。由于这是一个新提交,Git会要求他输入提交信息,默认信息是“合并了我们的工作”,这通常是合适的。

现在,Drew可以再次尝试运行git push。这次,操作成功,他的新提交被复制到了服务器。

然后,Genevieve可以通过运行git pull来获取Drew的最新代码,这些新的提交将被复制到她的本地仓库。

与课程评分系统的交互

我们详细讨论这个过程,不仅因为它在软件开发中极其有用,还因为这是你与本课程评分系统交互的方式。

  1. 你将代码推送到一个远程仓库。
  2. 评分系统拉取你的代码。
  3. 它对你的提交进行评分。
  4. 然后将你的成绩提交到一个grade.txt文件并推送回来。
  5. 你随后通过git pull获取这个成绩文件。

总结

本节课中,我们一起学习了Git远程协作的基本流程。我们了解了如何设置远程仓库,使用git push命令将本地提交推送到远程仓库,以及使用git pull命令从远程仓库获取他人的提交并合并到本地。掌握pushpull是进行团队协作和与自动化系统(如课程评分系统)交互的核心。

037:编译与运行程序 🚀

在本节课中,我们将要学习如何将你编写的C语言代码转换为计算机可以执行的程序。这个过程被称为“编译”。我们将介绍编译的基本概念、如何使用GCC编译器,以及如何运行编译后的程序。

编译代码:从文本到机器指令

上一节我们介绍了如何编写和保存C语言代码。本节中我们来看看如何处理这些代码。首先,你需要编译它。

这意味着你需要运行一个名为“编译器”的程序。编译器将你编写的代码翻译成实际的机器指令。随后,编译器会生成一个文件,你可以在命令行提示符下输入其名称来执行它,就像执行任何其他程序一样。

然而,在编译代码之前,你需要添加一些 #include 预处理指令。你将在本课后面学到更多关于这些指令和编译过程的知识。

使用GCC编译器

一旦你添加了所有必要的 #include 指令,你就可以像运行其他命令一样运行编译器。你将使用的编译器叫做GCC。它接受一些命令行选项,用于指定诸如生成的程序名称、以及它应该对看似有问题的代码发出多少警告等事项。

你将在接下来的课程中了解更多关于这些选项的知识。你还将学习一个名为 make 的工具,它将简化构建包含多个文件和/或需要向编译器传递大量选项的大型程序的过程。

运行编译后的程序

代码编译完成后,你就可以运行生成的可执行程序了。在这个例子中,我们指示GCC生成一个名为 hello 的程序。我们可以使用 ./hello 来运行它。

点号和斜杠(./)指定应在当前目录中查找名为 hello 的程序,我们稍后也会更多地讨论目录的概念。

你可以看到程序运行并打印了它的输出:“Hello world”。

总结

本节课中我们一起学习了C语言程序从源代码到可执行文件的完整流程。你现在知道了编译是将人类可读的代码转换为机器指令的关键步骤,了解了使用GCC编译器的基本方法,并掌握了如何运行编译后的程序。在接下来的课程中,我们将深入探讨编译器的选项和更复杂的项目管理工具。

038:你好世界 👋

在本节课中,我们将学习如何在Emacs编辑器中编写、编译并运行一个简单的C语言程序。我们将以“打印Hello World”这个经典任务为例,完整地走一遍从创建代码文件到最终运行程序的流程。


在Emacs中编写代码

上一节我们介绍了课程目标,本节中我们来看看如何开始编写代码。

首先,在编程环境中,我们可以使用 ls 命令查看当前目录的内容。执行后,会看到一个名为 readme 的文件。

接下来,我们使用Emacs打开这个 readme 文件。文件内容说明了本练习的目标:编写一个能打印“Hello World”的程序。

为了完成这个任务,我们需要创建一个新的C语言源文件。以下是具体步骤:

  1. 在Emacs中,按下 Ctrl+X 然后 Ctrl+F 组合键,这会提示你输入文件名。
  2. 输入文件名 hello.c 并确认。
  3. 在文件顶部,我们需要包含必要的头文件。对于打印功能,需要 stdio.h;为了使用 EXIT_SUCCESS,需要 stdlib.h
    #include <stdio.h>
    #include <stdlib.h>
    
  4. 然后,我们编写 main 函数。它返回一个整数(int),目前不接收任何参数(后续课程会学习如何处理命令行参数)。
    int main(void) {
        // 函数体将写在这里
    }
    
  5. main 函数体内,我们使用 printf 函数来打印“Hello World”,并在末尾添加换行符 \n
    printf("Hello world\n");
    
  6. 最后,函数返回 EXIT_SUCCESS 表示程序成功结束。
    return EXIT_SUCCESS;
    
  7. 完成代码编写后,使用 Ctrl+X 然后 Ctrl+S 保存文件。

编译与运行程序

代码编写完成后,我们需要将其编译成可执行文件。为此,我们需要暂时回到命令行终端。

在Emacs中,按下 Ctrl+Z 可以挂起(暂停)Emacs,回到终端shell。此时,我们可以在命令行中操作。

以下是编译和运行的步骤:

  1. 使用GCC编译器编译 hello.c 文件。-o hello 选项指定生成的可执行文件名为 hello。我们还会使用课程要求的所有严格编译标志,以确保代码质量。
    gcc -o hello hello.c
    
  2. 如果编译成功,GCC不会输出任何信息。此时,使用 ls 命令查看目录,会发现多了一个绿色的 hello 文件,绿色表示它是可执行的。
  3. 运行程序。在Unix-like系统中,运行当前目录下的可执行文件需要在文件名前加上 ./
    ./hello
    
  4. 执行后,终端将打印出 Hello world

版本控制与后续步骤

程序运行成功后,我们通常需要将源代码纳入版本控制(如Git),并清理生成的可执行文件。

以下是相关操作:

  1. 使用 git add 命令将 hello.c 文件添加到暂存区。
    git add hello.c
    
  2. 通常,我们不将编译生成的二进制文件(如 hello)提交到版本库。可以使用 rm 命令删除它。
    rm hello
    
  3. 提交代码更改,并附上提交信息。
    git commit -m "Wrote hello world program"
    
  4. 最后,将本地提交推送到远程仓库并完成评分。
    git push
    grade
    

完成这些后,请注意我们之前只是挂起了Emacs,并未退出。在终端输入 fg 命令可以让我们回到刚才的Emacs编辑会话中,继续修改代码。请避免同时开启多个Emacs实例。

本节课中,我们一起学习了第一个C语言程序的完整开发流程:从在Emacs中编写代码、保存文件,到挂起编辑器、使用GCC编译、运行可执行文件,最后进行版本控制的基本操作。你已经成功迈出了C语言编程的第一步。接下来,我们将进入下一个任务,编写打印平方数的程序。

039:规划isprime函数 📝

在本节课中,我们将学习如何规划和编写一个函数,该函数接收一个整数作为输入,并判断它是否为质数。


步骤一:手动计算至少一个实例

我们首先需要手动解决至少一个具体实例,以理解判断质数的过程。

以下是判断质数的步骤:

  • 检查数字7是否为质数:我们可能会直接回答“是”,但这无助于我们建立通用的解题步骤。
  • 克服思维定式:当我们已知答案时,很难看清背后的计算过程。有两种方法可以解决:
    • 方法一:思考如何向一个不相信的人逐步证明7是质数。
    • 方法二:思考一个更复杂、答案未知的问题,以观察自己采取的步骤。如果问题太大,可以先用这个步骤思路处理一个更简单的例子。

步骤二:分析一个更复杂的实例

为了看清步骤,我们分析一个更复杂的例子:判断29393是否为质数。

以下是判断29393是否为质数的计算过程:

  • 29393除以2,得14696余1。
  • 29393除以3,得9797余2。
  • 29393除以4,得7348余1。
  • 29393除以5,得5878余3。
  • 29393除以6,得4898余5。
  • 29393除以7,得4199余0。

因为29393能被7整除,所以答案是:,29393不是质数。

现在,我们将上述操作精确地写下来:

  1. 检查 29393 % 2 == 0 是否成立(即是否能被2整除)。不成立。
  2. 检查 29393 % 3 == 0 是否成立。不成立。
  3. 检查是否能被4整除。
  4. 检查是否能被5整除。
  5. 检查是否能被6整除。
  6. 检查 29393 % 7 == 0 是否成立。成立
  7. 当发现它能被7整除时,得出“不是质数”的结论。

步骤三:回到简单实例并记录步骤

上一节我们通过复杂实例理清了判断步骤,现在我们可以回到数字7,并记录下判断过程。

以下是判断7是否为质数的计算过程:

  • 7除以2,得3余1。
  • 7除以3,得2余1。
  • 7除以4,得1余3。
  • 7除以5,得1余2。
  • 7除以6,得1余1。

此时,我得出结论:,7是质数。因为我尝试了从2到6的所有数字,发现7不能被其中任何一个整除。

现在,为这个实例写下精确步骤:

  1. 检查 7 % 2 == 0。不成立。
  2. 检查 7 % 3 == 0。不成立。
  3. 检查 7 % 4 == 0
  4. 检查 7 % 5 == 0
  5. 检查 7 % 6 == 0
  6. 在检查了所有上述情况并确认结果均为“否”之后,得出“是质数”的结论。

总结

本节课中,我们一起学习了规划 isprime 函数的前期步骤。我们通过手动计算具体实例(包括质数7和非质数29393),分解并记录了判断一个数是否为质数的逐步过程。核心思路是:用从2到n-1的所有整数依次尝试整除目标数,如果发现任何一个能整除,则该数不是质数;如果全部都不能整除,则该数是质数。这个过程可以用以下代码逻辑概括:

for (int i = 2; i < n; i++) {
    if (n % i == 0) {
        return 0; // 不是质数
    }
}
return 1; // 是质数

这为下一步将思路转化为正式的C语言函数代码打下了坚实的基础。

040:泛化isprime函数

在本节课中,我们将学习如何将一个针对特定数字的素数判断步骤,逐步抽象和泛化,形成一个适用于所有整数的通用算法。我们将通过分析具体例子、寻找模式、修正边界条件来完成这个过程。

从具体例子中寻找模式

上一节我们为数字29393和7分别列出了判断素数的具体步骤。现在,我们来看看这两个例子,并从中寻找可以泛化的共同模式。

以下是两个例子的步骤对比:

  • 对于 n = 29393,我们检查它是否能被2到7之间的整数整除。
  • 对于 n = 7,我们检查它是否能被2到6之间的整数整除。

观察这两个列表,我们首先注意到,被检查的除数总是从2开始的连续整数。其次,核心操作始终是检查 n 是否能被某个数整除。如果发现能整除,则立即判定 n 不是素数(答案为“否”)。如果检查完所有指定的数都没有发现能整除的情况,则判定 n 是素数(答案为“是”)。

抽象出重复结构与循环

基于以上观察,我们可以将步骤抽象为一种重复结构。这个重复的操作是:检查 n 是否能被一个递增的整数 i 整除

对于不同的 ni 的取值范围有所不同:

  • 对于 n=7,i 从2递增到6(即 n-1)。
  • 对于 n=29393,i 从2递增到7。我们之所以在7这里停止,是因为发现29393能被7整除,从而提前得出了“否”的结论。否则,i 会一直递增到 n-1。

因此,我们可以将这个过程概括为:i 从2开始,一直递增到 n-1(不包括n本身)。在每次循环中,执行检查 n % i == 0。如果为真,则判定 n 不是素数并结束;如果循环结束都未发现能整除的数,则判定 n 是素数。

测试与修正边界条件

现在,我们有了一个初步的通用算法。但我们需要用更多例子来测试它,以确保其正确性,并发现可能的边界情况。

以下是测试不同输入的结果:

  • 测试其他素数(如5,13):算法正确返回“是”。
  • 测试其他合数(如4,9):算法正确返回“否”。
  • 测试边界值
    • n=2:算法正确返回“是”。
    • n=0, n=1, n=-1:算法错误地返回“是”。因为对于这些小于等于1的数,循环根本不会执行(i从2开始,已经大于n),直接跳到了最后的“是”。

测试表明,我们的算法对于大于1的整数是有效的,但无法正确处理小于等于1的情况。根据定义,素数必须是大于1的自然数。因此,我们需要在算法开始处增加一个前置条件检查。

修正后的算法步骤如下:

  1. 检查 n 是否小于等于1。如果是,则答案为“否”。
  2. 否则,让 i 从2循环到 n-1。
  3. 在循环中,检查 n % i == 0。如果为真,则答案为“否”并结束循环。
  4. 如果循环正常结束(未提前退出),则答案为“是”。

总结

本节课中,我们一起学习了算法泛化的完整过程。我们从解决具体问题的步骤出发,通过对比分析找到了重复的模式,进而用循环和变量将其抽象为通用算法。之后,我们通过广泛的测试验证了算法的有效性,并发现了其在边界情况(n <= 1)下的缺陷,最终通过增加前置条件修正了算法。这个过程体现了从特殊到一般、再通过测试完善的一般性编程思维。

在下一节视频中,我们将学习如何将这个完善的算法翻译成C语言代码。

041:将isprime算法转化为代码 🧮

在本节课中,我们将学习如何将判断一个数是否为质数的算法步骤,逐行转化为可运行的C语言代码。我们将从函数声明开始,逐步实现条件判断、循环和返回逻辑。


上一节我们讨论了判断质数的算法逻辑。本节中,我们来看看如何将这些逻辑步骤转化为具体的C语言代码。

首先,我们将算法步骤作为注释写入代码中,以便逐行进行翻译和实现。

// 函数声明开始
int isPrime(int n) {
    // 步骤1: 检查 n 是否小于等于 1
    // 步骤2: 从 2 到 n-1 进行循环计数
    // 步骤3: 检查 n 是否能被当前计数值整除
    // 步骤4: 根据检查结果返回是或否
}

接下来,我们实现第一个步骤:检查输入的数字 n 是否小于或等于1。这是一个条件判断,我们使用 if 语句来实现。

如果条件为真(即 n <= 1),我们知道这个数不是质数,需要立即返回 0(在C语言中通常用 0 表示“假”或“否”)。我们使用 return 语句来结束函数。

int isPrime(int n) {
    // 步骤1: 检查 n 是否小于等于 1
    if (n <= 1) {
        return 0; // 不是质数
    }
    // ... 后续代码
}

上一节我们介绍了初始条件检查。现在,我们来实现算法的核心循环部分。

我们需要从 2 开始,一直计数到 n-1,并将每个计数值称为 i。这对应一个 for 循环结构。

以下是 for 循环的组成部分:

  • 初始化int i = 2
  • 条件i < n (计数到 n 之前停止)
  • 递增i++ (每次计数加1)

循环体内部将包含我们对每个 i 要执行的操作。

int isPrime(int n) {
    if (n <= 1) {
        return 0;
    }
    // 步骤2: 从 2 到 n-1 进行循环计数
    for (int i = 2; i < n; i++) {
        // 循环体:对每个 i 执行的操作
    }
}

循环已经建立。在循环体内,我们需要检查当前的 n 是否能被当前的 i 整除。

这又是一个条件判断,我们使用 if 语句。条件表达式是 n % i == 0,其中 % 是取模运算符,用于计算余数。如果余数为 0,说明 n 能被 i 整除。

如果这个条件为真,那么 n 不是质数,我们同样立即返回 0

int isPrime(int n) {
    if (n <= 1) {
        return 0;
    }
    for (int i = 2; i < n; i++) {
        // 步骤3: 检查 n 是否能被当前计数值 i 整除
        if (n % i == 0) {
            return 0; // 发现能整除的数,不是质数
        }
    }
    // ... 后续代码
}

最后,我们需要处理循环结束后的情况。如果函数执行到这里,意味着:

  1. n 大于1。
  2. 循环从 2n-1 检查了所有可能的除数。
  3. 没有发现任何一个 i 能整除 n

因此,我们可以确定 n 是质数,并返回 1(表示“真”或“是”)。

int isPrime(int n) {
    if (n <= 1) {
        return 0;
    }
    for (int i = 2; i < n; i++) {
        if (n % i == 0) {
            return 0;
        }
    }
    // 步骤4: 循环结束未提前返回,说明是质数
    return 1;
}

此外,我们遵循一个常见的编程惯例:变量名通常使用小写字母。全大写名称通常预留给常量使用。因此,我们将函数参数命名为小写的 n


本节课中我们一起学习了如何将质数判断算法转化为C语言函数。我们逐步实现了:

  1. 使用 if 语句处理边界条件(n <= 1)。
  2. 使用 for 循环遍历从 2n-1 的所有整数。
  3. 在循环内使用 if 语句和取模运算符 % 检查整除性。
  4. 根据检查结果,使用 return 语句提前返回 0(非质数)或最终返回 1(质数)。

最终,我们得到了一个完整可用的 isPrime 函数。

042:使用diff比较输出

概述

在本节课中,我们将学习一个非常实用的Unix工具——diff。这个工具用于比较两个文件的内容,并指出它们之间的差异。在编程作业中,你可以用它来比较你的程序输出与标准答案是否一致。

使用diff比较文件

上一节我们介绍了Unix环境下的基本操作,本节中我们来看看如何使用diff命令来比较文件内容。

假设我们有一个编程作业,需要编写一个程序来打印特定模式的方块。我们已经有一个标准输出文件 ants_3582.txt,以及自己程序编译后生成的 squares 可执行文件。

运行自己的程序并将输出重定向到一个文件,而不是直接打印到屏幕,这是一种常见的做法。命令如下:

./squares 3582 > my_output.txt

现在,我们有了自己的输出文件 my_output.txt

比较输出文件

以下是使用diff命令比较两个文件的步骤。

首先,使用cat命令查看文件内容,进行初步的人工检查。

cat my_output.txt
cat ants_3582.txt

对于简单输出,人工检查可能足够。但对于复杂或大量的输出,人工检查会变得繁琐且容易出错。

此时,使用diff命令进行精确比较:

diff my_output.txt ants_3582.txt

如果两个文件内容完全相同,diff命令将不会有任何输出,这表示你的程序输出是正确的。

理解diff的输出格式

如果文件内容不同,diff会显示差异。其默认输出格式可能看起来有些奇怪。

  • < 开头的行表示该行存在于第一个文件(即diff命令的第一个参数)中,但不存在于第二个文件中。
  • > 开头的行表示该行存在于第二个文件中,但不存在于第一个文件中。

为了获得更直观的对比,可以使用 -y 选项进行并排显示:

diff -y my_output.txt ants_3582.txt

在并排视图中,中间会有一条竖线分隔两列,竖线两侧不同的行会被高亮显示,帮助你快速定位差异。

处理空白字符差异

有时,两个文件的差异可能仅仅在于空格、制表符或空行的数量。diff默认会将这些空白字符的差异也标记出来。

如果你希望diff在比较时忽略所有空白字符的差异,可以使用 -w 选项:

diff -w my_output.txt ants_3582.txt

使用 -w 选项需要谨慎,因为它可能会掩盖一些真正的逻辑错误。例如,一个本应有空格分隔的单词,如果漏掉了空格,-w选项会认为两者相同。

diff命令还有其他处理空白字符的选项。你可以通过查阅手册页来了解更多:

man diff

在手册页中搜索“white space”或“blank”,可以找到例如忽略行尾空格等更精细的控制选项。

总结

本节课中我们一起学习了diff工具的使用。我们了解了如何用它来比较程序输出文件与标准答案文件,如何解读其输出结果,以及如何使用 -y 选项进行并排对比和 -w 选项来忽略空白差异。掌握diff是验证程序输出正确性的高效方法,在后续的编程作业中你会经常用到它。

043:构建工具Make

概述

在本节课中,我们将要学习如何使用构建工具make来管理大型C语言项目的编译过程。我们将了解make如何通过分析文件之间的依赖关系,只重新编译那些发生变化的文件,从而显著提升开发效率。

从GCC到Make

上一节我们介绍了使用GCC编译器的基础知识。当然,你可以给GCC添加一些编译选项,以便它能更努力地识别代码中的潜在问题。

但是,如果你有一个非常庞大的程序,比如包含数百个源文件和成千上万行代码,该怎么办?

你可以直接告诉GCC编译当前目录下的所有.c文件,例如使用gcc *.c。但这意味着即使你只对代码做了一处微小的修改,也需要重新编译每一个文件。这将耗费大量时间,严重影响你的开发效率。

你也可以尝试手动挑选出需要重新编译的文件。但这个过程既繁琐又容易出错。如果你遗漏了某个文件,最终可能会导致程序出现奇怪的错误。

那么,你应该怎么做?你应该使用一个专门用于构建大型程序的工具,例如makemake不仅适用于构建大型程序,实际上几乎可以用于构建任何东西。例如,本课程所基于的教科书就是使用make构建的。

Makefile:构建的蓝图

make工具的输入是一个名为Makefile的文件。这个文件指定了构建的目标,即make应该为你构建哪些东西。

它同时还指定了依赖关系,你可以将其理解为构建一个目标所需的输入文件。如果某个依赖文件发生了变化,那么对应的目标就需要被重新构建。一个目标也可能是另一个目标的依赖项,在这种情况下,重建第一个目标就意味着你必须重建第二个目标。

Makefile还指定了从依赖文件构建目标的配方。这些就是运行make时,为了从依赖文件生成目标文件所需要执行的命令。

深入理解依赖关系

为了更好地理解依赖关系,让我们假设你想要构建一个名为myprogram的程序。

为了构建myprogram,你需要链接三个目标文件,如下图所示。这意味着myprogram依赖于这三个文件。如果其中任何一个文件被重新编译,我们就需要重新链接myprogram

如果abc.o是从abc.c编译而来的,那么这两个文件之间也存在依赖关系。同样地,如果abc.c包含了abc.h头文件,那么每当我们更改这个头文件时,我们就希望重新编译abc.o目标文件。

同理,xyz.o可能依赖于一个.c文件和一些头文件。在开发大型项目时,我们可能会在多个.c文件中包含同一个头文件。

最后,main.o依赖于main.c和我们的一个头文件。

现在,如果我们更改了xyz.c,由于xyz.o依赖于xyz.c,我们需要重新编译xyz.o。我们不需要重新编译其他任何目标文件。但是,由于myprogram依赖于xyz.o,我们需要重新链接我们的程序。

如果我们更改了xyz.h头文件,我们将需要重新编译两个目标文件,然后重新链接程序。

幸运的是,一旦我们告诉make这些依赖关系以及构建目标所需的命令,它就会自动计算出哪些目标需要被重新构建,并为我们运行相应的命令。

总结

本节课中,我们一起学习了构建工具make的核心概念。我们了解到,对于大型项目,手动管理编译既低效又易错。make通过读取Makefile文件,智能地分析目标、依赖和构建命令,只重新编译发生变化的文件及其依赖项,从而极大地提升了构建效率。理解并正确设置文件间的依赖关系,是高效使用make的关键。

044:使用Makefile编译

在本节课中,我们将学习如何使用Makefile来编译C语言程序。Makefile是一个强大的工具,它能帮助我们管理大型项目的编译过程,只重新编译那些发生变化的文件,从而节省时间。

概述

之前我们已经手动编译过程序,并讨论了make工具对于构建大型程序、跟踪文件依赖和变更的用处。本节将通过一个具体示例,演示如何创建和使用Makefile来编译一个包含多个源文件和头文件的项目。

使用Makefile编译

现在,我们有一个包含若干文件的项目:几个C源文件(.c)、几个头文件(.h)以及一个Makefile。虽然这不是一个庞大的项目,但足以作为一个小型示例。

如果我在终端输入make命令,它会根据Makefile中的规则编译所有文件,并生成最终的可执行程序。这个程序本身功能很简单,只是进行一些数学计算。

以下是编译过程的示例代码:

make

Makefile的智能编译

Makefile的一个关键优势在于其智能性。例如,如果我打开file1.c并修改其内容(比如将某个计算改为加一),然后再次运行make命令,你会发现这次它只重新编译了file1.c这一个文件,并将其链接到最终程序中。

这个过程避免了重新编译所有文件。对于小项目来说,速度差异不明显,但对于包含大量文件的实际大型项目,这能显著节省编译时间。

在编辑器内集成编译

实际上,我们甚至不需要离开代码编辑器(如Emacs)去命令行执行编译。在Emacs中,我们可以使用快捷键(例如 C-c C-v)来触发编译。编辑器底部会显示编译命令,通常是make -k

-k参数的意思是“keep going”,即遇到错误时继续尝试编译其他部分,以便一次性看到尽可能多的错误信息。这不是必须的,但通常是默认设置。

如果编译成功,会显示“finished with no problems”。如果代码中存在语法错误,例如漏掉了分号,编译器会在Emacs中显示错误信息。

为了演示,我们可以在代码中故意制造几个错误。此时,Emacs会将这些错误信息关联到具体的代码行。你可以直接点击错误信息,编辑器会自动跳转到产生该错误的代码行,这极大地方便了调试。

创建Makefile

在你的阅读材料中,会详细学习如何编写Makefile。通常从一个非常简单的Makefile开始,逐步构建成一个功能完善、灵活性强、能够轻松添加更多文件的Makefile。

基本方法是:创建一个名为Makefile(或makefile)的文件,在其中定义编译规则和依赖关系。之后,你就可以通过make命令来使用它了。

总结

本节课我们一起学习了Makefile的基本用法。我们了解到,Makefile能自动检测源文件的变化,并只编译必要的部分,从而提升效率。我们还看到了如何在代码编辑器(以Emacs为例)中集成编译过程,实现快速编译和错误定位。掌握Makefile是管理复杂C语言项目的重要一步。

045:测试与调试

在本节课中,我们将要学习软件工程中两个至关重要的环节:测试与调试。虽然这两个概念常常被一同提及,但它们是两项截然不同的任务。我们将首先明确两者的定义与目标,然后深入探讨如何设计有效的测试用例。

测试:发现缺陷的过程

上一节我们明确了课程目标,本节中我们来看看什么是测试。测试是发现代码中缺陷(bug)的过程。你的目标是找出那些会导致程序行为不正确的输入。

一旦程序在一个或多个测试用例上失败,你就需要进入下一个环节——调试。

调试:修复缺陷的过程

在了解了如何发现缺陷之后,我们来看看如何解决它们。调试是修复程序中缺陷的过程。测试和调试共同构成了确保程序质量的完整工作流。

什么是一个好的测试用例?

从定义出发,我们知道了测试的目标是发现缺陷。那么,什么样的测试用例能帮助我们更好地达成这个目标呢?许多人对此的直觉可能是错误的。

一个好的测试用例,恰恰是程序会失败的那个测试用例。这听起来可能令人惊讶。

为什么程序失败的测试用例反而是好的?请记住,测试的目标是发现程序中的缺陷。如果你的程序在一个测试用例上失败了,那就意味着你成功地发现了一个缺陷。

另一种理解“困难的测试用例就是好的测试用例”的思路,是类比对学生进行考试,而不是测试程序。

假设我有一组学生,我想测试他们的编程技能,也许是为了决定雇佣谁来参与我的项目,或者判断他们是否准备好编写运行重要系统的真实软件。

以下是一个好的测试问题吗?print(“Hello, world!”)。每个人都能答对,学生们会很高兴,但这真的能告诉我关于他们编程技能的什么信息吗?这个问题无法提供任何信息来判断学生是否真的会编程。

同理,如果你的测试用例过于简单,以至于无法识别出那些无法完成预期功能的代码中的问题,那么它在判断你的程序是否正确方面就没有太大用处。

因此,当你进行测试时,你需要构思出困难的测试用例。这在心理上通常是困难的,因为你希望你的代码能正常工作,你不想证明你的程序有错。但你更愿意现在发现你的代码有问题,而不是在它控制着自动驾驶汽车或驾驶飞机时出现问题。

课程预告与总结

在接下来的课程中,你将深入学习测试与调试。你将完成一些专注于为程序开发测试用例的作业,我们会提供许多有缺陷的实现供你测试。需要指出的是,测试与调试在编程课程中常常被忽视。我不断从行业内的朋友那里听到的反馈是:请多教一些测试

本节课中我们一起学习了测试与调试的核心区别:测试旨在发现缺陷,而调试旨在修复缺陷。我们明确了一个好的测试用例的价值在于其能揭示程序错误的能力,并鼓励大家克服心理障碍,积极设计能挑战程序极限的测试用例,这是编写健壮、可靠软件的关键一步。

046:测试驱动开发

在本节课中,我们将探讨软件开发中的一个重要实践——测试驱动开发。我们将回顾七步法,并重点讲解如何将测试的思维提前到编码之前,以构建更健壮的程序。

回顾七步法中的测试环节

上一节我们介绍了软件开发的整体流程。现在,让我们回顾一下七步法。

在七步法中,测试是第六步,位于设计算法并将其转化为代码之后。此时,许多程序员,甚至是有经验的程序员,都急于完成问题。这种急切的心态有时会导致程序员对测试的重视程度远低于其实际所需。他们可能只运行少数几个测试用例,就宣称代码可以工作。然而,测试代码非常重要,不应被如此草率地对待。

从早期步骤中获取测试用例

为了更好地进行测试,我们可以将目光回溯到更早的步骤。

在第一步(理解问题并手工计算实例)中,你已经通过手工计算得出了特定输入对应的正确输出。这些输入和输出就可以作为我们的测试用例,尤其是我们已经有了用于比对的正确答案。

事实上,如果我们采用这种方法,可能会应用黑盒测试的思想,在第一步就为我们认为困难的情况设计测试用例。也就是说,我们甚至可以在开始编码之前,就思考这个问题的难点所在,并在第一步中使用这些难点案例。

为何要提前设计测试用例?

我们为什么要这样做?这不仅能为我们在进行到第六步时准备好一套完善的测试用例,而且越早思考这些困难案例,后期需要修改的地方就越少。如果我们第一次就能全部处理正确,那是最好的。

这种先编写测试用例的理念实际上有一个专门的名称。

测试驱动开发简介

这种方法被称为测试驱动开发,并在工业界得到广泛应用。这个过程与七步法自然契合,因为在第一步中开发这些测试用例非常有效。在本课程的后续部分,我们会要求你在一些作业中使用这个理念。

测试驱动开发的核心流程是:先编写测试用例,然后开始开发你的代码。

总结

本节课中,我们一起学习了测试驱动开发的基本概念。我们认识到,将测试的考量提前到编码之初,不仅有助于在开发后期进行系统验证,更能促使我们在设计阶段就深入思考问题的边界与难点,从而编写出更加可靠和健壮的代码。记住这个简单的公式:先测试,后编码

047:代码审查

概述

在本节课中,我们将学习代码审查的概念、重要性及其多种实践形式。代码审查是确保代码质量、可读性和可维护性的关键环节,它超越了单纯的功能测试。

测试的局限性

上一节我们介绍了测试的重要性,本节中我们来看看测试的局限性。测试非常有用,你需要疯狂地测试你的代码,并编写大量复杂的测试用例,以便发现代码中的问题。

然而,测试有其局限性。首先,你永远无法编写足够多的测试用例来保证代码完全正确。即使你编写了一百万个非常巧妙且复杂的测试用例,你的代码仍有可能在第一百万零一个测试用例上失败。

此外,测试只能告诉你代码的功能行为是否正确,即它是否产生了正确的输出。测试无法告诉你代码是否具有可读性、是否使用了良好的变量名、是否编写了适当的文档,或是否存在其他风格上的问题。当你专注于让代码运行时,这些问题听起来可能不那么重要。但是,当你参与需要多名团队成员协作、持续数月或数年的大型项目时,这些问题就变得至关重要。

代码审查:提升代码质量的另一种方式

因此,另一种帮助你确保高质量代码的方法是进行代码审查。

在工作环境中,与一位同事(通常是你的团队成员)坐下来,逐行检查你的代码。解释每一行代码的作用及其原因。你甚至可以边检查边绘制代码执行的示意图,以确保双方对代码中发生的事情有完全一致的理解。

在这个过程中,你的同事会向你提问并指出潜在问题。他们可能会指出他们认为你未考虑到的用例。如果你认为你已经考虑到了,你们可以讨论你的代码如何处理该用例,以及如何让未来的代码阅读者更清楚地理解这一点。他们也可能指出你的代码在文档或其他可读性方面需要改进的地方。当然,他们还可能发现其他潜在问题,范围从安全漏洞到向用户打印的信息表述不当。

代码审查的多种形式

代码审查可以采取多种形式,而不仅仅是我们刚才讨论的那种。

你的同事也可能在你不在场的情况下审查你的代码。事实上,在许多公司中,代码在被推送到Git仓库的主分支等特定分支之前,必须经过同行审查。这确保了所有集成到开发主分支的代码都符合特定的质量准则。

审查甚至可以在编写代码时进行,这就是结对编程中发生的情况。

在结对编程中,一位伙伴担任“驾驶员”,负责编写代码;另一位伙伴担任“领航员”,负责观察驾驶员的操作、寻找问题并思考代码的宏观走向。

我们不会深入探讨这些主题,但如果你继续学习更多软件工程课程,你几乎肯定会学到更多关于代码审查的知识,我们在此提及是为了让你有所了解。

总结

本节课中我们一起学习了代码审查。我们了解到,虽然测试至关重要,但它无法覆盖代码质量的所有方面,如可读性和可维护性。代码审查通过同行协作,逐行检查代码,能够发现潜在问题、改进文档和风格,是确保高质量代码实践的关键组成部分。我们还简要了解了代码审查的多种形式,包括正式审查和结对编程。

048:使用Valgrind发现问题 🔍

在本节课中,我们将学习如何使用Valgrind工具来发现和调试C程序中的内存错误。我们将通过一个具体的例子,演示如何定位并修复一个由未初始化变量引起的bug。


概述

Valgrind是一个强大的内存调试和分析工具。它可以帮助我们发现程序运行时难以察觉的错误,例如使用未初始化的内存、内存泄漏或非法内存访问。本节我们将通过一个名为broken的程序实例,演示Valgrind的基本使用流程。

程序分析

首先,我们来看一下Drew提供的程序broken。该程序由一个quadratic函数和main函数组成。

quadratic函数旨在计算二次多项式 a*x² + b*x + c 的值。其函数声明如下:

double quadratic(double a, double b, double c, double x);

main函数中,程序使用两组不同的参数调用quadratic函数,并打印结果。

第一组参数 (1, -2, 0.5, 3) 的计算结果是正确的,输出为3.5。
然而,第二组参数 (-1, 3, 1, 2) 的计算结果出现了错误。根据公式 (-1)*2² + 3*2 + 1,正确结果应为3,但程序输出并非如此。

使用Valgrind定位问题

为了找出问题根源,我们使用Valgrind来运行程序。以下是运行命令:

valgrind ./broken

Valgrind报告了多达156个错误,来自52个不同的上下文。这表明程序中存在严重问题。通常,修复第一个报告的错误可以连带解决后续的许多错误。

Valgrind报告的第一个错误信息是:“Conditional jump or move depends on uninitialized values”。关键词是“uninitialized values”(未初始化的值)。错误发生在调用printf函数时。

为了获取更精确的错误位置信息,我们使用--track-origins=yes选项重新运行Valgrind:

valgrind --track-origins=yes ./broken

新的报告指出,这个未初始化的值是在quadratic函数中通过栈分配创建的。具体指向了源代码的第5行。

修复Bug

我们查看源代码第5行附近的代码。第5行是函数声明,第6行声明了变量total,第7行则执行了 total += ... 的操作。

double total; // 第6行:声明变量
total += a * x * x; // 第7行:使用未初始化的total进行加法操作

问题在于,变量total在声明后没有赋予初始值(例如0),就直接用于加法运算。这导致了未定义行为。

修复方法很简单:将total初始化为0。

double total = 0.0;

修复后重新编译并运行程序,两组测试用例都输出了正确的结果:3.5和3。

我们再次运行Valgrind进行验证。这次报告显示:“ERROR SUMMARY: 0 errors from 0 contexts”。这表明所有内存错误都已被修复。报告还显示“All heap blocks were freed -- no leaks are possible”,说明没有内存泄漏,这对于当前课程阶段是理想的结果。

编译器警告的作用

值得一提的是,许多此类错误也可以通过启用编译器的警告选项来发现。例如,使用GCC的-Wall选项编译未修复的程序:

gcc -Wall -o broken broken.c

编译器会给出警告:“‘total’ is used uninitialized in this function”,并指出具体的代码行号。这为我们提供了非常有用的调试信息。

然而,需要指出的是,编译器的静态分析有时会遗漏一些错误,而这些错误可能被Valgrind在运行时动态检测到。因此,结合使用编译器警告和Valgrind工具,是确保代码质量的更佳实践。


总结

本节课中,我们一起学习了如何使用Valgrind工具来调试C程序。我们通过一个实际案例,经历了从发现问题(程序输出错误)、使用Valgrind定位错误根源(未初始化变量)、到最终修复错误(初始化变量)的完整流程。同时,我们也了解了编译器警告在辅助调试中的作用。掌握这些工具和方法,将帮助你更高效地编写出健壮、无错的C语言程序。

049:使用GDB收集信息 🐛

在本节课中,我们将学习如何使用GDB(GNU调试器)来定位和修复C语言程序中的错误。我们将通过一个名为rotate的程序实例,演示如何设置断点、检查变量值、单步执行代码,并最终解决一个逻辑错误。

程序概述与问题发现

Drew给了我一个名为rotate的程序,并告知其中存在一个错误。让我们先查看这个程序,了解它预期完成的功能,然后尝试找出这个错误。

程序rotate.cmain函数调用了test_rotate。这个程序的功能是:给定一个点(例如(1,0))和一个旋转角度(例如90度),计算旋转后的点坐标并输出。在test_rotate函数中,我们使用点(1,0)和角度90进行测试,期望得到的旋转结果是(0,1)test_rotate函数会通过断言来验证输出是否符合预期。

以下是test_rotate函数的核心部分:

assert(rotated.x == expected.x);
assert(rotated.y == expected.y);

该函数会调用rotate函数,传入一个点和旋转角度。rotate函数通过调用数学头文件math.h中的三角函数cosinesine来完成旋转计算。

当我们运行程序时,第一个测试用例(将(1,0)旋转90度)失败了。断言失败信息指向了y值的比较,这意味着程序在计算y坐标时出现了问题。

使用GDB进行调试

回到编辑器,我们可以运行调试器来观察程序运行时的变量值,从而找出不符合预期的行为。启动GDB后,程序会在进入main函数后暂停。由于问题很可能不在main函数本身,而在test_rotate调用的rotate函数中,我们可以将注意力集中在那里。

为了深入调查,我们可以在rotate函数内部设置一个断点。这样,当程序执行到该函数时就会暂停,允许我们检查此时的状态。

以下是设置断点并检查变量的步骤:

  1. rotate函数入口处设置断点。
  2. 继续运行程序直到断点处。
  3. 使用display命令持续显示我们关心的变量值,例如p.xp.y

执行这些步骤后,我们观察到输入值p.xp.y分别是10,符合预期。接着单步执行代码。

定位并分析错误

单步执行时,我们意外地进入了sin函数。由于我们对此不感兴趣,可以使用finish命令跳出该函数,回到rotate函数中。

继续执行下一行代码后,我们发现p.x的值变成了一个极小的数(接近0),这符合我们的预期(旋转后x坐标应为0)。然而,再执行一行后,p.y的值也变成了一个极小的数,而不是我们期望的1

问题出在计算p.y的公式上:

p.y = p.x * sin(theta) + p.y * cos(theta);

输入的p.x1sin(90)1,所以这部分应为1。输入的p.y0cos(90)0,所以这部分应为0。整个表达式理应是1 + 0,但结果却是0

原因在于,我们在计算p.y时,使用的p.x已经是在前一行代码中被修改过的值(即旋转后的新x值,接近0),而不是原始的输入值1。这导致了计算错误。

修复程序错误

要解决这个问题,我们需要保留输入点(x, y)的原始值。一个有效的方法是创建一个新的point结构体变量(例如命名为answer),将计算结果分别赋值给answer.xanswer.y,最后返回这个answer

修改后的rotate函数核心部分如下:

struct point answer;
answer.x = p.x * cos(theta) - p.y * sin(theta);
answer.y = p.x * sin(theta) + p.y * cos(theta);
return answer;

保存修改后,重新编译程序,并再次使用GDB进行验证。在同样的断点处,我们检查p.xp.y,确认它们保持了原始的输入值(1,0)。单步执行后,检查answer.x(接近0)和answer.y(为1),均符合预期。这表明错误已被修复。

清除断点,让程序运行完毕。程序成功通过了所有测试用例,包括将(1,0)旋转90度得到(0,1),以及将(0,1)旋转90度得到(-1,0)等。所有断言均未触发失败,程序运行正确。

总结

本节课中,我们一起学习了使用GDB调试C程序的基本流程。我们通过一个具体的bug修复案例,实践了如何设置断点、单步执行、检查变量值,并分析了错误的根本原因——在计算过程中意外修改了后续计算所需的原始数据。最终的解决方案是使用临时变量来保存计算结果,避免覆盖输入参数。掌握这些调试技巧对于理解和解决编程中的逻辑错误至关重要。

050:杜克大学软件工程学生的建议

在本节课中,我们将聆听一位杜克大学软件工程硕士生的分享,了解她在学习编程过程中的心得体会,以及她对初学者的宝贵建议。

概述

本节内容基于杜克大学电气与计算机工程系硕士生Nis Saag Dbi的分享。她将分享自己学习编程的经历、最喜欢的项目,并为正在学习本课程的同学提供实用建议。

保持冷静,坚持不懈

我的建议是保持冷静,不要气馁。编程是一项全新的技能,它并不容易。没有唯一正确的学习方式。因此,我鼓励你坚持下去,保持动力。

最喜爱的编程项目

我最喜欢的编程项目是构建一个科学计算器。

这个项目让我认识到,我们常常对计算器的功能和运算速度习以为常。当我亲自编程实现时,我发现,像计算两个大数相乘这样的操作,在普通计算器上可能是瞬间完成的,但在编程实现时,其速度远比想象中要慢。

我真正享受的是,能够通过算法设计和问题解决,找出如何加速这些计算过程。这需要大量的算法工作和整体性的问题解决能力。

给未来软件工程师的建议

对于希望从事软件工程的人,我的建议是保持专注,持续编程。许多公司非常看重你主动构建个人项目的积极性。因为这表明你付出了努力去独立完成项目,并且为了使其正常运行,你真正彻底理解了某些知识。

给本课程学生的建议

对于正在学习本课程的学生,我的建议是:务必遵循课程提供的策略和技巧。当我学习这门课程时,我曾对课程推荐的方法论有所抵触。但我想说,你越早接受这是一种更好的学习方式,对你的长远发展就越有利。这最终一定会让你成为一名更优秀的程序员。

总结

本节课中,我们一起学习了来自杜克大学学姐的经验分享。核心要点包括:学习编程需要保持冷静坚持不懈;通过实践项目(如构建计算器)可以深入理解算法与性能优化;主动构建个人项目对职业发展有益;最后,虚心接受并遵循成熟的学习方法论,是成为优秀程序员的有效途径。记住,编程之旅充满挑战,但持续努力终将带来回报。

051:扑克项目介绍 🃏

在本节课中,我们将学习如何利用C语言编写一个程序,用于计算德州扑克中玩家获胜的概率。我们将探讨如何用计算机可读的方式描述牌局,并介绍一种名为“蒙特卡洛模拟”的强大技术来估算概率。


项目背景与目标

上一节我们介绍了C语言的基础知识,本节中我们来看看如何将这些知识应用于一个有趣的项目。在观看扑克比赛时,我们常常看到屏幕上显示每位玩家获胜的概率。这些概率是如何计算的呢?当未知牌很多时,手动计算会变得非常复杂。因此,我们的目标是编写一个程序,能够根据已知的牌面信息,自动计算出每位玩家的获胜概率。

如何描述一手牌

为了实现这个目标,我们首先需要找到一种让计算机易于处理的方式来描述牌局。在德州扑克中,每位玩家最终会拥有7张牌:2张底牌和5张公共牌。玩家需要从这7张牌中选出最好的5张来组成一手牌。

以下是描述单张牌的方法:

  • 牌面值:用一个字母表示,例如 K 代表国王(King)。
  • 花色:用一个字母表示,例如 H 代表红桃(Hearts)。

因此,一张“红桃K”可以表示为 KH。对于未知的牌,我们用问号加一个数字来表示,例如 ?2。相同的 ?2 代表同一张未知牌,它与 ?0?1 代表不同的牌。

让我们看一个具体的例子。从我的视角,我的牌可以这样描述:

  • 我的底牌:KH(红桃K), KC(梅花K)。
  • 已知的公共牌(翻牌圈和转牌圈):3S(黑桃3), TS(黑桃10), QS(黑桃Q), KS(黑桃K)。
  • 未知的河牌:?2

我的对手Genevieve的牌则由她的两张未知底牌(?0, ?1)和与我相同的五张公共牌组成。

这种表示方法非常灵活,足以描述许多其他扑克变种,例如没有公共牌的七张牌梭哈。

计算概率的挑战

现在我们知道如何描述牌局了,但如何实际计算概率呢?一种方法是让程序考虑所有可能的未知牌组合。例如,如果我们能看到两位玩家的手牌,但还有5张牌未发出,那么大约有2.05亿种可能的组合。虽然计算机能在几分钟内处理完,但我们希望程序能更快。

另一种方法是基于概率和组合数学原理为每种情况推导公式。但这工作量巨大,尤其是考虑到不同玩家手牌之间的相互影响。

解决方案:蒙特卡洛模拟

因此,我们将采用一种更通用、更高效的方法:蒙特卡洛模拟

那么,什么是蒙特卡洛模拟呢?它的核心思想是:

  1. 为未知牌进行大量(例如10万次)随机发牌。
  2. 在每次模拟中,根据完整的牌面判断哪位玩家获胜。
  3. 统计每位玩家获胜的次数。
  4. 用(获胜次数 / 总模拟次数)来估算每位玩家的真实获胜概率。

只要随机模拟的次数足够多,得到的结果就会非常精确。蒙特卡洛模拟不仅适用于扑克或卡牌游戏,它是一种广泛适用的技术,尤其适用于精确计算极其复杂或计算量巨大的问题。


总结

本节课中我们一起学习了扑克概率计算项目的核心思想。我们首先定义了用KH?2等形式描述牌面的方法,让计算机能够理解牌局。接着,我们认识到直接计算所有组合或推导公式的困难,从而引入了蒙特卡洛模拟这一强大技术。通过大量随机抽样和统计,我们可以高效且准确地估算出复杂的概率。在后续的课程中,你将学习如何用C语言实现这个模拟过程。

052:扑克项目路线图 🃏

在本节课中,我们将一起了解贯穿本系列课程的一个综合性项目——扑克牌项目。我们将看到该项目如何分阶段进行,以及每个阶段需要完成的核心任务。

项目概述

由于这个项目横跨多门课程,我们现在将为你展示整个项目的路线图,以便你了解在本课程中项目的进展方向。

课程2:处理单张牌 🃁

在课程2中,你将处理与 card_t 结构相关的代码,你将使用这个结构来表示一张扑克牌。

以下是你在本阶段需要完成的任务:

  • 编写打印扑克牌的函数。
  • 编写根据字母对(如“As”代表黑桃A)或整数创建扑克牌的函数。
  • 使用 assert 来检查一张牌是否有效。

课程3:处理牌组与牌型判断 🎲

上一节我们介绍了单张牌的处理,本节中我们来看看如何处理一整副牌以及判断牌型。

在课程3中,你将编写一些处理一副扑克牌的函数。

以下是你在本阶段需要完成的任务:

  • 编写一个随机洗牌的函数。这对于蒙特卡洛模拟非常有用,因为你需要抽取许多随机的牌手。
  • 编写判断一组牌构成何种扑克牌型(例如:一对、顺子、满堂红)的代码。
  • 在完成牌型判断后,编写比较两手牌以确定胜负的代码。

判断扑克牌型有一些棘手之处。在开始编写所有代码之前,拥有测试用例会很有帮助。由于我们在本课程中涵盖了测试内容,我们将让你现在就开始为牌型判断代码开发测试用例。

你可以在尚不知道如何实现判断代码的情况下完成此任务,并且我们会提供有缺陷的实现版本供你运行测试用例,就像你在其他作业中所做的那样。

课程4:整合项目与处理输入 📂

在课程3中,我们为牌组操作和牌型判断打下了基础。最后,在课程4中,你将完成整个项目。

以下是你在本阶段需要完成的任务:

  • 编写读取输入文件的代码,你将在该课程中学习如何操作。
  • 处理未知的牌,我们将其表示为 ?0?1 等。
  • 最后,将所有功能整合起来,编写 main 函数来调用你编写的所有其他函数并打印出结果。

总结

本节课中,我们一起学习了扑克牌项目的完整路线图。这个项目将汇集整个专项课程中的大部分主要概念,并为你提供一个展示新学编程技能的机会。

053:指针、数组与递归简介 🎯

在本课程中,我们将学习C语言中三个核心且强大的概念:指针数组递归。掌握这些知识将使你能够处理序列数据,从而解决更广泛、更复杂的问题。

课程概述 📋

在前两门课程中,你已经掌握了开发算法的基础、C语言编程以及编译运行程序所需的工具。现在,是时候深入探讨那些能让你处理数据序列的主题了。

指针:数据的“地址簿”📍

上一节我们介绍了本课程的整体目标,本节中我们来看看第一个核心概念——指针。

指针用于指定其他数据在内存中的位置。我们将从一些基本的机制和概念开始,然后再探讨它们的各种用途。

数组:数据的“序列”📊

理解了指针的基本概念后,本节我们将探讨指针的一个重要应用——数组。

数组用于表示数据序列。还记得第一门课程中的“最近点”算法吗?一旦你学会了数组,就可以将这个算法转化为代码。当然,你也能用数组解决各种各样的其他问题。

以下是数组的一些关键点:

  • 数组是内存中连续存储的相同类型数据的集合。
  • 数组名本身可以看作一个指向其首元素的指针。
  • 通过下标(如 arr[i])可以访问数组中的特定元素。

字符串与多维数组 🔤

在掌握了数组之后,你将有能力学习字符串和多维数组。

你之前使用过一些字符串字面量,但我们尚未深入探讨操作或计算字符串的细节。字符串本质上是字符数组,因此学习完数组后,你将准备好全面了解它们。

此外,你将学习多维数组。例如,二维数组允许你表示数据矩阵。如果你的问题需要,你还可以处理更高维度的数组。

递归:用自身定义自身 🔄

我们将通过讨论递归来结束本课程。

递归是思考问题的另一种方式,它将一个复杂问题实例的解决方案,用同一个问题但更简单实例的解决方案来表达。许多问题天然适合用递归解决,因此我们希望确保你掌握这项编程技能。

以下是递归的核心思想:

  • 一个递归函数会调用自身
  • 必须有一个或多个基准情况(base case)来终止递归。
  • 递归调用必须向基准情况推进

总结 🏁

本节课中,我们一起学习了C语言中三个进阶主题:指针数组递归。指针是操作内存地址的工具,数组是组织序列数据的结构,而递归则是一种强大的问题解决范式。掌握这些概念将极大地扩展你利用C语言解决问题的能力。现在,让我们正式开始深入探索吧。

054:指针的威力——从“朴素交换”说起 🔄

在本节课中,我们将通过一个具体的例子来理解为什么需要指针。我们将分析一个试图交换两个变量值但失败的函数,并以此引出指针如何解决此类问题。

为了让你直观地看到指针的威力,我们将展示一段没有指针就无法正常工作的代码。在我们介绍了指针的机制之后,我们将重新审视这段代码,看看如何利用指针使其正确运行。

一个失败的交换尝试

在这个例子中,一位思路有误的C程序员尝试编写一个交换两个变量值的函数,你可以在示例顶部看到这个函数。这个函数实际上做了什么?你可以自己分析,因为这里并没有涉及新知识。但我们还是来明确地走一遍流程。一如既往,我们从 main 函数的开始执行。

以下是 main 函数中的初始步骤:

  1. 我们声明变量 a 并将其初始化为 3
  2. 接着,我们声明变量 b 并将其初始化为 4
  3. 然后,我们调用 swap 函数,并将 ab 作为参数传入。

深入 swap 函数

当我们调用 swap 函数时,会为其创建一个新的栈帧。我们将 a 的值 3 传递给形参 x,将 b 的值 4 传递给形参 y。我们记下当 swap 返回时需要回到 main 中的哪一行代码,然后将执行箭头移到 swap 函数的第一行代码之前。这些都是自课程一开始你就学过的函数调用规则。

接下来,我们看看 swap 函数内部的操作:

  1. 我们声明变量 temp 并将其初始化为 x 的值,即 3
  2. 然后执行赋值 x = y,将 x 的值设置为 4
  3. 最后执行赋值 y = temp,将 y 的值设置为 3

现在,我们到达了 swap 函数的末尾,准备返回。我们将返回到之前在栈帧中记下的 main 函数中的调用位置,并销毁 swap 函数的栈帧。同样,这里没有新内容,这只是你在第一门课程中学过的函数返回规则。

回到 main 函数

然后,我们打印出 a3b4,随后程序退出。

请注意,ab 分别以 34 开始,并以相同的值结束。swap 函数实际上并没有对它们产生任何影响。根据C语言的规则,这个结果是合理的。swap 函数只能操作其自身栈帧中的副本,它无法影响 main 函数栈帧中的值。

指针能带来什么不同?

那么,指针将让我们能够做什么不同的事情呢?指针变量将为我们提供其他数据的位置(地址),即使这些数据位于不同的栈帧中。然后,我们可以通过指针来影响那些数据。这只是指针的用途之一,一旦我们掌握了基础知识,还会看到其他应用。

本节总结

本节课中,我们一起分析了一个没有使用指针的“朴素交换”函数为何会失败。关键在于,C语言中函数参数传递的是值的副本,因此函数内部对形参的修改不会影响调用处的实参。这引出了对指针的需求:指针允许我们间接地访问和修改其他内存位置的数据,从而解决此类问题。在接下来的课程中,我们将正式学习指针的语法和用法。

055:指针详解与图解 🧭

在本节课中,我们将通过逐行分析一段使用指针的C语言代码,学习如何绘制图解来清晰地理解指针在程序执行过程中的变化。我们将重点关注变量的声明、指针的赋值以及通过指针修改值的过程。


上一节我们介绍了指针的基本概念,本节中我们来看看如何通过图解来跟踪指针的实际操作。

第一行代码声明了一个整型变量 x 并初始化为 3

int x = 3;

下一行代码声明了一个整型指针变量 p,但尚未初始化。

int *p;

此时,我们有一个名为 p 的“盒子”,但尚不知道里面存放的值是什么。

接下来的一行代码将 p 初始化为变量 x 的地址。

p = &x;

理解此类赋值语句时,应先看左侧,再看右侧。左侧表明值将被存入变量 p。右侧表明要存入的值是 x 的地址。执行此行后,变量 p 将包含一个指向 x 的箭头。

然后,我们通过指针 p 来修改它所指向的值。

*p = 4;

左侧的 *p 表示 p 所指向的值,即变量 x。右侧的值是 4。执行此行后,x 的值将被改为 4

以下是代码执行的步骤总结:

  1. 声明并初始化 x
  2. 声明指针 p
  3. x 的地址赋给 p
  4. 通过 px 的值改为 4

接下来,我们继续分析后续的代码,看看指针如何与其他变量交互。

下一行代码声明了一个新的整型变量 y,并将其初始化为 p 所指向的值(此时为 4)。

int y = *p;

执行此行后,将创建一个名为 y 的新“盒子”,其值为 4

然后,声明另一个整型指针 q,并将其初始化为变量 y 的地址。

int *q = &y;

这意味着将创建一个名为 q 的新“盒子”,其中包含一个指向 y 的箭头。

下面这行代码稍复杂一些。

*q = *p + 1;

左侧的 *q 表示 q 所指向的位置,即变量 y。右侧的值是 *p + 1,即 p 所指向的值(4)加 1(结果为 5)。执行此行后,y 的值将被改为 5

以下是此部分的步骤总结:

  1. p 指向的值赋给新变量 y
  2. 声明指针 q 并指向 y
  3. 通过 qy 的值改为 p 指向的值加 1

最后,我们来看指针之间的直接赋值操作。

最后一行代码将 p 的值赋给了 q

q = p;

q 是接收方,它将获得 p 所具有的值,即那个指向 x 的箭头。执行此行后,q 的值将被替换,现在 q 也指向 p 所指向的变量(即 x)。


本节课中我们一起学习了如何通过图解来逐步分析指针代码。我们掌握了:

  1. 指针变量的声明与初始化。
  2. 使用取地址运算符 & 获取变量地址。
  3. 使用解引用运算符 * 访问或修改指针指向的值。
  4. 指针之间的赋值,会使它们指向同一个内存位置。

通过这种逐行图解的方法,可以清晰地可视化指针的行为,这是理解和调试指针相关代码的强大工具。

056:修正的交换函数

概述

在本节中,我们将学习如何使用指针编写一个真正有效的交换函数。我们将对比之前无效的版本,详细分析指针在函数参数传递中的作用,并通过逐步执行来理解其工作原理。

指针版交换函数解析

上一节我们介绍了没有使用指针的交换函数,它无法真正交换两个变量的值。本节中我们来看看使用指针的正确版本。

以下是修正后的交换函数代码:

void swap(int *x, int *y) {
    int temp = *x;
    *x = *y;
    *y = temp;
}

函数参数声明

首先观察函数参数声明的两种等效写法:

// 写法1:星号与类型相邻
void swap(int* x, int* y)

// 写法2:星号与变量名相邻
void swap(int *x, int *y)

两种写法都正确,在C代码中都会见到。xy是指向整数的指针,这意味着在函数内部访问它们指向的值时,需要进行解引用操作。

函数调用方式

main函数中调用此函数时,需要传递变量的地址而非值:

int main() {
    int a = 3, b = 4;
    swap(&a, &b);  // 传递地址
    printf("a = %d, b = %d\n", a, b);
    return 0;
}

逐步执行分析

让我们逐步跟踪程序的执行过程,理解指针如何实现真正的交换。

初始状态

main函数开始时:

  • 变量a的值为3
  • 变量b的值为4

函数调用

调用swap(&a, &b)时:

  • 参数x获得a的地址,指向变量a
  • 参数y获得b的地址,指向变量b

进入swap函数

标记调用位置为location1,进入swap函数内部:

  1. 创建临时变量

    int temp = *x;
    
    • 解引用x获得a的值3
    • 3存入临时变量temp
  2. 交换第一步

    *x = *y;
    
    • 解引用y获得b的值4
    • 4存入x指向的位置(即变量a
    • 此时a的值变为4
  3. 交换第二步

    *y = temp;
    
    • temp中的值3存入y指向的位置(即变量b
    • 此时b的值变为3

返回主函数

退出swap函数后:

  • 变量a的值为4
  • 变量b的值为3

执行printf语句将输出:

a = 4, b = 3

核心概念总结

以下是使用指针实现交换功能的关键要点:

  1. 指针参数:函数参数声明为int *x表示接收整数变量的地址
  2. 地址传递:调用时使用&运算符获取变量的地址
  3. 解引用操作:在函数内部使用*运算符访问指针指向的值
  4. 间接修改:通过指针可以修改函数外部的变量值

总结

本节课中我们一起学习了如何使用指针编写有效的交换函数。通过对比之前的无效版本,我们理解了为什么需要传递变量的地址而非值。指针作为C语言的核心概念,允许函数间接访问和修改调用者的数据,这是实现许多高级功能的基础。掌握指针参数的使用是成为熟练C程序员的重要一步。

057:硬件级交换 🔧

在本节课中,我们将重新审视交换函数,并特别关注其在硬件层面发生的一些关键过程。我们将通过分析内存中栈和代码区的具体操作,来深入理解函数调用和指针操作背后的原理。


上一节我们介绍了函数调用的基本概念,本节中我们来看看在硬件内存层面,一个交换函数是如何具体执行的。

首先,我们需要了解程序在内存中的布局。内存被划分为不同区域,各有其用途。在本例中,由于我们的函数未使用堆或静态数据区,我们将重点关注代码区

我们首先想象一下代码在内存中的样子。为了简化,我们假设每条指令恰好占用4个字节。因此,你会看到每个指令块在内存中是连续存放的。虽然这取决于具体的运行机器,但此图具有代表性。

请注意,由于内存地址向上增长,main函数中的第一条指令(将变量A设为3)实际上位于内存底部。随着程序执行,你总是向更高的内存地址移动。

在内存顶部是我们的,用于存储程序所需的所有局部变量。接下来我们展示栈的可能结构。

在之前的概念图中,我们总是将栈帧画成独立的、带有函数名称标签的方框。但在这种更贴近硬件视角的内存视图中,栈将显示为一段连续的内存空间,从一个栈帧直接过渡到下一个栈帧。

我将使用两种颜色来高亮不同函数的栈帧:main函数的栈帧用蓝色表示,swap函数的栈帧用紫色表示。

本视频中栈帧的构建方式与之前概念视频中的展示略有不同。我们将把参数放在调用函数的栈帧中,而不是被调用函数的栈帧里,这更贴近硬件实际发生的情况。

现在我们可以开始执行程序了。首先执行第一条指令,将变量A的值设为3。

A = 3;

下一条指令将变量B的值设为4。

B = 4;

接下来我们要调用swap函数。为此,需要准备它的两个参数。

以下是准备参数并调用函数的步骤:

  1. 准备第一个参数:第一个参数是变量A的地址。我们在内存中查找A的地址,假设是0xFFFC。我们将这个值存储在栈上,作为swap函数参数x的位置。
  2. 准备第二个参数:第二个参数是变量B的地址。我们在内存中查找B的地址,假设是0xFFF8。我们将这个值存储在栈上对应的位置。

现在,我们几乎准备好调用swap函数了。在之前的视频中,我们常用带数字的小圆圈标记调用点。现在,随着我们对指针和代码在内存中的位置有了更深的理解,我们将更明确地展示其工作原理。

事实上,我们栈帧的最后一个位置将专门用于存储所谓的返回地址。返回地址告诉我们,当swap函数执行完毕后,接下来应该执行哪条指令。

在本例中,接下来应该执行的是调用printf的那行代码,它位于内存地址0x4008。因此,我们将地址0x4008存储为返回地址。这将告诉我们完成swap函数后下一步该做什么。

现在,我们可以进入并执行swap函数。请注意,swap函数现在拥有自己的栈帧(此处显示为紫色方框),其中只包含一个变量temp

swap函数的第一行代码是取出x所指向的值,并将其存储在变量temp中。

int temp = *x;

如果我们查看栈上的x,会发现它的值是0xFFFC,它指向内存中值为3的变量A。因此,我们将取出3,并将其存入temp变量。

下一条指令稍微复杂一些,我们需要同时确定这个赋值语句的左值右值

*x = *y;
  • 左值:是x所指向的东西。我们再次查看x,其地址为0xFFFC。它指向地址为0xFFFC的方框,当我们执行赋值语句时,这个方框的值将被改变。
  • 右值:是y所指向的东西。y的值是0xFFF8,它指向一个值为4的方框。因此,我们要做的是取出值4,并将其存储到地址为0xFFFC的方框(即变量A)中。

现在,最后一行代码将为我们完成交换函数。

*y = temp;
  • 左值:是y所指向的东西。查看y,其值为0xFFF8,因此我们将改变地址为0xFFF8的方框(即变量B)的值。
  • 右值:是temp的值,即3

因此,我们将3存入地址为0xFFF8的方框。至此,我们完成了整个swap函数的执行。

此时,我们不再需要swap函数的栈帧。因此,现在我们只有一个栈帧,即位于栈底的、蓝色的main函数栈帧。在这个栈帧的底部,有一个指针(返回地址)告诉我们从swap函数返回后应该执行哪一行代码。

我们将严格按照这个指示执行:将执行箭头移动到内存地址为0x4008的代码处。现在,我们将执行main函数剩余的代码。


本节课中我们一起学习了交换函数在硬件内存层面的执行过程。我们追踪了代码在内存中的执行路径,观察了栈帧的构建与销毁,以及参数传递和返回地址的工作原理。通过将抽象的指针操作与具体的内存地址和值的变化对应起来,我们深化了对C语言函数调用机制的理解。

058:指针算术的数组访问 📚

在本节课程中,我们将学习一个具体的函数示例。该函数用于计算数组中所有元素的总和,并且它通过指针操作来访问这些数组元素。我们将一步步地分析代码的执行过程,理解指针如何遍历数组。


概述

我们将分析一个名为 sum_array 的函数。该函数接收一个指向整数数组的指针和数组的大小作为参数,然后通过循环和指针算术来累加数组中的所有元素,并返回总和。


代码执行流程分析

上一节我们介绍了指针的基本概念,本节中我们来看看如何将指针应用于数组遍历。

1. 主函数中的数组声明与初始化

首先,在 main 函数中,我们声明并初始化了一个包含四个整数的数组。

int data[4] = {4, 6, 8, 3};

在内存中,main 函数的栈帧会为这个数组分配四个连续的“盒子”,分别存储数值 4、6、8 和 3。

2. 调用求和函数

接着,我们调用 sum_array 函数,传入指向数组的指针 data 和数组的大小 4

int result = sum_array(data, 4);

3. 求和函数的内部操作

进入 sum_array 函数后,我们首先初始化两个局部变量。

int answer = 0;
int *pointer = arr; // arr 是传入的数组参数

此时,我们有了三个指向数组起始位置的“箭头”:data(在 main 中)、arr(函数参数)和 pointer(局部指针变量)。

4. 使用指针遍历数组的循环

以下是循环遍历数组并求和的核心逻辑。

for (int i = 0; i < n; i++) {
    answer += *pointer; // 解引用指针,获取当前值
    pointer++;          // 指针算术:移动到下一个元素
}

让我们逐步分析循环的每一次迭代:

  • 第一次迭代 (i = 0):

    • *pointer 指向数组的第一个元素 4
    • answer 变为 0 + 4 = 4
    • pointer++ 执行后,pointer 现在指向第二个元素 6
  • 第二次迭代 (i = 1):

    • *pointer 现在指向 6
    • answer 变为 4 + 6 = 10
    • pointer++ 执行后,pointer 指向第三个元素 8
  • 第三次迭代 (i = 2):

    • *pointer 现在指向 8
    • answer 变为 10 + 8 = 18
    • pointer++ 执行后,pointer 指向第四个元素 3
  • 第四次迭代 (i = 3):

    • *pointer 现在指向 3
    • answer 变为 18 + 3 = 21
    • pointer++ 执行后,pointer 指向数组边界之后的内存地址。

5. 关于指针越界的重要说明

循环结束后,pointer 指向了数组最后一个元素之后的位置。这是一个需要特别注意的关键点。

  • 指针可以指向数组之后:仅仅让指针指向数组边界之外的内存地址本身不是错误。
  • 危险操作是解引用:错误(或未定义行为)发生在你尝试解引用这个越界的指针时(例如 *pointer)。因为你无法预知它指向的是什么数据(可能是其他变量、返回地址或无效内存)。

在本例中,循环结束后我们立即返回 answer 的值,并未再次解引用 pointer,因此程序是安全的。随后,sum_array 函数的栈帧被销毁,局部变量 pointeranswer 也随之消失。

6. 返回结果

函数将计算出的总和 21 返回给 main 函数。

return answer;

main 函数接收到结果后将其打印输出,程序正常结束。


总结

本节课中我们一起学习了如何利用指针算术来遍历数组。我们通过一个求和函数的实例,详细分析了指针在循环中如何逐个访问数组元素,并重点讨论了指针越界的概念及其潜在风险。关键要点是:移动指针使其超出数组范围是允许的,但在进行此操作后解引用该指针则是危险的。 掌握这一点对于安全地使用指针至关重要。

059:指针索引的数组访问 📚

在本节课中,我们将学习如何使用数组索引来访问数组元素,并实现一个计算数组元素和的函数。我们将通过一个具体的例子,对比之前使用指针算术的方法,展示如何使用更直观的索引方式来完成相同的任务。

概述

在上一节中,我们介绍了如何使用指针算术来遍历数组并求和。本节中,我们来看看另一种实现方式:使用数组索引。这种方法通常对初学者更为友好,因为它直接使用了数组下标来访问元素。

函数实现与流程

以下是使用数组索引实现求和功能的步骤。我们将声明一个数组,调用求和函数,并在函数内部使用循环和索引来累加每个元素的值。

  1. 首先,我们声明并初始化一个数组 data
  2. 接着,我们调用 sumArray 函数,传入数组名 data(它指向数组的第一个元素)以及数组的元素数量 4
  3. sumArray 函数内部,我们声明并初始化变量 answer0
  4. 然后,我们开始一个 for 循环。循环变量 i0 开始。
  5. 在循环体内,我们执行 answer += array[i]。当 i0 时,array[0] 是数组的第一个元素 4,因此 answer 变为 4
  6. 循环继续,i 递增为 1。此时 array[1] 是第二个元素 6answer 更新为 4 + 6 = 10
  7. 下一轮循环,i2array[2] 是第三个元素 8answer 更新为 10 + 8 = 18
  8. 最后一轮循环,i3array[3] 是第四个元素 3answer 更新为 18 + 3 = 21
  9. 循环结束后,i 变为 4,不满足循环条件,循环终止。
  10. 函数返回 answer 的值 21main 函数,随后 sumArray 函数的栈帧被销毁,其内部的参数和局部变量随之释放。
  11. main 函数中,我们打印出结果 21,然后程序返回。

核心代码示例

以下是上述流程的核心代码片段,展示了如何使用索引进行数组求和:

int sumArray(int array[], int n) {
    int answer = 0;
    for (int i = 0; i < n; i++) {
        answer += array[i]; // 使用数组索引 array[i] 访问元素
    }
    return answer;
}

在这段代码中,array[i] 是使用索引访问数组元素的语法。循环变量 i 作为下标,依次访问数组中的每个元素。

总结

本节课中,我们一起学习了如何使用数组索引来访问和操作数组元素,并实现了一个求和函数。与指针算术相比,索引方式更贴近我们对数组的直观理解,即通过位置(下标)来获取值。掌握这两种方法对于深入理解C语言中数组和指针的关系至关重要。

060:寻找数组最大元素的索引 🔍

在本节课中,我们将学习如何在一个数组中寻找最大元素的索引。我们将从一个具体例子出发,逐步推导出通用的算法,并最终将其转化为C语言代码。


概述

寻找数组最大元素的索引是一个常见的编程问题。我们将通过一个手动解决问题的实例,来理解其背后的逻辑,然后将这个逻辑步骤化、通用化,最后编写成代码。


手动解决问题实例

首先,我们来看一个具体的数组例子。

观察这个数组,我们发现最大的元素是99,它位于索引2的位置。

如果直接看答案,我们可能无法理解找到答案的过程。试想一下,如果数组有一百万个元素,我们无法全部画出来,只能看到前几个。那么,我们该如何找到答案呢?


逐步推导算法

以下是寻找最大元素索引的通用步骤。

我们可能会从第一个元素开始看,它是16。然后看下一个元素12,16比12大,所以我们继续。接着看元素43,并将其与16比较。43比16大,因此我们需要将43与后面的值继续比较,直到找到更大的。

我们可以持续这个过程:将每个元素与目前见过的最大元素进行比较。当我们找到更大的元素时,就更新关于“最大元素”的信息。当到达数组末尾时,无论数组多大,我们都能找到最大元素。

对于庞大的数组,我们不想写下所有步骤。因此,让我们回到小例子,应用这个逐步比较的方法。

我们需要记录两个信息:目前找到的最大元素的索引,以及当前正在检查的数组位置。然后,我们逐个元素进行比较,并在遍历过程中更新“目前最大元素”的信息。


记录具体步骤

现在,让我们进入第二步,精确写下我们刚才所做的。

  1. 我们首先假设索引0可能是最大元素。
  2. 然后,我们从索引1开始进行逐个元素的比较。
  3. 我们将24与17比较,发现24更大。于是,我们将“目前最大元素”的猜测更新为位于索引1的24。
  4. 接着,我们将99与24比较。99比24大,所以我们再次更新目前找到的最大元素。
  5. 然后,我们将3与99比较。3比99小,因此无需更新最大元素。
  6. 数组中没有更多元素了。于是,我们给出2作为最终答案。

将步骤通用化

接下来,我们需要将这些步骤通用化。

  • 为什么从索引0开始?无论使用什么数组,我们总是从索引0开始。然而,我们应该考虑数组可能为空的情况。我们将这个初始猜测称为 largest_index
  • 为什么这些数字是24、99和3?这些是数组中第1、2、3个位置的元素。我们可以通过用它们在数组中的位置来替换这些具体数字,从而使算法更通用。
  • 为什么我们将数组中的数字分别与17、24和99比较?这些数字也来自数组中索引0、1、2处的数据。因此,我们可以这样使算法更通用。

但在这样做时,我们引入了另一组需要思考的具体数字:为什么这些索引是0、1、2?我们的第一反应可能是我们在计数,但我们真的在计数吗?

为了理解原因,让我们回到更复杂的大例子。在这个例子中:

  1. 我们首先比较了元素1和元素0。
  2. 然后比较了元素2和元素0。这里我们发现元素2更大,于是更新了 largest_index
  3. 接着,我们比较了元素3和元素2。比较中的第二个项的索引并不是在简单计数,因为我们用了0,然后又用了0,接着用了2。
  4. 然后我们比较了元素4与元素2,接着元素5与元素2,此时我们再次更新了 largest_index
  5. 现在我们比较元素6、7、8与元素5,然后再次在元素8处找到了新的更大项。

此时,我已经弄清楚了模式。思考一下,看看你是否明白我们在做什么。

这个模式是:我们总是将当前元素与 array[largest_index] 进行比较。因此,我们可以更新我们的算法以反映这种理解。

这些步骤看起来相当重复。唯一不完全重复的是,有时我们更新 largest_index,有时则不更新。我们需要弄清楚在什么条件下更新 largest_index。当当前元素大于我们目前见过的最大元素时,我们才这样做。

考虑到这一点,我们可以使这些步骤完全重复,这样我们就可以用计数来表达这种重复。

我们不会总是给出答案2,所以我们应该思考一下这个值从哪里来。我们意识到,在我们的例子中,它就是 largest_index 的值。

现在,我们的算法说在一般情况下给出 largest_index 作为答案。


处理边界情况

这个算法已经相当通用,但我们可能需要考虑一个边界情况:如果数组中没有元素怎么办?我们需要处理这种情况,并返回一个表示“无有效答案”的值。我们将选择-1作为答案,因为它不是数组中的有效索引。


测试算法

接下来,我们需要测试这个算法。我们把这留给你。在继续第五步之前,尝试在一些输入上测试它,并说服自己它是正确的。


转化为C语言代码

在这里,我们声明了函数 find_largest_index,并将我们的算法作为注释写在了代码内部。

第一行描述了两个步骤,但现在应该很熟悉了:一个 if 语句,里面包含一个 return 语句。

接下来,我们声明并初始化 largest_index

然后,我们有一个用于计数的 for 循环。

以及一个 if 语句来比较数组元素。如果条件为真,我们将 largest_index 更新为 i

最后,我们返回 largest_index,任务完成。

int find_largest_index(int array[], int len) {
    // 处理空数组的情况
    if (len == 0) {
        return -1;
    }

    // 步骤1:假设第一个元素是最大的
    int largest_index = 0;

    // 步骤2:从第二个元素开始遍历数组
    for (int i = 1; i < len; i++) {
        // 步骤3:比较当前元素与目前最大元素
        if (array[i] > array[largest_index]) {
            // 如果当前元素更大,更新最大元素的索引
            largest_index = i;
        }
    }

    // 步骤4:返回最大元素的索引
    return largest_index;
}

总结

本节课中,我们一起学习了如何寻找数组中最大元素的索引。我们从手动分析一个具体例子开始,逐步推导出通用的算法逻辑,考虑了边界情况(如空数组),并最终将完整的算法实现为C语言函数。关键点在于初始化一个“当前最大索引”,然后遍历数组,通过比较和更新来找到最终答案。

061:最近点算法逐步分析 🧮

在本节中,我们将通过一个具体的例子,逐步分析“寻找最近点”算法的代码执行过程,以验证其正确性。


在上一节中,我们将寻找最近点的算法翻译成了C语言代码。本节中,我们将通过一个具体的例子,逐步执行这段代码,以确保它能正常工作。

我们假设在调用此函数之前,内存中已存在一个点的数组。为了测试方便,我们使用一个包含四个点的子集作为示例。每个点都是一个包含 xy 值的结构体。同时,我们假设调用函数时传入了指向这个点数组的指针、数组的大小以及一个目标点 P

因此,我们有以下初始状态:

  • S:指向点数组起始地址的指针。
  • N:值为 4
  • P:值为 (1, -1)

现在,执行指针位于 closest_point 函数的开始,我们可以开始逐步分析了。

初始化阶段

首先,N 的值为 4,因此不会执行 if (N <= 0) return NULL; 语句。

接下来,我们将调用 compute_distance 函数,计算数组 S 中第 0 个点与目标点 P 之间的距离,并将结果存储在一个名为 best_distance 的新变量中。在这个例子中,计算出的距离是 8.06

同时,我们需要记录下这个距离对应的“最佳选择”,即数组的第 0 个元素。因此,best_choice 被初始化为指向 S[0] 的指针。

循环遍历与比较

现在,我们将进入 for 循环,继续检查集合 S 中的其余点,看看是否能找到比数组中第 0 个点更近的点。

以下是循环的每一步:

  1. 第一次迭代 (i = 1)
    进入 for 循环,i 的值为 1。现在计算 S[1] 与点 P 之间的距离,并将结果存储在本次迭代的 current_distance 变量中。计算出的 current_distance7.07
    接着,我们测试当前的 current_distance (7.07) 是否小于当前的 best_distance (8.06)。条件成立,因此我们进入 if 语句块。
    if 块中,我们更新 best_choice 指针,使其从指向 S[0] 改为指向 S[1]。同时,将 best_distance8.06 更新为 7.07
    到达 for 循环末尾,进行迭代,i 的值变为 2

  2. 第二次迭代 (i = 2)
    现在 i 的值为 2。计算 S[2] 与点 P 之间的距离,结果是 7.81
    再次测试 current_distance 是否小于 best_distance。此时,7.81 不小于 7.07,因此我们跳过 if 语句块。
    进行下一次迭代,i 的值变为 3

  3. 第三次迭代 (i = 3)
    在最后一次计算中,我们计算 S[3] 与点 P 之间的距离,结果是 5.66
    比较 current_distancebest_distance5.66 更小,因此我们进入 if 语句块。
    if 块中,将 best_choice 更新为指向 S[3],并将 best_distance 更新为 5.66
    然后,i 自增为 4,这结束了我们的 for 循环。

返回结果

当函数执行完毕并返回时,我们将返回 best_choice,它是指向离点 P 最近的那个点的指针,也就是在集合 S 中索引为 3 的点。

我们假设将结果返回给了调用函数。


总结与思考

请注意我们的代码是多么清晰,这得益于我们能够使用数组来表示集合 S。试想一下,如果我们不得不为集合 S 中的每个点都使用一个单独的变量名,代码很快就会变得非常混乱。想象一下,如果集合 S 中有 1000 个点会怎样。

值得庆幸的是,我们拥有数组,并且它们能与 for 循环完美配合。在 for 循环中,每次迭代对应一个整数 i,而这个整数可以用来索引我们当前正在处理的数组中的每个元素。这种结合使得处理数据集合变得既简洁又高效。

本节课中,我们一起通过逐步分析,验证了“寻找最近点”算法的代码逻辑。我们看到了如何使用数组和循环来高效地遍历和比较数据,这是编程中处理集合类问题的核心模式。

062:悬空指针详解 🧠

在本节课中,我们将学习悬空指针的概念。悬空指针是指向已释放内存的指针,使用它会导致未定义行为。我们将通过一段有问题的代码来演示悬空指针是如何产生的,并解释为什么这样的代码是危险的。

代码执行过程分析

上一节我们介绍了指针的基本概念,本节中我们来看看一段会产生悬空指针的代码是如何执行的。

主函数首先声明一个整型指针 P,并用函数 init_array 的返回值初始化它。

int* P = init_array(2);

我们为 P 创建一个存储空间,并为 init_array 函数创建一个栈帧,传入参数 2,然后将执行箭头移入函数内部。

接下来,在 init_array 函数中声明一个大小为 2 的整型数组。

int my_array[2];

我们在该栈帧中为两个整数分配空间。请注意,图中的方框大小不代表实际内存占用比例。参数 how_large 和数组 my_array 的每个元素占用的内存量相同,每个方框占用四个字节(假设 int 类型大小为 4 字节)。

然后进入 for 循环。

以下是循环的执行步骤:

  1. i 的值为 0,将数组的第一个元素初始化为 0。
  2. i 的值为 1,将数组的第二个元素初始化为 1。

循环结束后,数组完成初始化。接着,执行到 return my_array; 这一行。

如果你写了这段有问题的代码,你可能希望它将数组复制回主函数的栈帧。然而,由于多种原因,这无法实现。在主函数中,我们只申请了存储一个整型指针的空间,而不是一个任意大小的数组。如果需要在主函数栈帧中有一个数组,我们必须在那里声明一个数组并指定其大小。

在本专业课程后续内容中,我们将学习如何在栈外分配内存,使数据在函数调用后依然存在。但目前我们尚未学习这种方法。

因此,当我们返回 my_array 时,表达式 my_array 的值是一个指向数组首元素的指针。所以,返回到调用位置(位置 1)的返回值仅仅是一个指向该内存地址的指针。

悬空指针的产生

返回时,P 将被赋予这个指针值。然而,在从函数返回的过程中,init_array 函数的栈帧会消失。现在,这个指针指向一个已不存在的内存区域。

我们仍然将这个值赋给 PP 指向内存中那个位置上的任意内容。但那里已经没有与之关联的变量了。如果该内存位置的内容未被改变,它可能仍然保存着值 0。但由于该内存位置可能被其他数据重用,其值可能意外改变。

此时,P 就成为了一个悬空指针。请注意图中它指向了“虚无”。解引用 P 因此是错误的操作。

解引用悬空指针的后果

如果我们进入接下来的 for 循环,并尝试打印 p[0],它将输出 P 所指向内存位置的值。这个值可能仍然是 0,也可能不是。我们无法确定会得到什么结果。

在我的计算机上运行此代码,第一次迭代得到 0,第二次迭代也得到 0。第一次迭代很可能读取了 my_array 残留的 0。但当调用 printf 函数时,printf 的栈帧会覆盖这片内存区域。因此,它会破坏之前存储在该位置的值 1,而第二次迭代将读取来自前一次 printf 调用后残留在其栈帧中的某个值。

无论发生什么,这段代码都是错误的,我们不应该编写这样的代码。

总结

本节课中我们一起学习了悬空指针。我们通过分析一段代码,看到了悬空指针是如何在函数返回后指向已释放栈内存而产生的。使用悬空指针会导致程序读取到不可预测的数据,这是一种未定义行为,必须避免。记住,永远不要返回指向局部变量的指针。

063:字符串比较函数编写指南 🔤

在本节课中,我们将学习如何编写一个函数来比较两个字符串,判断它们的内容是否完全相同。我们将遵循解决问题的七个步骤,从手动分析一个具体例子开始,逐步抽象出通用算法,并最终将其转化为C语言代码。


步骤一:手动分析具体实例

我们首先手动分析一个具体例子,以理解字符串比较的过程。这里有两个字符串,我们明确地表示它们,包括每个字符串末尾的空终止符 \0

我们将从第一个字符串的第一个字母和第二个字符串的第一个字母开始比较。

这两个字母都是大写字母 A,因此我们继续查看下一个字母。接下来的字母都是 P,再下一个都是 L,再下一个都是 E。然而,现在一个字符串遇到了空终止符 \0,而另一个字符串是字母 S。这两个字符串不相同,因此我们的答案是“否”。


步骤二:抽象出通用步骤

现在,我们来总结刚才手动比较的步骤,并将其抽象为适用于任何两个字符串的通用算法。

以下是我们在手动比较中执行的所有步骤:

  1. 我们创建了一个箭头来跟踪在字符串1中的位置,它指向字符串1的第一个字母。我们将其命名为 P1
  2. 我们创建了另一个箭头来跟踪在字符串2中的位置,我们将其命名为 P2
  3. 我们检查 P1 指向的字母是否与 P2 指向的字母匹配。两者都是 A,因此我们将 P1P2 都向前移动到下一个字母。
  4. 我们再次检查 P1P2 指向的字母。两者都是 P,因此我们再次将 P1P2 向前移动。
  5. 重复此过程,直到我们发现 P1 指向空终止符 \0,而 P2 指向字母 S。此时,我们判定字符串不相同。

前两步是初始化步骤,无论比较哪两个字符串,我们总是需要设置 P1P2 指向各自字符串的第一个字母。

接下来的步骤具有明显的重复性:我们不断检查两个指针指向的字母是否相同,并同时向前移动两个指针。

我们何时停止重复这些步骤?当 P1P2 指向的字母不同时。

基于以上观察,我们可以将步骤重写得更通用。我们将这些重复的步骤表达为一个循环,只要 P1P2 指向的字母相同,就继续执行。

然而,这个算法存在一个重要问题:它总是返回“否”,永远不会返回“是”。这是因为我们只分析了一个结果为“否”的例子,尚未考虑如何得到“是”的结果。此外,我们从未显式检查字符串是否结束。


步骤三:完善算法以处理所有情况

为了解决上述问题,我们需要回到步骤一,分析一个结果为“是”的例子,例如两个字符串都是 "apple"

在这个例子中,我们会发现,当遍历到字符串末尾且未发现任何不匹配的字符时,即可判定它们相同。

我们如何知道已经到达字符串的末尾?当我们遇到空终止符 \0 时,就表示到达了字符串的末尾。

我们可以基于这个观察来调整算法。如果 P1 指向 \0,那么我们就到达了字符串的末尾。由于我们刚刚检查过 P1P2 指向的字母相同,所以如果 P1 指向 \0,那么 P2 此时也必然指向 \0。这样,我们的算法就显式地检查了字符串的结束条件。


步骤四:将算法转化为C语言代码

现在,让我们将这个完善的算法翻译成C语言代码。

首先,我们从函数声明开始。这个函数名为 string_equal,它接受两个 const char* 类型的参数。使用 const 是为了承诺该函数不会修改传入的字符串。函数返回一个 int 类型,0 表示“否”,1 表示“是”。

int string_equal(const char* string1, const char* string2) {
    // 函数体
}

接下来,我们实现算法步骤:

  1. 初始化指针:创建指针 P1 指向 string1 的第一个字符,指针 P2 指向 string2 的第一个字符。

    const char* p1 = string1;
    const char* p2 = string2;
    
  2. 循环比较:使用 while 循环,只要 p1p2 指向的字符相同,就继续循环。

    while (*p1 == *p2) {
        // 循环体
    }
    
  3. 检查结束条件:在循环体内,检查 p1 是否指向空终止符 \0。如果是,说明两个字符串完全相同,返回 1

    if (*p1 == '\0') {
        return 1;
    }
    
  4. 移动指针:如果未到达末尾,则将 p1p2 各自向前移动一个字符。

    p1++;
    p2++;
    
  5. 处理不匹配情况:如果 while 循环结束(即发现了不匹配的字符),则返回 0

    return 0;
    

将以上部分组合起来,完整的函数代码如下:

int string_equal(const char* string1, const char* string2) {
    const char* p1 = string1;
    const char* p2 = string2;

    while (*p1 == *p2) {
        if (*p1 == '\0') {
            return 1;
        }
        p1++;
        p2++;
    }
    return 0;
}

总结

本节课中,我们一起学习了如何编写一个字符串比较函数。我们从手动分析具体例子出发,识别出核心的重复步骤,并将其抽象为通用算法。随后,我们发现了初始算法的缺陷,并通过分析另一个例子来完善它,使其能正确处理字符串相等的情况。最后,我们将完善的算法一步步翻译成了清晰、可运行的C语言代码。这个过程展示了从具体问题分析到通用代码实现的标准路径。编写完成后,务必进行充分的测试,以确保代码在各种情况下都能正确工作。

064:字符串复制 📝

在本节课中,我们将学习如何编写一个安全的字符串复制函数。这个函数会将一个字符串从源位置复制到目标位置。为了避免写入超出目标数组的边界,我们还需要指定目标数组的可用空间大小,并在空间不足时停止写入。

算法推导与示例

为了理解复制过程,我们首先通过一个具体例子来手动模拟。假设我们要将字符串 "cat" 复制到一个未初始化的、大小为4的字符数组中。

复制过程是逐个字符进行的:

  1. 将字符 'C' 写入目标数组的第一个位置。
  2. 将字符 'A' 写入目标数组的第二个位置。
  3. 将字符 'T' 写入目标数组的第三个位置。
  4. 将空终止符 '\0' 写入目标数组的第四个位置。

使用指针描述过程

我们可以使用两个指针来更精确地描述这个过程:

  • P1:指向目标数组(dest)中当前待写入的位置。
  • P2:指向源字符串(source)中当前待读取的位置。

以下是使用指针描述的详细步骤:

  1. 创建指针 P1,使其指向 dest 的第一个字符。
  2. 创建指针 P2,使其指向 source 的第一个字符。
  3. P2 指向的字符('C')写入 P1 指向的位置。
  4. P1 向前移动,指向下一个位置。
  5. P2 向前移动,指向下一个字符。
  6. P2 指向的字符('A')写入 P1 指向的位置。
  7. P1 向前移动。
  8. P2 向前移动。
  9. P2 指向的字符('T')写入 P1 指向的位置。
  10. P1 向前移动。
  11. P2 向前移动。
  12. 将空终止符('\0')写入 P1 指向的位置。

归纳通用算法

观察上述步骤,我们可以发现一个清晰的模式。前两步是初始化,后面的步骤则高度重复。

初始化步骤

这两步总是发生:

  1. 设置 P1 指向 dest 的起始位置。
  2. 设置 P2 指向 source 的起始位置。

循环复制步骤

从第3步到第11步,我们重复执行了三次几乎相同的操作。每次操作包含三个子步骤:

  1. 写入:将 P2 指向的字符写入 P1 指向的位置。
  2. 移动P1:将 P1 向前移动一个位置。
  3. 移动P2:将 P2 向前移动一个位置。

唯一的区别是每次写入的字符不同,而这个字符正是由 P2 指向的。因此,我们可以用一句通用描述来概括:“将 P2 指向的字符写入 P1 指向的位置”。

循环终止条件

我们需要确定何时停止这个循环。回顾例子,循环在 P2 指向空终止符('\0')时停止。因此,循环的条件是:只要 P2 指向的字符不是空终止符,就继续执行

收尾步骤

循环结束后,我们需要执行最后一步:

  • 将空终止符('\0')写入 P1 当前指向的位置。

至此,我们得到了一个初步的通用算法。

算法测试与边界问题

现在,让我们用一个新的测试用例来检验这个算法。假设源字符串仍是 "cat",但目标数组 dest 的大小 n 只有2(即只能容纳2个字符)。

按照算法执行:

  1. P1 指向 dest[0]P2 指向 'C'
  2. P2 指向 'C',不是 '\0',进入循环。
    • 写入 'C'dest[0]
    • P1 移动到 dest[1]
    • P2 移动到 'A'
  3. P2 指向 'A',不是 '\0',继续循环。
    • 写入 'A'dest[1]
    • P1 移动到 dest[2]已超出数组边界!)。
    • P2 移动到 'T'
  4. P2 指向 'T',不是 '\0',继续循环。
    • 尝试将 'T' 写入 P1 指向的位置(即 dest[2])。这是一个严重错误,因为我们正在写入不属于 dest 数组的内存空间,可能导致程序崩溃(如段错误)或产生不可预测的行为。

测试表明,我们的初步算法存在安全隐患:它没有检查目标数组的剩余空间,可能造成缓冲区溢出

算法改进:引入边界检查

为了解决这个问题,我们需要修改算法,在复制过程中始终检查是否已到达目标数组的末尾。

我们需要引入第三个指针 stop

  • stop:指向目标数组 dest 末尾之后的一个位置。具体来说,如果 destn 个空间,则 stop = dest + n

改进后的算法逻辑如下:

  1. 设置 P1 指向 dest 的起始位置。
  2. 设置 P2 指向 source 的起始位置。
  3. 设置 stop 指向 dest + ndest 之后第 n 个位置)。
  4. 循环条件需要同时满足两个条件才执行复制:
    • P2 指向的字符不是空终止符(*P2 != '\0')。
    • P1 还没有到达 stop 指针(P1 != stop)。
  5. 在循环体内,执行写入和指针移动。
  6. 循环结束后,在写入最终的 '\0' 之前,必须再次检查 P1 是否已经到达 stop。只有 P1 != stop 时,才能安全地写入空终止符。

通过引入 stop 指针和额外的条件检查,我们确保了函数不会写入超出 dest 数组边界的内存,从而实现了安全的字符串复制。

总结

本节课中我们一起学习了如何设计一个安全的字符串复制算法。我们从具体例子出发,归纳出通用的复制步骤,并通过测试发现了缓冲区溢出的风险。为了解决这个问题,我们改进了算法,引入了边界指针 stop 来确保写入操作始终在目标数组的合法空间内进行。在下一节课中,我们将把这个改进后的算法步骤翻译成具体的C语言代码。

065:不兼容的表示形式

在本节课中,我们将要学习多维数组与指针数组之间的区别,以及当错误地处理这两种表示形式时,程序会出现什么问题。我们将通过分析一段有问题的代码,来理解为什么不能简单地对编译器进行类型转换来“修复”错误。

上一节我们介绍了多维数组的基本概念,本节中我们来看看当错误地混合使用多维数组和指针数组时会发生什么。

不兼容的指针类型

你一直在学习多维数组,以及使用多组方括号声明数组与声明指针数组之间的区别。

有时,新的C语言程序员难以理解这些区别及其重要性。

这里我们将看到一些有问题的代码,这些代码突显了这些表示形式的不兼容性,以及当我们不假思索地通过欺骗编译器来“修复”代码时会出现什么问题。

如果我们尝试编译这段代码,会得到以下错误信息。让我们花点时间来解析一下。

错误信息告诉我们,在调用 print_array 函数时(我们在这里调用),传递的第一个参数类型不兼容。

具体来说,函数期望一个 int** 类型的参数(因为 array 是这么声明的),但我们实际传递的是一个 int (*)[3] 类型。

这是一个第二维大小为3的多维数组。

请记住,由于多维数组在内存中的布局方式,编译器在类型中不需要包含第一维的大小。

一个天真的“修复”尝试

一个初学C语言的程序员可能会尝试通过添加一个类型转换来修复这个代码。任何时候你想进行类型转换,都需要非常仔细地思考你在做什么。

进行了这个更改后,代码编译通过了。

那么,一切都没问题了吗?事实证明,在数据布局问题上欺骗编译器通常是个坏主意。

因此,当我们尝试运行这段代码时,并不会得到我们想要的结果。

让我们看看会发生什么。我们从 main 函数开始,创建 data 数组。

然后我们调用 print_array,传入 data、3 和 3。我们进入第一个 for 循环,然后进入第二个 for 循环,现在准备打印 array[i][j]

所以我们需要计算这个表达式。首先,我们计算 array,它是一个指向 data 第一个元素的指针。

那么 array[i] 是什么?我们知道 i 是 0,所以这就是 array[0],也就是这里的这个“盒子”。

但是我们需要一个指针,因为我们告诉编译器 array 指向一个 int*。然而,我们这里只有一个数字。

此外,我们可能在一个平台上,int* 的大小大于 int 的大小。例如,在你工作的系统上,指针可能是 8 字节,而整数是 4 字节。

在这种情况下,我们实际上需要读取这两个“盒子”,并将它们解释为一个指针。

当我们尝试这样做时会发生什么?记住,在计算机中,一切都是数字。

所以程序只会读取一个数值,并将其解释为一个指针。

深入内存布局

为了更精确地了解可能发生的情况,我们需要深入底层。在左侧,我们列出了内存地址及其存储的值。每一行对应 4 字节。

细蓝线将 main 函数的栈帧(在上方)与 print_array 函数的栈帧(在下方)分开。

顶部的这些值是 data 数组,它的每个元素在内存中是连续排列的。

返回地址存储在栈帧中。同样,尽管它不直接影响我们的例子。

array 存储在栈帧中。注意它的数值是 data 第一个元素的地址。同时,作为一个指针,它在这个平台上占用 8 字节。

然后我们有 widthheightij

但让我们回到 array。由于我们试图计算 array[0],而我们告诉编译器 array 指向一个 int 指针。所以 array[0] 就是这里的这 8 个字节。

也就是说,array[0] 的值就是你在这里看到的这个指针。

这个指针指向程序中的哪个位置?我们并不是通过获取某个东西的地址来得到它的。我们只是把一些数字拼在一起,然后把它们变成了一个指针。

无效的内存访问

请记住内存布局的一般图示。这里显示的特定地址范围取决于平台和程序,但这是在 64 位架构上运行此测试程序获得的。

如果你看这张图,我们刚刚为 array[0] 计算出的指针正好落在这里——栈和堆之间的空白区域——所以它指向一个无效的内存区域。

因此,如果我们回到代码图示,array[0] 指向了无效的内存。

那么当我们尝试计算 array[i][j] 时会发生什么?我们将尝试解引用这个指针,而它指向地址空间的一个无效部分。

当你尝试解引用一个指向无效内存区域的指针时,会发生什么?程序会以大家最“喜爱”的方式崩溃——段错误。

事实证明,欺骗编译器是一个非常糟糕的主意。我们的程序编译通过了,但在尝试运行时崩溃了。

总结

本节课中我们一起学习了多维数组与指针数组在内存表示上的根本区别。我们通过一个具体的例子,分析了为什么不能简单地将多维数组的指针强制转换为指向指针的指针(int**)。这种不兼容的表示形式会导致程序访问无效的内存地址,最终引发段错误。关键在于理解:多维数组在内存中是连续存储的,而指针数组则存储着一系列指向其他内存位置的指针。混淆二者,即使编译器可能通过类型转换暂时“放过”你,程序在运行时也必然会出现问题。

066:缓冲区溢出漏洞剖析

在本节课中,我们将深入剖析一个严重且常见的安全漏洞——缓冲区溢出。我们将通过一个具体的代码示例,了解其底层机制、潜在危害以及如何避免此类问题。

上一节我们介绍了程序内存管理的基本概念,本节中我们来看看当程序违反C语言规则时,具体会发生什么。

栈内存布局回顾

首先,我们需要快速回顾一下栈内存的布局。下图展示了程序执行时栈帧的典型结构:

栈帧的底层布局与具体平台高度相关,但此处展示的攻击原理可以适配大多数平台。图中横线将底部 main 函数的栈帧与顶部其调用者(C库函数)的栈帧分隔开来。

以下是 main 函数栈帧中的关键数据:

  • argv:一个8字节的指针,指向一个字符串数组(图中未画出)。
  • argc:一个4字节的整数,其值为1。
  • 返回地址:一个8字节的指针,指向C库代码中调用 main 函数之后的位置。main 函数执行完毕后,程序将跳转到这个地址继续执行,而C库代码通常会调用 exit 函数并传入 main 的返回值。

漏洞代码分析

现在,我们开始执行 main 函数。代码中声明了一个字符数组 input,它在栈上分配了接下来的12个字节空间。

char input[12];

随后,程序调用了 gets 函数。

gets(input);

程序员期望用户输入的字符数少于12个(例如“hello”),这样数据就能安全地写入 input 数组的前一部分。

缓冲区溢出原理

然而,gets 函数存在一个根本性缺陷:它无法知晓目标缓冲区 input 的实际大小。如果用户输入超过12个字符,gets 会毫无顾忌地继续向栈上的后续地址写入数据。

请注意,返回地址也存储在栈上。如果溢出的数据覆盖了这个返回地址,就会改变 main 函数返回后的程序执行流程,这显然非常危险。

恶意攻击场景

事实上,如果输入数据是由恶意黑客精心构造的,情况会变得尤为糟糕。攻击数据可能看起来像这样(不可打印字符用 \x 加十六进制值表示):

\x90\x90\x90...\x90\xc3\xf4\x7f\xff\xff\x01\x00\x00\x00...

不必担心这些数据的细节。它们通常是由攻击者编写并编译他们希望目标程序执行的代码,然后从生成的可执行文件中提取出对应的机器码字节序列。

以下是攻击发生时的具体过程:

  1. 数据写入:前12个字节被正常写入 input 数组。
  2. 溢出覆盖:由于输入超过12字节,后续的8个字节会覆盖掉返回地址。请注意,覆盖进去的新值看起来像一个指向栈内存的有效指针(实际上是 argc 的地址)。紧接着,输入数据会继续覆盖 argcargv 以及调用者栈帧中的数据。
  3. 输入结束gets 最终读到一个换行符(\x0a)和一个空终止符(\x00)后结束。
  4. 程序打印gets 返回后,main 函数打印 input 直到遇到空终止符,因此会打印出部分字母和不可见字符。
  5. 劫持控制流:当 main 函数准备返回时,问题出现了。通常程序会正常退出,因为返回地址指向C库的退出代码。但现在,返回地址已被我们覆盖。程序将跳转到被覆盖的地址(即 argc 的位置)继续执行。
  6. 执行恶意代码:该地址位于栈上,里面存放的正是黑客精心输入的恶意数据。因此,程序将开始执行这些数据对应的机器指令。在这个特定例子中,这些指令被设计用于启动一个命令行shell。

核心危害与教训

试想,如果这段存在漏洞的代码是某个网络服务的一部分,攻击者通过缓冲区溢出获得了该系统上的一个shell,他们就能执行任意命令,后果不堪设想。

这个故事的教训非常明确:

  • 永远不要使用 gets 函数。
  • 务必小心,绝不允许发生缓冲区溢出

本节课中,我们一起学习了缓冲区溢出漏洞的底层机制。我们看到了粗心使用 gets 这类不安全函数如何导致栈上的关键数据(尤其是返回地址)被覆盖,进而可能被攻击者利用来劫持程序控制流并执行恶意代码。理解这一原理是编写安全、健壮C程序的重要基础。

067:手动执行递归阶乘函数 🧮

在本节课中,我们将通过手动追踪一个递归函数——阶乘函数 factorial 的执行过程,来深入理解递归在C语言中是如何工作的。我们将一步步地模拟计算机执行代码的过程,观察函数调用、栈帧创建和返回值传递。


从主函数开始

程序从 main 函数开始执行。第一行代码声明了一个变量 x,并将其初始化为 factorial(3) 的返回值。

int x = factorial(3);

此时,为了计算 factorial(3),程序需要调用 factorial 函数。


第一次函数调用:factorial(3)

程序为 factorial 函数创建了一个栈帧,并将参数 n 的值设置为 3。我们将这个调用点标记为 调用点1,并将执行箭头移入 factorial 函数内部。

factorial 函数中,首先检查条件 n <= 0。由于 3 不大于 0,程序跳过了 if 语句,直接执行到 return 语句。

return n * factorial(n - 1);

这条 return 语句包含了一个对 factorial 函数的递归调用,这次传入的参数是 2


第二次函数调用:factorial(2)

程序再次进入 factorial 函数,此时 n 的值为 2。我们标记这个递归调用的返回位置为 调用点2

同样,2 不小于等于 0,因此程序跳过 if 语句,到达最后的 return 语句。这里又产生了一个递归调用 factorial(1),但返回地址仍然是 调用点2


第三次函数调用:factorial(1)

程序进入 factorial 函数,n1。条件 n <= 0 仍不成立,程序继续执行到最后一行代码,产生了递归调用 factorial(0)


第四次函数调用:factorial(0) 与基准情形

这次调用 factorial 函数时,参数 n0。条件 n <= 0 终于成立,函数执行 if 语句块中的代码:

if (n <= 0) {
    return 1;
}

函数返回值 1。这是递归的基准情形,它停止了进一步的递归调用。


回溯与计算返回值

现在,递归调用开始逐层返回,并完成乘法计算。

  1. 返回值 1 被传回给调用 factorial(0) 的那个函数实例,即 factorial(1) 中的调用点。
  2. factorial(1) 中,return 语句计算 1 * 1,得到结果 1。这个值被返回给调用它的 factorial(2)
  3. factorial(2) 中,计算 2 * 1,得到结果 2,并返回给 factorial(3)
  4. 最后,在最初的 factorial(3) 调用中,计算 3 * 2,得到最终结果 6

返回主函数

factorial(3) 的返回值 6 被送回到 main 函数中最初的调用点。赋值语句完成,变量 x 被赋值为 6

// 此时,x 的值变为 6
int x = 6;

总结

本节课中,我们一起手动追踪了递归阶乘函数的完整执行过程。我们看到了:

  • 递归函数如何通过基准情形终止。
  • 每次递归调用如何创建新的栈帧并暂停当前执行。
  • 返回值如何沿着调用链回溯,并与每一层的参数相乘,最终得到结果。

这个过程清晰地展示了递归“递去”和“归来”的两个阶段,是理解递归机制的核心。

068:递归实现阶乘

在本节课中,我们将学习如何通过七个步骤来推导出计算阶乘的递归算法。我们将从手动计算一个实例开始,逐步抽象出通用步骤,并最终形成一个完整的算法。


第一步:手动计算一个实例

首先,我们手动计算 4的阶乘

根据阶乘的定义,4的阶乘是:
4! = 4 × 3!

因此,我们需要先计算 3!
3! = 3 × 2!

接着计算 2!
2! = 2 × 1!

然后计算 1!
1! = 1 × 0!

最后,根据定义,0! = 1

现在,我们可以反向代入计算结果:
0! = 1
1! = 1 × 1 = 1
2! = 2 × 1 = 2
3! = 3 × 2 = 6
4! = 4 × 6 = 24

因此,4! 的最终结果是 24


第二步:记录操作序列

现在,我们来回顾并记录第一步中的操作序列。

以下是计算每个阶乘值时的具体步骤:

  • 计算 0!:直接得出结果 1
  • 计算 1!:先计算 0!,然后将结果乘以 1
  • 计算 2!:先计算 1!,然后将结果乘以 2
  • 计算 3!:先计算 2!,然后将结果乘以 3
  • 计算 4!:先计算 3!,然后将结果乘以 4

第三步:将过程泛化

观察第二步的记录,我们可以发现一个模式。计算 0! 是一个直接给出答案的特殊情况,这被称为 基准情形。而计算其他正整数的阶乘都遵循相同的模式。

我们可以将这个过程总结为以下通用步骤:

  1. 判断基准情形:如果 n 等于 0,那么答案就是 1
  2. 递归步骤:否则,要计算 n!,我们需要:
    • 先计算 (n-1)!
    • 然后将 (n-1)! 的结果乘以 n
    • 这个乘积就是 n! 的答案。

用伪代码可以表示为:

如果 n == 0:
    返回 1
否则:
    返回 n * (n-1的阶乘)

第四步:测试算法

在将算法转化为代码之前,必须用多种输入进行测试。

以下是几个测试用例:

  • 0!:算法直接返回 1,正确。
  • 1!:算法计算 1 * 0! = 1 * 1 = 1,正确。
  • 4!:如第一步所示,结果为 24,正确。

然而,当我们测试负整数时,会发现一个缺陷。例如计算 -2!

  • 因为 -2 != 0,算法会尝试计算 -3!
  • 计算 -3! 时,因为 -3 != 0,算法会尝试计算 -4!
  • 这个过程将无限进行下去,永远无法到达基准情形 n == 0,导致无限递归。

第五步:修正算法

测试暴露了算法的一个错误:它没有正确处理非正整数输入。阶乘通常只对非负整数有定义。

因此,我们需要修正基准情形的判断条件。不应只检查 n 是否等于 0,而应检查 n 是否 小于或等于 0。通常,我们将 0!1! 都作为基准情形处理,两者都等于 1

修正后的算法步骤为:

  1. 如果 n <= 1,返回 1
  2. 否则,返回 n * (n-1的阶乘)

总结

本节课中,我们一起学习了设计递归算法的七个步骤中的前五步,并以阶乘函数为例进行了实践。我们从一个具体实例出发,记录了操作序列,将其泛化为通用算法,并通过测试发现了算法在处理负数输入时的缺陷,随后对其进行了修正。最终,我们得到了一个健壮的、用于计算非负整数阶乘的递归算法描述。在下一节课中,我们将把这个算法翻译成C语言代码。

069:递归阶乘代码转换 🔄

在本节课中,我们将把上一节视频中设计的阶乘算法,转化为实际的C语言代码。我们将遵循算法步骤,逐步构建一个名为 factorial 的递归函数。


上一节我们介绍了阶乘的递归算法逻辑。本节中,我们来看看如何将这些逻辑步骤用C语言代码实现。

我们已将算法步骤以蓝色注释的形式包含在代码中。现在,我们将把这些步骤转化为C代码。

首先,我们将所有步骤放入一个名为 factorial 的函数中。这个函数接收一个整数 n 作为输入,并返回一个整数,即 n 的阶乘结果。

int factorial(int n) {
    // 算法步骤将在此实现
}


现在,我们需要判断 n 是否小于或等于0。我们将使用 if-else 语句来实现这个逻辑判断。

如果 n 小于或等于0,根据算法,我们应返回结果1。这可以通过一个 return 语句完成。

if (n <= 0) {
    return 1;
}

接下来,我们需要处理“否则”的情况,即 else 部分。这个情况更为复杂。

以下是处理“否则”情况所需的步骤:

  1. 我们需要创建一个新变量 n_minus_1_fact 来存储中间结果。
  2. 我们需要进行一次函数调用来完成复杂步骤,以确定应赋给 n_minus_1_fact 的值。

我们想要调用的函数恰好是我们正在编写的 factorial 函数本身。因此,我们用参数 n - 1 来调用 factorial 函数。

else {
    int n_minus_1_fact;
    n_minus_1_fact = factorial(n - 1); // 递归调用
}

现在,我们得到了 n_minus_1_fact 的值。根据算法,我们需要将其乘以 n。这个乘积就是我们想要返回的最终结果,同样通过 return 语句实现。

    return n * n_minus_1_fact;
}

至此,我们的 factorial 函数已经完成。完整的函数代码如下所示:

int factorial(int n) {
    if (n <= 0) {
        return 1;
    } else {
        int n_minus_1_fact;
        n_minus_1_fact = factorial(n - 1);
        return n * n_minus_1_fact;
    }
}

函数完成后,剩余的工作就是对其进行测试和调试,以确保其正确运行。


本节课中我们一起学习了如何将递归阶乘算法转换为C语言代码。我们定义了一个 factorial 函数,使用 if-else 语句处理基础情况和递归情况,并通过函数自身的递归调用来计算阶乘值。

070:递归实现斐波那契数列 🧮

在本节课中,我们将学习如何使用递归方法实现斐波那契数列的计算。我们将遵循编程过程的四个步骤,通过一个具体的例子来理解递归的工作原理。

概述

我们将以计算 Fibonacci(4) 为例,逐步展示递归算法的设计过程,包括理解问题、列出步骤、归纳模式以及测试算法。我们还将讨论算法在边界情况(如负数输入)下的处理。

第一步:通过例子理解问题

首先,我们通过计算 Fibonacci(4) 来理解斐波那契数列的定义。根据定义,Fibonacci(4) 等于 Fibonacci(3) 加上 Fibonacci(2)

为了计算 Fibonacci(3),我们需要知道 Fibonacci(2)Fibonacci(1) 的值。

Fibonacci(2) 又等于 Fibonacci(1) 加上 Fibonacci(0)。根据定义,Fibonacci(1) 的值为 1Fibonacci(0) 的值为 0

因此,Fibonacci(2) 等于 1 + 0 = 1

接下来,我们回到计算 Fibonacci(3) 的表达式。其值为 Fibonacci(2) 加上 Fibonacci(1),即 1 + 1 = 2

现在,我们回到最初的表达式计算 Fibonacci(4)。其值为 Fibonacci(3) 加上 Fibonacci(2),即 2 + 1 = 3

所以,Fibonacci(4) 的结果是 3

第二步:列出计算步骤

在理解了计算过程后,我们需要将计算 Fibonacci(4) 时所执行的所有步骤系统地记录下来。

以下是计算过程中执行的所有操作:

  1. 计算 Fibonacci(3)
  2. 为了计算 Fibonacci(3),需要先计算 Fibonacci(2)
  3. 为了计算 Fibonacci(2),需要计算 Fibonacci(1)Fibonacci(0)
  4. 回到计算 Fibonacci(3) 的表达式,计算 Fibonacci(1)
  5. 回到计算 Fibonacci(4) 的表达式,计算 Fibonacci(2)
  6. 为了计算这个 Fibonacci(2),再次计算 Fibonacci(1)Fibonacci(0)

观察这些步骤,我们可以总结出计算不同斐波那契数的方法:

  • 计算 Fibonacci(0):直接返回 0
  • 计算 Fibonacci(1):直接返回 1
  • 计算 Fibonacci(2):需要计算 Fibonacci(1)Fibonacci(0),然后将结果相加。
  • 计算 Fibonacci(3):需要计算 Fibonacci(2)Fibonacci(1),然后将结果相加。
  • 计算 Fibonacci(4):需要计算 Fibonacci(3)Fibonacci(2),然后将结果相加。

第三步:归纳通用模式

现在,我们需要从具体的步骤中寻找规律,归纳出适用于所有情况(特别是 n > 1 的情况)的通用算法。

首先,我们注意到计算 Fibonacci(4)Fibonacci(3)Fibonacci(2) 的步骤非常相似。而计算 Fibonacci(1)Fibonacci(0) 则不同,它们是直接知道答案的特殊情况,我们称之为 基准情形

因此,算法的第一部分是处理基准情形:

  • 如果 n == 0,则返回 0
  • 如果 n == 1,则返回 1

对于非基准情形(即 n > 1 的情况),我们观察其计算模式:

  1. 计算 Fibonacci(n-1)
  2. 计算 Fibonacci(n-2)
  3. 将上述两个结果相加。

用伪代码可以表示为:

if (n == 0) return 0;
if (n == 1) return 1;
// 否则 (n > 1)
fib_n_minus_1 = Fibonacci(n - 1);
fib_n_minus_2 = Fibonacci(n - 2);
return fib_n_minus_1 + fib_n_minus_2;

第四步:测试与完善算法

设计出初步算法后,必须进行测试。我们测试几个案例:

  • Fibonacci(6):算法可以正常递归计算。
  • Fibonacci(1):算法直接返回 1,正确。

然而,当我们测试 Fibonacci(-2) 时,会发现问题。根据我们当前的算法:

  • -2 既不等于 0 也不等于 1,所以会进入“否则”分支。
  • 这将尝试计算 Fibonacci(-3)Fibonacci(-4)
  • 而计算 Fibonacci(-3) 又会触发计算 Fibonacci(-4)Fibonacci(-5)
  • 这个过程将无限进行下去,无法终止,导致程序出错。

这个测试案例暴露了我们算法的一个缺陷:它没有正确处理 n < 0 的情况。回顾斐波那契数列的完整数学定义,它实际上包含多种情况:

  1. n == 0
  2. n == 1
  3. n > 1
  4. n < 0n 为奇数
  5. n < 0n 为偶数

我们的算法只处理了前三种情况。我们需要明确处理 n > 1 的条件,并增加对负数的处理逻辑。对于负数 n,一种常见的处理方式是:

  • 计算 Fibonacci(-n),记作 fib_neg_n
  • 如果 n 是奇数,则结果就是 fib_neg_n
  • 如果 n 是偶数,则结果是 -fib_neg_n

修正后的算法逻辑会更加健壮。我们需要用包含负数的测试案例来验证这个修订后的算法。

总结

本节课我们一起学习了如何用递归实现斐波那契数列。我们经历了从具体例子入手、列出步骤、归纳通用模式到测试完善的完整算法设计流程。关键点在于识别出 基准情形n=0n=1)以及 递归情形n>1 时,问题可分解为两个更小的同类问题)。同时,我们也认识到全面考虑输入范围(包括负数)对于编写健壮程序的重要性。递归的核心思想是将复杂问题分解为相似的、更简单的子问题,这种思想是算法设计中的重要工具。

071:递归斐波那契代码转换 📝

在本节课中,我们将学习如何将一个计算第n个斐波那契数的算法,逐步转换为清晰、高效的C语言代码。我们将重点关注递归函数的构建、条件判断的逻辑以及代码的简化过程。


算法到代码的转换

上一节我们介绍了计算斐波那契数的算法。本节中,我们来看看如何将这个算法具体实现为C语言函数。

首先,我们根据算法步骤编写了注释,并构建了函数的基本框架。

int fib(int n) {
    // 函数体将在这里实现
}

处理基础情况

以下是处理n等于0或1这两种基础情况的步骤。

首先,我们判断n是否等于1。这对应一个熟悉的if-else语句结构。

if (n == 1) {
    // 返回答案
    return 1;
} else {
    // 继续其他判断
}

else子句中,我们需要再次比较n与另一个值(例如0),并返回相应的答案。其结构与上面的if语句类似。

if (n == 0) {
    return 0;
}

处理递归情况

现在我们需要处理n大于1的情况。这需要另一个if语句。

if (n > 1) {
    // 这里是递归计算部分
}

在这个if语句的then子句中,情况变得有趣。我们需要计算fib(n-1)。这看起来是一个复杂的步骤。

我们之前处理复杂步骤的方法,是将它们抽象到另一个函数中。这个函数应该接收一个整数,计算其斐波那契数并返回。这听起来正是我们正在编写的fib函数本身。因此,我们不需要创建新函数,直接递归调用fib即可。

int fib_n_minus_1 = fib(n - 1);

算法的下一步非常相似。我们将再次调用fib函数来计算fib(n-2)

int fib_n_minus_2 = fib(n - 2);

接下来,算法要求我们将这两个变量相加,并将结果作为答案返回。

return fib_n_minus_1 + fib_n_minus_2;

处理负数输入

现在开始处理else子句,即n小于0的情况。我们同样使用一个if语句(为了简洁,这里直接写else部分)。

else { // n < 0
    // 处理负数
}

我们再次声明一个变量,并希望用计算fib(-n)这个复杂步骤的结果来初始化它。和之前一样,我们信任正在编写的fib函数能完成这项工作。

int fib_neg_n = fib(-n);

还差最后一步。我们需要另一个if-else语句。这里使用取模运算符%来判断一个数是偶数还是奇数。

记住,n % 2会得到n除以2的余数。结果为0意味着n是偶数,结果为1意味着n是奇数。

if (n % 2 == 0) {
    // n为偶数时的答案
    return fib_neg_n;
} else {
    // n为奇数时的答案
    return -1 * fib_neg_n;
}

代码清理与优化

我们已经移除了所有注释,只留下代码。现在可以清理一下,让它看起来更美观。

变量声明有些杂乱。我们在步骤中描述得很详细,为每个计算都单独命名了变量。但我们可以直接返回fib(n-1) + fib(n-2)

// 优化前
int fib_n_minus_1 = fib(n - 1);
int fib_n_minus_2 = fib(n - 2);
return fib_n_minus_1 + fib_n_minus_2;

// 优化后
return fib(n - 1) + fib(n - 2);

同样,对于负数情况,与其写-1 * fib(-n),不如直接写-fib(-n)

// 优化前
return -1 * fib_neg_n;
// 优化后
return -fib(-n);

我们的条件判断是完备的。我们已经检查了n等于1、0和大于1的情况。因此,程序执行到此处时,剩下的唯一可能性就是n小于0。我们可以也应该移除这个额外的if判断。

事实上,如果不移除,编译器可能会警告函数有可能在没有返回值的情况下结束。大多数编译器不够智能,无法推理数字的代数性质来判断一组条件是否覆盖了所有可能情况。然而,它们能理解代码总是会执行if-else语句的then子句或else子句。

我们还可以通过意识到当n为0或1时,返回值就是n本身,从而简化前几个步骤。这可以合并为一个在条件表达式中使用逻辑或||if语句。

// 简化基础情况
if (n == 0 || n == 1) {
    return n;
}

我们可以在此处添加注释,以帮助读者理解我们的思路。

// 基础情况:F(0)=0, F(1)=1
if (n == 0 || n == 1) {
    return n;
}

然后,代码就完成了。大部分清理工作是可选的,因为这两段代码在算法上是等价的。然而,优化后的代码比我们开始时看起来要简洁美观得多。


本节课中,我们一起学习了如何将斐波那契算法转换为C语言递归函数。我们经历了从详细步骤注释到具体代码实现,再到逻辑优化和代码简化的完整过程。关键点在于理解递归调用、处理好基础情况,并编写出清晰高效的代码。

072:斐波那契数列中的计算重复问题 🐌

在本节课中,我们将通过追踪一个递归函数的调用过程,来学习递归算法中一个常见的问题:重复计算。我们将以计算斐波那契数列为例,分析其递归实现为何效率低下,并探讨可能的解决方案。

递归调用过程追踪

上一节我们介绍了递归的基本概念,本节中我们来看看一个具体的递归函数是如何执行的。

斐波那契数列的递归定义如下:对于任意值 nFibonacci(n) 等于 Fibonacci(n-1)Fibonacci(n-2) 之和。其基础情况是 Fibonacci(0) = 0Fibonacci(1) = 1

当我们计算 Fibonacci(5) 时,程序会首先尝试计算 Fibonacci(4)Fibonacci(3)。而为了计算 Fibonacci(4),又需要计算 Fibonacci(3)Fibonacci(2)。这个过程会一直递归下去,直到遇到基础情况(即 n 为 0 或 1 的“叶子节点”)。

以下是这个递归过程的简化表示:

Fibonacci(5) = Fibonacci(4) + Fibonacci(3)
             = (Fibonacci(3) + Fibonacci(2)) + (Fibonacci(2) + Fibonacci(1))
             = ... // 继续展开

重复计算的分析

通过追踪调用过程,我们可以清晰地看到同一个值被反复计算了多次。

以下是计算 Fibonacci(5) 时各子问题的调用次数统计:

  • Fibonacci(0) 被计算了 3 次。
  • Fibonacci(1) 被计算了 5 次。
  • Fibonacci(2) 被计算了 3 次。
  • Fibonacci(3) 被计算了 2 次。

这个递归定义在数学上是完全正确的,但从计算效率的角度看,它并不高效。问题的根源不在于递归本身速度慢,而在于这种算法会重复计算大量相同的子问题,导致了不必要的性能开销。

性能影响与解决方案

这种重复计算是否可以被接受,取决于具体的编程场景和性能要求。但作为一名程序员,必须意识到此类问题对程序性能的潜在影响。

如果这种低效的性能是不可接受的,我们可以从以下两个主要方向来优化算法:

以下是两种常见的优化策略:

  1. 重新设计算法:避免使用这种“自顶向下”的纯递归方法。例如,可以采用“自底向上”的迭代方法,从 Fibonacci(0)Fibonacci(1) 开始,逐步计算到目标值,确保每个值只计算一次。
  2. 使用记忆化:这是一种优化递归的经典技术。其核心思想是维护一个已计算值的查找表(例如一个数组)。在每次进行递归计算前,先检查表中是否已有结果;如果有,则直接返回,避免重复计算。

记忆化技术在不改变递归算法清晰逻辑的前提下,通过空间换时间,显著提升了效率。

总结

本节课中我们一起学习了递归算法中一个关键的性能陷阱——重复计算。我们以斐波那契数列为例,追踪了递归调用的展开过程,并统计了子问题的重复计算次数。我们认识到,低效的根源在于算法设计,而非递归机制本身。最后,我们探讨了通过迭代法或记忆化技术来避免重复计算、提升程序性能的两种思路。理解这一点对于编写高效的递归程序至关重要。

073:尾递归阶乘实现执行过程 🔍

在本节课中,我们将学习尾递归版本的阶乘函数,并逐步追踪其执行过程。我们将分析普通递归与经过编译器优化后的尾递归在内存使用上的关键区别。

概述

阶乘函数有一个参数 n,代表要计算阶乘的数字,它返回一个整数,即该输入的阶乘值。本课的重点是名为 fact_helper 的辅助函数,它有两个参数:整数 n 和整数 answern 是我们要计算阶乘的数字,而 answer 是一个随着每次递归调用而更新的累积乘积。当 fact_helper 调用自身时,这是它执行的最后一项计算,这被称为尾递归

普通尾递归执行过程

现在,让我们逐步追踪普通尾递归版本的执行过程。

我们从 factorial 函数内部开始,其中 n 的值为 3,我们将调用 fact_helper

以下是执行步骤:

  1. 创建一个栈帧,传入参数 n=3answer=1。我们用数字 1 标记调用点位置,以便在离开函数时知道返回何处。
  2. 进入 fact_helper 后,由于 n 的值为 3,我们跳过基本情况,将再次调用 fact_helper
  3. 为下一次递归调用创建新的栈帧,其中 n=2answer=3
  4. 再次进入函数,跳过基本情况,为下一次调用创建栈帧,其中 n=1answer=6
  5. 再次进入函数,跳过基本情况,为下一次调用创建栈帧,其中 n=0answer=6
  6. 这次进入函数时,n 小于等于 0,满足基本情况,我们返回答案 6
  7. 返回值 6 沿着调用链向上传递,依次返回到调用点位置 2,并销毁每个递归调用的栈帧。
  8. 最后,将值 6 返回到调用点位置 1,并将执行箭头移回 factorial 函数。

这里需要注意的关键点是,一旦我们放置了每个尾递归调用,我们只需要将值 6 一路返回。实际上,我们不再需要那些栈帧,因为帧中没有任何值会被再次使用。

尾递归消除优化

一个优化的编译器会识别到这一点,并执行所谓的尾递归消除。这样,就不会为每次递归调用 fact_helper 都创建新的栈帧。

让我们逐步追踪优化后的版本。

以下是优化后的执行步骤:

  1. 同样从 factorial 函数内部开始,n=3。我们为 fact_helper 创建一个栈帧,其中 n=3answer=1。标记调用点位置 1,然后进入 fact_helper
  2. n 不小于等于 0,因此我们将放置递归调用 fact_helper。但是,编译器会重用当前的栈帧
  3. 首先,我们必须分别计算新参数 n=2answer=3。注意,我们在开始更改帧中任何值之前完成此操作,因为我们需要使用当前值。
  4. 然后,我们将帧中 n 的值更新为 2,将 answer 的值更新为 3。
  5. 现在我们跳回 fact_helper 内部。跳过基本情况,再次准备放置递归调用。
  6. 编译器将使代码将 nanswer 的值更新为新的参数 16
  7. 跳过基本情况,再次重用同一个栈帧,这次 n=0answer=6
  8. 这次我们进入基本情况,然后直接将答案 6 返回到 factorial 函数中的调用点位置 1。

请注意,优化后,不需要为每次调用 fact_helper 都创建栈帧。这意味着所需的空间不再与输入 n 的大小成正比。你只需要一个 factorial 的帧和一个 fact_helper 的帧。

总结

本节课中,我们一起学习了尾递归阶乘函数的执行过程。我们对比了普通尾递归与经过编译器优化(尾递归消除)后的版本。关键区别在于,优化版本通过重用栈帧,将空间复杂度从 O(n) 降低到了 O(1),从而避免了递归深度过大可能导致的栈溢出问题,并提升了效率。理解尾递归及其优化是编写高效递归代码的重要基础。

074:互递归函数isodd与iseven的执行过程分析 🧠

在本节课中,我们将要学习两个互递归函数 isoddiseven 的执行过程。这两个函数不仅是互递归的,同时也是尾递归的,这意味着编译器可以进行尾递归优化,从而避免为每次函数调用创建新的栈帧。我们将通过一个具体的例子,逐步分析其执行流程。

函数定义与初始调用

首先,我们假设调用了函数 isodd,并传入参数 4。此时,变量 x 的值为 4

// 函数定义示例
int isodd(int x);
int iseven(int x);

执行过程逐步分析

以下是 isodd(4) 调用后的详细执行步骤。

第一步:进入 isodd 函数

isodd 函数内部,程序会检查两个基本情况。由于 x 的值是 4,既不等于 0 也不等于 1,因此我们会跳过这两个 if 语句。

第二步:执行尾递归调用

接下来,程序到达 return 语句,并准备调用 iseven 函数。由于这是一个尾递归调用,编译器不会为 iseven 创建新的栈帧,而是直接复用当前 isodd 的栈帧,并将 x 的值更新为 3

第三步:进入 iseven 函数

现在,我们进入了 iseven 函数的逻辑。同样地,程序会检查其基本情况。此时 x 的值为 3,不满足 x == 0 的条件,因此我们继续执行。

第四步:再次进行尾递归调用

iseven 函数中,程序再次到达一个 return 语句,需要调用 isodd 函数。同理,由于是尾递归,我们复用当前的栈帧(原本属于 iseven),并将 x 的值更新为 2

第五步:第二次进入 isodd 函数

我们再次进入 isodd 函数。x 的当前值是 2,仍然不满足两个基本情况,因此程序继续执行。

第六步:第三次尾递归调用

在这次的 isodd 函数中,程序需要再次调用 iseven 函数。我们继续复用栈帧,并将 x 的值更新为 1

第七步:触发基本情况

这次进入 iseven 函数时,x 的值是 1。程序检查基本情况,发现 x == 1 不成立,但 x == 0 也不成立。然而,根据函数逻辑(图中未完全展示的 else 部分),当 x1 时,函数会返回一个值。在这个例子中,我们假设它返回了 0

第八步:返回值传递

这个返回值 0 将返回到最初调用 isodd(4) 的地方。执行流程箭头返回到最初的调用点,整个过程结束。

核心概念总结

本节课中我们一起学习了互递归函数 isoddiseven 在尾递归优化下的执行过程。关键点在于:

  1. 互递归:两个或多个函数相互调用。
  2. 尾递归:递归调用是函数体中的最后一个操作。
  3. 尾递归消除(优化):编译器可以重用当前栈帧进行尾递归调用,避免栈空间过度消耗。其效果类似于一个 while 循环:
    while (!base_case_reached) {
        update_parameters;
        // 本质上在交替执行 isodd 和 iseven 的逻辑
    }
    

通过这个逐步分析,我们清晰地看到了参数 x 如何从 4 递减到 1,以及栈帧如何被复用,最终函数如何返回结果。理解这个过程对于掌握递归和编译器优化行为非常有帮助。

075:解决现实世界问题 💡

在本节课中,我们将学习杜克大学校友、现任软件工程师Marta分享的经验。她将结合自身在大型科技公司的实习与工作经历,讲述如何将在课堂上学到的编程知识应用于解决现实世界中的复杂问题,并为初学者提供宝贵的学习与职业建议。


概述

Marta在杜克大学攻读电气与计算机工程硕士学位期间,通过课程学习和暑期实习,将理论知识转化为解决实际工程问题的能力。她特别强调了耐心、热情以及明确职业方向的重要性。


从课堂到现实项目 🚀

上一节我们了解了课程概述,本节中我们来看看Marta如何将课堂所学应用于实际工作。

Marta在暑期实习期间,参与了一个具有真实影响力的项目,而不仅仅是模拟练习。她的项目与Andrew Hilton教授的“工程机器人服务软件”课程内容直接相关。在那门课程中,她学习了如何构建健壮的服务器、理解输入/输出(I/O)通信机制等知识。

这些知识在她的实习项目中发挥了关键作用。她的项目本质上是课程项目的延伸,但规模更大、难度更高。她负责重写一个被全球机器使用的服务。以下是她的主要工作内容:

  • 技术栈升级:她利用异步通信、线程、事件驱动和Future等现代编程范式重写了该服务。
  • 性能提升:经过重写,该服务的请求响应时间从1.7秒显著降低到0.7秒

看到自己掌握的知识能够应用于实际项目,并取得可量化的优异成果(使服务更可扩展、更健壮、更快速),令她感到非常满足。这证明了将编程学习与现实问题解决相结合的巨大价值。


给编程新手的建议 📝

在了解了理论联系实际的重要性后,本节我们来看看Marta为初学者提出的具体建议。

对于编程新手,Marta认为首要的是培养正确的心态和学习方法。她在杜克大学期间观察到,对编程充满热情的学生与仅仅因为流行而选择它的学生,在表现上存在差异。

以下是她的核心建议列表:

  • 保持耐心与热情:学习编程需要时间。热爱你所做的事情至关重要,否则过程会变得艰难。
  • 投入时间学习:选修这门课程时,你需要花时间研读材料、完成练习。
  • 保持开放心态:Marta提到,课程中最难的部分是“忘记”自己原有的编程方式,以接纳课程教授的新方法。这最终被证明是非常有价值的。
  • 建立扎实基础:即使你对C语言一无所知,学完本课程后,你也能达到中级水平,并在算法、C语言和数据结构方面打下坚实基础。
  • 明确职业方向:编程领域广阔。尽早确定你感兴趣并想解决的问题领域,这有助于你找到一份能持续激发学习动力、令人兴奋的工作。

职业发展与人际网络 🌐

上一节我们讨论了学习心态和基础,本节我们聚焦于职业规划的实际步骤。

除了技术学习,职业发展也需要策略。Marta结合自身经验,给出了以下建议:

  • 积极拓展人际网络:如果你认识业内的人,或者有时间,主动联系他们。了解他们的公司是否在招聘。
  • 参加招聘活动:积极参加职业招聘会或技术大会,与公司代表交流。
  • 拓宽公司选择视野:不要只盯着苹果、谷歌、微软、Facebook等巨头。还有许多科技公司需要合格的人才。

总结

本节课中,我们一起学习了杜克大学校友Marta的宝贵经验。她通过重写全球性服务的实际案例,生动展示了如何将课堂知识(如构建健壮服务器、理解I/O通信)转化为解决现实问题的能力,并取得了将性能从 1.7秒 提升到 0.7秒 的显著成果。同时,她为初学者指明了方向:保持耐心热情,打下扎实的算法数据结构基础,明确职业兴趣,并积极拓展人际网络。记住,编程不仅是学习语法,更是关于解决问题和创造价值。

076:为什么我们需要交互性和内存管理 🚀

在本节课中,我们将探讨C语言编程中两个至关重要的高级主题:程序与系统/用户的交互,以及动态内存管理。理解这些概念是编写功能完整、高效且健壮的程序的关键。

课程概述 📋

到目前为止,你的C语言编程技能已经得到了很好的发展。现在是时候通过几个非常重要的主题来完成这门专项课程的学习了。

第一个我们将要讨论的主题是与系统和用户的交互。你已经学会了向屏幕打印输出,但如果你想要读取用户的输入呢?如果你想把数据存储到文件中,而不是打印到屏幕上,或者从文件中读取数据,又该如何操作?那些使用命令行参数的程序又是如何利用这些参数的?我们将涵盖所有这些主题,并介绍一些关于你的程序与操作系统之间关系的背景知识,这些任务都涉及到操作系统。

本课程的第二个主要主题是动态内存分配。这意味着在程序运行时分配空间来存储数据,因为你在编写程序时并不知道需要多少空间。如果你的程序需要读取一个文件,你并不知道该文件中会有多少数据,可能是50行,也可能是5000万行。即使你创建了一个能容纳5000万个元素的数组,你仍可能发现你的程序需要处理5亿个元素。

最后,我们将讨论一些在你开始编写更大规模程序时需要牢记的重要概念。你目前在本课程中编写的程序规模还比较小。有些程序拥有数千甚至数百万行代码。我们虽然不会处理那么大规模的程序,但会讨论重要的原则,并通过一个中等规模的示例进行实践。

那么,让我们开始深入学习吧。

核心主题详解 🔍

程序交互性

上一节我们概述了本课程的方向,本节中我们来看看程序交互性的具体需求。程序不仅需要输出信息,更需要接收输入并与外部环境(如用户、文件系统)进行沟通。

以下是程序交互性的几个关键方面:

  • 用户输入:程序需要能够读取用户从键盘输入的数据。
  • 文件操作:程序需要能够将数据持久化存储到文件中,或从文件中读取数据以供处理。
  • 命令行参数:程序启动时,可以通过命令行接收额外的参数来改变其行为。

这些交互都依赖于程序与操作系统之间的协作。操作系统作为中间层,管理着硬件资源(如键盘、磁盘),并为程序提供统一的接口(如函数调用)来访问这些资源。

动态内存管理

理解了程序如何与外界交互后,我们来看看程序内部如何高效地管理数据。在许多情况下,程序所需的数据量在编写时是无法预知的。

动态内存分配解决了这个问题。它允许程序在运行时(而非编译时)请求操作系统分配特定大小的内存空间。这通过C标准库中的函数来实现,例如:

// 动态分配一个能容纳n个整数的内存空间
int *dynamic_array = (int*)malloc(n * sizeof(int));

如果预先分配的固定大小数组(例如 int array[1000];)不足以容纳所有数据,程序可能会崩溃或产生错误。动态内存管理提供了灵活性,使程序能够根据实际需求调整其使用的内存量。

迈向更大规模的程序

最后,当我们开始构思和编写更复杂、规模更大的程序时,需要遵循一些重要的软件工程原则。

以下是编写大型程序时需注意的几个核心概念:

  • 代码组织:将代码合理地分割成模块或函数,提高可读性和可维护性。
  • 可维护性:编写清晰、有注释的代码,便于自己或他人日后修改和扩展。
  • 模块化设计:构建可以独立测试和重用的代码单元。

我们将通过分析一个中等复杂度的示例程序,来具体观察这些原则是如何被应用的。

总结 🎯

本节课中,我们一起学习了C语言编程进阶的两个核心支柱:交互性内存管理。我们了解到,程序需要通过输入输出、文件操作和命令行参数与外界沟通;同时,为了处理未知大小的数据,必须掌握动态内存分配的技巧。最后,我们探讨了编写大规模程序时应遵循的组织与设计原则,为开发更复杂的软件奠定了基础。掌握这些知识,将使你的程序从简单的计算工具,转变为能够解决实际问题的强大应用。

077:使用fgetc读取文件 📖

在本节课中,我们将学习如何使用 fgetc 函数从文件中逐个读取字符,并通过一个具体的例子——统计文件中字母的数量——来演示其工作流程。

概述

fgetc 函数是C语言标准库中用于从文件流中读取单个字符的函数。其基本用法是循环调用 fgetc,直到读取到文件结束符(EOF)。我们将通过一个名为 count_letters 的程序来理解这个过程,该程序接受一个文件名作为命令行参数,并输出该文件中包含的字母数量。


程序执行流程详解

上一节我们介绍了 fgetc 的基本概念,本节中我们来看看一个完整的程序是如何运行的。

1. 程序启动与参数检查

程序从 main 函数开始执行。在本例中,我们假设程序名是 count_letters,并且通过命令行传递了一个参数 input.txt

  • argc 的值为 2。
  • argv[0] 是程序名 "count_letters"
  • argv[1] 是文件名 "input.txt"
  • argv[2]NULL

程序首先检查 argc 是否等于 2。如果不等于,则说明参数数量错误,程序应报错退出(示例中省略了错误处理代码)。由于 argc 等于 2,检查通过。

2. 打开文件

接下来,程序使用 fopen 函数打开文件。

FILE *f = fopen(argv[1], "r");
  • argv[1] 提供了文件名 "input.txt"
  • "r" 表示以只读模式打开。
  • 函数返回一个指向 FILE 结构的指针,我们将其存储在变量 f 中。

假设文件 input.txt 成功打开,其内容为:

ab4
c

此时,文件指针指向文件开头,文件结束状态为 false

3. 初始化变量

程序声明了两个变量:

  • int c:用于存储每次从文件读取的字符。
  • int letters = 0:用于计数字母数量,初始化为 0。

4. 循环读取与处理字符

程序进入核心的读取循环。以下是循环的代码模式:

while ((c = fgetc(f)) != EOF) {
    // 处理字符c
}

以下是循环的逐步解析:

  1. 第一次调用 fgetc

    • fgetc(f) 从文件当前位置读取一个字符 'a',并将文件指针向前移动一位。
    • 字符 'a' 被赋值给变量 c
    • 判断 c != EOF 为真,进入循环体。
    • 使用 isalpha(c) 函数检查 c 是否为字母。'a' 是字母,因此 letters 自增为 1。
  2. 第二次调用 fgetc

    • 读取字符 'b',赋值给 c
    • 'b' != EOF 为真,进入循环。
    • 'b' 是字母,letters 自增为 2。
  3. 第三次调用 fgetc

    • 读取字符 '4',赋值给 c
    • '4' != EOF 为真,进入循环。
    • '4' 不是字母,跳过 if 语句。
  4. 第四次调用 fgetc

    • 读取换行符 '\n',赋值给 c
    • '\n' != EOF 为真,进入循环。
    • '\n' 不是字母,跳过 if 语句。
  5. 第五次调用 fgetc

    • 读取字符 'c',赋值给 c
    • 'c' != EOF 为真,进入循环。
    • 'c' 是字母,letters 自增为 3。
  6. 第六次调用 fgetc

    • 读取换行符 '\n',赋值给 c
    • '\n' != EOF 为真,进入循环。
    • '\n' 不是字母,跳过 if 语句。
  7. 第七次调用 fgetc

    • 此时已到达文件末尾。fgetc(f) 返回 EOF,并将其赋值给 c
    • 判断 c != EOF 为假,循环条件不满足,退出 while 循环。

5. 输出结果并结束

退出循环后,程序执行打印语句:

printf("%s has %d letters in it\n", argv[1], letters);

这将输出:

input.txt has 3 letters in it

最后,程序返回 EXIT_SUCCESS,表示成功退出。main 函数的栈帧被清理。

注意:示例程序结束时没有调用 fclose 关闭文件。在实际编程中,打开的文件应该被正确关闭以释放资源,我们将在后续课程中学习如何操作。


总结

本节课中我们一起学习了 fgetc 函数读取文件的基本方法。我们通过一个统计文件字母数量的程序,详细跟踪了其执行过程,包括:

  1. 使用命令行参数获取文件名。
  2. 使用 fopen 打开文件。
  3. 利用 while ((c = fgetc(f)) != EOF) 这一惯用模式循环读取字符直至文件末尾。
  4. 在循环体内对每个读取的字符进行处理(本例中使用 isalpha 判断并计数)。
  5. 最终输出结果。

理解这个流程是掌握C语言文件操作的基础。

078:使用fgets读取文件 📖

在本节中,我们将学习如何使用 fgets 函数从文件中读取数据。我们将通过一个具体的例子,演示如何读取文件中的每一行文本,将其转换为整数,并计算这些整数的总和。同时,我们也会探讨当读取的行过长,超出我们预设的缓冲区大小时,程序应如何处理。


概述

fgets 函数用于从文件中读取指定长度的行。在本例中,我们将读取文件中的每一行文本,使用 atoi 函数将其转换为整数,然后将这些整数累加求和,并最终打印出总和。为了演示当 fgets 无法读取整行时的行为,我们故意将用于存储字符的数组大小设置得相对较小。


代码结构与初始化

首先,我们定义了一个常量 LINE_SIZE,其值为5。这意味着我们将创建一个大小为5的字符数组 line 来存储从文件中读取的每一行。同时,我们告诉 fgets 最多只能读取5个字符(包括末尾的空字符 \0)。

#define LINE_SIZE 5
char line[LINE_SIZE];

与之前一样,为了简化示例,我们省略了一些错误检查。在实际应用中,如果遇到错误(如文件打开失败),程序会打印错误信息并退出。

程序从 main 函数开始执行。我们假设命令行参数 argc 的值为2,argv[1] 是输入文件名 numbers.txt,该文件包含我们要读取的数字。

int main(int argc, char *argv[]) {
    if (argc != 2) {
        // 错误处理代码
    }
    // ...
}

由于 argc 等于2,程序不会执行错误处理代码,而是继续执行。


打开文件与变量初始化

接下来,我们以读取模式打开文件 numbers.txt。文件指针 f 指向文件的开头。

FILE *f = fopen(argv[1], "r");
if (f == NULL) {
    // 错误处理代码
}

我们初始化一个名为 total 的整型变量,用于存储累加和,其初始值为0。同时,我们创建了字符数组 line,它包含5个“盒子”,每个盒子可以存储一个字符。

int total = 0;
char line[LINE_SIZE];

第一次调用 fgets

现在,我们准备第一次调用 fgets 函数。它将从文件 f 中读取数据,并填充到 line 数组中。我们告诉 fgets 最多可以读取5个字符(实际上是4个字符加一个空终止符 \0)。

fgets(line, LINE_SIZE, f);

此时,文件中的光标位于起始位置。fgets 读取了第一行文本 “123\n”,并将其存储到 line 数组中。数组的内容变为:[‘1‘, ‘2‘, ‘3‘, ‘\n‘, ‘\0‘]。读取后,文件中的光标移动到下一行的开头。


检查是否读取了整行

为了检查 fgets 是否成功读取了整行(即是否读取到了换行符 \n),我们使用 strchr 函数。

strchr 函数接受一个字符串和一个字符作为参数。如果在该字符串中找到了该字符,则返回指向该字符第一次出现位置的指针;如果未找到,则返回 NULL

if (strchr(line, ‘\n‘) == NULL) {
    // 错误处理:行过长
}

在本例中,strchr 返回了一个指向 line 数组中第四个元素(即 \n)的指针,该指针不为 NULL。这表明 fgets 成功读取了整行。


转换文本并累加

既然成功读取了整行,我们使用 atoi 函数将字符串 “123” 转换为整数123,并将其加到 total 上。

total += atoi(line); // total 从 0 变为 123

然后,程序返回到 while 循环的顶部,准备读取下一行。


第二次调用 fgets

程序第二次调用 fgets。这次它读取下一行文本 “14\n”,并将其存储到 line 数组中。数组内容更新为:[‘1‘, ‘4‘, ‘\n‘, ‘\0‘, ‘\0‘]。注意,最后一个元素保持不变,因为 fgets 已经读取了整行。

再次使用 strchr 检查换行符,结果不为 NULL。然后,我们将整数14加到 total 上,total 的值从123更新为137。


处理过长的行

现在,程序进入循环的第三次迭代。下一行文本是 “-56789”,这个数字太长,无法完全存入大小为5的 line 数组。

fgets 会尽可能多地读取字符,然后停止。在本例中,它读取了 “-567”,然后停止,因为我们已经告诉它数组最多只能容纳5个字符(包括末尾的 \0)。此时,文件中的光标停在字符 ‘7‘ 和 ‘8‘ 之间。

如果我们再次调用 fgets(或任何其他文件读取函数),将会继续读取 ‘8‘ 和 ‘9‘。但我们的代码没有设置处理这种情况的逻辑,因此我们依靠错误检查来处理未读取整行的情况。


错误处理

以下是检查行是否过长的代码逻辑:

if (strchr(line, ‘\n‘) == NULL) {
    printf(“Line too long\n“);
    exit(EXIT_FAILURE);
}

对于 “-56789” 这一行,strchr 函数在 line 数组中没有找到换行符 \n,因此返回 NULL。程序随后执行错误处理代码,打印 “Line too long” 并退出。

请注意,在这个简单的示例中,我们还没有关闭文件。我们将在后续课程中学习如何正确地关闭文件。


总结

在本节课中,我们一起学习了如何使用 fgets 函数从文件中逐行读取数据。我们了解了如何将读取的文本行转换为整数并进行累加求和。更重要的是,我们探讨了当读取的行长度超过预设缓冲区大小时,如何通过检查换行符来发现并处理这种错误情况。通过这个例子,你应该对 fgets 的工作方式及其局限性有了更清晰的认识。

079:写入文件 📝

在本节课中,我们将学习如何编写一个C语言程序,将数据写入到文件中。我们将通过一个具体的代码示例,逐步解析如何打开文件、写入数据以及关闭文件。


概述

我们将分析一段C语言代码,该程序接收命令行参数,计算一系列数字的平方,并将结果写入到一个指定的文本文件中。我们将重点关注文件操作的关键步骤:使用 fopen 打开文件,使用 fprintf 写入数据,以及使用 fclose 关闭文件。


代码解析

程序启动与参数检查

程序从 main 函数开始执行,其参数为 argcargv。假设程序以 ./print_squares 15 17 output.txt 的方式运行。

首先,程序检查传入的参数数量是否正确。argc 的值为4,因此无需执行错误处理代码。

参数转换

接下来,程序使用 atoi 函数将字符串参数转换为整数。argv[1] 被转换为整数并存储在变量 start 中。同样地,argv[2] 被转换为整数并存储在变量 end 中。

打开文件

现在,程序准备使用 fopen 函数打开一个文件。我们传入 argv[3] 作为文件名,因此创建的文件将命名为 output.txt。我们传入 "w" 作为模式,这意味着如果文件不存在则创建它,如果存在则将其截断为零长度并从开头开始写入。

FILE *f = fopen(argv[3], "w");

文件状态

我们记录文件的相关细节、其内容以及下一次写入操作将发生的位置。

错误检查

程序检查 fopen 是否失败。在本例中,fopen 成功执行。

写入数据

接下来,程序进入一个 for 循环。循环变量 i 初始化为 start 的值(即15)。

在循环体内,使用 fprintf 函数将 i 的平方值写入文件 f。这与 printf 函数类似,但输出目标是文件而非屏幕。

fprintf(f, "%d\n", i * i);

由于我们在格式字符串中包含了换行符 \n,文件位置标记会移动到下一行。

循环继续执行,将 256 和换行符写入文件,然后进行下一次迭代,将 289 和换行符写入文件。

关闭文件

完成循环后,程序调用 fclose(f) 关闭文件,并检查操作是否成功。在本例中,fclose 成功执行,操作系统接受了所有待写入的数据(即使尚未物理写入磁盘)。

程序退出

最后,程序成功退出。


总结

在本节课中,我们一起学习了如何使用C语言进行文件写入操作。我们了解了如何通过命令行参数获取输入,如何使用 fopen 以写入模式打开文件,如何使用 fprintf 将格式化的数据写入文件,以及如何使用 fclose 安全地关闭文件。掌握这些基础文件操作是进行更复杂数据处理的关键步骤。

080:关闭文件 📂

在本节中,我们将学习调用 fclose() 函数时会发生什么。理解文件关闭的过程对于确保数据正确写入磁盘至关重要。

概述 📋

当我们调用 fclose() 关闭一个文件时,系统会执行一系列操作,以确保所有缓冲的数据都被正确写入磁盘。本节将概念化地解释这一过程,而不深入内核或硬件交互的细节。

数据缓冲与写入 💾

程序可能已将数据缓冲在C库管理的文件结构中,但尚未实际写入操作系统。这意味着数据可能还未真正保存到磁盘上。

调用 fclose() 时,如果C库中有未写入的数据,它首先会发起一个系统调用,将这些数据写入操作系统。

以下是写入系统调用的核心参数:

  • 文件描述符:一个低级通信机制,用于指定程序与内核之间的文件。在本例中,我们假设它为 3
  • 数据指针:指向要写入数据的指针。
  • 字节数:要写入的字节数量。例如,如果有三行数据,每行包含三个数字和一个换行符(共4字节),则总字节数为 12
// 概念上的系统调用示意
write(3, data_pointer, 12);

这些数据(以字节的数值形式,而非文本形式)将被写入操作系统的内存缓冲区。

关闭文件与磁盘写入 🔄

上一节我们介绍了数据如何从程序缓冲区写入操作系统。接下来,C库会发起 close 系统调用,并传入文件描述符,以通知操作系统关闭该文件。

随后,操作系统会决定将其缓冲区中的数据写入磁盘。它会将所有字节写入磁盘的某个位置。

磁盘操作完成后,会向操作系统报告写入成功。当然,写入也可能失败(例如磁盘损坏),但这里我们假设操作成功。

内核识别到文件已关闭,并通过返回 0 来告知程序文件关闭成功,这个 0 也将是 fclose() 函数的返回值。

此时,文件已完全关闭,我们无法再对其进行任何操作。fclose() 调用结束,程序将继续执行后续代码。

总结 ✨

本节课中,我们一起学习了 fclose() 函数的工作流程。关键点在于,关闭文件会强制将任何缓冲的数据写入磁盘,并通过系统调用完成与操作系统的通信,最终确保数据持久化保存。理解这个过程有助于我们编写更可靠的文件操作程序。

081:简单的malloc调用 📚

在本节课程中,我们将学习C语言中一个非常重要的概念:动态内存分配。具体来说,我们将通过一个简单的例子,详细解析 malloc 函数的工作原理、它在内存中的行为,以及它与栈内存的关键区别。


概述

malloc 函数用于在程序运行时从堆(heap)区域动态地申请一块内存。与在栈(stack)上声明的局部变量不同,堆内存的生命周期不由函数调用栈控制,需要程序员手动管理。理解 malloc 是掌握C语言内存管理的基础。

上一节我们介绍了指针和内存的基本概念,本节中我们来看看如何使用 malloc 进行动态内存分配。


malloc 的语义与示例

现在,让我们看看 malloc 的语义。这里有一段简单的示例代码,它使用了 malloc

int *p;
p = malloc(6 * sizeof(*p));

这段代码本身没有实际功能,但能清晰地展示 malloc 的作用。

我们的第一条语句声明了一个 int 型指针 p,它尚未初始化。
第二条语句是一个赋值语句。我们将遵循一贯的赋值规则:找到等号左侧的变量(在这里是 p),然后计算右侧表达式的值,并将这个新值放入 p 的“盒子”中。

在这个例子中,右侧的表达式是对 malloc 的调用,它将分配内存。


内存分配与“新盒子”

分配内存意味着你将在内存中“画”出一个新的“盒子”。这个新盒子位于中,而不是任何函数的栈帧里。与驻留在栈帧中的变量不同,你在堆中创建的这块内存没有自己的名字。

为了了解这个新盒子的样子,你需要查看传递给 malloc 的参数。

这里的参数是 6 * sizeof(*p),这意味着它将分配一个包含6个元素的数组。由于 p 是一个 int 指针,这些元素都将是 int 类型。让我们画出这个盒子。

请注意,数组内的每个元素都是未初始化的。每当你创建新的内存“盒子”时,它们都处于未初始化状态,直到你显式地放入一个值。


malloc 的返回值与赋值

malloc 的返回值是一个指向这块新分配内存的指针。现在,你可以完成赋值语句了:将 malloc 函数调用的返回值(即指向新内存的地址)放入 p 的盒子中。


函数返回时的内存状态

现在,让我们看看当函数返回时会发生什么。

当前函数的栈帧位于栈中,因此当函数返回时,它会自动被销毁。这种行为与你之前课程中学到的完全一致。

然而,由 malloc 分配的内存位于堆中。当函数返回时,这块内存不会自动被释放。因此,当你执行 return 语句时,只会销毁栈帧,而堆中的内容不会发生任何改变。


堆内存的生命周期

任何在堆中分配的内存都将持续存在,直到你显式地释放它。这允许你在一个函数内部分配内存,并返回一个有效的指针,以便在其他函数中使用它。

以下是关于 malloc 使用要点的总结:

  • 分配内存malloc(size) 在堆上分配指定字节数的内存。
  • 返回指针:它返回一个指向该内存块起始地址的 void* 指针,通常需要强制转换为目标类型。
  • 未初始化:分配的内存内容是未定义的(可能是垃圾值)。
  • 手动管理:堆内存必须使用 free() 函数手动释放,否则会导致内存泄漏。

总结

本节课中,我们一起学习了 malloc 函数的基本调用。我们了解到 malloc 用于从堆中动态申请内存,其生命周期独立于函数栈帧,需要手动管理。我们通过一个简单的代码示例,逐步分析了内存分配、指针赋值以及函数返回后堆栈状态的变化,明确了动态内存分配的核心机制。理解这一点是进行复杂内存操作和避免内存泄漏的关键。

082:free的机制原理 🧠

在本节课中,我们将要学习C语言中free函数的工作原理。你已经学会了如何使用malloc分配内存,同样重要的是,当你使用完内存后,需要调用free来释放它。本节将通过示例代码,详细讲解free的语义和内存释放后的状态变化。


上一节我们介绍了动态内存分配,本节中我们来看看如何正确地释放内存。

我们从一个示例代码开始,展示free的语义。首先,我们声明了一个指针P,并使用malloc将其初始化为指向一个包含4个整数的数组。然后,我们向数组中填充了一些数据。为了说明另一个要点,在代码末尾,我们创建了另一个指针Q,并将其设置为等于P

代码中有一个注释(...),表示我们省略了使用这些数据进行实际计算的代码部分。现在,我们展示在调用free之前程序的状态。

int *P = (int *)malloc(4 * sizeof(int));
// ... 填充数据 ...
int *Q = P;
// ... 使用数据的代码 ...
// 调用 free 之前的状态

现在,让我们看看执行free时会发生什么。首先,请注意我们是将指针P传递给free函数。free(P)实际上并不影响指针P本身,而是影响P所指向的内存。

让我们看看P指向哪里。它指向malloc之前分配给我们的那个包含4个整数的内存块。释放这块内存会“销毁”这个内存盒子,并使P成为一个悬空指针。

任何对P所指向内存的进一步使用都是无效的,就像使用任何悬空指针一样。同时请注意,由于Q指向与P相同的位置,Q现在也变成了悬空指针。我们也不应该尝试解引用Q


接下来,让我们尝试一个稍微复杂一点的例子。同样,我们从代码中准备释放内存的那个点开始。在继续之前,你应该自己推导出此时程序的状态。

以下是此时程序状态的图示(使用不同颜色的箭头帮助你理清指向关系,箭头颜色本身没有特殊含义):

首先,我们进入for循环,创建变量I并将其初始化为0。我们将释放P[I]。此时I为0,所以指针P[0]是这里的这个箭头。我们将释放它所指向的内存块。

调用free完成后,请注意该指针现在变成了悬空指针。只要我们不试图解引用它,这就没问题。

我们进入下一个循环迭代,释放P[1]所指向的内存。执行free,然后进入下一个循环迭代。我们进入for循环,释放P[2]所指向的内存。再次回到for循环的开头,释放P[3]所指向的内存。

现在I变为4,我们退出for循环。最后,我们释放P,它也指向一块由malloc分配的内存。释放P会释放那块内存。至此,我们已经释放了所有通过malloc分配的内存。

以下是该过程的代码示意:

// 假设 P 是一个指针数组,每个元素都指向一块 malloc 分配的内存
for (int i = 0; i < 4; i++) {
    free(P[i]); // 释放每块独立分配的内存
}
free(P); // 最后释放存储指针的数组本身

本节课中我们一起学习了free函数的核心机制。关键点在于:

  1. free释放的是指针所指向的内存,而非指针变量本身。
  2. 内存被释放后,指向它的指针变为悬空指针,不应再被解引用。
  3. 所有指向同一块内存的指针在释放后都会变为悬空指针。
  4. 对于复杂结构(如指针数组),需要确保释放所有动态分配的内存块,避免内存泄漏。

理解并正确使用free是管理内存、编写健壮C程序的基础。

083:存在内存泄漏的代码 💾

在本节课程中,我们将学习一个关于内存管理的具体案例:在循环中动态分配内存时,如何避免内存泄漏。我们将通过对比两段代码来理解内存泄漏的产生原因以及正确的释放方法。


上一节我们介绍了动态内存分配的基本概念,本节中我们来看看一个在循环中分配内存的常见场景。

我们首先分析第一段代码,这段代码在循环中分配内存但没有释放,会导致内存泄漏。

以下是代码的执行流程:

  1. 程序从 main 函数开始,初始化变量 x,然后进入一个 for 循环,循环变量 i 的初始值为 10。
  2. 在循环体内,第一行代码使用 malloc 在堆上为 10 个整数分配内存,指针 p 指向这块堆内存。
  3. 假设我们调用一个函数 do_some_computation 来初始化这个数组并进行某些计算,这可能会改变变量 x 的值。
  4. 需要记住的是,在这次循环迭代结束时,局部变量 p 将不复存在。这意味着当我们回到 for 循环的开头时,p 消失了。
  5. 这也意味着我们丢失了指向堆上已分配内存的指针。这块内存现在泄漏了。我们无法再访问它,也无法释放它。
  6. 循环变量 i 递增到 11,我们将执行下一次循环迭代。这次,我们将为 11 个整数分配空间。
  7. 注意,在循环的每一次迭代中,我们都会分配内存,然后泄漏它,并且每次泄漏的量越来越大。

这不是我们想要的结果。


接下来,我们考虑这个代码的第二个版本,其中我们加入了释放堆内存的语句。

以下是修正后代码的执行流程:

  1. 同样,程序从 main 函数开始,初始化变量 x 为 0,然后进入 for 循环,i 的初始值为 10。
  2. 我们为 10 个整数分配内存,然后执行计算函数来初始化数组并改变 x 的值。
  3. 关键的一步是,在离开本次循环迭代之前,我们将释放 p 所指向的内存。
  4. 现在,当变量 p 消失时,我们没有泄漏任何内存。
  5. i 递增到 11,我们回到 for 循环。我们将为 11 个整数分配内存。
  6. 再次调用计算函数初始化数组并改变 x,然后再次释放 p 所指向的内存。
  7. 我们可以继续下一次循环迭代。注意,每次分配内存后我们都将其释放,因此我们不会像第一个例子那样在堆中积累越来越多的泄漏内存。

本节课中我们一起学习了内存泄漏的一个典型例子。通过对比两段代码,我们明确了在动态分配内存后,必须在指针失效前使用 free 函数将其释放,尤其是在循环结构中,否则将导致持续的内存泄漏。正确的内存管理是编写健壮 C 程序的关键。

084:使用free时的三个常见问题 🚫

在本节课中,我们将学习C语言中动态内存管理的关键部分——free函数。正确释放内存对于避免程序崩溃和内存泄漏至关重要。我们将重点探讨三个在使用free函数时常见的错误,并解释它们为何会导致问题。


概述

free函数用于释放之前通过malloccallocrealloc在堆上分配的内存。然而,错误地使用free会导致程序行为未定义、崩溃或安全漏洞。本节将详细讲解三种典型的错误用法。


错误一:对同一堆内存地址调用两次free 🔄

第一种常见错误是对同一块堆内存进行两次free调用。

以下是一个代码示例:

int *p = (int*)malloc(4 * sizeof(int)); // 为4个整数分配堆空间
int *q = p; // 指针q也指向同一堆位置
free(q);    // 释放q指向的内存
free(p);    // 错误:试图再次释放已释放的内存

在这个例子中,第一行代码使用malloc为四个整数分配了堆空间,指针p指向该位置。假设我们还有另一个指针q,并让它也指向堆上的同一位置。在代码的后续部分,我们首先释放了q指向的内存。如果之后又尝试释放p指向的内存,就会产生问题。因为pq指向的是同一个地址,而该内存已经被释放。再次尝试释放它是非法的,会导致未定义行为


上一节我们介绍了重复释放内存的错误,接下来看看第二种错误类型。

错误二:尝试释放非堆内存 🏗️

第二种错误是尝试释放不在堆上的内存,例如栈上的局部变量。

考虑以下在某个函数内部的代码:

int x = 10;        // x是栈上的局部变量
int *p = &x;       // p指向栈上的变量x
free(p);           // 错误:试图释放栈内存

这里,我们有一个局部变量x,并将指针p初始化为指向x。如果在代码的后面尝试释放p指向的内存,程序将会异常终止。因为这个位置不在堆上,而是在栈上。释放栈内存是非法操作。


了解了释放非堆内存的问题后,我们来看最后一个常见错误。

错误三:尝试释放内存块的中间地址 🎯

第三种错误是尝试释放一个内存块的中间地址,而不是malloc返回的起始地址。

请看以下示例:

int *p = (int*)malloc(4 * sizeof(int)); // 分配一个大小为4的数组
p++;                                    // p现在指向数组的第一个元素,而非第零个
free(p);                                // 错误:试图释放分配块中间的地址

我们首先分配了一个包含4个整数的数组,p指向堆上的该位置。随后,我们递增了p,使其不再指向数组的第零个元素,而是指向第一个元素。如果在之后尝试释放p,我们实际上是在尝试使用一个并非由malloc返回给我们的地址来释放内存。相反,我们试图释放的是已分配内存块中间的一个地址。这同样是非法操作,会导致程序提前终止。


总结

本节课中,我们一起学习了使用free函数时三个必须避免的常见错误:

  1. 重复释放:对同一堆内存地址调用两次free会导致未定义行为。
  2. 释放非堆内存:尝试释放栈上的局部变量等非堆内存是非法操作。
  3. 释放内存块中间地址:只能使用malloc返回的原始起始地址来释放整个内存块,释放中间地址会导致程序崩溃。

牢记这些规则,是编写健壮、无内存错误C程序的基础。

085:realloc函数详解 🧠

在本节课中,我们将要学习C语言中一个非常重要的内存管理函数——realloc。我们将通过具体的例子,详细解析realloc函数的工作原理、使用场景以及需要注意的事项。


realloc的语义

上一节我们介绍了动态内存分配的基础,本节中我们来看看realloc函数的具体行为。你可以将realloc理解为:分配新空间、将旧数据复制到新空间、然后释放旧空间。让我们通过一个例子来观察这个过程。

我们首先声明一个指针,并为其分配指向10个整数的空间。

int *p = malloc(10 * sizeof(int));

接下来,我们用一些数据填充这些整数。为了节省篇幅,我们将其抽象为一个名为init_values的函数(此处未展示)。对于本例,我们并不关心具体的数值,但希望你能看到数据确实被复制了。因此,我们用一些值来初始化这些数据。

现在数组中有了数据,假设我们发现需要将数组扩大。这种情况可能发生在你读取数据但不知道需要保存多少数据时。在本例中,我们决定需要容纳14个整数,而不仅仅是10个。因此我们调用realloc

我们传入p(指向我们想要重新分配的内存)以及我们想要的空间大小(本例中是14个整数的空间)。

p = realloc(p, 14 * sizeof(int));

现在,让我们看看realloc会做什么。

以下是realloc在扩大内存时的步骤:

  1. 首先,它为请求的新大小(14个整数)分配空间。
  2. 接着,realloc将数据从旧内存复制到新分配的空间。注意,它只复制了10个元素,因为那是旧数组中的数量。剩余4个是未初始化的。
  3. 最后,realloc将释放不再需要的原始空间。
  4. 完成后,realloc会返回一个指向新分配内存的指针。

完成赋值语句后,由于函数调用表达式的结果是realloc的返回值,p将被更新为指向这个新分配的内存。

现在,假设我们进行更多的初始化来填充另外4个值。同样,为了节省篇幅,这部分代码没有展示。


使用realloc缩小内存

你也可以使用realloc来减少分配给某块内存的大小。如果你不再需要部分数据并希望减少内存使用,可能会想这样做。

这个过程遵循相同的规则。让我们看看它的运作。

以下是realloc在缩小内存时的步骤:

  1. 首先,你为4个整数分配新空间。
  2. 然后,你将前4个整数复制到新空间。
  3. 最后,你释放为旧空间分配的内存。
  4. 和之前一样,realloc返回一个指向新分配内存起始位置的指针。因此,在realloc返回后,赋值语句会更新p指向这个新分配的内存。


realloc的原地操作可能性

无论是在编写代码还是手动模拟执行代码时,你都应该始终假设realloc会将数据移动到一个新位置。然而,你应该知道realloc可能会将数据保留在原来的位置。让我们看同一个例子。

如果realloc将数据保留在相同的内存位置,过程如下:

我们以同样的方式开始,调用malloc然后初始化数据。接着,我们调用realloc将大小增加到14。然而这一次,realloc将在当前数据之后立即分配空间,从而避免了复制和释放旧数据的需要。和之前一样,realloc返回一个指向新数据的指针。这次,它恰好与之前的位置相同。我们的赋值语句随后会将p更新为指向与之前完全相同的位置。这完全没问题。

接下来,我们初始化这四个新值,然后再次调用realloc将大小减少到4。realloc可以选择只释放末尾不需要的元素,将此分配的大小减少到新请求的大小。然后它将返回一个指向这个更小数组的指针。和之前一样,p将被更新为指向同一个位置。

请注意,realloc是将数据保留在原地还是移动它,取决于realloc的特定实现,以及在调用realloc时哪些内存地址已被分配、哪些是空闲的。你无法猜测它是否会原地保留数据。因此,你必须始终编写预期realloc会移动数据的代码。如果它原地保留数据,那样的代码也能正常工作。


总结

本节课中我们一起学习了realloc函数。我们了解到它的核心功能是重新分配内存大小,其典型过程是“分配新空间-复制数据-释放旧空间”。我们通过扩大和缩小内存的例子,详细分析了其步骤。最重要的是,我们认识到虽然realloc有时可能原地操作,但在编程时必须始终假设数据会被移动,并据此编写健壮的代码。正确使用realloc是管理动态内存和构建灵活数据结构(如可扩展数组)的关键。

086:使用getline读取文件 📖

在本节课程中,我们将学习如何使用C标准库中一个非常方便的函数——getline——来逐行读取文件。我们将通过一个具体的例子,详细拆解其工作原理和内存管理过程。

概述

getline函数能够自动处理内存分配,简化从文件中读取文本行的过程。本节将演示如何声明变量、打开文件、循环调用getline,并解释其内部如何处理缓冲区、内存分配和文件结束(EOF)的情况。


变量初始化与文件打开

首先,我们需要声明并初始化几个局部变量,并打开要读取的文件。

  • size_t size:用于告知getline当前缓冲区的大小。
  • size_t length:用于接收getline返回的字符串长度。
  • char *line:这是一个指针,将指向getline分配的、包含当前读取行的缓冲区。
  • FILE *f:文件指针,指向以读取模式打开的文件。

以下是初始化代码:

size_t size = 0;
size_t length = 0;
char *line = NULL;
FILE *f = fopen("names.txt", "r"); // 假设文件包含三行姓名

首次调用getline

现在,我们开始使用getline读取文件的第一行。调用getline时,需要传递三个参数的地址。

以下是调用方式:

length = getline(&line, &size, f);

传递给getline的参数含义如下:

  1. &line:指向line指针的地址。初始时lineNULLgetline会据此分配内存。
  2. &size:指向size变量的地址。getline会更新此值以反映它分配的缓冲区大小。
  3. f:要读取的文件指针。

getline的内部操作

当执行流程进入getline函数内部时,它会按顺序执行以下主要任务:

  1. 检查并分配内存:由于传入的line指针为NULLgetline会在堆(heap)上分配一块初始内存(例如8字节)来存储读取的行。
  2. 更新size变量:分配内存后,getline通过我们传入的地址,将size更新为新缓冲区的大小(例如8)。
  3. 读取文件内容getline从文件中读取一行文本(直到遇到换行符\n),将其存入缓冲区,并在末尾自动添加字符串终止符\0
  4. 返回字符串长度:函数返回读取到的字符串长度(包括换行符,但不包括\0)。例如,对于"Alex\n",返回长度为4。

调用完成后,length被赋值为4,程序进入while循环打印该行。注意,printf中无需额外添加\n,因为换行符已包含在字符串中。

后续调用与内存重用

读取下一行时,我们再次调用getline(&line, &size, f)

此时,line已指向一个有效的缓冲区,且size记录了其大小(8字节)。因此,getline内部会跳过内存分配和size更新的步骤,直接尝试将新的一行读入现有的缓冲区。这会覆盖之前的内容。如果希望保留之前读取的行,需要自行将内容复制到别处。

缓冲区扩容

当尝试读取的行长度超过当前缓冲区大小时,getline会自动进行内存重分配。

例如,当前缓冲区大小为8,但下一行需要10字节的空间(包含\n\0)。getline会执行以下操作:

  1. 在堆上分配一块更大的新内存。
  2. 将现有缓冲区中的数据复制到新内存。
  3. 释放旧的缓冲区。
  4. 更新line指针,使其指向新的缓冲区。
  5. 更新size变量为新缓冲区的大小。
  6. 继续将完整的行读入新分配的缓冲区。

这个过程对调用者是透明的,无需手动干预。

处理文件结束(EOF)

当文件所有行都被读取后,再次调用getline会尝试读取。此时遇到文件结束(EOF),getline将返回-1(一个负值)。

由于返回值length不再大于等于0,while循环的条件while(length >= 0)不再满足,循环终止。

资源清理与程序结束

在退出程序前,必须手动释放getline在堆上分配的内存,并关闭已打开的文件。

以下是清理代码:

free(line);   // 释放缓冲区
fclose(f);    // 关闭文件

总结

本节课我们一起学习了getline函数的使用。我们了解到:

  1. getline能自动处理缓冲区的分配、扩容和释放,简化文件读取。
  2. 它通过指针参数来更新缓冲区地址和大小,并返回读取的字符串长度。
  3. 当读取到文件末尾时,getline返回-1,这通常用作循环结束的条件。
  4. 使用完毕后,必须记得用free()释放getline分配的内存,并用fclose()关闭文件,以避免内存泄漏和资源占用。

087:结合getline和realloc实现动态读取与排序 📚

在本节课中,我们将综合运用本课程及先前专项课程中学到的概念,构建一个稍大的示例程序。我们将使用 getlinerealloc 从文件中读取行,并在每次读取时增加存储行的数组大小,以便将所有行一次性存储在内存中。读取完成后,我们将对这些行进行排序,并打印排序后的结果。

程序概述与核心概念 🔍

上一节我们介绍了动态内存管理的基本思想,本节中我们来看看如何将其应用于实际的数据读取和排序任务。我们的目标是:动态读取未知数量的字符串行,将它们全部存储在内存中,排序后输出。

以下是程序将遵循的核心逻辑:

  1. 使用 getline 动态读取每一行。
  2. 使用 realloc 动态扩展存储字符串指针的数组。
  3. 使用 qsort 对字符串指针数组进行排序。
  4. 输出排序结果并释放所有已分配的内存。

代码执行流程分步解析 🧩

现在,我们来逐步解析代码的执行流程。

初始化阶段

首先,程序声明并初始化必要的变量。

char **lines = NULL;   // 指向字符串指针数组的指针,初始为空
char *current = NULL;  // 指向当前读取行的指针,初始为空
size_t size;           // getline 将使用的缓冲区大小变量
int i = 0;             // 记录已读取的行数

循环读取与动态扩展

接着,程序进入一个 while 循环,持续调用 getline 读取输入,直到文件结束。

第一次调用 getline

  • getline 发现 currentNULL,因此会分配一块内存,并将 current 指向这块内存。
  • 它将 size 更新为分配的内存字节数。
  • 从标准输入读取字符串 “cat\n\0” 到 current 指向的内存中。
  • 调用成功,进入 while 循环体。
  • 调用 realloc(lines, (i+1) * sizeof(char*))。由于 lines 初始为 NULLrealloc 的行为类似于 malloc,分配一个 char* 大小的内存块。
  • current 的指针(指向”cat”)赋值给 lines[i](即 lines[0])。
  • current 设为 NULL,以便下次 getline 调用时分配新内存。
  • i 自增为 1。

第二次调用 getline

  • getline 再次分配内存(例如 5 字节),读取字符串 “ant\n\0”,更新 size
  • 进入循环体,调用 realloc(lines, 2 * sizeof(char*))。此时 lines 已指向一块内存,realloc 会分配一个新的两元素数组,将旧数组的值(指向”cat”的指针)复制到新数组的第一个位置,第二个位置未初始化。
  • current 的指针(指向”ant”)赋值给 lines[1]
  • current 设为 NULL
  • i 自增为 2。

第三次调用 getline

  • getline 分配内存,读取字符串 “bread\n\0”。
  • 调用 realloc(lines, 3 * sizeof(char*)),扩展数组并复制旧值。
  • current 的指针赋值给 lines[2]
  • current 设为 NULL
  • i 自增为 3。

读取结束与内存清理

当再次调用 getline 时,遇到文件结束(EOF),while 循环终止。

注意: 最后一次 getline 调用可能仍会分配一些未初始化的内存,因此需要释放 current 指向的这块内存。

排序与输出

程序调用自定义的 sort 函数(内部使用 qsortstrcmp)对 lines 数组中的指针进行重新排列,使得它们指向的字符串按字典序排序。排序后,顺序变为:”ant”, “bread”, “cat”。

随后,程序进入一个 for 循环来打印和释放每个字符串:

  1. 打印 lines[0](”ant”),然后释放该字符串占用的内存。
  2. 打印 lines[1](”bread”),然后释放该字符串占用的内存。
  3. 打印 lines[2](”cat”),然后释放该字符串占用的内存。

循环结束后,释放 lines 指针数组本身所占用的内存。

至此,堆内存被妥善清理,所有动态分配的内存均已释放。程序返回,主函数栈帧销毁。

总结 📝

本节课中我们一起学习了如何将 getlinerealloc 结合使用,构建一个能够动态读取、存储、排序并输出任意数量文本行的完整程序。我们深入剖析了内存的动态分配、扩展、赋值和释放的每一步过程,理解了指针数组在管理多个字符串时的核心作用。掌握这种模式对于处理未知大小的数据集合至关重要。

088:大型程序开发原则 🚀

在本节课中,我们将总结本系列课程,并讨论当你开始编写更大型程序时需要掌握的重要原则。

概述

上一节我们完成了对C语言核心语法的学习。本节中,我们将目光转向软件开发实践,探讨构建更可靠、更易维护的大型程序所需遵循的基本原则。无论你的目标是编写中等规模的数据分析程序,还是立志成为一名专业的软件开发人员,这些原则都至关重要。

核心原则

以下是构建大型程序时需要关注的几个关键原则。

1. 模块化设计

将大型程序分解为多个独立、功能明确的模块。每个模块应具有高内聚性和低耦合性。这可以通过函数和头文件来实现。

代码示例:

// math_operations.h
#ifndef MATH_OPERATIONS_H
#define MATH_OPERATIONS_H
int add(int a, int b);
int multiply(int a, int b);
#endif

// math_operations.c
#include “math_operations.h”
int add(int a, int b) {
    return a + b;
}
int multiply(int a, int b) {
    return a * b;
}

2. 清晰的接口与文档

为每个模块和函数定义清晰的接口,并使用注释说明其用途、参数和返回值。

代码示例:

/**
 * 计算两个整数的最大公约数。
 * @param a 第一个整数
 * @param b 第二个整数
 * @return a和b的最大公约数
 */
int gcd(int a, int b) {
    // 使用欧几里得算法
    while (b != 0) {
        int temp = b;
        b = a % b;
        a = temp;
    }
    return a;
}

3. 防御性编程与错误处理

始终假设输入可能无效,并编写代码来优雅地处理错误情况,例如检查函数参数和返回值。

代码示例:

/**
 * 安全分配内存。
 * @param size 需要分配的字节数
 * @return 指向已分配内存的指针;若分配失败则返回NULL
 */
void* safe_malloc(size_t size) {
    void* ptr = malloc(size);
    if (ptr == NULL) {
        fprintf(stderr, “内存分配失败: 无法分配 %zu 字节。\n”, size);
        // 根据程序逻辑,可能在此处退出或抛出错误
    }
    return ptr;
}

4. 代码复用与抽象

避免重复代码。将通用的功能抽象成函数或库。识别模式并创建通用解决方案。

5. 测试与验证

为关键功能编写测试,确保代码在各种情况下都能按预期工作。这包括单元测试和集成测试。

学习路径建议

如果你的学习目标只是编写中等规模的数据分析程序,那么掌握上述原则已足够满足需求。

如果你计划成为一名专业的软件开发人员,这些原则是基础,但你还需要进一步学习。本节内容将是一个绝佳的起点,引导你继续学习软件工程的其他课程,更深入地了解更多的设计原则和最佳实践。

总结

本节课我们一起学习了构建大型C语言程序的核心原则。我们探讨了模块化设计、接口文档、防御性编程、代码复用以及测试的重要性。记住,编写能运行的代码只是第一步,编写清晰、健壮且易于维护的代码才是优秀程序员的标志。希望这些原则能帮助你在编程道路上走得更远。

089:名册规划 📝

在本节课中,我们将学习如何为一个中等规模的名册程序规划代码。我们将遵循自顶向下的设计方法,从高层次步骤开始,逐步细化到具体的函数和数据结构定义。


概述

我们将通过一个手动操作的例子开始,理解程序需要完成的任务。接着,我们会将这些任务分解为一系列步骤,并规划出实现这些步骤所需的数据结构和函数。核心目标是设计一个程序,它能读取包含学生和课程信息的输入文件,生成每个课程对应的学生名册。


第一步:手动操作示例

首先,我们通过一个具体例子来理解程序的功能。假设输入文件包含三名学生:Romeo、Juliet 和 Tybalt。

  • Romeo 选修了 Duel 300Romance 101
  • Juliet 选修了 Romance 101Poison 352
  • Tybalt 选修了 Duel 300

接下来,我们需要找出所有不重复的课程名称:

  • Duel 300(出现两次,但只记录一次)
  • Romance 101
  • Poison 352

然后,为每一门课程创建名册:

  • Duel 300 的名册包含:Romeo 和 Tybalt。
  • Romance 101 的名册包含:Romeo 和 Juliet。
  • Poison 352 的名册包含:Juliet。

第二步:记录操作步骤

上一节我们手动完成了一个示例,现在需要将这个过程精确地记录下来,作为我们程序的蓝图。

以下是完成此任务的高层次步骤:

  1. 读取输入文件,解析其中的数据,并将其组织成程序内部可以处理的概念单元。
  2. 生成一个列表,包含输入文件中出现的所有不重复的课程名称。
  3. 针对列表中的每一门课程,生成一个输出文件,列出选修该课程的所有学生。

这些步骤听起来比较抽象,但请记住,在自顶向下设计中,我们正是要从高层次步骤开始,逐步分解为更具体、更低层次的步骤。这有助于我们将大问题拆解成一系列小问题。


第三步:泛化步骤并规划函数

现在,我们需要将这些步骤进一步泛化,并思考如何用代码实现。

对步骤进行一般化描述:

  1. 从命令行参数 argv[1] 指定的文件中读取输入,将结果称为“名册”(roster)。
  2. 根据名册,生成一个所有课程的唯一列表,我们称之为 uniqueClassList
  3. 根据唯一课程列表和名册,为每一门课程写入一个输出文件。

由于这些步骤已经足够通用,我们暂时不需要深入到具体的底层算法细节。

接下来,将每个复杂步骤抽象为一个函数。这样,我们的 main 函数将主要由这几个函数调用和一些错误检查构成。我们无需在 main 函数中解决所有细节问题。

一个理想的 main 函数结构可能如下:

int main(int argc, char **argv) {
    // 错误检查,例如检查参数数量
    rosterT roster = readInput(argv[1]); // 步骤1
    classListT uniqueClasses = getUniqueClasses(roster); // 步骤2
    writeRosters(uniqueClasses, roster); // 步骤3
    // 清理内存等其他操作
    return 0;
}

第四步:设计数据结构

为了具体实现这些函数,我们需要更精确地定义数据的类型。例如,readInput 函数返回一个“名册”,那么这个名册应该是什么类型?

我们定义一个类型 rosterT(后缀 _T 是表示类型的常用约定)。那么,rosterT 应该如何定义?

从概念上讲,一个名册包含多个“学生”。在C语言中,我们可以用一个数组来存储这些学生。由于数组大小不固定,我们还需要一个整数来记录学生的数量。

因此,rosterT 可以设想为这样一个结构:

  • 一个指向“学生”指针数组的指针(studentT **)。
  • 一个表示学生数量的整数(int)。

用代码定义如下:

typedef struct {
    studentT **students; // 指向学生指针数组的指针
    int numStudents;     // 学生数量
} rosterT;

现在,我们需要定义 studentT。一个学生有:

  • 一个名字(字符串,char *)。
  • 一个课程列表(字符串数组,char **)。
  • 一个表示课程数量的整数(int)。

因此,studentT 可以定义如下:

typedef struct {
    char *name;          // 学生姓名
    char **classes;      // 指向课程名数组的指针
    int numClasses;      // 课程数量
} studentT;

综合起来,我们规划的数据结构关系如下图所示:变量 students 指向一个数组,数组的每个元素是一个指向 studentT 结构体的指针,而每个 studentT 结构体内部又包含指向其课程名数组的指针。



总结

本节课中,我们一起学习了为一个名册程序进行代码规划的完整过程。我们从手动示例开始,明确了程序的目标。然后,我们将任务分解为“读取输入”、“生成唯一课程列表”和“写入名册文件”三个高层次步骤。接着,我们规划了实现这些步骤所需的函数接口和核心数据结构 rosterTstudentT。至此,高层设计和核心数据模型已经就绪,为下一步具体编写各个函数打下了坚实的基础。

090:扑克项目最终部分 🃏

在本节课中,我们将完成扑克牌项目的最终部分。我们将学习如何处理输入和未知牌,并通过编写主函数将所有代码整合起来,实现蒙特卡洛模拟。

项目概述与目标

上一节我们介绍了扑克牌项目的核心逻辑。本节中,我们来看看如何整合所有代码,特别是处理包含未知牌的情况。

我们的目标是编写一个主函数,该函数能够读取包含未知牌的输入,并通过蒙特卡洛模拟来评估牌手的胜率。

处理未知牌的挑战

项目中一个看似棘手的部分是如何处理未知牌。例如,考虑以下包含两个手牌的输入示例:

  • 手牌1:红桃K,未知牌?0,未知牌?1
  • 手牌2:黑桃A,未知牌?0,未知牌?2

注意,两个手牌共享同一个未知标识符?0。这意味着在我们的实现中,必须确保两个手牌中的?0最终被赋予相同的随机牌值。实际手牌需要至少5张牌,但这里我们使用一个较小的例子来说明原理。

解决方案:使用未知牌结构

我们可以利用已学过的概念——指针、数组和结构体——来解决这个问题。我们将创建一个结构来追踪未知牌。

由于我们已经有一个deck类型来表示一组指向card的指针,我们将复用这个类型。我们的未知牌结构将包含一个deck数组,数组中的每个deck对应一个特定的未知标识符(例如,?0对应一个deck?1对应另一个)。

与普通的牌堆不同,这些deck中的指针将指向各个手牌中的“占位符”卡片,以便后续填充。

构建未知牌结构:分步解析

以下是构建该结构的具体步骤:

  1. 处理手牌1的红桃K:为手牌1和这张牌分配内存。
  2. 处理手牌1的?0:创建一张占位符卡片,可以将其值初始化为无效值(如-1),这样如果后续忘记赋值,更容易发现错误。由于这张牌是未知的,我们需要更新未知牌结构:为?0分配一个deck,并使其单元素数组指向刚创建的这张占位符卡片。
  3. 处理手牌1的?1:类似地,为?1创建一个deck,并使其指向这张新的占位符卡片。
  4. 处理手牌2的黑桃A:为手牌2和这张牌分配内存。
  5. 处理手牌2的?0:这张牌也是未知的?0。我们不需要创建新的deck,而是向?0对应的现有deck中添加一个元素,使其也指向手牌2中新建的这张占位符卡片。
  6. 处理手牌2的?2:创建占位符卡片。由于是?2,我们需要创建一个新的deck,并使其指向这张占位符卡片。

为未知牌分配随机值

构建好未知牌结构后,我们需要用它来为占位符卡片分配随机值。

首先,我们需要知道要抽取多少张随机牌。在这个例子中,我们需要3张(对应?0?1?2)。通常,这等于未知标识符的总数。

假设我们洗牌后,顶部三张牌是:梅花4、红桃Q、梅花7。我们如何将手牌中的卡片设置为这些值?

  1. ?0赋值:所有需要被设置为梅花4的卡片,都可以通过?0对应的deck中的指针找到。我们遍历这个数组,并使用找到的指针来引用那些需要被修改的卡片,将其值设置为梅花4。
  2. ?1赋值:使用?1对应deck中的指针,找到需要修改的卡片,并将其值设置为红桃Q。
  3. ?2赋值:对?2和梅花7重复相同的过程。

如果我们想用另一组随机牌重复此过程(例如进行下一次蒙特卡洛模拟),只需重新洗牌,并再次遍历未知牌结构即可。

核心逻辑代码示意

以下是上述逻辑的简化代码示意:

// 假设 unknown_cards 是一个数组,每个元素是一个 deck,对应一个未知标识符
// 假设 random_drawn_cards 是一个数组,包含从牌堆顶部抽取的随机牌

for (int i = 0; i < num_unknown_identifiers; i++) {
    // 获取当前未知标识符对应的 deck
    deck *d = &unknown_cards[i];
    // 获取对应要分配的随机牌
    card *c_to_assign = &random_drawn_cards[i];

    // 遍历该 deck 中的所有指针(指向各个手牌中的占位符卡片)
    for (int j = 0; j < d->n_cards; j++) {
        card *placeholder = d->cards[j];
        // 将占位符卡片的值设置为随机牌的值
        placeholder->value = c_to_assign->value;
        placeholder->suit = c_to_assign->suit;
    }
}

总结与下一步

本节课中,我们一起学习了如何完成扑克牌项目的最终整合。我们探讨了处理包含共享未知标识符的输入数据的挑战,并设计了一个利用指针、数组和结构体的解决方案来追踪和填充未知牌。最后,我们概述了如何使用这个结构,通过蒙特卡洛模拟为未知牌分配随机值并评估胜率。

现在,你已经掌握了所有必要的概念和策略,可以开始动手编写主函数,将之前课程中编写的代码模块(如牌堆操作、手牌评估等)与本节课的输入处理和模拟逻辑结合起来,最终完成这个项目。

posted @ 2026-03-29 09:35  布客飞龙I  阅读(1)  评论(0)    收藏  举报