1. 程式人生 > >陣列:為什麼很多程式語言中陣列都從0開始編號?

陣列:為什麼很多程式語言中陣列都從0開始編號?

提到陣列,我想你肯定不陌生,甚至還會自信地說,它很簡單啊。

是的,在每一種程式語言中,基本都會有陣列這種資料型別。不過,它不僅僅是一種程式語言中的資料型別,還是一種最基礎的資料結構。儘管陣列看起來非常基礎、簡單,但是我估計很多人都並沒有理解這個基礎資料結構的精髓。

在大部分程式語言中,陣列都是從 0 開始編號的,但你是否下意識地想過,為什麼陣列要從 0 開始編號,而不是從 1 開始呢? 從 1 開始不是更符合人類的思維習慣嗎?

你可以帶著這個問題來學習接下來的內容。

如何實現隨機訪問?

什麼是陣列?

我估計你心中已經有了答案。不過,我還是想用專業的話來給你做下解釋。陣列(Array)是一種線性表資料結構。它用一組連續的記憶體空間,來儲存一組具有相同型別的資料。

這個定義裡有幾個關鍵詞,理解了這幾個關鍵詞,我想你就能徹底掌握陣列的概念了。下面就從我的角度分別給你“點撥”一下。+第一是線性表(Linear+List)。顧名思義,線性表就是資料排成像一條線一樣的結構。每個線性表上的資料最多隻有前和後兩個方向。其實除了陣列,連結串列、佇列、棧等也是線性表結構。

而與它相對立的概念是非線性表,比如二叉樹、堆、圖等。之所以叫非線性,是因為,在非線性表中,資料之間並不是簡單的前後關係。

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

說到資料的訪問,那你知道陣列是如何實現根據下標隨機訪問陣列元素的嗎?

我們拿一個長度為 10 的 int 型別的陣列 int[ ] a = new int[10 ] 來舉例。在我畫的這個圖中,計算機給陣列 a[10],分配了一塊連續記憶體空間 1000~1039,其中,記憶體塊的首地址為 base_address = 1000。

 

我們知道,計算機會給每個記憶體單元分配一個地址,計算機通過地址來訪問記憶體中的資料。當計算機需要隨機訪問陣列中的某個元素時,它會首先通過下面的定址公式,計算出該元素儲存的記憶體地址:

1

a[i]_address = base_address

+ i * data_type_size

其中 data_type_size 表示陣列中每個元素的大小。我們舉的這個例子裡,陣列中儲存的是+int+型別資料,所以 data_type_size 就為 4 個位元組。這個公式非常簡單,我就不多做解釋了。

這裡我要特別糾正一個“錯誤”。我在面試的時候,常常會問陣列和連結串列的區別,很多人都回答說,“連結串列適合插入、刪除,時間複雜度 O(1);陣列適合查詢,查詢時間複雜度為 O(1)”。

實際上,這種表述是不準確的。陣列是適合查詢操作,但是查詢的時間複雜度並不為 O(1)。即便是排好序的陣列,你用二分查詢,時間複雜度也是 O(logn)。所以,正確的表述應該是,陣列支援隨機訪問,根據下標隨機訪問的時間複雜度為 O(1)。

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

前面概念部分我們提到,陣列為了保持記憶體資料的連續性,會導致插入、刪除這兩個操作比較低效。現在我們就來詳細說一下,究竟為什麼會導致低效?又有哪些改進方法呢?

我們先來看插入操作。

假設陣列的長度為 n,現在,如果我們需要將一個數據插入到陣列中的第 k 個位置。為了把第 k 個位置騰出來,給新來的資料,我們需要將第 k~n 這部分的元素都順序地往後挪一位。那插入操作的時間複雜度是多少呢?你可以自己先試著分析一下。

如果在陣列的末尾插入元素,那就不需要移動資料了,這時的時間複雜度為 O(1)。但如果在陣列的開頭插入元素,那所有的資料都需要依次往後移動一位,所以最壞時間複雜度是 O(n)。因為我們在每個位置插入元素的概率是一樣的,所以平均情況時間複雜度為 (1+2+…n)/n=O(n)。

如果陣列中的資料是有序的,我們在某個位置插入一個新的元素時,就必須按照剛才的方法搬移+k+之後的資料。但是,如果陣列中儲存的資料並沒有任何規律,陣列只是被當作一個儲存資料的集合。在這種情況下,如果要將某個陣列插入到第 k 個位置,為了避免大規模的資料搬移,我們還有一個簡單的辦法就是,直接將第 k 位的資料搬移到陣列元素的最後,把新的元素直接放入第 k 個位置。

為了更好地理解,我們舉一個例子。假設陣列 a[10]中儲存瞭如下 5 個元素:a,b,c,d,e。

我們現在需要將元素 x 插入到第 3 個位置。我們只需要將 c 放入到 a[5],將 a[2]賦值為 x 即可。最後,陣列中的元素如下: a,b,x,d,e,c。

利用這種處理技巧,在特定場景下,在第 k 個位置插入一個元素的時間複雜度就會降為 O(1)。這個處理思想在快排中也會用到,我會在排序那一節具體來講,這裡就說到這兒。

我們再來看刪除操作

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

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

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

我們繼續來看例子。陣列 a[10]中儲存了 8 個元素:a,b,c,d,e,f,g,h。現在,我們要依次刪除 a,b,c 三個元素。

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

如果你瞭解 JVM,你會發現,這不就是 JVM 標記清除垃圾回收演算法的核心思想嗎?沒錯,資料結構和演算法的魅力就在於此,很多時候我們並不是要去死記硬背某個資料結構或者演算法,而是要學習它背後的思想和處理技巧,這些東西才是最有價值的。如果你細心留意,不管是在軟體開發還是架構設計中,總能找到某些演算法和資料結構的影子。

警惕陣列的訪問越界問題

瞭解了陣列的幾個基本操作後,我們來聊聊陣列訪問越界的問題。

首先,我請你來分析一下這段+C+語言程式碼的執行結果:

1

2

3

4

5

6

7

8

9

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;

}

你發現問題了嗎?這段程式碼的執行結果並非是列印三行“hello+word”,而是會無限列印“hello+world”,這是為什麼呢?

因為,陣列大小為 3,a[0],a[1],a[2],而我們的程式碼因為書寫錯誤,導致 for 迴圈的結束條件錯寫為了 i<=3 而非 i<3,所以當 i=3 時,陣列 a[3] 訪問越界。

我們知道,在 C 語言中,只要不是訪問受限的記憶體,所有的記憶體空間都是可以自由訪問的。根據我們前面講的陣列定址公式,a[3] 也會被定位到某塊不屬於陣列的記憶體地址上,而這個地址正好是儲存變數 i 的記憶體地址,那麼 a[3] 就相當於 i=0,所以就會導致程式碼無限迴圈。

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

這種情況下,一般都會出現莫名其妙的邏輯錯誤,就像我們剛剛舉的那個例子,debug 的難度非常的大。而且,很多計算機病毒也正是利用到了程式碼中的陣列越界可以訪問非法地址的漏洞,來攻擊系統,所以寫程式碼的時候一定要警惕陣列越界。

但並非所有的語言都像 C 一樣,把陣列越界檢查的工作丟給程式設計師來做,像 Java 本身就會做越界檢查,比如下面這幾行 java 程式碼,就會丟擲 java.lang.ArrayIndexOutOfBoundsException。 

1

2

int[] a = new int[3];

a[3] = 10;

容器能否完全替代陣列?

針對陣列型別,很多語言都提供了容器類,比如 Java 中的 ArrayList、C++ 中的 vector。在專案開發中,什麼時候適合用陣列,什麼時候適合用容器呢?

這裡我拿 Java 語言來舉例。如果你是 Java 工程師,幾乎天天都在用 ArrayList,對它應該非常熟悉。那它與陣列相比,到底有哪些優勢呢?

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

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

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

不過,這裡需要注意一點,因為擴容操作涉及記憶體申請和資料搬移,是比較耗時的。所以,如果事先能確定需要儲存的資料大小,最好在建立 ArrayList 的時候事先指定資料大小。+比如我們要從資料庫中取出 10000 條資料放入 ArrayList。我們看下面這幾行程式碼,你會發現,相比之下,事先指定資料大小可以省掉很多次記憶體申請和資料搬移操作。

1

2

3

4

ArrayList<User> users = new ArrayList(10000);

for (int i = 0; i < 10000; ++i) {

users.add(xxx);

}

作為高階語言程式設計者,是不是陣列就無用武之地了呢?當然不是,有些時候,用陣列會更合適些,我總結了幾點自己的經驗。

1.Java+ArrayList 無法儲存基本型別,比如 int、long,需要封裝為 Integer、Long 類,而 Autoboxing、Unboxing 則有一定的效能消耗,所以如果特別關注效能,或者希望使用基本型別,就可以選用陣列。

2. 如果資料大小事先已知,並且對資料的操作非常簡單,用不到 ArrayList 提供的大部分方法,也可以直接使用陣列。

3. 還有一個是我個人的喜好,當要表示多維陣列時,用陣列往往會更加直觀。比如 Object[][] array;而用容器的話則需要這樣定義:ArrayList<ArrayList >+array。

我總結一下,對於業務開發,直接使用容器就足夠了,省時省力。畢竟損耗一丟丟效能,完全不會影響到系統整體的效能。但如果你是做一些非常底層的開發,比如開發網路框架,效能的優化需要做到極致,這個時候陣列就會優於容器,成為首選。

解答開篇

現在我們來思考開篇的問題:為什麼大多數程式語言中,陣列要從 0 開始編號,而不是從 1 開始呢?+從陣列儲存的記憶體模型上來看,“下標”最確切的定義應該是“偏移(offset)”。前面也講到,如果用 a 來表示陣列的首地址,a[0] 就是偏移為 0 的位置,也就是首地址,a[k] 就表示偏移 k 個 type_size 的位置,所以計算 a[k] 的記憶體地址只需要用這個公式:

1

a[k]_address = base_address + k * type_size

但是,如果陣列從+1+開始計數,那我們計算陣列元素+a%5Bk%5D+的記憶體地址就會變為:

1

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

對比兩個公式,我們不難發現,從 1 開始編號,每次隨機訪問陣列元素都多了一次減法運算,對於 CPU 來說,就是多了一次減法指令。

陣列作為非常基礎的資料結構,通過下標隨機訪問陣列元素又是其非常基礎的程式設計操作,效率的優化就要儘可能做到極致。所以為了減少一次減法操作,陣列選擇了從 0 開始編號,而不是從 1 開始。

不過我認為,上面解釋得再多其實都算不上壓倒性的證明,說陣列起始編號非 0 開始不可。所以我覺得最主要的原因可能是歷史原因。 C 語言設計者用 0 開始計數陣列下標,之後的 Java、JavaScript 等高階語言都效仿了 C 語言,或者說,為了在一定程度上減少 C 語言程式設計師學習 Java 的學習成本,因此繼續沿用了從 0 開始計數的習慣。實際上,很多語言中陣列也並不是從 0 開始計數的,比如 Matlab。甚至還有一些語言支援負數下標,比如 Python。

內容小結

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

課後思考

前面我基於陣列的原理引出 JVM 的標記清除垃圾回收演算法的核心理念。我不知道你是否使用 Java 語言,理解 JVM,如果你熟悉,可以在評論區回顧下你理解的標記清除垃圾回收演算法。 前面我們講到一維陣列的記憶體定址公式,那你可以思考一下,類比一下,二維陣列的記憶體定址公式是怎樣的呢?