摘要:筆者寫的數據結構與算法之美系列用的語言是,旨在入門數據結構與算法和方便以后復習。非線性表中的樹堆是干嘛用的其數據結構是怎樣的希望大家帶著這兩個問題閱讀下文。其中,前中后序,表示的是節點與它的左右子樹節點遍歷訪問的先后順序。
1. 前言
想學好前端,先練好內功,內功不行,就算招式練的再花哨,終究成不了高手。
非線性表(樹、堆),可以說是前端程序員的內功,要知其然,知其所以然。
筆者寫的 JavaScript 數據結構與算法之美 系列用的語言是 JavaScript ,旨在入門數據結構與算法和方便以后復習。
非線性表中的樹、堆是干嘛用的 ?其數據結構是怎樣的 ?
希望大家帶著這兩個問題閱讀下文。
2. 樹樹的數據結構就像我們生活中的真實的樹,只不過是倒過來的形狀。
術語定義
節點:樹中的每個元素稱為節點,如 A、B、C、D、E、F、G、H、I、J。
父節點:指向子節點的節點,如 A。
子節點:被父節點指向的節點,如 A 的孩子 B、C、D。
父子關系:相鄰兩節點的連線,稱為父子關系,如 A 與 B,C 與 H,D 與 J。
根節點:沒有父節點的節點,如 A。
葉子節點:沒有子節點的節點,如 E、F、G、H、I、J。
兄弟節點:具有相同父節點的多個節點稱為兄弟節點,如 B、C、D。
節點的高度:節點到葉子節點的最長路徑所包含的邊數。
節點的深度:根節點到節點的路徑所包含的邊數。
節點層數:節點的深度 +1(根節點的層數是 1 )。
樹的高度:等于根節點的高度。
森林: n 棵互不相交的樹的集合。
高度是從下往上度量,比如一個人的身高 180cm ,起點就是從 0 開始的。
深度是從上往下度量,比如泳池的深度 180cm ,起點也是從 0 開始的。
高度和深度是帶有度字的,都是從 0 開始計數的。
而層數的計算,是和我們平時的樓層的計算是一樣的,最底下那層是第 1 層,是從 1 開始計數的,所以根節點位于第 1 層,其他子節點依次加 1。
每個節點最多只有 2 個子節點的樹,這兩個節點分別是左子節點和右子節點。如上圖中的 1、 2、3。
不過,二叉樹并不要求每個節點都有兩個子節點,有的節點只有左子節點,有的節點只有右子節點。以此類推,自己想四叉樹、八叉樹的結構圖。
滿二叉樹一種特殊的二叉樹,除了葉子節點外,每個節點都有左右兩個子節點,這種二叉樹叫做滿二叉樹。如上圖中的 2。
完全二叉樹一種特殊的二叉樹,葉子節點都在最底下兩層,最后一層葉子節都靠左排列,并且除了最后一層,其他層的節點個數都要達到最大,這種二叉樹叫做完全二叉樹。如上圖的 3。
完全二叉樹與不是完全二叉樹的區分比較難,所以對比下圖看看。
堆之前的文章 棧內存與堆內存 、淺拷貝與深拷貝 中有說到:JavaScript 中的引用類型(如對象、數組、函數等)是保存在堆內存中的對象,值大小不固定,棧內存中存放的該對象的訪問地址指向堆內存中的對象,JavaScript 不允許直接訪問堆內存中的位置,因此操作對象時,實際操作對象的引用。
那么堆到底是什么呢 ?其數據結構又是怎樣的呢 ?
堆其實是一種特殊的樹。只要滿足這兩點,它就是一個堆。
堆是一個完全二叉樹。
完全二叉樹:除了最后一層,其他層的節點個數都是滿的,最后一層的節點都靠左排列。
堆中每一個節點的值都必須大于等于(或小于等于)其子樹中每個節點的值。
也可以說:堆中每個節點的值都大于等于(或者小于等于)其左右子節點的值。這兩種表述是等價的。
對于每個節點的值都大于等于子樹中每個節點值的堆,我們叫作大頂堆。對于每個節點的值都小于等于子樹中每個節點值的堆,我們叫作小頂堆。
其中圖 1 和 圖 2 是大頂堆,圖 3 是小頂堆,圖 4 不是堆。除此之外,從圖中還可以看出來,對于同一組數據,我們可以構建多種不同形態的堆。
二叉查找樹(Binary Search Tree)一種特殊的二叉樹,相對較小的值保存在左節點中,較大的值保存在右節點中,叫二叉查找樹,也叫二叉搜索樹。
二叉查找樹是一種有序的樹,所以支持快速查找、快速插入、刪除一個數據。
下圖中, 3 個都是二叉查找樹,
平衡二叉查找樹:二叉樹中任意一個節點的左右子樹的高度相差不能大于 1。
從這個定義來看,完全二叉樹、滿二叉樹其實都是平衡二叉樹,但是非完全二叉樹也有可能是平衡二叉樹。
平衡二叉查找樹中平衡的意思,其實就是讓整棵樹左右看起來比較對稱、比較平衡,不要出現左子樹很高、右子樹很矮的情況。這樣就能讓整棵樹的高度相對來說低一些,相應的插入、刪除、查找等操作的效率高一些。
平衡二叉查找樹其實有很多,比如,Splay Tree(伸展樹)、Treap(樹堆)等,但是我們提到平衡二叉查找樹,聽到的基本都是紅黑樹。
紅黑樹中的節點,一類被標記為黑色,一類被標記為紅色。除此之外,一棵紅黑樹還需要滿足這樣幾個要求:
根節點是黑色的。
每個葉子節點都是黑色的空節點(NIL),也就是說,葉子節點不存儲數據。
任何相鄰的節點都不能同時為紅色,也就是說,紅色節點是被黑色節點隔開的。
每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點。
下面兩個都是紅黑樹。
存儲完全二叉樹的存儲
鏈式存儲
每個節點由 3 個字段,其中一個存儲數據,另外兩個是指向左右子節點的指針。
我們只要拎住根節點,就可以通過左右子節點的指針,把整棵樹都串起來。
這種存儲方式比較常用,大部分二叉樹代碼都是通過這種方式實現的。
順序存儲
用數組來存儲,對于完全二叉樹,如果節點 X 存儲在數組中的下標為 i ,那么它的左子節點的存儲下標為 2 i ,右子節點的下標為 2 i + 1,反過來,下標 i / 2 位置存儲的就是該節點的父節點。
注意,根節點存儲在下標為 1 的位置。完全二叉樹用數組來存儲是最省內存的方式。
經典的方法有三種:前序遍歷、中序遍歷、后序遍歷。其中,前、中、后序,表示的是節點與它的左右子樹節點遍歷訪問的先后順序。
前序遍歷(根 => 左 => 右)
對于樹中的任意節點來說,先訪問這個節點,然后再訪問它的左子樹,最后訪問它的右子樹。
中序遍歷(左 => 根 => 右)
對于樹中的任意節點來說,先訪問它的左子樹,然后再訪問它的本身,最后訪問它的右子樹。
后序遍歷(左 => 右 => 根)
對于樹中的任意節點來說,先訪問它的左子樹,然后再訪問它的右子樹,最后訪問它本身。
實際上,二叉樹的前、中、后序遍歷就是一個遞歸的過程。
時間復雜度:3 種遍歷方式中,每個節點最多會被訪問 2 次,跟節點的個數 n 成正比,所以時間復雜度是 O(n)。
實現二叉查找樹二叉查找樹的特點是:相對較小的值保存在左節點中,較大的值保存在右節點中。
代碼實現二叉查找樹,方法有以下這些。
方法
insert(key):向樹中插入一個新的鍵。
search(key):在樹中查找一個鍵,如果節點存在,則返回 true;如果不存在,則返回 false。
min:返回樹中最小的值/鍵。
max:返回樹中最大的值/鍵。
remove(key):從樹中移除某個鍵。
遍歷
preOrderTraverse:通過先序遍歷方式遍歷所有節點。
inOrderTraverse:通過中序遍歷方式遍歷所有節點。
postOrderTraverse:通過后序遍歷方式遍歷所有節點。
具體代碼
首先實現二叉查找樹類的類
// 二叉查找樹類 function BinarySearchTree() { // 用于實例化節點的類 var Node = function(key){ this.key = key; // 節點的健值 this.left = null; // 指向左節點的指針 this.right = null; // 指向右節點的指針 }; var root = null; // 將根節點置為null }
insert 方法,向樹中插入一個新的鍵。
遍歷樹,將插入節點的鍵值與遍歷到的節點鍵值比較,如果前者大于后者,繼續遞歸遍歷右子節點,反之,繼續遍歷左子節點,直到找到一個空的節點,在該位置插入。
this.insert = function(key){ var newNode = new Node(key); // 實例化一個節點 if (root === null){ root = newNode; // 如果樹為空,直接將該節點作為根節點 } else { insertNode(root,newNode); // 插入節點(傳入根節點作為參數) } }; // 插入節點的函數 var insertNode = function(node, newNode){ // 如果插入節點的鍵值小于當前節點的鍵值 // (第一次執行insertNode函數時,當前節點就是根節點) if (newNode.key < node.key){ if (node.left === null){ // 如果當前節點的左子節點為空,就直接在該左子節點處插入 node.left = newNode; } else { // 如果左子節點不為空,需要繼續執行insertNode函數, // 將要插入的節點與左子節點的后代繼續比較,直到找到能夠插入的位置 insertNode(node.left, newNode); } } else { // 如果插入節點的鍵值大于當前節點的鍵值 // 處理過程類似,只是insertNode函數繼續比較的是右子節點 if (node.right === null){ node.right = newNode; } else { insertNode(node.right, newNode); } } }
在下圖的樹中插入健值為 6 的節點,過程如下:
搜索最小值
在二叉搜索樹里,不管是整個樹還是其子樹,最小值一定在樹最左側的最底層。
因此給定一顆樹或其子樹,只需要一直向左節點遍歷到底就行了。
this.min = function(node) { // min方法允許傳入子樹 node = node || root; // 一直遍歷左側子節點,直到底部 while (node && node.left !== null) { node = node.left; } return node; };
搜索最大值
搜索最大值與搜索最小值類似,只是沿著樹的右側遍歷。
this.max = function(node) { // min方法允許傳入子樹 node = node || root; // 一直遍歷左側子節點,直到底部 while (node && node.right !== null) { node = node.right; } return node; };
搜索特定值
搜索特定值的處理與插入值的處理類似。遍歷樹,將要搜索的值與遍歷到的節點比較,如果前者大于后者,則遞歸遍歷右側子節點,反之,則遞歸遍歷左側子節點。
this.search = function(key, node){ // 同樣的,search方法允許在子樹中查找值 node = node || root; return searchNode(key, node); }; var searchNode = function(key, node){ // 如果node是null,說明樹中沒有要查找的值,返回false if (node === null){ return false; } if (key < node.key){ // 如果要查找的值小于該節點,繼續遞歸遍歷其左側節點 return searchNode(node.left, key); } else if (key > node.key){ // 如果要查找的值大于該節點,繼續遞歸遍歷其右側節點 return searchNode(node.right, key); } else { // 如果要查找的值等于該節點,說明查找成功,返回改節點 return node; } };
移除節點
移除節點,首先要在樹中查找到要移除的節點,再判斷該節點是否有子節點、有一個子節點或者有兩個子節點,最后分別處理。
this.remove = function(key, node) { // 同樣的,允許僅在子樹中刪除節點 node = node || root; return removeNode(key, node); }; var self = this; var removeNode = function(key, node) { // 如果 node 不存在,直接返回 if (node === false) { return null; } // 找到要刪除的節點 node = self.search(key, node); // 第一種情況,該節點沒有子節點 if (node.left === null && node.right === null) { node = null; return node; } // 第二種情況,該節點只有一個子節點的節點 if (node.left === null) { // 只有右節點 node = node.right; return node; } else if (node.right === null) { // 只有左節點 node = node.left; return node; } // 第三種情況,有有兩個子節點的節點 // 將右側子樹中的最小值,替換到要刪除的位置 // 找到最小值 var aux = self.min(node.right); // 替換 node.key = aux.key; // 刪除最小值 node.right = removeNode(aux.key, node.right); return node; };
第三種情況的處理過程,如下圖所示。
當要刪除的節點有兩個子節點時,為了不破壞樹的結構,刪除后要替補上來的節點的鍵值大小必須在已刪除節點的左、右子節點的鍵值之間,且替補上來的節點不應該有子節點,否則會產生一個節點有多個字節點的情況,因此,找右側子樹的最小值替換上來。
同理,找左側子樹的最大值替換上來也可以。
先序遍歷
this.preOrderTraverse = function(callback){ // 同樣的,callback用于對遍歷到的節點做操作 preOrderTraverseNode(root, callback); }; var preOrderTraverseNode = function (node, callback) { // 遍歷到node為null為止 if (node !== null) { callback(node.key); // 先處理當前節點 preOrderTraverseNode(node.left, callback); // 再繼續遍歷左子節點 preOrderTraverseNode(node.right, callback); // 最后遍歷右子節點 } };
用先序遍歷遍歷下圖所示的樹,并打印節點鍵值。
輸出結果:11 7 5 3 6 9 8 10 15 13 12 14 20 18 25。
遍歷過程如圖:
中序遍歷
this.inOrderTraverse = function(callback){ // callback用于對遍歷到的節點做操作 inOrderTraverseNode(root, callback); }; var inOrderTraverseNode = function (node, callback) { // 遍歷到node為null為止 if (node !== null) { // 優先遍歷左邊節點,保證從小到大遍歷 inOrderTraverseNode(node.left, callback); // 處理當前的節點 callback(node.key); // 遍歷右側節點 inOrderTraverseNode(node.right, callback); } };
對下圖的樹做中序遍歷,并輸出各個節點的鍵值。
依次輸出:3 5 6 7 8 9 10 11 12 13 14 15 18 20 25。
遍歷過程如圖:
后序遍歷
this.postOrderTraverse = function(callback){ postOrderTraverseNode(root, callback); }; var postOrderTraverseNode = function (node, callback) { if (node !== null) { postOrderTraverseNode(node.left, callback); //{1} postOrderTraverseNode(node.right, callback); //{2} callback(node.key); //{3} } };
可以看到,中序、先序、后序遍歷的實現方式幾乎一模一樣,只是 {1}、{2}、{3} 行代碼的執行順序不同。
對下圖的樹進行后序遍歷,并打印鍵值:3 6 5 8 10 9 7 12 14 13 18 25 20 15 11。
遍歷過程如圖:
添加打印的方法 print。
this.print = function() { console.log("root :", root); return root; };
完整代碼請看文件 binary-search-tree.html
測試過程:
// 測試 var binarySearchTree = new BinarySearchTree(); var arr = [11, 7, 5, 3, 6, 9, 8, 10, 15, 13, 12, 14, 20, 18, 25]; for (var i = 0; i < arr.length; i++) { var value = arr[i]; binarySearchTree.insert(value); } console.log("先序遍歷:"); var arr = []; binarySearchTree.preOrderTraverse(function(value) { // console.log(value); arr.push(value); }); console.log("arr :", arr); // [11, 7, 5, 3, 6, 9, 8, 10, 15, 13, 12, 14, 20, 18, 25] var min = binarySearchTree.min(); console.log("min:", min); // 3 var max = binarySearchTree.max(); console.log("max:", max); // 25 var search = binarySearchTree.search(10); console.log("search:", search); // 10 var remove = binarySearchTree.remove(13); console.log("remove:", remove); // 13 console.log("先序遍歷:"); var arr1 = []; binarySearchTree.preOrderTraverse(function(value) { // console.log(value); arr1.push(value); }); console.log("arr1 :", arr1); // ?[11, 7, 5, 3, 6, 9, 8, 10, 15, 14, 12, 20, 18, 25] console.log("中序遍歷:"); var arr2 = []; binarySearchTree.inOrderTraverse(function(value) { // console.log(value); arr2.push(value); }); console.log("arr2 :", arr2); //?[3, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 18, 20, 25] console.log("后序遍歷:"); var arr3 = []; binarySearchTree.postOrderTraverse(function(value) { // console.log(value); arr3.push(value); }); console.log("arr3 :", arr3); // ?[3, 6, 5, 8, 10, 9, 7, 12, 14, 18, 25, 20, 15, 11] binarySearchTree.print(); // 看控制臺
結果如下:
看到這里,你能解答文章的題目 非線性表中的樹、堆是干嘛用的 ?其數據結構是怎樣的 ?
如果不能,建議再回頭仔細看看哦。
3. 文章輸出計劃JavaScript 數據結構與算法之美 的系列文章,堅持 3 - 7 天左右更新一篇,暫定計劃如下表。
| 標題 | 鏈接 |
| :------ | :------ |
| 時間和空間復雜度 | https://github.com/biaochenxu... |
| 線性表(數組、鏈表、棧、隊列) | https://github.com/biaochenxu... |
| 實現一個前端路由,如何實現瀏覽器的前進與后退 ?| https://github.com/biaochenxu... |
| 棧內存與堆內存 、淺拷貝與深拷貝 | https://github.com/biaochenxu... |
| 遞歸 | https://github.com/biaochenxu... |
| 非線性表(樹、堆) | https://github.com/biaochenxu... |
| 冒泡排序 | 精彩待續 |
| 插入排序 | 精彩待續 |
| 選擇排序 | 精彩待續 |
| 歸并排序 | 精彩待續 |
| 快速排序 | 精彩待續 |
| 計數排序 | 精彩待續 |
| 基數排序 | 精彩待續 |
| 桶排序 | 精彩待續 |
| 希爾排序 | 精彩待續 |
| 堆排序 | 精彩待續 |
| 十大經典排序匯總 | 精彩待續 |
如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。4. 最后
上個周六日,不小心看了盜墓筆記電視劇,沒忍住,還連看了兩部
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/105695.html
摘要:之所以把歸并排序快速排序希爾排序堆排序放在一起比較,是因為它們的平均時間復雜度都為。歸并排序是一種穩定的排序方法。因此,快速排序并不穩定。希爾排序思想先將整個待排序的記錄序列分割成為若干子序列。 showImg(https://segmentfault.com/img/bVbvpYZ?w=900&h=250); 1. 前言 算法為王。 想學好前端,先練好內功,只有內功深厚者,前端之路才...
摘要:今天來一堂阿里云服務器的普及課程,和新手說一下阿里云服務器是什么。大型云服務器商家有專人維護服務器,運行穩定不易出錯。今天來一堂阿里云服務器的普及課程,和新手說一下阿里云服務器是什么。 打個比方云服務器機房就是一座大樓,里面用隔斷分離出了很多小空間,每個小空間就是你租用的云服務器。現在這個隔斷可以根據需要調整大小,比如你剛入駐的時候使用資源較少,后來隨著發展越來越大就可以付費增加隔斷的面積。...
摘要:主機上電源鍵下面那個鍵是干嘛用的啊電源按鈕又稱開關機鍵,另一個按鈕是復位鍵。系統失去反應可按電源鍵幾秒后強制關機。主機上電源鍵下面那個鍵是干嘛用的啊?電源按鈕又稱開關機鍵,另一個按鈕是復位鍵。主機開機必須按一下電源開關,關機可程序關機或點一下電源按鈕,也會關機。系統失去反應可按電源鍵幾秒后強制關機。復位按鈕即電腦重啟鍵,按一下后電腦會由開機狀態直接重開機,跳過關機過程。電腦主機下面一個小按鈕...
摘要:棧內存與堆內存淺拷貝與深拷貝,可以說是前端程序員的內功,要知其然,知其所以然。棧內存與堆內存中的變量分為基本類型和引用類型。 showImg(https://segmentfault.com/img/bVbuvnj?w=900&h=250); 前言 想寫好前端,先練好內功。 棧內存與堆內存 、淺拷貝與深拷貝,可以說是前端程序員的內功,要知其然,知其所以然。 筆者寫的 JavaScrip...
摘要:每個線性表上的數據最多只有前和后兩個方向。數組鏈表隊列棧等就是線性表結構。非線性表數據之間并不是簡單的前后關系。不包含任何元素的棧稱為空棧。移除棧頂的元素,同時返回被移除的元素。 showImg(https://segmentfault.com/img/bVbuvnj?w=900&h=250); 前言 基礎知識就像是一座大樓的地基,它決定了我們的技術高度。 我們應該多掌握一些可移值的...
閱讀 1653·2021-11-23 09:51
閱讀 2677·2021-11-22 09:34
閱讀 1316·2021-10-14 09:43
閱讀 3661·2021-09-08 09:36
閱讀 3206·2019-08-30 12:57
閱讀 2025·2019-08-30 12:44
閱讀 2516·2019-08-29 17:15
閱讀 3014·2019-08-29 16:08