Julia編程基礎(九):數組並沒有你想的那麼簡單

本文是《Julia 編程基礎》開源版本第九章:容器:數組(上)。本書旨在幫助編程愛好者和專業程序員快速地熟悉 Julia 編程語言,並能夠在夯實基礎的前提下寫出優雅、高效的程序。這一系列文章由 郝林 採用 CC BY-NC-ND 4.0知識共享 署名-非商業性使用-禁止演繹 4.0 國際 許可協議)進行許可,請在轉載之前仔細閱讀上述許可協議。

數組(array)也是一種容器。與元組相比,它最顯著的特點有這麼幾個:

  1. 數組是可變的對象。關於這一點,我們在前面已經見識過了。
  2. 同一個數組中的所有元素值都必須有着相同的類型。雖然這個元素類型也可以是抽象類型,從而讓元素值的具體類型多樣化,但這樣做在很多時候都會給基於它的計算帶來不必要的負擔。
  3. 數組可以是多維(度)的。也就是說,它不只可以代表一列車隊,還可以代表一個停車場、一座停車樓,以及擁有更多維度的結構。而且,數組的維數(即維度的數量)與元素類型一樣,也會被寫入到其類型的字面量中。

從這些區別上,我們可以看得出來,數組擅長的不是承載函數參數值的列表,而是存儲表達形式一致的數據。它的特點非常有利於科學計算和數據分析。下面,我們就從數組的類型、值的表示和構造、常見的操作等幾個方面去詳細地瞭解一下這種容器。

9.1 類型

代表數組的具體類型名爲Array,它是AbstractArray的直接子類型。Julia 針對AbstractArray類型定義了大量且豐富的操作。因而,Array類型也就很自然地成爲了這些操作的有效目標。

我們已經知道,Array是一個參數化類型,它的全名是Array{T,N}。其中的類型參數T用於確定數組的元素類型,而類型參數N則用於確定數組的維數。這裏的N的取值通常是一個正整數(也可以是0,表示零維數組)。並且,在 64 位的計算機系統中,它的值不能超出Int64類型所能表示的數值範圍;在 32 位的計算機系統中,它的值不能超出Int32類型所能表示的數值範圍。下面是一些示例:

julia> Array{Float64,3}
Array{Float64,3}

julia> Array{Int64,N} where N
Array{Int64,N} where N

julia> Array{Int64}
Array{Int64,N} where N

julia> Array{T,typemax(Int64)} where T
Array{T,9223372036854775807} where T

julia> 

在一般情況下,我們直接使用的數組的維數都不會太多,大多在三維及以下。儘管在一些程序中可能會用到擁有更多維度的數組,但其維數肯定也比Int32類型所能表示的最大值要小得多。所以,這裏的類型參數N的取值範圍對於我們來說相當於沒有限制。

正因爲一維數組和二維數組都太常用了,所以 Julia 爲它們的類型提供了別名。別名Vector{T}代表類型Array{T,1},也就是一維數組的類型。而別名Matrix{T}則代表了Array{T,2},即二維數組的類型。其中的 vector(向量)和 matrix(矩陣)都是線性代數中最核心的概念。從形狀上來講,向量就是由一個個值組成的縱隊,而矩陣則是由一個個長度相同的縱隊組成的方陣。

順便說一下,我們在本書中不會專門去討論相關的數學知識。但是,我們有時候(尤其是講數組的時候)卻不得不提到一點,因爲有些對象及其操作基於的正是那些數學概念。不過別擔心,我會盡量用精煉、樸實的語言去描述它們。

我們再來說數組類型的其他特點。與元組類型不同,數組類型的字面量永遠也無法體現出元素的順序。這主要是因爲數組類型中只有一個可以代表元素類型的參數。想想看,如果一個元組類型的所有參數值全都相同,那麼它同樣無法體現出元素的順序。

另外,數組類型也無法體現出其元素的數量。因此,對於一個一維數組,我們可以隨意地增減其中的元素值,而不用擔心不符合其類型的約束。如:

julia> isa([1], Array{Int64,1})
true

julia> isa([1,2,3], Array{Int64,1})
true

julia> isa([1,2,3,4,5], Array{Int64,1})
true

julia> 

然而,對於多維數組來說,其各個維度上的元素數量卻不是隨意的。更確切地說,在一個多維數組中,處在同一個維度上的所有低維數組(即維數更低的數組)都應該具有相同的尺寸。這就好比一個方陣,其中的所有縱隊的長度都需要相同。又好比一個六面體,它的每一個面都應該是平面,既不能有任何的凹陷,也不能有任何的凸出。只有符合這種規則的數組才能被稱爲多維數組,其類型如Array{Int64,2}。否則,那個數組就只能算是多個數組的嵌套而已,其類型如Array{Array{Int64,1},1}

除了類型字面量上的一些特點,數組類型還具有非轉化的特性。因此,[][1]雖然同爲一維數組,但是它們的類型之間卻不存在繼承關係。這是由於空數組[]的類型是Array{Any,1},它不是Array{Int64,1}的超類型。驗證的代碼如下:

julia> typeof([1]) <: typeof([])
false

julia> Array{Int64,1} <: Array{Any,1}
false

julia> 

到這裏,我們知道了Array{T,N}類型中各個類型參數的取值範圍,還知道了最常用的一維數組和二維數組的類型別名。另外,我們還瞭解到,數組類型的字面量上只會體現它的元素類型和維數,而不會體現元素的順序以及各個維度上的元素數量。儘管如此,多維數組在各個維度上的元素數量仍需滿足既定的規則,否則就不能被稱爲多維數組,而只能算是多個數組的嵌套。這可能看起來比較抽象,不過沒有關係,我們在後面會把數組的值與它們的類型放在一起進行解讀。

最後,再次強調,數組類型具有非轉化的特性。

9.2 數組的表示

我們在很早以前就已經見過數組值的一般表示法了。它是這樣的:

julia> [1, 2, 3, 4, 5]
5-element Array{Int64,1}:
 1
 2
 3
 4
 5

julia> 

我們閱讀 REPL 環境回顯的內容就可以知道,[1, 2, 3, 4, 5]表示了一個有 5 個元素的一維數組,且元素的類型是Int64。不過,你可能會有個疑惑,爲什麼回顯內容中的元素值是豎排展示的呢?

實際上,這就是一維數組的正常形狀。它是一個由多個值組成的縱隊,相當於表格中的一列。從線性代數的角度講,這叫做列向量。更寬泛地說,只要我們用英文逗號分隔數組中的多個元素值,就會得到一個(列)向量。除了英文逗號,我們還可以使用英文分號:

julia> [1; 2; 3; 4; 5]
5-element Array{Int64,1}:
 1
 2
 3
 4
 5

julia> 

在這裏,我們可以認爲這兩種表示法是等價的。但我還是建議你在一般情況下使用英文逗號,因爲英文分號在含義上還是有別於英文逗號的。在數組值字面量的上下文中,英文分號代表着拼接。它可以把相關的數組中的所有元素值以及單一值全都拼接在一起,從而產生一個新的數組。例如:

julia> [[1]; [2,3]; 4; 5]
5-element Array{Int64,1}:
 1
 2
 3
 4
 5

julia>

請注意,這裏被英文分號分隔的不僅有 2 個單一值,還有 2 個數組。不過這些數組都被拆解了,其中的元素值也都成爲了新數組的元素值。這就是拼接的作用。我們還需要注意,正是由於這兩個符號在含義上的不同,所以我們不能在同一個地方混用它們。

我們現在來試驗一下,把上述示例中的英文分號全都替換成英文逗號會產生怎樣的效果:

julia> [[1], [2,3], 4, 5]
4-element Array{Any,1}:
  [1]   
  [2, 3]
 4      
 5      

julia> 

可以看到,英文逗號並不會使相鄰的數組被拆解。這些數組都被識別成了單一的元素值。因此,上述數組的元素類型纔是Any,而不是Int64。更明確地說,只要一個數組值字面量包含了不同類型的元素值,Julia 就會試圖找到它們的公共類型,並把這個公共類型當做數組的元素類型。這其實就是類型推斷的功能之一。

那麼,除了讓 Julia 自行推斷,我們是否能夠自己設定元素值的類型呢?答案是肯定的。示例如下:

julia> Int8[1, 2, 3, 4, 5]
5-element Array{Int8,1}:
 1
 2
 3
 4
 5

julia> 

只要我們把元素類型的字面量放在左中括號的左側就可以達到目的了。這不僅可以用在數組值的一般表示法上,還可以在拼接數組的時候加以運用。例如:

julia> Int8[[1]; [2,3]; 4; 5]
5-element Array{Int8,1}:
 1
 2
 3
 4
 5

julia> 

理所應當,只要我們提供的元素值中有一個不能被轉換成目的類型的值,Julia 就會立即報錯:

julia> Int8[[1]; [2,3]; 4; "5"]
ERROR: MethodError: Cannot `convert` an object of type String to an object of type Int8
# 省略了一些回顯的內容。

julia> 

到目前爲止,我們一直說的都是一維數組的表示法。下面我們來講怎樣表示二維數組。表示二維數組的標準方式是,在方括號中嵌入多個長度相同的一維數組,並用空格分隔這些數組,如:

julia> [[1,2] [3,4] [5,6]]
2×3 Array{Int64,2}:
 1  3  5
 2  4  6

julia> 

回顯內容中的2×3是指,這個二維數組包含了 2 行 3 列。這從下面的數組元素值展示上也能看得出來。不過,如果行數或列數太多的話,數組的元素值也不會全都被展示在這裏。因此,我們往往還是要以上面的那個NxM樣式的信息爲準。

依據回顯的內容,我們還可以知道,嵌入在這個二維數組中的每一個一維數組都獨立地表示了一列值。它們代表的依然是列向量。實際上,把多個長度相同的列向量橫向地拼接在一起就形成了矩陣。具體到上述示例,在形狀上把[1,2][3,4][5,6]都順時針旋轉 90 度(即還原爲列向量的原本形狀),然後再把它們橫向地拼接在一起,就形成了我們要表示的 2 行 3 列的矩陣。

你可能已經有所猜測,列向量之間的那些空格好像起到了拼接的作用。沒錯,在數組值字面量的上下文中,這些空格與英文分號一樣也是用於拼接的符號。但不同的是,英文分號用於縱向的拼接,而空格用於橫向的拼接。一旦明確了它們的作用,我們就可以探索出它們的更多用法。

比如,我們可以把上述列向量中的分隔符(即英文逗號)替換成空格,就像下面這樣:


julia> [1 2]
1×2 Array{Int64,2}:
 1  2

julia> 

如此一來,這個數組值的字面量就可以表示一個 1 行 2 列的矩陣了。你可能會有疑問,這個字面量表示的數組爲什麼是二維的,而不是一維的?其原因是,Julia 只認可列向量,而不認可所謂的行向量。

從線性代數的角度講,列向量和行向量都可以被看做是特殊形狀的矩陣。進一步說,列向量是Nx1的矩陣(即只有一列的矩陣),而行向量是1xM的矩陣(即只有一行的矩陣)。但是,在 Julia 中只有列向量可以獨立的存在,並由一維數組表示。而行向量卻只能作爲特殊形狀的矩陣,並且沒有獨立的表示法。

因此,我們在上面編寫的數組值字面量[1 2]會被 Julia 翻譯成[[1] [2]]。也就是說,在它表示的矩陣中,1是第一個列向量中唯一的元素值,而2則是第二個列向量中唯一的元素值。那個 1 行 2 列的矩陣就是這樣形成的。我們再來看一個例子:

julia> [[1] [2 3] 4 5]
1×5 Array{Int64,2}:
 1  2  3  4  5

julia> 

這個字面量中的空格作爲拼接符號會把那兩個嵌入的數組(即[1][2 3])都拆解掉,並將其中的元素值與後面的那兩個獨立的值(即45)一起作爲新數組的元素值。因此,這個字面量表示的就是一個 1 行 5 列的二維數組,當然也可以說它表示的是一個特殊形狀的矩陣。

現在,我們同時使用兩個拼接符號來表達二維數組,代碼如下:

julia> [[1;2] [3;4] [5;6]]
2×3 Array{Int64,2}:
 1  3  5
 2  4  6

julia> [[1 2]; [3 4]; [5 6]]
3×2 Array{Int64,2}:
 1  2
 3  4
 5  6

julia> 

在第一個字面量裏,我用英文分號分隔嵌入數組中的多個元素值,並用空格分隔多個嵌入數組。或者說,我把空格用在了外層,把英文分號用在了內層。如此一來,每一個嵌入數組就都表示一個列向量。我們再把這些列向量橫向地拼接在一起就形成了 2 行 3 列的矩陣。

而在第二個字面量裏,我使用英文分號和空格的方式正好相反,即:空格在內層,英文分號在外層。這樣的話,每一個嵌入數組就都表示一個只有一行的矩陣(或者說行向量)。我們再把這些只有一行的矩陣縱向地拼接在一起就形成了 3 行 2 列的矩陣。

可以看到,只要我們層次分明地使用英文分號和空格,就可以靈活地利用它們來表示各種 I 行 J 列的矩陣。不過,對於擁有更多維度的數組,這種表示法就無能爲力了。例如,即使我們像下面這樣編寫字面量,也仍然無法表示一個三維數組:

julia> [ [[1.0;2.0] [1.1;2.1]]; [[3.0;4.0] [3.1;4.1]]; [[5.0;6.0] [5.1;6.1]] ]
6×2 Array{Float64,2}:
 1.0  1.1
 2.0  2.1
 3.0  3.1
 4.0  4.1
 5.0  5.1
 6.0  6.1

julia> 

但幸運的是,Julia 提供了不少函數,可以被用來構造多維度的數組值。我們馬上就會講到它們。

9.3 數組的構造

關於可以構造數組值的那些函數,首當其衝的肯定是Array類型附帶的構造函數。

我們先說Array{T}(undef, dims)Array{T,N}(undef, dims)。這兩個函數都是用來構造未初始化的 N 維數組的。其中的T依然代表元素類型,N依然代表維數。

undef是一個常量,它代表着單例類型UndefInitializer的唯一值。所謂的單例類型,是指有且僅有一個實例的類型。無論我們實例化這種類型多少次,都只會得到同一個值,即該類型的唯一值。UndefInitializer類型專用於數組的初始化,其值表達的含義是創建一個未初始化的數組。或者說它表達的是,上述構造函數的調用者不想向這個數組填充任何的元素值。這時,Julia 會在該數組的所有元素位置上填充隨機值。

我們在前面講了,數組類型的字面量上不會體現出數組在各個維度上的元素數量。然而,這些數量卻是構造一個多維數組時必須要確定的信息。注意,對於多維數組,我們所說的在某個維度上的元素指的是可能一個個元素值,也可能是一個個低維數組。這在後面會有更詳細的解釋。

這裏的參數dims的作用正是表示數組在各個維度上的元素數量。更確切地說,它表示的是各個維度的長度。dims是 dimensions 的縮寫。它的值可以是一個包含了若干個整數的元組值,也可以是若干個由英文逗號分隔的整數值。不過後者只在三維及以下的數組構造中才有效。下面是一個例子:

julia> Array{Int64}(undef, 4, 3, 2)
4×3×2 Array{Int64,3}:
[:, :, 1] =
 4683772848  4667574256  4667574256
 4490317616  4667575152  4667574256
 4490317616  4667574256  4667575152
 4667574256  4490317616  4667574256

[:, :, 2] =
 4490317616  4667572800           0
 4490317472           0           0
 4667574256           0           0
 4488855536           0  4680843264

julia> 

請注意,回顯內容中表示的是一個4×3×2的三維數組。還記得嗎?我們可以把三維數組比喻成一座停車樓。那麼上面這個三維數組就相當於一個有 2 層的停車樓。現在,你要帶着這個想象跟我一起理解它的展示格式。

回顯內容的第一行反映了我們構造數組時給予的信息。第二行中的[:, :, 1]指的是在第三個維度上的第 1 個低維數組(即二維數組),相當於停車樓的上一層。你也可以把[:, :, 1]看成一個特殊的數組,其中的每一個元素的值都用於代表上述三維數組在對應維度上的某個低維數組。這個特殊的數組中的前兩個元素都由英文冒號:佔位,相當於選擇了對應維度上的所有低維數組。而其中的最後一個元素值是1,代表的正是上述三維數組中的第 1 個二維數組。由此,在它下面才展示了對應的二維數組中的所有元素值,相當於俯瞰停車樓的上一層。

我們已經知道,只要 N 大於 1,那麼 N 維數組就都可以被看做是由一個個尺寸相同的 N-1 維的數組拼接而成的結構,就像停車樓的每一層都有整齊的停車位那樣。因此,在上述數組的第三個維度上的第 1 個低維數組就應該是一個4×3的二維數組。在[:, :, 1]下面的那 4 行內容展示的正是這個二維數組。其中的所有元素值都是由 Julia 自行填充的隨機值。

又由於上述三維數組在第三個維度上的長度是 2,所以纔有了再下面的[:, :, 2],以及與它對應的又一個4×3的二維數組,相當於停車樓的下一層。

讓我們再來構造一個四維數組:

julia> Array{Int64, 4}(undef, (4, 3, 2, 2))
4×3×2×2 Array{Int64,4}:
[:, :, 1, 1] =
 4688801328  4688801456  4688801680
 4688801360  4688801488  4688801712
 4688801392  4688801616  4688801744
 4688801424  4688801648  4688801776

[:, :, 2, 1] =
 4688801808  4620636144  4688805040
 4688801840  4688935952  4688805072
 4688854576  4688991056  4688805104
 4688935312  4688991088  4688986896

[:, :, 1, 2] =
 4688805264  4620632072  4688805456
 4688987472  4688988016  4679072384
 4688805328  4688988176  4679072480
 4679071744  4688805424  4688989008

[:, :, 2, 2] =
 4688989104  4679073120  4679073520
 4688989200  4679073216  4679073680
 4688805584  4679073312  4679073728
 4688989712  4688990032  4688796304

julia> 

四維數組可能會挑戰到你的空間想象力。但有了前面的解釋,這個四維數組的展示格式就應該容易理解一些了。這個四維數組由 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。但這只是初始化元素值的一種選項而已。我們還可以選擇nothingmissing作爲這個參數的值。但前提是,該數組的元素類型必須是nothingmissing的類型的超類型。

nothingmissing也都是常量,其含義同樣比較特殊。我們在前面的章節中對它們都做過解釋。nothing代表着單例類型Nothing的唯一值,它的含義是“此處沒有值”。而missing則代表單例類型Missing的唯一值,它的含義是“此處的值是缺失的”。注意,nothing僅等於它自身,但涉及到missing的判等結果就要看使用的是哪種判等操作了。

那怎樣設定數組的元素類型才能讓它成爲NothingMissing的超類型呢?這個時候,Union類型就派上用場了。不要忘了,它的字面量可以表達多個類型的聯合。因此,我們把元素類型設定爲Union{Nothing, String}就意味着該數組的元素值既可以是一個字符串值,也可以是nothing。對於Missing來說也是類似的。下面是一些使用示例:

julia> Array{Union{Nothing, String}}(nothing, 2, 2)
2×2 Array{Union{Nothing, String},2}:
 nothing  nothing
 nothing  nothing

julia> Array{Union{Missing, Int64}}(missing, 2, 3)
2×3 Array{Union{Missing, Int64},2}:
 missing  missing  missing
 missing  missing  missing

julia> 

可以看到,如果我們傳給數組構造函數的第一個參數值是nothing,那麼此次被創建出的數組的所有元素值就都會是nothing。若傳入missing的話也是類似的。

除了上面講的構造函數,Julia 還提供了另外的一些可以創建多維數組的函數。比如,函數zeros可以創建元素值全爲零值的數組。示例如下:

julia> zeros(Int32, 4, 3)
4×3 Array{Int32,2}:
 0  0  0
 0  0  0
 0  0  0
 0  0  0

julia> zeros(Float32, 4, 3)
4×3 Array{Float32,2}:
 0.0  0.0  0.0
 0.0  0.0  0.0
 0.0  0.0  0.0
 0.0  0.0  0.0

julia> 

zeros函數的第一個參數的名稱是T,代表元素類型。這個參數是可選的,如果我們選擇不爲它傳入值,那麼其值就是缺省的Float64。該函數的第二個參數的名稱是dims,與前述的構造函數中的dims含義相同。

注意,這個函數的第一個參數值只能是一個數值類型。也就是說,它可以是任意的布爾類型、整數類型、浮點數類型、複數類型、有理數類型,以及無理數類型。另外,對於不同的數值類型,其零值也是不同的。所謂的零值,就是用來表示0的值。比如,UInt8類型的零值是0x00Complex類型的零值是0+0imRational類型的零值是0//1,等等。

與之類似,ones函數可以創建元素值全爲1的數組。其參數的定義與zeros函數的參數定義相同。仍要注意,不同的數值類型表示1的方式也不同。

還有一個名叫fill的函數,它有兩個參數:x‌和dims。參數x代表的值將會被填充到新數組的所有元素位置上。顯然,新數組的元素類型由x決定。與前面一樣,新數組的維數和大小仍由dims決定。下面是一個示例:

julia> fill(1.0f-3, 2, 3)
2×3 Array{Float32,2}:
 0.001  0.001  0.001
 0.001  0.001  0.001

julia> 

另外,函數truesfalses也很常用。它們都只有一個名爲dims的參數。trues函數用於創建元素值全爲true的數組,而falses函數則用於創建元素值全爲false的數組。注意,它們創建的數組的類型並不是Array,而是BitArray

BitArray類型也被稱爲位數組類型。它是元素類型爲BoolArray類型的優化版本。它僅使用 1 個比特來存儲一個元素值。要知道,在通常情況下,Bool類型的每一個值都需要佔用 8 個比特。這就意味着,位數組在存儲空間的利用率方面有着 8 倍的提升。爲了與標準的存儲方式保持兼容,從位數組取出的元素值會被還原成(新的)常規的布爾值。

以上就是我們構造數組值的時候經常會用到的函數。當然,還有一些函數也可以被用來構造數組值,如函數randrandncollectsimilarreinterpret等。不過,這些函數在功能上就沒有那麼的純粹了。

9.4 數組的基本要素

當我們拿到一個數組,首先應該去了解它的元素類型、維數和尺寸。在 Julia 中,這些信息都由專門的函數提供。函數eltype可以獲取到一個數組的元素類型,函數ndims用於獲取一個數組的維數。length函數用於獲得一個數組的元素總數量。而若要想獲得數組在各個維度上的長度,我們就需要使用size函數。

size函數有一個必選的參數A,代表目標數組。它還有一個可選的參數dim,代表維度的序號。在調用size函數的時候,如果我們只爲A指定了參數值,那麼該函數就會返回一個元組。這個元組會依次地包含該數組在各個維度上的長度。但倘若我們同時給定了dim的值,那麼它就只會返回對應的那個長度了。例如:

julia> array2d = [[1,2,3,4,5] [6,7,8,9,10] [11,12,13,14,15] [16,17,18,19,20] [21,22,23,24,25] [26,27,28,29,30]]
5×6 Array{Int64,2}:
 1   6  11  16  21  26
 2   7  12  17  22  27
 3   8  13  18  23  28
 4   9  14  19  24  29
 5  10  15  20  25  30

julia> size(array2d)
(5, 6)

julia> size(array2d, 2)
6

julia> eltype(array2d), ndims(array2d), length(array2d)
(Int64, 2, 30)

julia> 

我使用數組值的一般表示法創建了一個 5 行 6 列的數組array2d。這個數組擁有兩個維度,其元素類型是Int64。之所以表達式size(array2d)的求值結果爲(5, 6),是因爲該數組在第一個維度和第二個維度上的長度分別是56。實際上,我們用5乘以6就可以得到這個二維數組的元素總數量30

9.5 訪問元素值

在獲知了一個數組的基本要素之後,我們就可以去探查其中的元素值了。接下來,我會從最基本的訪問方式講起。

9.5.1 索引

對於數組來說,索引表達式依然是有效的。我們先看一個示例:

julia> array2d[1]
1

julia> array2d[[1,3,5]]
3-element Array{Int64,1}:
 1
 3
 5

julia> array2d[1:6]
6-element Array{Int64,1}:
 1
 2
 3
 4
 5
 6

julia> 

可以看到,我先使用點索引表達式獲取了array2d中的第 1 個元素值,又使用點索引表達式獲取了其中的第 1、3、5 個元素值。注意,在後者的中括號裏的是一個包含了 3 個索引號的數組。因此,我們也可以把後者稱爲多點索引表達式,而把前者稱爲單點索引表達式。

在這之後,我還使用範圍索引表達式獲取了array2d中的前 6 個元素值,其結果仍然是一個一維數組。更寬泛地說,針對數組的多點索引表達式和範圍索引表達式的求值結果總會是一個一維數組,無論其中的索引號橫跨了幾個維度都是如此。

在數組的上下文中,索引號就是元素位置的序號。它總是從1開始,且最後一個索引號總與當前數組的元素位置總數相等。還記得嗎?這種索引號組成的索引也被稱爲線性索引。對於一維數組,這很好理解。因爲其中的元素位置與索引號一樣,都只有一個維度,很容易就能對應起來。

對於多維數組,線性索引仍然是可用的。不過,與線性索引中的索引號不同,多維數組中的元素位置卻處在一個多維度的空間中。在這種情況下,對應兩者就不那麼容易了,需要一點空間想象力。Julia 會按照既定的順序把索引號逐個地分配給多維數組中的每一個元素位置。更確切地說,它依照的是數組中維度的次序以及各個維度上的元素順序。

就拿array2d來說,索引號15會被分配到這個二維數組包含的第 1 個一維數組。這個一維數組也就是它的第 1 列,即最左邊的那一列。因此,該二維數組中的元素值12345的索引號恰好分別是12345。接下來,它的第 2 列中的 5 個元素值的索引號分別是678910,其第 3 列中的 5 個元素值的索引號分別是1112131415,等等。總之,array2d中的每一個元素位置上的值正好就是它的索引號。這樣你也可以非常直觀地看到線性索引號在多維數組中的分配方式。

我們再來看一個例子:

julia> array3d = reshape(array2d, (3,5,2))
3×5×2 Array{Int64,3}:
[:, :, 1] =
 1  4  7  10  13
 2  5  8  11  14
 3  6  9  12  15

[:, :, 2] =
 16  19  22  25  28
 17  20  23  26  29
 18  21  24  27  30

julia> 

我使用reshape函數改變了array2d的複本,把它變爲了一個3×5×2的三維數組。我們重點來看array3d代表的三維數組。雖然數組從二維變成了三維,但是其中元素值的排列順序卻沒有被改變。所以,我們依然能夠通過各個元素位置上的值瞭解到它們的索引號。

使用前面的術語來講的話就是這樣的:索引號115會被分配到這個三維數組包含的第 1 個二維數組。而索引號13又會被分配到這個二維數組包含的第 1 個一維數據,也就是其中的最左邊那一列。按照這個思路,你應該就可以解釋這些索引號的每一個分配結果了。

無論一個數組擁有多少個維度,我們都可以使用線性索引的索引號定位到相應的元素值。雖然線性索引的速度很快,但是有時候使用它會有些麻煩,因爲這涉及到從多維到一維的換算。所以,對於多維數組,我們還經常使用更加直觀的笛卡爾索引(cartesian index)。笛卡爾索引中的索引號是多維的,並且其中的索引號的數量與當前數組的維數保持一致。

在 Julia 中,有一個專門代表笛卡爾索引的類型,名爲CartesianIndex。它的構造函數既可以接受一個包含了若干個索引號的元組,也可以接受若干個由英文逗號分隔的索引號。示例如下:

julia> CartesianIndex(3, 2, 1)
CartesianIndex(3, 2, 1)

julia> ans == CartesianIndex((3, 2, 1))
true

julia> 

CartesianIndex類型的每一個值都表示一個多維度的索引。在這樣的索引中,索引號I用於表示第 N 個維度上的第I個元素。這個元素對應的可能是一個 N-1 維的數組,也可能是單個的元素位置。其中的 N 與索引號在笛卡爾索引中的(從左到右的)次序保持一致。

CartesianIndex(3, 2, 1),它表示的是一個針對三維數組的笛卡爾索引。其中的1表示第三個維度上的第 1 個二維數組,2表示此二維數組包含的第 2 個一維數組,而3則表示此一維數組包含的第 3 個元素位置。由此,這個笛卡爾索引值就唯一地確定了一個元素位置。

經過前面的反覆闡釋,我相信你已經對多維數組有了足夠的空間想象力。笛卡爾索引其實就是基於多維空間而建立的。現在,讓我們把CartesianIndex(3, 2, 1)應用在array3d代表的三維數組上:

julia> array3d[CartesianIndex(3, 2, 1)]
6

julia>

如上所示,我們可以直接把CartesianIndex類型的值放在索引表達式的中括號中。實際上,這個索引表達式還可以被簡化爲array3d[3, 2, 1]。雖然這種簡化只是把針對各個維度的索引號直接羅列在了中括號內,但它卻讓更加靈活的索引方式成爲了可能。

還記得我們之前見過的[:, :, 1]嗎?它其實表達的就是一個多維度的索引。示例如下:

julia> array3d[:, :, 1]
3×5 Array{Int64,2}:
 1  4  7  10  13
 2  5  8  11  14
 3  6  9  12  15

julia> 

與之前的含義一致,這個多維索引選擇的是array3d中的第 1 個二維數組中的全部元素值。注意,上面的索引表達式的求值結果就是一個3×5的二維數組,就像把對應的二維數組原封不動地摘出來了一樣。

我們再來看一個更復雜一些的例子:

julia> array3d[:, [1,2], 1]
3×2 Array{Int64,2}:
 1  4
 2  5
 3  6

julia> 

看到了嗎?在上面的中括號裏還有中括號。這就意味着多維索引是可以嵌套的。在上面這個多維索引中,右邊的索引號1選擇的仍然是array3d中的第 1 個二維數組。中間的[1,2]是一個嵌入的多維索引,它選擇的是這個二維數組中的前 2 列。而左邊的:則表示選擇這 2 列中的所有元素值。因此,上述索引表達式的求值結果就是一個3×2的二維數組。

當然,我們也可以選擇array3d中的所有二維數組的前 2 列:

julia> array3d[:, [1,2], :]
3×2×2 Array{Int64,3}:
[:, :, 1] =
 1  4
 2  5
 3  6

[:, :, 2] =
 16  19
 17  20
 18  21

julia> 

這個求值結果是一個3×2×2的三維數組,就好像只是把那兩個二維數組的後 3 列都摳掉了似的。可見,通過多維索引選擇出的部分數組總是會最大限度地保持原有的形狀。不過,我們一定要注意下面兩種不同的索引方式所帶來的差異:

julia> array3d[:, [1,2], 1]
3×2 Array{Int64,2}:
 1  4
 2  5
 3  6

julia> array3d[:, [1,2], [1]]
3×2×1 Array{Int64,3}:
[:, :, 1] =
 1  4
 2  5
 3  6

julia> 

在多維索引中,如果針對某個維度的索引僅由一個索引號代表,那麼與這個維度對應的數組就會被拆散,或者說我們在最終的索引結果中就看不到原本在這個維度上的數組了。相對的,如果針對某個維度的索引是一個嵌入的多維索引,那麼我們在最終的索引結果中就仍然會完整或部分地看到原本在這個維度上的數組。

在多維索引[:, [1,2], 1]中,針對第三個維度的索引是索引號1。因此,與這個維度對應的數組就會被拆散,僅留下該索引號選擇的第 1 個二維數組。針對這個二維數組的索引是嵌入的多維索引[1,2],因此該二維數組的一部分就會被保留下來。針對一維數組的索引由:佔位,它等同於一個選擇了所有元素的嵌入索引,因此相應的一維數組會被完整地保留。由此,最終的索引結果就是一個擁有兩個維度的新數組。

你可能會想到,正是因爲中間的那個嵌入的多維索引選擇了兩個元素,對應的二維數組纔會被保留下來。這樣說沒有錯。但請記住,即使嵌入的多維索引只選擇了一個元素,當前維度上的數組也會被保留。

就拿上例中的第二個多維索引[:, [1,2], [1]]來說。雖然其中針對第三個維度的索引[1]只選擇了第 1 個二維數組,但由於它是一個嵌入的多維索引,所以與之對應的三維數組的一部分仍然會出現在最終的索引結果中。從 REPL 環境回顯的內容可知,這個索引結果是一個3×2×1的三維數組,而不是一個二維數組。並且,其中的那個唯一的二維數組是由[:, :, 1]指代的。這顯然是展示三維數組的格式。

我們再來看一組例子。這次先使用的是多維索引[:, 1, :]

julia> array3d[:, 1, :]
3×2 Array{Int64,2}:
 1  16
 2  17
 3  18

julia> 

我們這次選擇的是array3d裏的那兩個二維數組中的第 1 列。由於針對第二個維度的索引是索引號1,所以與之對應的兩個二維數組就都被拆散了,只留下了那兩個處於最左邊的一維數組。把它們拼接在一起就形成了最終的索引結果,即一個3×2的二維數組。

換個角度講,由於原來的二維數組已被拆散,導致原來的第三個維度變成了新的第二個維度,因此在最終的索引結果中就會有兩個維度。又由於針對第一個維度和原第三個維度的索引都由:佔位,所以最終的索引結果就是一個3×2的二維數組(請對比array3d代表的3x5x2的三維數組)。這個二維數組的內容完全由針對原第二個維度的索引號1指定。

我們接下來使用[1, :, :]array3d進行索引,結果如下:

julia> array3d[1, :, :]
5×2 Array{Int64,2}:
  1  16
  4  19
  7  22
 10  25
 13  28

julia> 

這一次,針對第二個維度和第三個維度的索引都由:佔位,而針對第一個維度的索引卻是索引號1。一維數組當然也可以被拆散。它會被拆成一個一個的元素值。這個索引號1會讓這些一維數組中的第 1 個元素值都被留下來,而其他的元素值都會被拋棄。

由於原來的一維數組已被拆散,導致原來的第二個維度變成了新的第一個維度,且原來的第三個維度變成了新的第二個維度。因此,最終的索引結果就是一個5×2的二維數組。之前被留下來的那些元素值會被依次地填充到這個二維數組中的各個元素位置上,且填充的順序會完全遵從線性索引的順序。

到這裏,我們講了針對一維數組和多維數組的線性索引,也講了針對多維數組的笛卡爾索引(也稱多維索引)。由於笛卡爾索引是可以嵌套的,因此使得它非常的靈活和強大。但這種索引的複雜度自然也就變高了。所以,我們在前面還舉了很多例子,並藉此詳細地討論了索引操作的主要過程。在看過了這些內容之後,你是否已經對數組的索引完全清楚了呢?

順便說一句,由於數組是可變的容器,所以我們還可以利用索引去修改其中的某個或某些元素位置上的值。

9.5.2 迭代

我們在前面說過,迭代是根據反饋重複地執行相同操作的過程。在 Julia 中,我們可以使用for語句來實現循環,並用它來迭代通常的容器,包括數組。請看下面的示例:

julia> for e in array2d
           println(e)
       end
1
2
3
4
5
6
# 省略一些輸出,此處會逐行地顯示元素值 7 至 27。
28
29
30

julia> 

這條for語句依次地打印出了array2d中的每一個元素值,且每個元素值都獨佔了一行。直到打印出array2d中的最後一個元素值,也就是與索引號30對應的元素值,這個循環才完全結束。數組中的元素值會被按照線性索引的順序依次地賦給迭代變量e

如果我們對數組中的元素值不感興趣,而只是想用for語句迭代出其中所有的線性索引號的話,那麼就可以使用eachindex函數。

eachindex函數可以接受一個數組作爲其參數值。這時,它會專門爲這個數組中的索引創建一個可迭代的對象(或稱迭代器),並將其作爲結果值返回。既然這個對象是可迭代的,那麼它就可以被用在for語句中。因此,下面的代碼是可行的:

julia> for i in eachindex(array2d)
           println("$(i): $(array2d[i])")
       end
1: 1
2: 2
3: 3
4: 4
5: 5
6: 6
# 省略一些輸出,此處會逐行地顯示線性索引號 7 至 27 以及與它們對應的元素值。
28: 28
29: 29
30: 30

julia> 

對於array2d來說,使用eachindex函數的意義好像並不大。但對於我們已經介紹過的各種可索引對象而言,這個函數提供了一種可以訪問其線性索引的標準方式。另外,該函數還可以被用來訪問其他類型的數組中的索引,甚至其他類型的容器中的索引。只不過,那就不一定是線性索引了,也可能是笛卡爾索引。這裏所說的其他類型的數組是指,除了Array之外且同樣繼承自AbstractArray的那些類型的實例。

此外,還有一種方式,它可以把數組中的各個元素值及其索引號分別包裝成鍵值對,然後創建一個能夠按照原有順序訪問這些鍵值對的迭代器。這就是pairs函數所提供的功能。注意,這些鍵值對都會以索引號爲鍵,並以元素值爲值。請看下面的示例:

julia> for (i, v) in pairs(array2d)
           println("$(i): $(v)")
       end
CartesianIndex(1, 1): 1
CartesianIndex(2, 1): 2
CartesianIndex(3, 1): 3
CartesianIndex(4, 1): 4
CartesianIndex(5, 1): 5
CartesianIndex(1, 2): 6
# 省略一些輸出,此處會逐行地顯示中間的鍵值對。
CartesianIndex(3, 6): 28
CartesianIndex(4, 6): 29
CartesianIndex(5, 6): 30

julia> 

注意,這裏有兩個迭代變量:iv。它們分別代表了鍵值對中的鍵和值。另外,我們還可以看到,上述鍵值對中的索引都是笛卡爾索引,因爲array2d是一個二維數組。對於多維數組,pairs函數會把元素值的笛卡爾索引作爲它們的鍵。而對於一維數組,pairs函數則會把元素值的線性索引號作爲它們的鍵。這都是在默認情況下的規則。

我們也可以自己選擇pairs函數所使用的索引。在 Julia 中,這也被稱爲索引風格的選擇。pairs函數還有一個可選的參數正是用於此種選擇的。它有三個選項,分別是:IndexLinear()IndexCartesian()IndexStyle(A)。前兩個選項分別是IndexLinear類型和IndexCartesian類型的實例。這兩個類型都是IndexStyle類型的子類型。從其名稱我們就可以看出,它們分別代表了線性索引風格和笛卡爾索引風格。

這個可選參數的第三個選項IndexStyle(A)是針對pairs函數的那個唯一的必選參數A而言的。因此,它的含義就是遵從A所代表的那個數組的索引風格。然而,不論是一維數組還是多維數組,只要它的類型是Array,它默認使用的就是線性索引風格。示例如下:

julia> pairs(IndexStyle(array2d), array2d)
pairs(IndexLinear(), ::Array{Int64,2}) with 30 entries:
  1 => 1
  2 => 2
  3 => 3
  4 => 4
  5 => 5
  6 => 6
  7 => 7
  ⋮ => ⋮

julia> 

至此,我們已經知悉了迭代數組的標準方式——使用for語句。我們還瞭解到,可以用eachindex函數或pairs函數包裝被迭代的數組,以達到不同的迭代效果。雖然可以實現這種包裝的函數不止這兩個,但是它們已經可以滿足絕大多數的需求了。在這裏,你應該特別記憶的是,那些相關的默認規則和定製化方式。

9.5.2 搜索

搜索指的是搜索數組中的元素值。在 Julia 中,這種搜索也是基於索引的。Julia 的Base模塊裏有不少提供了此功能的函數,我們在前面已經講過了一些。爲了方便你選用,我做了下面這張表。這樣你也可以對它們有一個整體上的瞭解。

表 9-1 可在數組中搜索的函數

函數名 搜索的起始點 搜索方向 結果值
findfirst 第一個元素位置 線性索引順序 首個滿足條件的元素值的索引號或nothing
findlast 最後一個元素位置 線性索引逆序 首個滿足條件的元素值的索引號或nothing
findnext 與指定索引號對應的元素位置 線性索引順序 首個滿足條件的元素值的索引號或nothing
findprev 與指定索引號對應的元素位置 線性索引逆序 首個滿足條件的元素值的索引號或nothing
findall 第一個元素位置 線性索引順序 包含了所有滿足條件元素值的索引號的向量
findmax 第一個元素位置 線性索引順序 最大的元素值及其索引號組成的元組或NaN
findmin 第一個元素位置 線性索引順序 最小的元素值及其索引號組成的元組或NaN

我們之前講過的函數findfirstfindlastfindnextfindprev都可以被用於搜索數組中的元素值。在一般情況下,我們傳給它們的第一個參數值都應該是一個用來做條件判斷的函數,而這個函數返回的結果都應該一個布爾值。下面是幾個簡單的例子:

julia> findfirst(isequal(7), [1,2,3,4,5,6,7,8,9])
7

julia> findfirst(isequal(27), [1,2,3,4,5,6,7,8,9]) == nothing
true

julia> findfirst(isequal(27), array2d)
CartesianIndex(2, 6)

julia> array2d[ans]
27

julia> findnext(iseven, array2d, CartesianIndex(2, 6))
CartesianIndex(3, 6)

julia> array2d[ans]
28

julia> 

一定要注意,對於一維數組,前面這 4 個函數在找到滿足條件的元素值之後,都會返回該值的線性索引號。而對於多維數組,它們在這時都會返回元素值的笛卡爾索引。這與pairs函數的默認規則是相同的,但是與eachindex函數的行爲以及(Array類型的)數組的默認索引風格卻有着明顯的差異。不過,這種差異只存在於對多維數組索引的選擇上。

相應的,我們在爲findnext函數和findprev函數傳參的時候也要注意這種差異。這兩個函數都需要一個代表了搜索起始點的參數值。如果搜索的是一維數組,那麼我們就必須使用線性索引號來表示這個起始點,否則就必須使用笛卡爾索引。

我們再來說findall函數。這個函數會在被搜索的數組的全範圍內尋找目標元素值,然後把那些滿足條件的元素值的索引號都放到一個一維數組中。即使沒有找到任何滿足條件的元素值,它也依然會返回這個空的一維數組,而不會像前 4 個函數那樣返回nothing。不過,在對數組索引的選擇上,findall函數總會與前 4 個函數保持一致。

到目前爲止,我們一直說的是前 5 個函數在一般情況下的調用方式。其實,我們也可以不傳入那個用來做條件判斷的函數。不過這樣的話,它們對被搜索的數組就有要求了。具體的要求是,被搜索的數組的元素類型必須是Bool。在這種情況下,這些函數拿來做判斷的條件就是“元素值必須等於true”。例如:

julia> array2d_bool = Bool[0 0 1 0 0 1; 1 0 1 0 0 0; 0 0 0 1 0 0; 1 0 0 0 1 1; 0 1 0 1 0 0]
5×6 Array{Bool,2}:
 0  0  1  0  0  1
 1  0  1  0  0  0
 0  0  0  1  0  0
 1  0  0  0  1  1
 0  1  0  1  0  0

julia> findlast(array2d_bool)
CartesianIndex(4, 6)

julia> findprev(array2d_bool, CartesianIndex(3, 6))
CartesianIndex(1, 6)

julia> findall(array2d_bool)
10-element Array{CartesianIndex{2},1}:
 CartesianIndex(2, 1)
 CartesianIndex(4, 1)
 CartesianIndex(5, 2)
 CartesianIndex(1, 3)
 ⋮                   
 CartesianIndex(4, 5)
 CartesianIndex(1, 6)
 CartesianIndex(4, 6)

julia> 

別忘了線性索引的順序。對於二維數組來說,它是先縱向、後橫向的。這與現代人寫字和閱讀的順序有着明顯的不同。

我們接着往下看。很顯然,findmax函數和findmin函數所依據的條件都不用我們來指定。並且,當數組中存在多個最大值或多個最小值的時候,它們只會選擇線性索引號最小的那一個。另外,一旦碰到NaN,那麼它們就會直接把這個NaN及其索引號組成的元組作爲結果值返回。還有,這兩個函數在對數組索引的選擇方面依然如同前面那 5 個搜索函數。但與那些函數不同的是,對於空的被搜索數組,這兩個函數都會立即拋出ArgumentError類型的錯誤。示例如下:

julia> findmin([115,65,18,2,117,-102,123,66,-93,-102])
(-102, 6)

julia> findmin([115,65,18,2,117,-102,123,66,NaN,-102])
(NaN, 9)

julia> findmin([])
ERROR: ArgumentError: collection must be non-empty
# 省略了一些回顯的內容。

julia> 

請注意,雖然我們在前面的例子中搜索的都是數值的數組,但你千萬不要以爲這些函數只能搜索這類數組。即使對於函數findmaxfindmin來說,只要一個數組中的所有元素值之間都是可比較的,那麼它們就可以對這個數組進行搜索。

除此之外,findmaxfindmin還可以幫助我們尋找多維數組在某個或某些維度中的最大值或最小值。我們以array2d爲例,代碼如下:

julia> array2d
5×6 Array{Int64,2}:
 1   6  11  16  21  26
 2   7  12  17  22  27
 3   8  13  18  23  28
 4   9  14  19  24  29
 5  10  15  20  25  30

julia> findmax(array2d, dims=1)
([5 10 … 25 30], CartesianIndex{2}[CartesianIndex(5, 1) CartesianIndex(5, 2) … CartesianIndex(5, 5) CartesianIndex(5, 6)])

julia> typeof(ans)
Tuple{Array{Int64,2},Array{CartesianIndex{2},2}}

julia> 

可以看到,當我在調用findmax函數的時候把1賦給了它的關鍵字參數dims。順便說一下,對於關鍵字參數,我們必須使用<name>=<value>的方式爲其賦值,如dims=1。此時,這個函數就會去尋找array2d裏的第一個維度(或者說各個列)中的所有最大值。它在這裏返回的結果值是一個元組。這個元組先後包含了每一列中的最大值(共有 6 個)以及它們的笛卡爾索引。

按照這個規則,如果我在這裏把2賦給這個函數的dims參數,那麼它就會去尋找array2d裏的第二個維度(或者說各個行)中的所有最大值。這時,它同樣會返回一個元組,並且其中會先後包含每一行中的最大值(應該有 5 個)以及它們的笛卡爾索引。

findmax函數的dims參數在含義上與我們在前面講過的同名參數並沒有什麼兩樣。這個參數的值在這裏既可以是一個代表了某個維度的整數,也可以是一個代表了多個維度的元組或數組。如果是後者,那麼該函數就會把指定的多個維度合起來看,並在其中尋找最大的值。例如:

julia> findmax(array2d, dims=(1,2))
([30], CartesianIndex{2}[CartesianIndex(5, 6)])

julia> typeof(ans)
Tuple{Array{Int64,2},Array{CartesianIndex{2},2}}

julia> 

我把元組(1,2)作爲了參數dims的值,使得findmax函數把array2d裏的第一個維度和第二個維度作爲一個整體看待,並去尋找這個整體中的最大值。顯然,這裏的這個最大值僅有一個,即處在第 5 行、第 6 列的30

對於findmin函數也是一樣,它同樣有一個名爲dims的可選參數,只不過它尋找的是多維數組在某個或某些維度中的最小值而已。

好了,只要你記住了上述 7 個函數的用法,就可以自如地在數組中搜索元素值了。

9.6 修改元素值

9.6.1 索引

修改一個數組最簡單的方式就是使用索引表達式。無論是單點索引表達式,還是多點索引表達式,又或是範圍索引表達式,都可以被用來修改數組。示例如下:

julia> array2d_copy = copy(array2d)
5×6 Array{Int64,2}:
 1   6  11  16  21  26
 2   7  12  17  22  27
 3   8  13  18  23  28
 4   9  14  19  24  29
 5  10  15  20  25  30

julia> array2d_copy[5] = 50;

julia> array2d_copy[[1,3]] = [10, 30];

julia> array2d_copy[7:9] = [70, 80, 90];

julia> array2d_copy
5×6 Array{Int64,2}:
 10   6  11  16  21  26
  2  70  12  17  22  27
 30  80  13  18  23  28
  4  90  14  19  24  29
 50  10  15  20  25  30

julia> 

這裏有兩點需要注意。第一點,當我們使用多點索引表達式或範圍索引表達式的時候,在賦值符號=右邊的應該是一個一維的數組。並且,這個一維數組的長度應該與我們要替換的元素值的數量一致。第二點,不管使用哪一種索引表達式,等號右邊的值或元素值都必須能被轉換成其左邊數組的元素類型的實例,否則 Julia 就會立即報錯:

julia> array2d_copy[[1,3]] = [10.1, 30.5]
ERROR: InexactError: Int64(10.1)
# 省略了一些回顯的內容。

julia> 

浮點數10.1Float64類型的,它不能被轉換成Int64類型的實例,所以 Julia 就報錯了。

另外,我們也可以利用笛卡爾索引對數組進行修改。比如:

julia> array3d_copy = copy(array3d)
3×5×2 Array{Int64,3}:
[:, :, 1] =
 1  4  7  10  13
 2  5  8  11  14
 3  6  9  12  15

[:, :, 2] =
 16  19  22  25  28
 17  20  23  26  29
 18  21  24  27  30

julia> array3d_copy[:, :, 1] = zeros(Int64, 3, 5);

julia> array3d_copy[:, 3:4, 2] = ones(Int64, 3, 2);

julia> array3d_copy[:, [1,5], 2] = fill(2, 3, 2);

julia> array3d_copy
3×5×2 Array{Int64,3}:
[:, :, 1] =
 0  0  0  0  0
 0  0  0  0  0
 0  0  0  0  0

[:, :, 2] =
 2  19  1  1  2
 2  20  1  1  2
 2  21  1  1  2

julia> 

簡單地解釋一下,函數copy用於淺拷貝一個值。在這裏,我利用copy函數得到了數組array3d的複本,並把這個複本賦給了變量array3d_copy。關於copy函數和淺拷貝,我在下一章都會進行詳細的說明。

9.6.2 視圖

我們已經知道,索引表達式可以讓我們獲得一個數組中的某個或某些元素。如果索引表達式返回的是單個的元素值,那麼這個值就是原數組中對應的那個元素值本身。如果索引表達式返回的是一個數組,那麼它就相當於在一個新的數組結構中沿用了原數組中的相應元素值。這其實與copy函數有着異曲同工之妙。然而,不論索引表達式的求值結果是什麼,我們都不能通過這個結果值去替換原有數組中的元素。但是,我們通過視圖(view)是可以做到這一點的。

函數view用於創建一個數組的視圖。它的第一個參數就是視圖基於的那個數組(或稱父數組)。除了父數組以外,我們還可以爲它傳入一個或多個索引號。爲了演示,我們先定義一個新的多維數組:

julia> array4d = reshape(Vector(1:36), (3,3,2,2))
3×3×2×2 Array{Int64,4}:
[:, :, 1, 1] =
 1  4  7
 2  5  8
 3  6  9

[:, :, 2, 1] =
 10  13  16
 11  14  17
 12  15  18

[:, :, 1, 2] =
 19  22  25
 20  23  26
 21  24  27

[:, :, 2, 2] =
 28  31  34
 29  32  35
 30  33  36

julia> 

解釋一下,Vector(1:36)會構造出一個向量。這個向量的元素類型是Int(具體到這裏是Int64),長度是36,並且其中會依次地包含從136的整數值。函數reshape會先創建一個此向量的複本,然後把該複本變成一個3×3×2×2的四維數組。這個四維數組的元素類型和長度都與原數組保持一致,只是在維數和尺寸上有所變化。

現在,我們基於四維數組array4d創建視圖:

julia> array4d_view1 = view(array4d, 26)
0-dimensional view(::Array{Int64,1}, 26) with eltype Int64:
26

julia> 

由 REPL 環境回顯的內容可知,我們創建了一個零維的視圖。什麼叫零維呢?如果說二維是一個面、一維是一條線的話,那麼零維就是一個點。零維的數組或視圖就相當於一個標量(scalar)。所謂的標量,可以說就是不包含其他值的單一值。像數值、字符值、字符串值、符號、類型、函數,以及一些常見的單例如missingnothing等都屬於標量。

零維數組沒有任何的維度,這意味着在任何維度上它們都沒有所謂的長度。因此,把size函數用在它們身上就只會返回空的元組。不過它們卻有總長度,而且這個總長度總是1。這是因爲它們終歸還是數組,並且裏面終歸還是有一個元素值的。相關的代碼如下:

julia> size(array4d_view1)
()

julia> ndims(array4d_view1), length(array4d_view1)
(0, 1)

julia> eltype(array4d_view1)
Int64

julia> 

那麼我們怎樣才能從中取出那個唯一的元素值呢?答案是,依然使用索引表達式。不過,在針對零維視圖的索引表達式中,索引號就變得可有可無了。例如:

julia> array4d_view1[1]
26

julia> array4d_view1[]
26

julia

既然我們可以這樣取出視圖中的元素值,那麼必然也可以利用這種方式替換元素值。代碼如下:

julia> array4d_view1[] = 260
260

julia> array4d_view1[]
260

julia> array4d[26]
260

julia> 

一定要注意,我們對視圖中元素值的替換肯定會改變其父數組中的對應元素值。因此,一旦替換了視圖array4d_view1中的那個元素值,也就等於替換了數組array4d中與線性索引號26對應的那個元素值。

我們也可以把數組中的多個元素值匯聚到同一個視圖裏。這時,我們需要用中括號把多個線性索引號包裹起來,並將其作爲view函數的第二個參數值。比如:

julia> array4d_view2 = view(array4d, [1,3,5])
3-element view(::Array{Int64,1}, [1, 3, 5]) with eltype Int64:
 1
 3
 5

julia> array4d_view2[[1, 2, 3]]
3-element Array{Int64,1}:
 1
 3
 5

julia> 

注意,視圖中的各個元素值的線性索引號,不一定就等於它們在父數組中的那個線性索引號。就拿視圖array4d_view2來說。其中有 3 個元素值,它們在這個視圖中的線性索引號分別是123。但是,後兩個元素值在該視圖的父數組array4d中的線性索引號卻分別是35。也就是說,視圖上分配的線性索引號與它的父數組沒有任何關係。它們是單獨排列的,互不干擾。

我們若想要通過array4d_view2替換掉其父數組中的元素值也很容易。代碼如下:

julia> array4d_view2[[1,2,3]] = [10, 30, 50]
3-element Array{Int64,1}:
 10
 30
 50

julia> array4d[[1, 3, 5]]
3-element Array{Int64,1}:
 10
 30
 50

julia> 

在這裏,我們需要小心的地方是,等號兩邊的視圖或數組所包含的元素值的數量必須一致,否則替換就無法成功完成。

另外,除了線性索引,我們還可以在創建視圖的時候使用笛卡爾索引。不過,笛卡爾索引在這裏就不需要由中括號包裹了。更確切地說,在調用view函數的時候,笛卡爾索引中的每一個部分都需要作爲一個獨立的參數值。就像這樣:

julia> array4d_view3 = view(array4d, :, 1, 2, 2)
3-element view(::Array{Int64,4}, :, 1, 2, 2) with eltype Int64:
 28
 29
 30

julia>  

上面這個視圖引用的是數組array4d裏的一個列向量中的所有元素值。而這個列向量就是array4d中的第 2 個三維數組中的第 2 個二維數組中的第 1 個一維數組。下面我們來替換它引用的那些元素值:

julia> array4d_view3[:] = [280, 290, 300]
3-element Array{Int64,1}:
 280
 290
 300

julia> array4d[:, 1, 2, 2]
3-element Array{Int64,1}:
 280
 290
 300

julia> 

怎麼樣?是不是很容易呢?只要理解了視圖的本質,這就絕對算不上難事。你可以把視圖想象成一個窗口。我們可以通過這個窗口看到其父數組中的一部分甚至全部的元素值。而且,更重要的是,透過這個窗口我們還可以直接存取那些看得到的元素值。

順便說一下,當我們拿到一個視圖時,可以通過調用parent函數得到它的父數組本身。如:

julia> parent(array4d_view3) === array4d
true

julia> 

另外,我們還可以通過調用parentindices函數獲得視圖裏的所有元素值在其父數組中的索引號(的另一種表現形式)。如:

julia> parentindices(array4d_view3)
(Base.Slice(Base.OneTo(3)), 1, 2, 2)

julia> CartesianIndices(ans)
3×1×1×1 CartesianIndices{4,NTuple{4,UnitRange{Int64}}}:
[:, :, 1, 1] =
 CartesianIndex(1, 1, 2, 2)
 CartesianIndex(2, 1, 2, 2)
 CartesianIndex(3, 1, 2, 2)

julia> array4d[ans]
3×1×1×1 Array{Int64,4}:
[:, :, 1, 1] =
 280
 290
 300

julia> vec(ans)
3-element Array{Int64,1}:
 280
 290
 300

julia> array4d[:, 1, 2, 2]
3-element Array{Int64,1}:
 280
 290
 300

julia> 

可以看到,我們需要對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 預定義的容器中是獨有的。

數組的類型字面量只能體現它的元素類型和維數,而不能體現元素的順序以及各個維度上的元素數量。不過多維數組在各個維度上的元素數量仍需滿足既定的規則。

我們可以使用一般表示法表示一維數組和二維數組。這涉及到了元素值分隔符“,”、縱向拼接符“;”以及作爲橫向拼接符的空格。不過,對於三維數組,這種表示法就無能爲力了。

我們可以利用數組的構造函數來創建擁有更多維度的數組。在這裏,我們需要注意的是,傳入的參數值對於新數組的尺寸以及其中元素值的影響。除了構造函數,我們還可以使用zerosonesfill之類的函數創建多維數組。

Julia 爲我們提供了專門的函數以獲取一個數組的元素類型、維數、元素值總數以及它在各個維度上的長度。我們在訪問數組中的元素值的時候有幾種方式可供選擇,比如使用索引表達式,又比如使用for語句進行迭代。注意,Array類型的數組擁有兩種索引,即:線性索引和笛卡爾索引。我們可以利用它們在這類數組上進行靈活的定位,並同時獲取到在不同位置上的多個元素值。除此之外,我們還可以通過一些搜索函數查找一個或多個值在某個數組中的索引號。

對於數組中元素值的修改,我們同樣可以使用索引表達式。索引表達式在這方面的不俗表現也同樣依託於強悍的索引機制。另外,我們還可以使用視圖來查看和修改數組中的元素值。它基於的依然是索引機制。它的一個顯著優勢是,我們可以通過視圖對原有數組中的元素值進行完全的替換。

最後,我們還速覽了一些可以對數組進行修改的專用函數。在通常情況下,我們用到這些函數的機會可能並不多。但是在一些專業的且目前很熱門的領域裏,它們卻可以帶來相當大的便利。

我們用了一整章的篇幅討論了數組本身,以及怎樣才能正確地表示、構造數組和存取其中的元素值。在看過這一章之後,你應該就可以比較熟練地運用數組了。不過,我們還應該去了解更多關於數組的知識。在下一章,我會繼續和你討論幾個與之有關的重要專題。雖然這些專題的內容並不像本章所講的那麼基礎,但是它們卻可以在很大程度上提高你的編碼效率。

系列文章:

Julia編程基礎(一):初識Julia,除了性能堪比C語言還有哪些特性?

Julia編程基礎(二):開發Julia項目必須掌握的預備知識

Julia編程基礎(三):一文掌握Julia的變量與常量

Julia 編程基礎(四):如何用三個關鍵詞搞懂 Julia 類型系統

Julia編程基礎(五):數值與運算

Julia編程基礎(六):玩轉字符和字符串

Julia編程基礎(七):由淺入深瞭解參數化類型

Julia編程基礎(八):如何在最合適的場景使用字典與集合?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章