基础数据结构(五):树结构、二叉搜索树封装

460 阅读6分钟

维基百科这样解释树结构:(英语:tree)是一种抽象数据类型(ADT)或是实现这种抽象数据类型的数据结构,用来模拟具有树状结构性质的数据集合。它是由n(n>0)个有限节点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:

  • 每个节点都只有有限个子节点或无子节点;
  • 没有父节点的节点称为根节点;
  • 每一个非根节点有且只有一个父节点;
  • 除了根节点外,每个子节点可以分为多个不相交的子树;
  • 树里面没有环路(cycle)

看图可能会更加直观:

image.png

树的表示方式

我们可以使用两种方式来描述一棵树,一个是父节点指向所有的子节点,使用伪代码大致是这样:

const rootNode = {
  value: A,
  left: BChildNode,
  center: CChildNode,
  right: DChildNode,
  ...
}

还有一种方式是父节点指向最左侧子节点,如果有兄弟节点就指向右侧兄弟节点,看下图更直观:

image.png 这种方式很像react描述vnode树的方式,不同的是react的子节点还会多出一个指针指向父节点。这种方式如果翻转一定的角度你会发现它其实可以看成一棵二叉树:

image.png 由此可以看出二叉树的重要性。

二叉树

定义:每个节点最多含有两个子树的树称为二叉树。 二叉树又有一些分支:

  • 完全二叉树:对于一棵二叉树,假设其深度为d(d>1)。除了第d层外,其它各层的节点数目均已达最大值,且第d层所有节点从左向右连续地紧密排列,这样的二叉树被称为完全二叉树;

    • 满二叉树:所有叶节点都在最底层的完全二叉树;
  • 平衡二叉树AVL树):当且仅当任何节点的两棵子树的高度差不大于1的二叉树;

  • 排序二叉树(二叉查找树(英语:Binary Search Tree)):也称二叉搜索树、有序二叉树;

二叉树的存储

一种方式是使用数组:按照从上到下,从左到右的方式。但是这种方式对于非完全二叉树来说,会造成非常严重的空间浪费。

image.png 所以一般存储二叉树我们使用链表:对比普通的单向链表无非是多了一个指针

const binaryRoot = {
  value: A,
  left: BChild,
  right: CChild
}

二叉搜索树

定义:是指一棵空树或者具有下列性质的二叉树:

  1. 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
  2. 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
  3. 任意节点的左、右子树也分别为二叉查找树;

二叉搜索树的查找其实就是二分查找的思想,查找所需的最大次数是树的深度,插入也是一样的思路。

二叉搜索树封装

二叉树跟链表类似,是由一个个节点组成,同时还需要一个根节点

class TreeNode<T> {
  value: T;
  left: TreeNode<T> | null = null;
  right: TreeNode<T> | null = null;
  // 记录父节点 方便删除操作
  parent: TreeNode<T> | null = null;
  constructor(value: T) {
    this.value = value;
  }
  // 是否是父节点的左子节点
  isLeftChild() {
    return this.parent !== null && this.parent.left === this;
  }
  // 是否是父节点的右子节点
  isRightChild() {
    return this.parent !== null && this.parent.right === this;
  }
}
class BSTree<T> {
  // 根节点默认为null
  private root: TreeNode<T> | null = null;
}
插入

根据二叉搜索树的特性,插入的节点value需要跟根节点value比较大小,然后插入合适的位置:

// 插入
insert(value: T) {
  const node = new TreeNode(value);
  if (this.root === null) {
    this.root = node;
    return;
  } else {
    this.insertNode(this.root, node)
  }
}
insertNode(node: TreeNode<T>, newNode: TreeNode<T>) {
  if (newNode.value < node.value) {
    if (node.left === null) {
      node.left = newNode;
      newNode.parent = node;
    } else {
      this.insertNode(node.left, newNode);
    }
  } else if(newNode.value > node.value) {
    if (node.right === null) {
      node.right = newNode;
      newNode.parent = node;
    } else {
      this.insertNode(node.right, newNode);
    }
  } else {
    console.warn('The value is already in the tree')
  }
}
遍历

二叉树的遍历分为先序遍历、中序遍历、后序遍历和层序遍历。层序遍历比较好理解,就是从上到下一层层从左到右遍历。对于先中后序遍历,拿先序举例:先序遍历就是先访问树(包括所有子树)的根节点,然后访问左节点和右节点。以此类推中序就是先访问左节点,然后访问根节点,最后是右节点。后续不再赘述。
先中后续比较简单,直接使用递归即可:

// 先序遍历
preOrderTraverse(root: TreeNode<T> | null = this.root) {
  if (root === null) {
    return;
  }
  console.log(root.value);
  this.preOrderTraverse(root.left);
  this.preOrderTraverse(root.right);
}
// 中序遍历
inOrderTraverse(root: TreeNode<T> | null = this.root) {
  if (root === null) {
    return;
  }
  this.inOrderTraverse(root.left);
  console.log(root.value);
  this.inOrderTraverse(root.right);
}
// 后序遍历
postOrderTraverse(root: TreeNode<T> | null = this.root) {
  if (root === null) {
    return;
  }
  this.postOrderTraverse(root.left);
  this.postOrderTraverse(root.right);
  console.log(root.value);
}

层序遍历使用递归的方法比较复杂,我们使用队列来做会更加简单:只需要将root先放入队列中,然后出队并将左右子节点依次入队,直到队列长度为0

// 层序遍历
levelOrderTraverse() {
  if (this.root === null) {
    return;
  }
  const queue: TreeNode<T>[] = [];
  // 将根节点入队
  queue.push(this.root);
  while (queue.length) {
    // 将队头出队 并将其左右子节点依次入队
    const node = queue.shift()!;
    console.log(node.value);
    if (node.left !== null) {
      queue.push(node.left);
    }
    if (node.right !== null) {
      queue.push(node.right);
    }
  }
}
最值

由于二叉搜索树的特性,最大值是在最右侧子节点,最小值在最左侧子节点:

// 获取最小值节点
getMinNode(node: TreeNode<T> | null) {
  let cur = node;
  while (cur && cur.left) {
    cur = cur.left;
  }
  return cur;
}
// 获取最大值节点
getMaxNode(node: TreeNode<T> | null) {
  let cur = node;
  while (cur && cur.right) {
    cur = cur.right;
  }
  return cur;
}
// 查找最大值
max() {
  const maxNode = this.getMaxNode(this.root);
  return maxNode ? maxNode.value : null;
}
// 查找最小值
min() {
  const minNode = this.getMinNode(this.root);
  return minNode ? minNode.value : null;
}
搜索

搜索也非常简单,使用while即可:

// 查找特定值的节点
searchNode(value: T) {
  let cur = this.root;
  while (cur) {
    if (value === cur.value) {
      return cur;
    } else if (value < cur.value) {
      cur = cur.left;
    } else {
      cur = cur.right;
    }
  }
  return null;
}
// 是否存在特定值
search(value: T) {
  return !!this.searchNode(value);
}
删除

删除节点相对复杂,我们可以分几种情况:删除节点是叶子节点;删除节点有一个子节点;删除节点有两个子节点;先来看最简单的删除叶子节点:拿到节点后判断左右子节点都为null,则该节点是叶子节点,拿到父元素直接删除该节点即可。

remove(value: T) {
  // 获取要删除的节点
  const removeNode = this.searchNode(value);
  if (removeNode === null) {
    return false;
  }
  // 判断removeNode是否是叶子节点
  if (removeNode.left === null && removeNode.right === null) {
    // 判断removeNode是否是根节点
    if (removeNode.parent) {
      // 判断removeNode是否是父节点的左子节点
      if (removeNode.isLeftChild()) {
        removeNode.parent.left = null;
      } else {
        removeNode.parent.right = null;
      }
    } else {
      // 如果是根节点
      this.root = null;
    }
  }
}

如果删除节点有一个子节点也非常简单,将该子节点接到父节点的对应位置即可:

remove(value: T) {
  // 获取要删除的节点
  const removeNode = this.searchNode(value);
  if (removeNode === null) {
    return false;
  }
  // 判断removeNode是否是叶子节点
  if (removeNode.left === null && removeNode.right === null) {
    ...
  }
  // 删除节点只有一个子节点
  else if (removeNode.left === null || removeNode.right === null) {
    if (removeNode.parent) {
      // 判断removeNode是否是父节点的左子节点
      if (removeNode.isLeftChild()) {
        removeNode.parent.left = removeNode.left || removeNode.right;
      } else {
        removeNode.parent.right = removeNode.left || removeNode.right;
      }
    } else {
      // 如果是根节点
      this.root = removeNode.left || removeNode.right;
    }
    // 处理removeNode子节点的父节点
    (removeNode.left || removeNode.right)!.parent = removeNode.parent;
  }
}

最后是最复杂的有两个子节点的情况。这种情况我们可以有两种方案:从左边找节点或右边找节点来替代被删除的节点。由于二叉搜索树的特性:左子节点比根节点小右子节点比根节点大,所以如果从左子树找一个替代节点要保证这个节点是左子树中最大的(这种节点我们称之为前驱节点),从右子树找当然就要找右子树中最小的(后继节点)。我们这里使用后继结点

// 获取后继节点
getSuccessor(node: TreeNode<T>) {
  const successor = this.getMinNode(node.right)!;
  // 处理后继结点的左子节点
  successor.left = node.left;
  node.left!.parent = successor;
  // 处理后继节点的右子节点
  // 如果后继节点是要删除节点的右子节点 则不用处理
  if (successor !== node.right) {
    // 如果后继节点有右子节点 则将其赋值到后继节点的父节点的左子节点
    if (successor.right) {
      successor.parent!.left = successor.right;
      successor.right.parent = successor.parent;
    } else {
      // 如果后继节点没有右子节点 则将其父节点的左子节点置为null
      successor.parent!.left = null;
    }
    successor.right = node.right;
    node.right!.parent = successor;
  }
  // 处理后继节点的父节点
  successor.parent = node.parent;
  return successor;
}
// 删除
remove(value: T) {
  // 获取要删除的节点
  const removeNode = this.searchNode(value);
  if (removeNode === null) {
    return false;
  }
  // 判断removeNode是否是叶子节点
  if (removeNode.left === null && removeNode.right === null) {
    ...
  }
  // 删除节点只有一个子节点
  else if (removeNode.left === null || removeNode.right === null) {
    ...
  }
  // 删除节点有两个子节点
  else {
    // 获取后继节点
    const successor = this.getSuccessor(removeNode);
    if (removeNode.parent) {
      // 判断removeNode是否是父节点的左子节点
      if (removeNode.isLeftChild()) {
        removeNode.parent.left = successor;
      } else {
        removeNode.parent.right = successor;
      }
    } else {
      // 如果是根节点
      this.root = successor;
    }
  }
}

这段逻辑的难点在后继结点的右子节点处理上,由于后继结点是右子树最小值,所以后继结点没有左子树。对于后继结点的右子树来说,需要在后继结点重新赋值右子节点前挂载到后继结点父节点的left上。这段逻辑看图可能更加清晰: image.png 如果我们要删除15节点,那么后继节点就是18,在18的right指向20节点之前,要处理19节点将其挂载到20节点的left。前驱节点的操作是类似的,感兴趣你可以尝试使用前驱节点实现删除。

以上就是二叉搜索树常用方法的封装,完整代码请参考:github.com/phm-front/d…