數據結構與算法(二):數組

注:我們先由簡到難總結一下常用的數據結構,如簡單數組、鏈表、散列表、隊列、棧、樹、圖等等,最後再來研討算法。

一、線性表

線性表是很基本的一種數據結構,就如字面意思一樣,它把若干數據線性組合在一起:每個元素都最多隻有前相鄰和後相鄰元素,也就是元素之間首尾相接。典型的線性表結構有數組、鏈表、棧、隊列等。它有一些特徵(摘自百度百科):

          1.集合中必存在唯一的一個“第一元素”。

          2.集合中必存在唯一的一個 “最後元素” 。

          3.除最後一個元素之外,均有唯一的後繼(後件)。

          4.除第一個元素之外,均有唯一的前驅(前件)。

注意:循環鏈表也是一種線性表結構,只是第一個元素和最後一個元素首尾相連。

二、數組

數組是一種元素序列,本身就是一種線性表結構,它用一塊連續的內存空間把相同類型的一些元素無序的組合起來(關於相同類型的說法並不是絕對的,比如VFP中並沒有要求數組中必須存儲相同類型的元素,我們只是站在數據結構的角度這樣說)。關於數組,相信大家都很熟悉,用的也比較多,特性之類的也比較瞭解,我們只需要強調一點,就是數組怎麼實現“隨機訪問”時間複雜度爲O(1)的?

這裏面有關鍵的兩點,一是它的內存空間是連續的,二是存儲相同的數據類型。具體是什麼意思呢?我們在malloc(動態分配)一塊內存的時候,會指定所需空間的大小,比如:

int *p = (int*) malloc(sizeof(int) * 10);

我們要創建一個數組,那麼一開始就確定了類型:type,同時我們指定數量:n,那麼需要的內存空間大小就能直接算出來:sizeof(type) * n。malloc函數分配該大小的內存空間,如果成功會返回指向被分配內存空間的指針,該指針指向此內存區域的起始地址。當然我們需要把void *類型轉換爲我們確定的類型,比如int *。

對於32位CPU,內存地址由32位無符號整數表示,我們這裏爲了方便講解,不會涉及到邏輯地址、物理地址等概念,圖中的描述也會儘可能的簡單,能理解意思就行。比如我們現在創建一個長度爲4的int數組arr,並且完成了賦值,其內存佈局如下圖所示,該內存空間的起始地址爲100,arr[0]到arr[3]四個元素的地址緊緊挨着,且每個元素佔4個單位的內存空間。

malloc成功之後,int *p就爲指向100地址的指針,100-104就存放着我們的arr[0],所以我們能夠不用循環操作直接獲取arr[0],時間複雜度爲O(1)。現在我們要完成一個隨機訪問:返回arr[2],怎麼處理呢?由於地址空間是連續的,且元素類型是已知的,我們就可以直接算出來arr[2]的起始地址:address = 100 + 4*2=108,結合圖中,arr[2]所在的內存起始地址確實爲108,然後就可以獲取108 -108+4這塊內存區域存放的數據了。我們發現,這裏同樣沒有用循環操作就直接獲取了對應下標的數據,雖然多了一個計算操作,但只需要計算一次,和n的規模無關,所以時間複雜度也爲O(1)。

所以我們就得出了數組通過下標訪問的尋址方式:

                                     address_n = base_address + n * sizeof (type),其時間複雜度爲O(1)。

到這裏我們就能理解,爲什麼數組的下標是以0開始的呢?如果以1開始的話,那麼我們的尋址計算公式就得變一變:

                                     address_n = base_address + (n - 1) * sizeof (type)。

明顯多了一次-1的操作o(╯□╰)o。

我們都知道,數組的最大優點是:隨機訪問時間複雜度爲O(1)(注意:數組只是通過下標訪問其時間複雜度爲O(1),如果要尋找和某指定元素相同的值,其時間複雜度可不爲O(1)!如果使用順序查找則爲O(n),即使是排好序的數組使用二分法查找也爲O(logn)。我經常在面試中問應聘者:數組查找元素的時間複雜度?大多數人都會毫不猶豫的說:O(1))。另外數組的刪除元素和插入元素相對來說就不那麼高效,平均時間複雜度爲O(n),感興趣的小夥伴可以根據時間複雜度和空間複雜度提到的內容自己算一算。

注:考慮到數組的特性,我們最好在使用之前就確定好(或者有個大致的上限標準)我們需要存儲的元素數量,因爲數組不同於我們接下來會聊的鏈表,它需要佔用連續的內存空間。如果我們實際使用的容量和申請的容量相差太大的話,會對內存空間造成比較大的浪費,另外我們也要儘可能避免數組的擴容,也就是咱們申請空間既不能太大,也不能太小。單純使用數組可能還好些,但是一些高級語言可能使用數組實現了一些“複雜”點兒的結構,它們隱藏了一些細節。比如JAVA裏的List,我們單純的new ArrayList();其實生成的是一個空數組,同時它有一些自己的擴容指標和方式,我們需要儘可能的避免Arrays.copyOf的發生。

三、擴展:二維數組

前面我們總結了一維數組的基礎知識,那麼我們思考一下二維數組又是怎樣的呢?其查找元素時間複雜度又是怎樣的呢?(這也是我經常會問應聘者的問題,哈哈O(∩_∩)O)。

很多人的第一反應可能都是,二維數組嘛,簡單!我們就在一維數組的基礎上進行擴展:一維數組中的每個元素又是一個一維數組。當然,這種結構看起來是沒有問題的,能夠達到我們二維的要求。如下圖所示:

我們來分析一下此結構的內存分配和尋址,以mXn(m行,n列)的二維數組舉例。

首先是內存分配:先分配m個長度的一維內存空間,在這之前我們需要知道元素個數和每個元素需要佔的空間大小。元素個數我們知道=m,每個元素佔的空間大小呢?這裏就不是sizeof(type)了,由於我們每個元素本身又是一個一維數組,所以元素只需要存數組的起始地址指針,所以應該是sizeof(pointer),比如32位系統中爲4個字節。看起來倒還是簡單,但是這個指針怎麼來呢?還是要通過malloc返回。所以我們還需要另外分配 m 個 n * sizeof(type)的內存空間,並且將這些空間的起始地址綁定到m一維數組的每個元素上。不論我們是一個元素一個元素的分配,還是分配完之後再進行賦值,是不是感覺都很麻煩?

然後是尋址方式:如果我們要尋找3行2列的元素,也就是array[3][2],怎麼處理呢?首先定位到第三行,address_3 = base_address + 3 * sizeof(pointer),這裏address_3的值還是一個指針,指向一個地址(另一個一維數組的起始地址)。接下來定位第二列,addres_3_2 = address_3->data + 2 * sizeof(type)。

從上面的描述可以看出來,其隨機訪問時間複雜度還是爲O(1)。但是不論是內存分配,還是尋址都很麻煩,會做很多操作。那麼該怎麼存儲“簡單”點兒呢?答案就是仍然用一維數組存儲二維數據。

我們還是以mXn的二維數組來舉例子,首先來看內存分配:由於我們還是使用一維的結構存儲,那麼需要總空間可以這樣算:m*n*sizeof(type),然後返回指向這塊內存起始地址的指針。但是二維數組的結構又該怎麼在一維中體現呢?

我們把二維數組中的數據按行的順序依次存入一維數組中,即先存入第一行,再接着存入第二行,其中存每行的時候按照第一列到最後一列的順序,就這樣依次循環把所有行都存入一維數組。比如我們創建2X3的數組,則元素的存入順序就是:arr[0][0]、arr[0][1]、arr[0][2]、arr[1][0]、arr[1][1]、arr[1[2],如下圖所示(爲了方便放圖,我這裏橫向作圖哈):

可以看出來,我們其實是“跳起來”存的,這種思想在很多地方都有體現,後面在聊更多的數據結構和算法的時候會提到。那麼針對這種結構我們又該怎樣尋址呢?其實很簡單。比如上圖中的2X3的例子,我們要找arr[i][j]的元素。現在令i=1,j=2,也就是要找arr[1][2]的元素,該怎麼定位呢?由於i=1,所以我們要跳過1*n個元素(n==3),跳過之後還要找到第j個元素,所以再+j就可以了,所以arr[1][2]的地址爲:base_address + (1 * 3 + 2) * sizeof(type)。

所以我們得出,在mXn的二維數組中要定位到arr[i][j]的地址,只需要這樣做:base_address + (i * n + j) * sizeof(type)。是不是簡單多了?(小夥伴們可以試試按列存儲的話,又該是怎麼一種情況)

四、總結

數組作爲一種我們常用的數據結構,在使用之前我們要明確它的優缺點,想清楚適不適合採取這一數據結構。而數據結構的魅力就在於,很多情況我們都可以使用多種不同的數據結構解決我們的問題,但是不同的數據結構適合不同的應用場景(簡單說來就是增刪改查),用“錯”了數據結構,雖說結果可能沒有差別,但是算法的性能可能會有天壤之別;同時就如前面提到的JAVA的ArrayList,我們要用好這些高級語言提供給我們的“工具”,就需要我們清楚其原理,這樣才能在合適的地方使用合適的運用方式,這也是我們每個做研發的人員必須掌握這些基礎知識的原因。

注:本文是博主的個人理解,如果有錯誤的地方,希望大家不吝指出,謝謝

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