1. 程式人生 > >05-陣列:為什麼陣列元素從0開始編號

05-陣列:為什麼陣列元素從0開始編號

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

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

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

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

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

這個定義裡有幾個關鍵詞,理解了這幾個關鍵詞,我想你就能徹底掌握陣列的概念了。下面就從我的角度分別給你“點撥”一下。

第一是線性表(Linear List)。顧名思義,線性表就是資料排成像一條線一樣的結構。每個線性表上的資料最多隻有前和後兩個方向。其實除了陣列,連結串列、佇列、棧等也是線性表結構。 在這裡插入圖片描述

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

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

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

我們拿一個長度為 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)。

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

我們先來看插入操作。

假設陣列的長度為 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 語言程式碼的執行結果:

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]=0 就相當於 i=0,所以就會導致程式碼無限迴圈。

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

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

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

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

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

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

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

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

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

不過,這裡需要注意一點,因為擴容操作涉及記憶體申請和資料搬移,是比較耗時的。所以,如果事先能確定需要儲存的資料大小,最好在建立 ArrayList的時候事先指定資料大小。

比如我們要從資料庫中取出 10000 條資料放入 ArrayList。我們看下面這幾行程式碼,你會發現,相比之下,事先指定資料大小可以省掉很多次記憶體申請和資料搬移操作。

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 則有一定的效能消耗,所以如果特別關注效能,或者希望使用基本型別,就可以選用陣列。

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

  2. 還有一個是我個人的喜好,當要表示多維陣列時,用陣列往往會更加直觀。比如 Object[][] array;而用容器的話則需要這樣定義:ArrayList<ArrayList > array。 我總結一下,對於業務開發,直接使用容器就足夠了,省時省力。畢竟損耗一丟丟效能,完全不會影響到系統整體的效能。但如果你是做一些非常底層的開發,比如開發網路框架,效能的優化需要做到極致,這個時候陣列就會優於容器,成為首選。

解答開篇 現在我們來思考開篇的問題:為什麼大多數程式語言中,陣列要從 0 開始編號,而不是從 1 開始呢?

從陣列儲存的記憶體模型上來看,“下標”最確切的定義應該是“偏移(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 對比兩個公式,我們不難發現,從 1 開始編號,每次隨機訪問陣列元素都多了一次減法運算,對於 CPU 來說,就是多了一次減法指令

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

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

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

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

課後思考?

1.前面我基於陣列的原理引出 JVM 的標記清除垃圾回收演算法的核心理念。我不知道你是否使用 Java 語言,理解 JVM,如果你熟悉,可以在評論區回顧下你理解的標記清除垃圾回收演算法。

2.前面我們講到一維陣列的記憶體定址公式,那你可以思考一下,類比一下,二維陣列的記憶體定址公式是怎樣的呢?

一、總結

文章結構: 陣列看起來簡單基礎,但是沒有理解這個資料結構的精髓。帶著為什麼陣列要從0開始編號,而不是從1開始的問題,進入主題。

  1. 陣列如何實現隨機訪問 1) 陣列是一種線性資料結構,用連續的儲存空間儲存相同型別資料 I) 線性表:陣列、連結串列、佇列、棧 非線性表:樹 圖 II) 連續的記憶體空間、相同的資料,所以陣列可以隨機訪問,但對陣列進行刪除插入,為了保證陣列的連續性,就要做大量的資料搬移工作 a) 陣列如何實現下標隨機訪問。 引入陣列再記憶體種的分配圖,得出定址公式 b) 糾正陣列和連結串列的錯誤認識。陣列的查詢操作時間複雜度並不是O(1)。即便是排好的陣列,用二分查詢,時間複雜度也是O(logn)。 正確表述:陣列支援隨機訪問,根據下標隨機訪問的時間複雜度為O(1)
  2. 低效的插入和刪除 1) 插入:從最好O(1) 最壞O(n) 平均O(n) 2) 插入:陣列若無序,插入新的元素時,可以將第K個位置元素移動到陣列末尾,把心的元素,插入到第k個位置,此處複雜度為O(1)。作者舉例說明 3) 刪除:從最好O(1) 最壞O(n) 平均O(n) 4) 多次刪除集中在一起,提高刪除效率 記錄下已經被刪除的資料,每次的刪除操作並不是搬移資料,只是記錄資料已經被刪除,當陣列沒有更多的儲存空間時,再觸發一次真正的刪除操作。即JVM標記清除垃圾回收演算法。
  3. 警惕陣列的訪問越界問題 用C語言迴圈越界訪問的例子說明訪問越界的bug。此例在《C陷阱與缺陷》出現過,很慚愧,看過但是現在也只有一丟丟印象。翻了下書,替作者加上一句話:如果用來編譯這段程式的編譯器按照記憶體地址遞減的方式給變數分配記憶體,那麼記憶體中的i將會被置為0,則為死迴圈永遠出不去。
  4. 容器能否完全替代陣列 相比於數字,java中的ArrayList封裝了陣列的很多操作,並支援動態擴容。一旦超過村塾容量,擴容時比較耗記憶體,因為涉及到記憶體申請和資料搬移。 陣列適合的場景: 1) Java ArrayList 的使用涉及裝箱拆箱,有一定的效能損耗,如果特別管柱效能,可以考慮陣列 2) 若資料大小事先已知,並且涉及的資料操作非常簡單,可以使用陣列 3) 表示多維陣列時,陣列往往更加直觀。 4) 業務開發容器即可,底層開發,如網路框架,效能優化。選擇陣列。
  5. 解答開篇問題 1) 從偏移角度理解a[0] 0為偏移量,如果從1計數,會多出K-1。增加cpu負擔。為什麼迴圈要寫成for(int i = 0;i<3;i++) 而不是for(int i = 0 ;i<=2;i++)。第一個直接就可以算出3-0 = 3 有三個資料,而後者 2-0+1個數據,多出1個加法運算,很惱火。 2) 也有一定的歷史原因

二、書籍推薦

1.入門級趣味書: 《大話資料結構》、《演算法圖解》、《資料結構和演算法分析》 2.面試必刷寶典: 《劍指offer》、《程式設計之美》、《程式設計珠璣》(有難度) 3.經典: 《計算機程式設計藝術》、 4.閒暇閱讀: 《演算法帝國》、《數學之美》、《演算法之美》

三、課程資料結構與演算法設計程式碼實現: