【數據結構與算法】->數據結構->數組

Ⅰ 前言

提到數組,大家想必都很熟悉,因爲這是編程早期最先學的一個數據結構。大家可能會覺得十分簡單,但是就是因爲簡單,可能很多人都沒有掌握數組的精髓。

事實上,在每一種編程語言中,基本都會有數組這種數據類型。

比如在Pascal中,INTEGER a(-3..4) 定義了一個下標爲-3到4,數據類型爲INTEGER的數組。

在VB中,dim ar(10) as integer 定義了一個元素類型爲integer,大小爲10的數組。

數組不僅僅是一種編程語言中的數據類型,還是一種最基礎的數據結構。

Ⅱ 數組的定位

數組(Array)是一種線性表數據結構,它用一組連續的內存空間,來存儲一組具有相同類型的數據。

這個定義中涉及到了兩個關鍵的知識點,一個是線性表,一個是連續的內存空間和相同類型的數據

A. 線性表

線性表(Linear List)顧名思義就是數據排成像一條線一樣的結構,每個線性表上的數據最多隻有前和後兩個方向。這個線性可以是邏輯上的,也可以是物理上的,所以除了數組,鏈表、隊列和棧都屬於線性表結構。

關於線性表的內容大家可以看我下面這篇文章👇
【C語言->數據結構與算法】->線性表->線性表工具庫的創建

那麼與線性表相對立的概念就是非線性表了。比如二叉樹、堆、圖等。在非線性表中,數據之間並不是簡單的前後關係。

B. 連續存儲空間 & 相同數據類型

數組中至關重要的兩個特性就是連續的內存空間和相同類型的數據。正是因爲這個特性,我們才能完成數組最精妙的功能——隨機訪問

但也正是這兩個特性,或者說是限制,使得數組的一些操作十分低效。比如要想在數組中刪除或者插入一個數據,爲了保證其連續性,需要做大量的數據搬移工作。

下面我們來看看數組的插入和刪除操作。

a. 插入操作

假設數組長度爲 n ,現在,如果我們需要將一個數據插入到數組中的第 x 個位置。爲了將第 x 個位置騰出來,給新要插入的數據,我們需要將第 x~n 這部分的元素都順序地往後挪一位。

如果在數組的末尾插入元素,那就不需要搬移數據,這時的時間複雜度就是 O(1) 。如果在數組的開頭插入元素,那就需要將所有數據都向後挪一位,所以最壞情況時間複雜度爲 O(n) 。因爲我們在每個位置插入元素的概率相同,所以平均時間複雜度爲 (1 + 2 + … + n) / n = O(n) 。

當然,這是在數組的數據都是有序的情況下,我們插入一個新的元素就需要按照上面的方法搬移 x 後的所有數據。那如果整個數組中存儲的數據都沒有任何規律,數組只是被當作一個存儲數據的集合呢?

這時候我們要插入一個數據到 x 的位置,直接將第 x 位的數據搬移到數組元素的末尾,把新的元素直接放入第 x 個位置就可以了。

比如有一個數組,存儲着 1,2,3,4,5,6 這六個元素。
如果要將100插入到下標爲2的地方,只需要將3移動到6的後面的位置,再做插入。
所以插入之後的元素就變成了:1,2,100,4,5,6,3

運用這個方法,在某些情況下,在數組中插入元素的時間複雜度就變成了 O(1) 。這個思想也是快速排序的思想,這個在我的其他文章中會講到。

b. 刪除操作

和插入操作的思想是一樣的,要刪除第 x 個位置的數據,爲了內存的連續,需要將 x 後的數據向前移一位,不然就會出現空洞。時間複雜度和插入是一樣的。

實際上在有些特殊場景下,我們並不一定非要追求數組中數據的連貫性,這時我們要怎麼進行刪除操作呢?

答案就是集中操作。我們將多次刪除操作集中在一起執行,這樣就大大提高了刪除的效率。

比如我們定義了一個數組如下

char arr[10] = {a, b, c, d, e, f, g, h};

我定義了一個大小爲10的數組,現在裏面存放了8個元素。現在,我們要依次刪除a, b, c三個元素。

爲了避免d, e, f, g, h這幾個數據被搬移三次,我們可以先記錄下已經刪除的數據,每次刪除操作並不真正刪除它,而是把它們由未刪除的狀態記錄爲已經被刪除了。

到最後如果數組已經沒有空間了,我們再觸發一次真正的刪除操作,這樣就能大大地減少刪除操作導致的數據搬移。

如果你對JVM有了解,就會發現,這個就是JVM標記清除垃圾回收算法的核心思想。

所以學習數據結構與算法,並不是要去死記硬背有些數據結構或者算法,而是學習它們背後的思想和處理技巧,這纔是最重要的。

c. 隨機訪問

講完了數組的這兩個特性帶來的弊端,接下來就要說其帶來的好處了,就是隨機訪問的實現。這是怎麼做到的呢?

我用一個長度爲10的int類型的數組來舉例子。

int arr[10];

這個定義實際上就是向內存申請了一段連續存儲空間1000~1039👇
在這裏插入圖片描述
其中,這個數組的首地址head_address = 1000。

數組的下標就是偏移量,根據偏移量計算機就會進行尋址。

arr[i]_address = head_address + i * data_size

這就是尋址公式。

當然,這只是一維數組,我們可以再拓寬一下,想想二維、三維甚至N維數組的尋址公式。

首先要明晰一個概念,內存上的存儲方式都是連續的,只有一維,所以所謂的二維、三維數組都是邏輯上的概念,它們本質上都是一維的。

關於計算機中的存儲可以看我下面的文章👇
【C語言基礎】->內存對齊模式->爲什麼我的結構體大小我猜不透

有了這個概念,我們就容易下手了。首先來看二維數組。

根據一維數組的尋址公式,我們可以看到,只需要知道偏移量,就可以根據首地址和元素的數據類型進行尋址了。所謂偏移量,其實就是要尋找的元素的前面,還有多少個元素。

假設有一個二維數組,定義爲 int arr[s1...e1, s2...e2],只是舉個例子,類型不重要。這就相當於定義了一個行座標從s1到e1,列座標從s2到e2的一個二維數組。我們要訪問arr[i1][i2]

這裏我再定義一個普通的二維數組作爲推導的基礎👇

int arr[4][3] = {
	1, 2, 3,
	4, 5, 6,
	7, 8, 9,
	10, 11, 12
};

由此我定義了一個四行三列的數組。這時候我如果要找arr[2][2],它的偏移量,即前面有多少個元素呢?

可以知道,第三行第三列的元素是9,它前面一共有8個元素,有兩行,每行有三個元素,即(2*3 + 2)個。

所以可以推導出,二維數組的尋址公式如下👇

arr[i1][i2]_address = head_address +[ i1 * (e2-s2+1) + (i2-s2)] * data_size

大家可以再自己推導一下。

三維數組大家可以想象一個立方體,然後可以自己推導一下偏移量。我直接給出公式。👇

arr[i1][i2][i3]_address = head_address + (i1-s1) * [(e2-s2+1) * (e3-s3+1) + (i2-s2) * (e3-s3+1) + (i3-s3)] * data_size

後面的維數計算偏移量都很複雜了,我在此就不多贅述了。

大家已經明白了數組尋址的機制,所以更要小心一個錯誤:下標越界
計算機會根據下標來計算地址,所以一般情況下標越界後計算機仍然能得到一個有效的地址,所以仍然會進行訪問。

有的同學可能經歷過,寫完程序一編譯,出現了“燙燙燙……”等字樣,或者各種亂碼,這時候就可能是下標越界了。

在C語言中,數組越界編譯器是發現不出錯誤的,有的時候程序會莫名崩潰或者出現很多邏輯錯誤,所以我們一定要警惕這個錯誤。

在Java中,雖然編譯器能查找到這種錯誤,但我們還是要養成一個良好的習慣。

Ⅲ 數組下標爲什麼要從0開始?

在尋址公式的推導過程中,我說過,下標 i 其實就是一個偏移量。根據偏移量我們就可以進行尋址。
所以一個數組中,arr[0]就代表着數組的首地址,此時偏移量爲0;arr[1]代表着偏移量爲1,即第二個元素。

如果下標從1開始,那麼尋址公式就變成了

arr[i]_address = head_address + (i-1) * data_size

每次計算機的尋址都多了一次減法運算,對於CPU而言,就是多了一次減法指令。數組作爲非常基礎的數據結構,通過下標進行隨機訪問又是極其基礎的操作,所以效率的優化要做到極致。爲了減少每次的一次減法操作,下標就從0開始了。

除了這個還有個解釋是歷史原因。因爲C語言的設計者用0開始計數數組下標,所以之後的Java、JavaScript等高級語言都效仿了C語言,這樣也是降低了C語言程序員學習Java的學習成本。

但是還是有語言不是從0開始計數的,比如我最開始舉的例子,還有Matlab,甚至Python還可以從負數開始。

Ⅳ 容器能否完全代替數組?

針對數組類型,很多語言都提供了容器類。比如Java中的ArrayList、C++ STL中的vector。那麼,在項目開發中,什麼時候適合用數組,什麼時候用容器呢?

這裏我用Java舉例。

ArrayList的最大優勢就是可以將很多數組操作的細節封裝起來,比如數組插入、刪除數據時需要搬移其它數據等,另外它還有個優勢,就是支持動態擴容

數組在定義時需要預先指定大小,因爲計算機要給它分配連續的存儲空間。所以當數據量超出時,比如定義了大小爲10的數組,結果出現了第11個元素,我們就需要重新申請一個更大的數組,並將之前的數組的數據複製進新的數組。

如果使用ArrayList就不需要擔心這個問題,每次存儲空間不夠時,它都會將空間自動擴容爲1.5倍大小。但是這個操作仍是耗時的,所以儘可能還是創建ArrayList時就事先指定數據大小。

數組與此同時就顯得捉襟見肘了,那是不是說明數組就沒有用了呢?

當然不是。以下三點作爲參考。

  1. Java ArrayList無法存儲基本類型,比如intlong,需要封裝爲IntegerLong類,而Autoboxing、Unboxing則有一定的性能消耗,所以如果特別關注性能,或者使用基本類型,就可以使用數組。

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

  3. 要表示多維數據時,用數組會更加直觀,這時候就可以選擇用數組。

所以對於業務開發,直接使用容器就夠了,很方便。損耗性能並不會影響到系統整體性能。但如果做的是底層的開發,比如開發網絡框架,性能的優化需要做到極致,這時候數組就是優於容器的選擇。

最後的最後還有個需要注意的地方。

有的面試官會問你數組和鏈表的區別,很多人都會回答,“鏈表適合插入、刪除,時間複雜度爲 O(1);數組適合查找,查找時間複雜度爲 O(1)。”

實際上這種表述是不準確的。

數組是適合查找操作,但是時間複雜度不爲 O(1),即便是排好序的數組,用二分法查找時間複雜度也是 O(logn)。所以正確的表述應該是:數組支持隨機訪問,根據下標隨機訪問的時間複雜度爲 O(1)。

另:此篇文章的部分知識點來源於極客時間,王爭的《數據結構與算法之美》

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