數組
數組定義:
數組(Array)是一種線性表數據結構。它用一組連續的內存空間,來存儲一組具有相同類型的數據。
這個定義裏有幾個關鍵詞。
第一是線性表(Linear List)。顧名思義,線性表就是數據排成像一條線一樣的結構。每個線性表上的數據最多隻有前和後兩個方向。除了數組,鏈表、隊列、棧等也是線性表結構。
而與它相對立的概念是非線性表,比如二叉樹、堆、圖等。之所以叫非線性,是因爲,在非線性表中,數據之間並不是簡單的前後關係。
第二個是連續的內存空間和相同類型的數據。正是因爲這兩個限制,它纔有了一個堪稱“殺手鐗”的特性:“隨機訪問”。但有利就有弊,這兩個限制也讓數組的很多操作變得非常低效,比如要想在數組中刪除、插入一個數據,爲了保證連續性,就需要做大量的數據搬移工作。
在進行數組的插入、刪除操作時,爲了保持內存數據的連續性,需要做大量的數據搬移,所以時間複雜度是 O(n)。
鏈表
鏈表並不需要一塊連續的內存空間,它通過“指針”將一組零散的內存塊串聯起來使用。
三種最常見的鏈表結構,它們分別是:單鏈表、雙向鏈表和循環鏈表。
(1)單鏈表
首先來看最簡單、最常用的單鏈表。
每個鏈表的結點除了存儲數據之外,還需要記錄鏈上的下一個結點的地址。如圖所示,把這個記錄下個結點地址的指針叫作後繼指針 next。
把第一個結點叫作頭結點,把最後一個結點叫作尾結點。其中,頭結點用來記錄鏈表的基地址。有了它,就可以遍歷得到整條鏈表。而尾結點特殊的地方是:指針不是指向下一個結點,而是指向一個空地址 NULL,表示這是鏈表上最後一個結點。
針對鏈表的插入和刪除操作,只需要考慮相鄰結點的指針改變,所以對應的時間複雜度是 O(1)。鏈表隨機訪問的性能沒有數組好,需要 O(n) 的時間複雜度。
(2)循環鏈表
循環鏈表是一種特殊的單鏈表。實際上,循環鏈表也很簡單。它跟單鏈表唯一的區別就在尾結點。
單鏈表的尾結點指針指向空地址,表示這就是最後的結點了。而循環鏈表的尾結點指針是指向鏈表的頭結點。
和單鏈表相比,循環鏈表的優點是從鏈尾到鏈頭比較方便。當要處理的數據具有環型結構特點時,就特別適合採用循環鏈表。
(3)雙向鏈表
單向鏈表只有一個方向,結點只有一個後繼指針 next 指向後面的結點。而雙向鏈表,顧名思義,它支持兩個方向,每個結點不止有一個後繼指針 next 指向後面的結點,還有一個前驅指針 prev 指向前面的結點。
雙向鏈表需要額外的兩個空間來存儲後繼結點和前驅結點的地址。所以,如果存儲同樣多的數據,雙向鏈表要比單鏈表佔用更多的內存空間。雖然兩個指針比較浪費存儲空間,但可以支持雙向遍歷,這樣也帶來了雙向鏈表操作的靈活性。
相比單鏈表,雙向鏈表適合解決哪種問題呢?
從結構上來看,雙向鏈表可以支持 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)雙向循環鏈表
(5)鏈表 VS 數組性能大比拼
不過,數組和鏈表的對比,並不能侷限於時間複雜度。而且,在實際的軟件開發中,不能僅僅利用複雜度分析就決定使用哪個數據結構來存儲數據。
數組簡單易用,在實現上使用的是連續的內存空間,可以藉助 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 的位置。
當我們調用兩次出隊操作之後,隊列中 head 指針指向下標爲 2 的位置,tail 指針仍然指向下標爲 4 的位置。
隨着不停地進行入隊、出隊操作,head 和 tail 都會持續往後移動。當 tail 移動到最右邊,即使數組中還有空閒空間,也無法繼續往隊列中添加數據了。
鏈式隊列:
同樣需要兩個指針:head 指針和 tail 指針。它們分別指向鏈表的第一個結點和最後一個結點。如圖所示,入隊時,tail->next= new_node, tail = tail->next;出隊時,head = head->next。
(2)循環隊列
剛纔用數組來實現隊列的時候,在 tail==n 時,會有數據搬移操作,這樣入隊操作性能就會受到影響。那有沒有辦法能夠避免數據搬移呢?我們來看看循環隊列的解決思路。
循環隊列,顧名思義,它長得像一個環。原本數組是有頭有尾的,是一條直線。現在我們把首尾相連,扳成了一個環。
圖中這個隊列的大小爲 8,當前 head=4,tail=7。當有一個新的元素 a 入隊時,我們放入下標爲 7 的位置。但這個時候,我們並不把 tail 更新爲 8,而是將其在環中後移一位,到下標爲 0 的位置。當再有一個元素 b 入隊時,我們將 b 放入下標爲 0 的位置,然後 tail 加 1 更新爲 1。所以,在 a,b 依次入隊之後,循環隊列中的元素就變成了下面的樣子:
通過這樣的方法,我們成功避免了數據搬移操作。看起來不難理解,但是循環隊列的代碼實現難度要比前面講的非循環隊列難多了。要想寫出沒有 bug 的循環隊列的實現代碼,最關鍵的是,確定好隊空和隊滿的判定條件。
在用數組實現的非循環隊列中,隊滿的判斷條件是 tail == n,隊空的判斷條件是 head == tail。那針對循環隊列,如何判斷隊空和隊滿呢?
隊列爲空的判斷條件仍然是 head == tail。
但隊列滿的判斷條件就稍微有點複雜了。
圖中畫的隊滿的情況,tail=3,head=4,n=8,所以總結一下規律就是:(3+1)%8=4。
當隊滿時,(tail+1)%n=head。
圖中的 tail 指向的位置實際上是沒有存儲數據的。所以,循環隊列會浪費一個數組的存儲空間。