早在向量这一概念里,我们就提到过:“数组可以有任意大小(只受硬件内存的限制),也可以有任意多的维度。”
从那以后,为了保持简单,我们基本忽略了多维数组。如果你试着通读 Julia 参考文档、体会一下它完整的复杂度,就会更理解这个决定。
不过,高维数组在科学计算中_非常非常_重要,所以我们需要理解它们。
命名约定: 沿袭数百年的数学惯例,我们把一维数组叫作 Vectors,把二维数组叫作 Matrices。
本文档中的示例大多是矩阵。处理 3 维或更高维时,语法几乎完全相同,但输出(在二维屏幕上)难以阅读,也让人费解。
一个 eltype 为 T 的 N 维数组,其 type 是 Array{T, N}。(译注:原文如此。)
为了方便,并与数学命名保持一致,Julia 定义了一些类型别名:Vector{T} 是 Array{T, 1} 的别名,Matrix{T} 是 Array{T, 2} 的别名。
# 3-D array
julia> m3 = ones(2, 3, 4);
julia> typeof(m3)
Array{Float64, 3}
# Vector
julia> v = [1, 2]
2-element Vector{Int64}:
1
2
julia> typeof(v) == Array{Int64, 1}
true
# Matrix
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> typeof(m)
Matrix{Int64} (alias for Array{Int64, 2})
julia> typeof(m) == Array{Int64, 2}
true
我们已经在方括号里放一个用逗号分隔的列表,创建过很多向量。分号也可以用作分隔符。
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
熟悉 NumPy 的人会知道,np.linspace 是一个广泛使用的函数,用来生成一个指定长度、指定端点、取值等距的数组。它通常用作图表的 x 轴,或线性模型中的自变量。
Julia 没有完全对应的函数,但非常灵活的 range() 函数可以模仿它:指定上下界,再加上 length 关键字参数。
任何在本地工作、能访问 Plots 包的人都可以运行这段代码:
julia> using Plots
julia> x = range(0, 2π; length=100)
0.0:0.06346651825433926:6.283185307179586
julia> typeof(x)
StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}, Int64}
julia> vals = [x sin.(x) cos.(x)]
100×3 Matrix{Float64}:
0.0 0.0 1.0
0.0634665 0.0634239 0.997987
0.126933 0.126592 0.991955
0.1904 0.189251 0.981929
(...truncated)
julia> plot(vals[:, 1], vals[:, 2:3])
StepRangeLen 可以像向量一样使用,包括转换成矩阵的一列。
另请参见对应的 logrange() 函数,它可以在对数轴上生成等距的取值。
对于二维数组,我们一般按 [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
单独一个 : 会把任意数组按列展平成一个向量。
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> m[:]
6-element Vector{Int64}:
1
4
2
5
3
6
前面我们见过把 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 的值是_最终长度为 1 的那个维度_,所以上面的例子里 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 级,所以复制可能会带来性能灾难!
这样的视图可以用于循环、广播,也可以用于我们目前在教学大纲里见过的其他各种操作。
此外,推导式在接收数组输入时既强大又灵活。循环这一概念里提过一些简单的情况,后面还会有更详细的讨论。
我们已经在向量运算这一概念里讨论过一维的情况。
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
点运算符 .- 通过 broadcasting 把单个值 0.5 扩展成与 [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)。
另外,函数也可以广播到 Matrix 的每个元素上,做法和用 Vector 时完全一样。
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。
许多对向量和矩阵的常见操作,都属于线性代数这个大范畴。
Exercism 网站上(目前)还有两个额外的概念,在我们设计出能与之配套的练习之前,它们只有文档。
将来可能会有第三个关于矩阵分解的概念。
不过请留意,线性代数基础相当偏数学,未必适合所有学生。
后面的概念(方程求解和矩阵分解)更进阶,真正面向的是能轻松应对大学数学的人。
很抱歉,但这就是这门学科本身的样子,也是 Julia 的一个重要应用场景。