【學點數據結構和算法】05-樹

寫在前面: 博主是一名軟件工程系大數據應用開發專業大二的學生,暱稱來源於《愛麗絲夢遊仙境》中的Alice和自己的暱稱。作爲一名互聯網小白,寫博客一方面是爲了記錄自己的學習歷程,一方面是希望能夠幫助到很多和自己一樣處於起步階段的萌新。由於水平有限,博客中難免會有一些錯誤,有紕漏之處懇請各位大佬不吝賜教!個人小站:http://alices.ibilibili.xyz/ , 博客主頁:https://alice.blog.csdn.net/
儘管當前水平可能不及各位大佬,但我還是希望自己能夠做得更好,因爲一天的生活就是一生的縮影。我希望在最美的年華,做最好的自己

        通過【學點數據結構和算法】系列的1-4,我們已經學習了數據結構中常用的線性結構。從物理存儲方面來說,它們又分爲順序存儲鏈式存儲結構。他們各自有自己的優缺點,順序存儲結構讀快寫慢,鏈式存儲結構寫快讀慢。但是這些數據元素之間的關係都爲一對一的關係,而我們生活中關係不止是一對一,有可能是一對多,多對多的情況… 本篇博客,我們就要學習一種新的數據結構——,它將爲我們展示一個全新的“世界”。

在這裏插入圖片描述


        

1、什麼是樹?

        跟博主一樣,初學數據結構的朋友可能會感到疑惑,到底什麼是樹呢?其實在現實生活中有很多體現樹邏輯的例子。

        最好的一個例子就是“家譜圖”,這就是一個“樹”。
在這裏插入圖片描述

        例如企業裏的職級關係,也是一個“樹”
在這裏插入圖片描述
        甚至是一本書的目錄,我們也能將其抽象成一個“樹”。在這裏插入圖片描述
        類似的例子還有很多,這裏就不一一列舉了。不知道細心的朋友們,是否發現以上這些例子有什麼共同點呢?爲什麼可以稱它們爲“樹”呢?

        因爲它們都像自然界中的樹一樣,從同一個“根”衍生出許多“枝幹”,再從每一個“枝 幹”衍生出許多更小的“枝幹”,最後衍生出更多的“葉子”。
在這裏插入圖片描述
        在數據結構中,樹的定義如下。

樹(tree)是n(n≥0)個節點的有限集。當n=0時,稱爲空樹。在任意一個非空樹中,有如下特點。
1.有且僅有一個特定的稱爲根的節點。
2.當n>1時,其餘節點可分爲m(m>0)個互不相交的有限集,每一個集合本身又是一 個樹,並稱爲根的子樹。

        下面這張圖,就是一個標準的樹結構。
在這裏插入圖片描述
        在上圖中,節點1是根節點(root);節點5、6、7、8、9是樹的末端,沒有“孩子”,被 稱爲葉子節點(leaf)。圖中的虛線部分,是根節點1的其中一個子樹。

        同時,樹的結構從根節點到葉子節點,分爲不同的層級。從一個節點的角度來看,它的上下級和同級節點關係如下。
在這裏插入圖片描述
        在上圖中,節點4的上一級節點,是節點4的父節點(parent);從節點4衍生出來的 節點,是節點4的孩子節點(child);和節點4同級,由同一個父節點衍生出來的節點, 是節點4的兄弟節點(sibling)

        樹的最大層級數,被稱爲樹的高度或深度。顯然,上圖這個樹的高度是4。

        瞭解了樹的基本術語之後,我們來學習一種典型的樹——二叉樹。
        

2、二叉樹

        二叉樹(binary tree)是樹的一種特殊形式。二叉,顧名思義,這種樹的每個節點最多有2個孩子節點。注意,這裏是最多有2個,也可能只有1個,或者沒有孩子節點。

        二叉樹的結構如圖所示。
在這裏插入圖片描述
        二叉樹節點的兩個孩子節點,一個被稱爲左孩子(left child),一個被稱爲右孩子 (right child)。這兩個孩子節點的順序是固定的,就像人的左手就是左手,右手就是右手,不能夠顛倒或混淆。

        此外,二叉樹還有兩種特殊形式,一個叫作滿二叉樹,另一個叫作完全二叉樹

2.1 滿二叉樹

        什麼是滿二叉樹呢?

        一個二叉樹的所有非葉子節點都存在左右孩子,並且所有葉子節點都在同一層級上, 那麼這個樹就是滿二叉樹
在這裏插入圖片描述
        簡單點說,滿二叉樹的每一個分支都是滿的。

2.2 完全二叉樹

        什麼又是完全二叉樹呢?完全二叉樹的定義很有意思。

        對一個有n個節點的二叉樹,按層級順序編號,則所有節點的編號爲從1到n。如果這個樹所有節點和同樣深度的滿二叉樹的編號爲從1到n的節點位置相同,則這個二叉樹爲完全二叉樹。

        如果覺得很繞的話,可以看看下面這個圖。
在這裏插入圖片描述
        在上圖中,二叉樹編號從1到12的12個節點,和前面滿二叉樹編號從1到12的節點位置完全對應。因此這個樹是完全二叉樹。

        完全二叉樹的條件沒有滿二叉樹那麼苛刻:滿二叉樹要求所有分支都是滿的;而完全二叉樹只需保證最後一個節點之前的節點都齊全即可
        

2.3 二叉樹的存儲

        二叉樹可以用鏈式存儲結構和數組結構來表達。

2.3.1 鏈式存儲結構

在這裏插入圖片描述
        鏈式存儲是二叉樹最直觀的存儲方式。

        之前介紹過的鏈表,是一對一的存儲方式,每一個鏈表節點擁有data變量和一個指 向下一節點的next指針。

        而二叉樹稍微複雜一些,一個節點最多可以指向左右兩個孩子節點,所以二叉樹的每 一個節點包含3部分

  • 存儲數據的data變量
  • 指向左孩子的left指針
  • 指向右孩子的right指針
2.3.2 數組

在這裏插入圖片描述
        使用數組存儲時,會按照層級順序把二叉樹的節點放到數組中對應的位置上。如果某 一個節點的左孩子或右孩子空缺,則數組的相應位置也空出來。

        爲什麼這樣設計呢?因爲這樣可以更方便地在數組中定位二叉樹的孩子節點和父節點

        假設一個父節點的下標是parent,那麼它的左孩子節點下標就是2×parent + 1;右孩子節點下標就是2×parent + 2。

        反過來,假設一個左孩子節點的下標是leftChild,那麼它的父節點下標就是 (leftChild-1)/ 2。

        假如節點4在數組中的下標是3,節點4是節點2的左孩子,節點2的下標可以直接通過計算得出。
在這裏插入圖片描述

        顯然,對於一個稀疏的二叉樹來說,用數組表示法是非常浪費空間的

        那什麼樣的二叉樹最適合用數組表示呢?

        後邊即將介紹的二叉堆,一種特殊的完全二叉樹,就是用數組來存儲的。

2.4 二叉樹的應用

        二叉樹包含許多特殊的形式,每一種形式都有自己的作用,但是其最主要的應用還在於進行查找操作和維持相對順序這兩個方面。

2.4.1 查找

        二叉樹的樹形結構使它很適合扮演索引的角色。

        這裏我們介紹一種特殊的二叉樹:二叉查找樹(binary search tree)。光看名字就可 以知道,這種二叉樹的主要作用就是進行查找操作。

        二叉查找樹在二叉樹的基礎上增加了以下幾個條件。

  • 如果左子樹不爲空,則左子樹上所有節點的值均小於根節點的值
  • 如果右子樹不爲空,則右子樹上所有節點的值均大於根節點的值
  • 左、右子樹也都是二叉查找樹

        下圖就是一個標準的二叉查找樹。
在這裏插入圖片描述
        二叉查找樹的這些條件有什麼用呢?當然是爲了查找方便。

        例如查找值爲4的節點,步驟如下。

        1. 訪問根節點6,發現4<6。在這裏插入圖片描述
        2. 訪問節點6的左孩子節點3,發現4>3。

在這裏插入圖片描述
        3. 訪問節點3的右孩子節點4,發現4=4,這正是要查找的節點。
在這裏插入圖片描述
        對於一個節點分佈相對均衡的二叉查找樹來說,如果節點總數是n,那麼搜索節點的時間複雜度就是O(logn),和樹的深度是一樣的

        這種依靠比較大小來逐步查找的方式,和二分查找算法非常相似

2.4.2 維持相對順序

        這一點仍然要從二叉查找樹說起。二叉查找樹要求左子樹小於父節點,右子樹大於父節點,正是這樣保證了二叉樹的有序性

        因此二叉查找樹還有另一個名字——二叉排序樹(binary sort tree)

        新插入的節點,同樣要遵循二叉排序樹的原則。例如插入新元素5,由於5<6,5>3, 5>4,所以5最終會插入到節點4的右孩子位置。

        再如插入新元素10,由於10>6,10>8,10>9,所以10最終會插入到節點9的右孩子位置。

在這裏插入圖片描述
        這一切看起來很順利,然而卻隱藏着一個致命的問題。什麼問題呢?下面請試着在二 叉查找樹中依次插入9、8、7、6、5、4,看看會出現什麼結果。
在這裏插入圖片描述

        不只是外觀看起來變得怪異了,查詢節點的時間複雜度也退化成 了O(n)。

        怎麼解決這個問題呢?這就涉及二叉樹的自平衡了。二叉樹自平衡的方式有多種, 如紅黑樹、AVL樹、樹堆等。

        除二叉查找樹以外,二叉堆也維持着相對的順序。不過二叉堆的條件要寬鬆一些,只要求父節點比它的左右孩子都大。

3、二叉樹的遍歷

        上面幾節我們學習了二叉樹的基礎知識,接下來我們來探討二叉樹的遍歷。

        當我們介紹數組、鏈表時,爲什麼沒有着重研究他們的遍歷過程呢?

        二叉樹的遍歷又有什麼特殊之處?

        在計算機程序中,遍歷本身是一個線性操作。所以遍歷同樣具有線性結構的數組或鏈表,是一件輕而易舉的事情。

在這裏插入圖片描述
        反觀二叉樹,是典型的非線性數據結構,遍歷時需要把非線性關聯的節點轉化成一個 線性的序列,以不同的方式來遍歷,遍歷出的序列順序也不同
在這裏插入圖片描述
        從節點之間位置關係的角度來看,二叉樹的遍歷分爲4種。

  1. 前序遍歷。
  2. 中序遍歷。
  3. 後序遍歷。
  4. 層序遍歷。

        從更宏觀的角度來看,二叉樹的遍歷歸結爲兩大類。

  • 深度優先遍歷(前序遍歷、中序遍歷、後序遍歷)
  • 廣度優先遍歷(層序遍歷)

3.1 深度優先遍歷

        深度優先和廣度優先這兩個概念不止侷限於二叉樹,它們更是一種抽象的算法思想, 決定了訪問某些複雜數據結構的順序。在訪問樹、圖,或其他一些複雜數據結構時,這兩 個概念常常被使用到。

        所謂深度優先,顧名思義,就是偏向於縱深,“一頭扎到底”的訪問方式。可能這種說法有些抽象,下面就通過二叉樹的前序遍歷、中序遍歷、後序遍歷,來看一看深度優先是怎麼回事吧。

3.1.1 前序遍歷

        二叉樹的前序遍歷,輸出順序是根節點、左子樹、右子樹。
在這裏插入圖片描述
        上圖就是一個二叉樹的前序遍歷,每個節點左側的序號代表該節點的輸出順序,詳細 步驟如下。

        1. 首先輸出的是根節點1。
在這裏插入圖片描述
        2. 由於根節點1存在左孩子,輸出左孩子節點2。

在這裏插入圖片描述
        3. 由於節點2也存在左孩子,輸出左孩子節點4。
在這裏插入圖片描述
        4. 節點4既沒有左孩子,也沒有右孩子,那麼回到節點2,輸出節點2的右孩子節點5。
在這裏插入圖片描述
        5. 節點5既沒有左孩子,也沒有右孩子,那麼回到節點1,輸出節點1的右孩子節點3。
在這裏插入圖片描述
        6. 節點3沒有左孩子,但是有右孩子,因此輸出節點3的右孩子節點6。
在這裏插入圖片描述
        到此爲止,所有的節點都遍歷輸出完畢。

3.1.2 中序遍歷

        二叉樹的中序遍歷,輸出順序是左子樹、根節點、右子樹。
在這裏插入圖片描述
        上圖就是一個二叉樹的中序遍歷,每個節點左側的序號代表該節點的輸出順序,詳細步驟如下。

        1. 首先訪問根節點的左孩子,如果這個左孩子還擁有左孩子,則繼續深入訪問下去, 一直找到不再有左孩子的節點,並輸出該節點。顯然,第一個沒有左孩子的節點是節點 4。
在這裏插入圖片描述

        2. 依照中序遍歷的次序,接下來輸出節點4的父節點2。
在這裏插入圖片描述
        3. 再輸出節點2的右孩子節點5。
在這裏插入圖片描述
        4. 以節點2爲根的左子樹已經輸出完畢,這時再輸出整個二叉樹的根節點1。
在這裏插入圖片描述
        5. 由於節點3沒有左孩子,所以直接輸出根節點1的右孩子節點3。
在這裏插入圖片描述
        6. 最後輸出節點3的右孩子節點6。
在這裏插入圖片描述
        到此爲止,所有的節點都遍歷輸出完畢。

3.1.3 後序遍歷

        二叉樹的後序遍歷,輸出順序是左子樹、右子樹、根節點。

在這裏插入圖片描述
        上圖就是一個二叉樹的後序遍歷,每個節點左側的序號代表該節點的輸出順序。

        由於二叉樹的後序遍歷和前序、中序遍歷的思想大致相同,相信各位小夥伴已經可以 推測出分解步驟,這裏就不再列舉細節了。

        下面展示不同遍歷方式的代碼書寫。

3.1.4 遞歸實現三種遍歷代碼
public class BinaryTreeTraversal {

    /**
     * 構建二叉樹
     * @param inputList   輸入序列
     */
    public static TreeNode createBinaryTree(LinkedList<Integer> inputList){
        TreeNode node = null;
        if(inputList==null || inputList.isEmpty()){
            return null;
        }
        Integer data = inputList.removeFirst();
        //這裏的判空很關鍵。如果元素是空,說明該節點不存在,跳出這一層遞歸;如果元素非空,繼續遞歸構建該節點的左右孩子。
        if(data != null){
            node = new TreeNode(data);
            node.leftChild = createBinaryTree(inputList);
            node.rightChild = createBinaryTree(inputList);
        }
        return node;
    }

    /**
     * 二叉樹前序遍歷
     * @param node   二叉樹節點
     */
    public static void preOrderTraversal(TreeNode node){
        if(node == null){
            return;
        }
        System.out.println(node.data);
        preOrderTraversal(node.leftChild);
        preOrderTraversal(node.rightChild);
    }

    /**
     * 二叉樹中序遍歷
     * @param node   二叉樹節點
     */
    public static void inOrderTraversal(TreeNode node){
        if(node == null){
            return;
        }
        inOrderTraversal(node.leftChild);
        System.out.println(node.data);
        inOrderTraversal(node.rightChild);
    }


    /**
     * 二叉樹後序遍歷
     * @param node   二叉樹節點
     */
    public static void postOrderTraversal(TreeNode node){
        if(node == null){
            return;
        }
        postOrderTraversal(node.leftChild);
        postOrderTraversal(node.rightChild);
        System.out.println(node.data);
    }

    /**
     * 二叉樹節點
     */
    private static class TreeNode {
        int data;
        TreeNode leftChild;
        TreeNode rightChild;

        TreeNode(int data) {
            this.data = data;
        }
    }

    public static void main(String[] args) {
        LinkedList<Integer> inputList = new LinkedList<Integer>(Arrays.asList(3,2,9,null,null,10,null,null,8,null,4));
        TreeNode treeNode = createBinaryTree(inputList);
        System.out.println("前序遍歷:");
        preOrderTraversal(treeNode);
        System.out.println("中序遍歷:");
        inOrderTraversal(treeNode);
        System.out.println("後序遍歷:");
        postOrderTraversal(treeNode);
    }

}

        運行結果

前序遍歷:
3
2
9
10
8
4
中序遍歷:
9
2
10
3
8
4
後序遍歷:
9
10
2
4
8
3

        二叉樹用遞歸方式來實現前序、中序、後序遍歷,是最爲自然的方式,因此代碼也非 常簡單。

        這3種遍歷方式的區別,僅僅是輸出的執行位置不同:前序遍歷的輸出在前,中序遍
歷的輸出在中間,後序遍歷的輸出在最後。

        代碼中值得注意的一點是二叉樹的構建。二叉樹的構建方法有很多,這裏把一個線性 的鏈表轉化成非線性的二叉樹鏈表節點的順序恰恰是二叉樹前序遍歷的順序。鏈表中的 空值,代表二叉樹節點的左孩子或右孩子爲空的情況

        在代碼的main函數中,通過{3,2,9,null,null,10,null,null,8,null,4}這樣一個線性序列,構 建成的二叉樹如下。
在這裏插入圖片描述

        絕大多數可以用遞歸解決的問題,其實都可以用另一種數據結構來解決,這種數據結構就是。因爲遞歸和棧都有回溯的特性

        如何藉助棧來實現二叉樹的非遞歸遍歷呢?下面以二叉樹的前序遍歷爲例,看一看具體過程。

        1. 首先遍歷二叉樹的根節點1,放入棧中。
在這裏插入圖片描述
        2. 遍歷根節點1的左孩子節點2,放入棧中。
在這裏插入圖片描述
        3. 遍歷節點2的左孩子節點4,放入棧中。
在這裏插入圖片描述
        4. 節點4既沒有左孩子,也沒有右孩子,我們需要回溯到上一個節點2。可是現在並不 是做遞歸操作,怎麼回溯呢?

        別擔心,棧已經存儲了剛纔遍歷的路徑。讓舊的棧頂元素4出棧,就可以重新訪問節 點2,得到節點2的右孩子節點5。

        此時節點2已經沒有利用價值(已經訪問過左孩子和右孩子),節點2出棧,節點5入棧。
在這裏插入圖片描述
        5. 節點5既沒有左孩子,也沒有右孩子,我們需要再次回溯,一直回溯到節點1。所以 讓節點5出棧。

        根節點1的右孩子是節點3,節點1出棧,節點3入棧。
在這裏插入圖片描述
        6. 節點3的右孩子是節點6,節點3出棧,節點6入棧。
在這裏插入圖片描述
        7. 節點6既沒有左孩子,也沒有右孩子,所以節點6出棧。此時棧爲空,遍歷結束。
在這裏插入圖片描述

3.1.5 非遞歸前序遍歷的代碼
/**
     * 二叉樹非遞歸前序遍歷
     * @param root   二叉樹根節點
     */
    public static void preOrderTraveralWithStack(TreeNode root){
        Stack<TreeNode> stack = new Stack<TreeNode>();
        TreeNode treeNode = root;
        while(treeNode!=null || !stack.isEmpty()){
            //迭代訪問節點的左孩子,併入棧
            while (treeNode != null){
                System.out.println(treeNode.data);
                stack.push(treeNode);
                treeNode = treeNode.leftChild;
            }
            //如果節點沒有左孩子,則彈出棧頂節點,訪問節點右孩子
            if(!stack.isEmpty()){
                treeNode = stack.pop();
                treeNode = treeNode.rightChild;
            }
        }
    }

        至於二叉樹的中序、後序遍歷的非遞歸實現,思路和前序遍歷差不太多,都是利用棧 來進行回溯。各位讀者要是有興趣的話,可以自己嘗試用代碼實現一下。
        

3.2 廣度優先遍歷

3.2.1 流程

        如果說深度優先遍歷是在一個方向上“一頭扎到底”,那麼廣度優先遍歷則恰恰相反: 先在各個方向上各走出1步,再在各個方向上走出第2步、第3步……一直到各個方向全部走完。聽起來有些抽象,下面讓我們通過二叉樹的層序遍歷,來看一看廣度優先是怎麼回事。

        層序遍歷,顧名思義,就是二叉樹按照從根節點到葉子節點的層次關係,一層一層橫向遍歷各個節點。
在這裏插入圖片描述
        上圖就是一個二叉樹的層序遍歷,每個節點左側的序號代表該節點的輸出順序。

        可是,二叉樹同一層次的節點之間是沒有直接關聯的,如何實現這種層序遍歷呢?

        這裏同樣需要藉助一個數據結構來輔助工作,這個數據結構就是隊列。

        詳細遍歷步驟如下。

        1. 根節點1進入隊列。
在這裏插入圖片描述

        2. 節點1出隊,輸出節點1,並得到節點1的左孩子節點2、右孩子節點3。讓節點2和節 點3入隊。
在這裏插入圖片描述
        3. 節點2出隊,輸出節點2,並得到節點2的左孩子節點4、右孩子節點5。讓節點4和節 點5入隊。
在這裏插入圖片描述
        5. 節點4出隊,輸出節點4,由於節點4沒有孩子節點,所以沒有新節點入隊。
在這裏插入圖片描述
        6. 節點5出隊,輸出節點5,由於節點5同樣沒有孩子節點,所以沒有新節點入隊。
在這裏插入圖片描述
        7. 節點6出隊,輸出節點6,節點6沒有孩子節點,沒有新節點入隊。
在這裏插入圖片描述
        到此爲止,所有的節點都遍歷輸出完畢。

3.2.2 代碼
/**
     * 二叉樹層序遍歷
     * @param root   二叉樹根節點
     */
    public static void levelOrderTraversal(TreeNode root){
        Queue<TreeNode> queue = new LinkedList<TreeNode>();
        queue.offer(root);
        while(!queue.isEmpty()){
            TreeNode node = queue.poll();
            System.out.println(node.data);
            if(node.leftChild != null){
                queue.offer(node.leftChild);
            }
            if(node.rightChild != null){
                queue.offer(node.rightChild);
            }
        }
    }

        

在這裏插入圖片描述

        
        本篇博客中代碼和彩圖來源於《漫畫算法》,應本書作者要求,加上本書公衆號《程序員小灰》二維碼。
在這裏插入圖片描述
        感興趣的朋友可以去購買正版實體書,確實不錯,非常適合小白入門。
在這裏插入圖片描述

小結

  • 什麼是樹

        樹是n個節點的有限集,有且僅有一個特定的稱爲根的節點。當n>1時,其餘節點可分爲m個互不相交的有限集,每一個集合本身又是一個樹,並稱爲根的子樹。

  • 什麼是二叉樹

        二叉樹是樹的一種特殊形式,每一個節點最多有兩個孩子節點。二叉樹包含完全二叉樹和滿二叉樹兩種特殊形式。

  • 二叉樹的遍歷方式有幾種

        根據遍歷節點之間的關係,可以分爲前序遍歷、中序遍歷、後序遍歷、層序遍歷這4 種方式;從更宏觀的角度劃分,可以劃分爲深度優先遍歷和廣度優先遍歷兩大類。


        因爲篇幅問題,本該屬於樹範圍內的知識——二叉堆和優先隊列的內容,就先不在本篇博客中展示了。

        受益的朋友或對大數據技術感興趣的夥伴記得點贊關注支持一波🙏

        希望我們都能在學習的道路上越走越遠😉
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章