演算法班筆記 第五章 二叉樹和基於樹的DFS
第五章 二叉樹和基於樹的DFS
在這一章節的學習中,我們將要學習一個數據結構——二叉樹
(Binary Tree),和基於二叉樹上的搜尋演算法。
在二叉樹的搜尋中,我們主要使用了分治法(Divide Conquer)來解決大部分的問題。之所以大部分二叉樹的問題可以使用分治法,是因為二叉樹這種資料結構,是一個天然就幫你做好了分治法中“分”這個步驟的結構。
二叉樹上的遍歷法
定義
遍歷(Traversal),顧名思義,就是通過某種順序,一個一個訪問一個數據結構中的元素
。比如我們如果需要遍歷一個數組,無非就是要麼從前往後,要麼從後往前遍歷。但是對於一棵二叉樹來說,他就有很多種方式進行遍歷:
- 層序遍歷(Level order)
- 先序遍歷(Pre order)
- 中序遍歷(In order)
- 後序遍歷(Post order)
我們在之前的課程中,已經學習過了二叉樹的層序遍歷,也就是使用 BFS 演算法來獲得二叉樹的分層資訊。通過 BFS 獲得的順序我們也可以稱之為 BFS Order。而剩下的三種遍歷,都需要通過深度優先搜尋的方式來獲得。而這一小節中,我們將講一下通過深度優先搜尋(DFS)來獲得的節點順序,
二叉樹上的分治法
分治法(Divide & Conquer Algorithm)是說將一個大問題,拆分為2個或者多個小問題,當小問題得到結果之後,合併他們的結果來得到大問題的結果。
在一棵二叉樹(Binary Tree)中,如果將整棵二叉樹看做一個大問題的話,那麼根節點(Root)的左子樹(Left subtree)就是一個小問題,右子樹(Right subtree)是另外一個小問題。這是一個天然就幫你完成了“分”這個步驟的資料結構。
遍歷法和分治法實戰
二叉樹的最大深度
既可遍歷又可分治
判斷平衡二叉樹
分治法,多個return value時可以用resultType
判斷二叉搜尋樹
中序遍歷從小到大,或者分治法
遞迴,分治法,遍歷法的聯絡與區別
聯絡
分治法(Divide & Conquer)與遍歷法(Traverse)是兩種常見的遞迴(Recursion)方法。
分治法解決問題的思路
先讓左右子樹去解決同樣的問題,然後得到結果之後,再整合為整棵樹的結果。
遍歷法解決問題的思路
通過前序/中序/後序的某種遍歷,遊走整棵樹,通過一個全域性變數或者傳遞的引數來記錄這個過程中所遇到的點和需要計算的結果。
兩種方法的區別
從程式實現角度分治法的遞迴函式,通常有一個返回值
,遍歷法通常沒有。
遞迴、回溯和搜尋
什麼是遞迴 (Recursion) ?
很多書上會把遞迴(Recursion)當作一種演算法。事實上,遞迴是包含兩個層面的意思的:
- 一種由大化小,由小化無的解決問題的演算法。類似的演算法還有動態規劃(Dynamic Programming)。
- 一種程式的實現方式。這種方式就是一個函式(Function / Method / Procedure)自己呼叫自己。
與之對應的,有非遞迴(Non-Recursion)和迭代法(Iteration),你可以認為這兩個概念是一樣的概念(番茄和西紅柿的區別)。不需要做區分。
什麼是搜尋 (Search)?
搜尋分為深度優先搜尋(Depth First Search)和寬度優先搜尋(Breadth First Search),通常分別簡寫為 DFS 和 BFS。搜尋是一種類似於列舉(Enumerate)的演算法。比如我們需要找到一個數組裡的最大值,我們可以採用列舉法,因為我們知道陣列的範圍和大小,比如經典的打擂臺演算法:
int max = nums[0];
for (int i = 1; i < nums.length; i++) {
max = Math.max(max, nums[i]);
}
列舉法通常是你知道迴圈的範圍,然後可以用幾重迴圈就搞定的演算法。比如我需要找到 所有 x^2 + y^2 = K 的整數組合,可以用兩重迴圈的列舉法:
// 不要在意這個演算法的時間複雜度
for (int x = 1; x <= k; x++) {
for (int y = 1; y <= k; y++) {
if (x * x + y * y == k) {
// print x and y
}
}
}
而有的問題,比如求 N 個數的全排列,你可能需要用 N 重迴圈才能解決。這個時候,我們就傾向於採用遞迴的方式去實現這個變化的 N 重迴圈。這個時候,我們就把演算法稱之為搜尋
。因為你已經不能明確的寫出一個不依賴於輸入資料的多重迴圈了。
通常來說 DFS 我們會採用遞迴的方式實現(當然你強行寫一個非遞迴的版本也是可以的),而 BFS 則無需遞迴(使用佇列 Queue + 雜湊表 HashMap就可以)。所以我們在面試中,如果一個問題既可以使用 DFS,又可以使用 BFS 的情況下,一定要優先使用 BFS。
因為他是非遞迴的,而且更容易實現。
什麼是回溯(Backtracking)?
有的時候,深度優先搜尋演算法(DFS),又被稱之為回溯法,所以你可以完全認為回溯法,就是深度優先搜尋演算法。在我的理解中,回溯實際上是深度優先搜尋過程中的一個步驟。比如我們在進行全子集問題的搜尋時,假如當前的集合是 {1,2} 代表我正在尋找以 {1,2}開頭的所有集合。那麼他的下一步,會去尋找 {1,2,3}開頭的所有集合,然後當我們找完所有以 {1,2,3} 開頭的集合時,我們需要把 3 從集合中刪掉,回到 {1,2}。然後再把 4 放進去,尋找以 {1,2,4} 開頭的所有集合。這個把 3 刪掉回到 {1,2} 的過程,就是回溯。
subset.add(nums[i]);
subsetsHelper(result, subset, nums, i + 1);
subset.remove(list.size() - 1) // 這一步就是回溯
遞迴三要素
1. 遞迴的定義
每一個遞迴函式,都需要有明確的定義,有了正確的定義以後,才能夠對遞迴進行拆解。
2. 遞迴的拆解
一個
大問題
如何拆解為若干個小問題
去解決。
3. 遞迴的出口
什麼時候可以直接知道答案,不用再拆解,直接 return
使用 ResultType 返回多個值
什麼是 ResultType
通常是我們定義在某個檔案內部使用的一個類。比如:
class ResultType {
int maxValue, minValue;
ResultType(int maxValue, int minValue) {
this.maxValue = maxValue;
this.minValue = minValue;
}
};
什麼時候需要 ResultType
當我們定義的函式需要返回多個值供呼叫者計算時,就需要使用 ResultType了。 所以如果你只是返回一個值就夠用的話,就不需要。
什麼是平衡二叉樹
定義
平衡二叉樹(Balanced Binary Tree,又稱為AVL樹,有別於AVL演算法)是二叉樹中的一種特殊的形態。二叉樹當且僅當滿足如下兩個條件之一,是平衡二叉樹:
- 空樹。
- 左右子樹高度差絕對值不超過1且左右子樹都是平衡二叉樹。
AVL樹的高度為 O(logN)
AVL樹有什麼用?
若二叉搜尋樹是AVL樹,則最大作用是保證查詢的最壞時間複雜度為O(logN)。而且較淺的樹對插入和刪除等操作也更快。
AVL樹必定是二叉搜尋樹,反之則不一定。
Set/Map
Set/Map 是底層運用了紅黑樹的資料結構
對比 unordered_set/unordered_map
- unordered_set/unordered_map 存取的時間複雜度為O(1),而 Set/Map 存取的時間複雜度為 O(logn) 所以在存取上並不佔優。
- unordered_set/unordered_map 內元素是無序的,而Set/Map 內部是有序的(可以是按自然順序排列也可以自定義排序)。
- unordered_set/unordered_map 還提供了類似 lowerBound 和 upperBound 這兩個其他資料結構沒有的方法
- 對於 Set, 實現上述兩個方法的方法為:
- lowerBound
- public E lower(E e) --> 返回set中嚴格小於給出元素的最大元素,如果沒有滿足條件的元素則返回 null。
- public E floor(E e) --> 返回set中不大於給出元素的最大元素,如果沒有滿足條件的元素則返回 null。
- upperBound
- public E higher(E e) --> 返回set中嚴格大於給出元素的最小元素,如果沒有滿足條件的元素則返回 null。
- public E ceiling(E e) --> 返回set中不小於給出元素的最小元素,如果沒有滿足條件的元素則返回 null。
- lowerBound
- 對於 Map , 實現上述兩個方法的方法為:
- lowerBound
- public Map.Entry<K,V> lowerEntry(K key) --> 返回map中嚴格小於給出的key值的最大key對應的key-value對,如果沒有滿足條件的key則返回 null。
- public K lowerKey(K key) --> 返回map中嚴格小於給出的key值的最大key,如果沒有滿足條件的key則返回 null。
- public Map.Entry<K,V> floorEntry(K key) --> 返回map中不大於給出的key值的最大key對應的key-value對,如果沒有滿足條件的key則返回 null。
- public K floorKey(K key) --> 返回map中不大於給出的key值的最大key,如果沒有滿足條件的key則返回 null。
- upperBound
- public Map.Entry<K,V> higherEntry(K key) --> 返回map中嚴格大於給出的key值的最小key對應的key-value對,如果沒有滿足條件的key則返回 null。
- public K higherKey(K key) --> 返回map中嚴格大於給出的key值的最小key,如果沒有滿足條件的key則返回 null。
- public Map.Entry<K,V> ceilingEntry(K key) --> 返回map中不小於給出的key值的最小key對應的key-value對,如果沒有滿足條件的key則返回 null。
- public K ceilingKey(K key) --> 返回map中不小於給出的key值的最小key,如果沒有滿足條件的key則返回 null。
- lowerBound
- lowerBound 與 upperBound 均為二分查詢(因此要求有序),時間複雜度為O(logn).
- 對於 Set, 實現上述兩個方法的方法為:
對比 priority_queue(Heap)
priority_queue是基於Heap實現的,它可以保證隊頭元素是優先順序最高的元素,但其餘元素是不保證有序的。
Heap是完全二叉樹
- 方法時間複雜度對比:
- 新增元素 add() / offer()
- Set: O(logn)
- priority_queue: O(logn)
- 刪除元素 poll() / remove()
- Set: O(logn)
- priority_queue: O(logn)
- 查詢 contains()
- Set: O(logn)
- priority_queue: O(n)
- 取最小值 first() / peek()
- Set: O(logn)
- priority_queue: O(1)
- 新增元素 add() / offer()
什麼是二叉搜尋樹
定義
二叉搜尋樹(Binary Search Tree,又名排序二叉樹,二叉查詢樹,通常簡寫為BST)定義如下:空樹或是具有下列性質的二叉樹: (1)若左子樹不空,則左子樹上所有節點值均小於或等於它的根節點值; (2)若右子樹不空,則右子樹上所有節點值均大於或等於它的根節點值; (3)左、右子樹也為二叉搜尋樹;
BST 的特性
- 按照中序遍歷(inorder traversal)列印各節點,會得到由小到大的順序。
- 在BST中搜索某值的平均時間複雜度為O(logN),其中N為節點個數。類似binary search,將待尋值與節點值比較,若不相等,則通過是小於還是大於,可斷定該值只可能在左子樹還是右子樹,繼續向該子樹搜尋。故一次比較平均排除半棵樹。
BST 的作用
- 通過中序遍歷,可快速得到升序節點列表。
- 在BST中查詢元素,只需要平均O(logN)的時間,這與有序陣列(sorted array)一樣。但BST平均log(N)即可實現元素的增加和刪除,有序陣列卻需要O(N)。
BST基本操作——增刪改查(CRUD)
基本操作之查詢(Retrieve)
-
思路
- 查詢值為val的節點,如果val小於根節點則在左子樹中查詢,反之在右子樹中查詢
基本操作之修改(Update)
-
思路
- 修改僅僅需要在查詢到需要修改的節點之後,更新這個節點的值就可以了
基本操作之增加(Create)
-
思路
- 根節點為空,則待新增的節點為根節點
- 如果待新增的節點值小於根節點,則在左子樹中新增
- 如果待新增的節點值大於根節點,則在右子樹中新增
- 我們統一在樹的葉子節點(Leaf Node)後新增
基本操作之刪除(Delete)
-
思路(最為複雜)
- 考慮待刪除的節點為葉子節點,可以直接刪除並修改父親節點(Parent Node)的指標,需要區分待刪節點是否為根節點
- 考慮待刪除的節點為單支節點(只有一棵子樹——左子樹 or 右子樹),與刪除連結串列節點操作類似,同樣的需要區分待刪節點是否為根節點
- 考慮待刪節點有兩棵子樹,可以將待刪節點與左子樹中的最大節點進行交換,由於左子樹中的最大節點一定為葉子節點,所以這時再刪除待刪的節點可以參考第一條
非遞迴的方式實現二叉樹遍歷
先序遍歷
思路
遍歷順序為根、左、右
- 如果根節點非空,將根節點加入到棧中。
- 如果棧不空,彈出出棧頂節點,將其值加加入到陣列中。
- 如果該節點的右子樹不為空,將右子節點加入棧中。
- 如果左子節點不為空,將左子節點加入棧中。
- 重複第二步,直到棧空。
中序遍歷
思路
遍歷順序為左、根、右
- 如果根節點非空,將根節點加入到棧中。
- 如果棧不空,取棧頂元素(暫時不彈出),
- 如果左子樹已訪問過,或者左子樹為空,則彈出棧頂節點,將其值加入陣列,如有右子樹,將右子節點加入棧中。
- 如果左子樹不為空,則將左子節點加入棧中。
- 重複第二步,直到棧空。
後序遍歷
思路
遍歷順序為左、右、根
- 如果根節點非空,將根節點加入到棧中。
- 如果棧不空,取棧頂元素(暫時不彈出),
- 如果(左子樹已訪問過或者左子樹為空),且(右子樹已訪問過或右子樹為空),則彈出棧頂節點,將其值加入陣列,
- 如果左子樹不為空,切未訪問過,則將左子節點加入棧中,並標左子樹已訪問過。
- 如果右子樹不為空,切未訪問過,則將右子節點加入棧中,並標右子樹已訪問過。
- 重複第二步,直到棧空。