# 05 | 数组:一秒钟,定义 1000 个变量 你好,我是胡光,咱们又见面了。通过前几节的学习,你已经了解了基本的程序结构。我们来简单总结一下,其中第一种结构就是顺序结构,它指的是我们所写的按照顺序执行的代码,执行完上一行,再执行下一行这种的。第二种就是分支结构,主要是用 if 条件分支语句来实现,主要特征是根据表达式的真假,选择性地执行后续代码。最后一种就是循环结构,用来重复执行某段代码的结构。 如果把程序比喻成工厂的话,现在你的工厂中已经有了各种各样的流水线,但这个工厂只是能生产产品还不行,还需要有存储的空间。今天,我们来学习的就是如何创建和使用工厂中的库房,本节之后,你的程序工厂就可以开工了! ## 今日任务 先来看看今天这10分钟的小任务吧。今天的任务是这样的,程序中读入一个整数 n,假设 n 不会大于 1000,请输出 1 到 n 的每一个数字二进制表示中的 1 的个数。 我举个例子哈,当 n 等于 7 的时候,我们把 1 到 7 的每个数字的二进制表示罗列出来,会得到下表所示内容: ![](https://static001.geekbang.org/resource/image/da/4a/da9aa66b4391bcf6078e6d521d2a134a.jpg "表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。 ![](https://static001.geekbang.org/resource/image/dd/a3/dd48cf83f2e2a3510c50a72b9368bca3.jpg "图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 个小格子的情况。这就是所谓的不可分割。 ![](https://static001.geekbang.org/resource/image/d7/9a/d7a4f1a553a70e730749f4af790e559a.jpg "图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 能够准确找到程序所需要数据的本质原因,也是因为每一个字节都有一个独立的编号,我们管这个编号,叫做:内存地址!下面我给你放了一张示意图: ![](https://static001.geekbang.org/resource/image/ef/64/ef6ef4330f86c45d5c18202d06edf364.jpg "图3:内存地址示意图") 上图中,下面空白的格子就是我们所谓的字节,具体的数据信息,就是存储在这些格子里面的,格子上面的是十六进制数字,就是我们所谓的地址,你会看到,在内存中,字节的地址是连续的。 最后我们来总结一下,比特是数据表示的最小单位,就是我们通常所说的一个二进制位。字节是数据存储的最基本单位,存储在计算机中的数据,一定是占用若干个字节的存储空间。最后就是内存地址,是每一个字节的唯一标记。 #### 3.直观感受:内存地址 你可能会觉得内存地址是一个很抽象的概念,不具体。其实我们可以像输出整型值一样,把内存地址也输出出来。 你还记得格式占位符的作用吧?不同数据类型,用不同的格式占位符输出,例如:%d 对应了十进制整型的输出。内存地址则采用 %p 这个格式占位符进行输出,下面给你一个演示程序,你可以在你的环境中运行一下: ``` #include int main() { int a; printf("%p\n", &a); // 输出 a 变量的地址 return 0; } ``` 代码中,首先定义一个了整型变量 a,然后使用 %p 占位符输出 a 变量的地址。单一的 & 运算符放到变量前面,取到的就是这个变量的**首地址**。 为什么说是首地址呢?上一部分说了,一个32位整型变量会占用4个字节的存储空间,每一个字节都会有一个地址,那么你会发现,上面程序中的 a 变量实际上有 4 个地址,这 4 个地址究竟哪一个作为 a 变量的地址呢?答案是最靠前的那个地址,作为 a 变量的地址,也就是这个变量的首地址。 ![](https://static001.geekbang.org/resource/image/83/10/8367f7c6d3405494a19b5c4289e4f710.jpg "图4:变量的首地址") 看到了变量的地址信息以后,下面就让我们来看一看与数组相关的地址信息,看下面这段程序: ``` #include 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 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,如下图所示: ![](https://static001.geekbang.org/resource/image/f2/96/f253de915071b2dcf400b5c2bb87d096.jpg "图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 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 的个数。 ## 课程小结 通过今天这个任务,你会发现,有了数组以后,我们可以记录一些计算结果,这些计算结果可能对后续的计算有帮助,从而提高程序的执行效率。关于数组的使用,会成为你日后学习中的一个重点,今天就当先热个身吧。下面呢,我来总结一下今天课程中需要你记住的重点: 1. 使用数组,可以很方便的定义出一组变量存储空间,数组下标从 0 开始。 2. 数据的最基本存储单位是字节,每一个字节都有一个独一无二的地址。 3. 一个变量占用若干个字节,第一个字节的地址,是这个变量的首地址,称为:变量地址。 记住今天这些,对于日后学习指针相关知识,会有很大的帮助。好了,今天就到这里了,我是胡光,我们下期见。