0%

TypeScript数据结构与算法:二叉搜索树

上一篇《TypeScript 数据结构与算法:散列表》实现了 Typescript 中散列表的数据结构与算法,本篇继续实现二叉搜索树

二叉搜索树(Binary Search Tree),可以把这个词分成三部分来理解:二叉|搜索|树。首先它是一个树,第二每个节点最多有两个子节点,第三左侧子节点 < 本节点 < 右侧子节点有序排列,便于搜索。整体的结构如下图所示:

下面是将要在二叉搜索树中中实现的方法。

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

数据结构

Node 节点

使用数据结构来表述就相当于每个节点都有三部分: leftkeyright

如上图所示,key 为本节点的键,left 指向比 key 小的左侧子节点,right 指向比 key 大的右侧子节点。所以先给每个节点写一个单独的数据结构 Node

1
2
3
4
5
6
7
8
9
10
export class Node<K> {
left: Node<K>;
right: Node<K>;

constructor(public key: K) {}

toString() {
return `${this.key}`;
}
}

辅助方法

因为二叉搜索树是有序排列的树,所以需要有个比较大小的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 比较结果的枚举值
export enum Compare {
LESS_THAN = -1,
BIGGER_THAN = 1,
EQUALS = 0,
}

// 规定自定义Compare的类型
export type ICompareFunction<T> = (a: T, b: T) => number;

/**
* @description: 默认的大小比较函数
* @param {T} a
* @param {T} b
* @return {Compare} 返回 -1 0 1 代表 小于 等于 大于
*/
export function defaultCompare<T>(a: T, b: T): Compare {
if (a === b) {
return Compare.EQUALS;
}
return a < b ? Compare.LESS_THAN : Compare.BIGGER_THAN;
}

BinarySearchTree 类

接下来创建 BinarySearchTree 类的骨架,使用 root 表示根节点。

1
2
3
4
5
6
7
8
import { Compare, defaultCompare, ICompareFunction } from '../util';
import { Node } from './models/node';

export default class BinarySearchTree<T> {
protected root: Node<T>;

constructor(protected compareFn: ICompareFunction<T> = defaultCompare) {}
}

具体的方法分成三类,getRootminmax 三种非递归的方法,inOrderTraversepreOrderTraversepostOrderTraverse 三种递归有序遍历方法,以及 searchinsertremove 三种递归树操作方法。

非递归方法

三种非递归方法的实现都非常简单,使用一张图就可以解释清楚:

根节点直接返回 root 即可,而极值则是分别向和向迭代查找,一直找到 undefined父节点即为极值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* @description: 返回根节点
*/
getRoot(): Node<T> {
return this.root;
}

/**
* @description: 返回树中的最小元素
*/
min(): Node<T> {
// 调用迭代方法
return this.minNode(this.root);
}

/**
* @description: 返回指定子树下的最小元素
*/
protected minNode(node: Node<T>): Node<T> {
let current = node;
// 不断向左查
while (current != null && current.left != null) {
current = current.left;
}
return current;
}

/**
* @description: 返回树中的最大元素
*/
max(): Node<T> {
// 调用迭代方法
return this.maxNode(this.root);
}

/**
* @description: 返回指定子树下的最大元素
*/
protected maxNode(node: Node<T>): Node<T> {
let current = node;
// 不断向右查
while (current != null && current.right != null) {
current = current.right;
}
return current;
}

有序遍历

三种有序遍历的作用就是按照特定的方式挨个遍历整个树,访问每个节点并执行回调函数。在二叉树中,递归只有两个方向(向左向右),先中后序遍历的区别就是查看 key 并执行回调函数的步骤放在两次递归步骤的前中后相对位置

先序遍历

先序遍历的顺序就是执行回调 -> 左 -> 右,所以访问顺序是父节点优先,适合打印结构化的文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @description: 先序遍历
*/
preOrderTraverse(callback: Function) {
// 调用先序遍历迭代方法
this.preOrderTraverseNode(this.root, callback);
}

private preOrderTraverseNode(node: Node<T>, callback: Function) {
// 基线条件
if (node != null) {
// 先序遍历的执行顺序是 执行回调 -> 左 -> 右
callback(node.key);
this.preOrderTraverseNode(node.left, callback);
this.preOrderTraverseNode(node.right, callback);
}
}

中序遍历

中序遍历的顺序就是左 -> 执行回调 -> 右,所以符合从小到大排列的顺序,可以用来打印排序后的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @description: 中序遍历
*/
inOrderTraverse(callback: Function) {
// 调用中序遍历迭代方法
this.inOrderTraverseNode(this.root, callback);
}

private inOrderTraverseNode(node: Node<T>, callback: Function) {
// 基线条件
if (node != null) {
// 中序遍历的顺序是 左 -> 执行回调 -> 右
this.inOrderTraverseNode(node.left, callback);
callback(node.key);
this.inOrderTraverseNode(node.right, callback);
}
}

后序遍历

后序遍历的顺序就是左 -> 右 -> 执行回调,所以访问顺序是子节点优先,可以应用到计算一个目录及其子目录中所有文件所占空间的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @description: 后序遍历
*/
postOrderTraverse(callback: Function) {
// 调用后序遍历迭代方法
this.postOrderTraverseNode(this.root, callback);
}

private postOrderTraverseNode(node: Node<T>, callback: Function) {
// 基线条件
if (node != null) {
// 后序遍历的执行顺序是 左 -> 右 -> 执行回调
this.postOrderTraverseNode(node.left, callback);
this.postOrderTraverseNode(node.right, callback);
callback(node.key);
}
}

递归树操作

searchinsertremove 三种增删查的常规方法,均需要对树进行递归。

查询的递归操作很简单,当 keynode.key 小时向左查,反之向右查,直到查到或者 null 为止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* @description: 搜索元素
*/
search(key: T): boolean {
// 调用递归方法
return this.searchNode(this.root, key);
}

/**
* @description: 递归搜索
*/
private searchNode(node: Node<T>, key: T): boolean {
// 基线条件:查到尽头返回false
if (node == null) {
return false;
}

if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
// key 比 node.key 小,向左查
return this.searchNode(node.left, key);
} else if (this.compareFn(key, node.key) === Compare.BIGGER_THAN) {
// key 比 node.key 大,向右查
return this.searchNode(node.right, key);
} else {
// 基线条件:既不大也不小,说明查到该元素,返回true
return true;
}
}

插入的操作时首先判断是否树是否为空,为空则插入到根节点。当树不为空时,肯定是要插入到一个合适的叶子节点的位置,所以就通过大小递归判断合适的插入位置即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* @description: 插入元素
*/
insert(key: T) {
if (this.root == null) {
// 边界情况:插入到根节点
this.root = new Node(key);
} else {
// 递归找到插入位置
this.insertNode(this.root, key);
}
}

/**
* @description: 递归插入方法
*/
protected insertNode(node: Node<T>, key: T) {
if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
// key 比 node.key 小就向左查
if (node.left == null) {
// 基线条件:左面为空直接赋值
node.left = new Node(key);
} else {
// 否则就接着递归
this.insertNode(node.left, key);
}
} else {
// key 比 node.key 大就向右查
if (node.right == null) {
// 基线条件:右面为空直接赋值
node.right = new Node(key);
} else {
// 否则就接着递归
this.insertNode(node.right, key);
}
}
}

删除节点的操作之所以放到最后,是因为删除操作是最复杂的。因为二叉搜索树是有序的,所以插入的时候会被插到正确的位置,不会插在树中间。但是在删除时,可以将树中的任何一个节点(包括树中间的节点)删除,这样就必须重新构造树的结构。分成以下三种情况:

  1. 删除叶子节点

移除叶子节点是最简单的操作,因为叶子节点被删除后不涉及树结构的重新构造:

  1. 删除只有一个子节点的节点

删除只有一个左侧或右侧子节点的节点后,子树就孤立了,需要将子树重新融入到树中。但是情况也不复杂,只需要将子节点夹带子树替代掉被删除的节点即可:

  1. 删除有两个子节点的节点

如果被删除的节点拥有两个子节点,情况就变复杂了。需要先在被删除的节点的右子树中找到最小的节点,然后替代掉被删除节点的位置,再重新指向右侧子树建立结构:

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* @description: 移除指定元素
*/
remove(key: T) {
// 调用递归方法,这里的递归很特殊,会将删除后的树返回
this.root = this.removeNode(this.root, key);
}

/**
* @description: 递归方法,在指定子树中移除指定元素,每次处理完后都需要将处理后的节点返回给本节点
*/
protected removeNode(node: Node<T>, key: T): Node<T> {
// 基线条件
if (node == null) {
return null;
}

if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
// 当 key 小于 node.key 时,向左去找
node.left = this.removeNode(node.left, key);
return node;
} else if (this.compareFn(key, node.key) === Compare.BIGGER_THAN) {
// 当 key 大于 node.key 时,向右去找
node.right = this.removeNode(node.right, key);
return node;
} else {
// 此时已经查到了要删除的节点
if (node.left == null && node.right == null) {
// 当要删除的节点为叶子节点
node = null;
return node;
} else if (node.left == null) {
// 当要删除的节点只有一个子节点
node = node.right;
return node;
} else if (node.right == null) {
// 同样删除的节点只有一个子节点
node = node.left;
return node;
} else {
// 当要删除的节点有两个子节点
const aux = this.minNode(node.right);
node.key = aux.key;
node.right = this.removeNode(node.right, aux.key);
return node;
}
}
}

下一篇来分析 AVL树


前端记事本,不定期更新,欢迎关注!


👆 全文结束,棒槌时间到 👇