第六节:树详解(各种概念术语、二叉搜索树的封装)
一. 树详解
1. 树优点及横向比较
(1). 数组
(2). 链表
(3). 哈希表
(4). 树
2. 相关术语
◼ 2.树的度 (Degree) :树的所有节点中最大的度数。
◼ 3.叶子节点(Leaf):度为0的节点。(也称为叶节点)
◼ 4.父节点(Parent):有子树的节点是其子树的根节点的父节点
◼ 5.子节点(Child):若A节点是B节点的父节点,则称B节点是A节点的子节点;子节点也称孩子节点。
◼ 6.兄弟节点(Sibling):具有同一父节点的各节点彼此是兄弟节点。
◼ 7.路径和路径长度:从节点n1到nk的路径为一个节点序列n1 ,n2,… ,nk ni是 n(i+1)的父节点, 路径所包含 边的个数为路径的长度。
◼ 8.节点的层次(Level):规定根节点在1层,其它任一节点的层数是其父节点的层数加1。
◼ 9.树的深度(Depth):对于任意节点n, n的深度为从根到n的唯一路径长,根的深度为0。
◼ 10.树的高度(Height):对于任意节点n,n的高度为从n到一片树叶的最长路径长,所有树叶的高度为0
3. 表示方法
儿子兄弟表示法
二. 二叉树详解
1. 二叉树概念
树中每个节点最多只能有两个子节点,这样的树就成为"二叉树"。几乎上所有的树都可以表示成二叉树的形式。
(1). 二叉树的定义
二叉树可以为空,也就是没有节点。
若不为空,则它是由根节点 和 称为其 左子树TL和 右子树TR 的两个不相交的二叉树组成。
(2). 二叉树有五种形态:
2. 二叉树特性
二叉树有几个比较重要的特性,在笔试题中比较常见:
(1). 一颗二叉树第 i 层的最大节点数为:2^(i-1),i >= 1;
(2). 深度为k的二叉树有最大节点总数为: 2^k - 1,k >= 1;
(3). 对任何非空二叉树 T,若n0表示叶子节点的个数、n2是度为2的非叶节点个数,那么两者满足关系n0 = n2 + 1。
eg: 如下图,叶子节点的个数为:4, 分别事D、J、K、H; 度为2的非叶子节点个数为:3,分别是A、B、E, 满足上述公式: 4=3+1
3. 完美二叉树
完美二叉树(Perfect Binary Tree) ,也称为满二叉树(Full Binary Tree), 在二叉树中,除了最下一层的叶节点外,每层节点都有2个子节点,就构成了满二叉树。
4. 完全二叉树
(1). 除二叉树最后一层外,其他各层的节点数都达到最大个数。
(2). 且最后一层从左向右的叶节点连续存在,只缺右侧若干节点。
(3). 完美二叉树是特殊的完全二叉树。
eg: 下面不是完全二叉树,因为D节点还没有右节点,但是E节点就有了左右节点。
5. 二叉搜索树(重点)
(1). 别名
也称二叉排序树或二叉查找树
(2). 概念
二叉搜索树是一颗二叉树,可以为空; 如果不为空,满足以下性质:
A. 非空左子树的所有键值小于其根节点的键值。
B. 非空右子树的所有键值大于其根节点的键值。
C. 左、右子树本身也都是二叉搜索树。
(3). 判断哪些是二叉搜索树
(4). 特性
二叉搜索树的特点就是相对较小的值总是保存在左节点上,相对较大的值总是保存在右节点上。
查找效率非常高,这也是二叉搜索树中,搜索的来源。
6. 二叉树存储分析
(1). 数组
(2). 链表 【推荐】
每个节点封装成一个Node,Node中包含存储的数据,左节点的引用,右节点的引用。
三. 二叉搜索树封装
1. 节点封装
(1). 首先要有个树节点类TreeNode,具有的属性:value、left、right.
(2). 二叉搜索树类:BSTree, 具有的属性:根节点root
/**
* 树的节点类
*/
class TreeNode<T> {
value: T;
left: TreeNode<T> | null = null; //左子树
right: TreeNode<T> | null = null; //右子树
constructor(value: T) {
this.value = value;
}
}
/**
* 二叉搜索树类
*/
class BSTree<T> {
private root: TreeNode<T> | null = null; //根节点
}
2. 插入
(1). 分两种情况讨论:
1. 根节点为空:直接插入根节点即可
2. 插入的是非根节点:
(1). 新节点的value 小于 原节点的value
A. 左子树为null, 直接插入即可 B. 左子树有内容, 需要继续向下查找, 走递归即可
(2). 新节点的value 大于 原节点的value
A. 右子树为null, 直接插入即可 B. 右子树有内容, 需要继续向下查找, 走递归即可
(2). 封装类的使用
借助 btPrint 方法进行二叉树的打印
(3). 递归的深入理解
这里的递归相对好理解,没有返回值,最后结束都是在以下两步结束了
要么: if (originNode.left === null) originNode.left = newNode; //直接赋值
要么: if (originNode.right === null) originNode.right == newNode; //直接赋值
代码分享:
/**
* 打印树结构
*/
print() {
btPrint(this.root);
}
/**
* 1. 插入
* @param value 节点值
*/
insert(value: T): void {
// 1. 创建树节点
let newNode = new TreeNode(value);
// 2. 判断根节点是否为空
if (!this.root) this.root = newNode; //根节点为空,直接赋值
else {
//调用递归
this.insertNode(this.root, newNode);
}
}
/**
* 非根节点的插入
* @param originNode 参考节点、原节点
* @param newNode 需要被插入的新节点
*/
private insertNode(originNode: TreeNode<T>, newNode: TreeNode<T>): void {
// 1. 新节点的value 小于 原节点的value
if (newNode.value < originNode.value) {
// 1.1 左子树为null
if (originNode.left === null) originNode.left = newNode; //直接赋值
// 1.2 左子树有内容
else {
this.insertNode(originNode.left, newNode); //进入递归调用
}
}
// 2. 新节点的value 大于 原节点的value
else {
// 2.1 右子树为null
if (originNode.right === null) originNode.right = newNode; //直接赋值
// 2.2 右子树有内容
else {
this.insertNode(originNode.right, newNode); //进入递归调用
}
}
}
测试: (后面都是用这组数据!!!)
const bst = new BSTree<number>();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);
bst.insert(6);
bst.print();
3. 遍历-先序遍历
(1). 说明
(2). 递归写法
/**
* 2. 先序遍历
*/
preOrderTraverse() {
this.preOrderTraverseNode(this.root);
}
/**
* 先序遍历节点
* @param node 从该节点开始先序遍历
*/
// private preOrderTraverseNode(node: TreeNode<T> | null) {
// if (node) {
// console.log(node.value);
// this.preOrderTraverseNode(node.left);
// this.preOrderTraverseNode(node.right);
// }
// }
/**
* 先序遍历节点 【严格卡递归格式】
* @param node 从该节点开始先序遍历
*/
public preOrderTraverseNode(node: TreeNode<T> | null) {
//1. 递归结束条件
if (node === null) {
return;
}
//2. 输出节点内容
console.log(node.value);
//3. 递归调用1
this.preOrderTraverseNode(node.left);
// console.log('--------------------');
//4. 递归调用2
this.preOrderTraverseNode(node.right);
// console.log('!!!!!!!!!!!!!!!!!!!!');
}
(3). 非递归--难
/**
* 2. 先序遍历-非递归
*/
preOrderTraverseNoRecursion() {
let stack: TreeNode<T>[] = []; //模拟栈
let current: TreeNode<T> | null = this.root;
let tempArray: T[] = []; //临时数组,用来存放最后的打印结果
while (current !== null || stack.length !== 0) {
while (current !== null) {
// console.log(current.value);
tempArray.push(current.value); //加入临时数组,用于最后打印
stack.push(current);
current = current.left;
}
current = stack.pop()!;
current = current.right;
}
//最终打印
console.log(tempArray.join(","));
}
4. 遍历-中序遍历
(1). 说明
(2). 递归写法
/**
* 3. 中序遍历
*/
inOrderTraverse() {
this.inOrderTraverseNode(this.root);
}
/**
* 中序遍历节点
* @param node 从该节点开始中序遍历
*/
private inOrderTraverseNode(node: TreeNode<T> | null) {
if (node) {
this.inOrderTraverseNode(node.left);
console.log(node.value);
this.inOrderTraverseNode(node.right);
}
}
(3). 非递归-难
/**
* 3. 中序遍历
*/
inOrderTraverseNoRecursion() {
let stack: TreeNode<T>[] = []; //模拟栈
let current: TreeNode<T> | null = this.root;
let tempArray: T[] = []; //临时数组,用来存放最后的打印结果
while (current !== null || stack.length !== 0) {
while (current !== null) {
stack.push(current);
current = current.left;
}
current = stack.pop()!;
// console.log(current.value);
tempArray.push(current.value); //加入临时数组,用于最后打印
current = current.right;
}
//最终打印
console.log(tempArray.join(","));
}
5. 遍历-后序遍历
(1). 说明
(2). 递归写法
/**
* 4. 后序遍历
*/
postOrderTraverse() {
this.postOrderTraverseNode(this.root);
}
/**
* 后序遍历节点
* @param node 从该节点开始后序遍历
*/
private postOrderTraverseNode(node: TreeNode<T> | null) {
if (node) {
this.postOrderTraverseNode(node.left);
this.postOrderTraverseNode(node.right);
console.log(node.value);
}
}
(3). 非递归-难
/**
* 4. 后序遍历
*/
postOrderTraverseNoRecursion() {
let stack: TreeNode<T>[] = [];
let current: TreeNode<T> | null = this.root;
let lastVisitedNode: TreeNode<T> | null = null;
let tempArray: T[] = []; //临时数组,用来存放最后的打印结果
while (current !== null || stack.length !== 0) {
while (current !== null) {
stack.push(current);
current = current.left;
}
current = stack[stack.length - 1];
if (current.right === null || current.right === lastVisitedNode) {
// console.log(current.value);
tempArray.push(current.value); //加入临时数组,用于最后打印
lastVisitedNode = current;
stack.pop();
current = null;
} else {
current = current.right;
}
}
//最终打印
console.log(tempArray.join(","));
}
6. 遍历-层序遍历
(1). 说明
(2). 实操
利用队列解决的经典场景。
①. 数组模拟队列,先进先出,push、shift方法
②. 核心思路
A. 根节点不存在,不需要遍历
B. 用数组模拟队列,并将根节点入队
C. while遍历,出队的同时,将其左右节点入队
/**
*5 层序遍历
*/
levelOrderTraverse() {
//1. 根节点不存在,不需要遍历
if (!this.root) return;
//2. 用数组模拟队列,并将根节点入队
let queue: TreeNode<T>[] = [];
queue.push(this.root);
//3. while遍历,出队的同时,将其左右节点入队
while (queue.length > 0) {
//3.1 出队
let current = queue.shift()!; //一定存在,所以加!
console.log(current.value);
//3.2 出队元素的左右节点依次入队(存在的情况下)
if (current.left) queue.push(current.left);
if (current.right) queue.push(current.right);
}
}
(1). 最小值:叶子节点中最左侧的那个值
(2). 最大值:叶子节点中最右侧的那个值
/**
* 6. 返回最小值
* @returns 返回最小值,可能为null
*/
getMinValue(): T | null {
if (!this.root) return null;
// 遍历获得最小值
let current = this.root;
while (current && current.left) {
current = current.left;
}
return current.value;
}
/**
* 7. 返回最大值
* @returns 返回最大值,可能为null
*/
getMaxValue(): T | null {
if (!this.root) return null;
// 遍历获得最小值
let current = this.root;
while (current && current.right) {
current = current.right;
}
return current.value;
}
(1). 说明
二叉搜索树不仅仅获取最值效率非常高,搜索特定的值效率也非常高
无论采用哪种方法,核心思路都是:左侧树节点比根节点小, 右侧节点比根节点大
(2). 非递归写法(推荐)
/**
* 8-1 搜索(遍历写法)
* @param value 被搜索节点的值
* @returns true找到,false没找到
*/
searchNoRecursion(value: T): boolean {
let current = this.root;
while (current) {
if (current.value === value) {
return true; //找到了节点
} else if (value < current.value) {
current = current.left; //向左侧遍历
} else {
current = current.right; //向右侧遍历
}
}
return false;
}
(3). 递归写法
(1).递归必须有退出条件,我们这里是两种情况下退出。
A. node === null,也就是后面不再有节点的时候。
B. 找到对应的value,也就是node.value === value的时候。
(2).在其他情况下,根据node.的value和传入的value进行比较来决定向左还是向右查找。
A. 如果node.value > value,那么说明传入的值更小,需要向左查找。
B. 如果node.value < value,那么说明传入的值更大,需要向右查找。
PS: 这里的递归很好理解,不需要向上找,直接就返回了
/**
* 8-2 搜索(递归写法)
* @param value 被搜索节点的值
* @returns true找到,false没找到
*/
searchRecursion(value: T): boolean {
return this.searchNode(this.root, value);
}
/**
* 递归找节点
* @param compareNode 用来比较的节点
* @param searchNodeValue 被搜索的节点的值
* @returns true找到,false没找到
*/
searchNode(compareNode: TreeNode<T> | null, searchNodeValue: T): boolean {
if (compareNode === null) return false; //直接退出递归
if (searchNodeValue === compareNode.value) {
return true; //表示找到节点了
} else if (searchNodeValue < compareNode.value) {
return this.searchNode(compareNode.left, searchNodeValue); //向左边继续查找
} else {
return this.searchNode(compareNode.right, searchNodeValue); //向右边继续查找
}
}
9. 删除
详见下面
四. 删除--重点剖析
1. 分析
删除节点比较麻烦,需要以下几步
(1).先找到要删除的节点,如果没有找到,不需要删除
(2).找到要删除节点
A 删除叶子节点(没有子节点)
B 删除只有一个子节点的节点
C 删除有两个子节点的节点
2. 查找要删除的节点
(1).思路:找到要删除的节点 和 其父节点, 如果没有找到要删除的的节点,则不需要删除, 直接就结束了.
(2).实现:
A. 遍历找节点,根据节点比较,决定向左 or 向右查找
B. 找到节点后,需要给当前节点的父节点赋值(给TreeNode类添加一个parent属性)
3. 删除叶子节点(没有子节点)
(1).思路
A. 如果这个节点是根节点, 直接删除即可
B. 如果不是根节点,则将该节点父节点的left 或 right 设置为null即可
(2).实操
A. 判断是否是叶子节点, 即current的right和left都为null
B. 判断为是否为跟节点
C. 判断是左节点 还是 右节点 (给TreeNode封装两个方法 isLeft、isRight)
4. 删除只有一个子节点的节点
(1).思路
A. 判断该节点的这一个子节点是左 or 右节点,分两种情况讨论。
B. 只有左子节点
又要分三种情况讨论,current是:根节点、左节点、右节点
最终被赋值的是current.left
C. 只有右子节点
又要分三种情况讨论,current是:根节点、左节点、右节点
最终被赋值的是current.right
(2).实操
同上思路。
5. 删除有两个子节点的节点
(1).分析
删除的节点有两个子节点,甚至子节点还有子节点!!,这种情况下我们需要从下面的子节点中找到一个节点,来替换当前的节点。
要么比current节点小一点点,要么比current节点大一点点
(2).结论-记住
A. 比current小一点点的节点,一定是current左子树的最大值, 称为前驱节点
B. 比current大一点点的节点,一定是current右子树的最小值, 称之为后继节点
(3).获取后继节点思路分析
(这里不光获取了后继节点,还处理了后继节点提升后,左右侧指向的问题)
A. 遍历获取后继节点
B. 处理删除节点右侧的指向(如果删除节点的右子节点正好是后继节点,是不需要进行该操作)
C. 处理删除节点左侧的指向(一定需要进行的操作)
(4).实操
判断删除节点是根节点、左节点、右节点
分别将根节点、current.parent.left 、 current.parent.right 指向后继节点
上述完整代码分享
树节点
/**
* 树的节点类
*/
class TreeNode<T> {
value: T;
left: TreeNode<T> | null = null; //左子树
right: TreeNode<T> | null = null; //右子树
parent: TreeNode<T> | null = null; //当前节点的父节点(用于删除)
//写法1
// isLeft(node: TreeNode<T>): boolean {
// return node.value === node.parent?.left?.value;
// }
// isRight(node: TreeNode<T>): boolean {
// return node.value === node.parent?.right?.value;
// }
//写法2--使用set语法
get isLeft(): boolean {
return this.value === this.parent?.left?.value;
}
get isRight(): boolean {
return this.value === this.parent?.right?.value;
}
constructor(value: T) {
this.value = value;
}
}
删除代码
/** * 9. 删除节点 * @param value 删除该值对应的节点 * @returns true成功、false失败 */ remove(value: T): boolean { //1. 搜索需要删除的节点 let current = this.searchForRemove(value); if (!current) return false; //表示节点不存在 //需要获取3个东西,当前节点、父节点、当前节点是左or右节点 console.log( `当前节点:${current.value},父节点:${current.parent?.value},是左节点:${current.isLeft}` ); //2. 删除的节点是叶子节点 if (current.left === null && current.right === null) { //2.1 删除节点是根节点 if (current.value === this.root?.value) { this.root = null; } //2.2 删除节点是左节点 else if (current.isLeft) { current.parent!.left = null; } //2.3 删除节点是右节点 else { current.parent!.right = null; } } //3.删除的节点只有一个子节点 //3.1 删除的节点只有一个左子节点 else if (current.left && current.right === null) { //删除的节点为根节点 if (current.value === this.root?.value) { this.root = current.left; } //删除的节点为左节点 else if (current.isLeft) { current.parent!.left = current.left; } //删除的节点为右节点 else { current.parent!.right = current.left; } } //3.2 删除的节点只有一个右子节点 else if (current.right && current.left === null) { //删除的节点为根节点 if (current.value === this.root?.value) { this.root = current.right; } //删除的节点为左节点 else if (current.isLeft) { current.parent!.left = current.right; } //删除的节点为右节点 else { current.parent!.right = current.right; } } //4.删除的节点右两个子节点 (子节点可能还有子节点) else { //获取后继节点 let successor = this.getSuccessor(current); //删除的节点为根节点 if (current.value === this.root?.value) { this.root = successor; } //删除的节点为左节点 else if (current.isLeft) { current.parent!.left = successor; } //删除的节点为右节点 else { current.parent!.right = successor; } } return true; } /** * 9-2 查找节点(服务于删除功能) * @param value 需要查找的节点值 * @returns 对应节点,没找到的话-返回null */ private searchForRemove(value: T): TreeNode<T> | null { let current = this.root; let parent: TreeNode<T> | null = null; while (current) { if (value === current.value) { return current; //找到节点了,进行返回 } parent = current; if (value < current.value) { current = current.left; //向左查找 } else { current = current.right; //向右查找 } // 当前节点的父节点属性赋值(current是null表示while结束了,没找到节点,没必要赋值parent) if (current) current.parent = parent; } return null; //表示没有找到节点,返回null } /** * 9-3 获取后继节点,并处理后继节点指向问题(服务于删除功能) * 后继节点:右子树中的最小值 * @param delNode 被删除的节点 * @returns 返回后继节点 */ private getSuccessor(delNode: TreeNode<T>): TreeNode<T> { //1.遍历获得后继节点 let current = delNode.right; let successor: TreeNode<T> | null = null; //后继节点 while (current) { successor = current; current = current.left; //服务于下面的 successor!.parent!.left, 否则parent没有值 if (current) { current.parent = successor; } } //2.处理删除节点的右侧指向 //如果删除节点的右子节点正好是后继节点,是不需要进行该操作 if (delNode.right?.value != successor?.value) { //后继节点有一个右子节点的情况(详见ppt图19节点) successor!.parent!.left = successor!.right; //后继节点的right指向删除节点的right(通用情况) successor!.right = delNode.right; } //3.处理删除节点的左侧指向 // 后继节点的left指向删除节点的left successor!.left = delNode.left; return successor!; }
6. 删除重构
(1). 说明
代码是简洁,但是对于第一次看代码,不方便理解
(2). 重构思路
第一步:判断删除节点是叶子节点、只有一个子节点、有两个子节点,获取对应的replaceNode
第二步: 设置指向,根据删除节点是根节点、左节点、右节点,从而设置指向
代码分享
/** * 9. 删除节点 * @param value 删除该值对应的节点 * @returns true成功、false失败 */ remove(value: T): boolean { //1. 搜索需要删除的节点 let current = this.searchForRemove(value); if (!current) return false; //表示节点不存在 //需要获取3个东西,当前节点、父节点、当前节点是左or右节点 console.log( `当前节点:${current.value},父节点:${current.parent?.value},是左节点:${current.isLeft}` ); let replaceNode: TreeNode<T> | null = null; //2. 获取replaceNode //2.1 删除的节点是叶子节点 if (current.left === null && current.right === null) { replaceNode = null; } //2.2 .删除的节点只有一个子节点 //2.2.1 删除的节点只有一个左子节点 else if (current.left && current.right === null) { replaceNode = current.left; } //2.2.2 删除的节点只有一个右子节点 else if (current.right && current.left === null) { replaceNode = current.right; } //2.3.删除的节点右两个子节点 (子节点可能还有子节点) else { //获取后继节点 let successor = this.getSuccessor(current); replaceNode = successor; } //3. 设置指向 //3.1 删除的节点为根节点 if (current.value === this.root?.value) { this.root = replaceNode; } //3.2 删除的节点为左节点 else if (current.isLeft) { current.parent!.left = replaceNode; } //3.3 删除的节点为右节点 else { current.parent!.right = replaceNode; } return true; } /** * 9-2 查找节点(服务于删除功能) * @param value 需要查找的节点值 * @returns 对应节点,没找到的话-返回null */ private searchForRemove(value: T): TreeNode<T> | null { let current = this.root; let parent: TreeNode<T> | null = null; while (current) { if (value === current.value) { return current; //找到节点了,进行返回 } parent = current; if (value < current.value) { current = current.left; //向左查找 } else { current = current.right; //向右查找 } // 当前节点的父节点属性赋值(current是null表示while结束了,没找到节点,没必要赋值parent) if (current) current.parent = parent; } return null; //表示没有找到节点,返回null } /** * 9-3 获取后继节点,并处理后继节点指向问题(服务于删除功能) * 后继节点:右子树中的最小值 * @param delNode 被删除的节点 * @returns 返回后继节点 */ private getSuccessor(delNode: TreeNode<T>): TreeNode<T> { //1.遍历获得后继节点 let current = delNode.right; let successor: TreeNode<T> | null = null; //后继节点 while (current) { successor = current; current = current.left; //服务于下面的 successor!.parent!.left, 否则parent没有值 if (current) { current.parent = successor; } } //2.处理删除节点的右侧指向 //如果删除节点的右子节点正好是后继节点,是不需要进行该操作 if (delNode.right?.value != successor?.value) { //后继节点有一个右子节点的情况(详见ppt图19节点) successor!.parent!.left = successor!.right; //后继节点的right指向删除节点的right(通用情况) successor!.right = delNode.right; } //3.处理删除节点的左侧指向 // 后继节点的left指向删除节点的left successor!.left = delNode.left; return successor!; }
!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。