从C#中的多维数组谈到内存中对于数据的存储

从C#中的多维数组谈到内存中对于数据的存储

    • 1. C#中的二维数组
          • 这个是利用一维数组中保存数组元素来表示二维数组
          • 利用C#中原生支持的语法来创建二维数组
    • 2. 计算机中的内存架构
          • C#中两种方法创建多维数组时候在内存中的存储情况
    • 3. 彩蛋:一维数组和多维数组下标的转换

1. C#中的二维数组

和Java不同的是,C#中有两种方法来创建二维数组:

  1. 利用一维数组中保存数组元素来创建二维数组;
  2. 利用C#中原生支持的语法来创建二维数组。

下面我们就用代码的形式来看看这两种方法吧。

这个是利用一维数组中保存数组元素来表示二维数组
int[][] arr = new int[4][];   // 因为这个本质上是一维数组,所以我们第二个方框里不能有数字。这个时候每个元素是null。
// arr[0][1] = 10;   // 如果没有用下面的语句先给每个元素赋一个一维数组,直接这样赋值会报空指针异常。这里是非常容易出错,也是非常不符合我们使用习惯的地方,所以尽量不要使用这种方法创建多维数组。
arr[0] = new int[] {0, 1, 2, 4};
arr[1] = new int[] {1, 100, 3, 34, 56};
arr[2] = new int[] {1};
arr[3] = new int[] {1000, 230};   // 我们可以发现,每个元素都是一个一维数组,不过数组的维度可以各不相同(不过,这种需求在实际应用中几乎看不见)。

// arr[0] = {0, 1, 2, 4};   // 必须使用new的方式对一维数组进行创建,这种方法会报编译时错误。

// 除了上面的方法进行二维数组的创建之外,还可以用下面两种方法
int[][] arr1 = {
                new int[] {0, 1, 2, 4},
                new int[]{2, 3}    // 同样,我们这里必须使用new来创建一维数组,写成{2, 3}会报编译时错误
            };
// 或者使用
int[][] arr2 = new int[2][]{   // 第一个方框中的2可以省略
                new int[] {0, 1, 2, 4},
                new int[]{2, 3}    // 同样,我们这里必须使用new来创建一维数组,写成{2, 3}会报编译时错误
            };

// 无论用什么方法初始化数组,都可以通过下标访问和修改数组中的元素
arr[0][0] = 1000;   // 数组变量arr1和arr2同理
int tmp = arr[0][1];  // 数组变量arr1和arr2同理

// 如果想要获得二维数组的行数
int row = arr.Length;

// 如果想要获得某一行的列数。当然,如果我们创建的时候是一个矩阵,那么矩阵的列数可以用arr[0].Length来表示,或者arr[1].Length也行,任意下标都可以,只要不越界就行
int col0 = arr[0].Length;
int col1 = arr[1].Length;

从上面的例子,大家可以发现用这种方法创建二维数组都那么麻烦,何况多维数组乎?除此之外它还有“对内存不友好”的缺点,这个我们在后面会提到。不过这种方法创建数组有一个优点,就是灵活,它可以创建“形态不规则”的多维数组。以二维数组举例,它创建的二维数组每行的列数可以不相同,这点是用C#创建多维数组的原生语法所无法实现的。那么下面我们就来介绍一下C#给我们提供的另一种更加简便和**高效(这里的高效主要是对内存的读写而言)**的创建多维数组的方法。

利用C#中原生支持的语法来创建二维数组
int[,] arr = new int[2, 3];   // 这里我们创建了一个两行三列的二维数组(或者说两行三列的矩阵)
arr[2, 3] = 10;  // 这个时候我们就可以愉快的给每个元素进行赋值了(不用担心出现第一种方法里的空指针异常了),
Console.WriteLine(arr[0, 1]);  // 输出0,因为我们没有给这个元素赋值,那么它就是int类型的初始值,为0
Console.WriteLine(arr[2, 3]);   // 输出10。可以看出,使用的时候也很方便,直接用下标访问就行了

// 注意:我们在访问和修改数组中的元素的时候,也是使用下标,不过和第一种方法不同的是,只有一个中括号,不同的维度用“,”隔开。

// 除了上面的方法进行二维数组的初始化以外,还可以用下面的方法
int[,] a = {
{2, 3}, 
{1, 2}, 
{4, 5} };   // 这里创建了一个3行2列的二维数组。这种最直观的创建二维数组的方法是第一种不具备的。

int[,] a1 = new int[3, 2]{   // 这里的3和2,必须和后面初始化的维度对应。当然,3和2可以同时省略,不过必须同时出现,同时省略。不能只出现一个。
{2, 3}, 
{1, 2}, 
{4, 5} }

// 获取二维数组的行列数和二维数组中所有元素的个数
Console.WriteLine(a.GetLength(0));    // 获取行数,这里是3。
Console.WriteLine(a.GetLength(1));    // 获取列数,这里是2
Console.WriteLine(a.Length);    // 获取所有元素的个数,这里是6. 2 * 3 = 6

从我们的代码上大家也可以发现,用第二种方法创建二维数组,乃至多维数组比第一种方法更加的简单,直观,更加符合我们的习惯。这种简单,直观在二维数组的时候可能体现不出那么明显,一旦数组的维度上了3维,这种优势就体现的很明显了。比如下面我们用两种方法来创建一个多维数组。缺点就是不能创建每行列数都不一样的二维数组,它创建的二维数组的每行列数必须一样,这是跟它底层实现有关(具体后面有讲)。

// 用第一种方法我们就不写了,因为太麻烦了。我这里就描述一下,首先我们类型表示成int[][][],这个本质也是一个一维数组,然后每个元素是一个二维数组。所以我们初始化的时候,每个元素都要想上面的例子那样初始化一个二维数组。天啊,简直是灾难。

// 用第二种方法创建一个3维数组
int[,,] a = new int[2, 3, 5];   // 方括号中的","数比维度少一个。
// 访问和修改元素的时候使用下标就行了
a[1, 2, 3] = 100; 

// 获取每个维度长度,依然使用GetLength(i)方法,其中的i从0开始,我们声明多维数组的时候方框里有几个",",i最大就到几。0表示取出方括号里的第一个数字,1表示第二个,依次类推。
// a.GetLength(0)返回表示2
// a.GetLength(0)返回表示3
// a.GetLength(0)返回表示5
// a.Length依然返回所有元素的个数30。多维数组元素的个数就是每个维度上的数字相乘就行了。

从使用方便这个方面我们阐述了为什么要使用第二种方法来创建多维数组,下面我们从
读写效率这个方面来阐述第二种方法比第一种方法好在哪里。要明白这个道理我们需要先讲讲和内存相关的一些知识点。

2. 计算机中的内存架构

计算机中的存储系统是一个类似于金字塔的结构。大致可以用下面的图表示
从C#中的多维数组谈到内存中对于数据的存储_第1张图片
上面金字塔上层的存储器的读写速度是它相邻下层的 几十 ~ 几百倍。由于计算机中的内存是线性结构,而且CPU从内存中读取数据的时候为了提升效率,会将数据在上面的金字塔结构的内存层级图中逐级缓存,并且一次是存一大块的数据,这一大块数据在内存中的地址一定是相连的。以为根据研究和统计,我们如果访问一块内存中的数据,那么我们很大几率会访问它相邻内存地址中的其他数据,而且在不久的将来,我们也有很大几率访问这块数据,这就是内存访问在时间和空间的上连续性。一旦我们内存中的数据被缓存到了速度更快的缓存(cache)中的时候,我们下次访问数据的时候先从缓存中查找数据,如果有,则直接获取,就不用再到主存(DRAM)中去找了,这个就叫做“cache hit”,我们希望的是多发生“cache hit”,因为这样获取数据的速度是最快的。但是由于缓存容量有限,不可能保存主存中的所有数据,所以,一旦我们在缓存中没有找到我们需要的数据,我们就要到主存中去寻找了,这个就叫做“cache miss”,“cache miss”一旦发生,数据的读取速度是“cache hit”时候的几十分之一,甚至几百分之一(现在即使主存的读取速度也足够快了,在一般的应用中一班这种差距不会有很大的影响)。

有了上面的知识,我们可以明白,我们在存储数据的时候如果将需要的数据存在相邻的内存地址上,可以获得更多的“cache hit”,从而获取更多的读取速度的提升。

C#中两种方法创建多维数组时候在内存中的存储情况

以二维数组为例:

  1. 用第一种方法创建二维数的时候
    如果用第一种方法创建二维数组,虽然用的是一个一维数组,保证了数据保存在内存中连续的地址里,但是我们保存在一维数组中所谓的数据其实不是我们真正想要的数据,而是其他一维数组的地址(C语言中成为指针),我们要访问真正需要的数据还需要从这个地址指向的内存空间中获取,而这个地址和我们之前访问的地址就是随机分布的了,极大可能不是连续的,所以虽然第一层一维数组中的所有数据都被缓存住了,但是这些数据是地址,他们指向的数据很可能还在主存中,再通过这些地址访问我们真正需要的数据的时候,都有很大的可能发生“cache miss”。这样就极大的减缓了我们获取数据的速度。

  2. 用第二种方法创建二维数组的时候
    第二种方法创建的二维数组虽然表面看起来和用起来都是二维数组的样子(其实是Duck Typing),但是它底层是用一维数组存储数据的(下面的彩蛋会讲一维数组和多维数组下标的转换),这里存储的数据是我们真正需要的数据,而不是数组的地址。所以在我们第一次获取数据的时候,我们真正需要的数据会全部缓存起来,这样我们再次访问这些数据的时候,就全是“cache hit”,这样就极大提升了我们获取数据的效率。

所以,在前面我说:“第二种方法比第一种方法更高效,对内存更友好”

3. 彩蛋:一维数组和多维数组下标的转换

在任何编程语言中都可以使用一维数组来作为底层的数据结构来实现多维数组的行为。要实现多维数组的行为,最重要的就是如何将一维数组的下标和多维数组的下标对应起来。我自己习惯将多维数组的下标转成一维数组的下标称为数组的“扁平化”处理,而将一维数组的下标转成多维数组的下标称为数组的“折叠”。

比如:

int[,] arr = new int[2, 3] {   // 底层保存成 {2, 3, 0, 4, 5, 2}
{2, 3, 0}, 
{4, 5, 2}};

int tmp = arr[1, 1];   // 我们知道返回5,但是因为arr的底层是一维数组,那么我们怎么用[1, 1]去一维数组中找到数字5呢?这个是由公式的:一维数组的下标 = 1 * 3 + 1 = 4,用4这个下标去一维数组中就可以找到5这个数字了。我们将公式总结如下

二维数组下标转一维数组下标公式:
一维数组下标 = 第一个维度的数(也就是arr[1, 1]中的第一个1)* 二维数组的列数(这里是3)+ 第二个维度的数(也就是arr[1, 1]中的第二个1)

推广的多维数组:
二维数组:
int[,] arr = new int[a, b];
arr[x, y]的下标转换成一维数组的下标就是:x * b + y

三维数组:
int[,] arr = new int[a, b, c];
arr[x, y, z]的下标转换成一维数组的下标就是:x * b * c + y * c + z

四维数组:
int[,] arr = new int[a, b, c, d];
arr[x, y, z, w]的下标转换成一维数组的下标就是:x * b * c * d + y * c * d + z * d + w

后面的多位数组的规律以此类推,就显而易见了。

一维数组下标转二维数组下标公式:
二维数组:
int[,] arr = new int[a, b];
a如果表示arr底层的一维数组,那么a[x]表示的二维数组的下标就是arr[u, v]
u = x / b
v = x % b // %表示取余数

你可能感兴趣的:(C#,数组,数据结构,存储,实现细节说明,data,structure)