數據結構與算法之美 - 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\")
	}
}

你發現問題了嗎?這段代碼的運行結果並非是打印三行”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.ArraylndexOutOfBoundsException。

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 1=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] 的內存地址只需要用這個公式:

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)。在平時的業務開發中,我們可以直接使用編程語言提供的容器類,但是,如果是特別底層的開發,直接使用數組可能會更合適。

課後思考

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

  • 前面我們講到一維數組的內存尋址公式,那你可以思考一下,類比一下,二維數組的內存尋址公式是怎樣的呢?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章