第八章 树
作者: Loiane Groner
目前为止,我们学习了一些有序列的数据。而哈希表为非序列数据。而这一章,我们要学习另一个非序列数据——树。树的特点是,可以用于存储需要被快速发现的信息。
树结构是对人类社会中等级结构的抽象。树结构在生活中最常见的实例是家庭成员树、公司组织结构图:
树的术语
树是由一系列有父子关系的节点组成的。除了第一个节点,每一个节点都有一个父节点,还有一个或多个子节点:
最顶端的节点(也就是刚刚的第一个节点),被称为根节点(11)。根节点是没有父节点的。树的成员都被称为节点。节点可以被称为内部节点和外部节点。至少有一个子节点的节点,被称为内部节点(7、5、9、15、13和20);而没有子节点的节点,被称为外部节点或者叶节点(3,6,8,10,12,14,18和25)。
一个节点是有祖先和后代的。其祖先可以为父节点、爷爷节点、太爷爷节点,以及更远的;其子孙节点,可以为儿子节点、孙子节点、曾孙节点,以及更后面的。比如说,节点5的祖先为7和11,节点5的后代为3和6。
生成一个二叉搜索树
现在我们一起生成一个二叉搜索树:
function BinarySearchTree(){
var Node = function(key){ // {1}
this.key = key;
this.left = null;
this.right = null;
};
var root = null; // {2}
}
下图展示了二叉搜索树的组织结构:
和链表一样,我们将使用指针来表示节点之间的链接(在术语中也叫边)。在双向链表中,我们用两个指针分别来指向前一个节点和后一个节点。而在树结构中,我们也会使用两个指针。不同的是,一个指针指向的是左边的子节点,另一个指针指向的是右边的子节点。为此,我们要声明一个Node类来表示树中的每一个节点**(行 {1})**。值得注意的是,我们不再像之前的章节一样,称呼一个节点本身为节点或者成员,而是以其key称呼每一个节点。
和声明链表一样,我们会声明一个变量来存储该结构的第一个成员。在树中,我们称呼第一个成员为根节点**(行 {2})**。
接下来,我们要实现一些方法:
- insert(key): 在树中插入一个新key
- search(key): 在树中搜索key参数。如果该key存在于树中,返回true,反之,返回false。
- inOrderTraverse: 返回中序遍历的结果
- preOrderTraverse: 返回先序遍历的结果
- postOrderTraverse: 返回后序遍历的结果
- min: 返回树的最小值
- max: 返回树的最大值
- remove(key):从树中删除特定值
我们将在下面的部分讲解每一个方法。
在树中插入新的key
树的insert方法要比之前的结构要复杂一些。在这个方法中,会经常用到递归。
以下为insert方法的第一部分:
this.insert = function(key){
var newNode = new Node(key); // {1}
if (root === null){ // {2}
root = newNode;
}else {
insertNode(root,newNode); // {3}
}
}
在树中插入一个新key,有三个步骤要执行。
第一步是生成一个newNode***(行 {1})***。由于构造函数的特性,我们只要传一个key进去即可,其left和right指针会自动指向为null。
第二步,我们要判断该树的根节点是否为空***(行 {2})***。如果根节点为空,将root变量指向为newNode对象即可。
第三部是在除了根以外的其他位置添加key。这时,我们需要一个内部方法***(行 {3})***来帮助我们:
var insertNode = function(node,newNode){
if (newNode.key < node.key){ // {4}
if (node.left === null){ // {5}
node.left = newNode; // {6{
}else {
insertNode(node.left, newNode); // {7}
}
}else {
if (node.right === null){ // {8}
node.right = newNode; // {9}
} else {
insertNode(node.right, newNode); // {10}
}
}
};
insertNode函数帮助我们把新key插入到正确的位置,以下步骤为上述代码的解释:
第一步:如果这个树是非空的,我们需要找到正确的插入位置。为此,我们要讲root和newNode作为参数,传入insertNode函数中。
第二步:如果newNode的key小于node参数(也就是root,行 {4}),我们我们要检查树的左边。如果左边的子节点为空***(行 {5}),我们将新节点插入至此(行 {6})。如果不为空,我们需要递归调用insertNode方法,进入该树的下一层(行 {7})***。此时,nenwNode要和下一层左边的节点进行比较。
第三步:如果newNode的key大于node参数,我们我们要检查树的又边。如果右边的子节点为空***(行 {8}),我们将新节点插入至此(行 {9})。如果不为空,我们需要递归调用insertNode方法,进入该树的下一层(行 {7})***。此时,nenwNode要和下一层又边的节点进行比较。
用一些例子来帮助我们理解吧。
假设以下情形:我们要插入一个key到新树中:
var tree = new BinarySearchTree( );
tree.insert(11);
此时,树中只有一个节点,root变量会指向这个key。此时机器会执行(行 {2})。
现在,让我考虑这样的树:
该树在刚刚的基础上,由以下代码生成的:
tree.insert(7);
tree.insert(15);
tree.insert(3);
tree.insert(9);
tree.insert(8);
tree.insert(10);
tree.insert(13);
tree.insert(12);
tree.insert(14);
tree.insert(20);
tree.insert(18);
tree.insert(25);
如果我们要插入值为6的key
tree.insert(6);
代码会按照以下步骤被执行:
- 该树不是空的,代码会从***行 {3}***开始执行。之后会调用insertNode方法(insertNode(root,key[6]))。
- 代码接下来会检查行 {4} (key[6] < root[11] 为 真),则之后将会检查行 {5}(node.left[7]不为空),之后运行 {7},执行insertNode(node.left[7],key[6])。
- 我们要再次进入insertNode方法,但这次的参数不一样。机器会再次检查行 {4}(key[6]<node[7] 为真),之后会检查 行 {5}(node.left[5]不为空),这时会执行insertNode(node.left[5],key[6])。
- 我们会再次进入insertNode方法。机器会再次检查行 {4}(key[6]<node[5] 为假),之后运行 {8}(node.rigth 为空——节点5没有右边子节点),此时执行 {9},将key 6当做节点5右边子节点插入。
- 之后,执行栈会按照后进先出的方式被推出。
这是插入值为6的key的样子:
树遍历
树遍历,即为访问树的所有节点,并对每一个节点进行特定操作的过程。那么,我们该如何遍历一棵树呢?我们是应该从树的顶部开始呢,还是从树的顶部开始,还是从树的左边,还是从树的右边。业界用几种常用的遍历方法:中序遍历、前序遍历和后序遍历。
在下面几节,我们会深入这几种遍历方法。
中序遍历
中序遍历以key从小到大次序访问树。该遍历方法可以用于排序。让我们看看其实现:
this.inOrderTraverse = function(callback){
inOrderTraverseNode(root,callback); // {1}
}
inOrderTraverse方法以回调函数作为参数。我们可以用递归来实现中序遍历,我们将使用一个以node和回掉函数为参数的内部函数,来实现这一功能:
var inOrderTraverseNode = function(node,callback){
if(node !== null){ // {2}
inOrderTraverseNode(node.left,callback); // {3}
callback(node.key);
inOrderTraverseNode(node.right,callback);// {4]
}
};
在遍历一颗树之前,我们首先要确认的是,这node参数是否为空(这是遍历停止的地方,也是该遍历的基准)。
接下来,我们会将node参数的左子树作为参数传入inOrderTraverseNode函数中***(行 {3}),递归调用。之后,我们对将node参数本身传入callback中(行 {4}),之后再以同样的方式递归访问node参数的右子树(行 {5})***。
我们现在打印一下之前生成的树实例:
function printNode(value){ // {6}
console.log(value);
}
tree.inOrderTraverse(printNode); // {7}
首先,我们要生成一个回调函数(行 {6})。这个回调函数是用来在控制台打印值的。之后,我们将该回调函数以参数的形式传入inOrderTraverse中。当我们执行这段代码时,其在控制台的输出如下:
3 5 7 8 9 10 11 12 13 14 15 18 20 25
该图展示了这个过程:
前序遍历
前序遍历的特点是,先访问父节点,再访问子节点。前序遍历可以用于访问结构化的文档。
让我们看看其代码实现:
this.preOrderTraverse = function(callback){
preOrderTraverseNode(root,callback);
};
preOrderTraverseNode函数的代码实现如下:
var preOrderTraverseNode = function(node,callback){
if (node !== null){
callback(node.key); // {1}
preOrderTraverseNode(node.left,callback); // {2}
preOrderTraverseNode(node.right,callback); // {3}
}
};
中序遍历与前序遍历的不同在于,前序遍历会先访问node***(行 {1}),之后访问左子树(行 {2}),再访问右子树(行 {3}),而中序遍历的访问顺序为{2}{1}{3}***。
以下为之前实例,用前序遍历输出的结果:
11 7 5 3 6 9 8 10 15 13 12 14 20 18 25
该图展示了这个过程:
后序遍历
后序遍历的特点是,先访问子节点,再访问父节点。后序遍历,可以用于凭借目录和子目录来计算存储空间。
让我们看看其代码实现
this.postOrderTraverse = function(callback){
postOrderTraverseNode(root,callback);
};
postOrderTraverseNode函数的代码实现如下:
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})***。
如我们所见,中序、前序和后序的代码实现是非常相似的,他们只是行 {1},行 {2},***行 {3}***顺序的不同。
以下为之前实例,用前序遍历输出的结果:
3 6 5 8 10 9 7 12 14 13 18 25 20 15 11
该图展示了这个过程:
在树中搜索一个key
树中有三种搜索场景:
- 搜索最大值
- 搜索最小值
- 搜索特定值
让我们看看每一个场景。
寻找最小值与最大值
让我们看看树对实例
通过图片,我们可以很轻松地找到树对最大值和最小值。
如果我们查看树最底层最左边的节点,我们会发现最小值为3。同理,我们查看树最底层的最右边,我们会发现树的最大值为25。这个发现对我们实现寻找最大值和最小值是非常有帮助的。
首先,我们看看寻找最小值的函数是如何实现的:
this.min = function() {
return minNode(root); // {1}
};
min函数是对用户开放的,使用min函数时,它又会调用minNode方法***(行 {1})***:
var minNode = function (node) {
if (node){
while (node && node.left !== null) { // {2}
node = node.left; // {3}
}
return node.key;
}
return null; //{4}
}
minNode方法可以帮我们找到树的最小值。我们可以用它找到树或者子树的最小值。我们要把root作为参数传入minNode函数***(行 {1})***——因为我们要找到整个树的最小值。
在minNode函数中,我们会遍历树的左边***(行 {2} 和 行 {3})***,直到我们遍历到树的最左边。
同理,也可以用同样的方法实现max方法:
this.max = function(){
return maxNode(root);
};
var maxNode = function(node){
if (node){
while( node && node.right !== null){ // {5}
node = node.right;
}
return node.key;
}
return null;
}
为了找到最大值,我们将遍历树的最右边((行 {5}),直到我们遍历到树的最右边。
总之,最小值在树的最左边,最大值在树的最右边。
在树中寻找特定值
在前面的章节,我们知道如何实现find、search和get方法(用于搜索特定值)。现在,我们要实为二叉搜索树实现search方法。让我看看代码是如何实现的:
this.search = function(key){
return searchNode(root,key); // {1}
};
var searchNode = function(node,key){
if (node === null){ // {2}
return false;
}
if (key < node.key){ // {3}
return searchNode(node.left,key); // {4}
}else if (key > node.key){ // {5}
return searchNode(node.right,key); // {6}
}else {
return true; // {7}
}
};
首先,我们要声明search方法。在search方法中,我们要调用searchNode方法***(行 {1})***。
searchNode是用于寻找特定值的。在searchNode中,我们首先要判断node参数是否为空。如果为空,则说明没有搜索到相应的key,函数会返回一个false。
如果node参数不为空,代码继续往下执行。如果key参数比现在的key小***(行 {3}),那么我们会在其左子树继续搜索(行 {4})。如果key参数比现在的key大(行 {5}),那么我们会在其右子树继续搜索(行 {6})。否则,则说明key参数等于现在的key,函数会返回true,告诉我们已经找到相应的key了如果key参数比现在的key小(行 {3}),那么我们会在其左子树继续搜索(行 {7})***。
让我们测试一下这个方法:
console.log(tree.search(1)? 'Key 1 found.': 'Key 1 not found')
console.log(tree.search(8)? 'Key 8 found.': 'Key 8 not found')
代码的输出为:
Value 1 not found
Value 8 found
删除一个节点
下一个要实现的是remove方法。这也是本书要实现的最复杂的方法。让我们看看其代码实现:
this.remove = function(key){
root = removeNode(root,key); // {1}
}
这个方法接收了一个key参数。方法内部调用了一个removeNode方法,并接收了root参数和key参数***(行 {1})***。
该方法的复杂,在于其要涵盖多种场景,且该方法要使用递归。
让我们看看removeNode方法的代码实现:
var remove = function(node,key){
if (node===null){ // {2}
return null;
}
if (key < node.key){ // {3}
node.left = removeNode(node.left,key);// {4}
return node;// {5}
} else if (key > node.key){ // {6}
node.right = removeNode(node.right,key);// {7}
return node;// {8}
} else { // key等于node.key
// 场景1 -该节点是一个叶节点
if(node.left === null && node.right === null){ // {9}
node = null;// {10}
return node;// {11}
}
// 场景2 -该节点只有一个子节点
if(node.left === null){// {12}
node= node.right;// {13}
return node;// {14}
} else if (node.right = null){ // {15}
node = node.left; // {16}
return node; // {17}
}
// 场景2 -该节点只有两个子节点
var aux = finMinNode(node.right); // {18}
node.key = aux.key; // {19}
node.right = removeNode(node.right, aux.key); // {20}
return node; // {21}
}
}
***行 {2}***是这个递归程序停止的点。如果这个node为空,这意味着这个key并不存在于树中,所以要返回null。
之后,我们要做的第一件事是在树中找到相应的key。如果key参数小于现在node的key***(行 {3}),我们会访问其左子树(行 {4})。如果key参数大于现在node的key(行 {6}),我们会访问其右子树(行 {7})***。
如果我们找到了相应的key,接下来有三种场景需要我们去处理。
要删除的节点是一个叶子节点
第一个要处理的场景是,该节点没有子节点***(行 {9})。在这个场景中,我们只要将其值赋为null即可。但如我们之前在链表里面学到的,仅仅讲node指向null是不够的,我们还要关注它的指针。在这个场景中,该节点没有任何子节点,但有一个父节点。我们还需要将其父节点的指向改为null,而这一步通过返回null即可(行 {11})***。
因为node值为null,其父指针也会接收这个null值。这也是为什么我们要把node作为函数的返回值。父节点会一直接收函数的返回值。
如果我们仔细回顾这个方法前面的代码,我们其实是通过***行 {4}和行 {7}的代码来更改node的左指针和右指针,并通过行 {5}和行 {8}***来返回更新后的node。
下图展示来删除叶节点的过程:
要删除的节点有一个子节点
现在让我们看看第二个场景,即要删除的节点有一个子节点(左或者右)。在这种情况下,我们只要跳过这个节点,让其父节点指向其子节点即可。
如果一个节点没有左子节点***(行 {12}),则说明它有右子节点。所以,我们将node的指向改为其右子节点(行 {13}),并返回node即可(行 {14})。如果节点没有右子节点,我们会进行同样的操作(行 {15})——,我们将node的指向改为其左子节点(行 {16}),并返回node即可(行 {17})***。
下图展示了删除只有一个子节点的节点的过程:
要删除的节点有两个子节点
现在,我们进入最复杂的场景三。要删除有两个子节点的节点,要执行以下四个步骤:
- 找到了要删除的node后,我们要找到其右子树的最小值(该值会替代被删除的node-行 {18})。
- 之后,我们将右子树的最小值赋到node上。通过这个操作,我们已经删除了要删除的node***(行 {19})***。
- 然而,这样树中有了两个相同的值。为此,我们要把右子树的最小值删掉***(行 {20})***。
- 最后,我们将更新后的node返回给其父节点***(行 {20})***。
finMinNode函数的实现和min函数基本是一样的。唯一不同的是,在min函数中,我们只返回key,而在findMinNode在,我们返回的是节点。
下图展示了删除有两个子节点的节点的过程:
更多关于二叉树的知识
现在我已经知道如何使用一颗二叉树了,如果你喜欢,可以继续深入。
二叉搜索树有一个问题,该树会出现一边非常深,另一半非常浅的情况,如下图所示:
这样会产生增删改查方面的性能问题。为此,Adelson-Velskii&Landis'tree(AVL树)问世了。AVL是一种自平衡树,这意味这左子树节点和右子树节点之差,最多只为一。这意味着,在添加和删除节点时,树会自我调节与平衡。
note 另一个需要学习的树是红黑树。红黑树是二叉树树的特殊版。这种树让中序遍历更加高效(goo.gl/OxED8K)。 也可了解一下堆树(goo.gl/SFlhW6)。
小结
在本章,我们学习了计算机科学中最常用的树的增删改查算法。我们也讲解了三种遍历树的方法。
在下一章,我们会学习图的基本概念。图和树一样,都是非线性的数据结构。
注:本文翻译自Loiane Groner的《Learning JavaScript Data Structures and Algorithm》