《數據結構與算法之美》是極客時間上的一個算法學習系列,在學習之后特在此做記錄和總結。
一、數組
數組(Array)是一種線性表數據結構。它用一組連續的內存空間,來存儲一組具有相同類型的數據。
1)線性表(Linear List)
顧名思義,線性表就是數據排成像一條線一樣的結構。每個線性表上的數據最多只有前和后兩個方向。其實除了數組,鏈表、隊列、棧等也是線性表結構。
2)非線性表
比如二叉樹、堆、圖等。之所以叫非線性,是因為,在非線性表中,數據之間並不是簡單的前后關系。
3)連續的內存空間和相同類型的數據
正是因為這兩個限制,它才有了一個堪稱“殺手鐧”的特性:“隨機訪問”。
這兩個限制也讓數組的很多操作變得非常低效,比如要想在數組中刪除、插入一個數據,為了保證連續性,就需要做大量的數據搬移工作。
4)誤區
在面試的時候,常常會問數組和鏈表的區別,很多人都回答說,“鏈表適合插入、刪除,時間復雜度 O(1);數組適合查找,查找時間復雜度為 O(1)”。
實際上,這種表述是不准確的。數組是適合查找操作,但是查找的時間復雜度並不為 O(1)。即便是排好序的數組,你用二分查找,時間復雜度也是 O(logn)。
所以,正確的表述應該是,數組支持隨機訪問,根據下標隨機訪問的時間復雜度為 O(1)。
二、鏈表
數組和鏈表的區別如下:
(1)數組需要一塊連續的內存空間來存儲,對內存的要求比較高。
(2)鏈表恰恰相反,它並不需要一塊連續的內存空間,它通過“指針”將一組零散的內存塊串聯起來使用。
三種最常見的鏈表結構,它們分別是:單鏈表、雙向鏈表和循環鏈表。
1)單鏈表
鏈表通過指針將一組零散的內存塊串聯在一起。其中,把內存塊稱為鏈表的“結點”。為了將所有的結點串起來,每個鏈表的結點除了存儲數據之外,還需要記錄鏈上的下一個結點的地址。把這個記錄下個結點地址的指針叫作后繼指針 next。
與數組一樣,鏈表也支持數據的查找、插入和刪除操作。
(1)刪除一個數據是非常快速的,只需考慮相鄰結點的指針改變,所以對應的時間復雜度是 O(1)。
(2)鏈表隨機訪問的性能沒有數組好,需要 O(n) 的時間復雜度。
2)循環鏈表
循環鏈表是一種特殊的單鏈表。實際上,循環鏈表也很簡單。
它跟單鏈表唯一的區別就在尾結點。循環鏈表的優點是從鏈尾到鏈頭比較方便。
當要處理的數據具有環型結構特點時,就特別適合采用循環鏈表。比如著名的約瑟夫問題。
3)雙向鏈表
顧名思義,它支持兩個方向,每個結點不止有一個后繼指針 next 指向后面的結點,還有一個前驅指針 prev 指向前面的結點。
雙向鏈表可以支持 O(1) 時間復雜度的情況下找到前驅結點,正是這樣的特點,也使雙向鏈表在某些情況下的插入、刪除等操作都要比單鏈表簡單、高效。
雙向鏈表盡管比較費內存,但還是比單鏈表的應用更加廣泛。實際上,這里有一個更加重要的知識點需要你掌握,那就是用空間換時間的設計思想:
對於執行較慢的程序,可以通過消耗更多的內存(空間換時間)來進行優化;而消耗過多內存的程序,可以通過消耗更多的時間(時間換空間)來降低內存的消耗。
4)應用場景
一個經典的鏈表應用場景,那就是 LRU 緩存淘汰算法。
常見的緩存清理策略有三種:先進先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。
用鏈表實現的思路是這樣的:維護一個有序單鏈表,越靠近鏈表尾部的結點是越早之前訪問的。當有一個新的數據被訪問時,從鏈表頭開始順序遍歷鏈表。
(1)如果此數據之前已經被緩存在鏈表中了,遍歷得到這個數據對應的結點,並將其從原來的位置刪除,然后再插入到鏈表的頭部。
(2)如果此數據沒有在緩存鏈表中,又可以分為兩種情況:
如果此時緩存未滿,則將此結點直接插入到鏈表的頭部;
如果此時緩存已滿,則鏈表尾結點刪除,將新的數據結點插入鏈表的頭部。
這種基於鏈表的實現思路,緩存訪問的時間復雜度為 O(n)。
5)邊界條件
檢查鏈表代碼是否正確的邊界條件有這樣幾個:
(1)如果鏈表為空時,代碼是否能正常工作?
(2)如果鏈表只包含一個結點時,代碼是否能正常工作?
(3)如果鏈表只包含兩個結點時,代碼是否能正常工作?
(4)代碼邏輯在處理頭結點和尾結點的時候,是否能正常工作?
6)鏈表題目
精選了 5 個常見的鏈表操作。只要把這幾個操作都能寫熟練,不熟就多寫幾遍,保證你之后再也不會害怕寫鏈表代碼。
(1)單鏈表反轉
(2)鏈表中環的檢測
(3)兩個有序的鏈表合並
(4)刪除鏈表倒數第 n 個結點
(5)求鏈表的中間結點
三、棧
后進者先出,先進者后出,這就是典型的“棧”結構。
從棧的操作特性上來看,棧是一種“操作受限”的線性表,只允許在一端插入和刪除數據。
但你要知道,特定的數據結構是對特定場景的抽象,而且,數組或鏈表暴露了太多的操作接口,操作上的確靈活自由,但使用時就比較不可控,自然也就更容易出錯。
1)實現
實際上,棧既可以用數組來實現,也可以用鏈表來實現。用數組實現的棧,我們叫作順序棧,用鏈表實現的棧,我們叫作鏈式棧。注意:
(1)在入棧和出棧過程中,只需要一兩個臨時變量存儲空間,所以空間復雜度是 O(1)。
(2)不管是順序棧還是鏈式棧,入棧、出棧只涉及棧頂個別數據的操作,所以時間復雜度都是 O(1)。
2)應用場景
比較經典的一個應用場景就是函數調用棧。
(1)操作系統給每個線程分配了一塊獨立的內存空間,這塊內存被組織成“棧”這種結構, 用來存儲函數調用時的臨時變量。
(2)每進入一個函數,就會將臨時變量作為一個棧幀入棧,當被調用函數執行完成,返回之后,將這個函數對應的棧幀出棧。
另一個常見的應用場景,編譯器如何利用棧來實現表達式求值。
比如:34+13*9+44-12/3。對於這個四則運算,人腦可以很快求解出答案,但是對於計算機來說,理解這個表達式本身就是個挺難的事兒。
(1)實際上,編譯器就是通過兩個棧來實現的。其中一個保存操作數的棧,另一個是保存運算符的棧。
(2)從左向右遍歷表達式,當遇到數字,我們就直接壓入操作數棧;當遇到運算符,就與運算符棧的棧頂元素進行比較。
(3)如果比運算符棧頂元素的優先級高,就將當前運算符壓入棧;如果比運算符棧頂元素的優先級低或者相同,從運算符棧中取棧頂運算符,從操作數棧的棧頂取 2 個操作數,然后進行計算,再把計算完的結果壓入操作數棧,繼續比較。
除了用棧來實現表達式求值,還可以借助棧來檢查表達式中的括號是否匹配。
比如,{[] ()[{}]}或[{()}([])]等都為合法格式,而{[}()]或[({)]為不合法的格式。
(1)用棧來保存未匹配的左括號,從左到右依次掃描字符串。當掃描到左括號時,則將其壓入棧中;當掃描到右括號時,從棧頂取出一個左括號。
(2)如果能夠匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,則繼續掃描剩下的字符串。
(3)如果掃描的過程中,遇到不能配對的右括號,或者棧中沒有數據,則說明為非法格式。
四、隊列
先進者先出,這就是典型的“隊列”。
隊列跟棧非常相似,支持的操作也很有限,最基本的操作也是兩個:
(1)入隊 enqueue(),放一個數據到隊列尾部;
(2)出隊 dequeue(),從隊列頭部取一個元素。
作為一種非常基礎的數據結構,隊列的應用也非常廣泛,特別是一些具有某些額外特性的隊列,比如循環隊列、阻塞隊列、並發隊列。它們在很多偏底層系統、框架、中間件的開發中,起着關鍵性的作用。
1)實現
用數組實現的隊列叫作順序隊列,用鏈表實現的隊列叫作鏈式隊列。
對於棧來說,只需要一個棧頂指針就可以了。但是隊列需要兩個指針:一個是 head 指針,指向隊頭;一個是 tail 指針,指向隊尾。
2)循環隊列
顧名思義,它長得像一個環。原本數組是有頭有尾的,是一條直線。現在我們把首尾相連,扳成了一個環。
3)阻塞隊列
簡單來說,就是在隊列為空的時候,從隊頭取數據會被阻塞。如果隊列已經滿了,那么插入數據的操作就會被阻塞,直到隊列中有空閑位置后再插入數據。
上述的定義就是一個“生產者 - 消費者模型”!可以有效地協調生產和消費的速度。
基於阻塞隊列,還可以通過協調“生產者”和“消費者”的個數,來提高數據的處理效率。
4)並發隊列
最簡單直接的實現方式是直接在 enqueue()、dequeue() 方法上加鎖,但是鎖粒度大並發度會比較低,同一時刻僅允許一個存或者取操作。
實際上,對於大部分資源有限的場景,當沒有空閑資源時,基本上都可以通過“隊列”這種數據結構來實現請求排隊。
五、跳表
跳表(Skip List)是一種各方面性能都比較優秀的動態數據結構,可以支持快速地插入、刪除、查找操作,寫起來也不復雜,甚至可以替代紅黑樹(Red-black Tree)。
1)實現
對於一個單鏈表來講,即便鏈表中存儲的數據是有序的,如果我們要想在其中查找某個數據,也只能從頭到尾遍歷鏈表。這樣查找效率就會很低,時間復雜度會很高,是 O(n)。
如果在鏈表上加一層索引之后,查找一個結點需要遍歷的結點個數減少了,也就是說查找效率提高了。
這種鏈表加多級索引的結構,就是跳表。
在一個單鏈表中查詢某個數據的時間復雜度是 O(n),在跳表中查詢任意數據的時間復雜度就是 O(logn),空間復雜度是 O(n)。
2)索引的空間復雜度
假設第一級索引需要大約 n/3 個結點,第二級索引需要大約 n/9 個結點。每往上一級,索引結點個數都除以 3。
為了方便計算,假設最高一級的索引結點個數是 1。把每級索引的結點個數都寫下來,就是一個等比數列。
通過等比數列求和公式,總的索引結點大約就是 n/3+n/9+n/27+…+9+3+1=(n-1)/2,空間復雜度就是 O(n)。
六、散列表
散列表(Hash Table)平時也叫“哈希表”或者“Hash 表”。散列表用的是數組支持的按照下標隨機訪問數據的特性,時間復雜度是 O(1) ,所以散列表其實就是數組的一種擴展,由數組演化而來。
例如參賽選手的編號我們叫做鍵(key)或者關鍵字。用它來標識一個選手。
把參賽編號轉化為數組下標的映射方法就叫作散列函數(或“Hash 函數”“哈希函數”),而散列函數計算得到的值就叫作散列值(或“Hash 值”“哈希值”)。
1)散列函數
顧名思義,它是一個函數。可以把它定義成 hash(key),其中 key 表示元素的鍵值,hash(key) 的值表示經過散列函數計算得到的散列值。
散列函數設計的基本要求:
(1)散列函數計算得到的散列值是一個非負整數;
(2)如果 key1 = key2,那 hash(key1) == hash(key2);
(3)如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
2)散列沖突
再好的散列函數也無法避免散列沖突,常用的散列沖突解決方法有兩類,開放尋址法(open addressing)和鏈表法(chaining)。
(1)開放尋址法的核心思想是,如果出現了散列沖突,就重新探測一個空閑位置,將其插入。
對於開放尋址沖突解決方法,除了線性探測方法之外,還有另外兩種比較經典的探測方法,二次探測(Quadratic probing)和雙重散列(Double hashing)。
不管采用哪種探測方法,當散列表中空閑位置不多的時候,散列沖突的概率就會大大提高。為了盡可能保證散列表的操作效率,會盡可能保證散列表中有一定比例的空閑槽位。
用裝載因子(load factor)來表示空位的多少。裝載因子的計算公式是:
散列表的裝載因子 = 填入表中的元素個數 / 散列表的長度
裝載因子越大,說明空閑位置越少,沖突越多,散列表的性能會下降。
(2)在散列表中,每個“桶(bucket)”或者“槽(slot)”會對應一條鏈表,所有散列值相同的元素我們都放到相同槽位對應的鏈表中。
七、二叉樹
樹(Tree)有三個比較相似的概念:高度(Height)、深度(Depth)、層(Level)。
除了葉子節點之外,每個節點都有左右兩個子節點,這種二叉樹就叫做滿二叉樹(編號2)。
葉子節點都在最底下兩層,最后一層的葉子節點都靠左排列,並且除了最后一層,其他層的節點個數都要達到最大,這種二叉樹叫做完全二叉樹(編號3)。
如果某棵二叉樹是一棵完全二叉樹,那用數組存儲無疑是最節省內存的一種方式。講到堆和堆排序的時候,你會發現,堆其實就是一種完全二叉樹,最常用的存儲方式就是數組。
1)遍歷
二叉樹的遍歷有三種,前序遍歷、中序遍歷和后序遍歷。遍歷的時間復雜度是 O(n)。
(1)前序遍歷是指,對於樹中的任意節點來說,先打印這個節點,然后再打印它的左子樹,最后打印它的右子樹。
(2)中序遍歷是指,對於樹中的任意節點來說,先打印它的左子樹,然后再打印它本身,最后打印它的右子樹。
(3)后序遍歷是指,對於樹中的任意節點來說,先打印它的左子樹,然后再打印它的右子樹,最后打印這個節點本身。
2)二叉查找樹
二叉查找樹(Binary Search Tree,BST)最大的特點就是,支持動態數據集合的快速插入、刪除、查找操作。
二叉查找樹要求,在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大於這個節點的值。
除了插入、刪除、查找操作之外,二叉查找樹中還可以支持快速地查找最大節點和最小節點、前驅節點和后繼節點。
還有一個重要的特性,就是中序遍歷二叉查找樹,可以輸出有序的數據序列,時間復雜度是 O(n),非常高效。因此,二叉查找樹也叫作二叉排序樹。
不管操作是插入、刪除還是查找,時間復雜度其實都跟樹的高度成正比,也就是 O(height)。
八、紅黑樹
平衡二叉樹的嚴格定義是這樣的:二叉樹中任意一個節點的左右子樹的高度相差不能大於 1。最先被發明的平衡二叉查找樹是AVL 樹。
紅黑樹的英文是“Red-Black Tree”,簡稱 R-B Tree。它是一種不嚴格的平衡二叉查找樹。
顧名思義,紅黑樹中的節點,一類被標記為黑色,一類被標記為紅色。除此之外,一棵紅黑樹還需要滿足這樣幾個要求:
(1)根節點是黑色的;
(2)每個葉子節點都是黑色的空節點(NIL),也就是說,葉子節點不存儲數據;
(3)任何相鄰的節點都不能同時為紅色,也就是說,紅色節點是被黑色節點隔開的;
(4)每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點;
紅黑樹中包含最多黑色節點的路徑不會超過 log2^n,所以加入紅色節點之后,最長路徑不會超過 2log2^n,也就是說,紅黑樹的高度近似 2log2^n。
紅黑樹是一種平衡二叉查找樹。它是為了解決普通二叉查找樹在數據更新的過程中,復雜度退化的問題而產生的。
紅黑樹的高度近似 log2^n,所以它是近似平衡,插入、刪除、查找操作的時間復雜度都是 O(logn)。
九、堆
堆(Heap)是一種特殊的樹。
(1)堆是一個完全二叉樹;
(2)堆中每一個節點的值都必須大於等於或小於等於其子樹中每個節點的值,前者叫大頂堆,后者叫小頂堆。
完全二叉樹比較適合用數組來存儲。用數組來存儲完全二叉樹是非常節省存儲空間的。因為我們不需要存儲左右子節點的指針,單純地通過數組的下標,就可以找到一個節點的左右子節點和父節點。
數組中下標為 i 的節點的左子節點,就是下標為 i*2 的節點,右子節點就是下標為 i*2+1 的節點,父節點就是下標為 2/i 的節點。
1)堆化
將堆進行調整,讓其重新滿足堆的特性,這個過程叫做堆化(heapify)。
堆化非常簡單,就是順着節點所在的路徑,向上或者向下,對比,然后交換。
讓新插入的節點與父節點對比大小。如果不滿足子節點小於等於父節點的大小關系,就互換兩個節點。
2)應用場景
堆這種數據結構幾個非常重要的應用:優先級隊列、求 Top K 和求中位數。
(1)優先級隊列中數據的出隊順序不是先進先出,而是按照優先級來,用堆來實現是最直接、最高效的。往優先級隊列中插入一個元素,就相當於往堆中插入一個元素;從優先級隊列中取出優先級最高的元素,就相當於取出堆頂元素。應用場景包括赫夫曼編碼、圖的最短路徑、最小生成樹算法等。
(2)維護一個大小為 K 的小頂堆,順序遍歷數組,從數組中取出數據與堆頂元素比較。如果比堆頂元素大,就把堆頂元素刪除,並且將這個元素插入到堆中;如果比堆頂元素小,則不做處理,繼續遍歷數組。這樣等數組中的數據都遍歷完之后,堆中的數據就是前 K 大數據了。
(3)維護兩個堆,一個大頂堆,一個小頂堆。大頂堆中存儲前半部分數據,小頂堆中存儲后半部分數據,且小頂堆中的數據都大於大頂堆中的數據。
十、圖
圖(Graph)和樹比起來,這是一種更加復雜的非線性表結構。
樹中的元素我們稱為節點,圖中的元素我們就叫做頂點(vertex)。圖中的一個頂點可以與任意其他頂點建立連接關系。我們把這種建立的關系叫做邊(edge)。度(degree)就是跟頂點相連接的邊的條數。
把這種邊有方向的圖叫做“有向圖”。以此類推,我們把邊沒有方向的圖就叫做“無向圖”。在有向圖中,我們把度分為入度(In-degree)和出度(Out-degree)。
在帶權圖(weighted graph)中,每條邊都有一個權重(weight),我們可以通過這個權重來表示 QQ 好友間的親密度。
圖最直觀的一種存儲方法就是,鄰接矩陣(Adjacency Matrix)。
十一、Trie樹
Trie 樹,也叫“字典樹”。顧名思義,它是一個樹形結構。它是一種專門處理字符串匹配的數據結構,用來解決在一組字符串集合中快速查找某個字符串的問題。
Trie 樹的本質,就是利用字符串之間的公共前綴,將重復的前綴合並在一起。
當在 Trie 樹中查找一個字符串的時候,比如查找字符串“her”,那將要查找的字符串分割成單個的字符 h,e,r,然后從 Trie 樹的根節點開始匹配。
每次查詢時,如果要查詢的字符串長度是 k,那只需要比對大約 k 個節點,就能完成查詢操作。
跟原本那組字符串的長度和個數沒有任何關系。所以說,構建好 Trie 樹后,在其中查找字符串的時間復雜度是 O(k),k 表示要查找的字符串的長度。
實際上,Trie 樹只是不適合精確匹配查找,這種問題更適合用散列表或者紅黑樹來解決。Trie 樹比較適合的是查找前綴匹配的字符串。