(三)陣列

小而困難發表於2020-11-05

定義

陣列(Array)是一種線性表資料結構。它用一組連續的記憶體空間,來儲存一組具有相同型別的資料。

  • 線性表
    陣列、連結串列、佇列、棧
  • 非線性表
    二叉樹、堆、圖

隨機訪問

連續的記憶體空間和相同型別的資料。正是因為這兩個限制,它才有了一個堪稱“殺手鐗”的特性:“隨機訪問”。但有利就有弊,這兩個限制也讓陣列的很多操作變得非常低效,比如要想在陣列中刪除、插入一個資料,為了保證連續性,就需要做大量的資料搬移工作。

  • short 2位元組 16位整數
  • int 4 位元組 32位整數
  • long 8位元組 64位整數

int[] a = new int[10],分配了一塊連續記憶體空間 1000~1039,其中,記憶體塊的首地址為 base_address = 1000。

定址公式

a[i]_address = base_address + i * data_type_size

陣列和連結串列的區別

  • 連結串列適合插入、刪除,時間複雜度 O(1)
  • 陣列支援隨機訪問,根據下標隨機訪問的時間複雜度為 O(1)

注:陣列是適合查詢操作,但是查詢的時間複雜度並不為 O(1)。即便是排好序的陣列,你用二分查詢,時間複雜度也是 O(logn)。

低效的“插入”和“刪除”

插入

假設陣列的長度為 n,現在,如果我們需要將一個資料插入到陣列中的第 k 個位置。為了把第 k 個位置騰出來,給新來的資料,我們需要將第 k~n 這部分的元素都順序地往後挪一位。

  • 在陣列末尾插入元素,不需要移動資料,時間複雜度為O(1)
  • 在陣列開頭插入元素,所有的資料都需要依次往後移動一位,所以最壞時間複雜度是 O(n)
    在每個位置插入元素的概率是一樣,平均時間複雜度:(1+2+…n)/n=O(n)。
  • 如果陣列資料有序,需要按照剛才的方法搬移 k 之後的資料。
  • 如果陣列中儲存的資料並沒有任何規律,陣列只是被當作一個儲存資料的集合。
    直接將第 k 位的資料搬移到陣列元素的最後,把新的元素直接放入第 k 個位置。


利用這種處理技巧,在特定場景下,在第 k 個位置插入一個元素的時間複雜度就會降為 O(1)。

刪除

跟插入資料類似,如果我們要刪除第 k 個位置的資料,為了記憶體的連續性,也需要搬移資料,不然中間就會出現空洞,記憶體就不連續了。

  • 刪除陣列末尾的資料,則最好情況時間複雜度為 O(1)
  • 如果刪除開頭的資料,則最壞情況時間複雜度為 O(n)
  • 平均情況時間複雜度也為 O(n)

在某些特殊場景下,我們並不一定非得追求陣列中資料的連續性。將多次刪除操作集中在一起執行,來提高刪除的效率。

為了避免 d,e,f,g,h 這幾個資料會被搬移三次,我們可以先記錄下已經刪除的資料。
每次的刪除操作並不是真正地搬移資料,只是記錄資料已經被刪除
當陣列沒有更多空間儲存資料時,我們再觸發執行一次真正的刪除操作,這樣就大大減少了刪除操作導致的資料搬移。

陣列越界


int main(int argc, char* argv[]){
    int i = 0;
    int arr[3] = {0};
    for(; i<=3; i++){
        arr[i] = 0;
        printf("hello world\n");
    }
    return 0;
}

i = 3 訪問越界。陣列越界在 C 語言中是一種未決行為,並沒有規定陣列訪問越界時編譯器應該如何處理。
因為,訪問陣列的本質就是訪問一段連續記憶體,只要陣列通過偏移計算得到的記憶體地址是可用的,那麼程式就可能不會報任何錯誤。

容器

針對陣列型別,很多語言都提供了容器類,比如 Java 中的 ArrayList、C++ STL 中的 vector

ArrayList 最大的優勢就是可以將很多陣列操作的細節封裝起來。比如前面提到的陣列插入、刪除資料時需要搬移其他資料等。另外,它還有一個優勢,就是支援動態擴容

陣列本身在定義的時候需要預先指定大小,因為需要分配連續的記憶體空間。如果我們申請了大小為 10 的陣列,當第 11 個資料需要儲存到陣列中時,我們就需要重新分配一塊更大的空間,將原來的資料複製過去,然後再將新的資料插入

使用 ArrayList,不需要關心底層的擴容邏輯,ArrayList 已經實現好了。每次儲存空間不夠的時候,它都會將空間自動擴容為 1.5 倍大小。

注意:擴容操作涉及記憶體申請資料搬移,是比較耗時的。所以,如果事先能確定需要儲存的資料大小,最好在建立 ArrayList 的時候事先指定資料大小

更適合使用陣列的地方:

  1. Java ArrayList 無法儲存基本型別,比如 int、long,需要封裝為 Integer、Long 類,而 Autoboxing、Unboxing 則有一定的效能消耗,所以如果特別關注效能,或者希望使用基本型別,就可以選用陣列。
  2. 如果資料大小事先已知,並且對資料的操作非常簡單,用不到 ArrayList 提供的大部分方法,也可以直接使用陣列。
  3. 表示多維陣列時,用陣列往往會更加直觀。比如 Object[][] array;而用容器的話則需要這樣定義:ArrayList<ArrayList<object>> array

陣列下標從0開始

  • 從陣列儲存的記憶體模型上來看,“下標”最確切的定義應該是“偏移(offset)”
    計算 a[k]的記憶體地址:
a[k]_address = base_address + k * type_size

如果陣列從 1 開始計數,那我們計算陣列元素 a[k]的記憶體地址就會變為:

a[k]_address = base_address + (k-1)*type_size

從 1 開始編號,每次隨機訪問陣列元素都多了一次減法運算,對於 CPU 來說,就是多了一次減法指令。

更多的是歷史原因:
C 語言設計者用 0 開始計數陣列下標,之後的 Java、JavaScript 等高階語言都效仿了 C 語言,或者說,為了在一定程度上減少 C 語言程式設計師學習 Java 的學習成本,因此繼續沿用了從 0 開始計數的習慣。實際上,很多語言中陣列也並不是從 0 開始計數的,比如 Matlab。甚至還有一些語言支援負數下標,比如 Python。

小結

陣列用一塊連續的記憶體空間,來儲存相同型別的一組資料,最大的特點就是支援隨機訪問,但插入、刪除操作也因此變得比較低效平均情況時間複雜度為 O(n)。在平時的業務開發中,我們可以直接使用程式語言提供的容器類,但是,如果是特別底層的開發,直接使用陣列可能會更合適。

思考

JVM 標記清楚垃圾回收演算法

大多數主流虛擬機器採用可達性分析演算法來判斷物件是否存活,在標記階段,會遍歷所有 GC ROOTS,將所有 GC ROOTS 可達的物件標記為存活。只有當標記工作完成後,清理工作才會開始。

不足:

  1. 效率問題。標記和清理效率都不高,但是當知道只有少量垃圾產生時會很高效。
  2. 空間問題。會產生不連續的記憶體空間碎片。

二維陣列記憶體定址

對於 m * n 的陣列,a [ i ][ j ] (i < m,j < n)的地址為:

address = base_address + ( i * n + j) * type_size

死迴圈問題

待解決

相關文章