JavaScript实现树结构(二)

429 阅读12分钟

一、二叉搜索树的封装

二叉树搜索树的基本属性

如图所示:二叉搜索树有四个最基本的属性:指向节点的(root),节点中的(key)、左指针(right)、右指针(right)。

32.png

所以,二叉搜索树中除了定义root属性外,还应定义一个节点内部类,里面包含每个节点中的left、right和key三个属性:

//封装二叉搜索树
function BinarySearchTree() {
  // 定义根节点
  this.root = null;
}
//用于创建节点
function Node(key) {
  this.key = key;
  this.left = null;
  this.right = null;
}

二叉搜索树的常见操作:

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

1.插入数据

实现思路:

  • 首先根据传入的key创建节点对象;
  • 然后判断根节点是否存在,不存在时通过:this.root = newNode,直接把新节点作为二叉搜索树的根节点。
  • 若存在根节点则重新定义一个内部方法insertNode()用于查找插入点。
//一.插入数据:insert方法:对外向用户暴露的方法
BinarySearchTree.prototype.insert = function (key) {
  //1.根据key创建节点
  let newNode = new Node(key);
​
  //2.判断根节点是否存在
  if (this.root == null) {
    this.root = newNode;
  } else {
    //根节点存在时
    this.insertNode(this.root, newNode);
  }
};

内部方法insertNode()的实现思路:

根据比较传入的两个节点,一直查找新节点适合插入的位置,直到成功插入新节点为止。

当newNode.key < node.key向左查找:

  • 情况1:当node无左子节点时,直接插入:
  • 情况2:当node有左子节点时,递归调用insertNode(),直到找到正确的位置

33.png

当newNode.key >= node.key向右查找:

  • 情况1:当node无右子节点时,直接插入:
  • 情况2:当node有右子节点时,依然递归调用insertNode(),直到找到正确的位置

34.png

insertNode()代码实现:

//内部使用的insertNode方法:用于比较节点从左边插入还是右边插入
BinarySearchTree.prototype.insertNode = function (node, newNode) {
  //当newNode.key < node.key向左查找
  if (newNode.key < node.key) {
    //情况1:node无左子节点,直接插入
    if (node.left == null) {
      node.left = newNode;
    } else {
      //情况2:node有左子节点,递归调用insertNode()
      this.insertNode(node.left, newNode);
    }
  //当newNode.key >= node.key向右查找
  } else {
    //情况1:node无右子节点,直接插入
    if (node.right == null) {
      node.right = newNode;
    } else {
      //情况2:node有右子节点,递归调用insertNode()
      this.insertNode(node.right, newNode);
    }
  }
};

测试代码:

//1.创建BinarySearchTree
let bst = new BinarySearchTree()
​
//2.插入数据
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(9);
console.log(bst);

应得到下图所示的二叉搜索树:

36.png

测试结果

37.png

2.遍历数据

这里所说的树的遍历不仅仅针对二叉搜索树,而是适用于所有的二叉树。由于树结构不是线性结构,所以遍历方式有多种选择,常见的四种二叉树遍历方式为:

  • 先序遍历;
  • 中序遍历;
  • 后序遍历;
  • 层序遍历

2.1.先序遍历

先序遍历的过程为:根左右

  • 先遍历根节点A,然后遍历A左边的节点,将左边的所有节点看成一个整体
  • 再次使用根左右的原则,遍历节点B,然后遍历B左边的节点,将左边的所有节点看成一个整体
  • 以此类推...

38.png

如上图所示,二叉树的节点遍历顺序为:A -> B -> D -> H -> I -> E -> C -> F -> G。

代码实现:

//二.树的遍历
//1.先序遍历:根左右
BinarySearchTree.prototype.preOrderTraversal = function () {
  // 定义一个数组用于收集遍历的结果
  let arr = [];
  this.preOrderTraversalNode(this.root, arr);
  return arr;
};
//封装内部方法,对某个节点进行遍历
BinarySearchTree.prototype.preOrderTraversalNode = function (node, arr) {
  if (node != null) {
    //1.收集经过的节点
    arr.push(node.key);
​
    //2.递归处理左子节点
    this.preOrderTraversalNode(node.left, arr);
​
    //3.递归处理右子节点
    this.preOrderTraversalNode(node.right, arr);
  }
};

过程详解:

以遍历以下二叉搜索树为例:

39.png

首先调用preOrderTraversal方法,在方法里再调用preOrderTraversalNode方法用于遍历二叉搜索树。在preOrderTraversalNode方法中,先处理父节点,然后递归1负责遍历左子节点,最后递归2负责遍历右子节点。

递归1执行过程如下图所示:

40.png

可以看到一共递归调用了4次方法,分别传入11、7、5、3,最后遇到 null 结束递归1;注意此时只是执行完最开始的递归1,并没有执行递归2,并且递归1执行到null停止后要一层层地从下向上返回,返回的过程中会开始逐层执行递归2,执行递归2的时候,又会开始执行递归1(因为处理右子节点的时候也要再次判断右子节点中是否有左子节点)

2.2.中序遍历

实现思路:左根右

代码实现:

//2.中序遍历:左根右
BinarySearchTree.prototype.midOrderTraversal = function () {
  // 定义一个数组用于收集遍历的结果
  let arr = [];
  this.midOrderTraversalNode(this.root, arr);
  return arr;
};
BinarySearchTree.prototype.midOrderTraversalNode = function (node, arr) {
  if (node != null) {
    //1.递归处理左子节点
    this.midOrderTraversalNode(node.left, arr);
​
    //2.收集经过的节点
    arr.push(node.key);
​
    //3.递归处理右子节点
    this.midOrderTraversalNode(node.right, arr);
  }
};

过程详解:

遍历的顺序应如下图所示:

43.png

首先调用midOrderTraversal方法,在方法里再调用midOrderTraversalNode方法用于遍历二叉搜索树。先使用递归1遍历左子树中的节点;然后处理父节点;最后使用递归2遍历右子树中的节点。

2.3.后序遍历

实现思路:左右根

代码实现:

//3.后序遍历:左右根
BinarySearchTree.prototype.postOrderTraversal = function () {
  // 定义一个数组用于收集遍历的结果
  let arr = [];
  this.postOrderTraversalNode(this.root, arr);
  return arr;
};
BinarySearchTree.prototype.postOrderTraversalNode = function (node, arr) {
  if (node != null) {
    //1.递归处理左子节点
    this.postOrderTraversalNode(node.left, arr);
​
    //2.递归处理右子节点
    this.postOrderTraversalNode(node.right, arr);
​
    //3.收集经过的节点
    arr.push(node.key);
  }
};

过程详解:

遍历的顺序应如下图所示:

45.png

首先调用postOrderTraversal方法,在方法里再调用postOrderTraversalNode方法用于遍历二叉搜索树。先使用递归1遍历左子树中的节点;然后使用递归2遍历右子树中的节点;最后处理父节点。

测试部分:对上面三种遍历方法进行测试

//1.创建BinarySearchTree
let bst = new BinarySearchTree();
​
//2.插入数据
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);
​
// 测试先序遍历
let pre = bst.preOrderTraversal();
console.log(pre);
​
// 测试中序遍历
let mid = bst.midOrderTraversal();
console.log(mid);
​
// 测试后序遍历
let post = bst.postOrderTraversal();
console.log(post);

测试结果

46.png

2.4.层序遍历

实现思路:逐层遍历

47.5.png

代码实现

//4.层序遍历:逐层遍历
BinarySearchTree.prototype.levelOrderTraversal = function (root = this.root) {
  if (root == null) {
    return [];
  }
  // 收集最后的结果
  let res = [];
  // 利用队列先进先出的性质实现
  let queue = [];
  // 先将根节点放入队列
  queue.push(root);
  // 该循环代表每层
  while (queue.length) {
    // arr负责收集每一层的数据
    let arr = [];
    //注意必须保存length 不然在循环中 如果有数据添加到队列中,那么队列的长度就会改变
    let length = queue.length;
    // 该循环代表每层中的每个
    for (let i = 0; i < length; i++) {
      // 删除队列第一个元素并收集
      let node = queue.shift();
      arr.push(node.key);
      // 添加左右子节点
      if (node.left) queue.push(node.left);
      if (node.right) queue.push(node.right);
    }
    // 按层次分类输出
    res.push(arr);
    // res = res.concat(arr)
  }
  return res;
};

测试代码

//1.创建BinarySearchTree
let bst = new BinarySearchTree();
​
//2.插入数据
...
​
// 测试层序遍历
let level = bst.levelOrderTraversal();
console.log(level);

测试结果

47.8.png

3.查找数据

3.1.查找最大值&最小值

在二叉搜索树中查找最值非常简单,最小值在二叉搜索树的最左边,最大值在二叉搜索树的最右边。只需要一直向左/右查找就能得到最值,如下图所示:

47.png

代码实现:

//三.寻找最值
//寻找最大值
BinarySearchTree.prototype.max = function () {
  //1.获取根节点
  let node = this.root;
  //2.定义key保存节点值
  let key = undefined;
  //3.依次向右不断查找,直到节点为null
  while (node != null) {
    key = node.key;
    node = node.right;
  }
  return key;
};
​
//寻找最小值
BinarySearchTree.prototype.min = function () {
  //1.获取根节点
  let node = this.root;
  //2.定义key保存节点值
  let key = undefined;
  //3.依次向左不断查找,直到节点为null
  while (node != null) {
    key = node.key;
    node = node.left;
  }
  return key;
};

3.2.查找特定值

查找二叉搜索树当中的特定值效率也非常高。只需要从根节点开始将需要查找节点的key值与之比较,若node.key < root则向左查找,若node.key > root就向右查找,直到找到或查找到null为止。这里可以使用递归实现,也可以采用循环来实现。

实现代码:

//查找特定的key
BinarySearchTree.prototype.search = function (key) {
  //1.获取根节点
  let node = this.root;
​
  //2.循环搜索key
  while (node != null) {
    if (key < node.key) {
      //小于根(父)节点就往左边找
      node = node.left;
    } else if (key > node.key) {
      //大于根(父)节点就往右边找
      node = node.right;
    } else {
      return true;
    }
  }
  return false;
};

4.删除数据

实现思路:

第一步: 先找到需要删除的节点,若没找到,则不需要删除;

首先定义变量current用于保存需要删除的节点、变量parent用于保存它的父节点、变量isLeftChild保存current是否为parent的左节点,这样方便之后删除节点时改变相关节点的指向。

第二步: 删除找到的指定节点,后分3种情况:

  • 删除叶子节点;
  • 删除只有一个子节点的节点;
  • 删除有两个子节点的节点;

4.1.情况1:没有子节点

情况1:删除的是叶子节点(没有子节点) 有三种情况:

1.当该叶子节点为根节点时,如下图所示,此时current == this.root,直接通过:this.root = null,删除根节点。

50.png

2.当该叶子节点不为根节点时也有两种情况,如下图所示:

51.png

  • 若current = 8,可以通过:parent.left = null,删除节点8;
  • 若current = 10,可以通过:parent.right = null,删除节点10;

4.2.情况2:有一个子节点

有六种情况分别是:

1.当current存在左子节点时(current.right == null):

  • 情况1:current为根节点(current == this.root),如节点11,此时通过:this.root = current.left,删除根节点11;
  • 情况2:current为父节点parent的左子节点(isLeftChild == true),如节点5,此时通过:parent.left = current.left,删除节点5;
  • 情况3:current为父节点parent的右子节点(isLeftChild == false),如节点9,此时通过:parent.right = current.left,删除节点9;

52.png

2.当current存在右子节点时(current.left = null):

  • 情况4:current为根节点(current == this.root),如节点11,此时通过:this.root = current.right,删除根节点11。
  • 情况5:current为父节点parent的左子节点(isLeftChild == true),如节点5,此时通过:parent.left = current.right,删除节点5;
  • 情况6:current为父节点parent的右子节点(isLeftChild == false),如节点9,此时通过:parent.right = current.right,删除节点9;

53.png

4.3.情况3:有两个子节点

这种情况十分复杂,首先依据以下二叉搜索树,讨论这样的问题:

54.png

删除节点9

在保证删除节点9后原二叉树仍为二叉搜索树的前提下,有两种方式:

  • 方式1:从节点9的左子树中选择一合适的节点替代节点9,可知节点8符合要求;
  • 方式2:从节点9的右子树中选择一合适的节点替代节点9,可知节点10符合要求;

删除节点7

55.png 在保证删除节点7后原二叉树仍为二叉搜索树的前提下,也有两种方式:

  • 方式1:从节点7的左子树中选择一合适的节点替代节点7,可知节点5符合要求;
  • 方式2:从节点7的右子树中选择一合适的节点替代节点7,可知节点8符合要求;

56.png

删除节点15

在保证删除节点15后原树二叉树仍为二叉搜索树的前提下,同样有两种方式:

  • 方式1:从节点15的左子树中选择一合适的节点替代节点15,可知节点14符合要求;
  • 方式2:从节点15的右子树中选择一合适的节点替代节点15,可知节点18符合要求;

57.png

规律总结: 如果要删除的节点有两个子节点,甚至子节点还有子节点,这种情况下需要从要删除节点下面的子节点中找到一个合适的节点,来替换当前的节点。

若用current表示需要删除的节点,则查找合适的节点指的是:

  • 在current的左子树中找最大值
  • 在current的右子树中找最小值

前驱&后继的说明

在二叉搜索树中,这两个特殊的节点有特殊的名字:

  • 比current小一点点的节点,称为current节点的前驱。比如下图中的节点5就是节点7的前驱;
  • 比current大一点点的节点,称为current节点的后继。比如下图中的节点8就是节点7的后继;

58.png

4.4.代码实现

  • 查找需要被删除的节点current的后继时,需要在current的右子树中查找最小值,即在current的右子树中一直向左遍历查找;
  • 查找前驱时,则需要在current的左子树中查找最大值,即在current的左子树中一直向右遍历查找。

下面只讨论查找current后继的情况,查找前驱的原理相同。

//四.删除节点
BinarySearchTree.prototype.remove = function (key) {
  /*------------1.寻找要删除的节点--------------*/
  //1.1.定义变量current保存删除的节点,parent保存它的父节点。isLeftChild保存current是否为parent的左节点
  let current = this.root;
  let parent = null;
  let isLeftChild = true;
​
  //1.2.开始寻找删除的节点
  while (current.key != key) {
    parent = current;
    // 小于则往左查找
    if (key < current.key) {
      isLeftChild = true;
      current = current.left;
    } else {
      isLeftChild = false;
      current = current.right;
    }
    //找到最后依然没有找到相等的节点
    if (current == null) {
      return false;
    }
  }
​
  /*----------2.根据对应情况删除节点----------*/
  //情况1:删除的是叶子节点(没有子节点)
  if (current.left == null && current.right == null) {
    if (current == this.root) {
      this.root = null;
    } else if (isLeftChild) {
      parent.left = null;
    } else {
      parent.right = null;
    }
  }
  //情况2:删除的节点有一个子节点
  //当current存在左子节点时
  else if (current.right == null) {
    if (current == this.root) {
      this.root = current.left;
    } else if (isLeftChild) {
      parent.left = current.left;
    } else {
      parent.right = current.left;
    }
    //当current存在右子节点时
  } else if (current.left == null) {
    if (current == this.root) {
      this.root = current.right;
    } else if (isLeftChild) {
      parent.left = current.right;
    } else {
      parent.right = current.right;
    }
  }
  //情况3:删除的节点有两个子节点
  else {
    //1.获取后继节点
    let successor = this.getSuccessor(current);
​
    //2.判断是否根节点
    if (current == this.root) {
      this.root = successor;
    } else if (isLeftChild) {
      parent.left = successor;
    } else {
      parent.right = successor;
    }
​
    //3.将后继的左子节点改为被删除节点的左子节点
    successor.left = current.left;
  }
};
//封装查找后继的方法
BinarySearchTree.prototype.getSuccessor = function (delNode) {
  //1.定义变量,保存找到的后继
  let successor = delNode;
  let current = delNode.right;
  let successorParent = null;
​
  //2.循环查找current的右子树节点
  while (current != null) {
    successorParent = successor;
    successor = current;
    current = current.left;
  }
​
  //3.判断寻找到的后继节点是否直接就是删除节点的right节点
  if (successor != delNode.right) {
    successorParent.left = successor.right;
    successor.right = delNode.right;
  }
  return successor;
};

二、平衡树

二叉搜索树的缺陷:

当插入的数据是有序的数据,就会造成二叉搜索树的深度过大。

比如原二叉搜索树由 11 7 15 组成,如下图所示:

60.png

但是当插入一组有序数据:6 5 4 3 2就会变成深度过大的搜索二叉树,会严重影响二叉搜索树的性能。

61.png

非平衡树

  • 比较好的二叉搜索树,它的数据应该是左右均匀分布的;
  • 但是插入连续数据后,二叉搜索树中的数据分布就变得不均匀了,我们称这种树为非平衡树
  • 对于一棵平衡二叉树来说,插入/查找等操作的效率是O(logN)
  • 而对于一棵非平衡二叉树来说,相当于编写了一个链表,查找效率变成了O(N) ;

树的平衡性

为了能以较快的时间O(logN) 来操作一棵树,我们需要保证树总是平衡的:

  • 起码大部分是平衡的,此时的时间复杂度也是接近O(logN)的;
  • 这就要求树中每个节点左边的子孙节点的个数,应该尽可能地等于右边的子孙节点的个数;

常见的平衡树

  • AVL树:是最早的一种平衡树,它通过在每个节点多存储一个额外的数据来保持树的平衡。由于AVL树是平衡树,所以它的时间复杂度也是O(logN)。但是它的整体效率不如红黑树,开发中比较少用。
  • 红黑树:同样通过一些特性来保持树的平衡,时间复杂度也是O(logN)。进行插入/删除等操作时,性能优于AVL树,所以平衡树的应用基本都是红黑树。