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 的个数。

课程小结

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

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

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

3. 一个变量占用若干个字节,第一个字节的地址,是这个变量的首地址,称为:变量地址。 



本文深入介绍了计算机内存存储和地址的概念,通过生动的示例和通俗易懂的语言,帮助读者深入理解了计算机内存存储和地址的概念,以及如何在程序设计中应用这些概念。文章首先从字节和地址的概念入手,解释了计算机中数据存储的基本单位和内存地址的作用。通过示例程序演示了如何使用 sizeof 运算符来获取数据类型所占用的字节数以及如何输出变量的内存地址。接着,讲解了数组元素在内存中的连续存储特性,以及数组名代表整个数组的首地址的等价关系。进一步探讨了 scanf 函数的使用,强调了向 scanf 函数传递变量地址的重要性。最后,给出了一个设计程序的思考题,要求读者设计一个去掉倍数的程序,展示了实际应用的技术挑战。通过今天的任务,读者会发现,有了数组以后,可以记录一些计算结果,这些计算结果可能对后续的计算有帮助,从而提高程序的执行效率。读者可以通过本文快速了解计算机内存存储的基本原理和相关技术应用,对于日后学习指针相关知识,会有很大的帮助。 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值