数组是 Julia 最核心的主题之一。前面几篇虽然已经零散提过一些数组相关语法,但如果不专门单独整理一篇,很多地方还是会比较乱。

这一篇主要记录:

  • Julia 的数组字面量和创建方式;
  • 索引、切片、视图、形状变换;
  • 常见的数组运算、拼接、遍历和广播;
  • Julia 数组系统里最值得重点记住的设计点。

概述

Julia 的 Array 是按列优先(column-major)存储的,这一点和 MATLAB、Fortran 一致,和 Numpy 默认的行优先风格不同。

默认读者已经熟悉 Python/Numpy 或 MATLAB,因此这里不再解释数组的基础概念,直接看 Julia 数组系统里最容易影响实际写法的部分:一维向量和二维矩阵的区分、闭区间切片、默认拷贝而不是默认视图、广播和点语法。

此外 Julia 的数组有几个很鲜明的特点:

  • 真正区分一维向量 Vector 和二维矩阵 Matrix
  • 索引从 1 开始;
  • 切片范围是闭区间;
  • 普通切片默认会拷贝;
  • 广播和点语法是数组代码的核心写法之一。

数组类型与维度

Array{T,N}

Julia 的一般数组类型可以写成 Array{T,N}

  • T 表示元素类型;
  • N 表示维度数。

例如

1
2
Vector{Int64} == Array{Int64, 1}
Matrix{Float64} == Array{Float64, 2}

当然平时更多还是直接使用别名 VectorMatrix

查看数组类型和尺寸:

1
2
3
4
5
6
7
A = [1 2 3; 4 5 6]

typeof(A) # Matrix{Int64}
size(A) # (2, 3)
ndims(A) # 2
length(A) # 6
eltype(A) # Int64

创建数组

数组字面量

一维数组最简单:

1
2
a = [1, 2, 3]
# 3-element Vector{Int64}

二维数组字面量比较像 MATLAB,但又不完全一样:

1
2
3
4
A = [1 2 3
4 5 6]

# 2×3 Matrix{Int64}

这里有几个规则:

  • 同一行元素之间用空格或 , 分隔;
  • 不同行之间用换行或 ; 分隔;
  • 字面量本质上带有“拼接”的语义。

例如下面也可以:

1
B = [1 2 3; 4 5 6]

不过复杂数组字面量很容易变得难读,因此推荐:

  • 简单矩阵可以直接写字面量;
  • 稍复杂时优先用 reshapehcatvcat 等显式函数。

指定元素类型

可以在字面量前显式指定元素类型:

1
2
Float32[1, 2, 3]   # Float32[1.0, 2.0, 3.0]
Any[1, "a", 3.0] # 混合类型

如果字面量里元素类型不完全一致,Julia 会尝试自动提升到公共类型:

1
[1, 2.0, 3] # Vector{Float64}

zerosonesfill

最常见的初始化函数:

1
2
3
4
5
zeros(3)          # 长度 3 的 Float64 向量
zeros(Int, 2, 3) # 2x3 Int 矩阵

ones(2, 2)
fill(7, 2, 3)

注意 fill(x, dims...) 会把同一个值反复填入整个数组。如果 x 是可变对象,就要小心共享引用的问题。

例如

1
2
3
4
5
6
7
8
A = fill([], 3)
push!(A[1], 1)

A
# 3-element Vector{Vector{Any}}:
# [1]
# [1]
# [1]

这和 Python 中的 [[]] * 3 是同类陷阱。

randsimilar

随机数组:

1
2
3
rand(3)        # 3 个 [0,1) 随机数
rand(2, 3) # 2x3 矩阵
rand(Int8, 4) # 随机 Int8

基于已有数组创建“同形状/相近类型”的新数组:

1
2
3
A = rand(2, 3)
B = similar(A)
C = similar(A, Int, 2, 2)

similar 在写泛型数组代码时很常用。

推导式

Julia 支持和 Python 很像的推导式:

1
2
[i^2 for i = 1:5]
# 5-element Vector{Int64}: [1, 4, 9, 16, 25]

二维推导式:

1
[(i, j) for i = 1:2, j = 1:3]

这会直接生成一个二维数组,而不是 Python 那种“列表里套列表”的默认表现。

collect

很多范围、生成器、迭代器本身并不立刻变成普通数组,可以用 collect 显式收集:

1
2
3
1:5              # UnitRange
collect(1:5) # Vector{Int64}
collect(1:2:9) # [1, 3, 5, 7, 9]

特殊数组

Julia 标准库里已经提供了不少特殊矩阵类型,来自 LinearAlgebra

1
2
3
4
5
6
using LinearAlgebra

I # UniformScaling
Diagonal([1,2,3])
UpperTriangular([1 2; 3 4])
LowerTriangular([1 2; 3 4])

这些类型不是普通 Matrix,但在很多线性代数运算中更高效,也更能表达语义。

例如

1
Diagonal([1, 2, 3]) * [1, 1, 1] # [1, 2, 3]

另外,范围对象本身也经常能充当“懒数组”:

1
2
1:10
range(0, 1, length=5)

很多时候没必要急着 collect

索引与切片

基础索引

Julia 索引从 1 开始:

1
2
3
4
5
a = [10, 20, 30]

a[1] # 10
a[end] # 30
a[begin] # 10

二维数组:

1
2
3
4
A = [1 2 3; 4 5 6]

A[1, 2] # 2
A[2, 3] # 6

范围切片

切片用范围对象:

1
2
3
4
a = [10, 20, 30, 40, 50]

a[2:4] # [20, 30, 40]
a[1:2:end] # [10, 30, 50]

注意这里是闭区间。

二维切片:

1
2
3
A[:, 2]     # 第 2 列
A[1, :] # 第 1 行
A[1:2, 2:3]

使用整数向量或布尔向量索引

Julia 支持高级索引,例如

1
a[[1, 3, 5]]

也支持布尔索引:

1
2
3
4
a = [10, 20, 30, 40]
mask = a .> 20

a[mask] # [30, 40]

这一点和 Numpy 比较接近。

CartesianIndex

对于多维数组,Julia 还提供了 CartesianIndex 来统一表示坐标:

1
2
A = reshape(1:9, 3, 3)
A[CartesianIndex(2, 3)] # 8

在写泛型多维代码时会比较有用。

切片默认拷贝与视图

默认是拷贝

这点非常重要:Julia 普通切片默认返回的是拷贝,而不是视图。

例如

1
2
3
4
5
6
a = [1, 2, 3, 4]
b = a[2:3]
b[1] = 100

a # [1, 2, 3, 4]
b # [100, 3]

这和 Numpy 常见切片默认视图的习惯不同。

view@views

如果需要视图,可以显式使用 view

1
2
3
4
5
a = [1, 2, 3, 4]
b = view(a, 2:3)
b[1] = 100

a # [1, 100, 3, 4]

对于一段代码中的多个切片,也可以使用 @views 宏:

1
2
3
4
@views begin
x = a[2:4]
y = A[:, 1]
end

这样这些切片会尽量转成视图。

形状变换

reshape

reshape 可以改变数组视图的形状:

1
2
a = collect(1:12)
A = reshape(a, 3, 4)

注意 Julia 按列优先填充:

1
2
3
4
reshape(1:6, 2, 3)
# 2×3 Matrix:
# 1 3 5
# 2 4 6

这和 Numpy 的默认阅读直觉很不一样,需要适应一下。

vecdropdimspermutedims

常见形状操作:

1
2
3
vec(A)                  # 拉平成向量
dropdims(B; dims=3) # 去掉长度为 1 的维度
permutedims(A, (2, 1)) # 维度重排

对于二维实数矩阵,转置最常见的是

1
2
A'
transpose(A)

注意 ' 对复数数组是共轭转置;如果只想单纯交换维度而不共轭,更偏向用 transposepermutedims

拼接与重复

vcathcatcat

Julia 的数组字面量其实本身就和拼接语法有关,因此也有对应的显式函数:

1
2
3
vcat([1, 2], [3, 4])      # [1, 2, 3, 4]
hcat([1, 2], [3, 4]) # 2x2 Matrix
cat(A, B; dims=3)

字面量和函数大致对应:

1
2
[a; b]   # 类似 vcat(a, b)
[a b] # 类似 hcat(a, b)

repeat

重复数组:

1
2
repeat([1, 2], 3)              # [1, 2, 1, 2, 1, 2]
repeat([1 2; 3 4], 2, 3)

数组遍历

eachindex

对于数组遍历,Julia 官方非常推荐 eachindex

1
2
3
for i in eachindex(a)
println(a[i])
end

相比直接写 1:length(a)eachindex 在更一般的数组类型上更稳妥,也更容易被优化。

axes

如果要按维度范围写循环,可以用 axes

1
2
3
for i in axes(A, 1), j in axes(A, 2)
println(A[i, j])
end

这里 axes(A, 1) 表示第 1 维允许的索引范围。

eachcoleachroweachslice

按列、按行、按某个维度切片遍历:

1
2
3
4
5
6
7
for col in eachcol(A)
println(col)
end

for row in eachrow(A)
println(row)
end

这些接口比自己手写 A[:, j] 更清晰,也更适合泛型代码。

广播与点语法

基础点调用

这是 Julia 数组代码里最有代表性的特性之一。

1
2
[1, 2, 3] .^ 2      # [1, 4, 9]
sin.([0, pi/2, pi]) # [0.0, 1.0, 0.0]

点语法表示逐元素调用,并自动进行广播。

广播规则

例如

1
2
3
4
5
A = [1, 2, 3]
B = [10, 20, 30]

A .+ B
A .+ 100

标量会被自动广播到每个位置。涉及多个数组时,会先尝试协调尺寸。

点赋值

就地更新常见写法:

1
2
A .= sin.(A)
A .+= 1

这在数值计算里非常重要,因为它避免了不必要的临时数组分配。

广播融合

像下面这种写法

1
@. 2A^2 + sin(A)

或者

1
2 .* A.^2 .+ sin.(A)

通常会融合成一次广播循环,而不是多次单独遍历。

这也是 Julia 向量化写法有竞争力的重要原因。

映射、过滤和归约

map

1
map(x -> x^2, [1, 2, 3]) # [1, 4, 9]

map 更强调“对元素做函数变换”,而广播更强调“按数组形状规则逐元素对齐计算”。

很多一元简单操作,两者都能做,但语义侧重点略有不同。

filter

1
filter(iseven, [1, 2, 3, 4, 5, 6]) # [2, 4, 6]

sumprodmaximum

1
2
3
sum([1, 2, 3])        # 6
prod([1, 2, 3, 4]) # 24
maximum([3, 1, 4]) # 4

对矩阵按维度归约:

1
2
sum(A; dims=1)  # 按列求和
sum(A; dims=2) # 按行求和

修改数组

常见的原地修改操作:

1
2
3
4
5
6
7
push!(a, 10)
pop!(a)
append!(a, [20, 30])
insert!(a, 2, 99)
deleteat!(a, 3)
sort!(a)
reverse!(a)

这里带 ! 的约定和前面说的一样,表示函数会修改参数本身。

数组与线性代数

对于 Julia 来说,数组不仅是容器,还是线性代数运算的基本对象。

例如

1
2
3
x = [1, 2, 3]
y = [4, 5, 6]
A = [1 2 3; 4 5 6]

可以直接写

1
2
3
dot(x, y)
A * x
A' * A

其中矩阵乘法直接使用 *,不需要像 Python/Numpy 那样再专门区分出一个 @

不过也要注意:

  • * 是线性代数意义的乘法;
  • .* 才是逐元素乘法;
  • ^.^ 也有类似区别。

小结

如果从 Python/Numpy 迁移到 Julia,多维数组这一篇最需要记住的大概是:

  • Julia 的数组是列优先,索引从 1 开始;
  • VectorMatrix 是不同的维度概念,不要再拿“行向量/列向量都是二维矩阵”那套 MATLAB 历史包袱来理解;
  • 普通切片默认拷贝,视图要用 view
  • 广播和点语法是 Julia 数值代码的主力写法;
  • 原地更新时优先考虑 .= 和各种 ! 函数。