计算机是进行数据处理的设备,而程序表示的就是处理顺序和数据结构。由于处理对象数据是存储在内存和磁盘上的,因此程序必须能自由地使用内存和磁盘。
内存的物理机制
内存实际上是一种名为内存IC的电子元件,虽然内存IC包括DRAM、SRAM、ROM等多种形式,但从外部来看,其基本机制都是一样的。内存IC中有电源、地址信号、数据信号、控制信号等用于输入输出的大量引脚(IC的引脚),通过为其指定地址来进行数据的读写。
内存IC能存储多少数据呢?
- 数据信号引脚D0~D7:表示一次可输入输出8bit(1byte)的数据
- 地址信号引脚A0~A9:表示可指定0000000000至1111111111共2的10次方即1024个地址
地址是用来表示数据的存储场所,因此这个内存IC可存储1024个1字节的数据,即容量为1KB。
假如要往该内存IC中写入1字节的数据,为了实现该目的,可以给VCC接入+5V,给GND接入0V的电源,并使用A0到A9的地址信号来指定数据的存储场所,然后再把数据的值输入给D0到D7的数据信号,并把WR(write 写入)信号设定为1。执行完这些操作后就可以在内存IC内部写入数据了。
读出数据时,只需要通过A0到A9的地址信号指定数据的存储场所,然后再将RD(read 读出)信号设成1即可。执行完这些操作,指定地址中存储的数据就会被输出到D0到D7的数据信号引脚中。另外,像WR和RD这样可让IC运行的信号称为控制信号。其中,当WR和RD同时为0时,写入和读出的操作都无法进行。
内存的逻辑模型
对于程序员来说,用楼房的图形来表示内存,1层可存储1个字节的数据,楼层号表示的就是地址。能够形象的解说有助于了解内存。
虽然内存的实体是内存IC,不过从程序员的角度来看,也可以把它假想成每层都存储着数据的楼房,并不需要过多地关注内存IC的电源和控制信号的。
程序员眼中的内存模型中,包含着物理内存中不存在的概念,那就是数据类型。编程语言中的数据类型表示存储的是何种类型的数据。从内存来看,就是占用的内存大小(占有的楼层数)的意思。即使是物理上以1个字节为单位来逐一读写数据的内存,在程序中,通过指定其类型(变量的数据类型等),也能实现以特定字节数为单位来进行读写。
// 定义变量
char a;// 1字节长度的char
short b;// 2字节长度的short
long c;// 4字节长度的long
// 给变量赋值
a = 123;
b = 123;
c = 123;
将数据低位存储在内存低位地址的低字节序(little endian)方式:
指针
理解指针的关键点就是要弄清楚数据类型这个概念。
指针也是一种变量,它所表示的不是数据的值,而是存储着数据的内存的地址。通过使用指针,可对任意指定地址的数据进行读写。
各种数据类型指针的定义,这些数据类型表示的是从指针存储的地址中一次能够读写的数据字节数。
char *d;// char类型的指针d的定义
short *e;// short类型的指针e的定义
long *f;// long类型的指针f的定义
数组是高效使用内存的基础
数组是指多个同样数据类型的数据在内存中连续排列的形式。作为数据元素的各个数据会通过连续的编号被区分开来,这个编号称为索引(index)。指定索引后,就可以对该索引所对应地址的内存进行读写操作。而索引和内存地址的变换工作则是由编译器自动实现的。
各种类型的数组定义
char g[100];// char类型数组变量g的定义
short h[100];//short类型数组变量h的定义
long i[100];//long类型数组变量i的定义
数组的定义中所指定的数据类型,也表示一次能读写的内存大小,数组是使用内存的基本。
- char类型的数组以1个字节为单位对内存进行读写
- short类型的数组以2个字节为单位对内存进行读写
- long类型的数组以4个字节为单位对内存进行读写
之所以说数组是内存的使用方法的基础,是因为数组和内存的物理构造式一样的。
栈、队列以及环形缓冲区
栈和队列都可以不通过指定地址和索引来对数组的元素进行读写。需要临时保存计算过程中的数据、连接在计算机上的设备或输入输出的数据时,都可以通过这些方法来使用内存。如果每次保存临时数据都需要指定地址和索引,程序就会变得比较麻烦,因此要加以改进。
栈和队列的区别在于数据出入的顺序不同,在堆内存数据进行读写时,栈用的是LIFO(Last Input First Output,后入先出),队列使用的则是FIFO(First Input First Output,先进先出)。
如果在内存中预留出栈和队列所需的空间,并确定好写入和读出的顺序,就不用再指定地址和索引了。如果要在程序中实现栈和队列,就需要以适当的元素来定义一个用来存储数据的数组,以及对该数组进行读写的函数。
使用数组实现栈和队列的操作
// 向栈中写入数据(入栈/栈底压入)
Push()
// 从栈中读出数据(出栈,栈顶弹出)
Pop()
// 向队列中写入数据(头部追加)
EnQueue()
// 从队列中读出数据(头部移出)
DeQueue()
在栈中,LIFO表示栈的数组中所保存的最后的数据(Last In)会被最先读取出来(First Out)。
// 向栈中写入数据
Push(123); // 写入123
Push(456); // 写入456
Push(789); // 写入789
// 从栈中读出数据
j = Pop(); // 读出789
k = Pop(); // 读出456
l = Pop(); // 读出123
栈(stack)的原意是“干草堆堆积如山”。干草堆堆积成山后,最后堆的干草会被最先抽取出来。干草堆也是用来临时保存家禽饲料的方式。程序中也是如此,为了实现临时保存数据的目的,使用类似干草堆的机制是非常方便的。这种机制提现在内存上就是栈。
在队列中,FIFO表示队列的数组中保存的最初的数据(First In)会最先被读取出来(First Out)。
// 向队列中写入数据
EnQueue(123); // 写入123
EnQueue(456); // 写入456
EnQueue(789); // 写入789
// 从队列中读出数据
m = DeQueue();// 读出123
n = DeQueue(); // 读出456
o = DeQueue(); // 读出789
队列的方式也称为排队,排队指的是买车票时在自动售票机前等候的队列。排队时,站在对前面的乘客先买票,购票后率先从队列中走出来。当随机前来的购票乘客数量和自动售票机的处理速度不相符时,排队能起到很好的缓冲作用。
程序也是如此,为了协调好数据输入和处理时机间的关系,采用类似于排队的机制是很方便的。在内存上实现这种机制的方式就是队列。
队列一般是以环状缓冲区(ring buffer)的方式来实现的
假设要用6个元素的数组来实现一个队列,这时候可以从数组的起始位置开始有序地存储数据,然后再按照储存时的顺序把数据读出。在数组的末尾写入数据后,后一个数据就会被写入数组的起始位置,此时数据已经被读出所以该位置是空的。这样,数组的末尾和开头连接了起来,数据的写入和读出也就循环起来了。
链表使元素的追加和删除更容易
链表和二叉查找树都是不用考虑索引的顺序就可以对数组元素进行读写的方式,通过使用链表可以更加高效地对数组数据(元素)进行追加和删除处理。而通过使用二叉查找树,则可以更加高效地对数组数据进行检索。
在数组的各个元素中,除了数据的值外,通过为其附带上下一个元素的索引,即可实现链表。数据的值和下一个元素的索引组合在一起,就构成了数组的一个元素。这样,数组元素相连就构成了念珠似的链表。由于链表末尾的元素没有后续的数据,因此就需要用别的值来填充。
在需要追加或删除数据的情况下,使用链表是很高效的。
如果不使用链表数组,那么中途删除或追加元素时,其后的元素就必须全部移动。
二叉查找树使数据搜索更有效
二叉查找树是指在链表的基础上向数组中追加元素时,考虑到数据的大小关系,将其分成左右两个方向的表现形式。
例如:将50个值保存到数组,如果接下来的值比先前保存的值大的话就将其放到右边,反之如果小的话就放到左边。实际的内存并不会分成两个方向,这是在程序逻辑上实现的。
为了实现二叉查找树,数组的每个元素只要有数据的值和两个索引信息就可以了。二叉查找树是由链表构造发展而来的表现形式,因此在追加或删除元素方面也同样是有效的。
使用二叉查找树的便利之处在于可以使数据的搜索等更有效率。在使用一般的数组时,必须从数组的开头按照索引顺序来查找目标数据。而使用二叉查找树时,当目标数据比现在读出来的数据小时就可以转向左侧,反之目标数据较大时即可转到链表的右侧,这样就加快了找到目标数据的速度。