<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="表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。<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 和 m，n 的大小不会超过10，m 的大小都不会超过 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="图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 &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. 一个变量占用若干个字节，第一个字节的地址，是这个变量的首地址，称为：变量地址。

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