通過有序線性結構構造AVL樹

IamQisir發表於2022-05-12

通過有序線性結構構造AVL樹

本部落格旨在結局利用有序陣列和有序連結串列構造平衡二叉樹(下文使用AVL樹代指)問題。

直接通過旋轉來構造AVL樹似乎是一個不錯的選擇,但是稍加分析就會發現,這樣平白無故做了許多毫無意義的旋轉。因為直接通過旋轉調整二叉查詢樹(下文使用BST代指)並沒有利用陣列或連結串列本身是有序的資訊,進行了大量無意義的操作。

下面通過leetcode兩道例題來說明這個問題。

1. 108. 將有序陣列轉換為二叉搜尋樹

題目分析

重點的問題在於:如何利用陣列的有序資訊呢?

首先我們先來觀察一個有序陣列和一棵AVL樹所呈現的關係

O0ID7q.png

我們可以發現陣列中間元素恰好是對應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;
    }
}

遞迴函式是如何設計的呢?

  1. 引數:我們需要訪問nums陣列元素,因此需要將其傳入函式,也可以在Solution類中設計一個例項變數引用nums陣列。同時,為例確定中間元素,我們需要明確陣列的左右邊界,因此傳入引數l和r,它們也是判斷陣列內是否還有元素用來建立AVL樹節點,也就是說當l小於r時,返回null節點。
  2. 返回值:這個比較顯而易見,我們需要遞迴函式返回一個TreeNode節點(或者說它是已構造好子樹的頭節點)。

複雜度分析

時間複雜度:這道題本質上是使用先序遍歷的方式構造一棵樹,每個節點都被構造一次且路過三次,所以時間複雜度顯然為O(N)

空間複雜度:遞迴呼叫棧深度為樹的深度,由於構造的樹為AVL樹,其深度不超過lg(N),遞迴呼叫棧深度也不超過O(lgN),故空間複雜度為O(lgN)

2. 109. 有序連結串列轉換二叉搜尋樹

題目分析

這道題是108題的兄弟版本,陣列可以隨機訪問元素,因此我們可以輕而易舉地得到中間元素,但是連結串列不再具備這個特性。因此我們需要採取其他的方法來解決這個問題。

  1. 將連結串列元素依次取出,構造出一個有序陣列,再利用108的方法去做。不過空間複雜度提升為O(N)
  2. 採用快慢指標法取出中間節點,但是每次構造子樹root節點均需使用快慢指標法,導致時間複雜度會降低為O(lgN)
  3. 更優秀的方法:利用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;
    }
}

遞迴函式設計說明:

  1. 返回值:返回值顯然要返回樹節點,因為我們需要向上一級呼叫函式返回構造好子樹的頭節點
  2. 引數:本函式引數選擇是重中之重,向上面程式碼所示選用閉開區間的寫法可以避免一些麻煩,不要忘記這是個單連結串列,如寫成閉區間的模式,那麼需要額外的prev指標來指示slow指標的前一個元素(對照108的程式碼去看)

方法2缺陷:快慢指標尋找中間元素所需時間複雜度是o(lgN),我們每次在構造子樹root節點時均需要使用它一次,這無疑造成了一些浪費。讓我們回到108題所示圖片中(把那個陣列想象為一個連結串列),AVL樹中序遍歷產生的序列與連結串列是一致的。可以利用這個特點改進方法2嗎?

方法3思路說明:

這裡附上一份詳細的參考連結

https://leetcode.cn/problems/convert-sorted-list-to-binary-search-tree/solution/shou-hua-tu-jie-san-chong-jie-fa-jie-zhu-shu-zu-ku/

方法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;
    }
}

遞迴函式的說明:

  1. 引數設計:有些人(包括我在內)開始可能很詫異為什麼不像方法2一樣使ListNode作為引數呢?不要忘記,一旦引數使用ListNode那不就變成方法2了嘛,無法直接訪問中間元素。要想直接訪問中間元,就要使用整數索引。
  2. 如何理解遞迴函式的流程:最好的方法就是逐行分析程式碼,嘗試模擬執行。不過這裡還是給出一些說明,首先我們先建立node節點,先遞迴地建立其左子樹,然後將連結串列頭listHead指向下一個元素,此時listHead指向的節點恰好為中間節點,我們取出它的值,再遞迴地建立其右子樹。

相關文章