前言
【從蛋殼到滿天飛】JS 資料結構解析和演算法實現,全部文章大概的內容如下: Arrays(陣列)、Stacks(棧)、Queues(佇列)、LinkedList(連結串列)、Recursion(遞迴思想)、BinarySearchTree(二分搜尋樹)、Set(集合)、Map(對映)、Heap(堆)、PriorityQueue(優先佇列)、SegmentTree(線段樹)、Trie(字典樹)、UnionFind(並查集)、AVLTree(AVL 平衡樹)、RedBlackTree(紅黑平衡樹)、HashTable(雜湊表)
原始碼有三個:ES6(單個單個的 class 型別的 js 檔案) | JS + HTML(一個 js 配合一個 html)| JAVA (一個一個的工程)
全部原始碼已上傳 github,點選我吧,光看文章能夠掌握兩成,動手敲程式碼、動腦思考、畫圖才可以掌握八成。
本文章適合 對資料結構想了解並且感興趣的人群,文章風格一如既往如此,就覺得手機上看起來比較方便,這樣顯得比較有條理,整理這些筆記加原始碼,時間跨度也算將近半年時間了,希望對想學習資料結構的人或者正在學習資料結構的人群有幫助。
樹結構
- 線性資料結構是把所有的資料排成一排
- 樹結構是倒立的樹,由一個根節點延伸出很多新的分支節點。
- 樹結構本身是一個種天然的組織結構
- 如 電腦中資料夾目錄結構就是樹結構
- 這種結構來源於生活,
- 比如 圖書館整體分成幾個大館,
- 如 數理館、文史館等等,
- 到了數理館還要分成 很多的子類,
- 如 數學類的圖書、物理類的圖書、化學類的圖書,計算機類的圖書,
- 到了計算機類的圖書還要再分成各種不同的子類,
- 如 按語言分類 c++、java、c#、php、python、javascript 等等,
- 如 按領域分類 網站程式設計、app 開發、遊戲開發、前端、後端等等,
- 每一個子領域可能又要分成很多領域,
- 一直到最後索引到一本一本的書,
- 這就是一個典型的樹結構。
- 還有 一個公司的組織架構也是這樣的一種樹結構,
- 從 CEO 開始下面可能有不同的部門,
- 如財務部門(Marketing Head)、人事部門(HR Head)、
- 技術部門(Finance Head)、市場部門(Audit Officer)等等,
- 每個部門下面還有不同的職能分工,最後才到具體的一個一個人。
- 還有家譜,他本身也是一個樹結構,
- 其實樹結構並不抽象,在生活中隨處可見。
- 樹結構非常的高效
- 比如檔案管理,
- 不可能將所有的檔案放到一個資料夾中,
- 然後用一個線性的結構進行儲存,
- 那樣的話查詢檔案太麻煩了,
- 但是如果給它做成樹機構的話,
- 那麼就可以很容易的檢索到目標檔案,
- 比如說我想檢索到我的照片,
- 直接找到個人資料夾,然後找到圖片資料夾,
- 最後找到自己的照片,這樣就很快速很高效的找到了目標檔案。
- 在公司使用這種樹形的組織架構也是這個原因,
- CEO 想就技術開發的一些問題進行一些討論,
- 他肯定要找相應職能的一些人,
- 他不需要去市場部門、營銷部門、人事部門、財務部門、行政部門找人,
- 他直接去技術部這樣的開發部門去找人就好了,
- 一下子就把查詢的範圍縮小了。
- 在資料結構領域設計樹結構的本質也是如此。
- 在電腦科學領域很多問題的處理
- 當你將資料使用樹結構進行儲存後,出奇的高效。
- 二分搜尋樹(Binary Search Tree)
- 二分搜尋樹有它的侷限性
- 平衡二叉樹:AVL;紅黑樹,
- 平衡二叉樹還有很多種
- 演算法需要使用一些特殊的操作的時候將資料組織成樹結構
- 會針對某一類特殊的操作產生非常高效的結果,
- 使用
堆
以及並查集
, - 都是為了滿足對資料某一個類特殊的操作進行高效的處理,
- 同時對於某些特殊的資料,很多時候可以另闢蹊徑,
- 將他們以某種形式儲存成樹結構,
- 結果就是會對這類特殊的資料
- 它們所在的那個領域的問題
- 相應的解決方案提供極其高效的結果。
- 線段樹、Trie(字典樹、字首樹)
- 線段樹主要用來處理線段這種特殊的資料,
- Trie 主要用於處理字串這類特殊的資料,
- 要想實現快速搜尋的演算法,
- 它的本質依然是需要使用樹結構的,
- 樹結構不見得是顯式的展示在你面前,
- 它同時也可以用來處理很多抽象的問題,
- 這就像棧的應用一樣,
- 從使用者的角度看只看撤銷這個操作或者只看括號匹配的操作,
- 使用者根本想不到這背後使用了一個棧的資料結構,
- 但是為了組建出這樣的功能是需要使用這種資料結構的,
- 同理樹也是如此,很多看起來非常高效的運算結果,
- 它的背後其實是因為有樹這種資料結構作為支撐的,
- 這也是資料結構、包括資料結構在電腦科學領域非常重要的意義,
- 資料結構雖然解決的是資料儲存的問題,
- 但是在使用的層面上不僅僅是因為要儲存資料,
- 更重要的是在你使用某些特殊的資料結構儲存資料後,
- 可以幫助你輔助你更加高效的解決某些演算法問題
- 甚至對於某些問題來說如果沒有這些資料結構,
- 那麼根本無從解決。
二分搜尋樹(Binary Search Tree)
- 二叉樹
- 和連結串列一樣,也屬於動態資料結構,
- 不需要建立這個資料結構的時候就定好儲存的容量,
- 如果要新增元素,直接 new 一個新的空間,
- 然後把它新增到這個資料結構中,刪除也是同理,
- 每一個元素也是存到一個節點中,
- 這個節點和連結串列不同,它除了要存放這個元素 e,
- 它還有兩個指向其它節點的變數,分別叫做 left、right,
class Node { e; // Element left; // Node right; // Node } 複製程式碼
- 二叉樹也叫多叉樹,
- 它每一個節點最多隻能分成兩個叉,
- 根據這個定義也能定義出多叉樹,
- 如果每個節點可以分出十個叉,
- 那就可以叫它十叉樹,能分多少叉就叫多少叉樹,
- Trie 字典書本身就是一個多叉樹。
- 在資料結構領域對應樹結構來說
- 二叉樹是最常用的一種樹結構,
- 二叉樹具有一個唯一的根節點,
- 也就是最上面的節點。
- 每一個節點最多有兩個子節點,
- 這兩個子節點分別叫做這個節點的左孩子和右孩子,
- 子節點指向左邊的那個節點就是左孩子,
- 子節點指向右邊的那個節點就是右孩子。
- 二叉樹每個節點最多有兩個孩子,
- 一個孩子都沒有的節點通常稱之為葉子節點,
- 二叉樹每個節點最多有一個父親,
- 根節點是沒有父親節點的。
- 二叉樹和連結串列一樣具有天然遞迴的結構
- 連結串列本身是線性的,
- 它的操作既可以使用迴圈也可以使用遞迴。
- 和樹相關的很多操作,
- 使用遞迴的方式去寫要比使用非遞迴的方式簡單很多。
- 二叉樹每一個節點的左孩子同時也是一個二叉樹的根節點,
- 通常叫管這棵二叉樹做左子樹。
- 二叉樹每一個節點的右孩子同時也是一個二叉樹的根節點,
- 通常叫管這棵二叉樹做右子樹。
- 也就是說每一個二叉樹它的左側和右側右分別連線了兩個二叉樹,
- 這兩個二叉樹都是節點個數更小的二叉樹,
- 這就是二叉樹所具有的天然的遞迴結構。
- 二叉樹不一定是“滿”的
- 滿二叉樹就是除了葉子節點之外,
- 每一個節點都有兩個孩子。
- 就算你整個二叉樹上只有一個節點,
- 它也是一個二叉樹,只不過它的左右孩子都是空,
- 這棵二叉樹只有一個根節點,
- 甚至 NULL(空)也是一棵二叉樹。
- 就像連結串列中,只有一個節點它也是一個連結串列,
- 也可以把 NULL(空)看作是一個連結串列。
- 二分搜尋樹是一棵二叉樹
- 在二叉樹定義下所有其它的術語在二分搜尋樹中也適用,
- 如 根節點、葉子節點、左孩子右孩子、左子樹、右子樹、
- 父親節點等等,這些在二分搜尋樹中也一樣。
- 二分搜尋樹的每一個節點的值
- 都要大於其左子樹的所有節點的值,
- 都要小於其右子樹的所有節點的值。
- 在葉子節點上沒有左右孩子,
- 那就相當於也滿足這個條件。
- 二分搜尋樹的每一棵子樹也是二分搜尋樹
- 對於每一個節點來說,
- 它的左子樹所有的節點都比這個節點小,
- 它的右子樹所有的節點都比這個節點大,
- 那麼用二分搜尋樹來儲存資料的話,
- 那麼再來查詢一個資料就會變得非常簡單,
- 可以很快的知道從左側找還是右側找,
- 甚至可以不用看另外一側,
- 所以就大大的加快了查詢速度。
- 在生活中使用樹結構,本質也是如此,
- 例如我要找一本 JS 程式設計的書,
- 那麼進入圖書館我直接進入電腦科學這個區域找這本書,
- 其它的類的圖書我根本不用去管,
- 這也是樹這種結構儲存資料之後再對資料進行操作時
- 才能夠非常高效的核心原因。
- 為了能夠達到二分搜尋樹的性質
- 必須讓儲存的元素具有可比較性,
- 你要定義好 元素之間如何進行比較,
- 因為比較的方式是具有多種的,
- 必須保證元素之間可以進行比較。
- 在連結串列和陣列中則沒有這個要求,
- 這個就是二分搜尋樹儲存資料的一個侷限性,
- 也說明了凡事都是有代價的,
- 如果想加快搜尋的話就必須對資料有一定的要求。
程式碼示例
-
二分搜尋樹其實不是支援所有的型別
- 所以應該對元素的型別有所限制,
- 這個限制就是 這個型別必須擁有可比較性,
- 也就是這個型別 element 必須具有可比較性。
-
程式碼實現
class MyBinarySearchTreeNode { constructor(element, left, right) { // 實際儲存的元素 this.element = element; // 當前節點的左子樹 this.left = left; // 當前節點的右子樹 this.right = right; } } class MyBinarySearchTree { constructor() { this.root = null; this.size = 0; } // 獲取二分搜尋樹中節點個數 getSize() { return this.size; } // 返回二分搜尋樹是否為空的bool值 isEmpty() { return this.size === 0; } } 複製程式碼
向二分搜尋樹中新增元素
- 如果二分搜尋樹的根節點為空的話
- 第一個新增的元素就會成為根節點,
- 如果再新增一個元素,那麼就因該從根節點出發,
- 根據二分搜尋樹的定義,
- 每個節點的值要比它的左子樹上所有節點的值大,
- 假設第二個新增的元素的值小於第一個新增的元素的值,
- 那麼很顯然第二個新增的元素要被新增到根節點的左子樹上去,
- 根節點的左子樹上只有一個節點,
- 那麼這個節點就是左子樹上的根節點,
- 這個左子樹上的根節點就是頂層根節點的左孩子。
- 按照這樣的規則,每來一個新元素從根節點開始,
- 如果小於根節點,那麼就插入到根節點的左子樹上去,
- 如果大於根節點,那麼就插入到根節點的右子樹上去,
- 由於不管是左子樹還是右子樹,它們又是一棵二分搜尋樹,
- 那麼這個過程就是依此類推下去,
- 一層一層向下比較新新增的節點的值,
- 大的向右,小的向左,不停的向下比較,
- 如果這個位置沒有被佔住,那麼就可以在這個位置上新增進去,
- 如果這個位置被佔了,那就不停的向下比較,
- 直到找到一個合適的位置新增進去。
- 如果遇到兩個元素的值相同,那暫時先不去管,
- 也就是不新增進去,因為已經有了,
- 自定義二分搜尋樹不包含重複元素,
- 如果想包含重複元素,
- 只需要定義左子樹小於等於節點、或者右子樹大於等於節點,
- 只要把“等於”這種關係放進定義裡就可以了。
- 二分搜尋樹新增元素的非遞迴寫法,和連結串列很像
- 但是在二分搜尋樹方面的實現儘量使用遞迴來實現,
- 就是要鍛鍊遞迴演算法的書寫,
- 因為遞迴演算法的很多細節和內容需要不斷去體會,
- 但是非遞迴的寫法也很實用的,
- 因為遞迴本身是具有更高的開銷的,
- 雖然在現代計算機上這些開銷並不明顯,
- 但是在一些極端的情況下還是可以看出很大的區別,
- 尤其是對於二分搜尋樹來說,
- 在最壞的情況下它有可能會退化成一個連結串列,
- 那麼在這種情況下使用遞迴的方式很容易造成系統棧的溢位,
- 二分搜尋樹一些非遞迴的實現你可以自己練習一下。
- 在二分搜尋樹方面,遞迴比非遞迴實現起來更加簡單。
程式碼示例
-
程式碼
class MyBinarySearchTreeNode { constructor(element, left = null, right = null) { // 實際儲存的元素 this.element = element; // 當前節點的左子樹 this.left = left; // 當前節點的右子樹 this.right = right; } } class MyBinarySearchTree { constructor() { this.root = null; this.size = 0; } // 新增元素到二分搜尋樹中 + add(element) { if (element === null) throw new Error("element is null. can't store."); if (this.root === null) { this.root = new MyBinarySearchTreeNode(element); this.size++; } else this.root = this.recursiveAdd(this.root, element); } // 新增元素到二分搜尋樹中 遞迴演算法 - recursiveAdd(node, newElement) { // 解決最基本的問題 也就是遞迴函式呼叫的終止條件 if (node === null) { node = new MyBinarySearchTreeNode(newElement); this.size++; return node; } // 1. 當前節點的元素比新元素大 // 那麼新元素就會被新增到當前節點的左子樹去 // 2. 當前節點的元素比新元素小 // 那麼新元素就會被新增到當前節點的右子樹去 // 3. 當前節點的元素比新元素相等 // 什麼都不做了,因為目前不新增重複的元素 if (this.compare(node.element, newElement) > 0) node.left = this.recursiveAdd(node.left, newElement); else if (this.compare(node.element, newElement) < 0) node.right = this.recursiveAdd(node.right, newElement); else { } // 將複雜問題分解成多個性質相同的小問題, // 然後求出小問題的答案, // 最終構建出原問題的答案 return node; } // 獲取二分搜尋樹中節點個數 + getSize() { return this.size; } // 返回二分搜尋樹是否為空的bool值 + isEmpty() { return this.size === 0; } // 新增一個比較的方法,專門用來比較新增的元素大小 - // 第一個元素比第二個元素大 就返回 1 // 第一個元素比第二個元素小 就返回 -1 // 第一個元素比第二個元素相等 就返回 0 compare(elementA, elementB) { if (elementA === null || elementB === null) throw new Error("element is null. can't compare."); // 先直接寫死 if (elementA > elementB) return 1; else if (elementA < elementB) return -1; else return 0; } } 複製程式碼
-
對於二分搜尋的插入操作
- 上面的程式碼是相對比較複雜的,
- 可以進行改進一下,
- 讓程式碼整體簡潔一些,
- 因為遞迴演算法是有很多不同的寫法的,
- 而且遞迴的終止條件也是有不同的考量。
深入理解遞迴終止條件
- 改進新增操作
- 遞迴演算法有很多不同的寫法,
- 遞迴的終止條件也有不同的考量。
- 之前的演算法
- 向以 node 為根的二分搜尋樹中插入元素 e,
- 其實將新的元素插入至 node 的左孩子或者右孩子,
- 如果 node 的左或右孩子為空,那可以進行相應的賦值操作,
- 如果是 node 的左右孩子都不為空的話,
- 那就只能遞迴的插入到相應 node 的左或右孩子中,
- 因為這一層節點已經滿了,只能考慮下一層了,
- 下一層符合要求並且節點沒有滿,就可以進行相應的賦值操作了。
- 但是有對根節點做出了特殊的處理,要防止根節點為空的情況發生,
- 如果根節點為空,那麼就將第一個元素賦值為根節點,
- 但是除了根節點以外,其它節點不需要做這種特殊處理,
- 所以導致邏輯上並不統一,並且遞迴的終止條件非常的臃腫,
程式碼示例
-
程式碼
class MyBinarySearchTreeNode { constructor(element, left = null, right = null) { // 實際儲存的元素 this.element = element; // 當前節點的左子樹 this.left = left; // 當前節點的右子樹 this.right = right; } } class MyBinarySearchTree { constructor() { this.root = null; this.size = 0; } // 新增元素到二分搜尋樹中 + add(element) { if (element === null) throw new Error("element is null. can't store."); this.root = this.recursiveAdd(this.root, element); } // 新增元素到二分搜尋樹中 遞迴演算法 - recursiveAdd(node, newElement) { // 解決最基本的問題 也就是遞迴函式呼叫的終止條件 if (node === null) { this.size++; return new MyBinarySearchTreeNode(newElement); } // 1. 當前節點的元素比新元素大 // 那麼新元素就會被新增到當前節點的左子樹去 // 2. 當前節點的元素比新元素小 // 那麼新元素就會被新增到當前節點的右子樹去 // 3. 當前節點的元素比新元素相等 // 什麼都不做了,因為目前不新增重複的元素 if (this.compare(node.element, newElement) > 0) node.left = this.recursiveAdd(node.left, newElement); else if (this.compare(node.element, newElement) < 0) node.right = this.recursiveAdd(node.right, newElement); else { } // 將複雜問題分解成多個性質相同的小問題, // 然後求出小問題的答案, // 最終構建出原問題的答案 return node; } // 獲取二分搜尋樹中節點個數 + getSize() { return this.size; } // 返回二分搜尋樹是否為空的bool值 + isEmpty() { return this.size === 0; } // 新增一個比較的方法,專門用來比較新增的元素大小 - // 第一個元素比第二個元素大 就返回 1 // 第一個元素比第二個元素小 就返回 -1 // 第一個元素比第二個元素相等 就返回 0 compare(elementA, elementB) { if (elementA === null || elementB === null) throw new Error("element is null. can't compare."); // 先直接寫死 if (elementA > elementB) return 1; else if (elementA < elementB) return -1; else return 0; } } 複製程式碼
-
雖然程式碼量更少了,但是也更難理解的了一些
- 首先從巨集觀的語意的角度去理解定義這個函式的語意後
- 整個遞迴函式處理的邏輯如何成立的,
- 其次從微觀的角度上可以寫一些輔助程式碼來幫助你一點一點的檢視,
- 從一個空的二分搜尋樹開始,往裡新增三五個元素,
- 看看每個元素是如何逐步的新增進去。
- 可以嘗試一些連結串列這個程式插入操作的遞迴演算法,
- 其實這二者之間是擁有非常高的相似度的,
- 只不過在二分搜尋樹中需要判斷一下是需要插入到左子樹還是右子樹而已,
- 對於連結串列來說直接插入到 next 就好了,
- 通過二者的比較就可以更加深入的理解這個程式。
二分搜尋樹的查詢操作
- 查詢操作非常的容易
- 只需要不停的看每一個 node 裡面存的元素,
- 不會牽扯到整個二分搜尋樹的新增操作
- 和新增元素一樣需要使用遞迴的進行實現
- 在遞迴的過程中就需要從二分搜尋樹的根開始,
- 逐漸的轉移在二分搜尋樹的子樹中縮小問題的規模,
- 縮小查詢的樹的規模,直到找到這個元素 e 或者發現找不到這個元素 e。
- 在陣列和連結串列中有索引這個概念,
- 但是在二分搜尋樹中沒有索引這個概念。
程式碼示例
-
程式碼
class MyBinarySearchTreeNode { constructor(element, left = null, right = null) { // 實際儲存的元素 this.element = element; // 當前節點的左子樹 this.left = left; // 當前節點的右子樹 this.right = right; } } class MyBinarySearchTree { constructor() { this.root = null; this.size = 0; } // 新增元素到二分搜尋樹中 + add(element) { if (element === null) throw new Error("element is null. can't store."); this.root = this.recursiveAdd(this.root, element); } // 新增元素到二分搜尋樹中 遞迴演算法 - recursiveAdd(node, newElement) { // 解決最基本的問題 也就是遞迴函式呼叫的終止條件 if (node === null) { this.size++; return new MyBinarySearchTreeNode(newElement); } // 1. 當前節點的元素比新元素大 // 那麼新元素就會被新增到當前節點的左子樹去 // 2. 當前節點的元素比新元素小 // 那麼新元素就會被新增到當前節點的右子樹去 // 3. 當前節點的元素比新元素相等 // 什麼都不做了,因為目前不新增重複的元素 if (this.compare(node.element, newElement) > 0) node.left = this.recursiveAdd(node.left, newElement); else if (this.compare(node.element, newElement) < 0) node.right = this.recursiveAdd(node.right, newElement); else { } // 將複雜問題分解成多個性質相同的小問題, // 然後求出小問題的答案, // 最終構建出原問題的答案 return node; } // 判斷二分搜尋樹中是否包含某個元素 + contains(element) { if (this.root === null) throw new Error("root is null. can't query."); return this.recursiveContains(this.root, element); } // 判斷二分搜尋樹種是否包含某個元素 遞迴演算法 - recursiveContains(node, element) { if (node === null) return false; // 當前節點元素比 要搜尋的元素 大 if (this.compare(node.element, element) > 0) return this.recursiveContains(node.left, element); else if (this.compare(node.element, element) < 0) // 當前元素比 要搜尋的元素 小 return this.recursiveContains(node.right, element); // 兩個元素相等 else return true; } // 獲取二分搜尋樹中節點個數 + getSize() { return this.size; } // 返回二分搜尋樹是否為空的bool值 + isEmpty() { return this.size === 0; } // 新增一個比較的方法,專門用來比較新增的元素大小 - // 第一個元素比第二個元素大 就返回 1 // 第一個元素比第二個元素小 就返回 -1 // 第一個元素比第二個元素相等 就返回 0 compare(elementA, elementB) { if (elementA === null || elementB === null) throw new Error("element is null. can't compare."); // 先直接寫死 if (elementA > elementB) return 1; else if (elementA < elementB) return -1; else return 0; } } 複製程式碼
二分搜尋樹的遍歷-前序遍歷
-
遍歷操作就是把這個資料結構中所有的元素都訪問一遍
- 在二分搜尋樹中就是把所有節點都訪問一遍,
-
訪問資料結構中儲存的所有元素是因為與業務相關,
- 例如 給所有的同學加兩分,給所有的員工發補貼等等,
- 由於你的資料結構是用來儲存資料的,
- 不僅可以查詢某些特定的資料,
- 還應該有相關的方式將所有的資料都進行訪問。
-
線上性結構下,遍歷是極其容易的
- 無論是陣列還是連結串列只要使用一下迴圈就好了,
- 但是這件事在樹結構下沒有那麼簡單,
- 但是也沒有那麼難:)。
-
在樹結構下遍歷操作並沒有那麼難
- 如果你對樹結構不熟悉,那麼可能就有點難,
- 但是如果你熟悉了樹結構,那麼並非是那麼難的操作,
- 尤其是你在掌握遞迴操作之後,遍歷樹就更加不難了。
-
對於遍歷操作,兩個子樹都要顧及
- 即要訪問左子樹中所有的節點又要訪問右子樹中所有的節點,
- 下面的程式碼中的遍歷方式也稱為二叉樹的前序遍歷,
- 先訪問這個節點,再訪問左右子樹,
- 訪問這個節點放在了訪問左右子樹的前面所以就叫前序遍歷。
- 要從巨集觀與微觀的角度去理解這個程式碼,
- 從巨集觀的角度來看,
- 定義好了遍歷的這個語意後整個邏輯是怎麼組建的,
- 從微觀的角度來看,真正的有一個棵二叉樹的時候,
- 這個程式碼是怎樣怎樣一行一行去執行的。
- 當你熟練的掌握遞迴的時候,
- 有的時候你可以不用遵守 那種先寫遞迴終止的條件,
- 再寫遞迴組成的的邏輯 這樣的一個過程,如寫法二,
- 雖然什麼都不幹,但是也是 return 了,
- 和寫法一中寫的邏輯其實是等價的,
- 也就是在遞迴終止條件這部分可以靈活處理。
- 寫法一看起來邏輯比較清晰,遞迴終止在前,遞迴組成的邏輯在後。
// 遍歷以node為根的二分搜尋樹 遞迴演算法 function traverse(node) { if (node === null) { return; } // ... 要做的事情 // 訪問該節點 兩邊都要顧及 // 訪問該節點的時候就去做該做的事情, // 如 給所有學生加兩分 traverse(node.left); traverse(node.right); } // 寫法二 這種邏輯也是可以的 function traverse(node) { if (node !== null) { // ... 要做的事情 // 訪問該節點 兩邊都要顧及 // 訪問該節點的時候就去做該做的事情, // 如 給所有學生加兩分 traverse(node.left); traverse(node.right); } } 複製程式碼
程式碼示例(class: MyBinarySearchTree, class: Main)
-
MyBinarySearchTree
class MyBinarySearchTreeNode { constructor(element, left = null, right = null) { // 實際儲存的元素 this.element = element; // 當前節點的左子樹 this.left = left; // 當前節點的右子樹 this.right = right; } } class MyBinarySearchTree { constructor() { this.root = null; this.size = 0; } // 新增元素到二分搜尋樹中 + add(element) { if (element === null) throw new Error("element is null. can't store."); this.root = this.recursiveAdd(this.root, element); } // 新增元素到二分搜尋樹中 遞迴演算法 - recursiveAdd(node, newElement) { // 解決最基本的問題 也就是遞迴函式呼叫的終止條件 if (node === null) { this.size++; return new MyBinarySearchTreeNode(newElement); } // 1. 當前節點的元素比新元素大 // 那麼新元素就會被新增到當前節點的左子樹去 // 2. 當前節點的元素比新元素小 // 那麼新元素就會被新增到當前節點的右子樹去 // 3. 當前節點的元素比新元素相等 // 什麼都不做了,因為目前不新增重複的元素 if (this.compare(node.element, newElement) > 0) node.left = this.recursiveAdd(node.left, newElement); else if (this.compare(node.element, newElement) < 0) node.right = this.recursiveAdd(node.right, newElement); else { } // 將複雜問題分解成多個性質相同的小問題, // 然後求出小問題的答案, // 最終構建出原問題的答案 return node; } // 判斷二分搜尋樹中是否包含某個元素 + contains(element) { if (this.root === null) throw new Error("root is null. can't query."); return this.recursiveContains(this.root, element); } // 判斷二分搜尋樹種是否包含某個元素 遞迴演算法 - recursiveContains(node, element) { if (node === null) return false; // 當前節點元素比 要搜尋的元素 大 if (this.compare(node.element, element) > 0) return this.recursiveContains(node.left, element); else if (this.compare(node.element, element) < 0) // 當前元素比 要搜尋的元素 小 return this.recursiveContains(node.right, element); // 兩個元素相等 else return true; } // 前序遍歷 + preOrder(operator) { this.recursivePreOrder(this.root, operator); } // 前序遍歷 遞迴演算法 - recursivePreOrder(node, operator) { if (node === null) return; // 呼叫一下操作方法 operator(node.element); console.log(node, node.element); // 繼續遞迴遍歷左右子樹 this.recursivePreOrder(node.left, operator); this.recursivePreOrder(node.right, operator); } // 獲取二分搜尋樹中節點個數 + getSize() { return this.size; } // 返回二分搜尋樹是否為空的bool值 + isEmpty() { return this.size === 0; } // 新增一個比較的方法,專門用來比較新增的元素大小 - // 第一個元素比第二個元素大 就返回 1 // 第一個元素比第二個元素小 就返回 -1 // 第一個元素比第二個元素相等 就返回 0 compare(elementA, elementB) { if (elementA === null || elementB === null) throw new Error("element is null. can't compare."); // 先直接寫死 if (elementA > elementB) return 1; else if (elementA < elementB) return -1; else return 0; } } 複製程式碼
-
Main
class Main { constructor() { this.alterLine('MyBinarySearchTree Area'); let myBinarySearchTree = new MyBinarySearchTree(); let nums = [5, 3, 6, 8, 4, 2]; for (var i = 0; i < nums.length; i++) { myBinarySearchTree.add(nums[i]); } ///////////////// // 5 // // / \ // // 3 6 // // / \ \ // // 2 4 8 // ///////////////// myBinarySearchTree.preOrder(this.show); this.show(myBinarySearchTree.contains(1)); console.log(myBinarySearchTree.contains(1)); } // 將內容顯示在頁面上 show(content) { document.body.innerHTML += `${content}<br /><br />`; } // 展示分割線 alterLine(title) { let line = `--------------------${title}----------------------`; console.log(line); document.body.innerHTML += `${line}<br /><br />`; } } window.onload = function() { // 執行主函式 new Main(); }; 複製程式碼
二分搜尋樹的遍歷除錯-前序遍歷
- 遍歷輸出二分搜尋樹
- 可以寫一個輔助函式自動遍歷所有節點生成字串,
- 輔助函式叫做 getBinarySearchTreeString,
- 這個函式的作用是,生成以 node 為根節點,
- 深度為 depth 的描述二叉樹的字串,
- 這樣一來要新增一個輔助函式,
- 這個函式的作用是,根據遞迴深度生成字串,
- 這個輔助函式叫做 getDepthString。
程式碼示例(class: MyBinarySearchTree, class: Main)
-
MyBinarySearchTree
class MyBinarySearchTreeNode { constructor(element, left = null, right = null) { // 實際儲存的元素 this.element = element; // 當前節點的左子樹 this.left = left; // 當前節點的右子樹 this.right = right; } } class MyBinarySearchTree { constructor() { this.root = null; this.size = 0; } // 新增元素到二分搜尋樹中 + add(element) { if (element === null) throw new Error("element is null. can't store."); this.root = this.recursiveAdd(this.root, element); } // 新增元素到二分搜尋樹中 遞迴演算法 - recursiveAdd(node, newElement) { // 解決最基本的問題 也就是遞迴函式呼叫的終止條件 if (node === null) { this.size++; return new MyBinarySearchTreeNode(newElement); } // 1. 當前節點的元素比新元素大 // 那麼新元素就會被新增到當前節點的左子樹去 // 2. 當前節點的元素比新元素小 // 那麼新元素就會被新增到當前節點的右子樹去 // 3. 當前節點的元素比新元素相等 // 什麼都不做了,因為目前不新增重複的元素 if (this.compare(node.element, newElement) > 0) node.left = this.recursiveAdd(node.left, newElement); else if (this.compare(node.element, newElement) < 0) node.right = this.recursiveAdd(node.right, newElement); else { } // 將複雜問題分解成多個性質相同的小問題, // 然後求出小問題的答案, // 最終構建出原問題的答案 return node; } // 判斷二分搜尋樹中是否包含某個元素 + contains(element) { if (this.root === null) throw new Error("root is null. can't query."); return this.recursiveContains(this.root, element); } // 判斷二分搜尋樹種是否包含某個元素 遞迴演算法 - recursiveContains(node, element) { if (node === null) return false; // 當前節點元素比 要搜尋的元素 大 if (this.compare(node.element, element) > 0) return this.recursiveContains(node.left, element); else if (this.compare(node.element, element) < 0) // 當前元素比 要搜尋的元素 小 return this.recursiveContains(node.right, element); // 兩個元素相等 else return true; } // 前序遍歷 + preOrder(operator) { this.recursivePreOrder(this.root, operator); } // 前序遍歷 遞迴演算法 - recursivePreOrder(node, operator) { if (node === null) return; // 呼叫一下操作方法 operator(node.element); console.log(node, node.element); // 繼續遞迴遍歷左右子樹 this.recursivePreOrder(node.left, operator); this.recursivePreOrder(node.right, operator); } // 獲取二分搜尋樹中節點個數 + getSize() { return this.size; } // 返回二分搜尋樹是否為空的bool值 + isEmpty() { return this.size === 0; } // 新增一個比較的方法,專門用來比較新增的元素大小 - // 第一個元素比第二個元素大 就返回 1 // 第一個元素比第二個元素小 就返回 -1 // 第一個元素比第二個元素相等 就返回 0 compare(elementA, elementB) { if (elementA === null || elementB === null) throw new Error("element is null. can't compare."); // 先直接寫死 if (elementA > elementB) return 1; else if (elementA < elementB) return -1; else return 0; } // 輸出二分搜尋樹中的資訊 // @Override toString 2018-11-03-jwl toString() { let treeInfo = ''; treeInfo += this.getBinarySearchTreeString(this.root, 0, treeInfo); return treeInfo; } // 寫一個輔助函式,用來生成二分搜尋樹資訊的字串 getBinarySearchTreeString(node, depth, treeInfo, pageContent = '') { //以前序遍歷的方式 if (node === null) { treeInfo += this.getDepthString(depth) + 'null \r\n'; pageContent = this.getDepthString(depth) + 'null<br /><br />'; document.body.innerHTML += `${pageContent}`; return treeInfo; } treeInfo += this.getDepthString(depth) + node.element + '\r\n'; pageContent = this.getDepthString(depth) + node.element + '<br /><br />'; document.body.innerHTML += `${pageContent}`; treeInfo = this.getBinarySearchTreeString( node.left, depth + 1, treeInfo ); treeInfo = this.getBinarySearchTreeString( node.right, depth + 1, treeInfo ); return treeInfo; } // 寫一個輔助函式,用來生成遞迴深度字串 getDepthString(depth) { let depthString = ''; for (var i = 0; i < depth; i++) { depthString += '-- '; } return depthString; } } 複製程式碼
-
Main
class Main { constructor() { this.alterLine('MyBinarySearchTree Area'); let myBinarySearchTree = new MyBinarySearchTree(); let nums = [5, 3, 6, 8, 4, 2]; for (var i = 0; i < nums.length; i++) { myBinarySearchTree.add(nums[i]); } ///////////////// // 5 // // / \ // // 3 6 // // / \ \ // // 2 4 8 // ///////////////// console.log(myBinarySearchTree.toString()); } // 將內容顯示在頁面上 show(content) { document.body.innerHTML += `${content}<br /><br />`; } // 展示分割線 alterLine(title) { let line = `--------------------${title}----------------------`; console.log(line); document.body.innerHTML += `${line}<br /><br />`; } } window.onload = function() { // 執行主函式 new Main(); }; 複製程式碼
二分搜尋樹的遍歷-中序、後序遍歷
-
前序遍歷
- 前序遍歷是最自然的一種遍歷方式,
- 同時也是最常用的一種遍歷方式,
- 如果沒有特殊情況的話,
- 在大多數情況下都會使用前序遍歷。
- 先訪問這個節點,
- 然後訪問這個節點的左子樹,
- 再訪問這個節點的右子樹,
- 整個過程迴圈往復。
- 前序遍歷的
前
表示先訪問的這個節點。
function preOrder(node) { if (node == null) return; // ... 要做的事情 // 訪問該節點 // 先一直往左,然後不斷返回上一層 再向左、終止, // 最後整個操作迴圈往復,直到全部終止。 preOrder(node.left); preOrder(node.right); } 複製程式碼
-
中序遍歷
- 先訪問左子樹,再訪問這個節點,
- 最後訪問右子樹,整個過程迴圈往復。
- 中序遍歷的
中
表示先訪問左子樹, - 然後再訪問這個節點,最後訪問右子樹,
- 訪問這個節點的操作放到了訪問左子樹和右子樹的中間。
function inOrder(node) { if (node == null) return; inOrder(node.left); // ... 要做的事情 // 訪問該節點 inOrder(node.right); } 複製程式碼
-
中序遍歷後輸出的結果是排序後的結果。
- 中序遍歷的結果是二分搜尋樹中
- 儲存的所有的元素從小到大進行排序後的結果,
- 這是二分搜尋樹一個很重要的一個性質。
- 二分搜尋樹任何一個節點的左子樹上所有的節點值都比當前節點的小,
- 二分搜尋樹任何一個節點的右子樹上所有的節點值都比當前節點的大,
- 每一個節點的遍歷都是從左往自己再往右,
- 先遍歷這個節點的左子樹,先把比自己節點小的所有元素都遍歷了,
- 再遍歷這個節點,然後再遍歷比這個節點大的所有元素,這個過程是遞迴完成的,
- 以 小於、等於、大於的順序遍歷得到的結果自然就是一個從小到大的排序的,
- 你也可以 使用大於 等於 小於的順序遍歷,那樣結果就是從大到小排序了。
- 也正是因為這個原因,二分搜尋樹有的時候也叫做排序樹,
- 這是二分搜尋樹額外的效能,
- 當你使用陣列、連結串列時如果想讓你的元素是順序的話,
- 必須做額外的工作,否則沒有辦法保證一次遍歷得到的元素都是順序排列的,
- 但是對於二分搜尋樹來說,你只要遵從他的定義,
- 然後使用中序遍歷的方式遍歷整棵二分搜尋樹就能夠得到順序排列的結果。
-
後序遍歷
- 先訪問左子樹,再訪問右子樹,
- 最後訪問這個節點,整個過程迴圈往復。
- 後序遍歷的
後
表示先訪問左子樹, - 然後再訪問右子樹,最後訪問這個節點,
- 訪問這個節點的操作放到了訪問左子樹和右子樹的後邊。
function inOrder(node) { if (node == null) return; inOrder(node.left); inOrder(node.right); // ... 要做的事情 // 訪問該節點 } 複製程式碼
-
二分搜尋樹的前序遍歷和後序遍歷並不像中序遍歷那樣進行了排序
- 後續遍歷的應用場景是那些必須先處理完左子樹的所有節點,
- 然後再處理完右子樹的所有節點,最後再處理當前的節點,
- 也就是處理完這個節點的孩子節點之後再去處理當前這個節點。
- 一個典型的應用是在記憶體釋放方面,如果需要你手動的釋放記憶體,
- 那麼就需要先把這個節點的孩子節點全都釋放完然後再來釋放這個節點本身,
- 這種情況使用二叉樹的後序遍歷的方式,
- 先處理左子樹、再處理右子樹、最後處理自己。
- 但是例如
java
、c#
、JS
這樣的語言都有垃圾回收機制, - 所以不需要你對記憶體管理進行手動的控制,
c++
語言中需要手動的控制記憶體,- 那麼在二分搜尋樹記憶體釋放這方面就需要使用後序遍歷。
- 對於一些樹結構的問題,
- 很多時候也是需要先針對一個節點的孩子節點求解出答案,
- 最終再由這些答案組合成針對這個節點的答案,
- 樹形問題有分治演算法、回溯演算法、動態規劃演算法等等。
-
二分搜尋樹的前中後序遍歷
- 主要從程式的角度進行分析,
- 很多時候對一些問題的分析,如果直接給你一個樹結構,
- 然後你能夠直接看出來對於這棵樹來說它的前中後序遍歷的結果是怎樣的,
- 那就可以大大加快解決問題的速度,
- 同時這樣的一個問題也是和計算機相關的考試的題目,
- 對於這樣的一個問題的更加深入的理解
- 也可以幫助你理解二分搜尋樹這種資料結構。
程式碼示例(class: MyBinarySearchTree, class: Main)
-
MyBinarySearchTree
class MyBinarySearchTreeNode { constructor(element, left = null, right = null) { // 實際儲存的元素 this.element = element; // 當前節點的左子樹 this.left = left; // 當前節點的右子樹 this.right = right; } } // 自定義二分搜尋樹 class MyBinarySearchTree { constructor() { this.root = null; this.size = 0; } // 新增元素到二分搜尋樹中 + add(element) { if (element === null) throw new Error("element is null. can't store."); this.root = this.recursiveAdd(this.root, element); } // 新增元素到二分搜尋樹中 遞迴演算法 - recursiveAdd(node, newElement) { // 解決最基本的問題 也就是遞迴函式呼叫的終止條件 if (node === null) { this.size++; return new MyBinarySearchTreeNode(newElement); } // 1. 當前節點的元素比新元素大 // 那麼新元素就會被新增到當前節點的左子樹去 // 2. 當前節點的元素比新元素小 // 那麼新元素就會被新增到當前節點的右子樹去 // 3. 當前節點的元素比新元素相等 // 什麼都不做了,因為目前不新增重複的元素 if (this.compare(node.element, newElement) > 0) node.left = this.recursiveAdd(node.left, newElement); else if (this.compare(node.element, newElement) < 0) node.right = this.recursiveAdd(node.right, newElement); else { } // 將複雜問題分解成多個性質相同的小問題, // 然後求出小問題的答案, // 最終構建出原問題的答案 return node; } // 判斷二分搜尋樹中是否包含某個元素 + contains(element) { if (this.root === null) throw new Error("root is null. can't query."); return this.recursiveContains(this.root, element); } // 判斷二分搜尋樹種是否包含某個元素 遞迴演算法 - recursiveContains(node, element) { if (node === null) return false; // 當前節點元素比 要搜尋的元素 大 if (this.compare(node.element, element) > 0) return this.recursiveContains(node.left, element); else if (this.compare(node.element, element) < 0) // 當前元素比 要搜尋的元素 小 return this.recursiveContains(node.right, element); // 兩個元素相等 else return true; } // 前序遍歷 + preOrder(operator) { this.recursivePreOrder(this.root, operator); } // 前序遍歷 遞迴演算法 - recursivePreOrder(node, operator) { if (node === null) return; // 呼叫一下操作方法 operator(node.element); console.log(node, node.element); // 繼續遞迴遍歷左右子樹 this.recursivePreOrder(node.left, operator); this.recursivePreOrder(node.right, operator); } // 中序遍歷 + inOrder(operator) { this.recursiveInOrder(this.root, operator); } // 中序遍歷 遞迴演算法 - recursiveInOrder(node, operator) { if (node == null) return; this.recursiveInOrder(node.left, operator); operator(node.element); console.log(node.element); this.recursiveInOrder(node.right, operator); } // 後序遍歷 + postOrder(operator) { this.recursivePostOrder(this.root, operator); } // 後序遍歷 遞迴演算法 - recursivePostOrder(node, operator) { if (node == null) return; this.recursivePostOrder(node.left, operator); this.recursivePostOrder(node.right, operator); operator(node.element); console.log(node.element); } // 獲取二分搜尋樹中節點個數 + getSize() { return this.size; } // 返回二分搜尋樹是否為空的bool值 + isEmpty() { return this.size === 0; } // 新增一個比較的方法,專門用來比較新增的元素大小 - // 第一個元素比第二個元素大 就返回 1 // 第一個元素比第二個元素小 就返回 -1 // 第一個元素比第二個元素相等 就返回 0 compare(elementA, elementB) { if (elementA === null || elementB === null) throw new Error("element is null. can't compare."); // 先直接寫死 if (elementA > elementB) return 1; else if (elementA < elementB) return -1; else return 0; } // 輸出二分搜尋樹中的資訊 // @Override toString 2018-11-03-jwl toString() { let treeInfo = ''; treeInfo += this.getBinarySearchTreeString(this.root, 0, treeInfo); return treeInfo; } // 寫一個輔助函式,用來生成二分搜尋樹資訊的字串 getBinarySearchTreeString(node, depth, treeInfo, pageContent = '') { //以前序遍歷的方式 if (node === null) { treeInfo += this.getDepthString(depth) + 'null \r\n'; pageContent = this.getDepthString(depth) + 'null<br /><br />'; document.body.innerHTML += `${pageContent}`; return treeInfo; } treeInfo += this.getDepthString(depth) + node.element + '\r\n'; pageContent = this.getDepthString(depth) + node.element + '<br /><br />'; document.body.innerHTML += `${pageContent}`; treeInfo = this.getBinarySearchTreeString( node.left, depth + 1, treeInfo ); treeInfo = this.getBinarySearchTreeString( node.right, depth + 1, treeInfo ); return treeInfo; } // 寫一個輔助函式,用來生成遞迴深度字串 getDepthString(depth) { let depthString = ''; for (var i = 0; i < depth; i++) { depthString += '-- '; } return depthString; } } 複製程式碼
-
Main
// main 函式 class Main { constructor() { this.alterLine('MyBinarySearchTree Area'); let myBinarySearchTree = new MyBinarySearchTree(); let nums = [5, 3, 6, 8, 4, 2]; for (var i = 0; i < nums.length; i++) { myBinarySearchTree.add(nums[i]); } ///////////////// // 5 // // / \ // // 3 6 // // / \ \ // // 2 4 8 // ///////////////// this.alterLine('MyBinarySearchTree PreOrder Area'); myBinarySearchTree.preOrder(this.show); this.alterLine('MyBinarySearchTree InOrder Area'); myBinarySearchTree.inOrder(this.show); this.alterLine('MyBinarySearchTree PostOrder Area'); myBinarySearchTree.postOrder(this.show); } // 將內容顯示在頁面上 show(content) { document.body.innerHTML += `${content}<br /><br />`; } // 展示分割線 alterLine(title) { let line = `--------------------${title}----------------------`; console.log(line); document.body.innerHTML += `${line}<br /><br />`; } } // 頁面載入完畢 window.onload = function() { // 執行主函式 new Main(); }; 複製程式碼
二分搜尋樹的遍歷-深入理解前中後序遍歷
-
再看二分搜尋樹的遍歷
- 對每一個節點都有三次的訪問機會,
- 在遍歷左子樹之前會去訪問一下這個節點然後才能遍歷它的左子樹,
- 在遍歷完左子樹之後才能夠回到這個節點,之後才會去遍歷它的右子樹,
- 在遍歷右子樹之後又回到了這個節點。
- 這就是每一個節點使用這種遞迴遍歷的方式其實會訪問它三次,
-
對二分搜尋樹前中後這三種順序的遍歷
- 其實就對應於這三個訪問機會是在哪裡進行真正的那個訪問操作,
- 在哪裡輸出訪問的這個節點的值,
- 是先訪問這個節點後再遍歷它的左右子樹,
- 還是先遍歷左子樹然後訪問這個節點最後遍歷右子樹,
- 再或者是 先遍歷左右子樹再訪問這個節點。
function traverse(node) { if (node === null) return; // 1. 第一個訪問的機會 前 traverse(node.left); // 2. 第二個訪問的機會 中 traverse(node.right); // 3. 第三個訪問的機會 後 } 複製程式碼
-
二叉樹前中後序遍歷訪問節點的不同
- 前序遍歷訪問節點都是在第一個訪問機會的位置才去訪問節點,
- 中序遍歷訪問節點都是在第二個訪問機會的位置才去訪問節點,
- 後序遍歷訪問節點都是在第三個訪問機會的位置才去訪問節點,