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

1,203 阅读6分钟

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

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

二叉搜索树.jpg

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

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

数据结构

Node 节点

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

二叉搜索树数据结构.jpg

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

export class Node<K> {
  left: Node<K>;
  right: Node<K>;

  constructor(public key: K) {}

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

辅助方法

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

// 比较结果的枚举值
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 表示根节点。

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 三种递归树操作方法。

非递归方法

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

最小最大值.jpg

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

/**
  * @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 并执行回调函数的步骤放在两次递归步骤的前中后相对位置

先序遍历

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

先序遍历.jpg

/**
  * @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);
  }
}

中序遍历

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

中序遍历.jpg

/**
  * @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);
  }
}

后序遍历

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

后序遍历.jpg

/**
  * @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 为止:

/**
  * @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;
  }
}

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

插入.jpg

/**
  * @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. 删除叶子节点

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

移除叶节点.jpg

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

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

移除有一个叶节点的节点.jpg

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

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

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

代码如下:

/**
  * @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树


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