【翻译】树 | 掘金技术征文-双节特别篇

498 阅读9分钟

第八章 树

书籍出处: 《Learning JavaScript Data Structures and Algorithm》

作者: 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);

代码会按照以下步骤被执行:

  1. 该树不是空的,代码会从***行 {3}***开始执行。之后会调用insertNode方法(insertNode(root,key[6]))。
  2. 代码接下来会检查行 {4} (key[6] < root[11] 为 真),则之后将会检查行 {5}(node.left[7]不为空),之后运行 {7},执行insertNode(node.left[7],key[6])。
  3. 我们要再次进入insertNode方法,但这次的参数不一样。机器会再次检查行 {4}(key[6]<node[7] 为真),之后会检查 行 {5}(node.left[5]不为空),这时会执行insertNode(node.left[5],key[6])。
  4. 我们会再次进入insertNode方法。机器会再次检查行 {4}(key[6]<node[5] 为假),之后运行 {8}(node.rigth 为空——节点5没有右边子节点),此时执行 {9},将key 6当做节点5右边子节点插入。
  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})***。

下图展示了删除只有一个子节点的节点的过程:

要删除的节点有两个子节点

现在,我们进入最复杂的场景三。要删除有两个子节点的节点,要执行以下四个步骤:

  1. 找到了要删除的node后,我们要找到其右子树的最小值(该值会替代被删除的node-行 {18})。
  2. 之后,我们将右子树的最小值赋到node上。通过这个操作,我们已经删除了要删除的node***(行 {19})***。
  3. 然而,这样树中有了两个相同的值。为此,我们要把右子树的最小值删掉***(行 {20})***。
  4. 最后,我们将更新后的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》

🏆 掘金技术征文|双节特别篇