一维、二维及高维数组

我们将进入一个新的板块:数组(Array)。数组是将一系列数据归并放在一起处理的一种特殊的数据类型。在 C# 里,一共有三种数组:多维数组(Multi-dimensional Array)、锯齿数组(也叫交错数组,Jigsaw Array)和混合数组(Mixed Array)。下面我们挨个介绍一下。
和 C 语言的数组的概念一样,但用法不同,所以我们给大家介绍具体写法格式的时候,希望你能多练习、多理解。

Part 1 一维数组

在 C 语言里,我们使用中括号,追加到变量之后,来表达和区分它和普通的变量不同。在 C# 里,我们这么书写:

  1. int[] array1 = new int[3];
  2. int[] array2 = new int[] { 1, 2, 4, 8, 16, 32 };
  3. int[] array3 = { 1, 2, 4, 8 };

C# 里拥有上述三种书写格式。第一种,我们使用 new 语句,在后面书写 int[3] 来表达数组长度是 3;第二种,我们使用 new 语句,在后面初始化数组的每一个元素;第三种是第二种的简写。在初始化的时候,可以不写 new int[] 这个部分。
当然,第二种写法里 new int[] 也可以写出来长度(后面有 6 个元素,因此可以写成 new int[6]。但是这个 6 是跟着后面元素个数的,因此这个 6 可以省略)。
另请注意,C# 把 int 类型的数组变量记作 int[],即写在类型的右侧紧跟一个中括号。
使用数组,我们可以采用中括号来获取数组指定位置上的数值。

int[] arr = { 1, 2, 4, 8 };
int a = arr[0], b = arr[1], c = arr[2], d = arr[3]; // 1, 2, 4, 8

这样一来, a、b、c 和 d 四个变量分别是 1、2、4 和 8。中括号里的数值称为下标(也叫索引,Index)。C# 里规定,数组下标从 0 开始,即第一个元素要写成 [0],第二个元素要写成 [1],等等。

可能你会问我,如果我写了一个过大或者过小的索引,比如说 arr[-1] 或者 arr[100],会怎么样呢?答案当然很简单:由于这种行为是非预期的行为,因此结果当然了产生异常了。实际上,运行期间,你就会看到,这样的代码会产生一个所谓的 IndexOutOfRangeException 类型的异常,这刚好代表你传入的索引是无效的:out of range 不就是超出范围的意思嘛。

另外,我们还可以使用 for 循环,对每一个元素进行取值:

for (int i = 0; i < arr.Length; i++)
{
    Console.WriteLine(arr[i]);
}

比如这样,我们使用 for 循环来对每一个元素进行取值操作,然后输出它们。
另外,在 C# 里,我们如果取出数组总长度,我们使用的是 .Length 语法。我们直接在数组本身后追加 .Length 来取出数组一共多少个元素。这一点和 C 语言不同:C 语言的数组是取长度是用的 sizeof(数组) / sizeof(元素类型)。
不过,C# 还允许另外一种循环类型:foreach 循环。在循环的内容里,我们没有讲解这个类型的循环,因为这个循环类型必须使用数组或别的集合才可以使用。它的格式是 foreach (类型 变量名 in 集合)。

foreach (int element in arr)
{
    Console.WriteLine(element);
}

这种循环类型专门针对于一个数组来用的话,我们就不必使用索引来写那么长了;从 foreach 循环来看,element 就是数组里面的每一个元素。直接取出数值本身,然后 element 就是已经是元素本身了。
目前来说,foreach 对数组可用;但是别的可以用 foreach 循环的东西我们还说不到,所以我们先等到用到的时候再来说。

Part 2 二维数组

和一维数组不一样的是,二维数组是两个维度的数组类型。你可以当成一个矩阵。

int[,] arr = new int[3, 4]
{
    { 1, 2, 3, 4 },
    { 2, 3, 4, 5 },
    { 3, 4, 5, 6 }
};

我们使用这样的语法,来初始化一个二维数组的每一个元素。当然,new int[3, 4] 是可以不写的;就算要写的话,这里的 3 和 4 也可以不写:new int[,]。注意这种语法格式。类型写的是 int[,],这表示 int 作为元素本体的数组类型,数组是二维数组,因为中括号里用了一个逗号。一个逗号表示将数组分成两个维度。
如果要获取里面的数值,我们依旧使用中括号,不过语法是这样的:

Console.WriteLine(arr[0, 0]); // 1
Console.WriteLine(arr[1, 0]); // 2
Console.WriteLine(arr[1, 3]); // 5
Console.WriteLine(arr[2, 3]); // 6

注意语法格式。arr[0, 0] 表示取第 1 行第 1 列的元素,arr[1, 0] 表示取第 2 行第 1 列的元素。和前文一样,0 表示第一个。
另外,通过 for 循环,我们也可以遍历每一个元素:

for (int i = 0, i < arr.GetLength(0); i++)
{
    for (int j = 0; j < arr.GetLength(1); j++)
    {
        Console.WriteLine(arr[i, j]);
    }
}

foreach 循环对多维数组的取值是“展开”式的。不论多少个维度,foreach 就把元素挨个展开排成一排挨个遍历(Traverse)。所谓的遍历,就是取每一个元素的过程。
最后,使用 .Length 得到的结果,可能和你预期结果不一样。.Length 取出来的结果是数组总的元素个数;二维数组虽然有维度的概念,但 .Length 照样取的是所有维度所有元素个数的总和。

Part 3 高维数组

高维数组专门指代三维及其以上的数组类型。我们拿三维数组举例:

int[,,] arr = new int[2, 3, 4]
{
    {
        { 1, 2, 3, 4 },
        { 2, 3, 4, 5 },
        { 3, 4, 5, 6 }
    },
    {
        { 1, 2, 3, 4 },
        { 2, 3, 4, 5 },
        { 3, 4, 5, 6 }
    }
};

三维数组虽然排版不是很好理解,不过你可以认为这是一个有长、有宽、有高的三维矩阵。另外,new int[2, 3, 4] 也可以不写;也可以写成 new int[,,]。两个逗号将中括号分成三部分,暗示是三个维度。
遍历和前文一致,使用 .GetLength 来获取每一个维度的长度,foreach 循环获取每一个元素。
三维数组以及别的高维数组是一样的,.Length 取出来的也是所有维度所有元素的个数总和。

锯齿数组

前文我们简单介绍了数组的基本使用,包括数组的初始化、声明以及数组的遍历。今天我们来看另外一种数组类型:锯齿数组。

Part 1 可拆分的数组

在前文里,我们介绍的数组是不可拆分的。所谓的不可拆分,就是数组本身无法被拆解成单个维度或低维度的数组,因为数组的元素只是逻辑上理解成多个维度的,但在遍历的时候,我们发现我们不得不依赖于 i、j 以及 foreach 循环才可以遍历。而这种遍历逻辑跟 i、j 本身是没有关系的,这些变量仅仅是用来表示一下下标罢了。真正遍历循环,使用的 foreach 也仅仅是一层循环就可以把所有元素全部迭代完成。包括 .Length 获取长度,其实是所有数组元素的总个数。这种种迹象都表明,数组本身其实是不可拆分的。
那么,既然想要把数组拆分成更小的数组,我们拥有一种新的数组结构,叫做锯齿数组。锯齿数组也叫交错数组和不规则数组。锯齿数组至少都需要两个中括号来表达数组类型:

int[][] arr = new int[3][]
{
    new int[] { 1, 2, 3 },
    new int[] { 4, 5, 6 },
    new int[] { 7, 8, 9 }
};

比如这则示例。我们使用 int[][] 来表示数组是一个锯齿数组。从人的理解角度出发,这种数组可以理解成 int[] 这个类型的一维数组,即每一个数组的元素都又是一个数组;从记号上理解的话,你可以当成是:整个数组是一个大的 [],每一个元素都是 [] 的类型。这个空的中括号我们可以自动理解为“一维数组”,那么这句话的意思就是,整个大数组是一个由 int 元素构成的一维的数组,而每一个元素都是一维数组类型的实体。
然后稍微注意一下,new int[3][] 这里的第二个中括号是不写数值的。即使我们可以看到,每一个数组元素都是长度 3 的,但我们依旧不写这个 3 在第二个中括号里。不不不,不是说可以省略掉,而是根本不能写出来。这是因为,这种数组模型,每一个元素都是一个单独的数组的关系,数组的长度是无法从锯齿数组上限制的。正是因为如此,锯齿数组允许每一个子数组(Subarray)长度可以不完全一样:

int[][] arr = new int[3][]
{
    new int[] { 1, 2 },
    new int[] { 3, 4, 5 },
    new int[] { 6, 7, 8, 9, 10 }
};

比如这样。这个写法里,每一个元素(子数组)分别长度是 2、3 和 5,它们并不完全一样大。从这个示例里,你可以看到,每一个子数组长度都不一样,我们也就无法确认第二个中括号到底写多少。不过,第一个中括号里的数字 3 是可以省略的,因为 3 就代表了一共是三个小的数组构成的一整个大数组,从初始化的内容里就可以知道长度,因此这个 3 省略,编译器也知道多大。

Part 2 取值

为了获取这种数组里面的元素,我们的做法也是使用两个中括号,然后写索引的方式来取。

Console.WriteLine(arr[0][0]); // 1
Console.WriteLine(arr[0][1]); // 2
Console.WriteLine(arr[1][2]); // 5
Console.WriteLine(arr[2][4]); // 10

这么写,如果你不理解的话,可以这么拆开来理解:

int[] subarray = arr[0];

Console.WriteLine(subarray[0]); // 1
Console.WriteLine(subarray[1]); // 2

我相信你这种理解方式,可以帮助你理解锯齿数组。

Part 3 遍历

这种数组的遍历稍微复杂一些。我们可使用如下的两种嵌套循环来解决遍历的问题。

首先,第一种是采用简单的 foreach-foreach 或 foreach-for 循环组合来遍历。

foreach (int[] subarray in arr)
{
    foreach (int element in subarray)
    {
        Console.WriteLine(element);
    }
}

采用这种遍历逻辑,我们可以无需关系数组的长度,因为 foreach 自身就自动去获取了每一个元素。当然,稍微复杂一点,可以采用 foreach-for 组合。

foreach (int[] subarray in arr)
{
    for (int i = 0; i < subarray.Length; i++)
    {
        Console.WriteLine(subarray[i]);
    }
}

这个写法也是可以的。
接着,是第二种稍微复杂一点的循环遍历过程:采用 for-for 循环组合。

for (int i = 0; i < arr.Length; i++)
{
    int[] subarray = arr[i];

    for (int j = 0; j < subarray.Length; j++)
    {
        Console.WriteLine(subarray[j]);
    }
}

我们采用 for 循环的话,由于建立的关系是索引,因此我们需要先提取子数组本身,然后才是继续内层的 for 循环的遍历过程。当然,如果你写熟练了,你仍然可以不用使用第三行这样的赋值过程:

for (int i = 0; i < arr.Length; i++)
{
    for (int j = 0; j < arr[i].Length; j++)
    {
        Console.WriteLine(arr[i][j]);
    }
}

C# 里,数组是引用传递的。所谓的引用传递,说白了就是,你创建一个新的变量,这个变量要是从别的变量里取出来的的话,那么这个新的变量和不取出来然后直接用是等价的写法。比如上面这个例子,arr[i] 和 subarray 就是同一个东西。如果还原回去,你直接将 subarray 全部改成 arr[i],一点问题都没有;另一方面,引用传递使得 C# 语法更为灵活。可以看到 arr[i].Length 这个写法,arr[i] 本身就是取 arr 的第 (i + 1) 个元素;取出来的结果自然是一个很普通的一维数组。我们仍旧可以直接把 arr[i] 当成一个数组来使用,因此 .Length 直接追加在后面是不会产生语法错误的。这一点很神奇,对吧。
当然了,第二种理解方式,未免有些复杂。特别是我们新学习的 int[] subarray = arr[i]; 的赋值逻辑,这是前文没有介绍过的。希望你掌握这种赋值行为的逻辑以及书写格式。另外啰嗦一下,这个赋值语句本身就在拆解取出数组里的每一个子数组元素,所以这个行为就是锯齿数组的拆分。而这个写法格式,是多维数组无法做到的。

Part 4 .Length 和 GetLength() 在锯齿数组里的行为

前文里我们介绍过 .Length,它是用于获取数组总元素个数,以及每一个维度的元素个数。在锯齿数组里,还是一样的吗?
首先是 .Length。我们可以试着写一段代码:

int[][] arr = new int[3][]
{
    new int[] { 1, 2 },
    new int[] { 3, 4, 5 },
    new int[] { 6, 7, 8, 9, 10 }
};

Console.WriteLine(arr.Length);
Console.WriteLine(arr[0].Length);

你好好看看,输出的结果应该是多少。实际上,输出语句有两个,对应的结果其实是 3 和 2。第一个输出的内容是 arr.Length,这个结果是 3;而第二个输出的内容是 arr[0].Length,输出的内容则是 2。第一个是 3 的原因是,我们之前说过,锯齿数组要当作是数组的每一个元素都是数组类型一样的存在。既然要这么看的话,那么这整个数组一共就三个元素(三个子数组)。因此,输出是 3;另外,第二个输出是 2 的原因是,arr[0] 是整个数组里的第一个子数组,然后 .Length 取的其实是第一个子数组的长度;而这个数组长度是 2(只有 1 和 2 两个元素构成),因此输出是 2。
接着我们说一下 GetLength。前文介绍过 GetLength 是用来获取每个维度的元素数量的。那么,既然长度不一导致无法在初始化语句 new int[][] 的第二中括号里无法写数字,GetLength 会得到什么结果呢?

int[][] arr = new int[3][]
{
    new int[] { 1, 2 },
    new int[] { 3, 4, 5 },
    new int[] { 6, 7, 8, 9, 10 }
};

Console.WriteLine(arr.GetLength(0));
Console.WriteLine(arr.GetLength(1));

首先,第一个结果是 3,它应该和前面 .Length 的结果完全一样;而第二个结果是多少呢?实际上并不能获取正确的结果,而是产生一个异常。
image.png
它产生了一个叫做 IndexOutOfRangeException 的类型的异常。这个异常告诉你,整个数组并没有你所输入的这个维度。虽然说这个句子不是很好懂,但是翻译过来其实也挺好理解的:由于数组本身是一个由若干个小的一维数组构成的一个大的一维数组,因此数组本身依旧是一维数组,如果尝试去取第 2 个维度的话,一维数组是不具有第二个维度的,因此会产生这个错误信息。
顺带一提。由于整个数组是一维由若干个小的数组构成的一个大的数组,数组本身并不存在维度的概念,因此整个锯齿数组还是一维的。这一点一定不要搞错了。我们就把这种 int[][] 的锯齿数组称为锯齿一维数组。

混合数组

前文我们了解了一种可拆分的数组类型:锯齿数组。今天我们学习的是一种稍微难一点的概念:混合数组(Mixed Array)。混合数组是将前面两种数组模型混用,产生的新的数组类型。千万别走神,接下来我们来理解一下混合数组的逻辑。
整个这一节的内容的难度都偏大,按需学习。不必掌握它们,但用到的时候,你可以再回来看。

Part 1 锯齿二维数组里反人类的理解逻辑

既然前面介绍过锯齿的一维数组,那么必然就存在锯齿二维数组。那么,锯齿二维数组是什么样的呢?

int[][,] arr = new int[2][,]
{
    new int[2, 3] { { 0, 1, 2 }, { 3, 4, 5 } },
    new int[4, 5]
    {
        { 6, 7, 8, 9, 10 },
        { 11, 12, 13, 14, 15 },
        { 16, 17, 18, 19, 20 },
        { 21, 22, 23, 24, 25 }
    }
};

当然,声明大小的时候,我们可以省略大小。比如例子里 new int[2][,] 里的 2、new int[2, 3] 里的 2 和 3,还有 new int[4, 5] 里的 4 和 5,都是可以不写的。

int[][,] arr = new int[][,]
{
    new int[,]
    {
        { 0, 1, 2 },
        { 3, 4, 5 }
    },
    new int[,]
    {
        { 6, 7, 8, 9, 10 },
        { 11, 12, 13, 14, 15 },
        { 16, 17, 18, 19, 20 },
        { 21, 22, 23, 24, 25 }
    }
};

不过,你可以看出来,这种写法确实有点古怪。明明是一个锯齿二维数组,结果却是“由若干不同的二维数组构成的大的一维数组”。这正是混合数组里最难搞定的地方。在锯齿一维数组里,我们将 int[][] 理解为“整个数组是一个大的 [],每一个元素都是 [] 的类型”;而这里也是一样:int[][,] 理解成“整个数组是一个大的 [],而每一个元素都是 [,] 的类型”。显然,[] 表示一个一维数组,而 [,] 则表示一个二维数组,所以 int[][,] 就是在说,整体是一个一维数组,而每一个元素都是一个二维数组,元素类型由 int 的实体构成。
当然,这类数组取值也是和标记 [][,] 是一样的:

Console.WriteLine(arr[1][2, 3]);

我指的是,取值的中括号书写风格是和声明语句里的 [][,] 标记是一样的。这里 arr[1][2, 3] 表示取 arr 里第二个元素(即第二个子数组,就是这个 20 个元素构成的数组);然后取这个数组里的第 3 行第 4 列的元素。那么显然结果就是 19 了。
尽管有点麻烦,你依然要明白,数组这一点比较严谨的表达逻辑。

Part 2 二维锯齿数组

如果记号 [][,] 反过来呢?[,][] 又能表达什么呢?

int[,][] arr = new int[2, 3][]
{
    {
        new int[1] { 1 },
        new int[2] { 2, 3 },
        new int[3] { 4, 5, 6 }
    },
    {
        new int[4] { 7, 8, 9, 10 },
        new int[5] { 11, 12, 13, 14, 15 },
        new int[6] { 16, 17, 18, 19, 20, 21 }
    }
};

看这个例子。这个例子告诉你,实际上整个数组类型 int[,][] 想表示一个二维数组,只是下面的每一个元素都是一个一维数组类型的实体。和前面的理解方式完全类似:int[][,] 理解成“int 元素、大数组是 [] 类型,每一个元素都是一个 [,]”。这里 int[,][] 就可以理解成“int 元素、大数组是 [,] 类型,每一个元素都是一个 []”。
我不是很想要提名字。前面那种叫锯齿二维数组,而这个叫二维锯齿数组。注意名字的顺序。将“二维”放在前面和放在后面是不一样的:二维锯齿数组是在说,数组本身就是二维的,只是元素是锯齿数组;而“锯齿”放在前面,则在说明数组本身是锯齿的数组,只是数组的元素类型并不是等大小的一维数组了,而是二维数组。

Part 3 我就要搞事情

简单看个例子就可以了。

int[][][] arr1 = new int[3][][]
{
    new int[2][]
    {
        new int[] { 1 },
        new int[] { 3, 5 }
    },
    new int[3][]
    {
        new int[] { 2, 4, 6 },
        new int[] { 8, 10, 12, 14 },
        new int[] { 16 }
    },
    new int[4][]
    {
        new int[] { 2 },
        new int[] { 3, 5, 7, 11 },
        new int[] { 13, 17 },
        new int[] { 19, 23, 29 }
    }
};

然后自己想想 int[,][][] 啊、int[][,][] 啊、int[][][,] 都是些啥玩意儿。反正我们也用不上。

Part 4 混合数组的遍历

混合数组的遍历,一旦搞清楚层次关系和逻辑的时候,我们就可以直接开始遍历了。我们拿基础的锯齿二维数组来遍历:

int[][,] arr = new int[][,]
{
    new int[,]
    {
        { 0, 1, 2 },
        { 3, 4, 5 }
    },
    new int[,]
    {
        { 6, 7, 8, 9, 10 },
        { 11, 12, 13, 14, 15 },
        { 16, 17, 18, 19, 20 },
        { 21, 22, 23, 24, 25 }
    }
};

显然,一旦清楚逻辑后,我们就可以遍历它们了:

foreach (int[,] subarr in arr)
{
    foreach (int element in subarr)
    {
        Console.WriteLine(element);
    }
}

再复杂一点:

foreach (int[,] subarr in arr)
{
    for (int i = 0; i < subarr.GetLength(0); i++)
    {
        for (int j = 0; j < subarr.GetLength(1); j++)
        {
            Console.WriteLine(subarr[i, j]);
        }
    }
}

再复杂一点:

for (int k = 0; k < arr.Length; k++)
{
    int[,] subarr = arr[k];
    for (int i = 0; i < subarr.GetLength(0); i++)
    {
        for (int j = 0; j < subarr.GetLength(1); j++)
        {
            Console.WriteLine(subarr[i, j]);
        }
    }
}

再复杂一点:

for (int k = 0; k < arr.Length; k++)
{
    for (int i = 0; i < arr[k].GetLength(0); i++)
    {
        for (int j = 0; j < arr[k].GetLength(1); j++)
        {
            Console.WriteLine(arr[k][i, j]);
        }
    }
}