理解數組
數組本質上是一種 線性表 數據結構,它用一組 連續的內存空間,來存儲一組具有 相同類型的數據。
線性表
如上圖所示,線性表就是數據排成一條線一樣的結構,在線性表中,每個數據都只有前后兩個方向。
與線性表相對的是非線性表結構,在非線性表中,每個數據會存在多個方向,數據之間不僅僅只是簡單的前后關系,而是呈現發散型的關系。
連續的內存空間
在數組中,存儲數據的內存空間是連續的。
也就是說,當一段內存空間標明用於存儲數組元素,則這一片空間只會存儲數組元素,不會再拆分出來存儲其他的數據。
相同類型的數據
在數組的原始定義中,數組只能存儲相同類型的數據,這樣可以保證數組中每個元素占用的內存空間都能保持一致。
數組的增刪查改
討論數組的增刪查改,從效率的角度上區分,主要可以分為兩類:高效存取、低效增刪。
高效存取
這里的“存取”指的是通過下標訪問數組元素,然后對這個元素做存取操作。
數組能做到高效存儲的根本原因在於,其擁有“連續的內存空間”和“相同類型的數據”這兩個限制。正因為這兩個限制,計算機可以通過一個尋址公式來尋找數組元素的地址,如下是尋址公式的簡單理解:
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)\)。
特殊數組
二維數組
二維數組指的是以數組作為數組元素的數組,即“數組的數組”,又被稱為矩陣。
從存儲結構上看,將普通的一維數組看作是一個如同直線的線性表,二維數組就可以看作是多個線性表並排的平面。
稀疏數組
在數組中,若為 0 的元素數目遠遠多於非 0 元素,並且非 0 元素分布沒有規律時,則可以用稀疏數組保存該數組的元素。
對於二維數組,行和列可以作為元素的坐標,通過坐標可以確定一個元素的位置,稀疏數組就是通過存儲坐標和元素值以保證重新轉換回二維數組。
稀疏數組通常是用作減少存儲空間浪費,壓縮數組規模,只保存有用數據。這是一個典型的時間換空間的應用。
常見問題
數組越界
雖然數組可以通過尋址公式做到高效隨機訪問,但是這並沒有限制使用超出數組長度的下標訪問數組,我們通常把訪問的數組下標超出數組長度的情況稱為數組越界。
在 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 開始編號可以通過數組的尋址公式來回答。
在 C 語言中,數組的下標不是指數組的第幾個元素,而是指數組元素的偏移。
如果使用 0
作為數組的起始下標,則可以使用下述的表達式作為尋址公式:
address[i] = base_address + data_type_size * i
如果使用 1
作為數組的起始下標,將不能直接使用上面的尋址公式,而是需要修改如下:
address[i] = base_address + data_type_size * (i - 1)
在對比前后兩個尋址公式之后,使用 1
作為起始下標的尋址公式會比使用 0
作為起始下標的尋址公式多一個簡單的減法指令。
對於非常底層的程序來說,即使只是多出一個簡單的減法指令,也是一種性能的損耗,為了做到極致優化,選擇 0
作為起始下標會更好。
當然還有一個歷史原因,C 語言使用了 0
作為數組的起始下標,后續出現的編程語言都紛紛仿效,這也算是為了統一,降低程序員的學習成本。