《數據結構與算法之美》學習筆記(3) 數據結構

數組

數組定義:

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

這個定義裏有幾個關鍵詞。

第一是線性表(Linear List)。顧名思義,線性表就是數據排成像一條線一樣的結構。每個線性表上的數據最多隻有前和後兩個方向。除了數組,鏈表、隊列、棧等也是線性表結構。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-gZCvh2fa-1585022718354)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584881336544.png)]

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

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-V5Z41irW-1585022718356)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584881391322.png)]

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

在進行數組的插入、刪除操作時,爲了保持內存數據的連續性,需要做大量的數據搬移,所以時間複雜度是 O(n)。

鏈表

鏈表並不需要一塊連續的內存空間,它通過“指針”將一組零散的內存塊串聯起來使用。

三種最常見的鏈表結構,它們分別是:單鏈表、雙向鏈表和循環鏈表。

(1)單鏈表

首先來看最簡單、最常用的單鏈表

每個鏈表的結點除了存儲數據之外,還需要記錄鏈上的下一個結點的地址。如圖所示,把這個記錄下個結點地址的指針叫作後繼指針 next

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-aoJjRniS-1585022718360)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584882913719.png)]

把第一個結點叫作頭結點,把最後一個結點叫作尾結點。其中,頭結點用來記錄鏈表的基地址。有了它,就可以遍歷得到整條鏈表。而尾結點特殊的地方是:指針不是指向下一個結點,而是指向一個空地址 NULL,表示這是鏈表上最後一個結點。

針對鏈表的插入和刪除操作,只需要考慮相鄰結點的指針改變,所以對應的時間複雜度是 O(1)。鏈表隨機訪問的性能沒有數組好,需要 O(n) 的時間複雜度。

(2)循環鏈表

循環鏈表是一種特殊的單鏈表。實際上,循環鏈表也很簡單。它跟單鏈表唯一的區別就在尾結點。

單鏈表的尾結點指針指向空地址,表示這就是最後的結點了。而循環鏈表的尾結點指針是指向鏈表的頭結點。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-7WKTwvXY-1585022718366)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584884713397.png)]

和單鏈表相比,循環鏈表的優點是從鏈尾到鏈頭比較方便。當要處理的數據具有環型結構特點時,就特別適合採用循環鏈表。

(3)雙向鏈表

單向鏈表只有一個方向,結點只有一個後繼指針 next 指向後面的結點。而雙向鏈表,顧名思義,它支持兩個方向,每個結點不止有一個後繼指針 next 指向後面的結點,還有一個前驅指針 prev 指向前面的結點。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-eWb9WdLq-1585022718369)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584884820589.png)]

雙向鏈表需要額外的兩個空間來存儲後繼結點和前驅結點的地址。所以,如果存儲同樣多的數據,雙向鏈表要比單鏈表佔用更多的內存空間。雖然兩個指針比較浪費存儲空間,但可以支持雙向遍歷,這樣也帶來了雙向鏈表操作的靈活性。

相比單鏈表,雙向鏈表適合解決哪種問題呢?

從結構上來看,雙向鏈表可以支持 O(1) 時間複雜度的情況下找到前驅結點,正是這樣的特點,也使雙向鏈表在某些情況下的插入、刪除等操作都要比單鏈表簡單、高效。

而單鏈表的插入、刪除操作的時間複雜度已經是 O(1) 了,雙向鏈表還能再怎麼高效呢?

先來看刪除操作

在實際的軟件開發中,從鏈表中刪除一個數據無外乎這兩種情況:

  • 刪除結點中“值等於某個給定值”的結點;
  • 刪除給定指針指向的結點。

對於第一種情況,不管是單鏈表還是雙向鏈表,爲了查找到值等於給定值的結點,都需要從頭結點開始一個一個依次遍歷對比,直到找到值等於給定值的結點,然後再通過指針操作將其刪除。

儘管單純的刪除操作時間複雜度是 O(1),但遍歷查找的時間是主要的耗時點,對應的時間複雜度爲 O(n)。根據時間複雜度分析中的加法法則,刪除值等於給定值的結點對應的鏈表操作的總時間複雜度爲 O(n)。

對於第二種情況,已經找到了要刪除的結點,但是刪除某個結點 q 需要知道其前驅結點,而單鏈表並不支持直接獲取前驅結點,所以,爲了找到前驅結點,我們還是要從頭結點開始遍歷鏈表,直到 p->next=q,說明 p 是 q 的前驅結點。

但是對於雙向鏈表來說,這種情況就比較有優勢了。因爲雙向鏈表中的結點已經保存了前驅結點的指針,不需要像單鏈表那樣遍歷。所以,針對第二種情況,單鏈表刪除操作需要 O(n) 的時間複雜度,而雙向鏈表只需要在 O(1) 的時間複雜度內就搞定了!

同理,如果希望在鏈表的某個指定結點前面插入一個結點,雙向鏈表比單鏈表有很大的優勢。雙向鏈表可以在 O(1) 時間複雜度搞定,而單向鏈表需要 O(n) 的時間複雜度。

除了插入、刪除操作有優勢之外,對於一個有序鏈表,雙向鏈表的按值查詢的效率也要比單鏈表高一些。因爲,我們可以記錄上次查找的位置 p,每次查詢時,根據要查找的值與 p 的大小關係,決定是往前還是往後查找,所以平均只需要查找一半的數據。

(4)雙向循環鏈表

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-nYCpu37L-1585022718391)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584885146988.png)]

(5)鏈表 VS 數組性能大比拼

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-1CphLfRG-1585022718396)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584885188020.png)]

不過,數組和鏈表的對比,並不能侷限於時間複雜度。而且,在實際的軟件開發中,不能僅僅利用複雜度分析就決定使用哪個數據結構來存儲數據。

數組簡單易用,在實現上使用的是連續的內存空間,可以藉助 CPU 的緩存機制,預讀數組中的數據,所以訪問效率更高。而鏈表在內存中並不是連續存儲,所以對 CPU 緩存不友好,沒辦法有效預讀。

數組的缺點是大小固定,一經聲明就要佔用整塊連續內存空間。如果聲明的數組過大,系統可能沒有足夠的連續內存空間分配給它,導致“內存不足(out of memory)”。如果聲明的數組過小,則可能出現不夠用的情況。這時只能再申請一個更大的內存空間,把原數組拷貝進去,非常費時。鏈表本身沒有大小的限制,天然地支持動態擴容。

和數組相比,鏈表更適合插入、刪除操作頻繁的場景,查詢的時間複雜度較高。不過,在具體軟件開發中,要對數組和鏈表的各種性能進行對比,綜合來選擇使用兩者中的哪一個。

(1)相關概念

從棧的操作特性上來看,棧是一種“操作受限”的線性表,只允許在一端插入和刪除數據。

當某個數據集合只涉及在一端插入和刪除數據,並且滿足後進先出、先進後出的特性,應該首選“棧”這種數據結構

棧既可以用數組來實現,也可以用鏈表來實現。用數組實現的棧,我們叫作順序棧,用鏈表實現的棧,我們叫作鏈式棧

不管是順序棧還是鏈式棧,我們存儲數據只需要一個大小爲 n 的數組就夠了。在入棧和出棧過程中,只需要一兩個臨時變量存儲空間,所以空間複雜度是 O(1)。

不管是順序棧還是鏈式棧,入棧、出棧只涉及棧頂個別數據的操作,所以時間複雜度都是 O(1)。

(2)棧的應用

  • 棧在函數調用中的應用

    操作系統給每個線程分配了一塊獨立的內存空間,這塊內存被組織成“棧”這種結構, 用來存儲函數調用時的臨時變量。每進入一個函數,就會將臨時變量作爲一個棧幀入棧,當被調用函數執行完成,返回之後,將這個函數對應的棧幀出棧。

  • 棧在表達式求值中的應用

  • 棧在括號匹配中的應用

隊列

先進者先出,這就是典型的“隊列”

棧只支持兩個基本操作:入棧 push()和出棧 pop()。隊列跟棧非常相似,支持的操作也很有限,最基本的操作也是兩個:入隊 enqueue(),放一個數據到隊列尾部;出隊 dequeue(),從隊列頭部取一個元素。

隊列跟棧一樣,也是一種操作受限的線性表數據結構

(1)順序隊列和鏈式隊列

跟棧一樣,隊列可以用數組來實現,也可以用鏈表來實現。用數組實現的棧叫作順序棧,用鏈表實現的棧叫作鏈式棧。同樣,用數組實現的隊列叫作順序隊列,用鏈表實現的隊列叫作鏈式隊列

順序隊列:

隊列需要兩個指針:一個是 head 指針,指向隊頭;一個是 tail 指針,指向隊尾。

結合下面這幅圖來理解。當 a、b、c、d 依次入隊之後,隊列中的 head 指針指向下標爲 0 的位置,tail 指針指向下標爲 4 的位置。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-pJXA9UqX-1585022718400)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584969788718.png)]

當我們調用兩次出隊操作之後,隊列中 head 指針指向下標爲 2 的位置,tail 指針仍然指向下標爲 4 的位置。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-jryyElUE-1585022718404)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584969807141.png)]

隨着不停地進行入隊、出隊操作,head 和 tail 都會持續往後移動。當 tail 移動到最右邊,即使數組中還有空閒空間,也無法繼續往隊列中添加數據了。

鏈式隊列:

同樣需要兩個指針:head 指針和 tail 指針。它們分別指向鏈表的第一個結點和最後一個結點。如圖所示,入隊時,tail->next= new_node, tail = tail->next;出隊時,head = head->next。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-NnNWUaD1-1585022718408)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584970108932.png)]

(2)循環隊列

剛纔用數組來實現隊列的時候,在 tail==n 時,會有數據搬移操作,這樣入隊操作性能就會受到影響。那有沒有辦法能夠避免數據搬移呢?我們來看看循環隊列的解決思路。

循環隊列,顧名思義,它長得像一個環。原本數組是有頭有尾的,是一條直線。現在我們把首尾相連,扳成了一個環。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-7fLD2k9A-1585022718411)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584970158563.png)]

圖中這個隊列的大小爲 8,當前 head=4,tail=7。當有一個新的元素 a 入隊時,我們放入下標爲 7 的位置。但這個時候,我們並不把 tail 更新爲 8,而是將其在環中後移一位,到下標爲 0 的位置。當再有一個元素 b 入隊時,我們將 b 放入下標爲 0 的位置,然後 tail 加 1 更新爲 1。所以,在 a,b 依次入隊之後,循環隊列中的元素就變成了下面的樣子:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-31vIOcRp-1585022718415)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584970306742.png)]

通過這樣的方法,我們成功避免了數據搬移操作。看起來不難理解,但是循環隊列的代碼實現難度要比前面講的非循環隊列難多了。要想寫出沒有 bug 的循環隊列的實現代碼,最關鍵的是,確定好隊空和隊滿的判定條件

在用數組實現的非循環隊列中,隊滿的判斷條件是 tail == n,隊空的判斷條件是 head == tail。那針對循環隊列,如何判斷隊空和隊滿呢?

隊列爲空的判斷條件仍然是 head == tail。

但隊列滿的判斷條件就稍微有點複雜了。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-79mltScO-1585022718418)(C:\Users\lin\AppData\Roaming\Typora\typora-user-images\1584970380335.png)]

圖中畫的隊滿的情況,tail=3,head=4,n=8,所以總結一下規律就是:(3+1)%8=4。

當隊滿時,(tail+1)%n=head

圖中的 tail 指向的位置實際上是沒有存儲數據的。所以,循環隊列會浪費一個數組的存儲空間。

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