109-人人都能学会的编程入门课

开篇词 | 别闹了，学编程 ≠ 学语言

你好，我是胡光。欢迎加入专栏和我一起学习编程。

我喜欢编程，从高一开始接触信息学竞赛的时候，就喜欢。我的信息学竞赛成绩，不能和同时期的那些真正的牛人相比，就算是小有成绩。后来我被保送到了哈尔滨工程大学，本硕博连读，外加学费全免。在本科期间，我参加了大学生的算法编程竞赛，并拿到了 ACM 竞赛亚洲区金牌，进过两次全球总决赛。可以这么说，我是在激烈的竞赛环境中，成长起来的。

毕业后进入百度，一直做算法研发相关的工作。期间，我开发了部门内部的第一版推理引擎，并顺利在人物关系推理等应用场景中落地实施。后来由于兴趣，自己开始从事教育行业，希望能够帮助更多的人从零开始学习编程，然后进入互联网公司从事相关职业。

作为教育行业的创业者，学生能够学有所成，是我最具成就感的事情。在我所教导的学生中，双非院校中的本科生最高毕业薪资包 59.5 万，211 类院校学生毕业平均薪资达到 35 万以上。

今天我来到极客时间，希望也能带你了解如何学习编程，掌握编程的精髓。

一个蕴含编程真谛的等式

程序 = 算法 + 数据结构

学编程，其实就是学习编写程序，那么程序到底是什么呢？上面是关于程序本质的一个非常著名的等式。其中数据结构的作用，是将问题中的相关数据，表示到计算机中，算法则是一套计算并且得到结果的流程。

这个公式中包含了两个最重要的部分，数据的表示和数据的计算，所以学会了这两部分，就等于学会了写程序，这就是这个等式经典的原因。但是对于刚刚接触编程的你来说，对着这个等式看会有两个问题。

如果你按照这个等式来，学完语言，就学习算法数据结构，肯定会感觉难度太大，我知道的很多人就是这样被“劝退”的。

如果你不听从这个等式的安排，只是大量学习语言，你会发现虽然学了各种语言知识，但很多的编程问题还是搞不定，甚至有些时候明明知道思路，却无法用程序语言表达出来，这也会极大地降低你学习编程的信心。

那么，是上面这个公式错了么？不，上面的公式是对的，这一点我丝毫没有质疑过。但我想说的是，上面的公式只描述了编程中的一部分，只是“程序”这一部分。而编程，不仅有程序，还有程序的设计过程。

因此，我想告诉你一个新的等式，这个新的等式，就是我将在这个专栏里带给你的学习编程的方法：

程序设计 = 算法 + 数据结构 + 编程范式

这个新等式包含了程序设计中重要的三部分，从前到后，重要程度依次递减。如果把程序比做人的话，那么算法是灵魂，数据结构是骨架，而编程范式就是性格。

对于三者的学习顺序呢，则应该是反过来的：当你掌握了一定的语言基础以后，应该先从编程范式开始学起，然后是数据结构，最后是算法，整个过程采用传统的螺旋式上升的方式进行，这样才能获得最好的学习效果。正如许多会学习的人，他们在学习中往往不会纠结于一个点，而是会反复来回地琢磨理解，从而达到对知识的融会贯通。

所以在专栏的设置上，除了有语言基础的讲解、部分算法及数据结构的讲解外，我还在中间加入了一个特别的章节，叫作“编码能力训练篇”，这里其实就是帮你把分析具体问题的思路转换成代码的技巧，也就是实战写代码的能力。这是我从具体教学环节中总结提炼出来的内容，也真真切切在线下帮助了一批又一批的学生，让他们在短时间里扎实地提升了编码能力。再配合后续的算法数据结构学习，这样进行下来，半数以上的学生最后能在 1 小时内，没有 Bug 地打出来红黑树的全部代码，顺利通过线上标准测试集的测试。

课程设置

从内容上来说，我将这个专栏设置为四部分：语言基础篇、编码能力训练篇、算法数据结构篇和综合项目篇。

语言基础篇，基于 C，我会带你体验一些有趣的语言特性，让你看到编程不是单纯枯燥的知识理解，而是轻松、有趣的思考与实战过程。这一部分与其他资料最不一样的是，以往大家都认为 C 语言是黑白的，在我的专栏里，C 语言是彩色的。以往大家都认为指针很难学，在我的专栏里面，我会教你一句话，通过这一句话，你就可以把指针的内容学得透彻。以往大家都认为，程序只是人写的，在我的专栏里面，我会教你利用程序写程序的技巧：预处理命令。

总而言之，这部分既是基础，又是我个人编程学习经验的总结，让你轻松入门 C 语言。由于专栏篇幅有限，我只会在专栏中讲解那些，容易被你忽视，容易被你误解的知识。如果你想更进一步详细了解 C 语言，我还会给你推荐几本比较好的图书，为此我还专门买了 15 本现在市面上面最畅销的 C 语言书，替你做了对比与筛选。

编码能力训练篇，主要解决你编程学习过程中，学了语言却写不出程序的尴尬局面，让你真正能够将思维变成代码。这里的课程设计原则是，减少你面对的问题数量，一步一个脚印地提升编码能力。举个例子，如果我一上来就给你讲算法，那么你不只要理解相关算法思维，还要同时理解相关算法的具体代码实现过程，这样就在你面前同时放了两个你不太擅长的问题。在这一篇里，我尽量在你面前放置一个陌生问题（编码技巧），和一个不太陌生的问题（简单的算法和数据结构），让你能够在舒缓的学习曲线中不断提升现有的编码能力。

算法数据结构篇，看到算法二字，你不要害怕，相信经过了编码能力训练篇的洗礼，再来面对算法和数据结构相关知识的时候，你只需要站在前面的基础上建立更深层次的理解。并且，我会教你另外一个学习数据结构的等式“数据结构 = 结构定义 + 结构操作”，其实就是学习这两个内容，就是这么简单的两步。

综合项目篇，这一模块主要是为了检验你之前的学习效果而准备的。你会接触到两个小项目，一个较难的项目，你需要仿照 Google 测试框架开发一个自己的测试框架，第二个项目较简单，就是实现一个带变量的表达式计算程序。项目过程中，我将带你从需求到设计逐步分析、拆解，并会给出一部分核心代码的讲解。其余部分，我希望你可以通过自己的思考和文章中的引导，自行补全没有给出的部分代码，从而完成整个项目的开发与实现。

总地来说，我希望通过接下来近 3 个月的学习，带你达成三个小目标：

掌握 C 这门迁移性最好的语言，以便日后学习其他语言。理解了 C 语言，你就理解了操作系统，也就是理解了所有语言的运行环境。

掌握从简单的问题出发训练编程技巧的方法，为日后将思想转换成代码减少障碍。

掌握算法、数据结构学习的关键方法，为自学其他数据结构和算法打好基础。

如果这门课程，能够帮助你逐渐的建立学习编程的方式方法以及建立学习过程中的信心，那将是我写这个专栏最大的快乐之处。

如果篇幅够长，我想跟你说的还有很多很多，可十几万字无法穷尽所有编程知识，但我希望这个专栏能够让你拥有自学的能力，强化独立思考的能力。我还希望，你可以基于这个专栏内容，总结升华出更棒的内容，讲给其他人听。计算机是一个新兴学科，我坚信，现在我们所接触的所有和教学相关的方式方法，在将来都将被改进。

如果说今天想让你记住点儿什么的话，那就是“学编程不等于学语言”。编程学习就像是探索一片森林，不同的人，会带你走不同的路线。站在你面前的我，已经全副武装，就等着你的加入了，我将带你去到这片编程森林中最有趣的地方，你不来一起看看么？

欢迎你在留言区，写下对于编程学习的认识以及你希望达到的目标，也好让我知道，应该给你准备哪些沿途中的装备。

我是胡光，我们留言区见！

01 | 学习编程，我到底该选择哪门语言？

你好，我是胡光。欢迎来到我的极客时间专栏。在接下来的两个多月里，我将陪伴在你的每一天的清晨或是夜晚，在人潮拥挤的上班地铁上，在你家里的书桌前，再或者是在你公司楼下的咖啡厅里，每天 10 分钟，让好学的你，有所收获，就是我的任务。

那些年，我学过的编程语言

面对编程这个话题，或许你已是一位编程老手，对编程熟悉无比，现在是想查缺补漏；亦或许你是一个纯新手，对编程一无所知，学习完全是从 0 开始。不管哪种情况，在我们讨论编程学习的时候，怎么都绕不开一个话题，那就是语言选择。

鉴于以往的工作经历，我了解或者熟悉的编程语言有十几种之多，包括：

最能反映系统本质的 C 语言

叫人难以捉摸的 C++

天生就格式优美的 Python

上古级的 Pascal

神奇的函数式编程语言 JavaScript

微软系的王牌语言 C#

被誉为世界上最好语言的 PHP

使用人数最多的 Java

能够方便操作系统的 Shell 脚本语言

还有我自己开发的一门娱乐级编程语言 Hython

此外，还有一些仅仅是使用过，能看懂的语言，就不列出来了。

你可能会有疑问了，为什么我会这么多编程语言呢？原因很简单，工作中是一个边学习边工作的过程，不同编程语言擅长做的事情不一样，让专业的语言干专业的事情，这是一个程序开发人员最基本的认知，所以我能学会多种编程语言也是情理之中。

你可能又会问了，学习了这么多编程语言，难道不会造成混淆么？其实，编程语言设计者，更多的还是为了让人们使用自己的编程语言进行开发，所以语言设计本身都会有前辈语言的主流特征，这也就是为什么，只要你学习了一门主流语言后，会大大降低你学习第二门语言的成本。可第一门语言的选择，是门技术活，这也是今天我们要讲的主题。

最后你是否好奇：“我为什么还要自己开发一门娱乐级编程语言呢？”简单点儿回答就是：自娱自乐。正式点儿的回答就是：经历了开发编程语言这个过程，会对很多语言的特性理解得更深刻，知其然，知其所以然。所以当你自己能发开出一门编程语言的时候，站在开发者的角度再去学习其他编程语言，简直就属于“降维打击”般的学习。

Pascal、C、Python，哪个是你的首选？

刚才列举了十多种编程语言，接下来我会以我的亲身经历，来说说学习不同语言都有什么样的体验，以及我在学习这些语言的过程中，遇到的惊喜和踩过的坑。

我从 2006 年开始接触编程，那年，我们微机老师向我们推介一门除数学、物理、生物和化学以外的第五大学科竞赛“信息学竞赛”。我当时对计算机的印象，还停留在《热血传奇》《半条命》和《红色警戒》那个阶段，没错，我对计算机的认识都是和游戏相关。当时老师在台上介绍了一大堆东西，现在我已经忘得一干二净了。

但有件事至今我还记得：课间的时候，我问老师：老师，编程学好了，能做游戏么？老师说了一声：恩！就是那种不置可否的“恩”，可以自行脑部相声演员岳云鹏的那个"恩"。对于幼小的我来说，这就是肯定句，从此我就踏上了学习编程的道路。

我所接触的第一门编程语言是 Pascal，这是一门上古级的编程语言，语法风格类似现在的 Python 和 C 的混搭风。现在没有多少人使用的原因，我猜是因为其特立独行的语法规则。Pascal 程序需要你在最开始把所有需要的变量都定义一个遍，然后再描述程序的过程逻辑。所以当时我们打趣说，作为 Pascal 的忠实用户，我们比那些使用 C 语言的要思虑周全。

下面放一段 Pascal 的“判断素数”的程序，你自己感受一下这门上古级语言那遮挡不住的力量。这段代码你看不懂没关系，只是让你对 Pascal 有个直观的感受。

program JudgePrime;                // 程序名称
var x, i : integer; f : boolean;   // 变量仅能在此定义
begin
    readln(x);
    f := true;
    if x < 2 then
    begin
        write('F');
        exit;
    end;
    for i:=2 to trunc(sqrt(x)) do
        if x mod i=0 then f:=false;
    if f then write('T') else write('F');
end.

在这段用 Pascal 写的判断素数的程序中，第一行是程序的名字，第二行就是定义变量部分，并且变量只能在这里定义。从 begin 到 end. 中间就是我们所谓的程序逻辑部分了，是不是有种看 Python 代码的感觉？

你能想象么，那时稚嫩的我，在定义变量名这件事儿上，把 26 个英文字母都用遍了以后，最后不得不使用类似 “aa”“bb”“ccd” 这种变量名，往事不堪回首啊。

过了两年，为了参加 ACM 竞赛，不得不学习 C++ 了，准确地说，是学习 C 语言风格的 C++，就像印度人说英语，怎么说怎么一股咖喱味儿。因此，在转 C 语言之前，我还有点儿担心这个过程会比较坎坷，谁知道，就用了几天的时间，就搞定了我编程时候需要的几乎全部语法。你要知道，我学习 Pascal 的时候，可是花了四个月啊！

从这以后，我才意识到那句话的真正含义，那句话是这样说的：“语言从来不是编程的障碍，思维才是”。所谓“思维”大多数的时候，反映出来的是“编程技巧”，更形式化一点，我们叫它 “编程范式” 和 “算法数据结构”，这部分的东西，我后面还会着重讲解，并且会教你一些提升编程技巧方法。

当时的 C 语言，真是一上来就让我欲罢不能。下面我给你来一段判断素数的 C 语言程序，你来感受一下，就像感受一个刚从牢笼中挣脱出来的鸟，正如汪峰有首歌所唱的：这是自由的感觉！

#include <stdio.h>

int main() {
    int x;                   // 定义变量 x
    scanf("%d", &x);
    int f = 0;               // 定义变量 f
    for (int i = 2; i * i <= x; i++) { // 定义循环变量 i
        if (x % i) continue;
        f = 1;
        break;
    }
    if (f) printf("F\n");
    else printf("T");
    return 0;
}

和上面的那段 Pascal 程序对比，你发现差别了么？对！就是变量定义这里，C 语言中我想在哪里定义，就在哪里定义！从此我跟 C 语言进入了蜜月期。

C 语言除了可以随处定义变量这个特性，它与 Pascal 语言还有什么不同的特性呢？这里就不得不提到我曾经做数独程序的经历了。有一次我在做一个数独的题目，就是每行、每列和每个 3*3 的宫格内部，不重复地填上 1～9 这 9 个数字。

这个问题我曾经用 Pascal 语言做过，解题思路是：每次向函数中传入一个代表数独的数组作为参数，然后不断尝试修改这个数组中相应位置的值，如果尝试进行不下去了，就回到上一个状态，重新尝试。

我就原封不动地将 Pascal 语言的解题思路搬到了 C 语言中，但怎么调试都是错的，我自己反反复复检查逻辑，可就是查不出错误。在挣扎了一下午以后，我终于忍不住，求助了学长，这才发现，有一个关键的语言特性，C 语言和 Pascal 完全不一样。下面我就说说这个事儿，你现在听不懂没关系，希望你记住这个事情，等到我们一起学习了一段时间以后，你再回头来细看这段。

普通变量向函数中传值，就是将原变量中的值拷贝给函数参数的过程，这个过程，我们称作“实参给形参赋值”。原变量就是实参，函数参数就是形参。在这个过程中，本质上还是两个变量，两片独立的存储位置，也就意味着，我们对形参的改动，不会影响实参。这一点上，C 语言和 Pascal 是完全一致的，下面就要说到不太一样的地方了。

请观察图 1，在 Pascal 中，如果你将一个数组作为参数传递给一个函数，函数内部还是会复制一份这个数组，也就是说，在 Pascal 中数组的传递过程和普通变量的传递过程没有任何区别。

你再观察图 2，图 2 展示的是 C 语言中数组作为参数传递的方式，你在图中会看到一个 “0x1234” 的信息，我们称之为地址，就类似于你家的门牌号。当我们传递一个数组时，其实在传递的是数组的首地址，也就是说，无论实参还是形参，实际上指向同一片存储区！

总结来说，对于数组，Pascal 函数内外，是两个互不相同，互不影响的存储区。C 语言则是函数内外是同一片存储区，任何一个修改，都意味着外部的数组也被修改了。就是这点差异，导致我用一下午也没找到错误的原因！

看了上面这段，不知道你可不可以理解我当时的困扰。我当时用 Pascal 的语言特性去检查 C 语言的程序，从逻辑上来讲，当然是发现不了任何 Bug 了。当时我还以为这个语言特性，是 C 的特立独行，后来才发现，特立独行的是 Pascal。

从我的这段经历你可以发现，初学编程选择什么语言作为自己的第一门语言是多么重要。如果你选择一个比较“偏”的语言，形成了它独特的语言特性，可能会对你学习其他语言造成不小的困扰。而 C 语言，由于它的共通性，很少会出现这样的问题。

最后给你介绍的一门语言就是 Python，上面我们欣赏过了从 Pascal 过渡到 C 语言的神清气爽，那你知道如果你学完 C，再学 Python，是什么感觉么？简直就跟吃了一大口芥末一样，提神！下面来看看 Python 的判断素数的一个程序。

#!/usr/bin/env python
# coding=utf-8
x = int(input())
i = 2
while i * i <= x:
    if x % i == 0:
        print("F")
        break
    i = i + 1
else:
    print("T")

对比上面的两段代码， Python 的这份代码，是不是看着就简洁、清爽？而 Python 为什么被评价为天生就格式优美呢？那是因为，在 Python 中，如果你不按照缩进组织程序的层次关系，你的程序根本没有办法正常运行。

不同的人编写代码可能有不同的风格，就像 C 语言，10 个人可能就有 10 种风格，但 Python 的代码风格就像人的指纹，它是天生的，不管多少人用 Python 编写代码，可能也只有一种风格。所以无论你是否写过程序，在写 Python 的时候，都将写得很漂亮，很舒服。

最适合学习编程的操作系统是 Linux，Linux 中有一个核心设计思想，叫做“一切皆文件”，理解了文件，就理解了整个 Linux 操作系统，这里说的文件，可不是你所认为的常规的 windows 文件。Linux 世界中的文件，就像是我们这个世界中的原子一样，是一种本质。

而 Python 中也有一个类似的核心设计思想，就是“一切皆对象”，理解了什么是对象，你就理解了 Python。而这么抽象的概念，我不认为我现在可以用两三句话就给你讲明白，不过还是那个道理“语言从来不是编程的障碍”，关于对象这个概念，在你日后对编程的知识逐渐丰富起来以后，自然就会明白了。

编程入门，舍我其谁：C 语言

听完了以上三种语言的介绍，你可能已经打定主意准备把界面精练的 Python 作为自己学习编程的入门语言。但是如果看完下面我给你的这张图，你可能需要再考虑考虑。

上图中的时间，只是一个参考，可能你比较有天赋，会比图中所标记时间用时短，可绝大多数的人，只会多于图上的时间，不会更少的。你会看到图上有两条学习路径，绿色的学习路径用时两个月多一点，红色的学习路径用时四个月。其实这张图，我就是想跟你说明，在我们学习过程中的一个重要的概念：学习路径。一个合理的学习路径，可以帮助你大大缩短整体的学习时长，毕竟你的时间才是最大的成本。

其实正如你所看到的，你用相同的时间掌握了 C 语言后，会对你学习其他语言有很大的帮助。反观，如果你一上来掌握的就是拥有很多奇怪语法特性的语言，不仅要花很长时间学，在日后的学习中，你会发现这些语法特性在其他语言甚至都找不到。基于这类知识锻炼出来的编程思维，是不具备延展性的。所以，在选择第一门语言的时候，一定要选择简洁、高效、不拘泥于语法特性的语言。就像学习武功一样，摒弃掉花拳绣腿，去稳扎稳打的进行练习，才是快速成长的诀窍。

这里，请记住：学习编程不等于学习语言，前者包含后者，也就是说想学好编程，不仅是学好语言，还有很多比语言更难的东西等着你呢。

既然要给你讲编程，我决定选择一门既可以带你潜入底层系统一窥究竟，又可以顺畅简洁表达逻辑，还没有特别多奇奇怪怪的语法特性的语言。我希望借助这门语言，让你在学习编程的过程中，能够专注于编程思维训练本身，帮助你一步一个脚印地学习编程，培养编程思维。这门语言，就是我们前文说到的 C 语言。

我有朋友是这样形容 C 语言的，我觉得很贴切，拿过来用一下，他说：学编程就像是学乾坤大挪移，而 C 就是语言中的九阳神功。

推荐书籍

专栏里面呢，由于篇幅有限，没有办法穷尽 C 语言的所有知识。不过，我讲的会是一些容易被你忽视的，容易被你误解的，以及你自学不容易学会的知识点。而关于 C 语言更多的知识，我专门买了市面上最畅销的 15 本 C 语言的书籍，经过一番筛选之后，我选出来了以下三本小册子，推荐给你。之所以说是小册子，那是因为他们每一本较其他 C 语言的相关书籍都很薄，内容也很详实准确，并且在内容上，三本有着递进的关系。

第一本：《啊哈 C 语言》

这本由电子工业出版社出版的《啊哈 C 语言！》被叫做“厕所 C 语言教科书”。这不是在说这本书很差，恰恰相反，这是一本浅显易懂的 C 语言入门书籍，即使是利用蹲马桶的时间看上一会儿，你也是看得懂的。并且和书籍配套的还有《啊哈 C 语言！》特别版编译器，会使得你在学习 C 语言基础知识的时候，更加轻松，自在。

第二本：《C 专家编程》

这本由人民邮电出版社出版的《C 专家编程》，会是你入门 C 语言以后的第二本必备书籍。这本书，会从 C 语言的发展历史讲解 C 语言中一些语法特性，以及相关语言特性当初被设计的目的，以及现有的缺陷，会给你一个更深层次的解释。并且，作者给你展现的，不仅仅是教你 C 语言语法，更多的是在给你讲 C 语言是怎么被设计出来的。这会使得你对于你今后所写的每一行代码，都会理解得比旁人深刻。

第三本：《C 缺陷与陷阱》

这本也是由人民邮电出版社出版，可以说是《C 专家编程》的延续，针对性会更强，直接指出 C 语言中各种各样的问题，并且加以分析。正所谓人无完人，那么由人所设计出来的语言，当然也没有完美的。你作为外行的时候， C 语言的美足够好好体会和欣赏，可想成为内行，你就必须要知道你所使用的工具，有什么缺点和短板，这样才能真正的在日后应用中，游刃有余。

课程小结

说了这么多，今天我只是想让你记住一件事情，不同的学习路径，会有不同的时间成本。C 语言只是我们入门编程的一个载体，也是最有效、最深刻的一个载体。从 C 语言入手，会使得你的总时间成本最低。永远记住，扎扎实实，稳扎稳打，才是真正的捷径。

最后呢，请你想一想，在你的学习过程中，有没有哪块知识，是你身边的行业前辈们告诉你很重要的，你一开始忽视了，然后过了好久，才发现，前辈说的对的，欢迎留言给我。

我是胡光，今天我们就先聊到这儿，下期内容，我们不见不散。

我录制了一个关于编程环境说明的视频，如果有对编程一点也不熟悉的朋友，可以看看这个视频，了解一下编程环境。

Sorry, your browser doesn't support embedded videos.



00:00 / 08:48



02 | 第一个程序：教你输出彩色的文字

你好，我是胡光。从今天开始，我将带你正式进入 C 语言的编程世界。我们总会听到这样一种说法：兴趣是最好的老师。引起你对编程的兴趣，就是今天这讲的目的。如果你之前对于 C 语言的认知还停留在黑白纯色阶段的话，今天就让我们一起来进入一个彩色的 C 语言世界，来，跟我一起给它点儿颜色！

今日任务

先来看看今天这 10 分钟，我们要完成的任务。今天你将会学到如何设计一个读入字符串，并且按照字符串信息中规定的颜色，输出相应颜色文本内容的方法程序。

例如当我们：

输入：red:color content， 输出红色的：color content

输入：blue:color content，输出蓝色的：color content

输入：yellow:color content，输出黄色的：color content

下面这张图呢，就是当你完成这个任务时，你程序应该有的一个输出效果：

那么想完成这个任务，我们都需要做哪些准备呢？又有哪些概念需要我们理解和掌握的？请你多点耐心，听我一步步讲。

必知必会，查缺补漏

1. 输出函数：让计算机“说出话”

我还记得我当年学习 C 语言的时候，最兴奋的地方就是计算机可以根据我的“指令”打印出一句我想说的话来。这个功能虽然简单，但它也预示着我们可以初步“控制”计算机了。下面我附了一段代码，代码后面有相应的中文说明，你可以先看看。

#include <stdio.h> // 文件包含，之所以能使用 printf，跟这行代码有关

int main() {       // 主函数部分，整个程序的入口位置
    printf("hello my friend!"); // 打印函数
    return 0;      // 暂不介绍，不是重点
}

不知道你有没有理解这段代码，我多啰嗦两句。上面代码中，// 后面的内容属于注释，它是用来说明代码功能的，不属于程序部分，而且就算写在程序里面，也不会影响程序逻辑的正确性。

如果你在编译器中运行上面这段程序，就会看到如下一段输出：

hello my friend！

恭喜你，现在你已经成功与我们的计算机朋友打招呼了，这是一个好的开始，不是么？

printf 函数就是所谓的“输出函数”，现在你可以尝试在 printf 函数的双引号中间换一些其他内容，来试试效果了。但要注意，printf 后面的括号和双引号（且是英文输入法环境下）是必须要有的，其中双引号里面的内容才是最后程序输出的内容。至于为什么是这样，你也不用想，一开始，先死记住就可以了，或者换句话说，这就是规则。有些规则，本来就没有为什么。

我学习计算机的时候，我的老师就让我把上面的代码敲了 N 遍，最后都成了肌肉记忆。现在想想，也是这么回事。

上面那段代码，如果你玩够了的话，可以将 printf 函数换成下面的内容，看看输出结果：

printf("Hi, my friend:\n\tthis is the first day I know you.");

你所看到的输出内容，应该与下面这段内容相似：

Hi, my friend:
    this is the first day I know you.

我们看到有了换行效果，且第二行开头有了缩进。

你要是使用过 Word 的话，应该知道 Tab 键吧，对，\t 的效果就相当于在相应位置敲击了 Tab 键， \n 的效果就相当于敲击了换行。在 C 语言中，\t、\n 都属于转义字符，还是和上面一样，它是 C 语言定义的规则，你也先不用问为什么，记住它，然后多用几次就可以了。下面这个表里，我还给出了一些更多的转义字符，你可以拿来玩一玩。

2. 类型与变量：组织语言让计算机理解你的意思

现在我们来假设一个场景，在一片硝烟弥漫的战场上，你身处其中，需要将战况传回指挥部，以便指挥官做出下一步的战斗指示，你可能会将如下信息回传：

报告指挥官，敌军兵力大致如下：

重步兵：100 人

坦克：4 辆

火箭炮：6 门

报告完毕，请总部做出下一步战斗指示！

这个场景中，100、4、6 都是对抽象的战场环境的客观且具体的数字描述。

现实世界就像这个战场一样，是抽象的，而计算机的世界是具体的、可计算的。从现在开始，你应该注意学习如何将现实世界的“信息”，映射到计算机世界中“数据”的技巧。

下面就来看现实生活中几种常见的信息，以及相应信息在计算机中的表示：

在上表中，金拱门有多少家，是一个整数，因为不可能出现 0.5 这样的半家，所以在计算机中表示为 int 的整数类型；巨人的身高则有零有整，所以在计算机中表示为 float 或 double 的浮点数类型；而一个人的名字就不能用数字类型来表示了，而是采用字符串类型来存储。

可以看到，我们说到的这几种基础数据类型，用来代表不同种类的信息。

在现实生活中，你可能会把各种信息记录在纸条上，或者本子的某个地方。在程序中，我们把这些信息，记录在一些叫做“变量”的东西里面。注意，类型和变量是两个完全不同的概念。

下面我们来看一个简单的变量示例：

#include <stdio.h>

int main() {
    int a = 167, b = 543;   // 定义变量 a,b
    printf("%d + %d = %d\n", a, b, a + b);
    return 0;
}

在上面的程序中，a、b 就是变量，167、543 就是数据。那么 167、543 这样的数据是什么类型呢？我们看到它们是整数，所以用的是 int 。可以看到，我们定义了两个整数型变量 a、b，并把数据 167、543 分别放到了变量 a 和变量 b 里，进而实现了程序目的。

所以，这里我划个重点，变量是用来存储数据的。你理解了吧？

上面例子中的 printf 函数虽然复杂一点儿，但其实简单来说就只有如下两部分：

“%d + %d = %d\n”叫做格式控制字符串，其中 %d 是整型的“格式占位符”。

a, b, a + b 叫做参数列表，每一项依次对应一个“格式占位符”要输出的内容。

“格式占位符”与参数列表中的项一一对应，按照顺序，第一个 %d 与 a 对应，第二个 %d 与 b 对应，第三个 %d 与 a+b 对应。在输出内容时，会被替换成为对应项的内容。例如，上述程序的输出结果如下图所示：

你会看到，相应的 %d 被按顺序替换成了变量 a、变量 b 以及表达式 a + b 的内容。

我们利用 printf 函数输出了二者加法表达式的值。至此，这个程序之于我们而言，已经具备了一个简单计算器的基本功能了。

下表是一些常用的“类型”与其“格式占位符”之间的对应关系，同样，还是不用问为什么，先试着去用，把它当做规则记住就可以了。

3. 输入函数：让计算机“捡起”你的话

前面，我们已经看过了 printf 函数的输出功能，它可以把程序中数据信息输出到屏幕上，这个屏幕，就是现在我们与程序交互的最基本的场所，以后你还会接触其他交互形式，但不急，慢慢来。

你可以把这个屏幕想象成一个桌子，你和计算机坐在桌子的两侧，当程序执行到 printf 的时候，计算机会把数据放到这个桌子上。那么这个时候，如果你往桌子上放了一些数据，计算机如何把这些数据信息“捡起来”呢？

看到这里，请在你的编译器中，输入如下程序：

#include <stdio.h>

int main() {
    int a, b; // 定义变量 a,b
    scanf("%d%d", &a, &b); // 输入函数
    printf("%d + %d = %d", a, b, a + b); // 输出函数
    return 0;
}

代码的第 5 行中，有一个 scanf 函数，它会帮计算机把你放在屏幕上的数据“捡起来”，就像上文中说的“变量是用来存放数据的”，计算机会把捡起来的数据存储在 a、b 两个变量中。

注意：a、b 两个变量前面有一个特殊的符号 &（取地址符），在这里暂不做过多解释。

当你运行上面这段程序后，可能会得到如下效果：

192 567
192 + 567 = 759

第一行是你放到屏幕上的信息，第二行是计算机放到屏幕上的信息。

有了输入函数以后，面对每次不同的计算需求，就不需要修改程序代码了，我们可以直接在屏幕上输入两个需要计算的数据，计算机就会给我们一个满意的答案。

最后我们来看看输入输出函数的返回值：

printf 函数的返回值，代表向屏幕上打印了多少个字符。

scanf 函数的返回值，代表成功给多少个变量赋了值（后续再展开讲）。

一起动手，搞事情

下面我给出两道思考题，希望你能尽量自己动手查资料解决。

以后，基本每堂课我都会留一些你抬抬脚就能解决的问题，不太简单，可绝不会难上天。我尽量控制，也欢迎你在专栏下面留下意见和建议，更欢迎你将思考过程留在专栏下面。

思考题（1）：位数输出

计算一个输入整数的十进制表示的位数？

条件 1：允许有多余输出的情况下，怎么实现？

条件 2：只允许输出数字位数的时候，又该怎么实现？

请单纯考虑使用 printf 一族函数方法实现。

思考题（2）：读入一行字符串

请大家自行实现一个读入一行字符串，并且输出相关内容的程序，思考如下：

条件 1：如果字符串中没有空格，怎么实现？

条件 2：如果字符串中有空格，又该怎么实现？

请单纯考虑用 scanf 一族函数方法实现。

“hello world”显示什么颜色，你做主

前面我们讲了如何使用输出函数让计算机显示什么，又讲了如何利用类型与变量等组织一句计算机可以理解的话语，以及如何让计算机接收到你想传达信息的渠道。接下来，就回到我们今天的任务：按照字符串信息中规定的颜色，输出相应颜色文本内容的方法程序。

在我讲输出函数的时候，提到转义字符，其中有一个转义字符就是用来操作颜色的，它就是：\033。下面就让我们具体看一下，它是如何工作的。

设置颜色，以 \033 开始，也以 \033 结束，这种首尾呼应的结构对记忆比较友好。具体格式如下：

格式：\033[属性代码{; 属性代码...}m 输出内容\033[0m

我们来介绍几个属性代码，并加以使用：

0 代表关闭所有属性

1 代表高亮 / 粗体

4 代表下划线

33 代表黄色

如果你在你的 Linux 环境中输入如下代码：

#include <stdio.h>

int main() {
    printf("\033[1;4;33mhello color\033[0m");
    return 0;
}

运行以后，你就会在终端看到一行高亮且带有下划线的“hello color”字符串。如下图所示：

至此，我们就准备好了完成课程任务的所有基本元素了，下面，就请你自行尝试一下本任务吧，即使做不出来，也不用担心，我会上传参考代码。

参考代码中，会涉及一些我们后续才会学到的编码技巧，你暂时看不懂也没关系，只需要欣赏它就好了。毕竟，想要进入一个行业的前提，是要懂得这个行业的审美标准。

课程小结

今天是我们第一次真正接触 C 语言，所涉及的专业词汇可能有点多，你可能看完后对一些概念也是分不太清楚，但不要担心，当你接触的多了，这些术语渐渐都会清晰明白。下面呢，我来给你总结以下今天的重点内容：

printf 函数是用来输出内容的方法，包含了格式控制字符串和参数列表两部分。

类型和变量是两个完全不同的概念，变量是用来存储数据的。

使用格式占位符的时候，需要对应到相关类型，整型对应到 %d，字符型对应到 %c，浮点型对应到 %f，双精度浮点型对应到 %lf。

总之，今天这堂课你已经知道如何和计算机打招呼，以及如何让计算机“听”你说的话了。

我是胡光，这是我带你第一次接触 C 语言，你还有什么疑惑或其他想知道的，我们留言区见。

03 | 判断与循环：给你的程序加上处理逻辑

你好，我是胡光，咱们又见面了。不知道上一讲的内容，你自己做练习了么？你是否还觉得 C 语言枯燥无味呢？不管你有没有练习，我都还要啰嗦下，学习编程，就像是学骑自行车，你只看别人怎么骑，你只看自行车的原理，那永远也不可能学会骑自行车，对于你来说，唯一的捷径就是多练习，多思考。在上一讲小试牛刀之后，今天我将带你领略一下算法和逻辑层面的小惊喜。

今日任务

先来看看今天这 10 分钟我们要完成的任务。日期这个概念你肯定不陌生，生日对你我来说都很重要，如果你身边有 2 月 29 号过生日的小伙伴，恐怕最少 4 年，才能为他 / 她办一次生日宴。今天我们的这个任务，就和日期有关系。如果我给你一个由年月日组成的日期，再给你一个数字 X，你能否准确地让程序输出 X 天后的日期呢？

例如下面这个数据：

1989 11 20
20
1989 12 10

数据中给出了 1989 年 11 月 20 日这个日期，然后问你 20 天后的日期是多少，你的程序应该输出 1989 年 12 月 10 日。特别需要注意的是，在这个任务中，你需要考虑到闰年中 2 月份的特殊性，闰年的 2 月有 29 天。今天我们就学习，如何用计算机解决这类任务吧。

必知必会，查缺补漏

根据对任务的理解，我们可以分成两步来思考这个问题：

第一步：我们来思考如何求解 1 天后的日期，在求解 1 天后日期的过程中，我们涉及到的核心问题就是判断日子是否跨月，月份是否跨年，即判断；

第二步：是要让计算机重复 X 次 1 天后日期的计算过程，即重复循环做这件事。

要解决这两个难题，我们需要讲讲 C 语言中的一些基础知识，其中包括了程序中用于逻辑分支判断的分支结构，以及可以重复做大量事情的循环结构。听着这些专业词汇，你可能有点懵，别怕，等我下面讲了它们是什么意思，你就会感觉这些其实很简单。

1. 给代码添加判断能力：“if…else”语法结构

我们一起来读下这句话：如果隔壁商店有酱油，就买酱油，否则就买点儿醋回来。可以看到，这句话用了“如果……就”的假设关系关联词，“如果”后面接的是假设条件，“就”后面接的是条件成立后的结果，“否则”接的是条件不成立后的结果。

现在我们想把计算机变成我们的小帮手，就必须要有一种语法，能够表达 “如果……就……否则……”的逻辑，这种语法，就是接下来我要介绍给你的 “if…else”语法结构。

在这里，我将简单介绍“if…else”语法结构，主要目的是让你看懂今天我们这个任务的代码，后续在课程逐步展开的过程中，我还会逐步的引入这个语法结构的一些其他知识点。

我们先来看 “if…else”最基本的语法结构：

if (条件表达式) 一条语句 1;
else 一条语句 2;

简单来说，if 和 else 都是关键字，代表分支逻辑中的 “如果”和 “否则”。if 后面跟着的括号里面，需要放一个条件表达式，条件表达式如果成立，程序会执行 “语句 1”，否则就会执行 “语句 2”。下面我来举个例子，你就明白了：

#include <stdio.h>

int main() {
    int a, b;
    scanf("%d%d", &a, &b);

    if (a == b) printf("a is equal to b!\n");
    else printf("a is not equal to b!\n");

    return 0;
}

这段程序中，首先定义了两个变量 a 和 b，然后通过输入函数（scanf）给变量 a、b 赋值。之后就是重点部分了，根据我们上面所说的，如果 if 后面的条件表达式成立，那么就会输出 “a is equal to b!\n”， 否则就会输出 “a is not equal to b!\n”。

最后，我就再带你理解两个概念，一是条件表达式是什么，二是怎样理解 if 后面跟一条语句，所谓一条语句的概念范围是什么。

回到上面的程序中，你会看到程序中的 if 后面跟着一个括号，括号里面放着一个表达式，这个就是我们所谓的条件表达式，而这个括号，是必不可少的。我们发现，这个条件表达式用两个等号连接 a 和 b，作用是判断 a 和 b 里面存储的值是否相等。可千万别跟赋值表达式的一个等号弄混了。

说到这里，我要告诉你一个重要的事实，变量有变量对应的值，表达式也有表达式对应的值。那么例如上面代码中的条件表达式“a == b”所对应的值是什么呢？其实就是数字 1 或者 0，分别表示“条件成立”（a 与 b 的值相等）和“不成立”（a 与 b 的值不相等）。

延伸内容：

那么除了条件判等以外，还有哪些条件运算符呢？有判断不等于的“a != b”，大于的 “a > b”，小于的 “a < b”，大于等于的 “a >= b”，小于等于的 “a <= b”，逻辑非 “!(a > b)”，等价于 “a <= b”。同时多个条件表达式，还可以用逻辑 && 和 || 进行连接，这个后面我再跟你细说。

事实上，if 的括号里面，不仅可以放条件表达式，类似于 “a - b”这种的表达式，也是可以当做 if 的条件的。

当一般表达式作为条件的时候，if 是怎么执行的呢？很简单，记住：表达式的值，非 0 即为真。例如，下面两行代码，效果等价：

if (a != b) printf("a is not equal to b!\n");
if (a - b) printf("a is not equal to b!\n");

你会看到，第二行代码中，用 “a - b”代替 “a != b”，取得了同样的程序运行效果。因此，你只需要重点思考，表达式 “a - b” 什么时候结果非 0 即可，是不是当且仅当 “a != b”时，“a - b”的结果非 0，根据之前所说的非 0 即为真，那么 if 条件也就算是成立了。

最后，我们来讲一下怎么理解“if 后面跟一条语句”这个概念，其实指的是 if 后面的条件成立时所执行的代码。这里，我们的重点是要理解一条语句都包含什么形式，大致可以分为如下几类。

第一种，空语句，就是什么都没有，单纯以一个分号结尾，例如下面这行代码，即使条件成立，也不会有任何实质上的操作。

if (a == 3) ;

第二种，单一语句，比空语句多了语句内容，以分号结尾，例如下面这行代码，当条件成立的时候，会输出 “hello geek!”。

if (a == 3) printf("hello geek!\n");

第三种，复合语句，被大括号包裹，中间是若干条语句，例如下面这段代码：

if (a == 3) {
    printf("hello geek1!\n");
    printf("hello geek2!\n");
    printf("hello geek3!\n");
}

当条件成立以后，程序会依次执行大括号里面的三条语句：

hello geek1!
hello geek2!
hello geek3!

第四种，结构语句，以 if，for，while 等开头的分支语句或循环语句，例如下面这段代码，首先会先判断 a==3，如果条件成立，才会执行下面第二条 if 分支语句，当第二条 if 分支语句的条件也成立的时候，才会输出 “hello geek!”。

if (a == 3)
    if (b == 4) {
        printf("hello geek!\n");
    }

由此可以看到，if 后面所谓跟着的一条语句，还真是丰富多彩，你可以在后面跟上像上面代码中所写的 printf 函数调用的单一语句，也可以用一个大括号，里面放上若干条语句，亦或是 if 后面跟着另一个 if 也是可以的！你看这种组合能力，有没有点儿像乐高玩具？

至此，你就已经掌握了基础的将 “如果……就……否则……”这种逻辑结构转换成代码的能力了。你的计算机，终于有了“判断力”。

2. 给程序添加重复执行功能：for 和 while 语句

想想小的时候，你最讨厌什么事情？我最讨厌的就是被老师罚写汉字，错一个字，罚写 100 遍那种的，在我看来真的是在浪费时间。可当我学了程序以后，我发现，程序真的是特别擅长做这种重复的工作，而实现这种功能的语法结构就是 for 语句和 while 语句。

我们先来看语法结构较简单的 while 语句：

while (循环条件) 一条语句；

以 while 关键字开头，后面跟着循环条件，也就是一个条件表达式，然后是一条语句。while 循环，顾名思义，当循环条件成立时，就会执行一次后面的语句，之后就是再判断循环语句是否成立，如果成立就再执行，一直到循环条件不成立为止。

下面呢，我们就用最简单的形式，利用 while 循环，输出前 100 个正整数：

int i = 0;
while (i++ < 100) printf("%d\n", i);

这段代码里面，出现了一个你之前没有见过的语法，就是 i++，这也是表达式，这个表达式的值等于 i 之前的值，当这条表达式执行完以后，i 会变成 i + 1 的值。例如，起初 i = 2，i++ 表达式的值就等于 2，可表达式执行以后，你要是输出 i 的值，这时 i 实际等于 3。

上面代码中，我们是用 i++ 表达式的值和 100 进行比较，表达式的值会遍历 0 到 99 所有的值，由于 printf 在 i++ 之后输出 i 的值，所以实际上每次输出的都是 i + 1 之后的值，也就是说 printf 会输出 1～100 所有值。具体的你可以参考下面的这个程序流程图。

while 循环流程图

另外，顺便再问你个问题，你还记得上一节课里，我们学到的\n 和 %d 分别代表什么意思嘛？如果不记得，记得回去再复习下。

有了 while 循环语句的加持之后，是不是重复做某件事，变得很方便了呢？不急，下面我要给你介绍的是功能更为强大的 for 语句。还是先来看一下 for 语句的结构吧：

for (初始化①; 循环条件②; 循环后操作③) 一条语句④;

正如你看到的，我把 for 语句的四部分已经给你标出来了，for 语句会按照 ①②④③②④③…循环，直到某一次循环条件②不成立了为止。

你会发现，①这一部分只在循环开始时执行了一次，真正所谓的循环，是以循环条件②，一条语句④以及循环后操作③组成的。

如果要是用 for 循环输出 1～100 所有值，会显得代码更清晰一些：

for (int i = 1; i <= 100; i++) printf("%d\n", i);

上面这段代码，就是用 for 循环实现了和之前 while 循环相同的功能。

看了 for 循环和 while 循环以后，你可能会问，实际中哪种循环用的比较多，我个人经验来说，for 循环用的比较多，因为 for 循环每一部分都非常明确，对于比较复杂的循环控制过程，for 循环写出来以后，一般都会比 while 循环可读性强。

为了让你感受到 for 循环真正的威力，写一段代码，让你感受一下：

for (int i = 1, k = 0; i <= 48; i++, k += 2) printf("%d\n", k);

上面这段程序中，我们用到了两个同步信息变量，i 和 k，i 从 1 到 48，保证循环了 48 次；代码中“k+=2”表示 k 每次增加 2 ，也就是说，在这个过程中，i 遍历了 1 到 48 这 48 个整型值，而 k 同步地遍历了从 0 开始的前 48 个偶数。这段代码的意思其实就是打印出从 0 开始后的共 48 个偶数，即 0、2、4……92、94。

如果用 while 来实现这个目的，知道怎么写吗？你可以自己在计算机上试一下。

一起动手，搞事情

思考题：打印乘法表

使用循环和条件判断，打印一个格式优美的 66 乘法表

要求 1：输出内容及样式参照下面给出的样例

要求 2：每两列之间用 \t 字符进行分隔，行尾无多余 \t 字符

1*1=1
1*2=2  2*2=4
1*3=3  2*3=6  3*3=9
1*4=4  2*4=8  3*4=12  4*4=16
1*5=5  2*5=10  3*5=15  4*5=20  5*5=25
1*6=6  2*6=12  3*6=18  4*6=24  5*6=30  6*6=36

“日期计算器”程序完成

准备完了所有的基础技能后，就让我们来完成开始说的那个任务吧，我们来思考一下哈，首先我们需要有一个循环，循环每一次，让计算机帮我们计算一次下一天的日期。每次在计算下一天日期的过程中，先让日子加 1，判断是否跨月，如果跨过了一个月份，就让日子从 1 开始，让月份加 1，再判断是否跨年，如果跨年了，就让月份从 1 开始，年份加 1。

如上的过程中，有一个关键问题需要你注意，就是 2 月份的月份天数的计算方法，咱们来简单回顾一下闰年的规则，年份满足以下其中一条即为闰年：

能被 4 整除，但不能被 100 整除；

能被 400 整除。

如果把闰年的规则翻译成逻辑判断，应该是下面这个样子：

if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) ...

下面就让我们把思路过程转换成程序过程：

#include <stdio.h>

int main() {
    int y, m, d, X; // 定义存储 年月日 和 X 的变量
    scanf("%d%d%d", &y, &m, &d); // 读入年月日
    scanf("%d", &X); // 读入 X 值
    for (int i = 0; i < X; i++) { // 循环 X 次，每次向后推一天
        d += 1;
        switch (m) {
            case 1:
            case 3:
            case 5: { // 第一部分逻辑
                if (d > 31) d = 1, m += 1;
                if (m == 13) m = 1, y += 1;
            }; break;
            case 4:
            case 6: { // 第二部分逻辑
                if (d > 30) d = 1, m += 1;
            } break;
            case 2: { // 第三部分逻辑
                if ((y % 4 == 0 && y % 100 != 0) || y % 400 == 0) {
                    if (d > 29) d = 1, m += 1;
                } else if (d > 28) {
                    d = 1, m += 1;
                }
            } break;
        }
    }
    printf("%d %d %d\n", y, m, d);
    return 0;
}

上面这段程序是个半成品，只处理了前 6 个月的情况，并且用到了 switch…case 的分支结构，与 if 结构类似，都是用于做逻辑分支判断的。关于这部分的内容，给你留个小作业，自学一下 switch…case 分支结构，然后按照自己的理解，补全上述程序，使得上述程序可以处理一年中 12 个月的全部情况。

虽然这个程序中有一部分内容需要你进行自学，可你也不要担心，我还是会跟你详细解释上述程序设计的思路。读入部分的代码，相信你现在已经可以很好的掌握了，这一部分就不展开解释了。程序整体设计中，是用 for 循环包裹了 switch…case 结构，for 循环负责循环 X 次，每次在循环内部，都将对日子变量 d 进行加 1 操作，而在 switch…case 结构内部，主要是处理跨月和跨年的问题。

你会看到 switch…case 结构中，主要分成三部分逻辑，第一部分逻辑，主要处理天数为 31 天的月份，由于 12 月也是 31 天，所以当本月是 12 月，并且发生了跨月，变成了 13 月，说明是到了下一年的 1 月，需要将年份 +1，月份置为 1 月。第二部分逻辑，主要处理天数为 30 天的月份。第三部分逻辑，主要处理 2 月份的情况，在这里，程序中分成两种情况来讨论，闰年和非闰年，闰年的时候，判断日子是否超过 29 天，非闰年，判断日子是否超过 28 天。

我保证，在你尝试补全上述程序的过程中，你会发现，上述程序易于修改和补全，你要是能试着将上述程序修改成 if 分支结构，那就更好了。这样你将对上述程序结构的美，会感受的更深刻。

课程小结

最后呢，来总结一下今天所学的重点。今天呢，我们主要学习了两种程序流程控制结构，一种分支结构，主要以 if 语句为代表，另一种循环结构，以 for 循环和 while 循环为代表。如果说你只想记住几点的话，那么应该是以下几点：

熟练掌握分支和循环结构的执行顺序，这一点很重要。

if 语句，首先判断条件表达式的真假，如果为真，则执行 if 里面的语句。

for 循环，分成四部分，其中②④③部分，构成了一个循环，第①部分是用做初始化的。

所谓一条语句的概念，包括了空语句，单一语句，复合语句和结构语句。

以上这 4 点要牢记哦，尤其是其中的分支和循环结构的执行顺序，因为掌握和理解了程序的执行顺序，才是分析程序，理解程序的第一步。

好了，今天就到这里了，下期我将带你来做一个小总结，我将带你学习一个有趣的圆周率的计算方法，我是胡光，我们下期见。

04 | 随机函数：随机实验真的可以算 π 值嘛？

你好，我是胡光。上次课里关于判断和循环的内容你做练习了么？其实这两部分内容都不复杂，你想，判断就是“如果…就…”，而循环就是重复做一件事情。程序里，只是我们换了一种方式来描述和抽象这两个场景。

今日任务

今天的任务其实也是和上次讲的内容有很大关系。如果你对上次讲的内容不理解，我建议你先再好好回顾下上次讲的知识，然后开始今天的任务。

先来看看今天这 10 分钟我们要完成的任务。圆周率 π 对你来说肯定不是一个陌生的概念，它指的是圆的周长与直径的比值。在古代，数学家们为了计算 π 的精确值想尽方法，可能穷尽一生也不过精确到小数点后几位而已。但到了现在，你可能不相信，只要你知道 π 的定义，就可以利用编程轻易计算出 π 的值。那究竟怎么做到呢？

我们先来看一个用蒙特卡罗法计算 π 的示意图：

图 1：蒙特卡罗法示意图

通过观察图 1，请你思考一个问题，如果你随机地在正方形区域中选择一个点，那么这个被选择的点，也恰巧落在圆形红色区域的概率是多大？这个问题很简单，就是圆面积和正方形面积的比值，简单计算就可以得到这个概率值，应该是 π/4。

也就是说，如果我们做大量的随机实验，最终落在圆内部的次数除以总次数再乘以 4 得到的值，应该接近圆周率 π。随机次数越多，所得到的数值越接近 π。你肯定不喜欢做这种重复的“重体力”劳动，但如果你写好编程，让它帮你做这件事，那就简单容易快捷多了。计算机可是一个不怕辛苦、没有怨言的好帮手，今天就让它来帮助我们完成这个任务吧。

必知必会，查缺补漏

思考一下，其实要完成上面这个任务，我们已经具备了一些基础知识，比如说：分支结构（if…else）可以帮助你判断某个点是否在圆内部，循环结构（for/while）可以帮助你完成大量的重复实验。

说到这里，你会发现，面对今天的这个任务，我们还需要做到随机选点，那么这个随机操作，在计算机中应该如何来完成呢？今天我将告诉你的就是程序语言中的随机函数，准备好了么？让我们开始吧。

1. 真随机与伪随机

说到随机，就需要说一下真随机与伪随机的概念了。

所谓真随机其实并不难理解，我们以掷骰子为例，掷出 1～6 点的概率均为 1/6，如果我问你，上一次掷出的点数是 4，那么下一次掷出 6 点的概率是多大？你会发现，依然是 1/6，我们称这两次掷骰子的事件是相互独立的，上一次的结果和下一次之间没有必然联系。

图 2：真随机示意图

通过上面这个示意图，你就很容易看出，所谓真随机，就是我们通常意义下的随机。那么什么又是伪随机呢？从名字上面来看，伪随机，带个伪字，说明本质上不是随机，可看起来是随机。

下面请你注意观察下图的两个数字循环序列：

图 3：显然规则与非显然规则

你观察上面这两个数字序列，会发现，第一个序列是 123456，这是一个有明显规律的序列，你一定不会觉得这个序列是随机生成的。另一个序列是 421635，好像没有什么明显的规律，相比于第一个序列，你是不是更偏向于相信第二个序列是随机生成的序列呢？

第二个序列就是我刚刚所说的伪随机，看起来像是随机序列，可实际上，4 后面一定会出现 2，2 后面一定是 1，1 后面一定是 6，也就是说前一个数字决定了后一个数字。

计算机中究竟如何制造出来这样一个伪随机序列呢，这个问题留到后面的 “动手搞事情” 中，我会使用一行简单的数学公式，制造一个包含 100 个数字的伪随机数字序列，类似于上图中第二个序列的加大版。

最后你会发现，所谓计算机中的伪随机数序列，就是类似第二个序列那样的，没有什么明显规律的一个规模更大的循环序列。

现在你知道为什么叫做伪随机了吧，那是因为，一旦要是上一个随机函数的值确定了，下一个数字也就确定了，而纯正意义上的真随机，应该是前后两次出现的数字是两次独立事件，没有任何关系。

2. 程序中的随机函数

现在我们所接触到的语言中，没有真随机，全是伪随机。也就是说，语言中给我们准备好了一个随机函数，这个随机函数会根据上一个随机值和一个固定的计算规则，得到下一个随机值。

而你在其他资料中可能会看到随机种子这个概念，设置随机种子就是在设置随机函数中记录的上一个随机值。例如，上面我们自己做出来的 6 个长度的伪随机序列，如果随机种子设置为值 1，我们得到的值依次是 635421，如果设置为值 3，那么我们将依次得到 542163。

下面就看看 C 语言中的随机函数的用法吧：

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

int main() {
    printf("%d\n", rand() % 1000); // 永远输出固定值
    srand(time(0));
    printf("%d\n", rand() % 1000); // 每次运行都不同
    return 0;
}

上面代码中，我们用 rand() 函数，获得一个随机值，这个就是我们前面讲的随机函数，它将依次的返回随机序列中的每一个值。

而 srand() 函数就是设置随机种子的函数，也就是设置随机函数上一次的状态值。time(0) 将返回一个时间戳，你就可以把他当成和当前时间相关的一个整型数字。

你会发现，上面这段程序中，在第 6 行代码里，我们虽然使用了 rand() 函数，可每次运行都将输出同样的值，这是因为我们没有设置随机种子，每次运行时 rand() 函数所记录的起始值都相同，所以每次运行输出的随机值也都相同。

而第 8 行代码中，由于我们根据程序运行时的当前时间设置了随机种子，每次运行程序，第 8 行都将输出不同的值。事实上，如果你在 srand() 函数里面设置一个固定值，每次运行程序，结果也都将是一样的，这个你可以自行尝试。

至此，我们就准备好了今天任务的全部基础知识了，接下来做道练习题，锻炼一下吧。

一起动手，搞事情

思考题：设计迷你随机函数

设计一个循环过程，循环 100 次，以不太明显的规律输出 1～100 中的每个数字。

要求 1：规律尽量不明显。

要求 2：只能使用循环和最基本的运算，不允许超前使用数组。

下表是我的程序输出的序列，以供你做参考：

  5 15 45 34  1  3  9 27 81 41
 22 66 97 89 65 94 80 38 13 39
 16 48 43 28 84 50 49 46 37 10
 30 90 68  2  6 18 54 61 82 44
 31 93 77 29 87 59 76 26 78 32
 96 86 56 67100 98 92 74 20 60
 79 35  4 12 36  7 21 63 88 62
 85 53 58 73 17 51 52 55 64 91
 71 11 33 99 95 83 47 40 19 57
 70  8 24 72 14 42 25 75 23 69

用有趣的方法计算 π 值

准备完了所有的基础技能后，就让我们来完成开始说的那个任务吧。

我们来思考一下哈，首先我们需要有一个循环，循环每一次，让计算机帮我们做一次实验。每次实验呢，让计算机模拟随机选择点的这个过程，然后我们需要判断一下随机选择的点，是否在圆内部；如果在，我们就记录一次。最后用落在圆里的次数比上总实验次数再乘以 4，就得到了 π 的近似值。

这个过程中，你到现在还比较懵的，可能就是随机选点的过程了。那就跟我来看下面代码吧：

double x = 1.0 * rand() / RAND_MAX;

上述代码中的 rand() 随机函数，返回值的范围是 [0, RAND_MAX]，通过上述表达式计算，我们就得到了一个 [0.0, 1.0] 之间的随机值了。

下面就让我们完善程序：

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

int main() {
    int n = 0, m = 0;
    for (int i = 0; i < 1000000; i++) {
        double x = 1.0 * rand() / RAND_MAX;
        double y = 1.0 * rand() / RAND_MAX;
        if (x * x + y * y <= 1.0) m += 1;
        n += 1;
    }
    printf("%lf\n", 4.0 * m / n);
    return 0;
}

上述代码中，我让计算机重复 10 万次实验，每次在坐标轴的第一象限中的 1 * 1 的区域中随机选择一个点，变量 m 记录的是落在圆内部的次数，变量 n 记录的是总实验次数。运行这个程序，在我的环境中，输出的是 3.142096，你可以试一下在你的环境中的运行结果，以及加大实验次数以后，对结果的影响。

是不是很难想象，如果没有计算机，我们自己将如何来完成这 10 万次实验呢？想想都是很痛苦的过程！

课程小结

今天这节课，你了解了 C 语言中的随机函数，以及计算机中随机函数的基本原理。最后呢，总结一下今天的重点，就两点：

计算机中都是伪随机函数，也就是说，下一次的随机值，跟本次的随机值是相关的。

使用 srand 函数设置随机种子，也就是设置伪随机过程中的第一个起始点的位置。

理解了上面这两点，也就算是真正理解了计算机中的随机函数的概念了。

从今天开始，记住，计算机就是你的小帮手了，以后的日子里，请动用你的智力，使用它的体力！随着你的思维逻辑越来越严谨，你会爱上这个帮手的，即使它日后可能会因为一些不知名的小 Bug 惹你不开心，相信我，都是暂时的。

好了，今天就到这里了，我是胡光，我们下期见。

05 | 数组：一秒钟，定义 1000 个变量

你好，我是胡光，咱们又见面了。通过前几节的学习，你已经了解了基本的程序结构。我们来简单总结一下，其中第一种结构就是顺序结构，它指的是我们所写的按照顺序执行的代码，执行完上一行，再执行下一行这种的。第二种就是分支结构，主要是用 if 条件分支语句来实现，主要特征是根据表达式的真假，选择性地执行后续代码。最后一种就是循环结构，用来重复执行某段代码的结构。

如果把程序比喻成工厂的话，现在你的工厂中已经有了各种各样的流水线，但这个工厂只是能生产产品还不行，还需要有存储的空间。今天，我们来学习的就是如何创建和使用工厂中的库房，本节之后，你的程序工厂就可以开工了！

今日任务

先来看看今天这 10 分钟的小任务吧。今天的任务是这样的，程序中读入一个整数 n，假设 n 不会大于 1000，请输出 1 到 n 的每一个数字二进制表示中的 1 的个数。

我举个例子哈，当 n 等于 7 的时候，我们把 1 到 7 的每个数字的二进制表示罗列出来，会得到下表所示内容：

表1：1到7的二进制表示

根据表 1 中的内容，如果你的程序编写成功的话，程序应该分别输出 1、1、2、1、2、2、3，这些输出内容分别代表每个数字二进制表示中 1 的数量。

对于这个任务，你想写出来一个可行的程序不难，例如：我们可以循环 n 次，每次计算一个数字二进制中 1 的数量。怎么计算一个数字二进制中 1 的数量呢？这个问题，你可能想采用如下程序来进行实现：

int cnt = 0;
while (n != 0) {
    if (n % 2 == 1) cnt += 1;
    n /= 2;
}

我解释下上面这段程序，它每次都会判断 n 的二进制末尾是不是 1，如果是 1，计数量 cnt 就加 1（+= 表达式，我这里就不解释了，如果你不理解，可以自己查下），然后将 n 除以 2，相当于去掉 n 的二进制表示的最后一位，这样就可以用 O(logn) 的时间复杂度（关于这个知识点，你也可以自行查阅相关资料，其实很简单）计算一个数字 n 二进制中 1 的数量。

以二进制数字 110 为例，末尾是 0，计数量 cnt 不进入计算；然后使用二进制除法，让 110 除以 2，即去掉最后一位的 0，变成了 11，此时末尾是 1，计数量 cnt 就加 1；11 再除以 2，变成了 1，此时末尾是 1，计数量 cnt 再次加 1 。最后的 n 等于 1，再除以 2，n 变成了 0，循环结束。

可以看到，当我们输入数字 6，二进制的表示是 110 时，整个程序中计数量 cnt 共计算了 2 次，所以最后的输出结果是 2 。

关于时间复杂度这个概念，后续我们还会进一步介绍，现在你可以简单地理解成为是程序运行的次数，例如 n=8 的时候，上面循环执行 3 次，也就是 log 以 2 为底 8 的对数的值。

如果你的方法像上面这么做的话，确实是一种可行的方法，可是效率不是很高。今天这个任务的要求是，对每一个数字，请用 O(1) 的时间复杂度计算得到其二进制表示中 1 的个数。O(1) 也就是 1 次，或者是与问题规模 n 无关的有限次，例如：2 次、3 次均可。下面就让我们来看看如何完成这个任务吧。

必知必会，查缺补漏

1. 数组：规模化存储工具

我要给你介绍的第一个帮助我们完成今天任务的工具是：数组。所谓数组，你可以把这两个字对调过来理解，即组数，一组数据。

以往我们定义的变量，都是单一变量，例如：一个整型变量，一个浮点型变量，等等。可当我们要同时记录 n 个整型数据的时候，通过以往的知识，你能实现这个需求么？注意，这个里面的 n 是通过读入的一个变量，通常情况会有一个最大范围，例如：n 不会超过 1000。你总不能定义 1000 个整型变量吧？

面对上面这种需求，数组就派上了用场，利用数组，我们可以定义存放一组数据的存储区，用法如下代码所示：

int arr[1000];

通过上述代码，我们很轻松的就定义了存储 1000 个整型变量的存储区 arr。这里相当于向计算机申请了可以存储 1000 个整型变量的存储空间。第一个存储整型数据的内存空间，也就是第一个整型变量，就是 arr[0]，第二个整型变量是 arr[1]，以此类推。arr 后面方括号里面的东西，我们称之为“数组下标”，数组下标从 0 开始，也就是说，代表 1000 个整型变量的数组，下标范围应该是 0 到 999，具体可以参考图 1。

图1：数组示意图

有了数组以后，你就可以轻松的完成读入 n 个整型数据的任务了，参考代码如下：

int n, arr[1000];
scanf("%d", &n);
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);

代码中，第一行定义了一个整型变量 n 和一个最多存储 1000 个整型元素的数组空间。第二行接下来读入 n 的值，第三行利用循环结构循环 n 次，循环变量 i 取值从 0 到 n-1，循环每次读入一个整型数据存放在 arr[i] 里面。

这样一段程序执行完后，n 个整型数据就被依次的存放在了 arr[0] 到 arr[n-1]中。当你想在程序中使用第三个整型数据的时候，只需要访问 arr[2] 即可。当然，上述循环变量的取值范围也可以调整到 1 到 n，这样做的话，相当于我们将 n 个整型数据存放在了 arr[1]到 arr[n] 处。

2. 字节与地址：数据的住所和门牌号

在之前第 2 篇的学习中，不知道你还记不记得一个叫做 char 的数据类型，我们称其为字符型。当时在学习的时候，我们说，字符型数据形如：“a”，“b”，“c”，“+”，“-” 等等这些被引号包裹着的内容。这次我将带你从 char 类型开始，深入理解两个概念：字节与地址。

什么是字节呢？它是计算机中最基本的存储单位，就像一个一个不可分割的小格子一样，存在于我们计算机的内存中。例如，我们通常所说的，一个 32 位整型元素占用 4 个字节，那就意味着这个元素需要占用 4 个小格子，不会存在某个元素占用 0.5 个小格子的情况。这就是所谓的不可分割。

图2：字节示意图

任何类型的元素，整型也好，浮点型也罢，只要是想存储在计算机中，就一定要放在这些小格子里面，唯一的区别，就是每一种类型的元素占用的格子数量不一样。例如：32 位整型占 4 个格子，double 双精度浮点型占 8 个格子。在这里，需要注意的是，每一种基础类型，在内存中存储时，一定是占用若干个连续的存储单元。

那么如何查看某个类型的元素究竟占用多大的存储空间呢？可以使用 sizeof 这个运算符，如下：

int a;
sizeof(a); // 计算 a 变量占用字节数量
sizeof(int); // 计算一个整型元素占用字节数量

正如你所看到的，sizeof 的使用，就像函数方法一样，我们想要查看什么元素或者类型所占用字节数量，就把什么传入 sizeof 即可，你可以使用 printf 语句输出 sizeof 表达式的值以查看结果。

了解了什么是字节以后，下面我们就要说一个更小的单位了，叫做比特，英文是 bit。这个是计算机中表示数据的最小单位。对比字节是存储数据的最基本单位，比特是表示信息的最基本单位。

那什么又是比特呢？在其他参考资料上你可能知道，计算机里面的所有数据，均是用二进制来表示以及存储的，这里需要注意，是所有的。那么一个比特，就是一个二进制位，要么是 0，要么是 1。8 比特位是 1 个字节，那么我们之前所说的 32 位整型，也就是占 32 个比特位的整数类型，换算一下，正好是占 4 个字节。

说完了字节的概念后，我们再来说说地址。

现在我们的一些小区里面都有一个集中式的邮箱，邮递员来投递信件的时候，只需要把信件放到相应的邮箱里面即可。而作为住户，会有一把能打开自己家邮箱的钥匙，找到自己的邮箱，取出信件即可。

如果把这个场景放在计算机中，住户其实就是 CPU，而邮箱就是内存。你会发现，住户之所以可以准确找到自己的邮箱，是因为每个邮箱上面有一个独立编号。那么 CPU 能够准确找到程序所需要数据的本质原因，也是因为每一个字节都有一个独立的编号，我们管这个编号，叫做：内存地址！下面我给你放了一张示意图：

图3：内存地址示意图

上图中，下面空白的格子就是我们所谓的字节，具体的数据信息，就是存储在这些格子里面的，格子上面的是十六进制数字，就是我们所谓的地址，你会看到，在内存中，字节的地址是连续的。

最后我们来总结一下，比特是数据表示的最小单位，就是我们通常所说的一个二进制位。字节是数据存储的最基本单位，存储在计算机中的数据，一定是占用若干个字节的存储空间。最后就是内存地址，是每一个字节的唯一标记。

3. 直观感受：内存地址

你可能会觉得内存地址是一个很抽象的概念，不具体。其实我们可以像输出整型值一样，把内存地址也输出出来。

你还记得格式占位符的作用吧？不同数据类型，用不同的格式占位符输出，例如：%d 对应了十进制整型的输出。内存地址则采用 %p 这个格式占位符进行输出，下面给你一个演示程序，你可以在你的环境中运行一下：

#include <stdio.h>
int main() {
    int a;
    printf("%p\n", &a); // 输出 a 变量的地址
    return 0;
}

代码中，首先定义一个了整型变量 a，然后使用 %p 占位符输出 a 变量的地址。单一的 & 运算符放到变量前面，取到的就是这个变量的首地址。

为什么说是首地址呢？上一部分说了，一个 32 位整型变量会占用 4 个字节的存储空间，每一个字节都会有一个地址，那么你会发现，上面程序中的 a 变量实际上有 4 个地址，这 4 个地址究竟哪一个作为 a 变量的地址呢？答案是最靠前的那个地址，作为 a 变量的地址，也就是这个变量的首地址。

图4：变量的首地址

看到了变量的地址信息以后，下面就让我们来看一看与数组相关的地址信息，看下面这段程序：

#include <stdio.h>
int main() {
    int arr[100];
    printf("&arr[0] = %p\n", &arr[0]); // 输出 arr[0] 的地址
    printf("&arr[1] = %p\n", &arr[1]); // 输出 arr[1] 的地址
    printf("    arr = %p\n", arr);     // 输出 arr 的信息
    return 0;
}

上述代码，会输出三行信息，针对这三行信息，每个人的程序运行出来的结果很可能是不一样的，这一点没关系，可你一定会发现如下规律：第一个地址与第二个地址之间差 4 字节，而输出的第三个地址与第一个地址完全相同。

下面我就来解释一下这两个现象。

第一，数组的每个元素之间在内存中是连续存储的，也就是对上面程序中的数组而言，第一个元素占头 4 个字节，第二个元素紧接着占接下来的 4 个字节的存储空间。再结合上面说到的变量首地址的概念，你就很容易理解为什么头两个地址之间差 4 了。

第二，在程序中，当我们单独使用数组名字的时候，实际上就代表了整个数组的首地址，整个数组（arr[100]）的首地址就是数组中第一个元素的首地址，也就是 arr[0] 的地址。

在这里，我们来进一步看一下这个等价关系，arr 等价于 &arr[0]（取地址 arr[0]），实际上我们的地址也是支持 +/- 法的，也就是 arr + 0 等价于 arr[0] 的地址，那么 arr[1] 的地址等于 arr 加几呢？

你可能会认为是加 4，这种直觉还是值得鼓励的，可结果不正确，这个和地址的类型有关系，后面讲到指针的时候，我再详细的讲给你听。不过，事实上，arr + 1 就等价于 arr[1] 的地址，更一般的 arr + i 就等价于 arr[i] 的地址。关于地址上的 +/- 运算的规则，我在后续的文章中会详细进行讲解。

4. 再看 scanf 函数：其实我是一个“邮递员”

有了上面对于地址的基本认识以后，我们再来回顾一下 scanf 函数的用法，你可能会有新的收获，看如下读入程序：

#include <stdio.h>
int main() {
    int a;
    scanf("%d", &a);
    return 0;
}

上面这个程序，就是一个最简单的读入程序，首先定义一个整型变量 a，然后读入一个整数，存储到 a 中。

学习完了地址以后，你就会意识到，我们传给 scanf 函数的，不是 a 变量，准确来说，而是 a 变量的地址。

为什么要把 a 变量的地址传递给 scanf 函数呢？这个很好理解，你就把 scanf 函数当成邮递员，邮递员得到了信件以后，需要知道这个数据放到哪个邮箱里面啊，而你需要做的就是把邮箱地址告诉这个邮递员即可，就是变量 a 的地址，这样 scanf 函数就能把获得的数据，准确的放到 a 变量所对应的内存单元中了。

一起动手，搞事情

思考题：去掉倍数

设计一个去掉倍数的程序，要求如下：

首先读入两个数字 n 和 m，n 的大小不会超过 10，m 的大小都不会超过 10000；

接下来读入 n 个各不相同的正整数，输出 1 到 m 中，有哪些数字无法被这 n 个正整数中任意的一个整除。

下面给出一组输入和输出的样例，以供你来参考。

输入如下：

3 12
4 5 6

输出如下：

1 2 3 7 9 11

用数组，做递推

有了对数组的基本认识之后，就让我们来看一下今天的任务应该如何求解。请你观察下面的位运算性质：

y = x & (x - 1)

我们看到，我们将 x 与 x - 1 这两个数字做按位与（这个名词的含义很简单，你随便查查资料就知道了），按位与以后的结果再赋值给 y 变量，下面我们着重来讨论 y 变量与 x 变量之间的关系。

既然是位运算，我们就需要从二进制的角度来思考这个问题。首先思考 x - 1 的二进制表示与 x 二进制表示之间的关系，当 x 二进制表示的最后一位是 1 的时候，x - 1 就相当于将 x 最后的一位 1 变成了 0，如果 x 二进制表示最后一位是 0 呢，计算 x - 1 的时候，就会试图向前借位，应该是找到最近的一位不为 0 的位置，将这一位变成 0，原先后面的 0 都变成 1，如下图所示：

图5：x 与 x-1 的二进制表示

图中打 * 的部分，代表了 x 与 x - 1 二进制表示中完全相同的部分。根据按位与操作的规则，相应位置都为 1，结果位就为 1，那么 x 与上 x - 1 实际效果等效于去掉 x 二进制表示中的最后一位 1，从而我们发现原来 y 变量与 x 变量在二进制表示中，只差一个 1。

回到原任务，如果我们用一个数组 f 记录相应数字二进制表示中 1 的数量，那么 f[i] 就代表 i 这个数字二进制表示中 1 的数量，从而我们可以推导得到 f[i] = f[i & (i - 1)] + 1，也就是说 i 比 i & (i - 1) 这个数字的二进制表示中的 1 的数量要多一个，这样我们通过一步计算就得到 f[i] 的结果。

下面给你准备了一份参考程序：

#include <stdio.h>
int f[1001];
int main() {
    int n;
    scanf("%d", &n);
    f[0] = 0;
    for (int i = 1; i <= n; i++) {
        f[i] = f[i & (i - 1)] + 1;
    }
    for (int i = 1; i <= n; i++) {
        if (i != 1) printf(" ");
        printf("%d", f[i]);
    }
    printf("\n");
    return 0;
}

这个程序中，首先先读入一个整数 n，代表要求解的范围，然后循环 n 次，每一次通过递推公式 f[i] = f[i & (i - 1)] + 1 计算得到 f[i] 的值，最后输出 1 到 n 中每个数字二进制表示中 1 的个数。

课程小结

通过今天这个任务，你会发现，有了数组以后，我们可以记录一些计算结果，这些计算结果可能对后续的计算有帮助，从而提高程序的执行效率。关于数组的使用，会成为你日后学习中的一个重点，今天就当先热个身吧。下面呢，我来总结一下今天课程中需要你记住的重点：

使用数组，可以很方便的定义出一组变量存储空间，数组下标从 0 开始。

数据的最基本存储单位是字节，每一个字节都有一个独一无二的地址。

一个变量占用若干个字节，第一个字节的地址，是这个变量的首地址，称为：变量地址。

记住今天这些，对于日后学习指针相关知识，会有很大的帮助。好了，今天就到这里了，我是胡光，我们下期见。

06 | 字符串：彻底被你忽略的 printf 的高级用法

你好，我是胡光，咱们又见面了，之前我们学习了基础数据类型，还有 scanf 函数和 printf 函数，这些知识都是单独的出现在我们之前的文章中的。今天呢，我将把它们组合在一起，给你展现一片新天地，你会掌握一种数据处理技巧，本质上是在上述几种数据类型之间做转换，从而使你对 scanf 函数和 printf 函数的理解更加深刻。

今天呢，我将给你介绍一种特殊的数组：字符数组，一种用来存储字符串信息的数组。来，跟我一起看看到底是怎么回事吧！

今日任务

首先，先让我们来看一下今天的任务。你还记得 printf 函数如何输出浮点型吧，那下面请你回忆一下，printf 函数在输出浮点型数据的时候，如何保留位数呢？下面的代码，演示了如何保留小数点后两位：

printf("%.2lf", 3.1415926);

%lf 是 double 双精度浮点型输出的格式占位符，当我们想要保留小数点后两位的时候，只需要在 % 和 lf 中间加上 .2 即可，2 写在 .(点) 的后面代表小数点后 2 位。

有了上面这个基础，现在我让你保留小数点后 2 位、3 位、4 位。对你来说肯定都不是什么难题了。先不要高兴太早，今天我们的任务是，实现一个能够保留小数点后 n 位的程序，这里的 n 是我们程序读入的一个变量。

例如，面对如下输入：

3.1415926 2
3.14

第 1 行有两个数字 3.1415926 和 2，第一个浮点数代表要进行保留位数输出的浮点值，第二个整型代表具体要保留 2 位小数，你的程序应该能够正确的输出 3.14。那么类似的，当程序输入 3.1415926 和 3 的时候，你的程序应该输出 3.142。

面对这样一个任务，你想怎样进行实现呢？下面就给你一个设计不太优美的程序示例：

#include <stdio.h>
int main() {
    double num;
    int n;
    scanf("%lf%d", &num, &n);
    switch (n) {
        case 1: printf("%.1lf\n", num); break;
        case 2: printf("%.2lf\n", num); break;
        case 3: printf("%.3lf\n", num); break;
        case 4: printf("%.4lf\n", num); break;
        case 5: printf("%.5lf\n", num); break;
    }
    return 0;
}

你会看到，在这段程序中，使用 switch…case 结构将 n 等于 1、2、3、4、5 时候所对应的程序输出行为都罗列了出来，看似好像解决了问题，可实际不然。当 n 等于 6、7、8、9、10 怎么办呢？就让我们通过今天的学习，来尝试解决这个问题吧。

必知必会，查缺补漏

你先来好好看看上面给出的那个设计的很丑的示例代码，case 结构中的 5 行代码，除了 printf 中的格式控制字符串不同以外，其余代码均相同，是不是稍稍感觉这里有些可操作的空间呢？

1. 值和变量

在继续往下讲之前，我们先来重新认识一下两个概念，就是“变量”和“值”的概念。“变量”你肯定不陌生，之前的课程中我们一直在说，例如：整型变量，浮点型变量。

所谓整型变量，就是存储整型的变量。这么解释呢，好像又啥都没说，可这句话本来就带有不说自明的效果。根据名字理解，就是可以变化的量，就像我在代码中定义了一个整型变量 a，然后通过读入给 a 赋值，我问你，a 等于多少，你只能回答不知道，因为这个 a 变量的值是可以变化的。

看完了变量这个概念以后，那什么又是“值”呢，也很简单，就是存储在变量中的内容，就是值。整型变量存储整型值，浮点型变量存储浮点型值。比如说，数字 3 就是整型值，如果我们为了后续计算想存储这个整型值，就需要放到一个整型变量中。

所以总得来说，具体结果就是值，明确知道等于多少的就是值。存储这些值的地方，就是变量，就是在程序中用来指代某片存储空间的名称，值就是存储空间里面的具体内容。如下图所示：

图1:变量和值

2. 字符串和字符数组

为什么要明确什么是“值”什么是“变量”呢，因为我想让这两个概念在你的脑子中产生具象化的效果，一说到“值”，你就知道，是存储在“变量”中的内容，某一种类型的值，存储在某一种类型的变量中。

下面将要讲解的这两个概念，就是“值”和“变量”概念的衍生品，它们是“字符串”与“字符数组”。

“字符串”就是“值”，而“字符数组”就是存储字符串数据的空间，类比于“变量”的概念。如果说 1、2、3、4、5 这种整型“值”，你可能很容易理解，因为在你之前的学习中就接触过。那么什么是字符串值呢? 先看一下字符串值的形式：

图2：字符串数据示意图

如上图所示，是一个 “hello world” 内容的字符串数据，字符串数据被一对双引号包裹，中间是字符串内容。像我们看到的上面字符串内容中的 h 啊，e 啊，l 啊，这些就是所谓的“字符”，那么多个字符写成一串，故名“字符串”。为了加以区分，字符内容是用单引号，字符串内容是用双引号。

之前我们学过，一个字符占一个字节的空间，那么这个 “hello world” 字符串内容占多少个存储空间呢？hello 有五个字符，world 有五个字符，是 10 个字符么？不对。别忘了中间还有一个空格字符呢，虽然不可见，可你看得出来它对内容的影响。

那么加在一起一共是 11 个字符么？也不对，这个字符串数据中，还有一个彻底看不见影响，可对于底层来说很重要的字符，我们管它称作“杠零字符”，写作 \0。每次读这个，都让我想起健身房里面的杠铃。

那么这段字符串实际在内存中存储的结果，应该如图中所示：

图3：字符串存储示意图

你可以看到，“hello world” 字符串中的每一个字符会占用一个字节存储单元，结尾还有一个 \0 字符，这个 \0 字符是标记字符串结束的。也就是，我们的程序在看到这个字符串的时候，会从第一个字符开始，直到碰到 \0 字符为止，这中间的内容就是字符串的内容。

这里我们要强调两个概念“字符串长度”和“字符串占用空间”，这是两个相似但不同的概念。字符串长度，就是字符串内容所占字节数量，例如示意图中“hello world”这个字符串内容，长度是 11。

而如果要说这个字符串所占用的空间大小，那就别忘了，还有一个 \0 字符需要额外占用 1 位呢。所以如果说到“字符串占用空间”是多少的话，应该是 12，它要比“字符串长度”多 1，多出来的这个 1，就是用来存放 \0 字符的。

看完了字符串的基本概念以后，字符数组的概念就容易理解得多了，就是用来存放字符串的变量空间。从名字上面看的话，字符数组本质上是个数组，但这个数组的每一个元素类型不再是我们之前说到的整数类型，而是变成了字符型，也就是之前所提到的 char 类型。

那么如果想要存储示意图中的字符串，我们需要定义一个多大的数组呢？你稍加思索，就应该知道，最少要定义一个长度为 12 的数组。

char str[12] = "hello world";
printf("%s", str);

就如上面这段代码所示，定义了一个长度为 12 的字符数组，然后将字符串 “hello world” 存储到这个空间中。注意，这种给字符数组赋值的方法，只能出现在字符数组定义时这样使用，非定义语句中，不能这样给字符数组赋值，会得到一个编译器给的错误提示。然后在第 2 行代码中，我们通过 printf 函数，输出字符数组中的信息，其中 %s 是字符串数据的格式占位符。最终你会在你的电脑上得到一行 hello world 的输出。

3. 字符串操作的利器

明确了什么是“字符串”和“字符数组”以后，下面来让我们看看如何操作字符串信息。说到操作，你来回忆一下整型数据支持的操作都有什么？能想到的最简单的是不是就是：加、减、乘、除和取余运算。

所谓操作，更准确地说是“运算”，就是使用现有值，产生新值的一个过程。上面我们明确了字符串数据就是一种值，那么这种值又支持哪些操作呢？

很多资料上面会讲解一些函数方法，例如：求字符串长度的 strlen 函数，拷贝字符串的 strcpy 函数，比较字符串的 strcmp 函数，连接两个字符串的 strcat 函数。因为这些函数方法较为简单，我打算把这些函数方法的学习作为你的自学作业，请你自学 string.h 头文件中的相关函数使用。

除此之外，今天我打算给你介绍两个更加灵活且强大的字符串操作函数：sscanf 函数和 sprintf 函数。

从名字上面来看，除了名称前面加了一个 s 以外，剩下的部分，就是 scanf 和 printf，前面这个 s 其实指代的是 string，也就是字符串。正常来说，与 scanf 和 printf 进行信息交互的场所就是你运行 C 语言程序时候所看到的那个黑色框框，而与 sscanf 和 sprintf 进行信息交互的场所则是字符数组。你读这句话可能有点懵，听我继续往下讲。

下面让我们看一个 sscanf 的简单例子：

char str[20] = "123.45";
double num;
sscanf(str, "%lf", &num);

在这个例子中，第 1 行是一个字符数组 str，其中的字符串信息是 “123.45”，第 2 行定义了一个浮点型的变量，第 3 行代码是重点，它使用 sscanf 从 str 所存储的字符串中读入一个浮点型数据，然后赋值给 num 变量，这里比传统的 scanf 函数多了一个参数，这个参数代表读取数据的字符串信息。

上面例子中展示了如何将一个字符串转换成一个浮点型数据，即把原始字符串的“123.45”转换成了浮点型数据“123.45”。那么你也可以自行设想将字符串转换成整型数据等等。其实 sscanf 就是提供了一种将字符串信息转换成其他信息的方式。

看完 sscanf 以后，下面看 sprintf 就简单多了，sprintf 也是比传统的 printf 函数多了一个参数，请看下面的这个使用 sprintf 输出 “hello world” 字符串的例子：

char str[100];
sprintf(str, "hello world");
printf("%s", str);

例子代码中分成 3 行，第 1 行定义了一个字符数组 str，第 2 行调用 sprintf 函数，相比于 printf 函数，多了第一项的参数，代表将原本输出到标准输出流中的内容，输出到 str 数组中。所谓标准输出流是一种专业的叫法，现在你可以简单的认为，就是你所认识的那个黑框框。

在这个例子中，也就是将 “hello world” 字符串，输出到了 str 字符数组中，也就是完成了一个字符数组赋值的过程。最后一行，使用 printf 函数，打印 str 数组的值，你会在屏幕上看到 “hello world” 字符串。

通过上面这个例子，你可以清楚的看到，sprintf 函数就是将原本 printf 函数输出的内容，输出到一个字符数组中存储起来，以方便在程序中的后续操作和使用。

sscanf 函数与 sprintf 函数对比着看的话，如果说 sscanf 是将字符串转换成整型、浮点型等其他类型的方法，那么 sprintf 就是将其他类型转换成字符串类型的方法。

一起动手，搞事情

今天的动手实践环节呢，我给你准备了两道特别有意思的题目，一起来看看吧。

思考题（1）：体验利器

刚刚在上面，介绍了 sprintf 和 sscanf 两个字符串处理的利器工具，那么就请使用 sprintf 函数实现 strlen、strcpy、strcat 函数的功能，注意哦，只允许使用 sprintf 函数，模仿如上三个函数的功能。

思考题（2）：优美的遍历技巧

介绍完了字符串的相关知识以后，请思考如下问题，如何在不计算字符串长度的情况下，遍历字符串的每一位呢？

程序设计要求是，读入一个字符串，然后在不计算字符串长度的情况下，输出字符串中的每一个字符，每个字符占一行。其中所谓计算字符串长度的方法，包括但不限于 strlen，sprintf，先行 for 循环遍历统计等。

实现保留任意小数

最后，让我们回到今天的任务，实现保留任意位小数输出的功能。请你注意观察，printf 函数的第一个参数，究竟是个什么？你会恍然大悟，printf 函数的第一个参数不就是个字符串，既然是字符串信息，那我只需要在 printf 语言前，拼接合适的字符串信息传给 printf 函数即可。

例如，要求保留 1 位小数的时候，我就拼接出来 “%.1lf” 字符串信息，要求保留 2 位小数的时候，就拼接处理出 “%.2lf”字符串信息即可。

下面是我给出的示例程序，你可以作为参考：

#include <stdio.h>
int main() {
    double num;
    int n;
    char str[100];
    scanf("%lf%d", &num, &n);
    sprintf(str, "%%.%dlf\n", n); // %% 代表输出一个 %
    printf(str, num);
    return 0;
}

程序主函数的第 3～5 行，分别定义了一个浮点型变量 num，一个整型变量 n 和 一个字符数组 str。接下来我们就要依据 n 的信息，利用 sprintf 函数把构造出来的格式控制字符串信息输出到 str 字符数组中。

这里需要注意的是第 7 行的两个百分号 （%%），它代表输出的时候，输出一个百分号，这是为了与格式占位符的 % 相兼容。试想一下，如果不这样规定，当你格式控制字符串是 “%d” 的时候，就会出现歧义，一方面可以解释成为是输出一个 % 和一个 d 字符，另一方面可以代表十进制整型的格式占位符。

以防出现这种歧义，当我们想输出一个百分号的时候，需要在格式控制字符串里面，打上两个百分号，以示区分。

最后我们把字符数组 str 当成原来的格式控制字符串传给 printf 函数，这样，就将文章开始时，代码中的 printf 函数的第一个参数，从定值字符串信息，替换成了变量字符数组 str 中所存储的信息。通过今天这个任务，你应该可以看到，拥有变量的程序，会使得我们的程序更具一般性和通用性。

其实变量代表了问题中可以被抽象化出来的量，就像以前，我们刻板的认为 printf 的第一个参数只能是一个显示的字符串信息，通过今天的学习，我们才彻底明白，printf 第一个参数，无非就是需要一个字符串的值，所以我们不仅仅可以传递给 printf 函数一个字符串的值，更可以传递给它一个字符数组，这样会使得整个程序功能更加灵活。

课程小结

通过今天对于字符串内容的学习，我们更加明确了“值”和“变量”的概念，这个概念，在后面学习指针相关知识的时候是非常重要的，所以你可千万不要忽视了今天我们花大量篇幅解释的这两个看似显然的概念。记住“值”和“变量”是两个概念，“变量”是存储“值”的地方。

最后，我希望你通过今天的学习，能够记住如下两点：

字符串信息可以存储在字符数组中，字符数组就是“变量”的概念，字符串就是“值”的概念

sscanf 和 sprintf 函数，本质上在做的是以字符串为中间值，做各种数据类型之间的转换。

好了，踏实地消化吸收今天的内容吧，我是胡光，我们下节课，见指针。

07 | 指针系列（一）：记住，指针变量也是变量

你好，我是胡光，上节课中，我们对两个概念做了区分，就是“值”和“变量”。你也看到了，当我们将 printf 函数中的第一个参数，抽象成变量以后，整个程序的功能会变得异常的灵活。

今天我们将要学习的 “指针”呢，也是一种变量，这是一种存储地址的变量。这种变量，可谓是所有变量的终极形态，掌握了指针，也就掌握了程序设计中“变量”的全部知识。今天，我们只会围绕着一句话进行学习，一定要记住，那就是 “指针变量也是变量”。

任务介绍

这次的任务，是需要我们结合两次学习（本节内容和下一节内容）才能完成，到底是什么呢？你不要有畏惧心理，其实这个任务很简单，假设有如下结构体数组，请看如下代码：

struct Data {
    int x, y;
} a[2];

请用尽可能多的形式，替换下面代码中 &a[1].x 的部分，使得代码效果不变：

struct Data *p = a;
printf("%p", &a[1].x);

你会看到，如上代码中，其实就是输出 a[1].x 的地址值。

到了这里，你可能对结构体还不熟悉，并且，你可能对于这个任务应该如何完成还是一头雾水，没关系，暂时忘了这个任务，我们先来讲讲可以解决任务的一些基础知识，再回来看这个任务。

进行下面的学习之前，我还是要强调一下那句话，这句话是我们这两次学习的重点，也是帮助你学习指针的利器，叫做“指针变量也是变量”。

必知必会，查缺补漏

1. 初识：结构体

为了完成今天的任务，你先要学习一些关于结构体的知识。先来想一个这样的问题：想要在程序中输入 n 个整数的话，我们知道可以用整型数组来进行存储，可是如果想要是输入 n 个点的坐标信息呢？用什么类型的数组来存储呢？是使用坐标类型的数组来存储么？没错！

你可能会疑问了，坐标类型怎么表示呢？其实这个坐标类型，可不像整型一样，整型是程序语言中给我准备好的现成的类型，而这个所谓的坐标类型，虽然程序语言中没有，但我们可以通过 C 语言里面的工具来描述这种类型的特点，这个可以用来描述和定义新类型的工具，就叫做：结构体。

下面我们看看如何用结构体定义一个新的数据类型，名字就叫做 point 类型吧：

struct point {
    // 描述这个类型的组成部分
};

上面在这行代码中，我们定义了一个新类型，是 struct point，也就是结构体点类。我这里强调一下，这个新类型不是 point，在 C 语言中，这个新类型是 struct point。struct 是关键字，代表结构体，point 是为了与其它结构体定义的类型相区分，后面的大括号内部是用来描述这个新类型的组成部分的。

有了这个类型以后，你就可以写如下的代码，来定义点类型的变量了：

struct point p1, p2;

正如你看到的，我们定义了两个点类型的变量，p1 和 p2，可由于上面我们没有具体描述点类型的组成部分，所以这个 p1 和 p2 变量只是名义上的点类型变量，却没有什么实质性的作用。

什么叫做“具体描述点类型的组成部分”呢？来让我们想想，我们如何表示一个坐标点，在数学中，一般情况是用一个二元组 (x, y) 表示一个点坐标。假设，在我们的问题场景中，点坐标都是整型的话，那么程序中的点类，就应该是由一对基础的整型变量组成的，具体写成代码如下所示：

struct point {
    int x, y;
};

正如你所看到的，我们在原本的结构体点类的大括号中，加入了两个整型字段，具体的语义含义是，一个点类型数据其实可以具体的表示成为两个整型数据。

在这个过程中，有没有一种盖房子的感觉？先有地基，再盖一楼，然后是二楼。也就是在程序中，先有基础数据类型，然后是基于这些基础数据类型，定义出新的数据类型。

你也可以想象，我们其实可以用我们定义出来的新类型，去定义另一个更新的类型。而所谓 C 语言中的基础数据类型，就是程序语言给我们准备好了的地基，而所谓程序的功能模块，就是别人盖好的房子，我们直接拿过来使用。就像之前我们了解的 printf 函数和 scanf 函数一样，都是 C 语言给我们准备好了的基础功能模块。

有了基础功能，我们可以开发更高级的功能，有了基础类型呢，我们也可以开发更复杂的类型。这个过程，将来你可以自己逐渐的加深体会，在这里，我就不过多的展开来说了。

描述了结构体点类型的具体组成部分以后，之前的 p1 和 p2 变量就具备了实际的功能了，下面，我们让 p1 代表点 (2, 3)，让 p2 代表点 (7, 9)，代码如下：

p1.x = 2;
p1.y = 3;
p2.x = 7;
p2.y = 9;

可以看到，我们可以给 p1 和 p2 变量中的 x，y 字段分别赋值。这里出现了一个新的运算符，就是点“.”运算符，这个也叫做“直接引用”运算符，p1.x，意思是 p1 变量里面的 x 字段。后面讲解完指针内容以后，我们还会介绍间接引用运算符“->”，由一个减号和一个大于号组成，这个我们后面再说。

2. 结构体变量的大小

就像我们之前所说的，变量是存储值的地方，只要是变量，就一定占用若干存储单元，也就是占用若干字节的空间。结构体变量既然也是变量的话，那么一个结构体变量又占用多少个字节呢？

以我们刚才设置的结构体变量为例，这个包含两个整型字段的结构体类型变量，占多少个字节的存储空间呢？你可能会想，那还不简单，最起码要拥有足够放下两个 32 位整型数据的存储空间吧，因为其中包括了两个整型字段，所以一个 struct point 类型变量最起码应该占 8 个字节。如何验证你的想法呢？还记得之前讲过的 sizeof 方法吧？

struct point p;
sizeof(p);
sizeof(struct point);

这两种使用 sizeof 方法的代码均能正确的告诉你一个 struct point 类型的变量占用的存储空间大小。至此，你可能感觉自己已经掌握了计算结构体变量大小的诀窍。

先不要高兴太早，看下面这两个结构体的情况：

图1：结构体占用空间对比

可以看到， Data1 和 Data2 两个结构体，都是由两个字符型字段和一个整型字段组成的。但这个对比中，存在两个你无法忽视的问题：

Data1 结构体，只包含一个整型和两个字符型字段，所占用的空间大小应该是 4+1+1=6 个字节啊，怎么变成了 8 个字节？

Data2 结构体，和 Data1 结构体包含字段种类都是一样的，那既然 Data1 是 8 个字节，为什么 Data2 是 12 个字节呢？

下面我们就来对这两个问题，一一作答，学会了这两个问题，你才是真正抓住了计算结构体变量大小的诀窍。

先来看第一个问题，为什么 Data1 类型的变量占用的是 8 个字节，而不是 6 个字节呢？这里就要说到结构体变量申请存储空间的规则了。正如你知道的，像整型这种 C 语言原有的内建类型，都是占用若干个字节，整型变量的存储，就是以字节为单位的。而今天我们学到的结构体变量，需要占用若干个存储单元，结构体变量的存储，就是以存储单元为单位的，那么一个存储单元占用多少个字节呢？

记住，下面这个就是重点了：对于某个结构体类型而言，其存储单元大小，等于它当中占用空间最大的基础类型所占用的字节数量。

说白了，对于 Data1 结构体类型来说，整型是其当中占用空间最大的基础类型，它的一个存储单元的大小，就是 4 个字节，等于它当中整型字段所占用的字节数量。也就是说，Data1 这个结构体类型，要不就占用 1 个存储单元，即 4 个字节的空间，要不然就占用 2 个存储单元，即 8 个字节的存储空间，不会出现 6 个字节的情况。

那么究竟占多少呢？按照最小存得下原则，Data1 最少应该占用 2 个存储单元，才能放下一个整型和两个字符型，这就是为什么 Data1 类型占用 8 个字节的原因。

你会问了，按照这个解释，那 Data2 为什么占用 12 个字节呢？Data2 中不也是一个整型和两个字符型么？先别着急，这就进入我要讲的第二个重点了：结构体的字段在内存中存储的顺序，是按照结构体定义时的顺序排布的，而且当本存储单元不够安放的时候，就从下个存储单元的头部开始安放。

这是什么意思呢？下面是我给你准备的一张 Data1 和 Data2 两个结构体类型的内存占用情况图：

图2：结构体内存结构示意图

你可以看到，在 Data1 中，首先是 int 类型的 a 变量，占用了第一个存储单元，然后 b 和 c 占用了第二个存储单元的前两个字节。

再看 Data2，由于 Data2 不同于 Data1 的字段顺序，b 占用了第一个存储单元的第一个字节，剩余的 3 个字节不够存放一个 int 类型变量的，所以按照上面我们讲的规则“当本存储单元不够安放的时候，就从下个存储单元的头部开始安放”， a 变量就单独占用了第二个存储单元，c 自己占用第三个存储单元的第一个字节。

所以，虽然在数据表示上，Data1 和 Data2 是等价的，可 Data2 却占用了更多的存储空间，相比于 Data1 造成了 50% 的空间浪费。由此可见，在设计结构体的时候，不仅要设计新的结构体类型中所包含的数据字段，还需要关注各个字段之间的顺序排布。

3. 指针变量也是变量

看完了结构体相关的知识以后，下面来让我们进入一个被很多初学者称为 C 语言中最难理解的的部分，指针相关知识的学习。面对这部分内容，我只希望你记住一句话：指针变量也是变量。

想想之前我们学习的“变量”和“值”的概念，我们说，什么类型的值，就用什么类型的变量进行存储，整型变量，是存储整型值的东西，浮点型变量是存储浮点型的东西。

当你听到“指针变量也是变量”这句话的时候，我希望你能提出如下问题：既然指针变量也是变量，那指针变量是存储什么类型的值的呢？还记得我们之前讲的地址的概念吧，你会发现，所谓变量的地址，就像整数和字符串一样，其实是一个明确的值啊。

那对于地址，我们使用什么变量来进行存储呢？没错，指针是变量，指针是一种用来存储地址的变量！在这里我再强调一遍“指针变量也是变量”，这意味着，你之前对于“变量”这个概念的认识，都可以放到指针变量的理解上。

让我们先来看一下如何定义一个指针变量：

int a = 123, *p = &a;
printf("%d %p %d\n", a, p, *p);

在上面这段代码中，a 是一个整型变量，p 变量前面多了一个 *，这个 * 就是用来说明 p 是一个指针变量，是一个存储整型变量地址的指针变量，在代码中，你也可以看到，我们将 a 的地址赋值给了 p 变量。

代码的第 2 行，共输出三项信息：第一项输出 a 中存储的整型值（第一个 %d 对应的是 a)，第二项是输出 p 中存储的地址值（%p 对应的是 p)，第三项输出的是 *p 的值（第二个 %d 对应的是 *p)，p 里面存储的是地址，*p 代表了 p 所指向的存储区内部的值。

为了更清楚的解释 *p，给你准备了下面的图，以便你理解 a 和 p 的关系：

图3：a 变量与 p 变量

从图中你可以看到，p 变量中存储的就是 a 变量的首地址，也就是说，我们可以通过 p 变量中所存储的信息，按图索骥，就能找到 a 变量所代表的存储区，进而操作那片存储区中的内容。 p 变量对于 a 变量的作用，是不是很像一个指路牌呢？指针的名称，也就由此而来。

我们再来看，如果 p 本身代表了 a 变量的地址，那么如何取到这个地址所对应的存储空间中的内容呢？这个就是 * 运算符，放到变量名前面，我们叫做“取值”运算符，对于 *p 的理解就是取值 p 所指向存储区的内容，也就是原有 a 变量中所存储的值。

一种更简单的理解方法是，在写程序的时候 *p 就是等价于 a，也就是说当你写如下代码的时候：

*p = 45;

实际上等价于写了一行代码 a = 45。也就是说，实际上是把 a 变量中存储的值给改变了。

课程小结

在最后的这个例子中呢，聪明的你有没有注意到这样一个问题：a 变量实际上有 4 个地址，p 中存储的只不过是 a 变量的首地址，也就是说，p 中所存储的地址，只指向了一个字节的存储空间，那为什么当我们使用 *p 的时候，程序可以正确的对应到 4 个字节中的数据内容呢？

上面这个问题，就要涉及到指针的类型的作用了，下一篇文章我们再详细聊一下这个事情。今天要说有什么重点需要你记住的，那就是希望你记住如下两点：

结构体是用来创造新类型的利器，而结构体类型所占存储空间大小，与其内部字段的组成和各个字段的顺序排布均有关。

指针变量也是变量，这是一种存储地址信息的变量。

好了，我是胡光，我们下次见。

08 | 指针系列（二）：记住，指针变量也是变量

你好，我是胡光，咱们又见面了，上节课中，我们介绍了结构体相关的基础知识，也介绍了指针变量，并且教给你了最重要的一句话“指针变量也是变量”。这句话的意思在于告诉你，所有你对变量的理解，都可以放到指针变量上，例如：变量有类型，变量有大小，变量里面的值支持某些操作等等。今天呢，我们就来详细地聊一下指针变量。

任务回顾

在正式开始之前，我们先来回顾一下上节课的任务内容：

上节课我们说，如果给我们如下 Data 结构体类型，这个类型中有两个整型数据字段 x，y：

struct Data {
    int x, y;
} a[2];

那么请用尽可能多得形式，替换下面代码中 &a[1].x 的部分，使得代码效果不变：

struct Data *p = a;
printf("%p", &a[1].x);

你会看到，如上代码中，就是输出 a[1].x 的地址值。

通过上节的学习，你现在已经掌握了关于结构体的相关知识，也初步地接触了“指针变量也是变量”的这个概念，今天就让我们再深入了解指针变量吧。

必知必会，查缺补漏

1. 深入理解：指针变量的类型

还记得我们是如何定义 p 变量的么？代码语句是：

 int *p

之前我们介绍了，语句中的 * 代表 p 变量是一个指针变量，而 int 的作用是什么呢？只是用来说明 p 是一个指向整型存储区的指针变量么？其实 int 更大的作用，就是用来解决我们上面提到的那个问题，根据 p 变量中的内容，我们可以找到一个存储区的首地址，然后再根据 p 的类型，就可以确定要取几个字节中的内容了。

下面给你举个例子：

int a = 0x61626364;
int *p = &a;
char *q = (char *)&a;
printf("%x %c\n", *p, *q);

这段上面代码中，p 和 q 同时指向了 a 变量的存储区。而取值 p 和取值 q 的结果，却截然不同。这是因为，取值 p 时，程序会从 p 所指向的首地址开始，取 4 个字节的内容作为数据内容进行解析，而取值 q 的时候，则是取 1 个字节的内容，作为数据内容进行解析。

你如果运行上述代码，大概率你会看到输出内容是：

61626364 d

小概率会看到输出内容是：

61626364 a

这个原因和“大端机”“小端机”有关，关于这个问题，你要是有兴趣的话，可以自行查阅相关资料。下面的图中呢，就是以“小端机”为例，说明的 p 和 q 取值的问题：

图1：指针变量取值示意图

如图所示，p 变量对应了 a 变量整个存储区中的内容，所以输出取值 p 和 a 原本存储内容相同。而 q 变量由于是字符型指针变量，只能从首地址取到 1 个字节的内容，取到的就是 64，这里的 64 注意可是 16 进制的数字，对应到 10 进制数字就是 100，而 %c 是输出一个字符，数字 100 对应的字符就是英文小写字母 ‘d’。

实际上，我们看到的任何字符，在底层都对应了一个具体的数字。常用的有字符 ‘a’，对应的是 97，字符 ‘b’，对应的是 98，以此类推，还有数字 ‘0’ 是 48，数字 ‘1’ 是 49，后面的对应规律类似，我们管这个对应规则叫做 ASCII 编码。

指针变量的类型，除了用来确定取值时，确定覆盖存储区的大小以外，还有其他作用。想一想，整型支持加减乘除操作，而我们所谓的地址类型的值，也可以在其上面做加减的操作，你可以试着运行下面的代码：

int a, *p = &a;
char *q = &a;
printf("%p %p", p, q);
printf("%p %p", p + 1, q + 1);

代码中，定义了三个变量，其中一个整型变量 a，两个指针变量 p 和 q，其中 p 是整型指针变量，q 是字符型指针变量。然后分别输出 p 和 q，以及 p + 1 和 q + 1 的值以作对比。

如果你运行上面的程序，你会看到，p 和 q 的值是相同的，都是 a 变量的首地址，但是 p + 1 和 q + 1 的值却不同。如果你仔细观察会发现，p + 1 的地址值与 a 的地址之间差了 4 个字节，而 q + 1 的地址值与 a 的地址之间只差了 1 个字节。

图2：地址加法操作结果

通过上图，你就可以更清晰的看到，由于 p 是整型指针，所以 p + 1 的计算结果，是向后跳了一个整型，相当于从第一个整型的首地址，跳到第二个整型的首地址；而由于 q 是字符型指针，所以 q + 1 的计算结果，就是向后跳了一个字符型。

这样，你就可以明白了吧？如果一个浮点型的指针变量加 1，就会向后跳一个浮点型。这就是指针变量类型的第二个作用：在加法或者减法时，确定增加或者减少的地址长度。

2. 指针变量与数组

理解了指针类型的作用以后，我们再回到“指针变量也是变量”这句话上，指针变量所存储的值，就是地址。在之前的学习中，还有什么是与地址相关的概念呢？你一定会想起数组这个概念。对，数组名代表了数组中第一个元素的首地址，也是整个数组的首地址，既然是地址，那就可以用指针变量来存储。

下面，我就跟你说几个之前没有告诉你，但却很有趣的事情。

假设有一个整型数组 arr，如何表示第二个元素的地址呢？是不是 &arr[1] ？如果 arr 也代表了整个数组的首地址，同时把这个首地址存储在一个整型指针变量 p 中，那么用这个指针变量如何表示第二个元素的地址呢？

根据上面的学习，应该是 p + 1。那如何表示 arr[n] 元素的地址呢？稍加思索，你就应该知道就是 p + n。所以我们现在知道了，在程序中，&arr[n] 等价于 p + n，当然也等价于 arr + n，聪明的你别犯糊涂，一定要注意，参与运算的是值，不是变量名！

既然 p 中存储了一个地址，可以参与加法运算，那么 arr 实际上也代表了一个地址，也可以参与加法运算。地址才是参与运算的值，指针只是存储地址值的变量，只是一个容器。所以，不是指针支持加减法操作，而是地址这种类型的值，支持加减法操作。

在这里，我们回头看数组名称后面的那一对方括号，如果我告诉你这也是一个运算符，你会想到什么？请注意认真看下面这一段合理化的猜想推理：

如果那一对方括号代表了运算符，而运算符本质上是作用在值上面，也就是说，当我们写 arr[1] 的时候，方括号运算符前面看似放着一个数组名，实际上放了一个地址，放了一个数组的首地址，因为 arr 就是数组的首地址，还是那句话：地址才是参与运算的值。也就是说，当我们把数组的首地址，存储在一个指针变量中以后，这个指针变量配合上方括号运算符，也可以达到相同的效果！

为了让你更清楚的理解，准备了如下演示代码：

int arr[100] = {1, 2, 3, 4};
int *p = arr;
printf("%d %d\n", arr[1], p[1]);

代码中，我们定义了一个整型数组 arr，然后将数组的首地址赋值给了一个整型指针变量 p，最后分别输出 arr[1] 和 p[1] 的值，你将看到输出的是同一个值，都是数组中第二个元素的值。

最后，我用一张图给你展示了指针与数组的几个程序代码层面的等价关系，在实际编程过程中，重点是需要分析，相关的指针操作后，对应的到底是哪个元素，对应的是这个元素的首地址，还是这个元素的值。

图3：指针与数组的等价表示

从上图的等价表示中，你可能会自己推导出另外一种等价表示 *(p + 5) 等于 arr[5]。我希望你重视等价表示的学习，因为所谓等价表示，就是在写程序的时候，多种等价表示，写哪一种都一样。这就造成了，不同的编码习惯，会用不同的符号来完成程序，如果你不理解这些等价的表示方法，很有可能在看别人程序的过程中，就会出现看不懂的现象。

3. 指针变量的大小

最后，我们再回到“指针变量也是变量”这句话上。只要是变量，就占据一定的存储空间，那一个指针变量占多少个字节的存储空间呢？

在回答这个问题之前，我先问你另一个问题，请你思考一下：是整型指针变量占用的存储空间大，还是字符型指针变量占用的存储空间大？我们想想啊，一种数据类型占用多少存储空间跟什么有关系？和存储的值有关系啊。当你想存储一个 32 位整数的时候，就必须要用 4 个字节，不能用 2 个字节，也不能用 3 个字节，这都是不够的。

究竟是哪一种类型的指针占的存储空间大呢？答案是：一样大。为什么呢？就是因为，无论是什么类型的指针，存储的值都是某个字节的地址，而在一个系统中，无论是哪个字节的地址，二进制数据长度都是一样的。所以，无论什么类型的指针，所需要存储的值的底层表示长度是一样的，那么所占用的存储空间也当然是一样的了！

有句话描述的非常形象“类型就是指针变量的职业”。什么意思呢？我们知道现实生活中，有些人做保安，有些人做工程师，还有些人当艺术家，可不管你做什么，你无法改变的是你作为人的生理结构。所以放到指针变量的概念里，那就是不管什么类型的指针，指针所改变不了的是其占用空间的存储大小，因为不管是什么类型的指针，存储的都是无差别的地址信息。

任务参考答案

至此，我们终于准备完了所有的基础知识，下面就让我们回到最开始的那个任务吧。对于这个任务，如果我们要是想写的话，至少能写出 20 种以上的答案。这里，我会选出两种比较有代表性的、比较有趣的做法分享给你。

1. 间接引用

首先来看第一种：

struct Data *p = a;
printf("%p", &((a + 1)->x));

这里用到了一个之前提到过，可是没有讲到的运算符，减号大于号（->），组合起来，我们叫做“间接引用”运算符，作用可以和“直接引用”运算符对比。

例如：a 是一个结构体变量，a 中有一个字段叫做 x，由 a 去找到 x，这个过程比较直接，我们就用 a.x 来表示。可如果 p 是一个指针，指向 a 变量，如果要是由 p 去找到 x，这个过程就是个间接的过程，所以我们就使用 p->x。简单来说，就是：是结构体变量引用字段，就直接引用，如果是指针想引用字段，就是间接引用。

在这个第一种做法中，直接用 a + 1 定位到第二个结构体元素的首地址，然后间接引用 x 字段，最后再对 x 字段取地址，那么得到的和原任务中所输出的地址是一样的。

2. 巧妙使用指针类型

再来看一下第二种：

struct Data *p = a;
printf("%p", &(a[0].y) + 1);

这个第二种做法就有点儿意思了。首先，它先定位到 a[0] 元素中 y 字段的首地址，然后对 y 字段取地址，这个时候，由于 y 字段是整型，所以取到的地址类型就是整型地址，之后再对这个整型地址执行 +1 操作，得到的也是 a[1].x 的首地址。

按照之前所学，画出内存中的存储示意图，你就会得到下面这张图的具体情况：

图4：a数组内存结构示意图

第二种方法巧妙的利用了地址类型这个知识点，通过整型地址加法操作结合对于内存存储结构的知识，综合运用以上两个知识点，最终定位 a[1].x 变量的地址。如果你可以独立想出这个方案，那我真的是要给你点赞的！

上面的方案中，都在用原数组 a 去定位 a[1].x 变量的地址，你可以使用 p 指针，完成相同的操作么？欢迎把你的答案写在留言区，让我也欣赏一下你的思维方式。记住，这个问题，至少能写出来 20 种以上的等价表示形式。

课程小结

今天我们终于讲完了指针部分，这一部分的知识，再回过头来看，虽然各种各样的知识点，可我想让你记住的还是那一句话：“指针变量也是变量”。

而在今天的学习中，我希望你记住的重点，有以下三点：

指针的类型，决定了指针取值时所取的字节数量。

指针的类型，决定了指针加减法过程中，所跨越的字节数量。

无论是什么类型的指针，大小都相等，因为地址信息是统一规格的。

好了，今天就到了这里了，我是胡光，我们下次见！

09 | 函数：自己动手实现低配版 scanf 函数

你好，我是胡光，今天是大年初一，是咱们中国传统的重要节日，春节！能在这样的节日氛围里，还能坚持过来学习的，我必须要说一声“鼠”你最优秀！在这里我也祝福热爱学习的你，在新的一年，身体健康，阖家欢乐！

今天呢，我们的学习课程也将迎来里程碑式的一课。所谓里程碑，是因为在这一节之前，你写的程序，只是片段，只是思想的随意表达，而通过了本节的学习，你的程序结构将发生翻天覆地的变化，会变得规格严整，变得可以复用，变得易于找错。

前面的课程，我们主要就是在一些基本的程序结构中做学习，包括顺序结构，分支结构以及循环结构。今天这一节中，我们将要认识的函数，可以将功能封装成可以复用的模块，就像创造乐高积木一样，废话不多说，开始今天的学习吧。

今日任务

对程序的输入输出函数，你应该已经很熟悉了。今天我们仿照 scanf 函数，实现一个低配版的 my_scanf 函数。这个函数的功能，简单来说就是将一个字符串信息转换成整型数字，能够完成这个任务，你会更深刻的理解 scanf 函数，更深刻的理解参数设计。下面给你几个例子。

首先先来看第一个基础功能：

int n = 98;
my_scanf("12345", &n);
printf("%d", n); // 输出 12345，而不是 98

上面这段代码中，我们利用 my_scanf 函数，将字符串信息转换成了整型数据，并且将结果存储到了 n 变量的内存空间中，调用 printf 函数打印 n 变量值的时候，输出的信息不是 n 变量原有的初值 98，而是 12345。对于这个基础的转换功能，要考虑兼容负数的情况。

只有这一个基础功能肯定是远远不够的，下面就让我们看另外一种情况：

int n = 98, m = 0;
my_scanf("123 45", &n, &m);
printf("n = %d m = %d", n, m); // 输出 n = 123 m = 45

上面这段代码中，首先我们定义了两个整型变量 n 和 m，然后给 n 初始化为 98，m 初始化为 0。之后给 my_scanf 函数传入的字符串信息中有一个空格，那么 my_scanf 函数会以空格作为分隔符，将第一个转换出来的数字 123 赋值给 n，第二个转换出来的数字 45 赋值给 m。

上面举例了 my_scanf 函数转换 1 个整型参数和 2 个整型参数情况，这些都是在函数的基本知识范围内的内容。经常有初学者学完函数相关的基本知识以后，就认为自己掌握了函数的全部知识，但事实绝非如此，而之所以初学者有这种“假想”，是因为他不知道如何找到和判定自己的知识盲区。

所以今天我们要讲的内容就是破除“假想”。这个任务就是要设计一个能够转换任意个整型参数的 my_scanf 函数，注意，这里的重点难点，可是在任意个参数上面。清楚了任务以后，下面就让我们进入今天的查缺补漏环节吧。

必知必会，查缺补漏

要完成今天的这个任务，首先你需要知道如何实现一个基本的函数，由于要支持转换任意多个整型参数，所以你还需要知道变参函数相关的知识。下面我们就逐项的来进行学习吧。

1. 函数的基础知识

数学中的函数，大家都不陌生，一般的形式是 f(x) = y，x 是自变量，y 是函数值。程序中的函数，和数学中的函数基本一致，有自变量，我们称作“传入参数”，还有函数值，我们叫做返回值。

先让我们来看一下程序中的函数的基本组成部分：

图1：函数的基本组成部分

如图 1 所示，一个程序函数从左到右，从上到下，大体可以分成四个部分：

第一个部分，是函数返回值的类型。

第二个部分，是函数名字，调用函数的时候，需要给出这个函数名，所以在设计函数名的时候，要尽量设计一个与函数功能有关的名字，例如上图中的函数，通过名字我们可知，这就是一个求平方的函数。

第三部分，是传入参数，就是数学函数中的自变量。

第四部分就是函数体，也就是要完成函数功能的逻辑代码，结果值是通过 return 语句进行返回的，而整个函数体的逻辑要包裹在一对大括号内部。

下面我们就来看一下在程序中如何使用函数功能：

#include <stdio.h>
int square(int x) { // 定义函数 square
    return x * x;
}
int main() {
    int n;
    scanf("%d", &n);
    printf("%d\n", square(n));
    return 0;
}

上述代码中，在主函数中，我们读入一个整型数字 n，然后输出 n 的平方值。这里在计算 n 的平方值的时候，程序中调用了上面定义的 square 函数，那么 printf 函数相当于输出的是 square 函数的返回值，根据 square 函数的实现，如果传入的值是 x，那么返回值就是 x * x ，即 x 的平方值。

这里需要你注意两个概念，我们将 n 传递给函数 square 的过程中，会涉及到 n 给 square 函数参数 x 赋值的过程。也就是说，主函数中的 n 变量和 square 函数参数 x 变量是两个相互独立的变量，其中 n 叫做“实参”，实际的参数，x 叫做“形参”，形式上的参数。

关于这个例子，我还要多说一句，还记得程序中的顺序结构吧，这是程序最基本的执行结构，也就是从左到右，从上到下的执行程序中的每一条语句。其实，函数和函数之间的关系，也可以理解为这种顺序执行的关系。

在这个例子中，我们在主函数中调用了 square 函数，也就意味着在这句话之前，程序中必须知道 square 函数的存在，因此 square 函数实现在了主函数之前。后面的文章中，你将会学到，其实 square 函数不用实现在主函数之前也可以，这就要涉及到“声明”与“定义”的区别了，这个我后面再和你详细解释。

2. 普通变量的函数传递参数

了解了函数的基本知识以后，接下来让我们重点学习一下函数的参数传递过程，也就是上文中提到的“形参”和“实参”之间关系的问题。接下来的学习，我们都是围绕着一句话展开的，你先记住：函数的参数传递过程，就是“实参”给“形参”赋值的过程，“实参”与“形参”之间互相独立，互不影响。

下面先来看一下普通变量的传递过程，请看下面这段程序：

#include <stdio.h>
void add(int n, int m) {
    n += m;
    return ;
}
int main() {
    int n, m;
    scanf("%d%d", &n, &m);
    add(n, m);
    printf("%d\n", n);
    return 0;
}

这段程序中，首先读入两个变量 n 和 m 的值，然后将 n 和 m 传递给一个名叫 add 的函数，add 函数的相关参数也叫 n 和 m，然后在 add 函数内部，将 m 累加到了 n 上面，之后函数返回结束，没有返回值。add 函数执行完后，回到主函数中，输出 n 的值。我的问题是，此时，n 的值有没有变化？

如果你实际运行这个程序，你会发现，n 的值不会改变，这就是我想让你记住的那句话，函数的参数传递过程，就是“实参”给“形参”赋值的过程。

这个程序中，主函数中的变量 n 就是“实参”，add 函数中的参数 n 就是“形参”，虽然两者名字一样，可完全是两个互相独立的变量。

两者有各自的存储空间，“实参”就是把自己存储空间中的值，复制一份给了“形参”，所以，在函数内部，我们实际修改的是“形参”中所存储的值，对主函数中的变量 n 毫无影响。整个过程如下图所示：

图2：实参、形参赋值示意图

如图所示，add 函数内部做的所有操作，都是在黄色的变量存储区内做的，对主函数中的变量存储区毫无影响。

那么如果我们想要改变 n 最后输出的值，你知道这个程序怎么改动呢？这里，你需要注意往下学习什么是传入参数和传出参数。

3. 数组的函数传参

看了普通变量的传参以后，下面来看一下数组作为参数时候的传参方式和特性，请看下面这段代码：

#include <stdio.h>
void add(int *p, int n) {
    for (int i = 1; i < n; i++) {
        p[0] += p[i];
    }
    return ;
}
int main() {
    int arr[10] = {1, 2, 3};
    add(arr, 3);
    printf("%d", arr[0]);
    return 0;
}

这段程序中，主函数定义了一个拥有 10 个整型元素的数组，然后数组的前三位分别初始化为 1、2、3，之后将数组作为 add 函数的第一个参数，第二个参数是一个数字 3，add 函数的功能是将传入的数组中的前 n 个位置的值，累加到数组的第一个元素上。在 add 函数执行完后，在主函数中输出数组第一个元素的值。

对于这份代码的输出，你有什么预测么？在你做出预测之前，我提醒你注意一个地方，就是 add 函数中负责接收数组参数的第一个参数的类型，是一个指针类型，这里结合之前的知识就能理解了。数组名一般情况下代表了数组的首地址，将一个地址作为值传入函数，当然要用指针变量来进行接收了。

最后，你运行这段程序，会发现输出的结果是 6，意味着数组中的第一个元素的值发生了变化。再想想今天我们要记住的那句话：函数的参数传递过程，就是“实参”给“形参”赋值的过程，“实参”与“形参”之间互相独立，互不影响。

不是说互相独立么，怎么数组的第一个元素的值却改变了呢。没错，数组的第一个元素的值确实在函数内部被改变了，可这跟“实参”和“形参”的关系完全没有冲突。

请你注意，这里面我们的“实参”，实际上是数组的首地址，形参是存储这个首地址的函数参数中的那个指针变量。也就是说，在 add 函数内部，操作的地址空间，和主函数中的那个数组的空间是一个空间，这就是为什么传递数组时，相关元素的值在函数内部可以被改掉的一个原因，因为传递的是地址！

图3：数组传参过程

就如图 3 中所示，主函数中的数组 arr 将自己的首地址赋值给了指针变量 p，两者虽然互相独立，可只要不改变指针变量 p 中存储的地址，p[0] 和 arr[0] 实际上对应的就是同一片存储空间，所以修改 p[0] 的值，也相当于修改了 arr[0] 的值。

4. 传入与传出参数

学习了函数的基本知识以后，最后让我们来看两个逻辑上的概念“传入参数”与“传出参数”。

请看下面这段程序：

#include <stdio.h>
void calc(int x, int *p) {
    *p = x * x;
    return ;
}
int main() {
    int n, m;
    scanf("%d", &n);
    calc(n, &m);
    printf("%d\n", m);
    return 0;
}

上面这段程序中，开始先定义了一个 calc 函数，calc 函数有两个参数，第一个是一个整型参数，第二个是一个整型地址，函数内部，将 x 的平方值存储到了 p 所指向的存储空间中。在主函数中调用了 calc 函数，分别传入 n 的值和 m 的地址，然后输出 m 的值，最后你会发现输出的 m 值，就是 n 的平方值。

在这里我们重点来讨论一下函数 calc 两个参数的本质作用。首先第一个参数 x，是外部传入的一个值，这个值在函数内部，要参与重要的运算过程，也就是说，这个值的感觉更像是从外部传入到内部，然后在函数内部发挥作用，这种类型的参数，我们就叫“传入参数”。

而再看 calc 函数的第二个参数，传入的是一个地址。在函数内部的作用，只是将计算得到的一些结果存储在这个地址所指向的空间中，而记录的这些结果，在函数内部是没有用的，是要等到函数执行完后，回到调用者之后，例如上面的主函数内部，才有用。这一个参数的作用，更像是把值从 calc 内部带出到主函数内部而设计的，这类参数，我们叫做“传出参数”。

就像上面代码中看到的，“传入参数”一般就是把值传进去就行，而“传出参数”由于要把值从函数中带出来，一般要传变量地址进去，这样，函数内部才能准确的把结果写入到相关地址所对应的内存中。

一起动手，搞事情

我们又到了每天的“一起动手，搞事情”的环节，今天呢，将给你留两个思考题。

思考题（1）：数组和函数的思考

请思考如下两个概念的异同：

一个整型数组元素，例如：arr[100]

一个传入整型并且返回整型的函数，例如：func(100)

这是一个开放思考题，写出你的理解及思考过程即可。

思考题（2）：如何确定存在知识的盲区

什么叫“存在知识的盲区”呢？就是当你面对一片黑暗的时候，你可以确定这里一定有知识，而不仅仅只是一片黑暗。就像今天我们学习了函数的相关知识，自然的，就会反问自己一句，这些就是函数知识的全部了么？我们如何来确定这个问题的答案呢？很简单，根据已知推未知。

我们假设现在学习的内容，已经是函数知识的全部了，也就是说，只要是函数，我们就能用我们现有知识对其加以解释。

那么，在之前，我们已知的函数中，有两个很基础，也很重要的函数，一个是 scanf 函数，一个是 printf 函数。

随便来看一个，例如来看 scanf 函数，当我问你，scanf 函数，传入几个参数的时候，你会发现是若干个。第一个参数是一个字符串，往后的参数，是根据字符串中格式占位符的数量而定的。在不要求你实现 scanf 函数功能的情况下，你能将 scanf 函数包含参数定义的形式写出来么？直到这里，我们就发现了一个存在知识的盲区。

所以，没有知识的盲区，只是盲区，发现有价值盲区的能力，也是我们要锻炼的重要能力。既然发现了这个知识盲区，给你留个小作业，自学“可变参函数”相关的知识吧。

实现 my_scanf 函数

准备完了对于函数的基础知识以后，再回到今天一开始提到的任务。首先来分析一下只转换一个整型参数的 my_scanf 函数应该如何进行实现。

第一步，我们先来看参数设计，第一个参数，应该是一个字符串类型的“传入参数”，代表要转换成整型信息的字符串信息。第二个参数，应该是一个指针类型的“传出参数”，指向存储转换结果的内存区域。

具体功能实现，请看下面这段代码：

#include <stdio.h>
void my_scanf(char *str, int *ret) {
    int num = 0, flag = 0;
    if (str[0] == '-') str += 1, flag = 1;
    for (int i = 0; str[i]; i++) {
        num = num * 10 + (str[i] - '0');
    }
    if (flag == 1) num = -num;
    *ret = num;
    return ;
}
int main() {
    char str[1000];
    int n = 65;
    scanf("%s", str);
    my_scanf(str, &n);
    printf("n = %d\n", n);
    return 0;
}

这段代码中，实现了 my_scanf 函数。在看 my_scanf 函数具体逻辑之前，先来看一下主函数里面都写了些什么。

主函数的头两行定义了两个变量，一个是字符数组 str，另外是一个整型变量 n，然后读入一个字符串，将其保存在字符数组中。再之后，使用 my_scanf 函数将字符数组中的字符串信息，转换成为整型信息存储在 n 中，最后，使用 printf 函数输出 n 的值，加以确认。

看完了主函数以后，再来看一下 my_scanf 函数的具体实现。my_scanf 函数第一行定义了两个变量，一个用于存放转换结果的 num 变量，另一个 flag 变量用来标记正负数的，0 代表正数，1 代表负数。

第 2 行判断字符串中的第一位是不是字符 ‘-’，如果是字符 ‘-’，就将 flag 标记为 1，并且把 str 字符指针所指的位置，向后跳动一位，因为 ‘-’ 后面就是要转换的第一个数字字符了。之后遍历字符串剩余的每一位，每次将当前字符所代表的数字，放到 num 数字的末尾。

其中 str[i] - ‘0’，就是将相关的数字字符，转换成对应的数字。之前我们说了，任何一个信息在底层存储的时候，都是二进制信息表示，也就是说，都可以转换为一个十进制数字，字符信息也不例外。其中字符 ‘0’ 所对应的底层数字是 48，字符 ‘1’ 是 49，字符 ‘2’ 是 50，依次类推。所以当我们用 ‘2’ - ‘0’ 的时候，相当于 50 - 48，得到的结果就是数字 2。

最后把 num 中的值拷贝到 ret 所指向的存储区中，也就是主函数中的 n 变量的内存区中。至此我们就完成了一个整型参数的 my_scanf 函数的实现。接下来，运用“可变参函数”的相关知识，改写这个程序，去独立完成最终形态的程序吧。

课程小结

今天讲的内容呢，是里程碑式的一课，到目前为止，你已经学会了将程序模块化的最基本技术：函数。也是从这一课开始，后面我将越来越多的起到引导你的作用，逐渐帮你撤掉学习中对我的依赖，如果后续学习中遇到什么问题，咱们随时在留言区中讨论。

最后呢，我来给你总结一下今天课程的重点，只希望你记住三点：

函数的作用，是做功能封装，以便在程序其他地方复用相关功能。

C 语言中的函数的传参过程，是实参给形参赋值的过程，改变形参的值，不会影响实参。

在函数参数设计中，一定要分清楚，传入参数和传出参数在功能上的差别。

好了，今天就到这里了，我是胡光，我们下次见。

10 | 预处理命令（上）：必须掌握的“黑魔法”，让编译器帮你写代码

你好，我是胡光，欢迎回来。今天是大年初四，春节的气氛依然很浓厚，春节玩得开心吗？但也别忘了咱们的继续学习哦。今天还在看专栏，依旧没有忘记学习的你，我必须赞叹一声：学会编程，非你莫“鼠”！

之前我们学习的编程知识，都是作用在程序运行阶段，也就是说，当我们写完了一段代码以后，只有编译成可执行程序，我们才能在这个可执行程序运行后，看到当初我们所写代码的运行效果。而你有没有想过，存在一些编程技巧，是作用在非运行阶段的呢？这就是我们今天要学习的内容。

今天呢，我们将来讲解整个语言基础篇的最后一部分：预处理命令。那么什么是预处理命令呢？它又为什么被称为程序设计中的“黑魔法”呢？让我们开始今天的学习吧。

任务介绍

这次这个任务呢，我们将分成两节来讲解，这是因为，想要掌握程序设计中的这门“黑魔法”，真的急不来，咱得慢慢来。

本次这个任务呢，和输出有关系：请你实现一个打印“漂亮日志格式”的方法。你可能想用 printf 直接打印，别着急，听我详细说完这个打印日志的功能介绍以后，你可能就知道什么叫做“魔法般的方法”了。

首先我们先说“日志”的作用，程序中的“日志”，通常是指在程序运行过程中，输出的一些与程序当前状态或者数据相关的一些信息。这些信息，可以帮助程序开发人员做调试，帮助运营人员做数据分析，帮助管理人员分析日活等等。总而言之，一份合理的日志信息，是非常有价值的数据。而我们今天呢，接触一种最简单的日志形式，就是程序运行过程中的调试信息。

请你实现一个参数形式和 printf 函数一样的 log 方法，用法如代码所示：

#include <stdio.h>

void func(int a) {
    log("a = %d\n", a);
}

int main() {
    int a = 123;
    printf("a = %d\n", a);
    log("a = %d\n", a);
    func(a);
    return 0;

你会看到上述代码中，有一个和 printf 名字不一样可用法完全一样的方法叫做 log，而这个 log 的输出结果，和 printf 可不一样。

具体如下：

a = 123
[main, 10] a = 123
[func, 4] a = 123

你会看到 log 的方法，虽然和 printf 函数的用法一致，可在输出内容中，log 方法的输出明显比 printf 函数的输出要多了一些信息。

首先第 1 行，是 printf 函数的输出，这个就不用我多说了，想必你已经很熟悉了。第 2 行和第 3 行都是 log 方法的输出，一个是主函数中的 log 方法，另外一个是在 func 函数中执行的 log 方法。

你会看到，log 方法的输出中，会输出额外的两个信息：一个是所在的函数名称信息，在主函数中的 log 方法就会输出 main 主函数的名称，在 func 函数中的 log 方法，就会输出 func 函数的名称；除了函数名称信息以外，另一个就是多了一个 log 函数所在代码第几行的信息，第一个执行的 log 在代码的第 10 行，就输出了个 10，第二个 log 执行的时候，在代码的第 4 行，就输出了个 4。

正是因为 log 方法比 printf 函数多了这些信息，才使我们更清晰地知道相关调试信息在源代码逻辑中所在的位置，能够帮助我们更好的去理解， 以及分析程序运行过程中的问题。哦，对了，这里再加一个小需求，就是设计完 log 方法以后，请再给这个 log 方法提供一个小开关，开关的作用是能够很方便的打开或者关闭程序中所有 log 的输出信息。

现在你应该清楚了本次的这个任务吧，那么如何完成这样的一个任务呢？跟我来一起开始预处理命令相关的学习吧。

必知必会，查缺补漏

1. 认识预处理命令家族

先来让我们认识一下今天课程的主角：预处理命令家族。在真实世界里面，有很多家族，每个家族都有自己的姓氏，例如：数学圈里面的伯努利家族，伯努利就是这个家族的统一的符号。而预处理命令家族，也有自己的特殊符号，那就是以 # 作为开头的代码。

说到这个特征，你能想到什么？你之前其实就见过这个家族的成员，只不过那个时候，我们没有特殊的提出来过。敏锐的你，可能想到了，#include 不就是以 # 作为开头的代码么？

没错，#include 就是预处理命令家族中的一员，对于它的认知，你可能觉得，是用来做功能添加的，当我们写了 #include <stdio.h> 以后，程序中就有了 scanf 函数或者 printf 等函数的功能了，这种认识没有错，不过还是不够精准。

为了更精准地认识预处理命令的作用，我们得先来说一下 C 语言程序从源代码到可执行程序的过程，为了让你能够更聚焦地进行学习，我挑了三个重要的环节来展示给你，理解了这三个环节，也就能够理解 C 语言在编译过程中所报出来的 90% 的错误原因。

这三个环节就是：预处理阶段，编译阶段，链接阶段。三个阶段从前到后依次执行，完成整个 C 语言程序的编译过程，上一个阶段的输出就是下一个阶段的输入。说到这里，你可能发现了，原来我们之前所说的编译程序，是这个复杂过程的简称。

为了让你更清楚地了解三个阶段的关系，我给你准备了下面的一张图，帮助你理解：

程序编译流程图

在上图中，有两个概念，你是熟悉的，一个是源代码，就是你所编写的代码，另外一个是可执行程序，就是你的编译器最终产生的，可以在你的环境中运行的那个程序。windows 下面，就是产生的那个后缀名为 .exe 的文件。

剩余两个概念，你可能比较陌生，一个是待编译源码，另外一个是对象文件。关于这两个概念，今天我将重点给你介绍的就是待编译源码，也就是预处理阶段输出的内容，同时也是编译阶段的输入内容。

关于对象文件的相关知识，我会在后面给你留个小作业，不用担心，现阶段，你即使不理解对象文件是什么东西，也不会影响你之后的学习。如果你想搞懂什么是对象文件， 那我建议你，先搞懂“声明”和“定义”的区别，这种学习路线，会更加有效一些。

2. 预处理阶段

下面呢，我们就来说说预处理阶段。首先先来看预处理阶段的输入和输出内容，输入内容是“源代码”就是你写的程序，输出内容是“待编译源码”。之所以叫做“待编译源码”，那是因为这份代码才是我们交给编译器完成后，续编译过程的真正的代码。它是由预处理器处理完“源代码”中的所有预处理命令后，所产生的代码，这份代码的内容跟“源代码”相比，已经算是面目全非了。

咱们下面就拿一个最简单的例子，来说明这一点。刚刚我们说过了，#include 是我们所谓的预处理命令家族中的一员，它真正的作用，是在预处理阶段的时候，把其后所指定文件中的内容粘贴到相应的代码处。

例如：#include <stdio.h> 这句代码，在预处理阶段，预处理器就会找到 stdio.h 这个文件，然后把这个文件中的内容原封不动的粘贴到 #include <stdio.h> 代码所在的位置。至于 stdio.h 这个文件在哪里，编译器是怎么找到它的，这个问题不是我们今天所讨论的重点，所以你可以先忽略它。这样呢，我们对于预处理命令 include 就有了更清晰的认识了。

下面呢，我们就围绕着 include 预处理命令设计一个小实验，来说明“源代码”和“待编译源码”的区别。

首先呢，我们准备两个文件，两个文件一定要在同一个目录下，一个文件的名字叫做 my_header.h，另外一个叫做 test_include.c，两个文件中的内容呢，如下所示：

//my_header.h 文件内容
int a = 123, b = 456;

//test_include.c 文件内容
#include <stdio.h>
#include "my_header.h"
int main() {
    printf("%d + %d = %d\n", a, b, a + b);
    return 0;
}

如果你编译运行 test_include.c 这个程序的话，你会发现，程序可以正常通过编译，并且会在屏幕上正确输出一行信息：

123 + 456 = 579

这个过程中，我们就重点来思考一个问题，为什么在 test_include 源文件中没有定义 a、b 变量，而我们在主函数中却可以访问到 a、 b 变量，并且 a、b 变量所对应的值和我们在 my_header 头文件中对 a、 b 变量初始化的值一样？

要解答上面这个问题，就要理解刚刚所说的 include 预处理命令的作用。回想一下，刚刚我们介绍 include 预处理命令的作用，就是在预处理阶段，把后面指定文件的内容原封不动的粘贴到对应的位置。也就是说，test_include 源代码文件经过了预处理阶段以后，所产生的待编译源码已经变成了如下样子，如下所示，其中我删掉了 stdio.h 展开以后的内容：

// 假装这里有 stdio.h 展开以后的内容
int a = 123, b = 456; // my_header.h 展开以后的内容
int main() {
    printf("%d + %d = %d\n", a, b, a + b);
    return 0;
}

在这份待编译源码中，你可以看到是存在变量 a 和 b 的相关定义和赋值初始化的。因为待编译源码是一份合法的代码，所以才能通过编译阶段，最终生成具有相应功能的可执行文件。

看完了这个过程以后，我希望你注意到一点，如果要分析最终程序的功能，不是分析“源代码”，而是要分析“待编译源码”，也就是说，是“待编译源码”决定了程序最终功能。

要想搞清楚待编译源码，就必须要理解预处理阶段做的事情，也就是各种预处理命令的作用。这些预处理命令，会在编译过程中，帮你改变你的代码，更形象化一点儿，就是仿佛是编译器在帮你修改代码一样。

那么程序最终的功能呢，就是由这份编译器修改过后的代码所决定的，编译器就是预处理命令这个“黑魔法”背后，那股神秘而强大的力量。

思考题

今天呢，没有以往具体的要求，让你写出一个实现什么功能的程序。而是留了一个对你要求更高，更加考验你思考总结能力的问题。

这个课后自学作业，就是请你通过自己查阅资料，搞清楚对象文件的作用，并且用尽可能简短的话语在留言区阐述你的理解。记住：由简到繁，是能力，由繁到简，是境界。

课程小结

最后，我来给你做一下这次的课程总结。今天，只希望你理解以下三点即可：

C 语言的程序编译是一套过程，中间你必须搞懂的有：预处理阶段，编译阶段和链接阶段。

程序最终的功能，是由“待编译源码”决定的，而“待编译源码”是由各种各样的预处理命令决定的。

预处理命令之所以被称为“黑魔法”，是因为编译器会根据预处理命令改变你的源代码，这个过程，神秘而具有力量，功能强大。

下篇文章中呢，我将带你具体的认识几个预处理命令家族中的成员，带你真正的体会一下这个“黑魔法”的力量，并且我们会在下一篇文章中，解决掉今天提到的“打印漂亮日志”的任务。

好了，今天就到这里了，我是胡光，我们下期见。

11 | 预处理命令（下）：必须掌握的“黑魔法”，让编译器帮你写代码

你好，我是胡光，欢迎回来。最近为了防范疫情，很多人应该都窝在家里吧？春节假期除了娱乐放松，也不要忘记学习提高呀！

上次呢，我们知道了，原来程序的编译，是一个复杂的过程，其中重要的是三个阶段：预处理阶段，编译阶段和链接阶段 。

同时，我们也搞清楚了“源代码”和“待编译源码”两个概念的区别，其中“待编译源码”是由“源代码”经过预处理阶段所产生的代码，并且“待编译源码”才是决定程序最终功能的终版代码。

今天呢，我们继续上节课的知识，来具体学习几个重要的，能够影响“待编译源码”内容的预处理命令吧。

本次任务

在正式开始今天课程之前，我们先来回顾一下任务内容：实现一个使用方法和 printf 函数一样的，但是输出信息却比 printf 更加人性化的，更加具体的 log 方法。

具体代码及事例，参考如下：

#include <stdio.h>

void func(int a) {
    log("a = %d\n", a);
}

int main() {
    int a = 123;
    printf("a = %d\n", a);
    log("a = %d\n", a);
    func(a);
    return 0;

a = 123
[main, 10] a = 123
[func, 4] a = 123

通过文稿代码可以看到，经过 log 方法后，我们获得了更多程序信息。但我们的任务是设计完 log 方法以后，请再给这个 log 方法提供一个小开关，使其很方便的打开或者关闭程序中所有 log 的输出信息。

回顾完了任务以后，就让我们一起来进行具体的预处理命令的学习吧。

必知必会，查缺补漏

在上一节，我们明确了 include 文件包含预处理命令的作用。今天，我们将来着重讲解两种预处理命令宏定义与条件编译。它们是什么意思呢？不要着急，听我一个个给你解释。

1. 初识宏定义

宏定义在预处理阶段的作用，就是做简单的替换，将 A 内容，替换成 B 内容，这里需要你特别注意的是，一个宏定义只能占一行代码，这可不是你所认为的一行代码，而是编译器所认为的一行代码，这里在后面，我们会详细来介绍一下。

这里先给你准备了一张示意图，用来说明宏定义的基本用法：

图1:宏定义语法结构示意图

正如上图所示，宏定义以 #define 作为语句的开头，之后两部分，用空格分隔，在预处理阶段期间，会把代码中的 A 内容替换成 B 内容，以此来最终生成“待编译源码”。

下面我们就使用宏来实现一个读入圆的半径，输出圆面积的程序：

#include <stdio.h>
#define PI 3.1415926

int main() {
    double r;
    scanf("%lf", &r);
    printf("%lf\n", PI * r * r);
    return 0;
}

在上面程序中，我们定义了一个名字为 PI 的宏，其替换内容为 3.1415926，也就是圆周率π的相似值。在主函数中，我们读入一个圆的半径值，存储在 r 变量中，然后输出圆的面积，在计算圆面积公式的时候，我们没有使用圆周率本来值来进行程序书写，而是使用刚刚上面定义的宏 PI 代替了圆周率的作用。

面对这份源代码，在预处理阶段的时候，编译器会把代码中所有使用 PI 的地方，都替换成 3.1415926，也就是说，上述代码中的输出函数中，原本的 PI * r * r 的代码内容，会被编译器改写成为 3.1415926 * r * r 作为“待编译源码”。

通过这个例子，我想你就能差不多明白了，什么叫做“宏定义在预处理阶段做的就是简单的替换”以及“宏定义在代码中，只能占一行”，简单来说，就是宏定义关键字、原内容和替换内容 三者必须写到一行。

2. 宏定义之傻瓜表达式

前面呢，我们说的是宏定义的最基本用法。其实，宏定义中的“原内容”的形式，不仅仅有刚才的类似于 PI 这种简单符号，还有一种更加灵活实用的带参数的形式，如图所示：

图2:傻瓜表达式结构示意图

可以看到，我们定义了一个支持两个参数的宏，名字为 mul，替换的内容为 a * b。注意，替换内容中的 a 是宏参数中的 a，b 也是宏参数中的 b。这里我再强调一下，理解宏的工作过程，始终离不开那句话：宏做的就是简单替换。

下面给你举个例子：

#include <stdio.h>
#define mul(a, b) a * b

int main() {
    printf("mul(3, 5) = %d\n", mul(3, 5));
    printf("mul(3 + 4, 5) = %d\n", mul(3 + 4, 5));
    return 0;
}

上面代码中，使用了 mul 宏，分别输出了 mul(3, 5) 的值，和 mul(3 + 4, 5) 的值。如果你把 mul 当成函数看待的话，你应该会觉得，第一行输出的值应该是 15，即 35 结果；第二行应该是 35，计算的应该是 75 的结果。

可如果你在你的环境中运行这个代码，你会看到第一行输出的结果确实是 15，和我们的预期一样，可第二行输出的却是 23，这个离我们预想的可就有点儿不一样了。

想要理解为什么输出的是 23，而不是 35 的话，我们需要综合以下两点来进行思考：

“待编译源码”决定了最终程序的功能。

宏做的就是简单的替换。

宏在预处理阶段将被展开，变成“待编译源码”中的内容，并且做的仅仅是简单的替换。也就是说，mul(a, b) 这个宏，替换的形式是 ab；而 mul(3 + 4, 5) 中 3 + 4 是参数 a 的内容，5 是 b 的内容，依次替换为 ab 式中的 a，b 的话，最终得到的替换内容应该是 “3 + 4*5”，这个才是“待编译源码”中真正的内容。面对这个替换以后的表达式，你就知道为什么输出的结果是 23，而不是 35 了吧。

所以，正如你所看到的，mul 的使用形式虽然和函数类似，可实际运行原理和函数完全不一样，甚至显得有些机械化。因为 mul 是宏，而宏做的就是简单的替换操作，变成最终的“待编译源码”中的内容。这个过程机械且简单，所以，我们有时也称其为傻瓜表达式。

再回来看上面的 mul 宏，使用形式像函数，但函数可以在代码中写成多行的一段代码。可宏呢，只能写成一行，就会使得当我们面对稍微复杂一点的替换内容，宏代码的可读性就会变得特别差。

还好，C 语言给我们提供了一种在行尾加 \（反斜杠）的语法，以此来告诉编译器，本行和下一行其实是同一行内容。这样就做到了：人在阅读代码的时候，看到的是两行代码，而编译器在解析的时候，会认为是一行代码，也就解决了复杂的宏定义的可读性的问题。

具体事例，看如下代码：

#include <stdio.h>
#define swap(a, b) { \
    __typeof(a) __temp = a; \
    a = b, b = __temp; \
}

int main() {
    int num_a = 123, num_b = 456;
    swap(num_a, num_b);
    printf("num_a = %d\n", num_a);
    printf("num_b = %d\n", num_b);
    return 0;
}

如上代码中，我们定义了一个用于交换两个变量值的宏 swap，代码的第 2、3、4 行的末尾都有一个反斜杠，编译器就会认为把程序的这几行内容当成一行内容来对待。这样，既保证了宏定义的只占用一行的语法要求，又兼顾了代码可读性。

需要特别注意的是，代码中反斜杠的后面，不能出现任何其他内容。作为新手的话，这里是最容易出错的，很多人会在反斜杠后面多打一个空格，会导致反斜杠失去原本的作用，代码查错的时候，也不容易被发现，这里一定要十分小心。

此外，你看到上述代码中，多了一个 __typeof 方法，关于这个方法的作用呢，给你留个小的作业题，请你自行查阅相关资料，并用一句话描述 __typeof 的作用。欢迎在专栏的留言区里面写下你认为足够简洁的 __typeof 的功能描述。

3. 初识条件编译

看完了宏定义之后，下面来让我们看看另一个使用的比较频繁的预处理命令：条件编译。说到条件编译，光看名字，你也许会联想到 if 条件分支语句。对，条件编译，就是预处理阶段的条件分支语句，其主要作用是根据条件，决定“源代码”中的哪些代码，接下来会被预处理继续进行处理。

我们先来从最容易理解的条件编译开始看起，来了解一下条件编译的语法格式：

图3:条件编译的语法结构示意图

如图所示，这个条件编译以指令 #ifdef 作为开头，后面接了一个 Debug。意思是如果定义了 Debug 这个宏，就让预处理器继续处理“代码内容 1”，否则就处理“代码内容 2”。记住，条件编译，可以没有 #else 部分，可最后一定要以 #endif 作为结束。

下面给你举个简单的例子：

#include <stdio.h>
#define Debug
#ifdef Debug
#define MAX_N 1000
#else
#define MAX_N 5000
#endif

int main() {
    printf("MAX_N = %d\n", MAX_N);
    return 0;
}

如果你运行上面这段代码，你的程序一定会输出 MAX_N = 1000，那是因为当代码运行到条件编译的时候，由于之前定义了 Debug 宏，条件编译的条件成立，保留的是第 4 行代码内容，所以主函数中的 MAX_N 宏最终就会被替换成为 1000。

如果你将第 2 行代码去掉的话，那么条件编译的条件就不成立了，最终被保留下来的是第 6 行代码，程序就会输出 MAX_N = 5000，关于这点，你可以自行尝试一下。

其实在条件编译中，除了我们刚才讲到的三个指令：#ifdef、#else、#endif 之外，还有 #if、#ifndef 以及 #elif 等指令。关于剩下的三个指令的含义和作用，有了这个基础之后，你就可以很轻松的学会了，我就不再赘述了。

我在这里给你准备了一张对照表，以说明这 6 个指令各自的作用：

一起动手，搞事情

思考题：没有 Bug 的 MAX 宏

请你完善下面代码中的 MAX 宏，MAX 宏的作用，就是接受两个元素，选择出两个元素中的最大值。完善以后的 MAX 宏，输出需要与如下给出的输出样例一致，注意，只能修改 MAX 宏的定义内容，不可以修改主函数中的内容。

#include <stdio.h>
#define P(item) printf("%s = %d\n", #item, item);
#define MAX(a, b) // TODO

int main() {
    int a = 6;
    P(MAX(2, 3));
    P(5 + MAX(2, 3));
    P(MAX(2, MAX(3, 4)));
    P(MAX(2, 3 > 4 ? 3 : 4));
    P(MAX(a++, 5));
    P(a);
    return 0;
}

输出结果参考：

MAX(2, 3) = 3
5 + MAX(2, 3) = 8
MAX(2, MAX(3, 4)) = 4
MAX(2, 3 > 4 ? 3 : 4) = 4
MAX(a++, 5) = 6
a = 7

输出漂亮的日志格式

准备完了上面的这些基础知识以后，下面来让我们回到最开始的那个任务。

首先我们来思考，要实现一个和 printf 使用方式一样的 log 方法， printf 函数是一个变参函数，那么 log 也需要支持变参，而 log 方法又比 printf 输出的更人性化一些，其中包括了可以输出所在的函数信息，以及所在的代码位置信息。这里，我们选择使用宏定义来实现所谓的 log 方法。

下面，就给你再补充一个小知识点，就是如何定义一个支持可变参数的 log 宏，看如下代码：

#define log(frm, args...) // 假装这里有内容，后续展开讲解

如上代码所示，在最后一个参数后面，加上三个点，就代表，这个宏除了第一个 frm 参数以外，后面接收的参数个数是可变的，那么后面的参数内容，统一存放在参数 args 中。

这样，我们就可以设计如下代码，使得 log 方法的使用方式与 printf 类似了：

#define log(frm, args...) printf(frm, args)

此时，log 方法的输出内容，只是和 printf 方法的输出内容是一致的，还无法输出所在函数以及所在代码位置的相关信息。

下面，我们来补充最后一个知识点，就是编译器会预设一些宏，这些宏会为我们提供很多与代码相关的有用信息，具体如下表所示：

我们看到表中有两个宏，是我们这个任务所需要的，一个是 __func__ 代表了当前所在的函数名，另一个是 __LINE__ 代表了当前行号。

其中宏 __func__ 后面的说明中，注明了是“非标准”，什么叫做非标准呢，也就是说，在不同的编译器中，这个宏的名称可能是不同的，甚至某些编译器不提供这个宏，也是有可能的。例如在 VC 6.0 的环境中就没有 __func__ 宏，因为这个宏不是 C 语言标准里面的东西。

通过这个 __func__ 宏，我想让你初步认识到什么是代码的 “可移植性”，也就是说，你写了一份代码，当你的运行环境发生改变时，你的代码到底要不要做修改？如果要做修改，到底要做多少修改？这是代码的可移植性所讨论的问题。

放到今天这个例子中，就是说，如果你在你的代码中，不做任何处理的，直接使用 __func__ 宏，那么就会影响你代码的可移植性。如果还不清楚什么是代码的可移植性，你就回想一下，当初我们输出彩色文字的那个代码，是不是在有些人的环境中，无法输出彩色文字？

最后，有了这些基础知识以后，就不难完成这个任务了，下面是我给出的 log 宏的参考代码：

#define log(frm, args...) {
    printf("[%s : %d] ",__func__,__LINE__); \
    printf(frm, args); \
}

正如你看到的，log 宏的定义中，使用了编写多行宏的技巧，就是在行尾添加反斜杠，以达到增强代码可读性的目的。然后 log 宏中，包含两个 printf 输出语句，第一个 printf 语句，输出函数以及代码位置信息；第二个 printf 语句，输出 log 宏所接收的内容。

至此，我们看似完成了最初的任务，可不要高兴太早，所有与宏相关的东西，都没那么简单。上面的这个实现，其实是有 Bug，不信的话，你就在你的环境中，尝试像如下代码一样调用 log 宏：

log("hello world\n");

这个就是今天给你留的最后一个需要自己独立解决的小 Bug，记住，勤用及善用搜索引擎，会大大提升你的学习效率和效果。至于如何方便的开关日志输出，参考今天的条件编译，思考一下，我相信这个难不倒你。

课程小结

通过这个任务呢，我们大体的认识了预处理命令家族，算是全方位地了解了宏及条件编译相关的内容。下面呢，我来给你做一下今天这节课的课程小结：

宏定义只占用一行代码，为了增强宏定义的代码可读性，我们可以采用在行尾加反斜杠的技巧，来使得上下两行代码，变成编译器眼中的一行代码。

宏的作用，就是替换，要想理解最终的代码行为，必须从宏替换以后的代码入手分析。

条件编译相当于一种预处理阶段的代码剪裁技巧。

编译器预设的宏，有标准的，也有非标准的，非标准的代码会影响其可移植性。

至此，我们就完成了“语言基础篇”的全部内容，从下一节开始呢，我们将进入注重培养编程思维“编码能力训练篇”的学习。届时，我们的学习更偏重于思维方式的训练和讲解，不会像语言基础篇一样，有这么多零零碎碎的知识点。我也相信，只要你勤于思考，就一定跟得上学习节奏。

好了，今天就到这里了，我是胡光，我们“编码能力训练篇”，不见不散。

做好闭环（一）：不看答案可能就白学了

你好，我是胡光。

不知不觉，语言基础篇已学习过半，我非常高兴，看到很多同学都在坚持学习。并且，还有一些同学，每每都能在专栏上线的第一时间里，给我留言，提出疑惑。当面对一些知识点的时候，如果在我的观念中它是不说自明，而对于新手的你来说，可能十分难理解的时候，我也很希望你能指出来，我会在留言区中给你解答的。因为，我知道这种讨论，肯定能够帮助到更多的人。

大部分留言，我都在相对应的文章中回复过了，而对于文章中的思考题呢，由于要给你留足思考时间，所以我选择，一起留在今天这样一篇文章中，给你进行一一的解答。

看一看我的参考答案，和你的思考结果之间，有什么不同吧。也欢迎你在留言区中，给出一些你感兴趣的题目的思考结果，我希望我们能在这个过程中，碰撞出更多智慧的火花。在这里呢，@rocedu 用户在第一篇留言区中给大家推荐的《程序设计实践》一书，也是非常优秀的书籍。有兴趣的小伙伴，也可以去到他提到的豆瓣读书主页中去游览一番。

第一个程序：教你输出彩色的文字

在这一篇里面呢，我们接触了如何在 Linux 环境下输出彩色文字的编程知识。初步学习了 scanf 和 printf 函数的基础用法，两者一个负责读入，一个负责输出。如果你对这篇文章的内容有点陌生，可以再回去看看《第一个程序：教你输出彩色的文字》。最后围绕着这两个函数，给你出了两个思考题。这两个思考题做的怎么样？下面来看看我的参考答案吧。

思考题（1）：位数输出

#include <stdio.h>

int main() {
    int n;
    scanf("%d", &n);
    printf(" has %d digits\n", printf("%d", n)); // 有多余输出
    char output[50];
    int ret = sprintf(output, "%d", n);
    printf("%d\n", ret); // 无多余输出
    return 0;
}

运行如上程序，如果输入 123，程序会输出如下两行内容：

123 has 3 digits
3

你会看到，第 1 行除了数字的位数信息以外，还有多余的输出，第 2 行则是没有多余的输出。而两个信息，都是单纯利用 printf 一族函数完成的。这个问题的解题关键是，理解 printf 函数是有返回值的，而其返回的含义是打印了多少个字符。

那么，当我们使用 printf 打印数字 n 的时候，printf 函数的返回值，就是代表了 n 的位数。类似的，sprintf 也是 printf 一族函数中的一员，它的返回值与 printf 含义相同。

思考题（2）：读入一行字符串

#include <stdio.h>
char str[100];
int main() {
    scanf("%[^\n]s", str);
    printf("%s\n", str);
    return 0;
}

这段代码展现了如何使用 scanf 读入一行包含空格的字符串信息。其中，要读入字符串，就需要使用 %s 格式占位符。可是这道题目中，在 % 和 s 中间有一对中括号[]，这个[] 代表了一个集合，用来控制 %s 在读入过程中可以读入的字符集合的，例如：%[a-z]s，是可以输入小写字母 a 到 z，那么一旦遇到了非小写字母，就会停止。

而上述代码中的 ^ 上尖号，读作非，“^\n” 就是非换行符，也就是说，只要不是换行符，就可以继续读入。这也就达到了我们想要用 scanf 读入一行的功能要求。你可以自己试一下换成 %[a-z]s，然后输入 “abcd12efeee”，看看程序的输出，你就能明白了。

判断与循环：给你的程序加上处理逻辑

在这篇文章《判断与循环：给你的程序加上处理逻辑》中呢，我们学习了除了顺序结构以外的两种程序执行结构：分支结构和循环结构。知识点的话，主要涉及：条件表达式、if 语句、for 语句等知识内容。我们说到，任何表达式都有返回值，条件表达式的值，就是 1 或者 0 代表“真”或者“假”，“成立”或者“不成立”。并且，介绍了条件判断的时候，实际上遵循的原则是“非零即为真”。最后呢，给你留了一个和循环相关的思考题“打印乘法表”，下面就看看我的参考答案吧。

思考题：打印乘法表

#include <stdio.h>
int main() {
    for (int i = 1; i <= 6; i++) {
        for (int j = 1; j <= i; j++) {
            j == 1 || printf("\t");
            printf("%d * %d = %d", j, i, i * j);
        }
        printf("\n");
    }
    return 0;
}

这段代码中，采用两层循环，外层循环控制行数，内层循环控制每一行的列数，第 i 行应该有 i 列，所以内层循环是从 1 循环到 i 为止。其中最值得琢磨的是“j == 1 || printf("\t");”这句代码，其实这句代码就是用来实现行尾无多余 \t 字符这个要求的。代码中采用了在每一列的前面输出一个 \t 字符，可是在第一列的前面不输出 \t 字符，这样就保证了行尾无 \t 字符。

那么“j == 1 || printf("\t");”这句代码是如何工作的呢？首先看 || 条件或运算符。|| 运算符的工作逻辑是，左右两侧只要有一个条件成立，那么最终结果就是成立的。这个工作逻辑，还值得细细思考，|| 运算符，从左到右依次判断两个条件是否成立，那么如果第一个左边的条件就成立了呢？作为一个聪明人，还需要判断第二个右边的条件么？你会发现，根本不需要再判断右边的条件了，也就是说不需要执行右边的代码了。

看完了条件“或”的这个特性之后，我们再看看“j == 1 || printf("\t");”这句代码，也就是说，当 j==1 成立时，也就是第一列的时候，右边的 printf("\t") 代码就根本不会执行。这也就意味着，第一列前面不会多输出一个 \t 字符。而其他的情况呢，均会执行 printf("\t") 代码，这也就实现了题目中的要求。

随机函数：随机实验真的可以算 π 值嘛？

这一篇文章里面《随机函数：随机实验真的可以算 π 值嘛？》，我们介绍了程序里面随机函数的基本原理，说明了“真随机”和“伪随机”的本质区别。看了一些留言以后，我来给你总结一下，所谓“真随机”与“假随机”，只要你不太清楚下一个产生的值是什么，那么对于你来说，就是随机的，而“真”或者“假”，讨论的是随机方法的本质。如果随机过程可以保证，下一次产生的每个值都有一定的概率，那么这个就是“真随机”，如果不能保证，那就是“伪随机”。

理解程序中的“伪随机”，你需要在你的脑袋中，构建一个由值组成的环形序列图，设置随机种子，就是选择图中的某个点作为起始点，在我们一次次地获得随机值的过程中，其实程序就是依次地输出了这个环形序列中的每个状态的值。

最后呢，给你留了一个设计随机函数过程的思考题，关于这个思考题，我要提前先跟你道歉，因为这个思考题，并不是想让你做出来的。下面来看看我的参考答案吧。

思考题：设计迷你随机函数

#include <stdio.h>
int main() {
    int n = 5;
    for (int i = 1; i <= 100; i++) {
        printf("%2d ", n);
        if (i % 10 == 0) printf("\n");
        n = (n * 3) % 101;
    }
    return 0;
}

当你运行这个程序的时候，就会看到程序的输出，正如原文中我给你的样例输出一样。要是想理解这段程序，你需要一些数论方面的基础知识，其中包括：欧拉函数，欧拉定理、费马小定理、取余循环节等知识。

在这里，我要再次因为设置这个你可能做不出来这个题，而向你道歉。不过，当你看到上面的那些知识以后，你会发现，这是一道初学者很大概率不可能完成的题目，尽管代码很简单，可背后的原理却看似不简单。其实，我就是想跟你说明，程序的灵魂在算法，算法的灵魂在数学。

数组：一秒钟，定义 1000 个变量

这一篇中，我们学习了数组的基本用法，学会了定义一组数据存储区的方法。并且，围绕着数组知识，完成了“计算数字二进制表示中 1 的个数”的递推程序的设计与实现。

相关的课后思考题呢，也是希望你使用数组来完成相关任务，我看到用户 @奔跑的八戒，完成的就很好，他的思路描述与参考答案一致。也非常感谢 @梅利奥猪猪毛丽莎肉酱（根据这位用户的名称，我猜可能是漫画《七大罪》的爱好者）和 @Geek_And_Lee00 给出的修改建议以及指正出文章中的笔误，再次感谢二位。如果有好奇的朋友，可以到原文章及留言区看看《数组：一秒钟，定义 1000 个变量》。

最后让我们来看看这篇文章的参考答案吧。

思考题：去掉倍数

#include <stdio.h>
int check[1005] = {0};
int main() {
    int n, m, num;
    scanf("%d%d", &n, &m);
    for (int i = 0; i < n; i++) {
        scanf("%d", &num);
        for (int j = num; j <= m; j += num) {
            check[j] = 1;
        }
    }
    for (int i = 1; i <= m; i++) {
        if (check[i] == 1) continue;
        printf("%d ", i);
    }
    return 0;
}

这段代码中，使用一个 check 数组作为标记，check[i] 等于 0，代表 i 这个数字不是 n 个数字中的任何一个数字的倍数。check[i] 等于 1，代表 i 这个数字能够被 n 个数字中的某个数字整除。其中第 7 行到第 10 行代码，是需要特别关注的。这段代码中，首先读入 n 个数字中的某一个，存储在 num 变量中，之后循环 m 以内所有 num 的倍数，把每个数字的 check 值标记为 1。最后我们循环把 1 到 m 中没有被标记的数字输出，就是符合题目要求的所有数字。

字符串：彻底被你忽略的 printf 的高级用法

这篇《字符串：彻底被你忽略的 printf 的高级用法》的文章中，我们认识了 scanf 和 printf 家族中的两员猛将：sscanf 函数和 sprintf 函数。这两者操作的是字符串，可以理解其本质，就是以字符串为中介做数据类型之间的转换。并且我们还介绍了字符串的相关知识，字符串的相关知识中，比较重要的就是那个 \0 字符，这是一个标记字符串结束的字符，虽然看不到，可作用非常重要，并且这个 \0 字符，也是需要占用存储空间的。

这篇文章中的两个思考题，都是帮助你打开脑洞的，主要就是想告诉你，知识点是死的，而理解知识点和应用知识点是活的，也就是我们常说的活学活用。下面就来看看这篇文章中的两个思考题的参考答案吧。

思考题（1）：体验利器

#include <stdio.h>
char str1[1000], str2[1000];
int main() {
    scanf("%s%s", str1, str2);
    printf("str1 = %s\tstr2 = %s\n", str1, str2);
    sprintf(str1, "%s", str1);   // strlen(str1)
    sprintf(str1, "%s", str2);   // strcpy(str1, str2)
    printf("str1 = %s\tstr2 = %s\n", str1, str2);
    sprintf(str1, "%s%s", str1, str2);   // strcat(str1, str2)
    printf("str1 = %s\tstr2 = %s\n", str1, str2);
    return 0;
}

在这段代码中，首先读入两个字符串，str1 和 str2。然后使用 sprintf 分别替代 strlen、strcpy 以及 strcat 三个函数的功能。具体如下：

首先，使用 sprintf(str1, “%s”, str1); 代替 strlen(str1) 的功能，正如你所知道的，sprintf 返回值代表输出了多少个字符，这行代码中也就是 str1 字符串中的字符数量。

其次，使用 sprintf(str1, “%s”, str2); 代替 strcpy(str1, str2) 的功能。使用 sprintf 函数，将 str2 中的内容，输出到 str1 的存储空间中，其实就相当于把 str2 的内容复制到了 str1 中。

最后，使用 sprintf(str1, “%s%s”, str1, str2); 代替 strcat(str1, str2) 的功能。这里，我们将 str1 和 str2 的值，依次性的输出到 str1 中以后，str1 的内容，就是原 str1 和 str2 内容连接以后的总内容了。

思考题（2）：优美的遍历技巧

#include <stdio.h>
int main() {
    char str[1000];
    scanf("%s", str);
    for (int i = 0; str[i]; i++) {
        printf("%c\n", str[i]);
    }
    return 0;
}

这段代码中，最值得思考的是循环的终止条件。当循环条件成立的时候，循环会一直执行，不成立的时候，循环就会终止。那么 str[i] 你可以看成是字符，也可以看成是一个整型值，因为任何信息在底层都是二进制存储的，那么其余字符均为非零值，也就是代表条件成立。

只有一个字符的值是零值，就是我们之前所说的字符串中的最后一个特殊的，看不见的字符，\0 字符，这个字符所对应的整型值就是 0，也就是我们所谓的假值。那么这个循环，就会一直循环到字符串的最后一位，才会停止。

好了，今天的思考题答疑就结束了，如果你还有什么不清楚的，或者有更好的想法的，欢迎告诉我，我们留言区见！

做好闭环（二）：函数是压缩的数组，数组是展开的函数

你好，我是胡光。

不知不觉，我们已经学完了语言基础篇的全部内容。其实还有很多东西想给你讲，可限于篇幅，所以咱们整个语言基础篇中的内容，都是那些，我认为你自学容易忽视的，容易学错的知识点。有道是，授之以鱼，不如授之以渔，我也相信只要你跟着课程学习，一定会感觉到自己收获到了“渔具”。如果能引发你的主动思考，进而触类旁通，举一反三，那这场学习过程就更加有意义啦。

我也非常高兴，看到很多同学都在紧跟着专栏更新节奏，坚持学习。每每在专栏上线的第一时间，这些同学就给我留言，提出自己的疑惑。大部分留言，我都在相对应的文章中回复过了，而对于文章中的思考题呢，由于要给你充足的思考时间，所以我选择在今天这样一篇文章中，给你进行一一的解答。

看一看我的参考答案，和你的思考结果之间，有什么不同吧。也欢迎你在留言区中，给出一些你感兴趣的题目的思考结果，我希望我们能在这个过程中，碰撞出更多智慧的火花。

函数：自己动手实现低配版 scanf 函数

在这一节里面呢《函数：自己动手实现低配版 scanf 函数》，我们讲了函数的基本概念，明确了“实参”和“形参”两个概念，并且知道了函数传参的过程，就是“实参”给“形参”赋值的过程。

还有，我们介绍了“传入参数”和“传出参数”两个概念，弄懂这两个概念，对于设计一个函数来说，还是非常重要的。“传入参数”是从外部，传入到函数内部，影响函数内部执行逻辑的参数，“传出参数”呢，就是由函数内部，传出到函数外部的参数，一般是以传送地址为主要形式。

最后呢，我留了两个开放性的思考题，我选择其中一个你可能会不知所措的题目，来讲解一下如何完成这个题目。下面就看看我的答案吧。

思考题（1）：数组和函数的思考

关于这个问题呢，我们首先来具象化一下，我们设想一种具体的问题情况，比如说：arr 数组里面，arr[i] 存储的是 2 * i 的值，你可以认为是第 i 个偶数的值；func 函数的功能呢，func(x) = 2*x，就是计算得到第 x 个偶数的值。如下述示意代码所示：

int arr[100] = {0, 2, 4, 6, 8, 10, ...};
int func(int x) {
    return 2 * x;
}

解析这个示例代码，我们先从数组 arr 和函数 func 的不同处开始说起。

很明显，两者的本质不一样，arr 是数组，对于代码 arr[36]， 相当于访问数组下标 36 的位置中存储的值；而 func 是函数，对于代码 func(36) 来说，也会得到一个整型值，但是这个整型值，却是由 func 的函数逻辑代码计算得到的。简单来说，就是对于 arr 中的值进行访问，是一个静态的过程，而对于 func 函数得到返回值的过程来说，是一个动态计算的过程。

我们再来看看两者的时间和空间效率，也就是代码运行速度以及所需要的存储空间之间的比较。

关于时间效率方面，通常来说是数组访问速度要比函数计算速度快很多。

而空间效率的话，函数通常要比数组节省很多存储空间，就像 func 函数的值，是动态计算得到的，通常情况下，不管我们代码中执行 func(100) 还是 func(10000)，我们不需要修改函数的代码。但对于 arr 数组来说，当我们需要访问 arr[100] 的时候，数组最起码要有 101 个元素空间，而当我们想要访问 arr[10000] 的时候，数组最起码要有 10001 个元素空间。总的来说，就是函数比数组更加节省空间，数组比函数呢，得到结果的速度更快。

说完二者的不同以后，我们再来看看二者的相同之处。

站在使用者的角度来看，当你盯着 arr[100] 和 func(100) 这两段代码看的时候，你没觉得这两个代码的异常的相似？func 和 arr 就是名字不一样，如果这个时候我将 func 后面的小括号换成中括号，你是不是就会觉得 func 是一个数组？

对！你可能发现了，在使用者看来，func(100) 和 arr[100] 的作用是完全一样的，区别可能只是中括号和小括号的区别。你不觉得站在使用者的角度，考虑这个问题很有趣么？本质区别很大的两个东西，一个函数，一个数组，突然发现它俩的区别根本没有那么大。

简单来说，就是在数学里，函数做的事情就是“映射”，传入一个值，传出一个值。在程序中也不例外，函数做的事情，就是从传入值到传出值的映射。而数组做的事情呢，其实是从下标到存储值的映射。你会发现，数组和函数做的事情，本质上都是映射！

最后，我来总结一下，这个总结讲对你日后的程序设计思维有巨大的帮助，这句话就是“函数是压缩的数组，数组是展开的函数”，也就是说当你可以用数组进行程序的时候，你也可以使用某个能够完成相同映射功能的函数来进行替代。

二者在程序设计方面的差别，就在于时间和空间的使用效率，数组在时间效率方面占优势，函数在空间效率方面占优势。当你理解了这些事情以后，你就可以更好的理解某些资料里面经常讲的“时间换空间”或者“空间换时间”的概念了。你现在可以简单的理解成为是数组思维和函数思维之间的互相转换。

预处理命令：必须掌握的“黑魔法”，让编译器帮你写代码

关于预处理命令这个知识点，我们用了两节课的篇幅来讲解，《预处理命令（上）：必须掌握的“黑魔法”，让编译器帮你写代码》 和《预处理命令（下）：必须掌握的“黑魔法”，让编译器帮你写代码》。其中讲了两种使用比较多的预处理命令，宏定义和条件编译。并且强调了，宏定义就是做简单替换，条件编译做的事情，就是代码剪裁，根据条件是否成立，决定哪段代码最终留在“待编译源码”中。

其中，用户 @一步 问到：有没有什么办法可以看到预处理阶段后的待编译源代码的内容？这个应该是很多小伙伴的共同问题吧，在这里我就来讲一下。

在 Linux/Mac 的编程环境下呢，操作比较简单，原本的程序编译命令是 gcc 加源文件名，如果你想看到待编译源码的内容，你只需要在中间加一个 -E 编译选项即可，例如：gcc -E test.c。如果你用的是集成开发环境，那你就需要自己搜索解决办法了，你可以搜索关键词如：XXX 下如何查看宏展开内容。XXX 就代表了你的集成开发环境。

对于课后的思考题，这里必须为用户 @Geek_Andy_Lee00 和 用户 @Aaren Shan 的回答点赞。答案虽然不是很完美，可我想说，答案不重要，重要的是思考过程。 下面就来看看我给出的参考答案吧。

思考题：没有 Bug 的 MAX 宏

就像之前所说的，对于这个问题呢，能否满分通过，是不重要的，重要的是你在解决这个问题过程中遇到的一个又一个 Bug，以及你对于这些 Bug 的思考过程。下面我就将带你一步一步地解决，这个问题中，你可能遇到的几个典型的 Bug，以及解决办法。

首先，让我们先对样例输出的每一行编上序号，如下所示：

①：MAX(2, 3) = 3
②：5 + MAX(2, 3) = 8
③：MAX(2, MAX(3, 4)) = 4
④：MAX(2, 3 > 4 ? 3 : 4) = 4
⑤：MAX(a++, 5) = 6
⑥：a = 7

我们先来实现一个最简单的 MAX 宏，如下所示：

#define MAX(a, b) a > b ? a : b

如上所示，MAX 宏的实现，利用了三目运算符，问号冒号表达式，a > b 条件如果成立，表达式的值等于 a 的值，否则等于 b 的值。看似没问题，但如果你要是运行代码，你会发现，程序的输出可能会如下所示：

MAX(2, 3) = 3
❌5 + MAX(2, 3) = 2
❌MAX(2, MAX(3, 4)) = 2
❌MAX(2, 3 > 4 ? 3 : 4) = 2
❌MAX(a++, 5) = 7
❌a = 8

你会发现，这种实现，只有第一行是对的，其余几行都是错的。我们就来首先分析一下第 3 行到底是什么错误。按照宏展开的替换原则，最外层的 MAX 宏会被替换成：2 > MAX(3, 4) ? 2 : MAX(3, 4)。然后我们再将里面的 MAX(3, 4) 宏展开，就变成了：

2 > 3 > 4 ? 3 : 4 ? 2 : 3 > 4 ? 3 : 4

这段表达式代码，看着有点儿乱，别急，我来帮你分析。首先我们从左向右看，先分离出来第一个问号冒号表达式的结构：

(2 > 3 > 4) ? (3) : (4 ? 2 : 3 > 4 ? 3 : 4)

我们看到在这个里面，第一部分 2 > 3 > 4 是条件；第二部分 3 是在表达式为真时候的返回值；第三部分，是包含两个问号冒号表达式结构的式子。我们继续对第三部分进行拆解：

(4) ? (2) : (3 > 4 ? 3 : 4)

继续拆解后，我们发现，第一部分 4 是条件；第二部分的 2 是表达式为真时的返回值；第三部分，就是一个单独的问号冒号表达式。拆解到现在为止，已经不需要再继续拆解了。

要想理解原表达式，我们需要先了解 2 > 3 > 4 这个“惨无人道”的表达式的值。这个表达式，从左向右执行，首先执行 2 > 3 这个条件表达式的判断。之前我们讲过，条件表达式的值，只有 1 和 0，那么 2 > 3 这个表达式的值，显然是 0，之后其实是在做 0 > 4 的判断，结果也是 0。

所以 2 > 3 > 4 这个表达式的值，就是 0，也就是假值，代表条件不成立，之后的事情，就是转到了两个问号冒号表达式的部分，剩下的事情，你自己就可以理解，最终原表达式的值为什么是 2 了。

理解了原表达式值计算的原理以后，下面让我们来分析一下，为什么会出现这种问题。本质原因，就在于我们实现的宏中，参数 a，b 原本都是独立的表达式部分，而我们却简单的把它们放到问号冒号表达式中，导致展开以后的内容前后连接到一起后，改变了原本我们想要的计算顺序。

所以针对这种情况，我们在实现宏的时候，可以给每个参数部分，都加上一个括号，就变成了如下所示的实现方式：

#define MAX(a, b) (a) > (b) ? (a) : (b)

至此，你就会得到如下的输出：

MAX(2, 3) = 3
❌5 + MAX(2, 3) = 2
MAX(2, MAX(3, 4)) = 4
MAX(2, 3 > 4 ? 3 : 4) = 4
❌MAX(a++, 5) = 7
❌a = 8

在这份输出中，第 1 行、第 3 行、第 4 行均已正确。如果你自己，仿照我上面说的方式对第二行内容的输出，加以分析，你一定可以知道如何修正第 2 行的结果错误。如果你努力以后，还是想不到的话，可以参考用户 @Aaren Shan 的留言。这样做以后呢，你程序的输出，就会变成如下输出：

MAX(2, 3) = 3
5 + MAX(2, 3) = 8
MAX(2, MAX(3, 4)) = 4
MAX(2, 3 > 4 ? 3 : 4) = 4
❌MAX(a++, 5) = 7
❌a = 8

其中还是有两行是错误的，你如果试着展开第 5 行的宏，你会得到如下的代码：

a++ > 5 ? a++ : 5

在这行代码中，如果 a++ 表达式的值真的大于 5 的话，那么 a++ 就会被执行两次。而原本使用者的意图，是执行一次 a++，如何让 a++ 只执行一次呢？这需要用到之前我跟你提过的 __typeof 相关的技巧了，下面是我给你准备的参考代码：

#define MAX(a, b) ({ \
    __typeof(a) __a = (a), __b = (b); \
    __a > __b ? __a : __b; \
})

在这段代码中，我们定义了两个中间变量，__a 和 __b 用来存储宏参数中 a 和 b 部分的原本的值，之后判断大小的部分呢，我们使用新的变量 __a 和 __b 即可。

这段代码中，我们看到了，无论是 a 部分，还是 b 部分的表达式，只被使用了一次，也就保证了只被计算了一次。而这个里面，用小括号括起来了大括号，实际作用是把一个代码段，变成一个表达式。根据任何表达式都有值的特性，这个代码段表达式的值，等于其中最后一行代码表达式的值，也就是等于最后那行问号冒号表达式的值。

第 6 行的错误，其实和第 5 行的一样，解决了第 5 行的错误，这一行的 Bug 也就解了。

至此，我们就几乎完美地解决了 MAX 宏的问题了。通过这个问题，你会看到，预处理命令虽然强大，可你需要拥有掌握这种强大的力量。这份力量，包括了你的基础知识储量，还包括了你严谨的思维逻辑。

想要掌握强大，必先变得强大，记住，一步一个脚印，才是最快、最靠谱的成长路线，学习过程中，没有捷径。

好了今天的思考题答疑就结束了，如果你还有什么不清楚的，或者有更好的想法的，欢迎告诉我，我们留言区见！

期中测试 | 给语言基础篇交一份满分答卷，去迎接新的挑战！

你好，我是胡光。

到这里，我们这门课程的“语言基础篇”就已经全部结束了。我给你准备了一个期中考试，来帮助你检验自己的学习效果。本次期中考试的主要内容，主要就是对“语言基础篇”的内容，做重点考察，毕竟“基础不牢，地动山摇”。

期中考试中的题目，都是之前我讲过的内容和少部分让你自学的内容。题目设置上呢，共 6 道单选题，2 道多选题，满分 100 分。你可以通过下面的答题系统来大体，检测自己的学习效果。

在答完题之后，你也可以回顾试卷内容，对不太理解或答错的问题，进行深入思考和学习。在每道题的解析中，我为你标注了题目所对应的专栏内容。希望你可以针对自己的疑惑点，到专栏中找到相应的课程深入学习。

12 | 数学归纳法：搞定循环与递归的钥匙

你好，我是胡光，今天我们正式开始“编码能力训练篇”的学习。

这里给你一个建议，在刚刚完成了语言基础篇的学习后，我希望你用心地体验“螺旋式上升”的学习过程。就是前面的基础篇虽然学完了，可并不是意味着，不需要再学习更多的语言相关的东西了，你可以做如下两件事情：

对于语言基础，你可以选择学习第二遍，当你站在第一遍的基础上，再回头看的时候，肯定会对之前的知识有更深的理解；

选择在其他参考资料中，继续学习语言中更多的知识点。你会发现，某些之前自己认为晦涩难懂的东西，可以自学搞明白了，这就是我提到的“螺旋式上升”的学习方法。

在接下来的“编码能力训练篇”里，我将着重给你讲解一些编程中的重要技巧。今天呢，我们就从理解循环与递归的编码技巧开始吧！

今日任务

循环结构，你已经不陌生了，如下代码所示，是一个单层循环的程序，依次地输出从 1 到 n 的每一个数字，每个数字占一行：

#include <stdio.h>

int main() {
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        printf("%d\n", i);
    }
    return 0;
}

当我们输入 4 的时候，程序的输出结果如下所示：

1
2
3
4

上面这个是单层循环的情况。下面这个例子，是一个双层循环的例子，每层循环都从 1 循环到 n，循环内部每次输出两个循环遍历的值：

#include <stdio.h>

int main() {
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            printf("%d %d\n", i, j);
        }
    }
    return 0;
}

当我们输入 3 的时候，程序的输出结果如下所示：

1 1
1 2
1 3
2 1
2 2
2 3
3 1
3 2
3 3

看了上面单层循环和双层循环的例子以后，如果让你改写成类似的三层循环的程序，想必这个你一定会做，无非就是在两层循环的内部，多加一层循环，然后 printf 输出的时候，输出的是三个变量的值即可。如果你可以自己理解到这个程序，那么你就可以理解今天这个任务。

今天这个任务，和上面的例子类似，但它不是实现一层循环的程序，也不是实现三层循环的程序，而是实现一个 k 层循环的程序。什么意思呢？就是 k 是一个读入参数，之后再读入一个参数 n，含义和上述程序中的 n 一致，而这个程序的输出结果，与上述例子中的输出结果类似，只不过每行输出 k 个数字。

简单来说，你要实现的是一个可变循环层数的程序。这下你清楚今天的任务了吧？那么我们正式开始学习吧。

必知必会，查缺补漏

理解了上面这个任务要做什么了，你可能还会发懵：为什么循环层数是可变的，代码结构不是确定性的么？别着急，今天我们将学习一个重要的编程技巧，那就是递归。

这里我要提醒一下，递归是一种编程技巧。你可能会在某些资料中，看到递归算法这种说法，其实这种说法是不合适的，因为明显的事实是，能够用循环实现的算法，都可以用递归这种编程技巧实现。如果递归算作算法，那你听过循环算法一说么？所以，用一个编程技巧，给一类算法命名，实际是不合适的。

1. 温故知新：数学归纳法

你知道么，计算机的本质，是一个用来计算的工具，它最开始就是帮助我们完成一些现实世界里面的计算任务，并且完成的又快又好。那么现实世界的问题，是如何转换成可以在计算机中计算的任务呢？这个转换的过程中，都有哪些必不可少的东西呢？请看下图：

图1:从现实问题到可计算任务

在这幅图中，我们把转换过程分成四个部分：“现实世界”“数学”“算法”和“计算机”。这四个部分形成了一个路线，也就是从现实世界中的实际问题，到计算机中的可计算任务的过程。

我稍微来详细解释一下这幅图所表达的含义。首先我们来想想，如果没有数学，现实生活中我们会遇到什么困难？我会毫不夸张地告诉你，可能会面临生存危机。试想一下，因为没有数学，我们不会计算每日食物的消耗，无法合理分配资源，导致食物匮乏，引发生存危机。这也是为什么人类最早的文字记录，或者说是信息传递，用的是结绳记事，以“算术”的形式来解决现实世界问题。可以说，现实世界中的问题，本质是可以计算的，也就是说实际问题都可以做数学建模。

然后，我们说说算法。算法是将数学问题，转换到计算机中的计算任务的桥梁。因为计算机是依靠指令序列来执行的，而不同的指令序列代表了不同的效率，不同的效率在很多时候就意味着可行或者不可行。试想一个数学抽象出来的公式，需要计算机运算 1000 年才能得出结果，你认为这种任务可以放到计算机上面做么？答案显然是否定的。算法就是使得计算任务变得更高效，更可行。

至此，你就对我所说的内容，有个大致的体会了：计算机的核心是算法，算法的核心是数学。接下来呢，我们就需要介绍一种，可以指导我们进行程序设计的数学方法：数学归纳法。

高中的时候，我们就接触过数学归纳法，你可能已经对这个概念了然于胸，不过我们还是来回顾一下数学归纳法证明过程中重要的三步骤。

图2:数学归纳法的三个步骤

其实数学归纳法的三个步骤，总结起来就是，有一个已知正确的初始状态，然后证明如果前一个状态成立，那么后一个状态也成立（这一步主要在做过程正确性的证明），最后就是得出结论，在这个初识状态和转移过程的正确保证下，所有问题中的状态都成立。

举个例子，便于你更好地理解。假设我们要利用数学归纳法来证明：如果我推倒了第一块多米诺骨牌，那么所有的多米诺骨牌都会倒下。那么放到这三个步骤里，就是：

第一步，验证边界条件，第一块多米诺骨牌倒下了。

第二步，就是假设，第 n 块倒下了，根据多米诺骨牌的结构性质，那么如果存在 n + 1 块，第 n + 1 块也一定会倒下。

第三步，得出结论，只要第一块倒了，所有的多米诺骨牌都会倒下。

注意，上面说的这个是广义层面数学归纳法，这个过程对于循环过程的正确性证明，是非常有效的。

想一想，进入循环之前的程序中关键变量的值，就是上面所说的第一步中的 k0；而每一次的循环，其实就是第二步中所要证明的那个上一个状态到下一个状态的过程。如果这两者都正确，我们就能很确信地知道，我们的整个循环过程就是正确的。

关于上面说的数学归纳法和循环程序之间的这一点联系，在日后的学习中，我还会详细地去举例说明，尤其是到了后续，我们学到了递推算法和动态规划算法的时候，会尤为明显。所以你要有足够的耐心和信心，咱们一起把这些问题搞懂。

2. 深入浅出：理解递归函数

放在编程的语境中，什么是递归呢？我这里先强调一句：递归是一种编程技巧。

你学完了函数以后，已经可以熟练地掌握在一个函数中，调用另外一个函数的方法了。可你有没有想过，如果在某个函数内部，调用自己同名函数过程，会发生什么？其实，和普通的函数调用过程一样，在具体执行过程中，只有等内部调用的函数执行完后，本层函数才会继续执行。

递归是一个过程，这个过程的每一步都类似，只是面对的问题规模不同。

下面我来举个例子：假如今年我上小学 5 年级，我现在想知道 1～5 年级的年级主任名字，但我现在只知道 5 年级的年级主任的名字，我可能会问一个 4 年级的学弟，希望他能告诉我 1～4 年级主任的姓名。

我这个学弟呢，也只知道他们年级主任的名字，那么我这个学弟就会问 3 年级学弟，问他 3 年级及以下的年级主任都有谁，依次类推，最后到了 1 年级的小学弟。

1 年级的小学弟，就会告诉 2 年级的学长自己年级主任的名字，2 年级的学长拿到 1 年级的年级主任的名字以后，会把 2 年级年级主任的名字填上去，然后再交给 3 年级的他学长……这样最终到我手里的就会是 1～4 年级的年级主任的所有名字，再加上我自己知道的 5 年级的年级主任姓名，这样，我就知道了全部信息。整个过程，如下图所示：

图3:年级主任问题示意图

在这个过程中，每个人问学弟的过程，就是我们所谓的“递”，而拿到学弟给的结果名单以后，再加上自己知道的结果反馈给自己学长的这个过程，就是“归”，整个过程就是我们所谓的“递归”。“递归”的过程，每一步的过程类似，可是问题规模不同。

接下来，我来举一个编程中的具体递归例子，看如下代码：

#include <stdio.h>

int f(int n) {
    if (n == 1) return 1;
    return f(n - 1) * n;
}

int main() {
    int n;
    scanf("%d", &n);
    printf("%d\n", f(n));
    return 0;
}

这段代码中，f 函数的作用，是计算 n 的阶乘的值，也就是从 1 乘到 n 的结果。在 f 函数内部，首先是一个边界条件，就是当 n == 1 的时候，直接返回 1 的阶乘的结果。否则，n 的阶乘的结果，应该等于 n - 1 阶乘的结果再乘上 n ，就得到了 n 的阶乘。在得到 n - 1 阶乘结果的过程中，我们调用的不是别的函数，还是 f 函数本身，只不过传入的参数范围，是一个比 n 更小的范围 n - 1。

关于这个 f 函数，类比于上面年级主任的那个例子，f(n) 就是我整理的信息，f(n - 1) 就是比我要小 1 个年级的学弟所整理得到的信息，而 n == 1 的边界条件判断，就是我那个最小的 1 年级的学弟。最后 f(n - 1) * n 当中的 * n 这个过程，就相当于每个人拿到了学弟整理的信息以后，再加上自己知道的信息，最后递交给自己的学长。

为什么这么做，能保证每个人所得到的信息都是正确的呢？在证明这个过程的时候，我们就需要用到前面提到的数学归纳法了。首先，我们知道 1 年级的学弟肯定能给出正确的信息，这就是数学归纳法中的边界条件。然后我们假设，如果上一个学弟，给出的信息是正确的，那么我所整理出来的信息，就一定是正确的，这就是数学归纳法中的证明过程的正确性。最终，我们就可以得到结论，在这个过程中，所有人获得的信息都是正确的，包括我自己。

其实，到了这里，我们也就得到了递归程序设计中的重要的两部分：边界条件和处理过程。

所谓边界条件，就是当递归函数中的参数等于多少的时候，可以直接返回的条件。

处理过程呢，就是设计程序过程，处理递归调用的返回结果，根据递归调用的返回结果，得到本函数的结果。

这两部分，分别对应了数学归纳法中的两步，step1 和 step2。当这两步都可以保证正确，所涉及的递归函数程序，也绝对是正确的。

一起动手，搞事情

今天的思考题呢，是关于一段递归程序的：

#include <stdio.h>

int fib(int n) {
    if (n == 1 || n == 2) return 1;
    return fib(n - 1) + fib(n - 2);
}

int main() {
    int n;
    scanf("%d", &n);
    printf("%d\n", fib(n));
    return 0;
}

上面这段程序中，fib 函数是求菲波那契数列第 n 项值的函数。菲波那契数列的定义如下：

图4:斐波那契数列

根据如上内容，你需要完成两个小的思考题：

请将上述菲波那契数列求解的程序从递归程序，改成循环程序。

请将上述递归程序的代码和数学归纳法中的步骤做一一对应，留在留言区中。

完成不定层数的循环程序

准备完了基础知识以后，让我们回到今天的任务，完成一个可变循环层数的程序。我们可以一开始假设，有一个函数，是实现 5 层循环打印的程序，那么它会循环 n 次，每次调用一个实现 4 层循环打印的程序。

依照这个大体的思路，我们就可以写出如下代码框架：

int print_loop(int k, int n) {
    if (k == 0) {
        // 打印一行
    }
    for (int i = 1; i <= n; i++) {
        print_loop(k - 1, n);
    }
    return ;
}

在这个代码框架中，我们先来看递归的过程，print_loop(k, n) 代表 k 层循环的程序，然后循环 n 次，每次调用一个 k - 1 层循环的程序。而递归的边界条件就是当 k == 0 的时候，就是所谓的 0 层循环，也就是程序打印一行具体内容的地方，可打印的这行内容究竟是什么呢？

你会发现，要打印的这行内容，与每层循环遍历到的数字有关系，那么我们就需要记录每层循环遍历到的数字。这个信息，我们可以记录在一个数组中，数组中存储的，就是当前要打印这行的每一个数字。基于上述代码框架，我们就可以得到下面这个更完善的代码：

int arr[100];
int print_loop(int k, int n, int total_k) {
    if (k == 0) {
        for (int i = total_k; i >= 1; i--) {
            if (i != total_k) printf(" ");
            printf("%d", arr[i]);
        }
        printf("\n");
    }
    for (int i = 1; i <= n; i++) {
        arr[k] = i;
        print_loop(k - 1, n, total_k);
    }
    return ;
}

正如你看到的，我们把每一层循环的值，放到了一个 arr 数组中，第 k 层循环变量的值，存储到 arr[k] 的位置。而在上述代码中，多了一个递归参数，就是 total_k，代表了一共有多少层循环，这个参数是为了方便我们最后确定循环输出的上界。至此，我们就完成了今天的任务。

课程小结

今天的重点，一个关于数学归纳法，一个关于递归，需要你记住如下两点：

数学归纳法中重要的两部分，一是要边界条件成立，二是证明转移过程成立。

程序设计最重要的是正确性，递归函数的正确性可以利用数学归纳法来保证。

关于数学归纳法和递归函数的设计，还需要你在日后不断的加以练习。注意总结两者的联系，能够使得你在接下来的学习中事半功倍。

好了，今天就讲到这里，我是胡光，我们下期见。

13 | 程序设计原则：把计算过程交给计算机

你好，我是胡光，欢迎回来。

上一节中，咱们说了数学思维对于编程的重要性，并且跟你介绍了一种最重要的程序设计思维：数学归纳法。这个思维，不仅可以帮助我们设计程序，而且还可以帮助我们理解及证明程序的正确性。

不过说了这些数学对编程的重要性，可能你还觉得不过瘾，感觉只是停留在理论层面，还是有一层窗户纸没有捅破。今天呢，我就给你带来一道具体的编程问题，从这个具体的问题中，让你过把瘾。

一道简单的数学题

首先，我们先看一道很简单的数学问题，求出 1000 以内所有 3 或 5 倍数的数字的和。什么意思呢？我们先缩小范围，就是求 10 以内，所有 3 或 5 的倍数。我们很快就能找到，这里有 3、5、6、9 ，它们相加之和是 23。注意，这里说的是 10 以内，所以不包括 10。

回到 1000 以内这个原问题，这个问题其实很简单，可能你现在就想马上撸起袖子开始写代码了。可别急，听我给你分析分析怎么做，才算是又好又快地用程序，解决这个实际的数学问题。

1. 把计算过程，交给计算机

一个简单的疑问，我们为什么要写程序，让计算机帮我们算这个问题呢？那是因为，计算机的计算速度，比我们人类要快上几百几千倍不止，出错率也比我们要低得多。我们写程序的一个目的，就是减少我们人类在解决问题中的具体计算过程，那什么叫做具体计算过程呢？

例如，当你写一行代码“ 3 + 5 ”的时候，这是把计算过程交给了计算机，而如果你直接在程序中写上了 8 这个结果的时候，相当于你自己做了这个计算过程。因此，所谓减少我们的具体计算过程，就是能在程序中写 3 + 5，就写 3 + 5，不要写 8。

这就是我要强调的，要把计算过程交给计算机来做，而不是我们自己来做，毕竟计算机是很擅长做这种事情的，你没必要替它省这个事儿。在这样的指导思想下，我们先来看下面这段程序：

#include <stdio.h>
int main() {
    int sum = 0;
    for (int i = 1; i < 1000; i++) {
        sum += i * (i % 3 == 0 || i % 5 == 0);
    }
    printf("%d\n", sum);
    return 0;
}

这段程序中，循环遍历 1000 以内的所有整数，然后把 3 或 5 的倍数累加到变量 sum 中，最后输出 sum 变量的值，就是 1000 以内，所有 3 或 5 的倍数和。

其中有一个编程技巧，就是利用条件表达式 (i % 3 == 0 || i % 5 == 0) 与数字 i 相乘，条件表达式等于 1 的时候，说明 i 是 3 或 5 的倍数，sum 累加的值就是 i * 1 就是 i 的值；而当条件表达式不成立的时候，sum 累加的值就是 0。掌握这个编程技巧，关键是理解条件表达式的值。

看完了程序的基本逻辑以后，我们来想想，在上述的程序中，有哪个数字，是我们人为计算得到，然后再写到程序中的？你会发现，根本没有。也就是说，我们将所有的计算过程，都交给了计算机，让它来帮我们完成。而我们做的，仅仅是描述这个计算过程，所以这份程序是一份合格的程序。

2. 数学思维：提升计算效率

为什么评价上面的程序，只是一份合格的程序呢？我们想象这么个场景，你是一个老板，手底下有一个工人，你的目的要让工人抬来一桶水。你可能有两种吩咐工人做事的方法：第一种，让工人拿个水瓢，去到 3 里以外，一瓢一瓢的打水，他来来回回跑好几趟，才能打满一桶水。第二种方式，就是你让工人去库房里面拿个水桶，然后再到 3 里以外去打一桶水回来，这样工人只需要跑一趟就能完成任务。

在这两个方法中，第一种工人打满一桶水的效率，明显要差于第二种，而造成这样的结果，是因为你作为老板，教给工人的方法不同，导致效率上的差别。

而在编程中呢，计算机其实就像示例中的工人，你教给它什么方法，它就执行什么方法，任务完成的效率，和计算机没关系，而是和你完成程序，所教给计算机的方法有关系。这个方法呢，就是我们前文中所说的“算法”。

再回到之前那个要求出 1000 以内所有 3 或 5 倍数的数字和的程序，程序虽然完成了任务，可是完成的效率不够高效。

下面我们就把数学类的算法思维，加进程序中，看看效果吧。记住，加入数学思维的同时，也要保证，将计算过程留给计算机。首先来看如下程序：

#include <stdio.h>
int main() {
    int sum3 = (3 + 999 / 3 * 3) * (999 / 3) / 2;
    int sum5 = (5 + 999 / 5 * 5) * (999 / 5) / 2;
    int sum15 = (15 + 999 / 15 * 15) * (999 / 15) / 2;
    printf("%d\n", sum3 + sum5 - sum15);
    return 0;
}

上面程序中，有三个整型变量分别代表 1000 以内所有 3 的倍数的和 sum3，所有 5 的倍数的和 sum5，和所有 15 倍数的和 sum15。最后呢，用 sum3 + sum5 - sum15 的值，代表了 3 或 5 的倍数的和。你对这个结果可能有点反应不过来，听我继续给你解释。

假设，我们现在手上有两个集合，第一个集合中装的是所有 3 的倍数，第二个集合中装的是所有 5 的倍数，想想两个集合的交集是什么？是不是就是所有 15 的倍数。那么当我们用第一个集合的所有元素和，加上第二个集合中的所有元素和的时候，两个集合交集中的元素，被重复加了一次。所以，最后再减去两个集合交集中的元素和即可。如上所述的程序思路，你可以参考如下示意图。

图1:问题的集合表示

看完了程序思路以后，我们来具体看一下其中的代码，就拿 sum3 的计算过程来举例，其实使用的就是“等差数列求和公式”，如果你忘了等差数列求和公式，请看下图：

图2： 等差数列求和公式

我们再来回顾一下程序，在编写这个程序的过程中，其中有哪些数字是我们计算得到的么？你会发现没有一个是我们直接计算得到的，哪怕是 5 的倍数 995 这个数字，也是我们通过一段代码算得到的。

而对于这段代码呢，咱们可以详细解释一下，首先用 1000 以内最后一个数字 999 除以 5，会得到在 1000 以内 5 的倍数有多少个。为什么会得到这个结果呢？这个就要说说 C 语言中的整型间的除法问题了。

在 C 语言中，两个整型数字相除，结果会做向零取整，什么是 向零取整呢？解释这个概念之前，先要介绍一下向下取整的概念，所谓向下取整，就是取小于等于当前数字的第一个整数。

例如，4.9 向下取整，就是 4，因为小于等于 4.9 的第一个整数就是 4。那么 -1.5 向下取整等于多少呢？这里需要注意，结果是 -2，不是 -1，因为小于等于 -1.5 的第一个整数是 -2，而 -1 比 -1.5 要大。

当你明白了什么是向下取整以后，就很好理解向零取整了，那就是取当前数字和 0 之间，与前数字距离最近的整数。对于正数来说，向零取整的结果和向下取整的结果相同，而对于负数来说结果恰好相反。

咱们还是拿 -1.5 举例，向下取整是 -2，可是向零取整就不同了，向零取整是在当前数字与 0 之间，取一个距离当前数字最近的整数，取到的就是 -1。

图3： 向下取整与向零取整

理解了 C 语言中的整数除法规则以后，我们再回到题目中看一下，题目中用 999 / 5 得到的就是 1000 以内有多少个 5 的倍数的数字，然后再用这个数字乘以 5 就得到了 1000 以内，最后一个 5 的倍数的数字。

这时候你可能又问了，为什么要这么麻烦呢？何不直接写一个 995 呢？你算得没错，995 确实是 1000 以内最后一个 5 的倍数。可你别忘了，今天我想教给你的是“把计算过程，交给计算机”，也就意味着计算 5 的倍数，可能还轻松一点儿，那要是计算 7 的倍数呢？13 的倍数呢？9973 的倍数呢？你会发现，还是计算机比你更适合做具体的计算。所以记住：将计算过程，留给计算机。

一起动手，搞事情

在做今天的思考题之前，我们先来弄清楚两个说法，“平方和”以及“和的平方”。

例如，10 以内自然数的平方和就是：

1^2 + 2^2 + 3^2 + 4^2 + 5^2 + 6^2 + 7^2 + 8^2 + 9^2 + 10^2 = 385

也就是 1 到 10 每个数字的平方相加之和。

而，10 以内自然数的和的平方就是：

(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10) ^ 2 = 3025

也就是 1 到 10 所有数字相加之和，然后再取平方的值。

思考题：和的平方减平方和

今天我们的思考题呢，分成两个子问题：

1. 请编写一个程序，计算 100 以内自然数“和的平方”与“平方和”的差。

2. 通过今天的学习，我们复习了等差数列求和公式，那你能否通过查阅资料，推导得到等差数列的平方和公式呢？

课程小结

好了，最后我们来做一下今天的课程小结吧。通过今天这个简单的小任务，我希望你记住如下三点：

具体的计算过程，计算机比你更擅长，所以请把具体的计算过程，留给计算机。

编写程序，其实是在描述和规定计算过程，而描述的方式不同，效率也不同。

不同的效率过程，就是我们所谓的不同的算法过程，记住：算法很重要。

关于“算法很重要”这句话，你可能有点儿听腻了，可我还是要强调一遍：所谓算法，叫得上来名字的算法是算法，还有很多叫不上来的名字，其实也是算法。两者放在一起，统一被描述成为“算法思维”。你想掌握一个有名字的算法很容易，可要掌握“算法思维”可就没那么容易了，这是需要很长一段时间的锻炼、总结和积累。

好了，今天就到这里了，不积跬步，无以至千里，希望你在看完本节课后，自己也多加练习体会。我是胡光，我们下期见。

14 | 框架思维（上）：将素数筛算法写成框架算法

你好，我是胡光，咱们又见面了。

上一节呢，我们提到了一个词，叫做“算法思维”，就是用算法去解决问题的思维方式，并且说明了算法思维有别于我们通常所说的“算法”。那么如何锻炼算法思维呢？

今天我要说的这个方法，就叫做“照猫画虎”。什么意思呢？如果我们把一个个具体的算法称之为猫，而每个具体算法中所能锻炼的“算法思维”就是那只虎。也就是说，我们可以通过学习一些简单具体的算法，来总结一些重要的算法思维。

接下来的两节中，我先带你锻炼的是算法思维中的“框架思维”，所谓框架思维就是将一个具体的算法学成一个框架，变成一个可以解决多个问题的利器。废话不多说，开始今天的课程吧。

今日任务

在开始今天的学习之前，先让我们来看看今天这 10 分钟的任务吧。这个任务很简单，就是求 1 万以内所有素数的和。

素数，也叫做质数，就是只能被 1 和其本身整除的数字。举例说，30 以内的素数依次是：2、3、5、7、11、13、17、19、23、29，这几个数字相加之和，等于 129。

而与素数相对的概念，就是合数，它指的是除了能被 1 和其本身整除以外，还可以被其他数字整除的数字。你可以简单理解为合数是由若干个素数相乘得到的数字，也就是说一个合数，一定能被某个素数整除。例如，6 就是合数，能被 2 和 3 这两个素数整除。

这里我多说几句，素数在数论当中（关于什么是数论，感兴趣的同学可以自行搜索了解），是一个很重要的概念，而数论可以说直接奠定了我们当代互联网经济的基础，那就是“信息安全”。试想，如果不能保证信息安全，你敢在网上使用你的手机号，进行某些登录操作么？如果不能保证信息安全，你敢在网络上购物，支付买单么？如果信息不安全，你敢和你的朋友在聊天工具上畅所欲言么？这一切的一切，都与我们今天说的素数有关系，你说素数重不重要？下面让我们正式开始今天的学习吧。

必知必会，查缺补漏

今天我将给你介绍一个算法，就是素数筛算法。这个算法呢，思想很直接，也很简单，相信我，你肯定可以学会的。

1. 素数筛算法介绍

所谓素数筛，是将其产出的信息存储在一个标记数组中，数组的第 i 位，标记的是 i 这个数字是否是合数的信息。如果 i 这个数字是合数，数组下标为 i 的位置就被标记成为 1，如果 i 不是合数，则数组下标为 i 的位置就是 0。素数筛就是通过一套算法流程，产生一个这样的数组。

可以看到，素数筛的作用就是把所有合数标记出来，在知道了这个范围内所有的合数之后，也就很容易找出这个范围内所有的素数了。

沿着这个思路，算法中要解决的第一个问题，就是如何标记合数？这个就要回忆一下合数的特征了，根据前面的解释，我们知道一个合数一定能被某个素数整除，也就是一定是某个素数的整数倍。也就是说，如果 2 是素数，那么 2 的 2 倍、3 倍、4 倍等等，一定不是素数，我们就可以把 4、6、8 这些数字分别标记为合数。

这个做法里面，你会发现好像有一个死结，我们要标记掉所有合数，就需要找到所有素数，这就又回到最开始素数筛要解决的问题，这不就变成了一个先有鸡，还是先有蛋的问题了么？其实不然，下图是我整理的算法流程：

图1：素数筛算法流程

素数筛算法从 2 开始，执行若干轮，每一轮呢，找到第一个没有被标记掉的数字，可以猜想到，这个数字就一定是素数。为什么呢？其实用我们之前说的“数学归纳法”就可以证明。

首先，2 是第一个没有被标记的数字，所以 2 肯定是素数，然后我们可以正确的标记掉所有 2 的倍数。假设在数字 n 之前，我们正确找到了所有素数，并且将这些素数的倍数均标记掉了，那么 n 作为后续第一个没有被标记掉的数字，n 就一定素数，最后，我们可以用 n 标记掉 n 所有的倍数，这也就保证了后续过程的正确性。在这个过程中，其实也证明了整个素数筛算法的正确性。

为了让你有个更直观的感受，我给你整理了 10 以内，素数筛算法前三轮的示意图：

图2：素数筛前三轮示意图

如图所示，第一轮的时候，2 没有被标记掉，我们就使用 2 标记掉所有 2 的倍数，标记掉的就是 4、6、8、10 这四个数字；第二轮的时候，继续向后找，第一个没有被标记掉的数字是 3，那么我们接着标记掉范围内所有 3 的倍数，就是 6、9 这两个；第三轮，发现 5 没有标记掉，那么就用 5 去标记了 10 这个数字。

2. 素数筛代码框架总结

在认识了基本的素数筛算法以后，让我们看看素数筛的具体代码实现，下面的示例代码呢，演示了如何标记 10000 以内所有合数，以此来找到这个范围内所有的素数。

int prime[10005] = {0};
void init_prime() {
    // 素数筛的标记过程
    for (int i = 2; i * i <= 10000; i++) {
        if (prime[i]) continue;
        // 用 j 枚举所有素数 i 的倍数
        for (int j = 2 * i; j <= 10000; j += i) {
            prime[j] = 1; // 将 j 标记为合数
        }
    }
    return ;
}

如代码所示，init_prime 就是素数筛算法的过程，并把最终生成的信息都存储在了 prime 数组中，如果 prime[i] 为 1 ，说明 i 是合数。

这个算法流程中呢，包含了两层循环结构，外层循环结构，从 2 开始遍历到根号 10000，也就是 100。其中这里还用到了一个编程技巧，原本代码应该写成：i <= sqrt(10000) 的这个不等式，而加上了左右平方，就变成了上面的 i * i <= 10000 这样的代码。这种改变是有好处的，会在代码运行速度上做提升，毕竟开方运算是很慢的，远远没有单独做一个乘法操作要快。

第 5 行代码，是判断 i 这个数字是否被标记过的，如果被标记过，就说明是合数，就不执行后续操作。当代码到了第 6 行的时候，说明此时 i 这个数字，一定是素数，我们就用内部的 j 循环，遍历所有数字 i 的倍数，并且将 prime[j] 标记为 1，也就是将 j 这个数字标记为合数。

执行完 init_prime 函数以后，prime 数组中就是所有合数的标记信息，反向思维就能找到所有素数，就是那些没有被标记掉的数字。

在这份代码中，你需要注意以下两点：一是到了代码的第 6 行，数字 i 有什么特性？二是为什么外层循环 i 只需要遍历到根号 10000 即可？

第一点比较好理解，到了代码第 6 行，这时候访问到的 i 一定是素数。第二点呢，就要从合数的特点思考了，合数一定可以表示为两个非 1 整数的乘积形式，否则那就是素数了。例如，6 可以拆解成 2 * 3，39 可以拆解成 3 * 13 等等。而质数 7 呢，只能表示成 1 * 7，这不是两个非 1 整数。

而用来表示合数 n 的这两个数字，一定是一个小于等于根号 n，一个大于等于根号 n。我们再具体看那个小于等于根号 n 的数字，假设它是数字 a ，如果 a 是素数，那么在素数筛算法中，i 遍历到根号 n，数字 a 一定可以正确的标记掉数字 n；而如果数字 a 不是素数，而是一个合数，那说明数字 n 可以被一个更小的数字标记掉。这也就说明，外层循环 i 只需要遍历到根号 n，就可以正确的标记掉 n 这个范围内所有的合数。

在你学习这份代码的时候，或者以后自学某些其他算法代码的时候，清晰地知道这份代码到了第几行，某些变量的取值有什么性质，这是理解框架性思维的最重要的一步。只有这样，你才能游刃有余地使用你所会的所有的算法代码。

最后，我们来说一下素数筛这个代码中最重要的性质吧，其实就是前面提到的“当代码到了第 6 行的时候，i 一定是素数”。这是你理解算法代码的第一步，所以我也不打算给你灌输太多内容，就这一点就够了，在后续的学习中，你会看到这一点所能扩展出来的其他代码形式。

一起动手，搞事情

思考题：因子分解程序正确性证明

今天的思考题呢，和整数的素因子分解有关。所谓的素因子分解，就是把一个整数，表示成为若干个素数相乘的形式，并且我们可以轻松的证明，这种只由素数表示的分解表示法，对于某个特定整数 N 来说，一定是唯一的。例如，67689 这个数字就可以分解为：3 * 3 * 3 * 23 * 109 = 67689，其中 3、23、109 都是素数。

下面呢，我给你准备了一段素因子分解的程序：

#include <stdio.h>

// 打印一个素因子，并且在中间输出 * 乘号
void print_num(int num, int *flag) {
    if (*flag == 1) printf(" * ");
    printf("%d", num);
    *flag = 1;
    return ;
}

int main() {
    int n, i = 2, flag = 0, raw_n;
    scanf("%d", &n);
    raw_n = n;
    // 循环终止条件，循环到 n 的平方根结束
    while (i * i <= n) {
        //①：只要 n 可以被 i 整除，就认为 i 是 n 的一个素因子
        while (n % i == 0) {
            print_num(i, &flag);
            n /= i;
        }
        i += 1;
    }
    //②：如果最后 n 不等于 1，就说明 n 是最后一个素数
    if (n != 1) print_num(n, &flag);
    printf(" = %d\n", raw_n);
    return 0;

今天的任务呢，就是请你解释 ① 处和 ② 处所写注释的正确性，也就是证明：

第 18 行代码中，只要 n 可以被 i 整除，i 就一定是素数，为什么？

第 25 行代码中，为什么只要 n 不等于 1，n 就一定是素数呢？

由于程序中用了循环，那么循环程序正确性的证明，你还记得吧？需要用到“数学归纳法”。而今天这两个程序过程中具体的证明，我可以给你一个小提示，尝试用“反证法”证明一下。

计算素数和

准备完了前面这些基础知识以后，最后让我们回到今天的任务：求出 1 万以内所有素数的和。如果你掌握了素数打表相关的算法以后，就很容易整理出解题思路，那就是利用素数打表算法标记掉 1 万以内所有的合数，然后将剩余的所有未被标记的数字相加，即可得到我们想要的结果。代码也不难，如下所示：

#include <stdio.h>
#define MAX_N 10000
int prime[MAX_N + 5];

// 初始化素数表
void init_prime() {
     prime[0] = prime[1] = 1;
     for (int i = 2; i * i <= MAX_N; i++) {
         if (prime[i]) continue;
         for (int j = 2 * i; j <= MAX_N; j += i) {
             prime[j] = 1; // 将 j 标记为合数
         }
     }
     return ;
}

int main() {
    init_prime();
    int sum = 0;
    for (int i = 2; i <= MAX_N; i++) {
        sum += i * (1 - prime[i]); // 素数累加
    }
    printf("%d\n", sum);
    return 0;

如上这段程序中，首先调用 init_prime 过程初始化 prime 数组。正如你看到的，init_prime 中，用到的是素数筛法，你可以自行改写成欧拉筛法，关于欧拉筛法，你可以自行查阅相关资料，如果经过你修改的程序，输出结果没有变，说明你的实现是没有问题的。

然后在主程序中，依次将每个素数累加到 sum 变量中，这里用到了一个我们之前讲过的技巧，就是用 1 - prime[i] 计算的结果，充当条件选择器：结果为 1 的时候，说明 i 为素数，就会往 sum 中累加一个 i * 1 ，也就是 i；如果结果为 0，说明 i 不是素数，就会往 sum 中累加一个 i * 0，也就是 0。最后，就是把所有素数全部累加到了 sum 变量中。

其实这段代码中，我最想讲的，是那个 MAX_N 宏的定义与使用。你会发现，程序中有三处用到了 MAX_N 宏，试想一下，如果我们现在想要修改程序的求解范围，修改成求解 100 万以内的所有素数累加之和，如果没有 MAX_N 宏的话，程序中我们最少要修改三个地方。

为什么说是最少修改三个地方呢？因为 100 万以内素数的和，很有可能超过 int 的表示范围，所以可能连 sum 的类型也要改掉。而使用了 MAX_N 宏这个技巧以后呢，我们只需要修改代码的一个地方，就可以确保，程序中所有和范围相关的地方，都被修改掉了。

课程小结

最后我们来做一下今天的课程总结，我希望你记住如下三点：

想把具体“算法”升华成“算法思维”，首先要习惯性地总结算法的“框架思维”。

素数筛是用素数去标记掉这个素数所有的倍数。

清楚地知道素数筛在执行过程中，每一行的性质。

这里，我希望你一定要熟记素数筛的算法框架，下一节我们将使用素数筛这个框架，解决几个其他问题，让你好好体会一下算法代码的“框架思维”。

好了，今天就到这里了，我是胡光，我们下期见。

15 | 框架思维（下）：用筛法求解其他积性函数

你好，我是胡光，咱们又见面了。

上一节，我们讲了素数筛这个算法，并且强调了，要按照框架思维去学习算法代码，因为当你学会这么做的时候，它就可以变成解决多个问题的利器了。

本节我将带你具体使用素数筛算法框架，去解决一些其他简单的数论问题。通过解决这几个具体问题的过程，我希望你能找到“框架思维”的感觉。

今日任务

今天这个任务，需要你依靠自己的力量来完成。不过你也不用担心，我会把需要做的准备工作都讲给你。

这个任务和因数和有关，什么叫做因数和呢？就是一个数字所有因数的和。那么什么是一个数字的因数呢？因数就是小于等于这个数字中，能整除当前数字的数。例如，28 这个数字的因数有 1、2、4、7、14、28 ，因数和就是各因数相加，即 56。

所以今天我们要做的，就是求出 10000 以内所有数字的因数和。你明白了要算的结果后，可能已经想出采用如下方法来解决：

#include <stdio.h>
int sum[10005] = {0};

void init_sum() {
    // 循环遍历 1 到 10000 的所有数字
    for (int i = 1; i <= 10000; i++) {
        // 用 j 循环枚举数字 i 可能的因数
        for (int j = 1; j <= i; j++) {
            // 当 i%j 不等于 0 时，说明 j 不是 i 的因数
            if (i % j) continue;
            sum[i] += j;
        }
    }
    return ;
}

int main() {
    init_sum();
    printf("hello world\n");
    return 0;

我们具体来看一下上面这个方法是怎么做的：在代码中，init_sum 函数内部就是初始化 sum 数组信息的方法，sum[i] 存储的就是 i 这个数字所有的因数和。在 init_sum 方法内部，使用了双重循环来进行初始化，外层循环 i 遍历 1 到 10000 所有的数字，内层循环遍历 1 到 i 所有的数字，然后找出其中是数字 i 因数的数字，累加到 sum[i] 里面，以此来计算得到数字 i 所有的因数和。

这个方法呢，诚然是正确的，可如果你真的运行上述代码，你会发现它会运行一段时间，即使你的电脑配置再好，也会感到它好像卡顿一下，然后才在屏幕上输出了 hello world 这一行信息。什么意思呢？，这表示这种程序方法运行速度较慢。

程序就像一个百米赛跑运动员，衡量一个百米赛跑运动员成绩的指标，除了看他能否到达终点，还有更重要的，就是完成比赛的时间。因此，你不仅要关注程序设计的正确性，还要关注程序的运行效率。

好了，了解完今天的任务以后，下面就让我们来看看，想要设计一个更好更快的程序，都需要准备哪些基础知识吧。

必知必会，查缺补漏

为了解决今天这个问题，你需要一点儿数论基础知识的储备。下面呢，我将分成三部分来给你讲解准备工作：

第一部分是掌握数论积性函数基础知识。有道是工欲善其事，必先利其器，数论是完成今日任务的重要利器。

第二部分，我会举一个具体数论积性函数的例子，就是求一个数字的因数的数量。

最后，我们会把因数数量的求解问题，套在我们之前所学的素数筛算法框架中，以此来说明素数筛的算法框架，基本上可以求解所有的数论积性函数。通过这个过程，彻底让你感受到框架思维的威力。

好了，废话不多说，让我们正式开始今天的学习吧。

1. 数论积性函数

首先我们来看一个知识点，就是关于“数论积性函数”的知识。所谓数论积性函数，首先，是作用在正整数范围的函数，也就是说函数 f(x) = y 中的 x 均是正整数。其次，是数论积性函数的一个最重要的性质，就是如果 n 和 m 互质，那么 f(n*m) = f(n) * f(m) 。

什么是互质呢？就是两个数字的最大公约数为 1，关于最大公约数的相关内容的话，是小学的基本内容，如果你实在是忘记了，就自行上网搜一下吧，我就不再赘述了。总地来说，只要一个函数满足以上两点，我们就可以称这个函数为数论积性函数。

这里我给出一个具体示例，帮助你理解：

其实我给你讲述这个数论积性函数这个定义的时候呢，并不希望你对它是死记硬背，而是希望你在理解这个定义的时候，可以凭借敏锐的嗅觉，或者说培养自己这方面的意识，能在这里面想到更多。

什么意思呢？当你看到数论积性函数中的 f(n * m) = f(n) * f(m) 的公式的时候，这就应该引起警觉：这个公式中，n*m 是一个要比 n 和 m 都大的值，而 f(n * m) 的函数值却是由 f(n) 和 f(m) 决定的。

这说明什么？说明我们可以利用较小数据 f(n) 和 f(m) 的函数值，计算得到较大数据 f(n * m) 的函数值。再往深的想，这其实就是一个由前向后的递推公式（可以看到递推公式的应用范围其实很广），也就是说，只要函数 f 是数论积性函数，就可以做递推！

这么说的话，你可能还是一脸懵，可以做递推有啥好的？那你就想错了，简单来说，做递推公式可以计算的更快！下面呢，我们就来看一个具体数论积性函数的例子。

2. 因数个数函数

在前面我们介绍了因数和的概念，那么因数个数的概念，就不难理解了，它指的是一个数字因数的数量。例如，数字 6，有 1、2、3、6 这 4 个因数，因数个数就是 4。

通常情况下，我们如何计算因数个数呢？这个其实比较简单，我们利用反向思维，考虑如何构造一个数字的因数。就拿 12 个数字来说吧，12 的因数需要满足什么条件呢？

第一，就是 12 的所有因数中只能包含 2 和 3 两种素因子；第二，就是 12 的所有因数中，2 和 3 素因子的幂次，不能超过 12 本身的 2 和 3 素因子的幂次。也就是说，12 的因数中最终可以含有 2 的 2 次方，不能含有 2 的 3 次方，因为 12 中最多就只有 2 个素因子 2，一个素因子中含有 3 个 2 的数字，不可能是 12 的因数。

综合以上两点，我们其实只要组合 2 和 3 可能取到的所有幂次，就能得到所有 12 的因数。

121243612​=22×31=20×30=21×30=22×30=20×31=21×31=22×31​

正如你所看到的，在构造 12 的因数的时候，2 的幂次从 0～2 有 3 种取值，3 的幂次从 0～1 有 2 种取值，总共的组合数就是 3 * 2 = 6 个，也就是说，12 一共有 6 个因数。

最后，就让我们来总结一下，如何计算一个数字的因数数量。对于一个数字 N，假设数字 N 的素因子分解式可以表示为：

N=p1​a1​×p2​a2​×p3​a3​×…×pm​am​​

其中，pi​，就是数字 N 中的第 i 种素因子，ai​ 就是第 i 种素因子的幂次。根据上面我们对于 12 这个数字因数数量的分析，就可以得到数字 N 的因数数量函数 g(N) 的公式表示：

g(N)=(a1​+1)×(a2​+1)×(a3​+1)×…×(am​+1)​

正如你所见，g 函数计算的就是数字 N 中各种素因子幂次数的一个组合数，就是数字 N 的因数数量。而这个 g 函数呢，就是我们之前所说的数论积性函数。对于数论积性函数来说，关键就是证明第二点，即当 n 和 m 互素，g(n * m) = g(n) * g(m)。关于这个证明，首先我们先把 n 和 m 的素因子分解式和因数数量表示出来：

因为 n 和 m 互素，所以 n * m 的素因子分解式和因数数量表示出来，就如下式所示：

这样，我们就证明了，在 n 和 m 互素的情况下，g(n * m) = g(n) * g(m)，所以 g 函数是数论积性函数。至此，我们完成了所有基础数学知识的准备。

下面呢，我们将从理论向实践迈进，也就是朝代码实现的方向迈进，实现一个求解 10000 以内所有正整数因子个数的程序。

3. 素数筛框架登场

如果想利用 g 函数的数论积性特点，我们就必须能够将一个数字 n，快速的分解成互素的两部分。如果我们能快速的拆解出一个数字 n 中的某种素数的话，那么这种素数，与剩余的部分，不就是互素的两部分么？

例如，如果我们能从数字 12 中，快速的拆解出只包含素数 2 的部分，就是因子 4，那么 4 与剩余的部分，数字 3 之间一定是互素的。想要完成这个子任务，我们可以求助素数筛框架，我对素数筛的代码做了一个小小的改动：

#define MAX_N 10000
int prime[MAX_N + 5] = {0};
void init_prime() {
    for (int i = 2; i * i <= MAX_N; i++) {
        if (prime[i]) continue;
        // 素数中最小的素因子是其本身
        prime[i] = i;
        for (int j = 2 * i; j <= MAX_N; j += i) {
            if (prime[j]) continue;
            // 如果 j 没有被标记过，就标记成 i
            prime[j] = i;
        }
    }
    return ;
}

正如代码所示，init_prime 函数是初始化 prime 数组信息的方法，只不过是 prime 数组中记录的信息与之前的素数筛程序不同了。这个程序中，prime[i] 中记录的是数字 i 中最小的素因子，例如 prime[8]中记录的是 2，prime[25] 中记录的是 5。当初始化完 prime 数组以后，我们利用 prime 数组中的信息，就可以快速地完成将一个数字拆解成互素的两部分。

下面这份代码，展示的就是我们如何利用 prime 数组，计算因数数量：

int g_cnt[MAX_N + 5];
void init_g_cnt() {
    // 1 的因数数量就是 1 个
    g_cnt[1] = 1;
    for (int i = 2; i <= MAX_N; i++) {
        int n = i, cnt = 0, p = prime[i];
        // 得到数字 n 中，包含 cnt 个最小素因子 p
        while (n % p == 0) {
            cnt += 1;
            n /= p;
        }
        // 此时数字 n 和最小素数 p 部分，就是互素的
        g_cnt[i] = g_cnt[n] * (cnt + 1);
    }
    return ;
}

这份代码中，g_cnt 数组记录的就是因数数量信息。在 init_g_cnt 函数中，一开始将 g_cnt[1] 置为 1，由于数字 1 的因数数量只有它自己本身，所以也就是 1 个。然后从 2 到 10000 循环，依次求解每个数字的因数数量。

循环内部，将数字 i 中，除去最小素因子的剩余部分存储到 n 中，将最小素因子的次数存储在 cnt 变量中。由于因数数量函数是积性函数，最终用 g_cnt[n] 乘上最小素因子 p 部分的 g_cnt 的值，也就是 cnt + 1 的值，即可。

这个程序之所以运行效率快的原因呢，我今天不做具体讨论，你只需要知道，这个程序比我们开始说的那个双层循环程序，运行速度快了一个数量级。

实际上，如果你掌握了“欧拉筛”相关内容，这个程序你会实现得更加漂亮，也更加能够体现我们所说的“框架思维”。“欧拉筛”实际上也是一种筛选出素数的方法，比我们之前学的素数筛更高效，同时，我也认为它体现的思想也更优美，你要是有兴趣，可以自行网上搜索了解。

一起动手，搞事情

前面，我给出了完整的求解因数数量的代码，以及相关数学公式的推导过程。其实，在最开始我们所说的因数和的求解任务，和因数数量的求解类似，都是基于对数字 N 的素因子分解式的观察和思考，得到相关的推导公式。并且，我这里可以预先给你一个确定性的结论，那就是因数和公式，本身也是数论积性函数。

说到这里，你可能就明白了，今天这堂课的作业，其实就是让你参照本节求解“因数数量”的过程，完成求解“因数和”的任务。你需要自行搜索的内容就是约数和公式，或者可以搜索任意一篇相关数论积性函数的文章，里面大概率也都会讲到这部分知识，然后找到解题方法。

课程小结

最后，我们来做一下今天的课程总结。我就希望你记住一点：所谓代码框架，就是要活学活用。

因为在真正的工作中，你所做的事情，大多是在多种代码框架之间做选择及组合拼装，每个算法代码只会解决遇到的一部分问题。而你在使用这些算法代码的时候，往往不能照搬照用，反而要做一些适应性的改变，这些都是“框架思维”中所重视的。

好了，今天就到这里了，我是胡光，我们下期见。

16 | 数据结构（上）：突破基本类型的限制，存储更大的整数

你好，我是胡光，咱们又见面了。

上两节呢，我们讲了素数筛这个算法，并且用素数筛算法演示了程序设计过程中的框架思维。其中提到了欧拉筛法，不知道勤奋的你有没有课后自己去学习一下呢？如果你学习了欧拉筛法以后，你会对我所说的框架思维有更深刻的体会。

在之前的文章中，我们介绍过算法和数据结构的作用。当时我讲到，算法的作用是做数据的计算，并且它对于编程的重要意义，不止是停留在那些叫得上来名字的具体算法上面，而是我们称之的算法思维。

算法思维的具体表现，就是我们处理得到相同信息时，所采用的不同的流程方法。这些方法呢，有好坏高低的比较，而评价的标准，主要就是从时空复杂度方面来考量。由于本专栏主要是教会你掌握编程思维，所以，即使你对时空复杂度不是很了解，也不用担心它会影响你的入门编程学习。你只需要知道，这是我们衡量算法好坏的重要指标即可。

前两篇文章呢，其实更多的就是给大家展示算法思维对于程序设计的重要性，并且，我还要在这里提醒一句，算法的底层是数学，适当的补充数学基础，对于算法的学习是有奇效的。

数据结构和算法，前者负责“表示数据”，后者负责“处理数据”。接下来，我将给你讲讲数据结构的重要性。

今日任务

表示数据到底是什么呢？为什么表示数据很重要？通过今天的 10 分钟任务，你就能明白其中的重要意义。这个任务很简单，就是请你实现一个程序，输出 2 的 1000 次方的结果是多少。

关于这个问题，你可能会意识到，C 语言中给我们提供的 int 类型，肯定是无法完成这个任务的，因为它表示不了这么大的数字。你可能想用 long long 类型来进行解决，那你这就要犯低级错误了。long long 是 64 位整型，也就是占 64 个 2 进制位，它顶多能表示 2 的 64 次方减 1 的结果，相对于 2 的 1000 次方来说，小太多了。

你可能又想到，既然 long long 表示不了，那就使用 double，不是说 double 是浮点数类型，可以表示很大很大的数字么？对，double 作为双精度浮点型，确实可以表示很大很大的数字，2 的 1000 次方这个数字，对于 double 的表示范围来说，也是不足挂齿的。

可这里面存在一个严重的问题，就是 double 是有精度损失的。什么意思呢？请耐心听我给你解释。

其实也很好理解，不管是 long long 类型，还是 double 类型，它们都是 64 位的信息，也就是说，它们都可以准确表示 2 的 64 次方个数量的数字。但是，即使 double 类型表示数字的范围比 long long 要大很多，可这个当中很多数字 double 是没有办法准确表示的。

至于 double 的表示精度，一般来说是有效数字 15 位，就是一个数字，由左向右，从第一个不为零的数字起，向后 15 位都是准确的。因此 double 类型实际上也没有办法，准确表示 2 的 1000 次方的计算结果。

那究竟应该如何来解决今天这个问题呢？带着这个疑问，让我们正式开始今天的释疑之行吧。

必知必会，查缺补漏

前面讲了这么多，我就是想让你明确一点，就是在我们所认识的 C 语言中，是没有任何一种数据类型，可以表示得下我们今天想要计算 2 的 1000 次方的结果。也就是说，基础类型表示不了我们今天所要计算的这个结果，那该怎么办呢？

还记得我讲过的关于结构体的相关知识么？当时我们使用结构体，创造了一个新的代表坐标点的数据类型。按照创造类型的思路去思考现在这个问题，也就是，如果我们能采用一种能够表示更大范围的整数的数字表示法，那今天这个问题，就可以解决了。这就是我们今天要学习的内容，它的大类名字叫做高精度表示法，更具体的叫做大整数表示法。

1. 大整数表示法

为了完成今天这个任务，我们需要从数据的表示上下功夫。其实，数据的表示绝不是只有一种方法，就好像你想表达数字 1 的一半，你既可以用 0.5 来表示，也可以用 1/2 来表示。所以，今天我们想要表示很大很大的整数，其实也有很多方法，下面就看看我要给你介绍的方法吧。

首先我们先来思考一个事情，如果我想要存储一个 100 位的十进制数字，为什么现有的 int 数据类型做不到？本质上是因为这个数字的位数，超过了 int 能够表示数字的位数上限。int 能够表示的数字大小的上限，是一个以 2 开头的 10 位数字，而我们想要存储的，却是一个 100 位的数字。

看到了这个本质问题后，其实也就找到了解决问题的方向，那就是我们要创造的这种数字的表示方法，能够有足够的空间去容纳更多位数的数字。提起空间，你想到了什么？是不是我们之前讲到的数组？也就是说，我们开辟一个整型数组空间，让这个数组的每个位置存储一位数字，这样是不是就可以很轻松地存储 100 位数字了。

下面就来看看这种大整数表示法，是如何存储数字 3526 的吧：

图1：大整数表示示意图

正如你所看到的，这种表示法中，使用数组的第 0 位存储数字的位数，因为 3526 有 4 位，所以数组的第 0 位就设置成了 4 这个值。接下来，数组从第 1 位到第 4 位记录的就是原数字 3526，可是你有没有发现，这个数字是好像是倒着放置的，数字的最高位，也放在数组的最高位中，在图上看着感觉怪怪的。

你可能会觉得别扭，可我要告诉你，这种存储方式，可是凝结了前人的智慧，最直接的一个好处，就是当你拿着两个这样的大整数做加法，产生一个新的大整数的时候，这个新产生的大整数会涉及到进位问题。

例如：95 + 12 = 107，两个两位的大整数相加，产生一个三位的大整数。在这种从右到左的倒着存储表示法中，是向着数组高位去进位，去扩充位数，这是便利可行的。可你要是从左到右去正着存储，你会发现一旦最高位产生进位，就很难处理。

2. 如何计算大整数加法

你可能还是不太理解，这种大整数表示法的好处，下面我们就拿“大整数加法”来举个例子。顺便也向你展示一下，我们究竟是如何操作这种大整数。

大整数加法，顾名思义就是利用大整数表式法，做加法运算。具体怎么做，你应该还记得小学时候，老师教给我们的加法竖式吧？其实大整数加法，本质上就是参考这种竖式计算法，把每一位对齐，然后按位相加，加完以后再统一处理进位。下面，我用一张图说明大整数加法，是如何计算 445 + 9667 的：

图2：大整数加法示意图

正如你所看到的，首先我们用大整数表示法，分别表示 445 和 9667 这两个数字；然后以位数最长的那个大整数，作为计算结果大整数的基础位数，445 和 9667 按位相加，得到一个 4 位的结果大整数，4 位分别是，9、10、10、12；最后我们再依次处理进位，就得到了底下那一行的结果：10112。

在这个过程中，你会看到最高位的 9 产生了进位，最终变成了一个 5 位的大整数，产生的新最高位，我们只需要继续向后放即可。这就是我刚刚所说的，这种大整数表示法，能够非常方便地处理进位。

看完了大整数加法的过程后，不可缺少的，就是代码的实现过程。下面我给你准备了一份代码，代码中有相关注释，这是需要你自己拿出时间，来进行自学的内容。

// 定义一个交换两个变量值的宏 swap
#define swap(a, b) { \
    __typeof(a) _t = a; \
    a = b, b = _t; \
}
// 实现大整数加法 a + b 的结果，存放在 c 中
void plus_big_integer(int *a, int *b, int *c) {
    // 让 a 指向位数较长的那个数字
    if (a[0] < b[0]) swap(a, b);
    // 大整数 c 的位数以 a 的位数为基准
    c[0] = a[0];
    // 循环模拟按位做加法
    for (int i = 1; i <= a[0]; i++) {
        if (i <= b[0]) c[i] = a[i] + b[i];
        else c[i] = a[i];
    }
    // 处理每一位的进位过程
    for (int i = 1; i <= c[0]; i++) {
        if (c[i] < 10) continue;
        // 判断是不是最高位产生了进位
        // 如果是最高位产生进位，就进行初始化
        if (i == c[0]) c[++c[0]] = 0;
        c[i + 1] += c[i] / 10;
        c[i] %= 10;
    }
    return ;
}

一起动手，搞事情

今天给你留的作业题，和我给你准备的那个大整数加法的代码有关。就是请你完成一个，能够实现读入两个大整数，并且输出两个大整数相加之和的程序。关于这个程序作业，你不需要考虑负数的情况，我们假设所有数字均是正整数。

这里给你个提示：在读入两个大整数的时候，你可以按照两个字符串数据进行读入，然后再把字符串数据，转换成我们上面所说的大整数表示法，最后调用上面那个大整数加法的过程。程序的关键提示已经告诉你了，剩下的部分，试试自己完成吧，加油！

突破类型，求解 21000 的值

最后，我们回到今天的任务。

要计算 2 的 1000 次方的结果，就是要计算 1000 次乘法，最终的结果由于数值太大，我们肯定要使用大整数表示法了。也就是说，我们要在大整数表示法的基础上，操作 1000 次乘法，每次都是乘以 2，那么怎么做大整数乘法呢？

要想理解这个计算过程，我们还是得回到大整数表示法本身，所对应的数学模型理解上，具体请看下图：

图3：大整数表示法的数学理解

如图所示，我们把大整数表示法中，每一个数字所对应的位权写出来，那么数组中所存储 3、5、2、6 的大整数信息，其实等价于下面的那一行数学公式，即 $310^{3}+510^{2}+210^{1}+610^{0}$

我们对（3526 这个）上面的大整数乘以 2，其实等价于对下面那个数学式子乘以 2，就可以得到如下结果：

图4：大整数乘法的理解

你会看到，对某个大整数乘 2 的操作，其实，可以看成是对这个大整数的每一位分别乘以 2 的操作，然后再仿照大整数加法的过程，依次处理进位即可。

最后，关于如何完成今天的任务，我给你一个参考程序。当然你也可以选择不看参考程序，自己实现这个过程。

#include <stdio.h>

// 将 num 数组初始化成大整数表示的 1
// 作用就是做累乘变量
int num[400] = {1, 1};

int main() {
    // 计算 100 次 2 的 10 次方相乘的结果
    for (int i = 0; i < 100; i++) {
        // 对大整数的每一位乘以 2 的 10 次方
        for (int j = 1; j <= num[0]; j++) num[j] *= 1024;
        // 处理进位
        for (int j = 1; j <= num[0]; j++) {
            if (num[j] < 10) continue;
            if (j == num[0]) num[++num[0]] = 0;
            num[j + 1] += num[i] / 10;
            num[j] %= 10;
        }
    }
    // 输出大整数
    // 由于大整数是倒着存的，所以输出的时候倒着遍历
    for (int i = num[0]; i >= 1; --i) printf("%d", num[i]);
    printf("\n");
    return 0;

课程小结

解决了这个任务后，恭喜你，又变强了一点点。今天我们学习了大整数的表示法，以及大整数加法和乘法的基本操作，我希望你记住以下几点：

在大整数的表示法中，数字是从右到左，倒着存放在数组中的。

大整数的表示法，体现的是数据结构对于程序设计的作用。

大整数的加法和乘法过程，体现的则是算法对于程序设计的作用。

同时，你还可以看到，我们在理解大整数乘法的过程中，是从数组的表示法与数学公式的等价性这个角度出发讨论的。其实我就是想再次跟你强调那句话，就是算法的底层是数学。

而通过今天的学习，想必你已经对“数据结构本质是用作数据的表示”这句话，已经有所感觉了。综合“算法是做数据的计算”这句话，说明算法和数据结构是程序中可以独立进行设计的两个部分，关于这点呢，将是下一节咱们讲解的重点。

好了，今天就到这里了，我是胡光，我们下期见。

17 | 数据结构（下）：大整数实战，提升 Shift-And 算法能力

你好，我是胡光，咱们又见面了。

上节课呢，我们讲了大整数表示法的相关知识，并且给你演示了大整数加法及乘法处理过程。其实，你是否掌握了大整数表示法是次要的，主要是你可以在这个过程中，认识到数据结构的作用，也就是我强调的数据结构就是负责表示数据。

原先，我们之所以无法做较大整数的运算，那是因为我们所掌握的数据类型，无法表示很大的数字，有了大整数表示法以后，我们就可以做特别特别大的整数表示了。

我之前也一直在说，算法是做数据计算的，它和数据结构是程序设计中，非常重要的两部分。既然是两部分，说明算法和数据结构可以独立分开设计。

关于这点呢，你可以想想上节课我们学的大整数加法，它其实就是算法。为什么这么说呢？你想想，这个加法过程难道是有了大整数以后，才出现的么？显然不是，即使没有大整数表示法，我们还是了解加法过程的，只不过这一次我们用大整数表示法，模拟了加法过程。注意，加法过程是一个独立的算法过程。

总而言之，就是在之前的课程中，我们确定了几点结论：如果是计算流程不合理，我们需要改进算法，如果是数据表示受限，我们需要求助于数据结构。

为了让你更清晰地认识到，算法和数据结构是两个可以独立设计的部分，今天我们通过一个具体的算法，来感受一下这个独立设计的过程。

字符串匹配问题

首先让我们来了解一个概念，那就是“字符串匹配问题”。什么意思呢？简单来说，就是在一个大的字符串里面，查找是否包含另外一个较小的字符串。

图1： 字符串匹配问题

如图所示，我们做的就是在字符串 cjakjoek 中，查找是否包含字符串 kjo，其中，我们把这个 cjakjoek 字符串叫做文本串，kjo 字符串叫做模式串。再举个例子，你手中有一篇英文文档，你想在这个文档中查找所有的 hello 单词。那么，英文文档就是我们所说的文本串，hello 就是模式串。

如果模式串是单独的一个，我们就称这种问题为“单模匹配问题”，如果模式串是多个，那就是“多模匹配问题”。我们今天重点讨论的是“单模匹配问题”。

如果给你一个文本串和模式串，让你查找文本串中是否包含模式串，你用程序怎么完成？最直观的做法，就是用模式串的首字母依次性的，和文本串中的每一位对齐，每次对齐以后，看看所对应区域是否匹配，如果匹配就说明文本串包含模式串。

下面我给出这个方法的程序代码：

// 暴力匹配算法程序
int bruce_force(const char *text, const char *p) {
    // 遍历文本串每一位
    for (int i = 0; text[i]; i++) {
        int flag = 1;
        // 从文本串的第 i 位开始与模式串进行匹配
        for (int j = 0; p[j]; j++) {
            if (text[i + j] == p[j]) continue;
            // 当代码到了这里，说明某一位不匹配。
            flag = 0;
            break;
        }
        if (flag) return 1;
    }
    return 0;
}

正如你所看到的，这是最简单粗暴的方法。代码中的 bruce_force 程序，就是暴力匹配算法的过程，其中参数 text 就是文本串，p 就是模式串，如果包含模式串，函数返回值是 1，如果不包含，返回值就是 0。

这个程序的效率，可以说是单模匹配的所有算法中最差的了，它的时间复杂度是 O(nm)，其中，n 是文本串的长度，m 是模式串的长度。怎么理解呢？就是如果文本串长度是 10，模式串长度是 3，那么这个程序差不多要计算 30 次，外层循环 10 次，内层循环每次循环 3 次。

按照这个理解，可以设想，当文本串长度是 10000，模式串长度是 1000 的时候，程序的运行次数是接近 1000 万次这个量级的！所以这种程序的效率最差。

初识 Shift-And 算法

其实，可以高效的解决“单模匹配问题” 的算法有很多。今天，我们将来学习一种叫做 Shift-And 的算法。

看到 Shift 你会想到了什么？是不是电脑键盘上的 Shift 键？我们知道，这个键的作用是做转换，例如当你按住 Shift + 8 的时候，输入的就不是数字 8，而是一个 *。

而 Shift-And 中的另一个单词 And ，其实指代的是位运算中的按位与操作。这两个单词，差不多清晰展示了这个算法的基本流程：首先做信息的转换，然后利用位运算，完成单模匹配问题。下面，我们就来具体对这两步做下讲解。

1. Shift-And 中的信息转换

在 Shift-And 算法中，是将模式串的信息，转换成另外一个种信息格式，如何转换呢？如下图所示：

图2： Shift-And 编码方法示意图

在 Shift-And 中，我们可以把模式串中的每一个字符，转换成一个数字，这个数字一般是由二进制表示。关于转换字符的编码有这么一个规则，就是如果某个字符在模式串的第 i 位中出现过，那么在相关字符编码的二进制数字表示中的第 i 位就为 1。

例如，图中字符 a，在模式串的第 0 位，第 5 位和第 6 位出现过，那么就将 a 字符编码的第 0、5、6 位设置为 1。在这里你需要注意的是，字符数组是从左向右看，也就是说最左边是最低位；而数字是从右向左看的，最右边才是最低位，这里是最容易犯糊涂的地方。

字符 c 呢，由于在第 1 位和第 4 位出现过，所以对应到二进制数字中，第 1 位和第 4 位都是 1，其余位置都是 0。按照这种规则呢，你会发现，没有在模式串中出现的字符，编码值就是 0 值，也就是它的所有二进制位上都是 0。

所以，在 Shift-And 算法中，通过看一个字符的编码，就能知道这个字符，在原模式串的第几位出现过。同时，通过模式串可以生成的编码信息，也可以还原模式串信息。

在之前的课程中，我们讲过类似的概念，一般来说，这种可以相互转换的信息，叫做等价信息表示。说白了就是信息一点儿也没少，只是换了一种表示形式。要想理解 Shift-And 算法，首先就要理解这种等价的信息表示方法。

2. 利用位运算做匹配

讲完了信息转换步骤后，我们明确了一个事情，就是 Shift-And 算法中，只是对模式串做了信息转换，但对文本串本质内容没有做任何改动。接下来，我们就来讲解 Shift-And 算法中的 And 部分，也就是来回答 Shift-And 算法，究竟是怎么用位运算来做字符串匹配的。先看下图：

图3： Shift-And匹配流程的关键因素

在图中，有一个最关键的，就是 p 变量，它是整个匹配过程的核心变量。我们假设模式串的长度是 m ，code(str[i]) 代表了文本串第 i 位字符的编码，编码方式前面已经介绍过了。整个匹配过程，从前往后，依次处理文本串的每一位，处理到第 i 位的时候，就是用第 i 位字符的编码（code（str[i])），与 p 左移 1 位并或上 1 以后的值（p<< 1 | 1），做“按位与”运算，把得到的值赋给 p 变量。最终，当 p 的二进制表示的第 m 位为 1 时，说明匹配成功了。

为了帮助你理解，我给你准备了一个具体示例，下图是模拟了当模式串为 cdd，文本串为 acdd 时候的匹配流程：

图4： Shift-And匹配流程示意图

要想理解这个匹配过程，首先就是需要注意到 ，变量 p 在第四步的时候，二进制表示的第 3 位为 1 了，说明此时截止到文本串 acdd 的第 4 位为止，匹配到了原模式串 cdd。这个过程，你需要仔细的琢磨琢磨，然后再往下看。

接下来我们来讨论一般情况下的 p 值，如果模式串长度为 m，那么在什么情况下，p 值的第 m 位为 1 呢？

由算法中的 p 值计算公式可知，p 是由“按位与”操作得到的值，也就是说，其中一部分 code(str[i]) 的二进制的第 m 位必须为 1，这就意味着 str[i] 是模式串第 m 位的字符。并且为了 p 值的第 m 位为 1，按位与的另一边 (p << 1 | 1) 这个值的第 m 位也必须是 1。

关于 (p << 1 | 1) 这一部分中，或 1 操作，只能影响二进制的最低位，我们可以暂时忽略它。关键就是理解 p << 1 这个操作，左移以后的第 m 位为 1，说明左移之前，p 的二进制表示的 m - 1 位也是 1。

通过分析上一轮 p 的二进制表示的 m - 1 位为什么是 1 时，你会推理得到 str[i - 1] 必须是模式串 m - 1 位的字符。依次类推，你就会得到一个结论：文本串 str 的第 i - m 位，到第 i 位之间的字符串，其实就等于原模式串的内容。下面给你准备了一个示意图：

图5： p 公式的理解与推导

其中 pm​ 代表 p 的二进制表示的第 m 位为 1，pm−1​ 表示 p 的二进制表示的第 m-1 位为 1。因为只有第 m-1 位为 1，才可能左移 1 位以后的结果第 m 位为 1。

最后我们来解释一下，为什么 p 左移 1 位以后，还需要或上一个 1 。其实也很好理解，如果 str[i] 是模式串的第 0 位字符，那么 p 在什么情况下，第 0 位是 1 ？你会发现，根据之前推理，只有在上一个状态 p 的 -1 位为 1 的时候，左移以后第 0 位才可能是 1。

但我们知道，根本没有 -1 位这个位置，也就是说，如果不看或 1 操作的话，一个初值为 0 的 p 变量，想通过单纯的左移操作，第 0 位永远不可能是 1。所以这个或 1 操作，其实就是为了使得 p 左移以后的第 0 位永远置为 1，而最终计算结果中的第 0 位是否为 1，这个要看 str[i] 这个字符是否在模式串的第 0 位出现过。

关于 Shift-And 算法这个知识点呢，我大致解释完了。你在学习这块知识的时候，可能感觉有点难，没准读完第一遍的时候，脑子都是懵的。但请相信我，也相信你自己，把这几段内容多看几遍，遇到不理解的句子，停下来多思考思考，看的次数多了，你就明白是什么意思了。

至此呢，我们就学习完了 Shift-And 算法的两个重要的过程。代码实现呢，如下所示：

int shift_and(const char *str, const char *p_str) {
    int code[256] = {0}, m = 0;
    // 初始化每一个字符的编码
    for (int i = 0; p_str[i]; i++, m++) {
        code[p_str[i]] |= (1 << i);
    }
    int p = 0;
    for (int i = 0; str[i]; i++) {
        p = (p << 1 | 1) & code[str[i]];
        // 如果 p 所对应的模式串最高位为1，代表匹配成功
        if (p & (1 << (m - 1))) return 1;
    }
    return 0;
}

在这份代码中，你会发现我们只用了两次循环，注意！是两次循环，而不是两层循环。一次循环是遍历模式串，生成编码 code 信息，第二次循环是遍历文本串 str，循环迭代得到 p 变量的值，直到 p 变量的第 m 位为 1 时，就代表匹配成功。

可以看到，这种算法的时间复杂度，和暴力匹配算法比起来，提升的不是一星半点。暴力算法是 O(nm) 的，而 Shift-And 算法的时间复杂度就是 O(n + m) 的。也就意味着，同样是文本串 10000 的长度，模式串 1000 长度，Shift-And 算法，是暴力匹配算法效率的 1000 倍！

改进 Shift-And 算法

说是 1000 倍，细心的你可能会发现一个问题，上述算法中的 p 变量，是一个整型变量，也就是说，p 变量最多支持，模式串长度不超过 32 位的单模匹配问题。

请你想想，这个问题究竟是出在算法上，还是出在数据结构上？答案很显然，是出在数据结构上。要是有一种数据结构，支持很大的二进制整数表示，同时在这种结构的数据上，还可以操作左移、或运算以及按位与运算的话，这种结构就可以取代原有整型 p 变量的作用。这样，我们就可以支持长度更长的模式串的匹配问题了！

所以今天给你留得作业呢，就是请你在尽量不修改算法流程的情况下，增加一个类型结构，实现可以处理 1000 位模式串的 Shift-And 算法。欢迎你把自己的答案写在留言区，我们一起来讨论实现方法。

课程小结

通过今天这堂课呢，我希望你彻底体会到，算法和数据结构是程序设计的两个部分，并且它们可以单独来进行学习、设计和实现。

如果说，今天想让你记住点儿什么的话，那就是：等价信息表示对于解决问题很重要。这个事情不止是对于程序设计而言，很多事情都是这样。同等的信息，不同的表示形式，其实就是不同的观察角度，最终的效果也会截然不同。就像今天的 Shift-And 算法，对于模式串的信息，做了一个等价转换以后，整个算法的时间复杂度就被优化了一个数量级，这个过程值得你花时间去仔细体会。

本节课，也是我们整个“编码能力训练篇”的最后一节了，我希望你通过这部分知识的学习，掌握计算思维，以及程序设计的核心法门。下一章节，我不再赘述算法和数据结构的重要性，而是请你带着在“编码能力训练篇”掌握的技巧，随我进入“算法与数据结构篇”的学习吧！

好了，今天就到这里了，我是胡光，我们下章见。

做好闭环（三）：编码能力训练篇的思考题答案都在这里啦！

你好，我是胡光。

不知不觉，我们已经学完了编码能力训练篇的全部内容。其实还有很多东西想给你讲，可限于篇幅，所以咱们整个编码能力训练篇中的内容，都是与接下来的算法数据结构篇有很大的联系，并且它们对于理解程序设计，也是非常基础且重要的内容。

有道是，授之以鱼，不如授之以渔，我也相信只要你跟着课程学习，一定会感觉到自己收获到了“钓鱼工具”。如果能引发你的主动思考，进而触类旁通，举一反三，那这场学习过程就更加有意义啦。

我也非常高兴，看到很多同学都在紧跟着专栏更新节奏，坚持学习。经常在专栏上线的第一时间，这些同学就给我留言，提出自己的疑惑。大部分留言，我都在相对应的文章中回复过了，而对于文章中的思考题呢，由于要给你充足的思考时间，所以我选择在今天这样一篇文章中，给你进行一一的解答。

看一看我的参考答案，和你的思考结果之间，有什么不同吧。也欢迎你在留言区中，给出一些你感兴趣的题目的思考结果，我希望我们能在这个过程中，碰撞出更多智慧的火花。

数学归纳法：搞定循环与递归的钥匙

在这一章里呢，我们介绍了保证程序正确性的最重要的数学思维：数学归纳法。并且，从数学归纳法出发，我们学习了递归程序设计。递归程序设计的几点要素，就是数学归纳法中的几个重要步骤。递归中的边界条件，就是数学归纳法中的 k0​，递归中的递归过程，就是数学归纳法中的假设 ki​ 成立并证明 ki+1​ 也成立那一步，最后两步结论放到一起，就能证明我们的递归程序整体是正确的。

思考题中呢，给你留了两个问题，第一个是将菲波那契数列的递归程序，改写成循环程序，关于这个问题，你可以参考留言区中 @奔跑的八戒、@徐洲更、@一步、@Geek_Andy_Lee00、@我思故我在 等用户的答案以及我在他们当中给出的回复内容。

第二个思考题呢，是做数学归纳法与菲波那契数列递归程序步骤的一一对应，关于这个问题，请看下面我给出的参考答案，看看和你想的有什么差别吧：

#include <stdio.h>

int fib(int n) {
    if (n == 1 || n == 2) return 1;
    return fib(n - 1) + fib(n - 2);
}

int main() {
    int n;
    scanf("%d", &n);
    printf("%d\n", fib(n));
    return 0;
}

其中代码的第 4 行，n == 1 和 n == 2 的条件判断，就是数学归纳法中所谓的 k0​ 成立，这一步保证了，fib 函数计算的第 1 项 和 第 2 项的斐波那契函数值一定是正确的。代码的第 5 行中呢，就是假设 fib(n - 1) 和 fib(n - 2)的值是正确的，那么 fib(n) 就的值就等于 fib(n - 1) + fib(n - 2) ，这就是数学归纳法中的第二步，假设 ki​ 成立，证明 ki+1​ 也成立。显然如果可以保证前两项的正确性，那么 fib(n) 的值一定正确。最后我们得出结论，这个fib 递归函数设计是正确的。

程序设计原则：把计算过程交给计算机

这一节中，我们强调了程序设计的基本原则，就是将计算过程交给计算机。我们负责逻辑组织，计算机负责具体计算过程，这就是所谓的专业的事情交给专业的人来做。

本节中的思考题是计算 100 以内自然数的 “和的平方” 与 “平方和” 的差值。在这里呢，我要给用户 @胖胖胖、@不便明言、@Geek_And_Lee00 点赞。具体的答案，你也可以参考这三个用户在留言区中的内容。

关于这道思考题的第一问，我就不给你做演示了，实现起来比较简单，你应该有能力自我完成的。下面，我主要给出 “平方和” 公式的推导过程，而对于 “和的平方” 你可以基于等差数列求和公式来求解。

教给你一种比较通用的推导平方和公式的方法，也是我用着最顺手的方法，就是依靠立方和，推导平方和。首先，我们先列出来相邻两项的立方差：

n3−(n−1)3(n−1)3−(n−2)3(n−2)3−(n−3)323−1313−03​=3×n2−3×n+1=3×(n−1)2−3×(n−1)+1=3×(n−2)2−3×(n−2)+1…=3×22−3×22+1=3×12−3×12+1​

如上公式所示，我们将上面罗列的 n 个等式的左右两侧分别相加，就得到了如下式子：

​左侧：n3=n3−(n−1)3+(n−1)3−(n−2)3+…−13+13−03右侧：3×i=1∑n​i2−3×i=1∑n​i+n​

我们看到左侧就剩下一项 n 的立方了，这一项是可算的，右侧有一个 3 倍的平方和项，和一个 3 倍的等差数列求和项，以及一个常数项 n。接下来，左侧等于右侧，我们将平方和项与其他几项分别置于等式的两侧，就得到了如下平方和公式：

左侧移项​=右侧：n3=3×i=1∑n​i2−(3×i=1∑n​i)+n：3×i=1∑n​i2=n3+(3×i=1∑n​i)−ni=1∑n​i2=3n3+(3×∑i=1n​i)−n​i=1∑n​i2=62×n3+3×(1+n)×n−2×n​​

至此，我们就得到了平方和公式。其实，你还可以尝试使用这种方法，求解立方和公式，整体步骤差不多，就是先表示出相邻两项的四次方差，然后用如上步骤，继续推导即可。

框架思维（上）：将素数筛算法学成框架算法

这一节课，我们学习了素数筛算法，素数筛每一轮找到一个素数，然后在一个标记数组中，标记掉这个素数所有的倍数，剩下没有被标记掉的数字，就是我们要的素数了。最后，我留了一个程序性质证明题，具体看如下代码：

#include <stdio.h>

// 打印一个素因子，并且在中间输出 * 乘号
void print_num(int num, int *flag) {
    if (*flag == 1) printf(" * ");
    printf("%d", num);
    *flag = 1;
    return ;
}

int main() {
    int n, i = 2, flag = 0, raw_n;
    scanf("%d", &n);
    raw_n = n;
    // 循环终止条件，循环到 n 的平方根结束
    while (i * i <= n) {
        //①：只要 n 可以被 i 整除，就认为 i 是 n 的一个素因子
        while (n % i == 0) {
            print_num(i, &flag);
            n /= i;
        }
        i += 1;
    }
    //②：如果最后 n 不等于 1，就说明 n 是最后一个素数
    if (n != 1) print_num(n, &flag);
    printf(" = %d\n", raw_n);
    return 0;
}

第一个，是要证明第 18 行代码中，只要 n 可以被 i 整除，i 就一定是素数。关于这个证明，我们可以使用反证法。

假设 i 可以被 n 整除，但 i 不是素数，由算术基本定理可知，一个非素数的数字 N，一定可以分解为几个小于 N 的素数乘积的形式。我们不妨假设 i=p1​×p2​，这里 p1​ 和 p2​ 均为素数，如果变量 n 可以被 i 整除，那么 n 也一定可以被小于 i 的素数 p1​ 整除。而根据程序的运行流程，n 中已经不可能存在小于 i 的因子了，所以 p1​ 不具备存在的条件，故原假设不成立，i 是素数。

第二个，是要证明第 25 行代码中，为什么只要 n 不等于 1，n 就一定是素数呢？其实也可以参考第一问的证明流程。在 while 循环处理过程中，数字 n 中已经不可能存在小于等于 i 的所有的因子了，又因为此时 i 是大于根号 n 的一个值，也就是说，在小于等于根号 n 范围内，找不到数字 n 的非 1 因子，而能够满足这种性质的数字，一定是素数。

至此，我们就证明完了程序中两处代码的性质。

数据结构（上）：突破基本类型的限制，存储更大的整数

在这一节中，我们学习了大整数表示法，说明了如果是数据表示的导致的程序设计过程不可行，那么我们就需要在数据结构中寻找解决方案了。

在大整数表示法中，我们是将一个数字，从右到左倒着存储在数组中，并且用数组的 0 位存储数字的位数。数组中存储的数字大小，应该等于其每一位的数字乘上相关存储位置的位权，数组的 1 位位权为 1，也就是 10 的 0 次方，2 位位权为 10，也就是 10 的 1 次方，以此类推。

那么接下来，我们理解大整数的乘法，也是通过这种数学公式上面的等价关系，来理解大整数乘法过程。最后给你留了一个编程题，是关于实现读入两个大整数，并且计算两个大整数加法结果的程序，以下是我的参考代码：

#include <stdio.h>
#include <string.h>
#define MAX_N 1000
char str_a[MAX_N + 5], str_b[MAX_N + 5];
int num1[MAX_N + 5], num2[MAX_N + 5], num3[MAX_N + 5];

void convert_to(char *str, int *num) {
    num[0] = strlen(str);
    for (int i = num[0] - 1; i >= 0; i--) {
        num[num[0] - i] = str[i] - '0';
    }
    return ;
}

void output_big_integer(int *num) {
    for (int i = num3[0]; i >= 1; i--) {
        printf("%d", num3[i]);
    }
    return ;
}

int main() {
    scanf("%s%s", str_a, str_b);
    convert_to(str_a, num1);
    convert_to(str_b, num2);
    plus_big_integer(num1, num2, num3);
    output_big_integer(num3);
    return 0;
}

可以看到，首先读入两个字符串 str_a 和 str_b，分别代表第一个和第二个大整数。然后调用 convert_to 方法，将第一个字符串与第二个字符串，转换成大整数表示法，分别存储在 num1 和 num2 数组中；然后再调用 plus_big_integer 方法，将两个大整数的加法结果，存储在 num3 数组中；最后，输出 num3 数组中所存储的大整数。其中，提到的 plus_big_integer 方法，在原文中有给出，你可以回到原文中进行查看。

这段程序设计中，最应该值得你注意的是，我们将大整数操作的相关过程，均封装成了函数方法。字符串转大整数表示法，封装成了函数 convert_to；大整数加法过程，封装成了 plus_big_integer；输出大整数，封装成了 output_big_integer。

封装成函数方法的好处，就在于只要保证每一个小方法是正确的，就能保证整个程序的正确性。更重要的是，如果你单独看主函数的话，即使不看每一个方法的具体实现过程，你也能够清晰的知道，这个程序流程究竟在干什么，增强了代码的可读性。最后一点好处，就是出现 Bug 的时候，便于改错。

关于第 17 篇文章中，所说的改进 Shift-And 算法中的数据结构，我这里给你个提示，你可以参考大整数表示法，再参照这道题目中的程序设计原则，将操作封装成函数。

对于改进 Shift-And 算法中的数据结构，你需要做的就是用大整数表示法，表示一个二进制数字，然后根据 Shift-And 算法的需求，做好需要封装的操作有：左移、或 1 操作、与运算以及判断这个数字的第 m 位是否为 1 这些需要封装的操作。最终你会发现，算法流程没有改变，改变的只有程序样式。更多内容呢，你可以参考文章中，我与 @陈洲更 的留言讨论内容。

好了今天的思考题答疑就结束了，如果你还有什么不清楚的，或者有更好的想法的，欢迎告诉我，我们留言区见！

18 | 重新认识数据结构（上）：初识链表结构

你好，我是胡光，欢迎来到“算法数据结构篇”的第一课。

在之前的学习中，我们对数据结构的认识，主要集中在它是用来做数据的表示，更具体地讲，就是数据结构所讨论的问题，是将现实中的数据如何合理地表示在程序中，以使程序完成既定的目标任务。

不知道你还记不记得，在上节课 Shift-And 算法的学习中，我们发现不同的数据，或者说信息表示方式，会给解决问题的效率带来很大的影响。因此，基本确定了数据的表示，在程序设计过程中发挥着非常重要的作用，也就意味着我们必须对数据结构的学习重视起来。

之前我们所讨论的数据结构呢，其实都只是停留在程序内部的数据结构，这是一种具体的，可见的数据结构，并对我们设计的程序产生重要影响。我们也认识到，这种具体的数据结构的重要作用，会对我们设计的程序产生重要的影响。今天呢，我将带你重新认识数据结构，发现它的另一面，那就是数据结构对我们思维方式的影响，这种影响更抽象，也更重要。

接下来的两次课程内容呢，我将通过链表结构的讲解，让你认识这种思维层面的数据结构。

必知必会，查缺补漏

今天我们将要学习的链表，是一种常见的基础数据结构，属于数据结构中线性结构的一种。在讲如何学习链表之前，我们先来看一看通常情况下，如何学习数据结构的相关的知识。

1. 数据结构：结构定义 + 结构操作

你应该玩过拼装式的玩具吧，类似于高达机器人之类的。面对这样的玩具，我一般在拼装之前看看说明书，知道这个玩具包含哪几部分，然后对这些部分进行拼装，等把各部分拼好了后，再把它们组合起来，最终的成品就完成了。

图1：高达机器人

其实学习某样知识也是一样的，要先搞清楚这门知识的组成部分，从组成部分开始入手学习，最后把所有的知识碎片整合到一起，就是知识的全貌了。

回到如何理解数据结构这个问题，我先给你列出重要的两句话：

数据结构 = 结构定义 + 结构操作

数据结构，就是定义一种性质，并且维护这种性质

其实这两句话，说的是一回事儿。结构定义，指的是就是说明这种数据结构长成什么样子，具备什么性质。结构操作，就是确定这种数据结构都支持哪些操作，同时结构操作的结果，不能破坏这类结构原本的性质。这也就到了第二句话中说的内容，维护这种性质。

这就好像刚才我说到的高达机器人，结构定义类比高达机器人的样子，结构操作就是这个机器人都支持什么功能，比如抬手、伸腿之类的。但是无论是哪种结构操作，你都不能把机器人玩坏掉（也就是不能破坏结构定义），这就是我们所说的：操作归操作，但是你要维护这种性质。

接下来呢，我将会通过这两句话，带你学习链表这种数据结构。

2. 链表的结构定义

链表的结构定义中，包含了两个信息，一个是数据信息，用来存储数据的，也叫做数据域；另外一个是地址信息，用来存储下一个节点地址的，也叫做指针域。

图2：链表结构定义

记住，链表结构是由一个一个链表节点组成的，在应用这种结构的时候，你无需对这种结构本身做改变，你只需要按照自己的需求，把自己想要的数据，放在链表结构定义的数据域中即可。比如说，整型是你想存储在链表中的数据，那么数据域的类型就是整型，如果字符串类型是你想存储的数据，那么数据域的类型就是字符串类型。

在示意图中可以看到，链表节点以整型作为数据域的类型，其中第一个链表节点，存储了 763 这个数据，指针域中呢，存储了一个 0x56432 地址，这个地址而 0x56432 正是第二个链表节点的地址。我们可以说，第一个节点指向第二个节点，因此这两个节点之间，在逻辑上构成了一个指向关系。

在第二个节点的指针域中呢，存储了一个地址，是 0x0，这个地址值所对应的整型值就是 0。这是一个特殊的地址，我们称它为空地址，在 C 语言中用 NULL 宏表示这个空地址，读作 nào。我们让第二个链表节点指向空地址，就意味着它就是这个链表结构的最后一个节点。

看完了链表的结构示意图以后，就来让我们看一下在代码中，如何定义链表节点结构吧：

struct Node {
    int data;
    struct Node *next;
};

正如这段代码所示，我们使用结构体，定义一种新的类型，叫做 struct Node 类型，来表示链表的节点结构。链表的每个节点内部，有一个数据域，一个指针域，对应到代码中，就是一个整型的 data 字段，和一个指向 struct Node 类型本身的指针字段 next。

值得注意的是，链表结构的指针域只有一个 next 变量，这就说明每一个链表节点，只能唯一地指向后续的一个节点，这是链表结构非常重要的一个性质，后续我们也会用到这个性质。

总地来说，链表结构中，数据域是留出来让我们实现自我需求的，就是想存整型，就改成整型，想存浮点型，就改成浮点型。而 next 指针域，是用来维护链表这个结构的，这里一般不需要你自由发挥，记住怎么回事儿，直接用就行了。记住，要想修改内存中的链表结构，就一定要修改节点内部 next 指针域中存储的地址值。

3. 链表的结构操作

接下来呢，我会给你介绍一种链表的基础操作，就是向链表中插入节点的操作。

在讲解链表的插入和删除方法之前呢，我们先来对齐一个概念，就是链表节点的位置。当你把链表结构画出来以后，你会发现链表结构和数组结构很类似，只不过数组结构在内存中存储是连续的，链表结构由于有指针域的存在，它的每一个节点在内存中存储的位置未必连续。

我们也可以参考数组下标的编号规则，给每个链表节点编一个号，从第一个开始依次是 0、1、2，具体如下图所示：

图3：链表节点位置定义

明白了什么是链表的节点位置以后呢，我们定义一个向链表中插入节点的函数方法：

struct Node *insert(struct Node *head, int ind, struct Node *a);

这个插入方法呢，传入三个参数，第一个是待操作的链表的头结点地址，也就是链表中第一个节点的地址；第二个参数代表插入位置；第三个参数是一个指针变量，指向要插入的新节点。

简单来说，就是向 head 所指向的链表的 ind 位置插入一个由 a 所指向的节点，返回值代表插入新节点以后的链表头结点地址。为什么要返回插入以后的链表头结点地址呢？因为新节点有可能插入到链表的第 0 位，插入以后，链表的头结点地址就发生了改变，我们必须把这个信息返回。

由于插入操作，会改变链表结构，刚刚我们说了，只有修改链表节点中的 next 指针域的值，才算是修改了链表的结构。为了完成插入操作，我们都需要修改哪些节点的 next 指针域的值呢？

首先是让 ind - 1 位置的节点指向 a 节点，然后是 a 节点指向原 ind 位置的节点，也就是说，涉及到两个节点的 next 指针域的值的修改，一个是 ind - 1 位置的节点，一个是 a 节点自身。我们就可以先找到 ind - 1 位置的节点，然后再进行相关操作即可。写成代码，如下所示：

struct Node *insert(struct Node *head, int ind, struct Node *a) {
    struct Node ret, *p = &ret;
    ret.next = head;
    // 从【虚拟头节点】开始向后走 ind 步
    while (ind--) p = p->next;
    // 完成节点的插入操作
    a->next = p->next;
    p->next = a;
    // 返回真正的链表头节点地址
    return ret.next;
}

代码中，涉及到一个很重要的技巧，就是 “虚拟头结点” 这个链表操作技巧。所谓虚拟头结点，就是在原有的链表头结点前面，加上另外一个节点，这个额外增加的头结点，就是虚拟头结点。增加虚拟头结点的目的，是为了让我们操作链表更方便，实际上，如果在某个操作中，头结点地址有可能发生改变，我们也可以使用虚拟头结点这个技巧。

我们来分析一下，对于插入操作，虚拟头结点有什么重要的作用。首先如果我们要在第 5 个位置插入新节点，那我们就要找到 4 号位的节点，也就是从头结点开始，向后走 4 步，确定了 4 号节点以后，再修改相关节点的 next 指针域即可。

也就是说，如果我们想插入到 ind 位，就需要从头结点向后走 ind - 1 步，定位到 ind - 1 号节点。如果插入的位置为 0 呢？我们总不能走 -1 步吧？这个时候，在程序中我们就只能对 ind 等于 0 的情况进行特殊判断了。这确实是一种可行的实现方法，可不够优美，因为这种做法没有统一 ind 在等于 0 和 不等于 0 时候的处理情况。

可是当我们在原链表前面，加入了一个虚拟头结点以后，这一切的操作就变得自然了许多！一开始 p 指向虚拟头结点，由于链表头部增加了一个节点，原先我们要定位链表 ind - 1 位置，要走 ind - 1 步，现在就是走 ind 步。

也就是说，在有虚拟头结点的情况下，如果我们插入到 5 号位，就从虚拟头结点向后走 5 步就行，同样的，想要插入到 0 号位呢，就向后走 0 步即可，即 p 指针指向虚拟头结点不动，直接将新的节点，插入到虚拟头结点后面即可。

图4：虚拟节点示意图

其实，对于链表的相关操作，无论是插入还是删除，只要是有可能改变原有链表头结点的操作，增加虚拟头结点都是一个很实用的处理技巧。

一起动手，搞事情

今天给你留的作业呢，与链表的操作有关系，请看如下函数接口定义：

struct Node *erase(struct Node *head, int ind);

请你参照文中的链表插入操作，实现一个链表节点删除的操作，删除函数传入两个参数，分别代表指向链表头结点的指针变量 head，以及要删除的节点位置 ind，返回值代表删除节点以后的链表头结点地址。

由于删除操作，有可能改变链表的头结点，所以你可以尝试使用前面我们讲到的虚拟头结点的编码技巧。仔细分析，你可以的！

课程小结

我们今天介绍的链表呢，其实真实姓名叫做“单向链表”，这是一种很有代表性的链表结构。实际上，你学会了单向链表，也就很容易掌握其他形式的链表了。比如说：单向循环链表、双向链表、双向循环链表、块状链表、跳跃表。

尤其是块状链表和跳跃表， 在工程中应用最广泛，C++ STL 中的 vector 底层用的就是块状链表。关于这些概念，如果你感兴趣，可以自行上网搜索相关资料。篇幅有限，我们就不一个个展开介绍了。

最后，我们来做一下今天课程的总结，今天我希望你记住如下几点：

数据结构 = 结构定义 + 结构操作，这个等式说明了我们学习数据结构的方法顺序。

单向链表节点中，存在数据域和指针域，指针域控制了链表的结构，一般不会根据应用场景的变化而变化，而数据域是根据应用场景的需求而设计的。

下节课呢，我将给你讲几种更有意思的链表操作。好了，今天就到这里了，我是胡光，咱们下期见。

19 | 重新认识数据结构（下）：有趣的 "链表" 思维

你好，我是胡光，欢迎回来。

上节课，我们着重介绍了数据结构的学习方法，就是把数据结构分成两部分进行学习：结构定义和结构操作。其中，结构定义是定义数据结构的样子和性质，结构操作就是数据结构的相关功能，并且在操作过程中需要维护相关结构的性质。在这个基础上，我们详细讲了链表的基础结构。

我们经常听到，算法中最有价值的是“算法思维”，其实在数据结构中，最有价值的也是“数据结构思维”。今天呢，我们就看看链表这种具体的数据结构，如何变成一种思维层面的数据结构，辅助我们进行思考。

今日任务

先来看一下今天这 10 分钟的任务吧。首先，我们定义一种数字名称，叫做“快乐数”。所谓快乐数就是经过有限次变换以后，等于 1 的数字。这个变换规则，给出一个非 1 的数字 a ，把它的位数拎出来，求各个位数的平方和，得到一个数字 b，如果数字 b 不是 1，那就对数字 b 的每一位数再做平方和，得到数字 c……经过不停的变换，确定最后能否得到 1。

例如，一开始的数字是 19，经过变换规则 12+92=82，得到数字 82；因为不是 1 ，所以接着做变换，就是 82+22=68，再做一次变换 62+82=100，最后一次做变换 12+02+02=1，得到了 1 以后，停止。

由于从 19 开始，通过有限次变换以后，能够到达数字 1，所以数字 19 是“快乐数”。在这个过程中，你应该明白，后面得到的 82、68、100 其实也是快乐数。所以今天我们要做的，就是给出一个正整数，你来判断这个数字是否是快乐数。

这个任务的难点，不在于判定哪些数字是快乐数，而在于如何判定一个数字不是快乐数。为什么这么说呢？因为不是快乐数，就说明原数字没有办法通过有限次变换，到达数字 1，那么经过多少次，才算是有限次呢？1 千次，1 万次还是 10 万次呢？你会发现，很难确定这个转换次数的上限。

你可能已经感觉到了这是一个有趣，但似乎又有点难度的任务。那就请你带着这份好奇和困惑，让我们用链表思维解决它！

必知必会，查缺补漏

先忘了上面这个任务，我们先从几个具体的例子，来学习链表相关的进阶操作。

1. 直观操作法：用数组判断链表中是否有环

我要带你学习的第一个链表相关的问题呢，就是如何判断一个链表中有环。链表中为什么会有环呢？通过上一节课的学习，我们知道单向链表的最后一个节点，原本应该指向一个叫做 NULL 的空地址，代表整个链表结束。可你有没有想过，如果链表的最后一个节点，指向的不是一个空地址，而是链表中的一个点，那是不是就形成了链表内部的一个环？如下图所示：

图1： 链表成环示意图

就如你所看到的，图中的 8 号节点，本应该是链表的最后一个节点，可它却指向了 3 号节点，这样就形成了一个以 3、4、5、6、7、8 号节点为循环状态的环形结构。当你使用指针遍历这个链表的时候，将会永无尽头。

那有没有什么办法，能够帮助我们判断在一个单向链表中，是否存在环呢？面对这个问题，你可能想要说，很简单啊，我只需要使用一个数组，记录出现过的节点信息，之后每次遍历到新节点，就判断这个节点是否在数组中有记录。如果有的话，说明链表中有环，如果遍历到了 NULL 地址，那就说明链表中无环。

上面这个方法看似可行，但数组会占用与链表等量的额外存储空间，并且效率太差了。假设链表有 n 个节点，当你经过第 1 个节点的时候，你需要在数组中查找 0 次；第 2 个节点的时候，需要在数组中查找 1 次；第 3 个节点需要查找 2 次。

依次类推，第 i 个节点，你需要在数组中查找 i - 1 次，可直到你遍历到第 n + 1 个节点的时候，才会发现有重复。此时在数组中查找的总次数将会是 (n + 1) * n / 2 次，接近于 n2 次，这种时间复杂度，写作 O(n2)。关于时间复杂度，简单来理解，它反映的其实是问题规模与运算次数之间的关系。

2. 快慢指针法：让判断链表是否有环变得简单

接下来呢，我们来假设一种场景，在操场上有两个运动员从跑道起点出发跑步，一个速度比较快，一个速度比较慢。同时操场的能见度特别低，他们根本不知道跑道是不是环形的，可过了一段时间以后，两个人就确定了这个跑道是否环形，他俩是怎么做到的？

你稍微想一想，就会明白这里面的道理：如果跑道是环形的，那么速度快的运动员，在足够的时间里，终究会从速度慢的人后面追上来了，形成两人相遇的情况。如果跑到不是环形的，那速度快的运动员会最先跑到终点，两人不会相遇！这就是今天我们要讲的链表判环的方法，叫做：快慢指针法。

图2： 快慢指针法示意图

简单来说，就是我们把链表当成跑道，放上两个指针，一个指针每次走一步，另一个指针每次走两步，如果快的指针，先跑到了终点，说明链表中没有环，如果两个指针相遇了，则说明链表中有环。并且，我们很容易知道两个指针一定是在环内部相遇的。

如果把上述过程写成代码的话，如下所示：

int hasCycle(struct Node *head) {
    if (head == NULL) return 0;
    // p 是慢指针，q 是快指针
    struct Node *p = head, *q = head;
    // 每次循环，p 走1步，q 走2步
    do {
        p = p->next;
        q = q->next;
        if (q == NULL) return 0;
        q = q->next;
    } while (p != q && q);
    return p == q;
}

代码比较简单，你可以自行阅读并学习。其中需要注意的是几个指针判空的语句，一定要保证指针非空的前提下，再用指针间接访问结构体字段，否则你的程序会瞬间崩溃。

一起动手，搞事情

前面讲了，如何判断链表有环。那么今天的“一起动手，搞事情”环节呢，我就给你留两个相关的子问题：

如何求解环的长度，如图 1 中，环的长度就是 5。

如何找到环的起点，如图 1 中，3 号点，就是环的起始点。

快乐数判定问题

准备完了前面这些基础知识以后，你可能还是摸不着头绪，不知道如何解决快乐数判定问题。那你可要跟住节奏了，下面就要进入将链表数据结构提升成为思维的阶段了。

首先，我们知道，整型表示的最大值是 231−1，大约是 20 亿左右。如果就在这个整型范围内解决快乐数判定的问题，我们可以得到哪些有用的结论呢？下面，让我们分析一下。

由本节开头的例子可知，从 19 开始，依次得到的是：82、68、100、1 这些数字。也就是说，从一个数字开始，按照快乐数的计算规则，会得到一串数字序列。这其中就蕴含着链表重要的结构思维：从当前节点，唯一映射到下一个节点。快乐数序列中的数字，就是链表中的节点，如果当前数字确定了，下一个数字也就是确定了的，就像数字 19，下一个肯定是数字 82，这种映射规则，就是链表节点之间的指向关系。

最后，我们做一个思维映射，一切就豁然开朗了。所谓快乐数序列，最终的目标是能到 1，这个数字 1，其实就可以看成是链表中的空地址。这样，我们就把快乐数问题，用链表思维做了一番改造，并且这种改造，一点儿违和感都没有。当你把这个思考过程搞清楚后，恭喜你，在这个过程中，你正在将你自己看待问题的方式，变得更加结构化，更加计算机化。

接下来我们分析一下，这个快乐数链表，最长能有多长？这个问题其实比较好分析，主要是思考究竟哪个数字，按照快乐数的计算规则，得到的下一个数字是最大的，这个最大的数字是多少。

稍加思索，你就知道，如果在整型范围内解决快乐数问题的话，1999999999 这个数字，按照各位平方和来进行计算，得到的下一个数字应该是 (9∗92+1)=730，也就是说，这个快乐数链表中，节点数量绝对不会超过 731 个。一个不超过 731 个节点的链表，还总也走不到末尾，说明什么？说明这个链表中有环！

至此，你会发现，判断一个数字是否是快乐数，等价于判断链表中是否有环。剩下的台词，我就不和你抢了，舞台留给你来发挥吧，代码我就不替你写了。加油！我相信，你一定写得出来。

课程小结

关于链表的内容，到这里就结束了。通过这两节，只是想让你记住一点：数据结构，不仅仅是计算机中的代码，更是我们思维的逻辑结构在计算机科学中的具体体现。

这种有趣的思维变换，才是算法和数据结构的真正魅力所在。有些技术的酷炫，是长在脸上的，让人一眼就能看到；而算法和数据结构的酷炫，从来都是那样的含蓄，那样的内敛，你只有深入到里面，才能感受到它的巨大魅力。我已经竭尽所能，试图将它的内在美描述出来，不知道你有没有 get 到，如果你没有 get 到，那一定是我的语言太过苍白，而不是算法数据结构没有魅力！

好了，今天先到这里了，我是胡光，我们下期见。

20 | 二分查找：提升程序的查找效率

你好，我是胡光，欢迎回来。

上节课，我们讲了链表的基础结构，以及体会了一把链表结构在思维逻辑层面的作用，就是面对看似复杂的问题，当我们把它转换成链表结构思维去解决的时候，这些问题和困难都迎刃而解。

今天呢，我将带你学习一种简单、有趣且高效的算法，叫做二分查找。在学习二分查找之前呢，有一个关于二分查找的笑话，你必须知道。

话说，在学校图书馆的计算机科学相关书籍借阅区里面，有一个女生抱着 40 本书往外走，经过图书馆安检机器的时候，安检机器发出了警报声。这时候，女生很无奈，就把书放到了地上，准备一本一本地去试，看看究竟是哪一本书没有消磁。

女生的举动，被旁边图书馆管理员阿姨看到了，阿姨看不下去了，叫住了她，说：这么一本一本的尝试，多慢啊！我教你一种方法。眼看阿姨将书分成两摞，拿起其中一摞的书，过了一下安检，安检机器响了，阿姨就又将这摞书分成了两部分，拿出其中一部分又过了一次安检……就这样，阿姨每次将书的数量减少一半，没几次就找到了那本没有消磁的书。阿姨得意洋洋地说：小姑娘，这就是书中讲的二分查找算法，你这专业知识不过关啊！次日，图书馆发现，丢了 39 本书。

上面这个故事中的阿姨，虽然知道二分查找算法，可明显是对使用算法的前提条件没有搞清楚。今天，我教给你的不仅是二分查找算法本身，还希望你能准确搞清楚二分查找算法的使用场景。

今日任务

在正式开始今天课程之前呢，先来看一下今天这 10 分钟的任务吧。

假设你手上有 n 段长度不等的绳子，你现在想将这些绳子进行裁剪，裁剪出 k 条长度相等的绳子，注意，只能剪断绳子，不能拼接绳子。问题就是，你能得到的这 k 段绳子的最长长度是多长？

图1： 切绳子任务示意图

如图所示，如果你手中有 3 条绳子，分别是 4 米、6 米 和 5 米，想要切出等长的 4 段，你会发现，每段最长就是 3 米。

那么我们是如何得到“每段最长就是 3 米”这个答案的呢？当然，你可以采用枚举法，就是先尝试能不能切出至少 4 段的 1 米长绳子，如果可以的话，再去尝试每段长度 2 米是否可行，依次尝试下去，直到尝试不下去为止。最后一次尝试可行的长度，就是每段绳子的最长长度了。

这种做法，就像前面故事中想要一本一本进行尝试的女生，显得低效且繁琐。而今天，我将扮演图书馆阿姨的角色，当然，不会像故事中的阿姨一样，犯了前提条件没有搞清的错误，去给你讲一种更高效的方法！

必知必会，查缺补漏

1. 二分查找算法基础

最简单的二分算法的形式，就是在一个有序数组中，查找一个数字 x 是否存在。而二分算法，就是要基于这种有序性，才能对原问题进行加速求解。

我们先来思考，如何在一个数组中查找一个数字 x，最直接的方法，就是从头到尾一个一个找，找到了就是有数字 x，找不到就是没有数字 x。

而二分算法呢，是确定一个查找区间，然后从查找区间的一半处，与 x 进行比较，如果中间的数字比 x 大，说明 x 在前半段，就把后半段扔掉；如果比 x 小，就把前半段扔掉，继续在后半段区间内查找。你会发现，二分查找的过程，每一次比较，都会使区间减少一半，对于一个大小为 n 的区间，我们只需要 log2​n 次比较操作即可确定结果。

具体的过程呢，如下图所示：

图2： 二分查找算法示意图

图中呢，我们以查找 17 这个数字为例，L 和 R 所圈定的，就是当前的查找区间，一开始 L = 0，R = 6，mid 所指向的就是数组的中间位置，根据 L 和 R 计算得到 mid 的值是 3。查看数组第 3 位的值是 12，比待查找值 17 要小，说明如果 17 在这个有序数组中，那它一定在 mid 所指向位置的后面，而 mid 本身所指向的数字已经确定不是 17 了，所以下一次我们可以将查找区间，定位到 mid + 1 到 R，也就是将 L 调整到 mid + 1 （即数组第 4 位）的位置。

理解二分查找的过程，首先要理解二分查找是怎么保证查找过程正确性的。中心思想就一个：不管如何调整区间，都要保证待查找数字，总是落在我们的由 L 和 R 标记的查找区间内部。而二分查找，实际上“二分”的就是查找范围。这个过程，就像警察排查犯罪嫌疑人一样，通过一些特定的条件，快速地缩小范围，并锁定真正的罪犯。

下面是一份二分查找的示例代码：

int binary_search(int *arr, int n, int x) {
    int l = 0, r = n - 1, mid;
    while (l <= r) {
        mid = (l + r) >> 1;
        if (arr[mid] == x) return mid;
        if (arr[mid] > x) r = mid - 1;
        else l = mid + 1;
    }
    return -1;
}

如代码所示，binary_search 函数传入三个参数，分别代表有序数组 arr，数组长度 n 和待查找数字 x。如果在数组中存在数字 x，函数将返回 x 数字的下标，否则就会返回 -1，代表数组中不存在数字 x。

你会看到，函数中有一个 while 循环，循环的执行条件是 l <= r，意味着待查找区间不为空。每次循环开始的时候，都是先通过 l 和 r 的值，计算得到一个中间位置的下标 mid 值，然后比较 mid 位置的值与 x 的大小关系，从而确定区间调整策略。

如果 arr[mid] 大于 x，说明 x 值在区间的前半段，那么 mid 及 mid 位置以后的值，就不在下一次查找的范围之内了，我们就把区间的尾部位置 r 向前移动，移动到 mid - 1 位。arr[mid] 小于 x 时候的调整策略与之类似。

至此，我们就学习完了基础的二分查找算法。

2. 二分答案的基本思路

其实二分查找的算法思想，最有价值的部分，不是刚才讲的有序数组查找问题。而是由其衍生出来的叫做“二分答案”的思想。

关于二分答案的思想，你可以先回想一下，我们是如何介绍数组与函数的关系的？我们说：数组和函数本质上做的都是映射，函数是压缩的数组，数组是展开的函数。

我们沿着函数和数组的关系，回头再看看二分查找的代码，你会发现二分查找是在一个有序数组中，确定某一位为待查找值的位置，也就是 arr[x] = y，给定待查找 y 值，确定 x 值。对于这个有序数组，我们可以看成是一个单调函数，而对于这个 arr[x] = y 中去确定 x 值的过程，可以看成是对单调函数 f(x) = y 进行求解的过程。

如何判断，什么场景下需要使用二分思想对问题求解呢？其实，二分能解决的问题，还是比较有代表性的，基本需要满足如下两点：

f(x) 是一个单调函数。

f(x) = y 函数，由 x 确定 y 值比较简单，而由 y 值确定 x 就比较困难。

关于第一点，我就不做过多解释了。至于第二点，你可以参考有序数组那个例子，对于数组来说，arr[x] = y，给定数组下标 x，确定值 y，这个过程对于计算机来说很容易，可给定 y 值，确定 y 值所在的下标 x，这个过程就不太容易了。所以，我们使用了二分查找算法。

总地来说，二分答案就是把二分查找过程中的数组换成了函数。关于二分答案的知识，你先理解思想，接下来我会用具体例子来给你展示。

一起动手，搞事情

今天给你留的作业题呢，是一个我们大家普遍都比较关心的问题，与计算工资有关系。下表是“个人所得税缴纳税率表”的一部分：

按照表格所示，如果一个人的每月工资是 18600，首先扣除不超过 3000 部分的 3% 的所得税 90 元，然后扣除 3000 到 12000 部分的 10%，就是 (12000 - 3000) * 10% = 900，最后是扣除 12000 到 18600 的部分的 20%，也就是 (18600 - 12000) * 20% = 1320。所以，此人每月到手工资应该是：18600 - 90 - 900 - 1320 = 16290 元。

如果要是让你通过上表，计算一个人的税后工资，那这个任务可太容易了，我不会将这么简单的任务交给你的。

你今天要做的，是通过一个人的税后工资，反推出他 / 她的税前工资，也就是针对于上面这个例子，我给出税后工资 16290，你的程序应该能够计算得到税前工资 18600。想一想，怎么做吧，加油！

切出最长的绳子

最后，我们回到今天的任务。我们将每一段绳子的长度 x，与能切出来的绳子段数之间，看成一个映射关系，用函数 f(x) = y 来表示，代表每一段长度为 x 的情况下，最多能切出来 y 段绳子。你很容易发现，f 函数是一个单调函数，随着每一段长度的增加，能切出来的段数 y 是在减少的，而对于我们来说，就是要确定 y = k 时的 x 的最大值。

让我们总结以下 f(x) 函数的性质，首先 f(x) 函数是单调函数，x 越大，y 值越小。其次，你应该可以感受出来，当我给你每一段长度 x 的时候，你很容易确定 f(x) = y 的 y 值，而如果让你通过 y 值求解 x，就没那么容易了！

至此，我们从这个任务出现的问题中，看到了能够使用二分思想的两个最重要的性质，下面我们就用二分思想的思路，来解决这个问题，下面是我给出的参考代码：

#define EPS 1e-7
double l[100], n;

int f(double x) {
    int cnt = 0;
    for (int i = 0; i < n; i++) {
        cnt += (int)floor(l[i] / x);
    }
    return cnt;
}

double binary_search(double *l, double *r, int k) {
    if (r - l <= EPS) return r;
    double mid = (l + r) / 2.0;
    if (f(mid) < k) return bs(l, mid, k);
    return bs(mid, r, k);
}

代码中的 binary_search 就是二分答案的过程，函数 f 传入每一段的长度 x，返回最多能切多少段，变量 n 记录的是原始绳子的数量，l 数组记录的是每一段原始绳子的长度。

让我们把目光集中到 binary_search 函数过程，这一段二分答案的程序，使用递归的程序设计技巧，其实和之前给你演示的循环程序本质思想都是一样的。其中 l 和 r 表示待查找区间范围，也就是每一段绳子的长度范围。

递归程序的边界条件，是当 r - l 小于等于一个极小值的时候，就终止递归。这里需要特殊的说明一下，根据浮点数在我们计算机中的表示方法，我们很难用判等操作来判断两个浮点值相等，取而代之的，就是当这两个浮点值已经很接近的时候，我们就认为它俩是一个值。代码中的 EPS 是一个宏，就是我们控制的精度，一般控制在 10−7 范围，两个值相差不到 10−7 的时候，我们就认为这两个浮点值相等。

关于调整搜索范围的代码，我就不再赘述了，剩下的你自己就可以看明白了。至此，我们就解决了今天的任务。

课程小结

最后，我们来做一下课程小结。今天我们学习了二分查找算法，以及由二分思想延伸出来的二分答案相关算法思想。关于这些知识，你只需要记住如下几点即可：

二分算法框架，是求解具有单调性问题的利器。

二分算法，通常用来求解那些 f(x) = y 问题中，给定 y，求解 x 的问题。

再次强调，数组和函数在思维层面，没有什么本质差别。

总结完了以后呢，我们再回顾那个笑话故事，图书馆阿姨用二分算法为什么会导致图书馆丢了 39 本书呢？如果我们将书的编号和是否是图书馆的书之间，做一个函数映射的话，你会发现这种映射出来的函数，本质上没有单调性。所以，原因就是阿姨将二分算法思想用错场景了。

好了，今天就到这里了，我是胡光，我们下期见。

21 | 队列与单调队列：滑动区间最大值

你好，我是胡光，欢迎回来。

上节课呢，我们学习了二分查找的基本思想，以及明确了二分答案所使用的问题模型，你会发现，正因为问题具有单调性，我们才可以使用二分查找算法对问题求解过程进行加速。

今天呢，我将带你学习一种性质有趣、简单且高效的数据结构，叫做：单调队列。学习这个数据结构的时候呢，我们还是要强调一下那句话：数据结构，就是定义一种性质，并且维护这种性质。

今日任务

在正式开始学习之前呢，先来看一下今天这 10 分钟的任务吧。

滑动区间最大值，就是指在固定区间长度的前提下，在一个序列上，从前到后滑动这个区间窗口，每次窗口内部的最大值，就组成了滑动区间最大值。

例如，给你如下包含 8 个数字的序列，区间长度设置为 3：

[6 4 2] 10 3 8 5 9 -> 6
6 [4 2 10] 3 8 5 9 -> 10
6 4 [2 10 3] 8 5 9 -> 10
6 4 2 [10 3 8] 5 9 -> 10
6 4 2 10 [3 8 5] 9 -> 8
6 4 2 10 3 [8 5 9] -> 9

滑动区间从数字 6 开始出发，每次向右移动一个数字，同时把左边的一个数字丢出去，保持区间长度为 3，最后移动到数字 9 停止。可以看到，这个序列共包含 8 个数字，所以最后形成的滑动区间最大值共有 6 个，依次是 6、10、10、10、8、9。

面对这个问题，你很容易采用 O(nm) 的算法来完成，n 是区间长度，m 是窗口长度，就是枚举区间的终止位置，每次扫描区间内部，获得最大值。

而我今天要给你讲的这种方法，能让时间复杂度降低到 O(n)，你可以认为是对原序列扫描一遍，就能得到问题的答案。这究竟是什么样神奇的方法呢？带着这份好奇，我们开始今天的课程吧！

必知必会，查缺补漏

想要完成今天这个任务呢，你必须掌握今天我将要教给你的一种新的结构，就是：单调队列。

1. 初识队列

首先让我们来认识一下最简单的队列结构，举一个生活中最常见的例子：火车站排队买票，你应该都经历过吧？售票员坐在窗口里面，每次只能服务队列中排在最前面的那个人，每当有人买完票，都会从队列的头部离开，后面的人上前一步，接替离开的人向售票员购票，当有其他人想要来买票的时候，必须从队列的末尾开始排队。这种结构就是典型的队列结构。

我们计算机中的队列，和买票的这个队列是一样的，先到先得，先入先出，每个元素都是从队列尾部入队，在头部被处理完后再出队。如下图所示：

队列结构示意图

如图所示，队列就像一个数组一样，每个元素从数组尾部进入数组，从头部出数组。这种结构很简单，你应该很容易理解它的工作顺序。任何事物，往往就是看起来越简单，想要掌握其真谛就越难。就像是我给你一把锤子，你知道这东西大概可以干什么，而我要是给你一块铁，你可能就懵了。其实队列就是这种表面简单，可作用却不简单的数据结构。

想要理解队列，你就必须理解一句话，叫做：计算机是很专注的。什么意思呢，我们回忆一下之前讲到的链表判环，作为人类的你和我，可以一眼就看出来链表中是否有环。而对于计算机程序来说，只有指针指向的地方，才是它能看得到的地方。所以，我们才费了很大的力气，为计算机设计了一个快慢指针的算法，来判断链表中是否有环。

实际上，当我们在实现程序的时候，我们不仅要把数据存储在计算机中，我们还要规定计算机处理这些数据的顺序。想一想，我们之前设计的所有的循环程序，不就是在规定计算机的处理顺序么？

而今天我们学习的这个队列，你可以把其中的元素，看成是计算机要处理的一个个的任务，那么队列结构，其实就是规定了这些任务的处理顺序，程序只从队列头部取任务，先到先处理，后到的任务，需要在队列后面排着，直到轮到它，这样就可以把计算机的专注与高效发挥到极致。

学习编程，与其说是将我们的思维转换成代码，不如说是将我们的思维，锻炼成计算机的思维。注意，计算机的处理逻辑，是有顺序的。今天，我们所说的队列，就代表了一种顺序。后面，我们还会介绍另外一种顺序的代表，就是“栈”，到时候我再详细讲解。

2. 队列升级：单调队列

讲完了最简单的队列以后，下面就来让我们学习一种队列的升级产物：单调队列。在正式讲解单调队列之前，让我们来讲一个现实中的单调队列的例子。

假设你的学长张三，作为乒乓球体育生，很幸运地进了高中学校的校队。学校规定当前校队中，能力最强的人，才有可能代表学校去参加比赛。那么在校的这三年，张三都有机会代表学校参加国家赛。

高一的时候张三战斗力 85，比他能力强的有两个人，一个是高二的孔令辉，战斗力 93，一个是高三的刘国梁，战斗力 98。那么此时，能代表学校参加比赛的只有最高战力的刘国梁。

过了一年，张三上了高二，原高二的孔令辉上了高三，刘国梁毕业了，如果张三的战力比后面上来的新生要强，那么他再等一年，有可能在高三的时候代表学校参赛。然而这时作为高一乒乓球体育生的你也进入了校队，战斗力 88。悲剧出现了，因为只要你在学校，张三永远不可能参加国家赛了。

这个时候你欣喜若狂，因为再熬一年，如果新学弟战斗力没你高的话，你就能代表学校去参加国家赛了。很快一年又过去了，你终于熬到了高二，同时也迎来了一名新学弟张继科，战斗力 90。悲剧再次上演，你和张三一样，也失去了代表学校参赛的机会了，此时你的心情，是不是五味杂陈？

上面的几个校队名单呢，就是我们所谓的单调队列，如果把学生从高年级到低年级排列，随着时间的流逝，这本身就是一个队列结构，高年级的同学从队列头部毕业，低年级的同学从队列尾部进入。

而这个校队名单，记录的是最有可能代表学校参加比赛队员的名字。刘国梁毕业了，最有可能接班的是孔令辉，孔令辉毕业了，最有可能接班的是张三。而当你进入队列的那一刻，张三尽管比你入队早，但战力没你高，所以张三就永远失去了机会。后来张继科进入队列，你遭遇了和张三一样的悲剧。

如果你要是仔细观察校队名单，你会发现校队名单上，永远是按照能力值的从高到低，来记录学校里面的种子选手。这个名单，既有队列的样子，又有单调的性质，所以称为“单调队列”。

单调队列的作用，就是用来维护在队列处理顺序中的区间最大值。就像上面所说的校队名单，维护的就是区间长度为 3 时候的最大值。当一个新的元素入队的时候，它会把其前面违反单调性的元素，都从队列中踢掉，就像张继科的入学，把你踢出了校队名单，最终他成为了队列里的最大值。

滑动区间最大值

让我们回到开始的求“滑动窗口最大值”的任务。其实，滑动窗口每次向后滑动一位，会有一个元素从队首出队，同时也会有一个元素从队尾入队，所以滑动窗口的过程，就遵照了我们所谓的队列处理顺序。

而这个任务，本身就是求区间最大值的，所以也符合了单调队列应用的场景：维护在队列处理顺序中的区间最大值。下面呢，我们就来看一下具体代码：

#define MAX_N 1000
int q[MAX_N + 5], head, tail;
void interval_max_number(int *a, int n, int m) {
    head = tail = 0;
    for (int i = 0; i < n; i++) {
        // a[i] 入队，将违反单调性的从队列 q 中踢出
        while (head < tail && a[q[tail - 1]] < a[i]) tail--;
        q[tail++] = i; // i 入队
        // 判断队列头部元素是否出了窗口范围
        if (i - m == q[head]) head++;
        // 输出区间内最大值
        if (i + 1 >= m) {
            printf("interval(%d, %d)", i - m + 1, i);
            printf(" = %d\n", a[q[head]]);
        }
    }
    return ;
}

如代码所示，interval_max_number 函数，传入三个参数，数组首地址 a，元素数量 n 以及 区间长度 m。代码中的 q 数组，后续的作用就是模拟单调队列，head 与 tail 代表了队列的头尾下标，这里我们采用左闭右开式的表示方法，也就是 head 和 tail 所指示的区间范围内，包含 head 所指位置，但不包含 tail 所指位置。

函数内部，依次处理数组中的每个元素，每次处理相应元素的时候，涉及到两个过程：

第一个过程，是将当前元素入队。在入队之前，将队列尾部违反单调性的元素都从队列中踢出，这个就是第 7 行 while 过程的作用，之后就是将编号 i 入队即可。这里注意，单调队列里面，存储的是 a 数组的下标，而不是 a 数组的值。其实存储了下标，我们就可以索引到值，而在上一节二分查找的课里面，我们也见识过了，要是存储了值，想要反向索引下标是比较困难的。

第二个过程呢，就是判断单调队列头部的元素是否超出了窗口范围，也就是前面我们例子中你的学长毕业的过程，如果元素下标已经超出了窗口范围，就将队列头部元素出队。

这样我们就可以保证，我们每次输出的，就都是滑动窗口内部的区间最大值了。

课程小结

以上就是我们今天要学习的单调队列的内容，关于单调队列的知识，你在理解其处理过程的时候，更应该记住单调队列应用的场景：就是维护队列处理顺序中的区间最大值。

这个里面，需要重点强调一个队列处理顺序 。也就是说，如果你可以把一个问题的求解顺序，抽象成队列求解顺序，并且在这个过程中，你还需要维护区间最大值，那么翻出“单调队列”，准能帮助你大幅度提升处理速度！而单调队列，无论是入队，还是出队，操作完以后，一定要保证队列内部满足单调性，这就是开头我们说的：定义一种性质，并且维护这种性质。单调队列，维护的就是单调性。

最后，我们来简单说一下单调队列处理单个元素的平均时间复杂度为什么是 O(1) 的。假设我们要处理 n 个元素，从整体上来看，每个元素会入队列 1 次，出队列最多也是 1 次，那么 n 个元素的总操作次数不会超过 2×n 次，平均到一个元素上就是 2 次，也就是常数次，记作 O(1) 时间复杂度。由此得知，处理 n 个元素的总时间复杂度，就是 O(n)。

今天没有思考题，因为这节课的内容只是作为一个铺垫，下节课关于“栈”的知识才是重头戏。我也希望你对这节课的内容认真学习体会，可以的话，在留言区说说你的看法和思考。

好了，单调队列的知识，就讲到这里了，我是胡光，我们下期见。

22 | 栈与单调栈：最大矩形面积

你好，我是胡光，欢迎回来。

上节课我们讲了单调队列这种具有单调性的结构，并且说明了单调队列适合：维护队列处理顺序中的区间最大值，并且我还提到单调队列只是一个铺垫，搞清楚了单调队列的内容，才能更好地学习新的数据结构。

今天我将带你学习一种队列和单调队列的兄弟数据结构，它的性质也很有趣，就是：栈与单调栈。学习这个数据结构的时候呢，我还是要再次强调一下那句话：数据结构，就是定义一种性质，并且维护这种性质。

今日任务

在正式开始学习之前呢，先来看一下今天这 10 分钟的任务吧。

假设有一面木板墙，每块木板的宽度都是 1，你现在想在木板墙上，沿着平行于地面的方向，切割出一块矩形区域。问题来了，如果给出了每一块木板的高度，那么如何切出面积最大的矩形区域？矩形木板墙如下图所示：

图1： 木板墙示意图

如你所见，图中有 7 块木板，每块木板的高度分别为：2、1、4、5、1、3、3。经过尝试，我们发现最大矩形就是红色阴影部分所示，也就是切割了高度为 4 和 5 两块木板，形成了一个高度为 4，宽度为 2 的矩形区域，这个最大面积为 8。

显而易见的结论：就是切下来的最大的矩形，一定是以最大矩形所在区域最短那块木板作为其高度值。如果不是这样的话，我们就可以提升一点点高度，让切下来的部分更大一点儿。

有了如上这个结论，我们就可以枚举每一块木板，每次都以当前木板作为高度，就是把当前这块木板，当成是切出来的矩形区域中的最矮的木板，然后向左边和右边分别做延伸，切出此时的最大矩形区域。当把所有木板都试过一遍后，我们在所有枚举结果中比较出最大值，这个最大值就是我们要求的最大矩形面积。如果木板的个数为 n，那这种做法的时间复杂度接近于 O(n2)。

而今天，我要给你讲的方法，能将这个时间复杂度降低到 O(n)，这种结构方法就像我们上次讲的单调队列一样有趣。接下来，就让我们一点点地揭开这个结构神秘的面纱。

必知必会，查缺补漏

想要完成今天这个任务呢，你必须掌握接下来我要教给你的一个新的数据结构：单调栈。

1. 栈：维护一种完全包含关系的结构

首先让我们来认识一下最简单的栈结构，所谓栈结构，你可以想象成只有一个口的羽毛球桶，羽毛球只能从唯一的一个口放入和取出。我们把编号 1、2、3 三个羽毛球按顺序放进球桶后，如果想取出来，那么这些球被取出来的顺序一定是编号 3、2、1。也就是说后放入的羽毛球，在取出的时候，会最先被取出来，它们放入和取出的顺序是相反的。

如果说，上一节我们学习的队列结构是先进先出的结构，那么今天我们学习的栈就是一种后进先出的结构。栈和队列一样，都是计算机中，用来规范处理顺序的基础结构。

图2： 栈结构示意图

图中所示，入栈顺序分别是 蓝、绿、红，那么出栈顺序就一定是红、绿、蓝。图中每一个颜色的方块上标注的数字，就是每一个方块入栈及出栈的顺序。

从示意图中，我们还可以观察到一个有趣的事情，在顺序上而言，红色方块被绿色方块包裹着，绿色方块被蓝色方块包裹着。这种结构，像是程序的调用过程，如果把蓝色方块，看成是主函数的话，那么绿色方块就是主函数中调用的一个函数 A，红色方块就是 A 函数中调用的另外一个函数 B，三个函数调用的顺序是主函数、函数 A、函数 B。

而它们的执行结束顺序恰恰是相反的，首先是 函数 B 结束，然后是 函数 A 结束，最后是主函数结束。实际上，我们计算机用来维护函数执行的底层系统，就是用的这种栈结构。

你可以认为，栈结构本身维护的是一种完全包含关系。其实函数之间的运行，就是一种完全包含关系，只要在主函数中调用函数 B，那么函数 B 一定在主函数结束之前结束，这就可以视为是主函数包含函数 B。

2. 单调栈：维护最近大于或小于关系的结构

我们了解了最简单的栈结构以后，接下来，就来让我们学习一种栈的升级产物：单调栈。理解单调栈的最简单方法，就是基于对单调队列的理解去学习它。如果你单调队列还没有掌握，那我建议你再好好看一看上节课中关于单调队列的相关内容。

我先问你一个问题，队列结构和栈结构到底有什么区别？你可能会说，它们唯一的区别就是，队列是从一端进另外一端出，栈是在同一端进出。

那我再问你一个问题，堵住出口的单调队列，和栈有什么区别？你会发现，好像没什么区别了，单调队列为了维护单调性，在入队列的时候，也会将违反单调性的元素弹出，所以，这就相当于栈的从同一端进出。

好了，如果你明白这些问题，我可以明确地告诉你：堵住出口的单调队列，就是我们今天要学习的“单调栈”。

既然堵住了单调队列的出口，那么这种所谓单调栈的结构，就再也维护不了区间最大值了。那它维护的是什么呢？让我们以单调递减栈为例。

图3： 单调栈示意图

如图所示，这是我假设的一种单调栈中元素的情况：序列第 12 号元素入栈以后，单调栈中只剩下了 4 个元素，从栈底到栈顶，值分别为 23、18、15 和 9，分别对应了原序列的第 2 号、第 5 号，第 9 号 以及 第 12 号。

关于单调栈性质的思考，我们只需要重点关注栈顶的 12 号元素和 9 号元素之间的关系即可。如果 12 号元素入栈以后，为了保持栈中的单调递减性，它最终放在了 9 号元素上面，那说明什么呢？是不是说明从 9 号元素到 12 号之间的元素值，均小于 12 号元素值呢？也就是说，10 号、11 号这两个元素的值，我们虽然不知道具体是多少，可这两个元素的值，肯定比 9 号元素小，甚至也比 12 号元素小。否则，按照单调栈的入栈规则，12 号元素和 9 号元素就不可能在栈中相邻。

其实，说到这里，你应该已经对单调栈的性质有所感觉了。如果我们将一个元素压入单调递减栈，那么这个元素会落在离它最近，且比它大的元素上面。就像上面的例子中，当 12 号元素入栈以后，它落在了 9 号元素上面，说明从 12 号元素向前找，9 号元素是第一个比 12 号元素值大的元素。

如果说单调队列是维护区间最值的高效结构，单调栈就是维护最近大于或小于关系的高效结构。如果想要维护最近大于关系，就建立一个单调递减栈，然后将每个元素依次入栈，在这个过程中，我们就可以统计得到每一个元素之前离它最近的，且大于它的元素。那要是想维护最近小于关系呢？就建立一个单调递增栈就好了！

至此，我们就掌握了单调栈的基本性质了。

一起动手，搞事情

今天的思考题呢，跟括号匹配有关系。任务很简单，就是给你一串括号序列，括号序列中可能包含小括号 ()，中括号[] 或者 大括号{}，你需要写程序，判断这个括号序列是否合法。只要括号之间，没有交错重叠的情况，就是合法的括号序列。

下列给出了一些合法的序列的示例：

({})
[]([]){()}

下面是一些非法的括号序列的示例：

([)]
(((){}

通过观察括号序列，你会发现合法的括号序列，其实就是一种完全包含的结构，关于这种结构合法性的判断，和我们今天讲的栈结构有什么关系呢？开动你聪明的大脑，思考一下吧！

最大矩形面积

最后我们回到今天的任务，先来回顾一下之前所说的解题过程：我们通过枚举每一块木板作为切割出的木板墙的高度，每次都需要向左边和右边分别做查找，一直找到一块高度小于当前木板高度的位置，这样就确定了切割木板墙的长度。

以图 1 中高度为 4 的木板为例，我们通过向左延伸查找，发现左边第一块就比它短，这样就确定了向左延伸的长度是 0；往右延伸查找，发现第二块木板比它短，也就是向右延伸的长度是 1。说到这里，你会发现，上面这个过程，不就是我们之前所说的，维护最近小于关系么！只需要建立一个单调递增栈，就可以完成这个任务！

下面，是一份我给出的示例代码：

#define MAX_N 1000
#define max(a, b) ((a) > (b) ？ (a) : (b))
int s[MAX_N + 5], top;
int l[MAX_N + 5], r[MAX_N + 5];
int max_matrix_area(int *h, int n) {
    h[0] = h[n + 1] = -1;
    top = -1, s[++top] = 0;
    // 找到每一块木板，左边第一块比其矮的木板编号
    for (int i = 1; i <= n; i++) {
        while (top >= 0 && h[s[top]] >= h[i]) --top;
        l[i] = s[top];
        s[++top] = i;
    }
    // 找到每一块木板，右边第一块比其矮的木板编号
    top = -1, s[++top] = n + 1;
    for (int i = n; i >= 1; i--) {
        while (top >= 0 && h[s[top]] >= h[i]) --top;
        r[i] = s[top];
        s[++top] = i;
    }
    // 在所有木板中，找到面积最大的矩形
    int ans = 0;
    for (int i = 1; i <= n; i++) {
        ans = max(ans, (r[i] - l[r] - 1) * h[i]);
    }
    return ans;
}

如上代码所示，max_matrix_area 函数传入两个参数，木板高度数组首地址 h，和木板数量 n。代码中的 s 数组，后续的作用就是模拟单调栈，top 代表了栈顶元素的下标。

你需要注意的是，代码中假设木板的编号是从 1 到 n 的，然后，在数组的 0 位 及 n + 1 位分别加入两块高度为 -1 的虚拟木板，这是边界控制的一种技巧。也就是说，在每块木板向左搜索的时候，最远也就搜索到 0 号位就停止了，向右搜索的时候呢，最远搜索到 n + 1 位也就停止了。通过加入虚拟木板，代码中就少了相关的边界条件判断，这是一种很实用的技巧，你一定要理解和掌握。

课程小结

以上就是我们今天要学习的单调栈的内容，关于单调栈，其实你只需要对比着单调队列进行学习和记忆即可，记住以下两点：

单调栈是用来维护最近大于或小于关系的数据结构。

单调栈就是堵住出口的单调队列，所以其时间复杂度与单调队列一致，平均到每个处理元素上，都是 O(1) 的时间复杂度。

好了，单调栈的知识，就讲到这里了。我是胡光，我们下期见。

23 | 深入理解：容斥原理与递推算法

你好，我是胡光，欢迎回来。

上两节呢，我们学习了两个具有单调性的数据结构：单调队列和单调栈。其中，单调队列是用来维护滑动窗口内的区间最值的单调结构，单调栈是用来维护最近大于或小于关系的单调结构。这两种单调结构的均摊时间复杂度都是 O(1)，每个元素的操作次数最多是 2 次，足以看到这两种结构的高效。如果想彻底掌握这两种结构，我建议你在课下时间要不断地练习。

从今天开始呢，我们将从数据结构，跳跃到算法的学习上。将要带大家认识一类比较偏重于思维的算法，大类叫做递推算法，以及其中的一个重要的组成部分，动态规划类算法。

关于递推算法，在“语言基础篇”的第 5 篇文章《数组：一秒钟，定义 1000 个变量》中，我们其实就使用了递推算法的思想。如果你已经忘了的话，可以先回去看看，重新复习一下之前的内容，为今天的课程做好充足的准备。

今日任务

咱们今天这 10 分钟的任务呢，和钱有关系。众所周知，在不计算小于 1 元钱的面额的前提下，我国的纸币系统中，曾经拥有如下面值：1 元、2 元、5 元、10 元、20 元、50 元 和 100 元。假设，每一种面值的纸币，我们都有无限张，现在想用这些钱凑出 1000 元，请问你有多少种不同的方案做到？

这里说的不同方案，是不关注钱币之间的顺序的，例如要凑 7 元钱，可以是 1 元、5 元、1 元，也可以是 1 元、1 元、5 元，这两种方案我们视为同一种。

好了，面对这个问题，你要怎样进行解决呢？让我们开始今天的学习吧。

必知必会，查缺补漏

温故知新：容斥原理

在讲今天的递推问题之前呢，先来带你学习一个与递推思维相关的数学原理：容斥原理。

我们知道，一般在计数问题中，为了保证计数准确，必须注意两个问题：一是没有重复，二是没有遗漏。保证没有遗漏，这一点比较好做到，就像对某片地区采取地毯式轰炸，只要炸弹足够多，你可以很容易地保证，没有任何一个被漏掉的地方。可你要保证任何一片土地都仅被轰炸一次，这就很难做到。计数类问题往往也是这样的，想要保证没有遗漏的计数，比较简单，可要是想保证没有重复的计数，可能就困难那么一点儿了。

容斥原理就是为了解决计数类问题中的重复问题，其基本思想是：先不考虑重叠的情况，把包含于某内容中的所有对象的数目先计算出来，然后再把计数时重复计算的数目排斥出去，使得计算的结果既无遗漏又无重复。简单来说，就是在计算过程中，如果加多了，就把加多的部分减掉，如果发现又减多了，就再加回来一部分，一直到不多不少为止。

这么说，你可能还是有点儿懵。没关系，回想一下，我们之前在第 13 节课《程序设计原则：把计算过程交给计算机》中，提到的求 1000 以内，3 或者 5 倍数的所有数字和的问题。

原问题解决方法如图所示：

图1： 倍数问题的集合表示

当时，我们提出了基于等差数列求和的一种做法，就是用 3 的倍数，加上 5 的倍数，然而我们发现，有一部分加多了，就又减掉了 15 的倍数，这样才是我们真正想要求的值。你仔细想一想，这种做法本质上，其实就是容斥原理的一种体现。

关于理解容斥原理，你需要理解问题的集合思维，关于这点呢，我在下面还会带着你做详细的理解，和问题模型的对应。

一个递推算法例子：兔子繁殖问题

我们先从一个简单的问题开始，逐步熟悉容斥原理与递推算法。假设有一片草原上，莫名其妙来了一只外星兔子，这种外星兔子呢，第一个月的时候是幼体，第二个月成长为成体，从第三个月开始，成体兔子每个月都会产生出一只克隆体的幼体兔子，而且这种兔子不会衰老，一旦成体以后，就会一直生下去。按照这种情况，请你计算出第 n 个月，草原上有多少只兔子？

这里我给出了前 6 个月，草原上兔子数量的情况：

图2： 兔子前6个月繁殖情况

我们看到，从第 1 个月到第 6 个月，草原上兔子的数量分别是：1、1、2、3、5、8，我们主要来分析第 6 个月兔子的组成情况。

第 6 个月共有 8 只兔子，其中 5 只成兔，3 只幼兔。可以看到，之所以有 5 只成兔，是因为上个月总共有 5 只兔子，毕竟根据兔子的成长周期，不管它们是否成年，到下个月都会成长为成兔，所以第 6 个月的成兔数量等于第 5 个月的兔子总数。

而第 6 个月的另外的 3 只幼兔，是由第 5 个月的 3 只成兔产生的，根据前面的推论，我们知道第 5 个月的 3 只成兔，是源自第 4 个月的兔子总数。所以，第 6 个月的幼兔数量等于第 4 个月的兔子总数。

图3：第6个月的兔子数量与前两个月兔子数量关系

因此，我们可以得出这样的一个结论：从第 3 个月开始，第 n 个月的兔子总数，等于该月的成兔数量与幼兔数量之和，也就等于第 n - 1 个月的兔子数量，与第 n - 2 个月的兔子数量之和。

在这个兔子繁殖问题中，我们把当前月份的兔子分成两类，一类是成年兔子，一类是幼年兔子。这种分类方法，就保证了我们对两类分别统计的时候，它们之间没有交集，也就不需要考虑容斥原理中剔除重复部分的过程了。

之后，我们重点分析本月的成年兔子数量、幼年兔子数量与之前的哪个量有关系，最终得到了一个只使用某个月兔子数量，对本月兔子数量进行表示的数学关系。这种使用序列中的前项的值，来计算当前项值的做法，就是我们今天要讲的递推算法。

那么这个算法具体如何求解呢？下面我们就来看看递推问题的求解过程。

递推问题求解步骤

递推问题，通常分成三步进行求解。第一步，确定递推状态，也叫做状态定义；第二步，推导递推公式；最后一步，程序设计与编写。

接下来，我将用前面的兔子繁殖问题为例，说明递推问题求解的前两个步骤。最后一步的程序设计与编写，将作为作业题，留给你来完成。

1. 确定递推状态

所谓确定递推状态，就是确定一个有明确含义的数学符号，这里重要的是这个明确含义，而非那个数学符号。

什么意思呢？就以兔子繁殖问题为例，当我们分析完问题以后，就可以定义出具有明确语义的数学符号，例如：f(n) 。如果我们仅仅列出这么一个数学符号，其实是没有多大意义的，可是当我们定义了它的语义，即 f(n) 代表第 n 个月兔子的数量，这才算是完成了状态定义。因为只有确定了状态定义，我们才能进行下一步的递推公式推导。

说到这里，在继续讲下面递推公式推导之前，你可能会有疑问了：这个状态定义中的数学符号虽然不重要，可一般是怎么确定出来的呢？

那就不得不提到我一般思考这类问题过程的技巧了：我一般会把递推类问题，看成是初中的代数问题，先分析问题中的自变量和因变量。自变量，就是问题中那些不受控制的量，就像兔子繁殖问题中的月份。而因变量就是那些随自变量改变而改变的量，就像兔子繁殖问题中兔子的数量，是随着月份而改变的。

所以，我的技巧就是把和问题求解量相关的自变量，都作为数学符号中的参数，然后将相关问题求解量作为数学符号映射值的含义。就像我们刚刚所说的，f(n) 代表第 n 个月兔子的数量，在这个状态定义中，将问题求解量，也就是兔子数量，作为函数映射值的含义；而与问题求解量，即兔子数量相关的自变量只有一个，那就是月份，所以我们将月份作为函数的参数。

这样，我们就完成了状态定义。

2. 推导递推公式

接下来，就是递推问题求解的第二步了，推导递推公式。在推导递推公式的时候，这里需要用到前面我们定义的递推状态，并且，使用时一定要严格遵守递推状态的语义信息。

例如，在兔子繁殖问题中，如果你想用状态 f(n) 做公式推导的时候，那么 f(n - 1) 就代表了第 n - 1 个月兔子的数量，而 f(n - 2) 就代表第 n - 2 个月兔子的数量。这就是我刚刚所说的“要严格遵守递推状态的语义信息”的意思。

一般做递推公式推导的时候，我们主要思考的事情是，当前递推状态和前几项递推状态之间的关系。例如，在兔子繁殖问题中，当我们确定了递推状态 f(n) 以后，通过分析可以得到如下递推公式：

图4： 兔子繁殖问题递推公式

根据前面对兔子繁殖问题的分析，你应该很容易理解这个递推公式吧？就是前两个月（n=1,2），兔子的数量都是 1 只；到第三个月以及之后的月份（n>=3），本月的兔子数量等于上两个月的兔子数量之和。其中 f(n - 1) 等于本月中成兔的数量，f(n - 2) 实际代表的是 2 个月前的兔子数量，它也等于本月中幼兔的数量。套用集合的思想就是，成兔与幼兔这两部分互为补集，加在一起就正好等于全集。

细心的你肯定发现了，这个公式是不是在哪里见过？没错，在咱们的第 12 节课《数学归纳法：搞定循环与递归的钥匙》中，我们提到了菲波那契数列递推公式，就跟这个兔子繁殖公式一模一样。

一起动手，搞事情

请你来完成兔子繁殖问题的第三步：程序设计与编写。要求是用两种方式完成：

请使用循环的程序实现方式

请使用递归的程序实现方式

在你用递归实现了兔子繁殖问题的求解过程以后，我希望你可以计算下 80 个月后，兔子的数量。你的程序将发生一些奇奇怪怪的现象，试着自己去理解这个程序现象，并且想一想，如何解决掉出现的问题吧。

在这里呢，再跟你多说一句，在进行程序实践的时候，一定要注意总结我们之前讲过的数学归纳法和递推算法与程序之间的关系。

凑钱币问题

最后，让我们回到今天的任务，也就是用 1 元、2 元、5 元、10 元、20 元、50 元和 100 元凑成 1000 元钱，总共有多少种方案。

第一步，让我们来确定递推状态。确定递推状态之前，我们需要分析清楚题目中的自变量与因变量。因变量比较好分析，就是方案总数，那这个方案总数都受什么影响呢？很明显，是钱币的种类和拼凑目标金额。也就是说，钱币种类发生变化，方案总数就会发生变化；同理，如果拼凑的目标金额发生变化，方案总数也一定会发生变化。所以，自变量是 2 个，钱币种类和拼凑的钱币数量。因变量是 1 个，就是方案总数。

通过上面的分析，我们就可以列出状态定义：f(i, j) ，代表使用前 i 种钱币，拼凑 j 元钱的方案总数。例如，f[3][10] 就代表使用前 3 种钱币，也就是只使用 1 元、2 元、5 元，凑 10 元钱的方案总数。

第二步，就是用这个状态定义，进行递推公式推导，关键就是分析当前项与前几项的关系。核心思想其实就是容斥原理，也就是用某几项表示 f(i, j) ，如果发现这些表示 f(i, j) 的项之间存在交集，就将交集部分减去，如果减多了再加回来一些，直到正好表示 f(i, j) 为止。

好在这道题目还算是一道简单的递推问题，我们可以将 f(i, j) 划分成性质不同且互为补集的两部分。在 f(i, j) 所代表的所有方案中，一部分方案是使用了第 i 种钱币的，另外一部分方案中是没有使用第 i 种钱币的，我们就用这个性质，将 f(i, j) 表示成两项相加之和的形式。

例如，在用前三种钱币，拼凑 10 元钱的所有方案中，可以按照方案中是否使用第 3 种钱币，也就是是否使用了 5 元钱，将所有方案划分成两类。

其中一类方案不包含第 3 种钱币，也就是不用 5 元这个钱币，这些方案的数量，等价于使用前 2 种钱币拼凑 10 元钱的方案总数，也就是 f[2][10] 的值。另外一类方案中，使用了至少 1 张 5 块钱，那么我们可以在这些方案中，都拿掉一张 5 元钱，剩余的部分组成的方案数量，就等于 f[3][5]，也就是用前 3 种钱币凑 5 元钱的方案总数。

这样我们就推导出了递推公式：f[3][10] = f[2][10] + f[3][5]。

图5： 凑钱币问题示意图

回到我们的任务，就是在 f(i, j) 代表的所有方案中，没有使用第 i 种钱币，拼凑 j 元钱的方案数量，就是 f(i - 1, j)，代表使用前 i - 1 种钱币拼凑 j 元钱的方案总数。剩下的使用了第 i 种钱币的方案中，由于都存在第 i 种钱币至少 1 张，假设第 i 种钱币的面额是 val[i]，也就意味着，我们可以使用前 i 种钱币，凑 j - val[i] 的钱数，给第 i 种钱币留出一个位置，这么做所对应的方案总数就是 f(i, j - val[i])。

最终，我们推导出了递推公式：f(i, j) = f(i - 1, j) + f(i, j - val[i])。其中，边界条件是 f(1, k * val[1]) = 1，也就是用在只使用第 1 种钱币的条件下，想要凑第 1 种钱币的整数倍面额的方案总数都是 1。

至此，我们就完成了凑钱币问题的递推求解过程。最后，还剩一个程序实现，试着自己完成一下吧，加油！你可以的！

课程小结

最后呢，我们来总结一下今天的内容，今天的内容主要想让你记住三点：

递推问题第一步是要确定递推状态，也就是给出一个数学符号，以及数学符号的相关描述。

在设计递推状态的时候，主要分析自变量与因变量的关系，一般因变量都是问题求解的那个量。

递推问题的第二步是推导递推公式，而容斥原理的思想，对于这一步的求解，十分重要。

递推问题的求解过程，不是一朝一夕就能掌握的，今天的课程呢，只是让你拥有这种感觉，以及掌握求解递推问题的重要思考过程。我相信，只要你沿着今天讲的递推问题求解过程，去学习每一个递推问题，总有一天，你会对递推问题理解得更加透彻。

对于学有余力的小伙伴们，如果想更深入地了解一下容斥原理，可以通过学习：莫比乌斯函数、狄利克雷卷积与莫比乌斯反演等内容，进一步感受一下这个思想所绽放出的光芒。

好了，关于递推的知识今天就讲到这里了，我是胡光，咱们下期见。

24 | 动态规划（上）：只需四步，搞定动态规划算法设计

你好，我是胡光，欢迎回来。

上节课呢，我们学习了递推算法的一般求解步骤：先是定义递推状态，然后推导递推公式，最后是程序设计与实现。并且为了顺利完成递推算法，还介绍了在推导递推公式中的重要指导思想“容斥原理”的相关内容。

递推算法解决的主要类型问题之一，就是计数类问题。就像上节课我们提到的，求 n 个月以后的小兔子数量，求拼凑钱币的方法总数，还有更早之前学习的，求前 n 个数字二进制表示中 1 的个数，等等，这些都是计数类问题。

而在递推算法中，还有一类不同于计数类问题，它是求解最优化解的问题的算法，这类算法有一个专有名称，叫做：动态规划。这就是我们今天要学习的，递推算法中的一个子集算法，动态规划算法。

初识：数字三角形问题

想了解什么是动态规划算法，咱们得先从一个叫做“数字三角形”的简单的动态规划问题开始。数字三角形这个问题很简单，这里我给出了一个由数字组成的 6 层三角形，如下图所示：

图1： 数字三角形结构示意图

由上到下，第 i 层由 i 个数字组成，目标从第 1 层开始，每次只能向下走到相邻的两个节点，求走到最后一层路径上面数字的最大和值是多少。就像图中标红的一条线路，就是路径和值最大的一条路线，和值为 39。如果给你的是一个 n 层的数字三角形，你该如何解决这个问题呢？

从数学归纳法思想出发，如果我们已知到第三层所有点的最大值，那么我们就可以计算得到起始点到第四层每一个的路径最大和值。如图 2 所示：所有绿色节点和蓝色节点，就是已经求出来的，起始点到其路径最大和值的点。其中的数字是根据 图 1 中的数字三角形计算所得，比如第 2 层的 12，是由图 1 中第 1 层的 3 与原所在位置的 9，相加之和的结果。

图2： 数学归纳法求解示意图

从图 2 中可知，如果想求从起始点到红色的点，也就是第 4 行数字 9 点的路径最大和值，那么根据数字三角形的规则，我们只能从图中的两个蓝色点转移到红色点。那究竟选择从哪个点走到红色点呢？当然是选择其中和值较大的了，也就是从和值为 14 的点转移到红色点，得到的就是起始点到红色点的路径最大和值。

我们来总结一下上述这个过程，若我们已知从起始点到第 i - 1 层上每个点的路径最大和值，那我们又是怎么得到从起始点到第 i 层上每个点的路径最大和值呢？请看下图：

图3： 由第 i-1层推导第 i 层示意图

如图所示，我们给每一层的节点，从左向右，从 0 开始依次编号，那么第 i 行的第 3 个点对应的坐标就是 (i, 2) 点。从第 1 层的点想要到达红色 (i, 2) 点，可以通过 (i - 1, 1) 点到达，或者通过 (i - 1, 2) 点到达。在已知从起始点到第 i-1 层上每个点的路径最大和值的前提下，从第 1 层到 (i, 2) 点的最大和值，就是在 (i - 1, 1) 和 (i - 1, 2) 这两个值中，选择一个路径和值最大的，然后转移到 (i, 2) 点，即为第 1 层到 (i, 2) 点的路径最大和值。

所以，我们基本可以确定一件事情了，如果我们要是知道第 1 层 到 i - 1 层的每个点的路径最大和值，那就很容易求得到第 i 层每个点的路径最大和值，从而推导出 i + 1 层、i + 2 层等等的路径最大和值，直到最后一层。

又因为，我们已知第 1 层到第 1 层每一个点的路径最大和值，就是起始点原本的值，所以沿着上面这个思路，就可以按照层序，来求解第一层到每一层的每个节点的路径最大和值了。

仔细体会一下，上面这个题目的推导过程，有没有点儿我们前面说的数学归纳法思想以及递推算法的意思？你会发现，岂止是有点儿，简直如出一辙。这就是我们所说的，递推算法中那类求解最优化问题的方法，动态规划。

下面我们就正式来介绍一下动态规划问题的求解步骤。

动态规划算法的四步走

关于动态规划，也被简称为：DP(dynamic programming)，它的问题类型非常的庞杂。如果按照问题类型来进行划分，可以分成：线性 DP、区间 DP，树型 DP，数位 DP，概率 DP 等等。说到动态规划中的概念呢，又有什么：最优子结构，重叠子问题，无后效性等等。这些都是让新手听起来特别摸不到头脑的总结性词汇。

但是你也不用着急犯晕，我们知道，任何总结，都来源于观察。所以今天，我想让你掌握的，不是这些前人总结的词汇概念，而是一套观察、学习动态规划的方法。

这套方法分为四个步骤，它会使得你学习动态规划算法的过程事半功倍。如果你按照我的方法，进行了若干种动态规划问题学习以后，再找来一些其他资料，看看今天我跟你说的动态规划中的概念名词，你会对动态规划有一个更具体的理解。

那这个方法到底是哪四个步骤呢？其实就是：状态定义，状态转移方程，正确性证明，以及程序设计与实现。同时，它们也分别代表了学会一个动态规划问题的四个方面。

1. 状态定义

首先我们从状态定义讲起，提到状态定义，你应该不会陌生，上节课我们已经说过递推问题的确定递推状态，其实二者是一样的，都是一个有明确语义信息的数学符号。

理解一个动态归划问题的状态定义，是理解其解法的第一步，也是最重要的一步。如果你在往下进行推导的时候，发现进行不下去了，那往往就是状态定义有问题，这时你就需要回到这个第一步，琢磨琢磨新的状态定义了。

并且，我们一直在强调，对于动态规划的状态定义，不仅仅是要一个数学符号，还要一个明确的语义信息，你的理解可能是：不同的语义信息，对应的不就是不同的数学符号么？那今天，我们就用同一个数学符号，表示不同的语义信息，在接下来的求解过程中，你会发现这两种不同的语义信息，所衍生出来的后续步骤过程，是完全不同的。

回到前面说的数字三角形问题，我们可以作出两种状态定义：

第一种状态定义：dp[i][j] 代表从起始点，到 (i, j) 点的路径最大值。

第二种状态定义：dp[i][j] 代表从底边的某个点出发，到 (i, j) 点的路径最大值。

为了后续讲解方便，我们假设所有坐标都是从 1 开始的，也就是第一行第一个点的坐标是 (1, 1)。你会发现，这两种状态定义，数学符号都是 dp[i][j]，而含义却完全相反，一个是从顶向下走，一个是从底向上走。对于第一种状态定义，如果数字三角形有 n 层的话，问题所求的最大值，就是在最后一层 dp[n] 中的某个值。而第二种状态定义，问题所求的最大值最终会存储在 dp[1][1] 这个状态值中。

2. 状态转移方程

看完了数字三角形问题的两种状态定义以后，下面就来讲讲状态转移方程。动态规划的状态转移方程，其实就是递推问题中所说的递推公式，只是从名字上更符合动态规划问题的情况。

状态转移，就是状态之间的转移，每一个状态的含义，在状态定义中规定的明明白白，而状态与状态之间的转移方式，是需要根据具体的问题以及具体的状态定义，进行具体分析。

根据刚才作的两种状态定义，我们可以分别画出来这样两种状态转移的方向：

图4： 两种状态转移示意图

如图所示，我以左边是第一种状态定义下的状态转移方向为例，来说明它是如何转移的。首先，它是自上向下转移的，所以想要求得 dp[i][j] 的值，我们需要知道 dp[i - 1][j - 1] 和 dp[i - 1][j] 的值。因为按照“走向下个相邻两点”的规则，只有 (i - 1, j - 1) 和 (i - 1, j) 这两个点，才能能走到 (i, j)，也就是我们讲到的转移到 (i, j) 点。右边的第二种状态定义转移过程和左边的一样，只是移动方向不一样而已。

所以，根据两种状态定义，我们可以分别列出这两种状态转移方程：

第一种状态转移方程：dp[i][j] = max(dp[i - 1][j - 1], dp[i - 1][j]) + val[i][j]

第二种状态转移方程：dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + val[i][j]

两种转移方程，都是在能够转移到 (i, j) 点的状态值中选择一个较大值，再加上 (i, j) 原本的数值 val[i][j]，就是各自起始点到达 (i, j) 点的路径最大值，也就是两种状态定义下的 dp[i][j] 的值。

到这里，你可以看出，状态定义不一样，直接导致我们的状态转移方程就不一样。所以，虽然是相同的数学符号，定义的含义不同，就会造成后续的解法不同，同时也意味着解决问题的难度不同。

这也就是很多同学在一开始学习动态规划算法的时候，总喊着不明白状态转移方程，而我会告诉他们的是，你不是不明白状态转移方程，你是不明白状态定义。要解决一个动态规划问题，要从状态定义着手，要学习动态规划算法，也要从状态定义开始学起。

关于状态转移方程这里，我们再来讲一个转移方向的问题。根据数字三角形这个问题的两种状态转移方程，我们可知这代表了两种不同的状态转移方向：第一种是从第一层开始，计算出第二行的所有值，再计算出第三行所有值；而第二种状态转移方向与第一种正好相反。这里我们就要引出动态规划算法中一个最重要的概念“阶段”。

什么是“阶段”呢，可以这样说，状态转移就是从一个阶段转移到下一个阶段。像数字三角形问题中，在第一种转移方式中，起始点的第一层，就是整个转移的第一个阶段，第二层就是整个转移的第二个“阶段”，你会发现转移的时候，只有一个阶段计算完了，才能计算下一个阶段中的状态值。

而在第二种转移方式中，作为起始点的最后一层，才是我们转移的第一个阶段，然后依次由下向上转移，一个阶段接着下一个阶段。

弄清什么是阶段，对于接下来我们证明算法的正确性，有决定性作用。

3. 正确性证明

动态规划算法的第三步，就是证明你推导出的状态转移方程的正确性。关于状态转移方程的正确性证明，借助的就是之前学习中，我们提到过的程序设计中最重要的数学思维：数学归纳法。

根据数学归纳法的三步走，我们试着证明一下第一种状态转移方程是正确的，也就是自上而下的状态转移方式。

第一步，我们已知在这种状态转移方式中，第一个阶段中的所有 dp 值都可以轻松获得，也就是可以很轻松的初始化 dp[1][1] 的值，应该等于 val[1][1] 的值。

第二步，我们假设如果第 i-1 阶段中的所有状态值，我们都正确的得到了。也就是正确的得到了从起始点到 i-1 层中每个点的路径最大和值。那根据状态转移方程：dp[i][j] = max(dp[i - 1][j], dp[i - 1][j + 1]) + val[i][j] 来说，就可以正确的计算得到第 i 个阶段中的所有状态值。

第三步，两步联立，就可得出结论，所有阶段中的状态值计算均正确。那么，从起始点到底边的路径最大和值，就在最后一个阶段的若干个状态值中。

以上就是我们使用数学归纳法，证明数字三角形问题的第一种状态转移方程正确性的过程。这个过程呢，比较简单，那是因为数字三角形问题本身就不难。当面对更难一些的动态规划问题的时候，将这种证明方法，加入到你学习动态规划算法的过程中，你会收获奇效的。

4. 程序设计与实现

动态规划解题的最后一步，就是程序的设计与实现了。关于数字三角形问题的两种解题方法的代码实现，就作为今天给你留的课后作业题了。

在上一篇递推算法的作业题中，你应该体会到了，对于同样的递推公式，我们不仅可以用循环实现，还可以用递归实现。今天的这两种状态定义方法呢，我只要求你用循环的程序方式实现即可。

当然，我还希望，在你实现出了这两种状态定义方法的程序以后，可以从程序的角度，对两种方法加以评价，并在留言区说说它们的优点和缺点。

课程小结

至此，我们就说完了动态规划算法的完整解题步骤，关于今天的课程呢，希望你记住如下几点：

状态定义，是动态规划算法的重点，无论是解题还是学习，都要从这一步开始。

不同的状态定义，决定了不同的状态转移方程，同时也可能代表了不同的解题难度，所以，学习如何定义优秀的状态很重要。

动态规划中的状态转移顺序，是建立在“阶段”概念之上的，只有本阶段的状态值计算完了，下一个阶段的状态值才能得以计算。

数学归纳法，是证明动态规划状态转移方程正确性的利器，掌握了它，会让你的动态规划学习过程事半功倍！

好了，关于动态规划算法，今天我们就先讲到这里。下一期我们将会使用这两期文章中学习到的技巧，来学习一个稍微有点儿难度的动态规划问题，也算是对我们近期学习效果的一个验证。

再好好看看这两期的内容吧，我是胡光，你要准备好，我们下期见。

25 | 动态规划（下）：背包问题与动态规划算法优化

你好，我是胡光，欢迎回来。

上节课呢，我们学习了动态规划算法的一般求解步骤：状态定义，推导状态转移方程、正确性证明，以及程序设计与实现。在这个过程中，我们又一次用到了之前我们讲的重要数学思维：数学归纳法 。

今天这节课，将是我们“算法数据结构篇”的最后一篇，其实从这个章节开始，我都在试图用我的语言和有序的课程设计，让你感受算法数据结构在思维层面的魅力。我还希望，你能通过这部分知识的学习，能对算法和数据结构产生兴趣，并且消除对算法学习的畏难心理。

好了，进入正题，今天我将借由动态规划算法，向你展示算法中追求极致的那一部分基因：算法优化。

初识：0/1 背包问题

想要感受算法优化，我们先从一类经典的动态规划问题，背包类问题开始。

简单的背包类问题，可以分成三类：0/1 背包问题，完全背包问题与多重背包问题。我们今天要讲的就是 0/1 背包与多重背包这两个问题。

0/1 背包问题可以说是所有背包问题的基础，它描述的场景是这样的：假设你有一个背包，载重上限是 W，你面前有 n 个物品，第 i 个物品的重量是 wi，价值是 vi，那么，在不超过背包重量上限的前提下，你能获得的最大物品价值总和是多少？

按照动态规划问题的四步走，咱们来分析一下这个问题。

1. 状态定义

关于状态定义，我们首先来分析 0/1 背包问题中的自变量和因变量。

因变量比较好确定，就是问题中所求的最大价值总和。自变量呢？经过分析你会发现，物品种类和背包承重上限就是自变量，因为它们都能够影响价值总和的最大值。这样我们就可以设置一个二维的状态，状态定义如下：

0/1 背包状态定义：dp[i][j] 代表使用前 i 个物品，背包最大载重为 j 的情况下的最大价值总和。

2. 推导状态转移方程

推导状态转移方程，也就是推导 dp[i][j] 的表达式。根据 dp[i][j] 的含义，我们可以将 dp[i][j] 可能达到最大值时的方案分成两类：一类是方案中不选择第 i 个物品的最大价值和，另一类是方案中选择了第 i 个物品的最大价值和。只需要在这两类方案的最大值中，选择一个价值和较大的方案，转移到 dp[i][j] 即可。下面，我们就分别表示一下这两种方案的公式。

不选择第 i 个物品的最大价值和，就是 dp[i - 1][j]。也就是说，在背包最大载重为 j 的情况下，前 i 个物品中，不选择第 i 个物品的最大价值和，就等于在前 i - 1 个物品中选择的最大价值和。

选择第 i 个物品的最大价值和就是 dp[i - 1][j - wi] + vi。关于这个公式的理解，可以参考我们前面讲的凑钱币问题，既然要求一定选择了第 i 个物品，那我们就可以先给第 i 个物品预留出来一个位置，然后给剩余的 i - 1 个物品留的载重空间就只剩下 j - wi了，那么 i - 1 个物品选择的最大价值和是 dp[i - 1][j - wi]，再加上 vi就是选择第 i 个物品时，我们能够获得最大价值和。

最终，我们得到 dp[i][j] 的状态转移方程，如下所示：

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])

3.  正确性证明

动规算法的正确性证明，还是需要依赖于数学归纳法，下面我们开始数学归纳法的三步走。

首先，dp[0][j] = 0，就是当没有物品的时候，无论背包限重是多少，能得到的最大价值和都是 0，这也就是已知 k0正确。

其次，假设我们已经正确计算得到了，在 i - 1 个物品的任意一种背包容量下的价值最大和值，也就是所有 dp[i - 1] 中的值。那么根据状态转移方程，我们也肯定可以正确的得到所有 dp[i] 中的值。

最后两步联立，整个求解过程对于任意 dp[i][j]，均正确。

请你认真理解这个证明过程，因为接下来的程序处理过程，其实和这个证明过程是一致的。

4. 程序设计与实现

完成了关于 0/1 背包问题的求解过程后，最后我们来看看程序的设计与实现。下面呢，是我给出的一段参考代码：

#define MAX_N 100
#define MAX_V 10000
int v[MAX_N + 5], w[MAX_N + 5];
int dp[MAX_N + 5][MAX_V + 5];

int get_dp(int n, int W) {
    // 初始化 dp[0] 阶段
    for (int i = 0; i <= W; i++) dp[0][i] = 0;
    // 假设 dp[i - 1] 成立，计算得到 dp[i]
    // 状态转移过程，i 代表物品，j 代表背包限重
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            // 不选择第 i 种物品时的最大值
            dp[i][j] = dp[i - 1][j];
            // 与选择第 i 种物品的最大值作比较，并更新
            if (j >= w[i] && dp[i][j] < dp[i - 1][j - w[i]] + v[i]) {
                dp[i][j] = dp[i - 1][j - w[i]] + v[i];
            }
        }
    }
    return dp[n][W];
}

可以看到，get_dp 函数就是求解 0/1 背包问题的过程，函数传入两个整形参数 n 和 W，分别代表了物品数量与背包最大限重。程序中有三个数组：v、w 与 dp，v[i] 代表第 i 个物品的价值，w[i]代表第 i 个物品的重量，dp[i][j] 代表背包问题相关的状态。

这一段代码，采用了正向递推的程序实现。而且，如果你注意观察 get_dp 函数的实现过程，你会惊奇地发现，这就是数学归纳法的证明过程。

首先，初始化 dp[0] 阶段的所有值，也就是保证了 k0成立；然后从 dp[1] 开始迭代计算到 dp[n] 中所有值，每一次 dp[i]依赖的就是 dp[i - 1] 中的值，只有 dp[i - 1] 中所有值是正确的，才能保证 dp[i]中所有值是正确的，这就是数学归纳法的第二步。最后，两步联立，就证明了以 dp 数组的第一维作为阶段，进行状态转移，计算得到的所有 dp 值均是正确的。

面对这种更加广义的数学归纳法，我们也有一个名称，叫做“结构归纳法”。

进阶：多重背包问题

接下来我们提高一下问题的复杂度，说说多重背包问题。

其实这个问题，整体和 0/1 背包问题类似，只不过从 n 个物品变成了 n 种物品，且每种物品都有不同的数量，我们可以设定第 i 种物品的数量是 ci。

现在你有一个载重上限为 15kg 的背包，有如下 4 件物品：

镀金极客币，每个 4kg，每个价值 10 块钱，一共有 5 个；

胡船长手办，每个 3kg ，每个价值 7 块钱，一共有 4 个；

西瓜，每个 12kg ，每个价值 12 块钱，一共有 2 个；

哈密瓜，每个 9kg ，每个价值 8 块钱，一共有 7 个。

经过分析，在不超过背包载重上限的情况下，你可以选择 3 个镀金极客币和 1 个胡船长手办装到背包里面，这种选择方案能获得最大价值为：37 块钱。

回到我们说的这个多重背包问题，你想如何求解呢？

其实最简单的解决办法，就是把 n 种物品中的每一个，都看成是 0/1 背包中的一个物品，然后按照 0/1 背包问题的求解过程来做即可。这也就是说，如果一种物品有 12 件，就相当于 0/1 背包中多了 12 件物品，我们就多做 12 轮运算，要是有 120 件呢，那就是多做 120 轮运算。

这种做法虽然可行，可显然太浪费我们计算机的计算资源了。下面就让我们看看怎么做，才能更优化。

1. 二进制拆分法

我们先来想象另外一个场景，假设你是一个卖白菜的老农，手上有 23 斤白菜和若干个筐，出于某种不知名的原因，你今天不能把称重器带到菜市场，只能提前把白菜称好装入不同的筐里贩卖给顾客。问题来了，白菜要如何分到这些筐里面，才能使得第一个顾客无论要多少斤白菜，你都能通过挑选其中的几筐白菜，从而满足顾客的需求呢？

一种最直接的装筐方法，就是每个筐里面装 1 斤白菜，共需要 23 个筐。这样，第一个顾客要多少斤白菜，你就给他多少筐就行。这种方法简单粗暴，可是用的筐太多了。

我们转换一个思路去想这件事：当你准备挑几个筐满足第一个顾客需求的时候，对于每个筐来说，都有两种状态，选或者不选，这不就是二进制每位上的数字么？我们就可以把每个筐，看成是二进制相应的位权。

图1： 二进制拆分法分白菜

可以看到，从第一个筐开始，我们依次装上 1 斤、2 斤、4 斤、8 斤，第五个筐应该装 16 斤的，可剩下的白菜不够 16 斤，所以就一起放到最后一个筐里面。这样，我们只需要 5 个筐，就装了 23 斤白菜，并且可以保证无论第一个客人要几斤白菜，都能满足他的需求。

以上，就是我讲的二进制拆分法。

2. 多重背包的拆分优化

看完二进制拆分法以后，我们再看看多重背包转 0/1 背包的这种解题思路有什么问题。

假设多重背包中，某一种物品有 23 件，转换到 0/1 背包问题中，就是 23 个物品，就跟前面一斤白菜装一筐的做法是一样的。我们虽然不知道，在 0/1 背包问题的最优方案中，这种物品被具体选择了多少件，可是只要我们通过一种合理的拆分方法，使得无论最优方案中选择了多少件这种商品，我们都可以组合出来。

简单粗暴地拆分成 23 份，是一种拆分方法，而二进制拆分法也是一种拆分方法，并且二进制拆分法只需要拆成 5 份物品，作为 0/1 背包问题中的 5 个单独的物品即可，这么做可以达到和拆分成 23 件物品等价的效果，并且节省了大量的计算资源。

例如，前面多重背包问题的那个例子中，按照原本简单粗暴的方式，我们是把 5 个镀金极客币、4 个胡船长手办、2 个西瓜、7 个哈密瓜，当作 18 个物品的 0/1 背包问题来求解的。但如果采用二进制拆分法，我们就会得到如下拆分方案：

1个镀金极客币，4kg每个，价值 10 块钱
2个镀金极客币，8kg每个，价值 20 块钱
2个镀金极客币，8kg每个，价值 20 块钱

1个胡船长手办，3kg每个，价值 7 块钱
2个胡船长手办，6kg每个，价值 14 块钱
1个胡船长手办，3kg每个，价值 7 块钱

1个西瓜，12kg每个，价值 12 块钱
1个西瓜，12kg每个，价值 12 块钱

1个哈密瓜，9kg每个，价值 8 块钱
2个哈密瓜，18kg每个，价值 16 块钱
4个哈密瓜，36kg每个，价值 32

这种拆分方案等价于求解 11 个物品的 0/1 背包问题，比之前求解的 18 个物品的 0/1 背包问题显然要优秀。

实际上，随着某个物品数量的增加，二进制拆分法的优势会愈加地明显。想一想 32 个二进制位能表示的数字大小，你就明白了。

3. 程序设计与实现

关于多重背包优化版本的程序，给你留作一个作业题，请你根据本节所讲的内容，用二进制拆分法实现多重背包的优化程序。相信你没问题的！

课程小结

按照惯例，最后我来做一下这节课的知识点总结：

0/1 背包问题中的自变量是物品的种类和背包限重，所以我们把这两维设计到了状态定义中。

多重背包问题可以转换成 0/1 背包 进行求解，转换过程不同，效率也就不同。

二进制拆分法，本质思想就是二进制的数字表示法，0/1 表示两种状态，表示选或不选。

好了，“算法数据结构篇”到这里就结束了，日后若有机会，我希望跟你分享更多编程中的美妙思维过程。

我是胡光，我们最后一章见。

做好闭环（四）：二分答案算法的代码统一结构

你好，我是胡光。

不知不觉，我们已经讲完了“算法数据结构篇”的全部内容。说是“讲完”，其实更意味着你的算法数据结构的学习之路才刚刚开始，因为编程的核心与灵魂就是算法和数据结构。但这毕竟是一个入门课，所以，整个这部分的内容，我更多是侧重说说那些你可能比较陌生的，且有趣的思维与结构。

我希望通过这个过程，能够激起你对于算法数据结构的学习热情。时至今日，我相信你应该更能深刻地理解我在开篇词里说到的，“学编程，不等于学语言“这句话的含义。

我也非常高兴，看到很多同学都在紧跟着专栏更新节奏，坚持学习。经常在专栏上线的第一时间，这些同学就给我留言，提出自己的疑惑。大部分留言，我都在相对应的课程中回复过了，而对于每节课中的思考题呢，由于要给你充足的思考时间，所以我选择在今天这样一节课中，给你进行一一的解答。

看一看我的参考答案，和你的思考结果之间，有什么不同吧。也欢迎你在留言区中，给出一些你感兴趣的题目的思考结果，我希望我们能在这个过程中，碰撞出更多智慧的火花。

重新认识数据结构（上）：初识链表结构

在这一节里，我们学习了基本的链表结构，并且演示了链表结构的插入操作。最后呢，给你留了一个题目，就是实现链表的删除操作。留言区中很多人实现的代码，我也都看过了，总的来说，很多用户对“虚拟头结点”的掌握还是很不错的，下面是我给出的参考代码：

struct Node *erase(strcut Node *head, int ind) {
    struct Node ret, *p = &ret, *q;
    ret.next = head;
    while (ind--) p = p->next;
    q = p->next;
    p->next = p->next->next;
    return ret.next;
}

由于删除操作，有可能删除的是 head 所指向链表的头结点，所以代码中使用了虚拟头结点的技巧来实现。其中，细心的你可能会发现一个致命的问题：删除节点的操作中，我们只是改变了链表节点的指向关系，跳过了那个待删除节点的位置，那原先那个待删除节点呢？这个节点的空间呢？

这就涉及到操作系统中的内存管理相关的知识了，由于这里不影响编程逻辑的理解，所以，我们就不展开说了。如果你感兴趣，可以自行搜索：内存泄漏、malloc、free 等关键字进行学习。

重新认识数据结构（下）：有趣的 “链表” 思维

这一节是上一节链表知识的升华，我们将一个快乐数序列，在思维层面映射成了链表结构，之后就将快乐数的判定问题，很自然的转换成了链表判环问题，算是彻彻底底的体验了一把链表思维。最后呢，我留了两个思考题，下面我给你一一解答。

1. 计算环的长度

第一个问题，如果链表中有环的话，那么这个环的长度是多少？这个问题比较简单，我看到留言区中很多用户都能自行想出来，在这里我就简单说一说。

我们可以肯定，如果链表中有环，那么采用快慢指针的方法，两个指针一定会在环中相遇。此时，可以让其中一个指针不动，另外一个指针再沿着环走一圈，直到两个指针再次相遇，这样，就能得到环的长度了。

2. 找到环的起始位置

第二个问题，如果链表中有环，请求出环的起始点。如下图所示，环的起始点为 3 号点。

图1： 链表成环示意图

这里呢，我将用图跟你感性地解答这个问题，请你注意，以下我所要讲的不是一个严格的证明过程，如果想要更准确地理解这个问题，你可以试着借助“同余式”来理解。下面，就开始我们的非严谨图例演示。

首先，假设从链表起始点到环的起点距离为 x，那么当快慢指针中的慢指针 p 刚刚走到环的起始点位置的时候，q 指针应该在环内部距离环起始点 x 的位置上，如图所示：

图2： p 节点刚刚进入环时刻

图中，q 指针距离环起始点 x 步，q 指针沿着链表向前走 y 步，就又可以到达环的起始点位置，如图所示 x + y 等于环长。也就是说，q 指针想要遇到 p 指针，就必须要追上 y 步的距离，又因为 p 指针每次走 1 步，q 指针每轮走 2 步，所以 q 指针每轮追上 1 步，也就是说，从此刻开始，当 q 指针追上 p 指针的时候，p 指针正好向前走了 y 步，如图所示：

图3： p、q环中相遇时刻

此时，你会发现 p 点在环中走了 y 步以后，p 和 q 相遇了，也就意味着 p 点再走 x 步就到环的起始点了。而恰巧，从链表头结点开始到环的起始点也是 x 步，所以此时只需要让 p 站在相遇点，q 指针回到链表的起始点，然后两个指针以相同的速度，一起往后走，直到二者再次相遇的时候，相遇点就是环的起始点了。

至此，我们就看完了求解环起始点位置的方法，至于代码么，就不是难题了，你可以自行发挥了。

二分查找：提升程序的查找效率

这一节中呢，我们学习了简单的二分查找算法，由此我们引申出了二分答案的相关算法。二分答案算法的应用场景呢，就是有一个函数 f(x) = y，如果它具有单调性，并且通过 x 求 y 很好求，而通过 y 确定 x 就很麻烦，这时，二分答案算法就该登场了。

最后的思考题中呢，是一道通过税后工资，计算税前工资的题目。我们可以发现，根据个人所得税缴纳的规则，肯定是税前工资越高，税后工资就越高，所以我们把税前工资 x 和税后工资 y 之间，看成是一个映射关系 f 的话，那么 f(x) = y 的函数，就是单调函数。

而这个 f 函数呢，我们也可以看到，通过税前工资 x 确定税后工资 y 的过程很简单，而通过税后工资 y 计算税前工资 x 的过程就不那么容易了。因此，我们当然要搬出二分答案算法，来解决这个问题了。下面是我给出的示例代码：

#define EPS 1e-7
#define min(a, b) ((a) < (b) ? (a) : (b))

double f(double x) {
    double xx = min(3000, x) * 0.03;
    if (x > 3000) {
        xx += (min(12000, x) - 3000) * 0.1;
    }
    if (x > 12000) {
        xx += (min(25000, x) - 12000) * 0.2;
    }
    if (xx > 25000) {
        xx += (min(35000, x) - 25000) * 0.25;
    }
    return x - xx;
}

double binary_search(double l, double r, double y) {
    if (r - l <= EPS) return l;
    double mid = (l + r) / 2.0;
    if (f(mid) < y) return binary_search(mid, r, y);
    return binary_search(l, mid, y);
}

你会发现，代码中的 binary_search 函数，和我们那一讲中所给的切绳子问题的代码几乎一模一样，唯一不同的就是 f 函数换了样子。

其实对于二分答案的算法实现，代码真的不是什么难点，难点在于发现问题可以采用二分算法的过程。也就是看到那两条性质判断：f(x)=y 是不是具有单调性；是不是通过 x 求 y 比较容易，通过 y 求 x 比较困难。

栈与单调栈：最大矩形面积

本节呢，我们学习了栈和单调栈的基本知识，并且知道了单调栈是用来维护最近大于或小于关系的数据结构。最后的思考题呢，是判断一个括号序列是否是合法的，所谓合法的括号序列，也就是括号之间要么是完全包含，要么是并列无关。

根据栈的基础知识，如果我们把一个元素入栈动作看成是左括号，出栈看成是对应的右括号，那么一组元素的入栈及出栈操作，就可以唯一对应到一个合法的括号序列。例如，如下操作序列：

  1    2   3   4    5    6   7    8   9  10
push push pop pop push push pop push pop pop

其中 push 是入栈操作，pop 是出栈操作。显然，3 号的 pop 操作，弹出的应该是 2 号 push 进去的元素，也就是 2 号和 3 号操作是一对操作。那么把 push 写成左括号，pop 写成右括号，如上操作序列，就可以对应如下的括号序列：

【（）】{【】【】}

你会发现，相对应的左右括号，就对应了相匹配的 push 和 pop 操作。那么判断一个括号序列是否合法，就可以把这个括号序列看成是一组入栈和出栈操作。

我们依次处理括号序列中的每一位，碰到左括号就入栈；碰到右括号，我们就弹出栈顶的一个左括号，看看是否和当前右括号是匹配的，如果不匹配就说明括号序列非法，如果匹配，就继续处理下一个括号序列中的字符。直到最后，如果栈中为空，就说明原括号序列合法。

好了，至此我们就讲完了这道题目的解题思路，接下来就是请你把我说的解题思路，转换成代码了，加油！如果实在想不出来，也可以参考用户 @胖胖胖 、@Hunter Liu 在留言区中的代码和解题思路。

动态规划（下）：动态规划之背包问题与优化

在这一节课，我们认识了背包类动态规划算法，讲了 0/1 背包问题，以及多重背包问题转 0/1 背包问题的转换技巧。其中我们提到了用二进制拆分法对多重背包拆分过程进行优化，这样不但可以大大减少拆分出来的物品数量，并且还不影响转换成等价的 0/1 背包问题。

关于动态规划状态定义的相关理解，这里给用户 @徐洲更 点赞，大家可以在《动态规划（上）：只需四步，搞定动态规划算法设计》当中看到他的留言。

下面呢，我就给出多重背包转 0/1 背包的示例代码：

#define MAX_N 100
#define MAX_W 10000
int v[MAX_N + 5], w[MAX_N + 5], c[MAX_N + 5];
int v1[MAX_N + 5], w1[MAX_N + 5], n2 = 0;
int dp[MAX_N + 5][MAX_W + 5];

// 添加一个0/1背包中的物品
void add_item(int v_value, int w_value) {
    n2++;
    v1[n2] = v_value;
    w1[n2] = w_value;
    return ;
}

int get_dp(int n, int W) {
    // 对多重背包中的每一种物品进行拆分
    for (int i = 1; i <= n; i++) {
        // 采用二进制拆分法
        for (int k = 1; k <= c[i]; c[i] -= k, k <<= 1) {
            add_item(k * v[i], k * w[i]);
        }
        if (c[i]) add_item(c[i] * v[i], c[i] * w[i]);
    }
    // 按照0/1背包的方式进行求解
    for (int i = 1; i <= n2; i++) {
        for (int j = 1; j <= W; j++) {
            dp[i][j] = dp[i - 1][j];
            if (j < w1[i]) continue;
            if (dp[i - 1][j - w1[i]] + v1[i] < dp[i][j]) continue;
            dp[i][j] = dp[i - 1][j - w1[i]] + v1[i];
        }
    }
    return 0;
}

代码中，v、w、c 数组存储的是多重背包中第 i 种物品的价值、重量和数量，v1、w1 数组用来存储拆分出来的 0/1 背包中的物品价值和重量信息，get_dp 函数就是求解多重背包问题的过程。

其中，分成两步进行求解，首先对多重背包中的每种物品，按照二进制拆分法，打包拆分成 0/1 背包中的若干个物品。拆分完成后，再按照 0/1 背包的算法流程进行求解，需要注意的是，代码中的循环变量 k，枚举的就是拆分的每一堆的物品数量，从数量 1 开始，每次扩大一倍。

对于多重背包中的每种物品，经过二进制拆分以后，最后剩下的几个，要单独算作一个物品，这就是代码第 22 行的含义。理解了二进制拆分的过程以后，后面的 0/1 背包的求解过程，就不需要我来解释了，都是老生常谈了。

好了，今天的思考题答疑就结束了，如果你还有什么不清楚，或者有更好的想法，欢迎告诉我，我们留言区见！

26 | 牛刀小试（上）：实现测试框架前的基础准备

你好，我是胡光。经历了千难万险，我们终于来到了这次编程探险旅程的最后一个阶段“综合项目篇”。

还记得开篇的时候，我跟你讲过的，要带你看看这片编程森林中最有趣的地方，不知道你感受到这份乐趣了吗？接下来的最后几节课，是我为你精心准备的，作为你编程入门学习之行的完成礼物，同时，也是对整个专栏学习内容的一个总结升华。

对于本章的学习，你需要综合运用“语言基础篇”“编码能力训练篇”以及“算法数据结构篇”中所学知识，完成两个小项目，一个是“个人测试框架开发”，另一个是“自制简易计算器”。我希望通过这两个项目，打通你编程学习的“任督二脉”，让你在后续的其他编程知识学习中，可以游刃有余，乐在其中。

软件开发流程

在开始做项目之前呢，让我们先来了解下一般的项目开发流程。

拿传统的软件工程开发流程来说，一个软件的项目开发流程，包括：需求分析、概要设计、详细设计、编码、测试、软件交付、验收和维护。虽然包括了 8 个阶段，可总的来说，你可以把它总结为软件开发的前中后三个部分。

软件开发的前期，由产品经理、项目经理跟进需求，做需求分析，然后是概要设计，出一份系统的详细设计。这是项目的头部阶段，主要是从理论上验证需求可行，并且最终产出一份切实可行的软件系统的详细设计。就像我在咱们每节课中，给你留思考题，此时我的角色就是项目经理和产品经理，我首先需要分析给你留的思考题是否可做，以及跟你说明白，大致怎么做。

软件开发的中期，是由技术人员负责，主要是根据系统的详细设计，进行编码和测试，把图纸上的系统实现出来。你也可以认为这是一个施工的过程。这个过程，类比到咱们的思考题上，就是你根据我的提示，具体完成每一道思考题的程序，并且对完成的程序做简单测试，以保证程序的正确性。

软件开发的后期，由项目经理带领技术支持人员做软件交付、验收及后期维护的相关工作。这一部分，你可以理解为，是你交作业的过程，客户就是老师，你的作业要是不合格，老师就会给你退回去并提出修改建议。

从这个过程中，你可以看到作为技术人员，与我们相关的，就是软件开发的中期阶段，也就是编码和测试。

在之前的学习中，我们把学习任务主要放在了编码阶段，一直没有提测试阶段的事情，下面我们就来说说测试阶段主要做的事情。

黑盒测试与白盒测试

关于测试阶段的测试方法，可以大致分为五种：白盒测试，黑盒测试，灰盒测试，静态测试与动态测试。

看着这些测试方法的名称，你可能有点儿懵，其实你完全没有必要掌握全部的测试方法，只需要了解其中的黑盒测试和白盒测试就行，知道了这两种测试方法，就可以满足你对项目开发流程的概念认知。

我们先来说说白盒测试与黑盒测试都是什么意思。首先名字里面的“盒”，其实指的就是项目中的系统，你可以理解成为是我们写的程序，它也是被测试的对象。至于这个白与黑呢，意思就是在测试过程中，是否关注代码实现逻辑。

白盒测试，就是关注代码实现逻辑，从而产生的测试行为。这种行为就像你把一个盒子打开，仔细检查其内部有无错误一样。

而不关注代码实现逻辑，而产生的测试行为，就是黑盒测试。在这个测试过程中，我们只关注系统的输出是否满足我们的要求。

下面我来举几个具体的例子，你来分析一下哪一种是白盒测试，哪一种是黑盒测试。

场景一：你写完了一个程序，运行以后输入数据，测试程序的输出结果是否符合预期。

场景二：当你发现，程序的结果不符合预期的时候，开始检查你程序的代码逻辑，并且针对于每一个函数功能做测试。

在场景一中，虽然程序是你写的，可你在做测试的时候，并没有关心程序内部的代码逻辑，而是关注整体程序的功能是否符合预期，所以这个属于黑盒测试。

而在场景二中，你在测试程序的时候，关注到了程序内部的代码逻辑是否正确，并且针对代码中的函数，开始做针对性测试。由于这个测试过程关注到了代码本身的逻辑，而不单单是程序功能本身，所以，这属于白盒测试。

通过这两个日常写程序的场景，你会发现，其实测试行为对于我们来说并不陌生，测试就是为了保证程序功能的正确性的，而所谓的黑盒测试或者白盒测试，并没有优劣之分。在实际工作中，我们也会经常综合运用这两种测试，来查找程序中存在的潜在问题。

总的来说，想要保证程序的正确性，必然会涉及到运用相关的测试方法。所以请你记住，测试并不是测试人员的专属，很多时候也是开发人员需要掌握的技能。

对于开发人员来说，最基本的测试方法，就是针对自己程序中的每一个功能模块，编写对应的单元测试，而单元测试中的单元，是程序中最小的测试单位。例如 C 语言中，一个单元就是一个函数，C++ 中的一个单元，就是一个类。

所以，我们说到 C 语言的单元测试，指的是对每一个函数，编写一段测试程序。而可以辅助开发人员编写这些单元测试的程序，我们叫做“单元测试框架”，也可以简称为“测试框架”。

初识：Google 测试框架

好了，关于软件开发流程的基本情况呢，我讲完了，下面我们正式说说测试框架。

咱们要完成的第一个项目，就是编写一个简易的 C 语言测试框架。不过在写这个 C 语言测试框架之前，咱们先看看之前的大厂或大牛们是怎么做的，这样，我们在写自己的测试框架的时候，也能有所借鉴。

今天，我要带你认识的是一个由 Google 开发的单元测试框架 Google Test，我们一般称它为 gtest。

关于如何安装 Google 测试框架呢，由于大家的编程环境不同，所以，你可以按照网上其他资料中给出的安装教程，结合自己的编程环境，准备好 gtest 的相关环境。

gtest 是一个 C++ 的单元测试框架，如果你不方便准备 gtest 相关环境，也不碍事儿，你可以看我下面的讲解。我的环境中，已经安装好了 gtest 的相关模块，为了简单说明 gtest 的使用与运行效果，给你准备了如下代码：

#include <stdio.h>
#include <gtest/gtest.h>

// 判断一个数字 x 是否是素数
int is_prime(int x) {
    for (int i = 2; i * i < x; i++) {
        if (x % i == 0) return 0;
    }
    return 1;
}

// 第一个测试用例
TEST(test1, test_is_prime) {
    EXPECT_EQ(is_prime(3), 1);
    EXPECT_EQ(is_prime(5), 1);
    EXPECT_EQ(is_prime(7), 1);
}

// 第二个测试用例
TEST(test2, test_is_prime) {
    EXPECT_EQ(is_prime(4), 0);
    EXPECT_EQ(is_prime(0), 0);
    EXPECT_EQ(is_prime(1), 0);
}

int main() {
    return RUN_ALL_TESTS();
}

就是一份简单的 gtest 的使用代码。代码中，包含了 gtest/gtest.h 头文件以后，程序就具备了一些魔法效果，下面让我来给你具体讲一讲。

从程序的结构上来说，先是一个 is_prime 函数，接下来是两段以 TEST 作为开头的代码（这两段代码的作用，咱们稍后做介绍），最后是一个主函数。主函数中原本 return 0 的位置，替换成了 return RUN_ALL_TESTS()，也就是说，主函数中只执行了一个 RUN_ALL_TESTS 函数，而这个 RUN_ALL_TESTS 函数有什么作用呢？咱们来看一下程序的具体输出：

[==========] Running 2 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 2 tests from test_is_prime
[ RUN      ] test_is_prime.test1
[       OK ] test_is_prime.test1 (1 ms)
[ RUN      ] test_is_prime.test2
gtest_test.cpp:25: Failure
Expected equality of these values:
  is_prime(4)
    Which is: 1
  0
gtest_test.cpp:26: Failure
Expected equality of these values:
  is_prime(0)
    Which is: 1
  0
gtest_test.cpp:27: Failure
Expected equality of these values:
  is_prime(1)
    Which is: 1
  0
[  FAILED  ] test_is_prime.test2 (0 ms)
[----------] 2 tests from test_is_prime (1 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test suite ran. (1 ms total)
[  PASSED  ] 1 test.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] test_is_prime.test2

 1 FAILED TEST

由于咱们的环境有所不同，所以你环境中的输出内容，可能和我这个输出结果略有差别，不过，这不影响我们接下来的讨论。

先看输出内容的第 4 行和第 6 行，意思是说，执行测试用例 test_is_prime.test1 和 test_is_prime.test2，这不就是上面两个以 TEST 开头的两段代码相关的输出内容么？

接下来从第 7 行到第 21 行是一段报错信息，意思就是说 is_prime(4)，is_prime(0) 与 is_prime(1) 函数返回值错误，也就意味着 is_prime 函数实现有错误，这段错误所涉及的信息，在源代码中的第二个测试用例中有涉及。

以上就是我们对 gtest 单元测试框架的一个感性认识，从这些感性认知，我们逐渐走向理性层面，逐步展开属于我们自己的思考。

对于 gtest 的三个思考

面对刚才的演示代码和输出内容，你可能会产生如下三个问题：

源代码中的 EXPECT_EQ 是做什么的？

以 TEST 开头的代码段，和我们学习的函数很不一样，那它究竟是什么？

主函数中只调用了 RUN_ALL_TESTS 函数，为什么好像是执行了程序中所有的 TEST 代码段？这个功能是怎么实现的？

第一个问题不难，查看相关 gtest 的文档资料，你就可以知道，EXPECT_EQ 是 gtest 里面自带的宏，主要作用是判断传入的两部分的值是否相等。如果不相等，就会产生类似于输出内容中第 7 行到第 21 行的输出内容。

第二个问题，以 TEST 开头的这段代码，明显不符合我们对 C 语言的语法认知，我们确实没有见过不用规定返回值类型，也不用规定参数类型的函数定义方式。关于 TEST 究竟是个什么的问题，更加合理的猜测，就是 TEST 实际上是一个宏。

我们来回顾一下宏的作用，宏就是做简单的替换。正是因为 TEST(test_is_prime, test1) 这段代码实际上是一个宏，所以展开以后，和后面的大括号中的内容一起组成了一段合法的代码内容，这样理解，原本的代码内容也就解释得通了。

而 TEST 宏展开以后会被替换成什么内容呢？关于这个问题，我留下充足的时间请你去思考，同时，包括第三个问题的答案，都留作今天的思考题。我希望你认真想一想，并将你的思考结果写在留言区，我们一起讨论。

下面这段代码，就是作为我们后续的项目测试代码，对于这份源代码，我们不会对它做任何的改动，所以我建议你把代码内容保存下来，以备咱们后面课程练习使用。

#include <stdio.h>
#include "geek_test.h" // 替换掉原 gtest/gtest.h 头文件

// 判断一个数字 x 是否是素数
int is_prime(int x) {
    for (int i = 2; i * i < x; i++) {
        if (x % i == 0) return 0;
    }
    return 1;
}

// 第一个测试用例
TEST(test1, test_is_prime) {
    EXPECT_EQ(is_prime(3), 1);
    EXPECT_EQ(is_prime(5), 1);
    EXPECT_EQ(is_prime(7), 1);
}

// 第二个测试用例
TEST(test2, test_is_prime) {
    EXPECT_EQ(is_prime(4), 0);
    EXPECT_EQ(is_prime(0), 0);
    EXPECT_EQ(is_prime(1), 0);
}

int main() {
    return RUN_ALL_TESTS();
}

我们后续的目标，就是开发一个自己的测试框架，替换掉上述代码中的 gtest/gtest.h 头文件，使得程序具有和之前类似的功能和输出内容。整个过程，我们不会改动上述代码中的内容，这一点，请你一定要牢记。

从这一课开始，我希望你能脱离我的指导和提示，可以独立完成咱们每节课的任务，这也是为了检验你这段时间以来的学习成果。当然，如果你实在想不出答案，也可以回来看看我的做法，以供参考。

课程小结

最后呢，我来给你做一下今天的课程小结：

测试行为，不是测试工程师的专属，你应该把它作为一个开发工程师的习惯。

单元测试属于白盒测试范畴，Google 的 gtest 就是一种辅助我们编写单元测试的框架。

gtest 中的 TEST 本质上是一个宏，而这个宏应该展开成怎样的代码内容，还需要你认真思考，这个思考过程对你来说是很有价值的。

好了，今天就到这里了，我是胡光，我们下期见。

27 | 牛刀小试（下）：实现一个自己的测试框架

你好，我是胡光，欢迎回来，今天呢，我们继续学习测试框架的相关内容。

上节课中，我们讲到了软件开发一般分为前中后三个部分，提到作为技术人员的我们，一般主要负责在软件开发中期的编码与测试阶段。还有，我还讲到我们一般会综合运用白盒测试与黑盒测试这两种方法来进行程序测试。

更主要的是，我们还介绍了 Google 的单元测试框架 gtest，并对测试框架代码进行了一番解读。其中提到代码中的 TEST 是一个宏，那它展开后被替换的内容是什么呢？还有， RUN_ALL_TESTS 函数是如何依次执行程序中所有的测试用例函数的？

今天呢，我们就一个一个地来解决这些问题，并最终实现一个咱们自己的测试框架。

初步实现 TEST 宏

今天我们实现的所有代码呢，都会写在一个名字为 geek_test.h 的头文件中。当然我们也知道，将声明和定义写在一起，在大型工程中是会出现严重的编译错误，在实际的工程开发中，我们并不会这么做。

今天把声明和定义写在一起，只是为了课程内容的讲解需要，而你也完全没有必要担心，这不会影响你对主要内容的学习。

我们先回到上节课中的源代码：

#include <stdio.h>
#include "geek_test.h" // 替换掉原 gtest/gtest.h 头文件

// 判断一个数字 x 是否是素数
int is_prime(int x) {
    for (int i = 2; i * i < x; i++) {
        if (x % i == 0) return 0;
    }
    return 1;
}

// 第一个测试用例
TEST(test1, test_is_prime) {
    EXPECT_EQ(is_prime(3), 1);
    EXPECT_EQ(is_prime(5), 1);
    EXPECT_EQ(is_prime(7), 1);
}

// 第二个测试用例
TEST(test2, test_is_prime) {
    EXPECT_EQ(is_prime(4), 0);
    EXPECT_EQ(is_prime(0), 0);
    EXPECT_EQ(is_prime(1), 0);
}

int main() {
    return RUN_ALL_TESTS();
}

我们的目的，是在不改变这份源代码的前提下，通过在 geek_test.h 中添加一些源码，使得这份代码的运行效果，能够类似于 gtest 的运行效果。

想要完成这个目标，我们就要先来思考 TEST 宏这里的内容，请你仔细观察这段由 TEST 宏定义的测试用例的相关代码：

TEST(test1, test_is_prime) {
    EXPECT_EQ(is_prime(3), 1);
    EXPECT_EQ(is_prime(5), 1);
    EXPECT_EQ(is_prime(7), 1);
}

TEST(test1, test_is_prime) 这部分应该是在调用 TEST 宏，而这部分被预处理器展开以后的内容，只有和后面大括号里的代码组合在一起，才是一段合法的 C 语言代码，也只有这样，这份代码才能通过编译。

既然如此，我们就不难想到，TEST 宏展开以后，它应该是一个函数定义的头部，后面大括号里的代码，就是这个展开以后的函数头部的函数体部分，这样一切就都说得通了。

在实现 TEST 宏之前，我们还需要想清楚一个问题：由于程序中可以定义多个 TEST 测试用例，如果每一个 TEST 宏展开都是一个函数头部的话，那这个展开的函数的名字是什么呢？如果每一个 TEST 宏展开的函数名字都一样，那程序一定无法通过编译，编译器会报与函数名重复相关的错误，所以， TEST 宏是如何确定展开函数的名字呢？

不知道你有没有注意到，TEST 宏需要传入两个参数，这两个参数在输出信息中与测试用例的名字有关。那我们就该想到，可以使用这两个参数拼接出一个函数名，只要 TEST 传入的这两个参数不一样，那扩展出来的函数名就不同。最后，我们就可以初步得到如下的 TEST 宏的一个实现：

#define TEST(test_name, func_name) \
void test_name##_##func_name()

如代码所示的 TEST 宏实现，我们将 TEST 宏的两个参数内容使用 ## 连接在一起，中间用一个额外的下划线连接，组成一个函数名字，这个函数的返回值类型是 void，无传入参数。

根据这个实现，预处理器会将源代码中两处 TEST 宏的内容，替换成如下代码所示内容：

void test1_test_is_prime() {
    EXPECT_EQ(is_prime(3), 1);
    EXPECT_EQ(is_prime(5), 1);
    EXPECT_EQ(is_prime(7), 1);
}

void test2_test_is_prime() {
    EXPECT_EQ(is_prime(4), 0);
    EXPECT_EQ(is_prime(0), 0);
    EXPECT_EQ(is_prime(1), 0);
}

这样，也就把原来看似不合理的 TEST 宏，转换成了合法的 C 语言代码了。

__attribute__：让其它函数先于主函数执行

在继续讲测试框架的设计之前，我们来补充一个知识点。

之前，我们所学习到的程序执行过程，既是从主函数开始，也是从主函数结束。也就是说，在常规的程序执行过程中，其它函数都是在主函数执行之后，才被直接或者间接调用执行。接下来，我就要给你讲一种能够让函数在主函数执行之前就执行的编程技巧。

首先，我们先来看如下代码：

#include <stdio.h>

void pre_output() {
    printf("hello geek!\n");
    return ;
}

int main() {
    printf("hello main!");
    return 0;
}

代码运行以后，会输出一行字符串 “hello main!”。

接下来呢，我们对上述代码稍微修改，在 pre_output 函数前面加上__attribute__((constructor)) 。这样，pre_output 函数就会先于主函数执行，代码如下：

#include <stdio.h>

__attribute__((constructor))
void pre_output() {
    printf("hello geek!\n");
    return ;
}

int main() {
    printf("hello main!\n");
    return 0;
}

如上代码执行以后，程序会输出两行内容，第 1 行是 pre_output 函数输出的内容 “hello geek!”，第 2 行才是主函数的执行输出内容 “hello main!”。

从输出内容可以看出，加了__attribute__((constructor)) 以后，pre_output 函数会先于 main 主函数执行，这种有趣的特性，在接下来的操作中我们还会用得上，你要理解并记住。

其实 __attribute__ 的作用还很多，你可以上网搜搜，会让你的程序性质变得特别有意思。

RUN_ALL_TESTS 函数设计

好了，准备工作都做完了，接下来让我们来思考一下 RUN_ALL_TESTS 函数要完成的事情，以及完成这些事情所需要的条件。

从主函数中调用 RUN_ALL_TESTS 函数的方式来看，RUN_ALL_TESTS 函数应该是一个返回值为整型的函数。这样，我们可以得到这样的函数声明形式：

int RUN_ALL_TESTS();

从测试框架的执行输出结果中看，RUN_ALL_TESTS 函数可以依次性地执行每一个 TEST 宏扩展出来的测试用例函数，这是怎么做到的呢？

我们可以这样认为：在主函数执行 RUN_ALL_TESTS 函数之前，有一些函数过程，就已经把测试用例函数的相关信息，记录在了一个 RUN_ALL_TESTS 函数可以访问到的地方，等到 RUN_ALL_TESTS 函数执行的时候，就可以根据这些记录的信息，依次性地执行这些测试用例函数。整个过程，如下图所示：

图1： RUN_ALL_TESTS 执行流程

图中红色部分，就是我们推测的，某些完成测试用例函数信息注册的函数，它们先于主函数执行，将测试用例的信息，写入到一个公共存储区中。

接下来，我们需要考虑的就是这些注册函数，究竟将什么信息存储到了公共存储区中，才能使得 RUN_ALL_TESTS 函数可以调用到这些测试用例？你自己也可以想想是什么。答案就是这个信息是测试用例函数的函数地址，因为只有把函数地址存储到这个存储区中，才能保证 RUN_ALL_TESTS 函数可以调用它们。所以，这片公共存储区，就应该是一个函数指针数组。

那如何解决注册函数问题呢？最简单直接的设计方法，就是每多一个由 TEST 宏定义的测试用例，就配套一个注册函数，所以这个注册函数的逻辑，可以设计在 TEST 宏展开的内容中。这就需要我们对 TEST 宏进行重新设计，这里我一会儿再给你进行说明。

我们先来完成 RUN_ALL_TESTS 函数从存储区中，读取并执行测试用例的过程：

typedef void (*test_function_t)();

test_function_t test_function_arr[100];
int test_function_cnt = 0;

int RUN_ALL_TESTS() {
    for (int i = 0; i < test_function_cnt; i++) {
        printf("RUN TEST : %d\n", i + 1);
        test_function_arr[i]();
        printf("RUN TEST DONE\n\n");
    }
    return 0;
}

代码中用到了函数指针相关的技巧，其中 test_function_t 是我们定义的函数指针类型，这种函数指针类型的变量，可以用来指向返回值是 void，传入参数为空的函数。

之后，定义了一个有 100 位的函数指针数组 test_function_arr，数组中的每个位置，都可以存储一个函数地址，数组中元素数量，记录在整型变量 test_function_cnt 中。这样，RUN_ALL_TESTS 函数中的逻辑就很简单了，就是依次遍历函数指针数组中的每个函数，然后依次执行这些函数，这些函数每一个都是一个测试用例。

重新设计：TEST 宏

根据前面的分析，TEST 扩展出来的内容，不仅要有测试用例的函数头部，还需要有先于主函数执行的注册函数，主要用于注册 TEST 扩展出来的测试用例函数。由此，我们可以得出如下示例代码：

#define TEST(test_name, func_name) \
void test_name##_##func_name(); \
__attribute__((constructor)) \
void register_##test_name##_##func_name() { \
    test_function_arr[test_function_cnt] = test_name##_##func_name; \
    test_function_cnt++; \
} \
void test_name##_##func_name()

这个新设计的 TEST 宏，除了末尾保留了原 TEST 宏内容以外，在扩展的测试用例函数头部添加了一段扩展内容，这段新添加的扩展内容，会扩展出来一个函数声明，以及一个以 register 开头的会在主函数执行之前执行的注册函数；注册函数内部的逻辑很简单，就是将测试函数的函数地址，存储在函数指针数组 test_function_arr 中，这部分区域中的数据，后续会被 RUN_ALL_TESTS 函数使用。

如果以如上 TEST 宏作为实现，原程序中的两个测试用例代码，会被展开成如下样子：

void test1_test_is_prime();

__attribute__((constructor))
void register_test1_test_is_prime() {
    test_function_arr[test_function_cnt] = test1_test_is_prime;
    test_function_cnt++;
}

void test1_test_is_prime() {
    EXPECT_EQ(is_prime(3), 1);
    EXPECT_EQ(is_prime(5), 1);
    EXPECT_EQ(is_prime(7), 1);
}

void test2_test_is_prime();

__attribute__((constructor))
void register_test2_test_is_prime() {
    test_function_arr[test_function_cnt] = test2_test_is_prime;
    test_function_cnt++;
}

void test2_test_is_prime() {
    EXPECT_EQ(is_prime(4), 0);
    EXPECT_EQ(is_prime(0), 0);
    EXPECT_EQ(is_prime(1), 0);
}

这个展开内容，是我给你做完代码格式整理以后的样子，实际展开结果会比这个格式乱一点儿，不过代码逻辑都一样。从展开内容中你可以看到，在展开代码的第 4 行和第 18 行分别就是两个测试用例函数的注册函数。

至此，我们就算是初步完成了测试框架中关键的两个部分的设计：一个是 TEST 宏，另外一个就是 RUN_ALL_TESTS 函数。它们同时也是串起测试框架流程最重要的两部分。

关于 EXPECT_EQ 是如何实现的，我就留作思考题吧，也希望你认真想一想，把你的答案写在留言区中，我们一起讨论。这个实现答案肯定不唯一，你只需要尽量做到最好即可。

课程小结

最后，我来给你做一下今天的课程小结：

1.__attribute__((constructor)) 可以修饰函数，使修饰的函数先于主函数执行。

2. RUN_ALL_TESTS 之所以可以获得程序中所有测试用例的函数信息，是因为有一批注册函数，将测试用例函数记录下来了。

3. 通过测试框架这个项目，我们再一次看到，宏可以将原本看似不合理的代码，变得合理。

通过这两次课程，我希望你意识到，我们不是在阅读已有的测试框架的源码，而是在根据已有的测试框架，脑补其内部实现过程。

其实，脑补这个能力，往往是项目开发中的重要能力之一。例如，根据产品需要的外在功能描述，脑补后续的开发细节；根据竞品可见的功能表现，脑补其背后的技术细节。能够脑补一个产品的实现细节，可以让我们逐渐掌握，认清相关技术边界的能力，这个能力可以让我们不盲目崇拜某个公司，也不会随意轻视某个产品。

好了，今天就到这里了。下期我将带你继续完善测试框架的相关功能，我是胡光，我们下期见。

28 | 尝试升级（上）：完善测试框架的功能与提示

你好，我是胡光，欢迎回来。

在上一节课中呢，我们学习了测试框架的主要功能流程，完成了最重要的 RUN_ALL_TESTS 函数的功能逻辑。并且在这个学习期间，我们还使用了注册函数的技巧，就是让一些函数先于主函数执行，将测试用例函数信息记录在一个函数指针数组中，为后续的 RUN_ALL_TESTS 函数功能的执行作铺垫。

可你有没有发现，我们上节课程所完成的代码，只能让我们的测试框架在整体流程功能上跑通，然而程序的输出内容却不如 gtest 丰富。

今天，我们的主要任务，就是参考 gtest 的输出，逐步完善我们自己测试框架的相关信息输出方面的细节，从而让输出内容更加符合我们想要的信息。来，让我们一起开始吧。

温故知新，gtest 的输出结果

我们先来回顾一下 gtest 的输出结果，gtest 的输出内容大体可以分成三个部分。

第一部分，一套单元测试的相关信息：

[==========] Running 2 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 2 tests from test_is_prime

这段信息说明这套单元测试中，包含了 2 个测试用例。

第二部分，是每个单元测试运行信息的输出：

[ RUN      ] test_is_prime.test1
[       OK ] test_is_prime.test1 (1 ms)
[ RUN      ] test_is_prime.test2
gtest_test.cpp:25: Failure
Expected equality of these values:
  is_prime(4)
    Which is: 1
  0
gtest_test.cpp:26: Failure
Expected equality of these values:
  is_prime(0)
    Which is: 1
  0
gtest_test.cpp:27: Failure
Expected equality of these values:
  is_prime(1)
    Which is: 1
  0
[  FAILED  ] test_is_prime.test2 (0 ms)

如上所示，第一个单元测试 test_is_prime.test1 运行结果正确，所用时间是 1ms；第二个单元测试 test_is_prime.test2 中，有三个判等 EXPECT 断言的结果是错误的，也就是 is_prime 函数的返回值，和测试用例中期望的返回值不符，这说明 is_prime 函数存在 Bug。

第三部分，就是这套单元测试的总结信息，以及整个程序单元测试结果的汇总信息。这段信息，有兴趣的小伙伴可以自己理解着看一下，由于不是咱们今天课程的重点，就不展开介绍了。

[----------] 2 tests from test_is_prime (1 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test suite ran. (1 ms total)
[  PASSED  ] 1 test.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] test_is_prime.test2

 1 FAILED

好了，关于 gtest 的输出内容，我大致说清楚了。

今天呢，我们先忽略 gtest 输出内容的第一部分和第三部分，主要关注 gtest 输出内容的第二部分，也就是每个单元测试运行信息的输出部分。通过第二部分的输出内容，你能想出我们应该从哪些方面来完善测试框架？

这里呢，我给出我的想法：通过观察第二部分的输出，我们基本要从三个方面完善测试框架的输出信息。

在每个测试用例运行之前，要先行输出相关测试用例的名字；

每个测试用例运行结束以后，要输出测试用例的运行时间与运行结果（OK 或者 FAILED）；

若测试用例中的 EXPECT 断言出错，需要输出错误提示信息。

好了，优化的方向找到了，那么接下来，我们就开始测试框架改装行动吧！

测试用例的名字输出

首先是如何输出测试用例的名字。我们先回忆一下上节课设计的注册函数，如下所示：

#define TEST(test_name, func_name) \
void test_name##_##func_name(); \
__attribute__((constructor)) \
void register_##test_name##_##func_name() { \
    test_function_arr[test_function_cnt] = test_name##_##func_name; \
    test_function_cnt++; \
} \
void test_name##_##func_name()

注册函数是随着 TEST 展开的，从展开的代码逻辑中可以看到，它只是将测试用例的函数地址记录在了函数指针数组中。要想 RUN_ALL_TESTS 函数后续能够输出测试用例的函数名称的话，我们只需要修改注册函数的功能逻辑即可，也就是让注册函数在记录函数信息的时候，增加记录对应测试用例的名称。

而这个名称信息，应该记录在哪里呢？有两种代码实现方式：

另外开辟一个记录测试用例名称的字符串数组；

修改 test_function_arr 数组中的元素类型，将新增的测试用例名称以及函数地址信息打包成一个数据元素。

显然，相较于第一种实现方式，第二种代码实现方式会使程序具有更好的封装特性。我们采用之前在“语言基础篇“中学习的结构体相关知识，就可以完成这种多种数据类型打包成一种新的数据类型的功能需求。

下面就是我们将函数指针信息和测试用例名称信息，封装成的一个新的结构体类型：

struct test_function_info_t {
    test_function_t func;  // 测试用例函数指针，指向测试用例函数
    const char *name; // 指向测试用例名称
} test_function_arr[100];
int test_function_cnt = 0;

如代码所示，我们定义了一种新的数据类型，叫做 test_function_info_t。这种结构体类型包含了指向测试用例的函数指针 func 字段， 与指向测试用例名称的字符串指针 name 字段，并且我们将这种结构体类型，作为 test_function_arr 数组新的元素类型。

既然测试用例信息的存储区 test_function_arr 的数据类型发生了改变，那么负责存储信息的注册函数，与使用信息的 RUN_ALL_TESTS 函数的相关逻辑都需要作出改变。

首先，我们来看注册函数的改变。想要修改注册函数的逻辑，就是修改 TEST 宏，从功能上来说，注册函数中需要额外记录一个测试用例名称信息，示例代码如下：

#define TEST(test_name, func_name) \
void test_name##_##func_name(); \
__attribute__((constructor)) \
void register_##test_name##_##func_name() { \
    test_function_arr[test_function_cnt].func = test_name##_##func_name; \
    test_function_arr[test_function_cnt].name = #func_name "." #test_name; \
    test_function_cnt++; \
} \
void test_name##_##func_name()

代码中主要是增加了第 6 行的逻辑，这一行的代码将 TEST 宏参数的两部分，拼成一个字符串，中间用点 (.) 连接，例如 TEST(test1, test_is_prime) 宏调用中，拼凑的字符串就是 test_is_prime.test1，和 gtest 中的输出的测试用例名称信息格式是一致的。

改完了注册函数的逻辑以后，最后调整一下 RUN_ALL_TESTS 中使用 test_function_arr 数组的逻辑代码即可：

int RUN_ALL_TESTS() {
    for (int i = 0; i < test_function_cnt; i++) {
        printf("[ RUN      ] %s\n", test_function_arr[i].name);
        test_function_arr[i].func();
        printf("RUN TEST DONE\n\n");
    }
    return 0;
}

代码中的第 3 行，是仿照 gtest 的输出格式进行调整的，在输出测试用例名称之前，先输出一段包含 RUN 英文的标志信息。

至此，我们就完成了输出测试用例名字的框架功能改造。

输出测试用例的运行结果信息

接下来，就让我们进行第二个功能改造：输出测试用例的运行结果信息。

以下是我们示例代码中的 2 个测试用例，在 gtest 框架下的运行结果信息输出：

[     OK ] test_is_prime.test1 (1 ms)
[ FAILED ] test_is_prime.test2 (0 ms)

根据输出的信息，我们可知 gtest 会统计每个测试用例运行的时间，并以毫秒为计量单位，输出此时间信息。不仅如此，gtest 还会输出与测试用例是否正确相关的信息，如果测试用例运行正确，就会输出一行包含 OK 的标志信息，否则就输出一行包含 FAILED 的标志信息。

根据我们自己测试框架的设计，这行信息只有可能是在 RUN_ALL_TESTS 函数的 for 循环中，执行完每一个测试用例函数以后输出的信息。

由此，我们面临的是两个需要解决的问题：

如何统计函数过程的运行时间？

如何确定获得每一个测试用例函数的测试结果是否正确？

说到如何统计函数过程的运行时间，我这里就需要介绍两个新的知识点，一个是函数 clock() ，另 一个是宏 CLOCKS_PER_SEC。下面我会对它们详细讲解。

我们先说函数 clock() 。它的返回值代表了：从运行程序开始，到调用 clock() 函数时，经过的 CPU 时钟计时单元。并且，这个 clock() 函数的返回值，实际上反映的是我们程序的运行时间。那这个 CPU 时钟计时单元究竟是什么呢？你可以把 1 个 CPU 时钟计时单元，简单的理解成是一个单位时间长度，只不过这个单位时间长度，不是我们常说的 1 秒钟。

接下来，我们再说说宏 CLOCKS_PER_SEC 。它实际上是一个整型值，代表多少个 CPU 时钟计时单元是 1 秒。这个值在不同环境中会有所不同，在早年我的 Windows 电脑上，这个值是 1000，也就是 1000 个 CPU 时钟计时单位等于 1 秒。而现在我的 Mac 电脑上，这个值是 1000000，也就是 1000000 个 CPU 时钟计时单位等于 1 秒钟。显然，这个数字越大，统计粒度就越精细。

有了上面这两个工具，我们就可以轻松地统计一个函数的运行时间。在函数运行之前，记录一个 clock() 值，函数运行结束以后，再记录一个 clock() 值，用两个记录值的差值除以 CLOCKS_PER_SEC ，得到的就是以秒为单位的函数运行时间，再乘以 1000，即为毫秒单位。

这样呢，我们就解决了刚刚提的第一个问题：统计函数过程的运行时间。

至于如何获得每一个测试用例的测试结果，我们可以采用一个简单的解决办法，那就是记录一个全局变量，代表测试用例结果正确与否。当测试用例中的 EXPECT_EQ 断言发生错误时，就修改这个全局变量的值，这样我们的 RUN_ALL_TESTS 函数，就可以在测试用例函数执行结束以后，得知执行过程是否有错。

综合以上所有信息，我们可以重新设计 RUN_ALL_TESTS 函数如下：

int test_run_flag;
#define EXPECT_EQ(a, b) test_run_flag &= ((a) == (b))

int RUN_ALL_TESTS() {
    for (int i = 0; i < test_function_cnt; i++) {
        printf("[ RUN      ] %s\n", test_function_arr[i].name);
        test_run_flag = 1;
        long long t1 = clock();
        test_function_arr[i].func();
        long long t2 = clock();
        if (test_run_flag) {
            printf("[       OK ] ");
        } else {
            printf("[  FAILED  ] ");
        }
        printf("%s", test_function_arr[i].name);
        printf(" (%.0lf ms)\n\n", 1.0 * (t2 - t1) / CLOCKS_PER_SEC * 1000);
    }
    return 0;
}

代码中的第 8 行是在测试用例运行之前，记录一个开始时间值 t1；代码中的第 10 行是在测试用例函数执行完后，记录一个结束时间值 t2；在代码的第 17 行，根据 t1 、t2 以及 CLOCKS_PER_SEC 的值，计算得到测试用例函数实际运行的时间，并输出得到的结果。

这段代码中增加了一个全局变量“test_run_flag”，这个变量每次在测试用例执行之前，都会被初始化为 1，当测试用例结束执行以后，RUN_ALL_TESTS 函数中，根据 test_run_flag 变量的值，选择输出 OK 或者 FAILED 的标志信息。同时，我们可以看到，test_run_flag 变量的值只有在 EXPECT_EQ 断言中，才可能被修改。

EXPECT_EQ 断言的实现

最后呢，我们还剩下一个 EXPECT_EQ 断言的实现，这个就给你留作思考题，请你基于我上述所讲的内容，试试自己实现这个带错误提示输出的 EXPECT_EQ 断言吧。也欢迎你把你的答案写在留言区，我们一起讨论。

课程小结

通过今天的课程呢，我希望你认识到工程开发中的一个基本原则：功能迭代，数据先行。也就是说，无论我们做什么样的功能开发，首先要考虑的是与数据相关的部分。更细致的解释，就是你考虑某种功能的实现，要明白这个功能都依赖于哪些数据信息，这些信息在哪里存储，在哪里修改，在哪里读取使用。把数据相关部分设计明白了，你的功能开发也就基本实现了一半了。

就像我们今天改造的第一个功能，输出测试用例的名字。

首先，我们考虑如何存储名字信息，最先被修改的就是 test_function_arr 数组的数据类型，我们改造了数据存储的结构。然后，我们修改了注册函数的相关功能逻辑，也就是解决了数据的写入与修改过程。最后，我们修改 RUN_ALL_TESTS 中的输出逻辑，也就是解决了数据在哪里读取和使用的事情。

至此，我已经向你演示了基本的功能迭代开发过程。接下来你可以自己试着，给输出的内容加上点儿颜色，以便更清晰地展示测试过程中的测试信息。除此之外呢，你也可以开动你的创造力，给测试框架加些令人惊喜的功能。

好了，今天就到这里了，我是胡光，我们下节课见。

29 | 尝试升级（下）：“链表”知识在测试框架中的应用

你好，我是胡光，欢迎回来。

上节课中，我们通过参考 gtest 的输出，完善了我们自己的测试框架的输出信息，也就是添加了测试用例的名称、运行结果以及运行时间。并且，我提到了在一般情况下，项目中的功能开发原则：功能迭代，数据先行。就是要开发新的功能之前，我们应该先考虑清楚实现这部分功能相关的数据，在系统中的存储与使用的情况，只有这样，才能更好地完成功能的实现与迭代优化。

今天迎来我们整个测试框架项目的最后一节课。这节课的目的，一是对前几节课内容进行一个总结，二是向你说明我们现在开发的测试框架代码，其实还有很多优化的空间。至于这个优化空间是什么呢？这次我将带着你结合之前学习的“链表”知识，对测试框架进行一个具体的优化改进。

关于测试框架的优化，是一个不断学习的过程。在这个过程中，你深刻体会到“知不足，然后能自反也”这句话的含义。就是在优化代码的过程中，你会发现自己的不足，然后努力提高自己的能力去弥补不足；当你提升了自己之后，你又会看到自己在其他方面的不足，进而继续提高自己。

好了，废话不多说，我们正式开始今天的学习。

揭晓答案：EXPECT_EQ 宏究竟是如何实现的

在对测试框架进行优化之前呢，我先来回答一下，可能困扰了你两节课的一个问题：就是 EXPECT_EQ 宏究竟是如何实现的？这个问题的答案呢，我给出一个可行的实现，仅供参考。

首先，EXPECT_EQ(a, b) 在 a，b 两部分值相等的时候，不会产生额外的输出信息，而当 a，b 两部分不相等的时候，就会输出相应的提示信息。如下所示：

gtest_test.cpp:25: Failure
Expected equality of these values:
  is_prime(4)
    Which is: 1
  0

这段输出信息，对应的是源代码中的 “EXPECT_EQ(is_prime(4), 0); ”的输出。如你所见，第 1 行的输出内容包含了源文件名（gtest_test.cpp），EXPECT_EQ 宏所在的代码位置（25），以及一个提示结果（Failure）。

接下来的信息，你自己就可以看懂了，就是关于 EXPECT_EQ 传入两部分的值。对于函数调用部分，EXPECE_EQ 会输出这个函数的调用形式及返回值信息，也就是输出中的 “is_prime(4)”“Which is: 1” 这段内容。而对于数值信息，只会输出数值信息本身，也就是输出信息中第 5 行的那个 0。

实际上，要想在宏中实现类似于这种根据传入参数类型，选择输出形式的功能，对于现在的你来说可能有点困难。所以，我们可以重新设计一种输出形式，只要能够清晰地展示错误信息就可以。

重新设计的输出提示，如下所示：

gtest_test.cpp:25: Failure
Expected (is_prime(4) == 0):
    Which is: (1 == 0)

修改完以后的输出信息，你可以看到，第 2 行就是传入 EXPECT_EQ 宏两部分的比较，第 3 行是这两部分实际输出值的比较。

重新设计了输出信息以后，就可以来看看 EXPECT_EQ 宏的实现了：

#define EXPECT(a, b, comp) { \
    __typeof(a) val_a = (a), val_b = (b); \
    if (!(val_a comp val_b)) { \
        printf("%s:%d: Failure\n", __FILE__, __LINE__); \
        printf("Expected (%s %s %s):\n", #a, #comp, #b); \
        printf("    Which is: (%d %s %d)\n", val_a, #comp, val_b); \
        test_run_flag = 0; \
    } \
}
#define EXPECT_EQ(a, b) EXPECT(a, b, ==)
#define EXPECT_LT(a, b) EXPECT(a, b, <)
#define EXPECT_GT(a, b) EXPECT(a, b, >)
#define EXPECT_NE(a, b) EXPECT(a, b, !=)

在这段实现中，你会发现，我们不仅实现了 EXPECT_EQ，还额外实现了 EXPECT_LT、EXPECT_GT、EXPECT_NE 等用于比较的宏。其中，LT 是英文 little 的缩写，是判断小于关系的；GT 是 great 的缩写，是判断大于关系的；NE 是 not equal 的缩写，是判断不等于关系的。而这些所有的宏，都是基于 EXPECT 宏实现的。

我们将用于比较的运算符，当作参数传递给 EXPECT 宏。有了 EXPECT 宏以后，你就可以参考代码中的第 10～13 行的内容，轻松地扩展出用于小于等于或者大于等于的宏了。由于 EXPECT 宏的实现，全都是我们之前学习过的知识点，所以在这里我就不再赘述了，你可以自行阅读文稿中的代码。

用链表存储测试用例

看完了 EXPECT 宏的参考实现以后，整个测试框架的基础功能，就算是彻底搭建完成了。

接下来，我们再重新审视下面这段函数指针数组 test_function_arr 的代码设计，来思考一下这个测试框架中还有没有可以优化的地方。

struct test_function_info_t {
    test_function_t func;  // 测试用例函数指针，指向测试用例函数
    const char *name; // 指向测试用例名称
} test_function_arr[100];
int test_function_cnt = 0;

这段代码中，我们使用了数组来定义存储测试函数信息的存储区，这个数组的大小有 100 位，也就是说，最多可以存储 100 个测试用例函数信息。

那我们来思考一个问题：要是有程序中定义了 1000 个测试用例，怎么办呢？毕竟，对于中型项目开发来说，定义 1000 个测试用例，可不是什么难事儿。这个时候，你可能会说，那简单啊，数组大小设置成 10000 不就行了。

但是你要明白，这种设计尽管简单粗暴且有效，可它一点儿程序设计的美感都没有。什么意思呢？就是当我们为测试用例准备了 10000 个数组空间的时候，可能在真正的开发过程中，只定义了 2 个测试用例，这就会浪费掉 9998 个数组空间。

更形象地描述这种行为的话，这种设计方式很像计划经济，计划多少用多少。同时，它的弊端也很明显，一旦计划不好，要不是造成空间浪费，要不就是资源紧张。

所以，我们应该尝试着从“计划经济”向“市场经济”转变一下，可不可以转变成想用多少就生产多少。那应该怎么做呢？

我们知道，在程序中数组的空间大小，是需要提前计划出来的。但是有一种结构的空间，是可以动态增加或减少的，那就是我们之前讲过的“链表”结构。你想一下，如果我们把一个一个的测试函数信息，封装成一个一个的链表节点，每当增加一个测试用例的时候，就相当于向整个链表中插入一个新的节点。此时，用链表实现的存储测试函数信息的结构，它所占空间大小就和实际测试用例的数量成正比了。这就是我说的用多少，就生产多少。

下面，我们就来说说具体怎么做。

第一步，我们需要改变 test_function_info_t 的结构定义，也就是把原先存储测试用例函数信息的结构体类型，改装成链表结构。最简单的方法，就是在结构体的定义中，增加一个指针字段，指向下一个 test_function_info_t 类型的数据，代码如下所示：

struct test_function_info_t {
    test_function_t func;  // 测试用例函数指针，指向测试用例函数
    const char *name; // 指向测试用例名称
    struct test_function_info_t *next;
};
struct test_function_info_t head, *tail = &head;

可以看到，我们给 test_function_info_t 结构体类型增加了一个链表中的 next 字段，除此之外，我们还定义了一个虚拟头节点 head 和一个指针变量 tail。这里你需要注意，head 是虚拟头节点，后续我们会向 head 所指向链表中插入链表节点，tail 指针则指向了整个链表的最后一个节点的地址。

第二步，在准备好了数据存储结构以后，需要改写的就是函数注册的逻辑了。在改写 TEST 宏中的注册函数逻辑之前呢，我们先准备一个工具函数 add_test_function，这个工具函数的作用，就是根据传入的参数，新建一个链表节点，并且插入到整个链表的末尾：

void add_test_function(const char *name, test_function_t func) {
    struct test_function_info_t *node;
    node = (struct test_function_info_t *)malloc(sizeof(struct test_function_info_t));
    node->func = func;
    node->name = name;
    node->next = NULL;
    tail->next = node;
    tail = node;
    return ;
}

好了， add_test_function 工具函数准备好之后，我们正式来改写 TEST 宏中注册函数的逻辑。其实难度也不大，也就是要求注册函数调用 add_test_function 函数，并且传入相关的测试用例的函数信息即可：

#define TEST(test_name, func_name) \
void test_name##_##func_name(); \
__attribute__((constructor)) \
void register_##test_name##_##func_name() { \
    add_test_function(#func_name "." #test_name, \
                      test_name##_##func_name); \
} \
void test_name##_##func_name()

最后一步，处理完了数据写入的过程以后，来让我们修改一下使用这份数据的代码逻辑，那就是 RUN_ALL_TESTS 函数中的相关逻辑。之前，RUN_ALL_TESTS 函数中，循环遍历数组中的每一个测试用例，并且执行相关的测试用例函数，对这一部分，修改成针对于链表结构的遍历方式即可，代码如下所示：

int RUN_ALL_TESTS() {
    struct test_function_info_t *p = head.next;
    for (; p; p = p->next) {
        printf("[ RUN      ] %s\n", p->name);
        test_run_flag = 1;
        long long t1 = clock();
        p->func();
        long long t2 = clock();
        if (test_run_flag) {
            printf("[       OK ] ");
        } else {
            printf("[  FAILED  ] ");
        }
        printf("%s", p->name);
        printf(" (%.0lf ms)\n\n", 1.0 * (t2 - t1) / CLOCKS_PER_SEC * 1000);
    }
    return 0;
}

这样，我们就彻底完成了测试用例函数信息存储部分的“链表”改造过程。

对于上面的这份代码实现，你会发现，链表节点空间是通过 malloc 函数动态申请出来的，可在我们的程序中，并没有对这些空间使用 free 进行释放，如果你想让这个程序对空间的申请与回收做到有始有终，变得更加干净，那应该怎么办呢？

这里你可以借助 __attribute__((destructor)) 的功能，之前我们介绍了一个__attribute__((constructor)) 的作用是让函数先于主函数执行，而 destructor 就是使函数在主函数结束以后才执行的函数特性设置。有了这个特性设置，你可以实现一个函数，专门用来销毁测试函数链表所占存储空间，这样在逻辑上，你的程序会变得更完美。当然，你即使不这么做，也不会影响到原有的程序功能的正确性。

项目小结

至此，我们关于测试框架开发的内容，就算是告一段落了。

从 26 讲到 29 讲，我们经历了一个项目从 0 到 1 的过程，继而又完成了项目从 1 到 1.5 的升级。所谓从 0 到 1 就是项目从最初的想法变成代码的过程，从 1 到 1.5 就是我们对于代码的优化过程。这是一个追求极致、不断优化项目的过程。

关于测试框架开发的讲解内容虽然结束了，但我希望这几节课可以成为你优化这份代码的一个起点。日后，你可以选择增加额外的功能，修改实现的架构，甚至是使用不同的语言重新进行实现，哪怕是一个小小的改动，都是值得称赞的。

在下节课，我将指导你完成一个简易的计算器程序，也算是给我们这个课程一个圆满的结束。

好了，今天就到这里了，我是胡光，我们下一节课见。

30 | 毕业设计：实现你自己的计算器程序

你好，我是胡光，欢迎来到“综合项目篇”的最后一节课。

这节课，我将带你完成一个富有挑战性的任务，就是一起开发一门“编程语言”。哈哈……我说开发一门编程语言当然是和你开玩笑，不过我们可以开发编程语言中的一小部分，那就是定义变量和基本的表达式运算功能。

三个月的时间，我们一起用 C 语言写了这么久的代码，我相信只要你坚持学习，不断拓展自己的编程能力，终有一天你可以开发出一门自己的编程语言。今天，我就带你打个头阵，从计算器程序开始。

计算器程序的功能设计

我们将要实现的计算器程序也算是开发一个小项目，那么开发项目的第一步，就是对我们实现的功能进行设计，一般计算器功能如下：

第一次出现的变量赋值语句，即为变量定义；

计算表达式的值。

这两个功能，看似简单，可实际要考虑的还很多，例如：变量是否有作用域的限制啊，合法变量名的规则，表达式中支持的运算符种类啊，每一种运算符的优先级，等等。这些需要考虑的细节，每一个都会给我们的项目增加一点点难度。

为了把难度控制在一个可以实现的范围，我们对计算器功能做进一步的细致描述，同时也是降低项目实现难度，重新修订的功能定义如下：

第一次出现的变量赋值语句，即为变量定义；

计算表达式的值；

没有作用域的概念，所有变量都是全局变量；

变量名只允许 26 个小写的英文字母，也就是说，程序中最多有 26 个变量；

表达式只支持四则混合运算 +、-、*、/ 以及 ()；

表达式中参与运算的值均为正整数，除法规则参考 C 语言整形之间的除法规则；

变量赋值语句和表达式语句，均各占一行。

这里，我给你看一份符合上述规则的输入数据：

a = 3
b = a * 3 + 5
(a + 4) * (b + 5)

可以看到，第 1 行输入，定义了变量 a，同时给 a 变量赋值为 3；第 2 行，定义了变量 b，同时给 b 变量赋值为 a * 3 + 5 的值，也就是 14；第 3 行，是一行表达式，计算的是 (a + 4) * (b + 5) 的值，最后的结果应该等于 7 * 19 = 133。

针对这份输入数据，我们的计算器程序分别输出每行表达式对应的值，也就是：

3
14
133

清楚了计算器程序的功能以后，下面我就给你讲讲如何完成这个程序。

二叉树的遍历

首先，你需要掌握二叉树的三种遍历方式，这是我们后续解决表达式求值问题的思维利器。在讲遍历方式之前，先来简单的看一下二叉树的基本结构。

二叉树，就是每个节点下面最多有两个子节点的结构。如下图所示，就是一个二叉树结构：

图1：二叉树结构示意图

我们把其中的 A 节点叫做“根节点”，B 和 C 是 A 节点的两个“子节点”，同理，E 和 F 是 C 节点的两个子节点，D 是 B 节点的子节点。如果更细致地划分，以 B 为根节点的子树，处于 A 节点的左侧，所以称为 A 节点的左子树，C 称为 A 节点的右子树。反过来，我们把 A 节点称为 B 和 C 节点的父节点，同时它也是 D、E、F 节点的祖先节点。以上就是二叉树中的一些基本概念了。

认识了二叉树的基本概念以后，我们接下来就来看看二叉树的三种遍历方式：前序遍历、中序遍历与后序遍历。

图2：二叉树的遍历方式

从图中可见，每一种遍历的方式，都是采用递归的定义方式。而所谓的前、中、后序遍历，其实说的是根节点的位置：根节点在左右子树遍历之前，那就是前序遍历；夹在左右子树中间，就是中序遍历；位于左右子树遍历之后，那就是后序遍历。

如果我们将图 1 中的二叉树结构，分别按照三种方式进行遍历，会得到如下所示的遍历结果：

前序遍历：A B D C E F
中序遍历：D B A E C F
后序遍历：D B E F C A

这里你一定要注意，在写某一种遍历结果的时候，一定是按照递归展开的方式。例如，在中序遍历中，我们是将根节点左子树所形成的中序遍历结果（D B），放在根节点 A 的左侧，然后是根节点 A，接着是根节点右子树的中序遍历结果（E C F）。所以最后，整棵树的中序遍历结果就是 D B A E C F。

思维利器：表达式树

介绍完了二叉树的基本概念及三种遍历方式后，我们接下来就要赋予这个二叉树结构一些实际的意义，让它能够帮助我们理解表达式求值的过程。

其实，任何一个四则混合运算的表达式，都能转换成相对应的二叉树，而原表达式的值，等于对应二叉树的后序遍历结果。例如，下图就是一个加法表达式和它所对应的表达式树：

图3：表达式树示意图（一）

我们看到，在表达式树中，根节点就是运算符 +(加号)，加号的左子树是数字 3，右子树是数字 5。根据刚刚所说的对应规则，在表达式树上，按照后序遍历的顺序，得到的就是表达式的值。图 3 中的表达式树，首先遍历得到左子树的数字 3，再遍历得到右子树的数字 5，最后遍历到根节点的运算符 +(加号)，就将左右子树的值做加法，得到原表达式的结果 8。

下面，我们来看一个稍微复杂一点儿的表达式，以及它所对应的表达式树。

图4：表达式树示意图（二）

从图中可见，原表达式是 (3 + 5) * (6 - 2)，而其对应的表达式树中，已经没有了括号的影子。那括号的影响在表达式树上怎么体现呢？其实括号对表达式的影响，已经被表达式树转换成了等价的树形结构关系。这一点怎么理解呢，听我慢慢给你解释。

这里有个关键词，就是“顺序”。我们知道，表达式是按照计算顺序，得到计算结果的。表达式树，按照的是后序遍历方式，这本身也是规定了一种计算“顺序”。根据后序遍历规则，我们可以知道，表达式树的根节点所代表的运算，是原表达式中最后一个执行的运算。

我们回到示意图中具体示例来分析，图中表达式树的计算顺序应该是这样的：首先计算左子树所代表的 3 + 5 表达式的值，再计算右子树代表的 6 - 2 表达式的值，最后根据根节点的乘法运算，计算得到左右子树的乘积值。

如此你会发现，表达式树的这种计算顺序，与原表达式添加了括号以后的计算顺序等价。

综上所述，我们可知，表达式树中越靠近根节点的运算符，优先级越低，而根节点代表了原表达式中，优先级最低的那个运算符。表达式中原有的括号，其实就是用来控制运算符之间的计算顺序的，这种计算顺序，对应的就是表达式树中的父子节点关系，这就是我们刚刚所说的，原表达式中的括号，被转换成了等价的树形结构关系的含义。

理解了表达式树以后，对于我们计算表达式的值，究竟有何作用呢？难道是在程序中，将读入的表达式字符串，转换成程序内的一棵表达式树结构么？

不知道你还记不记得，之前我们在讲链表结构的时候，提到链表不仅仅是一种程序中的结构，更重要的是它所体现出来的“链表思维”。其实今天我们学习的表达式树结构同样如此，我们不需要在程序中真正地建立一棵表达式树，而是利用表达式树去理解表达式计算的过程。

下面我们就来具体看看，如何利用这种思维，解决表达式计算问题。

我们知道，任何一个表达式，都对应一个等价的表达式树。而这个表达式树的根节点所对应的，就是原表达式中最后一个被计算的运算符。如果我们可以找到这个运算符在原表达式中的位置，那么这个运算符所的左边部分，对应的就是表达式树根节点的左子树，运算符的右边部分，对应的就是表达式树根节点的右子树。

我们用 String 代表原表达式字符串，op 代表整个表达式中最后一个被计算的运算符，L_String 是 op 运算符左边的字符串，R_String 就是右边的字符串。

假设，我们有一个函数 get_val(String)，可以得到 String 所代表的表达式的值。那么关于 get_val(String)，我们就可以得到如下递推关系：

get_val(String) = get_val(L_String) op get_val(R_String)

也就是当前表达式的值，等于左边表达式的值与右边表达式的值之间的运算结果。

这里我给你举个具体的例子，还是拿前面的那个乘法表达式来看：

get_val("(3+5)*(6-2)") = get_val("(3+5)") * get_val("(6-2)")

如果我们能确定，表达式字符串中最后一个被计算的运算符的位置，我们就可以把原表达式字符串分成两部分，进行递归求解。所以，找到最后一个被计算的运算符的位置，才是我们完成程序的关键。

到了这里，关于如何利用表达式树来解决表达式计算问题，我们就解释完了。

最后，我们就来看一下，确定运算符计算顺序的处理技巧。

确定运算符顺序的技巧

怎么确定表达式中每一个运算符的计算顺序呢？其实我们可以通过给每个运算符赋予一个权重，权重越高，代表计算优先级越高。下面我就来说说这个权重是怎么设置。

根据四则混合运算的基础规则，我们可以给 +、-、*、/ 运算符设定一个基础权重，例如，+、- 权重是 1，*、/ 权重是 2。

那括号呢？我们可以对括号里面的所有运算符，额外加上一个很大的权重。假设，运算符外有 1 层括号，就额外增加权重 100。如果一个运算符被套在了两层括号里面，那它的权重就应该被额外加上 200。

按照这个规则，请你计算下面这个表达式中，每个运算符的权重：

((3 + 5) * 6) - 7 * 9 + 4

很简单，从左到右，运算符号依次是 +、*、-、*、+，它们的运算符权重分别是 201、102、1、2、1。根据权重可知，最后一个被计算的运算符，应该是末尾权重为 1 的运算符，也就对应了表达式中最后一个 +(加号)。根据这个加号所在位置，我们可以把表达式分成左右两部分，进行递归求解。

在实际编码过程中，我们可以记录一个值 temp，代表由括号产生的额外权重，当碰到左括号的时候，我们就给 temp 加上 100，碰到右括号的时候，temp 就相应的减去 100。对于计算正常的 +、-、*、/ 运算符权重的时候，其权重值应该等于基础权重加上 temp 这个额外权重。

好了，整个程序的核心思路，我已经提供给你了，希望你能通过自己的思考，试着做出来。当然，如果你实在想不出来，可以参考文末我给出的参考代码。

至于如何定义变量，你可以先实现一个没有变量的表达式求值的程序，然后再将定义变量，作为一个新功能，加入到你的程序中。还记得我们之前讲的系统功能迭代过程吧？我们说：功能迭代，数据先行。对于定义变量这个功能的迭代，我们的实现过程也不例外，先思考清楚变量的值“如何存储，如何使用”，把这两个问题想明白了，功能也就开发完一半儿了。

课程小结

最后，我们做一下课程小结。通过今天的课程，我希望你知道：

二叉树的三种遍历方式：前序遍历、中序遍历与后序遍历，它们主要是依据根节点的位置划分出来的。

我们掌握了表达式与其对应的表达式树的对应关系。

表达式树的后续遍历结果，就等于原表达式的值。这种特性，给我们设计表达式求值程序，提供了思维方面的指导。

好了，今天的课程就到这里结束了。真的想跟你再说一次“我们下期见“，可送君千里，终有一别。初航我带你，远航靠自己，我是海贼胡船长，我们江湖再见。

参考代码

/*************************************************************************

> File Name: calc.cpp

> Author: hug

> Created Time: 五 3/27 22:13:04 2020

************************************************************************/

#include <stdio.h>

#include <string.h>

#define INF 0x3f3f3f3f

/*

* 计算表达式 str 从 l 到 r 位置的值

* */

int calc(const char *str, int l, int r) {

/*

* pos : 根节点运算符的位置，初始化为 -1

* priority : 根节点运算符的权重

* temp : 由括号产生的额外权重

* */

int pos = -1, priority = INF - 1, temp = 0;

for (int i = l; i <= r; i++) {

int cur_priority = INF;

switch (str[i]) {

case '(': temp += 100; break;

case ')': temp -= 100; break;

case '+':

case '-': cur_priority = 1 + temp;

case '*':

case '/': cur_priority = 2 + temp;

default: break;

}

/*

* cur_priority : 当前运算符的优先级

* 更新区间内最低优先级的运算符的位置

* */

if (cur_priority <= priority) {

pos = i;

priority = cur_priority;

}

}

/*

* 如果 pos == -1，说明这一段表达式中没有运算符

* 说明，这一段表达式中只有数字，也就是递归到了树的叶子结点

* */

if (pos == -1) {

int num = 0;

for (int i = l; i <= r; i++) {

if (str[i] < '0' || str[i] >= '9') continue;

num = num * 10 + (str[i] - '0');

}

return num;

}

/*

* 递归计算得到运算符左边及右边表达式的值

* 再根据当前运算符，得到当前表达式的值

* */

int a = calc(str, l, pos - 1);

int b = calc(str, pos + 1, r);

switch (str[pos]) {

case '+': return a + b;

case '-': return a - b;

case '*': return a * b;

case '/': return a / b;

}

return 0;

}

int get_val(const char *str) {

return calc(str, 0, strlen(str) - 1);

}

int main() {

char str[1000];

while (scanf("%[^\n]", str) != EOF) {

getchar();

printf("%s = %d\n", str, get_val(str));

}

return 0;

}

结束语 | 设立目标，有的放矢

你好，我是胡光。

恭喜你，来到了我们专栏的最后一节课，这也说明我们一起学完了整个课程，你也给了自己一个圆满的交代。

熟悉我的朋友都知道，我现在是自己创业，有一家编程学习的培训公司。平日里，很多学生都叫我船长，我也喜欢他们对我的这个称呼。因为一提到老师这个称呼，我就想到了大学里面诲人不倦的老教授，但我还想多做几天祖国的花朵。还有，我觉得船长这个称呼，更符合我一生放荡不羁爱自由的性格。

我在讲课过程中呢，也不喜欢一板一眼地单方面输出教学，更喜欢和学生去讨论，相信大家在相互对话中，才能更好地解释问题，理解知识。所以我和我的很多学生也不是以师生关系相处，更多是以朋友的身份沟通交流。

今天是最后一节课了，我想和你聊一个与技术无关，而与学习技术有关的概念，那就是“目标”。当面对一个新技术要学习的时候，你会采取怎样的一个步骤呢？所谓，工欲善其事，必先利其器，学会分析目标、设立目标，这就是对于学习中的利器。下面呢，我分享下我自己的一些经历和思考，希望对你能有所启发。

专栏的目标

这个专栏的定位是写一个面向 0 基础人群的编程入门课，但是最开始，0 基础这个概念就困扰了我一段时间，究竟怎样才算 0 基础？如果现在把这个问题交给你，你会如何来思考呢？

我当时的推理过程是这样的，首先将学习者对于编程的能力，大体分成三类：

真正的 0 基础，一点儿编程与计算机概念都没有；

有一点儿编程概念和语法基础的，可是经常被程序实现卡住；

编程能力已日趋熟练，现阶段只想查缺补漏。

第一类人群的问题是，不知道自己会什么。第二类人群的问题是，不知道自己不会什么。第三类人群的特点是，还想知道这里有什么。那么，你是第几类呢？

最终，我把主要目标定在了第二类人群。我想通过 30 节课，尽量让这类学习者在编程学习过程中，发现自己还不会什么，以便日后遇到问题的时候，能自行搜索到解决方案。这种内容上的设计，可能对于第一类学习者而言，难度会大一点儿，但也绝对不是说完全学不会，你依然可以有不小的收获。而对于第三类学习者呢，所谓教学相长，当我给你带去不一样的知识学习视角的时候，我也希望你能提出自己的建议。专栏不是著书立言的地方，而应该是咱们有幸相遇的场所，教学相长，最好不过。

以上就是我在设计这个专栏时候的目标。

讲课的目标

有了目标以后，你可能觉得接下来的事情就很简单了，只需要花时间围绕这个目标，往下做就好了。可真的一点儿难度没有么？其实也不是。最大的难题，就是我需要兼顾平日里的工作与专栏更新节奏，毕竟我这个祖国的大花朵年龄也不小了，也越来越经不起熬夜的摧残了。这是一个痛苦而又难忘的过程，所以我在写这篇结束语的时候，自己都有些难以置信，我竟然坚持了下来。

记得我在山东上小学的时候，作为一个东北孩子，我靠着自己的一个绝技，迅速地和班里其他同学打成一片。别误会，我的绝技可不是唱东北二人转，而是给班里其他孩子讲课。当时，很多学生都记不住英语单词的发音，这也不能怪我们，你要知道，我们英语老师教的可是地道的山东口音英语。

当时，我只做了一件事情，就是把每一个英语单词，都翻译成读音相近且意义相关的中文。例如，香蕉和苹果，我就会这样和同学们说：香蕉就是不能拿，“不拿呢”老师就会喜欢你，你要是拿了，就得“挨炮”。他们听了哈哈大笑，无一例外都记住了。

后来自己也开始当老师教课了，回想起小时候的事情，就容易让我产生一个疑问，老师讲课的目标到底是什么呢？都说“师者，传道授业解惑者也”，传道、授业、解惑，起初我觉得这是三种境界，可后来我慢慢发现，很少有人能同时做好这三件事情。

就我来说，每当我给别人讲课的时候，我就在想自己何以为师？我连“道”在哪里都没有看到，更不要提“传道”了。要说“授业”呢，现在是一个信息爆炸的时代，想欣赏任何一个时代的智慧，只需要上网搜索即可，我也只是欣赏这些智慧的旁观者。最后，我所能做的，就是结合我十几年的编程经验，给初学者们“解惑”了。

计算机科学在所有学科中，是一门年轻的学科。年轻学科，就意味着现在我们所接触的这个学科的教学方法和教学内容，有可能是错误的，也有可能需要被淘汰的。当然，这也是年轻学科的一个好处，一切都处于探索期，没有什么标准答案。

在计算机领域，作为一个负责“解惑”的老师，我就是找到一个更好的角度切入知识点的讲解，让学习者可以更能听得懂，学得会，记得住。而找到这个知识讲解角度，就是我讲课的目标，尽管这个角度有时候看起来很刁钻。但是，说不定在不久的将来，它们会成为主流的讲解方式。

目标的作用

目标，指引着我们发力的方向，没有目标，就容易陷入迷茫。我们的专栏也一样，它需要确定目标人群，才能使内容设计上不至于跑偏。讲课需要目标，否则容易变成 PPT 的朗读工具和催眠神器。学习需要设置合理化目标，否则容易丧失兴趣与信心。

小学的时候，我们的目标是“好好学习，天天向上”；初中的时候，我们的目标也是“好好学习，天天向上”；高中的时候，我们的目标还是这八个字。如果说在高中之前，你都没有怀疑过这个目标的正确性，那到了大学，依然将“好好学习，天天向上”作为目标，你会作何感受？难道不应该深思一下这个目标是否明确吗？

之前我有朋友问我：“你没参加过高考，遗憾不？”我的答案从来都是“没什么遗憾的”。因为这就好像是两个人一起去郊游，他们各自走了两条不一样的路线，从任何一方来看，对方都有缺失，要说遗憾，其实是两个人都有遗憾。所以，千万不要把自己的目标，钉到别人的靶子上，反过来，也不合适。

我可能不清楚你会去往何方，可我还是想通过这 30 节课的专栏学习，能给你未来的编程学习提供一些建议，让这段学习之旅更加平坦，更加快捷。我也希望你能在学习过程中，逐渐看清自己的目标，并坚定自己的信念，一步一个脚印地去实现自己的目标。

好了，最后的唠叨就到这里了，我是胡光，不是老师，是船长。我们有缘再见！