來年加薪必備,2020年攻破資料結構與演算法學習筆記-資料結構篇
著名資料專家沃斯曾說:演算法+資料結構=程式
今天我們就來講講資料結構
1. 陣列
陣列(Array)是一種 線性表資料結構。它用一組 連續的記憶體空間,來儲存一組具有 相同型別的資料。具有的特性:
- 線性表
- 連續的記憶體空間
- 相同型別的資料
- 可以隨機訪問
- 資料操作比較低效,平均情況時間複雜度為 O(n)
陣列為什麼下標從0開始
- 由於陣列是是一種線性表資料結構。它用一組 連續的記憶體空間,來儲存一組具有 相同型別的資料。 所以:
- 如果下標從0開始: 計算下標為k的物件的地址的公式為:a[k]_address = base_address + k * type_size
- 如果下標從1開始: 計算下標為k的物件的地址的公式為:a[k]_address = base_address + (k-1) * type_size 對於 CPU 來說,就是多了一次減法指令。
- C 語言設計者用 0 開始計數陣列下標,之後的 Java、JavaScript 等高階語言都效仿了 C 語言。
容器能否完全替代陣列?
例如Java的ArrayList,ArrayList 最大的優勢就是可以將很多陣列操作的細節封裝起來。比如前面提到的陣列插入、刪除資料時需要搬移其他資料等。另外,它還有一個優勢,就是支援動態擴容。
那麼,作為高階語言程式設計者,是不是陣列就無用武之地了呢?當然不是,有些時候,用陣列會更合適些,總的來說,對於業務開發,直接使用容器就足夠了,省時省力。畢竟損耗一丟丟效能,完全不會影響到系統整體的效能。但如果你是做一些非常底層的開發,比如開發網路框架,效能的最佳化需要做到極致,這個時候陣列就會優於容器,成為首選。
2. 連結串列 (Linked list)
不需要一塊連續的記憶體空間,它透過“指標”將一組零散的記憶體塊串聯起來使用。
幾種常見的連結串列形式: 1\. 單連結串列 2\. 迴圈連結串列 3\. 雙向連結串列 (空間換時間思想) 4\. 雙向迴圈列表
與陣列的對比:
不過,陣列和連結串列的對比,並不能侷限於時間複雜度。而且,在實際的軟體開發中,不能僅僅利用複雜度分析就決定使用哪個資料結構來儲存資料。
寫連結串列程式碼的幾個技巧: 1\. 理解指標或引用的含義、警惕指標丟失和記憶體洩漏 2\. 利用哨兵簡化實現難度 3\. 重點留意邊界條件處理 4\. 舉例畫圖、輔助思考 複製程式碼
寫連結串列程式碼是最考驗邏輯思維能力的。因為,連結串列程式碼到處都是指標的操作、邊界條件的處理,稍有不慎就容易產生 Bug。連結串列程式碼寫得好壞,可以看出一個人寫程式碼是否夠細心,考慮問題是否全面,思維是否縝密。所以,這也是很多面試官喜歡讓人手寫連結串列程式碼的原因。所以,這一節講到的東西,你一定要自己寫程式碼實現一下,才有效果。
- 單連結串列反轉
- 連結串列中環的檢測
- 兩個有序的連結串列合併
- 刪除連結串列倒數第 n 個結點
- 求連結串列的中間結點
3. 棧
- 用陣列實現的 順序棧
- 用連結串列實現的 鏈式棧
- 出棧入棧時間複雜度 空間複雜度都是O(1)
- 先進後出
應用:
- 1,函式的臨時變數的儲存銷燬
- 2,表示式求值
- 3,瀏覽器的前進後退
4. 佇列
特點:先進先出
- 用陣列實現 順序佇列
- 用連結串列實現 鏈式佇列
佇列擴充:
- 迴圈佇列 解決用陣列實現的佇列需要資料遷移的問題 隊空:head == tail 隊滿:(tail+1)%n=head。
- 阻塞佇列 佇列滿了時,不給入隊。 生產者 - 消費者模型
- 併發佇列 執行緒安全的佇列我們叫作併發佇列
5. 跳錶
我們知道,陣列支援快速的隨機訪問,而連結串列不支援,這樣的話,就不能用二分查詢法來對連結串列進行快速查詢。實際上,我們只需要對連結串列稍加改造,就可以支援類似“二分”的查詢演算法。我們把改造之後的資料結構叫作跳錶(Skip list)。
跳錶,其實就是對 有序連結串列建立多級“索引”,每兩個(也可以是其他數量)結點提取一個結點到上一級,我們把抽出來的那一級叫作索引或索引層。你可以看我畫的圖。圖中的 down 表示 down 指標,指向下一級結點。
如果我們現在要查詢某個結點,比如 16。我們可以先在索引層遍歷,當遍歷到索引層中值為 13 的結點時,我們發現下一個結點是 17,那要查詢的結點 16 肯定就在這兩個結點之間。然後我們透過索引層結點的 down 指標,下降到原始連結串列這一層,繼續遍歷。這個時候,我們只需要再遍歷 2 個結點,就可以找到值等於 16 的這個結點了。這樣,原來如果要查詢 16,需要遍歷 10 個結點,現在只需要遍歷 7 個結點。
我舉的例子資料量不大,查詢效率的提升也並不明顯。為了讓你能真切地感受索引提升查詢效率。我畫了一個包含 64 個結點的連結串列,按照前面講的這種思路,建立了五級索引。
從圖中我們可以看出,原來沒有索引的時候,查詢 62 需要遍歷 62 個結點,現在只需要遍歷 11 個結點,速度是不是提高了很多?所以,當連結串列的長度 n 比較大時,比如 1000、10000 的時候,在構建索引之後,查詢效率的提升就會非常明顯。
時間複雜度:
跳錶查詢某個資料的時間複雜度是多少呢?
按照我們剛才講的,每兩個結點會抽出一個結點作為上一級索引的結點,那第一級索引的結點個數大約就是 n/2,第二級索引的結點個數大約就是 n/4,第三級索引的結點個數大約就是 n/8,依次類推,也就是說, 第 k 級索引的結點個數是第 k-1 級索引的結點個數的 1/2,那第 k級索引結點的個數就是 n/(2k)。
假設索引有 h 級,最高階的索引有 2 個結點。透過上面的公式,我們可以得到 n/(2h)=2,從而求得 h=log2n-1。如果包含原始連結串列這一層,整個跳錶的高度就是 log2n。我們在跳錶中查詢某個資料的時候,如果每一層都要遍歷 m 個結點,那在跳錶中查詢一個資料的時間複雜度就是 O(m*logn)。
那這個 m 的值是多少呢?按照前面這種索引結構,我們每一級索引都最多隻需要遍歷 3 個結點,也就是說 m=3。
所以在跳錶中查詢任意資料的時間複雜度就是 O(logn)。這個查詢的時間複雜度跟二分查詢是一樣的。換句話說,我們其實是基於單連結串列實現了二分查詢,是不是很神奇?不過,天下沒有免費的午餐,這種查詢效率的提升,前提是建立了很多級索引,也就是我們在第 6 節講過的空間換時間的設計思路。
空間複雜度:
跳錶是不是很浪費記憶體?比起單純的單連結串列,跳錶需要儲存多級索引,肯定要消耗更多的儲存空間。那到底需要消耗多少額外的儲存空間呢?我們來分析一下跳錶的空間複雜度。
跳錶的空間複雜度分析並不難,我在前面說了,假設原始連結串列大小為 n,那第一級索引大約有 n/2 個結點,第二級索引大約有 n/4 個結點,以此類推,每上升一級就減少一半,直到剩下 2 個結點。如果我們把每層索引的結點數寫出來,就是一個等比數列。
這幾級索引的結點總和就是 n/2+n/4+n/8…+8+4+2=n-2。所以,跳錶的空間複雜度是 O(n)。也就是說,如果將包含 n 個結點的單連結串列構造成跳錶,我們需要額外再用接近 n 個結點的儲存空間。那我們有沒有辦法降低索引佔用的記憶體空間呢?
我們前面都是每兩個結點抽一個結點到上級索引,如果我們每三個結點或五個結點,抽一個結點到上級索引,是不是就不用那麼多索引結點了呢?
第一級索引需要大約 n/3 個結點,第二級索引需要大約 n/9 個結點。每往上一級,索引結點個數都除以 3。為了方便計算,我們假設最高一級的索引結點個數是 1。我們把每級索引的結點個數都寫下來,也是一個等比數列。
透過等比數列求和公式,總的索引結點大約就是 n/3+n/9+n/27+…+9+3+1=n/2。儘管空間複雜度還是 O(n),但比上面的每兩個結點抽一個結點的索引構建方法,要減少了一半的索引結點儲存空間。
實際上,在軟體開發中,我們不必太在意索引佔用的額外空間。在講資料結構和演算法時,我們習慣性地把要處理的資料看成整數,但是在實際的軟體開發中,原始連結串列中儲存的有可能是很大的物件,而索引結點只需要儲存關鍵值和幾個指標,並不需要儲存物件, 所以當物件比索引結點大很多時,那索引佔用的額外空間就可以忽略了。
跳錶索引動態更新
當我們不停地往跳錶中插入資料時,如果我們不更新索引,就有可能出現某 2 個索引結點之間資料非常多的情況。極端情況下,跳錶還會退化成單連結串列。
作為一種動態資料結構,我們需要某種手段來維護索引與原始連結串列大小之間的平衡,也就是說,如果連結串列中結點多了,索引結點就相應地增加一些,避免複雜度退化,以及查詢、插入、刪除操作效能下降。
當我們往跳錶中插入資料的時候,我們可以選擇同時將這個資料插入到部分索引層中。如何選擇加入哪些索引層呢?
我們透過一個隨機函式,來決定將這個結點插入到哪幾級索引中,比如隨機函式生成了值 K,那我們就將這個結點新增到第一級到第 K 級這 K 級索引中。
隨機函式的選擇很有講究,從機率上來講,能夠保證跳錶的索引大小和資料大小平衡性,不至於效能過度退化。
跳錶特點:
- 前提是有序連結串列
- 動態資料結構
- 支援快速的查詢、插入、刪除操作,時間複雜度為O(logn)
- 表面上空間複雜度是O(n),但是因為索引只需要儲存關鍵值和幾個指標,並不需要儲存物件,所以當物件比索引結點大很多時,那索引佔用的額外空間就可以忽略了。
- 和紅黑樹相比的優勢:當需要按區間查詢資料時,跳錶可以做到 O(logn) 的時間複雜度定位區間的起點,然後在原始連結串列中順序往後遍歷就可以了。
- 程式碼實現比紅黑樹容易很多。
6. 雜湊表
特性:
- 基於陣列可以根據下標快速查詢的特點
- 利用雜湊函式,可以把key雜湊後得出正整數,也就是陣列的下標,進行快速查詢。
- 插入、查詢、刪除的時間複雜度都是O(1)
雜湊衝突:
- 雜湊值很大可能會重複,所以就有了雜湊衝突
- 解決雜湊衝突的兩種方式: 開放定址法:線性探測、二次探測、雙重雜湊 優點: 雜湊表中的資料都儲存在陣列中,可以有效地利用 CPU 快取加快查詢速度。而且,這種方法實現的雜湊表,序列化起來比較簡單。 缺點:1.刪除資料的時候比較麻煩,需要特殊標記已經刪除掉的資料;2.裝載因子的上限不能太大,這也導致這種方法比連結串列法更浪費記憶體空間。 總結:當資料量比較小、裝載因子小的時候,適合採用開放定址法。這也是 Java 中的ThreadLocalMap使用開放定址法解決雜湊衝突的原因。 連結串列法 優點:1.記憶體的利用率比開放定址法要高,需要用的時候再申請;2.對大裝載因子的容忍度更高;3.可以用跳錶、紅黑樹來代替普通的連結串列,這樣的話即使是極端情況下,時間複雜度也只是O(logn) 總結:比較適合儲存大物件、大資料量的雜湊表,而且,比起開放定址法,它更加靈活,支援更多的最佳化策略,比如用紅黑樹代替連結串列。
- 用裝載因子來表示空位的多少 裝載因子 = 填入表中的元素個數/雜湊表的長度 裝載因子越大,說明空閒位置越少,衝突越多,雜湊表的效能會下降。
工業級水平的雜湊表:
最終要求:
- 支援快速的查詢、插入、刪除操作;
- 記憶體佔用合理,不能浪費過多的記憶體空間;
- 效能穩定,極端情況下,雜湊表的效能也不會退化到無法接受的情況。
具體設計方向:
- 雜湊函式要求: 儘可能要設計得讓雜湊值均勻分佈 不能設計得太複雜計算時間太久
- 支援動態擴容 根據裝載因子大小來進行動態擴容,當裝載因子超過閾值時,進行擴充套件。 合理設定裝載因子的閾值,如果太大,會導致衝突過多;如果太小,會導致記憶體浪費嚴重。 裝載因子閾值的設定要權衡時間、空間複雜度。如果記憶體空間不緊張,對執行效率要求很高,可以降低負載因子的閾值;相反,如果記憶體空間緊張,對執行效率要求又不高,可以增加負載因子的值,甚至可以大於 1。
- 合理選擇衝突解決方法
雜湊表和連結串列的組合應用
LRU 快取淘汰演算法
藉助雜湊表和連結串列,我們可以把 LRU 快取淘汰演算法的時間複雜度降低為 O(1)。
利用雜湊表,可以讓在連結串列裡查詢某個資料的時間複雜度為O(1),而連結串列本身的刪除和插入操作時間複雜度為O(1)。
Redis 有序集合
舉個例子,比如使用者積分排行榜有這樣一個功能:我們可以透過使用者的 ID 來查詢積分資訊,也可以透過積分割槽間來查詢使用者 ID 或者姓名資訊。這裡包含 ID、姓名和積分的使用者資訊,就是成員物件,使用者 ID 就是 key,積分就是 score。
所以,如果我們細化一下 Redis 有序集合的操作,那就是下面這樣:
- 新增一個成員物件;
- 按照鍵值來刪除一個成員物件;
- 按照鍵值來查詢一個成員物件;
- 按照分值區間查詢資料,比如查詢積分在 [100, 356] 之間的成員物件;
- 按照分值從小到大排序成員變數;
如果我們僅僅按照分值將成員物件組織成跳錶的結構,那按照鍵值來刪除、查詢成員物件就會很慢,解決方法與 LRU 快取淘汰演算法的解決方法類似。我們可以再按照鍵值構建一個雜湊表,這樣按照 key 來刪除、查詢一個成員物件的時間複雜度就變成了 O(1)。同時,藉助跳錶結構,其他操作也非常高效。
Java LinkedHashMap
如果你熟悉 Java,那你幾乎天天會用到這個容器。我們之前講過,HashMap 底層是透過雜湊表這種資料結構實現的。而 LinkedHashMap 前面比 HashMap 多了一個“Linked”,這裡的“Linked”是不是說,LinkedHashMap 是一個透過連結串列法解決雜湊衝突的雜湊表呢?
實際上,LinkedHashMap 並沒有這麼簡單,其中的“Linked”也並不僅僅代表它是透過連結串列法解決雜湊衝突的。
你可能已經猜到了,LinkedHashMap 也是透過雜湊表和連結串列組合在一起實現的。我們先看下面這段程式碼:
// 10是初始大小,0.75是裝載因子,true是表示按照訪問時間排序HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true); m.put(3, 11); m.put(1, 12); m.put(5, 23); m.put(2, 22); m.put(3, 26); m.get(5);for (Map.Entry e : m.entrySet()) { System.out.println(e.getKey()); }
這段程式碼列印的結果是 1,2,3,5。
其實,按照訪問時間排序的 LinkedHashMap 本身就是一個支援 LRU 快取淘汰策略的快取系統?實際上,它們兩個的實現原理也是一模一樣的。
總結一下,實際上, LinkedHashMap 是透過雙向連結串列和雜湊表這兩種資料結構組合實現的。LinkedHashMap 中的“Linked”實際上是指的是雙向連結串列,並非指用連結串列法解決雜湊衝突。
為什麼雜湊表和連結串列經常一塊使用?
雜湊表這種資料結構雖然支援非常高效的資料插入、刪除、查詢操作,但是雜湊表中的資料都是透過雜湊函式打亂之後無規律儲存的。也就說,它無法支援按照某種順序快速地遍歷資料。如果希望按照順序遍歷雜湊表中的資料,那我們需要將雜湊表中的資料複製到陣列中,然後排序,再遍歷。
因為雜湊表是動態資料結構,不停地有資料的插入、刪除,所以每當我們希望按順序遍歷雜湊表中的資料的時候,都需要先排序,那效率勢必會很低。為了解決這個問題,我們將雜湊表和連結串列(或者跳錶)結合在一起使用。
最後
如果需要看影片學習,可以看:
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69952849/viewspace-2667521/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 資料結構與演算法-學習筆記(二)資料結構演算法筆記
- 資料結構與演算法-學習筆記(16)資料結構演算法筆記
- 資料結構與演算法學習筆記01資料結構演算法筆記
- 資料結構學習筆記資料結構筆記
- 《資料結構與演算法之美》學習筆記之開篇資料結構演算法筆記
- 資料結構與演算法學習-開篇資料結構演算法
- 資料結構學習筆記-佛洛依德演算法資料結構筆記演算法
- 資料結構學習筆記--棧資料結構筆記
- 資料結構學習筆記1資料結構筆記
- 資料結構與演算法分析學習筆記(四) 棧資料結構演算法筆記
- 資料結構和演算法-學習筆記(一)資料結構演算法筆記
- 資料結構學習筆記-堆排序資料結構筆記排序
- 資料結構與演算法-資料結構(棧)資料結構演算法
- 學習資料結構與演算法心得資料結構演算法
- 資料結構筆記資料結構筆記
- 資料結構——並查集 學習筆記資料結構並查集筆記
- 2.1資料結構學習筆記--佇列資料結構筆記佇列
- 資料結構與演算法學習總結--遞迴資料結構演算法遞迴
- 資料結構與演算法學習-連結串列下資料結構演算法
- 資料結構與演算法學習-連結串列上資料結構演算法
- 【資料結構篇】認識資料結構資料結構
- 《資料結構與演算法之美》資料結構與演算法學習書單 (讀後感)資料結構演算法
- 《資料結構與演算法分析》學習筆記-第七章-排序資料結構演算法筆記排序
- 《資料結構與演算法之美》學習筆記之複雜度資料結構演算法筆記複雜度
- 資料結構與演算法學習-陣列資料結構演算法陣列
- 學習JavaScript資料結構與演算法 (一)JavaScript資料結構演算法
- 資料結構與演算法課程筆記(二)資料結構演算法筆記
- [學習筆記] Splay & Treap 平衡樹 - 資料結構筆記資料結構
- 資料結構學習資料結構
- 資料結構筆記——概述資料結構筆記
- 資料結構筆記——棧資料結構筆記
- 資料結構之Set | 讓我們一塊來學習資料結構資料結構
- 資料結構之Stack | 讓我們一塊來學習資料結構資料結構
- 資料結構之Queue | 讓我們一塊來學習資料結構資料結構
- 資料結構之LinkedList | 讓我們一塊來學習資料結構資料結構
- 資料結構學習之樹結構資料結構
- 資料結構學習筆記-迪傑斯特拉演算法資料結構筆記演算法
- 資料結構和演算法學習筆記十六:紅黑樹資料結構演算法筆記