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

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

整理自極客時間-資料結構與演算法之美。原文內容更完整具體,且有音訊。購買地址:

1.什麼是陣列

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

這個定義裡有幾個關鍵詞,理解了這幾個關鍵詞,我想你就能徹底掌握陣列的概念了。

  1. 線性表(Linear List):,線性表就是資料排成像一條線一樣的結構。每個線性表上的資料最多隻有前和後兩個方向。其實除了陣列,連結串列、佇列、棧等也是線性表結構。

    而與它相對立的概念是非線性表,比如二叉樹、堆、圖等。之所以叫非線性,是因為,在非線性表中,資料之間並不是簡單的前後關係。
  2. 連續的記憶體空間和相同型別的資料
    。正是因為這兩個限制,它才有了一個堪稱“殺手鐗”的特性:“隨機訪問”。但有利就有弊,這兩個限制也讓陣列的很多操作變得非常低效,比如要想在陣列中刪除、插入一個數據,為了保證連續性,就需要做大量的資料搬移工作

    根據下標隨機訪問陣列元素如何實現的:
    拿一個長度為 10 的 int 型別的陣列 int[] a = new int[10] 來舉例。在這個圖中,計算機給陣列 a[10],分配了一塊連續記憶體空間 1000~1039,其中,記憶體塊的首地址為 base_address = 1000。

    計算機會給每個記憶體單元分配一個地址,計算機通過地址來訪問記憶體中的資料。當計算機需要隨機訪問陣列中的某個元素時,它會首先通過下面的定址公式,計算出該元素儲存的記憶體地址:
    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)。

2.陣列的特性

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

  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)。這個處理思想在快排中也會用到,我會在排序那一節具體來講,這裡就說到這兒。
     
  2. 刪除操作

    跟插入資料類似,如果我們要刪除第 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 標記清除垃圾回收演算法的核心思想嗎?沒錯,資料結構和演算法的魅力就在於此,很多時候我們並不是要去死記硬背某個資料結構或者演算法,而是要學習它背後的思想和處理技巧,這些東西才是最有價值的

3.警惕陣列的訪問越界問題

來分析一下這段 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;
}

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

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

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

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

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

int[] a = new int[3];
a[3] = 10;

4.解答開篇

從陣列儲存的記憶體模型上來看,“下標”最確切的定義應該是“偏移(offset)”。前面也講到,如果用 a 來表示陣列的首地址,a[0] 就是偏移為 0 的位置,也就是首地址,a[k] 就表示偏移 k 個 type_size 的位置,所以計算 a[k] 的記憶體地址只需要用這個公式:
 

a[k]_address = base_address + k * type_size
但是,如果陣列從 1 開始計數,那我們計算陣列元素 a[k] 的記憶體地址就會變為:
a[k]_address = base_address + (k-1)*type_size
陣列作為非常基礎的資料結構,通過下標隨機訪問陣列元素又是其非常基礎的程式設計操作,效率的優化就要儘可能做到極致。所以為了減少一次減法操作,陣列選擇了從 0 開始編號,而不是從 1 開始。對比兩個公式,我們不難發現,從 1 開始編號,每次隨機訪問陣列元素都多了一次減法運算,對於 CPU 來說,就是多了一次減法指令。

不過我認為,上面解釋得再多其實都算不上壓倒性的證明,說陣列起始編號非 0 開始不可。所以我覺得最主要的原因可能是歷史原因。

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