CategoryResourceRepost/极客时间专栏/人人都能学会的编程入门课/语言基础篇/05 | 数组:一秒钟,定义 1000 个变量.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

253 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="05 | 数组:一秒钟,定义 1000 个变量" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/c0/e12cf1d352822c4003573932308c80c0.mp3"></audio>
你好,我是胡光,咱们又见面了。通过前几节的学习,你已经了解了基本的程序结构。我们来简单总结一下,其中第一种结构就是顺序结构,它指的是我们所写的按照顺序执行的代码,执行完上一行,再执行下一行这种的。第二种就是分支结构,主要是用 if 条件分支语句来实现,主要特征是根据表达式的真假,选择性地执行后续代码。最后一种就是循环结构,用来重复执行某段代码的结构。
如果把程序比喻成工厂的话,现在你的工厂中已经有了各种各样的流水线,但这个工厂只是能生产产品还不行,还需要有存储的空间。今天,我们来学习的就是如何创建和使用工厂中的库房,本节之后,你的程序工厂就可以开工了!
## 今日任务
先来看看今天这10分钟的小任务吧。今天的任务是这样的程序中读入一个整数 n假设 n 不会大于 1000请输出 1 到 n 的每一个数字二进制表示中的 1 的个数。
我举个例子哈,当 n 等于 7 的时候,我们把 1 到 7 的每个数字的二进制表示罗列出来,会得到下表所示内容:
<img src="https://static001.geekbang.org/resource/image/da/4a/da9aa66b4391bcf6078e6d521d2a134a.jpg" alt="" title="表11到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 就加111再除以2变成了1此时末尾是1计数量cnt 再次加1 。最后的n等于1再除以2n变成了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。<br>
<img src="https://static001.geekbang.org/resource/image/dd/a3/dd48cf83f2e2a3510c50a72b9368bca3.jpg" alt="" title="图1数组示意图">
有了数组以后,你就可以轻松的完成读入 n 个整型数据的任务了,参考代码如下:
```
int n, arr[1000];
scanf(&quot;%d&quot;, &amp;n);
for (int i = 0; i &lt; n; i++) scanf(&quot;%d&quot;, &amp;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 个小格子的情况。这就是所谓的不可分割。<br>
<img src="https://static001.geekbang.org/resource/image/d7/9a/d7a4f1a553a70e730749f4af790e559a.jpg" alt="" title="图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 能够准确找到程序所需要数据的本质原因,也是因为每一个字节都有一个独立的编号,我们管这个编号,叫做:内存地址!下面我给你放了一张示意图:
<img src="https://static001.geekbang.org/resource/image/ef/64/ef6ef4330f86c45d5c18202d06edf364.jpg" alt="" title="图3内存地址示意图">
上图中,下面空白的格子就是我们所谓的字节,具体的数据信息,就是存储在这些格子里面的,格子上面的是十六进制数字,就是我们所谓的地址,你会看到,在内存中,字节的地址是连续的。
最后我们来总结一下,比特是数据表示的最小单位,就是我们通常所说的一个二进制位。字节是数据存储的最基本单位,存储在计算机中的数据,一定是占用若干个字节的存储空间。最后就是内存地址,是每一个字节的唯一标记。
#### 3.直观感受:内存地址
你可能会觉得内存地址是一个很抽象的概念,不具体。其实我们可以像输出整型值一样,把内存地址也输出出来。
你还记得格式占位符的作用吧?不同数据类型,用不同的格式占位符输出,例如:%d 对应了十进制整型的输出。内存地址则采用 %p 这个格式占位符进行输出,下面给你一个演示程序,你可以在你的环境中运行一下:
```
#include &lt;stdio.h&gt;
int main() {
int a;
printf(&quot;%p\n&quot;, &amp;a); // 输出 a 变量的地址
return 0;
}
```
代码中,首先定义一个了整型变量 a然后使用 %p 占位符输出 a 变量的地址。单一的 &amp; 运算符放到变量前面,取到的就是这个变量的**首地址**。
为什么说是首地址呢上一部分说了一个32位整型变量会占用4个字节的存储空间每一个字节都会有一个地址那么你会发现上面程序中的 a 变量实际上有 4 个地址,这 4 个地址究竟哪一个作为 a 变量的地址呢?答案是最靠前的那个地址,作为 a 变量的地址,也就是这个变量的首地址。<br>
<img src="https://static001.geekbang.org/resource/image/83/10/8367f7c6d3405494a19b5c4289e4f710.jpg" alt="" title="图4变量的首地址">
看到了变量的地址信息以后,下面就让我们来看一看与数组相关的地址信息,看下面这段程序:
```
#include &lt;stdio.h&gt;
int main() {
int arr[100];
printf(&quot;&amp;arr[0] = %p\n&quot;, &amp;arr[0]); // 输出 arr[0] 的地址
printf(&quot;&amp;arr[1] = %p\n&quot;, &amp;arr[1]); // 输出 arr[1] 的地址
printf(&quot; arr = %p\n&quot;, arr); // 输出 arr 的信息
return 0;
}
```
上述代码会输出三行信息针对这三行信息每个人的程序运行出来的结果很可能是不一样的这一点没关系可你一定会发现如下规律第一个地址与第二个地址之间差4字节而输出的第三个地址与第一个地址完全相同。
下面我就来解释一下这两个现象。
<li>
第一数组的每个元素之间在内存中是连续存储的也就是对上面程序中的数组而言第一个元素占头4个字节第二个元素紧接着占接下来的4个字节的存储空间。再结合上面说到的变量首地址的概念你就很容易理解为什么头两个地址之间差4了。
</li>
<li>
第二在程序中当我们单独使用数组名字的时候实际上就代表了整个数组的首地址整个数组arr[100])的首地址就是数组中第一个元素的首地址,也就是 arr[0] 的地址。
</li>
在这里我们来进一步看一下这个等价关系arr 等价于 &amp;arr[0](取地址 arr[0]),实际上我们的地址也是支持+/-法的,也就是 arr + 0 等价于 arr[0] 的地址,那么 arr[1] 的地址等于 arr 加几呢?
你可能会认为是加 4这种直觉还是值得鼓励的可结果不正确这个和地址的类型有关系后面讲到指针的时候我再详细的讲给你听。不过事实上arr + 1 就等价于 arr[1] 的地址,更一般的 arr + i 就等价于 arr[i] 的地址。关于地址上的+/-运算的规则,我在后续的文章中会详细进行讲解。
#### 4.再看 scanf 函数:其实我是一个“邮递员”
有了上面对于地址的基本认识以后,我们再来回顾一下 scanf 函数的用法,你可能会有新的收获,看如下读入程序:
```
#include &lt;stdio.h&gt;
int main() {
int a;
scanf(&quot;%d&quot;, &amp;a);
return 0;
}
```
上面这个程序,就是一个最简单的读入程序,首先定义一个整型变量 a然后读入一个整数存储到 a 中。
学习完了地址以后,你就会意识到,我们传给 scanf 函数的,不是 a 变量,准确来说,而是 a 变量的地址。
为什么要把 a 变量的地址传递给 scanf 函数呢?这个很好理解,你就把 scanf 函数当成邮递员,邮递员得到了信件以后,需要知道这个数据放到哪个邮箱里面啊,而你需要做的就是把邮箱地址告诉这个邮递员即可,就是变量 a 的地址,这样 scanf 函数就能把获得的数据,准确的放到 a 变量所对应的内存单元中了。
## 一起动手,搞事情
#### 思考题:去掉倍数
>
<p>设计一个去掉倍数的程序,要求如下:<br>
首先读入两个数字 n 和 mn 的大小不会超过10m 的大小都不会超过 10000<br>
接下来读入 n 个各不相同的正整数,输出 1 到 m 中,有哪些数字无法被这 n 个正整数中任意的一个整除。</p>
下面给出一组输入和输出的样例,以供你来参考。
输入如下:
```
3 12
4 5 6
```
输出如下:
```
1 2 3 7 9 11
```
## 用数组,做递推
有了对数组的基本认识之后,就让我们来看一下今天的任务应该如何求解。请你观察下面的位运算性质:
```
y = x &amp; (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如下图所示<br>
<img src="https://static001.geekbang.org/resource/image/f2/96/f253de915071b2dcf400b5c2bb87d096.jpg" alt="" title="图5x 与 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 &amp; (i - 1)] + 1也就是说 i 比 i &amp; (i - 1) 这个数字的二进制表示中的 1 的数量要多一个,这样我们通过一步计算就得到 f[i] 的结果。
下面给你准备了一份参考程序:
```
#include &lt;stdio.h&gt;
int f[1001];
int main() {
int n;
scanf(&quot;%d&quot;, &amp;n);
f[0] = 0;
for (int i = 1; i &lt;= n; i++) {
f[i] = f[i &amp; (i - 1)] + 1;
}
for (int i = 1; i &lt;= n; i++) {
if (i != 1) printf(&quot; &quot;);
printf(&quot;%d&quot;, f[i]);
}
printf(&quot;\n&quot;);
return 0;
}
```
这个程序中,首先先读入一个整数 n代表要求解的范围然后循环 n 次,每一次通过递推公式 f[i] = f[i &amp; (i - 1)] + 1 计算得到 f[i] 的值,最后输出 1 到 n 中每个数字二进制表示中 1 的个数。
## 课程小结
通过今天这个任务,你会发现,有了数组以后,我们可以记录一些计算结果,这些计算结果可能对后续的计算有帮助,从而提高程序的执行效率。关于数组的使用,会成为你日后学习中的一个重点,今天就当先热个身吧。下面呢,我来总结一下今天课程中需要你记住的重点:
1. 使用数组,可以很方便的定义出一组变量存储空间,数组下标从 0 开始。
1. 数据的最基本存储单位是字节,每一个字节都有一个独一无二的地址。
1. 一个变量占用若干个字节,第一个字节的地址,是这个变量的首地址,称为:变量地址。
记住今天这些,对于日后学习指针相关知识,会有很大的帮助。好了,今天就到这里了,我是胡光,我们下期见。