通過有序線性結構構造AVL樹
本部落格旨在結局利用有序陣列和有序連結串列構造平衡二叉樹(下文使用AVL樹代指)問題。
直接通過旋轉來構造AVL樹似乎是一個不錯的選擇,但是稍加分析就會發現,這樣平白無故做了許多毫無意義的旋轉。因為直接通過旋轉調整二叉查詢樹(下文使用BST代指)並沒有利用陣列或連結串列本身是有序的資訊,進行了大量無意義的操作。
下面通過leetcode兩道例題來說明這個問題。
1. 108. 將有序陣列轉換為二叉搜尋樹
題目分析
重點的問題在於:如何利用陣列的有序資訊呢?
首先我們先來觀察一個有序陣列和一棵AVL樹所呈現的關係
我們可以發現陣列中間元素恰好是對應AVL樹的root節點(若陣列長為偶數,可取中間偏左或偏右元,此時左右子樹高度差為1)
我們只需要選取中間元素,構造頭節點,在遞迴地構造其左右子樹即可。
程式碼實現與說明
class Solution {
public TreeNode sortedArrayToBST(int[] nums) {
return build(nums, 0, nums.length - 1);
}
private TreeNode build(int[] nums, int l, int r) {
// 為什麼採用l小於r而不是等於來判斷呢?
// 我們這裡是採用的閉區間的寫法,當然可以採用閉開區間的寫法,也就是等於時返回null
// 迴圈和遞迴函式的邊界條件一定要多加註意
if (l > r)
return null;
// 防止因為l和r過大造成溢位,不過這道題陣列長度不會那麼大,但最好還是養成這樣的習慣
int mid = l + (r - l) / 2;
// 其實這是一箇中序遍歷,先建立root節點,在遞迴地建立左子樹和右子樹
TreeNode node = new TreeNode(nums[mid]);
node.left = build(nums, l, mid - 1);
node.right = build(nums, mid + 1, r);
// 建立好後返回root節點即可
return node;
}
}
遞迴函式是如何設計的呢?
- 引數:我們需要訪問nums陣列元素,因此需要將其傳入函式,也可以在Solution類中設計一個例項變數引用nums陣列。同時,為例確定中間元素,我們需要明確陣列的左右邊界,因此傳入引數l和r,它們也是判斷陣列內是否還有元素用來建立AVL樹節點,也就是說當l小於r時,返回null節點。
- 返回值:這個比較顯而易見,我們需要遞迴函式返回一個TreeNode節點(或者說它是已構造好子樹的頭節點)。
複雜度分析
時間複雜度:這道題本質上是使用先序遍歷的方式構造一棵樹,每個節點都被構造一次且路過三次,所以時間複雜度顯然為O(N)
空間複雜度:遞迴呼叫棧深度為樹的深度,由於構造的樹為AVL樹,其深度不超過lg(N),遞迴呼叫棧深度也不超過O(lgN),故空間複雜度為O(lgN)
2. 109. 有序連結串列轉換二叉搜尋樹
題目分析
這道題是108題的兄弟版本,陣列可以隨機訪問元素,因此我們可以輕而易舉地得到中間元素,但是連結串列不再具備這個特性。因此我們需要採取其他的方法來解決這個問題。
- 將連結串列元素依次取出,構造出一個有序陣列,再利用108的方法去做。不過空間複雜度提升為O(N)
- 採用快慢指標法取出中間節點,但是每次構造子樹root節點均需使用快慢指標法,導致時間複雜度會降低為O(lgN)
- 更優秀的方法:利用AVL樹中序遍歷生成的序列即為所給有序連結串列這一性質,採用中序遍歷構造AVL樹,兼具方法1和方法2的優點
程式碼實現與說明
方法1不給出程式碼,其實相比於108題只是多了一步構造陣列罷了。
方法2程式碼
class Solution {
public TreeNode sortedListToBST(ListNode head) {
return build(head, null);
}
private TreeNode build(ListNode l, ListNode r) {
if (l == r)
return null;
ListNode slow = l, fast = slow;
// 快慢指標尋找中間節點,可以說是本做法的核心,注意迴圈的條件
while (fast != r && fast.next != r) {
slow = slow.next;
fast = fast.next.next;
}
// 其實這是一個先序遍歷的過程,在過程AVL樹
TreeNode node = new TreeNode(slow.val);
node.left = build(l, slow);
node.right = build(slow.next, r);
return node;
}
}
遞迴函式設計說明:
- 返回值:返回值顯然要返回樹節點,因為我們需要向上一級呼叫函式返回構造好子樹的頭節點
- 引數:本函式引數選擇是重中之重,向上面程式碼所示選用閉開區間的寫法可以避免一些麻煩,不要忘記這是個單連結串列,如寫成閉區間的模式,那麼需要額外的prev指標來指示slow指標的前一個元素(對照108的程式碼去看)
方法2缺陷:快慢指標尋找中間元素所需時間複雜度是o(lgN),我們每次在構造子樹root節點時均需要使用它一次,這無疑造成了一些浪費。讓我們回到108題所示圖片中(把那個陣列想象為一個連結串列),AVL樹中序遍歷產生的序列與連結串列是一致的。可以利用這個特點改進方法2嗎?
方法3思路說明:
這裡附上一份詳細的參考連結
方法3程式碼
class Solution {
// 遞迴過程中各個函式均維護一個連結串列頭,故將其設為例項變數
ListNode listHead;
public TreeNode sortedListToBST(ListNode head) {
listHead = head;
int length = getLength(head);
return build(0, length - 1);
}
// 一定要多注意該函式引數的設計,引數是整數索引!不再像方法2那樣是ListNode引用
// 一定要好好理解這個函式
private TreeNode build(int left, int right) {
if (left > right)
return null;
TreeNode node = new TreeNode();
int mid = left + (right - left) / 2;
node.left = build(left, mid - 1);
node.val = listHead.val;
listHead = listHead.next;
node.right = build(mid + 1, right);
return node;
}
// 輔助函式,作用是遍歷連結串列,統計其長度
private int getLength(ListNode head) {
int counter = 0;
while (head != null) {
counter++;
head = head.next;
}
return counter;
}
}
遞迴函式的說明:
- 引數設計:有些人(包括我在內)開始可能很詫異為什麼不像方法2一樣使ListNode作為引數呢?不要忘記,一旦引數使用ListNode那不就變成方法2了嘛,無法直接訪問中間元素。要想直接訪問中間元,就要使用整數索引。
- 如何理解遞迴函式的流程:最好的方法就是逐行分析程式碼,嘗試模擬執行。不過這裡還是給出一些說明,首先我們先建立node節點,先遞迴地建立其左子樹,然後將連結串列頭listHead指向下一個元素,此時listHead指向的節點恰好為中間節點,我們取出它的值,再遞迴地建立其右子樹。