深入理解二叉樹(超詳細)

衍方發表於2020-09-18

二叉樹(Binary Tree)

回顧

在前面的文章 — 二叉樹前奏中,我們對於二叉樹的一些基本概念進行了回顧,同時對比了線性結構與樹形結構,總結了一些常見的二叉樹的性質,像二叉樹,真二叉樹,完全二叉樹,以及滿二叉樹等等,但是,我們僅僅是在概念上對於二叉樹有所瞭解,並沒有進行編碼工作,今天來完善一下這一步的操作

直接進入二叉樹的設計與編碼,如果你對於二叉樹的概念以及性質不瞭解的話,可以回去翻翻 二叉樹前奏,熟悉一下,因為編碼實際上就是對於二叉樹性質的一個體現

設計

屬性與節點

首先,我們的二叉樹是用來存放元素的,同時它還需要知道自己的父節點與子節點的關係,那麼,很容易想到的是使用節點類,那麼二叉樹的節點類該如如設計呢,同時我的二叉樹類該有哪些基本元素呢?

首先,我們需要知道二叉樹的節點數量,同時,對於樹而言,要有根,我們需要根節點,那麼可以確定的是有:

//樹節點的數量
protected int size;

//樹的根結點
protected Node<E> root;

這裡我們先不說訪問修飾符為什麼是protected,先來說一說節點類該怎麼設計,我們需要知道一個節點的父節點,以及左右子節點的關係,同時還有節點中儲存的元素element,那麼我們的節點應該是這樣:

/**
 * 自定義節點類,用於維護二叉樹
 * @param <E>
 */
protected static class Node<E>{
    E element;
    Node<E> left;
    Node<E> right;
    Node<E> parent;

    /**
     * 建構函式,新增節點時,要指定元素
     * 父節點的,但不一定有左右子節點
     * @param element
     * @param parent
     */
    public Node(E element,Node<E> parent){
        this.element = element;
        this.parent = parent;
    }

    /**
     * 判斷是否為葉子節點
     * @return
     */
    public boolean isLeaf() {
        return left == null && right == null;
    }

    /**
     * 判斷是否左右子節點都具備
     * @return
     */
    public boolean hasTwoChildren() {
        return left != null && right != null;
    }
}

該節點類,提供了一個建構函式以及兩個特有的方法,對於建構函式而言,初始化的時候,我們需要指定節點儲存的元素以及父節點,為什麼左右子節點不初始化呢,因為你新增一個節點時,是一定要知道其父節點的,但是你並不知道它有沒有子節點。

對於另外兩個方法,我覺的這是節點類所特有的行為,因為節點的概念在二叉樹中是通用的,所以直接封裝在節點類中,而不是在後面,對於每一種獨特的二叉樹,需要用到判斷葉子節點以及度為 2 的節點的時候,再去編寫,那樣就太繁瑣了

針對前面說到的size,root,以及節點類,這些應該是二叉樹的內部邏輯,對外是不公開的,外界不知道節點類的,它只需要指定節點,也就是二叉樹儲存的型別就可以了,但是我們需要對外界開放介面,通過介面來使用二叉樹,也就是說你只要知道怎麼用就好了,不需要知道我是怎麼實現的。

公共介面

對於二叉樹,我們提供給外界的方法應該有以下方法:

public int size() —— 獲取元素節點的數量

public boolean isEmpty() —— 判斷樹是否為空樹

public void clear() —— 清空樹的所有元素

public void preorder() —— 前序遍歷

public void inorder() —— 中序遍歷

public void postorder() —— 後序遍歷

public void levelOrder() —— 層序遍歷

public int height() —— 計算樹的高度

public boolean isComplete() —— 判斷是否為完全二叉樹

public boolean isProper () —— 判斷是否為真二叉樹

public String toString() —— 重新toString方法,樹狀列印二叉樹

我們對外界提供的方法大致就是這些了,那麼問題來了,我們可以有疑惑了,既然二叉樹是用來存放元素的,為什麼沒有addremove新增以及移除節點的方法的呢,那麼這樣的一棵樹new出來之後就是一棵空樹呀,是不是忘記了?很明確說明,沒有忘了,就是不給提供新增、移除的方法。

我們來思考一下,有下面這麼一段程式碼:

BinaryTree<Integer> bTree = new BinaryTree<>();
bTree.add(9);
bTree.add(5);
bTree.add(8);

可以明確的是第一個新增的元素9就是根節點,那麼問題來了,接下來的 5,是要作為 9 的左子節點還是右子節點,8 應該是 5 的兄弟節點還是左右子節點其中一個,是的,我們並沒有明確二叉樹的新增規則,寫起來是很麻煩也是沒有意義的,當然,我們也可以預設一致往左或者往右新增,但是這樣沒有多大意義,沒有明確的規則,那就是普通的二叉樹,是沒有什麼用處的,規則就是樹的特性,像二叉搜尋樹,紅黑樹,AV樹等等,都是有明確的規則的。實際上,我們是在普通的二叉樹加一些自定義的邏輯和規則,所以這裡的二叉樹類BinaryTree實際上應該是基類,而新增以及移除等特有的規則,應該在繼承普通二叉樹的基礎上編寫的,而二叉樹提供的就是一些通用的方法。這也是前面將BinaryTree的類的屬性的訪問修飾符設計為protected的原因

簡單方法

public int size() —— 獲取元素節點的數量

/**
 * 獲取元素節點的數量
 * @return
 */
public int size() {
    return size;
}

public boolean isEmpty() —— 判斷樹是否為空樹

/**
 * 判斷樹是否為空樹
 * @return
 */
public boolean isEmpty() {
    return size == 0;
}

public void clear() —— 清空樹的所有元素

/**
 * 清空樹的所有元素
 */
public void clear() {
    root = null;
    size = 0;
}

簡單的方法直接放出來,直接瞄一眼即可

有趣的遍歷

對於陣列,連結串列等資料結構,我們都能遍歷,獲取到所有元素,線性資料結構的遍歷比較簡單 — 正序遍歷與逆序遍歷,對於我們的二叉樹,同樣也應該提供遍歷的方法

根據節點訪問順序的不同,二叉樹的常見遍歷方式有4種常見的遍歷方式(Preorder Traversal):

  • 前序遍歷(Preorder Traversal)
  • 中序遍歷(Inorder Traversal)
  • 後序遍歷(Postorder Traversal)
  • 層序遍歷(Level Order Traversal)

我們以二叉搜尋樹 —— {7,4,9,2,5,8,11,3,12,1}為例,分別分析這四種遍歷方式

前序遍歷

訪問順序:根節點、前序遍歷左子樹、前序遍歷右子樹(根節點訪問在前)

在這裡插入圖片描述

遍歷結果是:7、4、2、1、3、5、9、8、11、10、12

前序遍歷、中序遍歷、後序遍歷用遞迴遍歷的方式實現都很簡單,這裡就不放出來佔篇幅了,但是有提供,如果想看的話,後面會將完整程式碼上傳Github,需要再去下載下來即可,下載的時候,注意選擇dev分支,那邊才是最新的程式碼

實現步驟:

  • 利用棧先進後出的特性:
  • 設定node = root,將root入棧,迴圈執行以下操作,直到棧為空
  • 彈出棧頂節點top,進行訪問
  • top.right入棧將top.left入棧
/**
 * 迭代法實現 —— 前序遍歷
 */
private void preorderByIteration(){
    if (root == null) return;

    Stack<Node<E>> stack = new Stack<>();
    stack.push(root);

    while (!stack.isEmpty()) {
        Node<E> node = stack.pop();
        
		System.out.print(node.element + "  ");
        if (node.right != null){
            stack.push(node.right);
        }

        if (node.left != null){
            stack.push(node.left);
        }
    }
}

中序遍歷

訪問順序: — (根節點訪問在中

1、中序遍歷左子樹、根節點、中序遍歷右子樹 (如果是二叉搜尋樹,結果升序)

2、中序遍歷右子樹、根節點、中序遍歷左子樹 (如果是二叉搜尋樹,結果降序)

在這裡插入圖片描述

遍歷結果是:1、2、3、4、5、7、8、9、10、11、12 (升序)

實現步驟:

  • 利用棧先進後出的特性:
  • 設定node = root,將root入棧,迴圈執行以下操作,直到棧為空
  • 如果node!= nullnode入棧,設定node = node.left
  • 如果node == null如果棧為空,結束遍歷,如果棧不為空,彈出棧頂元素並賦值給node,對node進行訪問
  • 設定node = node.right
/**
 * 迭代法實現 —— 中序遍歷
 */
private void inorderByIteration(){
    if (root == null) return;
    Stack<Node<E>> stack = new Stack<>();
    Node<E> node = root;
    
    while (node != null || !stack.isEmpty()) {

        while (node != null){
            stack.push(node);
            node = node.left;
        }
        node = stack.pop();
        System.out.print(node.element + "  ");
        node = node.right;
    }
}

後序遍歷

訪問順序:後序遍歷左子樹、後序遍歷右子樹、根節點 — (根節點訪問在後

在這裡插入圖片描述
遍歷結果是:1、3、2、5、4、8、10、12、11、9、7

實現步驟:

  • 利用棧先進後出的特性:
  • 設定node = root,將root入棧,迴圈執行以下操作,直到棧為空
  • 如果棧頂節點是葉子節點或者上一次訪問的節點是棧頂節點的子節點,彈出棧頂節點,進行訪問
  • 否則,將棧頂節點的rightleft按順序入棧
/**
 * 迭代法實現 —— 後序遍歷
 */
private void postorderByIteration(){
    if (root == null) return;

    Node<E> node = root;
    //記錄上一次訪問的節點
    Node<E> lastVisited = null;
    Stack<Node<E>> stack = new Stack<>();
    while (node != null || !stack.isEmpty()) {

        while (node != null){
            stack.push(node);
            node = node.left;
        }

        node = stack.pop();
        //棧頂節點是葉子節點或者上一次訪問的節點是棧頂節點的子節點
        if (node.right == null || node.right == lastVisited){
            System.out.print(node.element + "  ");
            lastVisited = node;
            //這裡node沒有改變指向,所以需要指向null,否則會死迴圈
            node = null;
        }else {
            //既不是子節點且上一次訪問的節點又不是棧頂節點的子節點話,代表是符節點,重新進棧
            stack.push(node);
            node = node.right;
        }
    }
}

層序遍歷

訪問順序:從上到下、從左到右依次訪問每一個節點

在這裡插入圖片描述

遍歷結果是:7、4、9、2、5、8、11、1、3、10、12

層序遍歷採用迭代的方式實現,利用佇列的先進先出性質,能很好的做到層序遍歷

實現步驟:

  • 利用佇列先進先出的特性:
  • 將根節點root入隊,迴圈執行以下操作,直到佇列為空
  • 將隊頭節點node出隊,進行訪問,將node的左子節點入隊,將node的右子節點入隊

畫一波圖解:

在這裡插入圖片描述

結合圖解,看程式碼,很清晰

/**
 * 層序遍歷,迭代方式
 */
public void levelOrderTraversal(){
    if (root == null) return;

    Queue<Node<E>> queue = new LinkedList<>();
    //入隊
    queue.offer(root);

    while (!queue.isEmpty()){
        Node<E> node = queue.poll();
        System.out.print(node.element + "  ");

        //如果有左子節點,入隊
        if (node.left != null){
            queue.offer(node.left);
        }
        //如果有右子節點,入隊
        if (node.right != null){
            queue.offer(node.right);
        }
    }
}

補充

如果對於二叉樹的四種遍歷方式還是比較迷惑的話,我只是畫了靜態圖的順序,可能閱讀起來理解不夠到位,但是有時候畫圖解的時間很長,所以如果看不太明白的話,可以先看看別人的文章,圖解二叉樹的遍歷,看一下圖解,再回來閱讀,相信會好一些

增強遍歷介面

對比: 上面四種遍歷的方法都編寫出來了,但是你覺得這樣的遍歷,功能夠嗎? 或者說,你覺得這個對比我們前面在動態陣列,連結串列、棧和佇列中的遍歷有什麼區別?沒有閱讀過動手編寫 —— 動態陣列連結串列棧和佇列的同學,有興趣的可以點選關鍵詞看看

我們簡單寫一下JDK陣列或者動態連結串列遍歷的程式碼:

//遍歷陣列
int[] arr = {1,2,3,4,5,6,7,8,9};
for (int value:arr) {
    System.out.println(value);
}

//遍歷連結串列
List<Integer> list = new LinkedList<>(){{
    add(1);add(2);add(3);add(4);add(5);
}};
for (int value:list) {
    System.out.println(value);
}

這樣看起來好像沒有什麼問題,二叉樹遍歷是System.out.print(node.element);

而陣列和連結串列是System.out.println(value); 都是列印呀,能有什麼區別呀。可能上面的程式碼具備迷惑性,我們再來看看另一個程式碼:

int[] arr = {1,2,3,4,5,6,7,8,9};
for (int value:arr) {
    System.out.println(value + "-->" + "Kalton是帥哥");
}

嗨,這樣就醒目點了,陣列可以列印出節點儲存的元素的同時,補上一句Kalton是帥哥,而上面二叉樹的遍歷卻是做不到的,因為一個是寫死在類裡面的,一個是在類外部編寫的。

這樣的區別就是,二叉樹的遍歷只是列印一遍元素,並沒有真正獲取到存在在二叉樹的元素,而陣列、連結串列的遍歷是獲取到每一個元素,至於做什麼,列印還是增加,還是說Kalton是帥哥,這些遍歷規則都是由呼叫者自定義的,而不是寫死了,所以我們的二叉樹內部應該做到能夠遍歷的同時將節點元素傳給呼叫者,由使用者自定義遍歷規則,我們的做法時,在二叉樹編寫一個抽象內部類Visitor

/**
 * 提供外部使用的遍歷器介面
 * @param <E>
 */
public abstract static class Visitor<E>{

    //遍歷停止遍歷的標記
    boolean stop;

    /**
     * visit方法將節點元素傳給呼叫者
     * @param element
     * @return 如果返回true,結束遍歷
     */
    abstract boolean visit(E element);
}

visit方法方法引數為E element,在遍歷的時候接收節點元素,傳給外部呼叫者,返回值如果是true,表示使用者希望結束遍歷,我們以前序遍歷為例,實現我們的邏輯:

/**
 * 迭代法實現 —— 前序遍歷
 * @param visitor
 */
public void preorderByIteration(Visitor<E> visitor){
    if (root == null || visitor == null) return;

    Stack<Node<E>> stack = new Stack<>();
    stack.push(root);

    while (!stack.isEmpty()) {
        Node<E> node = stack.pop();

        //傳給外部呼叫者,如果條件成立,停止遍歷
        if (visitor.visit(node.element)) return;

        if (node.right != null){
            stack.push(node.right);
        }

        if (node.left != null){
            stack.push(node.left);
        }
    }
}

實際上,就是使用者在呼叫前序遍歷preorderByIteration,需要傳入自定義的遍歷規則類Visitor,然後在我們原來的方法列印元素的地方改為if (visitor.visit(node.element)) return;,即將節點元素返回給方法呼叫者,使用呼叫的遍歷規則,同時返回給二叉樹類一個boolean變數值,用stop接收,告知是否結束遍歷,所以什麼時候結束遍歷也是有使用者自定義規則的

但是有一點不好的是,這樣我們呼叫二叉樹的遍歷方法時,需要強制傳入一個遍歷規則類,同時我們遍歷的方法是遞迴還是迭代都對呼叫者暴露了,所以我做了一下小小的封裝:

/**
 * 前序遍歷 —— 如果使用者沒有傳遍歷規則,預設列印元素
 */
public void preorder(){
    preorder(new Visitor<>() {
        @Override
        boolean visit(E element) {
            System.out.print(element + " ");
            return false;
        }
    });
}

/**
 * 增強前序遍歷,提供呼叫者編寫自己的遍歷規則
 * @param visitor
 */
public void preorder(Visitor<E> visitor) {
    if (visitor == null) return;

    /**
     * 底層使用遞迴法
     */
    //preorderByRecursive(root, visitor);

    /**
     * 底層使用迭代法
     */
    preorderByIteration(visitor);
    System.out.println();
}

public void preorder()方法不需要傳參,預設遍歷規則是列印節點元素,而使用者需要自定義比較規則則呼叫public void preorder(Visitor<E> visitor),傳入比較器,至於是使用preorderByRecursive遞迴還是preorderByIteration,使用者並不知道,由我們在設計時決定,其他 3 種遍歷也是一樣的邏輯,程式碼比較長,不必要的展示,我就沒有貼出來,會在後面給出GitHub地址,需要的話,大家自行下載閱讀

樹的判定

二叉樹前奏中,我們已經講了完全二叉樹真二叉樹的特點以及性質,這裡就不再複述了,實際上,對於他們的判定以及計算樹的高度,都是以層序遍歷方法為基礎,所完成的,所以層序遍歷是很重要的,最好是給我收手寫出來

完全二叉樹的判定

實現步驟:

在這裡插入圖片描述

/**
 * 判斷是否為完全二叉樹 —— (層序遍歷)
 * @return
 */
public boolean isComplete() {
    if (root == null) return false;
    Queue<Node<E>> queue = new LinkedList<>();
    queue.offer(root);

    boolean leaf = false;
    while (!queue.isEmpty()) {
        Node<E> node = queue.poll();
        if (leaf && !node.isLeaf()) return false;

        if (node.left != null) {
            queue.offer(node.left);
        } else if (node.right != null) {
            //相當於node.left == null && node.right != null
            return false;
        }

        if (node.right != null) {
            queue.offer(node.right);
        } else {
            //node.left == null && node.right == null
            //node.left != null && node.right == null
            // 後面遍歷的節點都必須是葉子節點
            leaf = true;
        }
    }
    return true;
}

真二叉樹的判定

實現步驟:

1、利用佇列先進先出的特性

2、利用真二叉樹的節點,要麼度為0,要麼度為2的特點

3、在層序遍歷的時候,判斷每層的節點數量,如果levelSize % 2 != 0,返回flase

4、結合上面層序遍歷的圖解,就會發現,每一層的節點遍歷完後,佇列中的節點數量size等於下一層的節點數量,而第一層只有根節點

/**
 * 判斷是否為真二叉樹 —— (層序遍歷)
 * @return
 */
public boolean isProper (){
    if (root == null) return false;

    // 儲存著每一層的元素數量
    int levelSize = 1;
    Queue<Node<E>> queue = new LinkedList<>();
    queue.offer(root);

    while (!queue.isEmpty()) {
        Node<E> node = queue.poll();
        levelSize--;

        if (node.left != null) {
            queue.offer(node.left);
        }

        if (node.right != null) {
            queue.offer(node.right);
        }

        // 意味著即將要訪問下一層
        if (levelSize == 0) {
            //每一層訪問完後,下一層的節點個數是佇列的size
            levelSize = queue.size();
            if (levelSize % 2 != 0) return false;
        }
    }
    return true;
}

樹的高度

樹的高度實際上就是樹的層數,與上面的判定真二叉樹很接近,只需要在設定一個height,在遍歷完每一層的時候,height++,結束遍歷後,返回的就是樹的高度

樹的高度實際上所有子節點的高度中最大的一個,然後再 + 1,這樣的思路,很容易以遞迴的方式實現,所以計算樹的高度,有遞迴和迭代兩種方法,這裡貼出遞迴的方法,因為迭代的方法就是上面判定二叉樹的方法做點小改動,就不貼出來了,需要的話,自行下載原始碼。

/**
 * 計算樹的高度
 * @return
 */
public int height(){
    //遞迴法
    return heightByRecursive(root);

    //迭代法
    //return heightByIteration();
}

/**
 * (遞迴法)獲取傳入節點的高度
 * @param node
 * @return
 */
private int heightByRecursive(Node<E> node){
    if (node == null) return 0;
    return 1 + Math.max(heightByRecursive(node.left),heightByRecursive(node.right));
}

前驅與後繼

尋找前驅節點

在這裡插入圖片描述

根據上面的判定條件給出實現程式碼:

/**
 * 獲取傳入節點的前驅節點
 * @param node
 * @return
 */
protected Node<E> predecessor(Node<E> node) {
    if (node == null) return null;

    // 前驅節點在左子樹當中(left.right.right.right....)
    Node<E> p = node.left;
    if (p != null) {
        while (p.right != null) {
            p = p.right;
        }
        return p;
    }

    // 從父節點、祖父節點中尋找前驅節點
    while (node.parent != null && node == node.parent.left) {
        node = node.parent;
    }

    // node.parent == null
    // node == node.parent.right
    return node.parent;
}

尋找後繼節點

在這裡插入圖片描述

根據上面的判定條件給出實現程式碼:

/**
 * 獲取傳入節點的後繼節點
 * @param node
 * @return
 */
protected Node<E> successor(Node<E> node) {
    if (node == null) return null;

    // 前驅節點在左子樹當中(right.left.left.left....)
    Node<E> p = node.right;
    if (p != null) {
        while (p.left != null) {
            p = p.left;
        }
        return p;
    }

    // 從父節點、祖父節點中尋找前驅節點
    while (node.parent != null && node == node.parent.right) {
        node = node.parent;
    }

    return node.parent;
}

現在我們可能會比較疑惑,這兩個方法,找出前驅或者後繼節點有什麼用,不知道你有沒有看到方法的訪問修飾符:protected,實際上著兩個方法都不是給使用者呼叫的,正如我們的疑惑一樣,使用者不知道怎麼用,用來幹嘛,實際上這是為繼承二叉樹BinaryTree類的子類所使用的,其作用是在刪除度為 2 的時,將找到的前驅或者後繼節點用來替代的,這在下一篇,二叉搜尋樹的時候回說到。

小結

​ 到這裡為止,已經將二叉樹的基本概念複習以及通用方法的設計編寫,對於二叉樹的結構有了一定的認識,但知識有時候總是在你覺得記住的時候偷偷溜走,因此,將所學的知識總結成筆記,以便後來翻閱,加深印象

宣告

文章為原創,歡迎轉載,註明出處即可

個人能力有限,有不正確的地方,還請指正

本文的程式碼已上傳github,歡迎star —— GitHub地址

相關文章