1. 程式人生 > 程式設計 >資料結構之陣列

資料結構之陣列

什麼是陣列

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

上面陣列的概念涉及兩個關鍵點:

  1. 線性表.

顧名思義,線性表就是資料排成像一條線一樣的結構。每個線性表上的資料最多隻有前和後兩個方向。 除了陣列,連結串列,佇列,棧等也是線性結構.

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

  1. 連續的記憶體空間儲存相同型別的資料

正是因為有了上面兩個限制,所以才有陣列的一個堪稱"殺手鐗"的特性:"隨機訪問"

但也正因為這兩個限制,讓陣列的插入和刪除操作需要做大量的資料搬移工作

陣列如何實現隨機訪問

舉例: 一個長度為10的int型別陣列int[] a = new int[10],我們知道int型別會佔用四個位元組,假設計算機給陣列分配的連續記憶體空間為1000~1039,首地址base_address為1000

計算機是通過地址來訪問記憶體中的資料,當需要隨機訪問陣列中的某個元素時,可以通過下面公式得到該元素的記憶體地址:

a[i]_address = base_address + i * data_type_size

其中 data_type_size 表示陣列中每個元素的大小

為什麼陣列下標是從0開始編號而不是從1開始?

從陣列儲存的記憶體模型來看,"下標"最確切的定義應該是"偏移(offset)".前面說到計算a[i]的地址的公式為

a[i]_address = base_address + i * data_type_size

如果從1開始編號的話,那麼計算a[i]的地址的公式為

a[i]_address = base_address + (i-1) * data_type_size

對比不難發現,從1開始編號的話,每次隨機訪問陣列資料時就多了一次減法運算,對CPU來說就多了一個減法指令

二維陣列如何定址?

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

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

低效的插入和刪除操作

插入操作

  1. 如果在陣列末尾插入元素,則不需要行動資料,時間複雜度為O(1)
  2. 如果在陣列頭部插入元素,那麼所有的資料都需要向後移動一位
  3. 在每個位置插入資料的概率是一樣的,那麼平均情況的時間複雜度為(1+2+...n)/2=O(n)

如果陣列儲存的資料沒有規律,陣列只是一個儲存元素的集合,那麼如果要在資料的第k個位置插入資料的話,為了避免大規模行動資料,可以把第k 位的資料搬移到陣列元素的最後,把新的元素直接放入第 k 個位置。

這種方法就能把插入元素的時間複雜度降為O(1)

刪除操作

  1. 如果刪除陣列末尾的元素,時間複雜度為O(1)
  2. 如果刪除陣列頭部的元素,那麼所有的資料都需要向前移動一位
  3. 在每個位置刪除資料的概率是一樣的,那麼平均情況的時間複雜度為(1+2+...n)/2=O(n)

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

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

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

這種思想可以類比一下JVM的標記清除垃圾回收演演算法.

陣列越界問題

下面一段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;
}
複製程式碼

是輸出三次hello world嗎?非也,而是迴圈列印hello world,為什麼呢?

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

  1. 這段如果是java程式碼的話,會丟擲異常java.lang.ArrayIndexOutOfBoundsException。
  2. 在 C 語言中,只要不是訪問受限的記憶體,所有的記憶體空間都是可以自由訪問的。根據我們前面講的陣列定址公式,a[3] 也會被定位到某塊不屬於陣列的記憶體地址上,而這個地址正好是儲存變數 i 的記憶體地址,那麼 a[3]=0 就相當於 i=0,所以就會導致程式碼無限迴圈。
  3. 陣列越界在 C 語言中是一種未決行為,並沒有規定陣列訪問越界時編譯器應該如何處理。因為,訪問陣列的本質就是訪問一段連續記憶體,只要陣列通過偏移計算得到的記憶體地址是可用的,那麼程式就可能不會報任何錯誤。

Q:那麼為什麼a[3] 剛好就是i的記憶體地址呢???

A:這個跟編譯器分配記憶體和位元組對齊有關

  1. 函式體內的區域性變數存在棧上,且是連續壓棧。

  2. 在Linux程式的記憶體佈局中,棧區在高地址空間,從高向低增長。

  3. 變數i和arr在相鄰地址,且i比arr的地址大,所以arr越界正好訪問到i。當然,前提是i和arr元素同型別,否則那段程式碼仍是未決行為。

  4. 陣列3個元素 加上一個變數a 。4個整數剛好能滿足8位元組對齊 所以i的地址恰好跟著a2後面 導致死迴圈。

  5. 如果陣列本身有4個元素 則這裡不會出現死迴圈。因為編譯器64位作業系統下 預設會進行8位元組對齊 變數i的地址就不緊跟著陣列後面了。

何時使用陣列何時使用容器?

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

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

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

    一個小誤區

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

    實際上,這種表述是不準確的。陣列是適合查詢操作,但是查詢的時間複雜度並不為 O(1)。即便是排好序的陣列,你用二分查詢,時間複雜度也是 O(logn)。

    所以,正確的表述應該是,陣列支援隨機訪問,根據下標隨機訪問的時間複雜度為 O(1)。