簡介
陣列本質上是一種線性表資料結構,它用一組連續的記憶體空間,來儲存一組具有相同型別的資料。
線性表
如上圖所示,線性表就是資料排成一條線一樣的結構,因此每個線性表中的資料只有前後兩個方向。像陣列、連結串列、棧、佇列都是線性表結構。
與線性表對應的就是非線性表結構,非線性表中的資料會存在多個方向,資料之間不是簡單的前後關係。像樹、堆都是非線性表結構。
連續的記憶體空間
陣列儲存使用的記憶體空間是連續的,在陣列儲存的這一片空間只會儲存陣列元素,不會再拆分出來儲存其他的資料。
相同型別的資料
在陣列的原始定義中,陣列只能儲存相同型別的資料,這樣可以保證陣列中每個元素佔用的記憶體空間都能保持一致。
正是陣列存在“連續的記憶體空間”和“相同型別的資料”這兩個限制,才讓它擁有了隨機存取這個高效的特性,但同時也使得陣列在插入、刪除資料的時候會比較低效。
陣列的特性
就陣列增刪改查的操作而言,陣列擁有高效隨機存取的特性,也存在低效插入、刪除的劣勢。
隨機存取
這裡的“隨機存取”指的的是通過下標訪問陣列元素,然後對這個元素做存取操作。
計算機會給每個記憶體單元分配一個地址,然後通過地址來訪問記憶體中的資料。當計算機需要隨機訪問陣列中的資料時,它可以通過下面的定址公式來計算出對應下標的記憶體地址:
address[i] = base_address + data_type_size * i
其中 base_address
表示陣列的起始地址,data_type_size
表示每個元素佔用的空間大小。
可想而知,同時具有“連續的記憶體空間”和“相同型別的資料”兩個限制的陣列結構,通過下標查詢陣列中的元素能達到 \(O(1)\) 的時間複雜度。
插入、刪除
同隨機儲存的高效不同,對一個陣列做插入、刪除操作是非常低效的。這裡的低效體現在插入和刪除元素之後,需要對陣列中的其他元素做搬移操作,以保證陣列元素的連續性。
在一個長度為 n 的陣列中,假如要在第 k 個位置插入一個元素,這不是修改元素的操作,不能直接替換掉第 k 個元素,而是需要依次將第 k 個及之後的元素都往後挪一位,然後才能在第 k 個位置上存入這個元素。
陣列刪除元素時和插入元素類似,為了避免刪除元素之後導致陣列中間出現空洞,需要將刪除位置之後的元素往前挪一位。
通過計算得知,插入、刪除的最好時間複雜度為 \(O(1)\)、最壞時間複雜度為 \(O(n)\),平均時間複雜度為 \(O(n)\)。
使用上的問題
陣列越界
陣列可以通過定址公式做到高效隨機訪問,但這並沒有限制使用超出陣列長度的下標訪問陣列,通常把訪問的陣列下標超出陣列長度的情況稱為陣列越界。
在 C 語言中,編譯器不會檢測出陣列越界的問題,如果出現陣列越界的情況而又沒處理的話,極可能出現如程式碼進入死迴圈等不可預知的情況。
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
和 arr
共佔據了 8 個位元組,表現為 {arr[0], arr[1], arr[2], i}
的形式,當迴圈到下標為 3
時,實際 arr[3]
指向的就是 i
所在地址,也就出現 arr[3] = i = 3
,出現死迴圈。
相比較下,使用 Java 會更加安全,Java 不會把檢查陣列越界的工作丟給程式設計師來做,其本身就會做越界檢查,如果出現錯誤則會丟擲 java.lang.ArrayIndexOutOfBoundsException
異常,而不是出現死迴圈。
容器和陣列的選擇
這裡說的容器指的是封裝了陣列的操作方法、並且支援動態擴容的容器類,比如 Java 中的 ArrayList
類。
與原生陣列相比,ArrayList
擁有非常大的優勢,但並不是所有地方都必須使用 ArrayList
而不使用陣列,在某些地方使用陣列更方便、效率更高。以下情況可以選擇原生陣列:
- 追求極致效能選擇原生陣列。Java 的
ArrayList
不支援儲存基本型別,而是儲存基本型別封裝後的物件,自動裝箱、拆箱會有一定的效能消耗 - 操作簡單,僅使用原生功能選擇原生陣列。雖然
ArrayList
提供了非常多額外的功能,但也額外增加了風險,使用原生陣列更簡單便捷
為什麼陣列從 0 開始編號
這個問題可以通過陣列的定址公式來回答。
首先需要重新定義陣列下標:下標不是隻陣列的第幾個元素,而是指陣列元素的偏移。
在使用時,使用 0
作為起始下標,陣列使用下述的表示式作為定址公式:
address[i] = base_address + data_type_size * i
如果使用 1
作為起始下標,將不能直接使用上面的定址公式,而是需要修改如下:
address[i] = base_address + data_type_size * (i - 1)
在對比前後兩個定址公式之後,可以發現,使用 1
作為起始下標的定址公式會比使用 0
作為起始下標的定址公式多一個簡單的減法指令。
對於非常底層的程式來說,即使只是多出一個簡單的減法指令,也是一種效能的損耗,為了做到極致優化,選擇 0
作為起始下標會更好。
當然還有一個歷史原因,C 語言使用了 0
作為陣列的起始下標,後續 Java、JavaScript 都紛紛仿效,這也算是為了統一,降低程式設計師的學習成本。