手撕红黑树很难?看完零基础都能手撕各种二叉树

标题略显浮夸,但仅仅是略显😄
作者大部分文章都在掘金:月夕的掘金主页

什么是树

树的基本概念

树(Tree)是一种非线性数据结构,树的使用案例非常多,数据库的实现大部分都是基于树结构的,比如在一种特殊的树结构“红黑树”中,寻找任意元素的复杂度仅仅只需要log(N)。树是一种由节点组成的数据结构,但它比链表更加高级,在链表中,一个节点连接着另一个节点,树也是由许多的节点构成的,唯一的区别就是一个树节点可以连接多个树节点,一颗树只有一个根节点,根节点作为起源,由它展开一个树状的数据结构。

现实中的树和计算机中的树

为了方便理解,我们一般会把树倒过来画(相对于现实中的树)

在实现树之前,我们来了解一下树的基本定义:

如下图所示,

树的结构

如果你是第一次了解树,你可能需要先了解一些专业名词:

  • 节点(node):节点是树的主要部分之一,用于存储树中的数据和关系。例如图中的A、B、C、D…都是一个节点

  • 根节点、父节点、子节点:如上图,A是根节点(root),也是B和C的父节点(parent node),也就是说B、C都是A的子节点(child node)。同理,B是D和E的父节点,以此类推。

  • 子树(sub-tree):一个树由许许多多的子树构成,每个节点加上它所有的子节点(包括子节点的子节点们)就是一个子树,D、H、和I就是能构成sub tree,B、D、E、H、I、和J也是一个子树。

  • 节点的度(degree):一个节点含有的子节点的个数称为该节点的度,A的度为2,E的度为1,而J的度为0;

  • 树的度:一棵树中,最大的节点度称为树的度,上图中树的度为2;

  • 叶节点(leaf node)终端节点:度为零或者是没有子节点的节点,如图中的H、I、J、F、G;

  • 非终端节点分支节点:度不为零的节点;

  • 兄弟节点(sibling node):具有相同父节点的节点互称为兄弟节点,如F和G

  • 堂兄弟节点:父节点在同一层的节点互为堂兄弟,图中G是F的兄弟节点,D是F的堂兄弟节点;

  • 祖先节点(ancestor node):从根到该节点所经分支上的所有节点,A、B、D都是H的祖先节点;

  • 子孙节点(descendant node):以某节点为根的子树中任一节点都称为该节点的子孙,D、E、H、I、J都是B的子孙节点。

  • 相连线(edge):每个节点都含有自己的数值,以及与之相连的子节点,连接节点的线叫做相连线

  • 节点的层次(level):从根开始定义起,根为第1层,根的子节点为第2层,以此类推;

  • 深度(depth):对于任意节点n,n的深度为从根到n的唯一路径长,根的深度为0,上图中H的深度为3;

  • 高度(height):对于任意节点n,n的高度为从n到一片树叶的最长路径长,所有树叶的高度为0;上图A的高度为3

  • 森林(forest):由m(m>=0)棵互不相交的树的集合称为森林;

树的种类

树的结构

树的种类有很多,其中二叉树会是本文的重点,至于其他的树类型后面也会单独有文章介绍:

  • 二叉树(Binary Tree):每个节点最多含有两个子节点,上面图中的树就是二叉树。
  • 完全二叉树(Complete Binary Tree):假设一个二叉树深度(depth)为d(d > 1),除了第d层外,其它各层的节点数量均已达到最大值,且第d层所有节点从左向右紧密排列,这样的二叉树就是完全二叉树,上图就是。
  • 满二叉树(Full Binary Tee):在满二叉树中,每个不是尾节点的节点都有两个子节点,上图就不是,因为E不是尾节点,但是只有一个子节点。
  • 排序二叉树(Binary Search Tree):在此树中,每个节点的数值比左子树上的每个节点都大,比所有右子树上的节点都小。
  • 平衡二叉树(AVL Tree):任何节点的两颗子树的高度差不大于1的二叉树。
  • B树(B-Tree):B树和平衡二插树一样,只不过它是一种多叉树(一个节点的子节点数量可以超过二)。
  • 红黑树(Red—Black Tree):是一种自平衡二叉寻找树。

二叉树的实现

二叉树的实现方式有许多,其中类似于链表的实现方式最容易理解,二叉树与链表一样都是有一个个节点构成,不同的是链表每个节点最多指向一个节点(后继节点),但是二叉树每个节点可以最多指向两个节点(左子节点和右子节点)

二叉树中每个节点一般有3个字段,方便用于表示节点的数据(data)、左子节点(leftChild)、右子节点(rightChild)

二叉树节点

js实现:

// binaryTree.js
class BinaryTreeNode {
    left = null;// 左子节点
    right = null;// 右子节点

    constructor(data) {
        this.data = data
    }
}

class BinaryTree {
    root = null;
}

树二叉的 root 属性用于指向这颗树的根节点,这很简单也很好理解,现在我们实现了二叉树的结构。我们要构建一下图中树的数据

二叉树

因为不同二叉树并未对插入、删除、查找等操作做出规范或者限制,所以我们随意的操作树的每一个节点。

我们先手动一个个来添加,后面会介绍二叉树插入节点的算法。

// BinaryTreeNodeTest.js
import { BinaryTreeNode, BinaryTree } from './binaryTree.js'

// 创建节点(Node)
const A = new BinaryTreeNode('A');
const B = new BinaryTreeNode('B');
const C = new BinaryTreeNode('C');
const D = new BinaryTreeNode('D');
const E = new BinaryTreeNode('E');
const F = new BinaryTreeNode('F');
const G = new BinaryTreeNode('G');
const H = new BinaryTreeNode('H');
const I = new BinaryTreeNode('I');
const J = new BinaryTreeNode('J');

// 建立相连线(Edge)
A.left = B;
A.right = C;

B.left = D;
B.right = E;

C.left = F;
C.right = G;

D.left = H;
D.right = I;

E.left = J;

// 创建树(Tree)
let binaryTree = new BinaryTree();
binaryTree.root = A;

这样我们就创建好了上面的树,现在我们如何验证我们创建的树对不对呢?没错,我们需要把他打印出来,但是如何打印一棵树是一个难点。

我们一般都是打印一棵树的遍历结果,树的遍历方法有很多种,不同的遍历方式得到的结果也不同。

树的遍历

对于树的遍历方法可分为两大类:深度优先广度优先

对于一颗二叉树,深度优先搜索(Depth First Search)是沿着树的深度遍历树的节点,尽可能深的搜索树的分支。
深度优先搜索有三种重要的方法。这三种方式常被用于访问树的节点,它们之间的不同在于访问每个节点的次序不同。这三种遍历分别叫做 先序遍历(preorder)中序遍历(inorder)后序遍历(postorder)

而广度优先搜索的常见方法就是 层次遍历(level)

重点:树的遍历无论是考研还是面试都是很常见的哦~

前序遍历

Leetcode 144. 二叉树的前序遍历

二叉树的 前序遍历 也叫做 先根遍历 ,其算法的核心思想是再遍历一棵树时,先输出其根节点,然后再对其左子树和右子树进行前序遍历(根左右)。

  1. 若二叉树为空则退出,否则进行以下步骤
  2. 访问当前的根节点
  3. 先根顺序遍历访问左子树
  4. 先根顺序遍历访问右子树
  5. 退出

前序遍历

图片PPT->WebM->GIF(已经模糊得无法直视了😭)

我们很容易想到这个算法的递归实现,来实现以下

// binaryTree.js
// 前序遍历-递归实现
BinaryTree.preorderTraversal = function(root, result = []) {
        // 1、若二叉树为空则退出
        if(!root){
            return result;
        }
        // 2、访问当前的根节点
        result.push(root.data);
        // 3、前序遍历访问左子树
        BinaryTree.preorderTraversal(root.left, result);
        // 4、前序遍历访问右子树
        BinaryTree.preorderTraversal(root.right, result);
        // 5、退出
        return result
    }

// BinaryTreeNodeTest.js
console.log('前序-递归:', BinaryTree.preorderTraversal(binaryTree.root).toString());
// 前序-递归: A,B,D,H,I,E,J,C,F,G

字节面试题:递归会有什么缺点?如何优化?

上面的实现方式为递归,因为递归实现的二叉树深度搜索是最好理解的。但是递归实现有一些缺点

  • 基于执行栈:执行栈,位于栈内存区,栈内存空间有限且较小,所以递归较深时有可能会导致栈内存不足(俗称爆栈)
  • 每一次递归都会创建新的执行上下文,重新编译代码(虽然有 热代码 机制),开销较大

ok,既然递归有缺点,那我们可以尝试其他的实现方式。(不知道你有没有听说过递归和循环是可以相互代替的)

非递归实现:

我们可以使用循环来代替递归,但是上面我们用到了递归的回溯功能(左子树遍历完后才遍历右子树)。想要在循环中实现回溯,那就需要模拟递归,也就是模拟一个栈,因为我们自己创建的栈占用的是堆内存,所以就不会有爆栈的风险了。

中序遍历的非递归实现思路如下:

  1. 首先将根节点保存到栈中,循环遍历栈直到栈为空
  2. 因为是前序遍历,因此第一步打印根节点,并将根节点出栈
  3. 打印完毕后,查询当前节点是否有右子节点,右子节点要先入栈
  4. 查询当前节点是否有左子节点,左子节点后入栈
  5. 右子节点比左子节点先入栈是因为先遍历左子树,所以在下一个循环中左子节点要先出栈,右子节点后出栈
  6. 右子节点出栈后又相当于一个根节点,也就是一棵树,继续执行第一步,直到栈为空。

前序遍历-非递归.gif

// binaryTree.js
// 前序遍历-非递归实现
BinaryTree.preorderTraversalFor = function(root) {
    let result = [];
    if(!root){
        return result;
    }

    // 创建一个栈
    let stack = new Stack();
    stack.push(root)

    while (!stack.isEmpty()) {
        root = stack.pop();
        result.push(root.data);

        // 保证左节点在右节点后入栈,这样左节点即可先出栈遍历
        if(root.right){
            stack.push(root.right);
        }
        if(root.left){
            stack.push(root.left);
        }
    }
    return result;
}

中序遍历

Leetcode 94. 二叉树的中序遍历

中序遍历的核心思想是先遍历左子树,然后访问根节点,最后遍历又子树,算法思路如下(左根右):

  1. 若二叉树为空,则退出,否则继续。
  2. 中序遍历左子树。
  3. 访问根结点。
  4. 中序遍历右子树。
  5. 退出

中序遍历

// binaryTree.js
// 中序遍历—递归实现
BinaryTree.inOrderTraverse = function (root, result = []){
    // 1、若二叉树为空则退出
    if(!root){
        return result;
    }
    // 2、中序遍历访问左子树
    BinaryTree.inOrderTraverse(root.left, result);
    // 3、访问当前的根节点
    result.push(root.data);
    // 4、中序遍历访问右子树
    BinaryTree.inOrderTraverse(root.right, result);
    // 5、退出
    return result;
}

// BinaryTreeNodeTest.js
console.log('中序-递归:', BinaryTree.inOrderTraverse(binaryTree.root).toString());
// 中序-递归: H,D,I,B,J,E,A,F,C,G

中序遍历的递归实现方式与前序遍历区别仅仅只是访问根节点的时机不同而已,代码并没有什么变化,一看就会。

当然我们也需要思考它的非递归实现,因为面试会问~

非递归实现

因为中序遍历是先访问左子树再在访问根节点,有一个回溯的过程。所以栈中需要存放根节点用于回溯时访问。主要思路如下:

  1. 申请一个栈 stack和变量 cur 代表当前访问的节点,初始时令 cur=root
  2. 如果 cur 不为空,则将 cur入栈,令cur=cur.left ,因为我们要先访问左子树
  3. 否则令 cur=stack.pop() 取栈中的根节点,因为入栈的节点左子树都已经被访问过了,所以这里直接访问根节点,然后通过 cur=cur.right ,下一轮访问右子树
  4. 不断循环2、3步骤,直到 curstack 都为空

中序遍历-非递归

// binaryTree.js
// 中序遍历—非递归实现
BinaryTree.inOrderTraverseLoop = function (root){
    let result = [];
    if(!root){
        return result;
    }

    let stack = new Stack();
    let cur = root;

    while (cur || !stack.isEmpty()){
        if(cur){
            // 准备访问左子树
            stack.push(cur);
            cur = cur.left;
        }
        else {
            cur = stack.pop();
            // 出栈的节点可保证已经访问过左节点,这里访问根节点准备访问右子树
            result.push(cur.data);
            cur = cur.right;
        }
    }

    return result;
}

后序遍历

Leetcode 145. 二叉树的后序遍历

后序遍历的核心思想是先遍历左子树,然后遍历右子树,最后然后访问根节点,如下如图的访问顺序。

后序遍历

递归实现

因为二叉树的三种深度优先搜索方法的递归实现都是相似的思路,只是需要移动访问根节点的代码位置即可得到不同的遍历结果,所以这里就不在赘述了。

递归算法思路如下(左右根):

  1. 若二叉树为空,则退出,否则继续。
  2. 后序遍历左子树。
  3. 后序遍历右子树。
  4. 访问根结点。
  5. 退出
// binaryTree.js
// 后序遍历—递归实现
BinaryTree.postOrderTraverse = function (root, result = []){
    // 1、若二叉树为空则退出
    if(!root){
        return result;
    }
    // 2、后序遍历访问左子树
    BinaryTree.postOrderTraverse(root.leftChild, result);
    // 3、后序遍历访问右子树
    BinaryTree.postOrderTraverse(root.rightChild, result);
    // 4、访问当前的根节点
    result.push(root.data);
    // 5、退出
    return result
}


console.log('后序-递归:', BinaryTree.postOrderTraverse(binaryTree.root).toString());
// 后序-递归: H,I,D,J,E,B,F,G,C,A

非递归实现

重点来了!重点来了!重点来了!

后续遍历的非递归是二叉树的三种遍历方式中最难实现的,因为访问根节点需要在访问完左、右子树之后,这让流程控制变得很复杂。

这里讲解三种实现思路

方法一:后序遍历为 左右中,先使用前序遍历的方式实现 中右左 遍历(即左子节点先入栈),然后反转结果即可得到后序遍历的结果了(可以使用数字的 reverse 方法或者使用另一个栈来反转)。这个实现方式比较简单,就即贴代码了。缺点:需要额外空间存储反转前的结果

方法二:这个比较复杂,主要思路就是存在左节点则往左走,否则存在右节点则往右走,直到寻找到叶节点(第一个输出的节点),沿途使用栈记录访问过的节点。出栈前需要判断右子树是否已经被遍历(如果上一次输出的为右节点或者不存在右节点则表示右节点已经被遍历)

1、定义 currentNode 指向当前节点,visitedNode 指向上一次输出的节点,初始时 currentNode 指向 root 。定义一个栈 stack ,让将 root 入栈。

后序非递归遍历-方法二-1

2、对一颗树后序遍历时,currentNode = currentNode.left 先延左方向找到第一个要输出的叶节点,沿途的节点入栈,直到 currentNodenull

后序非递归遍历-方法二-2
3、取栈顶元素作为根节点,判断是否需要访问右子树,访问条件(栈顶节点不存在右子树或者上传访问节点不是栈顶节点的右节点),如果不需要访问右子树,则访问根节点并记录访问节点,出栈,重复步骤3直到栈空。(因为H不存在右节点,所以不需要遍历右子树)

后序非递归遍历-方法二-3

如果需要访问右子树,则 currentNode 设置为栈顶元素的右子树,然后执行步骤 2。(因为栈顶D的右节点存在且不是上次访问的节点 H,所以需要对其右子树沿左方向查找到第一个叶节点,也就是I。执行步骤 2 后,I 入栈,currentNode 为空后执行步骤3,因为 I 不存在右节点,所以输出并出栈)

后序非递归遍历-方法二-4

好了,我们通过一张动图来看一下完整的流程吧:

后序非递归.gif

代码实现:

// binaryTree.js
// 后序遍历—非递归实现
BinaryTree.postOrderTraverseLoop = function (root){
    let result = [];
    if(!root){
        return result;
    }

    let stack = new Stack();
    let currentNode = root;// 记录当前的节点
    let visitedNode = root;// 记录访问的上一个节点

    while (currentNode || !stack.isEmpty()){
        while (currentNode){ // 往左方向查找到第一个叶节点,并将沿途节点入栈
            stack.push(currentNode);
            currentNode = currentNode.left;
        }

        currentNode = stack.top();// 取栈顶元素,这里复用了currenNode变量

        // 是否需要遍历右子树
        if(currentNode.right && currentNode.right !== visitedNode){
            currentNode = currentNode.right;
        }
        else {// 否则表示左右节点都已经遍历完成,输出根节点
            result.push(currentNode.data);
            visitedNode = currentNode;// 更新 visitedNode
            currentNode = null;// currenNode指向null,防止重复访问左子树
            stack.pop();
        }
    }

    return result;
}

方式三:教科书上的经典方法稍作修改,需要记录栈中每一个节点的右子树是否已经遍历,如果已经遍历则取根节点输出,否则继续遍历右子树。

思路与方法二大致相同,方式二通过判断上一次输出的节点是否是栈顶节点的右节点来判断其右子树是否已经遍历,而这种方法是为每一个栈中的节点都打上标记,直接通过标记判断其右子树是否已经遍历过了。

书中的实现方法大多都是对 TreeNode 添加一个字段用于记录节点状态。但是这需要修改树节点的结构,并不妥。这里可以考虑修改 StackNode 或者使用双栈来实现。

  1. 创建栈stack,StackNode 结构为 {data: BinaryTreeNode, tag: boolean}tagtrue 表示右子树已经访问过,默认为 false
  2. 定义 currentNode=root ,不断延其左方向找到第一个叶节点,并将沿途节点入栈。
  3. 如果栈顶的 tagfalse 且存在右子节点,则将 currentNode 指向栈顶的节点的右子节点,准备遍历其右子树,并将 tag 修改为true 表示其右子树已经遍历。重复步骤2。
  4. 否则输出栈顶的节点,重复步骤3直到栈为空结束

这里的代码采用了一层循环的写法,可能不太易于理解。其中与方法二是时间复杂度是一样的。

// binaryTree.js
// 后序遍历—非递归实现1
BinaryTree.postOrderTraverseLoop1 = function (root){
    let result = [];
    if(!root){
        return result;
    }

    let stack = new Stack();
    let currentNode = root;
    let currentTag = false;

    class StackNode {
        constructor(data, tag){
            this.data = data;
            this.tag = tag;
        }
    }

    while (currentNode || !stack.isEmpty()){
        if(currentNode){// 存在currenNode即表示需要继续遍历左子树
            stack.push(new StackNode(currentNode,false));
            currentNode = currentNode.left;
        }else {
            // 不存在currenNode有两种情况:1、需要遍历右子树,2、遍历根节点
            let stackTopNode = stack.top();
            currentNode = stackTopNode.data;
            currentTag = stackTopNode.tag;

            // 存在右子树且未遍历过,则遍历右子树
            if(currentNode.right && !currentTag){
                currentNode = currentNode.right;
                // 将栈顶tag修改为true,表示右子树已遍历
                stackTopNode.tag = true;
            }else {// 不需要遍历右子树
                // 访问根节点
                result.push(stack.pop().data);
                // 防止重复遍历左子树
                currentNode = null;
            }
        }

    }

    return result;
}

通过几张图片来看一下代码的执行过程

最初,cur 指向 root,栈 stack 为空

后序非递归-1.png

进入循环,因为存在 cur,所以需要将 cur 入栈,并将其 tag 设置为 false,并将 cur 指向其 左子节点

后序非递归-2.png

以此类推,直到 H 入栈后,因为 H 节点不存在左子节点,所以 cur 为 null

后序非递归-3.png

当我们循环遇到 cur 为空时,我们需要将 cur 指向栈顶元素,然后判断 cur 是否存在右子节点且未遍历过右子节点。如果存在未遍历的右子节点则将 cur 指向右子节点(因为后序遍历要求根节点需要在右节点输出后才能输出)。

后序非递归-4

因为 H 不存在右子节点,所以这里直接输出 H,并将 H 出栈,然后将 cur 设置为空

后序非递归-5

然后下一次循环将 cur 指向栈顶,也就是 D,但是 D 存在右子节点且 tag 为 false (表示还未遍历右子树)

后序非递归-6

所以我们需要先遍历 D 的右子树,这里将 cur 设置为 D 的右子节点也就是 I,并将栈中 D 的 tag 修改为 true(表示已经遍历右子树),当我们下一轮循环时即可将 I 入栈

后序非递归-7

因为 I 不存在左子节点,导致 cur 为空,下次循环继续走 cur 取栈顶的逻辑

后序非递归-8

cur 取栈顶:

后序非递归-9

因为 I 也不存在右子节点,所以直接输出 I,并将 I 出栈, cur 设置为空

后序非递归-10

下一轮循环 cur 指向栈顶 D,虽然 D 存在右节点,但是 D 的 tag 为 true 表示其右节点已经被遍历了,所以可以输出 D,然后将 D 出栈, cur 设置为空

后序非递归-11

按照这个逻辑一直走下去,当 stack 和 cur 都为空时,即遍历完毕。

层次遍历

Leetcode 102. 二叉树的层次遍历

树的层次遍历属于广度优先遍历,深度优先遍历和广度优先遍历的区别就像打游戏时单属性点满再点另一个属性和所有属性均衡发展的区别一样。

深度优先遍历只有沿一条路寻找到叶节点后才会返回,而层次遍历只有树的上一层都遍历完后才会遍历下一层,我们通过一张图来看看层次遍历的循序

层次遍历.png
看起来很好理解,无非就是从上到下,从左到右。

实现思路:

层次遍历的实现非常简单,我们只需要使用两个队列来分别存储当前正在遍历层的所有节点和下一层的所有节点,不断对当前层节点出队队输出并将其子节点入另一个队列。待当前层节点队列为空时交换两个队列的角色,当两个队列都为空时遍历结束。

  1. 定义 levelQueuenextLevelQueue 两个队列,levelQueue.push(root)
  2. 循环对 levelQueue 中节点进行出队输出,并将其左右子节点入队到 nextLevelQueue,直到 levelQueue 为空
  3. levelQueuenextLevelQueue 互换(目的是为了将 nextLevelQueue 中所有元素都移动到 levelQueue ,并将nextLevelQueue 清空)。
  4. 重复执行步骤2、3,直到 levelQueue为空时(也就是 levelQueuenextLevelQueue 都为空)
// binaryTree.js
BinaryTree.levelOrderTraverse = function (root){
    let result = []
    if(!root){
        return result;
    }

    let levelQueue = new Queue();
    let nextLevelQueue = new Queue();
    levelQueue.push(root);

    while(true){
        // 出队列访问
        let cur = levelQueue.pop();
        result.push(cur.data);

        // 将其子节点入下一层节点队列
        if(cur.left){
            nextLevelQueue.push(cur.left);
        }
        if(cur.right){
            nextLevelQueue.push(cur.right);
        }

        // 当前层遍历完成后,将下一层队列复制到当前层队列,并清空下一层队列
        if(levelQueue.isEmpty()){
            if(nextLevelQueue.isEmpty()){
                return result;
            }
            let t = levelQueue;
            levelQueue = nextLevelQueue;
            nextLevelQueue = t;
        }
    }
}


// binaryTree.test.js
console.log('层次遍历:', BinaryTree.levelOrderTraverse(binaryTree.root).toString());
// 层次遍历: A,B,C,D,E,F,G,H,I,J

二叉树的数组表示

通过层次遍历输出的节点顺序来看([ ‘A’, ‘B’, ‘C’, ‘D’, ‘E’, ‘F’, ‘G’, ‘H’, ‘I’, ‘J’ ]),我们完全可以使用一个数组来构建一棵树,数组的第一个元素表示根节点,第二个元素表示根节点的左子节点,第三个元素表示根节点的右子节点…

数组表示树.png

树节点node(下标为index)的关系可以由下来公式表示(画图一眼就能看出来规律):

node的左子节点下标:index*2+1

node的右子节点下标:index*2+2

node的父节点的下标:(index-1)/2向下取整

上面的树都是按顺序紧凑排列的,现在我们需要考虑一些特殊的二叉树

例如中间缺少了某个元素:

非完全二叉树-1.png

或者这样

非完全二叉树-2.png

由于数组元素只能通过下标的运算来表示节点之间的关系,或者说这个运算只有在二叉树中没有空缺(完全二叉树)时才有效,导致当树相对于完全二叉树而言,缺少的节点越多,数组中元素就越稀疏,浪费的空间就更大。

什么是完全二叉树:

上面已经介绍过完全二叉树的概念: 假设一个二叉树深度(depth)为d(d > 1),除了第d层外,其它各层的节点数量均已达到最大值,且第d层所有节点从左向右紧密排列,这样的二叉树就是完全二叉树

上面的图中如果去掉虚线部分就不是完全二叉树,如果补上虚线部分就是完全二叉树

这就是使用数组来存储树的优缺点

优点:

  • 不需要左右指针,节点关系通过下标运算来表示。
  • 当树为一颗完全二叉树时,占用的空间更小,查找更快且可以通过子节点查找到父节点。
  • 对于计算每一个节点的位置和高度相关信息可以直接通过公式得到

缺点:

  • 当树不是完全二叉树时,可能占用的空间更大,因为下标的关系运算只对完全二叉树有效,需要花费额外的空间来将其补全为完全二叉树
  • 也就是说数组只能表示没有空隙的树(完全二叉树、完全三叉树、完全四叉树)…,如果树存在空隙,则需要用null来填满空隙。是否使用数组表示需要权衡 补全后的无空隙树 的数组表示与 补全前的树 的链表表示两者占用的 空间 大小。

js实现:

class BinaryTree1{
    constructor(){
          // 用于存放节点
        this.nodes =[];
    }

    // 获取左子节点下标
    getLeftChildIndex(parentIndex){
        let leftChildIndex = parentIndex*2 + 1;
        return leftChildIndex>=this.nodes.length ? null : leftChildIndex;
    }

    // 获取右子节点下标
    getRightChildIndex(parentIndex){
        let rightChildIndex = parentIndex*2 + 2;
        return rightChildIndex>=this.nodes.length ? null : rightChildIndex;
    }

    // 获取父节点下标
    getParentIndex(childIndex){
        // 根节点没有父节点
        let parentIndex = childIndex===0 ? null : Math.floor((childIndex-1)/2);
        return parentIndex;
    }

      // 附上后序非递归遍历的实现代码,注释部分代表和链表表示的树的后序遍历的差异部分
    PostOrderTraverse() {
        let result = [];
          // if(!root){
        if(!this.nodes.length || this.nodes[0] === null){
            return result;
        }

        let stack = new Stack();
        let currenNode = 0;
        let currenTag = false;

        class StackNode {
            constructor(data, tag){
              this.data = data;
              this.tag = tag;
            }
        }

          // while (currenNode || !stack.isEmpty())
        while (
          (currenNode!==null && this.nodes[currenNode]!==null)
          || !stack.isEmpty()
        ){
              // if(currenNode){
            if(currenNode !== null && this.nodes[currenNode]!==null){
                stack.push(new StackNode(currenNode,false));
                currenNode = this.getLeftChildIndex(currenNode);
            }else {
                let stackTopNode = stack.top();
                currenNode = stackTopNode.data;
                currenTag = stackTopNode.tag;

                let rightChildIndex = this.getRightChildIndex(currenNode);
                  // if((currenNode.rightChild && !currenTag){
                if(
                  (rightChildIndex!==null && this.nodes[rightChildIndex]!==null)
                  && !currenTag
                ){
                    currenNode = rightChildIndex;
                    stackTopNode.tag = true;
                }else {
                    stack.pop();
                      // result.push(currenNode.data);
                    result.push(this.nodes[currenNode]);
                    currenNode = null;
                }
            }

        }

        return result;
    }
}

我们可以发现在数组表示的树中:

  • 使用下标表示节点的引用
  • 获取index节点的值通过nodes[index]
  • 根节点的下标为0,引用为nodes[0]
  • 判断一个节点是否存在需要判断下标是否越界以及节点的值是否为null

在其他方面并没有什么变化,也为发现什么缺点。

tip: 大多数实用的树都是趋近于完全二叉树的,因为完全二叉树的数组表示可以为我们节约许多空间(不需要额外的左右指针变量),所以后序的其他树大部分都会使用数组表示。如果你没有看懂这一部分,建议再看一遍哦~

二叉树的toString

在js中为了方便,我们基本上都会为一些对象定义toString方法。上面我们了解了二叉树的4种遍历方式和两种存储方式。我们发现任何遍历方式的打印结果都不能让我们直观的看出这一棵树。接下来我们就来实现将一颗树转化为类似下图的结构打印在控制台上。

二叉树.png

实现控制台打印上图,难点在于如何两颗节点中间的距离是多少。我们可以先将树补全为一颗每一层都占满的树。例如下面这样

补全后的二叉树

可以发现,除了最后一层节点之间的距离我我们可以自己定义以外,没一个节点的y轴位置都是在其两个子节点的中心上。通过这个规律我们只要确定了最后一层节点之间的距离,即可得到每个节点的位置了。

我们假设每个节点的内容最长为 maxLength,每个节点之间的距离为 spacing

在已知树高的情况下,我们可以通过公式得到最后一层满节点数量为多少 levelNodeCount = 2^height

所以我们得出最后一层输出的字符串长度 levelStringLength 为:节点数量\*节点内容长度\+ (节点数量-1)\*间距,也就是 levelStringLength = levelNodeCount\*maxLength + (levelNodeCount-1)\*spacing

我们可以先构建出一个 levelStringLenght\*levelStringLenght 的二维数组

// binaryTreeUtils.js
function creatTextMatrix(height, maxLength, spacing){
    let levelNodeCount = 2 ** height,
        levelStringLength = levelNodeCount * maxLength + (levelNodeCount-1) * spacing;

    let result = Array((height+1) * 2);
    for (let i = 0; i < result.length; i++) {
        result[i] = Array(levelStringLength).fill(' ');
    }

    console.log(result.map(_ => _.join('')).join('\n'));
}

这样我们就初始化完成了存储结果的二维数组,我们该如何插入数据呢?这里给出两种方案:

第一种就是先插入最后一层,在插入倒数第二层…,这种思路上面简单介绍过。

第二种是递归插入,每一次插入时给出一个起始地址和结束地址,将数据插入到起始位置和结束位置的中间,如果存在子树,则将返回分为两半分别再来渲染子树。

这里我们来实现第二种,为了适应数组存储的树和方便计算树高,我们会先将链表树转化为数组,并且实现相关的工具方法。printTree 方法中会先对需要遍历的树转化为数组表示,先来写几个相关的工具方法。

// binaryTreeUtils.js
// 将字符串使用空格补全为指定长度
function fillString(str, length){
    let left = true;
    while (str.length < length){
        if(left){
            str = " "+str;
        }else{
            str = str+" ";
        }

        left = !left;
    }

    return str;
}

// 链表树转化为数组
function createTreeArray(biTree){
    //前序遍历
    function NLR(biTree, index) {
        if (biTree == null) return;
        treeArray[index] = biTree.toString();
        NLR(biTree.left, 2 * index + 1);
        NLR(biTree.right, 2 * index + 2);
    }
    let treeArray = [];
    NLR(biTree,0);
    return treeArray;
}

// 获取左子节点下标
function getLeftChildIndex(parentIndex){
    let leftChildIndex = parentIndex*2 + 1;
    return leftChildIndex>=arr.length ? null : leftChildIndex;
}

// 获取右子节点下标
function getRightChildIndex(parentIndex){
    let rightChildIndex = parentIndex*2 + 2;
    return rightChildIndex>=arr.length ? null : rightChildIndex;
}
// 判断是否存在指定节点
function hasNode(index){
      return typeof arr[index] === 'string';
}

export function printTree(root){

      let nodes;
      if(Array.isArray(root)){
      nodes = root;
    } else {
      nodes = createTreeArray(root);
    }


    let nodeCount = 1,
        levelNodeCount = 1,
        height = 0;

      // 计算树高
    while (nodeCount < nodes.length){
        height++;
        levelNodeCount = 2 ** height;
        nodeCount += levelNodeCount;
    }

      // 打印一下树高,防止树为空时看不到打印信息
    let result = 'TreeHeight:' + (nodes.length ? height : -1) + '\n';
    result += nodes.length ? creatTextMatrix(nodes, height, 2, 2) : 'NULL';

      return result;
}

最后我们再来补全一下 creatTextMatrix 函数,按照上面第二种方法的实现思路,我们需要个 insert 函数用于递归插入,函数中我们除了要在 startend 的中间位置插入内容,还需要判断是否存在 左右子树,如果存在子树我们需要插入 连接线,然后递归。

// binaryTreeUtils.js
function creatTextMatrix(arr, height, maxLength, spacing){
    let levelNodeCount = 2 ** height,
        levelStringLength = levelNodeCount * maxLength + (levelNodeCount-1) * spacing;

    let result = Array((height+1) * 2);
    for (let i = 0; i < result.length; i++) {
        result[i] = Array(levelStringLength).fill(' ');
    }

    // 插入第level层的 rootIndex 子树
    function insert (level, rootIndex, start, end){
          if(!hasNode(arr, rootIndex)){
            return;
        }
          // 计算中间位置
        let midIndex = Math.floor((start + end) / 2);
        // 获取需要插入的数据
        let data = arr[rootIndex];
        // 将插入数据长度补全到最大长度
        if(data.length < maxLength){
            // 使用空行补全,优先补全前方
            data = fillString(data, maxLength);
        }

        // 以midIndex为中心,插入data
          // 开始插入点位置为中间位置向前走插入内容长度的一半距离
        let insertIndex = midIndex - Math.floor((maxLength-1)/2)
        // 插入
        result[level*2].splice(insertIndex, maxLength, ...data.split(''));
        // 是否有左子树
        if(hasNode(arr, getLeftChildIndex(arr, rootIndex))){
            // 在连接线行(数据的下一行)前start和midIndex的中间插入 '/' 字符
            let leftMidIndex = Math.floor(midIndex - (midIndex-start)/2)-1
            result[level*2+1][leftMidIndex+2] = '/';

              // 在连接线上一行相同位置到数据位置插入 '_' 字符
            for (let i = leftMidIndex+3; i < insertIndex; i++) {
                result[level*2][i] = '_';
            }
              // 插入左子树
            insert(level+1, getLeftChildIndex(arr, rootIndex), start, midIndex);
        }

          // 是否存在右子树(逻辑和上面类似)
        if(hasNode(arr, getRightChildIndex(arr, rootIndex))){
            let rightMidIndex = Math.ceil(midIndex + (midIndex-start)/2);
            result[level*2+1][rightMidIndex] = '\\';
            for (let i = insertIndex+maxLength; i < rightMidIndex; i++) {
                result[level*2][i] = '_';
            }
            insert(level+1, getRightChildIndex(arr, rootIndex), midIndex, end);
        }

    }

    insert(0, 0, 0, levelStringLength-1);

    return result.map(_ => _.join('')).join('\n');
}

// binaryTree.js
import { printTree } from './binaryTreeUtils.js'
BinaryTree.prototype.toString = function (){
    return printTree(this.root);
}

看一下执行效果,符合预期,至此我们就实现了二叉树的 toString 方法

console.log(binaryTree.toString());
/*
TreeHeight:3
         _____ A_____
        /            \        
     __ B__        __ C__
    /      \      /      \    
    D      E      F       G
  /  \   /                    
  H   I  J
*/

搜索二叉树

在搜索二叉树(Binary Search Tree)中,每个节点的数值比左子树上的每个节点都大,比所有右子树上的节点都小。如下图

搜索二叉树

搜索二叉树中不允许出现值重复的节点,也就是 key 需要保证唯一

其中每一个节点上面的数值我们称为 key,这个节点中还会有一个 data 字段用于存储 key 对应的数据。而搜索二叉树存在的最大意义就是可以快速的通过 key 找到其对应的 data ,例如数据库中通过id查找到对应数据。

数据库中为了优化查找性能也会将一些索引字段构建成搜索二叉树,只不是那个搜索二叉树不是普通的搜索二叉树,这个后面会介绍。

下面是搜索二叉树相对于普通二叉树做的一些增强

export class BinarySearchTreeNode extends BinaryTreeNode{
    constructor(key, data) {
        super(data);
          // 多了key属性
        this.key = key;
    }

    toString(){
        return String(this.key)
    }
}

为了维持搜索二叉树的特性,我们在插入和删除节点时需要遵守一定的规则,所以搜索二叉树会有一些约束方法来操作树。

export class BinarySearchTree extends BinaryTree {
      // 查找
      find(key){}
      // 插入
      insert(key, data){}
      // 删除
      remove(key, data){}
}

搜索规则

因为搜索二叉树中右子树的所有节点都大于根节点,左子树所有节点都小于根节点。

所以在搜索二叉树中搜索一个节点时,如果节点的值大于根节点则搜索右子树,小于根节点则搜索左子树,等于根节点时则这个根节点为搜索目标。

因为每一次搜索都会排除接近一半的节点,所以排序二叉树搜索一个节点的平均复杂度为 O(logn)

搜索二叉树-查找

// binarySearchTree.js

BinarySearchTree.prototype.findNode = function (root, key){
    if (!root) {
        return null;
    }

    // 查找
    let current = root
    while (current && current.key !== key) {// 节点存在且当前节点不是目标节点
        if (key > current.key) {// 如果目标节点大于当前节点,则向右子树查找
            current = current.right;
        } else if (key < current.key) {// 如果目标节点小于当前节点,则向左子树查找
            current = current.left;
        }
    }
    return current
}

// 查找指定key对应的数据
BinarySearchTree.prototype.find = function (key) {
    let node = this.findNode(this.root, key);
    return node ? node.data : null;
}

// 判断指定key的节点是否存在于树中
BinarySearchTree.prototype.has = function (key) {
    let node = this.findNode(this.root, key);
    return !!node;
}

// 获取key最小的值
BinarySearchTree.prototype.findMin = function (){
    let node = this.root;
    while(node.left !== null) {
        node = node.left;
    }
    return node.data;
}


// 获取key最大的值
BinarySearchTree.prototype.findMax = function (){
    let node = this.root;
    while(node.right !== null) {
        node = node.right;
    }
    return node.data;
}

插入规则

当需要在搜索二叉树中插入一个值时,需要先搜索目标值是否存在。

如果不存在则根据查找路径上最后一个非空节点值与待插入值比较来决定新节点应该插入在其左节点还是右节点

如果存在则表示 key 已经存在,由于搜索二叉树中不允许出现重复的树,所以这里可以选择覆盖原值或者报错或者返回插入失败都可以,本文这里选择覆盖。

搜索二叉树-插入.gif

// binarySearchTree.js
BinarySearchTree.prototype.insert = function (key, data){
    const newNode = new BinarySearchTreeNode(key, data);
    if(!this.root){
        this.root = newNode;
        return true;
    }

    // 查找目标节点以及其父节点
    let parent = this.root;
    let current = this.root;

    while(current && current.key!==key){
        parent = current
        current = key>current.key ? current.right : current.left;
    }

    // 目标key节点已经存在,则返回false
    if(current){
        current.data = data;
        return false;
    }

    // 目标key节点不存在,根据data的大小决定新节点的位置
    if(key > parent.key){
        parent.right = newNode;
    }else{
        parent.left = newNode;
    }
    return true;
}

删除规则

删除二叉树的节点比较复杂,因为我们需要考虑被删除的节点的子树交接问题。被删节点子树有4种情况:

情况一: 既不存在左子树也不存在右子树

对于搜索二叉树,如果是删除一个 叶节点,那么与普通二叉树无差异,自己将其 父节点 与其的指针指向 null 即可。

搜索二叉树-删除叶节点.gif

情况二: 只存左子树

对于这种情况,只需要使用其左子树替代其根节点即可。

例如需要删除 root 的左子节点,但是其左子节点存在左子树且不存在右子树,即 root.left = root.left.left

情况三: 只存右子树

对于这种情况,只需要使用其右子树替代其根节点即可。

例如需要删除 root 的左子节点,但是其左子节点存在右子树且不存在左子树,即 root.left = root.left.right

情况四: 即存在左子树也存在右子树

如果要删除一个 非叶节点 是比较麻烦的,因为我们需要调整被删除节点的子树到合适的位置,所以我们需要将一个叶节点的值覆盖到被删除的节点上,然后将这个叶节点删除。

如果删除的节点不是叶节点,则需要寻找到一个合适的节点来替换这个被删除的节点,这个节点需要大于被删除节点的左子树中所有节点且小于被删除节点的右子树中所有的节点

这个合适的值存在两个候选,一个是左子树中最大的节点,一个是右子树中最小的节点。例如我们要删除 7 ,因为 7 的右子树中最小的节点为 8 ,8 大于 7 的左子树(-2,4,5)中所有节点且大于右子树中(9)所有节点,我们可以将8 复制到 7 的位置,然后再删除8。同理节点 5 也满足这个条件。

符合替换7的节点

我们这里采用右子树中最小的节点,整个流程如下

搜索二叉树-删除非叶节点.gif

// binarySearchTree.js
// 摘除树根节点的候选节点并返回一颗新树
BinarySearchTree.prototype.getSuccessor = function (root){
    // 情况1,新树为空
    if(!root || (!root.right && !root.left)){
        return null;
    }

    // 情况2、3,新树为其子节点
    if(!root.right || !root.left){
        return root.right || root.left;
    }

    // 情况3,需要寻找右子树中最小的节点,然后替换原来的根节点并将其删除
    let minNode = root.right;
    let minParentNode = root;
    while (minNode.left){
        minParentNode = minNode;
        minNode = minNode.left;
    }

    // 摘除节点
    if(minParentNode.left === minNode){
        minParentNode.left = minNode.right;
    } else {
        minParentNode.right = minNode.right;
    }
    minNode.right = null

    return minNode;
}

// 删除指定key的节点
BinarySearchTree.prototype.remove = function (key){
    let parent = this.root;
    let cur = this.root;

    // 寻找需要被删除的节点
    while (cur && cur.key!==key){
        parent = cur;
        if(key > cur.key){
            cur = cur.right;
        }else{
            cur = cur.left;
        }
    }

    if(!cur){
        return null;
    }

    // 获取到候选节点
    let successorNode = this.getSuccessor(cur);
    // 使用候选替换当前节点
    if(cur === this.root){
        successorNode.left = this.root.left;
        successorNode.right = this.root.right;
        this.root = successorNode;
    }
    else if(parent.left === cur){
        parent.left = successorNode;
    } else {
        parent.right = successorNode;
    }

    return cur.data;
}

自此,我们就实现了搜索二叉树的搜索、插入、删除方法。测试一下

// binarySearchTree.test.js
import { BinarySearchTree } from './binarySearchTree.js'

let binarySearchTree = new BinarySearchTree();

for (const key of [4,2,6,1,3,5]) {
    binarySearchTree.insert(key,`key:${key}`);
}
console.log(binarySearchTree.toString());
/*
TreeHeight:2
     _ 4_
    /    \
    2     6
  /  \  /
  1  3  5
*/

console.log(binarySearchTree.insert(7, 'key:7'));// true
console.log(binarySearchTree.insert(6, 'key:7'));// false
console.log(binarySearchTree.toString());
/*
TreeHeight:2
     _ 4_
    /    \
    2     6
  /  \  /  \
  1  3  5   7
*/

console.log(binarySearchTree.remove(4));// key:4
console.log(binarySearchTree.toString());
/*
TreeHeight:2
     _ 5_
    /    \
    2     6
  /  \     \
  1  3      7
*/

console.log(binarySearchTree.remove(6));// key:6
console.log(binarySearchTree.toString());
/*
TreeHeight:2
     _ 5_     
    /    \    
    2     7   
  /  \        
  1  3 
*/

带父指针的二叉树

在实现搜索二叉树的删除方法时,我们想要删除一个节点必须要获取到这个节点的父节点,然后修改父节点的 left 或 right 指针为 null 来实现。所以我们不得不在循环时使用一个变量来跟着其父节点,这导致我们的方法比较耦合

例如 getSuccessor 方法不应该摘除其节点,应该只需要返回目标节点即可,但是由于外部无法得知其parent,所以外部想要实现删除就需要一个额外的通信变量,这会让程序变得很不可维护。

我们得知在链表实现的树中无法快速的通过节点访问父节点的更本原因是单向链表的查找方式是单向的,我们可以借助双向链表的思路来解决链表只能单向查找的问题。(图解数据结构js篇-链表结构(Linked-list)

我们为树的节点添加一个parent指针指向其上一个节点,也就是父节点。

带父指针的二叉树节点

这种结果可以让我们可以在O(1)时间内找到一个节点的父节点,并且可以非常方便的操作树。但是他会增加一个parent字段,导致节点占用的空间变大。数据结构与算法就是在空间和时间之间权衡,是否使用带父指针的二叉树还需要通过实际中的应用和其对应的指标来确定。

虽然父指针可以让我们很方便的查找父节点,但是在删除节点时,维护父指针的正确性也是一件比较棘手的事情。

平衡二叉搜索树

平衡二叉搜索树顾名思义就是一颗平衡的二叉搜索树,我们首先要知道什么是平衡的二叉树。

平衡二叉树:任何节点的两颗 子树高度差 不大于 1 的二叉树。如下图:

二叉树

图中每一个节点的左右子树高度差都不超过1。

例如下面这些树就不是平衡二叉树,因为他们不满足左右子树高度差不超过1的条件(橙色为失去平衡的节点)

不平衡的二叉树

知道了平衡二叉树的概念,我们很容易就能猜到平衡二叉搜索树。所谓平衡二叉搜索树,就是在一颗同时满足搜索二叉树和平衡二叉树特点的树。即左子树中所有节点都小于根节点,右子树中所有节点都大于根节点,且左右子树高度差不超过1。

平衡因子

某结点的左子树与右子树的高度(深度)差即为该结点的平衡因子(BF,Balance Factor)。

平衡二叉树上所有结点的平衡因子只可能是 -1,0 或 1。

在实现平衡二叉树时,为了方便快速计算每一科树是否平衡,我们会在每一个节点上存储这棵树的高度或者平衡因子。

平衡二叉树解决了什么问题?

上面有提到搜索二叉树拥有O(logn)的查找速度,这仅仅是在理想情况下。当从小到大或者从大到小的向搜索二叉树中插入数据时,会发生一些意想不到的情况。

let binarySearchTree1 = new BinarySearchTree();

for (let i = 0; i < 5; i++) {
    binarySearchTree1.insert(i,i)
}
console.log(binarySearchTree1.toString());
/*
TreeHeight:4
0_____________                 
             \                
              1______         
                     \        
                      2__     
                         \    
                          3   
                           \  
                            4
 */

退化为链表的二叉树

我们会发现得到了一颗退化为链表的搜索树,而链表的缺点正好就是查找,时间复杂度O(n),这违反了搜索树诞生是初衷。

只有一颗搜索树是平衡树时,才能保证他的查找速度为O(logn),所以就诞生了平衡二叉树。

如何保证平衡

对于一颗不平衡的二叉树,如果想要让其平衡,我们需要将其调整平衡,这个过程叫做 旋转,而根据树的不同情况也会采用不同的旋转方法

LL右旋转

当失衡的原因是因为失衡节点的左子树的左子树过高引起的时,使用右旋转,因此也叫 LL右旋转 (L表示left)。

右旋的目的是降低左子树的高度并增加右子树的高度,思路是将左节点作为新的根节点,旧根节点作为新根节点的右子树并接管其左子树,可能听起有点不太好理解,没关系,我们看图。

LL右旋转

图片应该很好理解,趁热打铁,我们先把右旋代码简单的实现了。

// 右旋转(当左子树比右子树深,且左子树的左子树比左子树的右子树深时使用)
AVLTree.singleRotateLeft = function (root){
    // 把左结点旋转为根结点
    let newRoot = root.left;
    // 旧根节点接管新根节点的右子树
    root.left = newRoot.right;
    // 旧的根节点变为新的根节点的右子树
    newRoot.right = root;
    // 重新计算高度
    root.height = AVLTree.computeHeight(root);
    newRoot.height = AVLTree.computeHeight(newRoot);

    return newRoot
}
RR左旋转

RR左旋转顾名思义,就是当由右子树过高 且 右子树的右子树较高引起的失衡时,我们需要对其进行左旋转,使得其右子树的高度降低。

大致思路如下图所示:

RR左旋转

// 左单旋转(当右子树比左子树深时使用)
AVLTree.singleRotateRight = function (root){
    let newRoot = root.right;

    root.right = newRoot.left;
    newRoot.left = root;

    // 重新计算高度
    root.height = AVLTree.computeHeight(root);
    newRoot.height = AVLTree.computeHeight(newRoot);

    return newRoot
}

上述的两种旋转对于某些特殊情况可能会失去作用,例如下面这两种。

LL右旋转和RR左旋转无法解决的情况

对于上述情况,如果只使用一次旋转是无法使树平衡的,这是我们就需要使用两次旋转,也就是先将其子树旋转为符合左旋转或右旋转的情况,然后再对整可树旋转。

LR右双旋转

右双旋转适用于上面的第二种情况,当因为左子树过高且左子树的右子树较高时使用,大概思路就是选对其左子树进行左旋转,将高度转移到其左子树的左子树上,此时整颗树就符合了右旋转的条件,对整棵树进行右旋转即可。

LR右双旋转

// LR右双旋转
AVLTree.doubleRotateWithLeft = function (root){
    // 先对左子树做左旋转,目的是将其高的子树移到其左子树上
    root.left = AVLTree.singleRotateRight(root.left);
    // 对root做右旋转,因为此事失衡原因是左子树的左子树深度过高,右旋转可解决
    return AVLTree.singleRotateLeft(root);
}
RL左双旋转

左双旋转和右双旋转的思路类似,这里就不在赘述,上图说话:

RL左双旋转

// RL左双旋转
AVLTree.doubleRotateWithRight = function (root){
  	// 先对右子树右旋转
    root.right = AVLTree.singleRotateLeft(root.right);
  	// 再对整棵树左旋转
    return AVLTree.singleRotateRight(root);
}

AVL树的实现

平衡二叉树的代表实现是 AVL树 ,AVL 作为平衡二叉树,当然也满足搜索二叉树的条件,所以我们直接继承搜索二叉树的一些方法。

在平衡二叉树中,我们想要快速是判断一颗树的平衡,就需要快速的得到其子树的高度差,所以我们可以在用一个字段来存储当前树的高度或者平衡因子。

存储平衡因子是最佳性能的做法,但是存储高度会更加容易理解。

我们的树节点结构和树要实现的一些方法大致如下:

// AVLTree.js
import { BinarySearchTree, BinarySearchTreeNode } from './binarySearchTree.js'

export class AVLTreeNode extends BinarySearchTreeNode{
    height = 0;
    constructor(key, data){
        super(key, data);
    }
}

export class AVLTree extends BinarySearchTree {
    get(key){
      return super.get(key);
    }
    set(key, value){}
    remove(key){}
		has(key){
      return super.has(key);
    }
  
  	// 验证树是否平衡
  	static isBalance(root){}
    // 修复树平衡
  	static useBalance(root){}
    // 获取树高度
  	static height(root){}
    // 重新计算树高度
  	static computeHeight(root){}
}

好,确定了要实现的方法,就开始动手实现吧,先实现静态方法,对于4种旋转方式上面已经实现了,这里就不贴代码了。

// 验证一棵二叉树是否平衡
AVLTree.isBalance = function (root){
    // 两棵子树高度查不超过1
    if(!root){
        return false;
    }
    return Math.abs(AVLTree.height(root.left) - AVLTree.height(root.right)) < 2;
}
// 获取树高
AVLTree.height = function (root){
    // 返回的当前节点的高度即可,null为-1
    return root ? root.height : -1;
}
// 计算树高
AVLTree.computeHeight = function (root){
    // 重新计算当前节点的高度,当前节点的高度 = 子树高度最大值+1
    if(!root){
        return false;
    }
    return Math.max(AVLTree.height(root.left), AVLTree.height(root.right)) + 1
}

// 调整一棵树平衡
AVLTree.useBalance = function (root){
    if(!root || AVLTree.height(root.height)<2 || AVLTree.isBalance(root)){
        return root;
    }
    // 需要判断当前失衡的原因来决定使用的旋转策略
    if(AVLTree.height(root.left) > AVLTree.height(root.right)){
        if(AVLTree.height(root.left.left) < AVLTree.height(root.left.right)){
            root = AVLTree.doubleRotateWithLeft(root)
        }
        else {
            root = AVLTree.singleRotateLeft(root)
        }
    }
    else {
        if(AVLTree.height(root.right.right) < AVLTree.height(root.right.left)){
            root = AVLTree.doubleRotateWithRight(root)
        }
        else {
            root = AVLTree.singleRotateRight(root)
        }
    }
    if(root){
        root.height = AVLTree.computeHeight(root)
    }
    return root
}

静态的工具方法都实现完成了,接下来实现最重要的查询/插入/删除方法

搜索规则

AVL 树作为一颗搜索二叉树,其搜索规则与搜索二叉树完全一致,因为我们这里 AVLTree 继承 了 BinarySearchTree,就不再单独实现 get 方法了,如果不还不了解搜索二叉树的搜索规则,请往上滑到对应的位置复习一下。

插入规则

AVL 树在插入一个节点时与搜索二叉树类似,区别是在插入节点后,需要对沿途的节点调整高度和平衡。

本次我们使用递归来实现,体验一下函数式编程的美。(其实还写了一版非递归的,无奈太乱了😭)

AVLTree.prototype.set = function (key, data){
    const newNode = new AVLTreeNode(key, data)
    this.root = AVLTreeNode.insertNode(this.root, newNode)
    return this;
}

// 在一棵树中插入节点并
AVLTreeNode.insertNode = function (root, newNode){
    if(root == null)
        return newNode;
    if(newNode.key > root.key) {
        root.right = insertNode(root.right, newNode);
    } else if(newNode.key < root.key) {
        root.left = insertNode(root.left, newNode);
    } else {
        root.data = newNode.data;
    }
  
  	// 更新树高并调整平衡
    root.height = AVLTree.computeHeight(root)
    root = AVLTree.useBalance(root);
    return root;
}

删除规则

对于普通的搜索二叉树来说删除节点就是最复杂的,AVL 当然也一样,但是思路还是与搜索二叉树树一致,唯一的区别就是在被删除节点到根节点的路径上需要做平衡修复和更新高度。

我们同样采用在右子树中寻找最小的节点来替换被删除节点的方式来简化存在两棵非空子树的节点的删除。

// 返回是否删除成功,如果不存在则表示删除失败
AVLTree.prototype.remove = function (key){
    let result = false
    if(this.has(key)){
        this.root = AVLTree.removeNode(this.root, key);
        result = true;
    }
    return result;
}

// 删除树中的指定节点,并返回新树
AVLTree.removeNode = function (root, key) {
    if(key == null || !root)
        return null;
    if(key < root.key) {
        root.left = AVLTree.removeNode(root.left, key);
    } else if(key > root.key) {
        root.right = AVLTree.removeNode(root.right, key);
    } else {
        if(root.left && root.right) {
						// 寻找右子树中最小的节点
            let min = root.right;
            while(min.left !== null) {
                min = min.left;
            }
          	// 替换
            root.data = min.data;
          	// 删除右子树中最小的节点
            AVLTree.removeNode(root.right, min.key)
        } else {
            root = (root.right || root.left);
        }
    }
  
  	// 更新树高并调整平衡
    if(root){
        root.height = AVLTree.computeHeight(root)
    }
    root = AVLTree.useBalance(root);
    return root;
}

递归实现代码量明显少了不少,就是不知道能不能跑。

简单测试一下,随机插入并删除20个数,一次跑通,耶✌🏻~。

let avlTree = new AVLTree();

for (let i=0; i<20; i++){
    let t = parseInt(Math.random() * 50)
    avlTree.set(t, `value:${t}`)
}
console.log(avlTree.toString());
/*
TreeHeight:4
                 _____________18_____________
                /                            \
         ______13______                ______29______
        /              \              /              \
     __ 9__         __16__         __23__          __42__
    /      \       /      \       /      \        /      \
    1      10     15      17     22      28      30      49
     \                          /                  \    /
      7                        21                  33  44
 */

for (let i=0; i<20; i++){
    let t = parseInt(Math.random() * 50)
    avlTree.remove(t);
}
console.log(avlTree.toString());
/*
TreeHeight:3
         _____18_____         
        /            \        
     __13__        __29__     
    /      \      /      \    
    9     16     23      33   
     \      \   /  \    /     
     10     17 21  28  30 
 */

后续

篇幅严重超标,只能拆分来写了~~

本篇介绍了:二叉树的基本知识、搜索二叉树、平衡搜索二叉树的实现。

中篇内容:b树,b+树 的介绍与实现

下篇内容:红黑树的介绍与实现

敬请期待~~~

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值