This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,247 @@
<audio id="audio" title="12 | 数学归纳法:搞定循环与递归的钥匙" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/92/29/9238c982114243a9eb9c437d4264dd29.mp3"></audio>
你好,我是胡光,今天我们正式开始“编码能力训练篇”的学习。
这里给你一个建议,在刚刚完成了语言基础篇的学习后,我希望你用心地体验“螺旋式上升”的学习过程。就是前面的基础篇虽然学完了,可并不是意味着,不需要再学习更多的语言相关的东西了,你可以做如下两件事情:
1. 对于语言基础,你可以选择学习第二遍,当你站在第一遍的基础上,再回头看的时候,肯定会对之前的知识有更深的理解;
1. 选择在其他参考资料中,继续学习语言中更多的知识点。你会发现,某些之前自己认为晦涩难懂的东西,可以自学搞明白了,这就是我提到的“螺旋式上升”的学习方法。
在接下来的“编码能力训练篇”里,我将着重给你讲解一些编程中的重要技巧。今天呢,我们就从理解循环与递归的编码技巧开始吧!
## 今日任务
循环结构,你已经不陌生了,如下代码所示,是一个单层循环的程序,依次地输出从 1 到 n 的每一个数字,每个数字占一行:
```
#include &lt;stdio.h&gt;
int main() {
int n;
scanf(&quot;%d&quot;, &amp;n);
for (int i = 1; i &lt;= n; i++) {
printf(&quot;%d\n&quot;, i);
}
return 0;
}
```
当我们输入 4 的时候,程序的输出结果如下所示:
```
1
2
3
4
```
上面这个是单层循环的情况。下面这个例子,是一个双层循环的例子,每层循环都从 1 循环到 n循环内部每次输出两个循环遍历的值
```
#include &lt;stdio.h&gt;
int main() {
int n;
scanf(&quot;%d&quot;, &amp;n);
for (int i = 1; i &lt;= n; i++) {
for (int j = 1; j &lt;= n; j++) {
printf(&quot;%d %d\n&quot;, 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. 温故知新:数学归纳法
你知道么,计算机的本质,是一个用来计算的工具,它最开始就是帮助我们完成一些现实世界里面的计算任务,并且完成的又快又好。那么现实世界的问题,是如何转换成可以在计算机中计算的任务呢?这个转换的过程中,都有哪些必不可少的东西呢?请看下图:<br>
<img src="https://static001.geekbang.org/resource/image/65/9b/65c32d9a5d416d8e8c65783ae59d4a9b.jpg" alt="" title="图1:从现实问题到可计算任务">
在这幅图中,我们把转换过程分成四个部分:“现实世界”“数学”“算法”和“计算机”。这四个部分形成了一个路线,也就是从现实世界中的实际问题,到计算机中的可计算任务的过程。
我稍微来详细解释一下这幅图所表达的含义。首先我们来想想,如果没有数学,现实生活中我们会遇到什么困难?我会毫不夸张地告诉你,可能会面临生存危机。试想一下,因为没有数学,我们不会计算每日食物的消耗,无法合理分配资源,导致食物匮乏,引发生存危机。这也是为什么人类最早的文字记录,或者说是信息传递,用的是结绳记事,以“算术”的形式来解决现实世界问题。可以说,现实世界中的问题,本质是可以计算的,也就是说实际问题都可以做数学建模。
然后我们说说算法。算法是将数学问题转换到计算机中的计算任务的桥梁。因为计算机是依靠指令序列来执行的而不同的指令序列代表了不同的效率不同的效率在很多时候就意味着可行或者不可行。试想一个数学抽象出来的公式需要计算机运算1000年才能得出结果你认为这种任务可以放到计算机上面做么答案显然是否定的。算法就是使得计算任务变得更高效更可行。
至此,你就对我所说的内容,有个大致的体会了:计算机的核心是算法,算法的核心是数学。接下来呢,我们就需要介绍一种,可以指导我们进行程序设计的数学方法:数学归纳法。
高中的时候,我们就接触过数学归纳法,你可能已经对这个概念了然于胸,不过我们还是来回顾一下数学归纳法证明过程中重要的三步骤。<br>
<img src="https://static001.geekbang.org/resource/image/d6/c3/d6624009d55447e273fc58a8799afbc3.jpg" alt="" title="图2:数学归纳法的三个步骤">
其实数学归纳法的三个步骤,总结起来就是,有一个已知正确的初始状态,然后证明如果前一个状态成立,那么后一个状态也成立(这一步主要在做过程正确性的证明),最后就是得出结论,在这个初识状态和转移过程的正确保证下,所有问题中的状态都成立。
举个例子,便于你更好地理解。假设我们要利用数学归纳法来证明:如果我推倒了第一块多米诺骨牌,那么所有的多米诺骨牌都会倒下。那么放到这三个步骤里,就是:
- 第一步,验证边界条件,第一块多米诺骨牌倒下了。
- 第二步,就是假设,第 n 块倒下了,根据多米诺骨牌的结构性质,那么如果存在 n + 1 块,第 n + 1 块也一定会倒下。
- 第三步,得出结论,只要第一块倒了,所有的多米诺骨牌都会倒下。
注意,上面说的这个是广义层面数学归纳法,这个过程对于循环过程的正确性证明,是非常有效的。
想一想,进入循环之前的程序中关键变量的值,就是上面所说的第一步中的 k<sub>0</sub>;而每一次的循环,其实就是第二步中所要证明的那个上一个状态到下一个状态的过程。如果这两者都正确,我们就能很确信地知道,我们的整个循环过程就是正确的。
关于上面说的数学归纳法和循环程序之间的这一点联系,在日后的学习中,我还会详细地去举例说明,尤其是到了后续,我们学到了递推算法和动态规划算法的时候,会尤为明显。所以你要有足够的耐心和信心,咱们一起把这些问题搞懂。
#### 2. 深入浅出:理解递归函数
放在编程的语境中,什么是递归呢?我这里先强调一句:递归是一种编程技巧。
你学完了函数以后,已经可以熟练地掌握在一个函数中,调用另外一个函数的方法了。可你有没有想过,如果在某个函数内部,调用自己同名函数过程,会发生什么?其实,和普通的函数调用过程一样,在具体执行过程中,只有等内部调用的函数执行完后,本层函数才会继续执行。
递归是一个过程,这个过程的每一步都类似,只是面对的问题规模不同。
下面我来举个例子假如今年我上小学5年级我现在想知道15年级的年级主任名字但我现在只知道5年级的年级主任的名字我可能会问一个4年级的学弟希望他能告诉我14年级主任的姓名。
我这个学弟呢也只知道他们年级主任的名字那么我这个学弟就会问3年级学弟问他3年级及以下的年级主任都有谁依次类推最后到了1年级的小学弟。
1年级的小学弟就会告诉2年级的学长自己年级主任的名字2年级的学长拿到1年级的年级主任的名字以后会把2年级年级主任的名字填上去然后再交给3年级的他学长……这样最终到我手里的就会是14年级的年级主任的所有名字再加上我自己知道的5年级的年级主任姓名这样我就知道了全部信息。整个过程如下图所示<br>
<img src="https://static001.geekbang.org/resource/image/71/5a/713f6589e7b8eb51c8af82ddc1efa65a.jpg" alt="" title="图3:年级主任问题示意图">
在这个过程中,每个人问学弟的过程,就是我们所谓的“递”,而拿到学弟给的结果名单以后,再加上自己知道的结果反馈给自己学长的这个过程,就是“归”,整个过程就是我们所谓的“递归”。“递归”的过程,每一步的过程类似,可是问题规模不同。
接下来,我来举一个编程中的具体递归例子,看如下代码:
```
#include &lt;stdio.h&gt;
int f(int n) {
if (n == 1) return 1;
return f(n - 1) * n;
}
int main() {
int n;
scanf(&quot;%d&quot;, &amp;n);
printf(&quot;%d\n&quot;, 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 &lt;stdio.h&gt;
int fib(int n) {
if (n == 1 || n == 2) return 1;
return fib(n - 1) + fib(n - 2);
}
int main() {
int n;
scanf(&quot;%d&quot;, &amp;n);
printf(&quot;%d\n&quot;, fib(n));
return 0;
}
```
上面这段程序中fib 函数是求菲波那契数列第 n 项值的函数。菲波那契数列的定义如下:<br>
<img src="https://static001.geekbang.org/resource/image/fa/9a/faa57fedb330f6c3fa27c22aac2f739a.jpg" alt="" title="图4:斐波那契数列">
根据如上内容,你需要完成两个小的思考题:
1. 请将上述菲波那契数列求解的程序从递归程序,改成循环程序。
1. 请将上述递归程序的代码和数学归纳法中的步骤做一一对应,留在留言区中。
## 完成不定层数的循环程序
准备完了基础知识以后,让我们回到今天的任务,完成一个可变循环层数的程序。我们可以一开始假设,有一个函数,是实现 5 层循环打印的程序,那么它会循环 n 次,每次调用一个实现 4 层循环打印的程序。
依照这个大体的思路,我们就可以写出如下代码框架:
```
int print_loop(int k, int n) {
if (k == 0) {
// 打印一行
}
for (int i = 1; i &lt;= n; i++) {
print_loop(k - 1, n);
}
return;
}
```
在这个代码框架中我们先来看递归的过程print_loop(k, n)代表 k 层循环的程序,然后循环 n 次,每次调用一个 k - 1 层循环的程序。而递归的边界条件就是当 k == 0 的时候,就是所谓的 0 层循环,也就是程序打印一行具体内容的地方,可打印的这行内容究竟是什么呢?
你会发现,要打印的这行内容,与每层循环遍历到的数字有关系,那么我们就需要记录每层循环遍历到的数字。这个信息,我们可以记录在一个数组中,数组中存储的,就是当前要打印这行的每一个数字。基于上述代码框架,我们就可以得到下面这个更完善的代码:
```
int arr[100];
void print_loop(int k, int n, int total_k) {
if (k == 0) {
for (int i = total_k; i &gt;= 1; i--) {
if (i != total_k) printf(&quot; &quot;);
printf(&quot;%d&quot;, arr[i]);
}
printf(&quot;\n&quot;);
return ;
}
for (int i = 1; i &lt;= n; i++) {
arr[k] = i;
print_loop(k - 1, n, total_k);
}
return ;
}
```
正如你看到的,我们把每一层循环的值,放到了一个 arr 数组中,第 k 层循环变量的值,存储到 arr[k] 的位置。而在上述代码中,多了一个递归参数,就是 total_k代表了一共有多少层循环这个参数是为了方便我们最后确定循环输出的上界。至此我们就完成了今天的任务。
## 课程小结
今天的重点,一个关于数学归纳法,一个关于递归,需要你记住如下两点:
1. 数学归纳法中重要的两部分,一是要边界条件成立,二是证明转移过程成立。
1. 程序设计最重要的是正确性,递归函数的正确性可以利用数学归纳法来保证。
关于数学归纳法和递归函数的设计,还需要你在日后不断的加以练习。注意总结两者的联系,能够使得你在接下来的学习中事半功倍。
好了,今天就讲到这里,我是胡光,我们下期见。

View File

@@ -0,0 +1,126 @@
<audio id="audio" title="13 | 程序设计原则:把计算过程交给计算机" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d6/00/d64904c97ff6fd52433acc26f0121b00.mp3"></audio>
你好,我是胡光,欢迎回来。
上一节中,咱们说了数学思维对于编程的重要性,并且跟你介绍了一种最重要的程序设计思维:数学归纳法。这个思维,不仅可以帮助我们设计程序,而且还可以帮助我们理解及证明程序的正确性。
不过说了这些数学对编程的重要性,可能你还觉得不过瘾,感觉只是停留在理论层面,还是有一层窗户纸没有捅破。今天呢,我就给你带来一道具体的编程问题,从这个具体的问题中,让你过把瘾。
## 一道简单的数学题
首先,我们先看一道很简单的数学问题,求出 1000 以内所有 3 或 5 倍数的数字的和。什么意思呢我们先缩小范围就是求10 以内,所有 3 或 5 的倍数。我们很快就能找到,这里有 3、5、6、9 ,它们相加之和是 23。注意这里说的是 10 以内,所以不包括 10。
回到1000以内这个原问题这个问题其实很简单可能你现在就想马上撸起袖子开始写代码了。可别急听我给你分析分析怎么做才算是又好又快地用程序解决这个实际的数学问题。
#### 1.把计算过程,交给计算机
一个简单的疑问,我们为什么要写程序,让计算机帮我们算这个问题呢?那是因为,计算机的计算速度,比我们人类要快上几百几千倍不止,出错率也比我们要低得多。我们写程序的一个目的,就是减少我们人类在解决问题中的**具体计算过程**,那什么叫做具体计算过程呢?
例如,当你写一行代码“ 3 + 5 ”的时候,这是把计算过程交给了计算机,而如果你直接在程序中写上了 8 这个结果的时候,相当于你自己做了这个计算过程。因此,所谓减少我们的具体计算过程,就是能在程序中写 3 + 5就写 3 + 5不要写 8。
这就是我要强调的,要把计算过程交给计算机来做,而不是我们自己来做,毕竟计算机是很擅长做这种事情的,你没必要替它省这个事儿。在这样的指导思想下,我们先来看下面这段程序:
```
#include &lt;stdio.h&gt;
int main() {
int sum = 0;
for (int i = 1; i &lt; 1000; i++) {
sum += i * (i % 3 == 0 || i % 5 == 0);
}
printf(&quot;%d\n&quot;, 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 &lt;stdio.h&gt;
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(&quot;%d\n&quot;, sum3 + sum5 - sum15);
return 0;
}
```
上面程序中,有三个整型变量分别代表 1000 以内所有 3 的倍数的和 sum3所有 5 的倍数的和 sum5和所有 15 倍数的和 sum15。最后呢用 sum3 + sum5 - sum15 的值,代表了 3 或 5 的倍数的和。你对这个结果可能有点反应不过来,听我继续给你解释。
假设,我们现在手上有两个集合,第一个集合中装的是所有 3 的倍数,第二个集合中装的是所有 5 的倍数,想想两个集合的交集是什么?是不是就是所有 15 的倍数。那么当我们用第一个集合的所有元素和,加上第二个集合中的所有元素和的时候,两个集合交集中的元素,被重复加了一次。所以,最后再减去两个集合交集中的元素和即可。如上所述的程序思路,你可以参考如下示意图。<br>
<img src="https://static001.geekbang.org/resource/image/11/4f/11df28dd9816e329c693e370e5596e4f.jpg" alt="" title="图1:问题的集合表示">
看完了程序思路以后,我们来具体看一下其中的代码,就拿 sum3 的计算过程来举例,其实使用的就是“等差数列求和公式”,如果你忘了等差数列求和公式,请看下图:<br>
<img src="https://static001.geekbang.org/resource/image/2a/96/2adc14943c92dc45db6cd7a4273f3096.jpg" alt="" title="图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。<br>
<img src="https://static001.geekbang.org/resource/image/2d/5d/2d0ed3409b33a106e38b10e2827a405d.jpg" alt="" title="图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 所有数字相加之和,然后再取平方的值。
#### 思考题:和的平方减平方和
今天我们的思考题呢,分成两个子问题:
>
<p>1.请编写一个程序,计算 100 以内自然数“和的平方”与“平方和”的差。<br>
2.通过今天的学习,我们复习了等差数列求和公式,那你能否通过查阅资料,推导得到等差数列的平方和公式呢?</p>
## 课程小结
好了,最后我们来做一下今天的课程小结吧。通过今天这个简单的小任务,我希望你记住如下三点:
1. 具体的计算过程,计算机比你更擅长,所以请把具体的计算过程,留给计算机。
1. 编写程序,其实是在描述和规定计算过程,而描述的方式不同,效率也不同。
1. 不同的效率过程,就是我们所谓的不同的算法过程,记住:算法很重要。
关于“算法很重要”这句话,你可能有点儿听腻了,可我还是要强调一遍:所谓算法,叫得上来名字的算法是算法,还有很多叫不上来的名字,其实也是算法。两者放在一起,统一被描述成为“算法思维”。你想掌握一个有名字的算法很容易,可要掌握“算法思维”可就没那么容易了,这是需要很长一段时间的锻炼、总结和积累。
好了,今天就到这里了,不积跬步,无以至千里,希望你在看完本节课后,自己也多加练习体会。我是胡光,我们下期见。

View File

@@ -0,0 +1,181 @@
<audio id="audio" title="14 | 框架思维(上):将素数筛算法写成框架算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/40/7f/4082ece80b2ead00fca42b52657d907f.mp3"></audio>
你好,我是胡光,咱们又见面了。
上一节呢,我们提到了一个词,叫做“算法思维”,就是用算法去解决问题的思维方式,并且说明了算法思维有别于我们通常所说的“算法”。那么如何锻炼算法思维呢?
今天我要说的这个方法,就叫做“照猫画虎”。什么意思呢?如果我们把一个个具体的算法称之为猫,而每个具体算法中所能锻炼的“算法思维”就是那只虎。也就是说,我们可以通过学习一些简单具体的算法,来总结一些重要的算法思维。
接下来的两节中,我先带你锻炼的是算法思维中的“框架思维”,所谓框架思维就是将一个具体的算法学成一个框架,变成一个可以解决多个问题的利器。废话不多说,开始今天的课程吧。
## 今日任务
在开始今天的学习之前,先让我们来看看今天这 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 这些数字分别标记为合数。
这个做法里面,你会发现好像有一个死结,我们要标记掉所有合数,就需要找到所有素数,这就又回到最开始素数筛要解决的问题,这不就变成了一个先有鸡,还是先有蛋的问题了么?其实不然,下图是我整理的算法流程:<br>
<img src="https://static001.geekbang.org/resource/image/ed/7b/ed6912b507bb8f08fe2b6c27a62d1c7b.jpg" alt="" title="图1素数筛算法流程">
素数筛算法从 2 开始,执行若干轮,每一轮呢,找到第一个没有被标记掉的数字,可以猜想到,这个数字就一定是素数。为什么呢?其实用我们之前说的“数学归纳法”就可以证明。
首先2 是第一个没有被标记的数字,所以 2 肯定是素数,然后我们可以正确的标记掉所有 2 的倍数。假设在数字 n 之前,我们正确找到了所有素数,并且将这些素数的倍数均标记掉了,那么 n 作为后续第一个没有被标记掉的数字n 就一定素数,最后,我们可以用 n 标记掉 n 所有的倍数,这也就保证了后续过程的正确性。在这个过程中,其实也证明了整个素数筛算法的正确性。
为了让你有个更直观的感受我给你整理了10以内素数筛算法前三轮的示意图<br>
<img src="https://static001.geekbang.org/resource/image/f2/9e/f2d266463bff797dc25b6bbef978a09e.jpg" alt="" title="图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 &lt;= 10000; i++) {
if (prime[i]) continue;
// 用 j 枚举所有素数 i 的倍数
for (int j = 2 * i; j &lt;= 10000; j += i) {
prime[j] = 1; // 将 j 标记为合数
}
}
return ;
}
```
如代码所示init_prime 就是素数筛算法的过程,并把最终生成的信息都存储在了 prime 数组中如果prime[i] 为 1 ,说明 i 是合数。
这个算法流程中呢,包含了两层循环结构,外层循环结构,从 2 开始遍历到根号 10000也就是 100。其中这里还用到了一个编程技巧原本代码应该写成i &lt;= sqrt(10000) 的这个不等式,而加上了左右平方,就变成了上面的 i * i &lt;= 10000 这样的代码。这种改变是有好处的,会在代码运行速度上做提升,毕竟开方运算是很慢的,远远没有单独做一个乘法操作要快。
第 5 行代码,是判断 i 这个数字是否被标记过的如果被标记过就说明是合数就不执行后续操作。当代码到了第6行的时候说明此时 i 这个数字,一定是素数,我们就用内部的 j 循环,遍历所有数字 i 的倍数,并且将 prime[j] 标记为 1也就是将 j 这个数字标记为合数。
执行完 init_prime 函数以后prime 数组中就是所有合数的标记信息,反向思维就能找到所有素数,就是那些没有被标记掉的数字。
在这份代码中,你需要注意以下两点:一是到了代码的第 6 行,数字 i 有什么特性?二是为什么外层循环 i 只需要遍历到根号 10000 即可?
第一点比较好理解到了代码第6行这时候访问到的 i 一定是素数。第二点呢,就要从合数的特点思考了,合数一定可以表示为两个非 1 整数的乘积形式否则那就是素数了。例如6可以拆解成 2 * 339 可以拆解成 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 &lt;stdio.h&gt;
// 打印一个素因子,并且在中间输出 * 乘号
void print_num(int num, int *flag) {
if (*flag == 1) printf(&quot; * &quot;);
printf(&quot;%d&quot;, num);
*flag = 1;
return ;
}
int main() {
int n, i = 2, flag = 0, raw_n;
scanf(&quot;%d&quot;, &amp;n);
raw_n = n;
// 循环终止条件,循环到 n 的平方根结束
while (i * i &lt;= n) {
//①:只要 n 可以被 i 整除,就认为 i 是 n 的一个素因子
while (n % i == 0) {
print_num(i, &amp;flag);
n /= i;
}
i += 1;
}
//②:如果最后 n 不等于 1就说明 n 是最后一个素数
if (n != 1) print_num(n, &amp;flag);
printf(&quot; = %d\n&quot;, raw_n);
return 0;
}
```
今天的任务呢,就是请你解释 ① 处和 ② 处所写注释的正确性,也就是证明:
1. 第 18 行代码中,只要 n 可以被 i 整除i 就一定是素数,为什么?
1. 第 25 行代码中,为什么只要 n 不等于1n 就一定是素数呢?
由于程序中用了循环,那么循环程序正确性的证明,你还记得吧?需要用到“数学归纳法”。而今天这两个程序过程中具体的证明,我可以给你一个小提示,尝试用“反证法”证明一下。
## 计算素数和
准备完了前面这些基础知识以后,最后让我们回到今天的任务:求出 1 万以内所有素数的和。如果你掌握了素数打表相关的算法以后,就很容易整理出解题思路,那就是利用素数打表算法标记掉 1 万以内所有的合数,然后将剩余的所有未被标记的数字相加,即可得到我们想要的结果。代码也不难,如下所示:
```
#include &lt;stdio.h&gt;
#define MAX_N 10000
int prime[MAX_N + 5];
// 初始化素数表
void init_prime() {
prime[0] = prime[1] = 1;
for (int i = 2; i * i &lt;= MAX_N; i++) {
if (prime[i]) continue;
for (int j = 2 * i; j &lt;= MAX_N; j += i) {
prime[j] = 1; // 将 j 标记为合数
}
}
return ;
}
int main() {
init_prime();
int sum = 0;
for (int i = 2; i &lt;= MAX_N; i++) {
sum += i * (1 - prime[i]); // 素数累加
}
printf(&quot;%d\n&quot;, 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 宏这个技巧以后呢,我们只需要修改代码的一个地方,就可以确保,程序中所有和范围相关的地方,都被修改掉了。
## 课程小结
最后我们来做一下今天的课程总结,我希望你记住如下三点:
1. 想把具体“算法”升华成“算法思维”,首先要习惯性地总结算法的“框架思维”。
1. 素数筛是用素数去标记掉这个素数所有的倍数。
1. 清楚地知道素数筛在执行过程中,每一行的性质。
这里,我希望你一定要熟记素数筛的算法框架,下一节我们将使用素数筛这个框架,解决几个其他问题,让你好好体会一下算法代码的“框架思维”。
好了,今天就到这里了,我是胡光,我们下期见。

View File

@@ -0,0 +1,192 @@
<audio id="audio" title="15 | 框架思维(下):用筛法求解其他积性函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/15/7542eba5f1669b70fb92e6f6d50ba715.mp3"></audio>
你好,我是胡光,咱们又见面了。
上一节,我们讲了素数筛这个算法,并且强调了,要按照框架思维去学习算法代码,因为当你学会这么做的时候,它就可以变成解决多个问题的利器了。
本节我将带你具体使用素数筛算法框架,去解决一些其他简单的数论问题。通过解决这几个具体问题的过程,我希望你能找到“框架思维”的感觉。
## 今日任务
今天这个任务,需要你依靠自己的力量来完成。不过你也不用担心,我会把需要做的准备工作都讲给你。
这个任务和因数和有关什么叫做因数和呢就是一个数字所有因数的和。那么什么是一个数字的因数呢因数就是小于等于这个数字中能整除当前数字的数。例如28 这个数字的因数有 1、2、4、7、14、28 ,因数和就是各因数相加,即 56。
所以今天我们要做的,就是求出 10000 以内所有数字的因数和。你明白了要算的结果后,可能已经想出采用如下方法来解决:
```
#include &lt;stdio.h&gt;
int sum[10005] = {0};
void init_sum() {
// 循环遍历 1 到 10000 的所有数字
for (int i = 1; i &lt;= 10000; i++) {
// 用 j 循环枚举数字 i 可能的因数
for (int j = 1; j &lt;= i; j++) {
// 当 i%j 不等于 0 时,说明 j 不是 i 的因数
if (i % j) continue;
sum[i] += j;
}
}
return ;
}
int main() {
init_sum();
printf(&quot;hello world\n&quot;);
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关于最大公约数的相关内容的话是小学的基本内容如果你实在是忘记了就自行上网搜一下吧我就不再赘述了。总地来说只要一个函数满足以上两点我们就可以称这个函数为数论积性函数。
这里我给出一个具体示例,帮助你理解:<br>
<img src="https://static001.geekbang.org/resource/image/67/99/670345a0e7c138de9ced322df04b9899.jpg" alt="">
其实我给你讲述这个数论积性函数这个定义的时候呢,并不希望你对它是死记硬背,而是希望你在理解这个定义的时候,可以凭借敏锐的嗅觉,或者说培养自己这方面的意识,能在这里面想到更多。
什么意思呢?当你看到数论积性函数中的 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 的因数。<br>
$$<br>
\begin{aligned}<br>
12 &amp;= 2^{2}\times3^{1} \\\<br>
1 &amp;= 2^0\times3^0 \\\<br>
2 &amp;= 2^1\times3^0 \\\<br>
4 &amp;= 2^2\times3^0 \\\<br>
3 &amp;= 2^0\times3^1 \\\<br>
6 &amp;= 2^1\times3^1 \\\<br>
12 &amp;= 2^2\times3^1 \\\<br>
\end{aligned}<br>
$$
正如你所看到的,在构造 12 的因数的时候2 的幂次从 02 有 3 种取值3 的幂次从 01 有2 种取值总共的组合数就是3 * 2 = 6 个也就是说12 一共有 6 个因数。
最后,就让我们来总结一下,如何计算一个数字的因数数量。对于一个数字 N假设数字 N 的素因子分解式可以表示为:<br>
$$<br>
\begin{aligned}<br>
N = {p_1}^{a_1}\times{p_2}^{a_2}\times{p_3}^{a_3}\times…\times{p_m}^{a_m}<br>
\end{aligned}<br>
$$<br>
其中,$p_i$,就是数字 N 中的第 i 种素因子,$a_i$ 就是第 i 种素因子的幂次。根据上面我们对于 12 这个数字因数数量的分析,就可以得到数字 N 的因数数量函数 g(N) 的公式表示:<br>
$$<br>
\begin{aligned}<br>
g(N) = ({a_1 + 1})\times({a_2 + 1})\times({a_3 + 1})\times…\times({a_m + 1})<br>
\end{aligned}<br>
$$<br>
正如你所见g 函数计算的就是数字 N 中各种素因子幂次数的一个组合数,就是数字 N 的因数数量。而这个 g 函数呢,就是我们之前所说的数论积性函数。对于数论积性函数来说,关键就是证明第二点,即当 n 和 m 互素g(n * m) = g(n) * g(m)。关于这个证明,首先我们先把 n 和 m 的素因子分解式和因数数量表示出来:<br>
<img src="https://static001.geekbang.org/resource/image/e3/c5/e34584588b5a339ed128c7a943db5ac5.jpg" alt=""><br>
因为 n 和 m 互素,所以 n * m 的素因子分解式和因数数量表示出来,就如下式所示:<br>
<img src="https://static001.geekbang.org/resource/image/36/31/36c4bd2e1df671298d2f86d830a34b31.jpg" alt=""><br>
这样,我们就证明了,在 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 &lt;= MAX_N; i++) {
if (prime[i]) continue;
// 素数中最小的素因子是其本身
prime[i] = i;
for (int j = 2 * i; j &lt;= MAX_N; j += i) {
if (prime[j]) continue;
// 如果 j 没有被标记过,就标记成 i
prime[j] = i;
}
}
for (int i = 2; i &lt;= MAX_N; i++) {
if (prime[i] == 0) prime[i] = i;
}
return ;
}
```
正如代码所示init_prime 函数是初始化 prime 数组信息的方法,只不过是 prime 数组中记录的信息与之前的素数筛程序不同了。这个程序中prime[i] 中记录的是数字 i 中最小的素因子例如prime[8]中记录的是 2prime[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 &lt;= 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 的素因子分解式的观察和思考,得到相关的推导公式。并且,我这里可以预先给你一个确定性的结论,那就是因数和公式,本身也是数论积性函数。
说到这里,你可能就明白了,今天这堂课的作业,其实就是让你参照本节求解“因数数量”的过程,完成求解“因数和”的任务。你需要自行搜索的内容就是约数和公式,或者可以搜索任意一篇相关数论积性函数的文章,里面大概率也都会讲到这部分知识,然后找到解题方法。
## 课程小结
最后,我们来做一下今天的课程总结。我就希望你记住一点:所谓代码框架,就是要活学活用。
因为在真正的工作中,你所做的事情,大多是在多种代码框架之间做选择及组合拼装,每个算法代码只会解决遇到的一部分问题。而你在使用这些算法代码的时候,往往不能照搬照用,反而要做一些适应性的改变,这些都是“框架思维”中所重视的。
好了,今天就到这里了,我是胡光,我们下期见。

View File

@@ -0,0 +1,163 @@
<audio id="audio" title="16 | 数据结构(上):突破基本类型的限制,存储更大的整数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/92/06dd572bd3c1f52b28dd276cc19f7692.mp3"></audio>
你好,我是胡光,咱们又见面了。
上两节呢,我们讲了素数筛这个算法,并且用素数筛算法演示了程序设计过程中的框架思维。其中提到了欧拉筛法,不知道勤奋的你有没有课后自己去学习一下呢?如果你学习了欧拉筛法以后,你会对我所说的框架思维有更深刻的体会。
在之前的文章中,我们介绍过算法和数据结构的作用。当时我讲到,算法的作用是做数据的计算,并且它对于编程的重要意义,不止是停留在那些叫得上来名字的具体算法上面,而是我们称之的算法思维。
算法思维的具体表现,就是我们处理得到相同信息时,所采用的不同的流程方法。这些方法呢,有好坏高低的比较,而评价的标准,主要就是**从时空复杂度方面来考量**。由于本专栏主要是教会你掌握编程思维,所以,即使你对时空复杂度不是很了解,也不用担心它会影响你的入门编程学习。你只需要知道,这是我们衡量算法好坏的重要指标即可。
前两篇文章呢,其实更多的就是给大家展示算法思维对于程序设计的重要性,并且,我还要在这里提醒一句,算法的底层是数学,适当的补充数学基础,对于算法的学习是有奇效的。
数据结构和算法,前者负责“表示数据”,后者负责“处理数据”。接下来,我将给你讲讲数据结构的重要性。
## 今日任务
表示数据到底是什么呢?为什么表示数据很重要?通过今天的 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 的吧:<br>
<img src="https://static001.geekbang.org/resource/image/bf/6d/bf84afda4623d6e9471be24b6325896d.jpg" alt="" title="图1大整数表示示意图">
正如你所看到的这种表示法中使用数组的第0位存储数字的位数因为 3526 有 4 位,所以数组的第 0 位就设置成了 4 这个值。接下来,数组从第 1 位到第 4 位记录的就是原数字 3526可是你有没有发现这个数字是好像是倒着放置的数字的最高位也放在数组的最高位中在图上看着感觉怪怪的。
你可能会觉得别扭,可我要告诉你,这种存储方式不是无缘无故的,而是凝结了前人的智慧。最直接的一个好处,就是当你拿着两个这样的大整数做加法,产生一个新的大整数的时候,这个新产生的大整数会涉及到进位问题。
例如95 + 12 = 107两个两位的大整数相加产生一个三位的大整数。在这种从右到左的倒着存储表示法中是向着数组高位去进位去扩充位数这是便利可行的。可你要是从左到右去正着存储你会发现一旦最高位产生进位就很难处理。
#### 2.如何计算大整数加法
你可能还是不太理解,这种大整数表示法的好处,下面我们就拿“大整数加法”来举个例子。顺便也向你展示一下,我们究竟是如何操作这种大整数。
大整数加法,顾名思义就是利用大整数表式法,做加法运算。具体怎么做,你应该还记得小学时候,老师教给我们的加法竖式吧?其实大整数加法,本质上就是参考这种竖式计算法,把每一位对齐,然后按位相加,加完以后再统一处理进位。下面,我用一张图说明大整数加法,是如何计算 445 + 9667 的:<br>
<img src="https://static001.geekbang.org/resource/image/6a/44/6a054bbbc6cca21bfc20034f0466aa44.jpg" alt="" title="图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] &lt; b[0]) swap(a, b);
// 大整数 c 的位数以 a 的位数为基准
c[0] = a[0];
// 循环模拟按位做加法
for (int i = 1; i &lt;= a[0]; i++) {
if (i &lt;= b[0]) c[i] = a[i] + b[i];
else c[i] = a[i];
}
// 处理每一位的进位过程
for (int i = 1; i &lt;= c[0]; i++) {
if (c[i] &lt; 10) continue;
// 判断是不是最高位产生了进位
// 如果是最高位产生进位,就进行初始化
if (i == c[0]) c[++c[0]] = 0;
c[i + 1] += c[i] / 10;
c[i] %= 10;
}
return ;
}
```
## 一起动手,搞事情
今天给你留的作业题,和我给你准备的那个大整数加法的代码有关。就是请你完成一个,能够实现读入两个大整数,并且输出两个大整数相加之和的程序。关于这个程序作业,你不需要考虑负数的情况,我们假设所有数字均是正整数。
这里给你个提示:在读入两个大整数的时候,你可以按照两个字符串数据进行读入,然后再把字符串数据,转换成我们上面所说的大整数表示法,最后调用上面那个大整数加法的过程。程序的关键提示已经告诉你了,剩下的部分,试试自己完成吧,加油!
## 突破类型,求解 ${2}^{1000}$ 的值
最后,我们回到今天的任务。
要计算 2 的 1000次方的结果就是要计算 1000次乘法最终的结果由于数值太大我们肯定要使用大整数表示法了。也就是说我们要在大整数表示法的基础上操作 1000 次乘法,每次都是乘以 2那么怎么做大整数乘法呢
要想理解这个计算过程,我们还是得回到大整数表示法本身,所对应的数学模型理解上,具体请看下图:<br>
<img src="https://static001.geekbang.org/resource/image/34/f4/34f31f36797356b51ce1205c4e45fef4.jpg" alt="" title="图3大整数表示法的数学理解">
如图所示,我们把大整数表示法中,每一个数字所对应的位权写出来,那么数组中所存储 3、5、2、6 的大整数信息,其实等价于下面的那一行数学公式,即$3 * 10^{3}+5 * 10^{2}+2 * 10^{1}+6 * 10^{0}$。
我们对3526这个大整数乘以 2其实等价于对下面那个数学式子乘以 2就可以得到如下结果<br>
<img src="https://static001.geekbang.org/resource/image/1e/df/1eea8ccb5a20819051d9b71e415ed7df.jpg" alt="" title="图4大整数乘法的理解">
你会看到,对某个大整数乘 2 的操作,其实,可以看成是对这个大整数的每一位分别乘以 2 的操作,然后再仿照大整数加法的过程,依次处理进位即可。
最后,关于如何完成今天的任务,我给你一个参考程序。当然你也可以选择不看参考程序,自己实现这个过程。
```
#include &lt;stdio.h&gt;
// 将 num 数组初始化成大整数表示的 1
// 作用就是做累乘变量
int num[400] = {1, 1};
int main() {
// 计算 100 次 2 的 10 次方相乘的结果
for (int i = 0; i &lt; 100; i++) {
// 对大整数的每一位乘以 2 的 10 次方
for (int j = 1; j &lt;= num[0]; j++) num[j] *= 1024;
// 处理进位
for (int j = 1; j &lt;= num[0]; j++) {
if (num[j] &lt; 10) continue;
if (j == num[0]) num[++num[0]] = 0;
num[j + 1] += num[j] / 10;
num[j] %= 10;
}
}
// 输出大整数
// 由于大整数是倒着存的,所以输出的时候倒着遍历
for (int i = num[0]; i &gt;= 1; --i) printf(&quot;%d&quot;, num[i]);
printf(&quot;\n&quot;);
return 0;
}
```
## 课程小结
解决了这个任务后,恭喜你,又变强了一点点。今天我们学习了大整数的表示法,以及大整数加法和乘法的基本操作,我希望你记住以下几点:
1. 在大整数的表示法中,数字是从右到左,倒着存放在数组中的。
1. 大整数的表示法,体现的是数据结构对于程序设计的作用。
1. 大整数的加法和乘法过程,体现的则是算法对于程序设计的作用。
同时,你还可以看到,我们在理解大整数乘法的过程中,是从数组的表示法与数学公式的等价性这个角度出发讨论的。其实我就是想再次跟你强调那句话,就是**算法的底层是数学**。
而通过今天的学习,想必你已经对“**数据结构本质是用作数据的表示**”这句话,已经有所感觉了。综合“**算法是做数据的计算**”这句话,说明算法和数据结构是程序中可以独立进行设计的两个部分,关于这点呢,将是下一节咱们讲解的重点。
好了,今天就到这里了,我是胡光,我们下期见。

View File

@@ -0,0 +1,147 @@
<audio id="audio" title="17 | 数据结构(下):大整数实战,提升 Shift-And 算法能力" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/84/f394bf4f617c9d521d8db6cf2800fb84.mp3"></audio>
你好,我是胡光,咱们又见面了。
上节课呢,我们讲了大整数表示法的相关知识,并且给你演示了大整数加法及乘法处理过程。其实,你是否掌握了大整数表示法是次要的,主要是你可以在这个过程中,认识到数据结构的作用,也就是我强调的**数据结构就是负责表示数据**。
原先,我们之所以无法做较大整数的运算,那是因为我们所掌握的数据类型,无法表示很大的数字,有了大整数表示法以后,我们就可以做特别特别大的整数表示了。
我之前也一直在说,算法是做数据计算的,它和数据结构是程序设计中非常重要的两部分。既然是两部分,说明**算法和数据结构可以独立分开设计**。
关于这点呢,你可以想想上节课我们学的大整数加法,它其实就是算法。为什么这么说呢?你想想,这个加法过程难道是有了大整数以后,才出现的么?显然不是,即使没有大整数表示法,我们还是了解加法过程的,只不过这一次我们用大整数表示法,模拟了加法过程。因此,加法过程是一个独立的算法过程。
总而言之,就是在之前的课程中,我们确定了这样一个结论:**如果是计算流程不合理,我们需要改进算法;如果是数据表示受限,我们需要求助于数据结构。**
为了让你更清晰地认识到,算法和数据结构是两个可以独立设计的部分,今天我们通过一个具体的算法,来感受一下这个独立设计的过程。
## 字符串匹配问题
首先让我们来了解一个概念,那就是“字符串匹配问题”。什么意思呢?简单来说,就是在一个大的字符串里面,查找是否包含另外一个较小的字符串。<br>
<img src="https://static001.geekbang.org/resource/image/90/23/905e0c01811e78d0bb009e49b3be7e23.jpg" alt="" title="图1 字符串匹配问题"><br>
如图所示我们做的就是在字符串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 算法中,是将模式串的信息,转换成另外一个种信息格式,如何转换呢?如下图所示:<br>
<img src="https://static001.geekbang.org/resource/image/43/c0/4362d2e4ffbfc68f596184290caceac0.jpg" alt="" title="图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 算法,究竟是怎么用位运算来做字符串匹配的。先看下图:<br>
<img src="https://static001.geekbang.org/resource/image/12/b4/1293af0c4e3ce6a29bfe681603f19cb4.jpg" alt="" title="图3 Shift-And匹配流程的关键因素">
在图中,有一个最关键的,就是 **p 变量,它是整个匹配过程的核心变量**。我们假设模式串的长度是 m code(str[i]) 代表了文本串第 i 位字符的编码,编码方式前面已经介绍过了。整个匹配过程,从前往后,依次处理文本串的每一位,处理到第 i 位的时候,就是用第 i 位字符的编码codestr[i])),与 p 左移 1 位并或上 1 以后的值p&lt;&lt; 1 | 1做“按位与”运算把得到的值赋给 p 变量。最终,当 p 的二进制表示的第 m 位为 1 时,说明匹配成功了。
为了帮助你理解,我给你准备了一个具体示例,下图是模拟了当模式串为 cdd文本串为 acdd 时候的匹配流程:<br>
<img src="https://static001.geekbang.org/resource/image/4f/e4/4fbf1d8708304abf3359fa933f90cfe4.jpg" alt="" title="图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 &lt;&lt; 1 | 1) 这个值的第 m 位也必须是 1。
关于 (p &lt;&lt; 1 | 1) 这一部分中,或 1 操作,只能影响二进制的最低位,我们可以暂时忽略它。关键就是理解 p &lt;&lt; 1 这个操作,左移以后的第 m 位为1说明左移之前p 的二进制表示的 m - 1 位也是 1。
通过分析上一轮 p 的二进制表示的 m - 1 位为什么是 1 时,你会推理得到 str[i - 1] 必须是模式串 m - 1 位的字符。依次类推,你就会得到一个结论:文本串 str 的第 i - m 位,到第 i 位之间的字符串,其实就等于原模式串的内容。下面给你准备了一个示意图:<br>
<img src="https://static001.geekbang.org/resource/image/b3/09/b376fbe3c81bd4631dab4da7d0c8fb09.jpg" alt="" title="图5 p 公式的理解与推导">
其中 $p_m$ 代表 p 的二进制表示的第 m 位为 1$p_{m-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 &lt;&lt; i);
}
int p = 0;
for (int i = 0; str[i]; i++) {
p = (p &lt;&lt; 1 | 1) &amp; code[str[i]];
// 如果 p 所对应的模式串最高位为1代表匹配成功
if (p &amp; (1 &lt;&lt; (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 算法,对于模式串的信息,做了一个等价转换以后,整个算法的时间复杂度就被优化了一个数量级,这个过程值得你花时间去仔细体会。
本节课,也是我们整个“编码能力训练篇”的最后一节了,我希望你通过这部分知识的学习,掌握计算思维,以及程序设计的核心法门。下一章节,我不再赘述算法和数据结构的重要性,而是请你带着在“编码能力训练篇”掌握的技巧,随我进入“算法与数据结构篇”的学习吧!
好了,今天就到这里了,我是胡光,我们下章见。

View File

@@ -0,0 +1,180 @@
<audio id="audio" title="做好闭环(三):编码能力训练篇的思考题答案都在这里啦!" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/d0/74fc759542b4888949fb2ff30a6b1cd0.mp3"></audio>
你好,我是胡光。
不知不觉,我们已经学完了编码能力训练篇的全部内容。其实还有很多东西想给你讲,可限于篇幅,所以咱们整个编码能力训练篇中的内容,都是与接下来的算法数据结构篇有很大的联系,并且它们对于理解程序设计,也是非常基础且重要的内容。
有道是,授之以鱼,不如授之以渔,我也相信只要你跟着课程学习,一定会感觉到自己收获到了“钓鱼工具”。如果能引发你的主动思考,进而触类旁通,举一反三,那这场学习过程就更加有意义啦。
我也非常高兴,看到很多同学都在紧跟着专栏更新节奏,坚持学习。经常在专栏上线的第一时间,这些同学就给我留言,提出自己的疑惑。大部分留言,我都在相对应的文章中回复过了,而对于文章中的思考题呢,由于要给你充足的思考时间,所以我选择在今天这样一篇文章中,给你进行一一的解答。
看一看我的参考答案,和你的思考结果之间,有什么不同吧。也欢迎你在留言区中,给出一些你感兴趣的题目的思考结果,我希望我们能在这个过程中,碰撞出更多智慧的火花。
## 数学归纳法:搞定循环与递归的钥匙
在这一章里呢,我们介绍了保证程序正确性的最重要的数学思维:**数学归纳法**。并且,从数学归纳法出发,我们学习了递归程序设计。递归程序设计的几点要素,就是数学归纳法中的几个重要步骤。递归中的边界条件,就是数学归纳法中的 $k_0$,递归中的递归过程,就是数学归纳法中的假设 $k_i$ 成立并证明 $k_{i + 1}$ 也成立那一步,最后两步结论放到一起,就能证明我们的递归程序整体是正确的。
思考题中呢,给你留了两个问题,第一个是将菲波那契数列的递归程序,改写成循环程序,关于这个问题,你可以参考留言区中 @奔跑的八戒@徐洲更@一步@Geek_Andy_Lee00@我思故我在 等用户的答案以及我在他们当中给出的回复内容。
第二个思考题呢,是做数学归纳法与菲波那契数列递归程序步骤的一一对应,关于这个问题,请看下面我给出的参考答案,看看和你想的有什么差别吧:
```
#include &lt;stdio.h&gt;
int fib(int n) {
if (n == 1 || n == 2) return 1;
return fib(n - 1) + fib(n - 2);
}
int main() {
int n;
scanf(&quot;%d&quot;, &amp;n);
printf(&quot;%d\n&quot;, fib(n));
return 0;
}
```
其中代码的第4行n == 1 和 n == 2 的条件判断,就是数学归纳法中所谓的 $k_0$ 成立,这一步保证了,`fib` 函数计算的第 1 项 和 第 2 项的斐波那契函数值一定是正确的。代码的第 5 行中呢,就是假设 `fib(n - 1)``fib(n - 2)`的值是正确的,那么 `fib(n)` 就的值就等于 `fib(n - 1) + fib(n - 2)` ,这就是数学归纳法中的第二步,假设 $k_i$ 成立,证明 $k_{i + 1}$ 也成立。显然如果可以保证前两项的正确性,那么 `fib(n)` 的值一定正确。最后我们得出结论,这个`fib` 递归函数设计是正确的。
## 程序设计原则:把计算过程交给计算机
这一节中,我们强调了程序设计的基本原则,就是将计算过程交给计算机。我们负责逻辑组织,计算机负责具体计算过程,这就是所谓的专业的事情交给专业的人来做。
本节中的思考题是计算100以内自然数的 “和的平方” 与 “平方和” 的差值。在这里呢,我要给用户 @胖胖胖@不便明言@Geek_And_Lee00 点赞。具体的答案,你也可以参考这三个用户在留言区中的内容。
关于这道思考题的第一问,我就不给你做演示了,实现起来比较简单,你应该有能力自我完成的。下面,我主要给出 “平方和” 公式的推导过程,而对于 “和的平方” 你可以基于等差数列求和公式来求解。
教给你一种比较通用的推导平方和公式的方法,也是我用着最顺手的方法,就是依靠立方和,推导平方和。首先,我们先列出来相邻两项的立方差:<br>
$$<br>
\begin{aligned}<br>
n^3 - (n - 1)^3 &amp;= 3 \times n^2 - 3 \times n + 1 \\\<br>
(n - 1)^3 - (n - 2)^3 &amp;= 3 \times (n - 1)^2 - 3 \times (n - 1) + 1 \\\<br>
(n - 2)^3 - (n - 3)^3 &amp;= 3 \times (n - 2)^2 - 3 \times (n - 2) + 1 \\\<br>
&amp;… \\\<br>
2^3 - 1^3 &amp;= 3 \times 2^2 - 3 \times 2^2 + 1 \\\<br>
1^3 - 0^3 &amp;= 3 \times 1^2 - 3 \times 1^2 + 1<br>
\end{aligned}<br>
$$
如上公式所示,我们将上面罗列的 n 个等式的左右两侧分别相加,就得到了如下式子:
$$<br>
\begin{aligned}<br>
&amp;左侧n^3 = n^3 - (n - 1)^3 + (n - 1)^3 - (n - 2)^3 +… - 1^3 + 1^3 - 0^3 \\\<br>
\\\<br>
&amp;右侧3 \times \sum_{i=1}^{n}i^2 - 3 \times \sum_{i=1}^{n}i + n<br>
\end{aligned}<br>
$$
我们看到左侧就剩下一项 n 的立方了,这一项是可算的,右侧有一个 3 倍的平方和项和一个3倍的等差数列求和项以及一个常数项 n。接下来左侧等于右侧我们将平方和项与其他几项分别置于等式的两侧就得到了如下平方和公式
$$<br>
\begin{aligned}<br>
左侧 &amp;= 右侧: \\\<br>
&amp; n^3 = 3 \times \sum_{i=1}^{n}i^2 - (3 \times \sum_{i=1}^{n}i) + n \\\<br>
移项&amp;\\\<br>
&amp; 3 \times \sum_{i=1}^{n}i^2 = n^3 + (3 \times \sum_{i=1}^{n}{i}) - n \\\<br>
&amp; \sum_{i=1}^{n}i^2 = \frac{n^3 + (3 \times \sum_{i=1}^{n}i) - n}{3} \\\<br>
&amp; \sum_{i=1}^{n}i^2 = \frac{2 \times n^3 + 3 \times (1 + n) \times n - 2 \times n}{6}<br>
\end{aligned}<br>
$$
至此,我们就得到了平方和公式。其实,你还可以尝试使用这种方法,求解立方和公式,整体步骤差不多,就是先表示出相邻两项的四次方差,然后用如上步骤,继续推导即可。
## 框架思维(上):将素数筛算法学成框架算法
这一节课,我们学习了素数筛算法,素数筛每一轮找到一个素数,然后在一个标记数组中,标记掉这个素数所有的倍数,剩下没有被标记掉的数字,就是我们要的素数了。最后,我留了一个程序性质证明题,具体看如下代码:
```
#include &lt;stdio.h&gt;
// 打印一个素因子,并且在中间输出 * 乘号
void print_num(int num, int *flag) {
if (*flag == 1) printf(&quot; * &quot;);
printf(&quot;%d&quot;, num);
*flag = 1;
return ;
}
int main() {
int n, i = 2, flag = 0, raw_n;
scanf(&quot;%d&quot;, &amp;n);
raw_n = n;
// 循环终止条件,循环到 n 的平方根结束
while (i * i &lt;= n) {
//①:只要 n 可以被 i 整除,就认为 i 是 n 的一个素因子
while (n % i == 0) {
print_num(i, &amp;flag);
n /= i;
}
i += 1;
}
//②:如果最后 n 不等于 1就说明 n 是最后一个素数
if (n != 1) print_num(n, &amp;flag);
printf(&quot; = %d\n&quot;, raw_n);
return 0;
}
```
第一个,是要证明第 18 行代码中,只要 n 可以被 i 整除i 就一定是素数。关于这个证明,我们可以使用反证法。
假设 i 可以被 n 整除,但 i 不是素数,由算术基本定理可知,一个非素数的数字 N一定可以分解为几个小于 N 的素数乘积的形式。我们不妨假设 $i = p_1 \times p_2$,这里 $p_1$ 和 $p_2$ 均为素数,如果变量 n 可以被 i 整除,那么 n 也一定可以被小于 i 的素数 $p_1$ 整除。而根据程序的运行流程n 中已经不可能存在小于 i 的因子了,所以$p_1$ 不具备存在的条件故原假设不成立i 是素数。
第二个,是要证明第 25 行代码中,为什么只要 n 不等于1n 就一定是素数呢?其实也可以参考第一问的证明流程。在 while 循环处理过程中,数字 n 中已经不可能存在小于等于 i 的所有的因子了,又因为此时 i 是大于根号 n 的一个值,也就是说,在小于等于根号 n 范围内,找不到数字 n 的非 1 因子,而能够满足这种性质的数字,一定是素数。
至此,我们就证明完了程序中两处代码的性质。
## 数据结构(上):突破基本类型的限制,存储更大的整数
在这一节中,我们学习了大整数表示法,说明了如果是数据表示的导致的程序设计过程不可行,那么我们就需要在数据结构中寻找解决方案了。
在大整数表示法中,我们是将一个数字,从右到左倒着存储在数组中,并且用数组的 0 位存储数字的位数。数组中存储的数字大小,应该等于其每一位的数字乘上相关存储位置的位权,数组的 1 位位权为 1也就是 10 的 0 次方2 位位权为10也就是 10 的 1 次方,以此类推。
那么接下来,我们理解大整数的乘法,也是通过这种数学公式上面的等价关系,来理解大整数乘法过程。最后给你留了一个编程题,是关于实现读入两个大整数,并且计算两个大整数加法结果的程序,以下是我的参考代码:
```
#include &lt;stdio.h&gt;
#include &lt;string.h&gt;
#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 &gt;= 0; i--) {
num[num[0] - i] = str[i] - '0';
}
return ;
}
void output_big_integer(int *num) {
for (int i = num3[0]; i &gt;= 1; i--) {
printf(&quot;%d&quot;, num3[i]);
}
return ;
}
int main() {
scanf(&quot;%s%s&quot;, 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** 这些需要封装的操作。最终你会发现,算法流程没有改变,改变的只有程序样式。更多内容呢,你可以参考文章中,我与 @陈洲更 的留言讨论内容。
好了今天的思考题答疑就结束了,如果你还有什么不清楚的,或者有更好的想法的,欢迎告诉我,我们留言区见!