本文是《Julia 编程基础》开源版本第九章:容器:数组(上)。本书旨在帮助编程爱好者和专业程序员快速地熟悉 Julia 编程语言,并能够在夯实基础的前提下写出优雅、高效的程序。这一系列文章由 郝林 采用 CC BY-NC-ND 4.0(知识共享 署名-非商业性使用-禁止演绎 4.0 国际 许可协议)进行许可,请在转载之前仔细阅读上述许可协议。
数组(array)也是一种容器。与元组相比,它最显著的特点有这么几个:
数组是可变的对象。关于这一点,我们在前面已经见识过了。
同一个数组中的所有元素值都必须有着相同的类型。虽然这个元素类型也可以是抽象类型,从而让元素值的具体类型多样化,但这样做在很多时候都会给基于它的计算带来不必要的负担。
数组可以是多维(度)的。也就是说,它不只可以代表一列车队,还可以代表一个停车场、一座停车楼,以及拥有更多维度的结构。而且,数组的维数(即维度的数量)与元素类型一样,也会被写入到其类型的字面量中。
从这些区别上,我们可以看得出来,数组擅长的不是承载函数参数值的列表,而是存储表达形式一致的数据。它的特点非常有利于科学计算和数据分析。下面,我们就从数组的类型、值的表示和构造、常见的操作等几个方面去详细地了解一下这种容器。
9.1 类型
代表数组的具体类型名为Array
,它是AbstractArray
的直接子类型。Julia 针对AbstractArray
类型定义了大量且丰富的操作。因而,Array
类型也就很自然地成为了这些操作的有效目标。
我们已经知道,Array
是一个参数化类型,它的全名是Array{T,N}
。其中的类型参数T
用于确定数组的元素类型,而类型参数N
则用于确定数组的维数。这里的N
的取值通常是一个正整数(也可以是0
,表示零维数组)。并且,在 64 位的计算机系统中,它的值不能超出Int64
类型所能表示的数值范围;在 32 位的计算机系统中,它的值不能超出Int32
类型所能表示的数值范围。下面是一些示例:
在一般情况下,我们直接使用的数组的维数都不会太多,大多在三维及以下。尽管在一些程序中可能会用到拥有更多维度的数组,但其维数肯定也比Int32
类型所能表示的最大值要小得多。所以,这里的类型参数N
的取值范围对于我们来说相当于没有限制。
正因为一维数组和二维数组都太常用了,所以 Julia 为它们的类型提供了别名。别名Vector{T}
代表类型Array{T,1}
,也就是一维数组的类型。而别名Matrix{T}
则代表了Array{T,2}
,即二维数组的类型。其中的 vector(向量)和 matrix(矩阵)都是线性代数中最核心的概念。从形状上来讲,向量就是由一个个值组成的纵队,而矩阵则是由一个个长度相同的纵队组成的方阵。
顺便说一下,我们在本书中不会专门去讨论相关的数学知识。但是,我们有时候(尤其是讲数组的时候)却不得不提到一点,因为有些对象及其操作基于的正是那些数学概念。不过别担心,我会尽量用精炼、朴实的语言去描述它们。
我们再来说数组类型的其他特点。与元组类型不同,数组类型的字面量永远也无法体现出元素的顺序。这主要是因为数组类型中只有一个可以代表元素类型的参数。想想看,如果一个元组类型的所有参数值全都相同,那么它同样无法体现出元素的顺序。
另外,数组类型也无法体现出其元素的数量。因此,对于一个一维数组,我们可以随意地增减其中的元素值,而不用担心不符合其类型的约束。如:
然而,对于多维数组来说,其各个维度上的元素数量却不是随意的。更确切地说,在一个多维数组中,处在同一个维度上的所有低维数组(即维数更低的数组)都应该具有相同的尺寸。这就好比一个方阵,其中的所有纵队的长度都需要相同。又好比一个六面体,它的每一个面都应该是平面,既不能有任何的凹陷,也不能有任何的凸出。只有符合这种规则的数组才能被称为多维数组,其类型如Array{Int64,2}
。否则,那个数组就只能算是多个数组的嵌套而已,其类型如Array{Array{Int64,1},1}
。
除了类型字面量上的一些特点,数组类型还具有非转化的特性。因此,[]
和[1]
虽然同为一维数组,但是它们的类型之间却不存在继承关系。这是由于空数组[]
的类型是Array{Any,1}
,它不是Array{Int64,1}
的超类型。验证的代码如下:
到这里,我们知道了Array{T,N}
类型中各个类型参数的取值范围,还知道了最常用的一维数组和二维数组的类型别名。另外,我们还了解到,数组类型的字面量上只会体现它的元素类型和维数,而不会体现元素的顺序以及各个维度上的元素数量。尽管如此,多维数组在各个维度上的元素数量仍需满足既定的规则,否则就不能被称为多维数组,而只能算是多个数组的嵌套。这可能看起来比较抽象,不过没有关系,我们在后面会把数组的值与它们的类型放在一起进行解读。
最后,再次强调,数组类型具有非转化的特性。
9.2 数组的表示
我们在很早以前就已经见过数组值的一般表示法了。它是这样的:
我们阅读 REPL 环境回显的内容就可以知道,[1, 2, 3, 4, 5]
表示了一个有 5 个元素的一维数组,且元素的类型是Int64
。不过,你可能会有个疑惑,为什么回显内容中的元素值是竖排展示的呢?
实际上,这就是一维数组的正常形状。它是一个由多个值组成的纵队,相当于表格中的一列。从线性代数的角度讲,这叫做列向量。更宽泛地说,只要我们用英文逗号分隔数组中的多个元素值,就会得到一个(列)向量。除了英文逗号,我们还可以使用英文分号:
在这里,我们可以认为这两种表示法是等价的。但我还是建议你在一般情况下使用英文逗号,因为英文分号在含义上还是有别于英文逗号的。在数组值字面量的上下文中,英文分号代表着拼接。它可以把相关的数组中的所有元素值以及单一值全都拼接在一起,从而产生一个新的数组。例如:
请注意,这里被英文分号分隔的不仅有 2 个单一值,还有 2 个数组。不过这些数组都被拆解了,其中的元素值也都成为了新数组的元素值。这就是拼接的作用。我们还需要注意,正是由于这两个符号在含义上的不同,所以我们不能在同一个地方混用它们。
我们现在来试验一下,把上述示例中的英文分号全都替换成英文逗号会产生怎样的效果:
可以看到,英文逗号并不会使相邻的数组被拆解。这些数组都被识别成了单一的元素值。因此,上述数组的元素类型才是Any
,而不是Int64
。更明确地说,只要一个数组值字面量包含了不同类型的元素值,Julia 就会试图找到它们的公共类型,并把这个公共类型当做数组的元素类型。这其实就是类型推断的功能之一。
那么,除了让 Julia 自行推断,我们是否能够自己设定元素值的类型呢?答案是肯定的。示例如下:
只要我们把元素类型的字面量放在左中括号的左侧就可以达到目的了。这不仅可以用在数组值的一般表示法上,还可以在拼接数组的时候加以运用。例如:
理所应当,只要我们提供的元素值中有一个不能被转换成目的类型的值,Julia 就会立即报错:
到目前为止,我们一直说的都是一维数组的表示法。下面我们来讲怎样表示二维数组。表示二维数组的标准方式是,在方括号中嵌入多个长度相同的一维数组,并用空格分隔这些数组,如:
回显内容中的2×3
是指,这个二维数组包含了 2 行 3 列。这从下面的数组元素值展示上也能看得出来。不过,如果行数或列数太多的话,数组的元素值也不会全都被展示在这里。因此,我们往往还是要以上面的那个NxM
样式的信息为准。
依据回显的内容,我们还可以知道,嵌入在这个二维数组中的每一个一维数组都独立地表示了一列值。它们代表的依然是列向量。实际上,把多个长度相同的列向量横向地拼接在一起就形成了矩阵。具体到上述示例,在形状上把[1,2]
、[3,4]
和[5,6]
都顺时针旋转 90 度(即还原为列向量的原本形状),然后再把它们横向地拼接在一起,就形成了我们要表示的 2 行 3 列的矩阵。
你可能已经有所猜测,列向量之间的那些空格好像起到了拼接的作用。没错,在数组值字面量的上下文中,这些空格与英文分号一样也是用于拼接的符号。但不同的是,英文分号用于纵向的拼接,而空格用于横向的拼接。一旦明确了它们的作用,我们就可以探索出它们的更多用法。
比如,我们可以把上述列向量中的分隔符(即英文逗号)替换成空格,就像下面这样:
如此一来,这个数组值的字面量就可以表示一个 1 行 2 列的矩阵了。你可能会有疑问,这个字面量表示的数组为什么是二维的,而不是一维的?其原因是,Julia 只认可列向量,而不认可所谓的行向量。
从线性代数的角度讲,列向量和行向量都可以被看做是特殊形状的矩阵。进一步说,列向量是Nx1
的矩阵(即只有一列的矩阵),而行向量是1xM
的矩阵(即只有一行的矩阵)。但是,在 Julia 中只有列向量可以独立的存在,并由一维数组表示。而行向量却只能作为特殊形状的矩阵,并且没有独立的表示法。
因此,我们在上面编写的数组值字面量[1 2]
会被 Julia 翻译成[[1] [2]]
。也就是说,在它表示的矩阵中,1
是第一个列向量中唯一的元素值,而2
则是第二个列向量中唯一的元素值。那个 1 行 2 列的矩阵就是这样形成的。我们再来看一个例子:
这个字面量中的空格作为拼接符号会把那两个嵌入的数组(即[1]
和[2 3]
)都拆解掉,并将其中的元素值与后面的那两个独立的值(即4
和5
)一起作为新数组的元素值。因此,这个字面量表示的就是一个 1 行 5 列的二维数组,当然也可以说它表示的是一个特殊形状的矩阵。
现在,我们同时使用两个拼接符号来表达二维数组,代码如下:
在第一个字面量里,我用英文分号分隔嵌入数组中的多个元素值,并用空格分隔多个嵌入数组。或者说,我把空格用在了外层,把英文分号用在了内层。如此一来,每一个嵌入数组就都表示一个列向量。我们再把这些列向量横向地拼接在一起就形成了 2 行 3 列的矩阵。
而在第二个字面量里,我使用英文分号和空格的方式正好相反,即:空格在内层,英文分号在外层。这样的话,每一个嵌入数组就都表示一个只有一行的矩阵(或者说行向量)。我们再把这些只有一行的矩阵纵向地拼接在一起就形成了 3 行 2 列的矩阵。
可以看到,只要我们层次分明地使用英文分号和空格,就可以灵活地利用它们来表示各种 I 行 J 列的矩阵。不过,对于拥有更多维度的数组,这种表示法就无能为力了。例如,即使我们像下面这样编写字面量,也仍然无法表示一个三维数组:
但幸运的是,Julia 提供了不少函数,可以被用来构造多维度的数组值。我们马上就会讲到它们。
9.3 数组的构造
关于可以构造数组值的那些函数,首当其冲的肯定是Array
类型附带的构造函数。
我们先说Array{T}(undef, dims)
和Array{T,N}(undef, dims)
。这两个函数都是用来构造未初始化的 N 维数组的。其中的T
依然代表元素类型,N
依然代表维数。
undef
是一个常量,它代表着单例类型UndefInitializer
的唯一值。所谓的单例类型,是指有且仅有一个实例的类型。无论我们实例化这种类型多少次,都只会得到同一个值,即该类型的唯一值。UndefInitializer
类型专用于数组的初始化,其值表达的含义是创建一个未初始化的数组。或者说它表达的是,上述构造函数的调用者不想向这个数组填充任何的元素值。这时,Julia 会在该数组的所有元素位置上填充随机值。
我们在前面讲了,数组类型的字面量上不会体现出数组在各个维度上的元素数量。然而,这些数量却是构造一个多维数组时必须要确定的信息。注意,对于多维数组,我们所说的在某个维度上的元素指的是可能一个个元素值,也可能是一个个低维数组。这在后面会有更详细的解释。
这里的参数dims
的作用正是表示数组在各个维度上的元素数量。更确切地说,它表示的是各个维度的长度。dims
是 dimensions 的缩写。它的值可以是一个包含了若干个整数的元组值,也可以是若干个由英文逗号分隔的整数值。不过后者只在三维及以下的数组构造中才有效。下面是一个例子:
请注意,回显内容中表示的是一个4×3×2
的三维数组。还记得吗?我们可以把三维数组比喻成一座停车楼。那么上面这个三维数组就相当于一个有 2 层的停车楼。现在,你要带着这个想象跟我一起理解它的展示格式。
回显内容的第一行反映了我们构造数组时给予的信息。第二行中的[:, :, 1]
指的是在第三个维度上的第 1 个低维数组(即二维数组),相当于停车楼的上一层。你也可以把[:, :, 1]
看成一个特殊的数组,其中的每一个元素的值都用于代表上述三维数组在对应维度上的某个低维数组。这个特殊的数组中的前两个元素都由英文冒号:
占位,相当于选择了对应维度上的所有低维数组。而其中的最后一个元素值是1
,代表的正是上述三维数组中的第 1 个二维数组。由此,在它下面才展示了对应的二维数组中的所有元素值,相当于俯瞰停车楼的上一层。
我们已经知道,只要 N 大于 1,那么 N 维数组就都可以被看做是由一个个尺寸相同的 N-1 维的数组拼接而成的结构,就像停车楼的每一层都有整齐的停车位那样。因此,在上述数组的第三个维度上的第 1 个低维数组就应该是一个4×3
的二维数组。在[:, :, 1]
下面的那 4 行内容展示的正是这个二维数组。其中的所有元素值都是由 Julia 自行填充的随机值。
又由于上述三维数组在第三个维度上的长度是 2,所以才有了再下面的[:, :, 2]
,以及与它对应的又一个4×3
的二维数组,相当于停车楼的下一层。
让我们再来构造一个四维数组:
四维数组可能会挑战到你的空间想象力。但有了前面的解释,这个四维数组的展示格式就应该容易理解一些了。这个四维数组由 2 个4×3×2
的三维数组拼接而成,而这 2 个三维数组又分别由 2 个4×3
的二维数组拼接而成。所以,[:, :, 1, 1]
指的就是,这个四维数组中的第 1 个三维数组中的第 1 个二维数组。而[:, :, 2, 1]
指的则是,这个四维数组中的第 1 个三维数组中的第 2 个二维数组。以此类推。紧挨在它们下面的那几行内容展示的就是对应的二维数组。你明白了吗?你可以再花一些时间思考一下。
为什么 Julia 会这样展示多维数组呢?这主要是因为,我们在平面(如屏幕、纸张等)之上最多只能铺开二维的数组。虽然我们也可以在纸上画出三维的物体(如六面体、球体等),但那终归只是一种视觉上的效果。而且,那些物体只能被当作图形来看待,很难完全用普通的文本直观地展示出来。即使我们生活在三维的世界里,可所用的文字和语言都只是二维的。这也是我们不容易理解四维以及更多维数的原因。总之,Julia 在用二维的方式展示多维数组。它把多维数组拆分成了一个个二维数组,并以普通文本的形式摆在我们面前。
言归正传。上例调用的是Array{T,N}(undef, dims)
。这时我们需要注意,替代N
的那个整数值一定要等同于替换掉dims
的那个元组值的长度(或者替换掉dims
的那些整数值的数量),否则 Julia 就会立即报错。因为两边给定的数组维数不一致。
在前面,我们传给数组构造函数的第一个参数值一直是undef
。但这只是初始化元素值的一种选项而已。我们还可以选择nothing
或missing
作为这个参数的值。但前提是,该数组的元素类型必须是nothing
或missing
的类型的超类型。
nothing
和missing
也都是常量,其含义同样比较特殊。我们在前面的章节中对它们都做过解释。nothing
代表着单例类型Nothing
的唯一值,它的含义是“此处没有值”。而missing
则代表单例类型Missing
的唯一值,它的含义是“此处的值是缺失的”。注意,nothing
仅等于它自身,但涉及到missing
的判等结果就要看使用的是哪种判等操作了。
那怎样设定数组的元素类型才能让它成为Nothing
或Missing
的超类型呢?这个时候,Union
类型就派上用场了。不要忘了,它的字面量可以表达多个类型的联合。因此,我们把元素类型设定为Union{Nothing, String}
就意味着该数组的元素值既可以是一个字符串值,也可以是nothing
。对于Missing
来说也是类似的。下面是一些使用示例:
可以看到,如果我们传给数组构造函数的第一个参数值是nothing
,那么此次被创建出的数组的所有元素值就都会是nothing
。若传入missing
的话也是类似的。
除了上面讲的构造函数,Julia 还提供了另外的一些可以创建多维数组的函数。比如,函数zeros
可以创建元素值全为零值的数组。示例如下:
zeros
函数的第一个参数的名称是T
,代表元素类型。这个参数是可选的,如果我们选择不为它传入值,那么其值就是缺省的Float64
。该函数的第二个参数的名称是dims
,与前述的构造函数中的dims
含义相同。
注意,这个函数的第一个参数值只能是一个数值类型。也就是说,它可以是任意的布尔类型、整数类型、浮点数类型、复数类型、有理数类型,以及无理数类型。另外,对于不同的数值类型,其零值也是不同的。所谓的零值,就是用来表示0
的值。比如,UInt8
类型的零值是0x00
、Complex
类型的零值是0+0im
,Rational
类型的零值是0//1
,等等。
与之类似,ones
函数可以创建元素值全为1
的数组。其参数的定义与zeros
函数的参数定义相同。仍要注意,不同的数值类型表示1
的方式也不同。
还有一个名叫fill
的函数,它有两个参数:x
和dims
。参数x
代表的值将会被填充到新数组的所有元素位置上。显然,新数组的元素类型由x
决定。与前面一样,新数组的维数和大小仍由dims
决定。下面是一个示例:
另外,函数trues
和falses
也很常用。它们都只有一个名为dims
的参数。trues
函数用于创建元素值全为true
的数组,而falses
函数则用于创建元素值全为false
的数组。注意,它们创建的数组的类型并不是Array
,而是BitArray
。
BitArray
类型也被称为位数组类型。它是元素类型为Bool
的Array
类型的优化版本。它仅使用 1 个比特来存储一个元素值。要知道,在通常情况下,Bool
类型的每一个值都需要占用 8 个比特。这就意味着,位数组在存储空间的利用率方面有着 8 倍的提升。为了与标准的存储方式保持兼容,从位数组取出的元素值会被还原成(新的)常规的布尔值。
以上就是我们构造数组值的时候经常会用到的函数。当然,还有一些函数也可以被用来构造数组值,如函数rand
、randn
、collect
、similar
、reinterpret
等。不过,这些函数在功能上就没有那么的纯粹了。
9.4 数组的基本要素
当我们拿到一个数组,首先应该去了解它的元素类型、维数和尺寸。在 Julia 中,这些信息都由专门的函数提供。函数eltype
可以获取到一个数组的元素类型,函数ndims
用于获取一个数组的维数。length
函数用于获得一个数组的元素总数量。而若要想获得数组在各个维度上的长度,我们就需要使用size
函数。
size
函数有一个必选的参数A
,代表目标数组。它还有一个可选的参数dim
,代表维度的序号。在调用size
函数的时候,如果我们只为A
指定了参数值,那么该函数就会返回一个元组。这个元组会依次地包含该数组在各个维度上的长度。但倘若我们同时给定了dim
的值,那么它就只会返回对应的那个长度了。例如:
我使用数组值的一般表示法创建了一个 5 行 6 列的数组array2d
。这个数组拥有两个维度,其元素类型是Int64
。之所以表达式size(array2d)
的求值结果为(5, 6)
,是因为该数组在第一个维度和第二个维度上的长度分别是5
和6
。实际上,我们用5
乘以6
就可以得到这个二维数组的元素总数量30
。
9.5 访问元素值
在获知了一个数组的基本要素之后,我们就可以去探查其中的元素值了。接下来,我会从最基本的访问方式讲起。
9.5.1 索引
对于数组来说,索引表达式依然是有效的。我们先看一个示例:
可以看到,我先使用点索引表达式获取了array2d
中的第 1 个元素值,又使用点索引表达式获取了其中的第 1、3、5 个元素值。注意,在后者的中括号里的是一个包含了 3 个索引号的数组。因此,我们也可以把后者称为多点索引表达式,而把前者称为单点索引表达式。
在这之后,我还使用范围索引表达式获取了array2d
中的前 6 个元素值,其结果仍然是一个一维数组。更宽泛地说,针对数组的多点索引表达式和范围索引表达式的求值结果总会是一个一维数组,无论其中的索引号横跨了几个维度都是如此。
在数组的上下文中,索引号就是元素位置的序号。它总是从1
开始,且最后一个索引号总与当前数组的元素位置总数相等。还记得吗?这种索引号组成的索引也被称为线性索引。对于一维数组,这很好理解。因为其中的元素位置与索引号一样,都只有一个维度,很容易就能对应起来。
对于多维数组,线性索引仍然是可用的。不过,与线性索引中的索引号不同,多维数组中的元素位置却处在一个多维度的空间中。在这种情况下,对应两者就不那么容易了,需要一点空间想象力。Julia 会按照既定的顺序把索引号逐个地分配给多维数组中的每一个元素位置。更确切地说,它依照的是数组中维度的次序以及各个维度上的元素顺序。
就拿array2d
来说,索引号1
至5
会被分配到这个二维数组包含的第 1 个一维数组。这个一维数组也就是它的第 1 列,即最左边的那一列。因此,该二维数组中的元素值1
、2
、3
、4
、5
的索引号恰好分别是1
、2
、3
、4
、5
。接下来,它的第 2 列中的 5 个元素值的索引号分别是6
、7
、8
、9
、10
,其第 3 列中的 5 个元素值的索引号分别是11
、12
、13
、14
和15
,等等。总之,array2d
中的每一个元素位置上的值正好就是它的索引号。这样你也可以非常直观地看到线性索引号在多维数组中的分配方式。
我们再来看一个例子:
我使用reshape
函数改变了array2d
的复本,把它变为了一个3×5×2
的三维数组。我们重点来看array3d
代表的三维数组。虽然数组从二维变成了三维,但是其中元素值的排列顺序却没有被改变。所以,我们依然能够通过各个元素位置上的值了解到它们的索引号。
使用前面的术语来讲的话就是这样的:索引号1
至15
会被分配到这个三维数组包含的第 1 个二维数组。而索引号1
至3
又会被分配到这个二维数组包含的第 1 个一维数据,也就是其中的最左边那一列。按照这个思路,你应该就可以解释这些索引号的每一个分配结果了。
无论一个数组拥有多少个维度,我们都可以使用线性索引的索引号定位到相应的元素值。虽然线性索引的速度很快,但是有时候使用它会有些麻烦,因为这涉及到从多维到一维的换算。所以,对于多维数组,我们还经常使用更加直观的笛卡尔索引(cartesian index)。笛卡尔索引中的索引号是多维的,并且其中的索引号的数量与当前数组的维数保持一致。
在 Julia 中,有一个专门代表笛卡尔索引的类型,名为CartesianIndex
。它的构造函数既可以接受一个包含了若干个索引号的元组,也可以接受若干个由英文逗号分隔的索引号。示例如下:
CartesianIndex
类型的每一个值都表示一个多维度的索引。在这样的索引中,索引号I
用于表示第 N 个维度上的第I
个元素。这个元素对应的可能是一个 N-1 维的数组,也可能是单个的元素位置。其中的 N 与索引号在笛卡尔索引中的(从左到右的)次序保持一致。
如CartesianIndex(3, 2, 1)
,它表示的是一个针对三维数组的笛卡尔索引。其中的1
表示第三个维度上的第 1 个二维数组,2
表示此二维数组包含的第 2 个一维数组,而3
则表示此一维数组包含的第 3 个元素位置。由此,这个笛卡尔索引值就唯一地确定了一个元素位置。
经过前面的反复阐释,我相信你已经对多维数组有了足够的空间想象力。笛卡尔索引其实就是基于多维空间而建立的。现在,让我们把CartesianIndex(3, 2, 1)
应用在array3d
代表的三维数组上:
如上所示,我们可以直接把CartesianIndex
类型的值放在索引表达式的中括号中。实际上,这个索引表达式还可以被简化为array3d[3, 2, 1]
。虽然这种简化只是把针对各个维度的索引号直接罗列在了中括号内,但它却让更加灵活的索引方式成为了可能。
还记得我们之前见过的[:, :, 1]
吗?它其实表达的就是一个多维度的索引。示例如下:
与之前的含义一致,这个多维索引选择的是array3d
中的第 1 个二维数组中的全部元素值。注意,上面的索引表达式的求值结果就是一个3×5
的二维数组,就像把对应的二维数组原封不动地摘出来了一样。
我们再来看一个更复杂一些的例子:
看到了吗?在上面的中括号里还有中括号。这就意味着多维索引是可以嵌套的。在上面这个多维索引中,右边的索引号1
选择的仍然是array3d
中的第 1 个二维数组。中间的[1,2]
是一个嵌入的多维索引,它选择的是这个二维数组中的前 2 列。而左边的:
则表示选择这 2 列中的所有元素值。因此,上述索引表达式的求值结果就是一个3×2
的二维数组。
当然,我们也可以选择array3d
中的所有二维数组的前 2 列:
这个求值结果是一个3×2×2
的三维数组,就好像只是把那两个二维数组的后 3 列都抠掉了似的。可见,通过多维索引选择出的部分数组总是会最大限度地保持原有的形状。不过,我们一定要注意下面两种不同的索引方式所带来的差异:
在多维索引中,如果针对某个维度的索引仅由一个索引号代表,那么与这个维度对应的数组就会被拆散,或者说我们在最终的索引结果中就看不到原本在这个维度上的数组了。相对的,如果针对某个维度的索引是一个嵌入的多维索引,那么我们在最终的索引结果中就仍然会完整或部分地看到原本在这个维度上的数组。
在多维索引[:, [1,2], 1]
中,针对第三个维度的索引是索引号1
。因此,与这个维度对应的数组就会被拆散,仅留下该索引号选择的第 1 个二维数组。针对这个二维数组的索引是嵌入的多维索引[1,2]
,因此该二维数组的一部分就会被保留下来。针对一维数组的索引由:
占位,它等同于一个选择了所有元素的嵌入索引,因此相应的一维数组会被完整地保留。由此,最终的索引结果就是一个拥有两个维度的新数组。
你可能会想到,正是因为中间的那个嵌入的多维索引选择了两个元素,对应的二维数组才会被保留下来。这样说没有错。但请记住,即使嵌入的多维索引只选择了一个元素,当前维度上的数组也会被保留。
就拿上例中的第二个多维索引[:, [1,2], [1]]
来说。虽然其中针对第三个维度的索引[1]
只选择了第 1 个二维数组,但由于它是一个嵌入的多维索引,所以与之对应的三维数组的一部分仍然会出现在最终的索引结果中。从 REPL 环境回显的内容可知,这个索引结果是一个3×2×1
的三维数组,而不是一个二维数组。并且,其中的那个唯一的二维数组是由[:, :, 1]
指代的。这显然是展示三维数组的格式。
我们再来看一组例子。这次先使用的是多维索引[:, 1, :]
:
我们这次选择的是array3d
里的那两个二维数组中的第 1 列。由于针对第二个维度的索引是索引号1
,所以与之对应的两个二维数组就都被拆散了,只留下了那两个处于最左边的一维数组。把它们拼接在一起就形成了最终的索引结果,即一个3×2
的二维数组。
换个角度讲,由于原来的二维数组已被拆散,导致原来的第三个维度变成了新的第二个维度,因此在最终的索引结果中就会有两个维度。又由于针对第一个维度和原第三个维度的索引都由:
占位,所以最终的索引结果就是一个3×2
的二维数组(请对比array3d
代表的3x5x2
的三维数组)。这个二维数组的内容完全由针对原第二个维度的索引号1
指定。
我们接下来使用[1, :, :]
对array3d
进行索引,结果如下:
这一次,针对第二个维度和第三个维度的索引都由:
占位,而针对第一个维度的索引却是索引号1
。一维数组当然也可以被拆散。它会被拆成一个一个的元素值。这个索引号1
会让这些一维数组中的第 1 个元素值都被留下来,而其他的元素值都会被抛弃。
由于原来的一维数组已被拆散,导致原来的第二个维度变成了新的第一个维度,且原来的第三个维度变成了新的第二个维度。因此,最终的索引结果就是一个5×2
的二维数组。之前被留下来的那些元素值会被依次地填充到这个二维数组中的各个元素位置上,且填充的顺序会完全遵从线性索引的顺序。
到这里,我们讲了针对一维数组和多维数组的线性索引,也讲了针对多维数组的笛卡尔索引(也称多维索引)。由于笛卡尔索引是可以嵌套的,因此使得它非常的灵活和强大。但这种索引的复杂度自然也就变高了。所以,我们在前面还举了很多例子,并借此详细地讨论了索引操作的主要过程。在看过了这些内容之后,你是否已经对数组的索引完全清楚了呢?
顺便说一句,由于数组是可变的容器,所以我们还可以利用索引去修改其中的某个或某些元素位置上的值。
9.5.2 迭代
我们在前面说过,迭代是根据反馈重复地执行相同操作的过程。在 Julia 中,我们可以使用for
语句来实现循环,并用它来迭代通常的容器,包括数组。请看下面的示例:
这条for
语句依次地打印出了array2d
中的每一个元素值,且每个元素值都独占了一行。直到打印出array2d
中的最后一个元素值,也就是与索引号30
对应的元素值,这个循环才完全结束。数组中的元素值会被按照线性索引的顺序依次地赋给迭代变量e
。
如果我们对数组中的元素值不感兴趣,而只是想用for
语句迭代出其中所有的线性索引号的话,那么就可以使用eachindex
函数。
eachindex
函数可以接受一个数组作为其参数值。这时,它会专门为这个数组中的索引创建一个可迭代的对象(或称迭代器),并将其作为结果值返回。既然这个对象是可迭代的,那么它就可以被用在for
语句中。因此,下面的代码是可行的:
对于array2d
来说,使用eachindex
函数的意义好像并不大。但对于我们已经介绍过的各种可索引对象而言,这个函数提供了一种可以访问其线性索引的标准方式。另外,该函数还可以被用来访问其他类型的数组中的索引,甚至其他类型的容器中的索引。只不过,那就不一定是线性索引了,也可能是笛卡尔索引。这里所说的其他类型的数组是指,除了Array
之外且同样继承自AbstractArray
的那些类型的实例。
此外,还有一种方式,它可以把数组中的各个元素值及其索引号分别包装成键值对,然后创建一个能够按照原有顺序访问这些键值对的迭代器。这就是pairs
函数所提供的功能。注意,这些键值对都会以索引号为键,并以元素值为值。请看下面的示例:
注意,这里有两个迭代变量:i
和v
。它们分别代表了键值对中的键和值。另外,我们还可以看到,上述键值对中的索引都是笛卡尔索引,因为array2d
是一个二维数组。对于多维数组,pairs
函数会把元素值的笛卡尔索引作为它们的键。而对于一维数组,pairs
函数则会把元素值的线性索引号作为它们的键。这都是在默认情况下的规则。
我们也可以自己选择pairs
函数所使用的索引。在 Julia 中,这也被称为索引风格的选择。pairs
函数还有一个可选的参数正是用于此种选择的。它有三个选项,分别是:IndexLinear()
、IndexCartesian()
和IndexStyle(A)
。前两个选项分别是IndexLinear
类型和IndexCartesian
类型的实例。这两个类型都是IndexStyle
类型的子类型。从其名称我们就可以看出,它们分别代表了线性索引风格和笛卡尔索引风格。
这个可选参数的第三个选项IndexStyle(A)
是针对pairs
函数的那个唯一的必选参数A
而言的。因此,它的含义就是遵从A
所代表的那个数组的索引风格。然而,不论是一维数组还是多维数组,只要它的类型是Array
,它默认使用的就是线性索引风格。示例如下:
至此,我们已经知悉了迭代数组的标准方式——使用for
语句。我们还了解到,可以用eachindex
函数或pairs
函数包装被迭代的数组,以达到不同的迭代效果。虽然可以实现这种包装的函数不止这两个,但是它们已经可以满足绝大多数的需求了。在这里,你应该特别记忆的是,那些相关的默认规则和定制化方式。
9.5.2 搜索
搜索指的是搜索数组中的元素值。在 Julia 中,这种搜索也是基于索引的。Julia 的Base
模块里有不少提供了此功能的函数,我们在前面已经讲过了一些。为了方便你选用,我做了下面这张表。这样你也可以对它们有一个整体上的了解。
表 9-1 可在数组中搜索的函数
我们之前讲过的函数findfirst
、findlast
、findnext
和findprev
都可以被用于搜索数组中的元素值。在一般情况下,我们传给它们的第一个参数值都应该是一个用来做条件判断的函数,而这个函数返回的结果都应该一个布尔值。下面是几个简单的例子:
一定要注意,对于一维数组,前面这 4 个函数在找到满足条件的元素值之后,都会返回该值的线性索引号。而对于多维数组,它们在这时都会返回元素值的笛卡尔索引。这与pairs
函数的默认规则是相同的,但是与eachindex
函数的行为以及(Array
类型的)数组的默认索引风格却有着明显的差异。不过,这种差异只存在于对多维数组索引的选择上。
相应的,我们在为findnext
函数和findprev
函数传参的时候也要注意这种差异。这两个函数都需要一个代表了搜索起始点的参数值。如果搜索的是一维数组,那么我们就必须使用线性索引号来表示这个起始点,否则就必须使用笛卡尔索引。
我们再来说findall
函数。这个函数会在被搜索的数组的全范围内寻找目标元素值,然后把那些满足条件的元素值的索引号都放到一个一维数组中。即使没有找到任何满足条件的元素值,它也依然会返回这个空的一维数组,而不会像前 4 个函数那样返回nothing
。不过,在对数组索引的选择上,findall
函数总会与前 4 个函数保持一致。
到目前为止,我们一直说的是前 5 个函数在一般情况下的调用方式。其实,我们也可以不传入那个用来做条件判断的函数。不过这样的话,它们对被搜索的数组就有要求了。具体的要求是,被搜索的数组的元素类型必须是Bool
。在这种情况下,这些函数拿来做判断的条件就是“元素值必须等于true
”。例如:
别忘了线性索引的顺序。对于二维数组来说,它是先纵向、后横向的。这与现代人写字和阅读的顺序有着明显的不同。
我们接着往下看。很显然,findmax
函数和findmin
函数所依据的条件都不用我们来指定。并且,当数组中存在多个最大值或多个最小值的时候,它们只会选择线性索引号最小的那一个。另外,一旦碰到NaN
,那么它们就会直接把这个NaN
及其索引号组成的元组作为结果值返回。还有,这两个函数在对数组索引的选择方面依然如同前面那 5 个搜索函数。但与那些函数不同的是,对于空的被搜索数组,这两个函数都会立即抛出ArgumentError
类型的错误。示例如下:
请注意,虽然我们在前面的例子中搜索的都是数值的数组,但你千万不要以为这些函数只能搜索这类数组。即使对于函数findmax
和findmin
来说,只要一个数组中的所有元素值之间都是可比较的,那么它们就可以对这个数组进行搜索。
除此之外,findmax
和findmin
还可以帮助我们寻找多维数组在某个或某些维度中的最大值或最小值。我们以array2d
为例,代码如下:
可以看到,当我在调用findmax
函数的时候把1
赋给了它的关键字参数dims
。顺便说一下,对于关键字参数,我们必须使用<name>=<value>
的方式为其赋值,如dims=1
。此时,这个函数就会去寻找array2d
里的第一个维度(或者说各个列)中的所有最大值。它在这里返回的结果值是一个元组。这个元组先后包含了每一列中的最大值(共有 6 个)以及它们的笛卡尔索引。
按照这个规则,如果我在这里把2
赋给这个函数的dims
参数,那么它就会去寻找array2d
里的第二个维度(或者说各个行)中的所有最大值。这时,它同样会返回一个元组,并且其中会先后包含每一行中的最大值(应该有 5 个)以及它们的笛卡尔索引。
findmax
函数的dims
参数在含义上与我们在前面讲过的同名参数并没有什么两样。这个参数的值在这里既可以是一个代表了某个维度的整数,也可以是一个代表了多个维度的元组或数组。如果是后者,那么该函数就会把指定的多个维度合起来看,并在其中寻找最大的值。例如:
我把元组(1,2)
作为了参数dims
的值,使得findmax
函数把array2d
里的第一个维度和第二个维度作为一个整体看待,并去寻找这个整体中的最大值。显然,这里的这个最大值仅有一个,即处在第 5 行、第 6 列的30
。
对于findmin
函数也是一样,它同样有一个名为dims
的可选参数,只不过它寻找的是多维数组在某个或某些维度中的最小值而已。
好了,只要你记住了上述 7 个函数的用法,就可以自如地在数组中搜索元素值了。
9.6 修改元素值
9.6.1 索引
修改一个数组最简单的方式就是使用索引表达式。无论是单点索引表达式,还是多点索引表达式,又或是范围索引表达式,都可以被用来修改数组。示例如下:
这里有两点需要注意。第一点,当我们使用多点索引表达式或范围索引表达式的时候,在赋值符号=
右边的应该是一个一维的数组。并且,这个一维数组的长度应该与我们要替换的元素值的数量一致。第二点,不管使用哪一种索引表达式,等号右边的值或元素值都必须能被转换成其左边数组的元素类型的实例,否则 Julia 就会立即报错:
浮点数10.1
是Float64
类型的,它不能被转换成Int64
类型的实例,所以 Julia 就报错了。
另外,我们也可以利用笛卡尔索引对数组进行修改。比如:
简单地解释一下,函数copy
用于浅拷贝一个值。在这里,我利用copy
函数得到了数组array3d
的复本,并把这个复本赋给了变量array3d_copy
。关于copy
函数和浅拷贝,我在下一章都会进行详细的说明。
9.6.2 视图
我们已经知道,索引表达式可以让我们获得一个数组中的某个或某些元素。如果索引表达式返回的是单个的元素值,那么这个值就是原数组中对应的那个元素值本身。如果索引表达式返回的是一个数组,那么它就相当于在一个新的数组结构中沿用了原数组中的相应元素值。这其实与copy
函数有着异曲同工之妙。然而,不论索引表达式的求值结果是什么,我们都不能通过这个结果值去替换原有数组中的元素。但是,我们通过视图(view)是可以做到这一点的。
函数view
用于创建一个数组的视图。它的第一个参数就是视图基于的那个数组(或称父数组)。除了父数组以外,我们还可以为它传入一个或多个索引号。为了演示,我们先定义一个新的多维数组:
解释一下,Vector(1:36)
会构造出一个向量。这个向量的元素类型是Int
(具体到这里是Int64
),长度是36
,并且其中会依次地包含从1
到36
的整数值。函数reshape
会先创建一个此向量的复本,然后把该复本变成一个3×3×2×2
的四维数组。这个四维数组的元素类型和长度都与原数组保持一致,只是在维数和尺寸上有所变化。
现在,我们基于四维数组array4d
创建视图:
由 REPL 环境回显的内容可知,我们创建了一个零维的视图。什么叫零维呢?如果说二维是一个面、一维是一条线的话,那么零维就是一个点。零维的数组或视图就相当于一个标量(scalar)。所谓的标量,可以说就是不包含其他值的单一值。像数值、字符值、字符串值、符号、类型、函数,以及一些常见的单例如missing
、nothing
等都属于标量。
零维数组没有任何的维度,这意味着在任何维度上它们都没有所谓的长度。因此,把size
函数用在它们身上就只会返回空的元组。不过它们却有总长度,而且这个总长度总是1
。这是因为它们终归还是数组,并且里面终归还是有一个元素值的。相关的代码如下:
那么我们怎样才能从中取出那个唯一的元素值呢?答案是,依然使用索引表达式。不过,在针对零维视图的索引表达式中,索引号就变得可有可无了。例如:
既然我们可以这样取出视图中的元素值,那么必然也可以利用这种方式替换元素值。代码如下:
一定要注意,我们对视图中元素值的替换肯定会改变其父数组中的对应元素值。因此,一旦替换了视图array4d_view1
中的那个元素值,也就等于替换了数组array4d
中与线性索引号26
对应的那个元素值。
我们也可以把数组中的多个元素值汇聚到同一个视图里。这时,我们需要用中括号把多个线性索引号包裹起来,并将其作为view
函数的第二个参数值。比如:
注意,视图中的各个元素值的线性索引号,不一定就等于它们在父数组中的那个线性索引号。就拿视图array4d_view2
来说。其中有 3 个元素值,它们在这个视图中的线性索引号分别是1
、2
和3
。但是,后两个元素值在该视图的父数组array4d
中的线性索引号却分别是3
和5
。也就是说,视图上分配的线性索引号与它的父数组没有任何关系。它们是单独排列的,互不干扰。
我们若想要通过array4d_view2
替换掉其父数组中的元素值也很容易。代码如下:
在这里,我们需要小心的地方是,等号两边的视图或数组所包含的元素值的数量必须一致,否则替换就无法成功完成。
另外,除了线性索引,我们还可以在创建视图的时候使用笛卡尔索引。不过,笛卡尔索引在这里就不需要由中括号包裹了。更确切地说,在调用view
函数的时候,笛卡尔索引中的每一个部分都需要作为一个独立的参数值。就像这样:
上面这个视图引用的是数组array4d
里的一个列向量中的所有元素值。而这个列向量就是array4d
中的第 2 个三维数组中的第 2 个二维数组中的第 1 个一维数组。下面我们来替换它引用的那些元素值:
怎么样?是不是很容易呢?只要理解了视图的本质,这就绝对算不上难事。你可以把视图想象成一个窗口。我们可以通过这个窗口看到其父数组中的一部分甚至全部的元素值。而且,更重要的是,透过这个窗口我们还可以直接存取那些看得到的元素值。
顺便说一下,当我们拿到一个视图时,可以通过调用parent
函数得到它的父数组本身。如:
另外,我们还可以通过调用parentindices
函数获得视图里的所有元素值在其父数组中的索引号(的另一种表现形式)。如:
可以看到,我们需要对parentindices
函数的调用结果做进一步的转换。这主要是因为,视图中的每一个元素值都会有自己的父数组索引。而这些索引无法仅由单个值来表示,甚至无法被简单地表示出来。
幸好CartesianIndices
函数可以正确地识别出parentindices
函数返回的结果值,并产出一个笛卡尔索引的序列。而且,这样的序列可以被直接应用在针对数组的索引表达式中。不过,如此索引出的结果可能会与直接索引(如array4d[:, 1, 2, 2]
)得出的结果在尺寸上有所不同。如果一定要保持一致,我们可以再调用一下vec
函数。这个函数能够沿着线性索引号把一个多维数组的复本捋直,让它变成一个一维数组。
总之,视图是一个基于数组的窗口。它能够让我们直接改动窗口内的元素值,同时又可以保护窗口之外的那些元素值。说它是修改数组的一把利器一点也不为过。
9.6.3 一些专用函数
除了上述的修改方式之外,Julia 还为数组提供了大量的专用函数。我在这里只简要地列举一下其中比较有特点的一些函数。注意,它们的名称都是以!
结尾的。
circshift!
函数:该函数可以在数组的一个或多个维度上循环式地挪动元素。我们之前说过,在某个维度上的元素指的可能是元素值,也可能是低维数组。所以在这里,在第一个维度上挪动的单元是元素值,而在更高维度上挪动的单元则是相应的低维数组。例如:数组[1, 2, 3, 4]
在按照线性索引的顺序挪动 1 次之后就生成了[4, 1, 2, 3]
。accumulate!
函数:该函数可以面向数组在某个维度上的元素做累积计算。例如,数组[1, 3, 5, 7]
在经过累积加法操作之后就生成了[1, 4, 9, 16]
。目的数组中的第 1 个元素值完全取自源数组中的第 1 个元素值1
。而这个元素值和源数组中的第 2 个元素值3
相加,就得到了目的数组的第 2 个元素值4
。然后,这个元素值再与源数组中的第 3 个元素值5
相加,就得到了目的数组的第 3 个元素值9
。以此类推。cumprod!
函数:该函数可以面向数组在某个维度上的元素做累积乘法。实际上,调用表达式cumprod!(dest, src)
就相当于accumulate!(*, dest, src)
。cumsum!
函数:该函数可以面向数组在某个维度上的元素做累积加法。实际上,调用表达式cumsum!(dest, src)
就相当于accumulate!(+, dest, src)
。permute!
函数:该函数可以置换向量中的元素值。更具体地讲,它可以根据第二个参数值给定的索引号序列,重新排列第一个参数值中的元素。例如,如果变量v
的值是[15, 24, 33, 42]
,且变量p
的值为[4, 2, 3, 1]
,那么调用表达式permute!(v, p)
的执行就会让v
的值变成[42, 24, 33, 15]
。invpermute!
函数:该函数可以对向量中的元素值进行逆置换。也就是说,它的功能与permute!
函数的功能是互逆的。例如,调用表达式invpermute!(permute!(v, p), p)
会让变量v
的值最终依然为原值。reverse!
函数:该函数可以逆序排列向量中的全部或部分元素值。例如,如果变量v
的值是[1, 2, 3, 4]
,那么表达式reverse!(v)
的求值结果就是[4, 3, 2, 1]
,而表达式reverse!(v, start=2, stop=3)
的求值结果则是[4, 2, 3, 1]
。
另外,Julia 还提供了很多与线性代数有关的函数。比如,可以转置向量和矩阵的transpose!
函数、可以做向量标准化的normalize!
函数、可以计算矩阵与矩阵或矩阵与向量的乘积的mul!
函数、可以对数组中的元素值进行缩放的lmul!
和rmul!
函数、可以求共轭转置数组的adjoint!
函数、可以获得矩阵特征值的eigvals!
函数、可以计算奇异值分解的svd!
函数,等等。它们与其他众多不会修改原值的线性代数函数一起被定义在了LinearAlgebra
模块里。我们在做数据特征工程或者构建机器学习模型的时候很可能会直接或间接地用到它们。
9.7 小结
我们在这一章讲的是 Julia 中最强大的容器——数组。它也是一种相对复杂的容器。它的特点可以由三个词组来概括,即:可变的对象、同类型的元素值,以及多维度的容器。其中的最后一个特点在 Julia 预定义的容器中是独有的。
数组的类型字面量只能体现它的元素类型和维数,而不能体现元素的顺序以及各个维度上的元素数量。不过多维数组在各个维度上的元素数量仍需满足既定的规则。
我们可以使用一般表示法表示一维数组和二维数组。这涉及到了元素值分隔符“,
”、纵向拼接符“;
”以及作为横向拼接符的空格。不过,对于三维数组,这种表示法就无能为力了。
我们可以利用数组的构造函数来创建拥有更多维度的数组。在这里,我们需要注意的是,传入的参数值对于新数组的尺寸以及其中元素值的影响。除了构造函数,我们还可以使用zeros
、ones
、fill
之类的函数创建多维数组。
Julia 为我们提供了专门的函数以获取一个数组的元素类型、维数、元素值总数以及它在各个维度上的长度。我们在访问数组中的元素值的时候有几种方式可供选择,比如使用索引表达式,又比如使用for
语句进行迭代。注意,Array
类型的数组拥有两种索引,即:线性索引和笛卡尔索引。我们可以利用它们在这类数组上进行灵活的定位,并同时获取到在不同位置上的多个元素值。除此之外,我们还可以通过一些搜索函数查找一个或多个值在某个数组中的索引号。
对于数组中元素值的修改,我们同样可以使用索引表达式。索引表达式在这方面的不俗表现也同样依托于强悍的索引机制。另外,我们还可以使用视图来查看和修改数组中的元素值。它基于的依然是索引机制。它的一个显著优势是,我们可以通过视图对原有数组中的元素值进行完全的替换。
最后,我们还速览了一些可以对数组进行修改的专用函数。在通常情况下,我们用到这些函数的机会可能并不多。但是在一些专业的且目前很热门的领域里,它们却可以带来相当大的便利。
我们用了一整章的篇幅讨论了数组本身,以及怎样才能正确地表示、构造数组和存取其中的元素值。在看过这一章之后,你应该就可以比较熟练地运用数组了。不过,我们还应该去了解更多关于数组的知识。在下一章,我会继续和你讨论几个与之有关的重要专题。虽然这些专题的内容并不像本章所讲的那么基础,但是它们却可以在很大程度上提高你的编码效率。
系列文章:
Julia编程基础(一):初识Julia,除了性能堪比C语言还有哪些特性?
Julia编程基础(二):开发Julia项目必须掌握的预备知识
Julia 编程基础(四):如何用三个关键词搞懂 Julia 类型系统
Julia编程基础(八):如何在最合适的场景使用字典与集合?
评论 4 条评论