12. 多维数组与矩阵算法¶
12.1. 数组的维度¶
到目前为止,在使用数组时,我们一直使用 一维数组 ,即使用单个下标值来标识位置。你可以将一维数组想象为一"行"值。
但是,有时你想表示的数据更容易以网格或表格而非直线形式来组织。Java 支持创建具有多个下标的数组——这对应多个 维度 。
二维数组 对某些类型的问题很有用。我们通常将二维数组视为矩形网格,并使用两个下标值而非一个。事实上,如果我们想象网格中的每个单独"行"都是自己的一维数组,那么矩形网格实际上是一系列排成一行的行——一个组件本身是数组的数组。
例如,如果你正在进行一项科学研究,需要跟踪一年中每天的降水量,并且你想按月份和日期来组织这些数据,你可能会使用二维数组(简称 2D 数组)。一种组织这些数据的方式是创建一个包含 365 个元素的一维数组:
double[] rainfall = new double [ 365 ];
但是,使用这种表示方式,计算特定月份的平均降水量将非常困难,而这可能是你研究的重要部分。
这个问题的一个更好的表示方式是使用二维数组,一个维度用于月份,另一个用于日期。
以下语句声明了数组变量 rainfall 并将其初始化为引用一个新创建的 12 x 31 的 double 值数组:
double[] rainfall = new double[12][31];
因此, rainfall 是一个数组的数组。你可以将第一个数组视为问题所需的 12 个月份。你可以将每个月视为包含 31 天的数组。月份的索引范围为 0 到 11,日期的索引范围为 0 到 30。
这种表示方式的问题是,当我们想引用 1 月 5 日的降水量时,必须使用 rainfall[0][4] 。这既笨拙又容易误导,因为与另一个人交流时我们通常会写"1/5"。问题在于日期(如 12/31/2021)从 1 开始计数,而数组从 0 开始计数。
由于很难记住这一事实,我们对降水量数据的表示方式可能会导致编写算法时出错。我们可以通过将数组定义为多一个月和每个月多一天来轻松解决这个问题:
double[] rainfall = new double[13][32];
这种表示方式创建了一个具有 13 个月份(索引范围 0 到 12)和每月 32 天(索引范围 0 到 31)的数组。然而,在所有处理该数组的算法中,我们可以简单地忽略第 0 月和第 0 天,使用从 1 开始的索引(单元索引)。换句话说,如果我们将此数组视为由 13 行和 32 列组成的二维表格,我们可以让第 0 行和第 0 列保持未使用状态。
如上图所示,这个 416 元素数组的第一个元素的下标为 (0,0),而最后一个位置的下标为 (12,31)。这种表示方式的主要优点是整个程序更容易阅读和理解,且更不容易出错。
要引用二维数组中的元素,需要使用两个下标。对于 rainfall 数组,第一个下标指定月份,第二个下标指定月中的日期。因此,以下语句将 1.15 赋值给表示 1 月 5 日的 rainfall 元素,然后打印其值:
double[] rainfall = new double[13][32];
rainfall[1][5] = 1.15; // rainfall for January 1st is 1.15
与一维数组一样,尝试引用数组中不存在的元素是错误的。以下每个示例在执行时都会导致 IndexOutOfBoundsException :
double[] rainfall = new double[13][32];
rainfall[13][32] = 1.15; // no such element
rainfall[11][33] = 1.15; // no such column
rainfall[14][2] = 1.15; // no such row
12.2. 遍历二维数组¶
如前所述, double 数组会自动将每个值初始化为 0.0,因此除非我们希望它们从不同的值开始,否则不需要初始化元素。记住,如果我们处理的是 String 或对象,情况就不会是这样!
然而,对于许多数组问题,有必要将数组元素初始化为其他值。对于二维数组,这需要嵌套循环。为了说明此算法,让我们使用嵌套的 for 循环将 rainfall 数组的每个元素初始化为 0:
for (int month = 1; month < rainfall.length ; month++)
{
for (int day = 1 ; day < rainfall[month].length ; day++)
{
rainfall[month][day] = 0.0;
}
}
注意两个 for 循环都从 1 开始,因为我们没有使用第 0 行或第 0 列。
记住,当你有一个嵌套的 for 循环时,内层循环迭代更快。因此,对于每个月份,内层循环将遍历 31 天。这等同于在前一节图片所示的表示方式中,逐行处理数组然后移至下一行。
注意,对于二维数组,两个维度都有一个关联的长度,在本示例中用于指定每个 for 循环的上界。对于 rainfall 数组,第一个维度(月份)的长度为 13,第二个维度(日期)的长度为 32。
另一种查看 rainfall 数组的方式是记住它是一个数组的数组。第一个数组的长度对应于月份数量(13),由 rainfall.length 给出。每个月份数组的长度对应于该月的天数(32),由 rainfall[month].length 给出。
嵌套 for 循环的外层循环遍历月份 1 到 12,内层 for 循环遍历日期 1 到 31。通过这种方式,数组中的 372 = 12 × 31 个元素被设置为 0.0。
12.3. 多维数组¶
Java 不会将数组限制为只有两个维度。例如,假设我们决定将降雨调查扩展到覆盖十年。现在,对于每一年,我们需要一个包含月份和日期的二维数组。这产生了一个三维数组,由年份数组组成,每个年份包含一个月份数组,每个月份包含一个日期数组:
int years = 10;
int months = 13;
int days = 32;
double [][][] rainfall = new double[years][months][days];
遵循不使用第 0 月和第 0 天的设计惯例,我们最终得到一个 10 × 13 × 32 的数组。
在下图中,降水量数据的每一年表示为一个单独的"页"。在每一页上,有一个由 12 行(每月 1 行)和 31 列(每天 1 列)组成的二维表格。
以下算法将用于初始化三维降水量数组的所有元素:
for (int year = 0; year < rainfall.length ; year++)
{
for (int month = 1 ; month < rainfall[year].length ; month++)
{
for(int day = 1 ; day < rainfall[year][month].length; day++)
{
rainfall[year][month][day] = 0.0;
}
}
}
再次注意正确使用数组每个维度的 length 属性。在外层循环中, rainfall.length 引用的是年份数量。在中间循环中, rainfall[year].length 引用的是给定年份中的月份数量。在内层循环中, rainfall[year][month].length 引用的是月份中的天数。
如果我们向数组添加第四个维度,例如表示不同的城市,并想扩展此算法来初始化它,我们只需将三级循环嵌套在另一个 for 循环中,该循环将遍历每个城市。
12.3.1. 初始化多维数组¶
如果我们不想使用像上面那样的代码中的循环,也可以使用我们之前看到的一维数组的替代方法来初始化多维数组,即在花括号({})内列出数组中每个项目的初始值。
回顾一下,我们可以这样初始化一个 int 的一维数组:
int[] numbers = {1, 2, 3};
对于多维数组,我们可以写:
int[][] grid = {
// two rows of 3 columns each
{1, 2, 3},
{4, 5, 6}
};
String[][][] arr3D = {
// a 2x2x2 "cube" of strings
{
{"a", "b"},
{"c", "d"}
},
{
{"e", "f"},
{"g", "h"}
}
};
12.3.2. 锯齿(或参差不齐)数组¶
由于 Java 中的多维数组是作为数组的数组创建的,表示不同行的各个数组本身是独立的对象。因此,它们不必都具有相同的长度。当多维数组的子数组大小不同时,称为"锯齿"(有时称为"参差不齐")数组,而非"完整"或"矩形"数组。锯齿数组具有不均匀(不等)大小的行。有时它们用于表示 稀疏矩阵 ,但也可以用于其他情况。
下面,我们看到一个由三行组成的 double 数组,每行包含不同数量的元素。第一行包含三个元素,第二行包含两个元素,最后一行包含四个元素。正如这个最后的示例所示,多维数组中的行不必都具有相同的长度。
double[][] arrDifferent = {
{1.0, 2.0, 3.0},
{4.0, 5.0},
{6.0, 7.0, 8.0, 9.0}
};
通过写出特定单元格值来初始化数组仅对相对较小的数组可行。要了解原因,只需想象我们的三维降水量数组的初始化表达式会是什么样子。它将需要 4,160(或 10 × 13 × 32)个零,用逗号分隔!然而,对于描述较小的数组,它非常有用。它也是 Java 允许程序员记录表示输入数字或字符串集合的字面值的主要方式,因此以此方式初始化的数组通常用于在 Java 程序中提供表格数据字面值。
12.4. 但是可以有多维列表吗?¶
数组相对于列表的一个优势是,它们内置支持堆叠任意多个维度。而 List 只有一个维度和一个可以用于 get() 方法的整数位置值。
或者真的是这样吗?事实证明,就像 Java 中的多维数组可以被视为"数组的数组"一样,你可以使用相同的概念来创建"列表的列表"(如果你需要多维列表的话)。毕竟,列表可以包含任何类型的对象,而列表本身就是对象,所以将列表放在列表中是合理的。
12.5. 整数除法与取模¶
假设你有一个以英寸为单位的测量值,想要转换为英尺和英寸。目标是除以 12(一英尺的英寸数)并保留余数。
我们已经见过除法运算符( / ),它计算两个数的商。如果数字是整数,则执行整数除法,丢弃答案的任何小数部分,不保留余数。
Java 还提供了 取模 运算符( % ),它将两个数相除并计算余数。
使用除法和取模,我们可以这样转换为英尺和英寸:
int quotient = 76 / 12; // division
int remainder = 76 % 12; // modulus
第一行的结果是 6。第二行读作"76 mod 12",结果是 4。所以 76 英寸等于 6 英尺 4 英寸。
取模运算符看起来像百分号,但你可以把它想象成向左旋转的除号(÷)会更有帮助。
取模运算符被证明非常有用。例如,你可以检查一个数是否能被另一个数整除:如果 x % y 为零,则 x 可以被 y 整除且没有余数。
例如,如果我们想编写一个仅在 int x 能被 5 整除时才运行的 if 语句,我们可以写:
if (x % 5 == 0)
{
// do some action
}
你还可以使用取模从数字中"提取"数字: x % 10 产生 x 的最右边数字,这与 x 除以 10 后的余数相同。类似地, x % 100 产生最后两位数字。你可以将其与除法结合使用,因为 x / 10 是不包含最右边数字的数字。例如,数字 1234 由 123(即 x / 10 )后跟 4(即 x % 10 )组成。
此外,许多加密算法广泛使用取模运算符。
这里有两个很短的视频可以帮助你理解模运算:
