JavaScript数据结构之树

JavaScript数据结构之树

树的相关术语

一个树结构包含一系列存在父子关系的节点。每个节点都有一个父节点(除了顶部的第一个节点)以及零个或多个子节点:

在这里插入图片描述

位于树顶部的节点叫作根节点(11)。它没有父节点。树中的每个元素都叫作节点,节点分为内部节点和外部节点。至少有一个子节点的节点称为内部节点(7、5、9、15、13和20是内部节点)。没有子元素的节点称为外部节点或叶节点(3、6、8、10、12、14、18和25是叶节点)。

一个节点可以有祖先和后代。一个节点(除了根节点)的祖先包括父节点、祖父节点、曾祖父节点等。一个节点的后代包括子节点、孙子节点、曾孙节点等。例如,节点5的祖先有节点7和节点11,后代有节点3和节点6。

有关树的另一个术语是子树。子树由节点和它的后代构成。例如,节点13、12和14构成了上图中树的一棵子树。
节点的一个属性是深度,节点的深度取决于它的祖先节点的数量。比如,节点3有3个祖先节点(5、7和11),它的深度为3。

树的高度取决于所有节点深度的最大值。一棵树也可以被分解成层级。根节点在第0层,它的子节点在第1层,以此类推。上图中的树的高度为3(最大高度已在图中表示——第3层)。

二叉树和二叉搜索树

二叉树中的节点最多只能有两个子节点:一个是左侧子节点,另一个是右侧子节点。这些定义有助于写出更高效的向/从树中插入、查找和删除节点的算法。二叉树在计算机科学中的应用非常广泛。

二叉搜索树(BST)是二叉树的一种,但是它只允许在左侧节点存储(比父节点)小的值,在右侧节点存储(比父节点)大(或者等于)的值。上一节的图中就展现了一棵二叉搜索树。二叉搜索树将是我们在本章中要研究的数据结构。

创建 BinarySearchTree 类

创建自己的 BinarySearchTree 类。首先,声明它的结构:

function BinarySearchTree() {
	var Node = function(key){ //{1}
		this.key = key;
		this.left = null;
		this.right = null;
	};
	var root = null; //{2}
}

下图展现了二叉搜索树数据结构的组织方式:
在这里插入图片描述
和链表一样,将通过指针来表示节点之间的关系(术语称其为边)。在双向链表中,每个节点包含两个指针,一个指向下一个节点,另一个指向上一个节点。对于树,使用同样的方式(也使用两个指针)。但是,一个指向左侧子节点,另一个指向右侧子节点。因此,将声明一个 Node类来表示树中的每个节点(行 {1} )。值得注意的一个小细节是,不同于链表将节点本身称作节点或项,树中会称其为键。键是树相关的术语中对节点的称呼。

遵循和 LinkedList 类中相同的模式,这表示也将声明一个变量以控制此数据结构的第一个节点。在树中,它不再是头节点,而是根元素(行 {2} )。

要在树类中实现的方法。

  • insert(key) :向树中插入一个新的键。
  • search(key) :在树中查找一个键,如果节点存在,则返回 true ;如果不存在,则返回false 。
  • inOrderTraverse :通过中序遍历方式遍历所有节点。
  • preOrderTraverse :通过先序遍历方式遍历所有节点。
  • postOrderTraverse :通过后序遍历方式遍历所有节点。
  • min :返回树中最小的值/键。
  • max :返回树中最大的值/键。
  • remove(key) :从树中移除某个键。

向树中插入一个键

下面的代码是用来向树插入一个新键的算法的第一部分:

this.insert = function(key){
	var newNode = new Node(key); //{1}
	if (root === null){ //{2}
		root = newNode;
	} else {
		insertNode(root,newNode); //{3}
	}	
};

要向树中插入一个新的节点(或项),要经历三个步骤。

  • 第一步是创建用来表示新节点的 Node 类实例(行 {1} )。只需要向构造函数传递我们想用来插入树的节点值,它的左指针和右指针的值会由构造函数自动设置为 null 。
  • 第二步要验证这个插入操作是否为一种特殊情况。这个特殊情况就是我们要插入的节点是树的第一个节点(行 {2} )。如果是,就将根节点指向新节点。
  • 第三步是将节点加在非根节点的其他位置。这种情况下,需要一个私有的辅助函数(行 {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 函数会找到新节点应该插入的正确位置。下面是这个函数实现的步骤。

  • 如果树非空,需要找到插入新节点的位置。因此,在调用 insertNode 方法时要通过参数传入树的根节点和要插入的节点。
  • 如果新节点的键小于当前节点的键(现在,当前节点就是根节点)(行 {4} ),那么需要检查当前节点的左侧子节点。如果它没有左侧子节点(行 {5} ),就在那里插入新的节点。如果有左侧子节点,需要通过递归调用 insertNode 方法(行 {7} )继续找到树的下一层。在这里,下次将要比较的节点将会是当前节点的左侧子节点。
  • 如果节点的键比当前节点的键大,同时当前节点没有右侧子节点(行 {8} ),就在那里插入新的节点(行 {9} )。如果有右侧子节点,同样需要递归调用 insertNode 方法,但是要用来和新节点比较的节点将会是右侧子节点。
    让我们通过一个例子来更好地理解这个过程。

考虑下面的情景:有一个新的树,并且想要向它插入第一个值。

var tree = new BinarySearchTree();
tree.insert(11);

这种情况下,树中有一个单独的节点,根指针将会指向它。源代码的行 {2} 将会执行。
现在,来考虑下图所示树结构的情况:
在这里插入图片描述
创建上图所示的树的代码如下,它们接着上面一段代码(插入了键为11的节点)之后输入执行:

tree.insert(7);
tree.insert(15);
tree.insert(5);
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 的键,执行下面的代码:

tree.insert(6);

下面的步骤将会被执行。

  1. 树不是空的,行 {3} 的代码将会执行。 insertNode 方法将会被调用( root, key[6] )。
  2. 算法将会检测行 {4} ( key[6] < root[11] 为真),并继续检测行 {5} ( node.left[7]不是 null ),然后将到达行 {7} 并调用 insertNode ( node.left[7], key[6] )。
  3. 将再次进入 insertNode 方法内部,但是使用了不同的参数。它会再次检测行 {4} ( key[6]< node[7] 为真),然后再检测行 {5} ( node.left[5] 不是 null ),接着到达行 {7} ,调用insertNode ( node.left[5], key[6] )。
  4. 将再一次进入 insertNode 方法内部。它会再次检测行 {4} ( key[6] < node[5] 为假),然后到达行 {8} ( node.right 是 null ——节点5没有任何右侧的子节点),然后将会执行行 {9} ,在节点 5 的右侧子节点位置插入键 6 。
  5. 然后,方法调用会依次出栈,代码执行过程结束。

这是插入键6后的结果:
在这里插入图片描述

树的遍历

遍历一棵树是指访问树的每个节点并对它们进行某种操作的过程。

访问树的所有节点有三种方式:中序、先序和后序。

中序遍历

中序遍历是一种以上行顺序访问BST所有节点的遍历方式,也就是以从最小到最大的顺序访问所有节点。中序遍历的一种应用就是对树进行排序操作。来看它的实现:

this.inOrderTraverse = function(callback){
	inOrderTraverseNode(root, callback); //{1}
};

inOrderTraverse 方法接收一个回调函数作为参数。回调函数用来定义我们对遍历到的每个节点进行的操作。由于在BST中最常实现的算法是递归,这里使用了一个
私有的辅助函数,来接收一个节点和对应的回调函数作为参数(行 {1} )。

var inOrderTraverseNode = function (node, callback) {
	if (node !== null) { //{2}
		inOrderTraverseNode(node.left, callback); //{3}
		callback(node.key); //{4}
		inOrderTraverseNode(node.right, callback); //{5}
	}
};

要通过中序遍历的方法遍历一棵树,首先要检查以参数形式传入的节点是否为 null (这就是停止递归继续执行的判断条件——行 {2} ——递归算法的基本条件)。
然后,递归调用相同的函数来访问左侧子节点(行 {3} )。接着对这个节点进行一些操作( callback ),然后再访问右侧子节点(行 {5} )。

在之前展示的树上执行下面的方法:

function printNode(value){ //{6}
	console.log(value);
}
tree.inOrderTraverse(printNode); //{7}

但首先,需要创建一个回调函数(行 {6} )。是在浏览器的控制台上输出节点的值。然后,调用 inOrderTraverse 方法并将回调函数作为参数传入(行 {7} )。当执行上面的代码后,下面的结果将会在控制台上输出(每个数字将会输出在不同的行):

3 5 6 7 8 9 10 11 12 13 14 15 18 20 25

下面的图描绘了 inOrderTraverse 方法的访问路径:
在这里插入图片描述

先序遍历

先序遍历是以优先于后代节点的顺序访问每个节点的。

先序遍历的一种应用是打印一个结构化的文档。

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}
	}
};

先序遍历和中序遍历的不同点是,先序遍历会先访问节点本身(行 {1} ),然后再访问它的左侧子节点(行 {2} ),最后是右侧子节点(行 {3} ),而中序遍历的执行顺序是: {2} 、 {1} 和 {3} 。
下面是控制台上的输出结果(每个数字将会输出在不同的行):

11 7 5 3 6 9 8 10 15 13 12 14 20 18 25

下面的图描绘了 preOrderTraverse 方法的访问路径:
在这里插入图片描述

后序遍历

后序遍历则是先访问节点的后代节点,再访问节点本身。

后序遍历的一种应用是计算一个目录和它的子目录中所有文件所占空间的大小。

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

下面的图描绘了 postOrderTraverse 方法的访问路径:
在这里插入图片描述

搜索树中的值

在树中,有三种经常执行的搜索类型:

  • 最小值;
  • 最大值;
  • 搜索特定的值。

搜索最小值和最大值

我们使用下面的树作为示例:
在这里插入图片描述

首先,寻找树的最小键的方法:

this.min = function() {
	return minNode(root); //{1}
};

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 方法允许从树中任意一个节点开始寻找最小的键。可以使用它来找到一棵树或它的子树中最小的键。因此,我们在调用 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} )直到找到最右端的节点。

因此,对于寻找最小值,总是沿着树的左边;而对于寻找最大值,总是沿着树的右边。

搜索一个特定的值

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 方法。和BST中声明的其他方法的模式相同,将
会使用一个辅助函数(行 {1} )。searchNode 方法可以用来寻找一棵树或它的任意子树中的一个特定的值。这也是为什么在行 {1} 中调用它的时候传入树的根节点作为参数。
在开始算法之前,先要验证作为参数传入的 node 是否合法(不是 null )。
如果是 null 的话,说明要找的键没有找到,返回 false 。
如果传入的节点不是 null ,需要继续验证。如果要找的键比当前的节点小(行 {3} ),那么继续在左侧的子树上搜索(行 {4} )。
如果要找的键比当前的节点大,那么就从右侧子节点开始继续搜索(行 {6} ),否则就说明要找的键和当前节点的键相等,就返回 true 来表示找到了这个
键(行 {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.

执行过程:

查找值为 1 的节点:

  • 调用 searchNode 方法,传入根节点作为参数(行 {1} )。( node[root[11]] )不是 null(行 {2} ),因此我们执行到行 {3} 。
  • ( key[1] < node[11] )为 ture (行 {3} ),因此来到行 {4} 并再次调用 searchNode方法,传入( node[7], key[1] )作为参数。
  • ( node[7] )不是 null ( {2} ),因此继续执行行 {3} 。
  • ( key[1] < node[7] )为 ture (行 {3} ),因此来到行 {4} 并再次调用 searchNode 方法,传入( node[5], key[1] )作为参数。
  • ( node[5] )不是 null (行 {2} ),因此继续执行行 {3} 。
  • ( key[1] < node[5] )为 ture (行 {3} ),因此来到行 {4} 并再次调用 searchNode 方法,传入( node[3], key[1] )作为参数。
  • ( node[3] )不是 null (行 {2} ),因此来到行 {3} 。
  • ( key[1] < node[3] )为真(行 {3} ),因此来到行 {4} 并再次调用 searchNode 方法,传入( null, key[1] )作为参数。 null 被作为参数传入是因为 node[3] 是一个叶节点(它没有子节点,所以它的左侧子节点的值为 null )。
  • 节点( null )的值为 null (行 {2} ,这时要搜索的节点为 null ),因此返回 false 。
  • 然后,方法调用会依次出栈,代码执行过程结束。

查找值为 8 的节点:

  • 调用 searchNode 方法,传入 root 作为参数(行 {1} )。( node[root[11]] )不是 null(行 {2} ),因此我们来到行 {3} 。
  • ( key[8] < node[11] )为真(行 {3} ),因此执行到行 {4} 并再次调用 searchNode 方法,传入( node[7], key[8] )作为参数。
  • ( node[7] )不是 null ,因此来到行 {3} 。
  • ( key[8] < node[7] )为假(行 {3} ),因此来到行 {5} 。
  • ( key[8] > node[7] )为真(行 {5} ),因此来到行 {6} 并再次调用 searchNode 方法,传入( node[9], key[8] )作为参数。
  • ( node[9] )不是 null (行 {2} ),因此来到行 {3} 。
  • ( key[8] < node[9] )为真(行 {3} ),因此来到行 {4} 并再次调用 searchNode 方法,传入( node[8], key[8] )作为参数。
  • ( node[8] )不是 null (行 {2} ),因此来到行 {3} 。
  • ( key[8] < node[8] )为假(行 {3} ),因此来到行 {5} 。
  • ( key[8] > node[8] )为假(行 {5} ),因此来到行 {7} 并返回 true ,因为 node[8]就是要找的键。
  • 然后,方法调用会依次出栈,代码执行过程结束。

移除一个节点

this.remove = function(key){
	root = removeNode(root, key); //{1}
};

这个方法接收要移除的键并且它调用了 removeNode 方法,传入 root 和要移除的键作为参数行 {1} )。
注意:root 被赋值为 removeNode 方法的返回值。

removeNode 方法的复杂之处在于我们要处理不同的运行场景,当然也包括它同样是通过递归来实现的。

removeNode 方法的实现:

var removeNode = 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 { //键等于node.key
	//第一种情况——一个叶节点
	if (node.left === null && node.right === null){ //{9}
		node = null; //{10}
		return node; //{11}
	}
	//第二种情况——一个只有一个子节点的节点
	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}
	}
	//第三种情况——一个有两个子节点的节点
	var aux = findMinNode(node.right){ //{18}
		node.key = aux.key; //{19}
		node.right = removeNode(node.right, aux.key); //{20}
		return node; //{21}
	}
};

先看行 {2} ,如果正在检测的节点是 null ,那么说明键不存在于树中,所以返回 null 。
然后,就是在树中找到要移除的节点。因此,如果要找的键比当前节点的值小(行 {3} ),就沿着树的左边找到下一个节点(行 {4} )。如果要找的键比当前节点的值大(行 {6} ),那么就沿着树的右边找到下一个节点(行 {7} )。

如果找到了要找的键(键和 node.key 相等),就需要处理三种不同的情况。

1. 移除一个叶节点

第一种情况是该节点是一个没有左侧或右侧子节点的叶节点——行 {9} 。在这种情况下,要做的就是给这个节点赋予 null 值来移除它(行 {9} )。

但是仅仅赋一个 null 值是不够的,还需要处理指针。在这里,这个节点没有任何子节点,但是它有一个父节点,需要通过返回 null 来将对应的父节点指针赋予 null 值(行 {11} )。

现在节点的值已经是 null 了,父节点指向它的指针也会接收到这个值,父节点总是会接收到函数的返回值。

另一种可行的办法是将父节点和点本身都作为参数传入方法内部。如果回头来看方法的第一行代码,会发现在行 {4} 和行 {7} 更新了节点左右指针的值,同样也在行 {5} 和行 {8} 返回了更新后的节点。
下图展现了移除一个叶节点的过程:
在这里插入图片描述

2. 移除有一个左侧或右侧子节点的节点

第二种情况,移除有一个左侧子节点或右侧子节点的节点。这种情况下,需要跳过这个节点,直接将父节点指向它的指针指向子节点。

如果这个节点没有左侧子节点(行 {12} ),也就是说它有一个右侧子节点。因此把对它的引用改为对它右侧子节点的引用(行 {13} )并返回更新后的节点(行 {14} )。如果这个节点没有右侧子节点,也是一样——把对它的引用改为对它左侧子节点的引用(行 {16} )并返回更新后的值(行 {17} )。

下图展现了移除只有一个左侧子节点或右侧子节点的节点的过程:
在这里插入图片描述

3. 移除有两个子节点的节点

第三种情况,也是最复杂的情况,那就是要移除的节点有两个子节点——左侧子节点和右侧子节点。要移除有两个子节点的节点,需要执行四个步骤。

  1. 当找到了需要移除的节点后,需要找到它右边子树中最小的节点(它的继承者——行{18} )。
  2. 然后,用它右侧子树中最小节点的键去更新这个节点的值(行 {19} )。通过这一步,改变了这个节点的键,也就是说它被移除了。
  3. 但是,这样在树中就有两个拥有相同键的节点了,这是不行的。要继续把右侧子树中的最小节点移除,毕竟它已经被移至要移除的节点的位置了(行 {20} )。
  4. 最后,向它的父节点返回更新后节点的引用(行 {21} )。
    findMinNode 方法的实现和 min 方法的实现方式是一样的。唯一不同之处在于,在 min 方法中只返回键,而在 findMinNode 中返回了节点。

下图展现了移除有两个子节点的节点的过程:
在这里插入图片描述

发布了118 篇原创文章 · 获赞 257 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/low666/article/details/105043433