一本正經的聊資料結構(2):陣列與向量

極客挖掘機發表於2020-04-16

前文傳送門:

一本正經的聊資料結構(1):時間複雜度

引言

這個系列沒有死,我還在更新。

最近事情太多了,這篇文章也是斷斷續續寫了好幾天才湊完。

上一篇我們介紹了一個基礎概念「時間複雜度」,這篇我們來看第一個真正意義上的資料結構「陣列」。

那為什麼題目中還會有一個向量呢?這個是什麼東西?

不要急,且聽我慢慢道來。

記憶體

在聊陣列之前,需要先了解一個硬體,這個就是我們電腦上記憶體。

先了解一下記憶體的物理構造,一般記憶體的外形都長這樣:

上面那一個一個的小黑塊就是記憶體顆粒,我們的資料就是存放在那個記憶體顆粒裡面的。

每一個記憶體顆粒叫做一個 chip 。每個 chip 內部,是由 8 個 bank 組成的。大致長這樣(靈魂畫手挖掘機老師開始上線):

而每一個 bank 是一個二維平面上的矩陣,矩陣中每一個元素中都是儲存了 1 個位元組,也就是 8 個 bit ,比如這樣:

所以準確的來講,我們的資料就是放在那一個一個的元素中的。

陣列

在 C 、C++ 、Java ,都將陣列作為一個基礎的資料型別內建。

很可惜,在 Python 中未將陣列作為一個基礎的資料型別,雖然 Python 中的 list 和陣列很像,但是我更願意叫它列表,而不是陣列。

那麼陣列究竟是啥呢,我這裡借用下鄧俊輝老師「資料結構」中的定義:

若集合 S 由 n 個元素組成,且各元素之間具有一個線性次序,則可將它們存放於起始於地址 A 、物理位置連續的一段儲存空間,並統稱作陣列( array ) ,通常以 A 作為該陣列的標識。具體地,陣列 A[] 中的每一元素都唯一對應於某一下標編號,在多數高階程式設計語言中,一般都是從 0 開始編號,依次是 0 號、 1 號、 2 號、 ...、 n - 1 號元素。

其中,對於任何 0 <= i < j < n , A[i] 都是 A[j] 的前驅( predecessor ) , A[j] 都是 A[i] 的後繼( successor ) 。特別地,對於任何 i >= 1, A[i - 1] 稱作 A[i] 的直接前驅( intermediatep redecessor ) ;對於任何 i <= n - 2 , A[i + 1] 稱作 A[i] 的直接後繼( intermediate successor ) 。 任一元素的所有前驅構成其字首( prefix ) ,所有後繼構成其字尾( suffix ) 。

概念永遠都是這麼的枯燥、乏味以及看不懂。

沒關係,繼續我的非本職工作「靈魂畫手」。

首先了解第一個知識點,陣列是放在記憶體裡的,記憶體的結構我們前面介紹過了,那麼陣列簡單理解就是放在一個一個格子裡的,就像這樣:

實際上不是這麼放的哈,簡單理解可以先這麼理解,這個涉及到記憶體對齊的知識,有興趣的同學可以度娘瞭解下。

便於理解,我在字母 A 後面加上了數字,這個數字理解為編號,並且是從 0 開始的。

這個結構讓我想起了糖葫蘆:

陣列中的數字就像是穿在棍子上的山楂,大晚上的看的有點流口水。

前驅和後繼可以看下面這張圖:

A3 是 A4 的直接前驅, A5 是 A4 的直接後繼,而排在 A4 前面的都叫 A4 的前驅,排在 A4 後面的都叫 A4 的後繼。

向量

那麼啥是向量( vector )呢?

這個東西可以簡單理解為陣列的升級版,在各種程式語言中,大多數都對向量的實現做了內建,不過很可惜,在 Python 中,向量未成為一個基礎的資料結構。

那麼我就只能對照著 Java 來聊了,向量這個資料結構在 Java 中的實現是:java.util.Vector ,用過 Vector 的應該都是上古程式設計師了,這個工具類是伴隨 JDK1.0 就有的,但是後面逐漸的棄用,原因我們後面有機會再聊,就不在這多說了。

第一個要聊的問題是,我們在使用陣列的時候有什麼不方便的地方?這個問題換一種問法,其實就是為什麼我們需要使用向量(vector)。

首先,我們在使用陣列的時候,需要宣告陣列的大小,因為程式需要根據我們宣告的陣列大小去向記憶體申請空間,這就很尷尬了,如果在一個我並不清楚後續可能會用多少空間的場景中,我就沒有辦法去宣告一個陣列,因為陣列是定長的。

然後就是陣列需要是同一資料型別,比如我一個陣列如果放入了陣列,那麼就不能再放入字串,就不提更加複雜的資料結構,這完全都是因為陣列的特性決定的,因為陣列在記憶體上是連續的。

那麼遇到了上面的問題怎麼辦,當然是解決問題咯,這樣,陣列的第一個升級版 Vector 就出來了,在建立的時候不需要宣告大小,然後放入的資料不一定都要是同一個資料型別,比如我想放數字就放數字,想放集合就放集合,想放字串就放字串。

宣告一點,向量( Vector )的實現是通過陣列來實現的。

在 Java 的 Vector 的原始碼中可以很清楚的找到證據:

protected Object[] elementData;

動態擴容

這裡就會出現第一個問題,陣列是定長的,那麼向量為什麼可以不定長?

當然是向量會自動擴容啦~~~~~

說到自動擴容,不禁讓我想起來上面那個糖葫蘆,當棍子放不下山楂還想往上放怎麼辦,當然是把棍子變長點咯:

當然,我們在程式中擴容並不是直接把原有陣列加長,因為陣列的要求是物理空間必須地址連續,而我們卻無法保證,其尾部總是預留了足夠空間可供擴充。

所以能想到的做法就是另行申請一個容量更大的陣列,並將原陣列中的成員集體搬遷至新的空間,比如這樣:

那麼,問題又產生一個,每次變長(擴容)的時候,變長(擴容)多少合適呢?

因為每次擴容的時候,元素的搬遷都需要花費額外的時間,這對效能是一個損耗,我們並不希望這個損耗過大,那麼是不是可以一次擴容擴的足夠大呢?當然也不行,這樣會造成記憶體的浪費,雖然記憶體便宜,但也不是這麼浪費的。

比如初始長度是 10 ,第一次擴容直接擴容到 100 ,第二次到 1000 ,這個就太誇張了,這裡可以直接參考 JDK 的原始碼,看下大神是怎麼擴容的。

篇幅原因我就不帶大家在這看 Java 的原始碼了,直接說結果,照顧下學 Python 的同學:

在 Java 中 Vector 的初始長度定義為 10 ,當元素個數超過原有容量長度 1 時,進行擴容,每次擴容的大小是原容量的 1 倍,那麼就是第一次擴容是從 10 變成了 20 。

至於為什麼是擴大 1 倍而不是其他這裡就不展開討論了,實際上 Java 在另一個資料結構列表( List )中擴容的大小是 0.5 倍 + 1 。

不同資料型別

還是拿上面的糖葫蘆舉例子,如果一個杆上只能穿山楂,那麼它是一個糖葫蘆(陣列),但是,只想穿山楂的糖葫蘆不是一個好糖葫蘆。

但我在吃山楂的同時還想吃臭豆腐,小龍蝦,扇貝,生蠔,帝王蟹,成年人的世界,就是我全都要這麼樸實無華。

然後糖葫蘆開啟了超進化模式:

變成了下面這玩意:

這個功能在 Java 中的實現是通過 Object 陣列來實現的,因為 Object 在 Java 中是所有類的超類,這個接觸過 Java 的同學應該都清楚,如果沒有接觸過 Java ,可以這麼理解,借用道德經裡一句話:

道生一,一生二,二生三,三生萬物

而這個 Object 就是那個一,由這個一才產生了豐富多彩的 Java 世界,所以 Object 陣列是可以轉化為任何型別的。

小結

小結一下吧,我們聊了一個最簡單的資料結構,陣列,還從陣列中引申出來了向量( Vector ),很遺憾,Python 中沒有這兩個基礎資料結構。

在向量( Vector )中,我們介紹了向量( Vector )的兩個不同於陣列的特性,一個是動態擴容,還有一個是向量中可以放不同的資料型別,這對比陣列極大的方便了我們日常的使用。

順便說一句,雖然很多資料結構都是基於陣列的,包括本文介紹的 Vector ,還有後面會介紹到的列表 List ,但是我們在實際的使用中是很少會直接使用陣列的,基本上都是使用陣列的超進化體。

參考

https://zhuanlan.zhihu.com/p/83449008

相關文章