來年加薪必備,2020年攻破資料結構與演算法學習筆記-資料結構篇

yilian發表於2019-12-09

著名資料專家沃斯曾說:演算法+資料結構=程式

今天我們就來講講資料結構

1. 陣列

陣列(Array)是一種 線性表資料結構。它用一組 連續的記憶體空間,來儲存一組具有 相同型別的資料。具有的特性:

  1. 線性表
  2. 連續的記憶體空間
  3. 相同型別的資料
  4. 可以隨機訪問
  5. 資料操作比較低效,平均情況時間複雜度為 O(n)

陣列為什麼下標從0開始

  1. 由於陣列是是一種線性表資料結構。它用一組 連續的記憶體空間,來儲存一組具有 相同型別的資料。 所以:
  • 如果下標從0開始: 計算下標為k的物件的地址的公式為:a[k]_address = base_address + k * type_size
  • 如果下標從1開始: 計算下標為k的物件的地址的公式為:a[k]_address = base_address + (k-1) * type_size 對於 CPU 來說,就是多了一次減法指令。
  1. C 語言設計者用 0 開始計數陣列下標,之後的 Java、JavaScript 等高階語言都效仿了 C 語言。

容器能否完全替代陣列?

例如Java的ArrayList,ArrayList 最大的優勢就是可以將很多陣列操作的細節封裝起來。比如前面提到的陣列插入、刪除資料時需要搬移其他資料等。另外,它還有一個優勢,就是支援動態擴容。

那麼,作為高階語言程式設計者,是不是陣列就無用武之地了呢?當然不是,有些時候,用陣列會更合適些,總的來說,對於業務開發,直接使用容器就足夠了,省時省力。畢竟損耗一丟丟效能,完全不會影響到系統整體的效能。但如果你是做一些非常底層的開發,比如開發網路框架,效能的最佳化需要做到極致,這個時候陣列就會優於容器,成為首選。

2. 連結串列 (Linked list)

不需要一塊連續的記憶體空間,它透過“指標”將一組零散的記憶體塊串聯起來使用。

image
image
幾種常見的連結串列形式:
1\. 單連結串列
2\. 迴圈連結串列
3\. 雙向連結串列 (空間換時間思想)
4\. 雙向迴圈列表

與陣列的對比:

image
image

不過,陣列和連結串列的對比,並不能侷限於時間複雜度。而且,在實際的軟體開發中,不能僅僅利用複雜度分析就決定使用哪個資料結構來儲存資料。

寫連結串列程式碼的幾個技巧:
1\. 理解指標或引用的含義、警惕指標丟失和記憶體洩漏
2\. 利用哨兵簡化實現難度
3\. 重點留意邊界條件處理
4\. 舉例畫圖、輔助思考
複製程式碼

寫連結串列程式碼是最考驗邏輯思維能力的。因為,連結串列程式碼到處都是指標的操作、邊界條件的處理,稍有不慎就容易產生 Bug。連結串列程式碼寫得好壞,可以看出一個人寫程式碼是否夠細心,考慮問題是否全面,思維是否縝密。所以,這也是很多面試官喜歡讓人手寫連結串列程式碼的原因。所以,這一節講到的東西,你一定要自己寫程式碼實現一下,才有效果。

  1. 單連結串列反轉
  2. 連結串列中環的檢測
  3. 兩個有序的連結串列合併
  4. 刪除連結串列倒數第 n 個結點
  5. 求連結串列的中間結點

3. 棧

  • 用陣列實現的 順序棧
  • 用連結串列實現的 鏈式棧
  • 出棧入棧時間複雜度 空間複雜度都是O(1)
  • 先進後出

應用:

  • 1,函式的臨時變數的儲存銷燬
  • 2,表示式求值
  • 3,瀏覽器的前進後退

4. 佇列

特點:先進先出

  • 用陣列實現 順序佇列
  • 用連結串列實現 鏈式佇列

佇列擴充:

  • 迴圈佇列 解決用陣列實現的佇列需要資料遷移的問題 隊空:head == tail 隊滿:(tail+1)%n=head。
  • 阻塞佇列 佇列滿了時,不給入隊。 生產者 - 消費者模型
  • 併發佇列 執行緒安全的佇列我們叫作併發佇列

5. 跳錶

我們知道,陣列支援快速的隨機訪問,而連結串列不支援,這樣的話,就不能用二分查詢法來對連結串列進行快速查詢。實際上,我們只需要對連結串列稍加改造,就可以支援類似“二分”的查詢演算法。我們把改造之後的資料結構叫作跳錶(Skip list)。

跳錶,其實就是對 有序連結串列建立多級“索引”,每兩個(也可以是其他數量)結點提取一個結點到上一級,我們把抽出來的那一級叫作索引或索引層。你可以看我畫的圖。圖中的 down 表示 down 指標,指向下一級結點。

image
image

如果我們現在要查詢某個結點,比如 16。我們可以先在索引層遍歷,當遍歷到索引層中值為 13 的結點時,我們發現下一個結點是 17,那要查詢的結點 16 肯定就在這兩個結點之間。然後我們透過索引層結點的 down 指標,下降到原始連結串列這一層,繼續遍歷。這個時候,我們只需要再遍歷 2 個結點,就可以找到值等於 16 的這個結點了。這樣,原來如果要查詢 16,需要遍歷 10 個結點,現在只需要遍歷 7 個結點。

我舉的例子資料量不大,查詢效率的提升也並不明顯。為了讓你能真切地感受索引提升查詢效率。我畫了一個包含 64 個結點的連結串列,按照前面講的這種思路,建立了五級索引。

image
image

從圖中我們可以看出,原來沒有索引的時候,查詢 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 個結點。如果我們把每層索引的結點數寫出來,就是一個等比數列。

image
image

這幾級索引的結點總和就是 n/2+n/4+n/8…+8+4+2=n-2。所以,跳錶的空間複雜度是 O(n)。也就是說,如果將包含 n 個結點的單連結串列構造成跳錶,我們需要額外再用接近 n 個結點的儲存空間。那我們有沒有辦法降低索引佔用的記憶體空間呢?

我們前面都是每兩個結點抽一個結點到上級索引,如果我們每三個結點或五個結點,抽一個結點到上級索引,是不是就不用那麼多索引結點了呢?

第一級索引需要大約 n/3 個結點,第二級索引需要大約 n/9 個結點。每往上一級,索引結點個數都除以 3。為了方便計算,我們假設最高一級的索引結點個數是 1。我們把每級索引的結點個數都寫下來,也是一個等比數列。

image
image

透過等比數列求和公式,總的索引結點大約就是 n/3+n/9+n/27+…+9+3+1=n/2。儘管空間複雜度還是 O(n),但比上面的每兩個結點抽一個結點的索引構建方法,要減少了一半的索引結點儲存空間。

實際上,在軟體開發中,我們不必太在意索引佔用的額外空間。在講資料結構和演算法時,我們習慣性地把要處理的資料看成整數,但是在實際的軟體開發中,原始連結串列中儲存的有可能是很大的物件,而索引結點只需要儲存關鍵值和幾個指標,並不需要儲存物件, 所以當物件比索引結點大很多時,那索引佔用的額外空間就可以忽略了。

跳錶索引動態更新

當我們不停地往跳錶中插入資料時,如果我們不更新索引,就有可能出現某 2 個索引結點之間資料非常多的情況。極端情況下,跳錶還會退化成單連結串列。

image
image

作為一種動態資料結構,我們需要某種手段來維護索引與原始連結串列大小之間的平衡,也就是說,如果連結串列中結點多了,索引結點就相應地增加一些,避免複雜度退化,以及查詢、插入、刪除操作效能下降。

當我們往跳錶中插入資料的時候,我們可以選擇同時將這個資料插入到部分索引層中。如何選擇加入哪些索引層呢?

我們透過一個隨機函式,來決定將這個結點插入到哪幾級索引中,比如隨機函式生成了值 K,那我們就將這個結點新增到第一級到第 K 級這 K 級索引中。

image
image

隨機函式的選擇很有講究,從機率上來講,能夠保證跳錶的索引大小和資料大小平衡性,不至於效能過度退化。

跳錶特點:

  1. 前提是有序連結串列
  2. 動態資料結構
  3. 支援快速的查詢、插入、刪除操作,時間複雜度為O(logn)
  4. 表面上空間複雜度是O(n),但是因為索引只需要儲存關鍵值和幾個指標,並不需要儲存物件,所以當物件比索引結點大很多時,那索引佔用的額外空間就可以忽略了。
  5. 和紅黑樹相比的優勢:當需要按區間查詢資料時,跳錶可以做到 O(logn) 的時間複雜度定位區間的起點,然後在原始連結串列中順序往後遍歷就可以了。
  6. 程式碼實現比紅黑樹容易很多。

6. 雜湊表

image
image

特性:

  1. 基於陣列可以根據下標快速查詢的特點
  2. 利用雜湊函式,可以把key雜湊後得出正整數,也就是陣列的下標,進行快速查詢。
  3. 插入、查詢、刪除的時間複雜度都是O(1)

雜湊衝突:

  1. 雜湊值很大可能會重複,所以就有了雜湊衝突
  2. 解決雜湊衝突的兩種方式:  開放定址法:線性探測、二次探測、雙重雜湊 優點: 雜湊表中的資料都儲存在陣列中,可以有效地利用 CPU 快取加快查詢速度。而且,這種方法實現的雜湊表,序列化起來比較簡單。 缺點:1.刪除資料的時候比較麻煩,需要特殊標記已經刪除掉的資料;2.裝載因子的上限不能太大,這也導致這種方法比連結串列法更浪費記憶體空間。 總結:當資料量比較小、裝載因子小的時候,適合採用開放定址法。這也是 Java 中的ThreadLocalMap使用開放定址法解決雜湊衝突的原因。  連結串列法 優點:1.記憶體的利用率比開放定址法要高,需要用的時候再申請;2.對大裝載因子的容忍度更高;3.可以用跳錶、紅黑樹來代替普通的連結串列,這樣的話即使是極端情況下,時間複雜度也只是O(logn) 總結:比較適合儲存大物件、大資料量的雜湊表,而且,比起開放定址法,它更加靈活,支援更多的最佳化策略,比如用紅黑樹代替連結串列。
  3. 用裝載因子來表示空位的多少 裝載因子 = 填入表中的元素個數/雜湊表的長度 裝載因子越大,說明空閒位置越少,衝突越多,雜湊表的效能會下降。

工業級水平的雜湊表:

最終要求:

  1. 支援快速的查詢、插入、刪除操作;
  2. 記憶體佔用合理,不能浪費過多的記憶體空間;
  3. 效能穩定,極端情況下,雜湊表的效能也不會退化到無法接受的情況。

具體設計方向:

  1. 雜湊函式要求: 儘可能要設計得讓雜湊值均勻分佈 不能設計得太複雜計算時間太久
  2. 支援動態擴容 根據裝載因子大小來進行動態擴容,當裝載因子超過閾值時,進行擴充套件。 合理設定裝載因子的閾值,如果太大,會導致衝突過多;如果太小,會導致記憶體浪費嚴重。 裝載因子閾值的設定要權衡時間、空間複雜度。如果記憶體空間不緊張,對執行效率要求很高,可以降低負載因子的閾值;相反,如果記憶體空間緊張,對執行效率要求又不高,可以增加負載因子的值,甚至可以大於 1。
  3. 合理選擇衝突解決方法

雜湊表和連結串列的組合應用

LRU 快取淘汰演算法

藉助雜湊表和連結串列,我們可以把 LRU 快取淘汰演算法的時間複雜度降低為 O(1)。

image
image

利用雜湊表,可以讓在連結串列裡查詢某個資料的時間複雜度為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/,如需轉載,請註明出處,否則將追究法律責任。

相關文章