早在 Vectors 概念里,我们就指出过:“数组的尺寸可以任意大(只受硬件内存的限制),维数也可以任意多。”
从那以后,为了保持简单,我们基本都避开了多维数组。如果你试着去读 Julia 的参考文档,感受一下它们那完整的复杂度,就会更明白这个决定。
不过,高维数组在科学计算中_非常非常_重要,所以我们必须理解它们。
术语说明: 沿袭几百年来数学上的惯例,我们把一维数组称为 Vectors,把二维数组称为 Matrices。
本文中的例子大多会是矩阵。处理 3 维或更多维度时,语法几乎完全相同,但在二维屏幕上,输出既难读又容易让人困惑。
我们已经通过把逗号分隔的列表放进方括号,创建过很多向量。分号也可以用作分隔符。
julia> v = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> w = [1; 2; 3]
3-element Vector{Int64}:
1
2
3
julia> v == w
true
如果用空格(或制表符)作分隔符,结果就不一样了。
julia> u = [1 2 3]
1×3 Matrix{Int64}:
1 2 3
Julia 现在会把它定义成一个 1×3 的矩阵(在其他语境里,我们会称它为 row vector)。
一般来说,空格把元素横向拼在一起,分号(或换行)把元素纵向拼在一起。
这里说的“东西”是刻意含糊的,因为不管你给它什么,Julia 都会试着处理。
julia> [v 2v]
3×2 Matrix{Int64}:
1 2
2 4
3 6
julia> [v; 2v]
6-element Vector{Int64}:
1
2
3
2
4
6
函数 hcat() 和 vcat() 做的事情是一样的,只是更明确地表明这是横向和纵向的拼接。更高维的推广则是 cat() 函数。
jjulia> hcat(v, 2v)
3×2 Matrix{Int64}:
1 2
2 4
3 6
julia> vcat(v, 2v)
6-element Vector{Int64}:
1
2
3
2
4
6
直接输入矩阵时,按行主序来写很方便,因为这样符合人的直觉(对使用横排文字的文化来说,看着更舒服):
julia> m = [1 2 3; 4 5 6]
2×3 Matrix{Int64}:
1 2 3
4 5 6
不过要注意,Julia(和 Fortran、R、Matlab 一样,但_不同于_ C/C++ 或 NumPy)是以列主序存储 N 维数组的,如果你要遍历元素,这会带来巨大的性能差异。善待你的 CPU 缓存,它也会善待你!
# put these integers in 2 rows and 3 columns
julia> reshape(collect(1:6), 2, 3)
2×3 Matrix{Int64}:
1 3 5
2 4 6
上面的例子把整数 1 到 6 按列填入一个 2×3 的矩阵。
还有很多实用函数可以构造常见的数组类型(均匀的或随机的)。
julia> zeros(2, 3) # see also ones()
2×3 Matrix{Float64}:
0.0 0.0 0.0
0.0 0.0 0.0
julia> falses(2, 2) # booleans, see also trues()
2×2 BitMatrix:
0 0
0 0
julia> rand(Float32, 2, 3) # random numbers in the interval [0, 1)
2×3 Matrix{Float32}:
0.768823 0.169633 0.632565
0.388451 0.109176 0.850381
对于二维数组,我们通常按 [row, col] 的顺序使用两个下标。
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> m[1, 2] # row 1, col 2
2
# Stay within bounds! There is no row 3.
julia> m[3, 1]
ERROR: BoundsError: attempt to access 2×3 Matrix{Int64} at index [3, 1]
julia> m[3]
2
最后一个例子可能有点出乎意料:用_单个_下标不会报错,而是返回单个元素。
原因还是要回到前面关于列主序的说明:Julia 会先走完第 1 列,再走第 2 列,直到在_内存中_找到第 3 个元素。
小心:这在编写通用库时有些用处,但更可能让你困惑!
查询数组大小时也有类似的问题。length() 给出元素总数,size() 给出一个包含 ndims() 个元素的元组,每个元素对应一个维度的长度。
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> length(m)
6
julia> size(m) # 2 rows, 3 cols
(2, 3)
julia> ndims(m) # how many dimensions? Like `m |> size |> length`
2
我们可以很方便地复制子矩阵。
在下面的例子里,reshape() 函数会把数组按列填充,强制转换成指定的维度,然后我们对它做切片。
julia> m12 = reshape(collect(1:12), 4, 3)
4×3 Matrix{Int64}:
1 5 9
2 6 10
3 7 11
4 8 12
julia> m12[2:4, 1:2]
3×2 Matrix{Int64}:
2 6
3 7
4 8
# some rows, all columns
julia> m12[2:4, :]
3×3 Matrix{Int64}:
2 6 10
3 7 11
4 8 12
单独一个 : 表示“复制这个维度里的所有内容”。
对于不连续的行或列,可以用向量:
julia> m12[[1, 3], :] # rows 1 and 3
2×3 Matrix{Int64}:
1 5 9
3 7 11
我们之前见过把 sum() 和 maximum() 这类聚合函数用在了一维集合上,它们会对所有元素进行操作,并返回一个标量结果。
这在更高维的情况下同样适用。不过,我们有时只想让函数沿某一个维度作用,比如向下求和或横向求和,从而返回一个大小为 1 的 singleton dimension 的数组。
为此,可以使用一个可选的 dims 关键字。
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> sum(m) # sum everything
21
julia> sum(m; dims=1) # sum down
1×3 Matrix{Int64}:
5 7 9
julia> sum(m; dims=2) # sum across
2×1 Matrix{Int64}:
6
15
# other aggregation functions are similar
julia> maximum(m; dims=2)
2×1 Matrix{Int64}:
3
6
dims 的值就是_变成单例的那个维度_,所以在上面几个例子里,dims=1 => 1×3,dims=2 => 2×1。
推而广之,如果把 dims 设为一个数组或区间,高维数组就能沿多个维度做归约(不过,要读懂输出可能还得费点脑筋!)。
dims 关键字在 sum() 这类内置函数里很常见,但我们要怎么在自己的代码里写出等价的功能呢?
一个不错的答案是使用 reduce() 这类高阶函数,这会在后面的概念里详细展开。
另一种办法是,Julia 提供了一些函数,让我们可以把 N 维数组当作嵌套的“向量的向量”来处理。
对于矩阵,eachrow() 和 eachcol() 用起来很方便,但更通用的函数是能处理任意维度的 eachslice()。
# m is as in the previous examples
julia> eachrow(m)
2-element RowSlices{Matrix{Int64}, Tuple{Base.OneTo{Int64}}, SubArray{Int64, 1, Matrix{Int64}, Tuple{Int64, Base.Slice{Base.OneTo{Int64}}}, true}}:
[1, 2, 3]
[4, 5, 6]
julia> eachcol(m)
3-element ColumnSlices{Matrix{Int64}, Tuple{Base.OneTo{Int64}}, SubArray{Int64, 1, Matrix{Int64}, Tuple{Base.Slice{Base.OneTo{Int64}}, Int64}, true}}:
[1, 4]
[2, 5]
[3, 6]
这个类型看着有点吓人,但这只是因为它是原数组的一个_视图_,让我们不用复制就能直接操作它。Julia 数组的大小可能达到 TB 级别,所以复制可能是性能上的噩梦!
这样的视图可以用于循环、广播,或者我们到目前为止在教学大纲里见过的其他各种操作。
另外,推导式在处理数组输入时可以非常强大、非常灵活。Loops 概念里提到过一些简单的用法,后面还会有更详细的讨论。
一维的情况我们在 Vector Operations 概念里讨论过。
julia> v = [1.2, 1.5, 1.7]
3-element Vector{Float64}:
1.2
1.5
1.7
julia> v .- 0.5
3-element Vector{Float64}:
0.7
1.0
1.2
带点的运算符 .- 会把单个的 0.5 通过 broadcasting 扩展到匹配的维度,即等同于 [0.5, 0.5, 0.5],然后再逐元素做减法。
把它扩展到更高维,其实也是同样的道理。
例如,把一个 2x1 的行向量 [1.0 1.5 2.0] 与 2x3 的矩阵 [1 2 3; 4 5 6] 的乘法进行广播时,就相当于先把 [1.0 1.5 2.0] 广播成 [1.0 1.5 2.0; 1.0 1.5 2.0],再逐元素相乘。
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> m .* [1.0 1.5 2.0] # broadcast a 1x3 row vector down
2×3 Matrix{Float64}:
1.0 3.0 6.0
4.0 7.5 12.0
julia> m .* [1.0 1.5 2.0; 1.0 1.5 2.0] # 2x3 elementwise multiplication
2×3 Matrix{Float64}:
1.0 3.0 6.0
4.0 7.5 12.0
julia> m ./ [1, 2] # broadcast a 2x1 column vector across
2×3 Matrix{Float64}:
1.0 2.0 3.0
2.0 2.5 3.0
julia> m ./ [1 1 1; 2 2 2] # 2x3 elementwise division
2×3 Matrix{Float64}:
1.0 2.0 3.0
2.0 2.5 3.0
注意: 进行广播时,非单例维度的大小应当一致(例如 2x3 Matrix .* 2x1 Matrix)。
此外,函数也可以像对 Vector 那样,逐元素广播到 Matrix 的每个元素上。
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> (x -> x^2).(m) # broadcast a function to all elements
2×3 Matrix{Int64}:
1 4 9
16 25 36
一开始你可能会觉得,程序员犯迷糊的空间会随着维数指数级增长,但多练习就会好很多。另外,如果你熟悉 NumPy 数组,那些经验也能很好地迁移到 Julia,尽管语法不太一样。
点阵是由点和空白组成的二维图像。 点在空白的背景上构成想要的图案。
点阵图像可以作为二维的Matrix存储在内存中并进行操作。
在下文中,矩阵里的“空白”用0表示,“点”则用非零值表示。
Matrix
该Matrix如下所示,其中0表示空白,1表示点:
[
0 0 1 1 0 0 0 0 0 0 0 0 0 0 1 1 0 0;
0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0;
0 1 0 0 0 1 0 0 0 0 0 0 1 0 0 0 1 0;
0 1 0 0 1 0 1 0 0 0 0 1 0 1 0 0 1 0;
0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0;
1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1;
0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0;
0 1 0 0 0 0 0 1 0 0 1 0 0 0 0 0 1 0;
0 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 0;
0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0;
0 0 1 1 0 0 0 0 0 0 0 0 0 0 1 1 0 0;
]
把它“渲染”出来,长这样:
XX XX
X X
X X X X
X X X X X X
X X
X X
X X X X
X X X X
X XX X
X X
XX XX
定义frown!()和frown()函数,它们接收 Exercism 标志Matrix。
返回一个Matrix,其中的微笑嘴巴改成了皱眉嘴巴。
得到的Matrix渲染出来会是这样:
XX XX
X X
X X X X
X X X X X X
X X
X X
X XX X
X X X X
X X X X
X X
XX XX
定义stickerwall()函数,它以 Exercism 矩阵为输入。
返回一个点阵的Matrix,渲染出来如下:
XX XX XX XX
X X X X
X X X X X X X X
X X X X X X X X X X X X
X X X X
X XX X
X X X X X XX X
X X X X X X X X
X XX X X X X X
X X X X
XX XX XX XX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XX XX XX XX
X X X X
X X X X X X X X
X X X X X X X X X X X X
X X X X
X XX X
X XX X X X X X
X X X X X X X X
X X X X X XX X
X X X X
XX XX XX XX
我们并不局限于只用1表示点,所以如果需要,还可以编码其他有用的信息。
定义colpixelcount()函数,它以任何用1作为点的点阵为输入。
返回一个同样大小的点阵,其中每一列的点变成该列中点的个数。
以 Exercism 标志Matrix作为输入时,输出如下所示的Matrix:
[
0 0 2 2 0 0 0 0 0 0 0 0 0 0 2 2 0 0;
0 8 0 0 0 0 0 0 0 0 0 0 0 0 0 0 8 0;
0 8 0 0 0 1 0 0 0 0 0 0 1 0 0 0 8 0;
0 8 0 0 1 0 2 0 0 0 0 2 0 1 0 0 8 0;
0 8 0 0 0 0 0 0 0 0 0 0 0 0 0 0 8 0;
1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1;
0 8 0 0 0 0 2 0 0 0 0 2 0 0 0 0 8 0;
0 8 0 0 0 0 0 1 0 0 1 0 0 0 0 0 8 0;
0 8 0 0 0 0 0 0 1 1 0 0 0 0 0 0 8 0;
0 8 0 0 0 0 0 0 0 0 0 0 0 0 0 0 8 0;
0 0 2 2 0 0 0 0 0 0 0 0 0 0 2 2 0 0;
]
与其只是谈论怎么渲染我们的作品,不如真正把它做出来,方便查看。
定义render()函数,它以点阵为输入。
返回一个字符串,其中点渲染为'X',0渲染为' ',每一行之间用换行符连接。
julia> render(E)
" XX XX \n X X \n X X X X \n X X X X X X \n X X \nX X\n X X X X \n X X X X \n X XX X \n X X \n XX XX "
打印出来时,它应该能按预期渲染出 ExercismMatrix。
julia> print(render(E))
XX XX
X X
X X X X
X X X X X X
X X
X X
X X X X
X X X X
X XX X
X X
XX XX
对于不是1的“点”,这也应该同样有效:
julia> print(render(colpixelcount(E)))
XX XX
X X
X X X X
X X X X X X
X X
X X
X X X X
X X X X
X XX X
X X
XX XX