數據結構:「答應我,別再逃避我了好嗎?」

本文咱們來介紹一下編程中常見的一些數據結構。html

爲何要學習數據結構?java

隨着業務場景愈來愈複雜,系統併發量越來也高,要處理的數據愈來愈多,特別是大型互聯網的高併發、高性能、高可用系統,對技術要求愈來愈高,咱們引入各類中間件,這些中間件底層涉及到的各類數據結構和算法,是其核心技術之一。如:node

  • ElasticSearch中用於壓縮倒排索引內存存儲空間的FST,用於查詢條件合併的SkipList,用於提升範圍查找效率的BKDTree;git

  • 各類分庫分表技術的核心:hash算法;github

  • Dubbo或者Nginx等的負載均衡算法;web

  • MySQL索引中的B樹、B+樹等;算法

  • Redis使用跳躍表做爲有序集合鍵的底層實現之一;數據庫

  • Zookeeper的節點樹;編程

  • J.U.C併發包的各類實現的阻塞隊列,AQS底層實現涉及到的鏈式等待隊列;後端

  • JDK對HashMap的Hash衝突引入的優化數據結構紅黑樹…

能夠發現,數據結構和算法真的是無處不在,做爲一個熱愛技術,拒絕粘貼複製的互聯網工程師,怎麼能不掌握這些核心技術呢?

與此同時,若是你有耐心聽8個小時通俗易懂的數據結構入門課,我強烈建議你看一下如下這個視頻,來自一位熱衷於分享的Google工程師:
Data Structures Easy to Advanced Course - Full Tutorial from a Google Engineer
https://www.youtube.com/watch?v=RBSGKlAvoiM


閱讀完本文,你將瞭解到一些常見的數據結構(或者溫習,由於大部分朋友大學裏面其實都是學過的)。在每一個數據結構最後一小節都會列出代碼實現,以及相關熱門的算法題,該部分須要你們本身去探索與書寫。只有本身能熟練的編寫各類數據結構的代碼纔是真正的掌握了,你們別光看,動手寫起來。閱讀完本文,您將瞭解到:

  • 抽象數據類型與數據結構的關係;

  • 如何評估算法的複雜度;

  • 瞭解如下數據結構,而且掌握其實現思路:數組,鏈表,棧,隊列,優先級隊列,索引式優先隊列,二叉樹,二叉搜索樹BST,平衡二叉搜搜書BBST,AVL樹,HashTable,並查集,樹狀數組,後綴數組。

  • 文章裏面不會貼這些數據結構的完整實現,可是會附帶實現的連接,同時每種數據類型最後一節的相關實現以及練習題,建議你們多動手嘗試編寫這些練習題,以及嘗試本身動手實現這些數據結構。

一、抽象數據類型

抽象數據類型(ADT abstract data type):是數據結構的抽象,它僅提供數據結構必須遵循的接口。接口並未提供有關應如何實現某種內容或以哪一種編程語言的任何特定詳細信息。

下標列舉了抽象數據類型和數據結構之間的構成關係:

二、時間與空間複雜度

咱們通常會關注程序的兩個問題:

  • 時間複雜度:這段程序須要花費多少時間才能夠執行完成?

  • 空間複雜度:執行這段代碼須要消耗多大的內存?

有時候時間複雜度和空間複雜度兩者不能兼得,咱們只能從中取一個平衡點。

下面咱們經過Big O表示法來描述算法的複雜度。

2.一、時間複雜度

2.1.一、Big-O

Big-O表示法給出了算法計算複雜性的上限。

T(n) = O(f(n)),該公式又稱爲算法的漸進時間複雜度,其中f(n)函數表示每行代碼執行次數之和,O表示執行時間與之造成正比例關係。

常見的時間複雜度量級,從上到下時間複雜度愈來愈大,執行效率愈來愈低:

  • 常數階 Constant Time: O(1)

  • 對數階 Logarithmic Time: O(log(n))

  • 線性階 Linear Time: O(n)

  • 線性對數階 Linearithmic Time: O(nlog(n))

  • 平方階 Quadratic Time: O(n^2)

  • 立方階 Cubic Time: O(n^3)

  • n次方階 Exponential Time: O(b^n), b > 1

  • 指數階 Factorial Time: O(n!)

下面是我從 Big O Cheat Sheet[1]引用過來的一張表示各類度量級的時間複雜度圖表:

2.1.二、如何得出Big-O

所謂Big-O表示法,就是要得出對程序影響最大的那個因素,用於衡量複雜度,舉例說明:

O(n + c) => O(n),常量能夠忽略;

O(cn) => O(n), c > 0,常量能夠忽略;

2log(n)3 + 3n2 + 4n3 + 5 => O(n3),取對程序影響最大的因素。

練習:請看看下面代碼的時間複雜度:

image-20200411175500608

答案依次爲:O(1), O(n), O(log(n)), O(nlog(n)), O(n^2)

第三個如何得出對數?假設循環x次以後退出循環,也就是說 2^x = n,那麼 x = log2(n),得出O(log(n))

2.二、空間複雜度

空間複雜度是對一個算法在運行過程當中佔用存儲空間的大小的衡量。

  • O(1):存儲空間不隨變量n的大小而變化;

  • O(n):如:new int[n];

2.三、經常使用數據結構複雜度

一些經常使用的數據結構的複雜度(注:如下表格圖片來源於 Big O Cheat Sheet[1]):

2.四、經常使用排序算法複雜度

(注:如下表格圖片來源於 Big O Cheat Sheet[1])

關於複雜度符號

O:表示漸近上限,即最差時間複雜度;

Θ:表示漸近緊邊界,即平均時間複雜度;

Ω:表示漸近下界,即最好時間複雜度;

三、靜態數組和動態數組

3.一、靜態數組

靜態數組是固定長度的容器,其中包含n個可從[0,n-1]範圍索引的元素。

問:「可索引」是什麼意思?

答:這意味着數組中的每一個插槽/索引均可以用數字引用。

3.1.一、使用場景

  • 1)存儲和訪問順序數據

  • 2)臨時存儲對象

  • 3)由IO例程用做緩衝區

  • 4)查找表和反向查找表

  • 5)可用於從函數返回多個值

  • 6)用於動態編程中以緩存子問題的答案

3.1.二、靜態數組特色

  • 只能由數組下標訪問數組元素,沒有其餘的方式了;

  • 第一個下標爲0;

  • 下標超過範圍了會觸發數組越界錯誤。

3.二、動態數組

動態數組的大小能夠增長和縮小。

3.2.一、如何實現一個動態數組

使用一個靜態數組:

  • 建立具備初始容量的靜態數組;

  • 將元素添加到基礎靜態數組,同時跟蹤元素的數量;

  • 若是添加元素將超出容量,則建立一個具備兩倍容量的新靜態數組,而後將原始元素複製到其中。

3.三、時間複雜度

3.四、編程實踐

  • JDK中的實現:java.util.ArrayList

  • 練習:

  • 兩數之和:
    https://leetcode-cn.com/problems/two-sum/

  • 刪除排序數組中的重複項:
    https://leetcode-cn.com/problems/remove-duplicates-from-sorted-array/

  • 楊輝三角:
    https://leetcode-cn.com/problems/pascals-triangle/

  • 最大子序和:
    https://leetcode-cn.com/problems/maximum-subarray/

  • 旋轉數組:
    https://leetcode-cn.com/problems/rotate-array/

四、鏈表

4.一、使用場景

  • 在許多列表,隊列和堆棧實現中使用;

  • 很是適合建立循環列表;

  • 能夠輕鬆地對諸如火車等現實世界的物體進行建模;

  • 某些特定的Hashtable實現用於處理散列衝突;

  • 用於圖的鄰接表的實現中。

4.二、術語

Head:鏈表中的第一個節點;

Tail:鏈表中的最後一個節點;

Pointer:指向下一個節點;

Node:一個對象,包含數據和Pointer。

4.三、實現思路

這裏使用雙向鏈表做爲例子進行說明。

4.3.一、插入節點

往第三個節點插入:x

從鏈表頭遍歷,直到第三個節點,而後執行以下插入操做:

遍歷到節點位置,把新節點指向先後繼節點:

後繼節點回溯鏈接到新節點,並移除舊的回溯關係:

前繼節點遍歷鏈接到新節點,並移除舊的遍歷關係:

完成:

注意指針處理順序,避免在添加過程當中致使遍歷出現異常。

4.3.二、刪除節點

刪除c節點:

從鏈表頭遍歷,直到找到c節點,而後把c節點的前繼節點鏈接到c的後繼接節點:

把c節點的後繼節點鏈接到c的前繼節點:

移除多餘指向關係以及c節點:

完成:

一樣的,注意指針處理順序,避免在添加過程當中致使遍歷出現異常。

4.四、時間複雜度

4.五、編程實踐

  • JDK中的實現:java.util.LinkedList

  • 練習:

  • 反轉鏈表:
    https://leetcode-cn.com/problems/reverse-linked-list/

  • 迴文鏈表:
    https://leetcode-cn.com/problems/palindrome-linked-list

  • 兩數相加:
    https://leetcode-cn.com/problems/add-two-numbers

  • 複製帶隨機指針的鏈表:
    https://leetcode-cn.com/problems/copy-list-with-random-pointer

五、棧

堆棧是一種單端線性數據結構,它經過執行兩個主要操做(即推入push和彈出pop)來對現實世界的堆棧進行建模。

5.一、使用場景

  • 文本編輯器中的撤消機制;

  • 用於編譯器語法檢查中是否匹配括號和花括號;

  • 建模一堆書或一疊盤子;

  • 在後臺使用,經過跟蹤之前的函數調用來支持遞歸;

  • 可用於在圖上進行深度優先搜索(DFS)。

5.二、編程實戰

5.2.一、語法校驗

給定一個由如下括號組成的字符串:()[] {},肯定括號是否正確匹配。

例如:({}{}) 匹配,{()(]} 不匹配。

思路:

凡是遇到( { [ 都進行push入棧操做,遇到 ) } ] 則pop棧中的元素,看看是否與當前處理的元素匹配:

匹配完成以後,棧必須是空的。

5.三、複雜度

5.四、編程實踐

  • 基於數組的實現:java.util.Stack

  • 基於鏈表的實現:stack
    https://github.com/arthinking/java-tech-stack-resource/blob/master/src/main/java/com/itzhai/algorithm/datastructures/stack/Stack.java

  • 練習:

  • 最小棧:
    https://leetcode-cn.com/problems/min-stack

  • 有效的括號:
    https://leetcode-cn.com/problems/valid-parentheses

  • 搜索旋轉排序數組:https://leetcode-cn.com/problems/search-in-rotated-sorted-array

  • 接雨水:
    https://leetcode-cn.com/problems/trapping-rain-water

六、隊列

隊列是一種線性數據結構,它經過執行兩個主要操做(即入隊enqueue和出隊dequeue)來對現實世界中的隊列進行建模。

6.一、術語

  • Dequeue:出隊,相似命名:Polling

  • Enqueue:入隊,相似命名:Adding,Offering

  • Queue Front:對頭

  • Queue Back:隊尾

隊列底層可使用數組或者鏈表實現

6.二、使用場景

  • 任何排隊等候的隊伍均可以建模爲Queue,例如在電影院裏的隊伍;

  • 可用於有效地跟蹤最近添加的x個元素;

  • 用於Web服務器請求管理,保證服務器先接受的先處理;

  • 圖的廣度優先搜索(BFS)。

6.2.一、請用隊列實現圖的廣度優先遍歷

提示:

遍歷順序:0 -> 2, 5 -> 6 -> 1 -> 9, 3, 2, 7 -> 3, 4, 8, 9

6.三、時間複雜度

6.四、編程實踐

  • 基於數組實現的Queue:ArrayQueue
    https://github.com/arthinking/java-tech-stack-resource/blob/master/src/main/java/com/itzhai/algorithm/datastructures/queue/ArrayQueue.java

  • 基於鏈表實現的Queue:LinkedListQueue
    https://github.com/arthinking/java-tech-stack-resource/blob/master/src/main/java/com/itzhai/algorithm/datastructures/queue/LinkedListQueue.java

  • 練習:

  • 設計循環隊列:
    https://leetcode-cn.com/problems/design-circular-queue/

  • 用隊列實現棧:
    https://leetcode-cn.com/problems/implement-stack-using-queues

七、優先級隊列PQ

優先級隊列是一種抽象數據類型(ADT),其操做相似於普通隊列,不一樣之處在於每一個元素都具備特定的優先級。優先級隊列中元素的優先級決定了從PQ中刪除元素的順序。

注意:優先級隊列僅支持可比較的數據,這意味着插入優先級隊列的數據必須可以以某種方式(從最小到最大或從最大到最小)進行排序。這樣咱們就能夠爲每一個元素分配相對優先級。

爲了實現優先級隊列,咱們必須使用到堆 Heap。

7.一、什麼是堆

堆是基於樹的非線性結構DS,它知足堆不變式:

  • 堆中某個節點的值老是不大於或不小於其父節點的值;

二叉堆是一種弄特殊的堆,其知足:

  • 是一顆徹底二叉樹[2]

將根節點最大的堆叫作最大堆或大根堆,根節點最小的堆叫作最小堆或小根堆。

在同級兄弟或表親之間沒有隱含的順序,對於有序遍歷也沒有隱含的順序。

堆一般使用隱式堆數據結構實現,隱式堆數據結構是由數組(固定大小或動態數組)組成的隱式數據結構,其中每一個元素表明一個樹節點,其父/子關係由其索引隱式定義。將元素插入堆中或從堆中刪除後,可能會違反堆屬性,而且必須經過交換數組中的元素來平衡堆。

7.二、使用場景

  • 在Dijkstra最短路徑算法的某些實現中使用;

  • 每當您須要動態獲取「次佳」或「次佳」元素時;

  • 用於霍夫曼編碼(一般用於無損數據壓縮);

  • 最佳優先搜索(BFS)算法(例如A*)使用PQ連續獲取下一個最有但願的節點;

  • 由最小生成樹(MST)算法使用。

7.三、最小堆轉最大堆

問題:大多數編程語言的標準庫一般只提供一個最小堆,但有時咱們須要一個最大PQ。

解決方法

  • 因爲優先級隊列中的元素是可比較的,所以它們實現了某種可比較的接口,咱們能夠簡單地取反以實現最大堆;

  • 也能夠先把全部數字取反,而後排序插入PQ,而後在取出數字的時候再次取反便可;

7.四、實現思路

優先級隊列一般使用堆來實現,由於這使它們具備最佳的時間複雜性。

優先級隊列是抽象數據類型,所以,堆並非實現PQ的惟一方法。例如,咱們可使用未排序的列表,但這不會給咱們帶來最佳的時間複雜度,如下數據結構均可以實現優先級隊列:

  • 二叉堆

  • 斐波那契堆;

  • 二項式堆;

  • 配對堆;

這裏咱們選取二叉堆來實現,二叉堆是一顆徹底二叉樹[2]

7.4.一、二叉堆排序映射到數組中

二叉堆索引與數組一一對應:

二叉堆排好序以後,即按照索引填充到數組中:

索引規則:

  • i節點左葉子節點:2i + 1

  • i節點右葉子節點:2i + 2

7.4.二、添加元素到二叉堆

insert(0)

以下圖,首先追加到最後一個節點,而後一層一層的跟父節點比較,若是比父節點小,則與父節點交換位置。

7.4.三、從二叉堆移除元素

poll() 移除第一個元素

第一個元素與最後一個元素交換位置,而後刪除掉最後一個元素;


第一個元素嘗試sink 下沉操做:一直與子節點對比,若是大於任何一個子節點,則與該子節點對換位置;


remove(7) 移除特定的元素

依次遍歷數組,找到值爲7的元素,讓該元素與最後一個元素對換,而後刪除掉最後一個元素;


  • 被刪除索引對應節點嘗試進行sink 下沉操做:與全部子節點比較,判斷是否大於子節點,若是小於,那麼就與對應的子節點交換位置,而後一層一層往下依次對比交換;

  • 若是最終該元素並無實際下沉,那麼嘗試進行swim 上浮操做:與父節點比較,判斷是否小於父節點,若是是則與父節點對換位置,而後一層一層往上依次對比交換;

思考:請問如何構造一個小頂堆呢?

遍歷數組,全部元素依次與子節點對比,若是大於子節點則交換。

7.五、嘗試讓刪除操做時間複雜度變爲O(log(n))

以上刪除算法的效率低下是因爲咱們必須執行線性搜索O(n)以找出元素的索引位置。

咱們能夠嘗試使用哈希表進行查找以找出節點的索引位置。若是同一個值在多個位置出現,那麼咱們能夠維護一個特定節點值映射到的索引的Set或者Tree Set中。

數據結構以下:

這樣咱們在刪除的時候就能夠經過HashTable定位到具體的元素了。

7.六、時間複雜度

7.七、編程實踐

  • JDK中的實現:java.util.PriorityQueue

  • 基於最小堆實現的優先級隊列 BinaryHeap:
    https://github.com/arthinking/java-tech-stack-resource/blob/master/src/main/java/com/itzhai/algorithm/datastructures/priorityqueue/BinaryHeap.java

  • 最小堆實現的優先級隊列,優化了刪除方法:
    https://github.com/arthinking/java-tech-stack-resource/blob/master/src/main/java/com/itzhai/algorithm/datastructures/priorityqueue/BinaryHeapQuickRemovals.java

八、索引式優先隊列 IPQ

索引優先級隊列(Indexed Priority Queue IPQ)是傳統的優先級隊列變體,除了常規的PQ操做以外,它還提供了索引用於支持鍵值對的快速更新和刪除。

咱們知道前面的優先級隊列的元素都是存放到一個list裏面的,咱們想知道知道某一個值在優先級隊列中的位置,也是須要遍歷一個個對比才知道的,要是有重複的值,那就區分不了了。既然找不到元素,那麼對元素的更新和刪除也就無從提及了。

爲此,咱們引入了以下兩個索引:節點索引ki位置索引im

如:

  • 請查找節點ki所在的優先級位置:能夠很快能夠從表1中找到 pm[ki];

  • 請查找優先級位置im存的是什麼節點:能夠很快從表2中找到節點的索引 ki[im]

與構造或更新刪除PQ不一樣的是,IPQ須要額外維護這些索引的關係。

8.一、時間複雜度

8.二、編程實踐

  • MinIndexedDHeap.java
    https://github.com/arthinking/java-tech-stack-resource/blob/master/src/main/java/com/itzhai/algorithm/datastructures/priorityqueue/MinIndexedDHeap.java

九、二叉樹與二叉搜索樹BST

二叉樹(Binary Tree)是每一個節點最多具備兩個子節點的樹;

二叉搜索樹(Binary Search Tree)是知足如下條件二叉樹:左子樹的元素較小,右子樹的元素較大。

9.一、使用場景

  • 某些map和set的ADT的實現;

  • 紅黑樹;

  • AVL樹;

  • 伸展樹(Splay Tree 分裂樹);

  • 用於二叉堆的實現;

  • 語法樹;

  • Treap-機率DS(使用隨機BST)

9.二、實現思路

9.2.一、插入元素

  • 二叉搜索樹(BST)元素必須具備可比性,以便咱們能夠在樹中對其進行排序;

  • 插入元素時,咱們將其值與當前節點中存儲的值進行比較:

  • 小於節點值:向下遞歸左子樹;

  • 大於節點值:向下遞歸右子樹;

  • 等於節點值:處理重複值;

  • 不存在節點:建立新節點。

極端場景:

這種狀況就變爲了線性結構,比較糟糕,這就是平衡二叉搜索樹出現的緣由。

9.2.二、移除元素

移除元素能夠分爲兩步:

  • 找到咱們想要移除的元素;

  • 若是存在後續節點,那麼咱們用後續節點替換掉要刪除的節點;

移除會有如下三種狀況:

9.2.2.一、移除的元素是一個葉子節點

找到對應待移除的節點,直接刪除掉便可:

remove(16):

9.2.2.二、移除的元素下面有左子樹或者右子樹

若是移除的元素下面帶有左子樹或者右子樹,那麼:找到對應待移除的節點,用子樹的根節點做爲移除元素的後繼者,替換掉須要移除的元素便可:

9.2.2.三、移除的元素下面有左子樹和右子樹

若是移除的元素下面帶有左子樹和右子樹,那麼應該用左子樹仍是右子樹中的節點做爲刪除元素的後繼者呢?

答案是二者均可以!後繼者能夠是左側子樹中的最大值,也能夠是右側子樹中的最小值。

下面咱們執行remove(8),統一選擇使用右子樹中的最小值。

具體步驟:

  1. 查找到須要刪除的元素;

    在其右子樹中尋找到最小值的節點;


  2. 最小值的節點和待刪除元素的值互換;

  3. 使用9.2.2.2的步驟刪除掉原來最小值節點位置的節點;

9.2.三、查找元素

BST搜索元素會出現如下四種狀況之一:

  • 咱們命中了一個空節點,這時咱們知道該值在咱們的BST中不存在;

  • 比較器值等於0,說明找到了元素;

  • 比較器值小於0,說明元素在左子樹中;

  • 比較器值大於0,說明元素在右子樹中。

如下是find(6)操做:

9.三、樹的遍歷

能夠分爲深度優先遍歷和廣度優先遍歷。而深度優先遍歷又分爲:前序遍歷、中序遍歷、後序遍歷、層序遍歷。

9.3.一、深度優先遍歷

深度優先遍歷都是經過遞歸來實現的,對應的數據結構爲棧。

9.3.1.一、前序遍歷

在遞歸方法最開始處理節點信息,代碼邏輯:

1void preOrder(node) {
2  if (node == nullreturn;
3  print(node.value);
4  preOrder(node.left);
5  preOrder(node.right);
6}

以下二叉樹將得出如下遍歷結果:A B D H I E J C F K G

9.3.1.二、中序遍歷

在遞歸調用完左子節點,準備右子節點以前處理節點信息,代碼邏輯:

1void inOrder(node) {
2  if (node == nullreturn;
3  inOrder(node.left);
4  print(node.value);
5  inOrder(node.right);
6}

二叉搜索樹使用中序遍歷,會獲得一個排好序的列表。

如下二叉搜索樹將得出以下遍歷結果:1 3 4 5 6 7 8 15 16 20 21

9.3.1.三、後序遍歷

在遞歸完左右子樹以後,再處理節點信息,代碼邏輯:

1void postOrder(node) {
2  if (node == nullreturn;
3  postOrder(node.left);
4  postOrder(node.right);
5  print(node.value);
6}

如下二叉樹得出以下遍歷結果:1 4 3 6 7 5 16 15 21 20 8

9.3.二、廣度優先遍歷

在廣度遍歷中,咱們但願一層一層的處理節點信息,經常使用的數據結構爲隊列。每處理一個節點,同時把左右子節點放入隊列,而後從隊列中取節點進行處理,處理的同時把左右子節點放入隊列,反覆如此,直至處理完畢。

9.四、BST時間複雜度

9.五、編程實踐

  • BST.java:
    https://algs4.cs.princeton.edu/32bst/BST.java.html

  • 練習:

  • BiNode:
    https://leetcode-cn.com/problems/binode-lcci/

十、平衡二叉搜索樹BBST

平衡二叉搜索樹(Balanced Binary Search Tree BBST)是一種自平衡的二叉搜索樹。因此自平衡意味着會自行調整,以保持較低(對數)的高度,從而容許更快的操做,例如插入和刪除。

10.一、樹旋轉

大多數BBST算法核心組成部分是:樹不變式樹旋轉的巧妙用法。

樹不變式:是在每次操做後必須知足的屬性/規則。爲了確保始終知足不變式,一般會應用一系列樹旋轉

在樹旋轉的過程當中須要確保保持BST的不變式:左子樹比較小,右子樹比較大。

10.1.一、更新單向指針的BBST

爲此,咱們可使用如下操做實現右旋轉,注意觀察宣傳先後,BST的不變式:

1public void rightRotate(Node a) {
2  Node b = a.left;
3  a.left = b.right;
4  b.right = a;
5  return b;
6}

以下圖,咱們準備執行rightRotate(a)

爲何能夠這樣變換呢?

仍是那個原則:BST不變式

全部BBST都是BST,所以對於每一個節點n,都有:n.left <n && n < n.right。(這裏的前提是沒有重複元素)

咱們在變換操做的時候只要確保這個條件成當即可,即保持BST不變性成立的前提下,咱們能夠對樹中的值和節點進行隨機變換/旋轉。

注意,如上圖,若是a節點還有父節點p,那麼就把p節點原來指向a節點變動爲指向b節點。

10.1.二、更新雙向指針的BBST

在某些須要常常方位父節點或者同級節點的BBST中,咱們就不是像上面那樣最多更新3個指針,而是必須更新6個指針了,操做會複製些許。

如下是代碼實現:

 1public void rightRotate(Node a) {
2  Node p = a.parent
3  Node b = a.left;
4  a.left = b.right;
5  if (b.right != null) {
6    b.right.parent = a;
7  }
8  b.right = a;
9  a.parent = b;
10  b.parent = p;
11  // 更新父指針
12  if (p != null) {
13    if (p.left == a) {
14      p.left = b;
15    } else {
16      p.right = b;
17    }
18  }
19  return b;
20}

BBST經過在不知足其不變性時執行一系列左/右樹旋轉來保持平衡。

10.二、AVL樹

AVL樹是平衡二叉搜索樹的一種,它容許以O(log(n))的複雜度進行插入、搜索和刪除操做。

AVL樹是第一種被發現的BBST,後來出現了其餘類型包括:2-3 tree、AA tree、scapegoat tree(替罪羊樹)、red-black tree(紅黑樹)。

使AVL樹保持平衡的屬性稱爲平衡因子(balanced factor BF)。

BF(node) = H(node.right) - H(node.left)

其中H(x)是節點的高度,爲x和最遠的葉子之間的邊數

AVL樹中使其保持平衡的不變形是要求平衡因子BF始終爲-一、0或者1。

10.2.一、節點存儲信息

  • 節點存儲的實際值,此值必須能夠比較;

  • BF的值;

  • 節點在樹中的高度;

  • 指向左右子節點的指針。

10.2.二、AVL的自平衡

當節點的BF不爲-一、0或者1的時候,使用樹旋轉來進行調整。能夠分爲幾種狀況:

左左

左右

image-20200425154944462

右右

右左

10.三、從BBST中移除元素

參考BST小節的刪除邏輯,與之不一樣的是,在刪除元素以後,須要執行多一個自平衡的過程。

10.四、時間複雜度

普通二叉搜索樹:

平衡二叉搜索樹:

10.五、編程實踐

  • AVLTreeST.java:
    https://algs4.cs.princeton.edu/code/edu/princeton/cs/algs4/AVLTreeST.java.html

  • 練習:

  • Balance a Binary Search Tree:
    https://leetcode.com/problems/balance-a-binary-search-tree

十一、HashTable

11.一、什麼是HashTable

HashTable,哈希表,是一種數據結構,能夠經過使用稱爲hash的技術提供從鍵到值的映射。

key:其中key必須是惟一的,key必須是能夠hash;

value:value能夠重複,value能夠是任何類型;

HashTable常常用於根據Key統計數量,如key爲服務id,value爲錯誤次數等。

11.二、什麼是Hash函數

哈希函數 H(x) 是將鍵「 x」映射到固定範圍內的整數的函數。

咱們能夠爲任意對象(如字符串,列表,元組等)定義哈希函數。

11.2.一、Hash函數的特色

若是 H(x) = H(y) ,那麼x和y可能至關,可是若是 H(x) ≠ H(y),那麼x和y必定不相等。

Q:咱們如何提升對象的比較效率呢?

A:能夠比較他們的哈希值,只有hash值匹配時,才須要顯示比較兩個對象。

Q:兩個大文件,如何判斷是否內容相同?

A:相似的,咱們能夠預先計算H(file1)和H(file2),比較哈希值,此時時間複雜度是O(1),若是相等,咱們才考慮進一步比較穩健。(穩健的哈希函數比哈希表使用的哈希函數會更加複雜,對於文件,一般使用加密哈希函數,也稱爲checksums)。

哈希函數 H(x) 必須是肯定的

就是說,若是H(x) = y,那麼H(x)必須始終可以獲得y,而不產生另外一個值。這對哈希函數的功能相當重要。

咱們須要嚴謹的使用統一的哈希函數,最小化哈希衝突的次數

所謂哈希衝突,就是指兩個不一樣的對象,哈希以後具備相同的值。

Q:上面咱們提到HashTable的key必須是可哈希的,意味着什麼呢?

A:也就是說,咱們須要是哈希函數具備肯定性。爲此咱們要求哈希表中的key是不可變的數據類型,而且咱們爲key的不能夠變類型定義了哈希函數,那麼咱們能夠成爲該key是可哈希的。

11.2.二、優秀哈希函數特色

一個優秀的Hash函數具備以下幾個特色:

正向快速:給定明文和Hash算法,在有限的時間和優先的資源內能計算到Hash值;

碰撞阻力:沒法找到兩個不相同的值,通過Hash以後獲得相同的輸出;

隱蔽性:只要輸入的集合足夠大,那麼輸入信息通過Hash函數後具備不可逆的特性。

謎題友好:也就是說對於輸出值y,很難找到輸入x,是的H(x)=y,那麼咱們就能夠認爲該Hash函數是謎題友好的。

Hash函數在區塊鏈中佔據着很重要的地位,其隱祕性使得區塊鏈具備了匿名性。

11.三、HashTable如何工做

理想狀況下,咱們經過使用哈希函數索引到HashTable的方式,在O(1)時間內很快的進行元素的插入、查找和刪除動做。

只有具備良好的統一哈希函數的時候,才能真正的實現花費恆定時間操做哈希表。

11.3.一、哈希衝突的解決辦法

哈希衝突:因爲哈希算法被計算的數據是無線的,而計算後的結果範圍是有限的,所以總會存在不一樣的數據結果計算後獲得相同值,這就是哈希衝突。

經常使用的兩種方式是:鏈地址法和開放定址法。

11.3.1.一、鏈地址法

鏈地址法是經過維護一個數據結構(一般是鏈表)來保存散列爲特定key的全部不一樣值來處理散列衝突的策略之一。

鏈地址一般使用鏈表來實現,針對散列衝突的數據,構成一個單向鏈表,將鏈表的頭指針存放在哈希表中。

除了使用鏈表結構,也可使用數組、二叉樹、自平衡樹等。

以下,假設咱們哈希函數實現以下:名字首字符的ASCII碼 mod 6,有以下數據須要存儲到哈希表中:

構造哈希表以下:

Q:一旦HashTable被填滿了,而且鏈表很長,怎麼保證O(1)的插入和查找呢?

A:應該建立一個更大容量的HashTable,並將舊的HashTable的所欲項目從新哈希分散存入新的HashTable中。

Q:如何查找元素?

A:把須要查找的元素hash成具體的key,在HashTable中查找桶位置,而後判斷是否桶位置爲一個鏈表,若是是則遍歷鏈表一一比較元素,判斷是否爲要查找的元素:

如查找Jason,定位到桶2,而後遍歷鏈表對比元素:

Q:如何刪除HashTable中的元素

A:從HashTable中查找到具體的元素,刪除鏈表數據結構中的節點。

11.3.1.二、開放式尋址法

在哈希表中查找到另外一個位置,把衝突的對象移動過去,來解決哈希衝突。

使用這種方式,意味着鍵值對存儲在HashTable自己中,並非存放在單獨的鏈表中。這須要咱們很是注意HashTable的大小。

假設須要保持O(1)時間複雜度,負載因子須要保持在某一個固定值下,一旦負載因子超過這個閾值時間複雜度將成指數增加,爲了不這種狀況,咱們須要增長HashTable的大小,也就是進行擴容操做。如下是來自wikipedia的負載因子跟查找效率的關係圖:

當咱們對鍵進行哈希處理H(k)獲取到鍵的原始位置,發現該位置已被佔用,那麼就須要經過探測序列P(x)來找到哈希表中另外一個空閒的位置來存放這個原始。

開放式尋址探測序列技術

開放式尋址常見的探測序列方法有:

  • 線性探查法:P(x) = ax + b,其中a、b爲常數

  • 平方探查法:P(x) = ax^2 + bx + c,其中a, b, c爲常數

  • 雙重哈希探查法:P(k, x) = x * H2(k),其中H2(k),是另外一個哈希函數;

  • 僞隨機數發生器法:P(k, x) = x*RNG(H(k), x),其中RNG是一個使用H(k)做爲seed的隨機數字生成函數;

11.3.1.2.一、開放式尋址法的解決思路

在大小爲N的哈希表上進行開放式尋址的通常插入方法的僞代碼以下:

1x = 1;
2keyHash = H(k) % N;
3index = keyHash;
4while ht[index] != null {
5    index = (keyHash + P(k, x)) %N;
6    x++;
7}
8insert (k, v) at ht[index]

其中H(k)是key的哈希函數,P(k, x)是尋址函數。

11.3.1.2.二、混亂的循環

大多數選擇的以N爲模的探測序列都會執行比HashTable大小少的循環。當插入一個鍵值對而且循環尋址找到的全部桶都被佔用了,那麼將會陷入死循環。

諸如線性探測、二次探測、雙重哈希等都很容易引發死循環問題。每種探測技術都有對應的解決死循環的方法,這裏不深刻展開探討了。

11.四、使用場景

  • 數據校驗

  • 單向性的運用,hash後存儲,hash對比是否一致

11.五、時間複雜度

11.六、編程實踐

  • JDK中的實現,鏈地址法:java.util.HashMap

  • JDK中的實現,開放式尋址法:java.lang.ThreadLocal.ThreadLocalMap

十二、並查集

關於並查集,有一個很牛逼的比喻博文,還不瞭解並查集的同窗能夠看看這裏:超有愛的並查集~:https://blog.csdn.net/niushuai666/article/details/6662911,包你一看就懂。主要提供三個功能:

  • 查找根節點

  • 合併兩個集合

  • 路徑壓縮

12.一、使用場景

  • Kruskal最小生成樹算法

  • 網格滲透

  • 網絡鏈接狀態

  • 圖像處理

12.二、最小生成樹[3]

最小生成樹:一個有 n 個結點的連通圖的生成樹是原圖的極小連通子圖,且包含原圖中的全部 n 個結點,而且有保持圖連通的最少的邊。[1] 最小生成樹能夠用kruskal(克魯斯卡爾)算法或prim(普里姆)算法求出。

若是對圖相關概念不太瞭解,能夠查閱這篇文章:圖論(一)基本概念。
https://blog.csdn.net/saltriver/article/details/54428685

生成基本流程:

把圖的邊按照權重進行排序;


  • 遍歷排序的邊並查看該邊所屬的兩個節點,若是節點有鏈接在一塊兒的路徑了,則不用歸入該邊,不然將其歸入在內並鏈接節點;這裏判斷節點是否已鏈接和鏈接節點主要用到並查集的查找根節點和合並兩個集合操做;

  • 當c處理完每條邊或全部頂點都鏈接在一塊兒以後,算法終止。

12.三、實現思路

12.3.一、構建並查集

假設咱們想經過這些字母構建並查集:E A B F C D,咱們能夠把這些字母映射到數組的索引中,數組的元素值表明當前字母的上級字母索引值,因爲剛開始尚未作合併操做,因此全部元素存的都是本身的索引值:

同時咱們新增一個數組,用於記錄當前字母手下收了多少個字母小弟,當兩個字母要合併的時候,首先找到兩個字母的大佬,而後字母大佬收的小弟少的要拜字母大佬小弟多的人爲大佬:

爲了合併兩個元素,能夠找到每一個組件的根節點,若是根節點不一樣,則使一個根節點成爲另外一個根節點的父節點。

接下來咱們要執行如下合併操做:

union(E, A), union(A, B)

接着執行

union(F, C), union(C, B)

執行到這裏,這裏會剩下兩個組件:EABFC,D

12.3.二、並查集搜索

看到這裏,相信你對並查集的搜索原理也瞭解了。要查找某個特定元素屬於哪一個組件,能夠經過跟隨父節點直到達到自環(該節點父節點指向自己)來找到該組件的根。好比要搜索C,咱們會沿着記錄的parent索引id一直往上層搜索,最終搜到E。

  • 組件數等於剩餘的root根數。另外,請注意,根節點的數量永遠不會增長。

12.2.三、並查集路徑壓縮

咱們能夠發現,在極端狀況下,須要找不少層的parent節點,才能找到最終的根節點。

爲此咱們能夠在find查找節點的時候,找到該節點到跟節點中間的全部節點,從新指向最終找到的根節點來減少路徑長度,這樣下次在find這些節點的時候,就很是快了。以下圖,咱們查找A的根節點,查找到以後進行路徑壓縮:

12.四、時間複雜度

α(n):均攤時間[4]

12.五、編程實踐

  • UnionFind:
    https://github.com/arthinking/java-tech-stack-resource/blob/master/src/main/java/com/itzhai/algorithm/datastructures/unionfind/UnionFind.java

1三、樹狀數組 Fenwick Tree

13.一、爲何須要Fenwick Tree

假設咱們有一個數組A,須要計算數組中[i, j) 區間的數據之和,爲了方便獲取,咱們提早把算好的前面n個元素之和存到另外一個數組B的n+1中,以下:

這樣咱們就很方便的計算區間和了,如:

[2, 5) = B[5] - B[2] = 18 - 6 = 12

可是假設咱們想修改A中第i個元素的值,那麼B中第i+1以後的元素值都得更新:

也就是說更新的複雜度爲O(n),有沒有更好的辦法加快更新速度呢?這個時候咱們的Fenwick Tree就要出場了,Fenwick Tree也叫Binary Indexed Tree(二元索引樹)。

13.二、什麼是Fenwick Tree

Fenwick Tree是一種支持給靜態數組範圍求和,以及在靜態數組中更新元素的值後也可以進行進行範圍求和的數據結構。

最低有效位(LSB least significant bit):靜態數組的小標能夠轉換爲二進制,如8:01000,最低有效位指的是從右往左數,不爲0的位,這裏爲 1000,計算數組小標最低有效爲的函數咱們通常命名爲lowbit,實現參考後續代碼。

數組下標的最低有效位的值n,表示該下標存儲了從該下標往前數n位元素的數值之和。以下圖:

咱們能夠發現:

  • 1:只保存當前下標元素的值,對應上面紅色區塊;

  • 10:保存下標往前數總共2個元素的值,對應上面藍色區塊;

  • 100:保存下標往前數總共4個元素的值,對應上面紫色區塊;

  • 1000:保存下標往前數總共8個元素的值,對應上面綠色區塊;

  • 10000:保存下標往前數總共16個元素的值,對應上面淺藍色區塊;

13.2.一、範圍求和

有了上面的數據結構,咱們就能夠進行範圍求和了。

假設咱們要求和[1, 7],咱們只要把如下紅色區塊值相加就能夠了,也就是 sum = B[7] + B[6] + B[4]

若是咱們要求和[10, 14],那麼咱們能夠這樣處理:sum = sum[1, 14] - sum[1, 9] = (B[14] + B[12] + B[8]) - (B[9] + B[8])。

也就是說,針對範圍查詢,咱們會根據LSB一直回溯上一個元素,而後把回溯到的元素都加起來。

最差的狀況下,求和的複雜度爲:O(log2(n))

如下是實現範圍求和的代碼:

 1/**
2 * 求和 [1, i]
3 */

4public int prefixSum(int i) {
5  sum = 0;
6  while(i != 0) {
7    sum = sum + tree[i]
8    i = i = lowbit(i);
9  }
10  return sum;
11}
12
13/**
14 * 求和 [i, j]
15 */

16public int rangeQuery(int i, int j) {
17  return prefixSum(j) - prefixSum(i - 1);  
18}

13.2.二、單點更新

更新數組中的某一個元素的過程當中,與範圍查詢相反,咱們不斷的根據LSB計算到下一個元素位置,同時給該元素更新數組。以下,更新A[9],會級聯查找到如下紅色的位置的元素:

如下是實現代碼,給第i個元素+x:

1public void add(int i, int x) {
2  while (i < N) {
3    tree[i] = tree[i] + x;
4    i = i + lowbit(i)
5  }
6}

13.2.三、構造Fenwick Tree

假設A爲靜態數組,B數組存放Fenwick Tree,咱們首先把A數組clone到B數組,而後遍歷A數組,每一個元素A[i]依次加到下一個負責累加的B節點B[i + LSB]中(稱爲父節點),直到到達B數組的上界,代碼以下:

 1public FenwickTree(int[] values) {
2    N = values.length;
3        values[0] = 0L;
4
5        // 爲了不直接操縱原數組,破壞了其全部原始內容,咱們複製一個values數組
6        tree = values.clone();
7
8        for (int i = 1; i < N; i++) {
9            // 獲取當前節點的父節點
10            int parent = i + lowbit(i);
11            if (parent < N) {
12                // 父節點累加當前節點的值
13                tree[parent] += tree[i];
14            }
15        }
16}

思考:若是咱們想要快速更新數組的區間範圍,如何實現比較好呢?參考:

13.三、時間複雜度

13.四、編程實踐

  • 單點更新,區間查找:FenwickTreeRangeQueuePointUpdate
    https://github.com/arthinking/java-tech-stack-resource/blob/master/src/main/java/com/itzhai/algorithm/datastructures/fenwicktree/FenwickTreeRangeQueryPointUpdate.java

  • 區間更新,單點查找:FenwickTreeRangeUpdatePointQuery
    https://github.com/arthinking/java-tech-stack-resource/blob/master/src/main/java/com/itzhai/algorithm/datastructures/fenwicktree/FenwickTreeRangeUpdatePointQuery.java

  • 練習:

  • Range Sum Query - Mutable
    https://leetcode.com/problems/range-sum-query-mutable

1四、後綴數組 Suffix Array

後綴數組是後綴樹的一種節省空間的替代方法,後綴樹自己是trie的壓縮版本。

後綴數組能夠完成後綴樹能夠完成的全部工做,而且帶有一些其餘信息,例如最長公共前綴(LCP)數組

14.一、後綴數組格式

以下圖,字符串:arthinking,全部的後綴,從長到短列出來:

給後綴排序,排序後對應的索引構成的數組既是後綴數組:

後綴數組sa:後綴suffix列表排序後,suffix的下標構成的數組sa;

rank:suffix列表每一個元素的排位權重(權重越大排越後面);

14.二、後綴數組構造過程

上面的後綴構造過程是怎樣的呢?

這裏咱們介紹最多見的倍增算法來獲得後綴數組。

咱們獲取每個元素的權重rank,獲取到以後,依次繼續

  • 第0輪:suffix[i] = suffix[i] + suffix[2^0],從新評估rank;

  • 第1輪:suffix[i] = suffix[i] + suffix[2^1],從新評估rank;

  • 第n輪:suffix[i] = suffix[i] + suffix[2^n],從新評估rank;

最終獲得全部rank都不相等便可。以下圖所示:

這樣就獲得rank了,咱們能夠根據rank很快推算出sa數組。

爲何能夠這樣倍增,跳過中間某些元素進行比較呢?

這是一種很巧妙的用法,由於每一個後綴都與另外一個後綴有一些共同之處,並非隨機字符串,遷移輪比較,爲後續比較墊底了基礎。

假設要處理substr(i, len)子字符串。咱們能夠把第k輪substr(i, 2^k)當作是一個由substr(i, 2^k−1)substr(i + 2^k−1, 2^k−1)拼起來的東西,而substr(i, 2^k−1)的字符串是上一輪比較過的而且得出了rank。

14.三、後綴數組使用場景

  • 在較大的文本中查找子字符串的全部出現;

  • 最長重複子串;

  • 快速搜索肯定子字符串是否出如今一段文本中;

  • 多個字符串中最長的公共子字符串

LCP數組

最長公共前綴(LCP longest common prefix)數組,是排好序的suffix數組,用來跟蹤獲取最長公共前綴(LCP longest common prefix)。

14.四、編程實踐

  • SuffixArray:
    https://github.com/arthinking/java-tech-stack-resource/blob/master/src/main/java/com/itzhai/algorithm/datastructures/suffixarray/SuffixArray1.java


這篇文章的內容就差很少介紹到這裏了,可以閱讀到這裏的朋友真的是頗有耐心,爲你點個贊。

本文爲arthinking基於相關技術資料和官方文檔撰寫而成,確保內容的準確性,若是你發現了有何錯漏之處,煩請高擡貴手幫忙指正,萬分感激。

你們能夠關注個人博客:itzhai.com 獲取更多文章,我將持續更新後端相關技術,涉及JVM、Java基礎、架構設計、網絡編程、數據結構、數據庫、算法、併發編程、分佈式系統等相關內容。

若是您以爲讀完本文有所收穫的話,能夠關注個人帳號,或者點個贊吧,碼字不易,您的支持就是我寫做的最大動力,再次感謝!

關注個人公衆號,及時獲取最新的文章。



更多文章

JVM系列專題:公衆號發送 JVM


References

算法的時間與空間複雜度
https://zhuanlan.zhihu.com/p/50479555
Time and Space Complexity
https://www.hackerearth.com/zh/practice/basic-programming/complexity-analysis/time-and-space-complexity/tutorial/ [1]: big o cheat sheet
https://www.bigocheatsheet.com/

[2]: 徹底二叉樹
https://www.cnblogs.com/-citywall123/p/11788764.html

[3]: 算法導論--最小生成樹(Kruskal和Prim算法)
https://blog.csdn.net/luoshixian099/article/details/51908175

[4]: Constant Amortized Time ↩
https://stackoverflow.com/questions/200384/constant-amortized-time

Data Structures Easy to Advanced Course - Full Tutorial from a Google Engineer


·END·

 訪問IT宅(itzhai.com)查看個人博客更多文章

掃碼關注及時獲取新內容↓↓↓



Java架構雜談

Java後端技術架構 · 技術專題 · 經驗分享

blog: itzhai.com


碼字不易,若有收穫,點個「贊」哦~



本文分享自微信公衆號 - Java架構雜談(itread)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索