Better

Ethan的博客,欢迎访问交流

bitmap、复杂度与浮点数

停下思考,只为走的更远

位图

每张图按大小来存储,即图像的长宽像素大小。如果一张图片的像素是100*100,则此图像在内存的存放是一个100*100的数组,每个数组的元素是 int 整型(整数占用 4 个 byte )

需要补充一些知识:数组中每个元素中整型数字含四位信息:R-G-B-A。

  1. R:存放 Red 红色通道(占一个 byte 取值 0~255)
  2. G:Green 绿色通道色(占一个 byte 取值 0~255 )
  3. B:Blue 蓝色通道(占一个 byte 取值 0~255 )
  4. A:Alpha 通道值,即该位置像素点的透明值(占一个 byte 取值 0~255)

图片大小是如何计算的呢?

一张图片(BitMap)占用的内存 = 图片长度 * 图片宽度 * 单位像素占用的字节数

一个 32 位的 PNG,像素是 1204*1024 ,那么占用空间是:1024*1024*(32/8)

面试题:100*100 的 canvas 占多少内存?

我们在定义颜色的时候就是使用 rgba(r,g,b,a) 四个维度来表示,而且每个像素值就是用十六位 00-ff 表示,即每个维度的范围是 0~255,即 2^8 位,即 1 byte, 也就是 Uint8 能表示的范围。所以100 * 100canvas 占的内存是 100 * 100 * 4 bytes = 40,000 bytes

数码相机原理:数码相机中所谓的支持 500W 像素就是这个意思,代表它能处理多大的图形色彩信息的能力,像素越高,需要处理时间越长,因为数组很大。

我们说的 500W 像素,就是由 500W 个这样的方块组成。像素点的尺寸是不一定的。

bitmap

这里说的 bitmap 是指数据结构中的数据结构。

位图 bitmap 是一种非常常用的结构,在索引,数据压缩等方面有广泛应用。

所谓的 bitmap 就是用一个 bit 位来标记某个元素对应的 value, 而 key 即是该元素。由于采用了 bit 为单位来存储数据,因此在存储空间方面,可以大大节省。

场景分析:如何判断一个数是否在40亿个整数中。前提如果计算机内存只有个 2G。

很明显全部装载进入内存是不可能的,因为 40 亿个 int 占 (40亿*4)/1024/1024/1024 大概为 14.9G 左右。乘以 4 是因为一个 int 整数占 4 个字节。

判断一个数存在不存在,其实只有两个状态,可以用一个位来代表。如果可以用一个 bit 位来标识一个 int 整数,那么存储空间将大大减少。

一个 int 数据是 4 个字节,总共就是 2 的 32 次方,大概 42亿 多点,因此申请 2的 32 次方个位,就可以把整个整数范围都覆盖了。内存占用 Math.pow(2, 32) / 8 / 1024 / 1024 = 512MB就搞定啦。

操作系统位数

其实我们说的 32 位和 64 位,指的是 CPU 每一次处理多少位的数据。对于 32 位 CPU,其一次只能处理 32 位(即 4 个字节)的数据;

而 64 位 CPU 一次可以处理 64 位(即 8 个字节)的数据。从处理数据的能力方面来看,64 位是 32 位的两倍,64 位要比 32 位好。

特点

  • 64 位 CPU 拥有更大的寻址能力,最大支持到 16GB 内存,而 32 位只支持 4G 内存;
  • 64 位 CPU 一次可提取 64 位数据,比 32 位提高了一倍,理论上性能会提升 1 倍。但这是建立在 64 位操作系统,64 位软件的基础上的。
  • 64 位操作系统的设计初衷是为了满足机械设计和分析、三维动画、视频编辑和创作,以及科学计算和高性能计算应用程序等领域中需要大量内存和浮点性能的客户需求,而 32 位系统,初期并没有考虑太多。

复杂度

简单规则

  • 规则一:“有限次操作”的时间复杂度往往是O(1)。
  • 规则二:“for循环”的时间复杂度往往是O(n)。
  • 规则三:“树的高度”的时间复杂度往往是O(lg(n))。

组合规则:通过简单规则的时间复杂度,来求解组合规则的时间复杂度。

比如冒泡:O(n)*O(n)*O(1) = O(n^2)

又比如 TopK 问题,通过建立k元素的堆,来从 n 个数中求解最大的 k 个数。新建立、for循环、调整堆,结果为:O(k) + O(n) O(lg(k)) = O(nlg(k))

关于对数 log2 与 lg,比如快排,准确而言,复杂度为 O(n*log2n),但为了方便而言,我们也可以直接均取 10 位底数,也就是 lg 了。

递归规则:这个就比较复杂了

浮点数

浮点数是我们在编程中常用的一个数据类型,计算机对浮点数的内部表示方法IEEE 874到底是怎么回事呢?

如果要你设计一个支持存储“小数”的方案,你会怎么办呢?

定点数: 最简单的办法就是把这32位存储分成若干部分, 例如三个部分

  • 用1位来表达正负位, 0为正, 1为负。
  • 再划出8位来表示整数部分
  • 剩下的23位表示小数部分。

上面这种方式,由于小数点固定在了第23位和第24位之间,这种方式可以称为“定点数”。

很明显,由于整数部分的长度比较短,所能表示的数据的范围就比较小。小数部分比较长,所能表示的精度就比较高。

这种方式有个很大问题,如果想要表达更大范围的数,只能又定义一个新的数据类型,让整数部分扩大一些。 但是精度又会受到损失了,可见用这种定点数的表示法,范围和精度是一对矛盾。而且每个需求定义一个数据类型,这不是坑程序员么,有没有更灵活的方式的,这就需要浮点数出场了。

怎么解决定点数的“僵化”问题呢?我们都知道科学记数法,例如368.79 用科学计数法表示就是 3.6879 * 10 ^2。其中3.6879就是尾数,10 是基数,2 是指数。浮点数就是利用指数达到了小数点“浮动”的效果。从而可以灵活地表达更大范围内的数,小数点的位置是不固定的,但有一个问题,就是通过改变指数的大小,会造成对于同一个浮点数,也有很多表达方式。

由于其多样性, 很多计算机厂商都设计了自己的表示浮点数的规则,以及对浮点数运算的细节。 多样的规则对于程序的可靠性和移植性都是不利的。于是就有了大名鼎鼎的 IEEE 754 标准。

该标准在1985年发布,成为了各个计算机厂商都支持的规范,大大提高了程序的可移植性。

IEEE 754

这个标准中有单精度(32位)和双精度(64位)。这里看看 32 位的就好。补充:64 位中尾数长度52,指数长度11,指数偏移量1023;同时约定小数点左边隐含有一位,通常这位数是1,因此最终尾数长度需要增加一位。 IEEE.webp

用科学记数法表示,应该是这样的:

(+ or - ) 1.(mantissa) * 2 ^ exponent

注意:小数点前面是有个1的。

比如如何把 5.8 这个 10 进制小数转换为 IEEE 754 表示的浮点数。

  1. 对 5.8 反复除以 2,直到小数为 1,得到 8 = 1.45 * 2 ^ 2
  2. 计算符号位:正数为 0
  3. 计算指数:指数也有正负之分,我们既要能用8位二进制数字表示正数,又要能表示负数。所以2的8次方这256个数字要区分开来使用: 从 0 到 127 表示负数, 从 128 到 255 表示正数。因此 127 被称为偏移值,得到exponents = 127 + 2 = 129,换算成2进制就是10000001
  4. 计算尾数 0.45 如何转换成 2进制呢,不断地乘2,取结果的整数部分就行,可以看出,出现无限循环了,我们取够IEEE 754要求的23位就行了(0.01 1100 1100 1100 1100 1100 1......),可见浮点数是不精确的!

思考,这个尾数总是1.mantissa的形式, 那用这种方式怎么才能表示零呢?

证明0.1+0.2!=0.3

根据上面的知识,我们知道尾数总是1.mantissa,那如果是0.1如何表示呢?之前我们求得指数的方式为持续除以2,直到为1.mantissa的形式,如果是小于1的数,则我们采用乘以2的方式,只不过最终指数的值是2个个数的负数。后面继续和大于1的一样的。

在JS中,整数和小数均采用64位浮点数存储,也就是说底层根本没有整数,所有数字都是小数。比如0.1十进制转换成二进制之后,就是个无限循环小数,简单来说二进制表示1/10的难度和十进制表示1/3是一样的。

不难得出最终0.1的二进制形式为:0 01111011 1001 1001 1001 1001 1001 100

0.2的二进制形式为:0 01111100 1001 1001 1001 1001 1001 100

接下来的两步就相互加和转成十进制,从而得到结果了。从IEEE 754表示结果为

0.1D = 2^-4 * 1.1001 1001 1001 1001 1001 100
0.2D = 2^-3 * 1.1001 1001 1001 1001 1001 100
// 指数统一,于是位数相加表示为
0.11001100110011001100110+ 
1.10011001100110011001100=
10.01100110011001100110011
// 小数点左移动一位
2^-2 * 1.00110011001100110011001
// 最终计算结果
0.0100110011001100110011001
=> Math.pow(2, -2) + Math.pow(2, -5) + Math.pow(2, -6) + Math.pow(2, -9) + Math.pow(2, -10) + Math.pow(2, -13) + Math.pow(2, -14) + Math.pow(2, -17) + Math.pow(2, -18) + Math.pow(2, -21)

上述计算结果为:0.2999997138977051(不保证完全正确哈,算晕了,太困了,准备睡觉了),为啥不是 0.30000000000000004 呢?可能因为我这里演算的是单精度,但在JS中,使用的是双精度。

unsigned int & int

首先第一个问题,有没有 unsigned float 和 unsigned double。哈哈其实上面已经说得很明白了,答案是没有,浮点数规定格式里边就是有符号位,底数位,和尾数位的,因此 float 和 double 必然是有符号的。

对于整数而言,因为第一位是符号位用来表示正负,但是你设置无符号就可以让后面的往这里进位,达到增加数据的目地。

我们知道对于 int 而言,一般使用 4 个字节,也就是 32 位,既然空间有限,那么数值就会有限制,就会存在最大值与最小值这一说,为了方便学习,我们可以选用 16 位来考虑

对于无符号的整型来说,它的二进制的最高位称为数据位,即那个0就是数据位,数据位是要参与运算的,如果我们把0改成1,即16个1,它的十进制就是65535(就是2的15次方+2的14次方...一直加到2的0次方),这是不同于有符号整型的。

有符号整型 signed int,int类型默认是有符号的,所以 int 实际上是 signed int ,我们通常省略 signed。

32767为例子,它的二进制为:0111 1111 1111 1111

对于有符号整型32767来说,它的二进制最高位称为符号位(而不是数据位了),符号位顾名思义就是决定正负号的,规则:0是正,1为负。

补码:在计算机中,负数是并不存在的,它是以二进制补码的形式表示和存放。补码运算,比如 -6

  1. -6的二进制: 1000 0000 0000 0110(称为原码,原码是计算机显示给我的)
  2. 对原码求反码:1111 1111 1111 1001(称为反码,保持符号位不变,将原码中的0变1,1变0)
  3. 对反码加1:1111 1111 1111 1010(称为补码,补码是计算机中存储负数的形式)

因此在计算机中,如果存储的二进制是1111 1111 1111 1010,那么显示在我们前台的十进制数字就是-6。即:负数在计算机中是以该负数的二进制的补码形式存储的

补码

那么为什么对于负数要用补码的形式存储呢?这里就要说到数字电路中的加法器设计了。

如果能用加法器同时实现加法和减法,能极大的节省CPU的电路设计。

把负数用补码表示,不但减法变加法,连符号为都可以参与运算。

在计算机内容,是使用补码来表示二进制数的。如果是一个正数,补码就是它本身。如果是一个负数,则需要把出符号位之外的二进制数执行取反加1的操作。

因此对于一个4位系统,如果只表示无符号数,那么范围是[0, 2^4-1],如果表示有符号数,它的范围就是[-2^3, 2^3-1]。

Refer



留言