向量

向量运算 属于 Julia

2 个练习

关于 向量运算

在Vectors概念中,我们说过“数组是 Julia 语言的核心”,而向量就是一维数组。

既然如此,我们完全可以期待这门语言提供许多灵活而强大的方式,来_对向量做各种操作_,不管这具体指什么。

关于术语的一点说明:虽然本文大量谈到“向量”,但其中很多内容同样适用于任何可迭代类型:范围、元组、集合,以及其他各种类型。

接受向量输入的函数

有些非常简单的函数接受向量输入,并且(对于一维输入)返回标量输出。

v = [2, 3, 4]
length(v)  # => 3
sum(v)  # => 9

等我们学到多维数组概念时就会更清楚:这其实是_降维_,而不一定是返回标量。 如果这句话让你摸不着头脑,现在先不用管它。

这类函数还有很多。 看看Statistics概念中的一些例子。

也有一些函数可以同时作用于多个向量,比如(非常实用的)zip。

julia> z = zip( 1:3, ['a', 'b', 'c'], ["I", "make", "tuples"] )
zip([1, 2, 3], ['a', 'b', 'c'], ["I", "make", "tuples"])

# convert iterator to vector
julia> collect(z)
3-element Vector{Tuple{Int64, Char, String}}:
 (1, 'a', "I")
 (2, 'b', "make")
 (3, 'c', "tuples")

zip() 接受任意数量的类向量输入,并返回一个由元组组成的迭代器。

这些输入通常长度相同。 如果其中一个更短,其他的就会被截断到最短的长度:这_也许_正是你想要的结果,但_更常见的情况_是代码里的一个 bug。

算术运算

假设你有一个数值向量,想把每个值都减去 0.5。

julia> v = [1.2, 1.5, 1.7]
3-element Vector{Float64}:
 1.2
 1.5
 1.7

julia> v - 0.5
ERROR: MethodError: no method matching -(::Vector{Float64}, ::Float64)

这样行不通,那减去另一个向量呢?

julia> v - [0.5, 0.5, 0.5]
3-element Vector{Float64}:
 0.7
 1.0
 1.2

成功了,但向量一长,这种方式既繁琐又耗内存。

根据你在课程体系中已经学到的内容,你大概还能想到其他做法:

  • 写一个循环,不过这样代码冗长又笨重。
  • 使用推导式:[x - 0.5 for x in v] 能得到想要的结果(Python 风格)。
  • 使用高阶函数:map(x -> x - 0.5, v) 同样可行(Haskell 风格,不过很多语言里都很常见)。

幸运的是,Julia 有一个“神奇”的点号,可以非常简单地解决这个问题:你只需要写 v .- 0.5。

下一节会解释其中的原因。

广播

那么,v - 0.5 会失败,而 v .- 0.5 能成功,我们需要弄明白这个点号到底在做什么。

它做了两件事,两件事合在一起就得到了想要的结果。

1) 逐元素应用

首先,在任何中缀运算符_前面_加一个点,意思是“把这个运算分别应用到每个元素上”。

同样,在函数名_后面_加一个点可以把它“向量化”,即使这个函数原本是为标量输入编写的。

julia> sqrt.([1, 4, 9])
3-element Vector{Float64}:
 1.0
 2.0
 3.0

顺便说一句,中缀运算符其实只是底层函数的语法糖。

也就是说,例如 [1, 5, 10] .% 3 会被翻译转换成 mod.([1, 5, 10], 3),随后执行 mod. 这种写法(两种写法都会返回 [1, 2, 1])。

2) 单元素扩展

在前面的例子里我们看到,可以相减两个长度相同的向量,不过要明白 .- 比 - 更_安全_,因为它把逐元素的意图表达得很清楚。

julia> v .- [0.5, 0.5, 0.5]
3-element Vector{Float64}:
 0.7
 1.0
 1.2

那长度不同的向量呢?

julia> v .- [0.5, 0.5]
ERROR: DimensionMismatch: arrays could not be broadcast to a common size

julia> v .- [0.5,]
3-element Vector{Float64}:
 0.7
 1.0
 1.2

一般来说,长度不同就是错误,_除非_其中一方的长度为 1(严格来说,这是一个“单元素”维度)。

像 [0.5,] 或者干脆 0.5 这样的单元素值,会自动通过重复扩展到所需的长度。 这正是broadcasting的核心。

如果有人担心这种“重复”会占用内存,大可放心:它的实现非常高效,并不会真的在内存中复制这些值。

熟悉其他语言中广播的程序员要注意:Julia 的做法(大体上)与 NumPy 相似,但对尺寸不匹配的容忍度比 R 低得多。

原地广播

如果在意内存占用,原地操作是减少内存分配的常见做法。 不过上面例子中的广播运算会创建一个新的 Vector,而不是修改原来的那个。

julia> v = [1, 2, 3]
3-element Vector{Int64}:
 1
 2
 3

julia> v .+ 1
3-element Vector{Int64}:
 2
 3
 4

julia> v
3-element Vector{Int64}:
 1
 2
 3

要想原地修改 v,这个修改就必须是广播的。

julia> v .= v .+ 1
3-element Vector{Int64}:
 2
 3
 4

julia> v .+= 1  # equivalent operation to above
3-element Vector{Int64}:
 3
 4
 5

julia> v
3-element Vector{Int64}:
 3
 4
 5

但要小心!赋值运算符 .= 前面的那个点号很重要。

julia> v = [1, 2, 3];

julia> v = v .+ 1
3-element Vector{Int64}:
 2
 3
 4

julia> v
3-element Vector{Int64}:
 2
 3
 4

看起来效果是一样的,但这里 v .+ 1 先创建了一个新向量 [2, 3, 4],再把它赋值给变量 v,原来的向量 [1, 2, 3] 则留在内存里等待垃圾回收。 这样一来,它占用的内存是前一个例子的两倍,而前一个例子会复用为初始向量分配的内存。

同样,用长度相同的向量做广播时,也有类似的细微差别。 例如,给定两个长度相同的向量 v 和 w:

  • v .= w 会把 w 的一个copy放进 v 所在的内存位置。之后修改 v 不会影响 w,反之亦然。
  • v = w 会产生另一个指向 w 所在内存位置的指针,名字叫 v。之后修改 v 会反映到 w 上,反之亦然。

不带点的运算符:一个警示故事

这一小节偏数学,大多数同学不必真正弄懂它。 不过它是个有用的提醒,当你看到意料之外的错误信息时,也许有助于调试。

julia> v = [1, 2, 3]
3-element Vector{Int64}:
 1
 2
 3

julia> v * v
ERROR: MethodError: no method matching *(::Vector{Int64}, ::Vector{Int64})

# look, no commas
julia> u = [1 2 3]
1×3 Matrix{Int64}:
 1  2  3

julia> u * v
1-element Vector{Int64}:
 14

julia> v * u
3×3 Matrix{Int64}:
 1  2  3
 2  4  6
 3  6  9

如果你恰好有线性代数的基础,那么(1)你并不是典型的 Exercism 用户_(但我们非常欢迎你!)_,(2)你可能会认出 v 是列向量,u 是行向量,u * v 是内积,而 v * u 是外积。 Julia 在这一点上,和其他所有事情一样,都遵循数学的规则。

**其他人请注意:**只需明白我们为什么建议做逐元素计算时总是用带点的运算符:v .* v 的效果和你想的完全一样,得到 [1, 4, 9]。

下标

前面的概念里已经讲过如何按下标选取向量的元素。

a = collect('A':'Z')  # => 26-element Vector{Char}

# index with an integer
a[2]  # => 'B'

# index with a range
 a[12:2:18]  # => ['L', 'N', 'P, 'R']
 
 # index with another vector
 a[ [1, 3, 5] ]  # => ['A', 'C', 'E']

逻辑下标

还可以选出满足某个逻辑表达式(严格来说是“谓词”)的元素。 这通常需要用到广播。

julia> a[a .< 'D']
3-element Vector{Char}:
 'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
 'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
 'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)

表达式越复杂,点号就越多(不过它们很小,敲起来也不费劲)。

julia> a[a .< 'D' .|| a .> 'W']
6-element Vector{Char}:
 'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
 'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
 'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)
 'X': ASCII/Unicode U+0058 (category Lu: Letter, uppercase)
 'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
 'Z': ASCII/Unicode U+005A (category Lu: Letter, uppercase)

提醒一下,这里的“向量”其实可以是任何合适的有序可迭代对象,比如一个范围:

julia> n = 3:10
3:10

julia> n[isodd.(n)]
4-element Vector{Int64}:
 3
 5
 7
 9

在内部,谓词会被转换成一个 BitVector,然后被当作下标使用。

julia> condition = a .< 'D'
26-element BitVector:
 1
 1
 1
 0
 # display truncated

julia> a[condition]
3-element Vector{Char}:
 'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
 'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
 'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)
通过 GitHub 编辑 该链接会在新窗口或标签页中打开

学习 向量运算